blog · git · desktop · images · contact & privacy · gopher


Details zu Umgebungsvariablen und Kindprozessen

Wie ist das nochmal mit dem Unterschied zwischen Shell-Variablen und („exportierten“) Umgebungsvariablen? Daraus entstand die Frage: Was passiert eigentlich genau, wenn man eine Variable exportiert, was passiert da auf C-Ebene, wo im Speicher landet die Variable und was genau läuft dann eigentlich bei einem Fork und Exec ab?

Situation nach Start eines Prozesses auf Assembler-Ebene

Fangen wir bei einem frisch erstellten Prozess an, der gerade sein _start betritt (siehe dazu auch frühere Postings). Ich habe hier bewusst „_start“ gewählt und nicht „main“, weil erst einmal der Fokus auf das gelegt werden soll, was der Kernel aufbaut. Wird „main“ betreten, ist ja schon die libc involviert, welche viel Magie einstreut. Okay, wenn wir bei „_start“ sind, dann sieht der Stack initial wie folgt aus:

+---------+                     --- höhere Adresse ---
|  NULL   |
+---------+
| envp[m] |
|   ...   |
| envp[2] |
| envp[1] |
| envp[0] |
+---------+
|  NULL   |
+---------+
| argv[n] |
|   ...   |
| argv[2] |
| argv[1] |
| argv[0] |
+---------+
|  argc   |  <-- RSP           --- niedrigere Adresse ---
+---------+

Siehe dazu auch fs/binfmt_elf.c, wobei das naturgemäß nicht besonders leicht zu lesen ist.

In Worten:

Es ist also ziemlich einfach, an die Kommandozeilenargumente und die Umgebungsvariablen zu kommen. Hier ein kleines x86_64-Programm, das die Argumente überspringt und nur die Umgebungsvariablen ausgibt:

.data
endl:
    .string "\n"

.text
.global _start

_start:
    /* argc liegt als erstes auf dem Stack, ist uns aber egal, weil die
     * Liste ohnehin von einem NULL-Pointer abgeschlossen wird. argc ist
     * zwar ein Integer und der ist bei x86_64 4 Byte lang, aber bei
     * dieser Architektur sind Stack-Elemente trotzdem immer 8 Byte
     * lang. */
    popq %rdi

    /* Hole nach und nach alle Argument (bzw. die Zeiger darauf) vom
     * Stack, bis dann endlich der NULL-Pointer gesehen wurde. */
    arg:
        popq %rdi
        test %rdi, %rdi
        jnz arg

    /* Nun sind wir bei den Umgebungsvariablen. Hole auch die nach und
     * nach vom Stack, gib sie aber aus. */
    env:
        popq %rdi
        test %rdi, %rdi
        jz quit               /* NULL-Pointer, wir sind fertig. */

        call output
        jmp env

    quit:
        movq $60, %rax        /* 60 = _exit() */
        movq $0, %rdi         /* Rückgabewert 0 */
        syscall

/*
 * Gib einen NUL-terminierten String gefolgt von einem Newline aus.
 * in: %rdi, Zeiger auf ein Byte-Array
 */
output:
    /* Bestimme zunächst die Länge des Strings, der sich hinter dem
     * Zeiger verbirgt. */
    movq $-1, %rdx
    strlen_again:
        incq %rdx
        /* In %rdi steht eine Speicheradresse und in %rdx ein Offset.
         * Addiere die beiden Werte, gehe an die resultierende Adresse
         * und hole das eine Byte, das dort steht, nach %al: */
        movb (%rdi, %rdx), %al
        /* Ist es noch kein NUL-Byte? Dann haben wir das Ende des
         * Strings noch nicht erreicht, also ab zum nächsten
         * Schleifendurchlauf: */
        test %al, %al
        jnz strlen_again

    /* Wir haben die Stringlänge jetzt in %rdx und können daher zwei
     * write()-Syscalls absetzen: */

    movq %rdi, %rsi       /* Zeiger auf den String kopieren */
    movq $1, %rax         /* 1 = write() */
    movq $1, %rdi         /* 1 = stdout */
                          /* %rsi ist schon Zeiger auf String */
                          /* %rdx ist schon Länge des Strings */
    syscall

    movq $1, %rax         /* 1 = write() */
    movq $1, %rdi         /* 1 für stdout */
    movq $endl, %rsi      /* Pointer auf den Newline-String */
    movq $1, %rdx         /* Länge des Strings */
    syscall

    /* Wir haben beim Betreten dieser "Funktion" keinen eigenen Stack
     * Frame aufgebaut, also brauchen wir kein leave, es reicht ein
     * einfaches ret. */
    ret

Übersetzen:

$ as -o environment.o environment.s
$ ld -o environment environment.o

Die Ausgabe bei Ausführung des Programms sieht genauso aus wie die des bekannten Programms „env“. Das war ja auch zu erwarten.

Man könnte sich hier auch einmal mit dem gdb den Stack anschauen (zu gdb und „upsa“ siehe frühere Postings):

$ gdb ./environment
...
(gdb) upsa
Breakpoint 1 at 0x4000b0

Breakpoint 1, 0x00000000004000b0 in _start ()

---------------------------------------------------
rax            0x0      0
rbx            0x0      0
rcx            0x0      0
rdx            0x0      0
rsi            0x0      0
rdi            0x0      0
rbp            0x0      0x0
rsp            0x7fffffffe0e0   0x7fffffffe0e0
r8             0x0      0
r9             0x0      0
r10            0x0      0
r11            0x0      0
r12            0x0      0
r13            0x0      0
r14            0x0      0
r15            0x0      0
rip            0x4000b0 0x4000b0 <_start>
eflags         0x202    [ IF ]
cs             0x33     51
ss             0x2b     43
ds             0x0      0
es             0x0      0
fs             0x0      0
gs             0x0      0
---------------------------------------------------
Dump of assembler code for function _start:
=> 0x00000000004000b0 <+0>:     pop    %rdi
End of assembler dump.
---------------------------------------------------
(gdb) x/32xg $rsp
0x7fffffffe0e0: 0x0000000000000001      0x00007fffffffe42b
0x7fffffffe0f0: 0x0000000000000000      0x00007fffffffe468
0x7fffffffe100: 0x00007fffffffe473      0x00007fffffffe47f
0x7fffffffe110: 0x00007fffffffe491      0x00007fffffffe4a6
0x7fffffffe120: 0x00007fffffffe4c6      0x00007fffffffe4dd
0x7fffffffe130: 0x00007fffffffe4ed      0x00007fffffffe501
0x7fffffffe140: 0x00007fffffffe510      0x00007fffffffe522
0x7fffffffe150: 0x00007fffffffe539      0x00007fffffffe558
0x7fffffffe160: 0x00007fffffffe562      0x00007fffffffe571
0x7fffffffe170: 0x00007fffffffe584      0x00007fffffffe59a
0x7fffffffe180: 0x00007fffffffeb30      0x00007fffffffeb50
0x7fffffffe190: 0x00007fffffffeb73      0x00007fffffffeba1
0x7fffffffe1a0: 0x00007fffffffebd1      0x00007fffffffebdd
0x7fffffffe1b0: 0x00007fffffffebe8      0x00007fffffffec11
0x7fffffffe1c0: 0x00007fffffffec30      0x00007fffffffec94
0x7fffffffe1d0: 0x00007fffffffecae      0x00007fffffffecbd

Wichtig: Ich habe hier zur Ausgabe des Stacks nicht das „stack“ benutzt, was ich im oben verlinkten gdb-Posting definiert hatte, sondern „x/32xg $rsp“. Das „stack“ ist auf die Verwendung eines Frame Pointers (siehe frühere Postings) ausgelegt, den wir hier nicht haben. Der direkte „x“-Befehl hingegen gibt einfach nur 32 „giants“ (ein „giant“ sind 8 Byte – genau die Größe eines Elements auf dem Stack) aus, was für diesen Fall optimal ist. Im Klartext: Dieser „x“-Befehl gibt dir die obersten 32 Elemente auf dem Stack aus. Zu lesen ist diese Ausgabe von links nach rechts und oben nach unten.

Da sieht man nun: Die ersten 8 Byte sind eine „1“, dann folgt eine Adresse (das ist der Pointer zu „argv[0]“), dann folgt ein NULL-Pointer, dann viele weitere Pointer (das sind die zu „envp[]“). Den NULL-Pointer, der „envp[]“ abschließt, sieht man hier nicht mehr. Verwendet man als Befehl zum Beispiel „x/64xg $rsp“, erhöht man also die Anzahl der auszugebenden „giants“, dann sieht man auch diesen NULL-Pointer irgendwann.

Zugriff auf die Umgebungsvariablen von C aus

In C kann zum Beispiel über „getenv()“ und „setenv()“ auf die Umgebungsvariablen zugegriffen werden. Das sind Funktionen in der libc. Ein „export“ an der Shell wird letztendlich soetwas wie „setenv()“ benutzen. „getenv()“ liefert einfach den Pointer, so wie er oben dargestellt auf dem Stack liegt – man muss hier also aufpassen, dass man den String hinter diesem Pointer nicht ungewollt verändert, da man sonst die Umgebungsvariablen des Prozesses verändern würde.

Während „getenv()“ noch einfach zu verstehen ist (die Funktion könnte ja einfach denselben Speicher auslesen, den wir oben im Assembler-Programm ausgelesen haben), wird es bei „setenv()“ endlich interessant. Hier stellt sich nämlich die Frage, was denn passiert, wenn ich eine neue Variable anlege oder eine existierende Variable verändern möchte. Das „envp[]“-Array auf Assembler-Ebene ist ja starr, da kann ich nicht einfach so einen neuen Pointer hinzufügen. Und die Strings, auf die diese Pointer zeigen, können vielleicht auch nicht ohne Weiteres verändert werden, insbesondere nicht verlängert. Wie also funktioniert das?

Die Erklärung ist die globale Variable „environ“. „getenv()“, „setenv()“ und andere Funktionen aus der libc arbeiten alle auf „environ“ und gar nicht auf dem originalen „envp[]“-Array, das nach dem Programmstart auf dem Stack liegt. Man kann auch aus seinem eigenen Programm heraus auf „environ“ zugreifen. Im Folgenden ein Beispielprogramm, das die Nutzung der Funktionen zeigt und auch den Zugriff über „environ“. Am besten führt man das mal selbst aus und beachtet dabei die Punkte, die ich in den Kommentaren erwähnt habe:

#include <stdio.h>
#include <stdlib.h>

extern char **environ;

int
main()
{
    char *shell;
    size_t i;

    /* Initial environ auslesen -- beachte die Adressen, die hier
     * herausfallen: Die Daten liegen auf dem Stack! */
    printf("Reading environ (%p):\n", (void *)environ);
    for (i = 0; environ[i] != NULL; i++)
        printf("\t%p: '%s'\n", (void *)environ[i], environ[i]);

    /* Via getenv() eine bestimmte Variable auslesen. Man sieht, dass
     * der String dieselbe Adresse hat wie bei obiger Ausgabe (man muss
     * natürlich bedenken, dass der Teil "SHELL=" übersprungen wurde). */
    shell = getenv("SHELL");
    printf("%p: '%s'\n", (void *)shell, shell);

    /* Ändere eine Umgebungsvariable: */
    printf("Changing a variable...\n");
    setenv("SHELL", "/bin/csh", 1);

    /* Wieder alles über environ auslesen -- nur die Adresse von SHELL
     * hat sich geändert, wie man sieht, es wird also nicht der
     * ursprüngliche String überschrieben, sondern ein neuer angelegt
     * und der Pointer auf diesen in environ abgelegt: */
    printf("Reading environ (%p):\n", (void *)environ);
    for (i = 0; environ[i] != NULL; i++)
        printf("\t%p: '%s'\n", (void *)environ[i], environ[i]);

    /* Und noch einmal direkt via getenv(), auch hier ist wieder
     * dieselbe Adresse wie beim Zugriff über environ zu sehen: */
    shell = getenv("SHELL");
    printf("%p: '%s'\n", (void *)shell, shell);

    return 0;
}

Übersetzen:

$ gcc -Wall -Wextra -o environ environ.c

Der obige C-Code hat nur eine bereits existierende Variable verändert. Man konnte auch sehen, dass die Adresse von „environ“ gleich geblieben ist. Was passiert nun, wenn ich eine neue Variable hinzufüge?

#include <stdio.h>
#include <stdlib.h>

extern char **environ;

int
main()
{
    size_t i;

    printf("Address of environ: %p\n", (void *)environ);
    setenv("FOO", "BAR", 1);
    printf("New address of environ: %p\n", (void *)environ);

    printf("Complete environment:\n");
    for (i = 0; environ[i] != NULL; i++)
        printf("\t%p: '%s'\n", (void *)environ[i], environ[i]);

    return 0;
}

Man wird sowas sehen:

$ ./environ2
Address of environ: 0x7fff507ad188
New address of environ: 0x1156010
Complete environment:
        0x7fff507ae4ab: 'MANWIDTH=72'
        0x7fff507ae4b7: 'XDG_VTNR=2'
        ...
        0x7fff507aefdf: 'OLDPWD=/home/void'
        0x1156200: 'FOO=BAR'

Das heißt, er hat eine flache Kopie des „environ“-Arrays angelegt, die dann wohl größer als das originale Array ist. Betonung auf flache Kopie: Das neue Array hat nur Platz für mehr Pointer, aber die Daten, auf die durch die Pointer gezeigt wird, sind unverändert an derselben Speicherstelle. Das sieht man an den ganzen Adressen, die weiterhin auf den Stack zeigen. Die neue Variable „FOO“ wurde dynamisch allokiert und liegt auf dem Heap, wie auch das neue „environ“.

Die libc verwaltet die Umgebungsvariablen also mit einem Copy-On-Write-Mechanismus: Die existierenden Daten werden verwendet, solange das möglich ist, kommen aber neue Daten hinzu oder werden welche verändert, dann wird dafür neuer Speicher allokiert.

Wenn man sich dafür interessiert, welcher Code in der glibc dafür verantwortlich ist, dann ist „setenv()“ ein guter Einstiegspunkt: stdlib/setenv.c.

Ich bin übrigens explizit nicht auf „main(int, char **, char **)“ eingegangen, weil das wohl mittlerweile am Sterben ist und es nicht mehr alle Plattformen unterstützen.

Fork und Exec

Wir wissen jetzt also, wie innerhalb eines Prozesses die Variablen verwaltet werden. Was passiert aber, wenn ein neuer Prozess erzeugt wird? Dieser muss ja die Umgebungsvariablen seines Elternprozesses erben. Sprich, es muss irgendwie „environ“ verwendet werden, denn das originale Pointer-Array auf dem Stack existiert zwar noch, ist aber unter Umständen nicht mehr aussagekräftig.

Welche Schritte sind zur Erzeugung eines neuen Prozesses notwendig? Man muss den aktuellen Prozess kopieren (Fork) und dann durch den Kernel den aktuellen Programmcode durch das neue Programm ersetzen lassen (Exec). Beim Fork passiert mit den Umgebungsvariablen gar nichts, der aktuelle Prozess wird einfach nur kopiert. Interessant wird es erst bei Exec.

Das Rätsel ist schnell gelöst, wenn man sich die verschiedenen Varianten von „exec*()“ anschaut („man 3 exec“), die ja auch alle libc-Funktionen sind:

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

Es gibt also genau zwei Varianten, bei denen man das Pointer-Array für die neuen Umgebungsvariablen explizit selbst bestimmen kann („execle()“ und „execvpe()“). Bei allen anderen gibt es diese Möglichkeit nicht, also muss es hier einen Automatismus geben. Ta-tah, es ist weiter unten dokumentiert:

The execle() and execvpe() functions allow the caller to spec‐ ify the environment of the executed program via the argument envp. The envp argument is an array of pointers to null-termi‐ nated strings and must be terminated by a null pointer. The other functions take the environment for the new process image from the external variable environ in the calling process.

Dass die glibc das wirklich tut, kann man zum Beispiel hier in posix/execv.c sehen. Nicht verwirren lassen: „__environist ein Weak Alias auf „environ“.

Zusammenfassung

So ergibt sich dann ein konsistentes Bild: