In diesem Artikel untersuchen wir verschiedene Konzepte auf niedriger Ebene (Kompilierung und Layout, primitive Laufzeiten, Assembler usw.) anhand des Prismas der RISC-V-Architektur und ihres Ökosystems. Ich bin selbst Webentwickler, mache nichts bei der Arbeit, aber es ist sehr interessant für mich, hier kommt der Artikel her! Begleite mich auf dieser hektischen Reise in die Tiefen des Chaos auf niedriger Ebene.
Lassen Sie uns zunächst ein wenig über RISC-V und die Bedeutung dieser Architektur sprechen, die RISC-V-Toolchain einrichten und ein einfaches C-Programm auf emulierter RISC-V-Hardware ausführen.
Inhalt
- Was ist RISC-V?
- Konfigurieren der QEMU- und RISC-V-Tools
- Hallo RISC-V!
- Naiver Ansatz
- Vorhang aufheben -v
- Durchsuchen Sie unseren Stapel
- Layout
- Hör auf!
Hammertime! Laufzeit!
- Debug aber jetzt echt
- Was weiter?
- Optional
Was ist RISC-V?
RISC-V ist eine kostenlose Befehlssatzarchitektur. Das Projekt entstand 2010 an der University of California in Berkeley. Eine wichtige Rolle für den Erfolg spielte die Offenheit des Codes und die Nutzungsfreiheit, die sich stark von vielen anderen Architekturen unterschied. Nehmen Sie ARM: Um einen kompatiblen Prozessor zu erstellen, müssen Sie eine Vorabgebühr
von 1 bis 10 Millionen US-Dollar sowie Lizenzgebühren von 0,5 bis 2% auf den Umsatz zahlen . Ein kostenloses und offenes Modell macht RISC-V für viele zu einer attraktiven Option, einschließlich Startups, die keine Lizenz für ARM oder einen anderen Prozessor bezahlen können, für akademische Forscher und (offensichtlich) für die Open Source-Community.
Das schnelle Wachstum der Popularität von RISC-V blieb nicht unbemerkt. ARM
hat eine Site gestartet , die (ziemlich erfolglos) versucht hat, die angeblichen Vorteile von ARM gegenüber RISC-V hervorzuheben (die Site ist bereits geschlossen). Das RISC-V-Projekt wird von
vielen großen Unternehmen unterstützt , darunter Google, Nvidia und Western Digital.
Konfigurieren der QEMU- und RISC-V-Tools
Wir können den Code erst auf dem RISC-V-Prozessor ausführen, wenn wir die Umgebung eingerichtet haben. Glücklicherweise erfordert dies keinen physischen RISC-V-Prozessor, stattdessen nehmen wir
Qemu . Befolgen Sie die
Anweisungen zur Installation
Ihres Betriebssystems . Ich habe MacOS, also geben Sie einfach einen Befehl ein:
Praktischerweise wird
qemu
mit
mehreren betriebsbereiten Maschinen qemu-system-riscv32 -machine
(siehe die
qemu-system-riscv32 -machine
).
Installieren
Sie als Nächstes
OpenOCD für die
Tools RISC-V und RISC-V.
Laden Sie hier vorgefertigte Baugruppen der RISC-V OpenOCD- und RISC-V-Tools herunter.
Wir extrahieren die Dateien in jedes Verzeichnis, ich habe es
~/usys/riscv
. Denken Sie daran für die zukünftige Verwendung.
mkdir -p ~/usys/riscv cd ~/Downloads cp openocd-<date>-<platform>.tar.gz ~/usys/riscv cp riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz ~/usys/riscv cd ~/usys/riscv tar -xvf openocd-<date>-<platform>.tar.gz tar -xvf riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz
RISCV_OPENOCD_PATH
Sie die Umgebungsvariablen
RISCV_OPENOCD_PATH
und
RISCV_PATH
so ein, dass andere Programme unsere
RISCV_PATH
finden können. Dies kann je nach Betriebssystem und Shell unterschiedlich aussehen: Ich habe die Pfade zur
~/.zshenv
.
Erstellen Sie in
/usr/local/bin
einen symbolischen Link für diese ausführbare Datei, damit Sie sie jederzeit ausführen können, ohne den vollständigen Pfad zu
~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/riscv64-unknown-elf-gcc
.
Und voila, wir haben ein funktionierendes RISC-V-Toolkit! Alle unsere ausführbaren Dateien, wie z. B.
riscv64-unknown-elf-gcc
,
riscv64-unknown-elf-gdb
,
riscv64-unknown-elf-ld
und andere, befinden sich in
~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/
.
Hallo RISC-V!
26. Mai 2019 Patch:
Leider funktioniert das Freedom-e-SDK-Programm "Hallo Welt" in QEMU aufgrund eines Fehlers in RISC-V QEMU nicht mehr. Es wurde ein Patch veröffentlicht, um dieses Problem zu lösen. Überspringen Sie diesen Abschnitt jedoch vorerst. Dieses Programm wird in den folgenden Abschnitten des Artikels nicht benötigt. Ich verfolge die Situation und aktualisiere den Artikel, nachdem ich den Fehler behoben habe.
Weitere Informationen finden Sie in diesem Kommentar .Lassen Sie uns mit den eingerichteten Tools das einfache RISC-V-Programm ausführen. Beginnen wir mit dem Klonen des SiFive
Freedom-e-SDK- Repositorys:
cd ~/wherever/you/want/to/clone/this git clone --recursive https://github.com/sifive/freedom-e-sdk.git cd freedom-e-sdk
Beginnen wir
traditionell mit dem Programm "Hallo Welt" aus dem
freedom-e-sdk
Repository. Wir verwenden das vorgefertigte
Makefile
, das sie zum Kompilieren dieses Programms im Debug-Modus bereitstellen:
make PROGRAM=hello TARGET=sifive-hifive1 CONFIGURATION=debug software
Und in QEMU ausführen:
qemu-system-riscv32 -nographic -machine sifive_e -kernel software/hello/debug/hello.elf Hello, World!
Dies ist ein guter Anfang. Sie können andere Beispiele von
freedom-e-sdk
. Danach werden wir schreiben und versuchen, unser eigenes Programm in C zu debuggen.
Naiver Ansatz
Beginnen wir mit einem einfachen Programm, das unendlich zwei Zahlen hinzufügt.
cat add.c int main() { int a = 4; int b = 12; while (1) { int c = a + b; } return 0; }
Wir möchten dieses Programm ausführen und als erstes müssen wir es für den RISC-V-Prozessor kompilieren.
Dadurch wird die Datei
a.out
erstellt, für die
gcc
standardmäßig ausführbare Dateien verwendet. Führen Sie nun diese Datei in
qemu
:
Wir haben uns für die
virt
Maschine entschieden, mit der
riscv-qemu
ursprünglich riscv-qemu
.
Nachdem unser Programm in QEMU mit dem GDB-Server auf
localhost:1234
, stellen wir über ein separates Terminal eine Verbindung mit dem RISC-V GDB-Client her:
Und wir sind in GDB!
Diese GDB wurde als "--host = x86_64-apple-darwin17.7.0 --target = riscv64-unknown-elf" konfiguriert. │
Geben Sie "show configuration" für Konfigurationsdetails ein. │
Anweisungen zur Fehlerberichterstattung finden Sie unter: │
<http://www.gnu.org/software/gdb/bugs/>. │
Das GDB-Handbuch und andere Dokumentationsressourcen finden Sie online unter: │
<http://www.gnu.org/software/gdb/documentation/>. │
│
Um Hilfe zu erhalten, geben Sie "help" ein. │
Geben Sie "apropos word" ein, um nach Befehlen zu suchen, die sich auf "word" beziehen ... │
Lesen von Symbolen aus a.out ... │
(gdb)
Wir können versuchen, die Befehle
run
oder
start
für die ausführbare Datei
a.out
in GDB auszuführen, aber im Moment funktioniert dies aus einem offensichtlichen Grund nicht. Wir haben das Programm als
riscv64-unknown-elf-gcc
kompiliert, daher sollte der Host auf einer
riscv64
Architektur ausgeführt werden.
Aber es gibt einen Ausweg! Diese Situation ist einer der Hauptgründe für die Existenz des Client-Server-Modells von GDB. Wir können die ausführbare Datei
riscv64-unknown-elf-gdb
verwenden und statt sie auf dem Host zu starten, ein Remote-Ziel (GDB-Server) angeben. Wie Sie sich erinnern, haben wir gerade
riscv-qemu
und uns angewiesen, den GDB-Server auf
localhost:1234
zu starten
localhost:1234
. Stellen Sie einfach eine Verbindung zu diesem Server her:
(gdb) Zielfernbedienung: 1234 │
Remote-Debugging mit: 1234
Jetzt können Sie einige Haltepunkte setzen:
(gdb) b main Breakpoint 1 at 0x1018e: file add.c, line 2. (gdb) b 5
Geben Sie abschließend GDB
continue
(abgekürzter Befehl
c
) an, bis wir den Haltepunkt erreichen:
(gdb) c Continuing.
Sie werden schnell feststellen, dass der Prozess in keiner Weise endet. Das ist seltsam ... sollten wir nicht sofort den Haltepunkt
b 5
? Was ist passiert?

Hier sehen Sie einige Probleme:
- Die Text-Benutzeroberfläche kann die Quelle nicht finden. Die Schnittstelle sollte unseren Code und alle in der Nähe befindlichen Haltepunkte anzeigen.
- GDB sieht die aktuelle Ausführungszeile (
L??
) nicht und zeigt den Zähler 0x0 ( PC: 0x0
) an.
- Einige Texte in der Eingabezeile, die in ihrer Gesamtheit so aussehen:
0x0000000000000000 in ?? ()
0x0000000000000000 in ?? ()
In Kombination mit der Tatsache, dass wir den Haltepunkt nicht erreichen können, zeigen diese Indikatoren an: Wir haben
etwas falsch gemacht. Aber was?
Vorhang aufheben -v
Um zu verstehen, was passiert, müssen Sie einen Schritt zurücktreten und darüber sprechen, wie unser einfaches C-Programm unter der Haube tatsächlich funktioniert. Die Hauptfunktion macht eine einfache Ergänzung, aber was ist es wirklich? Warum sollte es
main
heißen, nicht
origin
oder
begin
? Gemäß der Konvention werden alle ausführbaren Dateien mit der
main
ausgeführt, aber welche Magie bietet dieses Verhalten?
Um diese Fragen zu beantworten, wiederholen wir unser GCC-Team mit dem Flag
-v
, um eine detailliertere Ausgabe dessen zu erhalten, was tatsächlich passiert.
riscv64-unknown-elf-gcc add.c -O0 -g -v
Die Ausgabe ist groß, daher wird nicht die gesamte Liste angezeigt. Es ist wichtig zu beachten, dass GCC zwar formal ein Compiler ist, aber standardmäßig auch kompiliert wird (um sich auf Kompilierung und Assembly zu beschränken, müssen Sie das Flag
-c
angeben). Warum ist das wichtig? Schauen Sie sich das Snippet aus der detaillierten Ausgabe von
gcc
:
# Der eigentliche Befehl `gcc -v` gibt vollständige Pfade aus, aber diese sind ziemlich
# long, also tun Sie so, als ob diese Variablen existieren.
# $ RV_GCC_BIN_PATH = / Users / twilcock / usys / riscv / riscv64-unknown-elf-gcc- <Datum> - <Version> / bin /
# $ RV_GCC_LIB_PATH = $ RV_GCC_BIN_PATH /../ lib / gcc / riscv64-unknown-elf / 8.2.0
$ RV_GCC_BIN_PATH /../ libexec / gcc / riscv64-unknown-elf / 8.2.0 / collect2 \
... abgeschnitten ...
$ RV_GCC_LIB_PATH /../../../../ riscv64-unknown-elf / lib / rv64imafdc / lp64d / crt0.o \
$ RV_GCC_LIB_PATH / riscv64-unknown-elf / 8.2.0 / rv64imafdc / lp64d / crtbegin.o \
-lgcc --start-group -lc -lgloss --end-group -lgcc \
$ RV_GCC_LIB_PATH / rv64imafdc / lp64d / crtend.o
... abgeschnitten ...
COLLECT_GCC_OPTIONS = '- O0' '-g' '-v' '-march = rv64imafdc' '-mabi = lp64d'
Ich verstehe, dass dies auch in Kurzform viel ist, also lassen Sie mich das erklären. In der ersten Zeile führt
gcc
das Programm
collect2
aus und übergibt die Argumente
crt0.o
,
crtbegin.o
und
crtend.o
sowie die
-lgcc
und
--start-group
. Die Beschreibung von collect2 finden Sie
hier : Kurz gesagt, collect2 organisiert beim Start verschiedene Initialisierungsfunktionen und erstellt das Layout in einem oder mehreren Durchgängen.
Daher kompiliert GCC mehrere
crt
Dateien mit unserem Code. Wie Sie sich
crt
können, bedeutet
crt
"C-Laufzeit".
Hier wird detailliert beschrieben, wofür jedes
crt
, aber wir sind an
crt
interessiert, das eine wichtige Sache tut:
"Es wird erwartet, dass dieses [crt0] -Objekt das Zeichen _start
, das den Bootstrap des Programms angibt."
Das Wesen des „Bootstraps“ ist plattformabhängig, beinhaltet jedoch normalerweise wichtige Aufgaben wie das Einrichten eines Stapelrahmens, das Übergeben von Befehlszeilenargumenten und das Aufrufen von
main
. Ja, wir haben
endlich die Antwort auf die Frage gefunden: Es ist
_start
der unsere Hauptfunktion aufruft!
Durchsuchen Sie unseren Stapel
Wir haben ein Rätsel gelöst, aber wie bringt uns dies dem ursprünglichen Ziel näher - ein einfaches C-Programm in
gdb
auszuführen? Es bleibt noch einige Probleme zu lösen: Das erste hängt damit zusammen, wie
crt0
unseren Stack konfiguriert.
Wie wir oben gesehen haben, verwendet
gcc
standardmäßig die
crt0
. Standardparameter werden basierend auf mehreren Faktoren ausgewählt:
- Zieldriplett entsprechend der Struktur des
machine-vendor-operatingsystem
. Wir haben es riscv64-unknown-elf
rv64imafdc
, rv64imafdc
- Ziel ABI,
lp64d
Normalerweise funktioniert alles einwandfrei, aber nicht für jeden RISC-V-Prozessor. Wie bereits erwähnt, besteht eine der Aufgaben von
crt0
darin, den Stack zu konfigurieren. Aber er weiß nicht, wo genau der Stack für unsere CPU (
-machine
) sein soll? Er kann es nicht ohne unsere Hilfe tun.
Im
qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out
wir die
virt
Maschine verwendet. Glücklicherweise macht es
qemu
einfach, Maschineninformationen in einen
dtb
Dump (Gerätebaum-Blob) zu
dtb
.
Dtb-Daten sind schwer zu lesen, da es sich im Grunde genommen um ein Binärformat handelt. Es gibt jedoch ein
dtc
Befehlszeilenprogramm (Gerätebaum-Compiler), mit dem die Datei in etwas besser lesbares konvertiert werden kann.
Die Ausgabedatei ist
riscv64-virt.dts
, wo wir viele interessante Informationen über
virt
: die Anzahl der verfügbaren Prozessorkerne, den Speicherort verschiedener Peripheriegeräte wie UART, den Speicherort des internen Speichers (RAM). Der Stapel sollte sich in diesem Speicher befinden, suchen Sie ihn also mit
grep
:
grep memory riscv64-virt.dts -A 3 memory@80000000 { device_type = "memory"; reg = <0x00 0x80000000 0x00 0x8000000>; };
Wie Sie sehen können, hat dieser Knoten 'Speicher' als
device_type
. Anscheinend haben wir gefunden, wonach wir gesucht haben. Durch die Werte in
reg = <...> ;
Sie können bestimmen, wo die Speicherbank beginnt und wie lang sie ist.
In
der Devicetree-Spezifikation sehen wir , dass die
reg
Syntax eine beliebige Anzahl von Paaren ist
(base_address, length)
. Es gibt jedoch vier Bedeutungen in
reg
. Seltsam, reichen nicht zwei Werte für eine Speicherbank aus?
Wiederum ergibt sich aus der Gerätebaumspezifikation (Suche nach der
reg
Eigenschaft), dass die Anzahl der
<u32>
-Zellen zum Angeben der Adresse und Länge durch die Eigenschaften
#address-cells
und
#size-cells
<u32>
im übergeordneten Knoten (oder im Knoten selbst) bestimmt wird. Diese Werte sind in unserem Speicherknoten nicht angegeben, und der übergeordnete Speicherknoten ist einfach das Stammverzeichnis der Datei. Schauen wir uns diese Werte an:
head -n8 riscv64-virt.dts /dts-v1/; / { #address-cells = <0x02>; #size-cells = <0x02>; compatible = "riscv-virtio"; model = "riscv-virtio,qemu";
Es stellt sich heraus, dass sowohl die Adresse als auch die Länge zwei 32-Bit-Werte erfordern. Dies bedeutet, dass mit
reg = <0x00 0x80000000 0x00 0x8000000>;
Unser Speicher beginnt
0x00 + 0x80000000 (0x80000000)
und belegt
0x00 + 0x8000000 (0x8000000)
Bytes, d.
0x88000000
endet bei
0x88000000
, was 128 Megabyte entspricht.
Layout
Mit
qemu
und
dtc
wir die RAM-Adressen in der virtuellen Virtuellen Maschine gefunden. Wir wissen auch, dass
gcc
standardmäßig
crt0
, ohne den Stack nach Bedarf zu konfigurieren. Aber wie kann man diese Informationen verwenden, um das Programm schließlich auszuführen und zu debuggen?
Da
crt0
nicht zu uns passt, gibt es eine offensichtliche Option: Schreiben Sie Ihren eigenen Code und komponieren Sie ihn dann mit der Objektdatei, die wir nach dem Kompilieren unseres einfachen Programms erhalten haben. Unser
crt0
muss wissen, wo die Oberseite des Stapels beginnt, um ihn richtig zu initialisieren. Wir könnten
crt0
Wert
0x80000000
direkt in
crt0
0x80000000
, aber dies ist keine sehr geeignete Lösung, da Änderungen berücksichtigt werden, die möglicherweise in Zukunft erforderlich sind. Was ist, wenn wir eine andere CPU wie
sifive_e
mit unterschiedlichen Eigenschaften im Emulator verwenden
sifive_e
?
Glücklicherweise sind wir nicht die Ersten, die diese Frage stellen, und es gibt bereits eine gute Lösung. Mit dem GNU
ld
Linker
können Sie das in unserem
crt0
verfügbare
Zeichen definieren . Wir können das
__stack_top
Symbol definieren, das für verschiedene Prozessoren geeignet ist.
Anstatt Ihre eigene Linker-Datei von Grund auf neu zu schreiben, ist es sinnvoll, das Standard-Skript mit
ld
und es ein wenig zu ändern, um zusätzliche Zeichen zu unterstützen. Was ist ein Linker-Skript?
Hier ist eine gute Beschreibung :
Der Hauptzweck des Linkerskripts besteht darin, zu beschreiben, wie Dateibereiche in Eingabe und Ausgabe übereinstimmen, und das Layout des Speichers der Ausgabedatei zu steuern.
Wenn Sie dies wissen, kopieren wir das Standard-Linker-Skript
riscv64-unknown-elf-ld
in eine neue Datei:
cd ~/usys/riscv
Diese Datei enthält
viele interessante Informationen, viel mehr als wir in diesem Artikel diskutieren können. Die detaillierte Ausgabe mit der
--Verbose
enthält Informationen zur
ld
Version, unterstützten Architekturen und vielem mehr. Das ist alles gut zu wissen, aber eine solche Syntax ist im Linker-Skript nicht akzeptabel. Öffnen Sie daher einen Texteditor und löschen Sie alles Überflüssige aus der Datei.
vim riscv64-virt.ld
# Entfernen Sie alles über und einschließlich der Zeile ==============
GNU ld (GNU Binutils) 2.32
Unterstützte Emulationen:
elf64lriscv
elf32lriscv
mit internem Linker-Skript:
==================================================
/ * Skript für -z combreloc: Kombiniere und sortiere Reloc-Abschnitte * /
/ * Copyright (C) 2014-2019 Freie Software Foundation, Inc.
Kopieren und Verteilen dieses Skripts mit oder ohne Änderung,
sind in jedem Medium ohne Lizenzgebühr zulässig, sofern das Urheberrecht besteht
Bekanntmachung und diese Bekanntmachung bleiben erhalten. * /
OUTPUT_FORMAT ("elf64-littleriscv", "elf64-littleriscv",
"elf64-littleriscv")
... Rest des Linker-Skripts ...
Führen Sie danach den Befehl
MEMORY aus, um manuell zu bestimmen, wo sich
__stack_top
befindet. Suchen Sie die Zeile, die mit
OUTPUT_ARCH(riscv)
beginnt. Sie sollte sich oben in der Datei befinden, und fügen Sie den Befehl
MEMORY
darunter hinzu:
OUTPUT_ARCH(riscv) /* >>> Our addition. <<< */ MEMORY { /* qemu-system-risc64 virt machine */ RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } /* >>> End of our addition. <<< */ ENTRY(_start)
Wir haben einen Speicherblock namens
RAM
, für den das Lesen (
r
), Schreiben (
w
) und Speichern von ausführbarem Code (
x
) zulässig ist.
Großartig, wir haben ein Speicherlayout definiert, das den Spezifikationen unserer
virt
RISC-V-Maschine entspricht. Jetzt können Sie es verwenden. Wir wollen unseren Stack in Erinnerung behalten.
Sie müssen das Zeichen
__stack_top
definieren. Öffnen Sie Ihr Linker-Skript (
riscv64-virt.ld
) in einem Texteditor und fügen Sie einige Zeilen hinzu:
SECTIONS { /* Read-only sections, merged into text segment: */ PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x10000)); . = SEGMENT_START("text-segment", 0x10000) + SIZEOF_HEADERS; /* >>> Our addition. <<< */ PROVIDE(__stack_top = ORIGIN(RAM) + LENGTH(RAM)); /* >>> End of our addition. <<< */ .interp : { *(.interp) } .note.gnu.build-id : { *(.note.gnu.build-id) }
Wie Sie sehen können, definieren wir
__stack_top
mit
dem Befehl PROVIDE . Auf das Symbol kann von jedem Programm aus
__stack_top
, das diesem Skript zugeordnet ist (vorausgesetzt, das Programm selbst bestimmt nichts mit dem Namen
__stack_top
). Setzen Sie
__stack_top
auf
ORIGIN(RAM)
. Wir wissen, dass dieser Wert
0x80000000
plus
LENGTH(RAM)
, was 128 Megabyte (
0x8000000
Bytes) entspricht. Dies bedeutet, dass unser
__stack_top
auf
0x88000000
.
Der Kürze halber werde ich hier nicht die gesamte Linker-Datei
auflisten , Sie können sie
hier anzeigen.
Hör auf! Hammertime! Laufzeit!
Jetzt haben wir alles, was wir brauchen, um unsere eigene C-Laufzeit zu erstellen. Eigentlich ist dies eine ziemlich einfache Aufgabe, hier ist die gesamte Datei
crt0.s
:
.section .init, "ax" .global _start _start: .cfi_startproc .cfi_undefined ra .option push .option norelax la gp, __global_pointer$ .option pop la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end
Zieht sofort eine große Anzahl von Zeilen an, die mit einem Punkt beginnen. Dies ist eine Datei für Assembler
as
. Zeilen mit Punkten werden
Assembler-Anweisungen genannt : Sie liefern Informationen für Assembler. Dies ist kein ausführbarer Code wie RISC-V-Assembler-Anweisungen wie
jal
und
add
.
Lassen Sie uns die Datei Zeile für Zeile durchgehen. Wir werden mit verschiedenen Standard-RISC-V-Registern arbeiten. Schauen Sie sich also
diese Tabelle an , die alle Register und ihren Zweck abdeckt.
.section .init, "ax"
Wie im
Handbuch des GNU-Assemblers 'as' angegeben, weist diese Zeile den Assembler an, den folgenden Code in den Abschnitt
.init
, der zugewiesen (
a
) und ausführbar (
x
) ist. Dieser Abschnitt ist eine weitere
gängige Konvention zum Ausführen von Code innerhalb des Betriebssystems. Wir arbeiten mit reiner Hardware ohne Betriebssystem, daher ist eine solche Anweisung in unserem Fall möglicherweise nicht unbedingt erforderlich, aber in jedem Fall ist dies eine gute Praxis.
.global _start _start:
.global
stellt
ld
das folgende Zeichen zur Verfügung. Ohne dies funktioniert der Link nicht, da der
ENTRY(_start)
im
_start
auf das Symbol
_start
als Einstiegspunkt in die ausführbare Datei verweist. Die nächste Zeile teilt dem Assembler mit, dass wir mit der Definition des Zeichens
_start
.
_start: .cfi_startproc .cfi_undefined ra ...other stuff... .cfi_endproc
Diese
.cfi
Anweisungen
informieren Sie über die Struktur des Frames und dessen Handhabung. Die
.cfi_startproc
und
.cfi_endproc
signalisieren den Beginn und das Ende einer Funktion, und
.cfi_undefined ra
teilt dem Assembler mit, dass das
ra
Register vor dem
_start
nicht auf den darin enthaltenen Wert
wiederhergestellt werden _start
.
.option push .option norelax la gp, __global_pointer$ .option pop
Diese
.option
Anweisungen ändern das Verhalten des Assemblers gemäß dem Code, wenn Sie einen bestimmten Satz von Optionen anwenden müssen.
Hier finden Sie eine detaillierte Beschreibung, warum die Verwendung von
.option
in diesem Segment wichtig ist:
... da wir möglicherweise die Adressierung von Sequenzen auf kürzere Sequenzen relativ zum GP lockern, sollte die anfängliche Belastung des GP nicht geschwächt werden und sollte ungefähr so aussehen:
.option push .option norelax la gp, __global_pointer$ .option pop
so dass Sie nach der Entspannung den folgenden Code erhalten:
auipc gp, %pcrel_hi(__global_pointer$) addi gp, gp, %pcrel_lo(__global_pointer$)
statt einfach:
addi gp, gp, 0
Und jetzt der letzte Teil unserer
crt0.s
:
_start: ...other stuff... la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end
Hier können wir endlich das Symbol
__stack_top
, an dessen Erstellung wir so hart gearbeitet haben.
Der Pseudobefehl la
(Ladeadresse) lädt den Wert
__stack_top
in das Register
sp
(Stapelzeiger) und setzt ihn für die Verwendung im Rest des Programms.
Dann
add s0, sp, zero
die Werte der Register
sp
und
zero
(was eigentlich ein Register
x0
mit einer harten Referenz auf 0 ist) und
s0
das Ergebnis in das Register
s0
. Dies ist ein
spezielles Register , das in mehrfacher Hinsicht ungewöhnlich ist. Erstens handelt es sich um ein „beständiges Register“, das heißt, es wird bei Funktionsaufrufen gespeichert. Zweitens fungiert
s0
manchmal als Rahmenzeiger, der jedem Funktionsaufruf einen kleinen Platz im Stapel gibt, um die an diese Funktion übergebenen Parameter zu speichern. Wie Funktionsaufrufe mit den Stapel- und Rahmenzeigern funktionieren, ist ein sehr interessantes Thema, das Sie leicht einem separaten Artikel widmen
s0
.
s0
Sie jedoch, dass es in unserer Laufzeit wichtig ist, den Rahmenzeiger
s0
zu initialisieren.
Als nächstes sehen wir die
jal zero, main
. Hier bedeutet
jal
springen und verknüpfen. Der Befehl erwartet Operanden in Form von
jal rd (destination register), offset_address
. Funktionell schreibt
jal
den Wert des nächsten Befehls (
pc
Register plus vier) in
rd
und setzt dann das
pc
Register auf den aktuellen
pc
Wert plus Offset-Adresse mit
Vorzeichenerweiterung ,
jal
diese Adresse effektiv "aufgerufen" wird.
Wie oben erwähnt, ist
x0
eng an den
x0
0 gebunden, und das Schreiben darauf ist nutzlos.
Daher mag es seltsam erscheinen, dass wir ein Register als Zielregister verwenden zero
, das die RISC-V-Assembler als Register interpretieren x0
. Dies bedeutet schließlich einen bedingungslosen Übergang zu offset_address
. Warum tun Sie das, weil es in anderen Architekturen eine explizite Anweisung für einen bedingungslosen Übergang gibt?Dieses seltsame Muster jal zero, offset_address
ist eigentlich eine kluge Optimierung. Die Unterstützung für jeden neuen Befehl bedeutet eine Erhöhung und folglich eine Erhöhung der Kosten des Prozessors. Je einfacher die ISA, desto besser. Anstatt den Befehlsraum mit zwei Befehlen jal
und zu verschmutzen unconditional jump
, unterstützt die RISC-V-Architektur nur jal
und bedingungslose Sprünge werden durch unterstützt jal zero, main
.RISC-V verfügt über viele solcher Optimierungen, von denen die meisten in Form von sogenannten Pseudo-Anweisungen vorliegen . Assembler wissen, wie man sie in echte Hardwareanweisungen übersetzt. Beispielsweise übersetzen j offset_address
RISC-V-Assembler Pseudobefehle für bedingungslose Sprünge nach jal zero, offset_address
. Eine vollständige Liste der offiziell unterstützten Pseudoanweisungen finden Sie in der RISC-V-Spezifikation (Version 2.2) . _start: ...other stuff... jal zero, main .cfi_endproc .end
Unsere letzte Zeile ist die Assembler-Direktive .end
, die einfach das Ende der Datei markiert.Debug aber jetzt echt
Beim Versuch, ein einfaches C-Programm auf einem RISC-V-Prozessor zu debuggen, haben wir viele Probleme gelöst. Zuerst verwenden qemu
und dtc
finden Sie unseren Speicher in der virtuellen virt
RISC-V- Maschine . Anschließend haben wir diese Informationen verwendet, um die Speicherzuordnung in unserer Version des Standardskripts des Linkers manuell zu steuern riscv64-unknown-elf-ld
, sodass wir das Symbol genau bestimmen konnten __stack_top
. Dann haben wir dieses Symbol in unserer eigenen Version verwendet crt0.s
, die unseren Stack und unsere globalen Zeiger einrichtet, und schließlich die Funktion aufgerufen main
. Jetzt können Sie Ihr Ziel erreichen und mit dem Debuggen unseres einfachen Programms in GDB beginnen.Denken Sie daran, hier ist das C-Programm selbst: cat add.c int main() { int a = 4; int b = 12; while (1) { int c = a + b; } return 0; }
Kompilieren und Verknüpfen: riscv64-unknown-elf-gcc -g -ffreestanding -O0 -Wl,--gc-sections -nostartfiles -nostdlib -nodefaultlibs -Wl,-T,riscv64-virt.ld crt0.s add.c
Hier haben wir viel mehr Flaggen als beim letzten Mal angegeben, also gehen wir die durch, die wir vorher nicht beschrieben haben.-ffreestanding
teilt dem Compiler mit, dass die Standardbibliothek möglicherweise nicht vorhanden ist , sodass keine Annahmen über ihre obligatorische Präsenz getroffen werden müssen. Dieser Parameter ist nicht erforderlich, wenn die Anwendung auf ihrem Host (im Betriebssystem) gestartet wird. In diesem Fall ist dies jedoch nicht der Fall. Daher ist es wichtig, den Compiler über diese Informationen zu informieren.-Wl
- Eine durch Kommas getrennte Liste von Flags, die an den Linker ( ld
) übergeben werden sollen. Hier --gc-sections
bedeutet es "Garbage Collection-Abschnitte" und ld
wird angewiesen, nicht verwendete Abschnitte nach dem Verknüpfen zu entfernen. Flaggen -nostartfiles
, -nostdlib
und -nodefaultlibs
der Linker nicht die Standard - Systemstart - Dateien verarbeiten (zB Standardcrt0
), Standardimplementierungen von System stdlib und standardmäßig verknüpften Standardbibliotheken. Wir haben ein eigenes Skript crt0
und einen eigenen Linker. Daher ist es wichtig, diese Flags zu übergeben, damit die Standardwerte nicht mit unseren Benutzereinstellungen in Konflikt stehen.-T
gibt den Pfad zu unserem Linker-Skript an, was in unserem Fall einfach ist riscv64-virt.ld
. Schließlich geben wir die Dateien an, die wir kompilieren, kompilieren und komponieren möchten: crt0.s
und add.c
. Nach wie vor ist das Ergebnis eine vollständige und sofort einsatzbereite Datei mit dem Namen a.out
.Führen Sie jetzt unsere hübsche neue brandneue ausführbare Datei aus in qemu
:
Denken Sie jetzt daran gdb
, die Debugging-Symbole für zu laden a.out
und mit dem letzten Argument anzugeben: riscv64-unknown-elf-gdb --tui a.out GNU gdb (GDB) 8.2.90.20190228-git Copyright (C) 2019 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "--host=x86_64-apple-darwin17.7.0 --target=riscv64-unknown-elf". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out... (gdb)
Verbinden Sie dann unseren Client gdb
mit dem Server gdb
, den wir als Teil des Befehls gestartet haben qemu
: (gdb) target remote :1234 │ Remote debugging using :1234
Setzen Sie einen Haltepunkt in main: (gdb) b main Breakpoint 1 at 0x8000001e: file add.c, line 2.
Und starten Sie das Programm: (gdb) c Continuing. Breakpoint 1, main () at add.c:2
Aus der gegebenen Ausgabe geht hervor, dass wir den Haltepunkt in Zeile 2 erfolgreich erreicht haben! Dies ist auch in der Textoberfläche sichtbar, schließlich haben wir die richtige Zeile L
, der Wert PC:
ist L2
und PC:
- 0x8000001e
. Wenn Sie alles wie im Artikel beschrieben haben,
sieht die Ausgabe folgendermaßen aus: Von nun an können Sie sie gdb
wie gewohnt verwenden: -s
Fahren Sie mit der nächsten Anweisung fort, info all-registers
überprüfen Sie die Werte in den Registern, während das Programm ausgeführt wird usw. Experimentieren Sie zu Ihrem Vergnügen ... wir natürlich habe viel dafür gearbeitet!Was weiter?
Heute haben wir viel erreicht und hoffentlich viel gelernt! Ich hatte nie einen formellen Plan für diesen und nachfolgende Artikel, ich folgte einfach dem, was für mich in jedem Moment am interessantesten war. Daher bin ich mir nicht sicher, was als nächstes passieren wird. Besonders gut hat mir das tiefe Eintauchen in die Anleitung gefallen. jal
Vielleicht werden wir im nächsten Artikel das hier gewonnene Wissen zugrunde legen, es aber durch add.c
ein Programm in einem reinen RISC-V-Assembler ersetzen . Wenn Sie etwas Bestimmtes haben, das Sie sehen möchten oder Fragen haben, öffnen Sie Tickets .Danke fürs Lesen! Ich hoffe, im nächsten Artikel zu treffen!Optional
Wenn Ihnen der Artikel gefallen hat und Sie mehr wissen möchten, lesen Sie die Präsentation von Matt Godbolt mit dem Titel „Bits Between Bits: Wie wir zu main () kommen“ von der CppCon2018-Konferenz. Sie geht das Thema etwas anders an als wir hier. Wirklich guter Vortrag, überzeugen Sie sich selbst!