X86 Assembler - Eine Einführung
Aus HackerWiki
Contents |
Ziel des Artikels
Dieser Artikel führt in die x86 Assemblerprogrammierung mit dem GNU Assembler ein und ist für Einsteiger
als auch für fortgeschrittene Programmierer interessant. Rudimentäre Kentnisse in C sind von Nutzen aber nicht erforderlich
Er deckt die Themen Syscalls allgemein, Ein-/Ausgabe, Dateizugriffe und Kommandozeilenparameter ab.
Was ist Assembler, wozu ist das gut und wann ist es sinnvoll?
Assembler ist eine Programmiersprache, die dem Befehlssatz eines Prozessors sehr nahe steht.
Der Vorteil von Assembler gegenüber Hochsprachen liegt in der effizienten Nutzung der Hardware.
Dadurch werden die ausführbaren Programme sehr klein und sehr schnell.
Der Nachteile gegenüber Hochsprachen sind längere Entwicklungszeiten und weniger Strukturierungsmöglichkeiten
des Quelltextes, was diesen schwerer les- und wartbar macht.
Sinnvoll ist Assembler heutzutage in Echtzeitumgebungen, oder als schnelles Programmmodul innerhalb von Hochsprachenprogrammen.
Entweder direkt in den Quelltext eingearbeitet (inline-asm), als Objektdatei hinzugelinkt oder als eigenständiges Programm,
das vom Hochsprachenprogramm aufgerufen wird (Datenaustausch z.B. über Kommandozeilenparameter, Sharedmemory, Dateien oder Pipes).
Eingesetzt wird Assembler in der Kernel-/Treiberprogrammierung, im Bereich der virtuellen Maschinen, in hungrigen Grafikanwendungen
wie z.B. Gameengines(um ausreichend hohe Frameraten zu erreichen), in eingebetteten Systemen (kaum Resourcen verfügbar),
in ausführbaren Codefragmenten (Shellcode, Programmerweiterungen, Viren, etc) und zur Lehre an Universitäten.
GNU Asm <-> Intel Asm
In diesem Artikel werde ich den GNU Assembler verwenden.
Unterschiede
| GNUas | IntelAsm | Beschreibung |
| Die Reihenfolge der Instruktionsparameter | ||
mov a, b | mov b, a | Kopiert a nach b |
add a, b | add b, a | Addiert a auf b (Ergebnis in b) |
| Konstanten und Registerkennzeichnung | ||
mov $konst, %reg | mov reg, konst | Kopiert Konstante konst in das Register reg |
add $konst, %reg | add reg, konst | Addiert Konstante konst auf das Register reg |
| Datentypsuffixe | ||
movb %al, %bl | mov bl, al | Kopiert 1 Byte-Register (b=byte) |
movw %ax, %bx | mov bx, ax | Kopiert 2 Byte-Register (w=word) |
movl %eax, %ebx | mov ebx, eax | Kopiert 4 Byte-Register (l=long) |
| Komentare | ||
# Kommentar | ; Kommentar | Platziert ein Kommentar |
Dem Prozessor ist es letztendlich egal, mit welcher Syntax der Maschinencode erstellt wurde.
Auch die Qualität des Codes ist nicht von der Syntax abhängig.
Warum GNUas (gas) und nicht IntelAsm (nasm)
- Die Syntax ist meiner Meinung nach Intuitiver
- Beim Schreiben und Übersetzen ist durch die Suffixe eine eindeutige Zuordnung der Wortbreite gewährleistet
- Durch die explizite Unterscheidung zwischen Konstanten und Marken wird der Quelltext lesbarer (Fehleranfälligkeit sinkt)
- Wird auf Unix/Linux ähnlichen Systemen häufiger verwendet als nasm (z.B. in den Kernel- und Treiberquellen), schadet daher nicht, sich da schon auszukennen
- Ist der Standardassembler von GCC
- Für Kommentare wird die Shifttaste nicht benötigt (schneller, bequemer) beim Intelassembler könnte man Capslock einschalten, wenn da nicht die Kommas wären
Syscalls
Bei einem Syscall wird für ein Betriebssystemzugriff der Usermode des Prozessors verlassen und in den Kernelmode gewechselt. Das ganze nennt man dann auch Kerneltrap oder Softwareinterrupt. Im Gegensatz zu einem Hardwareinterrupt (asynchron) ist so ein Kerneltrap immer synchron zum Prozessortakt. Die Nummern und Parameter der Syscalls können in den Kernelquellen nachgelesen werden (/usr/src/linux/arch/x86/kernel/syscall_table.S). Der Aufruf erfolgt immer nach dem gleichen Schema: Die Syscallnummer steht in eax Die Parameter stehen in ebx, ecx und edx Ausgefuehrt wird ein Syscall mit int $0x80 Der Rueckgabewert steht in eax Der Vorteil von Syscalls : Sie sind die schnellste Möglichkeit, Kernelfunktionen zu nutzen. Man kann natürlich auch Bibliotheksfunktionen aufrufen (z.b. call printf), dazu muss das Programm dann allerdings mit der jeweiligen Bibliothek (z.B. libc, ld -lc -o prog prog.o) gelinkt werden und wird wieder groß und langsam. Ein weiterer Vorteil der Syscalls: werden die Binutils (cp, rm, mv, ...) vom System überwacht, kann über ein winziges Assemblerprogramm dieselbe Funktionalität erreicht werden. Zum Spass, zur Übung und für weitere diverse Vorteile, kann man sich Teile der Binutils in Assembler nachprogrammieren. In der Regel sind die Asm-Alternativen um den Faktor 10 kleiner. In vielen Fällen ist auch eine Geschwindigkeitssteigerung drin, wobei ein kleineres Programm nicht automatisch schneller ist. Die meisten Operationen werden durch I/O verlangsamt. Da machen 500 Instruktionen mehr oder weniger nichts mehr aus. Um die Wartezeiten durch I/O-Sequenzen gering zu halten gibt es eine ganze Reihe Tricks, die auch sehr komplex ausfallen können. Der cp Befehl erreicht z.B. eine immense Geschwindigkeit durch das Anpassen der I/O Puffergroesse an die Eigenschaften der Blockgeräte. Das Verhalten bei unterschiedlichen Puffergrößen kann man sich mit Assembler direkt anschauen. Wird die Datei Byteweise eingelesen und geschrieben, ist das die langsamste Implementierung. Will man die gesamte Datei in einen Puffer einlesen und danach den Puffer in die Zieldatei schreiben, ist das sicherlich eine der schnellsten Varianten bei kleinen Dateien. Allerdings muss man dafür erst die Größe des benötigten Puffers ermitteln und den Speicherplatz dafür im Hauptspeicher reservieren. Ist die Datei sehr groß, können dabei sehr leicht Zähler überlaufen, oder der Anfang der Datei wird vom Betriebssystem schon auf die Festplatte ausgelagert, da lange kein Zugriff darauf erfolgte und sich der freie Speicher dem Ende neigt. Ein weiteres Problem sind unnötige Wartezeiten durch aufeinanderfolgende I/O Operationen. Angenommen der Puffer ist zur Hälfte eingelesen und das Gerät braucht etwas Zeit um den nächsten Block einzulesen, das Gerät auf das geschrieben werden soll ist jedoch Schreibbereit und der Puffer ist zur Hälfte voll. Im Normalfall wird gewartet bis der Puffer ganz voll ist, um danach beim Schreibprozess wieder zu warten. Den Ansatz den man stets wählen sollte ist : MCCF Make the common case fast. Besser in 20% der Fälle Geschwindigkeitseinbusseen hinnehmen, als in 80% der Fälle langsamer zu fahren als nötig. Meine Implementierung von cp ist kleiner und in vielen Fällen schneller, als cp von binutils. Insbesondere bei grossen Dateien merkt man einen deutlichen Unterschied. Was bei einzelnen Kilobytes nur Millisekunden ausmacht, kann man bei Gigabytes schon an den Fingern abzählen. Allerdings verzichte ich auch auf alle möglichen Funktionen, die ich nicht benutze wie z.B. die SElinux-Flags.
Vergleich: Assembler, C, C++
hallo.s
.globl _start # Der Programmstart muss Global bekannt sein
.data # Hier kommen unsere Variablen rein
meintext: # Sprungmarken kann man auch als Variablennamen verwenden
.ascii "Hallo\n" # Das ist unser Datensatz der ausgegeben werden soll
.text # Hier faengt der Codeabschnitt an
_start: # Hier ist der Programmstart (wichtig für den Linker(Entrypoint))
movl $0x04, %eax # Syscall Nr. 4 = In Datei Schreiben
movl $0x01, %ebx # Datei Nr. 1 = stdout (Konsolenausgabe)
movl $meintext, %ecx # Adresse der Daten die geschrieben werden sollen
movl $0x06, %edx # Anzahl der Bytes die geschrieben werden sollen
int $0x80 # Syscall ausführen
movl $0x01, %eax # Syscall Nr. 1 = Sys_Exit
xorl %ebx, %ebx # Rückgabewert = 0 (XOR mit sich selbst gibt immer Null)
int $0x80 # Syscall ausführen
hallo_c.c
#include <stdio.h>
int main ()
{
printf ("Hallo\n");
return 0;
}
hallo_cpp.cpp
#include <iostream>
using namespace std;
int main ()
{
cout << "Hallo" << endl;
return 0;
}
Größenvergleich der Binärdateien
-rwx------ 605 Bytes hallo
-rwx------ 4835 Bytes hallo_c
-rwx------ 6383 Bytes hallo_cpp
Geschindigkeitsvergleich der Binärdateien
asm real 0.003 user 0.000 sys 0.001
c real 0.004 user 0.001 sys 0.002
cpp real 0.007 user 0.001 sys 0.003
Erstellen des lauffähigen Programms
Assembliert wird die Quelldatei mit as -o hallo.o hallo.s
Zu einem ausführbaren Programm gelinkt wird die Objektdatei mit ld -o hallo hallo.o
Ausgeführt wird die Datei dann ganz normal mit ./hallo
Dateizugriffe
In Dateien schreiben oder von ihnen lesen funktioniert wie die Konsolenausgabe, nur dass die Dateien vorher noch geöffnet werden müssen:
Datei zum Lesen öffnen
datei_oeffnen:
movl $0x05, %eax # Syscall sys_open
movl $adr_dateiname, %ebx # Adresse des Dateinames
movl $0x00, %ecx # Flag O_RDONLY
movl $0x00, %edx # Mode FMODE
int $0x80 # Syscall sys_open
testl %eax, %eax # Auf Nullzeiger pruefen
jz dateifehler # Die Datei konnte nicht geoeffnet werden
movl %eax, dateizeiger # Dateizeiger (FILE *) sichern
Daten aus Datei lesen
_datei_lesen:
movl $0x03, %eax # Syscall sys_read
movl dateizeiger, %ebx # Dateizeiger der Quelldatei
movl puffer, %ecx # Zeiger des Puffers
movl $0x80000, %edx # 512 KBytes auf einmal lesen
int $0x80 # Syscall sys_read
testl %eax, %eax # Anzahl der gelesenen Bytes pruefen
jz schliessen # Wenn nichts mehr gelesen wurde, feof
Kommandozeilenparameter
Kommandozeilenparameter werden vom Betriebsystem auf dem Stack abgelegt und können dadurch sehr einfach eingelesen werden.
Die Anzahl und der Aufbau stimmt mit der Deklaration von main in C überein:
int argc, char ** argv, char ** env
(Parameteranzahl, Zeiger auf die das Parameterfeld und die Umgebungsvariablen)
Im folgenden ein kleines Assemblerprogramm um den ersten Parameter auszugeben:
.globl _start # _start muss global sein, damit der Linker den Einsprungspunkt findet
.data # Hier beginnt das Datensegment
_fehlertext: # Marken können als Sprungziel aber auch als 'Variablennamen' dienen. Marken sind nichts weiter als Speicheradressen,
# denen man einen Namen gibt
.ascii "Sie haben nichts eingegeben\n" # Hier steht der Fehlertext
_laenge_fehlertext = . - _fehlertext # Die Länge können wir uns bequem ausrechnen lassen, ist nur die Differenz
# zwischen der aktuellen Speicheradresse und der Sprungmarke
.text # Hier beginnt die Codesektion
_start: # Am globale Einsprungspunkt beginnt unser Programm
popl %eax # Wir speichern uns den ersten Parameter vom Stack in den Akku (int argc)
decl %eax # Danach erniedrigen wir die Zahl um eins, argc=1 bedeutet, dass kein Argument übergeben wurde
jz _fehlerausgabe # Wenn wir jetzt auf Null testen, finden wir raus, ob argc 1 war (klar geht auf mit cmpl $0x01, %eax, aber so ists schneller)
popl %ecx # Wenn wir mindestens einen Parameter bekommen haben, wird argv vom Stack geholt, erstmal den Programmnamen, den wir ignorieren
popl %ecx # Danach den Zeiger auf unseren Parameter
xorl %edx,%edx # Jetzt müssen wir die Länge des Parameters ermitteln (äquivalent zu strlen), ersmal die Länge mit Null initialisieren
jmp _laenge_ermitteln # Danach zur Sprungmarke _laenge_ermitteln
_laenge_ermitteln_inc: # Dieser Befehl wird beim ersten Mal übersprungen, da immer erst nach der Zeichnprüfung hochgezählt werden soll
incl %edx # Erhöhe den Zeichenzähler um eins
_laenge_ermitteln: # In ecx ist die Startadresse unserer Zeichenkette, edx ist unser Zähler
movl (%edx,%ecx), %eax # Kopiere das Zeichen an der Stelle edx+ecx in das Lowbyte des Akku
cmpb $0x00, %al # Prüfe, ob das Byte Null ist
jne _laenge_ermitteln_inc # Falls nicht, springe zum inkrementieren
movl $0x04, %eax # Falls das Byte aber Null war, ist der Terminator erreicht und die Zeichenkettenlaenge ist in edx
movl $0x01, %ebx # Kopiere 0x04 (sys_write) in den Akku und 0x01 (stdout) ins Basisregister
int $0x80 # Die Startadresse haben wir schon in ecx und die Laenge steht auch shcon in edx, also ab in den kernel
xorl %ebx, %ebx # Der Rückgabewert des Programms wird auf Null gesetzt, da alles in Ordnung war
jmp _beenden # Die Fehlerausgabe ueberspringen und das Programm beenden
_fehlerausgabe: # Zur Fehlerroutine wird nur gesprungen, wenn kein Parameter angegeben wurde
movl $0x04, %eax # 0x04 (sys_write) in den Akku
movl $0x02, %ebx # 0x02 (stderr) ins Basisregister
movl $_fehlertext, %ecx # Die Adresse (daher der Konstantenoperator, vergleichbar mit & in C) des Fehlertextes ins Counterregister
movl $_laenge_fehlertext, %edx # Und die (schon beim assemblieren) vorberechnete Länge des Textes ins Datenregister
int $0x80 # Ausgabe des Fehlertextes über den Syscall
movl $-0x01, %ebx # Den Rückgabewert des Programms auf -1 setzen
_beenden: # Jetzt wird das Programm ordnungsgemäss beendet
mov $0x01, %eax # 0x01 (sys_exit) in den Akku, der Rückgabewert im Basisregister wurde bereits gesetzt
int $0x80 # Syscall ausführen

