Assembler-Programmierung für x86-Prozessoren/ Unterprogramme und Interrupts



Der Stack

Bearbeiten

Bevor wir uns mit Unterprogrammen und Interrupts beschäftigen, müssen wir uns noch mit dem Stack (dt. „Stapel“) vertraut machen. Der Stack ist ein wichtiges Konzept bei der Assemblerprogrammierung.

Dazu stellen wir uns vor, wir wären Tellerwäscher in einem großen Hotel. Unsere Aufgabe besteht neben dem Waschen der Teller auch im Einräumen derselbigen ins Regal. Den ersten Teller, den wir dort stapeln, bezeichnen wir als Teller 1, den nächsten als 2 usw. Wir nehmen an, dass wir 5 Teller besitzen.

Wenn wir nun wieder einen Teller benötigen, nehmen wir den obersten Teller vom Stapel. Das ist bei uns der Teller mit der Tellernummer 5. Benötigen wir erneut einen, dann nehmen wir Teller 4 vom Stapel – und nicht etwa Teller 1, 2 oder 3. Beim nächsten Waschen stellen wir zunächst Teller 4 und anschließend 5 (oder umgekehrt) auf den Stapel. Wir nehmen dabei immer den obersten Teller und nie einfach einen Teller aus der Mitte.

Das Konzept bei einem Stapel des Prozessors ist ganz ähnlich: Wir können immer nur auf das oberste Element des Stapels zugreifen. Ein Element können wir mit dem push-Befehl auf den Stapel legen. Mit dem pop-Befehl holen wir uns das oberste Element wieder vom Stapel.

Das Prinzip, dass wir das Element vom Stack holen, das wir zuletzt auf den Stapel gelegt haben, bezeichnet man als LIFO–Prinzip. Dies ist die englische Abkürzung für Last In First Out (dt. „zuletzt herein – zuerst heraus“).

Das folgende Programm legt 3 Werte auf den Stack und holt diese anschließend wieder vom Stapel:

 segment stack stack 
        resb 64h 

 segment code

   ..start:
   mov ax, 0111h
   mov bx, 0222h
   mov cx, 0333h
   push ax
   push bx
   push cx
   pop bx
   pop ax
   pop cx
   mov ah, 4Ch
   int 21h

Neben den Befehlen pop und push sind auch die ersten beiden Assembleranweisungen neu hinzugekommen. Mit diesen beiden Anweisungen wird ein Stack von 64 Byte (Hexadezimal) angelegt.

Diese Information erhält auch der Kopf der EXE-Datei. Deshalb kann das Betriebssystem den Register SP (Stack Pointer) mit der Größe des Stacks initialisieren:

AX=0111  BX=0222  CX=0333  DX=0000  SP=0064  BP=0000  SI=0000  DI=0000
DS=0D3A  ES=0D3A  SS=0D4A  CS=0D51  IP=0009   NV UP EI PL NZ NA PO NC
0D51:0009 50            PUSH    AX

Die Register SS (Stack Segment) und SP erhalten zusammen einen Zeiger, der auf das oberste Element des Stacks zeigt.

Der Push Befehl legt nun den Inhalt des Registers auf den Stack. Wir wollen uns nun ansehen, wie sich der Inhalt der Register verändert.

AX=0111  BX=0222  CX=0333  DX=0000  SP=0062  BP=0000  SI=0000  DI=0000
DS=0D3A  ES=0D3A  SS=0D4A  CS=0D51  IP=000A   NV UP EI PL NZ NA PO NC
0D51:000A 53            PUSH    BX

Beachten Sie bitte, dass sich der Stack in Richtung kleinere Adressen wächst. Daher erhalten wir als neue Zeiger für den SP-Register 0062 und beim nächsten push den Wert 0060 im SP-Register:

 -t

 AX=0111  BX=0222  CX=0333  DX=0000  SP=0060  BP=0000  SI=0000  DI=0000
 DS=0D3A  ES=0D3A  SS=0D4A  CS=0D51  IP=000B   NV UP EI PL NZ NA PO NC
 0D51:000B 51            PUSH    CX
 -t

 AX=0111  BX=0222  CX=0333  DX=0000  SP=005E  BP=0000  SI=0000  DI=0000
 DS=0D3A  ES=0D3A  SS=0D4A  CS=0D51  IP=000C   NV UP EI PL NZ NA PO NC
 0D51:000C 59            POP     BX
 -t

Anschließend holen wir die Werte wieder vom Stack. Der pop-Befehl holt den obersten Wert vom Stack und speichert dessen Inhalt im Register BX. Außerdem wird der Stack Pointer (SP-Register) um zwei erhöht. Damit zeigt er nun auf das nächste Element auf unserem Stack:

AX=0111  BX=0333  CX=0333  DX=0000  SP=0060  BP=0000  SI=0000  DI=0000
DS=0D3A  ES=0D3A  SS=0D4A  CS=0D51  IP=000D   NV UP EI PL NZ NA PO NC
0D51:000D 58            POP     AX
-t

In unserem Tellerbeispiel entspricht dies dem obersten Teller, den wir vom Stapel genommen haben. Da wir zuletzt den Wert 0333 auf den Stack gelegt haben, wird dieser Wert nun vom pop-Befehl vom Stack geholt.

Anschließend holen wir auch die anderen beiden Werte vom Stack. Der Vorgang ist analog, und sie können dies daher selbst mit Hilfe des Debuggers wenn nötig nachvollziehen.

Auch com-Programme können übrigens einen Stack besitzen. Wie sie ja bereits wissen, können diese maximal ein Segment groß werden. Daher befinden sich Code, Daten und Stack in einem einzigen Segment. Damit diese nicht in Konflikt miteinander geraten, wird der SP Register vom Betriebssystem mit dem Wert FFFE geladen. Da der Stack in Richtung negativer Adressen wächst, kommt es nur dann zu einem Konflikt, wenn Code, Daten und Stack zusammen eine Größe von mehr als 64 kB haben.

Unterprogramme

Bearbeiten

Unterprogramme dienen zur Gliederung des Codes und damit zur besseren Lesbarkeit und Wartbarkeit. Ein Unterprogramm ist das Äquivalent zu Funktionen und / oder Prozeduren in prozeduralen Sprachen wie C, Pascal oder Ada. Die Compiler prozeduraler Hochsprachen dürften fast ausnahmslos Funktionen bzw. Prozeduren in Assembler-Unterprogramme übersetzen (abgesehen von einigen Ausnahmen wie beispielsweise inline-Funktionen in C, mit dem man sich einen Funktionsaufruf ersparen kann – wenn es der Compiler denn für sinnvoll hält).

Bei (rein) objektorientierten Sprachen hingegen existiert kein Äquivalent zu Unterprogrammen. Eine gewisse Ähnlichkeit haben jedoch Operationen bzw. Methoden, die allerdings immer Bestandteil einer Klasse sein müssen.

Ein Unterprogramm wird wie normaler Code ausgeführt; der Code wird aber nach Beenden des Unterprogramms an der Stelle fortgesetzt, von wo das Unterprogramm aufgerufen wurde. Zum Aufrufen eines Unterprogrammes dient der Befehl call. Dieser schiebt seine Aufrufadresse auf den Stack und ruft das Unterprogramm anhand des Labels auf. Das Unterprogramm benutzt die Aufrufadresse auf dem Stack, um wieder zu der aufrufenden Stelle zurück zu springen.

Der call-Befehl hat die folgende Syntax:

  call Sprungmarke

Wie beim jmp-Befehl existieren mehrere Arten des call-Befehls: Beim near-call liegt das Sprungziel innerhalb eines Segments; beim far-call wird zusätzlich der Segmentregister herangezogen, so dass das Sprungziel auch in einem anderen Segment liegen kann. Außerdem kann der near-call-Befehl sowohl eine relative Adresse wie auch eine absolute Adresse besitzen. Die relative Adresse muss als Wert angegeben werden, wohingegen sich die absolute Adresse in einem Register oder in einer Speicherstelle befinden muss. Der far-call-Befehl hingegen kann nur eine absolute Adresse besitzen.

Dies ist alles zugegebenermaßen sehr unübersichtlich. Daher soll die folgende Tabelle nochmals eine Übersicht über die verschiedenen Arten des call-Befehls geben:

Befehl (Intel Syntax) NASM Syntax Beschreibung
call rel16 call imm Near-Call, relative Adresse
call r/m16 call r/m16 Near-Call, absolute Adresse
call ptr16:16 call imm:imm16 Far-Call, absolute Adresse
call m16:16 call far mem16 Far-Call, absolute Adresse

Die hier verwendete Schreibweise für die linke Spalte ist aus dem Intel Architecture Software Developer’s Manual Volume 2: Instruction Set Reference übernommen, die Schreibweise für die mittlere Spalte dem NASM Handbuch. Die Abkürzung rel16 bedeutet, dass der Operand eine relative 16 Bit Adresse ist, die als Zahlenwert direkt hinter dem Befehl folgt, r/m16 bedeutet, dass sich der Operand in einem der allgemeinen 16-Bit Register (AX, BX, CX, DX, SP, BP, SI, DI) oder einer 16-Bit breiten Speicherstelle befinden muss. Wie dann leicht zu erraten, bedeutet m16, dass sich der Operand nur in einer 16-Bit Speicherstelle befinden darf. Beim call-ptr16:16-Befehl besteht der Zeiger auf die Zieladresse aus einem Segmentanteil, der sich in einem der Segmentregister befindet und einem Offsetteil, der sich in einem der allgemeinen Register befindet.

Die NASM Schreibweise ist ganz ähnlich. Imm16 ist steht für immediate value und ist ein Operand, der direkt als Wert übergeben wird. Bei NASM müssen Sie lediglich angeben, ob es sich um einen far-call handelt oder nicht.

Das ganze sieht komplizierter aus als es in der Praxis ist: Die Hauptaufgabe übernimmt der Assembler, der das Label entsprechend übersetzt.

Zum Zurückkehren aus dem Unterprogramm in das Hauptprogramm wird der ret-Befehl benutzt. Er hat die folgende Syntax:

  ret [Stackanzahl]

Optional kann man den Parameter Stackanzahl angeben, der bestimmt, wie viele Bytes beim Zurückspringen von dem Stack abgezogen werden sollen. Diese Möglichkeit wird verwendet, wenn man Parameter zur Übergabe an das Unterprogramm gepusht hat und sich nach dem Rücksprung das poppen ersparen möchte (siehe weiter unten).

Auch für den ret-Befehl gibt es eine far- und eine near-Variante. Beide Varianten unterscheiden sich allerdings nur im Opcode. Der Assembler übersetzt den Befehl automatisch in die richtige Variante. Für den Programmierer ist die Unterscheidung deshalb nicht so sehr von Bedeutung. Für den Prozessor aber sehr wohl, da er wissen muss, ob er im Fall eines near-calls nur das oberste Element des Stacks in den IP Register zurückschreiben muss, oder im Fall eines far-calls die zwei obersten Elemente in die Register CS und IP. Auf jeden far-call muss deshalb ein entsprechender far-ret kommen und auf einen near-call ein entsprechender near-ret.

Beispiele für Unterprogramm-Aufrufe

Bearbeiten

Zwei Beispiele, aufbauend auf dem ersten Programm „Hello World!“ vom Beginn dieses Buchs, sollen das Prinzip illustrieren. Im ersten Beispiel steht das Unterprogramm in derselben Quelldatei wie das Hauptprogramm (ähnlich dem Beispiel oben in diesem Abschnitt). Im zweiten Beispiel steht das Unterprogramm in einer separaten Datei, die dem Unterprogramm vorbehalten ist.

Wenn das Unterprogramm nicht von mehreren, sondern nur von einem einzigen Programm verwendet wird, stehen beide Codeteile, Haupt- und Unterprogramm, besser in der selben Quelldatei. Das zeigt das erste der folgenden zwei Beispiele. Falls das Unterprogramm jedoch für mehrere Programme nützlich ist, ist es ökonomisch, es in eine eigene Quelldatei zu schreiben und diese bei Bedarf immer wieder mit einem anderen Programm zu verwenden. Das Linker-Programm verbindet die beiden Dateien zu einem gemeinsamen Programm.


1. Beispiel: Haupt- und Unterprogramm stehen in der selben Quelldatei

Aufbauend auf dem Beispiel der Exe-Datei des Hello-World-Programms ändern wir dessen Quelltext in:

 segment code
 
 ..start:
         mov ax, data
         mov ds, ax
         call print
         mov ah, 4Ch
         int 21h
 
  print: mov dx, hello
         mov ah, 09h
         int 21h
         ret
 
 segment data
  hello: db 'Hello World!', 13, 10, '$'

Assemblieren und linken Sie diesen Quellcode wie gewohnt mit Hilfe der folgenden zwei Befehle. Nennen Sie das Programm z. B. „hwrldsub“:

nasm hwrldsub.asm  -fobj -o hwrldsub.obj
alink hwrldsub.obj

(Die Warnung des Linkers alink „no stack“ können Sie in diesen Beispielen ignorieren.) Starten Sie das daraus gewonnene Programm hwrldsub.exe. Sie sehen, dass das Programm erwartungsgemäß nichts weiter macht, als „Hello World!“ auf dem Bildschirm auszugeben.

Im Quelltext haben wir den Codeteil, der den Text ausgibt, einfach an den Fuß des Codes verlagert, und diesen Block zwischen dem Unterprogramm-Namen print und den Rückkehrbefehl ret eingeschlossen. An der Stelle, wo dieser Code-Teil zuvor stand, haben wir den Befehl zum Aufruf des Unterprogramms call und den Namen des Unterprogramms print eingefügt. Der Name des Unterprogramms ist mit wenigen Einschränkungen frei wählbar.

Gelangt das Programm an die Zeile mit dem Unterprogramm-Aufruf call, wird das IP-Register zunächst erhöht, anschließend auf dem Stack gespeichert und schließlich das Programm am Label print fortgesetzt. Der Code wird weiter bis zum ret-Befehl ausgeführt. Der ret-Befehl holt das oberste Element vom Stack und lädt den Wert in den IP-Register, so dass das Programm am Befehl mov ah, 4Ch fortgesetzt wird, das heißt er ist zurück ins Hauptprogramm gesprungen.

Wir empfehlen zum besseren Verständnis eine Untersuchung des Programmablaufs mit dem Debugger. Achten Sie hierbei insbesondere auf die Werte im IP- und SP-Register.

Lassen Sie sich zur Untersuchung der Abläufe neben dem Fenster, in dem Sie mit debug das Programm Schritt für Schritt ausführen, in einem zweiten Fenster anzeigen, wie der Assembler den Code des Programms in den Speicher geschrieben hat. Dazu deassemblieren Sie das Programm hwrldsub.exe mit dem debug-Programm. Geben Sie dazu ein:

debug hwrldsub.exe
-u 

Das Ergebnis ist ein Programm-Listing. Vergleichen Sie zum Beispiel den Wert des Befehlszeigers bei jedem Einzelschritt mit den Programmzeilen des Programm-Listings.

In der Praxis wird wird man einen Codeteil, der wie in diesem Beispiel nur ein einziges Mal im Programm läuft, allerdings nicht in ein Unterprogramm schreiben; das lohnt nicht den Aufwand. In diesem Punkt ist unser Beispiel theoretisch und stark vereinfacht; es soll nur das Prinzip des Aufrufs zeigen. Lohnend, das heißt Tipparbeit sparend und Übersicht schaffend, ist ein Unterprogramm, wenn der Code darin voraussichtlich mehrmals während des Programmlaufs nötig ist.

2. Beispiel: Haupt- und Unterprogramm stehen in getrennten Quelldateien

Speichern und assemblieren Sie die Quelldatei

 extern print
 
 segment code
 ..start: 
   mov ax, daten
   mov ds, ax
   mov dx, hello
  
   call far print
  
   mov ah, 4ch
   int 21h
 
 segment daten
   hello: db 'Hello World!', 13, 10, '$'

zum Beispiel mit dem Namen mainprog, und die Quelldatei

 global print
 
 segment code
 
 print:  mov ah, 09h
         int 21h
         retf

zum Beispiel mit dem Namen sub_prog.asm

Kompilieren sie anschließend beide Programme, so dass Sie die Dateien mainprog.obj und sub_prog.obj erhalten. Anschließend verbinden (linken) Sie beide mit dem Befehl

alink mainprog.obj sub_prog.obj 

Das Ergebnis, die ausführbare Exe-Datei, trägt den Namen mainprog.exe und produziert erwartungsgemäß den Text „Hello World!“ auf dem Bildschirm. (An diesem Beispiel können Sie auch sehr gut erkennen, warum der Assembler bzw. der Linker bei Exe-Dateien die Anweisung ..start erwartet: So weiß der Linker, wie er die Dateien richtig zusammenlinken muss.)

Die Besonderheiten dieses Unterprogrammaufrufs im Unterschied zum ersten Beispiel:

  • extern print erklärt dem Assembler, dass er die Programm-Marke print in einer anderen Quelldatei findet, obwohl sie in dieser Quelldatei eingeführt (definiert) wird.
  • far im Aufruf call des Unterprogramms macht dem Assembler klar, dass sich der Code für das Unterprogramm in einer anderen Quelldatei befindet.
  • global print ist das Pendant zu extern print: Es zeigt dem Assembler, dass der Aufruf des Codeteils print in einer anderen Quelldatei steht.
  • retf, das „f“ steht für „far“, erklärt dem Assembler, dass das Ziel des Rücksprungs in einer anderen Quelldatei zu finden ist.

Diese Art des Unterprogramms ist nützlich, wenn man die Funktion des Unterprogramms in mehreren Hauptprogrammen benötigt. Dann schreibt und assembliert man die Quelldatei mit dem Unterprogramm nur einmal, bewahrt Quell- und Objektdatei gut auf und verlinkt die Objektdatei jedes gewünschten Hauptprogramms mit der des Unterprogramms.