3 Punkte von GN⁺ 2025-07-14 | 1 Kommentare | Auf WhatsApp teilen
  • 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
  • rsp ist der Stack Pointer, rsi/rdi dienen als Indizes für String-Verarbeitung; einigen Registern sind also spezielle Zwecke zugewiesen
  • rip ist der Instruction Pointer, rflags ist 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, GUI
  • entry 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 Bereich
  • start: : Vergibt den Namen für den zuvor festgelegten Einstiegspunkt
  • int3 : Ein Breakpoint für den Debugger, der das Programm pausiert, um den Zustand zu prüfen
  • ret : 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 rip lassen sich in Echtzeit beobachten

  • Nach der Ausführung von ret wird die Kontrolle an das Betriebssystem zurückgegeben; anschließend folgt über RtlExitUserThread das Beenden von Thread und Prozess

  • Hinweis: Wenn nur ret zum 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) ein
  • rva : 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 Register rcx auf 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 Register rcx sowie 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

 
GN⁺ 2025-07-14
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.

    • Hast du deinen eigenen Code direkt mit dem vom C++-Compiler erzeugten Code verglichen?
      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 add direkt 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.

    • Mich würde interessieren, ob es eine Eingabevalidierung gibt.
      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 frage mich, welche andere Assembler-Syntax es noch gibt.
  • Ich würde gern etwas in Assembler machen, aber mir fällt keine konkrete Idee ein.

    • Ich empfehle das Spiel TIS-100.
      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.