Teil 5/2 Gebäude 1: Kreuzung der RocketChip Avenue und der rutschigen Instrumentierungsspur

In den vorherigen vier Teilen wurden Vorbereitungen für Experimente mit dem RISC-V RocketChip-Kern getroffen, nämlich die Portierung dieses Kerns auf eine „Nicht-Standard“ -Karte mit Altera-FPGAs (jetzt Intel). Im letzten Teil stellte sich schließlich heraus, dass Linux auf diesem Board ausgeführt wurde. Weißt du, was mich an all dem amüsiert hat? Die Tatsache, dass ich gleichzeitig mit den Assemblern RISC-V, C und Scala arbeiten musste, und von allen war Scala die Sprache der niedrigsten Ebene (weil der Prozessor darauf geschrieben ist).


Lassen Sie uns C auch in diesem Artikel nicht anstößig machen. Wenn das Scala + Chisel-Bundle nur als domänenspezifische Sprache zur expliziten Beschreibung der Hardware verwendet wurde, lernen wir heute, wie einfache C-Funktionen in Form von Anweisungen in den Prozessor „gezogen“ werden.


Das ultimative Ziel ist die triviale Implementierung trivialer AFL-ähnlicher Instrumente in Analogie zu QInst , und die Implementierung separater Anweisungen ist nur ein Nebenprodukt.


Es ist klar, dass es (und nicht einen) kommerziellen OpenCL-RTL-Konverter gibt. Ich bin auch auf Informationen über ein bestimmtes COPILOT-Projekt für RISC-V mit ähnlichen Zielen gestoßen (viel weiter fortgeschritten), aber etwas googelt schlecht, und außerdem ist es höchstwahrscheinlich auch ein kommerzielles Produkt. Ich interessiere mich hauptsächlich für OpenSource-Lösungen, aber selbst wenn dies der Fall ist, macht es immer noch Spaß, dies selbst zu implementieren - zumindest als vereinfachtes Schulungsbeispiel, und dann, wie es geht ...


Haftungsausschluss (zusätzlich zu der üblichen Warnung vor „Tanzen mit einem Feuerlöscher“): Ich empfehle dringend nicht, den resultierenden Softwarekern rücksichtslos anzuwenden, insbesondere bei nicht vertrauenswürdigen Daten - bisher habe ich nicht so viel Vertrauen, als dass ich überhaupt verstehe, warum die verarbeiteten Daten nicht können "Flow" in einem Grenzfall zwischen Prozessen und / oder dem Kern. Nun, über die Tatsache, dass die Daten "schlagen" können, denke ich, und so ist es klar. Im Allgemeinen gibt es noch Validierung und Validierung ...


Wie nenne ich eine "einfache Funktion"? Für die Zwecke dieses Artikels bedeutet dies eine Funktion, bei der alle Übergänge (bedingt und bedingungslos) den Befehlszähler nur um einen konstanten Wert erhöhen. Das heißt, der Graph aller möglichen Übergänge ist (gerichtet) azyklisch ohne "dynamische" Kanten. Das ultimative Ziel im Rahmen dieses Artikels ist es, eine einfache Funktion aus dem Programm zu übernehmen und sie durch ein Assembler-Plug-In zu ersetzen und sie in der Synthesestufe in den Prozessor zu „nähen“, wodurch sie optional zu einem Nebeneffekt einer anderen Anweisung wird. Insbesondere wird die Verzweigung in diesem Artikel nicht gezeigt, aber im einfachsten Fall wird es nicht schwierig sein, sie zu erstellen.


C verstehen lernen (eigentlich nein)


Zuerst müssen Sie verstehen, wie wir C analysieren werden? Das ist auf keinen Fall richtig - es war nicht umsonst, dass ich gelernt habe, ELF-Dateien zu analysieren : Sie müssen nur unseren Code in C / Rust / etwas anderem in einen eBPF-Bytecode kompilieren und ihn bereits analysieren. Einige Schwierigkeiten werden durch die Tatsache verursacht, dass Sie in Scala nicht einfach elf.h verbinden und Strukturfelder lesen können. Sie können natürlich versuchen, JNAerator zu verwenden - bei Bedarf können sie Ordner für die Bibliothek erstellen - nicht nur Strukturen, sondern auch Code für die Arbeit mit JNA generieren (nicht zu verwechseln mit JNI). Als echter Programmierer schreibe ich mein Fahrrad und schreibe die Aufzählungs- und Versatzkonstanten sorgfältig aus der Header-Datei. Das Ergebnis und die Zwischenstrukturen werden durch die folgende Struktur von Fallklassen beschrieben:


 sealed trait SectionKind case object RegularSection extends SectionKind case object SymtabSection extends SectionKind case object StrtabSection extends SectionKind case object RelSection extends SectionKind final case class Elf64Header( sectionHeaders: Seq[ByteBuffer], sectionStringTableIndex: Int ) final case class Elf64Section( data: ByteBuffer, linkIndex: Int, infoIndex: Int, kind: SectionKind ) final case class Symbol( name: String, value: Int, size: Int, shndx: Int, isInstrumenter: Boolean ) final case class Relocation( relocatedSection: Int, offset: Int, symbol: Symbol ) final case class BpfInsn( opcode: Int, dst: Int, src: Int, offset: Int, imm: Either[Long, Symbol] ) final case class BpfProg( name: String, insns: Seq[BpfInsn] ) 

Ich werde den Parsing-Prozess nicht besonders beschreiben - dies ist nur eine langweilige Byte-Übertragung von java.nio.ByteBuffer - all die interessanten Dinge wurden bereits im Artikel über das Parsen von ELF-Dateien beschrieben . Ich kann nur sagen, dass Sie den opcode == 0x18 (Laden von 64-Bit- opcode == 0x18 in das Register) sorgfältig behandeln müssen, da zwei 8-Byte-Wörter gleichzeitig benötigt werden (möglicherweise gibt es andere solche Opcodes, aber ich bin noch nicht auf sie gestoßen). , und dies lädt nicht immer die Speicheradresse, die mit dem Umzug verbunden ist, wie ich anfangs dachte. Zum Beispiel verwendet __builtin_popcountl ehrlich die 64-Bit- Konstante 0x0101010101010101 . Warum mache ich keinen „ehrlichen“ Umzug mit dem Patchen der heruntergeladenen Datei - weil ich die Zeichen in symbolischer Form sehen möchte (Entschuldigung für das Wortspiel), damit später die Zeichen aus dem Abschnitt COMMON durch Register ersetzt werden können, ohne Krücken mit spezieller Behandlung von Adressen einer bestimmten Art zu verwenden (und es bedeutet, auch bei Tänzen mit konstanter / nicht konstanter UInt ).


Wir bauen Hardware nach einer Reihe von Anweisungen


Unter der Annahme, dass alle möglichen Ausführungspfade ausschließlich in der Liste der Anweisungen aufgeführt sind, bedeutet dies, dass die Daten entlang eines orientierten azyklischen Graphen fließen und alle seine Kanten statisch definiert sind. Gleichzeitig haben wir eine rein kombinatorische Logik (dh ohne Register auf dem Weg), die aus Operationen an Registern sowie Verzögerungen während Lade- / Speicheroperationen mit Speicher erhalten wird. Daher ist es im allgemeinen Fall möglicherweise nicht möglich, die Operation in einem Taktzyklus abzuschließen. Wir werden es einfach machen: Wir werden den Wert in Form von UInt , aber wie (UInt, Bool) : Das erste Element des Paares ist der Wert und das zweite ist ein Zeichen für seine Richtigkeit. Das heißt, es macht wenig Sinn, aus dem Speicher zu lesen, solange die Adresse falsch ist und das Schreiben im Allgemeinen unmöglich ist.


Das eBPF-Bytecode-Ausführungsmodell setzt eine Art RAM mit 64-Bit-Adressierung sowie einen Satz von 16 (oder sogar zehn) 64-Bit-Registern voraus. Ein primitiver rekursiver Algorithmus wird vorgeschlagen:


  • Wir beginnen mit dem Kontext, in dem die Operanden des Befehls in r1 und r2 , in den restlichen Nullen alle gültig (genauer gesagt, die Gültigkeit entspricht der „Bereitschaft“ des Coprozessorbefehls).
  • Wenn wir einen arithmetisch-logischen Befehl sehen, extrahieren wir seine Operandenregister aus dem Kontext, rufen uns für das Ende der Liste und den Kontext auf, in dem der Ausgabeoperand durch ein Paar ersetzt wird (data1 op data2, valid1 && valid2)
  • Wenn wir auf einen Zweig stoßen, bauen wir einfach beide Zweige rekursiv auf: Wenn der Zweig auftritt und wenn nicht
  • Wenn wir auf das Laden oder Speichern im Speicher stoßen, kommen wir irgendwie raus: Wir führen den übertragenen Rückruf aus, unter der Annahme, dass die valid Anweisung während der Ausführung dieser Anweisung nicht mehr abgerufen werden kann. Die Gültigkeit des Speichervorgangs ist UND von uns mit dem Flag globalValid , das gesetzt werden muss, bevor die Kontrolle zurückgegeben wird. Gleichzeitig müssen wir vorne lesen und schreiben, um Inkremente und andere Änderungen korrekt zu verarbeiten.

Somit werden die Operationen so parallel wie möglich und nicht schrittweise ausgeführt. Gleichzeitig bitte ich Sie, darauf zu achten, dass alle Operationen auf einem bestimmten Speicherbyte natürlich vollständig geordnet werden sollten, da sonst das Ergebnis unvorhersehbar ist, UB. Das heißt, *addr += 1 - dies ist normal, das Schreiben beginnt nicht genau, bis das Lesen abgeschlossen ist (kitschig, weil wir immer noch nicht wissen, was wir schreiben sollen), aber *addr += 1; return *addr; *addr += 1; return *addr; Ich gab im Allgemeinen sicher Null oder so etwas aus. Vielleicht lohnt es sich zu debuggen (vielleicht verbirgt es ein kniffligeres Problem), aber ein solcher Appell an sich ist auf jeden Fall eine mittelmäßige Idee, da Sie nachverfolgen müssen, welche Speicheradressen die Arbeit bereits erledigt hat, aber ich habe einen Wunsch Überprüfen Sie die Werte valid Möglichkeit statisch. Dies ist genau das, was für globale Variablen mit fester Größe getan wird.


Das Ergebnis ist eine abstrakte Klasse BpfCircuitConstructor , für die keine Methoden doMemLoad , doMemStore und resolveSymbol :


 trait BpfCircuitConstructor { // ... sealed abstract class LdStType(val lgsize: Int) { val byteSize = 1 << lgsize val bitSize = byteSize * 8 val mask: UInt = if (bitSize == 64) mask64 else ((1l << bitSize) - 1).U } case object u8 extends LdStType(0) case object u16 extends LdStType(1) case object u32 extends LdStType(2) case object u64 extends LdStType(3) def doMemLoad(addr: UInt, tpe: LdStType, valid: Bool): (UInt, Bool) def doMemStore(addr: UInt, tpe: LdStType, data: UInt, valid: Bool): Bool sealed trait Resolved { def asPlainValue: UInt def load(ctx: Context, offset: Int, tpe: LdStType, valid: Bool): LazyData def store(offset: Int, tpe: LdStType, data: UInt, valid: Bool): Bool } def resolveSymbol(sym: BpfLoader.Symbol): Resolved // ... } 

CPU-Kernintegration


Für den Anfang habe ich mich für den einfachen Weg entschieden: Stellen Sie mithilfe des Standard-RoCC-Protokolls (Rocket Custom Coprocessor) eine Verbindung zum Prozessorkern her. Soweit ich weiß, ist dies eine reguläre Erweiterung, nicht für alle RISC-V-kompatiblen Kernel, sondern nur für Rocket und BOOM (Berkeley Out-of-Order Machine). Daher wurden beim Ziehen der Upstream-Arbeit auf Compilern die Assembler- custom0 Mnemonics aus ihnen herausgeworfen. custom3 verantwortlich für Beschleunigerbefehle.


Im Allgemeinen können jedem Rocket / BOOM-Prozessorkern bis zu vier RoCC-Beschleuniger über die Konfiguration hinzugefügt werden. Es gibt auch Implementierungsbeispiele:


Configs.scala:


 class WithRoccExample extends Config((site, here, up) => { case BuildRoCC => List( (p: Parameters) => { val accumulator = LazyModule(new AccumulatorExample(OpcodeSet.custom0, n = 4)(p)) accumulator }, (p: Parameters) => { val translator = LazyModule(new TranslatorExample(OpcodeSet.custom1)(p)) translator }, (p: Parameters) => { val counter = LazyModule(new CharacterCountExample(OpcodeSet.custom2)(p)) counter }) }) 

Die entsprechende Implementierung befindet sich in der Datei LazyRoCC.scala .


Die Beschleunigerimplementierung stellt zwei Klassen dar, die bereits vom Speichercontroller bekannt sind: Eine davon wird in diesem Fall von LazyRoCC geerbt, die andere von LazyRoCCModuleImp . Die zweite Klasse verfügt über einen io Port vom Typ RoCCIO , der den cmd Anforderungsport, den Resp-Response-Port, den L1D-Cache-Port für den Mem-Zugriff, Besetzt- und interrupt Ausgänge sowie die exception . Es gibt auch einen Seitentabellen-Walker-Port und FPUs, die wir anscheinend noch nicht benötigen (ohnehin gibt es in eBPF keine echte Arithmetik). Bisher möchte ich versuchen, etwas mit diesem Ansatz zu tun, damit ich interrupt nicht berühre. Soweit ich weiß, gibt es auch eine TileLink-Schnittstelle für den nicht zwischengespeicherten Speicherzugriff, aber im Moment werde ich sie auch nicht berühren.


Organisator abfragen


Wir haben also einen Port für den Zugriff auf den Cache, aber nur einen. Gleichzeitig kann eine Funktion beispielsweise eine Variable inkrementieren (die zumindest in eine einzelne atomare Operation umgewandelt werden kann) oder sie sogar irgendwie trivial transformieren, indem sie geladen, aktualisiert und gespeichert wird. Am Ende kann eine einzelne Anweisung mehrere unabhängige Anforderungen stellen. Vielleicht ist dies nicht die beste Idee in Bezug auf die Leistung, aber warum nicht beispielsweise drei Wörter laden (die sich möglicherweise bereits im Cache befinden), sie irgendwie parallel zur kombinatorischen Logik verarbeiten (dann)? einen Schlag haben) und das Ergebnis speichern. Daher benötigen wir ein Schema, das Versuche des parallelen Zugriffs auf einen einzelnen Cache-Port effektiv „auflöst“.


Die Logik funct : Zu Beginn der Generierung der Implementierung einer bestimmten funct (ein 7-Bit- funct in Bezug auf RoCC) wird eine Instanz des Abfrageserialisierers erstellt (eine globale Instanz erscheint mir ziemlich schädlich, da sie eine Reihe zusätzlicher Abhängigkeiten zwischen Anforderungen erzeugt, die niemals gleichzeitig ausgeführt werden können, und verschwenden Fmax wird höchstwahrscheinlich). Als nächstes wird jeder erstellte "Saver" / "Loader" im Serializer registriert. Sozusagen in einer Live-Warteschlange. Bei jeder Maßnahme wird die erste Anfrage in der Registrierungsreihenfolge ausgewählt - bei der nächsten Maßnahme erhält er die Erlaubnis. Natürlich muss eine solche Logik gut mit Tests abgedeckt sein (ich habe wirklich noch nicht viele davon, daher ist dies nicht nur eine Überprüfung, sondern das Minimum, das erforderlich ist, um zumindest etwas Verständliches zu erhalten). Ich habe den Standard- PeekPokeTester einer mehr oder weniger offiziellen Komponente zum Testen von Meißelkonstruktionen verwendet. Ich habe es schon einmal beschrieben .


Das Ergebnis war eine solche Erfindung:


 class Serializer(isComputing: Bool, next: Bool) { def monotonic(x: Bool): Bool = { val res = WireInit(false.B) val prevRes = RegInit(false.B) prevRes := res && isComputing res := (x || prevRes) && isComputing res } private def noone(bs: Seq[Bool]): Bool = !bs.foldLeft(false.B)(_ || _) private val previousReqs = ArrayBuffer[Bool]() def nextReq(x: Bool): (Bool, Int) = { val enable = monotonic(x) val result = RegInit(false.B) val retired = RegInit(false.B) val doRetire = result && next val thisReq = enable && !retired && !doRetire val reqWon = thisReq && noone(previousReqs) when (isComputing) { when(reqWon) { result := true.B } when(doRetire) { result := false.B retired := true.B } } otherwise { result := false.B retired := false.B } previousReqs += thisReq (result, previousReqs.length - 1) } } 

Bitte beachten Sie, dass hier beim Erstellen einer digitalen Schaltung der Scala-Code sicher ausgeführt wird. Wenn Sie genauer hinschauen, können Sie sogar einen ArrayBuffer bemerken, in den die Teile der Schaltung gestapelt sind ( Boolean ist ein Typ von Scala, Bool ist ein Chisel-Typ, der Live-Geräte darstellt, und kein zur Laufzeit bekannter Boolean).


Arbeiten mit L1D Cache


Die Arbeit mit dem Cache erfolgt hauptsächlich über den Anforderungsport io.mem.req und den io.mem.resp . Gleichzeitig ist der Anforderungsport mit den herkömmlichen ready und valid Signalen ausgestattet: Der erste sagt Ihnen, dass er bereit ist, die Anforderung anzunehmen, der zweite besagt, dass die Anforderung bereit ist und bereits die richtige Struktur hat. Auf der Vorderseite gilt valid && resp Anforderung wird als angenommen betrachtet. In einigen solchen Schnittstellen besteht die Anforderung, dass Signale vom Zeitpunkt der Einstellung auf true und bis zur nachfolgenden positiven Flanke von valid && resp nicht reagieren (dieser Ausdruck kann der valid && resp mit der fire() -Methode konstruiert werden).


Der resp Response-Port hat wiederum nur ein valid Vorzeichen, und dies ist das Problem des Prozessors, Antworten in einem Taktzyklus zu rechen: Er ist unter der Annahme "immer bereit", und fire() gibt nur valid .


Wie ich bereits sagte, können Sie keine Anfragen stellen, wenn es schrecklich ist: Sie können nichts schreiben, ich weiß nicht was, und noch einmal lesen, was später auf der Grundlage des subtrahierten Werts überschrieben wird, ist auch irgendwie seltsam. Die Serializer Klasse versteht dies bereits, aber wir geben ihr nur ein Zeichen dafür, dass die aktuelle Anforderung bereits in den Cache next = io.mem.req.fire() : next = io.mem.req.fire() . Alles, was getan werden kann, ist sicherzustellen, dass im "Leser" die Antwort nur aktualisiert wird, wenn sie wirklich gekommen ist - nicht früher und nicht später. holdUnless gibt es eine bequeme holdUnless Methode. Das Ergebnis ist ungefähr die folgende Implementierung:


  class Constructor extends BpfCircuitConstructor { val serializer = new Serializer(isComputing, io.mem.req.fire()) override def doMemLoad(addr: UInt, tpe: LdStType, valid: Bool): (UInt, Bool) = { val (doReq, thisTag) = serializer.nextReq(valid) when (doReq) { io.mem.req.bits.addr := addr require((1 << io.mem.req.bits.tag.getWidth) > thisTag) io.mem.req.bits.tag := thisTag.U io.mem.req.bits.cmd := M_XRD io.mem.req.bits.typ := (4 | tpe.lgsize).U io.mem.req.bits.data := 0.U io.mem.req.valid := true.B } val doResp = isComputing && serializer.monotonic(doReq && io.mem.req.fire()) && io.mem.resp.valid && io.mem.resp.bits.tag === thisTag.U && io.mem.resp.bits.cmd === M_XRD (io.mem.resp.bits.data holdUnless doResp, serializer.monotonic(doResp)) } override def doMemStore(addr: UInt, tpe: LdStType, data: UInt, valid: Bool): Bool = { val (doReq, thisTag) = serializer.nextReq(valid) when (doReq) { io.mem.req.bits.addr := addr require((1 << io.mem.req.bits.tag.getWidth) > thisTag) io.mem.req.bits.tag := thisTag.U io.mem.req.bits.cmd := M_XWR io.mem.req.bits.typ := (4 | tpe.lgsize).U io.mem.req.bits.data := data io.mem.req.valid := true.B } serializer.monotonic(doReq && io.mem.req.fire()) } override def resolveSymbol(sym: BpfLoader.Symbol): Resolved = sym match { case BpfLoader.Symbol(symName, _, size, ElfConstants.Elf64_Shdr.SHN_COMMON, false) if size <= 8 => RegisterReference(regs.getOrElseUpdate(symName, RegInit(0.U(64.W)))) } } 

Für jede generierte Unteranweisung wird eine Instanz dieser Klasse erstellt.


Nicht alle auf dem Heap sind globale Variablen


Hmm, was ist ein Modellbeispiel? Welche Leistung möchte ich sicherstellen? Natürlich AFL Instrumentation! In der klassischen Version sieht es so aus:


 #include <stdint.h> extern uint8_t *__afl_area_ptr; extern uint64_t prev; void inst_branch(uint64_t tag) { __afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1; prev = tag; } 

Wie Sie sehen können, wird mehr oder weniger logisch ein Byte von __afl_area_ptr und __afl_area_ptr (und dazwischen ein Inkrement), aber hier fragt das Register nach der prev Rolle!


Aus diesem Grund wird die Resolved Schnittstelle benötigt: Sie kann entweder eine reguläre Speicheradresse umschließen oder eine Registerreferenz sein. Gleichzeitig betrachte ich bisher nur skalare Register mit einer Größe von 1, 2, 4 oder 8 Bytes, die immer mit einem Offset von Null gelesen werden. Für Register können Sie die Reihenfolge der Aufrufe daher relativ ruhig implementieren. In diesem Fall ist es sehr nützlich zu wissen, dass prev zuerst subtrahiert und zur Berechnung des Index verwendet und erst dann neu geschrieben werden muss.


Und jetzt die Instrumentierung


Irgendwann haben wir einen separaten und mehr oder weniger funktionierenden Beschleuniger mit der RoCC-Schnittstelle. Was jetzt? Trotzdem neu implementieren und die Prozessor-Pipeline durchlaufen? Es schien mir, dass weniger Krücken benötigt würden, wenn parallel zur angewiesenen Anweisung der Coprozessor mit der automatisch ausgegebenen Nutzwertfunktion einfach aktiviert würde. Grundsätzlich musste ich mich auch dafür quälen: Ich habe sogar gelernt, SignalTap zu benutzen, weil das Debuggen fast blind ist und selbst bei einer fünfminütigen Neukompilierung nach der geringsten Änderung (mit Ausnahme der Änderung des Bootroms - dort ist alles schnell) - das ist schon zu viel.


Infolgedessen wurde der Befehlsdecoder optimiert und die Pipeline leicht "aufgerichtet", um der Tatsache Rechnung zu tragen, dass unabhängig davon, was der Decoder über den ursprünglichen Befehl sagte, das plötzlich aktivierte RoCC selbst nicht bedeutet, dass wie während des Teilungsvorgangs eine lange Latenz in das Ausgangsregister geschrieben wird und den Datencache verpassen.


Im Allgemeinen ist eine Beschreibung eines Befehls ein Paar ([Muster zum Erkennen eines Befehls], [Wertesatz zum Konfigurieren von Datenpfadblöcken des Prozessorkerns]). Zum Beispiel sieht die default (nicht erkannte Anweisung) folgendermaßen aus (entnommen aus IDecode.scala , im Desktop-Habr sieht sie ehrlich gesagt hässlich aus):


 def default: List[BitPat] = // jal renf1 fence.i // val | jalr | renf2 | // | fp_val| | renx2 | | renf3 | // | | rocc| | | renx1 s_alu1 mem_val | | | wfd | // | | | br| | | | s_alu2 | imm dw alu | mem_cmd mem_type| | | | mul | // | | | | | | | | | | | | | | | | | | | | | div | fence // | | | | | | | | | | | | | | | | | | | | | | wxd | | amo // | | | | | | | | scie | | | | | | | | | | | | | | | | | dp List(N,X,X,X,X,X,X,X,X,A2_X, A1_X, IMM_X, DW_X, FN_X, N,M_X, MT_X, X,X,X,X,X,X,X,CSR.X,X,X,X,X) 

... und eine typische Beschreibung einer der Erweiterungen im Rocket-Kern wird folgendermaßen implementiert:


 class IDecode(implicit val p: Parameters) extends DecodeConstants { val table: Array[(BitPat, List[BitPat])] = Array( BNE-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SNE, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), BEQ-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SEQ, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), BLT-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SLT, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), BLTU-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SLTU, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), BGE-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SGE, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), BGEU-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SGEU, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N), // ... 

Tatsache ist, dass in RISC-V (nicht nur in RocketChip, sondern in der Architektur von Befehlen im Prinzip) die ISA-Aufteilung in die obligatorische Teilmenge I (ganzzahlige Operationen) sowie optional M (ganzzahlige Multiplikation und Division), A (Atomics) regelmäßig unterstützt wird usw.


Als Ergebnis die ursprüngliche Methode


  def decode(inst: UInt, table: Iterable[(BitPat, List[BitPat])]) = { val decoder = DecodeLogic(inst, default, table) val sigs = Seq(legal, fp, rocc, branch, jal, jalr, rxs2, rxs1, scie, sel_alu2, sel_alu1, sel_imm, alu_dw, alu_fn, mem, mem_cmd, mem_type, rfs1, rfs2, rfs3, wfd, mul, div, wxd, csr, fence_i, fence, amo, dp) sigs zip decoder map {case(s,d) => s := d} this } 

wurde ersetzt durch


das gleiche, aber mit einem Decoder zur Instrumentierung und Klärung des Grundes für die Aktivierung von rocc
 def decode(inst: UInt, table: Iterable[(BitPat, List[BitPat])], handlers: Seq[OpcodeHandler]) = { val decoder = DecodeLogic(inst, default, table) val sigs=Seq(legal, fp, rocc_explicit, branch, jal, jalr, rxs2, rxs1, scie, sel_alu2, sel_alu1, sel_imm, alu_dw, alu_fn, mem, mem_cmd, mem_type, rfs1, rfs2, rfs3, wfd, mul, div, wxd, csr, fence_i, fence, amo, dp) sigs zip decoder map {case(s,d) => s := d} if (handlers.isEmpty) { handler_rocc := false.B handler_rocc_funct := 0.U } else { val handlerTable: Seq[(BitPat, List[BitPat])] = handlers.map { case OpcodeHandler(pattern, funct) => pattern -> List(Y, BitPat(funct.U)) } val handlerDecoder = DecodeLogic(inst, List(N, BitPat(0.U)), handlerTable) Seq(handler_rocc, handler_rocc_funct) zip handlerDecoder map { case (s,d) => s:=d } } rocc := rocc_explicit || handler_rocc this } 

Von den Änderungen in der Prozessor-Pipeline war die vielleicht offensichtlichste folgende:


  io.rocc.exception := wb_xcpt && csr.io.status.xs.orR io.rocc.cmd.bits.status := csr.io.status io.rocc.cmd.bits.inst := new RoCCInstruction().fromBits(wb_reg_inst) + when (wb_ctrl.handler_rocc) { + io.rocc.cmd.bits.inst.opcode := 0x0b.U // custom0 + io.rocc.cmd.bits.inst.funct := wb_ctrl.handler_rocc_funct + io.rocc.cmd.bits.inst.xd := false.B + io.rocc.cmd.bits.inst.rd := 0.U + } io.rocc.cmd.bits.rs1 := wb_reg_wdata io.rocc.cmd.bits.rs2 := wb_reg_rs2 

Es ist klar, dass einige Parameter der Anforderung an den Beschleuniger korrigiert werden müssen: Es wird keine Antwort in das Register geschrieben, und die funct entspricht der vom Decoder zurückgegebenen. Es gibt jedoch eine etwas weniger offensichtliche Änderung: Tatsache ist, dass dieser Befehl nicht direkt an den Beschleuniger geht (vier davon - welcher?), Sondern an den Router. Sie müssen also so tun, als hätte der Befehl opcode == custom0 (yes, Prozess, und es ist genau der Nullbeschleuniger!).


Überprüfen Sie


Tatsächlich geht dieser Artikel von einer Fortsetzung aus, in der versucht wird, diesen Ansatz auf ein mehr oder weniger Produktionsniveau zu bringen. Sie müssen mindestens lernen, den Kontext (Status der Coprozessorregister) zu speichern und wiederherzustellen, wenn Sie Aufgaben wechseln. In der Zwischenzeit werde ich überprüfen, ob es unter Gewächshausbedingungen funktioniert:


 #include <stdint.h> uint64_t counter; uint64_t funct1(uint64_t x, uint64_t y) { return __builtin_popcountl(x); } uint64_t funct2(uint64_t x, uint64_t y) { return (x + y) * (x - y); } uint64_t instMUL() { counter += 1; *((uint64_t *)0x81005000) = counter; return 0; } 

bootrom/sdboot/sd.c in der main hinzu


 #include "/path/to/freedom-u-sdk/riscv-pk/machine/encoding.h" // ... ////    -   RoCC #define STR1(x) #x #define STR(x) STR1(x) #define EXTRACT(a, size, offset) (((~(~0 << size) << offset) & a) >> offset) #define CUSTOMX_OPCODE(x) CUSTOM_##x #define CUSTOM_0 0b0001011 #define CUSTOM_1 0b0101011 #define CUSTOM_2 0b1011011 #define CUSTOM_3 0b1111011 #define CUSTOMX(X, rd, rs1, rs2, funct) \ CUSTOMX_OPCODE(X) | \ (rd << (7)) | \ (0x7 << (7+5)) | \ (rs1 << (7+5+3)) | \ (rs2 << (7+5+3+5)) | \ (EXTRACT(funct, 7, 0) << (7+5+3+5+5)) #define CUSTOMX_R_R_R(X, rd, rs1, rs2, funct) \ asm ("mv a4, %[_rs1]\n\t" \ "mv a5, %[_rs2]\n\t" \ ".word "STR(CUSTOMX(X, 15, 14, 15, funct))"\n\t" \ "mv %[_rd], a5" \ : [_rd] "=r" (rd) \ : [_rs1] "r" (rs1), [_rs2] "r" (rs2) \ : "a4", "a5"); int main(void) { // ... //  RoCC extension write_csr(mstatus, MSTATUS_XS & (MSTATUS_XS >> 1)); //   bootrom       uint64_t res; CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1); CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 2); // ...     uint64_t x = 1; for (int i = 0; i < 123; ++i) x *= *(volatile uint8_t *)0x80000000; kputc('0' + x % 10); //   !!! // ... } 

write_csr , custom0 - custom3 . , illegal instruction, , , , . define - - , «» binutils customX RocketChip, , , .


sdboot , , .


:


 $ /hdd/trosinenko/rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set directories bootrom" builds/zeowaa-e115/sdboot.elf Reading symbols from builds/zeowaa-e115/sdboot.elf...done. Remote debugging using :3333 0x0000000000000000 in ?? () (gdb) x/d 0x81005000 0x81005000: 123 (gdb) set variable $pc=0x10000 (gdb) c Continuing. ^C Program received signal SIGINT, Interrupt. 0x0000000000010488 in crc16_round (data=<optimized out>, crc=<optimized out>) at sd.c:151 151 crc ^= data; (gdb) x/d 0x81005000 0x81005000: 246 

funct1
 $ /hdd/trosinenko/rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set directories bootrom" builds/zeowaa-e115/sdboot.elf Reading symbols from builds/zeowaa-e115/sdboot.elf...done. Remote debugging using :3333 0x0000000000010194 in main () at sd.c:247 247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1); (gdb) set variable $a5=0 (gdb) set variable $pc=0x10194 (gdb) set variable $a4=0xaa (gdb) display/10i $pc-10 1: x/10i $pc-10 0x1018a <main+46>: sw a3,124(a3) 0x1018c <main+48>: addiw a0,a0,1110 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 => 0x10194 <main+56>: 0x2f7778b 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> (gdb) display/x $a5 2: /x $a5 = 0x0 (gdb) si 0x0000000000010198 247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1); 1: x/10i $pc-10 0x1018e <main+50>: li a0,25 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 0x10194 <main+56>: 0x2f7778b => 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> 0x101a6 <main+74>: li a5,10 2: /x $a5 = 0x4 (gdb) set variable $a4=0xaabc (gdb) set variable $pc=0x10194 (gdb) si 0x0000000000010198 247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1); 1: x/10i $pc-10 0x1018e <main+50>: li a0,25 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 0x10194 <main+56>: 0x2f7778b => 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> 0x101a6 <main+74>: li a5,10 2: /x $a5 = 0x9 

Quellcode

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


All Articles