RISC-V von Grund auf neu

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


  1. Was ist RISC-V?
  2. Konfigurieren der QEMU- und RISC-V-Tools
  3. Hallo RISC-V!
  4. Naiver Ansatz
  5. Vorhang aufheben -v
  6. Durchsuchen Sie unseren Stapel
  7. Layout
  8. Hör auf! Hammertime! Laufzeit!
  9. Debug aber jetzt echt
  10. Was weiter?
  11. 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:

# also available via MacPorts - `sudo port install qemu` brew install qemu 

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 .

 # I put these two exports directly in my ~/.zshenv file - you may have to do something else. export RISCV_OPENOCD_PATH="$HOME/usys/riscv/openocd-<date>-<version>" export RISCV_PATH="$HOME/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>" # Reload .zshenv with our new environment variables. Restarting your shell will have a similar effect. source ~/.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 .

 # Symbolically link our gcc executable into /usr/local/bin. Repeat this process for any other executables you want to quickly access. ln -s ~/usys/riscv/riscv64-unknown-elf-gcc-8.2.0-<date>-<version>/bin/riscv64-unknown-elf-gcc /usr/local/bin 

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.

 # -O0 to disable all optimizations. Without this, GCC might optimize # away our infinite addition since the result 'c' is never used. # -g to tell GCC to preserve debug info in our executable. riscv64-unknown-elf-gcc add.c -O0 -g 

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 :

 # -machine tells QEMU which among our list of available machines we want to # run our executable against. Run qemu-system-riscv64 -machine help to list # all available machines. # -m is the amount of memory to allocate to our virtual machine. # -gdb tcp::1234 tells QEMU to also start a GDB server on localhost:1234 where # TCP is the means of communication. # -kernel tells QEMU what we're looking to run, even if our executable isn't # exactly a "kernel". qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out 

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:

 # --tui gives us a (t)extual (ui) for our GDB session. # While we can start GDB without any arguments, specifying 'a.out' tells GDB # to load debug symbols from that file for the newly created session. riscv64-unknown-elf-gdb --tui a.out 

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 # this is the line within the forever-while loop. int c = a + b; Breakpoint 2 at 0x1019a: file add.c, line 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:

  1. Die Text-Benutzeroberfläche kann die Quelle nicht finden. Die Schnittstelle sollte unseren Code und alle in der Nähe befindlichen Haltepunkte anzeigen.
  2. GDB sieht die aktuelle Ausführungszeile ( L?? ) nicht und zeigt den Zähler 0x0 ( PC: 0x0 ) an.
  3. 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 .

 # Go to the ~/usys/riscv folder we created before and create a new dir # for our machine information. cd ~/usys/riscv && mkdir machines cd machines # Use qemu to dump info about the 'virt' machine in dtb (device tree blob) # format. # The data in this file represents hardware components of a given # machine / device / board. qemu-system-riscv64 -machine virt -machine dumpdtb=riscv64-virt.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.

 # I'm running MacOS, so I use Homebrew to install this. If you're # running another OS you may need to do something else. brew install dtc # Convert our .dtb into a human-readable .dts (device tree source) file. dtc -I dtb -O dts -o riscv64-virt.dts riscv64-virt.dtb 

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 # Make a new dir for custom linker scripts out RISC-V CPUs may require. mkdir ld && cd ld # Copy the default linker script into riscv64-virt.ld riscv64-unknown-elf-ld --verbose > riscv64-virt.ld 

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_addressist 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 jalund zu verschmutzen unconditional jump, unterstützt die RISC-V-Architektur nur jalund 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_addressRISC-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 qemuund dtcfinden Sie unseren Speicher in der virtuellen virtRISC-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-sectionsbedeutet es "Garbage Collection-Abschnitte" und ldwird angewiesen, nicht verwendete Abschnitte nach dem Verknüpfen zu entfernen. Flaggen -nostartfiles, -nostdlibund -nodefaultlibsder Linker nicht die Standard - Systemstart - Dateien verarbeiten (zB Standardcrt0), Standardimplementierungen von System stdlib und standardmäßig verknüpften Standardbibliotheken. Wir haben ein eigenes Skript crt0und einen eigenen Linker. Daher ist es wichtig, diese Flags zu übergeben, damit die Standardwerte nicht mit unseren Benutzereinstellungen in Konflikt stehen.

-Tgibt 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.sund 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:

 # -S freezes execution of our executable (-kernel) until we explicitly tell # it to start with a 'continue' or 'c' from our gdb client qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -S -kernel a.out 

Denken Sie jetzt daran gdb, die Debugging-Symbole für zu laden a.outund 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 gdbmit 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 L2und PC:- 0x8000001e. Wenn Sie alles wie im Artikel beschrieben haben,



sieht die Ausgabe folgendermaßen aus: Von nun an können Sie sie gdbwie gewohnt verwenden: -sFahren 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. jalVielleicht werden wir im nächsten Artikel das hier gewonnene Wissen zugrunde legen, es aber durch add.cein 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!

Source: https://habr.com/ru/post/de454208/


All Articles