Friedrich-Alexander-Universität Erlangen-Nürnberg  /   Technische Fakultät  /   Department Informatik

Assembler-Crashkurs (x86)

Was ist das Ziel dieses Crashkurses?

Ziel dieses Crashkurses ist es, einen ├ťberblick ├╝ber die Assembler-Programmierung zu geben, insbesondere f├╝r die Teilnehmer von BS, die noch keine Assemblerkenntnisse besitzen.

Wir bilden uns nicht ein, dass ihr am Ende komplexe Assemblerprogramme schreiben k├Ânnt, aber das braucht ihr schlie├člich auch nicht. Wir hoffen aber, dass ihr auf diese Weise zumindest eine gewisse Vorstellung davon erhaltet, wie ein Hochsprachenprogramm in Assembler aussieht und bei entsprechender Hilfestellungen auch selbst ganz kleine Assemblerfunktionen schreiben k├Ânnt.

Die verschiedenen Konzepte werden am Beispiel des 80x86-Prozessors erl├Ąutert. Diese Prozessorreihe stammt von der Firma Intel und steckt direkt oder als Nachbau u. a. in jedem PC. Die verwendete Notation entspricht dem Netwide Assembler NASM, der auch bei der Entwicklung den ├ťbungsbetriebssystems OOStuBS Verwendung findet.

Den "Rahmen" eines Assemblerprogramms erkl├Ąren wir hier nicht, den schaut ihr euch am besten an einer Assemblerdatei ab.

Was ist ein Assembler?

Ein Assembler ist genaugenommen ein Compiler, der den Code eines "Assemblerprogramms" in Maschinensprache, d. h. Nullen und Einsen ├╝bersetzt. Anders als ein C-Compiler hat es der Assembler jedoch sehr einfach, da (fast immer) einer Assembleranweisung genau eine Maschinensprachenanweisung entspricht. Das Assemblerprogramm ist also nur eine f├╝r Menschen (etwas) komfortablere Darstellung des Maschinenprogramms:

Statt

000001011110100000000011

schreiben zu m├╝ssen, kann der Programmierer die Assembleranweisung

add ax,1000

verwenden, die (bei den 80x86-Prozessoren) genau dasselbe bedeutet:

symbolische Bezeichnung Maschinencode
add ax 00000101
1000 (dez.) 0000001111101000

Zus├Ątzlich vertauscht der Assembler noch die Reihenfolge der Bytes des Offsets:

00000101 11101000 00000011
add ax low-Byte high-Byte

Im ├╝blichen Sprachgebrauch wird unter "Assembler" jedoch weniger der Compiler verstanden, als die symbolische Notation der Maschinensprache. add eax,1000 ist dann also eine Assembleranweisung.

Was kann ein Assembler?

Ein Assembler kann eigentlich sehr wenig, n├Ąmlich nur das, was der Prozessor direkt versteht. Die ganzen sch├Ânen Konstrukte h├Âherer Programmiersprachen, die dem Programmierer erlauben, seine Algorithmen in verst├Ąndliche, (ziemlich) fehlerfreie Programme zu ├╝bertragen, fehlen:

  • keine komplexen Anweisungen
  • keine komfortablen for-, while-, repeat-until-Schleifen, sondern fast nur gotos
  • keine strukturierten Datentypen
  • keine Unterprogramme mit Parameter├╝bergabe
  • ...

Beispiele:

  • Die C-Anweisung

    summe = a + b + c + d;

    ist f├╝r einen Assembler zu kompliziert und muss daher in mehrere Anweisungen aufgeteilt werden. Der 80x86-Assembler kann immer nur zwei Zahlen addieren und das Ergebnis in einer der beiden verwendeten "Variablen" (Akkumulatorregister) speichern. Das folgende C-Programm entspricht daher eher einem Assemblerprogramm:

           summe = a;
           summe = summe + b;
           summe = summe + c;
           summe = summe + d;

    und w├╝rde beim 80x86-Assembler so aussehen:

           mov eax,[a]
           add eax,[b]
           add eax,[c]
           add eax,[d]
  • Einfache if-then-else-Konstrukte sind f├╝r Assembler auch schon zu schwierig:

           if (a == 4711)
            {
              ...
            }
           else
            {
              ...
            }

    und m├╝ssen daher mit Hilfe von gotos ausgedr├╝ckt werden:

                     if (a != 4711)
                        goto ungleich
           gleich:   ...
                     goto weiter:
           ungleich: ...
           weiter:   ...

    Im 80x86-Assembler sieht das dann so aus:

                     cmp eax,4711
                     jne ungleich
           gleich:   ...
                     jmp weiter
           ungleich: ...
           weiter:   ...
  • Einfache Z├Ąhlschleifen werden vom 80x86-Prozessor schon besser unterst├╝tzt. Das folgende C-Programm

           for (i=0; i<100; i++)
            { summe = summe + a;
            }

    sieht im 80x86-Assembler etwa so aus:

                      mov ecx,100
           schleife:  add eax,[a]
                      loop schleife

    Der Loop-Befehl dekrementiert implizit das ecx-Register und f├╝hrt den Sprung nur aus, wenn der Inhalt des ecx-Registers anschlie├čend nicht 0 ist.

Was ist ein Register?

In den bisher genannten Beispielen wurden anstelle der Variablennamen des C-Programms stets die Namen von Registern verwendet. Ein Register ist ein winziges St├╝ckchen Hardware innerhalb des Prozessors, das beim 80386 und h├Âher bis zu 32 Bits, also 32 Ziffern im Bereich 0 und 1 speichern kann.

Der 80386 besitzt folgende Register:

Allgemeine Register
Name Bemerkung
eax allgemein verwendbar, spezielle Bedeutung bei Arithmetikbefehlen
ebx allgemein verwendbar
ecx allgemein verwendbar, spezielle Bedeutung bei Schleifen
edx allgemein verwendbar
ebp Basepointer
esi Quelle (eng: source) f├╝r Stringoperationen
edi Ziel (eng: destination) f├╝r Stringoperationen
esp Stackpointer
Segmentregister
Name Bemerkung
cs Codesegment
ds Datensegment
ss Stacksegment
es beliebiges Segment
fs beliebiges Segment
gs beliebiges Segment
Sonstige Register
Name Bemerkung
eip Instruction Pointer
eflags Flags register

Die unteren beiden Bytes der Register eax, ebx, ecx und edx haben eigene Namen, beim eax-Register sieht das so aus:

ax f├╝r die unteren 16 Bits, al f├╝r die Bits 0 bis 7 und ah f├╝r die Bits 8 bis 15.
ax f├╝r die unteren 16 Bits, al f├╝r die Bits 0 bis 7 und ah f├╝r die Bits 8 bis 15.

Was ist Speicher?

Meistens reichen die Register nicht aus, um ein Problem zu l├Âsen. In diesem Fall muss auf den Hauptspeicher des Computers zugegriffen werden, der erheblich mehr Information speichern kann. F├╝r den Assemblerpogrammierer sieht der Hauptspeicher wie ein riesiges Array von Registern aus, die je nach Wunsch 8, 16 oder 32 Bits "breit" sind. Die kleinste adressierbare Einheit ist also ein Byte (= 8 Bits). Daher wird auch die Gr├Â├če des Speichers in Bytes gemessen. Um auf einen bestimmten Eintrag des Arrays "Hauptspeicher" zugreifen zu k├Ânnen, muss der Programmierer den Index, d. h. die Adresse des Eintrages kennen. Das erste Byte des Hauptspeichers bekommt dabei die Adresse 0, das zweite die Adresse 1 usw.

In einem Assemblerprogramm k├Ânnen Variablen angelegt werden, indem einer Speicheradresse ein Label zugeordnet und dabei Speicherplatz in der gew├╝nschten Gr├Â├če reserviert wird.

[SECTION .data]
gruss:       db 'hello, world'
unglueck:    dw 13
million:     dd 1000000


[SECTION .text]
             mov ax,[million]
             ...

Was ist ein Stack (deutsch: Stapel)?

Nicht immer will man sich ein neues Label ausdenken, nur um kurzfristig den Wert eines Registers zu speichern; beispielsweise, weil man das Register f├╝r eine bestimmte Anweisung ben├Âtigt, den alten Wert aber nicht verlieren m├Âchte. In diesem Fall w├╝nscht man sich etwas wie einen Schmierzettel. Den bekommt man mit dem Stack. Der Stack ist eigentlich nichts weiter als ein St├╝ck des Hauptspeichers, nur dass dort nicht mit festen Adressen gearbeitet wird, sondern die zu sichernden Daten einfach immer oben drauf geschrieben (push) bzw. von oben heruntergeholt werden (pop). Der Zugriff ist also ganz einfach, vorausgesetzt man erinnert sich daran, in welcher Reihenfolge die Daten auf den Stapel gelegt wurden. Ein spezielles Register, der Stackpointer esp, zeigt stets auf das oberste Element des Stacks. Da push und pop immer nur 32 Bits auf einmal transferieren k├Ânnen, ist der Stack in der folgenden Abbildung als vier Bytes breit dargestellt.

Adressierungsarten

Die meisten Befehle des 80x86 k├Ânnen ihre Operanden wahlweise aus Registern, aus dem Speicher oder unmittelbar einer Konstante entnehmen. Beim mov-Befehl sind (u. a.) folgende Formen m├Âglich, wobei der erste Operand stets das Ziel und der zweite stets die Quelle der Kopieraktion angeben:

  • Registeradressierung: Der Wert eines Registers wird in ein anderes ├╝bertragen.
    mov ebx,edi
  • Unmittelbare Adressierung: Die Konstante wird in das Register ├╝bertragen.
    mov ebx,1000
  • Direkte Adressierung: Der Wert, der an der angegebenen Speicherstelle steht, wird in das Register ├╝bertragen.
    mov ebx,[1000]
  • Register-indirekte Adressierung: Der Wert, der an der Speicherstelle steht, die durch das zweite Register bezeichnet wird, wird in das erste Register ├╝bertragen.
    mov ebx,[eax]
  • Basis-Register-Adressierung: Der Wert, der an der Speicherstelle steht, die sich durch die Summe des Inhalts des zweiten Registers und der Konstanten ergibt, wird in das erste Register ├╝bertragen.
    mov eax,[10+esi]

Anmerkung: Wenn der 80x86-Prozessor im Real-Mode betrieben wird (z. B. bei der Arbeit mit dem Betriebssystem MS-DOS), werden Speicheradressen durch ein Segmentregister und einen Offset angegeben. Bei der Veranstaltung Betriebssysteme ist das nicht n├Âtig (sondern sogar falsch), weil OOStuBS im Protected-Mode l├Ąuft und die Segmentregister von uns bereits f├╝r euch initialisiert wurden.

Prozeduren

Aus den h├Âheren Programmiersprachen ist das Konzept der Funktion oder Prozedur bekannt. Der Vorteil dieses Konzeptes gegen├╝ber einem goto besteht darin, dass die Prozedur von jeder beliebigen Stelle im Programm aufgerufen werden kann und das Programm anschlie├čend an genau der Stelle fortgesetzt wird, die nach dem Prozeduraufruf folgt. Die Prozedur selbst muss nicht wissen, von wo sie aufgerufen wurde und wo es hinterher weiter geht. Das geschieht irgendwie automatisch. Aber wie?

Die L├Âsung besteht darin, dass nicht nur die Daten des Programms, sondern auch das Programm selbst im Hauptspeicher liegt und somit zu jeder Maschinencodeanweisung eine eigene Adresse geh├Ârt. Damit der Prozessor ein Programm ausf├╝hrt, muss sein Befehlszeiger auf den Anfang des Programms zeigen, also die Adresse der ersten Maschinencodeanweisung in das spezielle Register des Befehlszeigers (instruction pointer eip) geladen werden. Der Prozessor wird dann den auf diese Weise bezeichneten Befehl ausf├╝hren und im Normalfall anschlie├čend den Inhalt des Befehlszeigers um die L├Ąnge des Befehls im Speicher erh├Âhen, so dass er auf die n├Ąchste Maschinenanweisung zeigt. Bei einem Sprungbefehl wird der Befehlszeiger nicht um die L├Ąnge des Befehls, sondern um die angegebene relative Zieladresse erh├Âht oder erniedrigt.

Um nun eine Prozedur oder Funktion (in Assembler dasselbe) aufzurufen, wird zun├Ąchst einmal wie beim Sprungbefehl verfahren, nur dass der alte Wert des Befehlszeigers (+ L├Ąnge des Befehls) zuvor auf den Stack geschrieben wird. Am Ende der Funktion gen├╝gt dann ein Sprung an die auf dem Stack gespeicherte Adresse, um zu dem aufrufenden Programm zur├╝ckzukehren.

Beim 80x86 erfolgt das Speichern der R├╝cksprungadresse auf dem Stack implizit mit Hilfe des call-Befehls. Genauso f├╝hrt der ret-Befehl auch implizit einen Sprung an die auf dem Stack liegende Adresse durch:

; ----- Hauptprogramm -----
;
main:  ...
       call f1
xy:    ...



; ----- Funktion f1
f1:    ...
       ret

Wenn die Funktion Parameter erhalten soll, werden diese ├╝blicherweise ebenfalls auf den Stack geschrieben, nat├╝rlich vor dem call-Befehl. Hinterher m├╝ssen sie nat├╝rlich wieder entfernt werden, entweder mit pop, oder durch direktes Umsetzen des Stackpointers:

      push eax     ; zweiter Parameter fuer f1
      push ebx     ; erster Parameter  fuer f1
      call f1
      add esp,8    ; Parameter vom Stack entfernen

Um innerhalb der Funktion auf die Parameter zugreifen zu k├Ânnen, wird ├╝blicherweise der Basepointer ebp zu Hilfe genommen. Wenn er gleich zu Anfang der Funktion gesichert und dann mit dem Wert des Stackpointers belegt wird, kann der erste Parameter immer ├╝ber [ebp+8] und der zweite Parameter ├╝ber [ebp+12] erreicht werden, unabh├Ąngig davon, wieviele push- und pop-Operationen seit Beginn der Funktion verwendet wurden.

f1:   push ebp
      mov  ebp,esp
      ...
      mov ebx,[ebp+8]    ; 1. Parameter in ebx laden
      mov eax,[ebp+12]   ; 2. Parameter in eax laden
      ...
      pop ebp
      ret

Fl├╝chtige und nicht-fl├╝chtige Register / Anbindung an C

Damit Funktionen von verschiedenen Stellen des Assemblerprogramms heraus aufgerufen werden k├Ânnen, ist es wichtig, festzulegen, welche Registerinhalte von der Funktion ver├Ąndert werden d├╝rfen und welche beim Verlassen der Funktion noch (oder wieder) den alten Wert besitzen m├╝ssen. Am sichersten ist es nat├╝rlich, grunds├Ątzlich alle Register, welche die Funktion zur Erf├╝llung ihrer Aufgabe ben├Âtigt, zu Beginn der Funktion auf dem Stack zu speichern und unmittelbar vor Verlassen der Funktion wieder zu laden.

Die Assemblerprogramme, die der GNU-C-Compiler erzeugt, verfolgen jedoch eine etwas andere Strategie: Sie gehen davon aus, dass viele Register sowieso nur kurzfristig verwendet werden, zum Beispiel als Z├Ąhlvariable von kleinen Schleifen, oder um die Parameter f├╝r eine Funktion auf den Stack zu schreiben. Hier w├Ąre es reine Verschwendung, die ohnehin l├Ąngst veralteten Werte zu Beginn einer Funktion m├╝hsam zu sichern und am Ende wiederherzustellen. Da man einem Register nicht ansieht, ob sein Inhalt wertvoll ist oder nicht, haben die Entwickler des GNU-C-Compilers einfach festgelegt, dass die Register eax, ecx und edx grunds├Ątzlich als fl├╝chtige Register zu betrachten sind, deren Inhalt einfach ├╝berschrieben werden darf. Das Register eax hat dabei noch eine besondere Rolle: Es liefert den R├╝ckgabewert der Funktion (soweit erforderlich). Die Werte der ├╝brigen Register m├╝ssen dagegen gerettet werden, bevor sie von einer Funktion ├╝berschrieben werden d├╝rfen. Sie werden deshalb nicht-fl├╝chtige Register genannt.