70. Systemprogrammierung V: File Descriptors

Unter Unix sehen fast alle Dinge wie Dateien aus: Dateien selbst, Netzwerkverbindungen, Geräte Für viele Aufgaben reicht aber stdio nicht aus.

File Descriptors

Unterhalb der FILEs der C-Bibliothek liegen unter Unix Integers, die File Descriptors. In jedem Prozess stehen 0 für stdin, 1 für stdout und 2 für stderr, weitere werden nach Bedarf vergeben. Die Funktion int fileno(FILE *stream) gibt den FD für einen FILE zurück.

Manipulation von FDs mit

int open(char *name, int flags);
int close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, void *buf, size_t count);
FILE *fdopen(int fd, char *mode);
aus unistd.h oder fcntl.h. Die flags bei open sind ein wenig mit dem Modestring in fopen vergleichbar. Sie sind hier allerdings Integers, die zusammengeodert werden. Es gibt unter anderem
  • O RDONLY, O_WRONLY, O_RDWR für nur lesenden oder schreibenden oder zugleich lesenden und schreibenden Zugiff. Einer davon muss angegeben werden.
  • O_CREAT – die Datei wird angelegt, wenn es sie noch nicht gibt
  • O_TRUNC – wenn es die Datei schon gibt, wird sie vorm Schreiben gekürzt (es gibt auch O_APPEND, dann wird angehängt, ansonsten einfach überschrieben)
  • O_NONBLOCK – wenn nicht sofort gelesen oder geschrieben werden kann, kommen read und write sofort zurück, auch ohne EOF (Non-Blocking I/O – sowas will man z.B. machen, um Deadlocks bei Koprozessen zu vermeiden). Vorsicht: Unix puffert normalerweise Ein- und Ausgaben, so dass dieser Flag allein noch nicht reicht, um etwas wie KeyPressed zu implementieren. Dafür sollte man eher Bibliotheken wie curses einsetzen.

Open gibt entweder eine positive Zahl zurück – eben den FD – oder aber -1 bei einem Fehler. Man kann open auch mit einem dritten Argument aufrufen, den anfänglichen Zugriffsrechten, die aus den mode-bits von stat zusammengeodert werden.

read und write geben jeweils die Zahl der gelesenen Zeichen zurück, oder -1 bei einem Fehler. Beim Zugriff auf nichtblockende FDs kann -1 zurückkommen, wenn keine Daten verfügbar sind, errno ist dann EAGAIN.

Die Funktion fdopen gibt einen FILE* für einen Deskriptor zurück. Das kann praktisch sein, wenn man irgendwelche low-level-Manipulationen an einem FD gemacht hat, danach aber wieder den Komfort von stdio haben will. Wichtig ist das vor allem beim Vererben von FILEs nach einem fork/exec: die FDs werden vom Elter auf das Kind vererbt, die Streams als Datenstrukturen der jeweiligen Programme verschwinden aber nach einem exec.

select

Manchmal will man auf Ereignisse in mehreren Datenströmen gleichzeitig reagieren. Dafür könnte man pollen, d.h. regelmäßig nachsehen, ob ein nichtblockendes read Daten liefert. Besser:

int select(int nfds, fd_set *rdfds, fd_set
  *wrfds, fd_set excfds, struct timeval *timeout)

Das folgende Programm demonstriert den Einsatz von rohen Files und select. Zunächst wird eine Funktion cpChr definiert, die Zeichen von einem Deskriptor auf einen anderen kopiert. Sie geht davon aus, dass die Deskriptoren nicht blocken und rechnet damit, dass zumindest read auch mal einen EAGAIN als Fehler setzt – in dem Fall ist natürlich keine Fehlermeldung angesagt.

Die main-Funktion besorgt sich zunächst die Deskriptoren für stdin und stdout und definiert Variablen vom Typ fd set und struct timeval. Ersteres ist eine “Menge” von Deskriptoren – wir brauchen das, um select mitzuteilen, auf welche FDs es aufpassen soll. Etwas weiter unten wird die so definierte Variable inSet mit einem Makro FD_ZERO zunächst “geleert”. Der Name des Makros suggeriert schon, dass fd_set so implementiert ist, wie wir bei den bitweisen Operatoren die Implementation von Mengen geplant hatten, nämlich als eine Art großen Integer, in dem gesetzte Bits die Mitgliedschaft in einer Menge symbolisieren. Entsprechend setzen wir weiter unten die FDs, auf die select hören soll, mit einem Makro FD SET.

Die Variable timeout vom Typ struct timeval ist demgegenüber eher schlicht. Sie soll eine Zeitspanne repräsentieren, die select warten soll, bis es erstmal aufgibt. Die Designer von select wollten die Möglichkeit eröffnen, nur Bruchteile von Sekunden zu warten, andererseits aber ggf. auch Stunden, und wollten keine Fließkommazahlen verwenden, weil das in Schnittstellen zum Betriebssystemkern unfein ist. So haben sie die Zeitspanne unterteilt in einen Sekundenanteil (tv_sec) und einen Anteil von Mikrosekunden (tv_usec). Dass select wirklich auf einer Zeitskala von Mikrosekunden genau ist, ist natürlich Unsinn.

Danach wird die Kommandozeile ausgewertet – das Programm erwartet zwei Dateinamen. Vom ersten liest es, auf das zweite schreibt es, und entsprechend werden die FDs per open erzeugt. Nach allen Präliminarien gehen wir in eine Endlosschleife, in der wir immer wieder timeout und inSet setzen (sie werden by reference an select übergeben, und in der Tat macht select sie auch kaputt), um dann select aufzurufen.

Select bekommt dann zunächst immer FD_SETSIZE übergeben – in diesem Makro steht letztlich, wie viele bits in einem fd_set sind, und darauf hat man keinen Einfluss. Dann kommen Pointer auf der Mengen von FDs – uns interessiert nur die erste, in der steht, auf welchen FDs select auf Eingabe warten soll. Im zweiten könnte man FDs übergeben, bei denen select aufpassen soll, wann eine Ausgabe passiert (z.B. interessant, wenn man wartet, bis ein Server am Netz Daten annimmt), im letzten könnten FDs übergeben werden, auf denen “etwas Besonderes” passieren soll; darunter kann man sich Nachrichten wie “guck jetzt bitte ganz schnell hierher” vorstellen.

Den Rückkehrwert von select (-1 bei einem Fehler, die Zahl der FDs, auf denen etwas passiert ist, sonst) ignorieren wir hier (sollten wir aber nicht tun), stattdessen werten wir aus, was select aus unserem inSet gemacht hat. Wir sehen mit FD_ISSET nach, ob einer der beiden FDs, die wir überwachen wollen, etwas unternommen hat und kopieren von ihm zu seiner entsprechenden Ausgabe, wenn das so ist. Haben beide nichts gesehen, ist offenbar der Timeout aktiv geworden, und wir geben einfach einen Punkt auf die Fehlerausgabe aus.

#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>

#define CP_SZ 80


int cpChr(int srcfd, int targfd)
{
  static char buffer[CP_SZ];
  int bytesRead;

  if ((bytesRead=read(srcfd, buffer, CP_SZ))==-1) {
    if (errno!=EAGAIN) perror("read");
  } else {
    if (bytesRead==0)
      exit(0);
    if (write(targfd, buffer, bytesRead)==-1) {
      perror("write");
    }
  }
  return 1;
}

int main(int argc, char **argv)
{
  int targwr, targrd;
  int srcwr=fileno(stdout), srcrd=fileno(stdin);
  fd_set inSet;
  struct timeval timeout={2, 0};

  if (argc!=3) { exit(1);}
  if (0>(targrd=open(argv[1], O_RDONLY|O_NONBLOCK))) {
    perror(argv[1]); exit(1);
  } 
  if (0>(targwr=open(argv[2], O_WRONLY))) {
    perror(argv[2]); exit(1);
  }
  fcntl(targrd, O_NONBLOCK);
  FD_ZERO(&inSet);
  while (1) {
    timeout.tv_sec = 2;
    FD_SET(targrd, &inSet);
    FD_SET(srcrd, &inSet);
    if (!select(FD_SETSIZE, &inSet, NULL, NULL, &timeout))
      fprintf(stderr, ".");
    if (FD_ISSET(targrd, &inSet)) cpChr(targrd, srcwr);
    if (FD_ISSET(srcrd, &inSet)) cpChr(srcrd, targwr);
    }
  }
  return 0;
}

Was kann nun dieses Programm? Sehr wenig, zunächst. Um zu sehen, wozu es gut sein kann, könnt ihr zunächst zwei spezielle Einträge am Dateisystem machen, so genannte Fifos oder named pipes, etwa mit

mknod fif1 p
mknod fif2 p

Diese Fifos haben die Eigenschaft, dass man Daten in sie schreiben und sie auch wieder aus ihnen lesen kann, und zwar so, dass das, was zuerst reingeschrieben wurde, auch wieder zuerst rauskommt. Daher kommt auch der Name, Fifo steht für First in, First out, der Gegenbegriff ist Lifo (Last in, First out), die entsprechende Datenstruktur könnte etwa ein Stack sein.

Über diese Fifos können jetzt zwei Instanzen unseres Programms kommunizieren. Wenn ihr das Programm selectdemo genannt habt, könnt ihr jetzt in einem Fenster

selectdemo fif1 fif2

und im anderen

selectdemo fif2 fif1

laufen lassen. Das Ergebnis ist, dass alles, was ihr im einen Fenster tippt, im anderen erscheint und umgekehrt. Das Programm fummelt nicht an den Terminaleinstellungen herum, so dass ihr in der Regel erst Return drücken müsst, bevor die Mitteilungen auf der anderen Seite ankommen. Wer das ändern will, müsste sich mit der Dokumentation zu termios oder besser curses auseinandersetzen.

Das ist noch nicht so spannend. Das Modell trägt aber im Prinzip auch für Fälle, in denen Programm tatsächlich sinnvoll kommunizieren müssen (stellt euch etwa einen Lemmatisierer vor, dem man ein Wort in seine Fifo schreibt und der das zugehörige Lemma zurückgibt), und zwar, wie wir auf der nächsten Folie sehen werden, ggf. auch über Maschinengrenzen hinweg.

Eine andere Anwendung kann z.B. die Steuerung eines Modems sein – wenn ihr ein Hayes-kompatibles Modem an einer seriellen Schnittstelle habt, könnt ihr

selectdemo /dev/ttyS0 /dev/ttyS0

probieren (statt der 0 muss ggf. etwas anderes stehen, je nach dem, an welcher Seriellen das Modem hängt). Wenn ihr jetzt AT tippt, sollte das Modem OK zurücksagen, und wenn ihr AT DP 06221 543248 tippt, pfeift euer Modem mir ins Ohr.


Markus Demleitner

Copyright Notice