blog · git · desktop · images · contact
2014-05-01
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?
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:
pop
“ erreichen
können, ist „argc
“: Ein Integer, der die Anzahl der
Kommandozeilenargumente angibt.argv[0]
“ bis
„argv[n]
“ wobei „n
“ = „argc - 1
“). Hinter diesen Pointern
verbergen sich die eigentlichen NUL-terminierten Strings der
Kommandozeilenargumente.argv
“-Liste abschließt.FOO=BAR
“.argc
“.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.
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.
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: „__environ
“
ist ein Weak Alias auf „environ
“.
So ergibt sich dann ein konsistentes Bild:
execve()
“ (siehe „man 2 execve
“). Hier muss immer auch ein
Pointer-Array für die Umgebungsvariablen des neuen Prozesses
angegeben werden. Der Kernel schert sich nicht um Zeugs wie
„environ
“.environ
“, welches mit dem Pointer-Array auf dem
Stack startet, Schreibzugriffe aber nach Copy-On-Write handhabt.execve()
“-Syscall bereit. Bei den Varianten, die keine explizite
Angabe der Umgebung erlauben, gilt der Default, dass das libc-eigene
„environ
“ benutzt wird.