Teil 2 - Assembler 101

Aus Lowlevel
Wechseln zu: Navigation, Suche
« Teil 1 - Entwicklungsumgebung Navigation Teil 3 - Trockenübungen »

Ziel

Dieser Artikel versucht, in aller Kürze zumindest die Grundlagen der Assemblerprogrammierung für x86 zu vermitteln. Ganz ohne Assembler kommt man bei der Betriebssystemprogrammierung nicht aus, deswegen ist ein Grundverständnis dafür nötig.

An dieser Stelle sei angemerkt, dass es mehrere verschiedene Assembler-Varianten gibt. Wir verwenden hier die AT&T-Syntax, die vom GNU-Assembler unterstützt wird.

Begriffe

  • Eine Instruktion ist ein kompletter Prozessorbefehl, z.B. addiere 5 auf den Inhalt des Registers eax (add $5, %eax). Mit Instruktion kann sowohl die Assemblerdarstellung als auch der Opcode gemeint sein.
  • Der Opcode ist der binäre Maschinenbefehl, wie er vom Prozessor verstanden wird. Er wird meistens hexadezimal und byteweise geschrieben: 05 05 00 00 00 für unser Beispiel. Oft wird unter Opcode auch nur der eigentliche Befehl (05 für add ..., %eax) verstanden.
  • Der Operand sind die Daten, auf denen der Maschinenbefehl arbeitet. Das ist zum einen die Zahl 5 und zum anderen das Register eax.
  • Das Mnemonic ist der Assemblername eines Befehls, z.B. add

Operanden

Als Operanden für Assemblerbefehle kommen nur drei verschiedene Arten in Frage:

  • Immediatewerte sind konstante Zahlen. Sie beginnen in Assembler mit $, wie im obigen Beispiel das $5.
  • Prozessorregister sind der Platz, wo Daten, mit denen gearbeitet wird, normalerweise liegen. x86 hat acht Allzweckregister, die jeweils 32 Bit groß sind. Die Register befinden sich direkt im Prozessor, so dass sie viel schneller sind als Zugriffe auf Arbeitsspeicher. Registernamen beginnen mit %, wie im obigen Beispiel das %eax.
  • Der Speicher ist schließlich, wo die meisten Daten liegen. Auf Speicher wird grundsätzlich über Adressen zugegriffen. Dabei gibt es folgende Möglichkeiten:
    • Direkte Angabe der Adresse: mov 5, %eax ohne $ kopiert den Wert von der Speicheradresse 5 ins Register eax
    • Indirekte Angabe über Register: mov (%ebx), %eax liest die Adresse aus dem Register ebx und kopiert dann von dieser Adresse nach eax
    • Indirekte Angabe mit Displacement: mov 8(%ebx), %eax liest wieder die Adresse aus ebx, zählt aber noch 8 dazu, bevor es die Adresse verwendet
    • Es geht noch mehr: mov 8(%ebx, %edx, 2) berechnet die Adresse als ebx + 2 * edx + 8

Labels

Oft bezieht sich ein Operand auf eine andere Stelle im Assemblercode, z.B. ein Sprung an eine andere Codestelle oder Auslesen von Daten. In diesem Fall möchte sich natürlich niemand die Mühe machen, von Hand auszurechnen, an welcher Adresse diese Stelle steht. Aus diesem Grund kann man überall sogenannte Labels definieren, Sprungmarken mit einem selbstgewählten Namen. Bei der Definition kommt hinter dem Namen ein Doppelpunkt: my_label:

Der Name des Labels kann dann für Operatoren anstatt von Zahlen verwendet werden. Wenn die Adresse benutzt werden soll, nicht vergessen, auch hier wieder ein $ vor den Namen des Labels zu schreiben. Ansonsten wird auf die Daten direkt nach dem Label zugegriffen.

Wenn das Label auch außerhalb der Datei sichtbar sein soll, muss zusätzlich .global my_label angegeben werden. Es ist auch möglich, auf Labels zuzugreifen, die in anderen Dateien definiert sind, sie müssen dann mit .extern my_label eingebunden werden. Diese Möglichkeit besteht nur, wenn ein Binärformat wie z.B. ELF benutzt wird und die vom Assembler erzeugten Objektdateien anschließend noch miteinander gelinkt werden.

Daten

Im Assemblercode können auch Daten definiert werden, auf die zugegriffen werden kann. Man muss dabei nur darauf achten, dass die Daten nicht mitten im Code stehen, sonst wird der Prozessor versuchen, die Daten als Befehle zu interpretieren.

Befehl Erklärung
.byte 0xff 8-Bit-Daten
.word 0xffff 16-Bit-Daten
.int 0xffffffff 32-Bit-Daten
.quad 0xffffffffffffffff 64-Bit-Daten
.space 256 256 Bytes freier Platz

Sektionen

Bei der Benutzung von Binärformaten wie ELF kann der Assemblercode in verschiedene Sektionen unterteilt werden. Mit .section name wird festgelegt, in welcher Sektion der folgende Code landen soll. Dabei kann auch zwischen Sektionen hin- und hergewechselt werden. Die Standardsektionen sind:

Befehl Erklärung
.section .text Ausführbare Befehle
.section .data Veränderbare Daten
.section .rodata Nur lesbare Daten
.section .bss Veränderbare Daten mit beim Start undefiniertem Wert (Platz mit .space reservieren)

Kommentare

Kommentare funktionieren wie in C:

// Einzeiliger Kommentar

/*
 * Mehrzeiliger Kommentar
 */

Register

Wie oben erwähnt, besitzt x86 acht 32-Bit-Allzweckregister: eax, ebx, ecx, edx, ebp, esp, esi, edi. Obwohl sie als Allzweckregister bezeichnet werden, gibt es Befehle, die nur mit einem bestimmten Register funktionieren. Bei allen diesen Registern ist es auch möglich, nur auf die niederwertigen 16 Bit zuzugreifen. Dabei fällt das e im Namen weg: ax sind die unteren 16 Bit von eax. ax, bx, cx und dx unterteilen sich wiederum in ein höherwertiges und ein niederwertiges Byte, genannt z.B. ah und al.

Außerdem existieren noch Segmentregister, Steuerregister, FPU-Register und mehr, aber diese haben eine spezielle Funktion und sollen hier außen vor bleiben.

Befehlssatz

An dieser Stelle ein kurzer Abriss über die von x86 unterstützten Befehle. Ich werde dabei im Allgemeinen nicht angeben, welche Kombinationen von Operanden alles erlaubt sind. Eine allgemeine Sache, die gilt, ist dass in einer Instruktion immer maximal ein Operand aus dem Speicher kommen darf. Eine andere ist, dass man mit Registern außer den Allzweckregistern nichts anderes machen kann als sie aus einem Allzweckregister zu laden oder ihren Inhalt in ein Allzweckregister zu kopieren.

Zuweisungen und Stack

Befehl Erklärung
mov Quelle, Ziel Kopiert den Wert von Quelle nach Ziel
lea Speicher, Ziel Lädt die Adresse des Speichers nach Ziel (lea (%eax), %edx ist dasselbe wie mov %eax, %edx)
push Operand Legt den Wert auf den Stack; d.h. esp wird um die Größe des Operanden verringert und der Operand an die neue Speicheradresse von esp kopiert.
pop Operand Holt den Wert vom Stack; d.h. der Wert an der Adresse von esp wird zum Operanden kopiert und esp um die Größe des Operanden erhöht.

Arithmetik

Befehl Erklärung
inc Operand Erhöht den Operanden um eins
dec Operand Verringert den Operanden um eins
add Quelle, Ziel Addiert den Wert von Quelle auf Ziel (der alte Wert von Ziel wird dabei überschrieben; dies gilt auch für alle folgenden Befehle)
sub Quelle, Ziel Subtrahiert den Wert von Quelle von Ziel
imul Quelle, Ziel Multipliziert Quelle mit Ziel und speichert das Ergebnis in Ziel
mul Quelle Multipliziert Quelle (wenn es ein 32-Bit-Register ist) mit eax und speichert das Ergebnis in edx:eax (d.h. die niederwertigen 32 Bit des Ergebnisses stehen in eax, die höherwertigen in edx)
div Quelle Dividiert edx:eax durch Quelle (wenn es ein 32-Bit-Register ist) und speichert das ganzzahlige Ergebnis in eax. Der Rest wird in edx gespeichert.
neg Operand Überschreibt den Operaden mit seinem negativen Wert (5 wird zu -5)

Bitoperationen

Befehl Erklärung
and Quelle, Ziel Berechnet das bitweise AND von Quelle und Ziel
or Quelle, Ziel Berechnet das bitweise OR von Quelle und Ziel
xor Quelle, Ziel Berechnet das bitweise XOR (exklusives Oder) von Quelle und Ziel
not Operand Berechnet das bitweise NOT vom Operanden
shl Anzahl, Operand Schiebt den Wert des Operanden um Anzahl Bits nach links. Rechts wird mit Nullen aufgefüllt.
shr Anzahl, Operand Schiebt den Wert des Operanden um Anzahl Bits nach rechts. Links wird mit Nullen aufgefüllt.
rol Anzahl, Operand Rotiert den Wert des Operanden um Anzahl Bits nach links. Was links rausgeschoben wird, kommt rechts wieder rein.
ror Anzahl, Operand Rotiert den Wert des Operanden um Anzahl Bits nach rechts. Was rechts rausgeschoben wird, kommt links wieder rein.

Vergleiche und Sprünge

Um den Programmfluss zu steuern, gibt es in Assembler zwei Arten von Sprüngen. Die erste Art sind unbedingte Sprünge, sie entsprechen am ehesten einem goto in einer Hochsprache. Die zweite Art sind bedingte Sprünge, die benutzt werden, um ein if umzusetzen. Bedingte Sprünge führen den Sprung nur aus, wenn eine bestimmte Bedingung erfüllt ist. Die Bedingungen bestehen in einigen Bits im Register eflags, die von vielen Befehlen automatisch gesetzt werden. Diese Bits sind:

  • Das Zero Flag: Ist gesetzt, wenn der letzte geschriebene Wert Null war
  • Das Carry Flag: Enthält den Übertrag bei vorzeichenlosen Operationen, wenn das Ergebnis zu groß ist, um vom Zieloperanden dargestellt werden zu können
  • Das Overflow Flag: Enthält den Übertrag bei vorzeichenbehafteten Operationen, wenn das Ergebnis zu groß oder zu klein ist, um vom Zieloperanden dargestellt werden zu können
  • Das Sign Flag: Ist gesetzt, wenn des letzte Ergebnis negativ war
Befehl Erklärung
cmp Quelle, Ziel Subtrahiert Quell von Ziel, ohne das Ergebnis zu speichern. Es werden nur die Flags angepasst.
test Quelle, Ziel Berechnet das bitweise AND von Quelle und Ziel, ohne das Ergebnis zu speichern. Es werden nur die Flags angepasst.
jmp Sprungziel Springt zum Sprungziel (unbedingt)
jz Sprungziel Springt zum Sprungziel, wenn das Zero Flag gesetzt ist (alternativ je für equal - da cmp subtrahiert, ist ZF gesetzt, wenn die Operanden gleich waren)
jnz Sprungziel Springt zum Sprungziel, wenn das Zero Flag nicht gesetzt ist (alternativ jne)
jg Sprungziel Springt zum Sprungziel, wenn beim cmp Quelle > Ziel war
jl Sprungziel Springt zum Sprungziel, wenn beim cmp Quelle < Ziel war
jge Sprungziel Springt zum Sprungziel, wenn beim cmp Quelle >= Ziel war
jle Sprungziel Springt zum Sprungziel, wenn beim cmp Quelle <= Ziel war
call Sprungziel Legt die Adresse des nächsten Befehls auf den Stack und springt zum Sprungziel (Funktionsaufruf)
ret Holt die Rücksprungadresse vom Stack und springt dorthin (Rücksprung aus einer Funktion)

Oft gebrauchte Muster

Bitoperationen

xor %eax, %eax

Setzt eax auf Null. Wird häufig verwendet, weil der Maschinencode dafür kürzer ist als der für mov $0, %eax.

or %eax, %eax

Setzt die Flags für eax, ohne es zu ändern. Wird zum Beispiel für Nullprüfungen verwendet, mit anschließendem jz.

Funktionen

my_func:
    push %ebp
    mov %esp, %ebp
    ...
    mov %ebp, %esp
    pop %ebp
    ret

ebp wird hier als Frame Pointer verwendet. Wenn jede Funktion die C-Aufrufkonvention benutzt (die dieses Schema beinhaltet), sieht der Stack einer Funktion damit folgendermaßen aus:

Adresse Inhalt
...
-4(%ebp) Lokale Variable
(%ebp) Frame Pointer der aufrufenden Funktion
4(%ebp) Rücksprungadresse
8(%ebp) Erster Parameter der Funktion
...

Dadurch, dass auch ein Verweis auf den Frame Pointer der aufrufenden Funktion vorhanden ist, lassen sich leicht Stack Backtraces für das Debugging produzieren.

Beispiel

Das folgende Programm, bestehend aus einem Assembler- und einem C-Teil berechnet die Fakultät einer Zahl: <asm>.section .text .global factorial factorial:

   push %ebp
   mov %esp, %ebp
   // Den Parameter nach eax laden
   mov 8(%ebp), %eax
   // Die Fakultät von 0 ist 1 (Rückgabewert kommt nach eax)
   or %eax, %eax
   jnz recurse
   mov $1, %eax
   jmp out

recurse:

   // Ansonsten rekursiv factorial für eax - 1 aufrufen und hinterher
   // mit unserem eax multiplizieren
   dec %eax
   push %eax
   call factorial
   add $4, %esp
   // eax enthält jetzt den Rückgabewert, also unseren Parameter nochmal
   // neu von unserem Stack laden
   mov 8(%ebp), %edx
   // Multiplizieren und das Ergebnis in eax (als Rückgabewert) speichern
   imul %edx, %eax

out:

   mov %ebp, %esp
   pop %ebp
   ret</asm>

<c>#include <stdio.h>

extern int factorial(int n);

int main(void) {

   int n = 5;
   printf("Die Fakultät von %d ist %d\n", n, factorial(n));
   return 0;

}</c>

Kompiliert wird das ganze (abhängig von den Dateinamen natürlich) wie folgt (-m32 ist auf 64-Bit-Systemen wichtig - wir haben hier 32-Bit-Code geschrieben):

gcc -m32 -o factorial main.c fact.S

Hinweis:

Bei gcc sollte die Assemblerdatei unbedingt die Endung .S (mit großem S) haben. Bei einem kleinen .s erkennt der Assembler die Datei sonst als bereits vom Präprozessor bearbeitet und würde diesen dann nicht mehr aufrufen. C-Kommentare, #define, andere Präprozessordirektiven und manche Befehle sind dann nicht mehr nutzbar!

Weblinks

Intels Prozessordokumentation


« Teil 1 - Entwicklungsumgebung Navigation Teil 3 - Trockenübungen »