Blog
Das Elend mit dem Linker
(und wie man sich dann doch mit ihm anfreundet)
2022-06-16
Inhalt
In der 4.
BST-Aufgabe wollen wir eigene Anwendungen getrennt vom Kernel
übersetzen und isoliert ausführen. Um deren Entwicklung bequemer zu
machen, soll es mit libsys
eine minimale (statische!) Standard-Bibliothek
geben.
Die Idee ist damit grundsätzlich ähnlich zur weit verbreiteten
glibc, aber
soll natürlich um Größenordnungen
einfacher sein — der historisch gewachsene GNU Code ist eine
unwartbare #ifdef
-Hölle, weswegen inzwischen auch häufig
(insbesondere im Forschungsbereich) auf kompakte & verständlichere
Alternativen wie musl zurückgegriffen
wird!
Grundlagen
Ein Compiler übersetzt ein Hochsprachenprogramm (ggf. über den
Zwischenschritt Assembler) in Maschinencode. Aus (ehemaligen)
Performancegründen wird das in systemnahen Sprachen wie C & C++
heutzutage noch modulweise gemacht: Die .cc
-Dateien werden
einzeln in Objektdateien .o
übersetzt — Änderungen an einer
Datei führen also nicht zwingend dazu, dass alles neu übersetzt werden
muss, sondern eben nur der modifizierte Teil. Als die CPUs noch
langsamer waren, war das auch für kleine Projekte relevant, heute ist
das maximal noch bei großen Projekten von Bedeutung.
Damit das funktionieren kann, muss der Compiler wissen, welche
Funktionen es gibt, welche Parameter[typen] diese erwarten & was sie
zurück geben — also die Signaturen (Funktionsdeklarationen). Analog gilt
dies für (globale) Variablen, Strukturen usw. Dafür verwenden wir die
Headerdatei (.h
).
Der Compiler macht hierfür symbolische Verknüpfungen, gibt dabei an, welche Symbole er in der Objektdatei selbst anbietet (definiert) und wo & in welcher Form er im generierten Maschinencode ein externes (undefiniertes) Symbol — genauer: dessen Zieladresse — braucht.
Dabei entspricht in C der Funktions- oder Variablenname direkt dem Symbolnamen, in C++ muss (aufgrund der Möglichkeit Funktionen zu überladen) der Name noch mit weiteren Eigenschaften (z.B. Parametertypen) verwurschtelt werden.
Zudem teilt der Compiler den Maschinencode und die dazugehörigen Daten in Sektionen ein, unter anderem:
.text
für den ausführbaren Maschinencode.data
für initialisierte Variablen (mit einem Wert ungleich0
).bss
für mit0
initialisierte Variablen [damit kann man später den Speicherverbrauch der Binärdatei optimieren].rodata
für nur lesbare Variablen (Konstanten)
Das ist nur eine Konvention — es können beliebig weitere Sektionen erstellt werden, man kann dies auch selbst in den Quelltextdateien steuern!
Am Ende, wenn wir alle Quelltextdateien im übersetzten Zustand vorliegen haben, muss es noch zusammen gebunden werden — was bekanntlich die Aufgabe vom Linker/Binder ist. Dieser braucht nun nicht zwingend ein Verständnis was der Code da jeweils tut, sondern Interessiert sich vereinfacht gesagt für zwei Dinge: die Symbole und die Sektionen.
Er geht der Reihe nach die Objektdateien durch, speichert sich in einer Datenstruktur (Hashmap) welche definierten Symbole er bis jetzt vorgefunden hat und ordnet diesen (Abhängig von den Sektionen in den sie liegen und was die Konfiguration dafür vorgibt) Adressen zu. Sollte er auf ein undefiniertes Symbol stoßen, schaut er in seiner Hashmap nach, und ersetzt es entweder mit der Adresse von dem Symbol — oder wirft einen Fehler, weil er auf ein ihm bisher unbekanntes Symbol gestoßen ist.
Das ist nun natürlich eine maximal vereinfachte Darstellung, denn mit positionsunabhängigen Code fügt der Linker durchaus eigene Sektionen wie die Global Offset Table ein, bei Binde-Zeit-Optimierungen (LTO) übernimmt er sogar die Aufgabe des Compilers und erstellt (emitiert) den Maschinencode — was effizienten Code bei deutlich höherer Laufzeit des Binders ermöglicht. Aber diese ganzen Themen sind für das grundlegende Verständnis erst einmal nicht weiter wichtig!
Wenn der Linker aber alle Symbole findet, fällt (hoffentlich) eine ausführbare Datei heraus. Und genau um die geht es in dieser Aufgabe 🙂
Ausgangssituation
In user/app1/main.c
schreiben wir unser erstes Programm
— ein typisches hello-world-Beispiel:
#include <syscall.h>
extern "C" void main() {
while(1) {
(1, "Hallo Welt!\n", 12);
sys_write(1000);
sys_sleep}
}
Damit das laufen kann, haben wir in der libsys
unsere
Systemaufrufstümpfe (stubs) angelegt. Und da auch unser
Standardbibliotheksordner bei include
-Anweisungen aufgrund
des Parameters -I ../../libsys
in der Makefile durchsucht
wird, können wir die Systemaufrufe dank der Deklarationen in der
syscall.h
bequem verwenden!
Randnotiz: Es ist in Ordnung, wenn die
Systemaufrufnummer von nun an in zwei Dateien verwaltet werden: Einmal
in der libsys
und ein zweites Mal im Kernel bei den
skeletons. Man könnte das zwar mit includes und
Symlinks auch gemeinsam in einer Datei managen, aber macht es lieber
(zumindest anfangs) so einfach wie möglich!
Das Benutzeranwendungslinkerskript
Wie in der Tafelübung erwähnt brauchen wir für C(++) Code erst einmal
ein Linkerskript für die Anwendungen. Die können wir uns aus dem Kernel
von compiler/sections.ld
kopieren und passen das dann
an:
- unsere Einsprungsfunktion soll nun
start
sein - start im Userspace (bei
0x4000000
= 64 MB) - unnötige Sektionen (
.boot
und.setup_ap_seg
) werden entfernt - ebenso brauchen wir die Source Code References
(
___KERNEL_START___
usw.) nicht mehr
Die Initialisierungsroutinen
Außerdem brauchen wir ebenfalls noch die Startup-Routinen für die
Initialisierung. Da bedienen wir uns wieder beim Kernel und kopieren das
aus compiler/libc.cc
nach user/init.cc
zusammen, zum Beispiel:
#ifdef __cplusplus
extern "C" {
#endif
extern void _init();
extern void _fini();
extern void main();
extern void(*__preinit_array_start[]) ();
extern void(*__preinit_array_end[]) ();
extern void(*__init_array_start[]) ();
extern void(*__init_array_end[]) ();
extern void(*__fini_array_start[]) ();
extern void(*__fini_array_end[]) ();
[[noreturn]] void start() {
// Call constructors
const unsigned int preinit_size = __preinit_array_end - __preinit_array_start;
for (unsigned int i = 0; i != preinit_size; ++i)
(*__preinit_array_start[i])();
();
_init
const unsigned int init_size = __init_array_end - __init_array_start;
for (unsigned int i = 0; i != init_size; ++i)
(*__init_array_start[i])();
// Call teh application
();
main
// Call destructors
const unsigned int fini_size = __fini_array_end - __fini_array_start;
for (unsigned int i = 0; i != fini_size; ++i)
(*__fini_array_start[i])();
();
_fini
// Wait forever
while(1) {}
}
#ifdef __cplusplus
}
#endif
Wozu wird das eigentlich gebraucht?
Nun, es kann zum Beispiel sein, dass nicht alle globalen Objekte zur
Übersetzungszeit ausgewertet werden — einfaches (StuBS) Beispiel ist
eine Instanz einer Klasse, welche im Konstruktor die Anzahl der aktiven
CPU Kerne auswertet. Das muss zur Laufzeit geschehen, aber vor der
eigentlichen main
.
Der Gcc-Compiler kann übrigens auch mittels Funktionsattribut eine Ausführung während der Initialisierung ermöglichen:
__attribute__((constructor)) void foo() {
(1, "bar\n", 4);
sys_write}
Der Übersetzer würde hierbei lediglich einen Zeiger auf
foo
in das Init-Array stecken (und im vorherigen Beispiel
einen Zeiger auf den Konstruktor des Objekts), und unsere
Initialisierungsroutinen kümmern sich um die Ausführung bevor das
eigentliche Hauptprogramm (main
) aufgerufen wird.
Objektdateien
Wenn wir die Datei mit den Initialisierungsroutinen nun zur
init.o
kompilieren (in Gcc mit dem Parameter
-c
, in der Makefile tools/build.mk
wird das im
Rezept $(BUILDDIR)/%.o
gemacht), so kommt eine relozierbare
Objektdatei raus.
Was heißt das?
Schauen wir uns mal den Assembler des übersetzte Objekt (mittels
objdump -d user/.build/init.o
) an:
0000000000000000 <start>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: b8 00 00 00 00 mov $0x0,%eax
a: 53 push %rbx
b: 48 2d 00 00 00 00 sub $0x0,%rax
11: 48 c1 f8 03 sar $0x3,%rax
15: 48 83 ec 08 sub $0x8,%rsp
19: 85 c0 test %eax,%eax
1b: 74 18 je 35 <start+0x35>
1d: 8d 68 ff lea -0x1(%rax),%ebp
20: 31 db xor %ebx,%ebx
22: ff 14 dd 00 00 00 00 callq *0x0(,%rbx,8)
29: 48 89 d8 mov %rbx,%rax
29: 48 89 d8 mov %rbx,%rax
2c: 48 83 c3 01 add $0x1,%rbx
30: 48 39 e8 cmp %rbp,%rax
33: 75 ed jne 22 <start+0x22>
35: e8 00 00 00 00 callq 3a <start+0x3a>
...
Was man hier sieht:
start
beginnt an Adresse0
- es sind insgesammt auffallend viele Nullen, egal ob an Adresse
6
(5
+ 1 Byte),d
(b
+ 2 Bytes) oder bei dencallq
ab Adresse25
&36
…
Beides ist Aufgabe vom Linker: Adressen für Symbole zu vergeben und die Referenzen darauf anzupassen. Aber dazu fehlen noch weitere Informationen — allen voran, was überhaupt wohin zeigen soll.
Relokationen
Die Objektdatei liegt ja im ELF-Format vor, und da können mehr
Sektionen drin sein — nicht nur der Code selbst. Wir lassen uns dazu mal
die sogenannten Relokationen mittels
readelf -r user/.build/init.o
ausgeben — für uns ist nur
.rela.text
interessant:
Relocation section '.rela.text' at offset 0xfd8 contains 12 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000006 00110000000a R_X86_64_32 0000000000000000 __preinit_array_end + 0
00000000000d 00120000000b R_X86_64_32S 0000000000000000 __preinit_array_start + 0
000000000025 00120000000b R_X86_64_32S 0000000000000000 __preinit_array_start + 0
000000000036 001300000004 R_X86_64_PLT32 0000000000000000 _init - 4
...
Offset trifft ganz zufällig die Adressen, die uns gerade aufgefallen sind. Die Symbolnamen können wir auch gut zum obigen Hochsprachencode zuordnen. Hinter Type verbergen sich die verschiedenen Berechnung, die (unter anderem) mit der Adresse des Symbols und dem Addend den Wert ausrechnen, der an die durch Offset angegebene Adresse angegeben wird.
Beispiel: Wenn sich der Linker dazu entscheidet, das
(automatisch vom Compiler generierte Symbol)
__preinit_array_end
an die Adresse 0x4002000
zu legen, dann wird bei R_X86_64_32
schlicht diese
Symboladresse zusammen mit dem Addend (hier 0
) als
32-Bit langer Wert ausgewertet (04 00 20 00
) und an Offset
geschrieben — aber Intel-typisch dank little-endian
umgedreht: 00 20 00 04
.
Eine detaillierte Übersicht über die verschiedenen Relokationstypen findet sich unter 4.4. Relocation der System V ABI
Binden
Aber wie wird der Linker aufgerufen? Standardmäßig ist
ld
der Linker. Außer ihr nutzt Clang — dann ist es
ld.lld
. Oder wollt gewisse Binde-Zeit-Optimierungen,
dann ld.gold
. Da das alles verwirrend ist, kann euch der
Compiler (über eine weitere Indirektion: collect2)
das abnehmen und direkt selbst den Linker aufrufen — was wir auch aus
Kompatibilitätsgründen in StuBS nutzen: Im Kernel lautet die
entsprechende Makefile-Zeile
$(VERBOSE) $(CXX) $(CXXFLAGS) -Wl,-T $(LINKER_SCRIPT) -o $@ $(LDFLAGS) $(CRTI_OBJECT) $(CRTBEGIN_OBJECT) $(ASM_OBJECTS) $(CC_OBJECTS) $(LIBGCC) $(CRTEND_OBJECT) $(CRTN_OBJECT)
Hinter $(CXX)
versteckt sich mit g++
unser
Compiler, und mit -Wl,
weisen wir diesen an, den nach dem
Komma folgenden Parameter an den Linker weiterzugeben, hier
-T
um ein eigenes Linkerskript (statt dem für
Betriebssystem völlig ungeeignete Standardlinkerskript, siehe
gcc -Wl,-verbose
) zu verwenden.
Mit erstellter statischer libsys
Bibliothek und
main.o
Objektdatei ignorieren wir mal die Parameter und
führen das an unsere Anwendung angepasste
g++ $(CXXFLAGS) -Wl,-T ../sections.ld -o user/app1/.build/app user/.build/init.o user/app1/.build/main.o -Llibsys/.build -lsys
aus — als vereinfachendes Beispiel gehen wir davon aus, dass die Übersetzung funktioniert, etwaige Fehler in diesem Schritt werden weiter unten erklärt.
Die resultierende (nun ausführbare Elf-)Datei wird wieder
dissassembiliert (objdump -d user/app1/.build/app
):
0000000004000000 <start>:
4000000: f3 0f 1e fa endbr64
4000004: 55 push %rbp
4000005: b8 00 20 00 04 mov $0x4002000,%eax
400000a: 53 push %rbx
400000b: 48 2d 00 20 00 04 sub $0x4002000,%rax
4000011: 48 c1 f8 03 sar $0x3,%rax
4000015: 48 83 ec 08 sub $0x8,%rsp
4000019: 85 c0 test %eax,%eax
400001b: 74 18 je 4000035 <start+0x35>
400001d: 8d 68 ff lea -0x1(%rax),%ebp
4000020: 31 db xor %ebx,%ebx
4000022: ff 14 dd 00 20 00 04 callq *0x4002000(,%rbx,8)
4000029: 48 89 d8 mov %rbx,%rax
400002c: 48 83 c3 01 add $0x1,%rbx
4000030: 48 39 e8 cmp %rbp,%rax
4000033: 75 ed jne 4000022 <start+0x22>
4000035: e8 e2 0d 00 00 callq 4000e1c <_init>
...
Wir sehen, der Linker hat gar nicht so viel gezaubert, sondern
lediglich die init.o
an den Anfang geklatscht,
start
zum ersten Symbol gemacht (also die Adresse die wir
im Linkerskript als Beginn definiert hatten) und die zuvor genullten
Stellen gemäß den Relokationen wie im Beispiel gezeigt angepasst!
Für aufmerksame Beobachter: Nicht wundern — da das Preinit-Array leer ist, sind Start- und Endadresse gleich.
Alle, die Flat Binaries implementieren, müssen hier
aufpassen: Der Linker klebt die gleichnamigen Sektionen der
Objektdateien in der Reihenfolge zusammen, wie sie als Parameter
angegeben werden! Wenn ihr zuerst die main.o
und danach die
init.o
angebt, werden (in .text) die
Initialisierungsroutinen nicht aufgerufen, sonder direkt die
main
(was je nach Anwendungsinhalt vielleicht sogar mal
funktionieren kann). Und selbst bei der richtigen Reihenfolge, mit der
init.o
als erste Objektdatei, muss man noch aufpassen, dass
start
die erste Funktion in der Datei (also auch im
Quelltext) ist — uns nicht etwa eine kleine Hilfsfunktion, die direkt
davor steht… Man kann das entweder mit objdump
abprüfen
(wenn wie oben 0000000004000000 <start>
steht ist
alles gut) oder mittels
nm user/app1/.build/app | grep "4000000 T start
(was auch
idealerweise als Check in der Makefile eingebaut wird).
Und exakt aus diesem Grund ist diese Datei im Beispiel aus der
Tafelübung auch nicht Teil von libsys
, sondern liegt unter
user
— den als Teil einer statischen Bibliothek ist es
nicht einfach möglich diese Objektdatei als erste dem Linker zu
übergeben! Wenn du nun jedoch den ELF-Lader implementierst, dann ist es
nicht weiter notwendig, dass der Einsprung die erste Adresse ist — du
könntest in diesem Fall auch den Code der Initialisierungsroutinen in
das Verzeichnis für die Bibliothek packen!
Bindefehler
Bis jetzt funktioniert das alles jedoch nur in der Theorie…
Alte (De-)Initialisierungsmethode
In der Praxis wird uns obiger Aufruf zum Linken ein paar Fehler werfen, allen voran
undefined reference to `_init'
Und hier beißen uns wieder historische
Altlasten von vor der Einführung der (deutlich flexibleren)
[Pre]Init-Arrays, also zu Zeiten von Gcc 4.4. Damals wurde der Aufruf
von Konstruktoren (wie auch das Beispiel mit
__attribute__((constructor))
bei den
Initialisierungsroutinen) noch über die Funktion _init
abgewickelt:
Für jede Initialisierung in einer Objektdatei wird vom Compiler
schlicht Code für einen Funktionsaufruf in die .init
Sektion geklebt und vom Linker zusammen gefügt. Davor kommt noch ein
Prolog (der zum Beispiel den Rahmenzeiger setzt) und Epilog (mit
Rücksprung) — crti.o
und crtn.o
(aus den
gleichnamigen Assemblerdateien in Kernel compiler/
). Und,
weil’s grad so schön ist, will sich der Übersetzer auch die Möglichkeit
beibehalten weitere eigene Routinen dran klatschen zu können (vor und
nach den eigentlichen Initialisierungen, mittels crtbegin.o
und crtend.o
) — unnötig zu erwähnen, dass es dann noch
crt0.o
, crt1.o
usw geben kann… (mehr Details)
Und, quasi als Sahnehäubchen für die Komplexität: Wir haben bereits
gelernt, dass der Linker die Objekte in der Reihenfolge abarbeitet, wie
sie als Parameter übergeben werden. Das heißt, wir müssen sicherstellen,
dass crti.o
vor den ganzen Benutzerobjektdateien (mit
Ausnahme der init.o
) ausgeführt wird, direkt gefolgt von
crtbegin.o
. Und am Ende dann crtend.o
und
crtn.o
.
Welche Datei der Compiler will, kann man zum Glück mit dem Parameter
--print-file-name=crtbegin.o
abfragen — im CIP ist das
demnach zum Beispiel die
/usr/lib/gcc/x86_64-linux-gnu/10/crtbegin.o
, und diese wird
heutzutage nur noch verwendet um Hilfsfunktionen (z.B. für transaktionalen
Speicher!) einzubauen — also nur .text
, aber keinen
Code für die ursprünglich eigentlich angedachte
.init
-Sektion.
Die tatsächliche Realität ist leider sogar noch ein Stück
verwirrender, denn es wird abhängig von den Compilerflags unter
Umständen eine andere Datei benötigt, z.B. die crtbeginT.so
für statische und crtbeginS.so
für dynamische Programme.
Aber sie besitzen in den für uns relevanten Fällen ebenfalls keine
.init
-Sektion.
Das bedeutet also, dass wir zwar aus historischen Gründen diese
ganzen crt*.o
einbinden müssen, aber in der Praxis davon
nur der Funktionsprolog aus der crti.o
und der
Funktionsepilog aus der crtn.o
als
_init
-Funktion in unsere ausführbare Binärdatei wandern,
ohne jedoch wirklich wichtigen Code zu enthalten — wie eine Auswertung
mittels objdump -d user/app1/.build/app
zeigt (Kommentare
nachträglich eingefügt):
...
0000000004000e5c <_init>:
# Stuff from crti.asm
4000e5c: 55 push %rbp
4000e5d: 48 89 e5 mov %rsp,%rbp
# Space which would have been filled by crtbegin.S, crt0.S, ...., crtend.S in previous compiler versions
# Stuff from crtn.asm
4000e60: 5d pop %rbp
4000e61: c3 retq
...
Im obigen Beispiel haben wir uns nur die Initialisierung
(init
) angeschaut, aber analog gibt es auch noch eine
Deinitialisierung, also z.B. Destruktoren von globalen Objekten, dass
wird fini
genannt und funktioniert exakt genauso (eigene
.fini
Sektion, in welcher die Funktion _fini
steckt)!
Bonus: den toten Mechanismus wiederbeleben
Es ist übrigens einfach, dieses ursprüngliche Verfahren auch heute
noch zu nutzen, z.B. wenn man das oben vorgestellte
constructor
Funktionsattribut nicht verwenden will. Wir
nehmen, wie im vorherigen Beispiel, wieder unsere Funktion
foo
, hier mit C-Linkage:
extern "C" void foo() {
(1, "bar\n", 4);
sys_write}
Und nun erstellen wir noch eine Assembler Datei (der Einfachheit halber in gewohnter NASM Syntax, selbstverständlich könnte man das aber auch als Inline-ASM in die C(++)-Datei schreiben):
]
[EXTERN fooSECTION .init]
[call foo
Das ist alles: Wir sagen, dass wir gerne den Code
call foo
in der Sektion .init
hätten. Und wenn
nun diese Objektdatei einfach in der richtigen Reihenfolge, also
irgendwann nach crti.o
aber vor crtn.o
vom
Linker verarbeitet wird, steht folgender Maschinencode in der erzeugten
Binärdatei:
0000000004000e5c <_init>:
4000e5c: 55 push %rbp
4000e5d: 48 89 e5 mov %rsp,%rbp
4000e60: e8 2b f3 ff ff callq 4000190 <foo>
4000e65: 5d pop %rbp
4000e66: c3 retq
Nun sieht auch _init
aus wie eine typische übersetzte
C-Funktion 🙂
Wir haben in StuBS eine leicht vereinfachte/abgewandelte
crti.asm
und crtn.asm
im Vergleich zur glibc,
dort wird zum Beispiel der Rahmenzeiger (%rbp
) nicht
verwendet und entsprechend steht in letzterer dann auch
add $0x8,%rsp
statt pop %rbp
.
Fehlende Methoden gemäß C++ ABI
Der nächste auftretende Fehler ist möglicherweise
undefined reference to `__cxa_atexit'
Diese fehlende Funktion ermöglicht das dynamische
Registrieren von Destruktoren in einer bestimmten Reihenfolge und
wird eigentlich nur für dynamische Bibliotheken (.so
)
gebraucht. Da wir diese aber in der Übung aufgrund der immensen
Komplexität nicht unterstützen werden, ist es für uns also irrelevant
(d.h. wir müssen uns da keinen Kopf bzgl. Logik machen, ein Dummy
reicht).
Analog verhält es sich mit der __cxa_pure_virtual()
,
welche aufgerufen wird, wenn eine rein virtuelle Methode angesprungen
werden sollte (was ja eigentlich verboten ist). Hier machen wir’s uns
einfach und gehen in eine Endlosschleife (mangels anderer Alternativen
derzeit).
Beides ist im Kernel übrigens auch schon in der
compiler/libcxx.cc
implementiert, denn sonst würden wir die
gleichen Probleme beim Kernel bauen haben — wir klauen wieder dreist
(kann entweder in die init.cc
oder in eine Datei im
libsys
-Ordner):
// additional C++ stuff
extern "C" void __cxa_pure_virtual() {
// Pure virtual function was called -- this if obviously not valid,
// therefore we wait infinitely.
while(1) {}
}
// (Dynamic) libraries can register exit commands -- we don't need them,
// hence a dummy is sufficient
extern "C" int __cxa_atexit(void(*func) (void *), void * arg, void * dso_handle) {
(void)func;
(void)arg;
(void)dso_handle;
return 0;
}
Fehlendes delete
Ein dynamischer Speicherallokator ist in dieser Aufgabe noch nicht
verlangt (und zumindest für den Anfang auch gar nicht empfohlen). Aber
selbst wenn wir die Finger von new
und delete
lassen, kann es vorkommen, dass sich in einem Destruktor über eine
fehlende Referenz zum delete
-Operator beschwert wird:
undefined reference to `operator delete(void*, unsigned long)'
Das ist wieder so eine C++ ABI Sache, bei dem ein Destruktur aufgrund einer virtuellen Vererbungshierarchie automatisch vom Übersetzer angelegt wird — der deleting destructor. Da wir zu Beginn keinen Allokator haben, reichen vorerst Dummyfunktionen:
void operator delete(void *ptr) {
(void) ptr;
}
void operator delete(void *ptr, __SIZE_TYPE__ size) {
(void) ptr;
(void) size;
}
Die libgcc
und weitere Abhängigkeiten
Sind wir nun fertig? Nun, es könnte sein das alles tut. Aber muss
nicht. Der Compiler darf prinzipiell beliebigen Code emittieren, statt
direkt Maschinencode auch einfach Aufrufe an (Hilfs-)Funktionen — die er
dann in einer eigenen Bibliothek bereitstellen muss. Das macht er auch
für diverse arithmetische Operationen (sofern diese von der Architektur
nicht direkt unterstützt werden) und Fehlerbehandlungsroutinen in der libgcc
,
weswegen immer auch gegen diese gelinkt werden sollte (siehe auch Beitrag auf
OSDev).
Im Kernel ist diese Abhängigkeit übrigens nicht ganz
unproblematisch — vor allem wegen der red zone (3.2.2
System V ABI): Funktionen können damit die nächsten 128 Byte des
Stacks einfach nach belieben nutzen (solang sie selbst keine anderen
Funktionen aufrufen), auch ohne %rsp
zu ändern. Aber in
Ring 0 in StuBS würde ein Interrupt dazu führen, dass der
interrupt_entry
direkt beim Kernelstack an
%rsp
weiter macht und Register sichert (und dadurch ggf.
Daten im red zone überschreibt). Um das zu verhindern, wird der
Kernel selbst mit dem Parameter -mno-red-zone
gebaut, so dass dieses Feature für Kernelcode eigentlich nicht zur
Verfügung steht… aber die libgcc.a
bauen wir nicht selbst,
sondern wird vom Compiler mitgeliefert, und da ist die red zone
aktiv… Das ist einer der Gründe, wieso bei Betriebssystementwicklern
eigentlich das Verwenden
eines eigenen Cross-Compilers empfohlen wird. Wir setzen uns in
BS/BST jedoch bewusst darüber hinweg, um die Einstiegshürde so gering
wie möglich zu halten, und versuchen solche Probleme weitestgehend
auszuschließen (bis jetzt mit Erfolg) 🙂
Weiterhin kann es noch passieren, das beim Kopieren von großen
Datenmengen (z.B. Klassen mit großen Arrays) der Compiler auf die Idee
kommt, statt dem Maschinencode einen Aufruf zu memcpy
zu
emittieren. Das ist deswegen sinnvoll, weil die glibc diese
Speicherfunktionen hoch optimiert hat, auch angepasst an die jeweilige
Architektur. Hier sieht man gut die enge Zusammenarbeit zwischen der
GNU Compiler Collection (Gcc) und der GNU LibC
(glibc), aber wir können mit der bereits im Kernel implementierten
utils/string.cc
Abhilfe schaffen — diese kann mit Ausnahme
der Stringduplikationsfunktionen
strdup
/strndup
(aufgrund des fehlenden
Allokators) einfach übernommen werden.
Das Optimieren der Speicheroperationsfunktionen
memcpy
, memset
usw lohnt
sich wirklich! Der Linux
Kernel patcht sich da zum Bootvorgang selbst, abhängig von den
verfügbaren Hardwareerweiterungen nimmt er die schnellste Variante. Und
in glibc hat man dafür sogar einen eigenen Symboltyp eingebaut
(GNU indirect function aka IFUNC
), welche beim
ersten Funktionsaufruf die vorhandene Architekturfeatures abfrägt und
entsprechend die beste Variante bindet. Gruseliges Zeug, allerdings sind
das durchaus die wenigen Stellen, wo das wirklich einen signifikanten
Unterschied bringen kann & entsprechend wurde das dann auch trotz
des Aufwands (und der aus Software-Engineering-Sicht eher ungünstigen
Codestruktur) umgesetzt.
Wieso wusste ich von all dem noch nichts?
Weil du in SP geschlafen hast? 😉
Vieles der Komplexität sind historische Altlasten, und aus der
Geschichte des x86er wissen wir sehr genau, dass Abwärtskompatibilität
in der Praxis ein sehr wichtiger Punkt ist. Entsprechend wurde nicht
versucht diese Altlasten zu entfernen, sondern sie zu verstecken: Der
Compiler ruft den Linker meist mit allen notwendigen Parametern für euch
auf, ihr müsst lediglich die benötigten Bibliotheken angeben
(-l...
). Ein generisches Linkerskript passt für so gut wie
alle Benutzeranwendungen. Die Initialisierung der Laufzeitumgebung wird
komplett von der glibc übernommen, für uns sieht es so aus als
ob wir einfach in der main
starten, von dem davor und
danach bekommen wir nichts mit. Vielleicht wundern wir uns kurz, wieso
das ein simples statisch gebautes hello-world
(gcc -static -o hw hw.c
) selbst ohne Debugsymbole
(strip hw
) noch deutlich über 750 KiB groß ist, bei der C++
Variante (mit cout
) sogar 1.9 MiB — im Vergleich zu 15 KiB
bei der gleichen Anwendung in unserem StuBS. Aber das sind alles die
Kosten, die wir für unsere Bequemlichkeit im normalen Userspace zahlen.
Dafür müssen wir uns halt keine eigene Standardbibliothek schreiben,
keine Gedanken über Linkerskripte und Linkeraufrufe machen.
Alles funktioniert (meistens) einfach.
Ihr wisst nun aber auch, was im Hintergrund alles noch passiert!
Und wie soll ich nun meine Ordner und Dateien strukturieren?
Vorweg: Es ist eure Sache, solang es funktioniert passt es. Es gibt nicht die eine richtige Lösung, und (meines Wissens nach) keine wirklich schöne.
Klar sollte sein, dass alles, was generisch für die Verwendung in den
unterschiedlichen Apps ist, nach libsys
soll:
Syscall-Stubs, Ausgabestrom, Stringfunktion, …
Sonderfälle bleiben crti.asm
, crtn.asm
und
init.cc
: Thematisch passen diese besser in die
libsys
, jedoch sollten diese nicht in die statische
Bibliothek libsys.a
gepackt werden, sondern müssen — wie
oben ausführlich erklärt — an die richtigen Stellen beim Linker für das
Bauen einer jeden Anwendung gepackt werden.
Bei der init.cc
gilt das natürlich nur für die Flat
Binaries — bei ELF darf das von dieser Datei bereitgestellte
start
irgendwo in der Binärdatei stehen, es muss nicht
zwangsweise die erste Adresse sein! Entsprechend kann dann die Datei
ohne weitere Sonderbehandlung auch in der libsys
stecken.
Eine Möglichkeit wäre da filter-out
im libsys/Makefile
, während die Dateien dann beim
Makefile
der einzelnen Anwendungen an der korrekten Stelle
eingebunden werden (also quasi
../../libsys/$(BUILDDIR)/crti.asm.o
usw). Dazu muss aber
dafür gesorgt werden, dass diese Dateien beim erstellen der statischen
Bibliothek trotzdem übersetzt werden.
Das geht nun wiederum bequemer, wenn man die Dateien in
user/
packt und eben zusammen mit der jeweiligen Anwendung
neu übersetzt.
Schlussendlich solltet ihr eine für euch gangbare Ordnung finden — ihr müsst noch ein paar Aufgaben damit leben 😉
Makefiles
Ich hab mich für eine Variante entschieden, in der ich
crti.asm
und crtn.asm
in libsys
packe, die init.cc
und section.ld
aber in
user
— hier mal eine mögliche Verzeichnisstruktur:
StuBSmI ├── kernel │ └── ... ├── libsys │ ├── crti.asm │ ├── crtn.asm │ ├── iostream.cc │ ├── iostream.h │ ├── libcxx.cc │ ├── Makefile │ ├── math.h │ ├── outputstream.cc │ ├── outputstream.h │ ├── rand.cc │ ├── rand.h │ ├── stringbuffer.cc │ ├── stringbuffer.h │ ├── syscall.asm │ ├── syscall.h │ ├── types.h │ └── vector.h ├── Makefile ├── tools │ ├── build.mk │ ├── common.mk │ ├── cpplint.py │ ├── image.mk │ ├── linter.mk │ ├── qemu.mk │ └── remote.mk └── user ├── imgbuilder.cc ├── init.cc ├── Makefile ├── sections.ld ├── app1 │ ├── Makefile │ ├── main.cc │ └── main.h └── app2 ├── keypress.cc ├── keypress.h └── Makefile -> ../app1/Makefile
Ich habe die hier verlinkten Makefile
s nochmal leicht
angepasst (sie sind nun hoffentlich etwas verständlicher) und es gibt
nun auch ein Beispiel für die Apps selbst. Eine Hilfe ist übrigens auch
make VERBOSE=
, um zu sehen was überhaupt ausgeführt wird.
Aber dies ist nur ein Vorschlag, keine Vorgabe — wenn
es euch nicht gefällt oder ihr nicht damit zurecht kommt, baut euch
bitte selbst welche.
Außerdem solltet ihr euch unbedingt auch mal die
tools/build.mk
anschauen: Diese bestimmt zum Beispiel die
Variable CRTI_OBJECT
(anhand der lokalen
CRTI_SOURCE
oder, sofern es fehlt, der systemweiten Datei
crti,o
vom Übersetzer).
Es gibt noch einen kleinen Schönheitsfehler, so werden die
crti.asm.o
sowie crtn.asm.o
im Ordner
user/libsys/
gebaut, aber das stört mich nicht weiter — ihr
könnt das gerne bei euch ändern.
Bonus: ein einfacher Ausgabestrom
Schön wäre es, wenn man in den Benutzeranwendungen ähnlich wie im im
Linux-Userspace-C++ einfach cout
verwenden könnte. Aber
eigentlich haben wir mit OutputStream
und
StringBuffer
im Kernel schon alles implementiert was wir
brauchen. Einfach beide Dateien in libsys
kopieren (oder
symlinken) und eine neue iostream.h
erstellen:
#pragma once
#include "outputstream.h"
#include "syscall.h"
class IOStream : public OutputStream {
private:
(IOStream ©); // no copy
IOStreamint fd;
public:
explicit IOStream(int sysfd = 0) : fd(sysfd) {}
~IOStream() {
if (pos > 0) {
(fd, buffer, pos);
sys_write}
}
void flush() override {
(fd, buffer, pos);
sys_write= 0;
pos }
};
extern IOStream cout, cerr;
und passend dazu in die iostream.cc
Datei
#include <iostream.h>
;
IOStream cout(1); IOStream cerr
Nun kann man das (leicht erweiterte) hello-world-Beispiel in der Benutzeranwendung gewohnt einfach schreiben:
void main() {
for (unsigned i = 0; ; i++) {
<< "Hallo Welt!" << endl;
cout << "Durchlauf Nr. " << i << endl;
cerr (1000);
sys_sleep}
}
Fühlt sich doch schon langsam fast an wie ein richtiges Betriebssystem? 🙂
Weiterführende Literatur
- Linkers and Loaders von John R. Levine
- Eli Bendersky’s Blog mit vielen systemnahen Betrachtungen rund um dieses Thema (siehe Archiv, vor etwa 10 Jahren)