Assembler-Programmierung für x86-Prozessoren/ Druckversion
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).
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:
- Treiberprogrammierung (wobei hier auch häufig C benutzt wird).
- Optimierung von (zeit-) kritischen Stellen in Programmen.
- Benutzung von Befehlen, die der Hochsprachen-Compiler (noch) nicht kennt.
- Der Programmierer hat volle Kontrolle über den erzeugten Code.
- Bei der Fehlersuche mit einem Debugger sind Assemblerkenntnisse sehr zu empfehlen.
- Kennenlernen der Arbeitsweise von Prozessor und restlichem Computersystem.
- 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:
- The Art of Assembly
- USENET – comp.lang.asm.x86
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
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.
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 Flagclc
(Clear Carry Flag) – löscht das Carry Flagcmc
(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 Linksverschiebungsar
(Shift Arithmetic Right): arithmetische Rechtsverschiebungshl
(Shift Logical Left): logische Linksverschiebungshr
(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)
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
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
: Linksrotationror
: Rechtsrotationrcl
: Linksrotation mit Carry Flagrcr
: Rechtsrotation mit Carry Flag
Die Rotationsbefehle haben die folgende Syntax:
rol / ror /rcl /rcr Zieloperand, Zähleroperand
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 demja
-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 testetjb
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-Markeprint
in einer anderen Quelldatei findet, obwohl sie in dieser Quelldatei eingeführt (definiert) wird.
far
im Aufrufcall
des Unterprogramms macht dem Assembler klar, dass sich der Code für das Unterprogramm in einer anderen Quelldatei befindet.
global print
ist das Pendant zuextern print
: Es zeigt dem Assembler, dass der Aufruf des Codeteilsprint
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. Hatimul
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
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
- Entwicklungsseite von MenuetOS, einem 32-Bit-Betriebssystem das komplett in Assembler entwickelt wird und unter der GPL steht
- Sehr umfangreiches Buch über die Assemblerprogrammierung unter DOS, Linux und Windows. (englisch)
- Kurze Einleitung zum Thema Assembler in Kombination mit Pascal.
NASM
- Neben einer weiteren Downloadmöglichkeit für NASM auch ein Assembler Tutorial von Dr. Paul Carter (englisch)
- Neben einigen nützlichen Weblinks auch einige Beispielprogramme (englisch)
FASM
MMX/SSE
Windows Programmierung
Hardware
- Bericht über den Itanium Prozessor im Linux Magazin
- Die Intelprozessoren im Überblick im Elektronik-Kompendium
- "Moderne Prozessorarchitekturen" von der Uni Trier
- Sehr umfassende technische Dokumentation zu x86 Prozessoren
Source Code
Weitere Themen
Interruptlisten
- Ralf Brown’s Interrupt List HTML-Version. (englisch)
- Ralf Brown’s Interrupt List Download-Version. (englisch)
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.