7. Technik: Maschinensprache I

Bevor wir uns ansehen, was wir C eigentlich gesagt haben, werfen wir einen Blick auf den erzeugten Maschinencode. Dieser Kurs wird noch öfter einen Blick “nach unten” werfen – das hilft zu verstehen, warum C so ist wie es ist.

Normale Mikroprozessoren haben sehr primitive Instruktionen. Sie können üblicherweise Dinge wie:

  • Werte bewegen (Konstante nach Register, Speicher nach Register und umgekehrt, Register nach Register, manchmal auch Speicher zu Speicher): mov
  • Rechnungen mit zwei Operanden: add, sub, mul div
  • Bitweise Logik: and, or, not
  • Werte vergleichen: cmp
  • Springen, auch bedingt auf den Zustand der Flags: jmp, je, jne, jz, jc uvm.
  • In “Unterprogramme” springen und aus diesen zurückkehren: call/ret, int/iret
  • Werte auf einen Stack legen und wieder von ihm runterholen: push, pop
  • Nichts tun: nop

Beachtet: Hier steht nirgends etwas von Variablen (gut, man kann in Speicher schreiben), von Funktionen (die “Unterprogramme” qualifizieren vielleicht, aber von Argumenten ist hier noch nicht die Rede) oder von Schleifen (dafür kann man springen).

Dafür haben wir hier schon einen Stack (vgl. Programmieren I, Folie XML II: SAX), d.h. eine Datenstruktur, die Daten so aufnimmt, dass die zuletzt reingeschobenen Daten zuerst wieder rauskommen (Last in, First out, LIFO).

Diese Instruktionen nehmen jeweils Operanden, und aus Instruktion plus Operanden lassen sich die Zahlen ausrechnen, die der Prozessor schließlich ausführen kann.

Für x86-Prozessoren ist das hoch kompliziert, bei anderen Prozessoren ist das viel einfacher. Der Umstand, dass ausgerechnet die idiotischste verfügbare Prozessorarchitektur sich am Markt durchgesetzt hat, sollte im Hinblick auf den Wahrheitsgehalt diverser ökonomischer Theorien zu denken geben.

Wir verwenden hier die so genannte AT&T-Syntax, um die Maschinenbefehle in lesbare Form zu bringen. Außerhalb der GNU-Welt verbreiteter ist die so genannte Intel-Syntax, die sich in vieler Hinsicht von der AT&T-Syntax unterscheidet – am dramatischsten ist wahrscheinlich, dass Quell- und Zieloperanden bei Intel vertauscht sind, d.h. mov a, b bewegt in AT&T-Syntax von a nach b (wie “bewege a nach b”), in Intel-Syntax von b nach a (wie bei einer Zuweisung).

Wer sich näher mit der Programmierung mit Assemblern (das sind Programme, die die Mnemonics in den tatsächlichen Maschinencode umrechnen) auseinandersetzen wird, wird damit nicht lange Schwierigkeiten haben, zumal in der manpage des GNU as eine schöne Übersicht über die syntaktischen Differenzen gegeben wird.

Beispiele: push %epb (das heißt: Lege den Inhalt des Registers ebp auf den Stack) besteht aus dem Opcode für push (das ist im “alternate encoding” 01010rrr2), worin rrr das Register spezifiziert, was für ebp 1012 ist. Es ist 010101012 =0x55.

sub $0x8,%esp (das heißt: Ziehe den Wert 8 – man spricht bei solchen Literalen hier auch gern von “immediate value”, Werte also, die ohne weiteres Nachsehen bekannt sind und im Opcode kodiert werden können – vom Stackpointer ab) besteht aus dem Opcode für sub immediate to register, 100000sw2:11101rrr2, wo rrr wieder das Register (1002), s die sign extension (siehe unten) und w die Größe des Arguments gibt, und dem Argument. s ist hier 1, w ist 1, weil wir ein Byte abziehen (und nicht etwa ein Wort (16 bit) oder ein Doppelwort (32 bit)). Das Argument ist einfach ein Byte, 8. Zusammen haben wir: 100000112:111011002:000010002 oder kurz 83 ec 08 hexadezimal.

Was aber tut das Codefragment auf der letzten Folie? Gehen wir es durch:

08048654 <main>:
#include <stdio.h>

int main(void)
{
 8048654:       55                      push   %ebp
 8048655:       89 e5                   mov    %esp,%ebp
 8048657:       83 ec 08                sub    $0x8,%esp
 804865a:       83 e4 f0                and    $0xfffffff0,%esp
 804865d:       b8 00 00 00 00          mov    $0x0,%eax
 8048662:       29 c4                   sub    %eax,%esp

Wir sehen zunächst, dass die komische include-Zeile keinen Code produziert. Wir werden gleich sehen, warum das so ist.

Der folgende Funktionskopf erzeugt hingegen einen Haufen Code, der ausgesprochen wirr wirkt. In Wirklichkeit wird hier im Wesentlichen dafür gesorgt, dass Platz für lokale Variablen der Funktion da ist (vgl. die Folie “Parameterübergabe in C”). Jede C-Funktion auf x86-Maschinen muss nach Konvention (der so genannten calling convention) als erstes das Register ebp (Base Pointer, das e steht dafür, dass es 32 bit groß sein soll) auf dem Stack retten (push) und danach den Stackpointer in den ebp kopieren (mov). Das, und was danach kommt, sird auf der genannten Folie erklärt.

        printf("Hello World.\n");
 8048664:       c7 04 24 44 87 04 08    movl   $0x8048744,(%esp,1)
 804866b:       e8 08 ff ff ff          call   8048578 <_init+0x38>

Hier haben wir endlich Maschinencode für das, was wir geschrieben haben. Das movl (move long) legt die Adresse 0x8048744 auf den Stack. Eigentlich sollte hier ein push stehen, und es reicht, sich das so vorzustellen. Der gcc macht auf meiner Maschine die Dinge hier etwas konfuser, aber das Ergebnis seiner Operation unterscheidet sich nich von einem push, gefolgt von einem pop nach der folgenden Instruktion. Die Hoffnung ist, dass der Code so schneller ist.

Wirklich interessant an der movl-Instruktion ist aber, dass hier indirekt adressiert wird: Der Rechner nimmt die Adresse, an die das erste Argument kommen soll, aus dem Register esp (im Gegensatz zu: Es schreibt das erste Argument nach esp) – das ist die Bedeutung der Klammern, und wir werden sehen, dass C mit dem *-Operator ein ziemlich enges Analogon dazu bietet.

Tatsächlich können CPUs (sogar die von Intel) noch viel raffinierter adressieren: Ein Ausdruck wie 16(%esp, 20, 4) nimmt den Wert von esp, addiert 16 und dann nochmal 20 ⋅ 4 drauf. Warum man sowas haben möchte, werdet ihr sehen, wenn wir arrays und records behandeln.

An der erwähnten Adresse 0x8048744 sollte unser String Hello World[n liegen. Das wird er in der Realität natürlich nicht tun, weshalb das Betriebssystem diese Adresse auch anpassen wird, wenn es das Programm lädt und ausführt. Aber das ist noch ein anderes Thema.

Auch die im call erwähnte Adresse wird angepasst, so dass sie auf die Funktion printf zeigt, deren Maschinencode anderswo liegt. Dafür ist (nicht ganz genau, aber ungefähr) der Linker zuständig, von dem wir bald hören werden. Jedenfalls sorgt das call dafür, dass die Ausführung in dessen Code weitergeht, bis dort ein ret (wie return) auftaucht. Danach läuft das Programm weiter in unserem Code – wohin er zurückspringen muss, weiß der Prozessor, weil call die Adresse der nächsten Instruktion auf dem Stack hinterlassen hat, so dass ret nur noch diesen Wert in den Program Counter poppen muss.

       return 0;
 8048670:       b8 00 00 00 00          mov    $0x0,%eax

Return macht in C ziemlich das, was es in Python auch tut. Der Compiler macht aus unserem Ansinnen, Null zurückzugeben, einen move nach eax – und in der Tat sehen die calling conventions vor, dass eine Funktion ihren Rückgabewert in eax lässt (das bedeutet insbesondere, dass es nicht so einfach sein wird, aus C-Funktionen mehr als einen Wert zurückzugeben, weil es eben nur ein eax-Register gibt).

}
 8048675:       c9                      leave
 8048676:       c3                      ret

Die Instruktion leave hatte ich oben nicht erwähnt – sie macht das, was der Compiler oben aus unserem Funktionskopf gemacht hat, rückgängig (in der Tat hätte gcc oben auch die Maschineninstruktion enter erzeugen können, was er aber wohl deshalb nicht gemacht hat, weil auf Athlon XPs der Code, den er wirklich erzeugt hat, schneller ist als ein äquivalentes enter). Das ret hatten wir oben schon disktutiert, es sorgt hier dafür, dass unser Programm wieder in den Untiefen des eigenartigen Compiler-Codes verschwindet.


Markus Demleitner

Copyright Notice