Assembler-Programmierung für x86-Prozessoren/ Druckversion

Aus Wikibooks
Assembler-Programmierung für x86-Prozessoren



Wikibooks

Einleitung

Maschinensprache

Ein Prozessor ist dazu gemacht, Programme zu verarbeiten. Dafür muss er Befehle entgegen nehmen und ausführen. Die Befehle, die ein Prozessor unterstützt, sind vom jeweiligen Hersteller festgelegt. Ihr Aufbau hängt zusätzlich noch von dem internen Aufbau des Prozessors ab. Dieser Befehlssatz ist die Maschinensprache.

Die einzige Codierung, die ein digitaler Prozessor direkt versteht, besteht aus Binärzahlen. Die Maschinensprache wird deshalb in Form binärer Zahlen gespeichert und verarbeitet. Darum bezeichnet man ausführbare Dateien im Englischen auch als "Binaries".

Als Beispielprogramm sehen Sie die ersten 8 Byte des 1. Sektors jeder Festplatte, den Beginn des so genannten „Urladeprogramms“. In der ersten Spalte steht die Speicheradresse in hexadezimaler Schreibweise, in der zweiten Spalte der Inhalt des Speicherplatzes, und zwar in binärer Darstellung:

Adr Code
0000 11111010
0001 00110011
0002 11000000
0003 10001110
0004 11010000
0005 10111100
0006 00000000
0007 01111100

Diese Programmdarstellung ist sehr unpraktisch. Die Kolonnen von Einsen und Nullen sind unübersichtlich, fehleranfällig bei der Eingabe und nehmen unverhältnismäßig viel Platz auf dem Papier ein. Deshalb ist es üblich, die gleichen Daten in hexadezimaler Darstellung aufzulisten. Dabei kann man jeweils vier Stellen einer Binärzahl zu einer hexadezimalen Ziffer zusammenfassen. Damit besteht die Darstellung eines Bytes mit acht Binärstellen nur noch aus zwei hexadezimalen Ziffern.

Beispiel gefällig? Sie sehen hier vom gleichen Programm den Anfang, und zwar nicht nur acht Byte, sondern die ersten 32 Byte:

Adr Code (Hexadezimal)
0000 FA 33 C0 8E D0 BC 00 7C 8B F4 50 07 50 1F FB FC
0010 BF 00 06 B9 00 01 F2 A5 EA 1D 06 00 00 BE BE 07

Weil die hexadezimale Schreibweise kompakter ist, hat es sich eingebürgert, wie in diesem Beispiel, in jede Zeile den Inhalt von 16 Speicherplätzen zu schreiben. Die Spalte links zeigt daher die Nummern jedes 16ten Speicherplatzes, und zwar in hexadezimaler Darstellung. Hexadezimal 0010 ist der 17te Speicherplatz; er ist in diesem Beispiel mit BF gefüllt. Anmerkung: Hexadezimal 0010 entspricht der Dezimalzahl 0016. Häufig kennzeichnet man zur besseren Unterscheidung Hexadezimalzahlen mit "h" oder tiefgestellter "(16)" am Ende oder einem führenden "0x" (Beispiel: 0x0010 = 0010h = 001016). Dezimalzahlen erhalten stattdessen eine tiefgestellte "(10)" oder ein "d" (Beispiel: 001610 = 0016d)

Der Computer versteht, durch die binäre Codierung, nur Einsen und Nullen als Befehle. Daher ist es selbst für Spezialisten extrem schwierig ein Programm in diesem Code zu verstehen oder gar zu entwerfen. Zudem haben x86-Befehle variable Längen, von einem Byte bis zu 32 Byte für einen Befehl! Weil aber nicht nur die Befehle, sondern auch alle Daten binär codiert sind, sieht man den Bytefolgen nicht einmal an, was was ist - es gibt keinen erkennbaren Unterschied zwischen Programm- und Datencode.

Vom Maschinencode zum Assembler

Um die Programmierung von Computern zu vereinfachen, kam bald die Idee auf, die Befehle in einem für Menschen besser geeigneten Textformat zu schreiben. Die Übersetzung in den Maschinencode ließ man von einem Programm erledigen - dem Assembler. Das Besondere dabei ist, dass sich ein Assemblerbefehl normalerweise eins zu eins in einen Maschinenbefehl übersetzen lässt.

Der erste Befehl „FA“ des Beispielprogramms oben ist clear interrupt-flag, auf Deutsch „Interrupt-Flag löschen“ - damit werden Unterbrechungsanforderungen anderer Hardwareeinheiten ignoriert. Aus den Anfangsbuchstaben wird die Abkürzung cli gebildet, die für einen Programmierer leichter zu merken ist als das hexadezimale FA bzw. das binäre 11111010. Solche Abkürzungen werden mnemonische Bezeichnungen, kurz: Mnemonics, genannt.

Woher kommen diese Abkürzungen? Der Hersteller des Prozessors, zum Beispiel Intel, liefert zu jedem neuen Prozessor ein umfangreiches Handbuch mit. Darin ist für jeden Befehl ein langer Name, ein Kurzname (Mnemonic) und eine Beschreibung des Befehls aufgeführt. Die Mnemonics sind möglichst kurz, um den Schreibaufwand beim Programmieren gering zu halten. So wird beispielsweise das englische Wort move (bewegen, transportieren) zu mov verkürzt, subtract (subtrahieren) zu sub usw.

Mit Mnemonics und lesbaren Registerbezeichnungen geschrieben sieht das Beispiel von oben so aus:

   0000 FA           CLI
   0001 33C0         XOR      AX,AX
   0003 8ED0         MOV      SS,AX
   0005 BC007C       MOV      SP,7C00
   0008 8BF4         MOV      SI,SP
   000A 50           PUSH     AX
   000B 07           POP      ES
   000C 50           PUSH     AX
   000D 1F           POP      DS
   000E FB           STI
   000F FC           CLD
   0010 BF0006       MOV      DI,0600
   0013 B90001       MOV      CX,0100
   0016 F2           REPNZ
   0017 A5           MOVSW
   0018 EA1D060000   JMP      0000:061D
   001D BEBE07       MOV      SI,07BE

Der Befehl CLI schaltet also Interrupts ab, der darauf folgende Befehl XOR führt eine bitweise Exclusiv-Oder Verknüpfung durch. In diesem Falle führt das dazu, dass der Inhalt des AX-Registers auf den Wert "0" gesetzt wird. MOV SS, AX bedeutet „Kopiere den Inhalt des Registers AX nach Register SS“.

Als Register bezeichnet man die wenigen Speicherplätze, die im Prozessorkern eingebaut sind. Sie werden später erläutert.

Das vollständige Urladerprogramm finden Sie hier.

Moderne Assembler-Programme erleichtern dem Programmierer die Arbeit noch weiter: Sie verstehen nämlich auch symbolische Bezeichnungen, Kommentare und Organisationsanweisungen. Der Programmierer kann sie verwenden, um seinen Code besser zu strukturieren und damit den Überblick zu behalten. Unser Beispiel könnte damit so aussehen:

         ORG     07C00             ; Startadresse des Programms festlegen
 AA_MBR  SEGMENT CODE
 
 STRT:   CLI                       ; alle INTR sperren
         XOR     AX,AX
         MOV     SS,AX
         MOV     SP,Segment AA_MBR ; Sektor von 07C00 umkopieren
 
 ; Sektor mit 512 Byte ab 07C00h umkopieren nach 00600h
         MOV     SI,SP
 ...
         JMP     STEP2             ; Sprung nach 0000:061D
 
 ; Partitionstabelle wird nach aktiver Partition abgesucht
         ORG     0000:0600
 STEP2:  MOV     SI,OFFSET PAR_TAB
 ...
 AA_MBR  ENDS
         END

Die ORG-Anweisung ganz am Anfang legt fest, ab welcher Speicheradresse das Programm während der späteren Ausführung im Arbeitsspeicher untergebracht werden soll. Sie wird nicht übersetzt, sondern stellt eine Steueranweisung für den Assembler dar. AA_MBR, STRT und STEP2 sind Sprungmarken, AA_MBR ist außerdem der Programmname. PAR_TAB ist die symbolische Bezeichnung der Adresse des Beginns der Partitionstabelle. Die Namen der Sprungmarken, des Programmnamens und der Adressbezeichnung hier wählt der Programmierer nach Belieben; sie könnten auch anders aussehen als in diesem Beispiel.

Das klingt kompliziert, erleichtert das Programmieren jedoch enorm (darauf gehen wir im Laufe des Buches noch genauer ein).

Abb. 1 – Übersetzung eines Assemblerprogramms in Maschinensprache

Ein Programm, das einen solchen Quelltext in Maschinensprache übersetzt, nennt man Assemblerprogramm oder kurz Assembler. Das englische Wort to assemble bedeutet „zusammenfügen, zusammenbauen“.

Einordnung von Assembler gegenüber anderen Programmiersprachen

Assemblersprachen zählen zur zweiten Generation der Programmiersprachen wenn der reine Maschinencode der ersten Generation entspricht. Da sie so stark von der Maschinensprache und der Prozessorarchitektur abhängen, muss man, wenn man von Assemblersprache spricht, eigentlich auch immer angeben, auf welche Prozessorfamilie man sich bezieht. Das bedeutet aber auch, dass ein Assembler-Programm, dass für eine bestimmte Prozessorfamilie geschrieben wurde, für eine andere Architektur komplett neu geschrieben werden muss.

Zur dritten Generation der Programmiersprachen zählen so genannte höhere Programmiersprachen wie Pascal oder C. Diese Sprachen sind leichter verständlich und nicht mehr auf ein bestimmtes Computersystem beschränkt, dabei jedoch noch so hardwarenah, dass man C z.B. auch gerne als "Hochsprachen-Assembler" bezeichnet. Im Optimalfall kann man den selben Quelltext für verschiedene Zielsysteme kompilieren, ohne dass man ihn ändern muss. In der Praxis sind häufig trotzdem noch kleinere Anpassungen erforderlich.

Höhere Programmiersprachen nehmen dem Programmierer darüber hinaus noch Dinge wie die Speicherverwaltung ab und bieten weitere Annehmlichkeiten wie mächtigere Sprachkonstrukte. Beispiele dafür sind z.B. Java, C#, Basic, usw. Dafür sind sie in der Ausführung meist deutlich langsamer als in Assembler oder C geschriebene Programme. Außerdem ist die direkte Hardwareprogrammierung oft nicht möglich. Pascal dagegen ist ein Hybrid, da es eingebetteten Assembler anbietet, aber auch noch höhere Abstraktionen als C bietet (z.B. automatische Speicherverwaltung für Strings/Zeichenketten).

Wofür benötigt man heute noch Assembler?

Angesichts der Begrenzung von Assemblersprachen auf eine Prozessorfamilie und der Unverständlichkeit des Codes liegt die Frage nahe: Wofür verwendet man heute noch Assembler, wo es doch komfortable Hochsprachen wie Java, C# und andere gibt?

Einige Gründe für die Verwendung von Assemblersprache:

  1. Treiberprogrammierung (wobei hier auch häufig C benutzt wird).
  2. Optimierung von (zeit-) kritischen Stellen in Programmen.
  3. Benutzung von Befehlen, die der Hochsprachen-Compiler (noch) nicht kennt.
  4. Der Programmierer hat volle Kontrolle über den erzeugten Code.
  5. Bei der Fehlersuche mit einem Debugger sind Assemblerkenntnisse sehr zu empfehlen.
  6. Kennenlernen der Arbeitsweise von Prozessor und restlichem Computersystem.
  7. Programmierung von eingebetteten Systemen.

Im Gegensatz zur weit verbreiteten Meinung ist ein Betriebssystem normalerweise nicht in Assembler, sondern weitgehend in einer Hochsprache geschrieben. Das liegt daran, dass Assembler nur dort benötigt wird, wo der direkte Hardwarezugriff unbedingt nötig ist, z.B. wenn für einen Zweck ein ganz bestimmtes Prozessorregister benutzt werden muss. Das betrifft z.B. kleine Teile der Speicherverwaltung oder den Bootloader des Betriebssystems.

Was wird für die Assemblerprogrammierung benötigt?

Um Assemblerquellcode zu übersetzen wird natürlich ein entsprechender Assembler benötigt. Hier verwenden wir NASM, den Netwide Assembler. Er kann unter DOS, Windows, Linux und OSX verwendet werden, ist freie Software und damit kostenlos und wird aktiv weiterentwickelt.

Darüber hinaus wird ein Texteditor benötigt, vorzugsweise einer, der Syntax-Highlighting für Assembler im Intel-Format unterstützt. Unter Windows würde ich Notepad++ empfehlen. Notepad++ ist ebenfalls freie Software und unterstützt Syntax-Highlighting für alle möglichen Programmiersprachen.

Unter Linux ist die Auswahl an guten Editoren sehr viel größer. Vim und Emacs sind selbstverständlich nutzbar, Nano und GEdit ebenfalls. Dazu alles Andere, was unformatierten Text speichern kann.

Den NASM gibt es hier:

Notepad++ ist hier zu finden:

Unter Linux ist es am einfachsten, einen beliebigen Editor über den jeweiligen Paketmanager zu installieren.

Weitere Informationen findet man unter anderem hier:

Welche Vorkenntnisse werden benötigt?

In diesem Buch wird davon ausgegangen, dass bereits Grundkenntnisse in einer imperativen Programmiersprache vorhanden sind. Es wird hier nicht mehr erklärt, was eine Schleife oder eine Auswahlanweisung ist. Außerdem sollten Sie den Umgang mit Linux und/oder Windows beherrschen.

Grundlagen

Zahlensysteme

In der Assemblerprogrammierung werden neben dem bekannten Dezimalsystem häufig auch das Dual- und Hexadezimalsystem verwendet, selten auch das Oktalsystem.

Die Namen der Zahlensysteme leiten sich von ihrer Basis ab, beim Dezimalsystem ist das die 10. Das bedeutet, mit einer Stelle in diesem Zahlensystem lassen sich 10 verschiedene Werte darstellen, nämlich die Zahlen 0 bis 9. Will man größere Zahlen darstellen, benötigt man weitere Stellen.

Das Dualsystem dagegen hat die Basis 2. Eine Stelle kann also zwei Werte haben, 0 oder 1. Beim Oktalsystem ist die Basis 8, beim Hexadezimalsystem 16. Da wir normalerweise nur zehn Ziffern zur Verfügung haben, werden beim Hexadezimalsystem noch die Buchstaben A bis F als zusätzliche Ziffern benutzt, durch die Basis 16 müssen wir schließlich Werte von 0 bis 15 bzw. 0 bis F pro Stelle codieren können.

Prinzipiell kann man auch Zahlensysteme zu jeder beliebigen anderen Basis benutzen.

Das Dualsystem

Da fast alle heutigen Rechner in Digitaltechnik mit zweiwertiger Logik realisiert sind, bietet sich zur anschaulichen Darstellung der internen Verarbeitungsvorgänge ein zweiwertiges Zahlensystem an, also statt der Basis 10 des Dezimalsystems ein Zahlensystem mit der Basis 2. Das zweiwertige Zahlensystem wird Dualsystem genannt und benötigt nur die Ziffern 0 und 1. Zur Unterscheidung von anderen Zahlensystemen wird oft ein b angehangen.

Nehmen wir als Beispiel die Dualzahl 111001b: Wie im Dezimalsystem hat auch hier jede Ziffer ihren Stellenwert. Während sich im Dezimalsystem der Ziffernwert mit jeder Verschiebung um eine Stelle nach links verzehnfacht, bewirkt die gleiche Verschiebung im Dualsystem eine Verdopplung. Gemäß der obigen Definition können wir die Zahl 111001b folgendermaßen darstellen, wobei die Basis nun 2 ist:


Von links nach rechts verkörpern die einzelnen Ziffern somit folgende Werte:

Zusammenaddiert ergibt sich die Dezimalzahl:

Um ein besseres Gefühl für das Dualsystem zu bekommen, wollen wir uns die Zahlen von 0 bis 7 ansehen (in Klammer ist jeweils der Dezimalwert angegeben):

0 (0)
1 (1)

Soweit ist alles wie gewohnt. Da wir aber nun keine weitere Ziffer zu Verfügung haben, müssen wir auf die nächste Stelle ausweichen:

10 (2)
11 (3)

Um die nächst höhere Zahl darzustellen, benötigen wir wiederum eine weitere Stelle:

100 (4)
101 (5)
110 (6)
111 (7)

usw.

Dies ist sicher gewöhnungsbedürftig. Mit Übung gelingt der Umgang mit Dualzahlen aber fast so gut wie im Dezimalsystem. Auch das Rechnen mit Dualzahlen funktioniert vom Prinzip her genauso wie mit Dezimalzahlen.

Jede Ziffer bei diesem System wird als „Bit“ (Abkürzung für binary digit) bezeichnet. Das Bit äußerst rechts ist das least significant bit (lsb), das niederwertigste, und das ganz linke heißt most significant bit (msb), ist also das höchstwertigste.

Und wie rechnen wir eine Dezimalzahl in eine Dualzahl um?
Ganz einfach:

Die Restwertmethode

Bei der Restwertmethode wird die Dezimalzahl so oft durch zwei geteilt, bis wir den Wert 0 erhalten. Der Rest der Division entspricht von unten nach oben gelesen der Dualzahl. Sehen wir uns dies am Beispiel der Dezimalzahl 25 an:

25 / 2 = 12 Rest 1 (lsb)
12 / 2 =  6 Rest 0 
 6 / 2 =  3 Rest 0
 3 / 2 =  1 Rest 1
 1 / 2 =  0 Rest 1 (msb)

Wichtig: Rechnen Sie beim Restwertsystem immer bis 0 herunter.
Als Ergebnis erhalten wir von unten nach oben gelesen die Dualzahl 11001b, was der Dezimalzahl 25 entspricht.

Bevor wir im Dualsystem weiterrechnen, folgt ein Exkurs in die Darstellung von Datenmengen und Speicherplatz: Jeweils 8 Bit ergeben ein Byte. Das Byte ist auf vielen Systemen die kleinste adressierbare Speichereinheit. 1024 Byte entsprechen wiederum einem Kilobyte. Diese auf den ersten Blick ungewöhnliche Zahl entspricht 210. Mit 10 Bit ist es möglich, 210 = 1024 verschiedene Zustände darzustellen. Zur Abgrenzung zu Dezimalpräfixen sollten die von der IEC festgelegten Binärpräfixe für 210-fache verwendet werden.

Weitere häufig verwendete Größen:

Größe Einheit
210 Byte (1024 Byte) Kilobyte
220 Byte (1024 Kilobyte) Megabyte
230 Byte (1024 Megabyte) Gigabyte
240 Byte (1024 Gigabyte) Terabyte

Die alte und immer noch oft zu sehende Abkürzung für 1024 Byte war KB. 1998 veröffentlichte das IEC (International Electrotechnical Commission) einen neuen Standard, nachdem 1024 Byte einem Kibibyte (kilobinary Byte) entspricht und mit KiB abgekürzt wird [Nat00]. Damit soll in Zukunft die Verwechselungsgefahr verringert werden.

Deshalb werden in diesem Buch die neuen SI Binärpräfixeinheiten verwendet: KiB als Abkürzung für 210 Byte, für 220 Byte MiB, für 230 Byte GiB und für 240 Byte TiB.

Beispiel: Der Intel 80386 arbeitet mit einem Adressbus von 32 Bit. Wie viel Speicher kann er damit adressieren?

Lösung: Mit 32 Bit lassen sich 232 verschiedene Dualzahlen darstellen, eine für jede Speicheradresse. Das heißt, der Intel 80386 kann 232 = 4.294.967.296 Byte adressieren. Dies sind 4.194.304 Kibibyte oder 4096 Mebibyte oder 4 Gibibyte. Verwendete man stattdessen die Dezimalpräfixeinheiten, so erhielte man: 4.294.967.296 B = 4.294.967,296 KB = 4.294,967.296 MB = 4,294.967.296 GB

Nun geht es wieder an das Rechnen mit Dualzahlen.

Das Zweierkomplement

Wenn wir negative Zahlen im Dualsystem darstellen wollen, so gibt es keine Möglichkeit, das Vorzeichen mit + oder – darzustellen. Man könnte auf die Idee kommen, stattdessen das Most Significant Bit (MSB, links) als Vorzeichen zu benutzen. Ein gesetztes MSB, also mit dem Wert 1, stellt beispielsweise ein negatives Vorzeichen dar. Dann sähe zum Beispiel die Zahl –7 in einer 8-Bit-Darstellung so aus: 10000111

Diese Darstellungsform hat noch zwei Probleme: Zum einen gibt es nun zwei Möglichkeiten die Zahl 0 im Dualsystem darzustellen (nämlich 00000000 und 10000000). Weil der Computer an dieser Stelle nicht klar entscheiden kann, welche Darstellung er verwenden soll, strauchelt er an dieser Stelle. Das zweite Problem: Wenn man negative Zahlen in dieser Form addiert, wird das Ergebnis falsch.

Um das klar zu sehen, addieren wir zunächst zwei positiven Zahlen, wieder in 8-Bit Breite (in Klammern steht der Wert dezimal):

  00100011 (35)
+ 00010011 (19)
Ü      11  
---------------
  00110110 (54)

Die Addition im Binärsystem ist der im Dezimalsystem ähnlich: Im Dualsystem gibt 0 + 0 = 0, 0 + 1 = 1 und 1 + 0 = 1. Soweit ist die Rechnung äquivalent zum Dezimalsystem. Da nun im Dualsystem aber 1 + 1 = 10 ergibt, findet bei 1 + 1 ein Übertrag statt, ähnlich dem Übertrag bei 9 + 7 im Dezimalsystem.

In der Beispielrechnung findet ein solcher Übertrag von Bit 0 (ganz rechts) auf Bit 1 (das zweite von rechts) und von Bit 1 auf Bit 2 statt. Die Überträge sehen Sie in der Zeile Ü.

Wir führen nun eine Addition mit einer negativen Zahl genau wie eben durch und benutzen das Most Significant Bit als Vorzeichen:

  00001000  ( 8)
+ 10000111  (–7)
Ü 
----------------
  10001111 (–15)

Das Ergebnis ist offensichtlich falsch.

Wir müssen deshalb eine andere Möglichkeit finden, mit negativen Dualzahlen zu rechnen. Die Lösung ist, negative Dualzahlen als Zweierkomplement darzustellen. Um es zu bilden, werden im ersten Schritt alle Ziffern der positiven Dualzahl umgekehrt: 1 wird 0, und umgekehrt. Dadurch entsteht das Einerkomplement. Daraus wird das Zweierkomplement, indem wir 1 addieren.

Beispiel: Für die Zahl –7 wird das Zweierkomplement gebildet:

  • 00000111 (Zahl 7)
  • 11111000 (Einerkomplement)
  • 11111001 (Zweierkomplement = Einerkomplement + 1)

Das Addieren einer negativen Zahl ist nun problemlos:

  00001000 (8)
+ 11111001 (-7, das Zweierkomplement von 7)
Ü 1111
  -------------
  00000001 (1) 

Wie man sieht, besteht die Subtraktion zweier Dualzahlen aus einer Addition mit Hilfe des Zweierkomplements. Weiteres Beispiel: Um das Ergebnis von 35 – 7 zu ermitteln, rechnen wir 35 + (–7), wobei wir einfach für die Zahl –7 das Zweierkomplement von 7 schreiben.

Eine schwer zu findende Fehlerquelle bei der Programmierung lauert hier: Beim Rechnen mit negativen Zahlen kann es nämlich zum Überlauf (engl. Overflow) kommen, d.h. das Ergebnis ist größer als die höchstens darstellbare Zahl. Bei einer 8-Bit-Zahl etwa, deren linkes Bit das Vorzeichen ist, lassen sich nur Zahlen zwischen –128 und +127 darstellen. Hier werden 100 und 50 addiert, das Ergebnis müsste eigentlich 150 sein, aber:

  01100100 (100)
+ 00110010 ( 50)
Ü 11
----------------
  10010110 (–106, falls das Programm das als vorzeichenbehaftete Zahl ansieht)

Das korrekte Ergebnis 150 liegt nicht mehr zwischen –128 und +127. Wenn wir das Ergebnis als vorzeichenbehaftete Zahl, das heißt als Zweierkomplement, ansehen, beziehungsweise, wenn der Computer das tut, lautet es –106. Nur wenn wir das Ergebnis als positive Binärzahl ohne Vorzeichen ansehen, lautet es 150. Unser Programm muss den Computer wissen lassen, ob er das Ergebnis als Zweierkomplement oder als vorzeichenlose Binärzahl ansehen muss.

Noch ein Wort zu Überläufen: Überläufe können auch bei vorzeichenlosen Rechnungen vorkommen und treten immer dann auf, wenn das Ergebnis einer Rechnung nicht mehr mit der gegebenen Anzahl Bits dargestellt werden kann. Die einzige Lösung besteht dann darin, einen Datentyp mit einer größeren Anzahl Bits, also mehr Stellen, zu verwenden.

Das Oktalsystem

Mit Dualzahlen lassen sich zwar sehr gut Rechen- und Schaltvorgänge darstellen, große Zahlen benötigen jedoch viele Stellen und sind damit sehr unübersichtlich. Kompakter ist da das Oktalsystem. Seine Basis ist 23. Somit werden 8 unterscheidbare Ziffern benötigt, genommen werden 0 bis 7. Jede Oktalziffer kann drei Dualziffern ersetzen:

dual oktal
000   0
001   1
010   2
011   3
100   4
101   5
110   6
111   7

Oktalzahlen werden oft durch eine vorangestellte 0 kenntlich gemacht: 011 beispielsweise verkörpert die dezimale 9. Die Umrechnung von Dezimal- in Oktalzahlen geht wieder mit Hilfe der Restwertmethode:

234 / 8 = 29 Rest 2  Stellenwert: 8^0
 29 / 8 =  3 Rest 5      ...      8^1
  3 / 8 =  0 Rest 3      ...      8^2

Und so geht’s von Oktal- nach Dezimalzahlen: 0352 =

2 * 8^0 =   2
5 * 8^1 =  40
3 * 8^2 = 192
-------------
Summe:    234

Das Oktalsystem hat jedoch den Nachteil, dass jede Ziffer 3 Bit ersetzt, die kleinste adressierbare Speichereinheit in der Regel jedoch das Byte mit 8 Bit Speicherinhalt ist. Somit müsste man drei Oktalziffern zur Darstellung eines Bytewertes verwenden und hätte zusätzlich noch einen Bereich mit ungültigen Zahlenwerten. Besser eignet sich hierzu das Hexadezimalsystem.

Das Hexadezimalsystem (Sedezimalsystem)

Das Hexadezimalsystem ist ein Stellenwertsystem mit der Basis 24, benötigt also 16 unterscheidbare Ziffern. Jede der 24 Ziffern kann eine 4-stellige Dualzahl ersetzen. Da jedem Bit eine Dualziffer zugeordnet werden kann und jede Hexadezimalziffer eine 4-stellige Dualzahl ersetzt, kann jeder Bytewert durch eine zweistellige Hexadezimalzahl dargestellt werden.

Da das Hexadezimalsystem 16 unterscheidbare Ziffern benötigt, werden außer den Ziffern 0 bis 9 noch die Buchstaben A bis F wie in der folgenden Tabelle genutzt. Zur Unterscheidung haben Hexadezimalzahlen oft das Präfix 0x, x oder die Endung h (z. B. 0x11, x11 oder 11h).

dezimal binär hex okt
   0    0000   0   0
   1    0001   1   1
   2    0010   2   2
   3    0011   3   3
   4    0100   4   4
   5    0101   5   5
   6    0110   6   6
   7    0111   7   7
   8    1000   8  10
   9    1001   9  11
  10    1010   A  12
  11    1011   B  13
  12    1100   C  14
  13    1101   D  15
  14    1110   E  16
  15    1111   F  17

Nun rechnen wir eine Hexadezimalzahl in eine Dezimalzahl um:

13 * 16^0 =  13
11 * 16^1 = 176
 2 * 16^2 = 512
---------------
Summe:      701





Die Umrechnung vom Dualsystem in das Hexadezimalsystem ist sehr einfach: Die Dualzahl wird dabei rechts beginnend in Viererblöcke unterteilt und dann blockweise umgerechnet. Beispiel: Die Zahl 1010111101b soll in die Hexadezimalform umgerechnet werden.

Zunächst teilen wir die Zahl in 4er-Blöcke ein:

10 1011 1101

Anschließend rechnen wir die Blöcke einzeln um:

10b = 2, 1011b = B, 1101b = D

somit ist


Wichtig: Die Viererblöcke müssen von „hinten“, also vom least significant bit (lsb) her, abgeteilt werden.

Üblich und daher auch im Assembler NASM ist die Schreibweise von Hexadezimalzahlen mit einem vorangestellten ‚0x‘ (z. B. 0x2DB) oder mit der Endung ‚h‘ (z. B. 2DBh). Wir verwenden die Schreibweise mit ‚h‘.

Der ASCII-Zeichensatz und Unicode

Ein Rechner speichert alle Informationen numerisch, das heißt als Zahlen. Um auch Zeichen wie Buchstaben und Satzzeichen speichern zu können, muss jedem Zeichen ein eindeutigen Zahlenwert zugeordnet werden.

Die ersten Kodierungen von Zeichen für Computer gehen auf die Hollerith-Lochkarten (benannt nach deren Entwickler Herman Hollerith) zurück, die zur Volkszählung in den USA 1890 entwickelt wurden. Auf diesen Code aufbauend entwickelte IBM den 6-Bit-BCDIC-Code (Binary Coded Decimal Interchange Code), der dann zum EBCDIC (Extended Binary Coded Decimal Interchange Code), einem 8-Bit-Code, erweitert wurde.

Der ASCII-Zeichensatz

1968 wurde der American Standard Code for Information Interchange (ASCII) verabschiedet, der auf einem 7-Bit-Code basiert und sich schnell gegen andere Standards wie dem EBCDIC durchsetzte. Das achte Bit des Bytes wurde als Prüf- oder Parity-Bit zur Sicherstellung korrekter Übertragungen verwendet.

Im Unterschied zum heute praktisch nicht mehr verwendeten, konkurrierenden EBCDIC-Code gehört beim ASCII-Code zu jedem möglichen Binärzeichen ein Zeichen. Das macht den Code ökonomischer. Außerdem liegen die Groß- und die Kleinbuchstaben jeweils in zusammenhängenden Bereichen, was für die Programmierung deutliche Vorteile hat, mit einem Pferdefuß: Werden Strings (Zeichenfolgen, wie etwa Wörter) nach ASCII sortiert, geraten die klein geschriebenen Wörter hinter alle mit einem Großbuchstaben beginnenden. Die lexikalische Sortierung erfordert daher etwas mehr Aufwand.

Neben den alphanumerischen Zeichen enthält der ASCII-Code noch eine Reihe von Sonder- und Steuerzeichen. Steuerzeichen sind nicht druckbare Zeichen, die ursprünglich dazu dienten, Terminals, Drucker oder andere Ausgabegeräte zu steuern. Die 128 Zeichen des ASCII-Codes lassen sich grob so einteilen (in Klammern die Nummern in hexadezimaler Notierung):

  • Zeichen 0 bis 31 (1Fh): Steuerzeichen, wie zum Beispiel Tabulatoren, Zeilen- und Seitenvorschub
  • Zeichen 48 (30h) bis 57 (39h): Ziffern 0 bis 9
  • Zeichen 65 (41h) bis 90 (5Ah): Großbuchstaben A bis Z
  • Zeichen 97 (61h) bis 122 (7Ah): Kleinbuchstaben a bis z

Leer- und Interpunktionszeichen, Klammern, Dollarzeichen usw. verteilen sich in den Zwischenräumen.

Der ASCII-Code enthält leider nur die im Amerikanischen geläufigen Sonderzeichen. Die deutschen Umlaute beispielsweise fehlen, genau wie viele andere länderspezifische Zeichen. Mit dem Erscheinen des PCs erweiterte IBM daher den ASCII-Code auf 8 Bit, so dass nun 128 weitere Zeichen darstellbar waren. Neben zusätzlichen Sonderzeichen besaß der "erweiterte ASCII-Code" auch eine Reihe von Zeichen zur Darstellung von Blockgrafiken, z.B. Linienelemente, die zur Erstellung von Menüs und Benutzeroberflächen verwendet werden konnten.

Code Pages

Mit MS-DOS 3.3 führte Microsoft die sogenannten „Code Pages“ ein. Diese erlaubten es, den Zeichen von 128 bis 255 landesspezifische Sonder- und Schriftzeichen zuzuweisen, wie z.B. das griechische Alphabet, deutsche Umlaute usw.

Die Code Pages machten jetzt zwar Sonderzeichen darstellbar, dafür gab es jetzt neue Probleme. Wenn ein Text unter Verwendung einer Code Page erstellt, jedoch unter Verwendung einer anderen angezeigt wurde, gab es Zeichensalat bei den Sonderzeichen. Ein ähnliches Phänomen gibt es auch heute noch, wenn zum Beispiel Umlaute in E-Mails oder auf Webseiten nicht korrekt dargestellt werden, weil der falsche Zeichensatz verwendet wird. Allerdings tragen hier die Codepages keine Schuld, diese sind mit DOS ausgestorben...

Unicode

Weitere Probleme tauchten auf, als es darum ging, auch japanische oder chinesische Zeichen darzustellen. Die chinesische Schrift definiert etwa 87.000 verschiedene Zeichen, von denen 3000 - 5000 im täglichen Leben benutzt werden. Diese Anzahl übersteigt den Zeichenvorrat von ASCII, Windows 1252 oder ISO-8859 (je 256 Zeichen) bei weitem!

Aus diesem Grund wurde der Unicode-Zeichensatz entworfen. Die klassische Form, UTF-16, stellt alle Zeichen als 16-Bit-Wert dar und kann damit 65536 unterschiedliche Zeichen codieren. Dazu gibt es noch UTF-32 und UTF-8. Mit UTF-32 werden 4 Bytes für die Codierung eines Zeichens verwendet und es lassen sich alle Zeichen der Welt in einem einheitlichen Zeichensatz erfassen. Leider benötigt ein UTF-32 codierter Text viermal soviel Speicherplatz wie ein ASCII codierter.

UTF-16 und UTF-32 codieren Zeichen immer mit der festen Länge von zwei oder vier Byte. Dadurch bleiben die Algorithmen zur Textverarbeitung recht einfach, dafür ist der Speichermehrbedarf erheblich, wenn nur lateinische Buchstaben genutzt werden. Aus diesem Grunde existiert UTF-8. UTF-8 codiert Zeichen mit variabler Länge. Der Zeichenvorrat von ASCII wird mit den unteren sieben Bits eines Bytes abgebildet, das MSB ist null. Ist das MSB gesetzt, leitet dies ein Multybyte-Zeichen mit zwei oder vier Byte Länge bei Bedarf ein. Unter den meisten Linuxen ist dies der standardmäßig verwendete Zeichensatz. Windows verwendet dagegen einen eigenen, als Windows 1252 oder kurz CP1252 bezeichneten Zeichensatz. Daneben werden auch häufig die ISO-8859-1 oder ISO-8859-15 Zeichensätze in der westlichen Welt genutzt. (Vergleichbar mit ASCII + Code Pages).

Im Rahmen dieses Werkes werden Zeichenketten normalerweise als ASCII oder ISO-8859/CP1252 codiert. Unterschiede gibt es dabei eh nur bei der Darstellung von Umlauten und der Vorteil der einfacheren Handhabung in Assembler überwiegt den hier fragwürdigen Nutzen von Unicode.


Der Prozessor

Die 80x86-Prozessorfamilie

Direkt nach dem Einschalten des PCs befindet sich jeder x86-Prozessor im 8086-Kompatibilitätsmodus, dem sog. Real-Mode. Deshalb beginnen wir mit der Betrachtung dieses Urahns der x86-Architektur, da alles, was seine Programmierung und Architektur betrifft, noch immer Gültigkeit besitzt.

Der 8086 und 8088

Der 8086 ist ein 16-Bit-Prozessor. Er wurde 1978 vorgestellt und basiert auf seinen 8-Bit Vorgängern, dem 8080 und 8085. Dies hat Folgen z.B. bei der Speicheradressierung oder dem Registersatz.

Der 8088 ist aus Programmierersicht identisch mit dem 8086, wurde allerdings als Low-Cost-Variante des 8086 für den Einsatz mit älterer 8-Bit-Hardware entworfen. So kann der 8086 mit einem Speicherzugriff 16 Bit Daten übertragen, der 8088 nur 8 Bit. Die Aufteilung eines 16-Bit-Speicherzugriffs auf zwei 8-Bit-Zugriffe erfolgt automatisch durch die Hardware des 8088, der Programmierer merkt davon nichts. Dadurch ist die Rechenleistung des 8088 geringer als die des 8086.

Die Register

Registersatz der 8088/86-CPU

Ein Register ist ein sehr schneller Speicherplatz im Prozessor, auf den Befehle direkt zugreifen können. Da Register relativ viel Platz auf dem Silizium belegen, besitzen grade ältere Prozessorarchitekturen meist sehr wenige davon. Je mehr Register ein Prozessor beinhaltet, desto mehr Zwischenwerte können während des Programmablaufes direkt im Prozessor gehalten werden. Dadurch sind weniger langsame Zugriffe auf den Arbeitsspeicher nötig und die Rechenleistung steigt. Die Register des 8086 sind alle 16 Bit breit, dies ist kennzeichnend für eine 16-Bit-Architektur.

Die Register können in verschiedene Typen unterteilt werden:

  • Allzweck-Datenregister sind in der Regel universell einsetzbar und nehmen Operanden auf.
  • Indexregister werden für die indirekte Adressierung verwendet, können jedoch auch wie Allzweckregister genutzt werden.
  • Stackregister werden für die Verwaltung des Stacks benötigt. Der Stack ist eine Datenstruktur, auf der der Inhalt einzelner Register wie auf einem Stapel vorübergehend abgelegt werden können. Dies ist vor allem bei Unterprogrammaufrufen oder Interrupts nützlich.
  • Spezialregister werden vom Prozessor intern benutzt und sind kaum durch den Programmierer beeinflussbar. Der Programmierer kann sie meist nur indirekt ändern.
  • Segmentregister spielen eine besondere Rolle bei der Speicherverwaltung. Ihre Funktion wird im Abschnitt zur Speicheradressierung beschrieben.
Die Allzweckregister

Der 8086 ist mit vier Allzweckregistern ausgestattet.

  • AX - Das Akkumulator-Register
  • BX - Das Basis-Register
  • CX - Das Counter-Register
  • DX - Das Data-Register

Eine Besonderheit dieser Register ist, dass sie auch als Teilregister mit je 8 Bit benutzt werden können. So lässt sich das Low-Byte von AX, also die Bits Null bis Sieben, als AL ansprechen, das High-Byte heißt AH. Das funktioniert nur mit den Registern AX, BX, CX und DX, mit keinem anderen!

Auch wenn diese Register für fast alle Operationen verwendet werden können, spielen sie bei bestimmten Befehlen eine Sonderrolle. So wird das AX-Register häufig von arithmetischen Befehlen benutzt, das CX-Register dagegen ist bei der Assemblerversion von FOR-Schleifen der Schleifenzähler. Das BX-Register wird häufig im Zusammenhang mit indirekter Adressierung genutzt und das DX-Register erweitert bei bestimmten Befehlen das AX-Register auf die doppelte Breite.

Ein schönes Beispiel für so einen Befehl ist MUL. Der Befehl MUL BX etwa multipliziert AX mit BX und speichert das Ergebnis in dem Registerpaar DX:AX. In DX steht der höherwertige Teil des Ergebnisses, in AX der niederwertige. Der MUL-Befehl verwendet immer AX oder AL als einen Operanden der Multiplikation.

Die Indexregister

Es gibt zwei Indexregister, SI, den Source-Index und DI, den Destination-Index. Diese Register sind besonders für die Benutzung mit Stringbefehlen optimiert. String meint hier nicht unbedingt eine Zeichenkette sondern steht allgemein für Arrays. In den Datenblättern sind sie jedoch entsprechend benannt. Diese Register können auch wie Allzweckregister verwendet werden.

Die Stackregister

Die Stackregister bestehen aus dem Stacksegmentregister SS, dem Stackpointer SP und dem Basepointer BP. SS wird bei den Segmentregistern erläutert. SP zeigt immer auf die Spitze des aktuellen Stapelspeichers, BP ist nützlich bei der Parameterübergabe per Stack und kann sonst auch als weiteres Universalregister verwendet werden. Näheres zum Stack im entsprechenden Kapitel.

Der Instruction Pointer

Der Instruction-Pointer, kurz IP, enthält immer die Adresse des nächsten auszführenden Befehls und wird automatisch weitergezählt. Er wird nur durch Sprungbefehle und Unterprogrammaufrufe neu geladen, ansonsten kann auf ihn nicht zugegriffen werden.

Die Flags

Das Flag-Register enthält Status- und Steuerbits des Prozessors, die sog. Flags. Diese Bits zeigen bestimmte Vorkommnisse im Prozessor an, etwa ob das Ergebnis der letzten arithmetischen Operation Null war, und werden von bedingten Sprungbefehlen ausgewertet. Einige andere Flags können bestimmte Funktionen des Prozessors steuern. Ein gesetztes Flag hat den Wert Eins, ein gelöschtes den Wert Null. Bedingte Sprünge entsprechen in etwa if-Anweisungen.

Die Flags im Einzelnen:

  • Bit 11 – Overflow Flag (OF)
  • Bit 10 – Direction Flag (DF)
  • Bit 9 – Interrupt Flag (IF)
  • Bit 8 – Trap Flag (TF)
  • Bit 7 – Sign Flag (SF)
  • Bit 6 – Zero Flag (ZF)
  • Bit 4 – Auxiliary Flag (AF)
  • Bit 2 – Parity Flag (PF)
  • Bit 0 – Carry Flag (CF)
Das Carry Flag (Bit Nr. 0)

Das Carry Flag wird gesetzt, wenn das Ergebnis einer Operation zu einem Werteüberlauf geführt hat, also wenn das Ergebnis größer als die größte darstellbare Zahl war oder kleiner als die Kleinste. Es wird auch gerne benutzt, um anzuzeigen, dass in einer Unterfunktion ein Fehler aufgetreten ist.

Das Parity Flag (Bit Nr. 2)

Das Parity Flag zeigt an, ob die Anzahl an Einsen des untersten Bytes einer vorangegangenen arithmetischen, logischen oder einer Vergleichsoperationen grade oder ungerade ist. Ist die Anzahl gerade, ist es gesetzt.

Dieses Flag stammt noch aus einer Zeit, in der häufig serielle Verbindungen genutzt wurden, die Paritätsbits zur Fehlererkennung benutzten. Heute wird es in dieser Funktion praktisch nicht mehr benötigt.

Das Auxiliary Flag (Bit Nr. 4)

Das Auxiliary Flag wird auch als Half-Carry bezeichnet und zeigt, wie das Carry Flag, einen Werteüberlauf an. Allerdings wird es bei einem Über- oder Unterlauf der unteren vier Bits eines Bytes gesetzt. Das wird vor allem für die Verarbeitung von BCD-Zahlen gebraucht.

Das Zero Flag (Bit Nr. 6)

Das Zero Flag wird gesetzt, wenn das Ergebnis einer arithmetischen oder logischen Operation Null ist. Es wird häufig benötigt, z.B. für die Konstruktion von Schleifen.

Das Sign Flag (Bit Nr. 7)

Das Sign Flag enthält jeweils eine Kopie des höchstwertigen Bits vom Ergebnis einer arithmetischen oder logischen Operation. Bei vorzeichenbehafteten Zahlen entspricht dies dem Vorzeichen. Es ist genau dann gesetzt, wenn das Ergebnis negativ ist. (Hierbei zählt 0 als positive Zahl)

Das Trap Flag (Bit Nr. 8)

Dieses Flag wird von Debuggern und ähnlichen Programmen gesetzt, um eine Ausführung des Programmes in Einzelschritten zu ermöglichen.

Das Interrupt Flag (Bit Nr. 9)

Dieses Flag zeigt an, ob der Prozessor Interrupts (hierzu später mehr) zulassen soll oder nicht.

Das Direction Flag (Bit Nr. 10)

Das Direction Flag indiziert eine Richtung (hin zu höheren oder hin zu niedrigeren Adressen), die eine Auswirkung auf die Funktion einiger Befehle hat. Die genaue Funktion wird bei den entsprechenden Befehlen beschrieben.

Das Overflow Flag (Bit Nr. 11)

Das Overflow Flag wird gesetzt, wenn das Ergebnis einer Operation zu einem Übertrag auf der höchsten Stelle geführt hat. Dies hat bei vorzeichenbehafteten Zahlen in etwa dieselbe Bedeutung wie das Setzen des Carry Flags bei Vorzeichenlosen.

80186, 80188

Der Vollständigkeit halber sollen hier noch kurz die Nachfolger dieser beiden Prozessoren erwähnt werden. Deren Befehlsumfang wurde erweitert und stellenweise optimiert, so dass ein 80186 etwa ein Viertel schneller arbeitet als ein gleich getakteter 8086. Der 80186 ist eher ein integrierter Mikrocontroller als ein Mikroprozessor, denn er enthält auch einen Interrupt Controller, einen DMA-Chip und einen Timer. Diese waren allerdings inkompatibel zu den vorher verwendeten externen Logiken des PC/XT, was eine Verwendung dort sehr schwierig machte. Darum wurde er im PC selten verwendet, war jedoch auf Adapterkarten als selbstständiger Mikroprozessor relativ häufig zu finden, beispielsweise auf ISDN-Karten.

Der 80188 ist wiederum eine abgespeckte Version mit 8-Bit-Datenbus, der 16-Bit-Zugriffe selbsttätig aufteilt. Wie beim 80188 sind 8-Bit-Zugriffe genauso schnell wie beim 80186, 16-Bit-Zugriffe benötigen die doppelte Zeit.

80286

Man kann den 80286 als direkten Nachfolger des 8086 sehen, da der 80186/80188 durch die Inkompatibilitäten in PCs nicht weit verbreitet war. Das interessanteste neue Feature war der 16 Bit Protected Mode. Damit ist es möglich, mehrere (quasi) gleichzeitig laufende Programme voneinander abzuschotten.

Dieser Modus ist in der x86-Architektur die Basis für moderne Betriebssysteme, die Multitasking und geschützte Speicherbereiche unterstützen. Zusätzlich steigt mit dem 80286 im Protected Mode der adressierbare Arbeitsspeicher von 1 MiB auf 16 MiB. Leider kann man den Protected Mode auf dem 286er nur durch einen Reset verlassen. Dadurch war er, zur Hochzeit von DOS, für die meisten Anwendungen eher unattraktiv und konnte sich auf dieser Platform nie richtig durchsetzen.

80386

Mit dem 80386 entwickelte Intel erstmals eine 32-Bit-CPU. Damit konnte der Prozessor 4 GiB Speicher linear ansprechen. Da sich der Protected Mode beim 80286 anfänglich nicht durchsetzen konnte, enthielt die 80386-CPU einen virtuellen 8086-Modus. Er kann aus dem Protected Mode heraus gestartet werden und simuliert einen oder mehrere 8086-Prozessoren. Die internen Speichereinheiten des Prozessors, die Register, wurden von 16 Bit auf 32 Bit verbreitert.

Wie schon beim 8086 brachte Intel mit dem 80386SX eine Low-Cost-Variante in den Handel, die statt des 32 Bit breiten nur einen 16 Bit breiten Datenbus hatte.

80486

Mit dem 80486-Prozessor brachte Intel eine überarbeitete 80386-CPU auf den Markt, die um einen Cache und einen internen mathematischen Coprozessor erweitert wurde. Da der Cache sehr viel schneller als der normale Hauptspeicher ist, beschleunigt er die Ausführung von Programmen erheblich. Dies wird dadurch erreicht, dass Daten im Voraus aus dem Hauptspeicher geladen werden können und dann sofort zur Verfügung stehen, wenn die CPU sie benötigt. Hin und wieder muss der Prozessor zwar immer noch auf den langsamen Hauptspeicher warten, nämlich wenn die Daten nicht im Cache vorhanden sind, aber eben viel seltener.

Den mathematischen Coprozessor kennen wir heute eher als FPU, die Fließkommaeinheit. Bis zum 80486 war das grundsätzlich ein zweiter Chip, der auf einem eigenen Sockel auf der Hauptplatine nachgerüstet werden konnte. Mit dem 80486SX gab es wieder eine „Sparvariante“ des normalen 80486, die ohne den integrierten mathematischen Coprozessor auskommen musste. Es gab dafür aber auch wieder einen Externen, den 80487. Der war aber so teuer, dass ein vollwertiger 80486 billiger als der Nachrüstchip war. Der Datenbus wurde beim 80486SX nicht beschränkt. Mit seinem Cache verhielt er sich wie ein leistungsfähigerer 80386.

80586/Pentium-Architektur

Mit dem Pentium-Prozessor (und den kompatiblen Varianten der Konkurrenz) wurde unter anderem ein Konzept der parallelen Instruktionsausführung in die Prozessoren aufgenommen, die superskalare Ausführung. Der Prozessor analysiert die Befehle, die demnächst auszuführen sind. Falls keine direkte Abhängigkeit der Instruktionen untereinander besteht und die nötige Ressource (z. B. der Coprozessor) frei ist, wird die Ausführung vorgezogen und die Instruktion parallel ausgeführt. Dies verbessert die Ausführungsgeschwindigkeit deutlich, macht jedoch auch das Assembler Programmieren komplexer, denn die Ausführungszeit von Instruktionen hängt nun vom Kontext der vorher und nachher auszuführenden Instruktionen ab.

Mit der späteren MMX-Erweiterung der Pentium-Prozessor-Architektur fand eine weitere Form der parallelen Ausführung ihren Platz in der x86-Architektur, die parallele Datenverarbeitung auf Vektoren (SIMD). Hierzu wurde der Befehlssatz um Vektoroperationen erweitert, welche mit den Registern des Coprozessors verwendet werden können.

x86-64-Architektur

Mit der x86-64-Architekturerweiterung, eingeführt von AMD und später von Intel übernommen, wurden die bisherigen Konzepte und Instruktionen der 32-Bit-x86-Prozessorarchitektur beibehalten. Die Register und Busse wurden von 32 Bit auf 64 Bit erweitert und ein neuer 64Bit Ausführungsmodus eingeführt, die 32-Bit-Betriebsmodi sind weiterhin möglich. Allerdings fiel hier der virtual protected Mode weg, der eh nicht mehr verwendet wurde.

Die Register der 8086/8088-CPU

Die Register im Einzelnen:

Adressregister

CS-, DS-, ES- und SS-Register

Eine spezielle Bedeutung unter den Registern haben die Segmentregister CS (Codsegmentregister), DS (Datensegmentregister), ES (Extrasegmentregister) und SS (Stacksegmentregister). Sie bilden im so genannten 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.

Interrupts

Während die CPU ein Programm bearbeitet, können verschiedene Ereignisse wie diese eintreten: Der Anwender drückt eine Taste, dem Drucker geht das Papier aus oder die Festplatte ist mit dem Schreiben der Daten fertig.

Um darauf reagieren zu können, hat der Prozessor zwei Möglichkeiten:

  • Die CPU kann ständig nachfragen, ob so ein Ereignis eingetreten ist. Dieses Verfahren bezeichnet man als Polling oder auch "aktives Warten". Der Nachteil dieser Methode ist, dass der Prozessor die ganze Zeit mit Warten beschäftigt ist und nichts anderes machen kann.
  • Das Gerät erzeugt ein Signal, sobald ein Ereignis eintritt, und schickt dieses über einen Interrupt-Controller an den Prozessor. Die CPU unterbricht das gerade laufende Programm, verarbeitet das Ereignis und setzt anschließend die Verarbeitung des unterbrochenen Programms fort. Das unterbrochene Programm bekommt von dem aufgetretenen Interrupt nichts mit.

Ein solches Interrupt-Signal ist ein Hardware-Interrupt, im Unterschied zu Software-Interrupts, die durch den INT-Befehl ausgelöst werden. Hardware-Interrupts sind asynchron, d.h. es ist nicht vorhersagbar, wann sie ausgelöst werden. Ein Software-Interrupt hingegen wird vom laufenden Programm ausgelöst und ist damit synchron zum Programmablauf.

Manchmal ist es nötig, die Unterbrechung des Programms durch Interrupts zu verhindern, zum Beispiel bei der Manipulation der Interrupt-Vector-Tabelle. Dazu löscht man das Interruptflag (IF = 0) mit dem Befehl cli (Clear Interruptflag). sti (Set Interruptflag) setzt es (IF = 1). Sind die Interrupts auf diese Weise gesperrt, kann das System auf keine Ein- oder Ausgabe mehr reagieren. Der Anwender merkt das daran, dass das System seine Eingaben über die Tastatur oder die Maus nicht mehr bearbeitet.

Die 80x86-CPU hat zwei Interrupt-Eingänge, den INTR (Interrupt Request) und den NMI (Non Maskable Interrupt). Der Interrupt INTR ist für normale Anfragen an den Prozessor zuständig, während der NMI nur besonders wichtigen Interrupts vorbehalten ist. Das Besondere am Non Maskable Interrupt („nicht maskierbar“) ist, dass er nicht durch Löschen des Interruptflags unterdrückt werden kann.

Adressierung des Arbeitsspeichers im Real Mode

Der Adressbus dient der CPU dazu, Speicherplätze im Arbeitsspeicher anzusprechen, indem sie die Adresse eines Speicherplatzes über die Leitungen des Busses sendet. Die 8086/88-CPU hat dafür 20 Adressleitungen, die den Adressbus bilden. Damit könnte der Prozessor unmittelbar 220 = 1 MByte adressieren, wenn auch das zuständige Register in der CPU 20 Bit breit wäre. In dem Register muss sich die Adresse befinden, bevor sie auf den Adressbus geht. Mit ihren 16-Bit-Registern kann die 8086/88-CPU jedoch höchstens 216 = 65.536 Byte direkt adressieren.

Segmentierung in Real Mode

Um mit dieser CPU ein Megabyte Speicher anzusprechen, benötigt man zwei Register. Mit zwei 16-Bit-Registern können wir 232 = 4 Gigabyte Arbeitsspeicher ansprechen - mehr als wir benötigen. Eine 20-Bit-Adresse könnte mit Hilfe der zwei Register gebildet werden, indem man auf das erste Register die ersten 16 Bit der Adresse legt und auf das zweite die restlichen 4 Bit. Ein Vorteil dieser Methode ist, dass der gesamte Adressraum linear angesprochen werden kann. „Linear“ bedeutet, dass die logische Adresse – also die, die sich in den Registern befindet – mit der physikalischen Adresse der Speicherzelle im Arbeitsspeicher übereinstimmt. Das erspart uns die Umrechnung zwischen logischer und physikalischer Adresse.

Das Verfahren hat allerdings auch Nachteile: Die CPU muss beim Adressieren immer zwei Register laden. Zudem bleiben zwölf der sechzehn Bits im zweiten Register ungenutzt.

Deshalb ist im Real Mode ein anderes Adressierungsverfahren üblich: Mit einem 16-Bit-Register können wir 65.536 Byte an einem Stück ansprechen. Bleiben wir innerhalb dieser Grenze, brauchen wir für die Adressierung einer Speicherzelle kein zweites Register.

Um den ganzen Speicherbereich von 1 MByte zu nutzen, können wir mehrere 65.536-Byte-Blöcke über den gesamten Speicher von 1 MByte verteilen und jeden von ihnen mit dem zweiten Register adressieren. So ein Block heißt Segment. Das zweite Register legt fest, an welcher Speicheradresse ein Segment anfängt, und mit dem ersten Register bewegen wir uns innerhalb des Segments.

Der Speicher von 1 MByte, der mit den 20 Leitungen des Adressbusses adressiert werden kann, ist allerdings nicht in ein paar säuberlich getrennte Segmente unterteilt. Vielmehr überlappen sich eine Menge Segmente, weil jedes 16. Byte, verteilt über 1 MByte, als möglicher Beginn eines 64-KByte-Segments festgelegt wurde. Der Abstand von 16 Byte kommt zu Stande, weil die erlaubten Anfänge der Segmente gleichmäßig auf die 1 MByte verteilt wurden (Division 1.048.576 Byte durch 65.536).

Die Überlappung der Segmente ist eine Stolperfalle für Programmierer: Ein und dieselbe Speicherzelle im Arbeitsspeicher kann durch viele Kombinationen von Adressen des ersten und des zweiten Registers adressiert werden. Passt man nicht auf, dann überschreibt ein Programm unerlaubt Daten und Programmcode und stürzt ab.

Die Adresse im zweiten Register, die den Anfang eines Segments darstellt, heißt Segmentadresse. Die physikalische Adresse im Arbeitsspeicher errechnet sich aus der Segmentadresse und der Adresse des ersten Registers, der so genannten Offsetadresse. Erst wird die Segmentadresse mit 16, dem Abstand der Segmente, multipliziert. Zur Multiplikation mit 16 hängt man rechts einfach vier Nullen an. Hinzu wird die Offsetadresse addiert:

Physikalische Adresse = Segmentadresse * 16 + Offsetadresse


Die Offsetadresse im ersten Register erlaubt, auf ein 65.536 Byte großes zusammenhängendes Speicherfeld zuzugreifen. In Verbindung mit der Segmentadresse im zweiten Register kann man den gesamten Speicher erreichen. Beschränkt sich ein Programm auf die Speicherplätze innerhalb eines Segments, braucht die Segmentadresse gar nicht geändert werden.

Sehen wir uns dazu ein Beispiel an: Nehmen wir an, dass das Betriebssystem ein Programm lädt, das 40 KByte Daten und 50 KByte Code umfasst. Daten und Code wandern in den Arbeitsspeicher, wo sie säuberlich getrennt eigene Speicherbereiche einnehmen sollen. Um separate Speicherbereiche für die Daten und den Code zu schaffen, initialisiert das Betriebssystem zuvor das Datensegmentregister (DS) und das Codesegmentregister (CS) mit den Adressen, an denen beginnend die Daten und der Code im Arbeitsspeicher gelagert werden.

Wollen wir beispielsweise Text ausgeben, müssen wir eine Betriebssystemfunktion aufrufen, der wir die Adresse des Textes übergeben, den sie ausgeben soll. Die Segmentadresse des Textes steht schon im Datensegmentregister. Wir müssen der Betriebssystemfunktion nur noch die Offsetadresse nennen. Man sieht: Das Betriebssystem erfährt die vollständige Adressinformation, obwohl wir der Funktion nur den Offsetanteil übergeben haben.

Kommt ein kleines Programm mit weniger als 64 kByte inklusive aller Daten und des Codes aus, braucht man das Segmentregister beim Programmstart nur einmal zu initialisieren. Die Daten erreicht man alle allein mit dem Offset – eine ökonomische Sache.

Fassen wir die Eigenschaften von Segmenten und Offsets zusammen:

  • Jedes Segment fasst maximal 64 kByte, weil die Register der CPU nur 16 Bit breit sind.
  • Speicheradressen innerhalb eines Segments erreicht man mit Hilfe des Offsets.
  • Es gibt 65.536 Segmente (216).
  • Die Segmentanfänge liegen immer mindestens 16 Byte weit auseinander.
  • Segmente können sich gegenseitig überlappen.
  • Die Adressierung mit Segmenten und Offset erfolgt nur im Real Mode so wie beschrieben.

Neuere Prozessoren und Betriebssysteme besitzen die genannte Adressregister-Beschränkung nicht mehr. Ab dem 80386 können bis zu 4 GB linear adressiert werden. Deshalb setzen wir uns nicht weiter im Detail mit Segmenten und Offset auseinander. Die Programme jedoch, die wir im Folgenden entwickeln, sind dennoch höchstens 64 kByte groß und kommen deshalb mit einem Segment aus.


Das erste Assemblerprogramm

Die Befehle MOV und XCHG

Der mov-Befehl (move) ist wohl einer der am häufigsten verwendeten Befehle im Assembler. Er hat die folgende Syntax:


  mov op1, op2

Mit dem mov-Befehl wird der zweite Operand in den ersten Operanden kopiert. Der erste Operand wird auch als Zieloperand, der zweite als Quelloperand bezeichnet. Beide Operanden müssen die gleiche Größe haben. Wenn der erste Operand beispielsweise die Größe von zwei Byte besitzt, muss auch der zweite Operand die Größe von zwei Byte besitzen.

Es ist nicht erlaubt, als Operanden das IP-Register zu benutzen. Wir werden später mit Sprungbefehlen noch eine indirekte Möglichkeit kennen lernen, dieses Register zu manipulieren.

Außerdem ist es nicht erlaubt, eine Speicherstelle in eine andere Speicherstelle zu kopieren. Diese Regel gilt für alle Assemblerbefehle mit zwei Operanden: Es dürfen niemals beide Operanden eine Speicherstelle ansprechen. Beim mov-Befehl hat dies zur Folge, dass, wenn eine Speicherstelle in eine zweite Speicherstelle kopiert werden soll, dies über ein Register erfolgen muss. Beispielsweise wird mit den zwei folgenden Befehlen der Inhalt von der Speicherstelle 0110h in die Speicherstelle 0112h kopiert:

 mov ax, [0110]
 mov [0112], ax

Der xchg-Befehl (exchange) hat die gleiche Syntax wie der mov-Befehl:

 xchg  op1, op2

Wie sein Name bereits andeutet, vertauscht er den ersten und den zweiten Operanden. Die Operanden können allgemeine Register oder ein Register und eine Speicherstelle sein.

Das erste Programm

Schritt 1: Installieren des Assemblers

Laden Sie zunächst den Netwide-Assembler unter der folgenden Adresse herunter:

http://sourceforge.net/project/showfiles.php?group_id=6208

Dort sehen Sie eine Liste mit DOS 16-Bit-Binaries. Da wir unsere ersten Schritte unter DOS machen werden, laden Sie anschließend die Zip-Datei der aktuellsten Version herunter und entpacken diese.

macOS Anwender können eine aktuelle Version mittels Homebrew herunterladen.

Schritt 2: Assemblieren des ersten Programms

Anschließend kopieren Sie das folgende Programm in den Editor:

 org 100h
 start:
   mov ax, 5522h
   mov cx, 1234h
   xchg cx,ax 
   mov al, 0
   mov ah,4Ch
   int 21h

Speichern Sie es unter der Bezeichnung „firstp.asm“. Anschließend starten Sie die Eingabeaufforderung "cmd.exe" und wechseln in das Verzeichnis, in das Sie den Assembler entpackt haben. Dort übersetzen Sie das Programm (wir nehmen an, 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 KB 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.

Sie können das Programm nun ausführen, indem Sie firstp eingeben. Da nur einige Register hin und her geschoben werden, bekommen Sie allerdings nicht viel zu sehen.

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 benutzen wir den Debugger, der bereits seit MS-DOS mitgeliefert wurde. Er ist zwar nur sehr einfach und textorientiert, hat jedoch den Vorteil, dass er nicht erst installiert werden muss und kostenlos ist.

Starten Sie den Debugger mit debug firstp.com. Wenn Sie den Dateinamen beim Starten des Debuggers nicht mit angegeben haben, können Sie dies jederzeit über n firstp.com (n steht für engl. name) und dem Befehl l (load) nachholen.

Anschließend geben Sie den Befehl r ein, um sich den Inhalt aller Register anzeigen zu lassen:

-r
AX=0000  BX=0000  CX=000C  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0100   NV UP EI PL NZ NA PO NC
0CDC:0100 B82255        MOV     AX,5522

In der ersten Zeile stehen die Datenregister sowie die Stackregister. Ihr Inhalt ist zufällig und kann sich von Aufruf zu Aufruf unterscheiden. Nur das BX-Register ist immer auf 0 gestellt und das CX-Register gibt die Größe des Programmcodes an. Sämtliche Angaben sind in hexadezimaler Schreibweise angegeben.

In der nächsten Zeile befinden sich die Segmentregister sowie der Befehlszähler. Die Register CS:IP zeigen auf den ersten Befehl in unserem Programm. Wie wir vorher gesehen haben können wir die tatsächliche Adresse über die Formel

Physikalische Adresse = Offsetadresse + Segmentadresse * 16dez

errechnen. Setzen wir die Adresse für das Offset und das Segment ein, so erhalten wir

Physikalische Adresse = 0x0CDC * 16dez + 0x0100 = 0xCDC0 + 0x0100 = 0xCEC0.

Dahinter befinden sich die Zustände der Flags. Vielleicht werden Sie sich wundern, dass ein Flag fehlt: Es ist das Trap Flag, das dafür sorgt, dass nach jeder Anweisung der Interrupt 1 ausgeführt wird, um es zu ermöglichen, dass das Programm Schritt für Schritt abgearbeitet wird. Es ist immer gesetzt, wenn ein Programm mit dem Debugger aufgeführt wird.

In der dritten und letzten Zeile sehen Sie zu Beginn nochmals die Adresse des Befehls, die immer mit dem Registerpaar CS:IP übereinstimmt. Dahinter befindet sich der Opcode des Befehls. Wir merken uns an dieser Stelle, dass der Opcode für den Befehl MOV AX,5522 3 Byte groß ist (zwei Hexadezimalziffern entsprechen immer einem Byte).

Mit der Anweisung t (trace) führen wir nun den ersten Befehl aus und erhalten die folgende Ausgabe:

-t
AX=5522  BX=0000  CX=000C  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0103   NV UP EI PL NZ NA PO NC
0CDC:0103 B93412        MOV     CX,1234

Wir haben die Register hervorgehoben, die sich verändert haben. Der Befehl 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 (also unsere 3 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:

-t
AX=5522  BX=0000  CX=1234  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0106   NV UP EI PL NZ NA PO NC
0CDC:0106 91            XCHG    CX,AX

Der XCHG-Befehl vertauscht nun die Register AX und CX, so dass jetzt AX den Inhalt von CX hat und CX den Inhalt von AX.

Wir haben nun genug gesehen und beenden deshalb das Programm an dieser Stelle. Dazu rufen wir über den Interrupt 21h die Betriebssystemfunktion 4Ch auf. 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). Den Debugger können Sie nun beenden, indem Sie q (quit) eingeben.

„Hello World“-Programm

Es ist ja inzwischen fast üblich geworden, ein „Hello World“-Programm in einer Einführung in eine Sprache zu entwickeln. Dem wollen wir natürlich folgen:

 org 100h
 start:
  mov dx,hello_world
  mov ah,09h
  int 21h
  mov al, 0
  mov ah,4Ch
  int 21h
 section .data
  hello_world: db 'hello, world', 13, 10, '$'

Nachdem Sie das Programm übersetzt und ausgeführt haben, starten Sie es mit dem Debugger:

-r
AX=0000  BX=0000  CX=001B  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0100   NV UP EI PL NZ NA PO NC
0CDC:0100 BA0C01        MOV     DX,010C
- q

Wie Sie erkennen können, hat der Assembler hello_world durch die Offsetadresse 010C ersetzt. Diese Adresse zusammen mit dem CS-Register entspricht der Stelle im Speicher, in der sich der Text befindet. Wir müssen diese der Betriebssystemfunktion 9h des Interrupts 21 übergeben, die dafür sorgt, dass der Text auf dem Bildschirm ausgegeben wird. Wie kommt der Assembler auf diese Adresse?

Die Anweisung „section .data“ bewirkt, dass hinter dem letzten Befehl des Programms ein Bereich für Daten reserviert wird. Am Beginn dieses Datenbereichs wird der Text 'hello, world', 13, 10, '$' bereitgestellt. Ab welcher Adresse der Datenbereich beginnt, lässt sich nicht genau vorhersagen. Wieso?

Wir können zunächst bestimmen, wo unser Programmcode beginnt und wie lang unser Programmcode ist.

COM-Programme beginnen immer erst bei der Adresse 100h (sonst kann sie das Betriebssystem nicht ausführen). Die Bytes von 0 bis FFh hat das Betriebssystem für Verwaltungszwecke reserviert. Dies muss natürlich dem Assembler mit der Anweisung org 100h mitgeteilt werden, der sonst davon ausgehen würde, dass das Programm bei Adresse 0 beginnt. Es sei hier nochmals darauf hingewiesen, dass Assembleranweisungen nicht mit übersetzt werden, sondern lediglich dazu dienen, dass der Assembler das Programm richtig übersetzen kann.

Der Opcode von mov dx,010C hat eine Größe von 3 Byte, der Opcode von mov ah,09 und mov ah,4Ch jeweils 2 Byte und der Opcode der Interruptaufrufe nochmals jeweils 2 Byte. Der Programmcode hat somit eine Größe von 11 Byte dezimal oder B Byte hexadezimal. Woher ich das weiß? Entweder kompiliere ich das Programm und drucke das Listing aus, oder ich benutze das DEBUG-Programm und tippe mal eben schnell die paar Befehle ein. Wie auch immer:

Das letzte Byte des Programms hat die Adresse 10Ah. Das ist auf den ersten Blick verblüffend, aber die Zählung hat ja nicht mit 101h, sondern mit 100h begonnen. Das erste freie Byte hinter dem Programmcode hat also die Adresse 10Bh, ab dieser Adresse könnten Daten stehen. Tatsächlich beginnt der Datenbereich erst an 10Ch, weil die meisten Compiler den Speicher ab geradzahligen oder durch vier teilbaren Adressen zuweisen.

Warum ist das so? Prozessoren lesen (und schreiben) 32 Bit gleichzeitig aus dem Speicher. Wenn eine 32-Bit-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 kann. 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 10Ch oder 110h beginnen lassen.

Eine weitere Anweisung in diesem Programm ist die DB-Anweisung. Durch sie wird byteweise Speicherplatz reserviert. Der NASM kennt darüber hinaus noch weitere Anweisungen, um Speicherplatz zu reservieren. Dies sind:

Anweisung Breite Bezeichnung
DW 2 Byte Word
DD 4 Byte Doubleword
DF 6 Byte -
DQ 8 Byte Quadword
DT 10 Byte Ten Byte

Die Zahl 13 dezimal entspricht im ASCII Zeichensatz dem Wagenrücklauf, die Zahl 10 dezimal entspricht im ASCII-Zeichensatz dem Zeilenvorschub. Beide zusammen setzen den Cursor auf den Anfang der nächsten Zeile. Das $-Zeichen wird für den Aufruf der Funktion 9h des Interrupts 21h benötigt und signalisiert das Ende der Zeichenkette.

EXE-Dateien

Die bisher verwendeten COM-Dateien können maximal ein Segment groß werden. EXE-Dateien können dagegen aus mehreren Segmenten bestehen und damit größer als 64 KB werden.

Im Gegensatz zu DOS unterstützt Windows überhaupt keine COM-Dateien mehr und erlaubt nur EXE-Dateien auszuführen. Trifft Windows auf eine COM-Datei, wird sie automatisch in der Eingabeaufforderung als MS-DOS-Programm ausgeführt.

Im Gegensatz zu einer COM-Datei besteht eine EXE-Datei nicht nur aus ausführbarem Code, sondern besitzt einen Header, der vom Betriebssystem ausgewertet wird. Der Header unterscheidet sich zwischen Windows und DOS. Wir wollen jedoch nicht näher auf die Unterschiede eingehen.

Ein weiterer Unterschied ist, dass EXE-Dateien in zwei Schritten erzeugt werden. Beim Übersetzen des Quellcodes entsteht zunächst eine „Objektdatei“ als Zwischenprodukt. Eine oder mehrere Objektdateien werden anschließend von einem Linker zum EXE-Programm zusammengefügt. Bei einem Teil der Objektdateien handelt es sich üblicherweise um Dateien aus Programmbibliotheken.

Der Netwide-Assembler besitzt allerdings selbst keinen Linker. Sie müssen deshalb entweder auf einen Linker eines anderen Compilers oder auf einen freien Linker wie beispielsweise ALINK zurückgreifen. ALINK kann von der Webseite http://alink.sourceforge.net/ heruntergeladen werden.

Das folgende Programm ist eine Variante unseres „Hello World“-Programms.

 segment code

 start:
 mov ax, data
 mov ds, ax

 mov dx, hello
 mov ah, 09h
 int 21h

 mov al, 0
 mov ah, 4Ch
 int 21h

 segment data
 hello: db 'Hello World!', 13, 10, '$'

Speichern Sie das Programm unter hworld.asm ab und übersetzen Sie es:

nasm hworld.asm -f obj -o hworld.obj
alink hworld.obj

Beim Linken des Programms gibt der Linker möglicherweise eine Warnung aus. Bei ALINK lautet sie beispielsweise Warning – no stack. Beachten Sie diese Fehlermeldung nicht. Wir werden in einem der nächsten Abschnitte erfahren, was ein Stack ist.

Die erste Änderung, die auffällt, sind die Assembleranweisungen segment code und segment data. Die Anweisung ist ein Synonym für section. Wir haben uns hier für segment entschieden, damit deutlicher wird, dass es sich hier im Unterschied zu einer COM-Datei tatsächlich um verschiedene Segmente handelt.

Die Bezeichnungen für die Segmente sind übrigens beliebig. Sie können die Segmente auch hase und igel nennen. Der Assembler benötigt die Bezeichnung lediglich für die Adressberechnung und muss dazu wissen, wo ein neues Segment anfängt. Allerdings ist es üblich das Codesegment mit code und das Datensegment mit data zu bezeichnen.

Die Anweisung start: legt den Anfangspunkt des Programms fest. Diese Information benötigt der Linker, wenn er mehrere Objektdateien zusammenlinken muss. In diesem Fall muss er wissen, in welcher Datei sich der Eintrittspunkt befindet.

In einer Intel-CPU gibt es keinen direkten Befehl, eine Konstante in ein Segmentregister zu laden. Man muss deshalb entweder den Umweg über ein universelles Register gehen (im Beispiel: AX), oder man benutzt den Stack (den man vorher korrekt initialisieren muss): push data, dann pop data.

Nachdem Sie das Programm übersetzt haben, führen Sie es mit dem Debugger aus:

 debug hworld.exe
 -r
 AX=0000  BX=FFFF  CX=FE6F  DX=0000  SP=0000  BP=0000  SI=0000  DI=0000
 DS=0D3A  ES=0D3A  SS=0D4A  CS=0D4A  IP=0000   NV UP EI PL NZ NA PO NC
 0D4A:0000 B84B0D        MOV     AX,0D4C
 -t

 AX=0D4C  BX=FFFF  CX=FE6F  DX=0000  SP=0000  BP=0000  SI=0000  DI=0000
 DS=0D3A  ES=0D3A  SS=0D4A  CS=0D4A  IP=0003   NV UP EI PL NZ NA PO NC
 0D4A:0003 8ED8          MOV     DS,AX
 -t

 AX=0D4B  BX=FFFF  CX=FE6F  DX=0000  SP=0000  BP=0000  SI=0000  DI=0000
 DS=0D4C  ES=0D3A  SS=0D4A  CS=0D4A  IP=0005   NV UP EI PL NZ NA PO NC
 0D4A:0005 BA0000        MOV     DX,0000

Die ersten zwei Zeilen unterscheiden sich vom COM-Programm. Der Grund hierfür ist, dass wir nun zunächst das Datensegmentregister (DS) initialisieren müssen. Bei einem COM-Programm sorgt das Betriebssystem dafür, dass alle Segmentregister einen Anfangswert haben, und zwar alle den gleichen Wert. Bei einem COM-Programm braucht man sich deshalb nicht um die Segmentregister kümmern, hat dafür aber auch nur 64k für Programm + Daten zur Verfügung. Bei einem EXE-Programm entfällt die Einschränkung auf 64 k, aber man muss sich selbst um die Segmentregister kümmern.

Wie Sie sehen, beträgt die Differenz zwischen DS- und CS-Register anschließend 2 (0D4C – 0D4A). Das bedeutet allerdings nicht, dass nur zwei Byte zwischen Daten- und Codesegment liegt - darin würde der Code auch keinen Platz finden. Wie wir im letzten Abschnitt gesehen haben, liegen die Segmente 16 Byte auseinander, weshalb DS und CS Segment tatsächlich 32 Byte auseinanderliegen.

Das DX-Register erhält diesmal den Wert 0, da die Daten durch die Trennung von Daten- und Codesegment nun ganz am Anfang des Segments liegen.

Der Rest des Programms entspricht dem einer COM-Datei. Beenden Sie deshalb anschließend den Debugger.

Hello World in anderen Betriebssystemen

Die oben angegebenen Programme enthalten zwei verschiedene Systemabhängigkeiten: Zum einen logischerweise die Abhängigkeit vom Prozessor (es handelt sich um Code für den 80x86, der logischerweise nicht auf einem anderen Prozessor lauffähig ist), aber zusätzlich die Abhängigkeit vom verwendeten Betriebssystem (in diesem Fall DOS; beispielsweise wird obiger Code unter Linux nicht laufen). Um die Unterschiede zu zeigen, sollen hier exemplarisch Versionen von Hello World für andere Betriebssysteme gezeigt werden. Der verwendete Assembler ist in allen Fällen nasm.

Eine Anmerkung vorweg: Die meisten Betriebssysteme verwenden den seit dem 80386 verfügbaren 32-Bit-Modus. Dieser wird später im Detail behandelt, im Moment ist nur interessant, dass die Register 32 Bits haben und ein „e“ (für extended = erweitert) am Anfang des Registernamens bekommen (z. B. eax statt ax), und dass man Segmentregister in der Regel vergessen kann.

Linux

Unter Linux werden statt Segmenten sections verwendet (letztlich landet alles im selben 32-Bit-Segment). Die Code-Section heißt „.text“, die Datensection „.data“ und der Einsprungpunkt für den Code „_start“. Systemaufrufe benutzen int 80h statt des 21h von DOS, Werte werden in der Regel wie unter DOS über Register übergeben (wenngleich die Einzelheiten sich unterscheiden). Außerdem wird ein Zeilenende unter Linux nur mit einem Linefeed-Zeichen (ASCII 10) gekennzeichnet. Es sollte nicht schwer sein, die einzelnen Elemente des Hello-World-Programms im folgenden Quelltext wiederzuerkennen.

section .text
global _start
_start:
    mov ecx, hello
    mov edx, length
    mov ebx, 1      ; Dateinummer der Standard-Ausgabe
    mov eax, 4      ; Funktionsnummer: Ausgabe
    int 80h
    mov ebx, 0
    mov eax, 1      ; Funktionsnummer: Programm beenden
    int 80h

section .data
    hello  db 'Hello World!', 10
    length equ $ - hello;

Die folgenden Kommandozeilenbefehle bewirken das Kompilieren und Ausführen des Programms:

 nasm -g -f elf32 hello.asm
 ld hello.o -o hello
 ./hello

Für einen 64-Bit Prozessor sind folgende Kommandozeilenbefehle notwendig

 nasm -g -f elf64 hello.asm
 ld hello.o -o hello
 ./hello

Erläuterung: nasm ist wieder der Assembler. Er erzeugt hier die Datei hello.o, die man noch nicht ausführen kann. Die Option -g sorgt dafür, dass Debuginformationen in die zu erzeugende hello.o-Datei geschrieben werden. Der sogenannte Linker ld erzeugt aus hello.o die fertige, lauffähige Datei hello. Der letzte Befehl startet schließlich das Programm.

Hat man den GNU Debugger am Start, kann man per

 gdb hello

ähnlich wie unter MS Windows debuggen. Der GDB öffnet eine Art Shell, in der diverse Befehle verfügbar sind. Der Befehl list gibt den von uns verfassten Assemblercode inklusive Zeilennummern zurück. Mit break 5 setzen wir einen Breakpoint in der fünften Zeile unseres Codes. Ein anschließendes run führt das Programm bis zum Breakpoint aus, und wir können nun mit info registers die Register auslesen. Einen einzelnen Schritt können wir mit stepi ausführen.

(gdb) list
1       section .text
2       global _start
3       _start:
4           mov ecx, hello
5           mov edx, length
6           mov ebx, 1      ; Dateinummer der Standard-Ausgabe
7           mov eax, 4      ; Funktionsnummer: Ausgabe
8           int 80h
9           mov ebx, 0
10          mov eax, 1      ; Funktionsnummer: Programm beenden
(gdb) break 5
Breakpoint 1 at 0x8048085: file hello.asm, line 5.
(gdb) run
Starting program: /home/name/projects/assembler/hello_world/hello 

Breakpoint 1, _start () at hello.asm:5
5           mov edx, length
(gdb) info registers
eax            0x0      0
ecx            0x80490a4        134516900
edx            0x0      0
ebx            0x0      0
esp            0xbf9e6140       0xbf9e6140
ebp            0x0      0x0
esi            0x0      0
edi            0x0      0
eip            0x8048085        0x8048085 <_start+5>
eflags         0x200292 [ AF SF IF ID ]
cs             0x73     115
ss             0x7b     123
ds             0x7b     123
es             0x7b     123
fs             0x0      0
gs             0x0      0
(gdb) stepi
_start () at hello.asm:6
6           mov ebx, 1      ; Dateinummer der Standard-Ausgabe
(gdb) quit

NetBSD

Die NetBSD-Version unterscheidet sich von der Linux-Version vor allem dadurch, dass alle Argumente über den Stack übergeben werden. Auch der Stack wird später behandelt; an dieser Stelle ist nur wesentlich, dass man mit push-Werte auf den Stack bekommt, und dass die Reihenfolge wesentlich ist. Außerdem benötigt man für NetBSD eine spezielle .note-Section, die das Programm als NetBSD-Programm kennzeichnet.

section .note.netbsd.ident
    dd 0x07,0x04,0x01
    db "NetBSD",0x00,0x00
    dd 200000000

section .text
global _start
_start:
    push dword length
    push dword hello
    push dword 1
    mov	eax, 4
    push eax
    int	80h
    push dword 0
    mov eax, 1
    push eax
    int 80h

section .data
    hello  db 'Hello World!', 10
    length equ $ - hello;

macOS (Intel 64bit)

macOS erfordert je nach Betriebssystemversion einen unterschiedlichen Einstiegspunkt in das Programm.

; Dateiname: HelloWorld.macOS.asm
    global    _main                           ; exportable macOS entry 10.8 and upper
    global    start                           ; exportable macOS entry 10.7 and lower
    section   .text
start:                                        ; entry point macOS entry 10.7 and lower
_main:                                        ; entry point macOS entry 10.8 and upper
    mov       rax, 0x02000004
    mov       rdi, 1
    mov       rsi, message
    mov       rdx, 13
    syscall
    mov       rax, 0x02000001
    xor       rdi, rdi
    syscall
    section   .data
message:
    db        "Hello, World", 10

Um das Programm lauffähig zu machen rufen wir nacheinander den Assembler und Linker auf, wobei wir kein ELF Dateiformat sondern ein Macho-64bit Dateiformat für macOS benötigen.

nasm -f macho64 -o HW.obj HelloWorld.macOS.asm
ld -macosx_version_min 11.2.1 -static -o world.run HW.obj


Rechnen mit dem Assembler

Die Addition

Für die Addition stehen zwei Befehle zu Verfügung: add und adc. Der adc (Add with Carry) berücksichtigt das Carry Flag. Wir werden weiter unten noch genauer auf die Unterschiede eingehen.

Die Syntax von add und adc ist identisch:

 add Zieloperand, Quelloperand
 adc Zieloperand, Quelloperand

Beide Befehle addieren den Quell- und Zieloperanden und speichern das Resultat im Zieloperanden ab. Ziel- und Quelloperand können entweder ein Register oder eine Speicherstelle sein (natürlich darf nur entweder der Ziel- oder der Quelloperand eine Speicherstelle sein, niemals aber beide zugleich).

Das folgende Programm addiert zwei Zahlen miteinander und speichert das Ergebnis in einer Speicherstelle ab:

 org 100h
 start:
   mov bx, 500h
   add bx, [summand1]
   mov [ergebnis], bx
   mov ah, 4Ch
   int 21h
 section .data
   summand1 DW 900h
   ergebnis DW 0h

Unter Linux gibt es dabei - bis auf die differierenden Interrupts und die erweiterten Register - kaum Unterschiede:

 section .text
 global _start
 _start:
   mov ebx,500h
   add ebx,[summand1]
   mov [ergebnis],ebx
   ; Programm ordnungsgemäß beenden
   mov eax,1
   mov ebx,0
   int 80h
 section .data
   summand1 dd 900h
   ergebnis dd 0h

Es fällt auf, dass summand1 und ergebnis in eckigen Klammern geschrieben sind. Der Grund hierfür ist, dass wir nicht die Adresse benötigen, sondern den Inhalt der Speicherzelle.

Fehlen die eckigen Klammern, interpretiert der Assembler das Label summand1 und ergebnis als Adresse. Im Falle des add-Befehls würde das BX Register folglich mit der Adresse von summand1 addiert. Beim mov-Befehl hingegen würde dies der Assembler mit einer Fehlermeldung quittieren, da es nicht möglich ist, den Inhalt des BX Registers in eine Adresse zu kopieren und es folglich keinen Opcode gibt.

Wir verfolgen nun wieder mit Hilfe des Debuggers die Arbeit unseres Programms:

 -r
 AX=0000  BX=0000  CX=0014  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
 DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0100   NV UP EI PL NZ NA PO NC
 0CDC:0100 BB0005        MOV     BX,0500
 -t

 AX=0000  BX=0500  CX=0014  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
 DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0103   NV UP EI PL NZ NA PO NC
 0CDC:0103 031E1001      ADD     BX,[0110]                          DS:0110=0900

Wir sehen an DS:0110=0900, dass ein Zugriff auf die Speicherstelle 0110 im Datensegment erfolgt ist. Wie wir außerdem erkennen können, ist der Inhalt der Speicherzelle 0900.

Abschließend speichern wir das Ergebnis unserer Berechnung wieder in den Arbeitsspeicher zurück:

AX=0000  BX=0E00  CX=0014  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0107   NV UP EI PL NZ NA PE NC
0CDC:0107 891E1201      MOV     [0112],BX                          DS:0112=0000
-t

Wir lassen uns nun die Speicherstelle 0112, in der das Ergebnis gespeichert ist, über den d-Befehl ausgeben:

-d ds:0112 L2
0CDC:0110        00 0E

Die Ausgabe überrascht ein wenig, denn der Inhalt der Speicherstelle 0112 unterscheidet sich vom (richtigen) Ergebnis 0E00 im BX Register. Der Grund hierfür ist eine Eigenart der 80x86 CPU: Es werden High und Low Byte vertauscht. Als High Byte bezeichnet man die höherwertige Hälfte, als Low Byte die niederwertige Hälfte eines 16 Bit Wortes. Um das richtige Ergebnis zu erhalten, muss entsprechend wieder Low und High Byte vertauscht werden.

Unter Linux sieht der Debugging-Ablauf folgendermaßen aus:

(gdb) break 6
Breakpoint 1 at 0x8048085: file add.asm, line 6.
(gdb) run
Starting program: /(...)/(...)/add 

Breakpoint 1, _start () at add.asm:6
6		add ebx,[summand1]
(gdb) info registers
eax            0x0	        0
ecx            0x0	        0
edx            0x0	        0
ebx            0x500	        1280
esp            0xbffe0df0	0xbffe0df0
ebp            0x0	        0x0
esi            0x0	        0
edi            0x0	        0
eip            0x8048085	0x8048085 <_start+5>
eflags         0x200392	[ AF SF TF IF ID ]
cs             0x73	        115
ss             0x7b	        123
ds             0x7b	        123
es             0x7b	        123
fs             0x0	        0
gs             0x0	        0
(gdb) stepi
_start () at add.asm:7
7		mov [ergebnis],ebx
(gdb) info registers
eax            0x0	0
ecx            0x0	0
edx            0x0	0
ebx            0xe00	3584
esp            0xbffe0df0	0xbffe0df0
ebp            0x0	0x0
esi            0x0	0
edi            0x0	0
eip            0x804808b	0x804808b <_start+11>
eflags         0x200306	[ PF TF IF ID ]
cs             0x73	115
ss             0x7b	123
ds             0x7b	123
es             0x7b	123
fs             0x0	0
gs             0x0	0


Wie Sie sehen, lässt sich der GDB etwas anders bedienen. Nachdem wir einen Breakpoint auf die Zeile 6 gesetzt haben (Achtung: Das bedeutet, dass die sechste Zeile nicht mehr mit ausgeführt wird!), führen wir das Programm aus und lassen uns den Inhalt der Register ausgeben. Das ebx-Register enthält die Zahl 500h (dezimal 1280), die wir mit dem mov-Befehl hineingeschoben haben. Mit dem GDB-Kommando stepi rücken wir in die nächste Zeile (die den add-Befehl enthält). Erneut lassen wir uns die Register auflisten. Das ebx-Register enthält nun die Summe der Addition: 0xe00 (dezimal 3584).


Im Folgenden wollen wir eine 32-Bit-Zahl addieren. Bei einer 32-Bit-Zahl vergrößert sich der darstellbare Bereich für vorzeichenlose Zahlen von 0 bis 65.535 auf 0 bis 4.294.967.295 und für vorzeichenbehafteten Zahlen von –32.768 bis +32.767 auf –2.147.483.648 bis +2.147.483.647. Die einfachste Möglichkeit bestünde darin, ein 32-Bit-Register zu benutzen, was ab der 80386-CPU problemlos möglich ist. Wir wollen hier allerdings die Verwendung des adc-Befehls zeigen, weshalb wir davon nicht Gebrauch machen werden.

Für unser Beispiel benutzen wir die Zahlen 188.866 und 103.644 (Dezimal). Wir schauen uns die Rechnung im Dualsystem an:

   10 11100001 11000010 (188.866)
+   1 10010100 11011100 (103.644)
Ü  11       11 1
---------------------------------
  100 01110110 10011110 (292.510)

Wie man an der Rechnung erkennt, findet vom 15ten nach dem 16ten Bit ein Übertrag statt (fett hervorgehoben). Der Additionsbefehl, der die oberen 16 Bit addiert, muss dies berücksichtigen.

Die Frage, die damit aufgeworfen wird ist, wie wird der zweite Additionsbefehl davon in Kenntnis gesetzt, dass ein Übertrag stattgefunden hat oder nicht. Die Antwort lautet: Der erste Additionsbefehl, der die unteren 16 Bit addiert, setzt das Carry Flag, wenn ein Übertrag stattfindet, andernfalls löscht er es. Der zweite Additionsbefehl, der die oberen 16 Bit addiert, muss nun eins hinzuaddieren, wenn das Carry Flag gesetzt ist. Genau dies tut der adc-Befehl (im Gegensatz zum add-Befehl) auch.

Das folgende Programm addiert zwei 32-Bit-Zahlen miteinander:

 org 100h
 start:
   mov ax, [summand1]
   add ax, [summand2]
   mov [ergebnis], ax

   mov ax, [summand1+2]
   adc ax, [summand2+2]
   mov [ergebnis+2], ax

   mov ah, 4Ch
   int 21h
 section .data
   summand1 DD 2E1C2h
   summand2 DD 194DCh
   ergebnis DD 0h

Die ersten drei Befehle entsprechen unserem ersten Programm. Dort werden Bit 0 bis 15 addiert. Der add-Befehl setzt außerdem das Carry Flag, wenn es einen Übertrag von der 15ten auf die 16te Stelle gab (was hier der Fall ist, wie wir auch gleich mit Hilfe des Debuggers nachprüfen werden).

Mit den nächsten drei Befehlen werden Bit 16 bis 31 addiert. Deshalb müssen wir dort zwei Byte zur Adresse hinzuaddieren und außerdem mit dem adc-Befehl das Carry Flag berücksichtigen.

Wir sehen uns das Programm wieder mit dem Debugger an. Dabei sehen wir, dass der add-Befehl das Carry Flag setzt:

 AX=E1C2  BX=0000  CX=0024  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
 DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0103   NV UP EI PL NZ NA PO NC
 0CDC:0103 03061C01      ADD     AX,[011C]                          DS:011C=94DC
 -t

 AX=769E  BX=0000  CX=0024  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
 DS=0CDC  ES=0CDC  SS=0CDC  CS=0CDC  IP=0107   OV UP EI PL NZ NA PO CY
 0CDC:0107 A32001        MOV     [0120],AX                          DS:0120=0000

Mit NC zeigt der Debugger an, dass das Carry Flag nicht gesetzt ist, mit CY, dass es gesetzt ist. Das Carry Flag ist allerdings nicht das einzige Flag, das bei der Addition beeinflusst wird:

  • Das Zero Flag ist gesetzt, wenn das Ergebnis 0 ist.
  • Das Sign Flag ist gesetzt, wenn das Most Significant Bit den Wert 1 hat. Dies kann auch der Fall sein, wenn nicht mit vorzeichenbehafteten Zahlen gerechnet wird. In diesem Fall kann das Sign Flag ignoriert werden.
  • Das Parity Bit ist gesetzt, wenn das Ergebnis eine gerade Anzahl von Bits erhält. Es dient dazu, Übertragungsfehler festzustellen. Wir gehen hier aber nicht näher darauf ein, da es nur noch selten benutzt wird.
  • Das Auxiliary Carry Flag entspricht dem Carry Flag, wird allerdings benutzt, wenn mit BCD-Zahlen gerechnet werden soll.
  • Das Overflow Flag wird gesetzt, wenn eine negative Zahl nicht mehr darstellbar ist, weil das Ergebnis zu groß geworden ist.

Subtraktion

Die Syntax von sub (subtract) und sbb (subtract with borrow) ist äquivalent mit dem add/adc-Befehl:

 sub/sbb Zieloperand, Quelloperand

Bei der Subtraktion muss die Reihenfolge von Ziel- und Quelloperand beachtet werden, da die Subtraktion im Gegensatz zur Addition nicht kommutativ ist. Der sub/sbb-Befehl zieht vom Zieloperanden den Quelloperanden ab (Ziel = Ziel – Quelle):

Beispiel: 70 – 50 = 20

Diese Subtraktion kann durch die folgenden zwei Anweisungen in Assembler dargestellt werden:

mov ax,70h
sub ax,50h

Wie bei der Addition können sich auch bei der Subtraktion die Operanden in zwei oder mehr Registern befinden. Auch hier wird das Carry Flag verwendet. So verwundert es nicht, dass die Subtraktion einer 32-Bit-Zahl bei Verwendung von 16-Bit-Register fast genauso aussieht wie das entsprechende Additionsprogramm:

 org 100h
 start:
   mov ax, [zahl1]
   sub ax, [zahl2]
   mov [ergebnis], ax

   mov ax, [zahl1+2]
   sbb ax, [zahl2+2]
   mov [ergebnis+2], ax

   mov ah, 4Ch
   int 21h
 section .data
   zahl1 DD 70000h
   zahl2 DD 50000h
   ergebnis DD 0h

Der einzige Unterschied zum entsprechenden Additionsprogramm besteht tatsächlich darin, dass anstelle des add-Befehls der sub-Befehl und anstelle des adc- der sbb-Befehl verwendet wurde.

Wie beim add- und adc-Befehl werden die Flags Zero, Sign, Parity, Auxiliary Carry und Carry gesetzt.

Setzen und Löschen des Carryflags

Nicht nur die CPU sondern auch der Programmierer kann das Carry Flag beeinflussen. Dazu existieren die folgenden drei Befehle:

  • stc (Set Carry Flag) – setzt das Carry Flag
  • clc (Clear Carry Flag) – löscht das Carry Flag
  • cmc (Complement Carry Flag) – dreht den Zustand des Carry Flag um

Die Befehle INC und DEC

Der Befehl inc erhöht den Operanden um eins, der Befehl dec verringert den Operanden um eins. Die Befehle haben die folgende Syntax:

inc Operand
dec Operand

Der Operand kann entweder eine Speicherstelle oder ein Register sein. Beispielsweise wird über den Befehl

 dec ax

der Inhalt des AX Registers um eins verringert. Der Befehl bewirkt damit im Grunde das Gleiche wie der Befehl sub ax, 1. Es gibt lediglich einen Unterschied: inc und dec beeinflussen nicht das Carry Flag.

Zweierkomplement bilden

Wir haben bereits mit dem Zweierkomplement gerechnet. Doch wie wird dieses zur Laufzeit gebildet? Die Intel-CPU hält einen speziellen Befehl dafür bereit, den neg-Befehl. Er hat die folgende Syntax:

 neg Operand

Der Operand kann entweder eine Speicherzelle oder ein allgemeines Register sein. Um das Zweierkomplement zu erhalten, zieht der neg-Befehl den Operanden von 0 ab. Entsprechend wird das Carryflag gesetzt, wenn der Operand nicht null ist.

Die Multiplikation

Die Befehle mul (Multiply unsigned) und imul (Integer Multiply) sind für die Multiplikation zweier Zahlen zuständig. Mit mul werden vorzeichenlose Ganzzahlen multipliziert, wohingegen mit imul vorzeichenbehaftete Ganzzahlen multipliziert werden. Der mul-Befehl besitzt nur einen Operanden:

 mul Quelloperand

Der Zieloperand ist sowohl beim mul- wie beim imul-Befehl immer das AL- oder AX-Register. Der Quelloperand kann entweder ein allgemeines Register oder eine Speicherstelle sein.

Da bei einer 8-Bit-Multiplikation meistens eine 16-Bit-Zahl das Ergebnis ist (z. B.: 20 * 30 = 600) und bei einer 16-Bit-Multiplikation meistens ein 32-Bit-Ergebnis entsteht, wird das Ergebnis bei einer 8-Bit-Multiplikation immer im AX-Register gespeichert, bei einer 16-Bit-Multiplikation in den Registern DX:AX. Der höherwertige Teil wird dabei vom DX-Register aufgenommen, der niederwertige Teil vom AX-Register.

Beispiel:

 org 100h
 start:
   mov ax, 350h
   mul word[zahl]
   mov ah, 4Ch
   int 21h
 section .data
   zahl     DW 750h
   ergebnis DD 0h

Für Linux:

section .text
global _start
_start:
  mov eax, 350h
  mov ebx, 750h
  mul ebx ; multiply eax with ebx, put the breakpoint here

  ; EXIT
  mov eax, 1
  mov ebx, 1
  int 80h

Wie Sie sehen, besitzt der Zeiger zahl das Präfix word. Dies benötigt der Assembler, da sich der Opcode für den mul-Befehl unterscheidet, je nachdem, ob es sich beim Quelloperand um einen 8-Bit- oder einen 16-Bit-Operand handelt. Wenn der Zieloperand 8 Bit groß ist, dann muss der Assembler den Befehl in Opcode F6h übersetzen, ist der Zieloperand dagegen 16 Bit groß, muss der Assembler den Befehl in den Opcode F7h übersetzen.

Ist der höherwertige Anteil des Ergebnisses 0, so werden Carry Flag und Overflow Flag gelöscht, ansonsten werden sie auf 1 gesetzt. Das Sign Flag, Zero Flag, Auxilary Flag und Parity Flag werden nicht verändert.

In der Literatur wird erstaunlicherweise oft „vergessen“, dass der imul-Befehl im Gegensatz zum mul-Befehl in drei Varianten existiert. Vielleicht liegt dies daran, dass diese Erweiterung erst mit der 80186-CPU eingeführt wurde (und stellt daher eine der wenigen Neuerungen der 80186-CPU dar). Aber wer programmiert heute noch für den 8086/8088?

Die erste Variante des imul-Befehls entspricht der Syntax des mul-Befehls:

 imul Quelloperand

Eine Speicherstelle oder ein allgemeines Register wird entweder mit dem AX-Register oder dem Registerpaar DX:AX multipliziert.

Die zweite Variante des imul-Befehls besitzt zwei Operanden und hat die folgende Syntax:

 imul Zieloperand, Quelloperand

Der Zieloperand wird mit dem Quelloperand multipliziert und das Ergebnis im Zieloperanden abgelegt. Der Zieloperand muss ein allgemeines Register sein, der Quelloperand kann entweder ein Wert, ein allgemeines Register oder eine Speicherstelle sein.

Die dritte Variante des imul-Befehls besitzt drei(!) Operanden. Damit widerlegt der Befehl die häufig geäußerte Aussage, dass die Befehle der Intel-80x86-Plattform entweder keinen, einen oder zwei Operanden besitzen können:

 imul Zieloperand, Quelloperand1, Quelloperand2

Bei dieser Variante wird der erste Quelloperand mit dem zweiten Quelloperanden multipliziert und das Ergebnis im Zieloperanden gespeichert. Der Zieloperand und der erste Quelloperand müssen entweder ein allgemeines Register oder eine Speicherstelle sein, der zweite Quelloperand dagegen muss ein Wert sein.

Bitte beachten Sie, dass der DOS-Debugger nichts mit dem Opcode der zweiten und dritten Variante anfangen kann, da er nur Befehle des 8086/88er-Prozessors versteht. Weiterhin sollten Sie beachten, dass diese Varianten nur für den imul-Befehl existieren – also auch nicht für den idiv-Befehl.

Die Division

Die Befehle div (Unsigned Divide) und idiv (Integer Division) sind für die Division zuständig. Die Syntax der beiden Befehle entspricht der von mul und imul:

div  Quelloperand
idiv Quelloperand

Der Zieloperand wird dabei durch den Quelloperand geteilt. Der Quelloperand muss entweder eine Speicherstelle oder ein allgemeines Register sein. Der Zieloperand befindet sich immer im AX-Register oder im DX:AX-Register. In diesen Registern wird auch das Ergebnis gespeichert: Bei einer 8-Bit-Division befindet sich das Ergebnis im AL-Register und der Rest im AH-Register, bei der 16-Bit-Division befindet sich das Ergebnis im AX-Register und der Rest im DX-Register. Da die Division nicht kommutativ ist, dürfen die Operanden nicht vertauscht werden.

Beispiel:

 org 100h
  start:
    mov dx,0010h
    mov ax,0A00h
    mov cx,0100h
    div cx         ; DX:AX / CX
    mov ah, 4Ch
    int 21h

Das Programm teilt das DX:AX-Register durch das CX-Register und legt das Ergebnis im AX-Register ab.

Da das Ergebnis nur im 16 Bit breiten AX-Register gespeichert wird, kann es passieren, dass ein Überlauf stattfindet, weil das Ergebnis nicht mehr in das Register passt. Einen Überlauf erzeugt beispielsweise der folgende Programmausschnitt:

mov dx, 5000h
mov ax, 0h
mov cx, 2h
div cx

Das Ergebnis ist größer als 65535. Wenn die CPU auf einen solchen Überlauf stößt, löst sie eine Divide Error Exception aus. Dies führt dazu, dass der Interrupt 0 aufgerufen und eine Routine des Betriebssystems ausgeführt wird. Diese gibt die Fehlermeldung „Überlauf bei Division“ aus und beendet das Programm. Auch die Divison durch 0 führt zum Aufruf der Divide Error Exception.

Wenn der Prozessor bei einem Divisionsüberlauf und bei einer Division durch 0 eine Exception auslöst, welche Bedeutung haben dann die Statusflags? Die Antwort lautet, dass sie bei der Division tatsächlich keine Rolle spielen, da sie keinen definierten Zustand annehmen.

Logische Operationen

In Allgemeinbildungsquiz findet man manchmal die folgende Frage: „Was ist die kleinste adressierbare Einheit eines Computers?“ Überlegen Sie einen Augenblick. Wenn Sie nun mit Bit geantwortet haben, so liegen Sie leider falsch. Der Assembler bietet tatsächlich keine Möglichkeit, ein einzelnes Bit im Arbeitsspeicher zu manipulieren. Die richtige Antwort ist deshalb, dass ein Byte die kleinste adressierbare Einheit eines Computers ist. Dies gilt im Großen und Ganzen auch für die Register. Nur das Flag-Register bildet hier eine Ausnahme, da hier einzelne Bits mit Befehlen wie sti oder clc gesetzt und zurückgesetzt werden können.

Um dennoch einzelne Bits anzusprechen, muss der Programmierer deshalb den Umweg über logische Operationen wie AND, OR, XOR und NOT gehen. Wie die meisten höheren Programmiersprachen sind auch dem Assembler diese Befehle bekannt.

Die nachfolgende Tabelle zeigt nochmals die Wahrheitstabelle der logischen Grundverknüpfungen:

A B AND OR XOR
0 0 0 0 0
0 1 0 1 1
1 0 0 1 1
1 1 1 1 0


Ein kleines Beispiel zur Verdeutlichung:

    10110011
AND 01010001
------------
    00010001

Die logischen Verknüpfungen haben die folgende Syntax:

and Zieloperand, Quelloperand
or  Zieloperand, Quelloperand
xor Zieloperand, Quelloperand

Das Ergebnis der Verknüpfung wird jeweils im Zieloperanden gespeichert. Der Quelloperand kann ein Register, eine Speicherstelle oder ein Wert sein, der Zieloperand kann ein Register oder eine Speicherstelle sein (wie immer dürfen nicht sowohl Ziel- wie auch Quelloperand eine Speicherstelle sein).

Das Carry und Overflow Flag werden gelöscht, das Vorzeichen Flag, das Null Flag und das Parity Flag werden in Abhängigkeit des Ergebnisses gesetzt. Das Auxiliary Flag hat einen undefinierten Zustand.

Die NOT-Verknüpfung dreht den Wahrheitswert der Bits einfach um. Dies entspricht dem Einerkomplement einer Zahl. Der not-Befehl hat deshalb die folgende Syntax:

 not Zieloperand

Der Zieloperand kann eine Speicherstelle oder ein Register sein. Die Flags werden nicht verändert.

In vielen Assemblerprogrammen sieht man übrigens häufig eine Zeile wie die folgende:

 xor ax, ax

Da hier Quell- und Zielregister identisch sind, können die Bits nur entweder den Wert 0, 0 oder 1, 1 besitzen. Wie aus der Wahrheitstabelle ersichtlich, ist das Ergebnis in beiden Fällen 0. Der Befehl entspricht damit

 mov ax, 0

Der mov-Befehl ist drei Byte lang, während die Variante mit dem xor-Befehl nur zwei Byte Befehlscode benötigt.

Schiebebefehle

Schiebebefehle verschieben den Inhalt eines Registers oder einer Speicherstelle bitweise. Mit den Schiebebefehlen lässt sich eine Zahl mit 2n multiplizieren bzw. dividieren. Dies geschieht allerdings wesentlich schneller und damit effizienter als mit den Befehlen mul und div.

Der 8086 kennt vier verschiedene Schiebeoperationen: Links- und Rechtsverschiebungen sowie arithmetische und logische Schiebeoperationen. Bei arithmetischen Schiebeoperationen wird das Vorzeichen mit berücksichtigt:

  • sal (Shift Arithmetic Left): arithmetische Linksverschiebung
  • sar (Shift Arithmetic Right): arithmetische Rechtsverschiebung
  • shl (Shift Logical Left): logische Linksverschiebung
  • shr (Shift Logical Right): logische Rechtsverschiebung

Die Schiebebefehle haben immer die folgende Syntax:

 sal / sar / shl / shr Zieloperand, Zähloperand

Der Zieloperand gibt an, welcher Wert geschoben werden soll. Der Zähloperand gibt an, wie oft geschoben werden soll. Der Zieloperand kann eine Speicherstelle oder ein Register sein. Der Zähloperand kann ein Wert oder das CL-Register sein.

Da der Zähloperand 8 Bit groß ist, kann er damit Werte zwischen 0 und 255 annehmen. Da ein Register allerdings maximal 32 Bit breit sein kann, sind nur Werte von 1 bis 31 sinnvoll. Alles was darüber ist, würde bedeuten, dass sämtliche Bits an den Enden hinausgeschoben werden. Der 8086-CPU war dies noch egal: Sie verschob auch Werte die größer als 31 sind, was allerdings entsprechend lange Ausführungszeiten nach sich ziehen konnte. Ab der 80286 werden deshalb nur noch die ersten 5 Bit beachtet, so dass maximal 31 Verschiebungen durchgeführt werden.

Das zuletzt herausgeschobene Bit geht zunächst einmal nicht verloren: Vielmehr wird es zunächst als Carry Flag gespeichert. Bei der logischen Verschiebung wird eine 0 nachgeschoben, bei der arithmetischen Verschiebung das Most Significant Bit.

Dies erscheint zunächst nicht schlüssig, daher wollen wir und die arithmetische und logischen Verschiebung an der Zahl –10 klar anschauen. Wir wandeln die Zahl zunächst in die Binärdarstellung um und bilden dann das Zweierkomplement:

 00001010 (10)
 11110101 (Einerkomplement)
 11110110 (Zweierkomplement) 

Abb 1 – Die Schiebebefehle. Der Ausgangszustand ist in der ersten Zeile dargestellt.

Abbildung 1 zeigt nun eine logische und eine arithmetische Rechtsverschiebung. Bei der logischen Rechtsverschiebung geht das Vorzeichen verloren, da eine 0 nachgeschoben wird. Für eine negative Zahl muss deshalb eine arithmetische Verschiebung erfolgen, bei dem immer das Most Significant Bit erhalten bleibt (Abbildung 1a). Da in unserem Fall das Most Significant Bit eine 1 ist, wird eine 1 nachgeschoben (Abbildung 1b). Ist das Most Significant Bit hingegen eine 0, so wird eine 0 nachgeschoben.

Sehen wir uns nun die Linksverschiebung an: Wir gehen wieder von der Zahl –10 aus. Abbildung 1c veranschaulicht arithmetische und logische Linksverschiebung. Wie Sie erkennen können, haben logische und arithmetische Verschiebung keine Auswirkung auf das Vorzeichenbit. Aus diesem Grund gibt es auch zwischen SHL und SAL keinen Unterschied! Beide Befehle sind identisch!

Wie wir mit dem Debugger nachprüfen können, haben beide den selben Opcode:

 
 sal al,1
 shl al,1
 

Beim Debugger erhält man:

-r
AX=0000  BX=0000  CX=0008  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0D36  ES=0D36  SS=0D36  CS=0D36  IP=0100   NV UP EI PL NZ NA PO NC
0D36:0100 D0E0          SHL     AL,1
-t
AX=0000  BX=0000  CX=0008  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0D36  ES=0D36  SS=0D36  CS=0D36  IP=0102   NV UP EI PL ZR NA PE NC
0D36:0102 D0E0          SHL     AL,1

Seit der 80386-CPU gibt es auch die Möglichkeit den Inhalt von zwei Registern zu verschieben (Abbildung 2). Die Befehle SHLD und SHRD haben die folgende Syntax:

 shld / shrd Zieloperand, Quelloperand, Zähleroperand
Abb 2 – Links- und Rechtsshift mit SHLD und SHRD

Der Zieloperand muss bei beiden Befehlen entweder ein 16- oder 32-Bit-Wert sein, der sich in einer Speicherstelle oder einem Register befindet. Der Quelloperand muss ein 16- oder 32-Bit-Wert sein und darf sich nur in einem Register befinden. Der Zähloperand muss ein Wert oder das CL-Register sein.

Rotationsbefehle

Bei den Rotationsbefehlen werden die Bits eines Registers ebenfalls verschoben, fallen aber nicht wie bei den Schiebebefehlen an einem Ende heraus, sondern werden am anderen Ende wieder hinein geschoben. Es existieren vier Rotationsbefehle:

  • rol: Linksrotation
  • ror: Rechtsrotation
  • rcl: Linksrotation mit Carry Flag
  • rcr: Rechtsrotation mit Carry Flag

Die Rotationsbefehle haben die folgende Syntax:

 rol / ror /rcl /rcr Zieloperand, Zähleroperand
Abb. 3 – Die Rotationsbefehle. Der Ausgangszustand ist in der ersten Zeile dargestellt.

Der Zähleroperand gibt an, um wie viele Bits der Zieloperand verschoben werden soll. Der Zieloperand kann entweder ein allgemeines Register oder eine Speicherstelle sein. Der Zähleroperand kann ein Wert sein oder das CL-Register.

Bei der Rotation mit rol und ror wird das Most bzw. Least Significant Bit, das an das andere Ende der Speicherstelle oder des Registers verschoben wird, im Carry Flag abgelegt (siehe Abbildung 3).

Die Befehle rcl und rcr nutzen das Carry Flag für die Verschiebung mit: Bei jeder Verschiebung mit dem rcl-Befehl wird das Most Significant Bit zunächst in das Carry Bit verschoben und der Inhalt des Carry Flags in das Least Significant Bit. Beim rcr ist es genau andersherum: Hier wird das Least Significant Bit zunächst in das Carry Flag geschoben und dessen Inhalt in das Most Significant Bit (siehe Abbildung 2).

Wie bei den Schiebebefehlen berücksichtigt der Prozessor (ab 80286) lediglich die oberen 5 Bit des CL-Registers und ermöglicht damit nur eine Verschiebung zwischen 0 und 31.


Sprünge und Schleifen

Unbedingte Sprünge

Unbedingte Sprünge entsprechen im Prinzip einem GOTO in höheren Programmiersprachen. Im Assembler heißt der Befehl allerdings jmp (Abkürzung für jump; dt. „springen“).

Das Prinzip des Sprungbefehls ist sehr einfach: Um die Bearbeitung des Programms an einer anderen Stelle fortzusetzen, wird das IP-Register mit der Adresse des Sprungziels geladen. Da die Adresse des IP-Registers zusammen mit dem CS-Register immer auf den nächsten Befehl zeigt, den der Prozessor bearbeiten wird, setzt er die Bearbeitung des Programms am Sprungziel fort.

Der Befehl hat die folgende Syntax:

  jmp Zieloperand

Je nach Sprungart kann der Zieloperand ein Wert und/oder ein Register bzw. eine Speicherstelle sein. Im Real Mode gibt es drei verschiedene Sprungarten:

  • Short Jump: Der Sprung erfolgt innerhalb einer Grenze von -128 bis +127 Byte.
  • Near Jump: Der Sprung erfolgt innerhalb der Grenzen des Code-Segments.
  • Far Jump: Der Sprung erfolgt innerhalb des gesamten im Real Mode adressierbaren Speichers.

Bevor wir noch genauer auf die Syntax eingehen, sehen wir uns ein Beispiel an, das einen Short Jump benutzt. Bei einem Short Jump darf der Zieloperand nur als Wert angegeben werden. Ein Register oder eine Speicherstelle ist nicht erlaubt.

Der Wert wird als relative Adresse (bezogen auf die Adresse des nächsten Befehls) interpretiert. Das heißt, dem Inhalt des IP-Registers wird der Wert des Zieloperanden hinzuaddiert. Ist der Wert des Zieloperanden positiv, so liegt das Sprungziel hinter dem jmp-Befehl, ist der Wert negativ, so befindet sich das Sprungziel vor dem jmp-Befehl.

Das folgende Programm führt einen Short Jump aus:

  org 100h
  start:
         jmp short ziel
         ;ein paar unsinnige Befehle:
         mov ax, 05h
         inc ax
         mov bx, 07h
         xchg ax, bx
  ziel:  mov cx, 05h
         mov ah,4Ch
         int 21h

Das Sprungziel ist durch ein Label gekennzeichnet (hier: ziel). Damit kann der Assembler die (absolute oder relative) Adresse für das Sprungziel ermitteln und in den Opcode einsetzen.

Wenn Sie das Programm mit dem Debugger ausführen, bekommen Sie die folgende Ausgabe:

-r
AX=0000  BX=0000  CX=0011  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0D3E  ES=0D3E  SS=0D3E  CS=0D3E  IP=0100   NV UP EI PL NZ NA PO NC
0D3E:0100 EB08          JMP     010A
-t

Lassen Sie sich nicht von der Ausgabe JMP 010A irritieren. Wie Sie am Opcode EB08 sehen können, besitzt der Zieloperand tatsächlich den Wert 08h; EBh ist der Opcode des (short) jmp-Befehls.

Die nächste Adresse nach dem jmp-Befehle beginnt mit 0102h. Addiert man hierzu den Wert 08h, so erhält man die Adresse des Sprungziels: 010Ah. Mit dieser Adresse lädt der Prozessor das IP-Register:

AX=0000  BX=0000  CX=0011  DX=0000  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=0D3E  ES=0D3E  SS=0D3E  CS=0D3E  IP=010A   NV UP EI PL NZ NA PO NC
0D3E:010A B90500        MOV     CX,0005
-t

Wie Sie nun sehen, zeigt nun die Registerkombination CS:IP auf den Befehl mov cx,0005h, wo der Prozessor mit der Bearbeitung fortfährt.

Beachten Sie, dass NASM nicht automatisch erkennt, ob es sich um einen Short oder Near Jump handelt (im Gegensatz zum Turbo Assembler und zum Macro Assembler ab Version 6.0). Fehlt das Schlüsselwort short, so übersetzt dies der Assembler in einen Near Jump (was zur Folge hat, dass der Opcode um ein Byte länger ist).

Beim Short Jump darf der Zieloperand nur eine relative Adresse besitzen, wohingegen beim Near Jump sowohl eine relative wie auch eine absolute Adresse möglich ist. Dies ist auch leicht einzusehen, wenn Sie sich nochmals den Opcode ansehen: Da nur ein Byte für die Adressinformation zu Verfügung steht, kann der Befehl keine 16bittige absolute Adresse erhalten.

Wenn der Near Jump-Befehl aber sowohl ein relative als auch eine absolute Adresse erhalten kann, wie kann der Assembler dann unterscheiden, in welchen Opcode er den Befehl übersetzen muss? Nun, wenn der Zieloperand ein Wert ist, handelt es sich um eine relative Adresse, ist der Zieloperand in einem Register oder einer Speicherstelle abgelegt, so handelt es sich um eine absolute Adresse.

Beim Far Jump wird sowohl das CS Register wie auch das IP Register neu geladen. Damit kann sich das Sprungziel auch in einem anderen Segment befinden. Die Adresse kann entweder ein Wert sein oder ein Registerpaar. In beiden Fällen handelt es sich um eine absolute Adresse. Eine relative Adresse existiert beim Far Jump nicht.

Das folgende Beispiel zeigt ein Programm mit zwei Codesegmenten, bei dem ein Far Jump in das zweite Segment erfolgt:

 segment code1 
   start:
         jmp word far ziel

  segment code2 
  ziel:  mov ah, 4Ch
         int 21h

Bitte vergessen Sie nicht, dass com-Dateien nur ein Codesegment besitzen dürfen und deshalb das Programm als exe-Datei übersetzt werden muss (siehe Kapitel Das erste Assemblerprogramm / EXE-Dateien).

Das Schlüsselwort word wird benutzt, da es sich um eine 16-Bit-Adresse handelt. Ab der 80386 CPU sind auch 32-Bit-Adressen möglich, da das IP Register 32 Bit breit ist (die Segmentregister sind auch bei der 80386 CPU nur 16 Bit groß).

Bedingte Sprünge

Mit bedingten Sprüngen lassen sich in Assembler Kontrollstrukturen vergleichbar mit IF … ELSE Anweisungen in Hochsprachen ausdrücken. Im Gegensatz zum unbedingten Sprung wird dazu nicht in jedem Fall ein Sprung durchgeführt, sondern in Abhängigkeit der Flags.

Natürlich müssen dazu erst die Flags gesetzt werden. Dies erfolgt mit dem cmp- oder test-Befehl.

Der cmp-Befehl hat die folgende Syntax:

 cmp operand1, operand2

Wie Sie sehen, existiert weder ein Quell- noch ein Zieloperand. Dies liegt daran, dass keiner der beiden Operanden verändert wird. Vielmehr entspricht der cmp- einem sub-Befehl, lediglich mit dem Unterschied, dass nur die Flags verändert werden.

Der test-Befehl führt im Gegensatz zum cmp-Befehl keine Subtraktion, sondern eine UND Verknüpfung durch. Der test-Befehl hat die folgende Syntax:

 test operand1, operand2

Mit dem test-Befehl kann der bedingte Sprung davon abhängig gemacht werden, ob ein bestimmtes Bit gesetzt ist oder nicht.

Für Vorzeichenbehaftete Zahlen müssen andere Sprungbefehle benutzt werden, da die Flags entsprechend anders gesetzt sind. Die folgende Tabelle zeigt sämtliche bedingte Sprünge für vorzeichenlose Zahlen:

Sprungbefehl Abkürzung für Zustand der Flags Bedeutung
ja jump if above CF = 0 und ZF = 0 Operand1 > Operand2
jae jump if above or equal CF = 0 Operand1 >= Operand2
jb jump if below CF = 1 Operand1 < Operand2
jbe jump if below or equal CF = 1 oder ZF = 1 Operand1 <= Operand2
je jump if equal ZF = 1 Operand1 = Operand2
jne jump if not equal ZF = 0 Operand1 <> Operand2, also Operand1 != Operand2

Wie Sie an der Tabelle erkennen können, ist jeder Vergleich an eine charakteristische Kombination der Carry Flags und des Zero Flags geknüpft. Mit dem Zero Flag werden die Operanden auf Gleichheit überprüft. Sind beide Operanden gleich, so ergibt eine Subtraktion 0 und das Zero Flag ist gesetzt. Sind beide Operanden dagegen ungleich, so ist das Ergebnis einer Subtraktion bei vorzeichenlosen Zahlen einen Wert ungleich 0 und das Zero Flag ist nicht gesetzt.

Kleiner und größer werden durch das Carry Flag angezeigt: Wenn Zieloperand kleiner als der Quelloperand ist, so wird das Most Significant Bit „herausgeschoben“. Dieses Herausschieben wird durch das Carry Flag angezeigt. Das folgende Beispiel zeigt eine solche Subtraktion:

    0000 1101 (Operand1)
-   0001 1010 (Operand2)
Ü 1 111   1
-------------
    1111 0011

Der fett markierte Übertrag stellt den Übertrag dar, der das Carry Flag setzt. Wie Sie an diesem Beispiel sehen, lässt sich das Carryflag also auch dazu benutzen um festzustellen, ob der Zieloperand kleiner oder größer als der Quelloperand ist.

Mit diesem Wissen können wir uns nun herleiten, warum die Flags so gesetzt sein müssen und nicht anders:

  • ja: Hier testen wir mit dem Carry Flag, ob die Bedingung Operand1 > Operand2 erfüllt ist. Da die beiden Operanden nicht gleich sein sollen, testen wir außerdem noch das Zero Flag.
  • jae: Der Befehl entspricht dem ja-Befehl, mit dem Unterschied, dass wir nicht das Zero Flag testen, da beide Operanden gleich sein dürfen.
  • jb : Hier testen wir mit dem Carry Flag, ob die Bedingung Operand1 < Operand2 erfüllt ist. Aber wieso testet jb nicht auch das Zero Flag? Sehen wir uns dies an einem Beispiel an: Angenommen der erste und zweite Operand haben jeweils den Wert 5. Wenn wir beide voneinander subtrahieren, so erhalten wir als Ergebnis 0. Damit ist das Zero Flag gesetzt und das Carry Flag ist 0. Folge: Es reicht aus zu prüfen, ob das Carry Flag gesetzt ist oder nicht. Die beiden Operanden müssen in diesem Fall ungleich sein.
  • jbe: Mit dem Carry Flag testen wir wieder die Bedingung Operand1 < Operand2 und mit dem Zero Flag auf Gleichheit. Beachten Sie, dass entweder das Carry Flag oder das Zero Flag gesetzt werden müssen und nicht beide gleichzeitig gesetzt sein müssen.

Die Befehle je und jne dürften nun selbsterklärend sein.

Wie bereits erwähnt, unterscheiden sich die bedingten Sprünge mit vorzeichenbehafteten Zahlen von Sprüngen mit vorzeichenlosen Zahlen, da hier die Flags anders gesetzt werden:

Sprungbefehl Abkürzung für Zustand der Flags Bedeutung
jg jump if greater ZF = 0 und SF = OF Operand1 > Operand2
jge jump if greater or equal SF = OF Operand1 >= Operand2
jl jump if less ZF = 0 und SF <> OF Operand1 < Operand2
jle jump if less or equal ZF = 1 oder SF <> OF Operand1 <= Operand2

Die Befehle je und jne sind bei vorzeichenlosen und vorzeichenbehafteten Operatoren identisch und tauchen deshalb in der Tabelle nicht noch einmal auf.

Darüber hinaus existieren noch Sprungbefehle, mit denen sich die einzelnen Flags abprüfen lassen:

Sprungbefehl Abkürzung für Zustand der Flags
jc jump if carry CF = 1
jnc jump if not carry CF = 0
jo jump if overflow OF = 1
jno jump if not overflow OF = 0
jp jump if parity PF = 1
jpe jump if parity even PF = 1
jpo jump if parity odd PF = 0

Es fällt auf, dass sowohl der jb und jae wie auch jc und jnc testen, ob das Carry Flag gesetzt bzw. gelöscht ist. Wie kann der Prozessor also beide Befehle unterscheiden? Die Antwort lautet: Beide Befehle sind Synonyme. Die Befehle jb und jc besitzen beide den Opcode 72h, jae und jnc den Opcode 73h. Das gleiche gilt auch für jp und jpe, die ebenfalls Synonyme sind.

Das folgende Beispiel zeigt eine einfache IF-THEN-Anweisung:

 org 100h
 start:
   mov dx,meldung1
   mov ah,9h
   int 021h
   mov ah, 01h    ;Wert über die Tastatur einlesen
   int 021h
   cmp al, '5'
   jne ende
   mov dx,meldung2
   mov ah,9h
   int 021h
 ende:
   mov ah,4Ch
   int 21h
 section .data
   meldung1: db 'Bitte geben Sie eine Zahl ein:', 13, 10, '$'
   meldung2: db 13, 10, 'Sie haben die Zahl 5 eingegeben.', 13, 10, '$'

Nachdem Sie das Programm gestartet haben, werden Sie aufgefordert, eine Zahl einzugeben. Wenn Sie die Zahl 5 eingeben, erscheint die Meldung Sie haben die Zahl 5 eingeben.

Wie Sie sehen, prüfen wir nicht Gleichheit, sondern auf Ungleichheit. Der Grund hierfür ist, dass wir die nachfolgende Codesequenz ausführen wollen, wenn der Inhalt des AL Registers 5 ist. Da wir aber nur die Möglichkeit eines bedingten Sprungs haben, müssen wir überprüfen ob AL ungleich 5 ist.

Das folgende Programm zeigt die Implementierung einer IF-THEN-ELSE-Anweisung im Assembler:

 org 100h
 start:
     mov dx,meldung1
     mov ah,9h
     int 021h
     mov ah, 01h    ;Wert über die Tastatur einlesen
     int 021h
     cmp al, '5'

     ja l1
     mov dx,meldung2
     mov ah,9h
     int 021h
     jmp ende

 l1: mov dx,meldung3
     mov ah,9h
     int 021h
 ende:
     mov ah,4Ch
     int 21h
 section .data
 meldung1: db 'Bitte geben Sie eine Zahl ein:', 13, 10, '$'
 meldung2: db 13, 10, 'Sie haben eine Zahl kleiner oder gleich 5 eingegeben', 13, 10, '$' 
 meldung3: db 13, 10, 'Sie haben eine Zahl groesser 5 eingegeben' , 13, 10, '$'

Auch hier besitzt der bedingte Sprung eine „umgekehrte Logik“. Beachten Sie den unbedingten Sprung. Er verhindert, dass auch der ELSE-Zweig des Programms ausgeführt wird und darf deshalb nicht weggelassen werden.

Schleifen

Wie in jeder anderen Sprache auch hat der Assembler Schleifenbefehle. Eine mit FOR vergleichbare Schleife lässt sich mit loop realisieren. Der loop-Befehl hat die folgende Syntax:

  loop adresse

Bei der Adresse handelt es sich um eine relative Adresse, die 8 Bit groß ist. Da das Offset deshalb nur einen Wert von –128 bis +127 einnehmen darf, kann eine Schleife maximal 128 Byte Code erhalten – dies gilt auch für eine 32-Bit-CPU wie beispielsweise den Pentium IV. Umgehen lässt sich diese Beschränkung, wenn man die Schleife mit den Befehlen der bedingten Sprünge konstruiert, beispielsweise mit einem Near Jump.

Die Anzahl der Schleifendurchgänge befindet sich immer im CX-Register (daher auch der Name „Counter Register“). Bei jedem Durchgang wird der Inhalt um 1 verringert. Ist der Inhalt des CX-Registers 0 wird die Schleife verlassen und der nächste Befehl bearbeitet. Der Befehl ließe sich also auch mit der folgenden Befehlsfolge nachbilden:

dec cx
jne adresse

Wenn der Schleifenzähler um mehr als 1 verringert werden soll, kann der sub-Befehl verwendet werden. Wenn der Schleifenzähler um 2 oder 3 verringert wird, werden die meisten Assemblerprogrammierer stattdessen auf den dec-Befehl zurückgreifen, da dieser schneller als der sub-Befehl ist.

Das nachfolgende Programm berechnet 7!, die Fakultät von 7:

 org 100h
 start:
   mov cx, 7
   mov ax, 1
   mov bx, 1
 schleife:
   mul bx
   inc bx
   loop schleife
   mov ah, 4Ch
   int 21h

Mit dem Befehl mov cx, 7 wird der Schleifenzähler initialisiert. Insgesammt wird die Schleife 7-mal durchlaufen, im ersten Durchlauf mit CX=7 und BX=1, im letzten mit CX=1 und BX=7.

Das Ergebnis kann mit jedem Debugger überprüft werden (AX=13B0).

Neben den Normalen FOR-Schleifen existieren in den meisten Hochsprachen auch noch WHILE- oder „REPEAT ... UNTIL“-Schleifen. Im Assembler kann dazu die loope (loop if equal) und loopne (loop if not equal) als Befehl benutzt werden. Beide Befehle prüfen nicht nur, ob das CX-Register den Wert 0 erreicht hat, sondern zusätzlich noch das Zeroflag: Der loope durchläuft die Schleife solange, wie der Zeroflag gesetzt ist, der loopne-Befehl dagegen, solange das Zeroflag ungleich 0 ist.


Unterprogramme und Interrupts

Der Stack

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

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

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.


Stringbefehle

Maschinensprache-Befehle zur Stringverarbeitung

Die CPU der 80x86-Familie besitzen spezielle Befehle, mit denen die Verarbeitung von Massendaten, zum Beispiel von Zeichenketten, optimiert werden kann.

Beispiel: Kopieren Byteweise ab Adresse SI nach DI

        ;
        MOV        CX,40   ; Zeichenanzahl
 SCHL:  MOV        AL,[SI]
        MOV        [DI],AL
        INC        SI
        INC        DI
        LOOP       SCHL

In jedem Schleifendurchlauf muss der Prozessor die gleichen fünf Befehle immer neu lesen und decodieren. Ersetzt man obige Befehle durch

        ;
        MOV        CX,40   ; Zeichenanzahl
        REP MOVSB

wird das Programm kürzer und läuft auch viel schneller.

Hierbei gibt es folgende Befehle:

Übertragungsbefehle:  
MOVSx* Kopiert ein Zeichen von der Adresse ds:si/esi an die Adresse es:di/edi und inkrementiert/dekrementiert** si/esi und di/edi um die Zeichengröße.
LODSx* Kopiert ein Zeichen von der Adresse ds:si/esi in den Akkumulator (al/ax/eax) und inkrementiert/dekrementiert** si/edi um die Zeichengröße.
STOSx* Kopiert ein Zeichen aus dem Akkumulator an die Adresse es:di/edi und inkrementiert/dekrementiert** di/edi um die Zeichengröße.
Vergleichsbefehle:  
SCASx* Vergleicht ein Zeichen von der Adresse es:di/edi mit den Akkumulator (al/ax/eax) und setzt die Flags wie cmp. Inkrementiert/dekrementiert** di/edi um die Zeichengröße.
CMPSx* Vergleicht ein Zeichen von der Adresse ds:si/esi mit einem von der Adresse es:di/edi und setzt die Flags wie cmp. Inkrementiert/dekrementiert** si/esi und di/edi um die Zeichengröße.
Wiederholungspräfixbefehle:  
REP, REPE oder REPZ Führt den folgenden Stringbefehl solange durch und dekrementiert jedes mal cx/ecx um 1, bis cx/ecx null erreicht oder die Zero-Flag nicht gesetzt ist.
REPNE oder REPNZ Führt den folgenden Stringbefehl solange durch und dekrementiert jedes mal cx/ecx um 1, bis cx/ecx null erreicht oder die Zero-Flag gesetzt ist.
 
* x symbolisiert die Zeichengröße. B steht für einen Byte, W für ein Wort und D (im 32 bit Modus) für ein Doppelwort.

** Wenn die Direktion Flag gelöscht ist (erreichbar mit CLD), wird inkrementiert, ist sie gesetzt (erreichbar mit STD) wird dekrementiert.

Beispiel: Das erste Leerzeichen in einem String finden

       ;
       MOV    CX,SIZE STRING   ; Stringlänge
       CLD                     ; Richtung
       MOV    AL,20H           ; Quelloperand: Leerzeichen
 
       MOV    DI,SEG STRING    ; Segmentadresse
       MOV    ES,DI
       MOV    DI,OFFSET STRING ; Offsetadresse
 
       REPNE SCASB             ; Wiederholen solange ungleich AL
 
       JNZ    xxx              ; bis Ende durchsucht und kein Leerzeichen gefunden
       JZ     yyy              ; bis Ende durchsucht und Leerzeichen gefunden

Beispiel: Zwei Strings auf Gleichheit überprüfen

       ;
       CLD                      ; In Richtung steigender Adressen vergleichen
       MOV    CX,SIZE STRING1   ; Stringlänge
 
       MOV    SI,SEG STRING1    ; Segmentadresse
       MOV    DS,SI
       MOV    SI,OFFSET STRING1 ; Offsetadresse
 
       MOV    DI,SEG STRING2    ; Segmentadresse
       MOV    ES,DI
       MOV    DI,OFFSET STRING2 ; Offsetadresse
 
       REPE CMPSB               ; Wiederholen solange ungleich AL
 
       JNZ   xxx                ; Strings sind ungleich
       JZ    yyy                ; Strings sind gleich


Befehlsreferenz

Dieses Kapitel beschreibt die Assembler-Syntax in der Intelschreibweise.

Die Syntaxbeschreibungen bestehen jeweils aus drei Teilen: Dem Opcode in hexadezimal, der eigentlichen Syntax und dem Prozessor, seit dem der Befehl existiert. Der Opcode-Aufbau soll in Zukunft in einem eigenen Kapitel beschrieben werden. Die Syntax hat die folgenden Bedeutungen:

  • r8: Eines der folgenden 8-Bit-Register kann verwendet werden: AH, AL, BH, BL, CH, CL, DH oder DL
  • r16: Eines der folgenden 16-Bit-Register kann verwendet werden: AX, BX, CX oder DX; BP, SP, DI oder SI
  • r32: Eines der folgenden 32-Bit-Register kann verwendet werden: EAX, EBX, ECX oder EDX; EBP, ESP, EDI oder ESI
  • imm8: Ein Bytewert zwischen –128 bis +127
  • imm16: Ein Wortwert zwischen –32.768 bis +32.767
  • r/m8: Der Wert kann entweder ein allgemeines 8-Bit-Register (AH, AL, BH, BL, CH, CL, DH oder DL) oder ein Bytewert aus dem Arbeitsspeicher sein
  • r/m16: Der Wert kann entweder ein allgemeines 16-Bit-Register (AX, BX, CX oder DX; BP, SP, DI oder SI) oder ein Bytewert aus dem Arbeitsspeicher sein
  • SReg: Segmentregister


Der Aufbau des Opcodes hat die folgende Bedeutung:

  • ib: Operation bezieht sich auf 8 Bit Daten. (byte)
  • iw: Operation bezieht sich auf 16 Bit Daten. (word)
  • id: Operation bezieht sich auf 32 Bit Daten. (double word)
  • /0 bis /7, /r, r+b, r+w, r+d: Interne Information zum Aufbau des Maschinenbefehls


Wenn nicht explizit etwas anderes angegeben ist, haben die Flags die folgende Bedeutung:

  • OF – Overflow Flag / Überlauf
  • SF – Sign Flag / Vorzeichen
  • ZF – Zero Flag / Ist 1 falls das Resultat 0 ist, sonst 0
  • AF – Auxiliary Flag / Hilfs-Übertragsflag bei Übertrag von Bit 3 auf 4. Dies macht nur Sinn bei BCD-Zahlen
  • PF – Parity Flag / Paritätsflag
  • CF – Carry Flag / Übertrag


ADD (Add)

ADD addiert zu einem Speicherbereich oder einem Register einen festen Wert oder den Wert eines Registers.

04 ib       add AL, imm8       8086+
05 iw       add AX, imm16      8086+
80 /0 ib    add r/m8, imm8     8086+
81 /0 iw    add r/m16, imm16   8086+
83 /0 ib    add r/m16, imm8    8086+
00 /r       add r/m8, r8       8086+
01 /r       add r/m16, r16     8086+
02 /r       add r8, r/m8       8086+
03 /r       add r16, r/m16     8086+
05 id       add eax,imm32      80386+
81 /0 id    add r/m32, imm32   80386+
83 /0 ib    add r/m32, imm8    80386+
01 /r       add r/m32, r32     80386+
03 /r       add r32, r/m32     80386+

Flags:

  • OF, SF, ZF, AF, CF, PF

Beispiel:

ADD eax,10
ADD eax,ebx

ADC (Add with carry)

ADC addiert zu einem Speicherbereich oder einem Register einen festen Wert sowie das Carry-Flag oder den Wert eines Registers.

14 ib       adc AL, imm8       8086+
15 iw       adc AX, imm16      8086+
80 /2 ib    adc r/m8, imm8     8086+
81 /2 iw    adc r/m16, imm16   8086+
83 /2 ib    adc r/m16, imm8    8086+
10 /r       adc r/m8, r8       8086+
11 /r       adc r/m16, r16     8086+
12 /r       adc r8, r/m8       8086+
13 /r       adc r16, r/m16     8086+
15 id       adc EAX,imm32      80386+
83 /2 ib    adc r/m32, imm8    80386+
11 /r       add r/m32, r32     80386+
13 /r       add r32, r/m32     80386+

Flags:

  • OF, SF, ZF, AF, CF, PF

AND

Verknüpft die Operanden durch ein logisches AND.

24 ib       and AL, imm8       8086+
25 iw       and AX, imm16      8086+
80 /4 ib    and r/m8, imm8     8086+
81 /4 iw    and r/m16, imm16   8086+
83 /4 ib    and r/m16, imm8    8086+
20 /r       and r/m8, r8       8086+
21 /r       and r/m16, r16     8086+
22 /r       and r8, r/m8       8086+
23 /r       and r16, r/m16     8086+
25 id       and EAX, imm32     80386+
81 /4 id    and r/m32, imm32   80386+
21 /r       and r/m32, r32     80386+
83 /4 ib    and r/m32, imm8    80386+
23 /r       add r32, r/m32     80386+

Flags:

  • OF und CF werden gelöscht
  • SF, ZF und PF
  • AF ist undefiniert

CLC (Clear Carry Flag)

Löscht das Carry-Flag.

F8          clc                 8086+

Flags:

  • CF wird gelöscht

CLI (Clear Interrupt Flag)

Löscht das Interrupt-Flag. Die CPU bearbeitet keine Interrupt-Requests (Hardware-Interrupts) mehr.

FA          cli                 8086+

Flags:

  • IF wird gelöscht

CMC (Complement Carry Flag)

Dreht den Wahrheitswert des Carry-Flags um.

F4          cmc                 8086+

Flags:

  • CF -Wert wird invertiert; [ 0==>1 | 1==>0 ]

DEC (Decrement)

Subtrahiert 1 vom Zieloperanden.

FE /1       dec r/m8            8086+
FF /1       dec r/m16           8086+
48 r+w      dec r16             8086+
FF /1       dec r/m32           80386+
48 r+w      dec r32             80386+

Flags:

  • OF, SF, ZF, AF, PF
  • Das Carry-Flag wird nicht beeinflust

Beispiel:

DEC eax
DEC [eax]

DIV (Unsigned Divide)

Dividiert das AX-, DX:AX- oder EDX:EAX-Register durch den Quelloperanden und speichert das Ergebnis im AX-, DX:AX- oder EDX:EAX-Register.

F6 /6       div r/m8           8086+
F7 /6       div r/m16          8086+
F7 /6       div r/m32          80386+

Flags:

  • CF, OF, SF, ZF, AF und PF sind undefiniert

IMUL (Signed Multiply)

Multipliziert eine vorzeichenbehaftete Zahl.

F6 /5      imul r/m8               8086+
F7 /5      imul r/m16              8086+
F7 /5      imul r/m32              80386+
0F AF /r   imul r16, r/m16         80386+
0F AF /r   imul r32, r/m32         80386+
6B /r ib   imul r16, r/m16, imm8   80186+
6B /r ib   imul r32, r/m32, imm8   80386+
6B /r ib   imul r16, imm8          80186+
6B /r ib   imul r32, imm8          80386+
69 /r iw   imul r16, r/m16, imm16  80186+
69 /r id   imul r32, r/m32, imm32  80386+
69 /r iw   imul r16, imm16         80186+
69 /r id   imul r32, imm32         80386+

Flags:

  • Wenn imul nur einen Operanden hat, ist das Carry-Flag und das Overflow-Flag gesetzt, wenn ein Übertrag in die höherwertige Hälfte des Ergebnisses stattfindet. Passt das Ergebnis exakt in die untere Hälfte des Ergebnisses werden Carry- und Overflow-Flag gelöscht. Hat imul zwei oder drei Operanden, wird das Carry- und Overflow-Flag zu groß für den Zieloperanden, andernfalls wird es gelöscht
  • SF, ZF, AF und PF sind undefiniert

INC (Increment)

Addiert 1 zum Zieloperanden.

FE /0        inc r/m8        8086+
FF /0        inc r/m16       8086+
40 r+w       inc r16         8086+
FF /6        inc r/m32       80386+
40 r+d       inc r32         80386+

Flags:

  • OF, SF, ZF, AF, PF
  • Das Carry-Flag wird nicht beeinflusst

Beispiel:

INC eax
INC [eax]

INT (Interrupt)

INT löst einen Software-Interrupt aus. Dies ist vergleichbar mit dem Aufruf eines Unterprogramms (hier ein Interrupt-Handler). Über die allgemeinen CPU-Register können dem Interrupt-Handler Werte übergeben werden. Der Interrupt-Handler kann über diese Register auch Werte zurückliefern. Nach INT steht eine Interrupt-Nummer, die zwischen 00h und FFh liegt. Über diese kann man die Lage der Einsprungadresse für den entsprechenden Interrupt-Handler in der Interrupt-Vektor-Tabelle ermitteln. Der Interrupt-Befehl 3 dient beispielsweise als Haltepunkt beim Debuggen eines Programms. Dieser ist daher auch als Ein-Byte-Befehl zusätzlich vorhanden.

CC           int3            8086+
CD           int imm8        8086+

Flags:
(keine)

Beispiel:

MOV ah,4Ch;  Der Wert 4Ch (= Beenden des Programms) wird dem Interrupt-Handler übergeben  
INT 21h;     Der Interrupt-Handler wird aufgerufen. Die Interrupt-Nummer ist 21h (MS-DOS).

IRET (Interrupt Return)

Rückkehr aus dem Interrupt-Handler, welcher mit INT aufgerufen wurde. Dieser Befehl ist vergleichbar mit RET, jedoch werden hier die Flags restauriert.

CF           iret            8086+

Flags:
Alle Flag-Werte werden auf den Zustand vor dem Interrupt zurückgesetzt.

MOV (Move)

Mit dem MOV Befehl wird der zweite Operand in den ersten Operanden kopiert.

88 /r       mov r/m8, r8       8086+
89 /r       mov r/m16, r16     8086+
8A /r       mov r8, r/m8       8086+
8B /r       mov r16, r/m16     8086+
C6 /0       mov r/m8, imm8     8086+
C7 /0       mov r/m16, imm16   8086+
8C /r       mov r/m16, SReg    8086+
8E /r       mov SReg, r/m16    8086+
A0          mov al, moffs8     8086+
A1          mov ax, moffs16    8086+
A2          mov moffs8, al     8086+
A3          mov moffs16, ax    8086+

Flags:
(keine)

Beispiel:

mov eax,10
mov eax,ebx

MUL (unsigned Multiplication)

Multipliziert den Zieloperanden mit dem AL-, AX- oder EAX-Register. Das Ergebnis wird im AX-, DX:AX- oder EDX:EAX-Register abgelegt (abhängig von der Größe des Zieloperanden). Der höherwertige Anteil des Ergebnisses befindet sich im AH-, DX- bzw. EDX-Register.

F6 /4       mul r/m8           8086+
F7 /4       mul r/m16          8086+
F7 /4       mul r/m32          80386+

Flags:

  • OF und CF werden auf 0 gesetzt wenn der höherwertige Anteil des Ergebnisses 0 ist

NEG (Two’s Complement Negation)

Bildet das Zweierkomplement indem der Operand von 0 abgezogen wird.

F6/3        neg r/m8            8086+
F7/3        neg r/m16           8086+
F7/3        neg r/m32           80386+

Flags:

  • OF, SF, ZF, AF und PF
  • Setzt das Carry-Flag auf 0, wenn das Ergebnis 0 ist, andernfalls wird es auf 1 gesetzt.

NOP (No Operation)

Führt keine Aktion aus.

90         nop                 8086+

(Der Opcode entspricht xchg ax,ax bzw. xchg eax,eax.)

Flags:
(keine)

NOT

Dreht den Wahrheitswert der Bits um. Entspricht dem Einerkomplement.

F6 /2       not r/m8          8086+
F7 /2       not r/m16         8086+
F7 /2       not r/m32         80386+

Flags:
(keine)

OR

Verknüpft die Operanden durch ein logisches ODER.

0C ib       or AL, imm8       8086+
0D iw       or AX, imm16      8086+
80 /1 ib    or r/m8, imm8     8086+
81 /1 iw    or r/m16, imm16   8086+
83 /1 ib    or r/m16, imm8    8086+
08 /r       or r/m8, r8       8086+
09 /r       or r/m16, r16     8086+
0A /r       or r8, r/m8       8086+
0B /r       or r16, r/m16     8086+
0D id       or EAX, imm32     80386+
81 /1 id    or r/m32, imm32   80386+
09 /r       or r/m32, r32     80386+
83 /1 ib    or r/m32, imm8    80386+
0B /r       or r32, r/m32     80386+

Flags:

  • OF und CF werden gelöscht
  • SF, ZF und PF
  • AF ist undefiniert

SBB (Subtraction with Borrow)

SBB subtrahiert von einem Speicherbereich oder einem Register den Wert eines Speicherbereichs oder eines Registers und berücksichtigt dabei das Carry-Flag.

1C ib       sbb AL, imm8       8086+
1D iw       sbb AX, imm16      8086+
80 /3 ib    sbb r/m8, imm8     8086+
81 /3 iw    sbb r/m16, imm16   8086+
83 /3 ib    sbb r/m16, imm8    8086+
18 /r       sbb r/m8, r8       8086+
19 /r       sbb r/m16, r16     8086+
1A /r       sbb r8, r/m8       8086+
1B /r       sbb r16, r/m16     8086+
1D id       sbb EAX, imm32     80386+
81 /3 id    sbb r/m32, imm32   80386+
83 /3 ib    sbb r/m32, imm8    80386+
19 /r       sbb r/m32, r32     80386+
1B /r       sbb r32, r/m32     80386+

Flags:

  • OF, SF, ZF, AF, PF, CF

SHL (Shift Left)

Verschiebt alle Bits im Register um x Stellen nach links. Mathematisch wird der Wert im Register zum Beispiel beim Verschieben um eine Stelle mit 2 multipliziert. Beim Multiplizieren mit 2, 4 und so weiter sollte statt MUL besser dieser Befehl eingesetzt werden, da er von der CPU schneller abgearbeitet wird.

shl r8,x                    8086+
shl r16,x                   8086+
shl r32,x                  80386+

Flags:

  • CF

Beispiel:

 mov al,00000001b (al = 00000001b)
 shl al,1         (al = 00000010b)

SHR (Shift Right)

Verschiebt alle Bits im Register um x Stellen nach rechts. Mathematisch wird der Wert im Register zum Beispiel beim Verschieben um eine Stelle durch 2 dividiert.

shr r8,x                    8086+
shr r16,x                   8086+
shr r32,x                  80386+

Flags:

  • ZF?

Beispiel:

 mov al,00000010b (al = 00000010b)
 shr al,1         (al = 00000001b)

STC (Set Carry Flag)

Setzt das Carry-Flag.

F9          stc                8086+

Flags:

  • CF

STI (Set Interrupt Flag)

Setzt das Interrupt-Flag.

FB          sti                8086+

Flags:

  • IF

SUB (Subtract)

SUB subtrahiert von einem Speicherbereich oder einem Register den Wert eines Speicherbereichs oder eines Registers.

2C ib       sub AL, imm8       8086+
2D iw       sub AX, imm16      8086+
80 /5 ib    sub r/m8, imm8     8086+
81 /5 iw    sub r/m16, imm16   8086+
83 /5 ib    sub r/m16, imm8    8086+
28 /r       sub r/m8, r8       8086+
29 /r       sub r/m16, r16     8086+
2A /r       sub r8, r/m8       8086+
2B /r       sub r16, r/m16     8086+
2D id       sub EAX, imm32     80386+
81 /5 id    sub r/m32, imm32   80386+
83 /5 ib    sub r/m32, imm8    80386+
29 /r       sub r/m32, r32     80386+
2B /r       sub r32, r/m32     80386+

Flags:

  • OF, SF, ZF, AF, PF, CF

Beispiel:

SUB eax,10
SUB eax,ebx

XCHG (Exchange)

Der erste und zweite Operand wird vertauscht.

90+rw       xchg ax, r16       8086+
90+rw       xchg r16, ax       8086+
86 /r       xchg r/m8, r8      8086+
86 /r       xchg r8, r/m8      8086+
87 /r       xchg r/m16, r16    8086+
87 /r       xchg r16, r/m16    8086+

Flags:
(keine)

XOR

Verknüpft die Operanden durch ein logisches exklusives Oder (Antivalenz).

34 ib       xor AL, imm8       8086+
35 iw       xor AX, imm16      8086+
80 /6 ib    xor r/m8, imm8     8086+
81 /6 iw    xor r/m16, imm16   8086+
83 /6 ib    xor r/m16, imm8    8086+
30 /r       xor r/m8, r8       8086+
31 /r       xor r/m16, r16     8086+
32 /r       xor r8, r/m8       8086+
33 /r       xor r16, r/m16     8086+
35 id       xor EAX, imm32     80386+
81 /6 id    xor r/m32, imm32   80386+
31 /r       xor r/m32, r32     80386+
83 /6 ib    xor r/m32, imm8    80386+
33 /r       xor r32, r/m32     80386+

Flags:

  • OF und CF werden gelöscht
  • SF, ZF und PF
  • AF ist undefiniert


Befehlsliste

Befehlsliste i8086
Code Bezeichnung Flags Beschreibung
AAA ASCII adjust for addition AC  
AAD ASCII adjust for division PSZ
AAM ASCII adjust for multiply PSZ
AAS ASCII adjust for subtraktion AC
ADC Add with carry ACOPSZ Addiere zwei Operanden und CY
ADD Addition ACOPSZ Addition ohne Übertrag
AND And: logical conjunction COPSZ Logische Operation "UND"
CALL     Unterprogramm aufrufen
CBW Convert byte to word   AH wird mit Bit 7 von AL gefüllt. Code 98H - 10011000
CLC Clear carry flag C Lösche CF. Code F8H = 11111000
CLD Clear direction flag   Lösche DF. Code FCH = 11111100
CLI Clear interrupt enable flag   Sperrt maskierbare Interrupts. Code FAH = 11111010
CMC Complement carry flag C Komplementiere CF. Code F5H = 11110101
CMP Compare two operands ACOPSZ Logischer Vergleich: Die Operanden werden subtrahiert, die Flags gesetzt, das Ergebnis verworfen.
CMPSB Compare byte string ACOPSZ Das durch ES:[DI] adressierte Byte wird vom Operanden DS:[SI] subtrahiert, Flags gesetzt, das Ergebnis verworfen. SI und DI werden geändert: +1, wenn (DF)=0, sonst -1. Code 1010011w
CMPSW Compare word string ACOPSZ Das durch ES:[DI] adressierte Wort wird vom Operanden DS:[SI] subtrahiert, Flags gesetzt, das Ergebnis verworfen. SI und DI werden geändert: +2, wenn (DF)=0, sonst -2. Code 1010011w
CWD Convert word to doubleword   Vorzeichengerechte Erweiterung: DX wird mit Bit 15 von AX gefüllt. Code 99H = 10011001
DAA Decimal adjust for addition ACPSZ Korrigiere AL nach BCD-Addition
DAS Decimal adjust for subtraction ACPSZ Korrigiere AL nach BCD-Subtraktion
DEC Decrement destination by 1 AOPSZ Operand wird um 1 verringert. Operand ist Register oder Speicher, Byte oder Wort.
DIV     Vorzeichenlose Division
ESC Speicherzugriff für Coprozessoren
HLT Die CPU hält an bis Reset oder einem erlaubten externen Interrupt. Code: F4H = 11110100
IDIV Integer division, signed ACOPSF Vorzeichengerechte Integer-Division
IMUL Integer multiply accumulator by register-or-memory; signed CO Integer-Multiplikation mit Vorzeichen
IN Input byte / word   Überträgt Byte/Wort vom Eingabeport (Adr. im DX oder als Direktoperand < 255) nach AL/AX.
INC Increment destination by 1 AOPSZ Operand wird um 1 vergrößert.
INT Interrupt   Software-Interrupt: Indirekter FAR-Sprung über Interruptvektor 0 ... 255 mit Speichern der Rückkehradresse.
INTO Interrupt if overflow   Wenn OF wird INT 4 ausgeführt.
IRET Interrupt return   Rücksprung aus INT- bzw. INTO- Routine.
JA Jump if above   Sprung wenn Ergebnis größer ist
JAE Jump if above or equal   Sprung wenn größer oder gleich
JB Jump if below   Sprung wenn Ergebnis kleiner ist
JBE Jump if below or equal   Sprung wenn Ergebnis kleiner/gleich
JC Jump if Carry   Sprung wenn CF gesetzt ist
JCXZ Jump if CX = 0   Sprung wenn Register CX = 0 ist
JE Jump if equal   Sprung wenn log. Ergebnis gleich ist
JG     Sprung wenn Ergebnis einer log. Operation arithmetisch größer ist
JGE     Sprung wenn Ergebnis arithmetisch größer oder gleich ist
JL     Sprung wenn Ergebnis einer log. Operation arithmetisch kleiner ist
JMP Jump   Springe zu angegebener Adresse
JNA Jump if not above   Sprung wenn Ergebnis nicht größer ist
JNAE Jump if not above or equal   Sprung wenn nicht größer oder gleich
JNB Jump if not below   Sprung wenn Ergebnis nicht kleiner ist
JNBE Jump if not below or equal   Sprung wenn Ergebnis nicht kleiner/gleich
JNC Jump if not Carry   Sprung wenn CF gelöscht ist
JNE Jump if not equal   Sprung wenn log. Ergebnis nicht gleich ist
JNG Jump if not greater   Sprung wenn Ergebnis einer log. Operation nicht arith. größer ist
JNGE Jump if not greater or equal   Sprung wenn Ergebnis nicht arithmetisch größer oder gleich ist
JNL     Sprung wenn Ergebnis einer log. Operation nicht arith. klein ist
JNLE     Sprung wenn Ergebnis einer log. Op. nicht arith. kleiner oder gleich ist
JNO     Sprung, wenn Flag OF nicht gesetzt ist
JNP     Sprung bei ungerader Parität (Flag PF gelöscht)
JNS Jump if not signed   Sprung wenn Ergebnis einer log. Operation positiv ist
JNZ Jump if non zero   Sprung, wenn log. Ergebnis ungleich ist.
JO     Sprung, wenn Flag OF gesetzt ist
JP     Sprung bei gerader Parität (Flag PF gesetzt)
JPO     Sprung bei ungerader Parität (Flag PF gelöscht)
JS Jump if signed   Sprung wenn Ergebnis einer log. Operation negativ ist
JZ Jump if zero   Sprung, wenn log. Ergebnis gleich ist.
LAHF Load AH from flags SZAPC Bits von AH werden mit Flags gefüllt: Bit 7->S, 6->Z, 4->A, 2->P, 0->C. Code: 9FH = 10011111
LDS Load data segment register)   Von einem DWORT (Pointer, Vektor) wird LOW in ein 16 Bit Register (außer SS-Reg) und HIGH ins DS- Register geladen
LEA Load effective adress   Die Offset- Adr. eines Speicheroperanden wird in ein Register geladen. Nur verwenden, wenn die EA zur Laufzeit berechnet werden muss, sonst MOV ..., OFFSET ... verwenden!
LES Load extra-segment register   Lade Physikalische Adr. nach ES:
LOCK     Sperre den Bus
LODSB Load byte string   Überträgt Byte DS:[SI] nach AL. SI + bzw. - um 1 je nach DF. Code 10101l0w
LODSW Load word string   Überträgt Word DS:[SI] nach AX. SI + bzw. - um 2 je nach DF. Code 10101l0w
LOOP Iterate instruktion sequence
until count complete
  Das Count Register (CX) wird um 1 dekrementiert. Wenn CX #0, wird relativ gesprungen: IF (CX) #0 THEN (IP) := (IP) + disp(sign-extended to 16 bits. Code 11100010
LOOPZ Loop on zero   identisch mit LOOPE
LOOPE Loop on equal   CX := CX-1 . Solange CX #0 und wenn ZF =1 : Relativer Sprung. Code 11100001 disp
LOOPNZ Loop on non zero   identisch mit LOOPNE
LOOPNE Loop on not equal   CX := CX-1 . Solange CX #0 und wenn kein ZF: Relativer Sprung. Code 11100000 disp
MOV MOVE   Lade Wert
MOVSB Move byte string   Speichert DS:[SI] nach ES:[DI], dann SI und DI + bzw. - um 1. Ist eine Kombination der Befehle LODSB und STOSB. Code 10100l0w
MOVSW Move word string   Speichert DS:[SI] nach ES:[DI], dann SI und DI + bzw. - um 2. Ist eine Kombination der Befehle LODSW und STOSW. Code 10100l0w
MUL Multiply accumulator by register-or-memory; unsigned CO AL bzw. AX werden mit dem Operanden multipliziert. Obere Hälfte des Resultats in AH bzw. DX. Wenn High #0 ist, werden CF und OF gesetzt. Code 111101lw
NEG Form 2's complement   Negation (Zweier-Komplement)
NOP No operation   Code 90H = 10010000
NOT Form 1's complement   invertiert Operand und speichert zurück.
OR Or, inclusive COPSZ Zwei Operanden werden bitweise verknüpft, CF und OF gelöscht.
OUT Output byte / word   siehe IN
POP Pop word off stack into destination   Holt Wort aus Stack, speichert es nach Register oder Memory und erhöht SP um zwei.
POPF Pop flags off stack Alle Hole Wort vom Stack ins Statusregister, Siehe PUSHF. Code 9DH = 10011101
PUSH Push word onto stack   Wort in Stack speichern. Verringert SP um 2 und speichert Register bzw. Memory-Wort.
PUSHF Push flags onto stack)   Rettet Flags in den Stack (siehe POPF) und verringert SP um 2.
RCL Rotate left through carry CO Rotiere links durch das CF Flag
RCR Rotate right through carry CO Rotiere rechts durch das Flag CF
REP Repeat string operation   FLAGS: Siehe konkrete String-Operation. Die nachfolgende primitive String- Operation (MOVS, SCAS, CMPS) wird wiederholt, wenn (CX) # 0 ist. CX wird um 1 verringert.
REPZ     identisch mit REPE
REPE Repeat string operation   FLAGS: Siehe konkrete String-Operation. Die nachfolgende primitive String- Operation (MOVS, SCAS, CMPS) wird wiederholt, wenn (CX) # 0 ist. CX wird um 1 verringert. Der Zyklus wird auch abgebrochen, wenn die Bedingung nicht erfüllt ist.
REPNE     identisch mit REPNZ
REPNZ Repeat string operation   FLAGS: Siehe konkrete String-Operation. Die nachfolgende primitive String- Operation (MOVS, SCAS, CMPS) wird wiederholt, wenn (CX) # 0 ist. CX wird um 1 verringert. Der Zyklus wird auch abgebrochen, wenn die Bedingung nicht erfüllt ist.
RET Return from procedure   Lädt IP und evt. noch CS aus dem Stack und erhöht SP um 2 bzw. 4 (je nachdem ob Inter-Segment- bzw. Intra-Segment-Rücksprung. Wenn Konstant-Operand angegeben ist, wird er zum Stack addiert.
ROL Rotate left CO Rotiere nach links. Verschiebeanzahl im CL oder =1 ist möglich. Wenn Verschiebung nur einmalig, wird OF gesetzt, falls vor Verschiebung die 2 obersten Bits ungleich waren.
ROR Rotate right CO Rotiere nach rechts. Siehe ROL
SAHF Store AH to flags   Flags werden mit den Bits von AH geladen:
Bit  7  6  5  4  3  2  1  0
Flag SF ZF xx AF xx PF xx CF
(xx = undefin. Wert). Code: 9EH = 10011110
SAL=
SHL
Shift arithmetic left and shift logical left COPSZ In untere Bits werden Nullen eingeschoben. Das oberste Bit wird nach CF geschoben. Verschiebeanzahl im CL oder =1. Wenn Verschiebung einmalig, wird OF gesetzt, falls vor Verschiebung die 2 obersten Bits ungleich waren.
SAR Shift arithmetic right COPSZ Das oberste Bit wird gleich nach dem alten obersten Bit eingeschoben. Das unterste Bit wird nach CF geschoben. Verschiebeanzahl im CL oder =1. Wenn Verschiebeanzahl=1, wird OF gesetzt, falls vor Verschiebung die beiden obersten Bits ungleich waren.
SBB Subtrakt with borrow ACOPSZ Vorzeichenlose Subtraktion inclusive CF
SCASB Scan byte string ACOPSZ Das Byte ES[DI] wird mit AL verglichen. Dann wird DI um 1 erhöht (wenn DF=0) bzw. erniedrigt.
SCASW Scan word string ACOPSZ Das Wort ES[DI] wird mit AX verglichen. Dann wird DI um 2 erhöht (wenn DF=0) bzw. erniedrigt.
SHL Shift logical left   siehe bei SAL
SHR Shift logical right COPSZ Schiebe nach rechts. In obere Bits werden Nullen eingeschoben. Das unterste Bit wird nach CF geschoben. Verschiebeanzahl im CL oder =1. Wenn Verschiebeanzahl =1, wird OF gesetzt, falls vor Verschiebung die beiden obersten Bits ungleich waren.
STC Set carry flag C Setze das Carry Flag. Code: F9H = 11111001
STD Set direction flag D Setze das Zählrichtungs-Flag DF. Code: FDH = 11111101
STI Set interrupt enable flag   Erlaubt maskierbare externe Interrupts nach Ausführung des nächsten Befehls. Code: FBH = 11111011
STOSB Store byte string   AL wird nach ES:[DI] gespeichert. DI wird um 1 erhöht, wenn DF =0 ist, sonst dekrementiert. Code: 101010lw
STOSW Store word string   AX wird nach ES:[DI] gespeichert. DI wird um 2 erhöht, wenn DF =0 ist, sonst dekrementiert. Code: 101010lw
SUB Subtrakt ACOPSZ Vorzeichenlose Subtraktion
TEST Test, or logical compare COPSZ Operanden werden verglichen (AND). CF und OF werden gelöscht.
WAIT     Warten, bis der BUSY-Eingang nicht aktiv ist
XCHG Exchange   Byte oder Wort wird zwischen Registern oder Reg. und Speicher ausgetauscht. Für Sreg nicht anwendbar.
XLAT Translate   Übersetzen: AL := <DS:BX+AL> Lädt ein Byte aus einer Tabelle (im DS) nach AL. Das AL- Reg. wird als Index einer durch das BX- Register adressierten Tabelle verwendet. Der so adressierte Operand wird nach AL geladen. Code: D7H = 11010111. z.B. MOV BX, OFFSET TAB_NAME,   XLAT BYTE PTR [BX]
XOR Exclusive or COPSZ Operanden werden verglichen und Bits gesetzt, wo ungleich. CF und OF werden gelöscht.

Literatur und Weblinks

Quellenangaben

Literatur und Weblinks

[Nat00]
International System of Units (SI) – Prefixes for binary multiples. National Institut of Standard and Technology http://physics.nist.gov/cuu/Units/binary.html.
[Sie04]
Prozessorgrundlagen: Von-Neumann-Architektur, Teil. Prof. Dr. Christian Siemers. http://www.tecchannel.de/technologie/prozessoren/402283/.
[Pod03]
Das Assembler-Buch I – Grundlagen, Einführung und Hochsprachenoptimierung. ISBN 3827319293, Addison-Wesley, Deutschland, 2002.


Weitere Weblinks

Assemblerprogrammierung

NASM

FASM

MMX/SSE

Windows Programmierung

Hardware

Source Code

Weitere Themen

Interruptlisten

Weitere Literatur

  • Marcus Roming, Joachim Rohde: Assembler - Grundlagen der Programmierung, mitp-Verlag, 2003, ISBN 382660671X.
  • Wolfgang Links, Assembler Programmierung; Franzis Verlag GmbH, 2004, ISBN 3772370144.
  • Trutz Eyke Podschun: Das Assembler-Buch; Grundlagen und Hochsprachenoptimierung, Addison-Weslay, 1999, ISBN 3827315131.
  • Peter Monadjemi: PC-Programmieren in Maschinensprache, Markt & Technik, ISBN 3-89090-957-4 (nicht mehr verfügbar).
  • Don & Penn Brumm: 80386; Markt & Technik, 1995, ISBN 3890905919 (wahrscheinlich nicht mehr verfügbar).
  • Robert Hummel: Die Intel-Familie – Technisches Referenzhandbuch für den 80x86 und 80x87; Ziff-Davis Press; 1995, ISBN 389362807X.
  • Maurus, Reinhold; Wohak, Bertram: 80x86/Pentium Assembler; IWT, 1996 ISBN 382662601X.
  • Podschun, Trutz Eyke: Die Assembler-Referenz II – Kodierung, Dekodierung und Referenz; Addison-Wesley, 2003, ISBN 3827320151.
  • Osborne, Adam: Einführung in die Mikrocomputertechnik; TEWI, 1983, ISBN 3921803128.