Systemprogrammierung - Teil 3

Prozesse

von Wolfgang Hetzler


Systemprogrammierung gilt als schwierig, da ihre Inhalte dem Benutzer sehr oft abstrakt gegenüber treten. Daß diese Abstraktheit nicht sein muß und die Systemcalls eine wohldefinierte Schnittstelle zum Kernel darstellen, will diese Serie zeigen.

Programme sind statisch, Prozesse sind wirklich und dynamisch, sie sind das "Salz in der Suppe"; so könnte man metaphorisch das Thema der heutigen Folge umschreiben. Jedoch sollen hier keine Metaphern geliefert werden sondern konkrete Informationen.

Linux als Multitaskingbetriebssystem kann als Server für eine Vielzahl von Prozessen interpretiert werden, die als Clients um Resourcen konkurrieren, die vom Kernel verwaltet werden:

Alle Informationen zu Prozessen werden vom Kernel in der Prozeßtabelle verwaltet (wie immer eine ringverkettete Liste), wobei jeder Prozeß eine eindeutige Kennziffer hat - seine pid oder auch die Prozeßidentifiktation; viele Zugriffe auf Prozesse laufen über diese pid.

Die Prozesse bewegen sich in ihren Adressräumen, die speichertechnisch von den Segmenten text, data und stack strukturiert werden. Zusätzliche Informationen wie die Verweise auf geöffnete Dateien oder das aktuelle Verzeichnis finden sich sind in der sogenannten u-area. Dies ist ebenfalls ein Speicherbereich, der allerdings nicht direkt von einem Prozeß adressiert werden kann (Linux kennt nicht explizit diesen Begriff - viele Informationen sind in der task, die per Prozeß gehalten wird).

Vom Standpunkt des Kernels kann man also Prozesse als speichertechnische Verwaltungseinheiten interpretieren, die nach einem optimierenden Algorithmus einen fairen Zugriff auf ihre Resourcen erhalten. Der Programmierer hat einen anderen Standpunkt, er will Prozesse erzeugen, die zum Träger seiner gesteuerten Aktionen werden: Linux stellt dazu die klassichen Unix-Systembefehle fork, exec, wait und exit zur Verfügung. Alle vier sind relativ einfach und doch mächtig. Wir werden sie uns nun genauer anschauen.

fork - die Gabel

Der Systemcall fork dupliziert einen laufenden Prozeß (Internas später), die Kopie kann als Kind interpretiert werden, das Original als Elternteil. Hierzu ein kleines Beispiel (siehe Listing 1).

Listing 1: fork.C

#include <unistd.h>
#include <iostream.h>

int main(int argc, char **argv)
{
   int pid;
   if((pid=fork()) != 0)
      cout << "bin das Elternteil";
   else
      cout << "bin das Kind";
   return 1;
}

Nach dem fork existieren das Original und die Kopie, die beide nach der Aktivierung durch den Kernel im if aufsetzen und zu unterschiedlichen Ergebnissen kommen: das Original erhält die wirkliche pid des neuen Prozesses zurück, die Kopie erhält als Rückgabewert eine 0 und kann sich so identifizieren, da kein über fork gestarteter Prozeß die pid 0 hat. Ein einfacher, aber wirksamer Mechanismus, der es auch schnell ermöglicht, den Rechner in die Knie zu zwingen (siehe Listing 2).

Listing 2: and_so_on.C

#include <unistd.h>
#include <iostream.h>

int main(int argc, char **argv)
{
 for(int i = 0; i < 10; ++i)
  if(!fork())
   cout << "ab geht´s";
 return 1; //never reached
}

Dieses kleine Programm kann als Prozeß verheerende Wirkung haben, da die Kopie sich kopiert und sich kopiert und sich kopiert ... und aus. Wozu ist denn nun dieses fork nützlich? Dies wird erst klar durch

die exec-Family

die uns mit sechs Familienmitgliedern verwirrend vielfältig gegenübertritt. Bevor ich die Familie vorstelle, zunächst einige Bemerkungen zu ihrer Aufgabe. Der exec-Systemcall überlagert den Adressraum eines Prozesses mit den Daten eines neuen Programms (Internas auch hier später), das sich als Prozeß dann in diesen Bereichen breit macht - die Daten des vorherigen Programms sind verloren - , es gibt keinen neuen Prozeß, aber ein neues Programm im alten Prozeß.

Der Name des Programms und seine Argumente - argv - müssen beim Aufruf eines der Familienmitglieder übergeben werden (siehe Listing 3).

Listing 3: exec_as_you_like.C

#include <unistd.h>

char * command_and_parm_array_with_path[] = {"/bin/ls","/bin/ls","-l","/",0};

char *command_and_parm_arry[] = {"ls","ls","-l","/",0};

char * environment[] = {"HOME=/home/het","PATH=/bin:/usr/bin:/home/het",0};

int main(int argc, char **argv, char **envp)

{
 char c;

 if(!fork())
  execl("/bin/ls","/bin/ls","-l","/",0);
  // die Argumente als Liste mit Pfad

 if(!fork())
  execv(*command_and_parm_array_with_path,command_and_parm_array_with_path);
  // die Argumente als array mit Pfad

 if(!fork())
  execlp("ls","ls","-l","/",0); // die Argumente als Liste ohne Pfad

 if(!fork())
   execvp(*command_and_parm_array,command_and_parm_array);
   // die Argumente als array ohne Pfad

 if(!fork())
  execle("/home/het/put","/home/het/put",0,environment);
  // die Argumente als Liste mit Pfad und das Enviroment als array
  /* put ist ein Testprogramm zur Ausgabe des Environment */

 if(!fork())
  execve(*command_and_parm_array_with_path,command_and_parm_array_with_path,environment);
  // die Argumente als array mit Pfad und geänderten Environment als array

}

Wie aus dem Listing 3 ersichtlich wird, hat die exec-Familie zwei Hauptlinien: die Argumente werden als Array übergeben oder sie werden in der Parameterleiste in aufzählender Form übergeben - v für Vektor und l für Liste. Jede Hauptlinie wiederum kennt drei Varianten: der Pfad wird mit angegeben, es wird die Umgebungsvariable PATH benutzt oder man kann eine neue Umgebung mitliefern - execl und execv für Pfad wird mitgeliefert, execlp und execvp für PATH wird benutzt und execle und execve für das geänderte Environment.

Die Unterschiede liegen also nur in der Art und Weise, wie der Name der ausführbaren Datei und ihr Argumentvektor übergeben wird, alle münden in einem Systemcall, der die oben beschriebene Wirkung zeigt.

Warum nun fork und exec getrennt, obwohl nur in der Kombination beider ein Schuh draus wird?

Eine Frage, die viele Leute beschäftigt und auch zu diversen fork_and_exec Systemcalls geführt hat. Jedoch gibt es gute Gründe: ein fork impliziert einen neuen Prozeß, ein exec hingegen impliziert einen anderen Prozeß in der alten Gestalt, dies aber bleibt dem Erzeuger verborgen, ein Vorteil, der für den login-Prozeß von Bedeutung ist: init ist der einzige Prozeß, der via fork (intern) vom Kernel erzeugt und gestartet wird. Alle anderen Prozesse sind auf init zurückzuführen, auch die relevanten Prozesse zur Erzeugung einer Multiuser-Umgebung, wobei sowohl fork als auch exec benutzt werden. Wir schauen uns das kurz an:

Nach dem Booten wird der Kernel in den Hauptspeicher geladen, der seine Informationsstrukturen setzt, Initialisierungen vornimmt, das Rootdatei-System mountet und schließlich über ein internes Forken den Prozeß init startet. Init ist der erste Prozeß, der im Adressraum der User läuft, also ein nichtpriviligierter Prozeß.

init liest die /etc/inittab (s. Manpages), entnimmt das initiale Runlevel und startet für jeden Eintrag mit diesem Level den entsprechenden Prozeß. Interessant sind die Einträge, in denen getty gestartet wird, denn dieses Programm initialisiert das Terminal und bringt die Meldung login: auf den Bildschirm - getty wird von init über fork und exec gestartet. getty wartet auf eine Eingabe und startet dann über exec das Programm login, das den Paßwortcheck durchführt und dann üblicherweise eine Shell über exec startet (wobei jetzt natürlich die Möglichkeit genutzt werden muß, das Environment zu ändern). Terminiert die Shell (man loggt sich aus), ist für init der Prozeß getty gestorben , denn die Identität (die pid) der Prozesse hat seit dem Starten von getty nicht gewechselt - mit forken sähe die Sache anders aus. init ist aber

tired of waiting

denn es wartet mit dem Systemcall wait auf das Ende von getty. Der Systemcall
int wait(int *status);

blockiert in seiner traditionellen Form einen Prozeß, bis der von ihm erzeugte Kindsprozeß terminiert, Infos über das Ende werden in der Variablen status gespeichert, deren Adresse beim Aufruf übergeben wird. Infos über das Ende können auch von dem Kindsprozeß kommen, denn der terminiert mit

aus is´s nun

mit dem Systemcall
int exit(int);

der einen Prozeß von innen heraus an jeder beliebigen Stelle terminiert - der exit-Code kann vom todessehnsüchtigen Prozeß übergeben werden. Die traurige Unix-Konsequenz: die Kinder sterben vor den Eltern.

Zum Abschluß sei das Zusammenwirken dieser Calls an der C-Funktion system und einer kleinen Minishell gezeigt. Als erstes schreiben wir die Funktion system, die ja bekanntlich Kommandos von C oder C++ aus zur Ausführung bringt (siehe Listing 5).

Listing 5: system.C

int system(char * cmd)
{
 int pid,status;

 switch(pid = fork())
 {
  case 0: execl("/bin/sh","/bin/sh","-c",cmd,0);
  case -1: return -1;
  default: while(wait(&status) != pid);
 }
}

Exec ruft einfach die Shell sh auf und übergibt das Kommando als String, der von der Shell entsprechend behandelt wird; auf das Ende des Kommandos wird gewartet. Wir schreiben nun (siehe Listing 6):

Listing 6: mini.C

#include <unistd.h>
#include <wait.h>
#include <iostream.h>
#include <string.h>

int tokenize_it(char *, char **);
int cd(char *);

int main(int argc, char** argv)
{
        char cmd_buffer[512],*tokens[20];
        int pid,status,is_bg=0,i;
        char newline_off;

        while(cout << ": ",cin.get(cmd_buffer,512))

                    //prompt ausgeben und Kommando lesen
        {
                cin.get(newline_off);

                     //newline aus Puffer
                if(!*cmd_buffer) //nichts eingegeben?
                        continue;
                        
                if(cmd_buffer[i=strlen(cmd_buffer)-1] =='&')

                    //Kommando im Hintergrund ausführen?
                {
                        cmd_buffer[i]=0;
                        is_bg = 1;
                }
                        
                else
                        is_bg = 0;
                
                tokenize_it(cmd_buffer,tokens);

                     // Kommando in syntaktische Teile zerpflücken

                if(!strcmp(*tokens,"cd"))                    
                        cd(*(tokens+1));

                else if(!strcmp(*tokens,"exit"))                    
                        exit(0);

                        // interne Kommandos ?

                else
                        if(!(pid = fork()))

                         //Kindsprozeß erzeugen
                        {
                                if(execvp(*tokens,tokens)==-1)
                                        exit(1);
                        }
                        else
                        {
                                if(!is_bg)
                                        while(wait(&status)!=pid);

                        //auf das Ende warten
                                else
                                        cout << "....started with " << pid << endl;
                        }
        }
}

int tokenize_it(char *cmd, char **tab)
{
        while(*tab++=strtok(cmd," \t"))
                //liefert Zeiger auf tokens
                cmd=0;
}

int cd(char *path)
{
        if(path == 0)
                return chdir(getenv("HOME"));  
        return chdir(path);
}

Diese kleine Shell ist ausbaufähig, die aktuelle Version bringt lediglich einfache Kommandos mit eventuellen Parametern zur Ausführung. dies geschieht im Vorder- bzw. Hintergrund, die internen Kommandos cd und exit werden erkannt. Wait wartet in einer Schleife, da diese Variante lediglich pid von Kindern zurückliefert, ohne daß spezifiziert werden kann, auf wen wir warten.

In der nächsten Folge beschäftigen wir uns mit weiteren Systemcalls zur Prozeßgestaltung.

Infos

[1] Autorenteam,Linux-Kernel-Programmierung, Addison-Wesley, 1997
[2] Rochkind,Unix Programmierung für Fortgeschrittene, Hanser, 1985
[3] Bentson,Randolpf,Inside Linux, Seattle:Specialized Systems Consultants, 1996
.
Der Autor

Wolfgang Hetzler arbeitet seit 1985 freiberuflich als Dozent für Informatik und als Lehrbeauftragter an der FH Frankfurt. Linux kennt er seit 0.9xx und ist ihm ein Quell ständiger Auseinadersetzung. Zu erreichen ist er unter het@het.gg.eunet.de

Copyright © 1998 Linux-Magazin Verlag