Unix Input/Output

Einführung

Die C-Standardbibliothek bietet eine Reihe von I/O-Funktionen, die zum Lesen und Schreiben von Dateien verwendet werden können. Dazu gehören Funktionen wie fopen, fread, fwrite, fclose usw. Diese Funktionen abstrahieren die zugrunde liegenden Betriebssystemaufrufe und stellen eine plattformunabhängige Möglichkeit dar, Dateien zu verarbeiten.

Es gibt jedoch auch I/O-Funktionen auf Betriebssystemebene, wie beispielsweise die Funktionen open, read, write und close unter Unix-Systemen. Diese Funktionen bieten eine direkte Schnittstelle zum Betriebssystem und erlauben es, Dateien auf einer niedrigeren Ebene zu öffnen, zu lesen, zu schreiben und zu schließen.

  • Bisherige Funktionen sind C-Standard aus <stdio.h>
    ⇒ plattformunabhängig
  • Unix und Windows haben eigene I/O-Bibliotheken
    • sind plattformabhängig
    • Kommunikation, die nicht im C-Standard vorgesehen ist (z. B. Netzwerk)
    • mehr Kontrolle und erweiterte Funktionen
  • bei Linux zu finden in fcntl.h, unistd.h, sys/socket.h etc.

Es gibt mehrere Gründe, warum zusätzlich zu den C-Standardbibliotheksfunktionen Betriebssystem-I/O-Funktionen verwendet werden:

  1. Spezifische Betriebssystemfunktionen: Betriebssystem-I/O-Funktionen ermöglichen den Zugriff auf spezifische Funktionen und Eigenschaften des Betriebssystems. Je nach Betriebssystem können bestimmte I/O-Operationen nur über die entsprechenden Betriebssystemfunktionen durchgeführt werden. Zum Beispiel kann die open-Funktion unter Unix spezielle Flags wie den Zugriffsmodus oder Dateiattribute akzeptieren, die in der C-Standardbibliotheksfunktion fopen nicht verfügbar sind.
  2. Performance: Betriebssystem-I/O-Funktionen bieten oft eine bessere Leistung als die entsprechenden Funktionen auf C-Ebene. Dies liegt daran, dass Betriebssystemfunktionen direkt mit den internen Mechanismen des Betriebssystems interagieren können, um die I/O-Operationen zu optimieren.
  3. Erweiterte Funktionalität: Betriebssystem-I/O-Funktionen können erweiterte Funktionen bieten, z. B. zur Verwaltung von Netzwerkverbindungen, Gerätedateien oder speziellen Ressourcen.
  4. Nicht-dateibasierte E/A: Während die C-Standardbibliothek hauptsächlich auf die Verarbeitung von Dateien ausgerichtet ist, bieten Betriebssystem-I/O-Funktionen weitere Optionen, z. B. Netzwerkverbindungen, Prozesskommunikation über Pipes/Sockets oder auf andere Ressourcen wie serielle Schnittstellen oder Hardwaregeräte zuzugreifen.

File-Descriptor

Die Philosophie von Unix lautet Alles ist eine Datei. Dieses Konzept besagt, dass in Unix-artigen Betriebssystemen nahezu alle Ressourcen und Geräte als Dateien behandelt werden, unabhängig davon, ob es sich um tatsächliche Dateien auf dem Dateisystem handelt oder um andere Ressourcen wie Eingabegeräte, Netzwerkverbindungen, Drucker, Serielle-Schnittstellen usw.

Unix-Philosophie: alles ist eine Datei

  • dieselben Funktionen können angewendet werden (read, write, close)
  • File Descriptor (FD)
    • repräsentiert jede geöffnete Ressource
    • unsigned int-Wert
    • einer Ressource können mehrere File-Deskriptoren zugeordnet sein
  • vordefinierte File-Deskriptoren (sind bereits automatisch geöffnet)
    • 0: Standardeingabe (stdin)
    • 1: Standardausgabe (stdout)
    • 2: Standardfehlerausgabe (stderr)

Durch die Behandlung aller Ressourcen als Dateien können einheitliche Schnittstellen und Methoden für den Zugriff und die Verarbeitung verwendet werden. Statt spezifischer Funktionen für jedes Gerät oder jede Ressource zu haben, können Entwickler auf eine einheitliche Art und Weise auf sie zugreifen, indem sie Dateioperationen verwenden.

Ein entscheidendes Konzept, das mit der Philosophie „Alles ist eine Datei“ einhergeht, ist der File Descriptor. Er ist eine nicht-negative Ganzzahl (unsigned int), die eine geöffnete Ressource (z. B. Datei) repräsentiert und dazu dient, diese in den verschiedenen Funktionen anzusprechen.

Standardmäßig sind drei File-Deskriptoren geöffnet:

- File-Descriptor 0: Standardeingabe (stdin)

- File-Descriptor 1: Standardausgabe (stdout)

- File-Descriptor 2: Standardfehlerausgabe (stderr)

Diese werden verwendet, um Eingabe von der Tastatur zu lesen (stdin), Ausgabe auf den Bildschirm zu schreiben (stdout) und Fehlermeldungen zu senden (stderr).

Schreiben auf stdout
#include <string.h>
#include <unistd.h>

int main(int argc, char** argv) {
    char* text = "Hello, world!\n";
    size_t len = strlen(text);

    /* Direkt auf stdout per FD schreiben */
    write(1, text, len);

    /* FD NICHT schließen, wir haben ihn auch nicht geöffnet */
}

Die Funktion write wird aufgerufen, um den Inhalt der Zeichenkette text mit einer Länge von len direkt auf die Standardausgabe (stdout) zu schreiben. Der erste Parameter der Funktion ist 1, was dem Dateideskriptor für die Standardausgabe entspricht. Der zweite Parameter ist der Zeiger auf den Text, der geschrieben werden soll, und der dritte Parameter ist die Anzahl der zu schreibenden Zeichen.

File öffnen

Wie bereits erwähnt versteckt sich hinter dem Begriff „Datei“ alles, was in irgendeiner Weise Daten liefern oder speichern kann. Deswegen muss im Folgenden immer, wenn der Begriff „File“ oder „Datei“ fällt klar sein, dass damit viel mehr als Dateien auf der Festplatte gemeint ist.

  • int open (const char *pathname, int flags)
    Öffnet eine Datei zum Lesen und/oder Schreiben
    • flags kontrolliert, wie Datei geöffnet wird, z. B. O_WRONLY
    • mehrere Flags werden über Oder | verknüpft
  • int creat (const char *pathname, mode_t mode)
    entspricht open mit flags = O_CREAT|O_WRONLY|O_TRUNC, d. h. Datei wird angelegt und zum Schreiben geöffnet
  • int openat (int dirfd, const char *pathname, int flags)
    Wie open allerdings ist pathname relativ zum Verzeichnis dirdf

open und openat sind überladen und erlauben es, die Berechtigungen der Datei beim Anlegen zu bestimmen. Hierzu haben beide einen weiteren Parameter mode.

  • int open(const char *pathname, int flags, mode_t mode)
  • int openat(int dirfd, const char *pathname, int flags, mode_t mode)

Beispiele für mode sind (siehe man 2 open)

  • S_IRWXU 00700 user (file owner) has read, write, and execute permission
  • S_IRUSR 00400 user has read permission
  • S_IWUSR 00200 user has write permission
  • S_IXUSR 00100 user has execute permission
  • S_IRWXG 00070 group has read, write, and execute permission
  • S_IRGRP 00040 group has read permission

Daten lesen und schreiben

  • ssize_t read (int fd, void *buf, size_t count)
    Liest count Bytes vom File-Deskriptor fd in den Puffer buf.
  • ssize_t write (int fd, const void *buf, size_t count)
    Schreibt count Bytes aus dem Puffer buf in den File-Desktiptor fd
  • off_t lseek (int fd, off_t offset, int whence)
    Verschiebt die aktuelle Position in der Datei

Fehler werden über den Rückgabewert angezeigt, deswegen ssize_t

  • ssize_t wird benutzt, um eine Anzahl Elementen und einen Fehler-Indikator zu speichern (signed)
  • size_t wird benutzt, um eine Anzahl von von Elementen zu speichern (unsigned)
  • off_t wird benutzt, um Dateigrößen und einen Fehler-Indikator zu speichern (signed)

In C gibt es drei verwandte, aber unterschiedliche Datentypen: ssize_t, size_t und off_t:

  1. ssize_t (Signed Size Type):
    • ssize_t ist ein vorzeichenbehafteter Ganzzahl-Datentyp, der zur Darstellung von Größen oder Ergebnissen von Operationen verwendet wird, bei denen das Vorzeichen relevant ist.
    • Der Datentyp ssize_t wird häufig in Funktionen verwendet, die Größen oder Ergebnisse zurückgeben können, bei denen negative Werte eine spezifische Bedeutung haben, beispielsweise Fehlercodes.
    • Beispiele für Funktionen, die ssize_t verwenden, sind read, write und lseek, bei denen die Rückgabewerte die Anzahl der gelesenen/schreibenden Bytes oder die Position im File-Deskriptor darstellen können. Negative Werte werden verwendet, um Fehler oder spezielle Zustände zu signalisieren.
  2. size_t (Size Type):
    • size_t ist ein vorzeichenloser Ganzzahl-Datentyp, der zur Darstellung von Größen oder Indizes verwendet wird.
    • Der Datentyp size_t wird oft für Größen von Objekten, Arrays, Speicherblöcken oder Dateien verwendet.
    • Beispiele für Funktionen oder Operationen, die size_t verwenden, sind sizeof, strlen, malloc und fread. Diese Funktionen geben die Größe eines Objekts, die Länge einer Zeichenkette oder die Anzahl der gelesenen Bytes zurück.
  3. off_t (Offset Type):
    • ist ein vorzeichenhafter Ganzzahl-Datentyp, der zur Darstellung von Dateioffset-Werten verwendet wird.
    • Er wird häufig in Zusammenhang mit Dateioperationen und Positionierungsoperationen verwendet, bei denen der Dateizeiger innerhalb einer Datei verschoben wird.
#define BUFFER_SIZE 1024

int main(int argc, char** argv) {
    char buffer[BUFFER_SIZE];
    int source_fd = open("source.txt", O_RDONLY); /* zum Lesen öffnen */
    int destination_fd = open("destination.txt", /* zum Schreiben öffnen */
            O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);

    ssize_t bytes_read;
    while ((bytes_read = read(source_fd, buffer, BUFFER_SIZE)) > 0) {
        ssize_t bytes_written = write(destination_fd, buffer, bytes_read);
    }

    /* Schließen der Dateien */
    close(source_fd);
    close(destination_fd);
    return 0;
}

In diesem Beispielprogramm wird eine Quelldatei namens source.txt geöffnet, deren Inhalt gelesen und in eine Zieldatei namens destination.txt geschrieben. Die Funktionen open, read und write werden verwendet, um die Dateien zu öffnen und Daten zu lesen und zu schreiben.

Es ist zu beachten, dass Fehlerüberprüfungen und Fehlerbehandlungen in dem Beispielprogramm weggelassen wurde. Vollständig würde das Programm wie folgt aussehen:

Programm mit Fehlerbehandlung und #include
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

int main(int argc, char** argv) {
    char buffer[BUFFER_SIZE];

    int source_fd = open("source.txt", O_RDONLY); /* zum Lesen öffnen */
    if (source_fd == -1) {
        perror("Fehler beim Öffnen der Quelldatei");
        exit(1);
    }

    /* zum Schreiben öffnen (falls sie nicht existiert, wird sie erstellt) */
    int destination_fd = open("destination.txt",  /* zum Schreiben öffnen */
            O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
    if (destination_fd == -1) {
        perror("Fehler beim Öffnen der Zieldatei");
        exit(1);
    }

    /* Lesen von Daten aus der Quelldatei und Schreiben in die Zieldatei */
    ssize_t bytes_read;
    while ((bytes_read = read(source_fd, buffer, BUFFER_SIZE)) > 0) {
        /* Schreiben der Daten in die Zieldatei */
        ssize_t bytes_written = write(destination_fd, buffer, bytes_read);
        if (bytes_written == -1) {
            perror("Fehler beim Schreiben in die Zieldatei");
            exit(EXIT_FAILURE);
        }
    }

    /* Schließen der Dateien */
    close(source_fd);
    close(destination_fd);

    return 0;
}

Pipe

Eine Pipe erlaubt es zwei Prozessen miteinander zu kommunizieren

  • anonyme Pipe: File-Deskriptor muss beiden Prozessen bekannt sein (pipe)
  • named Pipe: Spezielle Datei im Filesystem, über die Prozesse kommunizieren können (mkfifo)

Pipe hat zwei Enden (pipe liefert zwei File-Deskriptoren)

  • write End in das geschrieben wird
  • read End aus dem man die Daten wieder auslesen kann
Für eine bidirektionale Kommunikation benötigen die Prozesse zwei Pipes

Anonyme Pipes werden normalerweise zusammen mit fork benutzt, da so der Elternprozess die Pipe öffnet und dann die File-Deskriptoren an die per fork erzeugten Kinder weitergibt.

int pipefd[2]; /* [0]: read, [1]: write */
char buffer[BUFFER_SIZE];
pipe(pipefd); /* Pipe erstellen */

/* Prozess duplizieren */
pid_t pid = fork();
if (pid > 0) { /* Elternprozess */
    close(pipefd[0]);
    const char* message = "Hallo, Kindprozess!";
    write(pipefd[1], message, strlen(message) + 1);
    close(pipefd[1]);
} else { /* Kindprozess */
    close(pipefd[1]);
    ssize_t bytes_read = read(pipefd[0], buffer, BUFFER_SIZE);
    printf("Nachricht vom Elternprozess erhalten: %s\n", buffer);
    close(pipefd[0]);
}

In diesem Beispiel wird eine Pipe mit der Funktion pipe erstellt. Der Prozess wird mit fork dupliziert, wodurch ein Elternprozess und ein Kindprozess entstehen. Der Elternprozess schreibt eine Nachricht in die Pipe und der Kindprozess liest diese Nachricht aus der Pipe.

Die unbenutzten Dateideskriptoren werden geschlossen, um Ressourcenlecks zu vermeiden. Im Elternprozess wird das Lese-Ende der Pipe (pipefd[0]) geschlossen, während im Kindprozess das Schreib-Ende der Pipe (pipefd[1]) geschlossen wird.

Der Elternprozess schreibt die Nachricht „Hallo, Kindprozess!“ in die Pipe mithilfe der Funktion write. Der Kindprozess liest die Nachricht aus der Pipe mithilfe der Funktion read und gibt sie auf der Konsole aus.

Vollständige Version mit Fehlerbehandlung und #include
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main(int argc, char** argv) {
    int pipefd[2];
    pid_t pid;
    char buffer[BUFFER_SIZE];

    /* Pipe erstellen */
    if (pipe(pipefd) == -1) {
        perror("Fehler beim Erstellen der Pipe");
        exit(EXIT_FAILURE);
    }

    int read_end = pipefd[0];
    int write_end = pipefd[1];

    /* Prozess duplizieren */
    if ((pid = fork()) == -1) {
        perror("Fehler beim Forken des Prozesses");
        exit(EXIT_FAILURE);
    }

    if (pid > 0) {
        /* Elternprozess (schreibt in die Pipe) */
        close(read_end);  /* Schließe das Lese-Ende der Pipe */

        /* Daten in die Pipe schreiben */
        const char* message = "Hallo, Kindprozess!";
        write(write_end, message, strlen(message) + 1);

        /* Schließe das Schreib-Ende der Pipe */
        close(write_end);
    } else {
        /* Kindprozess (liest aus der Pipe) */
        close(write_end);  /* Schließe das Schreib-Ende der Pipe */

      /* Daten aus der Pipe lesen */
      ssize_t bytes_read = read(read_end, buffer, BUFFER_SIZE);
      if (bytes_read == -1) {
          perror("Fehler beim Lesen aus der Pipe");
          exit(EXIT_FAILURE);
      }

      printf("Nachricht vom Elternprozess erhalten: %s\n", buffer);

      /* Schließe das Lese-Ende der Pipe */
      close(read_end);
    }

    return 0;
}

Socket

Netzwerkverbindungen erfolgen über Socket

  1. Socket mit socket erstellen
    ⇒ gibt einen Dateideskriptor (r/w) zurück
  2. Socket mit bind an IP-Adresse und Portnummer zu binden (nur Server)
  3. Socket mit connect mit einem Server verbinden (nur Client)
  4. Mit read und write Daten lesen und schreiben
  5. Socket mit close wieder schließen
Client
char buffer[BUFFER_SIZE];

/* Socket erstellen */
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);

/* Serveradresse und Portnummer konfigurieren */
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

/* Verbindung zum Server herstellen */
connect(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
/* Daten vom Socket lesen */
ssize_t bytes_read = read(socket_fd, buffer, BUFFER_SIZE);
close(socket_fd); /* Socket schließen */

Im vorherigen Beispiel wurden die Includes und die Fehlerbehandlung weggelassen. Vollständig sieht es wie folgt aus.

Client mit Fehlerbehandlung
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define BUFFER_SIZE 1024

int main(int argc, char** argv) {
    char buffer[BUFFER_SIZE];
    struct sockaddr_in server_addr;

    /* Socket erstellen */
    int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        perror("Fehler beim Erstellen des Sockets");
        exit(EXIT_FAILURE);
    }

    /* Serveradresse und Portnummer konfigurieren */
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8080);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    /* Verbindung zum Server herstellen */
    if (connect(socket_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("Fehler beim Verbinden mit dem Server");
        exit(EXIT_FAILURE);
    }

    /* Daten vom Socket lesen */
    ssize_t bytes_read = read(socket_fd, buffer, BUFFER_SIZE);
    if (bytes_read == -1) {
        perror("Fehler beim Lesen vom Socket");
        exit(EXIT_FAILURE);
    }

    /* Verarbeitung der gelesenen Daten */
    /* ... */

    /* Socket schließen */
    close(socket_fd);

    return 0;
}

Copyright © 2025 Thomas Smits