S
Zahlensysteme, Teil 3 - Beispielanwendungen
Inhalt
1. Einführung
1.1 CPU Register
1.2 Grundlegende Instruktionen
2. Byteswap Algorithmus
3. Zeichenkettenmanipulationen
4. Basenkonvertierungen
1 Einführung
1.1 CPU Register
1.2 Grundlegende Instruktionen
2 Byteswap Algorithmus
Das Umsortieren von einer Byte Order in die andere nennt man im Englischen "swappen". Ein Byteswap-Algorithmus ist in Assembler schnell geschrieben. Doch dazu später. Wollen wir zunächst einmal anschauen, wie der Prozessor Daten im Arbeitspeicher hinterlegt:
int main(int argc, char* argv[])
{
unsigned short us_var = 0x3FE4;
unsigned int ui_var = 0x8C4E2FA0;
unsigned long long ull_var = 0x9D4A6CF39A1CB346;
return 0;
}
In diesem Beispiel machen wir nichts anderes, als Speicher für drei Variablen unterschiedlicher Breite zu reservieren und ihnen unterschiedliche Ganzahlwerte im Hexadezimalformat zuzuordnen. Auf einem x86_64 kompatiblen Rechner ist die erste Variable 16 Bit breit (also ein Word), die zweite ein Doubleword (32 Bit) und die dritte ein Quadword (also 64 Bit). Wie können wir uns nun ansehen, wie der Prozessor die Werte im Speicher abgelegt hat? Versuchen wir uns an der allmächtigen printf() Funktion:
int main(int argc, char* argv[])
{
unsigned short us_var = 0x3FE4;
unsigned int ui_var = 0x8C4E2FA0;
unsigned long long ull_var = 0x9D4A6CF39A1CB346;
printf("%.2X\n", us_var);
printf("%.2X\n", ui_var);
printf("%.2X\n", ull_var);
return 0;
}
Wir erhalten folgende Ausgabe:
3FE4
8C4E2FA0
9D4A6CF39A1CB346
Was ist passiert? Mit der Formatzeichenkette "%.2X\n" sagen wir der printf() Funktion nichts anderes, als dass sie uns die übergebenen Daten in Hexadezimalform ausgeben soll. die ".2" in der Zeichenkette sorgt dafür, dass wenn die resultierende Hexadezimalzahl einstellig ist, eine führende Null angehängt werden soll. Nun, also ist es nicht überraschend, dass wir genau das erhalten, was wir im Quelltext definiert haben. Der x86_64 kompatible Prozessor arbeitet doch im Little Endian Mode. Sollte also ein
printf("%.2X\n", us_var);
nicht
E43F
ausgeben? Nein, dies tut es nicht. Es ist richtig, die Zahl 0x3FE4 wird exakt so gepeichert. Nur, wie schon erwähnt, fällt es auf Anwendungsebene nicht ins Gewicht. Operationen, ob Addition, Substraktion oder was auch immer ergeben immer das zu erwartende Ergebnis, es ist nur die Reihenfolge wie die Zahl gespeichert wird, die etwas kurios wirkt. Um nun zu überprüfen, wie die Zahl tatsächlich gespeichert wird, nutzen wir ein Paar Tricks, die uns die Sprache C bietet, und implementieren eine simple "Dump Memory" Funktion:
#include <stdio.h>
int dump_buffer(void* pInBuffer, unsigned long ulLength)
{
unsigned int i;
unsigned int a = 0;
unsigned char* tmp = pInBuffer;
if ((tmp == NULL) || (ulLength < 1)) return -1;
printf("\n");
printf(" | ");
for (i = 0; i < 16; i++)
{
printf("%.2X ", i);
}
printf("\n");
printf("-----------|------------------------------------------------");
for (i = 0; i < ulLength; i++)
{
if (i % 16 == 0)
{
printf("\n");
printf("0x%.8X | ", a);
a += 16;
}
printf("%.2X ", tmp[i]);
}
printf("\n\n");
return 0;
}
Wir nutzen erneut die selbe main() Funktion und übergeben der dump_buffer() Funktion die Adresse unserer Variablen und die Anzahl der Bytes, welche ausgegeben werden sollen. Das besondere ist, dass wir in dump_buffer() jetzt wirklich Byte für Byte aus dem Speicher auslesen und entsprechende ausgeben. Die Schlüsselzeile dafür ist:
printf("%.2X ", tmp[i]);
Mit i indizieren wir die Speicherzelle, dessen Wert wir ausgeben möchten. Nun rufen wir dump_buffer() mit folgendem Code auf:
int main(int argc, char* argv[])
{
unsigned short us_var = 0x3FE4;
unsigned int ui_var = 0x8C4E2FA0;
unsigned long long ull_var = 0x9D4A6CF39A1CB346;
dump_buffer(&us_var, sizeof(unsigned short));
dump_buffer(&ui_var, sizeof(unsigned int));
dump_buffer(&ull_var, sizeof(unsigned long long));
return 0;
}
Als Ausgabe erhalten wir folgendes:
| 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------|------------------------------------------------
0x00000000 | E4 3F
| 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------|------------------------------------------------
0x00000000 | A0 2F 4E 8C
| 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------|------------------------------------------------
0x00000000 | 46 B3 1C 9A F3 6C 4A 9D
Jetzt wird ersichtlich, dass der Prozessor die Daten tatsächlich in anderer Reihenfolge gespeichert hat.
Wenn es aber denn unerheblich ist, da der Prozessor doch mit unseren Daten richtig rechnet, warum sollten wir uns über ein mögliches Umsortieren Gedanken machen? Zwei Beispiele habe ich schon genannt, die Audio CD und Datentransport über das Netzwerk. Bei der Audio CD werden Daten im Little Endian Mode gespeichert. Warum sich also kümmern, repräsentiert unser doch so geliebter x86_64 kompatible Prozessor Daten in exakt dieser Reihenfolge? Nun, es mag komisch klingen, aber PowerPC Anwender möchten vielleicht auch Audio CDs erstellen. Seien wir also gnädig und geben der Big Endian Architektur ein Werkzeug in die Hand, um Daten die auf dem Computer gepeichert sind, in die Reihenfolge zu bringen, wie sie für Audio CDs notwendig ist.
Es existieren zwar Makros und Funktionen für C, allerdings ist dies eine gute Gelegenheit dem interessierten Leser einige Assemblergrundlagen näher zu bringen. Da ich versprochen habe minimal notwendiges Hintergrundwissen gesondert zu erklären, werde ich zunächst die prozessorinternen Register beschreiben.
Daten werden für üblich im Arbeitsspeicher des Computers gehalten. Wenn ein Prozessor allerdings Berechnungen durchführen soll, ist es notwendig Teile der Daten in einen sehr schnellen prozessorinternen Speicher zu kopieren. Diesen Speicher nennt man Register. Mit Hochsprachen hat man nur wenig Einfluss Daten direkt in diesen Registern zu manipulieren. Diese Möglichkeit bietet uns Assembler. Es gibt mehrere Arten dieser Register, da Sie auch unterschiedliche Zwecke erfüllen. Die sogenannten General Purpose Register auf i386 kompatiblen Architekturen sind die am häufigst benutztesten. Sie sind 32 Bit breit, können also ein Doubleword auf einmal halten. Auf x86_64 kompatiblen Prozessoren sind diese Register 64 Bit breit, und demnach dafür geeignet ein Quadword zu speichern. Da diese Architektur zunehmend an Bedeutung gewinnt, beschränke ich mich wie schon erwähnt bei den Beispielen auf diese.
Der Aufbau eines Prozessorregisters ist leicht verständlich. Auf x86_64 Architekturen bezeichnet man das Akkumulatorregister als
rax
Das Akkumulatorregister ist als Zwischenspeicher für Daten gedacht. Desweiteren erwarten sehr viele Instruktionen einen Operanden im Akkumulatorregister, bzw. speichert der Prozessor häufig Intruktionsresultate in jenem ab.
rax selbst ist also 64 Bit breit:
[pic]
Der Kompatibilität wegen, und da es nicht immer notwendig ist mit 64 Bit breiten Daten zu operieren, ist das 64 Bit breite Register unterteilt:
[pic]
eax ist 32 Bit breit, ax is 16 Bit breit, ah und al jeweils 8 Bit. Es ist nicht möglich den höherwertigen Teil von eax, bzw. von rax direkt zu erreichen. Um auf Daten zuzugreifen, die dort gespeichert sind, muss die Anwendung vorher Sorge tragen, dass diese Daten nach ax, bzw. eax transferiert werden. Dies lässt sich mit Schiebe- oder Rotationsoperationen erreichen. Näheres dazu im Abschnitt 2.2.
Um unsere Swapfunktion aus C Code heraus aufrufen zu können, gibt es zwei Möglichkeiten. Auf der einen Seite können wir Inlineassembleranweisungen benuzten, um Assemblecode direkt in C Code einzubetten. Für kurze Codepassagen ist dies der effizientere Weg, kann aber bei sehr langem Assemblercode unangenehm wirken. Auf der anderen Seite können wir reinen Assemblercode schreiben und mit einem Assembler assemblieren. Wir erhalten eine Objektdatei, welche wir wie gewohnt mit dem Linker zu den restlichen Objektdateien unseres Programms hinzulinken können. Ich wähle den komplizierteren zweiten Weg, da er mehr Spaß macht. Im Ernst, er hilft zu verstehen, wie die Parameter einer Funktion auf der x86_64 Architektur an die Funktion übergeben werden.
Wir haben bereits ein Register kennengelernt, das rax Register. Für unseren Byteswap-Algortihmus benötigen wir noch ein weiteres. Das rdi Register ist ebenfalls 64 Bit breit, wie es das "r" im Namen schon vermuten lässt. Es unterteilt sich, ähnlich wie schon beim rax Register gezeigt, in folgende Unterregister:
edi, di, dh, dl
mit den selben Eigenschaften, dass eben edi 32 Bit breit, di 16 Bit breit, dh und dl 8 Bit breit sind.
Zuerst wollen wir uns um den Rahmencode in C bemühen, welcher, wie in den obigen Beispielen, Speicher reserviert, einer Variable einen Wert zuweist, und ausgibt, wie der Prozessor diesen Wert im Arbeitsspeicher tatsächlich abgespeichert hat:
extern unsigned short _swab16(unsigned short usValue);
int main(int argc, char* argv[])
{
unsigned short us_var = 0x3FE4;
dump_buffer(&us_var, sizeof(unsigned short));
us_var = _swab16(us_var);
dump_buffer(&us_var, sizeof(unsigned short));
return 0;
}
Soweit nichts ungewöhnliches. Mit
extern unsigned short _swab16(unsigned short usValue);
teilen wir dem Kompiler mit, dass wir die Funktion _swab16() nicht im aktuellen Objekt definieren, der Linker sich also darum kümmern soll, die Funktionsdefinition in einem anderen Objekt zu finden.
Mit
us_var = _swab16(us_var);
rufen wir diese Funktion auf. Der erwartete Effekt ist, dass das abschließende dump_buffer() auf die Variable us_var uns den Inhalt der Variable im Big Endian Order anzeigt. Um dies zu erreichen, müssen wir nun natürlich diese Funktion definieren. Wir erstellen eine neue Datei mit folgendem Inhalt:
; file: swab.asm
global _swab16
_swab16:
mov ax, di
xchg al, ah
ret
Was bedeutet dies im einzelnen? Mit
; file: swab.asm
erreichen wir gar nichts. Es ist ein Kommentar. Kommentare in Assembler beginnen für üblich mit einem Semikolon.
global _swab16
An dieser Stelle möchte ich darauf hinweisen, dass ich yasm als Assembler benutze. Assemblersyntax folgt keinem Standard wie C oder C++, daher ist es mitunter nicht so einfach möglich Assemblercode mit jedem beliebigen Assembler zu assemblieren. Yasm kann unter http://www.tortall.net/projects/yasm/ für die gängigsten Plattformen heruntergeladen werden. Mit global instruieren wir, dass der Assembler nachfolgendes Symbol exportieren soll. Das heißt, dass es für einen Linker sichtbar wird, und somit, einfach ausgedrückt, weiß welchen Code er mit dem _swab16() Aufruf in unserem C Code verbinden soll. Die eigentliche Definition des Symbols erfolgt nach erneuter Anführung mit einem Doppelpunkt:
_swab16:
Jetzt folgt der eigentliche Funktionscode. Ein Blick auf unseren Prototypen
extern unsigned short _swab16(unsigned short usValue);
verrät, dass wir doch einen Parameter übergeben wollen. Dies ist der Punkt, an dem unser rdi Register ins Spiel kommt. Unter Linux auf x86_64 Architekturen werden Funktionsparameter grundsätzlich in Registern übergeben. Die Reihenfolge dabei ist festgelegt, so dass wir wissen, wenn wir einen Zeiger oder einen Integerwert übergeben wollen, das erste Argument in rdi gepeichert sein wird. Das zweite würde in rsi, einem weiteren 64 Bit Register, abgelegt. Unter Windows sieht dies ein wenig anders aus, soll aber auch hier nicht weiter interessieren.
Da usValue als Parameter unsigned short und somit 16 Bit breit ist, benötigen wir natürlich nicht das gesamte rdi Register. usValue wird beim Funktionsaufruf also in di liegen, ist doch di eben 16 Bit breit. Betrachten wir unsere erste Instruktion:
mov ax, di
Wir kopieren den Wert in di, also unsere usValue (welche ja 0x3FE4 beinhaltet), nach ax mit mov. mov ist ein Mnemonic, und soll die Abbkürzung von Move, also Bewegen darstellen. Wir haben unseren Wert 0x3FE4 nun in ax. Der nächste Befehl ist fast schon selbsterklärend:
xchg al, ah
Mit xchg tauschen wir die Inhalte der beiden 8 Bit (also ein Byte) breiten al und ah Register aus. Da al und ah Teile von ax sind, modifizieren wir automatisch den Inhalt von ax. Erinnern wir uns, vor der xchg Instruktion beinhaltete ax 0x3FE4. Danach wird ax 0xE43F beinhalten. Wir haben das erreicht, was wir wollen - die Bytes sind vertauscht, somit haben wir erfolgreich den Wert 0x3FE4 in seine Big Endian Order Darstellung konvertiert. Um uns auch wirklich davon überzeugen, und mit diesem Wert in unserem C Code weiterarbeiten zu können, müssen wir erreichen das unsere Funktion diesen Wert auch als Funktionsrückgabewert zurückliefert. Dies geschieht mit der Instruktion
ret
Es ist vergleichbar mit dem C Schlüsselwort "return". Ein ret gibt die Kontrolle wieder an den Aufrufer zurück. Aber wo ist unser Rückgabewert? Auch sehr einfach. C unter Linux auf x86_64 Architekturen erwartet den Rückgabewert einer Funktion (wenn er ein Zeiger oder ein Integertyp ist) eben im rax Register. Da unser eben konvertierter Wert aber schon in ax liegt, und wir im Funktionsprototypen unsigned short (also 16 Bit Datenbreite) als Rückgabetyp deklariert haben, lassen wir rax einfach unangetastet und kehren ohne Umschweife zu unserem C Code mit ret zurück.
Ein abschließendes dump_buffer() bringt den Beweis. Unser Programm erzeugt folgende Ausgabe:
| 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------|------------------------------------------------
0x00000000 | E4 3F
| 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
-----------|------------------------------------------------
0x00000000 | 3F E4
Der erste Dump zeigt den Inhalt der Variable vor dem "swappen", der zweite danach. Wir haben tatsächlich erfolgreich unsere ersten Daten in ihre Big Endian Order Darstellung gebracht. An dieser Stelle möchte ich nochmals darauf hinweisen, dass der Inhalt der geswappten Variable auf unserem Computer ein komplett anderer ist. Auf einem PowerPC transferiert und gepeichert würden die Daten allerdings wieder wertgleich mit dem Inhalt unserer urprünglichen Variable sein.
Das Programm kann wie folgt kompiliert werden:
gcc -o main.o -c main.c
Die Datei swab.asm lässt sich mit folgender Befehlszeile assemblieren:
yasm -f elf64 -o swab.o swab.asm
Mit gcc können die Objektdateien wie gewohnt zum eigentlichen Programm gelinkt werden:
gcc -o swabtest main.o swab.o
3 Zeichenkettenmanipulationen
4 Basenkonvertierungen