Assembler-Kurs · Lektion 2

Hello, World! mit printf und Formaten

Am Ende kannst du Text ausgeben, einer Funktion Argumente übergeben und Zahlen mit printf als Dezimalzahl, Hexzahl und Zeichen schreiben. Voraussetzung: Lektion 1.

Architektur:
Video zu dieser Lektion folgt.

Die Idee: eine fertige Funktion benutzen

Ein Programm, das etwas auf den Bildschirm schreibt, müsste selbst mit dem Betriebssystem sprechen. Einfacher ist es, eine fertige Funktion zu benutzen. Wir nehmen printf aus der C-Laufzeitbibliothek msvcrt.dll, die auf jedem Windows vorhanden ist. Mit .import msvcrt.dll printf sagst du dem Assembler, dass dein Programm diese Funktion braucht. Er legt eine Importtabelle an, in der Windows beim Start die Adresse von printf einträgt. Aufgerufen wird die Funktion über diesen Eintrag.

Zwei Dinge brauchst du dafür: den Text im Speicher und die Regeln, wie man einer Funktion Argumente gibt.

Text im Speicher

Im Abschnitt .data legst du Daten ab. msg db 'Hello, World!', 10, 0 heißt: db (define byte) legt Bytes hintereinander in den Speicher; der Text in Hochkommas wird zu seinen Zeichencodes (ein Byte pro Zeichen, H ist 72). 10 ist der Zeilenumbruch, 0 markiert das Ende des Textes – printf liest, bis es eine 0 findet. Das Label msg steht für die Adresse, an der der Text beginnt. An printf gibst du also keinen Text, sondern die Adresse, wo er steht.

Wohin mit den Argumenten?

Die Regel, welches Argument wo übergeben wird, heißt Aufrufkonvention. Sie gilt für jede Funktion, die du aufrufst, nicht nur für printf.

Auf dem i386 (cdecl) kommen die Argumente auf den Stack, das letzte zuerst: push legt einen Wert oben auf den Stack. Nach dem Aufruf muss der Aufrufer sie selbst wieder entfernen (add esp, 4 für ein Argument à 4 Byte). Das Ergebnis der Funktion steht in eax.

Auf dem x64 (Windows) kommen die ersten vier Argumente in rcx, rdx, r8 und r9, das erste in rcx. Weitere kämen auf den Stack. Zusätzlich muss der Aufrufer 32 Byte Platz auf dem Stack lassen (Shadow-Space) und den Stack auf 16 Byte ausgerichtet halten – deshalb das sub rsp, 40 (32 + 8: beim Start des Programms liegt schon die 8 Byte große Rücksprungadresse auf dem Stack, die 8 Byte machen ihn wieder durch 16 teilbar). Das Ergebnis steht in rax.

Auf ARM 32 kommen die ersten vier Argumente in r0, r1, r2 und r3, das erste in r0. Der Aufruf blx springt in die Funktion und legt die Rücksprungadresse in lr. Weil der Aufruf lr überschreibt, muss ein Programm, das selbst Funktionen aufruft, lr zuerst auf dem Stack sichern (push {r4, lr}) und am Ende in pc zurückholen (pop {r4, pc}). Das Ergebnis steht in r0. r0 bis r3 darf jede Funktion verändern; r4 bis r11 muss sie unverändert zurückgeben.

Auf ARM 64 kommen die ersten acht Argumente in x0 bis x7, das erste in x0. Der Aufruf blr springt in die Funktion und legt die Rücksprungadresse in x30. Weil der Aufruf x30 überschreibt, muss ein Programm, das selbst Funktionen aufruft, x30 zuerst auf dem Stack sichern (stp x29, x30, [sp, #-16]!) und am Ende zurückholen (ldp). Das Ergebnis steht in x0. x0 bis x17 darf jede Funktion verändern; x19 bis x28 muss sie unverändert zurückgeben.

Hello, World!

; Lesson 2: Hello, World! with printf (i386, cdecl: the arguments go on the stack).

.format pe console
.arch i386
.import msvcrt.dll printf

.code
start:
  push dword msg          ; the one argument: the address of the text
  call [printf]           ; through the import table
  add esp, 4              ; cdecl: the caller takes the argument off the stack
  xor eax, eax            ; exit code 0
  ret

.data
msg db 'Hello, World!', 10, 0     ; 10 = new line, 0 = end of the text
; Lesson 2: Hello, World! with printf (x64, Windows: the first arguments are in rcx, rdx, r8, r9).

.format pe console
.arch x64
.import msvcrt.dll printf

.code
start:
  sub rsp, 40             ; 32 bytes shadow space for printf + 8 to align the stack
  lea rcx, [msg]          ; first argument: the address of the text
  call [printf]
  xor eax, eax            ; exit code 0
  add rsp, 40
  ret

.data
msg db 'Hello, World!', 10, 0     ; 10 = new line, 0 = end of the text
; Lesson 2: Hello, World! with printf (ARM 32, Thumb-2: the first arguments are in r0 to r3).

.format pe console
.arch thumb
.import msvcrt.dll printf

.code
start:
  push {r4, lr}           ; save lr (the way back); r4 keeps the stack 8-byte aligned
  adr r0, msg             ; first argument: the address of the text (relative to pc)
  ldr r3, printf          ; the address of printf from the import table
  blx r3                  ; call it (lr = the next instruction)
  movs r0, #0             ; exit code 0
  pop {r4, pc}            ; return: the saved lr goes into pc

.data
msg db 'Hello, World!', 10, 0     ; 10 = new line, 0 = end of the text
; Lesson 2: Hello, World! with printf (ARM 64: the first arguments are in x0 to x7).

.format pe console
.arch arm64
.import msvcrt.dll printf

.code
start:
  stp x29, x30, [sp, #-16]!     ; save the way back (x30 = lr)
  adrp x0, msg                  ; the page of the text ...
  add x0, x0, :lo12:msg         ; ... plus the position in the page: first argument
  adrp x16, printf
  ldr x16, [x16, :lo12:printf]  ; the address of printf from the import table
  blr x16
  mov x0, #0                    ; exit code 0
  ldp x29, x30, [sp], #16
  ret

.data
msg db 'Hello, World!', 10, 0     ; 10 = new line, 0 = end of the text

Formate

Das erste Argument von printf ist ein Formattext. Steht darin ein %, setzt printf dort das nächste Argument ein:

Formatschreibt das Argument alsBeispiel für 42
%dDezimalzahl mit Vorzeichen42
%xHexzahl (%X: mit Großbuchstaben)2a
%cein Zeichen (der Wert ist der Zeichencode)*
%sText; das Argument ist die Adresse des Textes–
%%das Zeichen % selbst%

Das Programm rechnet 20 + 22 und gibt drei Zeilen aus. Achte darauf, wie die Werte in der jeweiligen Architektur zu printf kommen.

; Lesson 2: printf with formats (i386). %d = decimal, %x = hex, %c = character, %s = text.

.format pe console
.arch i386
.import msvcrt.dll printf

.code
start:
  mov eax, 20
  add eax, 22             ; eax = 42
  push eax                ; the arguments from right to left: the last one first
  push 22
  push 20
  push dword f_sum        ; and the format at the end (it is the first argument)
  call [printf]
  add esp, 16             ; 4 arguments of 4 bytes

  push 42                 ; %c
  push 42                 ; %x
  push 42                 ; %d
  push dword f_same
  call [printf]
  add esp, 16

  push 8                  ; %d
  push dword name         ; %s: the address of the text
  push dword f_text
  call [printf]
  add esp, 12

  xor eax, eax
  ret

.data
f_sum  db '%d + %d = %d', 10, 0
f_same db '%d is %x in hex and the character %c', 10, 0
f_text db 'The name %s has %d letters', 10, 0
name   db 'Asmonite', 0
; Lesson 2: printf with formats (x64). %d = decimal, %x = hex, %c = character, %s = text.

.format pe console
.arch x64
.import msvcrt.dll printf

.code
start:
  sub rsp, 40
  mov edx, 20             ; second argument: rdx (edx is its lower half)
  mov r8d, 22             ; third argument: r8
  mov r9d, edx
  add r9d, r8d            ; fourth argument: r9 = 20 + 22 = 42
  lea rcx, [f_sum]        ; first argument: the format
  call [printf]

  lea rcx, [f_same]
  mov edx, 42             ; %d
  mov r8d, 42             ; %x
  mov r9d, 42             ; %c
  call [printf]

  lea rcx, [f_text]
  lea rdx, [name]         ; %s: the address of the text
  mov r8d, 8              ; %d
  call [printf]

  xor eax, eax
  add rsp, 40
  ret

.data
f_sum  db '%d + %d = %d', 10, 0
f_same db '%d is %x in hex and the character %c', 10, 0
f_text db 'The name %s has %d letters', 10, 0
name   db 'Asmonite', 0
; Lesson 2: printf with formats (ARM 32, Thumb-2). %d = decimal, %x = hex, %c = character, %s = text.
; The arguments: r0 = format, r1, r2, r3 = the values after it.

.format pe console
.arch thumb
.import msvcrt.dll printf

.code
start:
  push {r4, lr}
  ldr r4, printf          ; r4 keeps the address of printf: r4 stays unchanged across calls

  movs r1, #20
  movs r2, #22
  adds r3, r1, r2         ; r3 = 20 + 22 = 42
  adr r0, f_sum           ; the format
  blx r4

  adr r0, f_same
  movs r1, #42            ; %d
  movs r2, #42            ; %x
  movs r3, #42            ; %c
  blx r4

  adr r0, f_text
  adr r1, name            ; %s: the address of the text
  movs r2, #8             ; %d
  blx r4

  movs r0, #0
  pop {r4, pc}

.data
align 4
f_sum  db '%d + %d = %d', 10, 0
align 4
f_same db '%d is %x in hex and the character %c', 10, 0
align 4
f_text db 'The name %s has %d letters', 10, 0
align 4
name   db 'Asmonite', 0
; Lesson 2: printf with formats (ARM 64). %d = decimal, %x = hex, %c = character, %s = text.
; The arguments: x0 = format, x1, x2, x3, ... = the values after it (w1 is the lower half of x1).

.format pe console
.arch arm64
.import msvcrt.dll printf

.code
start:
  stp x29, x30, [sp, #-32]!
  str x19, [sp, #16]
  adrp x19, printf
  ldr x19, [x19, :lo12:printf]  ; x19 keeps the address of printf: x19 stays unchanged across calls

  mov w1, #20
  mov w2, #22
  add w3, w1, w2                ; w3 = 20 + 22 = 42
  adrp x0, f_sum
  add x0, x0, :lo12:f_sum       ; the format
  blr x19

  adrp x0, f_same
  add x0, x0, :lo12:f_same
  mov w1, #42                   ; %d
  mov w2, #42                   ; %x
  mov w3, #42                   ; %c
  blr x19

  adrp x0, f_text
  add x0, x0, :lo12:f_text
  adrp x1, name
  add x1, x1, :lo12:name        ; %s: the address of the text
  mov w2, #8                    ; %d
  blr x19

  mov x0, #0
  ldr x19, [sp, #16]
  ldp x29, x30, [sp], #32
  ret

.data
f_sum  db '%d + %d = %d', 10, 0
f_same db '%d is %x in hex and the character %c', 10, 0
f_text db 'The name %s has %d letters', 10, 0
name   db 'Asmonite', 0

Die Argumente werden von rechts nach links auf den Stack gelegt: zuerst der letzte Wert, zuletzt das Format. So liegt das Format oben auf dem Stack und ist für printf das erste Argument. Danach räumt add esp, 16 vier Argumente (je 4 Byte) weg; bei der dritten Ausgabe sind es drei, also add esp, 12. Rechne die Zahl nach, wenn du etwas änderst – ein falsches add esp zerstört den Stack.

Für jeden Aufruf lädst du neu: Format nach rcx, die Werte nach rdx, r8, r9. Die Funktion darf diese Register verändern, also setzt du sie vor jedem Aufruf neu. Mit edx, r8d und r9d schreibst du die unteren 32 Bit; die oberen werden auf 0 gesetzt, das reicht für %d und %x. Bei %s steht in rdx eine volle 64-Bit-Adresse, darum lea rdx, [name].

Das Format steht in r0, die Werte in r1 bis r3. printf verändert diese Register, also setzt du sie vor jedem Aufruf neu. Die Adresse von printf steht in r4 – das Register überlebt den Aufruf unverändert. So muss sie nicht jedes Mal geladen werden. adds r3, r1, r2 addiert und schreibt das Ergebnis in ein drittes Register, ohne die Summanden zu verändern. align 4 sorgt dafür, dass jeder Text an einer durch 4 teilbaren Adresse beginnt; adr verlangt das.

Das Format steht in x0, die Werte in w1 bis w3 (die unteren 32 Bit von x1 bis x3). printf verändert diese Register, also setzt du sie vor jedem Aufruf neu. Die Adresse von printf steht in x19 – das Register überlebt den Aufruf unverändert. Dafür sichert das Programm das alte x19 am Anfang auf dem Stack und holt es am Ende zurück. add w3, w1, w2 addiert und schreibt das Ergebnis in ein drittes Register.

In Asmonite ausprobieren

  1. Öffne ein Programm (Hello, World! oder Formate) und assembliere es mit Strg+F9.
  2. Setze mit F5 einen Haltepunkt auf die Zeile mit call [printf] (bei ARM: auf blx beziehungsweise blr) und starte mit F9. Das Programm hält vor dem Aufruf.
  3. Sieh dir den Stack rechts an: oben liegt die Adresse des Textes, darunter die Werte. Das ist genau, was du mit push dort abgelegt hast.
  4. Sieh dir die Register rcx, rdx, r8 und r9 an: dort stehen die Argumente. Im Speicherfenster siehst du den Text an der Adresse in rcx.
  5. Sieh dir die Register r0 bis r3 an: dort stehen die Argumente. Im Speicherfenster siehst du den Text an der Adresse in r0.
  6. Sieh dir die Register x0 bis x3 an: dort stehen die Argumente. Im Speicherfenster siehst du den Text an der Adresse in x0.
  7. Drücke F8: der Aufruf läuft, und im Ausgabefenster steht der Text. Mit F8 gehst du weiter durch die nächsten Aufrufe.
  8. Drücke F1 auf der Zeile mit dem Aufruf: rechts erklärt die Hilfe printf mit seinen Parametern.

Was du siehst

Hello, World!
20 + 22 = 42
42 is 2a in hex and the character *
The name Asmonite has 8 letters

Die Zahl 42 kommt dreimal an printf; was daraus wird, bestimmt allein das Format: 42, 2a oder das Zeichen mit dem Code 42, ein Stern. Die CPU weiß nicht, was eine Zahl „bedeutet“ – das legt erst das Programm fest.

Übungen

1. Dein Name

Ändere im Formatbeispiel den Namen in deinen eigenen. Die Ausgabe nennt noch die alte Anzahl der Buchstaben. Woran liegt das, und was müsstest du tun, damit sie stimmt?

Lösung

Die Zahl steht als Konstante im Befehl (8), das Programm zählt nichts. Du musst die Zahl selbst anpassen. Selbst zählen kann das Programm mit einer Schleife, die den Text bis zur 0 durchläuft – das machen wir in einer späteren Lektion (Schleifen, Strings).

2. Negative Zahlen

Gib mit demselben Format %d und %x die Zahl −1 aus. Was erscheint, und warum?

Lösung

Mit %d erscheint -1, mit %x ffffffff. Im Register stehen in beiden Fällen dieselben 32 Bit, alle gleich 1. %d liest sie als Zahl mit Vorzeichen (−1), %x als Zahl ohne Vorzeichen (4294967295 = ffffffff). Wie negative Zahlen gespeichert werden, kommt in der Lektion über Bits.

3. Knobelaufgabe: führende Nullen

Gib die Zahl 255 in der Form 0x000000FF aus – mit einem einzigen Format, ohne die Nullen selbst zu schreiben. Das Format %x kennt dafür eine Breite und eine Auffüllung.

Lösung

Das Format 0x%08X: die Zeichen 0x werden unverändert geschrieben, %08X schreibt die Zahl in großen Hexziffern, mindestens 8 Stellen breit (8), links mit Nullen aufgefüllt (0).

Merke