Content |
Intel 64 Assembler Sprache
Nachdem wir uns einigermaßen mit der Intel 64 Architektur auskennen wollen wir auch verstehen wie man diese mittels Assembler programmieren kann. Wir fragen dazu jemanden der sich auskennt: Nicht mich sondern den gcc. Wenn man ein bisschen C versteht und einfache Programme in Assembler übersetzen lässt ist der Einstieg gar nicht so schwer.
Erstes Beispiel
unsigned b = 2;
unsigned c = 3;
void
first()
{
a = b + c;
if (a==4) {
b = 3;
} else {
c = a & b;
}
while (a>0) {
a--;
}
}
Mit
$shell> gcc-4.8 -Wall -S -fno-asynchronous-unwind-tables first.c
erzeugen wir den Assembler Code
.data
.align 2
_a:
.long 1
.globl _b
.align 2
_b:
.long 2
.globl _c
.align 2
_c:
.long 3
.text
.globl _first
_first:
pushq %rbp
movq %rsp, %rbp
movl _b(%rip), %edx
movl _c(%rip), %eax
addl %edx, %eax
movl %eax, _a(%rip)
movl _a(%rip), %eax
cmpl $4, %eax
jne L2
movl $3, _b(%rip)
jmp L4
L2:
movl _a(%rip), %edx
movl _b(%rip), %eax
andl %edx, %eax
movl %eax, _c(%rip)
jmp L4
L5:
movl _a(%rip), %eax
subl $1, %eax
movl %eax, _a(%rip)
L4:
movl _a(%rip), %eax
testl %eax, %eax
jne L5
popq %rbp
ret
.subsections_via_symbols
Zeile für Zeile
Zeilen die mit einem Punkt (.) beginnen sind sogenannte Assembler Direktiven. Diese werden nicht in Maschinenbefehle umgewandelt sondern stellen eine Art Verwaltungsinformation für den Assembler oder Linker dar.
Die .globl Direktive besagt hier, dass das Symbol _a von anderen Dateien aus referenziert werden kann. Das ist später für den Linker wichtig. Zu beachten ist, dass nicht gesagt wird um was für ein Symbol es sich handelt. Es könnte sich theoretisch um eine Funktion oder eine Variable handeln.
Aus historischen Gründen wird vom C Compiler jedem Symbol übrigens ein Unterstrich vorangesetzt. Damit sollten angeblich Namenskonflikten mit vordefinierten Symbolen vermieden werden. Wenn man Assembler Code von Hand schreibt, dann muss man sich daran nicht halten. Der Fortran Compiler hält sich an ähnliche Konventionen. Möchte man C von Fortran oder umgekehrt aufrufen dann wird das relevant.
Diese Direktive sagt, dass alles was ab jetzt kommt später ins Data Segment kommen soll. Jedenfalls solange bis etwas anderes gesagt wird.
Im Prinzip können bei der Intel Architektur die Daten an einer beliebigen Adresse gespeichert werden. Ein double word (wie zum Beispiel unsigned) könnte an der Adresse 1, 2, 3 oder 4 abgespeichert werden. Hier wird aber verlangt, dass die Adresse des nächsten Datensatzes, der im Data-Segment angelegt wird, durch \(2^2=4\) teilbar sein soll. Dazu wird der location counter auf die nächste durch 4-teilbare Zahl aufgerundet. Wieso mit so einer Direktive die Effizienz gesteigert werden kann behandeln wir später.
Dies ist ein sogenannter Label. Damit wird gesagt, dass ab jetzt die Definition des Symbols _a erfolgt (von dem schon vorher gesagt wurde, dass es von Außen referenziert werden kann).
Jetzt wird gesagt, dass an der Stelle des location counter 4 Bytes (long, double word) mit 1 initialisiert werden sollen. Anschliessend wird der location counter um 4 erhöht.
.align 2
_b:
.long 2
Analog wird die Variable _b definiert:
-
von Außen referenzierbar,
-
an einer durch 4 teilbaren Adresse und
-
mit 2 initialisiert.
.align 2
_c:
.long 3
Und nochmals für _c:
-
von Außen referenzierbar,
-
an einer durch 4 teilbaren Adresse und
-
mit 3 initialisiert.
Ab jetzt wird das Text-Segment mit den Intel 64 Instruktionen gefüllt.
_first:
Auch hier wird ein öffentlich zugängliches Symbol (_first) deklariert. Da wir jetzt im Text Segment sind, ist klar, dass das Symbol eine Funktion darstellt. Der Begriff “Funktion” hört sich im Kontext von Assembler etwas hoch gegriffen an. Wenn der Maschinencode erzeugt wird muss klar sein an welcher Speicherstelle der nächste Befehl steht.
Die Intel 64 Spezifikation sagt:
Registers %rbp, %rbx and %r12 through %r15 “belong” to the calling function and the called function is required to preserve their values.
Da im Folgenden der Wert keiner dieser Register verändert wird, könnte man dies eigentlich ignorieren. Schaltet man beim Compiler die Optimierung ein, dann wird dies ignoriert. Ohne Optimierung geht der Compiler aber auf Nummer Sicher.
Der Befehl push legt sein Argument auf den Stack. Das geht so:
-
Der Wert von %rsp enthält die Adresse des obersten Datensatzes auf dem Stack. Also die Position der Spitze.
-
Soll ein 4-Byte Datensatz auf den Stack gelegt werden, dann wird %rsp um 4 dekrementiert. Denn der Stack wächst nach unten.
-
Das 4-Byte Argument wird an die in %rsp enthaltene Adresse kopiert.
Woher soll push wissen wie groß sein Argument ist? Das Suffix q sagt, dass das Argument als quad word interpretiert wird. Der Befehl kommt also in verschiedenen Varianten:
pushb |
Argument ist 1 Byte |
pushw |
Argument ist ein Word (2 Byte) |
pushl |
Argument ist ein Long (4 Byte) |
pushq |
Argument ist ein Quad-Word (8 Byte) |
Was immer also in %rbp war ist jetzt also auf dem Stack.
Mit dem Move Befehl mov kann man Daten kopieren. Und zwar:
-
Vom Speicher in ein Register,
-
von Register zu Register,
-
von Register in den Speicher.
Nicht erlaubt ist das Kopieren von "Speicheradresse zu Speicheradresse*. Die Daten müssen also einmal durch die Register.
Hier wird also der Inhalt von %rsp in %rbp gespeichert. Es ist also eine Kopie von Register nach Register. Wie gesagt, das vorige Ablegen von %rbp auf den Stack und anschliessende Überschreiben mit %rsp ist hier eigentlich unnötig. Wieso dies im Allgemeinen notwendig ist sehen wir später.
Hier werden 4 Bytes von _b(%rip) ins Register %edx kopiert. Klar ist, dass mit %edx die ersten 4 Bytes des %rdx Register gemeint sind. Aber was soll _b(%rip) sein?
Natürlich soll das die Adresse im Data-Segment darstellen an der _b steht. Das Problem ist, dass man erst nach dem Linken weiss wo _b letztlich im Data-Segement steht. Denn man muss daran denken, dass eventuell viele Object Files gelinkt werden und jedes etwas zum Data Segment beiträgt.
Nun werden in _b(%rip) mehrere Informationen verknüpft:
-
%rip enthält die Adresse des Maschinenbefehls der als nächstes ausgeführt wird.
-
Mit der Notation (%rip) wird aus dem Wert in %rip eine Adresse (mit diesem Wert) erzeugt.
-
Sei D eine Konstante, dann wird mit D(%rip) eine von (%rip) um D verschobene (displaced) Adresse erzeugt.
-
Der Linker weiss wo die mit Label _b gekennzeichnete Stelle im Data-Segment letztlich gelandet ist. Daraus wird beim Linken die Adresse vom Datensatz mit Label _b relativ zur Adresse des Befehls erzeugt.
Analog werden 4-Bytes von der Adresse des Label _c ins Register %eax kopiert.
Der Inhalt von %edx wird zu %eax addiert. Das Ergebnis steht also in %eax.
Hier wird der Inhalt von %eax an die Adresse des Label _a kopiert.
Jetzt wird der Wert von _a wieder in %eax kopiert. Hallo?!?!? Okay, man bedenke, dass der Compiler noch nicht wirklich optimiert hat.
cmpl ist die cmp Variante um zwei Word Daten zu vergleichen. Analog gibt es cmpb, cmpw und cmpq. Außerdem muss man wissen, dass im Assembler Code Konstanten mit einem Dollarzeichen codiert werden. Hier wird also der Inhalt von Register %eax mit 4 Verglichen.
Das geschieht indem die beiden Werte subtrahiert werden und das Ergebnis weggeworfen wird. Allerdings werden auch die Flags CF (Carry Flag) und ZF (Zero Flag) gesetzt! Und zwar so:
-
Wenn die Differenz ist, dann wird das ZF gesetzt.
-
Wenn der rechte Operand größer als der linke ist (also “links-rechts<0”) dann wird das CFgesetzt.
Man kann also folgende Fälle unterscheiden:
CMP A B |
ZF |
CF |
A > B |
0 |
0 |
A < B |
0 |
1 |
A = B |
1 |
0 |
Das heisst mit den Flags ZF und CF kann man alle drei Fälle unterscheiden.
jne steht für jump if not equal. Falls das ZF Flag nicht gesetzt ist, dann wird das %rip Register überschrieben mit der Adresse des Label L2. Das heisst: Wenn das ZF Flag gesetzt ist, dann wird als nächstes der Befehl ausgeführt, der beim Label L2 steht. Alte Hasen kennen das vom berüchtigten go to Befehl.
Das Label L2 wird übrigens erst weiter unten definiert. Wenn das ZF Flags gesetzt ist (also _a gleich 3 ist), dann geht es dort weiter. Sonst wird der näachte Befehl ausgeführt:
Die 4-Bytes mit Symbol-Bezeichnung _b werden mit der Konstanten 3 überschreiben.
Das %rip register wird auf die Adresse von L4 gesetzt. Das heisst der Befehl der an dieser Adresse steht wird als nächstes ausgeführt.
Das Label L2wird definiert. Nach dem Linken ist damit eine Adresse im Text Segment eindeutig identifizierbar.
Dieser Befehl steht an der Adresse, die mit L2 identifizierbar ist. Der Wert der 4-Bytes die mit dem Symbol _a assoziert sind werden ins Register %edx kopiert.
Die 4-Bytes von _b werden ins Register %eax kopiert.
Es wird eine AND-Bitverknüpfung der Register %edx und eax durchgeführt. Das Ergebnis steht dann in %eax.
Wert von %eax zurück in _c kopieren.
Weiter geht es bei Marke L4.
movl _a(%rip), %eax
subl $1, %eax
movl %eax, _a(%rip)
Bei der Marke L5 passiert also folgendes:
-
Wert von _a in Register %eax kopieren,
-
davon die Konstante \(1\) (als 4 Byte Zahl aufgefasst) abziehen und
-
das Ergebnis in _a zurück kopieren.
Insgesamt wurde der Wert von _a also dekrementiert.
movl _a(%rip), %eax
testl %eax, %eax
Bei Marke L4 wird zuerst der Inhalt von _a in %eax kopiert. Dann wird der Befehl test (bzw. seine 4-Byte Variante) benutzt um zu prüfen, ob %eax den Wert enthält.
Intern wird bei test eine bitweise AND-Verknüpfung der Argumente durchgeführt und das Ergebnis verworfen. Abhängig vom Ergebnis werden aber die SF, ZF, PF, CF, OF und AF Flags gesetzt. In diesem Fall ist relevant, dass ZF auf 1 gesetzt wird, wenn das Ergebnis 0 ist. Ansonsten wird ZF auf 0 gesetzt.
Wenn ZF ungleich Null ist, dann wird als nächstes der Befehl bei L5 ausgeführt.
Das Register %rbp bekommt seinen alten Wert.
Da steckt sehr viel dahinter und wir werden es noch sehr ausführlich behandeln.
Fragt Google.
Aufgaben
Was macht der Linker?
Im vorigen Beispiel wurde mit otool disassembliert. Mit strip wurden die Symbole mit Zahlen ersetzt. Rechnet bei der Ausgabe von otool alle Adressen der Form D(%rip) nach.
Was macht der Optimierer?
Wir schalten nun die Optimierung ein:
$shell> gcc-4.8 -Wall -O3 -S -fno-asynchronous-unwind-tables first.c
Damit erhält man:
.align 4,0x90
.globl _first
_first:
movl _b(%rip), %edx
movl _c(%rip), %eax
addl %edx, %eax
cmpl $4, %eax
movl %eax, _a(%rip)
je L6
andl %eax, %edx
testl %eax, %eax
movl %edx, _c(%rip)
je L1
L3:
movl $0, _a(%rip)
L1:
rep; ret
.align 4,0x90
L6:
movl $3, _b(%rip)
jmp L3
.globl _c
.data
.align 4
_c:
.long 3
.globl _b
.align 4
_b:
.long 2
.globl _a
.align 4
_a:
.long 1
.subsections_via_symbols
Analysiert den Assembler Code:
-
Was tun die einzelnen Zeilen?
-
Was wird insgesamt getan? Wenn die Funktion first z.B. gleich bei Programm Start aufgerufen wird (also a und b noch nicht verändert ist) kann man das Programm von Hand abarbeiten (spielt “Virtuelle Maschine”).
-
Mindestens einen Befehl muss man googlen. Bei einem anderen muss man googlen was das zweite Argument tut.
-
Vergleicht den Assembler Code, den verschiedene gcc Versionen produzieren. Die Versionen sind z.B. gcc-4.7, gcc-4.8, gcc-4.9.
Wie groß sind die Datentypen?
Vervollständigt die Tabelle:
-
nicht mit Google sondern mit dem GCC
-
aber auch nicht mit sizeof sondern aus dem Assemble Code
C Deklaration |
Intel Daten Typ1 |
GAS2 Suffix |
x86-64 Größe in Bytes |
char |
|||
short |
|||
int |
|||
unsigned |
|||
long int |
|||
unsigned long |
|||
char * |
|||
float |
|||
double |
|||
long double |
Von Hand Linken
Aus first.c alleine kann man keine Ausführbare Datei machen. Den die muss laut Konvention ein Label _main enthalten, das nach dem Programm laden als erstes angesprungen wird.
Wir ändern first.c aber nicht ab sondern schreiben ein zweites C Source File das ein _main Label enthält und die Funktion first() aufruft.
first();
int
main()
{
first();
return 0;
}
Wir schauen uns erst den Assembler Code an (nachmachen)
$shell> gcc-4.8 -Wall -S -fno-asynchronous-unwind-tables main4first.c
der so aussieht
.globl _main
_main:
pushq %rbp
movq %rsp, %rbp
movl $0, %eax
call _first
movl $0, %eax
popq %rbp
ret
.subsections_via_symbols
und erzeugen dann für first.c und main4first.c jeweils Object Files (nachmachen):
$shell> gcc-4.8 -Wall -c first.c $shell> gcc-4.8 -Wall -c main4first.c
Deren Inhalt sieht jeweils so aus (nachmachen):
$shell> otool -tdV first.o first.o: (__TEXT,__text) section _first: 0000000000000000 pushq %rbp 0000000000000001 movq %rsp, %rbp 0000000000000004 movl _b(%rip), %edx 000000000000000a movl _c(%rip), %eax 0000000000000010 addl %edx, %eax 0000000000000012 movl %eax, _a(%rip) 0000000000000018 movl _a(%rip), %eax 000000000000001e cmpl $0x4, %eax 0000000000000021 jne 0x2f 0000000000000023 movl $0x3, _b-4(%rip) 000000000000002d jmp 0x54 000000000000002f movl _a(%rip), %edx 0000000000000035 movl _b(%rip), %eax 000000000000003b andl %edx, %eax 000000000000003d movl %eax, _c(%rip) 0000000000000043 jmp 0x54 0000000000000045 movl _a(%rip), %eax 000000000000004b subl $0x1, %eax 000000000000004e movl %eax, _a(%rip) 0000000000000054 movl _a(%rip), %eax 000000000000005a testl %eax, %eax 000000000000005c jne 0x45 000000000000005e popq %rbp 000000000000005f ret (__DATA,__data) section 0000000000000060 01 00 00 00 02 00 00 00 03 00 00 00 $shell> otool -tdV main4first.o main4first.o: (__TEXT,__text) section _main: 0000000000000000 pushq %rbp 0000000000000001 movq %rsp, %rbp 0000000000000004 movl $_main, %eax 0000000000000009 callq _first 000000000000000e movl $_main, %eax 0000000000000013 popq %rbp 0000000000000014 ret
Man sieht, dass bei main4first gar kein Data Segment vorkommt. Ist auch klar, denn es wurde dort keine globale Variable definiert. Außerdem beginnen die Adressen in beiden Object Files bei 0000000000000000 (Wieso?).
Diese Object Files Linken wir nun (nachmachen):
$shell> gcc-4.8 first.o main4first.o
Das mit wurde die ausführbare Datei a.out erzeugt. Deren Inhalt sieht so aus (nachmachen):
$shell> otool -tdV a.out
a.out:
(__TEXT,__text) section
_first:
0000000100000eeb pushq %rbp
0000000100000eec movq %rsp, %rbp
0000000100000eef movl _b(%rip), %edx
0000000100000ef5 movl _c(%rip), %eax
0000000100000efb addl %edx, %eax
0000000100000efd movl %eax, _a(%rip)
0000000100000f03 movl _a(%rip), %eax
0000000100000f09 cmpl $0x4, %eax
0000000100000f0c jne 0x100000f1a
0000000100000f0e movl $0x3, _a(%rip)
0000000100000f18 jmp 0x100000f3f
0000000100000f1a movl _a(%rip), %edx
0000000100000f20 movl _b(%rip), %eax
0000000100000f26 andl %edx, %eax
0000000100000f28 movl %eax, _c(%rip)
0000000100000f2e jmp 0x100000f3f
0000000100000f30 movl _a(%rip), %eax
0000000100000f36 subl $0x1, %eax
0000000100000f39 movl %eax, _a(%rip)
0000000100000f3f movl _a(%rip), %eax
0000000100000f45 testl %eax, %eax
0000000100000f47 jne 0x100000f30
0000000100000f49 popq %rbp
0000000100000f4a ret
_main:
0000000100000f4b pushq %rbp
0000000100000f4c movq %rsp, %rbp
0000000100000f4f movl $0x0, %eax
0000000100000f54 callq _first
0000000100000f59 movl $0x0, %eax
0000000100000f5e popq %rbp
0000000100000f5f ret
(__DATA,__data) section
0000000100001000 01 00 00 00 02 00 00 00 03 00 00 00
Man beachte die Zeile mit callq _first (6 letzte Zeile). Mit strip können wir den Wert von _first sichtbar machen (nachmachen):
$shell> strip a.out $shell> otool -tdV a.out a.out: (__TEXT,__text) section 0000000100000eeb pushq %rbp 0000000100000eec movq %rsp, %rbp 0000000100000eef movl 0x10f(%rip), %edx 0000000100000ef5 movl 0x10d(%rip), %eax 0000000100000efb addl %edx, %eax 0000000100000efd movl %eax, 0xfd(%rip) 0000000100000f03 movl 0xf7(%rip), %eax 0000000100000f09 cmpl $0x4, %eax 0000000100000f0c jne 0x100000f1a 0000000100000f0e movl $0x3, 0xec(%rip) 0000000100000f18 jmp 0x100000f3f 0000000100000f1a movl 0xe0(%rip), %edx 0000000100000f20 movl 0xde(%rip), %eax 0000000100000f26 andl %edx, %eax 0000000100000f28 movl %eax, 0xda(%rip) 0000000100000f2e jmp 0x100000f3f 0000000100000f30 movl 0xca(%rip), %eax 0000000100000f36 subl $0x1, %eax 0000000100000f39 movl %eax, 0xc1(%rip) 0000000100000f3f movl 0xbb(%rip), %eax 0000000100000f45 testl %eax, %eax 0000000100000f47 jne 0x100000f30 0000000100000f49 popq %rbp 0000000100000f4a ret 0000000100000f4b pushq %rbp 0000000100000f4c movq %rsp, %rbp 0000000100000f4f movl $0x0, %eax 0000000100000f54 callq 0x100000eeb 0000000100000f59 movl $0x0, %eax 0000000100000f5e popq %rbp 0000000100000f5f ret (__DATA,__data) section 0000000100001000 01 00 00 00 02 00 00 00 03 00 00 00
Es sollt klar sein, dass der Wert von _first erst nach dem Linken fest steht. Denn erst dann weiss man wo der Maschinen-Code der Funktion first() im Text-Segment steht.
Fussnoten
- 1Also Byte, Word, Double Word, Quad Word
- 2GAS = GNU Assembler