Wie wird C zu Assemblercode?
Von Präprozessierung und Linken bis zu x86-64-Aufrufkonventionen und dem vollständigen stdio.h-Ablauf
Wie wird C zu Assemblercode?
Die Beiträge dieses Blogs sind als Lehrmaterial gedacht. Compiler-Version, Betriebssystem, ABI und Optimierungsoptionen können den erzeugten Code verändern. Wichtige Details sollten deshalb in der eigenen Umgebung geprüft werden.
gcc -S file.c ist schnell eingegeben. Die erzeugte .s-Datei zu lesen ist schwieriger: #include <stdio.h> ist verschwunden, von der großen printf-Implementierung bleibt am Aufrufort nur ein call, und Linux- sowie Windows-Beispiele verwenden unterschiedliche Argumentregister.
Statt einzelne Befehle auswendig zu lernen, ist es sinnvoller, den gesamten Weg von der C-Quelldatei zum laufenden Programm zu trennen. Dieser Beitrag behandelt Präprozessierung, Kompilierung, Assemblierung, Linken, x86-64-Aufrufkonventionen, Optimierung und alle 45 Kernfunktionen von stdio.h.
Wenn nichts anderes angegeben ist, gelten x86-64 Linux, die System-V-ABI und Intel-Syntax. Der C-Standard legt weder eine CPU noch eine Assemblersyntax fest.
Fünf Stufen bis zum laufenden Programm
- Der Präprozessor verarbeitet
#include,#defineund#if. - Der Compiler analysiert den vorverarbeiteten C-Code und erzeugt Assemblercode für das Ziel.
- Der Assembler codiert Befehle und Metadaten in eine Objektdatei.
- Der Linker löst Symbole auf und verbindet Objekte mit Bibliotheken.
- Der Betriebssystemlader legt Programm und dynamische Bibliotheken im Speicher ab.
Mit „C in Assembler umwandeln“ ist meistens gemeint, nach der zweiten Stufe anzuhalten und eine .s- oder .asm-Datei zu behalten.
Ein kleines Beispiel
#include <stdio.h>
static int add(int left, int right)
{
return left + right;
}
int main(void)
{
int value = add(20, 22);
printf("value = %d\n", value);
return 0;
}
Das Programm enthält einen Funktionsaufruf, lokale Daten, ein Zeichenkettenliteral und die variadische Funktion printf, bleibt aber klein genug für eine genaue Untersuchung.
GCC an jeder Stufe anhalten
gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace
-E behält die Präprozessorausgabe, -S den Assemblercode und -c beendet den Lauf nach der Objektdatei. -masm=intel wählt Intel-Syntax. Ein Vergleich von -O0 und -O2 zeigt sowohl die erkennbare Quellstruktur als auch realistisch optimierten Code.
gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace
Mit -save-temps=obj lassen sich mehrere Zwischendateien in einem Lauf erhalten.
Clang und MSVC
clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll
LLVM IR ist kein CPU-Assembler, sondern eine Zwischendarstellung vor der endgültigen Befehlsauswahl.
MSVC kann in einer Developer Command Prompt eine Assemblerliste schreiben.
cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c
Compilerliste und Disassemblierung
Eine Compilerliste kann Labels, Abschnitte, Direktiven und Quellkommentare enthalten. Eine Disassemblierung liest bereits codierte Befehle aus einer Objektdatei oder einem fertigen Programm.
objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace
Nach dem Linken erscheinen möglicherweise Stubs für dynamisches Linken und verschobene Adressen. Debugsymbole erleichtern das Erkennen von Namen, stellen aber den ursprünglichen C-Code nicht wieder her.
Intel-Syntax knapp lesen
In Intel-Syntax steht das Ziel üblicherweise zuerst.
mov eax, 42
add eax, 8
cmp eax, 50
je .equal
mov kopiert Daten, lea berechnet einen Adressausdruck, cmp setzt Statusflags und bedingte Sprünge lesen diese Flags. call speichert die Rücksprungadresse und überträgt die Kontrolle, ret kehrt zurück. Eckige Klammern kennzeichnen einen Speicherzugriff.
Die unoptimierte Funktion add
Ein debugfreundlicher System-V-Build kann ungefähr so aussehen:
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
Die ersten beiden Ganzzahlargumente kommen in edi und esi, das Ergebnis liegt in eax. Optimiert können die Speicherzugriffe verschwinden, die Funktion auf lea und ret schrumpfen oder vollständig inline eingesetzt werden.
Unter System V x86-64 verwenden die ersten sechs Ganzzahl- oder Zeigerargumente normalerweise rdi, rsi, rdx, rcx, r8 und r9. Ganzzahlergebnisse stehen in rax, Gleitkommawerte in XMM-Registern.
Windows x64 verwendet für die ersten vier Argumente gewöhnlich rcx, rdx, r8 und r9. Der Aufrufer reserviert zusätzlich 32 Byte Shadow Space. Register dürfen erst nach Bestimmung der Ziel-ABI interpretiert werden.
Den printf-Aufruf verstehen
mov esi, 42
lea rdi, .LC0[rip]
xor eax, eax
call printf@PLT
rdi erhält die Adresse der Formatzeichenkette, esi den Wert 42. Bei variadischen System-V-Aufrufen teilt al außerdem die Anzahl verwendeter Vektorregister mit; hier wird eax deshalb auf null gesetzt. printf@PLT ist ein Einstieg für dynamisches Linken und nicht die gesamte printf-Implementierung.
Optimierung entfernt die Form des C-Codes
Der Compiler darf add inline einsetzen, 20 + 22 beim Übersetzen berechnen, keinen Stackplatz für value anlegen und unerreichbaren Code löschen. Ein konstantes printf("hello\n") kann zu puts werden, sofern das beobachtbare Verhalten unter den Übersetzungsannahmen erhalten bleibt.
Was <stdio.h> tatsächlich liefert
Das Einbinden des Headers kopiert nicht die komplette I/O-Bibliothek in den Quelltext. Sichtbar werden Typen, Makros und Funktionsdeklarationen. Die Implementierung liegt normalerweise in der C-Laufzeitbibliothek.
Die zentrale Abstraktion ist ein durch FILE * dargestellter Stream. Ein FILE-Objekt kann Puffer, Position, EOF-, Fehler- und Synchronisationszustand verwalten. Seine Felder sind Implementierungsdetails. Zu Programmbeginn stehen stdin, stdout und stderr bereit.
Wichtige Typen sind FILE, fpos_t und size_t. Zu den wichtigen Makros gehören EOF, NULL, BUFSIZ, FOPEN_MAX, FILENAME_MAX, L_tmpnam, TMP_MAX, _IOFBF, _IOLBF, _IONBF, SEEK_SET, SEEK_CUR und SEEK_END.
Alle 45 Kernfunktionen
Dateioperationen, 4
| Funktion | Zweck |
|---|---|
remove | Datei anhand ihres Namens entfernen |
rename | Datei umbenennen |
tmpfile | temporären Binärstream erzeugen |
tmpnam | temporären Namen erzeugen; schwer sicher zu verwenden |
Für echte Programme ist eine sichere Plattform-API vorzuziehen, die eine eindeutige Datei atomar erzeugt und öffnet.
Öffnen, Schließen und Puffern, 6
fclose, fflush, fopen, freopen, setbuf und setvbuf.
Häufige Modi sind r, w, a, r+, w+ und a+, optional mit b. w kürzt eine vorhandene Datei. Jeder Rückgabewert von fopen muss geprüft werden. Portables C verwendet fflush(stdin) nicht zum Verwerfen von Eingaben.
Formatierte Ausgabe, 8
fprintf, printf, sprintf, snprintf, vfprintf, vprintf, vsprintf und vsnprintf.
Eine Konvertierung hat allgemein die Form %[flags][width][.precision][length]specifier. Format und tatsächlicher Argumenttyp müssen übereinstimmen. Nicht vertrauenswürdiger Text darf nicht direkt als Formatzeichenkette verwendet werden. Bei variadischen Argumenten wird float zu double, kleinere Ganzzahltypen werden meist zu int befördert.
Formatierte Eingabe, 6
fscanf, scanf, sscanf, vfscanf, vscanf und vsscanf.
Konvertierungsziele sind normalerweise schreibbare Zeiger des richtigen Typs. Für %s ist eine Feldbreite anzugeben, und der Rückgabewert mit der Anzahl erfolgreicher Zuweisungen muss geprüft werden. Interaktive Eingabe lässt sich oft besser mit fgets zeilenweise lesen und anschließend getrennt parsen.
Zeichen- und Zeichenketten-I/O, 10
fgetc, fgets, fputc, fputs, getc, getchar, putc, putchar, puts und ungetc.
Ein gelesenes Zeichen gehört in ein int, damit alle unsigned-char-Werte von EOF unterscheidbar bleiben.
Block-I/O, 2
fread und fwrite übertragen Arrays von Objekten. Ihr Rückgabewert ist die Anzahl vollständig übertragener Elemente, nicht immer eine Byteanzahl. Rohes Schreiben einer Struktur ist wegen Padding, Byte-Reihenfolge und Typgrößen kein portables Dateiformat.
Dateiposition, 5
fgetpos, fseek, fsetpos, ftell und rewind. Positionen in Textstreams unterliegen zusätzlichen Regeln. Für sehr große Dateien stellen Plattformen oft eigene 64-Bit-Erweiterungen bereit.
Status und Diagnose, 4
clearerr, feof, ferror und perror. EOF wird erst nach einem Leseversuch über das Ende hinaus erkannt. Schleifen sollten daher vom Ergebnis der Leseoperation und nicht von while (!feof(file)) gesteuert werden.
Annex K und Erweiterungen
Einige Implementierungen bieten fopen_s, fprintf_s oder scanf_s. Sie sind kein überall verfügbares Ersatz-API für die Kernfunktionen und besitzen bei Microsoft eigene Parameterregeln. ISO C, POSIX und Windows-Erweiterungen sollten getrennt dokumentiert werden.
Standard-I/O ist nicht ein Systemaufruf pro Funktion
printf formatiert Daten meist im Benutzerraum und schreibt in einen FILE-Puffer. Erst ein späteres Leeren kann eine OS-Funktion wie POSIX write aufrufen. Pufferung reduziert teure Übergänge in den Kernel.
getc und putc dürfen als Makros umgesetzt sein; der schnelle Pufferpfad kann direkt eingesetzt werden. Ein fehlendes call getc bedeutet daher nicht, dass die C-Operation verschwunden ist.
Grundschleife für eine Binärkopie
unsigned char buffer[64 * 1024];
size_t read_count;
while ((read_count = fread(buffer, 1, sizeof buffer, source)) > 0)
{
size_t written = 0;
while (written < read_count)
{
size_t count = fwrite(buffer + written, 1,
read_count - written, destination);
if (count == 0)
{
break;
}
written += count;
}
}
if (ferror(source))
{
perror("fread");
}
Ein vollständiges Programm prüft zusätzlich beide fopen-Aufrufe, Teilschreibvorgänge, ferror und Fehler, die erst bei fclose sichtbar werden.
Sinnvolle Lernreihenfolge
Beginne mit einer Funktion, die nur Ganzzahlargumente und einen Rückgabewert hat. Vergleiche -O0 und -O2. Füge danach einzeln ein lokales Array, einen normalen Aufruf und printf hinzu. Vergleiche Objektdatei und fertiges Programm und wiederhole den Versuch schließlich mit einer anderen ABI wie Windows x64.
Häufige Fehler
- Für ein C-Programm wird genau eine Assemblerübersetzung erwartet.
-O0wird für eine mechanische Zeilenübersetzung gehalten.- Formatspezifizierer und Argumenttypen passen nicht zusammen.
- Benutzereingaben werden als
printf-Format verwendet. scanf("%s", buffer)besitzt keine Breitenbegrenzung.- Eine Schleife verwendet
while (!feof(file)). - Portabler Code verlässt sich auf
fflush(stdin). - Ein Fehler von
fclosebei der Ausgabe wird ignoriert. - Rohe Strukturbytes gelten fälschlich als portables Format.
- Aus undefiniertem Verhalten wird eine allgemeine Assemblerregel abgeleitet.
Von einer printf-Zeile zur Ausgabe
<stdio.h> liefert die Deklaration. Der Compiler ordnet Argumente nach der ABI an. Der Assembler erzeugt Befehle mit Symbolreferenz. Linker oder Lader verbinden das Symbol mit der C-Laufzeit. Zur Laufzeit formatiert die Bibliothek die Werte, aktualisiert den Streampuffer und bittet bei Bedarf das Betriebssystem, Bytes zu schreiben.
Quellen
- ISO-C-Arbeitsentwurf N1570
- GCC code generation options
- Clang command-line reference
- MSVC 0
- System V AMD64 ABI
- Microsoft x64 calling convention
- GNU objdump
Zusammenfassung
C durchläuft Präprozessierung, Kompilierung, Assemblierung, Linken und Laden. Der sichtbare Assemblercode hängt von Ziel-ABI und Optimierung ab. <stdio.h> liefert Deklarationen und Makros, die Laufzeitbibliothek die eigentliche Stream-Implementierung. Werden diese Ebenen getrennt betrachtet, ergeben Befehle, Aufrufe, Puffer und Betriebssystem-APIs einen verständlichen Gesamtweg.