Hoe werkt een "Hello, world!" programma in C?
Van source code naar executable
Wanneer je een C-programma bouwt, doorloopt het meerdere stappen voordat het eindigt als een uitvoerbaar bestand. Deze stappen zijn: preprocessing, compilatie, assemblage en linking. Deze kunnen worden onderverdeeld in twee hoofdfasen: de front-end en de back-end van de compiler. De front-end omvat de preprocessing en compilatie, terwijl de back-end bestaat uit assemblage en linking.
De front-end
De front-end van de compiler is het gedeelte dat verantwoordelijk is voor het analyseren en omzetten van de broncode naar een tussenrepresentatie. Deze fase controleert of de code grammaticaal en logisch correct is en bereidt deze voor op verdere verwerking. De front-end vormt daarmee de eerste stap in het compilatieproces, voordat de code wordt overgedragen aan de back-end.
Preprocessing
Tijdens de preprocessing worden speciale instructies in de broncode verwerkt, zoals #include en #define. Deze instructies worden door de preprocessor geïnterpreteerd voordat de eigenlijke compilatie begint. Bijvoorbeeld, #include voegt de inhoud van een ander bestand in op de plaats waar de instructie staat, terwijl #define macro's definieert die later in de code kunnen worden gebruikt. Het resultaat van deze stap is een 'gepreprocesseerde' versie van de code, die klaar is voor compilatie.
Compilation
In de compilatiefase wordt de gepreprocesseerde code omgezet in assembly code, een laag-niveau representatie die dichter bij machinecode ligt. De compiler analyseert de syntaxis en semantiek van de code, controleert op fouten en optimaliseert waar mogelijk. Het resultaat is een assembly-bestand dat specifieke instructies bevat voor de processor.
De back-end
De back-end van de compiler bestaat uit de laatste fasen van het bouwproces: assemblage en linking. Tijdens de assemblage wordt de assembly code, die door de front-end is gegenereerd, omgezet in machinecode de binaire instructies die de processor kan uitvoeren. Vervolgens zorgt de linker ervoor dat alle benodigde code, zoals functies uit bibliotheken, samen worden gevoegd tot één volledig uitvoerbaar bestand. De back-end optimaliseert ook de code waar mogelijk om efficiëntere executables te maken. Samen zorgen deze stappen ervoor dat de broncode uiteindelijk verandert in een programma dat direct door de computer kan worden uitgevoerd.
We gaan de executable, oftewel de binary, van ons "Hello, world!" programma analyseren met behulp van objdump
.
Deze tool zet machinecode om in leesbare assembly code.
Door de binary te bestuderen krijgen we inzicht in de instructies die de processor uitvoert en begrijpen we beter wat er gebeurt nadat de broncode is gecompileerd en gelinkt.
Assemblage
Tijdens de assemblage wordt de door de compiler gegenereerde assembly code omgezet in machinecode. Machinecode bestaat uit binaire instructies die direct door de processor kunnen worden uitgevoerd. Deze stap is specifiek voor de hardware-architectuur waarop het programma draait, omdat verschillende processoren wisselende instructiesets hebben, zoals bijvoorbeeld x86_64 of ARM. Hierdoor moet de gegenereerde machinecode exact aansluiten bij de instructieset van de doelprocessor.
Linking
Linking is het proces waarbij verschillende stukjes code en data, zoals objectbestanden en bibliotheken, worden samengevoegd tot één uitvoerbaar bestand. De linker zorgt ervoor dat alle verwijzingen naar functies en variabelen correct worden gekoppeld, zodat het programma volledig functioneel is. Zonder linking kan de processor de verschillende onderdelen van het programma niet correct uitvoeren.
Reverse engineering van "Hello, world!" binary
We gaan de executable, oftewel de binary, van ons "Hello, world!" programma analyseren met behulp van objdump
.
Deze tool zet machinecode om in leesbare assembly code.
Door de binary te bestuderen krijgen we inzicht in de instructies die de processor uitvoert en begrijpen we beter wat er gebeurt nadat de broncode is gecompileerd en gelinkt.
Programma source code
De source code van het "Hello, world!" programma in C is als volgt:
#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}
Disassembly van de binary
Na het compileren van het programma met gcc -o program main.c
kunnen de disassembled instructies worden bekeken met objdump -d program
.
Disassembly is het proces waarbij een gecompileerde binaire executable wordt terugvertaald naar assemblerinstructies. Dit is het tegenovergestelde van assembly, waarbij leesbare assemblercode juist wordt vertaald naar machinecode.
Bij disassembly worden instructies uit de binary gehaald, maar deze zijn vaak minder duidelijk of moeilijker te interpreteren dan oorspronkelijke assemblycode, omdat symbolische informatie (zoals variabelenamen of labels) meestal verloren is gegaan tijdens de compilatie. Disassembly is vooral nuttig voor reverse engineering, debugging of beveiligingsanalyse.
program: file format elf64-x86-64
Disassembly of section .init:
0000000000001000 <_init>:
1000: f3 0f 1e fa endbr64
1004: 48 83 ec 08 sub $0x8,%rsp
1008: 48 8b 05 d9 2f 00 00 mov 0x2fd9(%rip),%rax # 3fe8 <__gmon_start__@Base>
100f: 48 85 c0 test %rax,%rax
1012: 74 02 je 1016 <_init+0x16>
1014: ff d0 call *%rax
1016: 48 83 c4 08 add $0x8,%rsp
101a: c3 ret
0000000000001060 <_start>:
1060: f3 0f 1e fa endbr64
1064: 31 ed xor %ebp,%ebp
1066: 49 89 d1 mov %rdx,%r9
1069: 5e pop %rsi
106a: 48 89 e2 mov %rsp,%rdx
106d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1071: 50 push %rax
1072: 54 push %rsp
1073: 45 31 c0 xor %r8d,%r8d
1076: 31 c9 xor %ecx,%ecx
1078: 48 8d 3d ca 00 00 00 lea 0xca(%rip),%rdi # 1149 <main>
107f: ff 15 53 2f 00 00 call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
1085: f4 hlt
1086: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
108d: 00 00 00
Init sectie
De .init
sectie bevat initialisatiecode die wordt uitgevoerd voordat de main
functie start. Deze sectie maakt deel uit van de startup-routine van het programma en wordt automatisch aangeroepen bij het opstarten van de executable.
De code in deze sectie is meestal gegenereerd door de compiler of linker en zorgt bijvoorbeeld voor het aanroepen van eventuele globale constructorfuncties (zoals in C++) of het initialiseren van runtime-onderdelen. In veel eenvoudige C-programma's, zoals "Hello, world!", bevat deze sectie alleen standaardinstructies die weinig tot geen effect hebben op het gedrag van het programma zelf.
In het disassemblyvoorbeeld zien we bijvoorbeeld dat er een check wordt gedaan op het bestaan van __gmon_start__
, een symbool dat wordt gebruikt voor profilering met tools zoals gprof. Als deze functie bestaat, wordt ze aangeroepen; anders gebeurt er niets. Daarna wordt de stackpointer hersteld en wordt teruggekeerd naar de volgende fase van het opstartproces.
Start sectie
De _start
sectie markeert het beginpunt van de uitvoering van een programma in een Linux-omgeving. Deze functie wordt niet door de programmeur geschreven, maar automatisch door de linker toegevoegd tijdens het bouwen van de executable. Wanneer het programma door het besturingssysteem wordt geladen, wordt _start
als eerste uitgevoerd — nog vóór main
.
In de _start
functie worden de registers en stack klaargemaakt, zodat het programma in een consistente staat kan starten. Vervolgens wordt een aanroep gedaan naar de functie __libc_start_main
, een centrale functie binnen de GNU C Library (glibc). Deze functie krijgt als argument o.a. een verwijzing naar main
, en zorgt ervoor dat:
- de omgeving (zoals argc, argv en environment) wordt geïnitialiseerd;
- eventuele initialisatiecode wordt uitgevoerd (zoals constructors in C++);
- en uiteindelijk de
main
functie wordt aangeroepen.
Symbolisch naar glibc
In de assembly zien we dat __libc_start_main
wordt aangeroepen via een symbolische verwijzing naar een functie in de glibc (GNU C Library). Glibc is een verzameling standaardbibliotheken die C-programma’s gebruiken voor o.a. I/O, geheugenbeheer en procescontrole.
Een symbool zoals:
107f: ff 15 53 2f 00 00 call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
duidt aan dat de binaire code op dat moment een aanroep doet naar een adres dat uiteindelijk door de dynamische linker gekoppeld wordt aan de echte __libc_start_main
-functie in de glibc bibliotheek. Deze symbolische koppeling maakt het mogelijk dat programma’s gebruikmaken van gedeelde systeemfunctionaliteit zonder de code ervan zelf te bevatten.
Kortom, _start
zet alles klaar en vertrouwt op glibc om main
op de juiste manier aan te roepen en het programma correct te laten eindigen.
Conclusie
Een "Hello, world!" programma in C lijkt misschien simpel, maar achter de schermen gebeurt er behoorlijk wat.
Van preprocessing tot linking, en van het klaarmaken van de stack in de _start
functie tot het aanroepen van functies in de GNU C Library (glibc), elke stap is nodig om het programma uiteindelijk goed te laten draaien.
Je hebt nu een globaal beeld van hoe een stukje C-code verandert in een uitvoerbaar programma dat echt op je processor draait. Veel van de ingewikkelde opstarttaken worden geregeld door glibc, zoals het aanroepen van main
en het netjes afsluiten van het programma.
Als je dieper wilt duiken in onderwerpen zoals hoe de dynamische linker werkt, hoe een proces in het geheugen wordt geladen of hoe glibc precies in elkaar zit, dan heb je wel wat meer kennis nodig van besturingssystemen en programmeerconcepten op laag niveau.
Maar met deze uitleg heb je alvast een goede basis gelegd om te begrijpen wat er allemaal bij komt kijken voordat die ene simpele regel "Hello, world!" op je scherm verschijnt.