30. Technik: Maschinensprache II

Die konkrete Umsetzung dieses Rezepts ist im Maschinencode, den gcc erzeugt, zu erkennen. Hier die relevanten Passagen, wie objdump -S sie sieht:

#include <stdio.h>

int baz(int zot)
{ int bla;
 8048684:       55              push   %ebp
 8048685:       89 e5           mov    %esp,%ebp
 8048687:       83 ec 04        sub    $0x4,%esp
        bla = zot+9;
 804868a:       8b 45 08        mov    0x8(%ebp),%eax
 804868d:       83 c0 09        add    $0x9,%eax
 8048690:       89 45 fc        mov    %eax,0xfffffffc(%ebp)
        return bla;
 8048693:       8b 45 fc        mov    0xfffffffc(%ebp),%eax
}
 8048696:       c9              leave
 8048697:       c3              ret

void foo(int bar)
{
 8048698:       55              push   %ebp
 8048699:       89 e5           mov    %esp,%ebp
 804869b:       83 ec 08        sub    $0x8,%esp
        bar = baz(bar+1);
 804869e:       8b 45 08        mov    0x8(%ebp),%eax
 80486a1:       40              inc    %eax
 80486a2:       89 04 24        mov    %eax,(%esp,1)
 80486a5:       e8 da ff ff ff  call   8048684 <baz>
 80486aa:       89 45 08        mov    %eax,0x8(%ebp)
}
 80486ad:       c9              leave
 80486ae:       c3              ret

int main(void)
{
        int bar=7;
 80486af:       55              push   %ebp
 80486b0:       89 e5           mov    %esp,%ebp

 80486b2:       83 ec 18        sub    $0x18,%esp
 80486b5:       83 e4 f0        and    $0xfffffff0,%esp
 80486b8:       b8 00 00 00 00  mov    $0x0,%eax
 80486bd:       29 c4           sub    %eax,%esp
 80486bf:       c7 45 fc 07 ... movl   $0x7,0xfffffffc(%ebp)
        foo(bar);
 80486c6:       8b 45 fc        mov    0xfffffffc(%ebp),%eax
 80486c9:       89 04 24        mov    %eax,(%esp,1)
 80486cc:       e8 c7 ff ff ff  call   8048698 <foo>
        printf("%d", bar);
 80486d1:       8b 45 fc        mov    0xfffffffc(%ebp),%eax
 80486d4:       89 44 24 04     mov    %eax,0x4(%esp,1)
 80486d8:       c7 04 24 b4 ... movl   $0x80487b4,(%esp,1)
 80486df:       e8 c8 fe ff ff  call   80485ac <_init+0x38>
        return 0;
 80486e4:       b8 00 00 00 00  mov    $0x0,%eax
}
 80486e9:       c9              leave
 80486ea:       c3              ret
 80486eb:       90              nop

Nochmal, um Missverständnissen vorzubeugen: Die Zahlen am Anfang sind zwar Adressen, aber nicht notwendig die, an denen das Programm später laufen wird – das Betriebssystem ist schlau genug, die Adressen zu korrigieren, bevor es das Programm ausführt.

Wir sehen, dass der Compiler wie vor einiger Zeit behauptet jeweils am Anfang den Base Pointer speichert und dann den Wert des Stack Pointers in den Base Pointer schreibt – das gehört zur C Calling Convention auf x86-Maschinen. Sinn der Operation ist, dass (1) Argumente und lokale Variablen jetzt relativ zum Base Pointer adressiert werden können, während wir munter Sachen auf den Stack schieben können und (2) wir am Ende der Funktion den Base Pointer vom Stack holen können und er damit für die aufrufende Funktion wieder stimmt.

Danach holt sich der gcc durch eine Subtraktion vom Stack Pointer Platz auf dem Stack, in den er lokale Variablen und ggf. Argumente von Funktionen schreiben kann (der gcc “simuliert” den push wohl aus Laufzeitgründen). In baz wird eine vier subtrahiert, genug Platz für bla, das also bei sp anfängt. Sp liegt zu diesem Zeitpunkt vier Adressen unter bp, und so wird bla später bei bp-4 angesprochen.

Um zot+9 auszurechnen, holt der Compiler zunächst das Argument nach eax. Das geht über indirekte Adressierung: 0x8(%ebp) bedeutet: Nimm den Wert, der an der Adresse 8 Bytes über der steht, auf die bp zeigt. Über bp steht, so wie das gemacht ist, bp selbst (vom push am Anfang) und dann noch die Rückkehradresse (vom call). Danach steht dann das Argument, das der Aufrufer vor dem call gepusht hat – das geht also auf.

Die Addition mit einer Konstanten wird mit einem immediate-Argument gemacht, und das Ergebnis der Rechnung wird dann nach bp-4 geschrieben – eben dorthin, wo wir uns auf dem Stack Speicher für bla besorgt hatten. Das ist wieder indirekte Adressierung, und objdump stellt die -4 in ihrem Zweierkomplement dar (prüft es nach).

Um den dadurch berechneten Wert zurückzugeben, holt die Maschine den gerade bewegten Wert nach eax zurück. Dann muss sie nur noch aufräumen. Leave kopiert bp nach sp und poppt dann bp (d.h., es schreibt den obersten Wert des Stacks nach bp), was gerade die Beschwörung vom Anfang der Prozedur rückgängig macht. Leave ist in dem Sinn ein Service der CPU-Designer an die AutorInnen von C-Compilern auf x86-Maschinen. Das abschließende ret nimmt den obersten Wert vom Stack und springt dorthin – das call, das uns zu baz geführt hat, hat ja eben dort die Adresse des nächsten auszuführenden Statements hinterlassen.

Ganz offenbar ist dieser Code alles andere als optimal – hier wurde viel zu viel Kram durch die Gegend geschoben. Compiler können merken, wenn sie so einen Unsinn machen und Codesequenzen vereinfachen. Dieser Prozess heißt Optimierung. In der Regel muss sie per Hand “angeschaltet” werden, weil sie die Kompilierung verlangsamt und das Debuggen erschwert (ganz abgesehen davon, dass man glauben muss, dass der Compiler weiß, was er tut). Dennoch: Die Funktion baz wird, mit dem Flag -O2 kompiliert, zu

int baz(int zot)
{ int bla;
 8048690:       55                      push   %ebp
 8048691:       89 e5                   mov    %esp,%ebp

        bla = zot+9;
 8048693:       8b 45 08                mov    0x8(%ebp),%eax
        return bla;
}
 8048696:       5d                      pop    %ebp
 8048697:       83 c0 09                add    $0x9,%eax
 804869a:       c3                      ret

– wasdeutlich kürzer ist und wohl auch deutlich schneller läuft (auch wenn das bei modernen CPUs nicht immer leicht zu überblicken ist). Tatsächlich bringt die Optimierung aber nur bei wenigen Programmen auch nur annährend so viel Nutzen wie man nach diesem einfachen Beispiel erwarten würde.

Wir tun uns jetzt schon etwas leichter mit foo. Etwas komisch wirkt hier nur, dass gleich 8 Bytes für lokale Variablen reserviert werden, obwohl keine einzige definiert ist. Vier Bytes davon braucht gcc für das Argument an baz, die anderen vier Bytes werden in der Funktion nicht benutzt – vermutlich hat der Compiler hier beschlossen, sich zur Sicherheit Speicher für das Zwischenergebnis von bar+1 zu reservieren, falls er ihn brauchen würde. Im optimierten Code gibts sowas natürlich nicht mehr.

Die nächsten Statements verstehen wir aus dem Stand: Die Maschine holt das Argument aus 8(%ebp) nach eax und inkrementiert es (das ist das bar+1). Dieses Ergebnis kommt auf den Stack. Die Schreibweise (%esp,1) bedeutet dabei “die Adresse esp um eine Einheit erhöht”, wobei die Einheit hier vier Bytes sind. Dies entspricht fast einem push auf den Stack, nur dass hier der Stackpointer nicht bewegt werden muss (was klappt, weil der Speicher über dem Stackpointer extra reserviert war). Der Code läuft so etwas schneller als mit einem regulären push, und er ist ja zunächst auch nicht für menschliche Konsumption geschrieben (für uns wäre ein push ganz offenbar klarer).

Dann wird baz aufgerufen (was insbesondere impliziert, dass die Adresse des folgenden mov-Statements auf den Stack gepusht wird). Nach der Rückkehr wird das Ergebnis (das in eax zurückkommt) nach bar kopiert. Danach ist auch diese Funktion fertig.

In main werden diesmal gleich 24 Bytes lokaler Speicher reserviert. Die darauf folgende and-Instruktion sorgt dafür, dass sp auf einer durch 16 teilbaren Adresse steht (die letzten vier bit einer Zahl geben den Rest bei der Division durch 16). So etwas heißt alignment (Ausrichtung). Je nach Architektur und Compiler wird auf durch 4, 8 oder sogar 16 teilbare Adressen alignet, weil Zugriffe auf Wörter, Doppelwörter (in sowas werden in der Regel doubles gespeichert) oder sonstige Daten furchtbar langsam werden, wenn sie anders liegen (beim 68000 waren sie sogar ganz verboten). Warum hier auf 16 Bytes alignet wird, entzieht sich meiner Kenntnis. Ebenso weiß ich nicht, was sich der Compiler bei den nächsten beiden (offenbar wirkungslosen) Statements gedacht hat – die Mühe, das im Quellcode des Compilers nachzuvollziehen, lohnt wohl nicht. Die Maintainer des gcc würden wohl argumentieren, dass der Optimierer mit diesen Dingen fertig wird.

Dann wird die Vorbelegung von bar erledigt, wie immer indirekt über den bp. Die nächsten beiden Zeilen bringen bar auf den Stack – hier kann viel optimiert werden, da eigentlich klar ist, dass das Argument sowieso immer sieben ist (vgl. unten). Nach dem Aufruf von foo werden dann die Argumente fürs printf auf den Stack geschoben (beachtet das displacement 0x04 bei bar – das simuliert, dass bar zuerst gepusht wird, dann erst die Adresse des Strings "%d").

Der Rest ist mittlerweile wohlbekannt. Am Schluss stehen im Code noch ein paar nops – die nichts machen und, weil vor ihnen ein ret steht, auch nie ausgeführt werden. Sie sind letztlich irgendein Compiler-Voodoo, der vermutlich beim passenden Alignment von Instruktionen oder Spielereien mit dem Instruktionscache der CPU helfen soll. Bei richtig modernen CPUs kann es, das nur nebenbei, tatsächlich sein, dass Programme durch gezieltes Einstreuen von nops schneller werden.

Die optimierte Fassung von main ist

int main(void)
{ int bar=7;
 804869c:       55                      push   %ebp
 804869d:       89 e5                   mov    %esp,%ebp
 804869f:       83 ec 08                sub    $0x8,%esp
 80486a2:       83 e4 f0                and    $0xfffffff0,%esp

  foo(bar);
 80486a5:       83 ec 0c                sub    $0xc,%esp
 80486a8:       6a 07                   push   $0x7
 80486aa:       e8 e1 ff ff ff          call   8048690 <foo>
 80486af:       58                      pop    %eax
 80486b0:       5a                      pop    %edx
        printf("%d", bar);
 80486b1:       6a 07                   push   $0x7
 80486b3:       68 94 87 04 08          push   $0x8048794
 80486b8:       e8 ef fe ff ff          call   80485ac <_init+0x38>
        return 0;
}
 80486bd:       31 c0                   xor    %eax,%eax
 80486bf:       c9                      leave
 80486c0:       c3                      ret

– das Auseinanderklamüsern davon sei euch überlassen. Das xor von ax mit sich selbst, so viel sei verraten, ist nur eine schnelle Art, die Null nach ax zu bekommen.

Der Stack in foo, bevor baz aufgerufen wird (80486a5):


Markus Demleitner

Copyright Notice