Sie haben verschiedene (aber verwandte) Fragen miteinander kombiniert. Einige von ihnen sind hier nicht wirklich thematisch (z. B. Codierungsstandards), daher werde ich diese ignorieren.
Ich werde damit beginnen, ob der Kernel "technisch inkorrekter C-Code" ist. Ich fange hier an, weil die Antwort die spezielle Position erklärt, die ein Kernel einnimmt, was für das Verständnis des Restes entscheidend ist.
Ist der Kernel technisch falscher C-Code?
Die Antwort ist definitiv "falsch".
Es gibt einige Möglichkeiten, wie ein C-Programm als falsch bezeichnet werden kann. Lassen Sie uns zuerst ein paar einfache aus dem Weg räumen:
- Ein Programm, das nicht der C-Syntax folgt (dh einen Syntaxfehler aufweist), ist falsch. Der Kernel verwendet verschiedene GNU-Erweiterungen der C-Syntax. Dies sind nach C-Standard Syntaxfehler. (Für GCC natürlich nicht. Versuchen Sie, mit
-std=c99 -pedantic
oder ähnlichem zu kompilieren ...)
- Ein Programm, das nicht das tut, wofür es entwickelt wurde, ist falsch. Der Kernel ist ein riesiges Programm, und wie selbst eine schnelle Überprüfung seiner Änderungsprotokolle beweisen wird, ist dies sicherlich nicht der Fall. Oder, wie wir allgemein sagen würden, es hat Fehler.
Was Optimierung in C bedeutet
[HINWEIS: Dieser Abschnitt enthält eine sehr lose Anpassung der tatsächlichen Regeln. Einzelheiten finden Sie im Standard und suchen Sie nach Stapelüberlauf.]
Nun zu dem, der mehr Erklärung braucht. Der C-Standard besagt, dass bestimmter Code ein bestimmtes Verhalten erzeugen muss. Es heißt auch, dass bestimmte Dinge, die syntaktisch gültig sind, "undefiniertes Verhalten" haben; Ein (leider häufiges!) Beispiel ist der Zugriff über das Ende eines Arrays hinaus (z. B. ein Pufferüberlauf).
Undefiniertes Verhalten ist mächtig. Wenn ein Programm es enthält, auch nur ein kleines bisschen, kümmert sich der C-Standard nicht mehr darum, welches Verhalten das Programm zeigt oder welche Ausgabe ein Compiler erzeugt, wenn er damit konfrontiert wird.
Aber selbst wenn das Programm nur definiertes Verhalten enthält, lässt C dem Compiler dennoch viel Spielraum. Als triviales Beispiel (Anmerkung: In meinen Beispielen lasse ich der #include
Kürze halber Zeilen usw. weg ):
void f() {
int *i = malloc(sizeof(int));
*i = 3;
*i += 2;
printf("%i\n", *i);
free(i);
}
Das sollte natürlich 5 gefolgt von einer neuen Zeile drucken. Das verlangt der C-Standard.
Wenn Sie dieses Programm kompilieren und die Ausgabe zerlegen, erwarten Sie, dass malloc aufgerufen wird, um Speicher zu erhalten. Der zurückgegebene Zeiger wird irgendwo gespeichert (wahrscheinlich ein Register), der Wert 3 wird in diesem Speicher gespeichert und 2 wird in diesem Speicher hinzugefügt (möglicherweise) sogar das Laden, Hinzufügen und Speichern erforderlich), dann der auf den Stapel kopierte Speicher und die ebenfalls "%i\n"
auf den Stapel gesetzte Punktzeichenfolge, dann die printf
aufgerufene Funktion. Ein gutes Stück Arbeit. Stattdessen sehen Sie möglicherweise Folgendes, als hätten Sie geschrieben:
/* Note that isn't hypothetical; gcc 4.9 at -O1 or higher does this. */
void f() { printf("%i\n", 5) }
und hier ist die Sache: Der C-Standard erlaubt das. Der C-Standard kümmert sich nur um die Ergebnisse , nicht um die Art und Weise, wie sie erreicht werden.
Darum geht es bei der Optimierung in C. Der Compiler bietet eine intelligentere Methode (im Allgemeinen entweder kleiner oder schneller, abhängig von den Flags), um die vom C-Standard geforderten Ergebnisse zu erzielen. Es gibt einige Ausnahmen, wie beispielsweise die -ffast-math
Option von GCC , aber ansonsten ändert die Optimierungsstufe nicht das Verhalten technisch korrekter Programme (dh solche, die nur definiertes Verhalten enthalten).
Können Sie einen Kernel nur mit definiertem Verhalten schreiben?
Lassen Sie uns unser Beispielprogramm weiter untersuchen. Die Version, die wir geschrieben haben, nicht die, in die der Compiler sie geschrieben hat. Das erste, was wir tun, ist anzurufen malloc
, um etwas Gedächtnis zu bekommen. Der C-Standard sagt uns, was er malloc
tut, aber nicht, wie er es tut.
Wenn wir uns eine Implementierung ansehen, malloc
die auf Klarheit abzielt (im Gegensatz zu Geschwindigkeit), werden wir sehen, dass es einen gewissen Systemaufruf (wie z. B. mmap
mit MAP_ANONYMOUS
) macht, um einen großen Teil des Speichers zu erhalten. Intern werden einige Datenstrukturen beibehalten, die angeben, welche Teile dieses Blocks im Vergleich zu frei verwendet werden. Es findet einen freien Block, der mindestens so groß ist wie das, wonach Sie gefragt haben, schneidet den Betrag heraus, nach dem Sie gefragt haben, und gibt einen Zeiger darauf zurück. Es ist auch vollständig in C geschrieben und enthält nur definiertes Verhalten. Wenn es threadsicher ist, kann es einige pthread-Aufrufe enthalten.
Nun endlich, wenn wir uns was ansehen mmap
tut, wir sehen alle Arten von interessanten Sachen. Zunächst werden einige Überprüfungen durchgeführt, um festzustellen, ob das System über genügend freien RAM und / oder Swap für die Zuordnung verfügt. Als Nächstes wird ein freier Adressraum zum Einfügen des Blocks gefunden. Anschließend wird eine Datenstruktur namens Seitentabelle bearbeitet, und es werden wahrscheinlich mehrere Inline-Assembly-Aufrufe ausgeführt. Möglicherweise werden tatsächlich einige freie Seiten des physischen Speichers gefunden (dh tatsächliche Bits in tatsächlichen DRAM-Modulen) - ein Prozess, bei dem möglicherweise auch andere Speicher zum Austauschen gezwungen werden müssen -. Wenn dies nicht für den gesamten angeforderten Block der Fall ist, werden stattdessen die Dinge so eingerichtet, dass dies geschieht, wenn zum ersten Mal auf den Speicher zugegriffen wird. Ein Großteil davon wird durch Inline-Assemblierung, Schreiben an verschiedene magische Adressen usw. erreicht. Beachten Sie auch, dass große Teile des Kernels verwendet werden, insbesondere wenn ein Austausch erforderlich ist.
Die Inline-Baugruppe, das Schreiben an magische Adressen usw. liegt außerhalb der C-Spezifikation. Das ist nicht überraschend. C läuft über viele verschiedene Maschinenarchitekturen - einschließlich einer Reihe, die in den frühen 1970er Jahren, als C erfunden wurde, kaum vorstellbar waren. Das Ausblenden dieses maschinenspezifischen Codes ist ein zentraler Bestandteil dessen, wofür ein Kernel (und in gewissem Maße eine C-Bibliothek) gedacht ist.
Wenn Sie zum Beispielprogramm zurückkehren, wird natürlich klar, printf
dass es ähnlich sein muss. Es ist ziemlich klar, wie alle Formatierungen usw. in Standard C ausgeführt werden. aber tatsächlich auf den Monitor bekommen? Oder an ein anderes Programm weitergeleitet? Wieder einmal viel Magie vom Kernel (und möglicherweise X11 oder Wayland).
Wenn Sie an andere Dinge denken, die der Kernel tut, befinden sich viele außerhalb von C. Beispielsweise liest der Kernel Daten von Festplatten (C kennt keine Festplatten, PCIe-Busse oder SATA) in den physischen Speicher (C kennt nur Malloc, nicht von DIMMs, MMUs usw.), macht es ausführbar (C weiß nichts über Prozessorausführungsbits) und ruft es dann als Funktionen auf (nicht nur außerhalb von C, sehr unzulässig).
Die Beziehung zwischen einem Kernel und seinen Compilern
Wenn Sie sich von früher erinnern, wenn ein Programm undefiniertes Verhalten enthält, was den C-Standard betrifft, sind alle Wetten ungültig. Aber ein Kernel muss wirklich undefiniertes Verhalten enthalten. Es muss also eine Beziehung zwischen dem Kernel und seinem Compiler bestehen, zumindest genug, damit die Kernelentwickler sicher sein können, dass der Kernel trotz Verstoßes gegen den C-Standard funktioniert. Zumindest im Fall von Linux schließt dies ein, dass der Kernel einige Kenntnisse darüber hat, wie GCC intern funktioniert.
Wie wahrscheinlich ist es zu brechen?
Zukünftige GCC-Versionen werden wahrscheinlich den Kernel beschädigen. Ich kann das ziemlich sicher sagen, wie es schon mehrmals passiert ist. Natürlich haben Dinge wie die strengen Aliasing-Optimierungen in GCC neben dem Kernel auch viele andere Dinge kaputt gemacht.
Beachten Sie auch, dass das Inlining, von dem der Linux-Kernel abhängt, kein automatisches Inlining ist, sondern ein Inlining, das die Kernel-Entwickler manuell angegeben haben. Es gibt verschiedene Leute, die den Kernel mit -O0 kompiliert haben und berichten, dass er im Grunde funktioniert, nachdem einige kleinere Probleme behoben wurden. (Einer ist sogar in dem Thread, mit dem Sie verlinkt haben). Meistens sehen die Kernel-Entwickler keinen Grund zum Kompilieren -O0
, und wenn eine Optimierung als Nebeneffekt erforderlich ist, funktionieren einige Tricks, und niemand testet damit -O0
, sodass sie nicht unterstützt werden.
Dies kompiliert und verknüpft beispielsweise mit -O1
oder höher, jedoch nicht mit -O0
:
void f();
int main() {
int x = 0, *y;
y = &x;
if (*y)
f();
return 0;
}
Mit der Optimierung kann gcc herausfinden, dass dies f()
niemals aufgerufen wird, und es weglassen. Ohne Optimierung verlässt gcc den Aufruf und der Linker schlägt fehl, weil es keine Definition von gibt f()
. Die Kernel-Entwickler verlassen sich auf ein ähnliches Verhalten, um das Lesen / Schreiben des Kernel-Codes zu vereinfachen.