Assemblerkurs, Teil 2

Der erste Teil dieses Kurses zeigte ausgehend von einem gegebenen Assemblerlisting den Weg über Compiler und Linker hin bis zum ausführbaren COM-Programm. Hierzu noch ein Nachtrag:
Von Jan Laitenberger gibt es den Freeware-Assembler JASMIN, der unkomprimiert nur knapp 20 KByte groß ist und speziell für den Einsatz auf dem Portfolio konzipiert wurde. Der begrenzte Funktionsumfang (keine Makros, nur 8086/88, nur COM-Programme) ermöglicht eine sehr einfache Bedienung, so daß JASMIN besonders für Einsteiger gut geeignet ist. Trotz seiner Eigenheiten (Labels müssen mit @ beginnen, Speicherzugriffe sind durch eckige Klammern kenntlich zu machen) ist dieser Assembler ein Muß für Portfolio-Fans! (Bezugsquelle: http://www.franksteinberg.de/ZIPS/JASMIN16.ZIP)

Vielleicht wartet der eine oder andere Leser bereits ungeduldig darauf, endlich die ersten 8086-Befehle (der 8088 ist bekanntlich kompatibel) zu lernen, um selbst ein Assemblerprogramm zu "stricken". Und in der Tat ist man mit einem Dutzend gelernten Befehlen schon ganz brauchbar gerüstet. Zunächst sollte der Neuling jedoch die (zugegebenermaßen etwas chaotische) Architektur des Prozessors kennenlernen:

Die Register des Prozessors

Die Hauptbeschäftigung des Prozessors ist es, Befehle und Daten aus dem Speicher zu holen, die Daten zu verarbeiten und z.B. an eine andere Stelle des Speichers zurückzuschreiben, etwa in den Grafikspeicher. Zur Zwischenspeicherung im Prozessor dienen dabei die Register. Die 14 Register des 8088, die alle 16 Bit breit sind, sollte man beim Programmieren stets vor Augen haben. Es folgt hier deshalb eine Übersicht mit knapper Erläuterung. Das 2-Zeichen-Kürzel zur Benennung eines Registers weist auf seinen bevorzugten Verwendungszweck hin, denn jedes Register besitzt besondere Fähigkeiten. Einige der dazu benötigten Spezialbefehle sind in der Übersicht aufgeführt, auch wenn der Anfänger sich darum zunächst nicht zu kümmern braucht.

1. Die vier Allzweckregister (z.B. für arithmetische und logische Operationen):
Diese Register sind am vielseitigsten einsetzbar. Die oberen bzw. unteren 8 Bits lassen sich auch getrennt verwenden, d.h. es stehen hiermit bis zu acht 8-Bit-Register zur Verfügung. Für die ersten Assembler-Gehversuche reichen die Allzweckregister bereits.
    AX = AH|AL Akkumulator        MUL, DIV
    BX = BH|BL Basisregister      XLAT
    CX = CH|CL Count-Register     LOOP
    DX = DH|DL Daten-Register     IN, OUT

2. Die Index- und Pointerregister:
Abgesehen von SP und IP dürfen auch diese Register nach Belieben verwendet werden. Insbesondere eignen sie sich aber zur indirekten Adressierung und für die sog. Stringbefehle.
    IP Instruction Pointer (Finger weg!)
    SP Stackpointer (Finger weg!) PUSH, POP
    BP Basepointer
    SI Sourceindex                LODSB
    DI Dest.index                 STOSB

3. Die Segmentregister:
Die Segmentregister haben eine spezielle Funktion, die später noch genauer erklärt wird. Nur ES steht zur freien Verfügung.
    CS Codesegment (Finger weg!)
    DS Datensegment (Vorsicht!)
    SS Stacksegment (Vorsicht!)
    ES Extrasegment               STOSB

4. Das Flag-Register:
Dieses Register, auf das nicht direkt zugegriffen werden kann, benötigt der Prozessor intern zur Speicherung von Status-Bits, den sog. Flags. Das für uns wichtigste Flag ist das Zero-Flag, welches die Gleichheit der beiden Operanden des letzten Compare-Befehls (CMP) signalisiert. Die Abfrage erfolgt mit dem bedingten Sprungbefehl JZ (jump if zero), der nur bei gesetztem Zero-Flag vollzogen wird.

Die Sache mit den Segmenten

Standardmäßig werden alle Speicherzugriffe über 16-Bit-Adressen abgewickelt, schließlich stehen uns ja auch nur 16 Bit breite Register zur Verfügung. Beispielsweise lädt der Befehl "mov al,[si]" das Register al mit dem Byte-Wert, der an der Speicherstelle steht, die durch das Register si bestimmt ist (indirekte Adressierung). Bekanntlich lassen sich mit 16 Bit aber nur 65536 Speicherplätze, sprich 64 KByte adressieren. Mit den vier Segmentregistern können vier Bereiche zu je 64 KByte aus dem gesamten Adreßraum des Prozessors (1 MByte) ausgewählt werden. Diese Segmente lassen sich in 16-Byte-Schritten plazieren und können sich auch überlappen. Für kleine Programme ist es aber oft überhaupt nicht nötig, die Segmente zu verschieben.
Wie die Namen der Segmentregister vermuten lassen, ist es vorgesehen, für Programmcode, Daten und den Stack (Zwischenspeicher für Unterprogramme) jeweils ein eigenes Segment zu spendieren. Für uns spielt das aber keine Rolle, weil das Betriebssystem bei COM-Programmen die drei Segmentregister CS, DS und SS mit dem selben Wert initialisiert. Es besteht also zum Glück kaum die Chance, einen Befehl zu verwenden, der sich nicht auf das gewünschte Segment bezieht. Allerdings muß sich ein COM-Programm deshalb mit höchstens 64 KByte begnügen, was aber für die meisten Portfolio-Programme mehr als ausreichend ist. Zudem sind compilierte Assemblerprogramme so kompakt, daß ihre Größe in der Regel nur etwa 1/10 von der des Quelltexts beträgt.

Eine handvoll Befehle

Wie gesagt läßt sich mit einigen wenigen Assemblerbefehlen bereits eine Menge (im Prinzip alles, nur nicht immer sehr elegant) anfangen. Da der Befehlssatz des 8088 zu komplex für eine komplette Betrachtung an dieser Stelle ist, beschränken wir uns auf folgende oft benötigte Befehle: MOV, CMP, JMP, JZ, ADD, SUB, IN, OUT, INT, CALL, RET. Die Wirkungsweise dieser (und anderer) Befehle läßt sich dank der Registeranzeige gut mit DEBUG erforschen. Viele Befehle erlauben nur bestimmte Adressierungsarten oder sind nicht auf alle Register anwendbar. Hier heißt es studieren oder probieren!

MOV Ziel,Quelle
Der vielseitigste und meist genutzte Assemblerbefehl ist sicherlich der MOV-Befehl (move). Er überträgt ein Datenwort z.B. aus dem Speicher in ein Register oder aus einem Register in ein anderes. Zudem unterstützt dieser Befehl verschiedene Adressierungsarten für Speicherzugriffe. Hierzu eine Auswahl an Beispielen:
    mov al,2      Lädt das Register al mit dem Wert 2
    mov al,[2]    Lädt al mit dem Byte-Wert, der an der
                  Speicherstelle 2 im Datensegment steht
    mov ax,bx     Kopiert bx nach ax
    mov al,[bx]   Lädt al mit dem Byte-Wert, der an der Speicherstelle
                  bx im Datensegment steht (indirekte Adressierung)
    mov cl,[si+3] Lädt cl mit dem Byte-Wert, der an der Speicherstelle
                  si+3 im Datensegment steht (indirekte indizierte Adr.)
    mov [7],dh    Speichert dh an der Speicherstelle 7 im Datensegment
Diese Beispiele wickeln den Datenaustausch mit dem Speicher nur über die 8-Bit-"Halbregister" ab, wodurch genau 1 Byte im Speicher angesprochen wird. Es sind aber auch 16-Bit-Speicherzugriffe möglich. Dabei ist es oft hilfreich zu wissen, daß gemäß Intel-Konvention an der angegebenen Speicherstelle das niederwertige Byte (Lowbyte) und an der darauffolgenden Speicherstelle das höherwertige Byte (Highbyte) angesiedelt ist.
Wer sich mit DEBUG einmal den Maschinencode für obige Varianten des MOV-Befehls ansieht, kann feststellen, daß wir es hier eigentlich mit verschiedenen Maschinenbefehlen zu tun haben:
    B002      MOV   AL,02
    A00200    MOV   AL,[0002]
    89D8      MOV   AX,BX
    8A07      MOV   AL,[BX]
    8A4C03    MOV   CL,[SI+03]
    88360700  MOV   [0007],DH
Besonders effizient codiert wurden die ersten beiden Zeilen: Die Befehlscodes (OP-Codes) bestehen dort aus einem einzigen Byte (B0 bzw. A0), gefolgt von einem bzw. zwei Bytes für den zweiten Parameter. Man sieht auch, daß die 16-Bit-Konstante 0002 im Format Lowbyte/Highbyte gespeichert ist.
Die anderen vier - etwas weniger gebräuchlichen - Varianten benötigen jeweils 2 Bytes für den OP-Code und bis zu zwei weitere Bytes für Konstanten.

CMP Operand1,Operand2
Wie erwähnt, dient der Befehl CMP dem Vergleich zweier (gleichberechtigter) Operanden. Intern führt der Prozessor dazu eine Subtraktion durch und setzt das Zero-Flag, falls das Ergebnis null ist.

JZ Sprungziel (jump if zero)
Dieser bedingte Sprungbefehl setzt den Instruction pointer auf die angegebene Adresse, falls das Zero-Flag gesetzt ist. Die Kombination aus CMP und JZ entspricht etwa folgender BASIC-Anweisung:
    IF Operand1=Operand2 THEN GOTO Sprungziel
Zum Befehl JZ existiert die alternative Schreibweise JE (jump if equal), die im Einzelfall zum besseren Verständnis verwendet werden kann.
Soll ein Sprung erfolgen, wenn der letzte Vergleich keine Übereinstimmung ergab, setzt man den Befehl JNZ (jump if not zero) oder JNE ein.
Beispiel:
    cmp al,27  ; ist al=27?
    jz Escape  ; Ja: Springe zur Marke 'Escape'

JMP Sprungziel
Unbedingter Sprungbefehl. BASIC-Äquivalent: "GOTO Sprungziel"

INT Nummer
Dieser Befehl ruft die durch Nummer spezifizierte Interruptroutine auf. Der INT-Befehl stellt die Schnittstelle zum Betriebssystem dar, denn sowohl BIOS als auch DOS belegen etliche Interruptvektoren mit Routinen, die von Anwendungsprogrammen genutzt werden können (und sollen). Je nach Funktion müssen zuvor diverse Parameter in bestimmten Registern abgelegt werden. Beispiel:
    mov ah,7   ; 7=Zeicheneingabe nach al
    int 21h    ; 21=wichtigster DOS-Interrupt
    mov dl,al  ; eingelesenes Zeichen
    mov ah,6   ; 6=Zeichenausgabe (dl=ASCII)
    int 21h
Übrigens lautet der Maschinencode für Int 21h 'CD 21' (dezimal 205, 33) und dürfte eine der häufigsten Bytekombinationen in DOS-Programmen darstellen.

IN Register,dx
Eine wichtige Eigenart der 80x86-Prozessorfamilie sind die Portadressen. Parallel zum Arbeitsspeicher existiert ein 64 KByte großer Adreßraum, über den Peripheriebausteine (z.B. paralleles Interface) angesprochen werden. Der Prozessorbefehl IN liest einen 8-oder 16-Bit-Wert von der Portadresse dx ein. Folgendes Beispiel übernimmt den Zustand der Statusleitungen des Druckerports in das Register al:
    mov dx,807Ah
    in al,dx ; nur dx möglich!

OUT dx,Register
Als Pendant zu IN schreibt OUT einen 8-oder 16-Bit-Wert an die Portadresse dx. IN und OUT werden benötigt, wenn am Betriebssystem "vorbeiprogrammiert" werden soll, um Hardwarezugriffe zu beschleunigen. Der Grafikmodus des Portfolio läßt sich so überhaupt erst effizient nutzen. Der nächste Kursteil wird sich damit genauer befassen.

ADD Operand1,Operand2
Addiert zum Operand1 den Operand2. Falls das Ergebnis zu groß für Operand1 ist, wird das Carry-Flag gesetzt (Abfrage z.B. über den bedingten Sprungbefehl JC - jump if carry), welches somit das im Ergebnis fehlende höchste Bit repräsentiert. Beispiel:
    mov cx,1997   ; cx=1997
    add cx,3      ; cx=2000

SUB Operand1,Operand2
Subtraktion analog zu ADD. Beispiel:
    mov bx,0605h  ; bh=6, bl=5
    sub bl,bh     ; bl=FFh
Hier wird das Carry-Flag gesetzt, was bedeutet, daß FFh als -1 zu interpretieren ist.

CALL Sprungziel und RET
Der pure Luxus: Strukturierte Programmierung in Maschinensprache durch Unterprogramme! CALL springt an den Anfang des Unterprogramms und sichert automatisch die Rücksprungadresse auf dem Stack. RET beendet das Unterprogramm, indem es die Rücksprungadresse wieder vom Stack holt, um das Programm mit dem Befehl hinter 'CALL' fortzusetzen. Ein anständiges Unterprogramm sichert zunächst alle verwendeten Register mit PUSH auf dem Stack und stellt sie am Ende mit POP wieder her (Diese Befehle sind allerdings - wie auch CALL und RET - mit etwa 20 Takten recht langsam). Beispiel:
             ...
             call PortAus
             ...
    PortAus: push dx      ; Register
             push al      ; retten
             mov dx,8078h ; Pofo-Druckerport
             mov al,0
             out dx,al
             pop al       ; umgekehrte
             pop dx       ; Reihenfolge
             ret

Weitere Prozessorbefehle
Aus Platzgründen kommen hier leider viele nützliche Befehle zu kurz. Wenigstens nicht unerwähnt bleiben sollten diese Instruktionen:
AND, OR, XOR     bitweise Verknüpfungen
INC, DEC         Operand um 1 erhöhen/vermindern
ADC, SBC         Addition/Subtr. mit Übertrag
LOOP             Schleife mit cx als Zähler
SHL,SHR ROL,ROR  Schieben und Rotieren
LODSB, STOSB     Stringbefehle

Ein Wort zur Ausführungsgeschwindigkeit

Als klassischer CISC-Prozessor geizt der 8088 nicht gerade mit den Taktzyklen, die pro Instruktion benötigt werden. Man kann mit etwa 2 bis 20 Takten pro Befehl rechnen, einige Spezialbefehle (insbes. MUL, DIV) können sogar über 100 Taktzyklen in Anspruch nehmen. Zur Erinnerung: Der Prozessortakt des Portfolio liegt standardmäßig bei 4.9 MHz.

Zwischenbilanz

Das Programmbeispiel aus dem ersten Kursteil sollte nun eigentlich nicht mehr mit Hieroglyphen zu verwechseln sein. Auch das damalige Programmende kann jetzt erklärt werden:
    mov ax,4c80h
    out dx,al
    int 21h
Die Funktion 4Ch (ah=4Ch) des DOS-Interrupts 21h beendet das Programm. Im Register al wird der Wert 80h nur benötigt, um damit den Lautsprecher per Portausgabe abzuschalten. Als Nebeneffekt (feature?) ist 80h gleichzeitig der Rückgabewert des Programms, der mit ERRORLEVEL abgefragt werden kann.

Ausblick

Der nächste Kursteil wird sich voraussichtlich mit der Ein- und Ausgabe auf dem Portfolio befassen. Dabei wird der Verwendung von Betriebssystemfunktionen der direkte Zugriff auf die Hardware gegenübergestellt. Weiterhin steht noch eine Beschreibung zu Variablen- und Konstantendefinitionen aus.

Fragen und Anmerkungen bitte an .
Eventuelle Korrekturen oder Ergänzungen werden zu finden sein auf der WWW-Seite:
http://leute.server.de/peichl/pf.htm
 



Zum 1. Kursteil
Zum 3. Kursteil
Zum Hauptmenü