blog · git · desktop · images · contact
2010-09-02
Gibt man Pfade zu Dateien an, kann man das so tun:
touch foo
rm ./foo
vim ../mein_skript.sh
Dabei meint "foo" die Datei "foo" im aktuellen Verzeichnis und
"../mein_skript.sh
" ist auch relativ zum aktuellen Verzeichnis
gemeint, ebenso "./foo". So weit, so einfach. Möchte man aber Programme
ausführen, muss zwingend ein vollständiger Pfad angegeben werden:
./mein_skript.sh
/usr/bin/vim
Kurze Definition, da der Begriff "vollständiger Pfad" nicht so üblich ist: Ich meine damit einen Pfad, bei dem -- wenn das aktuelle Verzeichnis bekannt ist -- unmissverständlich klar ist, welche Datei gemeint ist. Bei "./foo" ist das der Fall, denn der Punkt bezeichnet das aktuelle Verzeichnis. Da gibt es keinen Interpretationsspielraum. Bei nur "foo" ist das aber eigentlich gar nicht so klar und es muss eine Konvention eingeführt werden.
Zurück zum Beispiel: Wird hier auf "./" oder "/usr/bin/" verzichtet, dann ist nicht mehr klar festgelegt, welche Datei gemeint ist -- der Pfad ist also unvollständig. Ist eine Datei im aktuellen Verzeichnis gemeint? Oder wo ganz anders? In diesem Fall wird tatsächlich der Inhalt von "$PATH" angeschaut und die dortigen Verzeichnisse nach der Datei durchsucht. Das aktuelle Verzeichnis wird nur betrachtet, wenn es explizit in "$PATH" an geeigneter Position auftaucht.
Wer schon einmal mit der Shell Kontakt hatte, für den ist das alles kalter Kaffee. Man lernt das ganz schnell. Trotzdem ist es inkonsistent. Bei "touch foo" wird "touch" ausschließlich über "$PATH" bestimmt, dagegen bezieht sich "foo" ausschließlich auf das aktuelle Verzeichnis. Beides sind aber Pfadangaben.
Warum ist das so? Müssen letztendlich nicht beide Dateien über etwas wie "open()" geöffnet werden? Oder läuft das bei "exec()" ganz anders ab? Warum kann bei "foo" die Angabe "./" ausgelassen werden? Das könnte genauso gut Pflicht sein. Wer sucht "$PATH" ab? Welche weiteren Mechanismen gibt es vielleicht sonst noch und wo ist all dies jeweils implementiert? Zeit, diesen Fragen auf den Grund zu gehen.
Folgendes Minimalbeispiel:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, const char *argv[])
{
int fd = open("foo", O_RDONLY);
if (fd == -1)
{
perror("open() failed");
return 1;
}
printf("File opened.\n");
close(fd);
return 0;
}
"open()" ist also der fragliche Syscall, dem einfach nur "foo" übergeben
wird. Über die Syscall-Table gelangt man (wie zu erwarten) zu
"sys_open
", was sich in fs/open.c wiederfindet und auf
"do_sys_open()
" in derselben Datei verweist.
Dort springt einem zwar "getname()" ins Auge, aber die tatsächliche
Magie spielt sich in "do_filp_open()
" in fs/namei.c ab. Nach ein
paar "if"-Statements findet sich in dieser Funktion der Kommentar "find
the parent" und darunter ein Call zu "path_init()
".
Dort sieht man nun, dass, wenn der Parameter "dfd" auf "AT_FDCWD
"
gesetzt ist, das aktuelle Arbeitsverzeichnis als Basis verwendet wird.
"dfd" ist kurz für "directory file descriptor". Zur "current"-Variable
steht übrigens hier ein Wort -- sie ist ein struct vom Typ
task_struct
und enthält den Kontext des aktuellen Prozesses. Ist
"dfd" also entsprechend gesetzt? Zurück zum ursprünglichen
"open()"-Syscall. Ja, hier wird "AT_FDCWD
" übergeben. Damit ist
für jeden "open()"-Aufruf sicher, dass sich relative Pfade auf das
aktuelle Verzeichnis beziehen -- auch unvollständige.
– Nachtrag vom 12.04.2011 zu "current": Der oben verlinkte Artikel ist
von 2002 und bezieht sich damit wohl auf Linux 2.4. Wie ich eben im Buch
"Understanding the Linux Kernel" (3. Ausgabe, Seite 86 f.), das Linux
2.6.11 behandelt, gelesen habe, stimmt obige Beschreibung so nicht mehr.
"current" ist keine Variable mehr, sondern ein Makro (und das ist heute
in Linux 2.6.38 immernoch so, siehe "arch/x86/include/asm/current.h
"
und "arch/x86/include/asm/thread_info.h
"). Die Bedeutung ist aber
immernoch dieselbe. Details zu diesem Makro möchte ich in diesem
Nachtrag nicht auch noch einwerfen, sondern verweise mal ganz frech
auf's Buch. Das ist ohnehin hochinteressant und sehr empfehlenswert.
Übrigens bezieht sich das nur auf den ersten Startpunkt. Relative
Angaben wie "." oder ".." werden danach in "link_path_walk()
"
weiterverarbeitet, was kurz nach "path_init()
" aufgerufen wird.
Neben "open()" gibt es aber auch noch
"openat()", das benutzt man bloß in
der Regel nicht. Hier ist "AT_FDCWD
" nicht fest verdrahtet,
sondern man kann einen beliebigen Verzeichnis-Deskriptor übergeben.
Relativ zu diesem wird dann die Datei geöffnet. Folgendes Beispiel würde
"/tmp/foo" öffnen:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, const char *argv[])
{
int dirfd = open("/tmp", O_RDONLY);
if (dirfd == -1)
{
perror("open(dir) failed");
return 1;
}
int fd = openat(dirfd, "foo", O_RDONLY);
if (fd == -1)
{
perror("openat(file) failed");
return 1;
}
printf("File opened.\n");
close(fd);
close(dirfd);
return 0;
}
Wie bereits bekannt (siehe Posting zum Shebang-Mechanismus), ist
"do_execve()
" in fs/exec.c der "point of interest", wenn es um
das Ausführen von Dateien geht. Jeder mögliche "exec()"-Call landet
hier. Dort findet sich dann ein Call zu "open_exec()
" in
derselben Datei, wo sich wieder "do_filp_open()
" findet. Whoops.
Dieser Call wird auch mit "AT_FDCWD
" ausgeführt. Hieße das etwa, dass
in "execve()" auch immer das aktuelle Verzeichnis als Basis verwendet
wird? Ja, heißt es:
#include <stdio.h>
#include <unistd.h>
int main(int argc, const char *argv[])
{
char *args[] = { "test.sh", NULL };
char *envs[] = { NULL };
if (execve("test.sh", args, envs) == -1)
{
perror("execve()");
return 1;
}
return 0;
}
Befindet sich im aktuellen Verzeichnis eine ausführbare Datei namens "test.sh", dann wird diese auch tatsächlich ausgeführt.
Etwas erhellend ist es, einmal RTFM auszuführen. In "man 3 execve" (Achtung, nicht "man 2 execve") werden nämlich auch die Varianten "execlp()" und "execvp()" erwähnt. Diese suchen nun "$PATH" ab, das heißt, hier wird ein systemweites "test.sh" gesucht:
#include <stdio.h>
#include <unistd.h>
int main(int argc, const char *argv[])
{
if (execlp("test.sh", "test.sh", NULL) == -1)
{
perror("execlp()");
return 1;
}
return 0;
}
Höchstwahrscheinlich wird keines gefunden und es kommt zum Fehler. In diesem Fall muss "./test.sh" explizit angegeben werden, um "test.sh" im aktuellen Verzeichnis zu starten.
Man kann festhalten: Die Inkonsistenz ist keine grundlegende Eigenschaft, sondern tritt erst "in Sonderfällen" auf, wenn bewusst "$PATH" verwendet werden soll. Ansonsten verhält sich "exec()" ohne "p" wie "open()".
Weil das heutzutage so ein großes Thema ist: Man könnte vorschnell auf die Idee kommen, dass ein unvorsichtiges Benutzen von "execve()" oder den anderen Varianten ohne "p" ein ähnliches Einfallstor öffnet -- schließlich werden Dateien aus dem aktuellen Arbeitsverzeichnis geöffnet. Dem ist aber mitnichten so. Denn will man "ls" starten, kann man nicht folgendes schreiben:
...
execl("ls", "ls", "-al", NULL);
...
Dieser Aufruf würde zwar "ls" im aktuellen Verzeichnis starten, es gibt aber keinen Fallback-Mechanismus, falls es dort kein "ls" gibt. Man kann so nicht ungewollterweise das richtige "ls" starten. Man müsste explizit "/bin/ls" starten -- und dann ist wieder ausgeschlossen, dass eine Datei im aktuellen Verzeichnis gemeint ist. Also gilt für die "exec()"-Varianten ohne "p":
Die "p"-Varianten sind also nur Hilfsfunktionen für den Fall, dass man nicht genau weiß, wo sich Binaries befinden, und dies vom System über "$PATH" herausgefunden haben möchte. Sie führen aber niemals Dateien aus dem aktuellen Verzeichnis aus, es sei denn, das ist explizit gewünscht (was nicht der Standard ist).
Bleibt die Frage zu klären, wo die "p"-Varianten implementiert sind und
wer "$PATH" abläuft. Leider bleibt einem hier nicht erspart, in der
glibc mit der Suche zu starten. Da "execlp()" nur ein Frontend für
"execvp()" ist, beginnt man besser mit letzterem. Über
posix/execvp.c wird man an "__execvpe()
" verwiesen, was in
posix/execvpe.c ausprogrammiert ist.
Diese Funktion prüft zuerst, ob der übergebene Pfad einen Schrägstrich enthält. Da solche nicht in Dateinamen erlaubt sind und immer Verzeichnistrenner sind, ist also klar, dass es sich um einen vollständigen Pfad handeln muss. Dann wird "$PATH" gar nicht angerührt.
Nur dann, wenn es keinen Schrägstrich gibt, wird "$PATH" ausgelesen. Die
Funktion ist dann ganz pragmatisch: Sie baut nacheinander eine
Pfadangabe aus einem Element aus "$PATH" und dem übergebenen Dateinamen
zusammen. Das wird an "__execve()
" übergeben. Schlägt dieser Aufruf
fehl, wird einfach das nächste Element aus "$PATH" verwendet. Danach
endet die Funktion aber -- es gibt definitiv kein Fallback, um eben doch
eine Datei im aktuellen Verzeichnis auszuführen, wenn sie im Pfad nicht
gefunden wurde (andersherum auch nicht).
Einzig für den Fall, dass eine Datei ausführbar ist, vom Kernel aber
nicht gestartet werden konnte, gibt es einen Ausnahmefall: Dann wird
"eine Shell" verwendet, um die Datei zu interpretieren. Relevant ist
hierbei der Wert von "_PATH_BSHELL
", was für Linux in der glibc als
"/bin/sh" definiert ist. Warum man das tut, ist mir -- ehrlich
gesagt -- schleierhaft. Zumindest die Shell des Nutzers könnte man
verwenden, aber sauberer fände ich es, in diesem Fall einfach mit einem
Fehler abzubrechen. "execl()" tut das übrigens: Hier gibt es keinen
"och, probieren wir's mal mit einer Shell"-Fallback.
Bezüglich "open()":
AT_FDCWD
" als Argument für "do_filp_open()
" sorgt auf jeden
Fall dafür, dass sich unvollständige Pfade immer auf das aktuelle
Verzeichnis beziehen. Daher ist kein "./" notwendig.Bezüglich der "exec()"-Varianten ohne "p":
"exec()"-Varianten mit "p":