Programmieren mit ARM64 Assembler
Grundlagen
Der ARM-Prozessor ist ein sogenannter RISC-Computer (Reduced Instruction Set Computer). Das Designprinzip von RISC-Prozessoren führt zu einem kleineren und übersichtlicheren Befehlssatz, was das Erlernen von Assembler theoretisch einfacher macht. In dieser Einführung werden wir die grundlegenden Konzepte der ARM64-Bit Assembler Programmierung verständlich und praxisorientiert erklären, sodass sie auch für Anfänger zugänglich ist.
Was ist Assembler?
Assembler ist eine Programmiersprache, die es ermöglicht, den Computer auf sehr niedriger Ebene direkt zu steuern. Anders als Hochsprachen wie C oder Python kommuniziert Assembler direkt mit der Hardware, was eine präzise Kontrolle über den Prozessor und den Speicher ermöglicht. Diese direkte Steuerungsmöglichkeit macht Assembler zu einer mächtigen Sprache für ressourcenintensive oder sehr spezifische Aufgaben.
Aufbau dieses Tutorials
Dieses Tutorial ist so strukturiert, dass es sich von einem Kapitel zum nächsten Schritt für Schritt selbst erklärt. Zur Demonstration der Beispiele verwende ich einen Raspberry Pi 5, aber die Beispiele sollten ebenso auf den Modellen Raspberry Pi 3 und Raspberry Pi 4 funktionieren, sofern diese mit einem 64-Bit-Linux-System betrieben werden. Zu Beginn werden wir uns ansehen, wie man überhaupt Assemblerprogramme schreibt und diese auf einem ARM64-System ausführt.
Inhalt
- Programmierumgebung unter Linux erstellen und testen
- Tools, die zur Programmierung benötigt werden
- Allgemeines zu Zahlen (Dezimal, Binär, Hexadezimal)
- Das erste Programm "Hello World"
- Register und Speicher
- Laden und Speichern von Werten
- Addieren und Subtrahieren
- Multiplizieren, Dividieren und Akkumulation
- Programmablauf steuern
- Funktionen und Stack
- Systemaufrufe
- GPIO Programmierung
- Interaktion mit C
- C-Funktionen aus Assembler aufrufen
- Deklaration und Aufruf von C-Funktionen in Assembler
- Assemblerfunktion in C aufrufen
- Definition und Deklaration von Assemblerfunktionen in C
- Inline-Assembler in C
- Verwendung von Inline-Assembler in C-Programmen für Leistungsoptimierung
- Bibliotheken verwenden
- Einbindung und Nutzung von C-Bibliotheken in Assembler
- C-Funktionen aus Assembler aufrufen
- Gleitkommaoperationen
- FPU-Register
- Verwendung der Register in Funktionen
- Nutzung der FPU-Register in Assemblerfunktionen
- Arbeiten mit FPU-Registern
- Konvertierung von Gleitkommazahlen
- Vergleichen
- NEON Coprozessor
- Einführung in den NEON-Coprozessor
- SIMD (Single Instruction Multiple Data) Befehle
- Optimierung von Multimedia- und Signalverarbeitungsanwendungen
Diassemblieren
-> objdump -s -d HelloWorld.o > HelloWorld.diassem
Hello.o: file format elf64-littleaarch64
Contents of section .text:
0000 200080d2 e1000058 a20180d2 080880d2 ......X........ 0010 010000d4 000080d2 a80b80d2 010000d4 ................ 0020 00000000 00000000 ........
Contents of section .data:
0000 48656c6c 6f20576f 726c6421 0a Hello World!.
Disassembly of section .text:
0000000000000000 <_start>:
0: d2800020 mov x0, #0x1 // #1 4: 580000e1 ldr x1, 20 <_start+0x20> 8: d28001a2 mov x2, #0xd // #13 c: d2800808 mov x8, #0x40 // #64 10: d4000001 svc #0x0 14: d2800000 mov x0, #0x0 // #0 18: d2800ba8 mov x8, #0x5d // #93 1c: d4000001 svc #0x0
...
negative Zahlen
-> Beispiel: 5 + -3
3 in 1 byte is 0x03 or 0000 0011. Inverting the bits is 1111 1100 Add 1 to get 1111 1101 = 0xFD Now add 5 + 0xFD = 0x102 = 2
movadd.o: file format elf64-littleaarch64
Contents of section .text:
0000 001980d2 812580d2 0200018b a00080d2 .....%.......... 0010 41008092 0200018b 000080d2 a80b80d2 A............... 0020 010000d4 ....
Disassembly of section .text:
0000000000000000 <_start>:
0: d2801900 mov x0, #0xc8 // #200 4: d2802581 mov x1, #0x12c // #300 8: 8b010002 add x2, x0, x1 c: d28000a0 mov x0, #0x5 // #5 10: 92800041 mov x1, #0xfffffffffffffffd // #-3 14: 8b010002 add x2, x0, x1 18: d2800000 mov x0, #0x0 // #0 1c: d2800ba8 mov x8, #0x5d // #93 20: d4000001 svc #0x0
Big vs. Little Endian
-> Reihenfolge der Bytes im Speicher -> ARM-Prozessor erlaubt beide Versionen -> Linux verwendet Little Endian
Shiften und Rotation
mov x0, #0b00000011 // Lege 3 nach x0 (GDB p /t $x0)
lsl x0,x0,#4
// -> 00110000
In der ARM64-Assemblerprogrammierung spielen Shift- und Rotationsoperationen sowie der Umgang mit dem Carry-Flag eine wichtige Rolle für die Manipulation von Bits in Registern. Hier ist eine detaillierte Übersicht:
---
- **1. Shiften (Bitverschiebung)**
- **Logical Shift Left (LSL)**
- Verschiebt die Bits eines Registers nach links. - Mit jedem Shift-Schritt wird rechts eine `0` eingefügt. - Kann verwendet werden, um Werte zu multiplizieren (z. B. mit Potenzen von 2).
- Syntax:**
```asm LSL Rd, Rn, #shift ``` - `Rd`: Zielregister - `Rn`: Quellregister - `#shift`: Anzahl der Bitverschiebungen
- Beispiel:**
```asm LSL X0, X1, #3 // X0 = X1 << 3 ``` Dies verschiebt die Bits in `X1` um 3 Stellen nach links.
---
- **Logical Shift Right (LSR)**
- Verschiebt die Bits eines Registers nach rechts. - Mit jedem Shift-Schritt wird links eine `0` eingefügt. - Kann verwendet werden, um Werte zu dividieren (z. B. durch Potenzen von 2).
- Syntax:**
```asm LSR Rd, Rn, #shift ```
- Beispiel:**
```asm LSR X0, X1, #2 // X0 = X1 >> 2 ``` Dies verschiebt die Bits in `X1` um 2 Stellen nach rechts.
---
- **Arithmetic Shift Right (ASR)**
- Verschiebt die Bits eines Registers nach rechts, **behält aber das Vorzeichen bei**. - Das höchstwertige Bit (Sign-Bit) wird aufgefüllt, um den Wert konsistent zu halten (z. B. bei negativen Zahlen im Zweierkomplement).
- Syntax:**
```asm ASR Rd, Rn, #shift ```
- Beispiel:**
```asm ASR X0, X1, #2 // X0 = X1 arithmetisch >> 2 ``` Dies eignet sich für signierte Division durch Potenzen von 2.
---
- **2. Rotation**
- **Rotate Right (ROR)**
- Verschiebt die Bits eines Registers nach rechts und **"dreht" die herausfallenden Bits zurück auf die linke Seite**. - Die Bitanzahl bleibt unverändert.
- Syntax:**
```asm ROR Rd, Rn, #shift ```
- Beispiel:**
```asm ROR X0, X1, #4 // X0 = X1 rotierte 4 Bits nach rechts ``` Wenn `X1 = 0b1101_0001`, wird es zu `0b0001_1101`.
---
- **3. Carry-Flag und Rotation mit Carry**
Der Carry-Flag wird bei Shifts oder Rotationen verwendet, wenn: 1. Ein Bit aus dem Register "herausfällt". 2. Dieses Bit in das Carry-Flag geschrieben wird.
- **Einsatz von Carry-Flag**
- Wird vor allem für Mehrwort-Arithmetik (z. B. 128-Bit-Operationen mit zwei 64-Bit-Registern) oder für rotierende Operationen verwendet.
---
- Zusammenfassung der Unterschiede:
| Operation | Verhalten | Auffüllung links/rechts | |----------------------|------------------------------------------|-------------------------| | **LSL** | Linksverschiebung, rechts `0` | Rechts `0` | | **LSR** | Rechtsverschiebung, links `0` | Links `0` | | **ASR** | Rechtsverschiebung mit Vorzeichenbit | Links Vorzeichenbit | | **ROR** | Rotation nach rechts, wieder einfügen | - |
Aliase
Laden von Registern
-> mov x0,x1; Ein Alias. Gleiche ist möglich mit add x0, xzr, x1, aber tatsächlich ist es orr x0,xzr,x1
Sourcecode:
mov x1,#100 mov x0,x1 mov x1,#200 add x0, xzr, x1 mov x1,#300 orr x0,xzr,x1
0000000000000000 <_start>:
0: d2800c81 mov x1, #0x64 // #100 4: aa0103e0 mov x0, x1 8: d2801901 mov x1, #0xc8 // #200 c: 8b0103e0 add x0, xzr, x1 10: d2802581 mov x1, #0x12c // #300 14: aa0103e0 mov x0, x1 18: d2800000 mov x0, #0x0 // #0 1c: d2800ba8 mov x8, #0x5d // #93 20: d4000001 svc #0x0
Aliase
-> Verwendung von Aliase, um den Code besser zu verstehen, Problem beim debuggen, objdump, da eventuell andere Aliases verwendet werden.
Die von dir beobachtete Diskrepanz resultiert aus dem Unterschied zwischen der **Assembly-Syntax** und der tatsächlichen **Maschinencodierung** sowie der Art und Weise, wie `objdump` und der Assembler die Befehle interpretieren. Lass uns das Schritt für Schritt erklären:
---
- **1. ARM64-Befehlsaliasing**
In ARM64 gibt es viele **Aliase**, d. h. alternative Schreibweisen für denselben Maschinenbefehl. Diese Aliase werden oft verwendet, um die Lesbarkeit des Codes zu verbessern. Beispiele:
- **`MOV x0, x1`** ist ein Alias für **`ORR x0, xzr, x1`**.
- Semantik: Kopiere den Inhalt von `x1` nach `x0`. - Implementierung: Ein logisches OR mit `xzr` (dem Null-Register) ergibt einfach den Wert von `x1`.
- **`ADD x0, xzr, x1`** ist ebenfalls ein Alias für **`ORR x0, xzr, x1`**.
- Semantik: Addiere `x1` zu `xzr` (immer `0`), was effektiv einfach `x1` ist.
---
- **2. Warum wird aus `MOV` oder `ADD` ein `ORR`?**
Die ARM64-Maschinenarchitektur verwendet für mehrere Befehle denselben Opcode. Der tatsächlich verwendete Maschinenbefehl für alle drei (`MOV`, `ADD xzr`, `ORR`) lautet **`ORR`**, da dieser Opcode universell genug ist, um die gewünschten Operationen auszuführen.
Der Assembler interpretiert den Code basierend auf der Absicht des Programmierers: - **`MOV`**: Ein Alias, wird zu einem `ORR` kompiliert. - **`ADD xzr`**: Ein Alias für denselben `ORR`. - **`ORR`**: Der explizite Maschinenbefehl.
---
- **3. Warum zeigt `objdump` wieder `MOV` oder `ADD` an?**
Das Tool `objdump` arbeitet nicht nur auf Maschinencode-Ebene, sondern versucht, den Assembly-Code durch **Rückübersetzung** lesbar zu machen. Dabei: - Entscheidet es sich für die **verständlichste Darstellung**, basierend auf dem Kontext und den Aliases. - Wenn es sieht, dass der Maschinenbefehl einem Alias entspricht (z. B. `ORR x0, xzr, x1`), gibt es ihn möglicherweise als `MOV x0, x1` oder `ADD x0, xzr, x1` zurück.
In deinem Fall: 1. Der Assembler hat den Alias `MOV x0, x1` in den Maschinenbefehl `ORR x0, xzr, x1` kompiliert. 2. `objdump` hat diesen Maschinenbefehl rückübersetzt und **den Alias gewählt, der am besten passt**.
- Hier hat es sich wieder für `MOV x0, x1` oder `ADD x0, xzr, x1` entschieden, obwohl der zugrunde liegende Befehl tatsächlich `ORR` ist.
---
- **4. Warum nicht immer `ORR`?**
Tools wie `objdump` bevorzugen eine lesbare Darstellung: - **`MOV`** ist für Menschen intuitiver als `ORR xzr`. - Der Assembler und Disassembler abstrahieren unnötige Details, um den Code einfacher zu machen.
---
- **Zusammenfassung**
Dein Verständnis ist korrekt: Alle drei Befehle (`MOV`, `ADD xzr`, `ORR`) sind **funktional identisch**. `objdump` wählt jedoch je nach Kontext einen Alias aus, der die Intention des Originalcodes besser widerspiegelt.
Es gibt also keinen Fehler in deiner Beobachtung. Wenn du immer den zugrunde liegenden Maschinenbefehl sehen möchtest, kannst du die Binärdatei mit einem Hex-Editor oder Disassembler auf Opcode-Ebene analysieren. Dort siehst du, dass alle drei als `ORR` codiert sind.
Logische Operatoren
and{s} Xd, Xs, Operand2 eor{s} Xd, Xs, Operand2 orr{s} Xd, Xs, Operand2 bic{s} Xd, Xs, Operand2
| and | eor | orr | bic | Xs | 1100 | 1100 | 1100 | 1100 | Operand2 | 1010 | 1010 | 1010 | 1010 | Ergebnis | 1000 | 0110 | 1110 | 0100 |
- CMN Xn, Operand2
-> addiert Operand2 von Xn
- TST Xn, Operand2
-> Bitweise AND von Operand2 und Xn
Speicher
- Speicheradressen 64 Bit lang
- .data
-> Dezimalzahl: Beginnt mit 1-9 und enthält 0-9 -> Oktalzahl: Beginnt mit 0 und enthält 0-7 -> Binärzahl: Beginnt mit 0b und enthält 0-1 -> Hexadezimal: Beginnt mit 0x und enthält 0-f -> Gleitkommawerte: Beginnt mit 0f oder 0e und enthält die Gleitkomma-Zahl -> Präfix: "-" nimmt das Zweierkomplement, "~" nimmt das Einerkompliment
.byte -0xa3, -22, ~0b11010010
Directive Beschreibung .ascii Eine Zeichenfolge in doppelten Anführungszeichen .asciz Eine mit 0 Bytes abgeschlossene ASCII-Zeichenfolge .byte 1-byte Ganzzahl .double Gleitkommawerte mit doppelter Genauigkeit .float Gleitkommawerte .octa 16-byte Ganzzahl .quad 8-byte Ganzzahl .short 2-byte Ganzzahl .word 4-byte Ganzzahl
- Hilfreiche Direktiven:
.fill Anzahl, Größe, Inhalt: Erzeugt einen Speicher mit dem "Inhalt" der "Größe" und "Anzahl". .rept Anzahl ... .endr: Wiederholt "Anzahl" den Inhalt von "..."
- ASCII-Strings
Escape Description \b Backspace (ASCII code 8) \f Seitenvorschub (ASCII code 12) \n Neue Zeile (ASCII code 10) \r Return (ASCII code 13) \t Tabulator (ASCII code 9) \ddd Ein Okctaler ASCII code (ex \123) \xdd Ein Hexadezimaler ASCII code (ex \x4F) \\ Das “\” Zeichen \” Das Anführungszeichen
- Daten ausrichten
.align Ausrichtung: Setzt den nächsten Wert auf ein "Ausrichtung" definierten Speicher. Beispiel:
.byte 0xef .align 4 //Ausrichtung auf Wortbreite .word 0x26ef43de
ldr
Relative Adressierung am PC
-> ldr PC-relativ Adresse 19-Bit breit (ca. +/-1MB) Beispiel:
ldr x1,=hello ... .data hello: .ascii "Hallo"
Die Adresse zum Label "hello" wird relativ zum PC ermittelt. -> TRICK:
ldr x1,=0x1234567890abcdef //Direkte 64-Bit Zuweisung
Der Assembler wandelt diese Anweisung:
ldr x1,#8 .quad 0x1234567890abcdef
- ldr{Typ} xt,[xa], wobei für Typ steht:
B Unsigned byte SB Signed byte H Unsigned halfword (16 bits) SH Signed halfword (16 bits) SW Signed word
Beispiel:
ldr x0,=MeineZahl //läd die Adresse meiner Variablen ldr x0,[x0] //läd dann den Inhalt der Variablen .data MeineZahl: .quad 0x1234567fe
- str{Typ} xt,[xa]
Indexsierung (Arrays)
PseudoCode:
dim a[10] as word a[5] = 10 //setze das 5 Element mit 10 x = a[8] //Schreibe das 8 Element nach x for i = 1 to 10 a[i] = i *8 next
In Assembler:
ldr x0,=MeinArray //Lade die Adresse von MeinArray nach x0 mov w1,#10 //Speichere 10 in w1 ab str w1,[x0,#(4*4)] //Speichere w1 in das 5 Element (Begin ist 0) (Word = 4 Byte) ldr w1,[x0,#(7*4)] //lade das 8 Element nach w1 mov w2,#1 //i=1 forloop: lsl w4,w2,#3 // i * 8 str w4,[x0, w2, lsl #2] // speichere der Wert aus w4 nach Basisadresse x0 mit index aus w2, der um 4 Multipliziert wird. add w2,w2,#1 // i=i+1 cmp w2,#10 // if i<10 b.le forloop
- Der Index kann auch negativ sein.
- Post-indexierte Adressierung
-> ldr x1,[x2], #4 //Holt den Wert aus x2 und erhöht anschließen x2 um 4
- ldp, stp -> 128-Bit
ldr x1,=GrosseZahl ldp x2,x3,[x1] stp x2,x3,[x1] .data GrosseZahl: .octa 0x12345678901234567890123456789012
.equ
-> Definiert Werte einer lesbaren Schreibweise zu. Allerdings nur mit Zahlen möglich
Makros
.macro Name übergabe1,übergabe2,...
Beispiel:
.macro speicher wert1,wert2 ldr x0,=\wert1 ldr x1,=\wert2 .endm
.global _start _start: speicher buffer1,buffer2 ... .data buffer1: .fill 255,1,0 buffer2: .fill 255,1,0
Beim Aufruf "speicher" wird an dieser Stelle des Codes einfach das Makro eingesetzt. Das definieren des Makros erzeugt keinen Code!
.include
Ersetzt an dieser stelle, den Code, welcher über dem includebefehl geladen wird:
.include "ZusätzlicherSource.s"
Labels in Makros
Problematisch wird es, wenn in einem Makro Labels definiert wurden. Wird das Makro zweimal verwendet, so wird der Assembler melden, dass die Labels doppelt vergeben wurden. Dies ist nicht erlaubt. Alternativ, was der Assembler erlaubt, sind Zahlen als Label, die auch mehrfach verwendet werden können. Um auf die richtigen Labels zu zeigen, wird bei der Sprunganweisung einfach der Zahl "f" angehängt, wenn das Label nach diesem Sprung angesprochen werden soll. Ein "b", wenn das Label zuvor angesprochen werden soll.
Praktische Makros
.macro push1 register str \register,[sp,#-16]! .endm .macro push2 register1,register2 stp \register1,\register2,[sp,#-16]! .endm .macro pop1 register ldr \register,[sp],#16 .endm .macro pop2 register1,register2 ldp \register1,\register2,[sp],#16 .endm
Linux Systemaufrufe
https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/unistd.h https://github.com/ilbers/linux/blob/master/arch/sh/include/uapi/asm/unistd_64.h
x0–x7: Parameterübergabe x8: SystemCallNummer svc 0 -> Aufruf des Systemcalls x0: Ergebniss
Wenn x0 negativ -> negieren -> dann Fehler: https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/errno.h https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/errno-base.h
In der Parameterübergabe erwarten viele SystemCalls eine Struktur. Ein einfacher SystemCall wie nanosleep wird als C-Code wie folgt aufgerufen:
int nanosleep(const struct timespec *req, struct timespec *rem);
Dieser Aufruf erwartet jeweils eine Struktur "timespec", die in x0 und x1 übergeben wird. Diese Struktur ist wie folgt definiert:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
Nach Assembler:
timespec: timespec_tv_sec: .dword 0 timespec_tv_nsec: .dword 100000000
Um es der Funktion zu übergeben:
ldr x0,=timespec ldr x1,=timespec
Arbeiten mit Dateien
https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/fcntl.h Datei öffnen:
mov x0,#-100 ldr x1,=Dateiname mov x2,#flags //O_RDONLY, O_WRONLY, =O_CREAT mov x3,#666 mov x8,#323 //#define __NR_openat 323 svc 0
Rückgabe in x0 ->
adds x10,XZR,x0 //file descriptor nach x10 bpl ok //Wenn positiv, dann hat es geklappt Fehler...
Datei schließen:
mov x0,x10 //Beispiel x10 für file descriptor mov x8,#118 //#define __NR_fsync 118 svc 0 mov x0,x10 //Beispiel x10 für file descriptor mov x8,#6 //#define __NR_close 6 svc 0
Lesen aus einer Datei:
mov x0,x10 //Beispiel x10 für file descriptor ldr x1,=buffer //Hierhin sollen die Daten geladen werden mov x2,#256 //Max Anzahl an Bytes, die geladen werden sollen (Beispiel: 256Bytes) mov x8,#3 //#define __NR_read 3 svc 0
Speichern in eine Datei:
mov x0,x10 //Beispiel x10 für file descriptor ldr x1,=buffer //Von hier sollen die Daten gespeichert werden mov x2,#256 //Anzahl an Bytes, die gespeichert werden sollen (Beispiel: 256Bytes) mov x8,#4 //#define __NR_write 4 svc 0
Ein Beispiel in ARM64-Assembler für Linux, das zeigt, wie Sie eine Datei öffnen, die Daten lesen, bearbeiten und anschließend die bearbeiteten Daten in einer anderen Datei speichern können. Ich werde dafür Systemaufrufe (syscalls) verwenden.
Überblick der Schritte: Datei öffnen (open). Daten aus der Datei lesen (read). Daten bearbeiten (In diesem Beispiel ändern wir die Daten nicht, sie könnten aber leicht modifiziert werden). Neue Datei erstellen und öffnen (open). Daten in die neue Datei schreiben (write). Beide Dateien schließen (close). Vollständiges Beispiel in ARM64-Assembler: .section .data
filename: .asciz "input.txt" newfilename:.asciz "output.txt" buffer: .space 1024 // Pufferspeicher zum Lesen der Datei
.section .bss
.lcomm result, 4 // Speicher für den Rückgabewert von syscalls
.section .text .global _start _start:
// Datei öffnen (input.txt) mov x0, 0 // stdin als File Descriptor (0) ldr x1, =filename // filename in x1 laden mov x2, 0 // O_RDONLY (0) mov x8, 2 // Syscall-Nummer für 'open' ist 2 svc 0 // Systemaufruf
mov x19, x0 // x0 enthält den File Descriptor ldr x1, =buffer // Speicheradresse des Puffers in x1 laden mov x2, 1024 // Maximale Anzahl von Bytes zu lesen mov x8, 63 // Syscall-Nummer für 'read' ist 63 svc 0 // Systemaufruf
// Ergebnis der gelesenen Bytes in x0 speichern mov x20, x0
// Datei zum Schreiben öffnen (output.txt) ldr x1, =newfilename // filename in x1 laden mov x2, 241 // O_WRONLY | O_CREAT | O_TRUNC (241) mov x3, 0644 // Modus 0644 (rw-r--r--) mov x8, 2 // Syscall-Nummer für 'open' (2) svc 0 // Systemaufruf
mov x21, x0 // File Descriptor für die Ausgabedatei
// Daten in die neue Datei schreiben ldr x1, =buffer // Speicheradresse des Puffers in x1 laden mov x2, x20 // Anzahl der gelesenen Bytes mov x0, x21 // File Descriptor der Ausgabedatei mov x8, 64 // Syscall-Nummer für 'write' (64) svc 0 // Systemaufruf
// Eingabedatei schließen mov x0, x19 // File Descriptor der Eingabedatei mov x8, 57 // Syscall-Nummer für 'close' (57) svc 0 // Systemaufruf
// Ausgabedatei schließen mov x0, x21 // File Descriptor der Ausgabedatei mov x8, 57 // Syscall-Nummer für 'close' (57) svc 0 // Systemaufruf
// Beende das Programm mov x8, 93 // Syscall-Nummer für 'exit' (93) mov x0, 0 // Rückgabewert 0 svc 0 // Systemaufruf
Erklärung: Datenabschnitt: .section .data:
filename: Speichert den Namen der Eingabedatei input.txt. newfilename: Speichert den Namen der Ausgabedatei output.txt. buffer: Ein Speicherbereich zum Lesen und Schreiben der Dateiinhalte. .section .bss:
Speicher zur Speicherung des Rückgabewerts von read. Textabschnitt: Datei öffnen (input.txt):
mov x0, 0: Bereitet den Systemaufruf vor, um stdin als Dateideskriptor zu verwenden. ldr x1, =filename: Lädt die Adresse des Dateinamens in x1. mov x2, 0: Setzt den Modus auf O_RDONLY (nur lesen). mov x8, 2: Setzt die Systemaufrufnummer für open (2). svc 0: Führt den Systemaufruf aus. Der Dateideskriptor wird in x0 zurückgegeben und in x19 gespeichert. Daten lesen:
ldr x1, =buffer: Lädt die Adresse des Puffers in x1. mov x2, 1024: Gibt an, dass bis zu 1024 Bytes gelesen werden. mov x8, 63: Setzt die Systemaufrufnummer für read (63). svc 0: Führt den Systemaufruf aus. Die Anzahl der gelesenen Bytes wird in x0 zurückgegeben und in x20 gespeichert. Datei öffnen (output.txt):
ldr x1, =newfilename: Lädt die Adresse des neuen Dateinamens in x1. mov x2, 241: Setzt den Modus auf O_WRONLY | O_CREAT | O_TRUNC (schreiben, Datei erstellen, Datei kürzen). mov x3, 0644: Setzt die Dateiberechtigungen auf rw-r--r--. mov x8, 2: Setzt die Systemaufrufnummer für open (2). svc 0: Führt den Systemaufruf aus. Der Dateideskriptor wird in x0 zurückgegeben und in x21 gespeichert. Daten schreiben:
ldr x1, =buffer: Lädt die Adresse des Puffers in x1. mov x2, x20: Setzt die Anzahl der zu schreibenden Bytes auf die Anzahl der gelesenen Bytes. mov x0, x21: Setzt den Dateideskriptor auf die Ausgabedatei. mov x8, 64: Setzt die Systemaufrufnummer für write (64). svc 0: Führt den Systemaufruf aus. Dateien schließen:
mov x0, x19: Setzt den Dateideskriptor auf die Eingabedatei. mov x8, 57: Setzt die Systemaufrufnummer für close (57). svc 0: Führt den Systemaufruf aus. mov x0, x21: Setzt den Dateideskriptor auf die Ausgabedatei. mov x8, 57: Setzt die Systemaufrufnummer für close (57). svc 0: Führt den Systemaufruf aus. Programm beenden:
mov x8, 93: Setzt die Systemaufrufnummer für exit (93). mov x0, 0: Setzt den Rückgabewert auf 0. svc 0: Führt den Systemaufruf aus und beendet das Programm. Dieses Beispiel demonstriert die wesentlichen Systemaufrufe und die Datenbearbeitung in einem ARM64-Assemblerprogramm unter Linux.
GPIO Programmierung
- Mit Linux (Syscalls):
Die Pins werden wie Dateien behandelt. Der Dateipfad ist: /sys/class/gpio Es wird einfach ein String, der den PIN angibt nach /sys/class/gpio/export geschrieben. Der Treiber erstellt dann vier Dateien:
- /sys/class/gpio/gpio17/direction: Wird verwendet, um anzugeben, ob der Pin für Eingabe oder Ausgabe ist
- /sys/class/gpio/gpio17/value: Wird verwendet, um den Wert des Pins festzulegen oder zu lesen
- /sys/class/gpio/gpio17/edge: Wird verwendet, um einen Interrupt festzulegen, um Wertänderungen zu erkennen
- /sys/class/gpio/gpio17/active_low: Wird verwendet, um die Bedeutung von 0 und 1 umzukehren
!!!GPIO17 ändern!!!!!!!!!!!!! und testen Nach dem Beenden wird das Gerät mit dem File "/sys/class/gpio/unexport" und dem String des PINs geschlossen.
.text nanosleep:
ldr x0,=timespec ldr x1,=timespec mov x8,#162 //#define __NR_nanosleep 162 svc 0
.data
timespec: timespec_tv_sec: .dword 0 timespec_tv_nsec: .dword 100000000
- PIN Öffnen:
.text
mov x0,#-100 ldr x1,=gpioexp mov x2,#1 //#define O_WRONLY 00000001 mov x3,#666 //Dateirechte (Jeder darf alles...) mov x8,#323 //#define __NR_openat 323 svc 0 mov x10,x0 //File Descriptor ldr x1,=pin mov x2,#2 mov x8,#4 //#define __NR_write 4 svc 0 mov x0,x10 mov x8,#118 //#define __NR_fsync 118 svc 0 mov x0,x10 mov x8,#6 //#define __NR_close 6 svc 0
.data gpioexp: .asciz "/sys/class/gpio/export" pin: .asciz "17" //Ändern!!!!!!!!!!!!!
GPIODirectionOut pin17:
Muss ich probieren! Seite 180 im Buch
Näher an die direkte Programmierung
In Bare Metal, siehe Kurs hier, werden Geräte über Adressen angesprochen. Diese werden laut Dokumentation des Braodcom-Chips als Register bezeichnet. Dies hat allerdings nichts mit den ARM-Registern zu tun. Hier sind die Speicheradressen gemeint. Leider ist die Dokumentation nicht wirklich gut, so dass wir diese Adressen hier im Linux-System heraussuchen müssen. Dies ist über folgenden Befehl in der Konsole möglich:
dmesg genauer dmesg | grep gpio //Sucht nur die Info für gpio heraus...
FPU-Register
v0 - v31: 128-Bit d0 - d31: 64-Bit (double floating Point) s0 - s31: 32-Bit (single) h0 - h31: 16-Bit (float)
- Register "d" ist ein Teil des REgister "v", das Register "s" ein Teil des Registers "d" usw. Dies ist zu vergleichen wie bei den ARM-Registern "x" und "w".
| Bits | 127 - 112 | 111 - 96 | 95 - 80 | 79 - 64 | 63 - 48 | 47 - 32 | 31 - 16 | 15 - 0 | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| V-Register (NEON) | 128-Bit | |||||||||||
| D-Register (FPU) | 64-Bit | |||||||||||
| S-Register (FPU) | 32-Bit | |||||||||||
| H-Register (FPU) | 16-Bit | |||||||||||
Der NEON-Coprozessor verwendet das V-Register. Allerdings kann der Coprozessor auch mit 128-Bit Ganzzahlen umgehen. Dies sind die gleichen Register, werden aber "Q"-Register genannt.
Verwendung der Register in Funktionen
Auch hier müssen bestimmte Register auf dem Stack gesichert werden, da die Aufrufende Funktion davon ausgeht, dass diese nicht verloren gehen, wenn sie eine Funktion aufruft.
Davon sind die Register V8-V15 betroffen. Werden diese verwendet, müssen diese auf dem Stack gesichert werden und am ende der Funktion wieder hergestellt werden.
Beispiel:
stp q8,q9,[sp,#-32]! str q10, [sp,#16]! ldr q10,[sp],#16 ldp q8,q9,[sp],#32
Arbeiten mit FPU-Registern
- Verwendung von ldr und str
- Kopieren von ARM-Register mit FPU-Registern: fmov
fadd rd, rn, rm // rd = rn + rm fsub rd, rn, rm // rd = rn - rm fmul rd, rn, rm // rd = rn * rm fdiv rd, rn, rm // rd = rn / rm fmadd rd, rn, rm, ra // rd = ra + rm * rn fmsub rd, rn, rm, ra // rd = ra – rm * rn fneg rd, rn // rd = -rn fabs rd, rn // rd = Absuluter Wert ( rn ) fmax rd, rn, rm // rd = Max( rn, rm ) fmin rd, rn, rm // rd = Min( rn, rm ) fsqrt rd, rn // rd = Quadratwurzel( rn )
Für "r" können alle FPU-Register (d,s,h) verwendet werden
Konvertierung von Gleitkommazahlen
Beispiel:
fcvt d0, s1 // s1 -> d0 fcvt s1, d0 // d0 -> s1 fcvt s1, h2 // h2 -> s1 fcvt h2, s1 // s1 -> h2
- Integer nach Gleitkommazahl:
-> (Un/Signed Convert to Floating Point)
SCVTF <Sd>, <Wn>: Konvertiert einen 32-Bit-Ganzzahlwert ('<Wn>' ) in eine 32-Bit-Single-Precision-Floatzahl ('<Sd>' ).
SCVTF
- Gleitkommazahlen nach Integer:
Vergleichen
FCMP Hd, Hm FCMP Hd, #0.0 FCMP Sd, Sm FCMP Sd, #0.0 FCMP Dd, Dm FCMP Dd, #0.0
NEON Coprozessor
Der NEON-Coprozessor, der in ARM64-Prozessoren integriert ist, bietet erweiterte SIMD (Single Instruction, Multiple Data)-Fähigkeiten. Diese ermöglichen es, mehrere Datenpunkte mit einer einzigen Anweisung zu verarbeiten, was die Leistung bei bestimmten Arten von Berechnungen erheblich steigert.
NEON-Coprozessor: Überblick Architektur und Integration:
SIMD-Fähigkeiten: NEON erweitert die ARMv8-A-Architektur (auch bekannt als ARM64) mit fortschrittlichen SIMD-Befehlssätzen. Diese sind besonders nützlich für Multimediaverarbeitungen, Signalverarbeitungen, mathematische Berechnungen und Machine-Learning-Anwendungen. VFP (Vector Floating Point) und NEON: VFP bietet skalare Floating-Point-Operationen. NEON bietet sowohl skalare Floating-Point- als auch SIMD-Operationen, was eine noch effizientere Verarbeitung von vektorisierten Daten erlaubt. Register und Datentypen:
Register: NEON verfügt über 32 128-bit-Register (V0-V31), die je nach Bedarf in kleinere Register unterteilt werden können, z. B. in 64-bit (D0-D31), 32-bit (S0-S31), 16-bit (H0-H31) und 8-bit (B0-B31) Register. Datentypen: NEON unterstützt eine Vielzahl von Datentypen, darunter: 8-bit, 16-bit, 32-bit, 64-bit Integers 32-bit, 64-bit Floating-Point-Zahlen Polytyp-Daten für fortschrittliche kryptografische Operationen Instruktionssatz:
NEON instruiert SIMD-Operationen wie Addition, Subtraktion, Multiplikation, Division sowie erweiterte Funktionen wie Vektorschieben, Rangieren, Laden und Speichern von Vektoren. Beispiele für NEON-Instruktionen: VADD (Vector Add): Addiert zwei Vektoren. VMUL (Vector Multiply): Multipliziert zwei Vektoren. VDUP (Vector Duplicate): Verdoppelt ein Element in allen Positionen des Zielvektors. VLD1 (Vector Load): Lädt Daten aus dem Speicher in ein NEON-Register. VST1 (Vector Store): Speichert Daten aus einem NEON-Register in den Speicher. Anwendungsfälle:
Multimedia-Verarbeitung: NEON kann Videodekodierung/-codierung, Bildbearbeitung und Audioprozessoren beschleunigen. Beispiele sind JPEG-Dekodierung, H.264-Videokodierung und -decodierung sowie MP3-Dekodierung. Digitale Signalverarbeitung (DSP): Häufige Anwendungen umfassen FFT (Fast Fourier Transform), Faltung und Filterung von Signalen. Maschinelles Lernen: NEON kann als Beschleuniger für neuronale Netzwerke und andere maschinelle Lernalgorithmen dienen, indem es massive Mengen an Matrizenoperationen effizient ausführt. Mathematische Berechnungen: Matrizen-Multiplikation, Vektoraddition und andere numerische Algorithmen profitieren von den parallelen Rechenfähigkeiten des NEON. Performance:
Parallelismus: Die SIMD-Natur des NEON ermöglicht die Bearbeitung mehrerer Datenpunkte gleichzeitig, was die Berechnungsrate für Aufgaben mit massiv parallelen Anforderungen drastisch erhöht. Effizienz: Die Kombination von NEONs parallelen Rechenfähigkeiten mit ARM64-Energieeffizienz bietet eine leistungsstarke und energieeffiziente Plattform für eine Vielzahl moderner Anwendungen.
Verwendung des NEON-Coprozessors im Raspberry Pi Der Raspberry Pi, basierend auf einem ARM64-Prozessor, nutzt NEON, um eine Vielzahl von leistungsstarken rechnerischen Aufgaben zu erledigen. Durch die Integration von NEON ermöglicht der Raspberry Pi:
Schnellere Multimediaverarbeitung: Optimierung von Videocodecs und Grafikrendering. Verbesserte mathematische und wissenschaftliche Berechnungen: Effiziente Ausführung von Operationen, die massive Datenmengen verarbeiten. Höhere Leistung in Anwendungen des maschinellen Lernens: Beschleunigung von Berechnungen für neuronale Netzwerke und andere lernbasierte Algorithmen. Der NEON-Coprozessor macht den Raspberry Pi zu einer idealen Plattform für Entwickler, die kostengünstige, leistungsstarke und energieeffiziente Lösungen für anspruchsvolle Rechenaufgaben suchen.
Das LANE-Prinzip (LANE steht für "Lane-based") ist ein Konzept, das beim Design von SIMD (Single Instruction, Multiple Data)-Architekturen, wie dem NEON-Coprozessor in ARM64-Prozessoren, Anwendung findet. Hierbei wird der SIMD-Registersatz in mehrere "Lanes" unterteilt, sodass parallele Operationen effizient ausgeführt werden können. Lassen Sie uns tiefer in das LANE-Prinzip eintauchen und seine Bedeutung und Implementierung im Kontext des NEON-Coprozessors des Raspberry Pi betrachten.
LANE-Prinzip: Überblick Grundlagen des LANE-Prinzips:
Lanes: Eine "Lane" ist ein Kanal innerhalb eines SIMD-Registers, der unabhängig von den anderen Lanes operationell sein kann. Jede Lane kann mehrere Datenwerte gleichzeitig verarbeiten. SIMD-Parallele Verarbeitung: SIMD ermöglicht die gleichzeitige Ausführung der gleichen Operation auf mehrere Datenpunkte. Das LANE-Prinzip unterteilt die Daten in separate Lanes, wobei jede Lane eine Teilmenge der Daten repräsentiert. Register und Datentypen:
NEON verwendet 128-bit Register (V-Register), die in mehrere Lanes unterteilt werden können: 16 Lanes von 8-bit Daten (Vn.16B) 8 Lanes von 16-bit Daten (Vn.8H) 4 Lanes von 32-bit Daten (Vn.4S) 2 Lanes von 64-bit Daten (Vn.2D) Jede Lane kann unabhängig operieren, wodurch parallele Berechnungen möglich werden. Operationen im LANE-Prinzip:
Vektor-Addition: Kann so implementiert werden, dass jede Lane eines Registers parallel zur Addition genutzt wird. Vektor-Multiplikation: Multipliziert paarweise Elemente in Lanes parallel. Datenumschichtung: Operationen können basierend auf Lanes Daten umschichten; zum Beispiel das Vertauschen von Lanes innerhalb eines Registers. Vorteile des LANE-Prinzips Effizienz und Parallelisierung:
Durch das Aufteilen von Daten in Lanes und die parallele Verarbeitung kann die Effizienz und Geschwindigkeit erheblich gesteigert werden. Parallele Berechnungen reduzieren die Ausführungszeit von Operationen, insbesondere bei datenintensiven Anwendungen wie Signalverarbeitung, Grafikbearbeitung und wissenschaftlicher Berechnungen. Deterministische Leistung:
Jede Lane operiert unabhängig, was zu einer deterministischen und vorhersehbaren Leistungsverbesserung führt. Das bedeutet, dass die Ausführungszeit für SIMD-Operationen gut vorhersagbar ist. Reduzierte Speicherzugriffe:
Durch die Fähigkeit, mehrere Datenpunkte gleichzeitig zu verarbeiten, werden Speicherzugriffe reduziert, was die Gesamtleistung erhöht und den Datendurchsatz verbessert. Beispiele von NEON-Operationen unter Verwendung des LANE-Prinzips Vektoraddition von zwei 128-Bit-Registern: // Beispiel: Addiere zwei Vektoren von vier 32-Bit-Werten (4 Lanes) VADD.F32 Q0, Q1, Q2 // Addiert die 4 x 32-Bit Floating-Point-Werte in Q1 und Q2, Ergebnis in Q0 In diesem Fall:
Q1 ist in 4 Lanes von 32-bit Floating-Point-Werten unterteilt. Q2 ist ebenso unterteilt. Jede Lane wird parallel addiert, und das Ergebnis wird in der entsprechenden Lane von Q0 gespeichert. Vektorladen und -speichern: // Beispiel: Lade und speichere Daten mit Lanes VLD1.32 {Q0}, [R0] // Lade vier 32-bit-Werte von der Speicheradresse in R0 nach Q0 VST1.32 {Q0}, [R1] // Speichere vier 32-bit-Werte aus Q0 an die Speicheradresse in R1 Die Vektorinstruktionen arbeiten mit Lanes, um gleichzeitig mehrere Daten zu laden und zu speichern.
Anwendung und Kontext im Raspberry Pi Mediensysteme und Bildverarbeitung: Anwendungen wie Videokodierung/-dekodierung, Bildverarbeitung, Audioprozessoren nutzen intensiv die parallele Verarbeitung von NEON und das LANE-Prinzip zur Beschleunigung von Algorithmen. Maschinelles Lernen: Leistungsfähige Netzwerke und Algorithmen für maschinelles Lernen können durch die parallele Verarbeitung von NEON optimiert werden. Digitale Signalverarbeitung (DSP): Frequenzanalyse, Filterung und andere DSP-Anwendungen profitieren von SIMD-Operationen. Der NEON-Coprozessor, der das LANE-Prinzip nutzt, macht ARM64-Prozessoren wie die im Raspberry Pi besonders effizient für moderne, rechenintensive Anwendungen.
Beispiel, wie Sie die 4D-Vektordistanz in ARM64-Assembler mit NEON-Instruktionen berechnen können. Diese Berechnung beinhaltet typischerweise die Schritte:
Subtrahiere die entsprechenden Komponenten zweier Vektoren. Quadriere die resultierenden Differenzen. Addiere die quadrierten Differenzen. Ziehe die Quadratwurzel des Sums, um die euklidische Distanz zu erhalten. Beispiel: Berechnung der euklidischen Distanz zwischen zwei 4D-Vektoren Angenommen, wir haben zwei 4D-Vektoren A und B, deren Komponenten in den NEON-Registern V0 und V1 gespeichert sind. Dieser Beispielcode verwendet 32-bit Gleitkommazahlen zur Darstellung der Vektorkomponenten.
Register-Setup: V0: enthält den 4D-Vektor A mit den Komponenten [A0, A1, A2, A3] V1: enthält den 4D-Vektor B mit den Komponenten [B0, B1, B2, B3] V2: verwendet zur Zwischenspeicherung der Differenzen V3: verwendet zur Zwischenspeicherung der quadratischen Differenzen V4: verwendet zur Zwischenspeicherung der Summe der quadratischen Differenzen ARM64-Assembly-Code: // Nehmen wir an, V0 und V1 sind bereits mit 4D-Float-Vektoren geladen
// Schritt 1: Subtrahiere die Komponenten FSUB V2.4S, V0.4S, V1.4S // V2 = V0 - V1
// Schritt 2: Quadriere die Differenzen FMUL V3.4S, V2.4S, V2.4S // V3 = V2 * V2
// Schritt 3: Addiere die quadrierten Differenzen FADDV S4, V3.4S // S4 = V3[0] + V3[1] + V3[2] + V3[3]
// Schritt 4: Ziehe die Quadratwurzel der Summe FSQRT S4, S4 // S4 = sqrt(S4) Erklärung: Subtraktion der Komponenten:
FSUB V2.4S, V0.4S, V1.4S FSUB V2.4S, V0.4S, V1.4S: Subtrahiert die jeweiligen Elemente der Vektoren V0 und V1 und speichert das Ergebnis in V2. Dies führt zu V2 = [A0-B0, A1-B1, A2-B2, A3-B3]. Quadrieren der Differenzen:
FMUL V3.4S, V2.4S, V2.4S FMUL V3.4S, V2.4S, V2.4S: Quadriert die Differenzen und speichert sie in V3. Dies führt zu V3 = [(A0-B0)², (A1-B1)², (A2-B2)², (A3-B3)²]. Addition der quadratischen Differenzen:
FADDV S4, V3.4S FADDV S4, V3.4S: Addiert alle Elemente in V3 und speichert das Ergebnis in S4. Dies führt zu S4 = (A0-B0)² + (A1-B1)² + (A2-B2)² + (A3-B3)². Quadratwurzel der Summe:
FSQRT S4, S4 FSQRT S4, S4: Berechnet die Quadratwurzel des Ergebnisses in S4, wodurch die euklidische Distanz zwischen den Vektoren A und B entsteht. Zusammenfassung: Diese Assembly-Sequenz berechnet die euklidische Distanz zwischen zwei 4D-Vektoren effizient mit NEON-Instruktionen, indem sie die parallele Verarbeitung von Vektordaten in SIMD-Architekturen nutzt.
- Interaktion mit anderen Programmiersprachen
- Zugriff auf Hardwaregeräte
- Anweisungen für den Gleitkommaprozessor
- Anweisungen für den NEON-Prozessor