| Lehre |
Ziel dieses Crashkurses ist es, einen Überblick über die Assembler-Programmierung zu geben, insbesondere für die Teilnehmer von BS1, die noch keine Assemblerkenntnisse besitzen.
Wir bilden uns nicht ein, daß Ihr am Ende komplexe Assemblerprogramme schreiben könnt, aber das braucht Ihr schließlich auch nicht. Wir hoffen aber, daß 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 Stubs Verwendung findet.
Den "Rahmen" eines Assemblerprogramms erklären wir hier nicht, den schaut Ihr Euch am besten an einer Assemblerdatei ab.
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
000001011110100000000011schreiben zu müssen, kann der Programmierer die Assembleranweisung
add ax,1000verwenden, 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.
summe = a + b + c + d;ist für einen Assembler zu kompliziert und muß 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]
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: ...
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.
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 | Datasegment |
| ss | Stacksegment |
| es | beliebiges Segment |
| fs | beliebiges Segment |
| gs | beliebiges Segment |
| Sonstige Register | |
|---|---|
| Name | Bemerkung |
| eip | Instruction Pointer |
| ef | Flags |
Die unteren beiden Bytes der Register eax, ebx, ecx und edx haben eigene Namen, beim eax Register sieht das so aus:
Meistens reichen die Register nicht aus, um ein Problem zu lösen. In diesem Fall muß 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, muß 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]
...
| ![]() |
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 vier Bytes breit dargestellt.
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:
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 1 ist das nicht nötig (sondern sogar falsch), weil Stubs im Protected Mode läuft und die Segmentregister von uns bereits für Euch initialisiert wurden.
Aus den höheren Programmiersprachen ist das Konzept der Funktion oder Prozedur bekannt. Der Vorteil dieses Konzeptes gegenüber einem goto besteht darin, daß 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 muß nicht wissen, von wo sie aufgerufen wurde und wo es hinterher weiter geht. Das geschieht irgendwie automatisch. Aber wie?
Die Lösung besteht darin, daß 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, muß sein Befehlszeiger auf den Anfang des Programms zeigen, also die Adresse der ersten Maschinencodeanweisung in das spezielle Register Befehlszeiger (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 daß 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 daß 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
Die Assemblerprogramme, die der GNU C Compiler erzeugt, verfolgen jedoch eine etwas andere Strategie: Sie gehen davon aus, daß 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, daß 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.