Assembler-Programmierung für x86-Prozessoren/ Grundlagen
Zahlensysteme
[Bearbeiten]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
[Bearbeiten]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
[Bearbeiten]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
[Bearbeiten]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)
[Bearbeiten]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
[Bearbeiten]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
[Bearbeiten]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
[Bearbeiten]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
[Bearbeiten]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.