Einführung in die Assemblerprogrammierung
Anhand zweier Beispielprogramme soll hier eine Einführung in die Assemblerprogrammierung gegeben werden, um den Ablauf eines Programms auf Maschinenebene besser verstehen zu können.
Zielsystem
Der Einstieg in die Assemblerprogrammierung erfolgt wegen der größeren Übersichtlichkeit auf Basis des 8086/8088-Prozessors. Das gewählte Betriebssystem ist ebenfalls u.a. wegen der Einfachheit DOS.
Intels 80x86-Prozessorfamilie
Alle aktuellen Prozessoren dieser Familie sind abwärtskompatibel. Der Pentium 4 kann sich verhalten wie ein 8086/8088-Prozessor – freilich nutzt er dann nur einen kleinen Teil seiner Fähigkeiten. Der Nachteil daran ist, dass alle 80x86-CPUs heute alte Technik enthalten.
Register der 8086/8088-CPU
Eine CPU besitzt
eigene, interne Speicher, die sogenannten Register. Sie kann auf die Daten in den Registern viel schneller zugreifen als auf die im Hauptspeicher oder im Cache. Allerdings bieten die Register noch weniger Speicherplatz als der Cache: Nur 14 Register,
die je 16 Bit aufnehmen können, finden sich auf dem Chip der 8086/8088-CPU.
Datenregister (AX, BX, CX, DX)
-
Das AX-Register (Akkumulator) dient hauptsächlich der Durchführung von Rechenoperationen. Einige Arithmetikbefehle laufen ausschließlich über dieses Register.
-
Das BX-Register wird häufig bei indirekten Speicheroperationen genutzt.
-
Das CX-Register (Count Register) dient einigen Maschinenbefehlen als Zählregister. Z.B. legt der Schleifenbefehl die Anzahl der Durchgänge im CX-Register ab.
-
Das DX-Register unterstützt das AX-Register bei der Berechnung von Zahlen, deren Ergebnis länger als 16 Bit ist.
Diese Register fungieren auch als allgemeine Register, solange sie nicht für besondere Aufgaben gebraucht werden. Die unteren und die oberen 8 Bit der Register AX, BX, CX und DX lassen sich getrennt ansprechen. Mit AL (Low) werden beispielsweise die unteren 8 Bit des AX Registers angesprochen, mit AH (High) die oberen acht.
Adressregister (CS, DS, ES, SS)
Eine spezielle Bedeutung unter den Registern haben die Segmentregister CS (Codesegmentregister), DS (Datensegmentregister), ES (Extrasegmentregister) und SS (Stacksegmentregister). Sie bilden im sogenannten Real Mode gemeinsam mit dem Offset die physikalische Adresse. Man kann sie nur dafür nutzen; sie können weder als allgemeine Register benutzt, noch direkt verändert werden.
Stackregister (BP, SP)
Während das Base Pointer-Register (BP) meistens als allgemeines Register benutzt werden kann und nur zur Adressberechnung herangezogen wird, dient das Stack Pointer-Register (SP) zusammen mit dem Stacksegmentregister SS zur Verwaltung des Stacks. Der Stack ist ein Bereich im Arbeitsspeicher, in dem Werte der Register zwischengespeichert werden können. Der Stack funktioniert nach dem sogenannten LIFO-Prinzip (last in, first out). Das bedeutet, dass der Wert, der als letztes auf den Stack gelegt wurde, auch als erstes wieder von ihm geladen werden muss - ähnlich einem Stapel oder Keller.
Spezialregister (IP)
Das Instruction Pointer-Register (IP) enthält zusammen mit dem CS-Register immer die Adresse des Speicherplatzes mit dem als nächstes auszuführenden Befehl. Dadurch "weiß" der Prozessor immer, wo er mit der Bearbeitung des Programms fortsetzen muss. Das IP-Register kann der Programmierer nicht mit einem anderen Wert füllen. Eine Änderung ist nur indirekt über Sprungbefehle oder Prozeduraufrufe möglich, wodurch das Programm an einer anderen Stelle fortgesetzt wird.
Das Flag-Register
Das Flag-Register ist ein Register von 16 Bits, die in diesem Register Flags heißen, von denen jedes eine Spezialaufgabe hat und einzeln mit einem Wert gefüllt werden kann. Hat eines den Wert 1, spricht man von einem gesetzten Bit, oder hier: Flag. Ist es 0, nennt man es gelöscht.
Das Flag-Register dient der Kommunikation zwischen Programm und Prozessor. So kann z.B. das Programm richtig reagieren, wenn unerwartet ein Ergebnis nicht in ein 16-Bit-Register passt, und der Programmierer dafür gesorgt hat, dass das Programm das dafür zuständige Flag prüft. Der Prozessor und das Programm können Flags setzen oder zurücksetzen.

-
Bit 11 - Überlaufflag / Overflow Flag (OF)
-
Bit 10 - Richtungsflag / Direction Flag (DF)
-
Bit 9 - Interruptflag (IF)
-
Bit 8 - Einzelschrittflag /Trap Flag (TF)
-
Bit 7 - Vorzeichenflag / Sign Flag (SF)
-
Bit 6 - Nullflag / Zero Flag (ZF)
-
Bit 4 - Auxiliary Flag (AF)
-
Bit 2 - Paritätsflag / Parity Flag (PF)
-
Bit 0 - Übertragsflag / Carryflag (CF)
Einige der Flags haben bei der 8086/8088-CPU noch keine Bedeutung und sind für spätere CPUs reserviert. Deshalb darf der Programmierer sie nicht verwenden.
Speicheradressierung
Die 8086/8088-CPU besitzt 20 Adressleitungen, kann also 220 Byte = 1048567 Byte = 1 MiByte adressieren. Die CPU besitzt aber aus Gründen der Abwärtskompatibilität zur 8085-CPU nur 16-Bit-Register. Damit lassen sich aber nur 216 Byte = 65536 Byte = 64 kiByte auswählen.
Intel löste das Problem, indem sich die physikalische Adresse aus den Inhalten zweier Register bilden lässt („Real Mode“ wird unter Windows nicht mehr genutzt.):

Um eine physikalische Adresse zu berechnen wird der Inhalt eines Segmentregisters um 4 Bits nach links geschoben, was einer Multiplikation mit 16 entspricht. Dazu wird der Inhalt eines Offsetregisters addiert.
Physikalische Adresse = Segmentadresse * 16 + Offsetadresse
Beispiel:
CS:IP = B010:3AC2
Inhalt des Segmentregisters * 16: B0100
Inhalt des Offsetregisters: + 3AC2
Physikalische Adresse: = B3BC2
Das erste Programm
Schritt
1: Installieren des Assemblers (und des DOS-Emulators)
Der Netwide-Assembler wird unter unter der folgenden Adresse heruntergeladen:
https://sourceforge.net/projects/nasm/files/
Anschließend wird die Zip-Datei der aktuellsten DOS-16-Bit-Version heruntergeladen und entpackt.
Die DOS-Box zur Emulation der von DOS unter 64-Bit-Betriebssystemen wird hier hier heruntergeladen: https://www.dosbox.com/
Beide Programme liegen bereits für Sie auf dem Laufwerk Q:\ bereit und müssen nur noch von dort in Verzeichnis U:\ kopiert werden.
Schritt 2: Assemblieren des ersten Programms
Anschließend wird folgendes Programm in einem Editor geschrieben (editiert):
;erstes Assemblerprogramm (Kommentar ab Semikolon)
org 100h
mov ax,5522h ;Laden von 5522 hex in das ax-Register (move)
mov cx,1234h ;Laden von 1234 in cx-Register
xchg cx,ax ;Tauschen der Inhalte von cx und ax (exchange)
mov al,0
mov ah,4Ch
int 21h ;zurück zum Betriebssystem
Gespeichern wird es unter der Bezeichnung firstp.asm.
Anschließend wird die DOS-Box gestartet und in
das Verzeichnis gewechselt, in das Sie den Assembler entpackt haben.
Dazu müssen das Arbeitsverzeichnis erst in der DOS-Box "gemounted" werden. Um eine Anleitung zu erhalten:
mount help
Nachdem in das Arbeitsverzeichnis navigiert wurde, kann das Programm übersetzt, d.h. assembliert werden. (Unter der Annahme, dass der Quellcode im selben Verzeichnis steht wie der Assembler; ansonsten muss der Pfad natürlich mit angegeben werden.):
nasm firstp.asm -f bin -o firstp.com
Mit
der Option -f
kann festgelegt werden, dass die übersetze Datei eine *.COM
ist (dies ist die Voreinstellung des NASM). Bei diesem Dateityp
besteht ein Programm aus nur einem Segment in dem sowohl Code, Daten
wie auch der Stack liegt. Allerdings können COM-Programme auch nur
maximal 64 kiB groß werden. Eine komplette Liste aller möglichen
Formate für -f
können
Sie sich mit der Option -hf
ausgeben lassen. Über den Parameter -o
legen Sie fest, dass Sie den Namen der assemblierten Datei selbst
festlegen wollen.
Das Programm kann nun ausgeführt werden, indem Sie firstp
eingeben. Da nur einige Register hin und her geschoben werden,
bekommen Sie allerdings keine Ausgabe.
Schritt 3: Analyse
Das Programm besteht sowohl aus Assembleranweisungen als auch Assemblerbefehlen. Nur die Assemblerbefehle werden übersetzt. Assembleranweisungen hingegen enthalten wichtige Informationen für den Assembler, die er beim Übersetzen des Programms benötigt.
Die
Assembleranweisung org
(origin) sagt dem Assembler, an welcher Stelle das Programm
beginnt. Wir benötigen diese Assembleranweisung, weil Programme mit
der Endung .com
immer mit Adresse 100h beginnen. Dies ist wichtig, wenn der Assembler
eine Adresse beispielsweise für Sprungbefehle berechnen muss. In
unserem sehr einfachen Beispiel muss der Assembler keine Adresse
berechnen, weshalb wir die Anweisung in diesem Fall auch hätten
weglassen können. Mit start:
wird schließlich der Beginn des Programmcodes festgelegt.
Für die Analyse des Programms der Debugger dosbox-74-debug.exe verwendet.
Das Programm wird mit debug firstp.com in den Debugger geladen.
Alle Registerinhalte werden angezeigt. Sämtliche Angaben sind in hexadezimaler Schreibweise angegeben.
---(Register Overview )---
EAX=00000000 ESI=00000100 DS=01FE ES=01FE FS=0000 GS=0000 SS=01FE Real
EBX=00000000 EDI=0000FFFE CS=01FE EIP=00000100 C0 Z0 S0 O0 A0 P0 D0 I1 T0
ECX=000000FF EBP=0000091C IOPL3 CPL0
EDX=000001FE ESP=0000FFFE 789150779---(Code Overview Scroll: up/down )---
01FE:0100 B82255 mov ax,5522
01FE:0103 B93412 mov cx,1234
01FE:0106 91 xchg cx,ax
01FE:0107 B000 mov al,00
01FE:0109 B44C mov ah,4C
01FE:010B CD21 int 21
Codesegmentregister sowie der Befehlzähler CS:IP zeigen auf den ersten Befehl in unserem Programm. Wie vorher gezeigt wurde, kann die tatsächliche Adresse über die Formel
Physikalische Adresse = Offsetadresse + Segmentadresse * 16dez
errechnet werden. Setzen wir die Adresse für das Offset und das Segment ein, so erhalten wir
Physikalische Adresse = 0x01FE * 16dez + 0x0100 = 0x1FE0 + 0x0100 = 0x20E0.
Dahinter befinden sich die Zustände der Flags.
Die aktuelle Adresse des nächsten Befehls stimmt immer mit dem Registerpaar CS:IP überein. Dahinter befindet sich der Maschinencode des Befehls. Der 1. Befehl ist drei Byte groß (zwei Hexadezimalziffern entsprechen immer einem Byte).
Mit F11 führen wir nun den ersten Befehl aus und erhalten die folgende Ausgabe:
Der BefehlEAX=00005522 ESI=00000100 DS=01FE ES=01FE FS=0000 GS=0000 SS=01FE Real
EBX=00000000 EDI=0000FFFE CS=01FE EIP=00000103 C0 Z0 S0 O0 A0 P0 D0 I1 T0
ECX=000000FF EBP=0000091C IOPL3 CPL0
EDX=000001FE ESP=0000FFFE 789150780
---(Code Overview Scroll: up/down )---
01FE:0100 B82255 mov ax,5522
01FE:0103 B93412 mov cx,1234
01FE:0106 91 xchg cx,ax
01FE:0107 B000 mov al,00
01FE:0109 B44C mov ah,4C
01FE:010B CD21 int 21
mov (move) hat dafür gesorgt, dass 5522 in das AX-Register kopiert wurde.
Der Prozessor hat außerdem den Inhalt des Programmzählers erhöht - und zwar um die Anzahl der Bytes, die der letzte Befehl im Arbeitsspeicher benötigte (also3 Bytes). Damit zeigt das Registerpaar CS:IP nun auf den nächsten Befehl.
Hat der Assembler nun wiederum den Befehl mov cx,1234 abgearbeitet, erhöht er das IP-Register wiederum um die Größe des Opcodes und zeigt nun wiederum auf die Adresse des nächsten Befehls:
---(Register Overview )---
EAX=00005522 ESI=00000100 DS=01FE ES=01FE FS=0000 GS=0000 SS=01FE Real
EBX=00000000 EDI=0000FFFE CS=01FE EIP=00000106 C0 Z0 S0 O0 A0 P0 D0 I1 T0
ECX=00001234 EBP=0000091C IOPL3 CPL0
EDX=000001FE ESP=0000FFFE 789150781
---(Code Overview Scroll: up/down )---
01FE:0100 B82255 mov ax,5522
01FE:0103 B93412 mov cx,1234
01FE:0106 91 xchg cx,ax
01FE:0107 B000 mov al,00
01FE:0109 B44C mov ah,4C
01FE:010B CD21 int 21
Der XCHG -Befehl vertauscht nun die Inhalte von Register AX und CX.
Danach wird das Programm beendet. Dazu wird über den Interrupt 21h die Betriebssystemfunktion 4Ch aufgerufen. Der Wert in AL (hier 0 für erfolgreiche Ausführung) wird dabei an das Betriebssystem zurückgegeben (er kann z.B. in Batch-Dateien über %ERRORLEVEL% abgefragt werden).
Das
Debugging
beenden Sie mit F5.
"Hello World"-Programm
Traditionellerweise wird zu Beginn ein "Hello World"-Programm entwickelt.
org 100h mov dx,zeichenkette ;Speicheradresse von „hello, world“ nach dx
mov ah,09h ;09h nach ah
int 21h ;DOS-Funktion zur Textaufgabe aufrufen mov al,0
mov ah,4Ch int 21h
section .data
zeichenkette: db 'hello, world', 13, 10, '$'
Der Debugger zeigt, dass der Assembler zeichenkette durch die Speicheradresse 0110 ersetzt hat. Mit d 01fe:0100 lässt sich der Speicherinhalt anzeigen (dump).
---(Data Overview Scroll: page up/down)---
01FE:0100 BA 10 01 B4 09 CD 21 B0 00 B4 4C CD 21 00 00 00 ......!...L.!...
01FE:0110 68 65 6C 6C 6F 2C 20 77 6F 72 6C 64 0D 0A 24 46 hello, world..$F
01FE:0120 FC C7 06 2E CD 01 00 C7 46 F6 00 00 EB 89 C7 46 ........F......F
01FE:0130 F6 9D FF 83 7E F6 9D 74 03 E9 2A 05 1E 68 A4 02 ....~..t..*..h..
01FE:0140 6A 02 FF 1E 28 CD 83 C4 06 E9 1A 05 26 80 7F 01 j...(.......&...
01FE:0150 50 74 07 26 80 7F 01 70 75 0C FF 76 FE 50 9A 0E Pt.&...pu..v.P..
01FE:0160 6F 7C 16 EB 4C 90 26 80 7F 01 44 74 07 26 80 7F o|..L.&...Dt.&..
01FE:0170 01 64 75 0C FF 76 FE 50 9A 9A 6F 7C 16 EB 32 90 .du..v.P..o|..2.
---(Code Overview Scroll: up/down )---
01FE:0100 BA1001 mov dx,0110
01FE:0103 B409 mov ah,09
01FE:0105 CD21 int 21
01FE:0107 B000 mov al,00
01FE:0109 B44C mov ah,4C
01FE:010B CD21 int 21
01FE:010D 0000 add [bx+si],al ds:[0100]=10BA
01FE:010F 006865 add [bx+si+65],ch ds:[0165]=2690
01FE:0112 6C insb
01FE:0113 6C insb
-> d 01fe:0100
COM-Programme beginnen immer erst bei der Adresse 100h (sonst kann sie das DOS-Betriebssystem nicht ausführen). Die Bytes von 0 bis FFh hat das Betriebssystem für Verwaltungswecke reserviert. Dies muss dem Assembler mit der Anweisung org
100h mitgeteilt werden, der sonst davon ausgehen würde, dass das Programm bei Adresse 0 beginnt. (Assembleranweisungen werden nicht übersetzt, sondern dienen lediglich dazu, dass der Assembler das Programm richtig übersetzen kann.)
Der Maschinencode von mov dx,010C hat eine Größe von 3 Byte, der Maschinencode von mov ah,09 und mov ah,4Ch und der beiden Interrupt-Aufrufe sind jeweils 2 Byte groß. Der Programmcode hat somit eine Größe von
11 Byte dezimal oder B Byte hexadezimal. Damit hat das letzte Byte des Befehlsteils die Adresse 10Ch. Das erste freie Byte hinter dem Programmcode hat also die Adresse 10Dh, ab dieser Adresse könnten Daten stehen. Tatsächlich beginnt der Datenbereich
erst an 110h, weil die meisten Compiler dem Speicher geradzahlige oder durch vier teilbaren Adressen zuweisen.
Das liegt daran, dass 32-Bit-Prozessoren 4 Byte gleichzeitig aus dem Speicher lesen bzw. schreiben. Wenn eine 4 Byte-Variable an einer durch vier teilbaren Speicheradresse beginnt, kann sie von der CPU mit einem Speicherzugriff gelesen werden. Beginnt die Variable an einer anderen Speicheradresse, sind zwei Speicherzyklen notwendig.
Nun hängt es vom verwendeten Assemblerprogramm und dessen Voreinstellungen ab, welcher Speicherplatz dem Datensegment zugewiesen wird. Ein „kluges“ Assemblerprogramm wird eine 16-Bit-Variable oder eine 32-Bit-Variable stets so anordnen, dass sie mit einem Speicherzugriff gelesen werden können. Nötigenfalls bleiben einige Speicherplätze ungenutzt. Wenn der Assembler allerdings die Voreinstellung hat, kein einziges Byte zu vergeuden, obwohl dadurch das Programm langsamer wird, tut er auch das. Die meisten Assembler werden den Datenbereich ab 110h beginnen lassen.
Eine weitere Anweisung in diesem Programm ist die DB - Anweisung. Durch sie wird byteweise Speicherplatz reserviert.
Die Zahl 13 dezimal entspricht im ASCII Zeichensatz dem Wagenrücklauf, die Zahl 10 dezimal entspricht dem Zeilenvorschub. Beide zusammen setzen den Cursor auf den Anfang der nächsten Zeile. Das $-Zeichen signalisiert der Funktion 9h des Interrupts 21h das Ende der Zeichenkette.
Quellen:
https://www.infobytes.de/pages/erlaeuterungen.html (04.11.2022)
Assembler-Programmierung für x86-Prozessoren – Wikibooks, Sammlung freier Lehr-, Sach- und Fachbücher (04.11.2022)
NASM (04.11.2022)
DOSBox, an x86 emulator with DOS (04.11.2022)
Guide to the DOSBox debugger (04.11.2022)