x86-64-Assembly lernen
(gpfault.net)- Vorstellung des ersten Beitrags einer Einsteigerreihe zu x86-64-Assembly
- Erläuterung der Installation von Werkzeugen und der Grundstruktur auf Basis moderner 64-Bit-Systeme
- Empfehlung von Flat Assembler (FASM) und WinDbg als zentrale Entwicklungs- und Debugging-Tools
- Enthält eine Zusammenfassung zentralen Wissens für die Praxis wie PE-Format, DLL-Importe und die Windows-Aufrufkonvention
- Praxisorientierte Erklärung anhand des Schreibens eines einfachen Programms zum Beenden und des Debugging-Ablaufs
Einführung und Bedeutung
- Beim ersten Kontakt mit x86-Assembly wurde an Universitäten oft noch auf Basis veralteter Umgebungen unterrichtet (16 Bit, DOS, segmentierter Speicher)
- Da heute 64-Bit-Prozessoren den Standard bilden, behandelt diese Reihe ausschließlich die tatsächlich genutzte x86-64-Umgebung und lässt alle veralteten Elemente weg
- Dieses Tutorial konzentriert sich auf die Entwicklung von 64-Bit-Programmen für das Windows-Betriebssystem
- Es beginnt mit minimalem Code, der ohne Bibliotheken direkt auf das Betriebssystem zugreift
- Der Beitrag richtet sich an Entwickler, die Assembly zum ersten Mal lernen möchten, und setzt grundlegende C/C++-Kenntnisse voraus
Entwicklungswerkzeuge vorbereiten
Assembler
- Die CPU kann nur Maschinencode ausführen, der für Menschen schwer verständlich ist; Assembly-Sprache ist dessen menschenlesbare Form
- Ein Assembler ist ein Programm, das Assembly-Sprache in Maschinencode umwandelt
- Für x86-64-Assembly gibt es keinen festen Standard; Syntax und Verhalten unterscheiden sich je nach Assembler
- In dieser Reihe wird Flat Assembler (FASM) verwendet, da er klein, einfach zu nutzen und mit einem leistungsfähigen Makrosystem sowie Editor ausgestattet ist
Debugger
- Zur Analyse des geschriebenen Assembly-Codes und zur Beobachtung des Ausführungsflusses ist ein Debugger ein unverzichtbares Werkzeug
- Empfohlen wird WinDbg, mit dem sich Register, Speicher und Assembly-Code unabhängig voneinander prüfen und manipulieren lassen
- Die Installation ist möglich, indem im Windows 10 SDK nur die entsprechenden Komponenten ausgewählt werden
- Mit dem Debugger lassen sich der interne Programmzustand, die Speicherstruktur und Registeränderungen direkt beobachten
Die Sichtweise des Assembly-Programmierens
CPU-Struktur und Befehlssatz
- Eine CPU kann nur eine begrenzte Menge an Operationen ausführen, entsprechend einem bestimmten Befehlssatz
- Ein Befehl ist die grundlegende Arbeitseinheit, die eine CPU ausführen kann
- Jeder Befehl arbeitet zusammen mit Parametern sehr einfach, etwa zum Speichern von Werten oder für arithmetische Operationen
- Für Low-Level-Programmierung und Debugging ist es entscheidend zu verstehen, dass diese Struktur die Grundlage aller High-Level-Konzepte bildet
Register
- Register sind sehr schnelle, dedizierte Speicherbereiche innerhalb der CPU
- In x86-64 gibt es 16 Allzweckregister, alle mit 64 Bit Breite
- Auf jedes Register kann teilweise auch byte-, wort- oder doppelwortweise zugegriffen werden
| Register | Unteres Byte | Unteres Wort | Unteres Doppelwort |
|---|---|---|---|
| rax | al | ax | eax |
| rbx | bl | bx | ebx |
| rcx | cl | cx | ecx |
| rdx | dl | dx | edx |
| rsp | spl | sp | esp |
| rsi | sil | si | esi |
| rdi | dil | di | edi |
| rbp | bpl | bp | ebp |
| r8~r15 | r8b~r15b | r8w~r15w | r8d~r15d |
rspist der Stack Pointer,rsi/rdidienen als Indizes für String-Verarbeitung; einigen Registern sind also spezielle Zwecke zugewiesenripist der Instruction Pointer,rflagsist ein spezielles Register mit Status-Flags für Rechenergebnisse
Speicher und Adressen
- Speicher verhält sich wie ein zusammenhängendes Byte-Array, beginnend bei Index 0
- In älteren x86-Architekturen war das Segment-Offset-Modell zwingend, in x86-64 wird jedoch der gesamte Speicher als flacher (Flat) Adressraum behandelt
- Tatsächlich stellen Betriebssystem und Hardware für jeden Prozess einen virtuellen Adressraum bereit, der dynamisch auf physischen Speicher abgebildet wird
- Das heißt: Dieselbe virtuelle Adresse kann in verschiedenen Prozessen auf unterschiedlichen physischen Speicher zeigen
- Befehle und Daten liegen im selben Speicher (Von-Neumann-Architektur); das unterscheidet sich von Harvard-Architekturen wie AVR auf Arduino, bei denen Daten getrennt gespeichert werden
Das erste Assembly-Programm schreiben
- Nach der Installation von FASM folgt eine Übung zum Schreiben und Bauen des folgenden einfachen Programms
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
ret
Code-Erklärung
format PE64 NX GUI 6.0: Legt das von FASM zu erzeugende ausführbare Dateiformat fest; hier PE (Portable Executable), 64 Bit, GUIentry start: Definiert den Entry Point des Programms; die Ausführung beginnt an der Position dieses Labels (start)section '.text' code readable executable: Markiert den Code-Abschnitt der PE-Datei; ein ausführbarer Bereichstart:: Vergibt den Namen für den zuvor festgelegten Einstiegspunktint3: Ein Breakpoint für den Debugger, der das Programm pausiert, um den Zustand zu prüfenret: Ein Befehl, der eine Adresse vom Stack nimmt und die Kontrolle an diese Position übergibt; in diesem Programm führt das direkt zum Beenden
Debugging-Übung
-
In WinDbg wird die ausführbare Datei (.exe) des obigen Programms geöffnet, und verschiedene Fenster wie Disassembly und Register werden vorbereitet
-
Mit F5 läuft das Programm bis zum Breakpoint, und mit jedem Druck auf F8 wird genau ein Befehl ausgeführt (Einzelschritt)
-
Änderungen an Registern wie
riplassen sich in Echtzeit beobachten -
Nach der Ausführung von
retwird die Kontrolle an das Betriebssystem zurückgegeben; anschließend folgt überRtlExitUserThreaddas Beenden von Thread und Prozess -
Hinweis: Wenn nur
retzum Beenden verwendet wird, kann der Prozess abhängig von zusätzlicher Hintergrundausführung neben dem Thread bestehen bleiben; daher ist es für ein korrektes Beenden sinnvoll, immer ExitProcess aufzurufen
PE-Format und DLL-Importe
Überblick über die Struktur des DLL-Funktionsimports
- WinAPI-Funktionen wie ExitProcess befinden sich in KERNEL32.DLL
- Um solche externen Funktionen zu verwenden, muss die Importtabelle der ausführbaren Datei (Sektion
.idata) aufgebaut werden - Die Import Directory Table (IDT) in der
idata-Sektion enthält Informationen wie DLL-Name, Funktionsname sowie Adressen von IAT/ILT (RVA) - Die IAT (Import Address Table) wird zur Laufzeit vom OS-Loader mit den tatsächlichen Funktionsadressen überschrieben
- Die Hint/Name Table besteht aus Namens- und Hint-Informationen zu jeder Funktion
Beispiel für die Definition der .idata-Sektion in FASM
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name: db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
db/dw/dd/dq: Fügt Werte als Byte/Wort/Doppelwort/Quadword (8 Byte) einrva: Berechnet die virtuelle Adresse eines Symbols (Relative Virtual Address)- IAT und Name Table können von Hand aufgebaut werden, um DLL-Funktionen zu referenzieren
64-Bit-Windows-Aufrufkonvention (MS x64 Calling Convention)
- Eine Standardkonvention, die festlegt, wie beim Funktionsaufruf Argumente übergeben und der Stack verwendet wird
- Unter 64-Bit-Windows wird die Microsoft x64 Calling Convention verwendet
- Wichtige Merkmale:
- Der Stack Pointer muss immer 16-Byte-ausgerichtet sein
- Die ersten vier Integer-/Pointer-Argumente werden über die Register rcx, rdx, r8, r9 übergeben
- Die ersten vier Gleitkomma-Argumente werden in xmm0~xmm3 abgelegt
- Weitere Argumente werden über den Stack übergeben
- Unabhängig von der Anzahl der Argumente müssen 32 Byte Shadow Space auf dem Stack reserviert werden
- Für das Aufräumen des Stacks ist der Aufrufer verantwortlich
Beispielaufruf von ExitProcess
format PE64 NX GUI 6.0
entry start
section '.text' code readable executable
start:
int3
sub rsp, 8 * 5
xor rcx, rcx
call [ExitProcess]
section '.idata' import readable writeable
idt:
dd rva kernel32_iat
dd 0
dd 0
dd rva kernel32_name
dd rva kernel32_iat
dd 5 dup(0)
name_table:
_ExitProcess_Name dw 0
db "ExitProcess", 0, 0
kernel32_name db "KERNEL32.DLL", 0
kernel32_iat:
ExitProcess dq rva _ExitProcess_Name
dq 0
Analyse des neuen Codes
-
sub rsp, 8 * 5: Passt den Stack Pointer an (40 Byte reservieren), um 16-Byte-Ausrichtung und Shadow Space auf einmal sicherzustellen -
xor rcx, rcx: Setzt das erste Argument im Registerrcxauf 0 (als Exit-Code verwendet) -
call [ExitProcess]: Springt an die tatsächliche Funktionsadresse von ExitProcess, die in der Importtabelle eingetragen wurde -
Bei der schrittweisen Ausführung in WinDbg lassen sich die Änderungen am Stack Pointer (
rsp) und am Registerrcxsowie der Ablauf der Prozessbeendigung direkt nachvollziehen
Abschluss
- Dieser Beitrag führt praxisnah durch den gesamten Ablauf von x86-64-Assembly, von der Einrichtung der Grundwerkzeuge über PE-Format, DLL-Importe und x64-Aufrufkonventionen bis zum ersten Programm und Debugging
- Im nächsten Teil sollen vielfältigere Funktionen und echter Code behandelt werden
1 Kommentare
Hacker-News-Kommentare
Ich möchte ein Projekt teilen, das ich über mehrere Jahre entwickelt habe.
https://asm-editor.specy.app
Es ist eine interaktive Online-IDE, die verschiedene Assemblersprachen wie M68K, MIPS, RISC-V und X86 unterstützt.
Sie bietet viele Funktionen, um Assemblerprogrammierung zu unterrichten.
Sie kann auch in andere Websites eingebettet werden.
Ich wusste nicht, dass es für Pointer-Indexierungsregister einen direkten Zugriff auf das Low-Byte gibt (z. B. kann man bei 16/32 Bit auf si/esi über sil zugreifen).
Das ist ein ähnliches Konzept wie der Zugriff auf al über ax/eax.
Ich frage mich, ob die in x86_64 neu hinzugefügten Opcodes tatsächlich existieren.
Ich denke, ich sollte die Plattformspezifikation noch einmal prüfen.
Ich frage aus reiner Neugier.
Ich teile ein selbst geschriebenes Einstiegsmaterial zu Assembler.
https://www.nayuki.io/page/a-fundamental-introduction-to-x86-assembly-programming
Ich habe versucht, die Dispatch-Logik meines CPU-Emulators in Assembler zu optimieren, weil ich wissen wollte, ob ich sie schneller als in C++ machen kann.
Ich habe ein Fibonacci-Programm ausgeführt, aber das Ergebnis kam nicht einmal annähernd heran.
Am Ende habe ich es nur mit einer standardmäßig deaktivierten Option gemergt.
Trotzdem glaube ich, dass es definitiv einen Weg gibt, es schneller zu machen.
https://github.com/libriscv/libriscv/blob/master/lib/libriscv/amd64/inaccurate_dispatch.nasm
Während ich gelernt habe, wie man auf Speicher zugreift, konnte ich die Performance ein wenig verbessern.
Ich habe die Jump-Table von 64 Bit auf 32 Bit verkleinert und sie in die
.text-Sektion gelegt, damit RIP-relativer Zugriff möglich ist.Das Fibonacci-Programm brauchte nicht viele Bytecodes.
Ich würde mich wirklich über Tipps freuen, was ich noch verbessern kann.
Ich kenne den Kontext nicht genau, aber ich denke, der Unterschied könnte nicht am Dispatch-Mechanismus (also daran, wie Instruktionen geholt werden) liegen, sondern an Unterschieden in der eigentlichen Implementierung der Instruktionen.
Eine mögliche Optimierung wäre, die emulierten Register auf echte x86-64-Register abzubilden und sie gar nicht erst in den Speicher auszulagern.
Dann könnte man Operationen wie
adddirekt ausführen, ohne Werte erst aus dem Speicher zu laden.Allerdings wird der Emulator dadurch deutlich mühsamer zu schreiben.
Das ist ein Einstiegsmaterial zu x86-Assembler, mit dem man direkt im Browser üben kann.
Beispiele lassen sich ohne besonderes lokales Setup sofort ausführen.
https://shikaan.github.io/assembly/x86/guide/2024/09/08/x86-64-introduction-hello.html
Zur Einordnung: Das Material habe ich selbst geschrieben.
Es wirkt so, als würde direkt mit NASM assembliert und dann das Binärprogramm ausgeführt, deshalb frage ich mich wegen der Sicherheit.
Nur anhand des Profilbilds dachte ich, du wärst junferno.
Schon allein einmal mit Assembler zu arbeiten, vertieft das allgemeine Verständnis und ist deshalb immer eine gute Erfahrung.
Man muss dafür kein großes Projekt bauen; ich würde empfehlen, einfach mutig zu sein und wenigstens ein bisschen selbst damit herumzuspielen.
Ich teile den Link zur damaligen HN-Diskussion (2020).
https://news.ycombinator.com/item?id=24195627
Ich bin froh, dass es die Intel-Syntax für Assembler ist.
Ich würde gern etwas in Assembler machen, aber mir fällt keine konkrete Idee ein.
Es ist ein Spiel, in dem man mit einer Art Pseudo-Assembler Rätsel löst.
Ich denke, solche Spiele können das Verlangen stillen, sich mit Assembler zu beschäftigen.