Kein Zweifel, die Programmiersprache C dominiert die IT-Landschaft und erst recht den Linux-Kernel. Effizienter Code, Hardwarenähe und Freiheit für den Programmierer zählen unter C-Fans zu den Gründen für den Einsatz.
Doch der Hardwarenähe und Effizienz zum Trotz geht es prinzipbedingt auch im Jahre 2008 noch immer nicht ohne den guten alten Assembler: Die Programmiersprache C kennt - klugerweise - weder direkten Zugriff auf die Ein- und Ausgabegeräte der Hardware noch in der Sprache verankerte Elemente zur Synchronisation des Prozessors. Darüber hinaus ist ein vom Profi handoptimierter Assembler-Code - sparsam und strategisch eingesetzt - in seiner Performance durch automatisch generierten Code eines Compilers selten zu schlagen.
Die Frage ist also weniger, ob Entwickler Assembler-Code benötigen, sondern vielmehr, wie sie ihn effizient mit C-Code kombinieren, um die jeweiligen Vorteile zu nutzen. Eigenständige Assembler-Funktionen zu schreiben und sie mittels Linker mit dem C-Code zu kombinieren ist wenig spektakulär. Die Entwickler wünschen sich stattdessen Teile einer C-Funktion in Assembler auszuführen, den Assembler-Code also in den C-Code einzubetten. Genau diese Funktion bietet der GNU-Compiler GCC mit seinem von den Linux-Entwicklern reichlich genutzten Inline-Assembler.
Wie der Code-Ausschnitt aus dem Linux-Kernel in Listing 1 zeigt, hat dieser Inline-Assembler allerdings etwas von einer Geheimsprache, die nur eingeweihte Entwickler kennen. Zeit, um etwas Licht ins Dunkel zu bringen und zu erläutern, wie man Assembler im Kernel liest und schreibt.
01 static int check_stack_overflow(void) {
02 long sp;
03
04 __asm__ __volatile__("andl %%esp, %0"
05 : "=r" (sp)
06 : "0" (THREAD_SIZE - 1));
07
08 return sp <
09 (sizeof(struct thread_info) + STACK_WARN);
10 }
|
Abstimmung erforderlich
Um Assembler-Code einzubetten, stellt der GCC das Makro »asm« bereit. Denkbare Konflikte mit einem Bezeichner dieses Namens vermeiden Kernelentwickler, indem sie stattdessen auf die gleichwertige Variante »__asm__« zurückgreifen. Das Makro nimmt nicht nur die einzelnen Assembler-Zeilen entgegen, sondern regelt zugleich dessen Interaktion mit dem C-Code. Denn will der Low-Level-Programmierer auf die dort definierten Variablen zugreifen, muss er sich mit dem Compiler abstimmen, wie sie untereinander Daten austauschen.
Das geht entweder über die Register der CPU oder über Speicherzellen. Dies gilt für beide Richtungen, vom C-Programm zum Assembler-Code und umgekehrt. Das Asm-Makro koordiniert den Austausch und hat vier Felder, die jeweils ein Doppelpunkt (»:«) voneinander trennt (siehe Abbildung 1).
Abbildung 1: Das Asm-Makro baut eine Brücke zwischen Quelltext in C und eingebettetem Assembler-Code. Es hat vier Felder, Doppelpunkte trennen sie ab. Neben dem eigentlichen Code (gelbes Feld) gibt der Programmierer für ihn Ein- (hellorgange) und Ausgabevariablen (dunkelorange). Ändert der Code weitere Register, benennt der Entwickler diese im vierten, optionalen Feld (rot) als „clobbered Register“.
Im ersten Feld findet sich der eigentliche Assembler-Code (siehe Kasten "Registermodell und Befehlssatz einer x86-CPU" und Tabelle 1). Ihm folgen - im zweiten und dritten Feld - erst eine Zuordnungsliste von Ausgaberegistern zu C-Variablen und dann eine Abbildung von C-Variablen zu Eingaberegistern, jeweils aus Sicht des übergebenen Assembler-Code. Im letzten Feld schließlich findet sich optional eine Liste von Registern, die der Assembler-Code modifiziert.
|
Bei der häufig eingesetzten x86-Architektur ist am Registermodell der Ballast einer ursprünglich auf 16 Bit ausgelegten Architektur sichtbar. Den 8086 stellte Intel immerhin bereits 1978 vor. Dessen Code läuft im Prinzip heute noch auf den modernsten Multicore-CPUs. Die Abbildung 3 illustriert, dass aus den ehemals sechs 16-Bit-Registern zwischenzeitlich 32 64-Bit-Register wurden. Jede Erweiterung der Registerbreite bringt ihr eigenes Präfix vor dem Registernamen mit, nutzt aber praktisch das gleiche Register. So lässt sich ablesen, ob ein Operand mit 8 Bit (AL), mit 16 Bit (AX), mit 32 Bit (EAX) oder schließlich mit 64 Bit (RAX) gemeint ist.
Die meisten Assembler-Befehle im GCC verwenden die so genannte AT&T-Syntax und haben den Aufbau »Befehl mit Ergänzung Quelle, Ziel;«. Ein Beispiel, das einen 32-Bit-Wert (long, »l«) von Register EAX nach EBX kopiert, lautet:
movl %eax, %ebx;
Die Befehlsergänzung - möglich sind hier die Buchstaben »b«, »s«, »l« und »q« - legt die Größe der Operanden fest. Die Buchstaben am Ende der Opcodes geben an, dass die Argumente jeweils eine Breite von 8, 16, 32 oder 64 Bit haben, und korrespondieren mit den jeweiligen Registernamen gleicher Länge.
Tabelle 1 listet die wichtigsten Befehle auf, sortiert nach Befehlsgruppen. Eine Einführung in den x86-Prozessor, sein Registermodell, die Adressierungsarten und seine Programmierung findet sich unter [5] und [6]. CPU-Hersteller Intel selbst beschreibt in [7] ausführlich die einzelnen Befehle.
|
Den eigentlichen Programmtext definiert der Entwickler in einem einzelnen String. Jede einzelne Codezeile schließt dabei entweder ein Semikolon oder noch besser »nt« ab. Das ist darum vorteilhaft, weil der resultierende Assembler-Code so übersichtlicher wird.
Die nächsten zwei Felder der Anweisung sind komplizierter. Sie legen die Beziehung zwischen den C-Variablen und den CPU-Registern oder Speicherzellen fest. Dazu fasst der Programmierer die verwendeten C-Variablen in normale Klammern ein und trennt sie durch Kommas. Zusätzlich spezifiziert er für jede Variable, wie der C-Compiler sie handhaben soll: Entweder überlässt er es dem Compiler, ein aus dessen Sicht geeignetes Prozessorregister für den Variableninhalt auszuwählen, oder er gibt selbst eines vor.
Geben und nehmen
Diese Entscheidung legt er über die so genannten Constraints fest (siehe Tabelle 2). So überlassen die Constraints »r« für normale 32-Bit-Register und »q« für die Register der 64-Bit-Erweiterung dem Compiler die freie Registerwahl für Variablen (die generische Variante ohne x86-Bezug für »q« ist »g«). Ein Constraint »a« bis »d« zwingt dagegen den Compiler im Fall einer 32-Bit-Variablen dazu, eine Variable über die Register EAX bis EDX zugänglich zu machen (siehe Listing 2). Benötigt die zugehörige Variable weniger Platz, verwendet der Inline-Assembler automatisch die passenden Register, etwa AX oder AL und so weiter.
01 #include <stdio.h>
02
03 int main(int argc, char **argv) {
04 char *from = "Hello Worldn";
05 int count;
06
07 asm(
08 "movl $-1, %%ecx nt"// Charzähler
09 "movb $0, %%al nt" // Suchzeichen '
|
Es gibt noch viele weitere Constraints. Sie erlauben es dem Entwickler, dediziert auf alle Register inklusive der 64-Bit-Erweiterungen zuzugreifen. Eine ausführliche Liste der Constraints für verschiedene Prozessorfamilien steht in der Dokumentation zum GNU-C-Compiler [1].
Falls ein Programmierer eine Zuordnung zwischen C-Variable und CPU-Register über ein Constraint wie »a« fixiert hat, verwendet er im Assembler-Code auch die bekannten Registernamen (»eax«). Will er dem Compiler die Möglichkeit zur Optimierung einräumen, überlässt er diesem die Wahl des Registers. Dann verwendet er im Code statt der ausgeschriebenen Registernamen eine numerische Referenz aus den Zuordnungslisten. Dabei zählt der Assembler die Variablen in den Listen bei Null beginnend durch und referenziert sie dann im Code über die Substitutionszeichen »%n«. Den Platzhalter »n« ersetzt der Entwickler mit dem entsprechenden Zählwert.