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:
- 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-Standardbibliotheksfunktionfopen
nicht verfügbar sind. - 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.
- Erweiterte Funktionalität: Betriebssystem-I/O-Funktionen können erweiterte Funktionen bieten, z. B. zur Verwaltung von Netzwerkverbindungen, Gerätedateien oder speziellen Ressourcen.
- 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
).
#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 Schreibenflags
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)
entsprichtopen
mitflags
=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)
Wieopen
allerdings istpathname
relativ zum Verzeichnisdirdf
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 permissionS_IRUSR
00400
user has read permissionS_IWUSR
00200
user has write permissionS_IXUSR
00100
user has execute permissionS_IRWXG
00070
group has read, write, and execute permissionS_IRGRP
00040
group has read permission
Daten lesen und schreiben
ssize_t
read
(int fd, void *buf, size_t count)
Liestcount
Bytes vom File-Deskriptorfd
in den Pufferbuf
.ssize_t
write
(int fd, const void *buf, size_t count)
Schreibtcount
Bytes aus dem Pufferbuf
in den File-Desktiptorfd
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
:
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, sindread
,write
undlseek
, 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. 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, sindsizeof
,strlen
,malloc
undfread
. Diese Funktionen geben die Größe eines Objekts, die Länge einer Zeichenkette oder die Anzahl der gelesenen Bytes zurück. 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:
#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
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.
#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
- Socket mit
socket
erstellen
⇒ gibt einen Dateideskriptor (r/w) zurück - Socket mit
bind
an IP-Adresse und Portnummer zu binden (nur Server) - Socket mit
connect
mit einem Server verbinden (nur Client) - Mit
read
undwrite
Daten lesen und schreiben - Socket mit
close
wieder schließen
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.
#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;
}