Programmiert man möglichst nahe an der „Maschine“, so versteht man die Funktion eines Betriebssystems und den Ablauf eines Programms besser als beim Einsatz einer Hochsprache. Der Einstieg in diese Materie erfordert jedoch Geduld und Übung. Wir beginnen Schritt für Schritt. Zunächst beschaffen wir einen Assembler.
Was ist das eigentlich genau? Ein Assembler ist ein Programm, das Befehlsfolgen von Assemblersprache in Maschinensprache übersetzt. Ein Programm, das in der Assemblersprache (kurz: Assembler) geschrieben wurde, wird analog einem Hochsprachenprogramm einfach als Text gespeichert und ist für die Entwickler gut lesbar. Bekannte Assembler sind Microsoft’s Assembler (MASM) oder Borland’s Assembler (TASM). Wir verwenden in diesem Tutorial zunächst den Netwide Assembler (NASM), da man ihn einfach und kostenfrei erhält. Programmbeispiele dieses Assemblers sind verstärkt anzutreffen. Die Syntax ist klar und gut verständlich.
Will man z.B. den Wert 0x41 in das Register dl (Low Byte des Registers dx) überführen, sieht die Anweisung wie folgt aus:
Maschinensprache:
B2 41 (hexadezimal)
bzw. 10110010
01000001 (binär)
Assembler:
mov dl,0x41
In
Assembler ist die Bedeutung der Anweisung für Menschen
selbstverständlich besser
verständlich als in binärem Maschinencode. Die Anweisung „mov“
ist ein
sogenanntes Mnemonic für den
Transferbefehl (move). Ein Assembler liest ein Source-Programm und
wandelt die
Mnemonics in Maschinensprache um. Bei Hochsprachen wird dieser Vorgang
durch
einen sogenannten Compiler durchgeführt. Ein Assembler funktioniert
jedoch viel
einfacher als ein Compiler, denn jedes Mnemonic entspricht direkt einem
Befehl
in Maschinensprache.
Prozessoren gibt es in PCs (z.B. Intel, AMD), aber auch in
Microcontroller (z.B. Atmel, Pic). Jeder Prozessortyp verfügt über
einen spezifischen Befehlsvorrat,
so dass die Maschinensprache sich in Abhängigkeit von der eingesetzten
CPU
stark unterscheidet. Hier sind Hochsprachen bezüglich der Portabilität
im Vorteil.
Aus diesem Grund wird Assemblercode häufig auch in Kombination mit
einer
Hochsprache eingesetzt. Es lohnt sich auf jeden Fall, sich mit den nur
durch die Maschine selbst begrenzten Möglichkeiten der
„Maschinensprache“ zu
beschäftigen. Die geistige und praktische Wanderung entlang der
Entwicklungsgeschichte der PC’s auf Basis der Intel 80x86-Familie
stellt einen
interessanten historischen Lehrpfad von 1978 bis heute dar. Sehr
interessant ist auch die Programmierung von Microcontroller in
Assembler.
Nun wenden wir uns der Praxis am PC zu. Ich gehe davon aus, dass Sie ein PC-Betriebssystem wie MS DOS oder MS Windows mit einer Konsole („DOS Box“) für unsere Experimente zur Verfügung haben.
Besorgen Sie sich bitte auf diesen Seiten die Programme und die Dokumentation, die wir für NASM benötigen:
Übersicht über NASM Downloads: http://sourceforge.net/project/showfiles.php?group_id=6208
Download der Version NASM 0.98.36 (MS DOS, 16 bit): http://prdownloads.sourceforge.net/nasm/nsm09836.zip?download
Sie sollten nun über die Programme nasm.exe und ndisasm.exe verfügen, die wir für erste Gehversuche im 16-bit-DOS einsetzen. Als Editor für unseren Sourcecode verwenden wir z.B. unter MS Windows den Texteditor notepad.exe (im Zubehör). Erstellen Sie folgenden Sourcecode und speichern Sie die Textdatei unter bsp1.asm (die Endung txt bitte entfernen).
Nehmen wir an, Sie haben die Dateien nasm.exe,
ndisasm.exe
und unser kleines Beispiel im gemeinsamen Unterverzeichnis
C:\ASS\NASM\Beispiele abgelegt, dann erfolgt das Assemblieren wie
folgt:
nasm sourcecode
nasm sourcecode -o ausführbare_datei
Als Resultat erhält man auf diese Weise die ausführbare Datei bsp1.com, die nach dem Starten den Text „Hello, World!“ am Bildschirm ausgibt. Der klassische Einstieg! Man kann auch einfach „nasm bsp1.asm“ eingeben. Daraufhin erhält man ein File namens „bsp1“, das man zur Ausführung zuerst nach „bsp1.com“ umbenennen muss. Dieses Umbennen erledigt die Anweisung „-o“ beim Assemblieren.
Die Anweisung org 0x100 ist an NASM gerichtet und bedeutet, dass das Programm ab der Speicheradresse 0x100 (dezimal: 256) geladen werden soll. Diesen Wert geben wir an, weil com-Files generell an diese Adresse geladen werden. Der erste Befehl ist mov (move), der eine Information von der Quelle zum Ziel transferiert. Die Anordnung der Parameter erfolgt gemäß der Intel-Syntax, also zuerst das Ziel, dann die Quelle.
Anschließend verwenden wir sogenannte interrupt service routines (ISR) des Betriebssystems MS DOS, die uns in der „DOS-Box“ zur Verfügung stehen. Diese Software-Interrupts funktionieren analog zu Hardware-Interrupts. Man muss diese nur selbst über die Anweisung INT … auslösen. MS-DOS ISR werden mit INT 0x21 ausgelöst. Einen Überblick erhält man hier:
http://spike.scu.edu.au/~barry/interrupts.html
Nachfolgend finden Sie die Beschreibung unserer beiden Beispiele:
AH
= 09h : DISPLAY STRING
Display
string of characters found in memory starting at the address given by
[DS:DX]
and ending with a "$" (an ASCII$ string);
the
"$" character is not displayed.
AH
= 4Ch : TERMINATE PROGRAM WITH ERRORLEVEL
Terminate
program execution and return an "errorlevel" code equal to the value
in the AL register; a value of 00h normally is used to indicate a
normal
(non-abort) program termination. For .COM-style programs (only) an
alternate
method of program termination is with a different interrupt, "int
20h"; note that this method only works properly with .COM programs and
does not allow for a return code value.
Bei der ISR “DISPLAY STRING” übergeben wir die Adresse unseres Strings an das Daten-Register DX.
db bedeutet define byte.
message db
"Hello, World!",0x0D,0x0A,"$"
message steht hier als Symbol für die Adresse unseres Textstrings, 0x0D,0x0A sind die ASCII-Werte der Kombination „carriage return / line feed“, und das $-Zeichen wird von unserer MS-DOS-Routine als Abschluß des Strings erwartet.
Durch die direkte Zuordnung zwischen Maschinensprache und Assembler kann man den Prozess des Assemblierens auch umkehren. Das nennt man Disassemblieren. Unser diesbezügliches Werkzeug ist ndisasm.exe. Wir wenden dies zur Übung auf unser Programm bsp1.com an, und erhalten folgendes Bild:
Wir erkennen, das dies mit unseren Anweisungen gut klappt. Der Message-Bereich wird allerdings sinnfrei als „Befehlsfolge“ interpretiert. Man sieht, dass die Speicheradresse 0x10B in das Register DX übertragen wird. Das kommt daher, dass wir org 0x100 als Startadresse angegeben haben. Unser String beginnt ab der Offset-Speicherstelle 0x00B, zusammen ergibt dies 0x10B. Auf diese Weise wird die Adresse unserer Daten gefunden.
Ändern Sie versuchsweise die org-Anweisung ab auf org 0x101, dann erhalten Sie als Ausgabe „ello, World!“
Schauen Sie sich das anschließend genau mit der Anweisung „ndisasm bsp1.com“ an. Nun wird die Adresse 0x10c nach DX übertragen. Das ist erfolgt, da wir NASM angewiesen haben, unser Programm ab 0x101 zu beginnen. 0x101 und 0x00B ergibt 0x10C. Ein com-File wird aber nach 0x100 (und nicht nach 0x101) geladen. Damit zeigt unsere Adresse ein Zeichen zuweit nach hinten. Sie sehen, wie wichtig in diesem Fall die korrekte Angabe org 0x100 ist.
Merke:
ORG legt
die “origin address” fest, an der NASM den Beginn des Programms
erwartet,
nachdem es in den Speicher geladen wurde.
Dieses Beispiel ist mit seinen 27 Byte bereits recht übersichtlich, aber wir wollen es weiter vereinfachen, damit die Zusammenhänge noch klarer werden:
Noch weniger Sourcecode! Erzeugen Sie das entsprechende com-File bitte mittels nasm bsp0.asm -o bsp0.com.
Informieren Sie sich über die verwendete DOS ISR, z.B. an folgender Stelle:
http://www.ctyme.com/intr/rb-2554.htm
Man überführt den Zeichencode in das Datenregister dl, gibt diesen aus und beendet das Programm.
Der ASCII-Zeichencode 0x41 (siehe: http://de.wikipedia.org/wiki/ASCII-Tabelle) wird bei der Ausgabe in das große A umgewandelt. Wenn wir unser Endprodukt bsp0.com wieder disassemblieren, finden wir unseren denkbar einfachen Code:
Schauen Sie sich das com-File an. Es ist 10 Byte groß. Sie kennen jedes dieser Bytes auf Maschinenebene mit Unterstützung des Disassemblers.
Dies sind die Bytes in kompakter Hexadezimal-Schreibweise:
B2
41 B4 02 CD 21 B4 4C CD 21
In Binärform (Maschinensprache) sieht unser Programm damit wie folgt aus:
10110010
01000001
10110100
00000010
11001101
00100001
10110100
01001100
11001101
00100001
Klarer geht es nicht mehr. Damit sind die ersten Grundlagen im Umgang mit Assemblersprache und NASM gelegt. Erstellt man Programme z.B. wie hier für das Betriebssystem MS DOS, so muss man sich verdeutlichen, dass man hier mit völlig unterschiedlichen Bestandteilen arbeitet: Hardware, BIOS und Betriebssystem. Als Speichermodell verwendet man in diesem Fall zunächst den sogenannten Real Mode.
Ab dem Jahre 1978 vermarktete Fa. Intel den Intel-Prozessor 8086 der inzwischen legendären 80x86-Familie. Seine 16-Bit-Architektur war Ende der siebziger Jahre des letzten Jahrhunderts relativ fortschrittlich. Durch den 20-Bit-Adressbus konnte man mit Hilfe eines Tricks insgesamt 1 MB Speicher ansprechen. Hierbei geht es darum, diese 20 Bit mit16 Bit breiten Registern darzustellen.
Der Wert eines 16-Bit-Segmentregisters wurde
hierbei um 4 Bit
nach links geschoben (entspricht Multiplikation mit 16, hexadezimal
wird
einfach eine 0 angehängt) und dann ein 16-Bit-Offset eines
Vielzweckregisters
addiert. Diese Art der Speicheradressierung nennt man Real Mode. Aus
Gründen
der Kompatibilität wurde dies bis zum Pentium II erhalten.
Ab dem Intel
80286,
der im IBM PC AT
eingesetzt wurde, gab es dann zusätzlich den sogenannten
Protected
Virtual Adress Mode, kurz Protected Mode,
der eine 32 Bit Adressierung mit
32-Bit-Registern bis zu 4 GB Speicher erlaubt.
Zunächst ist ein grundlegendes Verständnis der CPU, der Register und der Adressierung notwendig:
http://www.inf.hs-anhalt.de/Service/Assembler/Register8086.htm
Merke:
Akkumulator AX
Basisregister BX
Zählregister CX
Datenregister DX
Die Befehlsübersicht des Intel 8086 findet man an vielen Stellen, z.B. hier:
http://www.inf.hs-anhalt.de/Service/Assembler/Assemblerbefehle8086.htm
Bisher wurde die Ausgabe eines Textstrings und eines Zeichens realisiert. Wichtig ist auch die Eingabe von Zeichen über die Tastaur. Dafür stehen unter MS DOS natürlich ebenfalls Routinen zur Verfügung. Wir schauen zunächst bei INT 0x21:
AH = 01h : KEYBOARD INPUT AND DISPLAY
Wait for
keyboard character; echo character on display; return ASCII code for
character
in AL. Terminate program if Ctrl-C or Ctrl-Break is typed.
Wir verwenden folgenden Sourcecode für unser Programm bsp2.asm:
;bsp2.asm
Das funktioniert doch schon sehr gut. Mit dem unter MS DOS bekannten CTRL C kann man der Schleife entfliehen und das Programm beenden. Wenn wir die heute eher gebräuchliche ESC-Taste zusätzlich zum Abbruch zulassen wollen, benötigen wir eine Kontrollstruktur, in der wir das eingegebene Zeichen
mit dem ASCII-Wert der Escape-Taste vergleichen.
In einer Hochsprache wie C / C++ ist eine if / then - Struktur einfach zu verstehen. Im Assembler-Stil sieht das leider etwas verworrener aus, aber das ist alles Gewöhnung (Pseudo-Code):
if (Bedingung) goto L2
L1: ...
... ; Bedingung nicht erfüllt (false)
goto L3
L2: ...
... ; Bedingung erfüllt (true)
L3: ... ; Hier geht es anschliessend weiter
Zunächst schreiben wir das obige Programm etwas um, damit wir auch andere ISR von MS DOS erproben können. Wir zerlegen die Tastenabfrage und die Darstellung am Bildschirm in zwei Schritte:
; bsp3.asm
org
0x100
mov
dx,message
mov ah,9
int 0x21
keyboard:
mov ah,8 ; Zeichencode ->
al
int 0x21
mov dl,al
cmp al,0x1b ;
ESC-Taste?
je ende
mov ah,2 ; dl -> Anzeige
int 0x21
jmp keyboard
ende:
mov ah,0x4C
int 0x21
message db "Bitte
Text
eingeben:",0x0D,0x0A,"$"
Die entscheidende Stelle, an der wir in unserem Programm abfragen, ob die ESC-Taste gedrückt wurde, ist der folgende Zweizeiler:
cmp al,0x1b ;
ESC-Taste?
je ende
JE bedeutet „jump if equal“. Das Programm springt zur angegebenen Zielmarke, wenn das Zero-Flag gesetzt ist. JE ist übrigens identisch mit JZ („jump if zero“).
Hier noch einmal eine Übersicht über die bedingten Sprungbefehle des 8086:
JA
/ JB |
Sprung
nach Vergleich vorzeichenloser Zahlen (above / below) |
JAE /
JBE |
(above or equal / below or equal) |
JG
/ JL |
Sprung
nach Vergleich von Zahlen mit Vorzeichen (greater / less) |
JGE /
JLE |
(greater or equal / less or equal) |
JE
/ JNE |
Sprung
in Abhängigkeit von ZF (equal / not equal) |
JZ
/ JNZ |
Sprung
in Abhängigkeit von ZF (zero /not zero) |
JC
/ JNC |
Sprung
in Abhängigkeit von CF |
JO
/ JNO |
Sprung
in Abhängigkeit von OF |
JP
/ JNP |
Sprung
in Abhängigkeit von PF |
JS
/ JNS |
Sprung
in Abhängigkeit von SF |
JCXZ |
Sprung,
wenn CX gleich Null |
Zunächst schreiben wir ein Programm, das den Zeichenvorrat unseres PC ausgibt. Dafür benötigen wir eine einfache Schleife. Das kennen Sie im Prinzip schon:
; bsp4.asm
org
0x100
L1: mov
dl,0x20
L2:
mov ah,0x02 ;
bringt den Inhalt von dl zur Anzeige
int 0x21
inc
dl ; erhöht dl um 1 (increase)
cmp dl,0xFF
je L1
jmp L2
Als Abkürzung für die anzuspringenden Labels verwenden wir hier L1, L2, ..., man findet auch M1, M2, ... Wir geben hier in bsp4.com sich wiederholend die Zeichen von ASCII-Code 0x20 bis 0xFF aus. Wenn Sie gerne akustisch verwöhnt werden wollen, können Sie die 0x20 durch 0x00 ersetzen. Viel Spass! Da Assembler schnell ist, benötigen wir für bestimmte Operationen eine einstellbare Warteschleife, wie z.B. hier bei der Bildschirmausgabe, denn wir wollen die einzelnen Zeichen wahrnehmen können. Wie wird dies in Assemblersprache realisiert?
;
bsp5.asm
org
0x100
L1:
mov
dl,0x00
L2:
mov
ah,0x02
;
bringt den Inhalt von dl zur Anzeige
int 0x21
call Warteschleife
; Aufruf der Subroutine
Warteschleife
inc dl
; erhöht dl um 1 (increase)
cmp dl,0xFF
je L1
jmp L2
Warteschleife:
; Unterprogramm
Warteschleife
mov bl,0xF0
M2:
mov cx,0xFFFF
M1:
dec cx
; erniedrigt cx um 1 (decrease)
jnz M1
dec bl
jnz M2
ret
;
Rücksprung
Im obigen Programm fügten wir eine Warteschleife als Subroutine (Unterprogramm) ein. Die Warteschleife besteht aus einer äußeren Schleife, die den Wert 0xF0 im Register bl in Einerschritten bis auf null erniedrigt. Zusätzlich verwenden wir eine innere Schleife, die das Register cx von 0xFFFF bis auf null durchzählt.
Die Länge der Warteschleife stellt man in bl ein. Nun kann man die Zeichen schon etwas geruhsamer betrachten. Wir haben uns nun auch getraut, von Zeichencode 0x00 beginnend auszugeben. Jetzt sollte die „Bell“ erträglich sein.
Sie sehen an diesem einfachen Beispiel, wie man ein Unterprogramm mit call aufruft. Die Rückkehr zum aufrufenden Programmteil erfolgt durch ret.