OOStuBS - Technische Informatik II (TI-II)
2.4
|
Ziel dieser Seite ist es, insbesondere für die Teilnehmer von Technische Informatik II, die noch keine Assemblerkenntnisse besitzen, einen Überblick über die Assembler-Programmierung zu geben. Wir bilden uns nicht ein, dass ihr am Ende komplexe Assemblerprogramme schreiben könnt, aber das wird auch nicht nötig sein. Wir hoffen jedoch, dass ihr auf diese Weise zumindest eine gewisse Vorstellung davon erhaltet, wie ein Hochsprachenprogramm in Assembler aussieht und dass ihr bei entsprechender Hilfestellungen auch selbst kleine Funktionen in Assembler 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.
Heutige Tabletts und Smartphone verwenden jedoch meist andere Prozessoren.
Den Rahmenaufbau eines Assemblerprogramms werden wir hier nicht erklären, den schaut ihr euch am besten an einer Assemblerdatei ab.
Ein Assembler ist genau genommen 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) eine Assembleranweisung genau einer Maschinensprachenanweisung entspricht. Das Assemblerprogramm ist also nur eine für Menschen 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 ax,1000
ist dann also eine Assembleranweisung.
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:
Beispiele:
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: 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 die folgenden 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 |
Die unteren beiden Bytes der Register eax
, ebx
, ecx
und edx
haben eigene Namen, beim eax
Register sieht das so aus:
31 | 15 | 7 | 0 | ||||
al | |||||||
ah | |||||||
ax | |||||||
eax |
Name | Bemerkung |
---|---|
cs | Codesegment |
ds | Datasegment |
ss | Stacksegment |
es | beliebiges Segment |
fs | beliebiges Segment |
gs | beliebiges Segment |
Name | Bemerkung |
---|---|
eip | Instruction Pointer |
ef | Flags |
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 Programmierer 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.
Der im Folgenden abgebildete Speicher ist pro Zelle ein Byte groß. Jedes Byte hat jeweils die gleiche Höhe und die Breite. Ist eine Zelle doppelt so hoch wie eine andere, so belegt sie auch doppelt so viel Speicher.
niedrigste Adresse | ||
gruss: | 'h' | |
'e' | ||
'l' | ||
'l' | ||
'o' | ||
',' | ||
' ' | ||
'w' | ||
'o' | ||
'r' | ||
'l' | ||
'd' | ||
'\n' | ||
'\0' | ||
unglueck: | 13 | |
million: | 1000000 | |
höchste Adresse |
Nicht immer will man sich ein neues Label ausdenken, nur um kurzfristig mal den Wert eines Registers zu speichern, beispielsweise, weil man dieses Register für eine bestimmte Anweisung benötigt, den alten Wert aber nicht verlieren möchte. In diesem Fall wünscht man sich so 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 vier Bytes breit dargestellt. Oben sind die niedrigen Adressen, unten die hohen.
eax=10, ebx=47 | eax=7, ebx=47 | eax=7, ebx=40 | eax=7, ebx=47 | ||||
---|---|---|---|---|---|---|---|
47 | ← esp | 47 | |||||
10 | ← esp | 10 | 10 | ← esp | |||
← esp | |||||||
———————→push %eax mov $7, %eax | ———————→push %ebx sub %eax, %ebx | ———————→pop %ebx |
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:
mov edi, ebx
mov $1000, ebx
mov 1000, ebx
mov (eax), ebx
mov 10(esi), eax
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 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 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:
vor call f1 | nach call f1 | nach ret | ||||||
---|---|---|---|---|---|---|---|---|
main: | ... | main: | ... | main: | ... | |||
call f1 | ← eip | call f1 | call f1 | |||||
xy: | ... | xy: | ... | xy: | ... | ← eip | ||
f1: | ... | f1: | ... | ← eip | f1: | ... | ||
ret | ret | ret | ||||||
xy | ← esp | xy | ||||||
← esp | ← esp | |||||||
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:
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 8(ebp)
und der zweite Parameter über 12(ebp)
erreicht werden, unabhängig davon, wie viele push
und pop
Operationen seit Beginn der Funktion verwendet wurden.
Bei der folgenden Darstellung wird angenommen: ebx = 47.
Die niedrigste Adresse ist oben, die höchste unten.
47 | ← esp | ||
alter ebp | ← ebp ← esp | alter ebp | ← ebp |
Rücksprungadr. | Rücksprungadr. | ||
1. Parameter | ← ebp+8 | 1. Parameter | ← ebp+8 |
2. Parameter | ← ebp+16 | 2. Parameter | ← ebp+16 |
————————→push ebx |
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 bei Verlassen der Funktion noch - oder wieder - den alten Wert besitzen müssen. Am sichersten ist es natürlich, grundsätzlich alle Register, die 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.