Dans les quatre parties précédentes, des préparatifs ont été faits pour des expériences avec le noyau RISC-V RocketChip, à savoir le portage de ce noyau sur une carte «non standard» pour celui-ci avec des FPGA Altera (maintenant Intel). Enfin, dans la dernière partie , il s'est avéré exécuter Linux sur cette carte. Savez-vous ce qui m'a amusé à propos de tout cela? C'est en même temps que je devais travailler avec l'assembleur RISC-V, C et Scala, et de tous, Scala était le langage de niveau le plus bas (parce que le processeur est écrit dessus).
Faisons en sorte que C ne soit pas offensant dans cet article non plus. De plus, si le bundle Scala + Chisel a été utilisé uniquement en tant que langage spécifique au domaine pour une description explicite du matériel, nous allons aujourd'hui apprendre à "tirer" des fonctions C simples dans le processeur sous la forme d'instructions.
Le but ultime est la mise en œuvre triviale d'instruments triviaux de type AFL par analogie avec QInst , et la mise en œuvre d'instructions distinctes n'est qu'un sous-produit.
Il est clair qu'il existe (et pas un) convertisseur OpenCL vers RTL commercial. J'ai également rencontré des informations sur un certain projet COPILOT pour RISC-V avec des objectifs similaires (beaucoup plus avancés), mais quelque chose va mal sur Google, et en plus, il s'agit très probablement d'un produit commercial. Je suis principalement intéressé par les solutions OpenSource, mais même si elles le sont, c'est toujours amusant d'essayer de l'implémenter vous-même - au moins comme un exemple de formation simplifié, puis comment ça se passe ...
Clause de non-responsabilité (en plus de l'avertissement habituel de «danser avec un extincteur»): je déconseille fortement d'appliquer le noyau logiciel résultant, en particulier avec des données non fiables - jusqu'à présent, je n'ai pas tellement confiance que de comprendre pourquoi les données traitées ne peuvent pas «Flow» dans certains cas limites entre les processus et / ou le cœur. Eh bien, à propos du fait que les données peuvent "battre", je pense, et c'est donc clair. En général, il y a encore validation et validation ...
Pour commencer, comment appeler une "fonction simple"? Aux fins du présent article, cela signifie une fonction dans laquelle toutes les transitions (conditionnelles et inconditionnelles) n'augmentent le compteur d'instructions que d'une valeur constante. Autrement dit, le graphique de toutes les transitions possibles est (dirigé) acyclique, sans arêtes "dynamiques". Le but ultime dans le cadre de cet article est de pouvoir prendre une fonction simple du programme et, en la remplaçant par un plug-in d'assembleur, de la «coudre» dans le processeur au stade de la synthèse, ce qui en fait éventuellement un effet secondaire d'une autre instruction. Plus précisément, la ramification ne sera pas présentée dans cet article, mais dans le cas le plus simple, il ne sera pas difficile de les créer.
Apprendre à comprendre C (en fait, non)
Vous devez d'abord comprendre comment nous analyserons C? C'est vrai, pas du tout - ce n'est pas en vain que j'ai appris à analyser les fichiers ELF : il vous suffit de compiler notre code en C / Rust / quelque chose d'autre dans un bytecode eBPF, et de l'analyser déjà. Certaines difficultés sont dues au fait que dans Scala, vous ne pouvez pas simplement connecter elf.h
et lire les champs de structure. Vous pouvez, bien sûr, essayer d'utiliser JNAerator - si nécessaire, ils peuvent créer des liens vers la bibliothèque - non seulement des structures, mais aussi générer du code pour travailler via JNA (à ne pas confondre avec JNI). En tant que vrai programmeur, j'écrirai mon vélo et j'écrirai soigneusement les constantes d'énumération et de décalage à partir du fichier d'en-tête. Le résultat et les structures intermédiaires sont décrits par la structure suivante des classes de cas:
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] )
Je ne décrirai pas particulièrement le processus d'analyse - il s'agit juste d'un transfert d'octets ennuyeux de java.nio.ByteBuffer
- toutes les choses intéressantes ont déjà été décrites dans l' article sur l'analyse des fichiers ELF . Je peux seulement dire que vous devez gérer soigneusement l' opcode == 0x18
(chargement de valeurs immédiates 64 bits dans le registre), car il prend deux mots de 8 octets à la fois (peut-être qu'il existe d'autres opcodes de ce type, mais je ne les ai pas encore rencontrés) , et cela ne charge pas toujours l'adresse mémoire associée à la relocalisation, comme je le pensais initialement. Par exemple, __builtin_popcountl
utilise honnêtement la constante 64 bits 0x0101010101010101
. Pourquoi ne fais-je pas une relocalisation «honnête» en corrigeant le fichier téléchargé - parce que je veux voir les caractères sous forme symbolique (désolé pour le jeu de mots), afin que plus tard les caractères de la section COMMON
puissent être remplacés par des registres sans utiliser de béquilles avec un traitement spécial des adresses d'un type spécial (et cela signifie, même avec des danses avec UInt
constant / non constant).
Nous construisons du matériel selon un ensemble d'instructions
Ainsi, par hypothèse, tous les chemins d'exécution possibles descendent exclusivement dans la liste d'instructions, ce qui signifie que les données circulent le long d'un graphe acyclique orienté, et que tous ses bords sont définis statiquement. Dans le même temps, nous avons une logique purement combinatoire (c'est-à-dire sans registres en cours), obtenue à partir d'opérations sur les registres, ainsi que des retards lors des opérations de chargement / stockage avec de la mémoire. Ainsi, dans le cas général, l'opération peut ne pas être possible de se terminer en un cycle d'horloge. Nous ferons simple: nous transférerons la valeur à sous la forme de UInt
, mais comme (UInt, Bool)
: le premier élément de la paire est la valeur, et le second est un signe de son exactitude. Autrement dit, il n'est pas très logique de lire de la mémoire, tant que l'adresse est incorrecte et que l'écriture en général est impossible.
Le modèle d'exécution de bytecode eBPF suppose une sorte de RAM avec un adressage 64 bits, ainsi qu'un ensemble de 16 (voire dix) registres 64 bits. Un algorithme récursif primitif est proposé:
- nous commençons par le contexte dans lequel les opérandes de l'instruction se trouvent dans
r1
et r2
, dans le reste - zéros, tous valides (plus précisément, la validité est égale à la "préparation" de l'instruction coprocesseur) - si nous voyons une instruction arithmétique et logique, nous extrayons ses registres d'opérandes du contexte, nous appelons pour la fin de la liste et le contexte dans lequel l'opérande de sortie est remplacé par une paire
(data1 op data2, valid1 && valid2)
- si nous rencontrons une branche, nous construisons simplement les deux branches récursivement: si la branche se produit, et sinon
- si nous rencontrons un chargement ou une sauvegarde en mémoire, nous en sortons: nous exécutons le rappel transféré, en supposant l'invariant qu'une fois l'instruction
valid
ne peut pas être rappelée lors de l'exécution de cette instruction. La validité de l'opération de sauvegarde est ET par nous avec l'indicateur globalValid
, qui doit être défini avant de retourner le contrôle. En même temps, nous devons faire de la lecture et de l'écriture sur le devant afin de traiter correctement les incréments et autres modifications.
Ainsi, les opérations seront effectuées le plus parallèlement possible, et non pas par étapes. Dans le même temps, je vous demande de faire attention à ce que toutes les opérations sur un octet de mémoire spécifique soient naturellement complètement ordonnées, sinon le résultat est imprévisible, UB. C'est-à-dire *addr += 1
- c'est normal, l'écriture ne commencera pas exactement jusqu'à la fin de la lecture (ringard parce que nous ne savons toujours pas quoi écrire), mais *addr += 1; return *addr;
*addr += 1; return *addr;
J'ai généralement donné zéro ou quelque chose comme ça en toute sécurité. Peut-être que cela vaudrait la peine d'être débogué (peut-être que cela cache un problème plus délicat), mais un tel appel en soi est en tout cas une idée médiocre, car vous devez garder une trace des adresses de mémoire où le travail a déjà été fait, mais j'ai un désir Validez les valeurs valid
possible statiquement. C'est exactement ce qui sera fait pour les variables globales de taille fixe.
Le résultat est une classe abstraite BpfCircuitConstructor
, qui n'a aucune méthode implémentée doMemLoad
, doMemStore
et resolveSymbol
:
trait BpfCircuitConstructor {
Intégration au cœur du processeur
Pour commencer, j'ai décidé de suivre la voie la plus simple: se connecter au cœur du processeur en utilisant le protocole standard RoCC (Rocket Custom Coprocessor). D'après ce que je comprends, il s'agit d'une extension régulière non pas pour tous les noyaux compatibles RISC-V, mais uniquement pour Rocket et BOOM (Berkeley Out-of-Order Machine), par conséquent, lorsque vous custom0
glisser le travail en amont sur les compilateurs, l'assembleur custom0
mnémoniques a été jeté hors d'eux - custom3
responsable des commandes d'accélérateur.
En général, chaque noyau de processeur Rocket / BOOM peut avoir jusqu'à quatre accélérateurs RoCC ajoutés via la configuration, il existe également des exemples d'implémentation:
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 }) })
L'implémentation correspondante se trouve dans le fichier LazyRoCC.scala
.
L'implémentation de l'accélérateur représente deux classes déjà familières du contrôleur de mémoire: l'une dans ce cas est héritée de LazyRoCC
, l'autre de LazyRoCCModuleImp
. La deuxième classe a un port io
de type RoCCIO
, qui contient le port de demande cmd
, le port de réponse resp
, le port de cache d'accès L1D mem, busy
sorties busy
et d' interrupt
et l'entrée d' exception
. Il y a aussi un port de marcheur de table de pages et des FPU dont nous ne semblons pas encore avoir besoin (de toute façon, il n'y a pas de véritable arithmétique dans eBPF). Jusqu'à présent, je veux essayer de faire quelque chose avec cette approche, donc je ne toucherai pas à l' interrupt
. Aussi, si je comprends bien, il existe une interface TileLink pour l'accès à la mémoire non mise en cache, mais pour l'instant je n'y toucherai pas non plus.
Organisateur de requête
Nous avons donc un port pour accéder au cache, mais un seul. Dans le même temps, une fonction peut, par exemple, incrémenter une variable (qui, à tout le moins, peut être transformée en une seule opération atomique) ou même la transformer d'une manière ou d'une autre d'une manière non triviale en la chargeant, la mettant à jour et l'enregistrant. Au final, une seule instruction peut faire plusieurs requêtes indépendantes. Ce n'est peut-être pas la meilleure idée en termes de performances, mais, d'autre part, pourquoi ne pas, disons, charger trois mots (qui, très probablement, sont déjà dans le cache), les traiter en quelque sorte en parallèle avec la logique combinatoire (alors un battement) et enregistrez le résultat. Par conséquent, nous avons besoin d'une sorte de schéma qui «résout» efficacement les tentatives d'accès parallèle à un seul port de cache.
La logique sera quelque chose comme ceci: au début de la génération de la mise en œuvre d'une sous-injection spécifique (un champ de funct
7 bits en termes de RoCC), une instance du sérialiseur de requêtes est créée (ce qui rend un global me semble assez dangereux, car cela crée un tas de dépendances supplémentaires entre les requêtes qui ne peuvent jamais être exécutées simultanément, et gaspilleur Fmax sera très probablement). Ensuite, chaque «économiseur» / «chargeur» créé est enregistré dans le sérialiseur. Dans une file d'attente en direct, pour ainsi dire. À chaque mesure, la première demande dans l'ordre d'enregistrement est sélectionnée - il est autorisé à la mesure suivante . Naturellement, une telle logique doit être bien couverte de tests (je n'en ai vraiment pas encore beaucoup, donc ce n'est pas seulement une vérification, mais l'ensemble minimum nécessaire pour obtenir au moins quelque chose d'intelligible). J'ai utilisé le PeekPokeTester
standard à partir d'un composant plus ou moins officiel pour tester les conceptions de ciseaux. Je l'ai déjà décrit une fois .
Le résultat était un tel engin:
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) } }
Veuillez noter qu'ici dans le processus de création d'un circuit numérique, le code Scala est exécuté en toute sécurité. Si vous y regardez de plus près, vous pouvez même remarquer un ArrayBuffer
dans lequel les pièces du circuit sont empilées ( Boolean
est un type de Scala, Bool
est un type Chisel représentant un équipement en direct, et pas un booléen connu au moment de l'exécution).
Travailler avec le cache L1D
Le travail avec le cache s'effectue principalement via le io.mem.req
demande io.mem.req
et le io.mem.resp
réponse io.mem.resp
. Dans le même temps, le port de demande est équipé des signaux traditionnels ready
et valid
: le premier vous indique qu'il est prêt à accepter la demande, le second nous disons que la demande est prête et a déjà la structure correcte, le long du front, valid && resp
demande est considérée comme acceptée. Dans certaines de ces interfaces, il existe une exigence de «non-réponse» des signaux à partir du moment de la définition de true
et jusqu'au bord positif suivant de valid && resp
(cette expression peut être construite en utilisant la méthode fire()
pour plus de commodité).
Le port de réponse resp
, à son tour, n'a qu'un signe valid
, et c'est le problème du processeur pour ratisser les réponses en un cycle d'horloge: il est "toujours prêt" par hypothèse, et fire()
retourne juste valid
.
De plus, comme je l'ai déjà dit, vous ne pouvez pas faire de demande quand c'est horrible: vous ne pouvez pas écrire quelque chose, je ne sais pas quoi, et relire ce qui sera écrasé plus tard sur la base de la valeur soustraite est également quelque peu étrange. Mais la classe Serializer
comprend déjà cela, mais nous lui donnons seulement un signe que la requête actuelle a déjà été next = io.mem.req.fire()
dans le cache: next = io.mem.req.fire()
. Tout ce qui peut être fait est de s'assurer que dans le «lecteur», la réponse n'est mise à jour que lorsqu'elle est vraiment venue - pas plus tôt et plus tard. Il existe une méthode holdUnless
pratique pour holdUnless
. Le résultat est approximativement l'implémentation suivante:
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)))) } }
Une instance de cette classe est créée pour chaque sous-instruction générée.
Tout sur le tas n'est pas une variable globale
Hmm, qu'est-ce qu'un exemple de modèle? Quelles performances voudrais-je garantir? Bien sûr, l'instrumentation AFL! Il ressemble à la version classique comme ceci:
#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; }
Comme vous pouvez le voir, il a un chargement et une sauvegarde plus ou moins logique (et entre eux un incrément) d'un octet de __afl_area_ptr
, mais ici le registre demande le rôle prev
!
C'est pourquoi l'interface Resolved
est nécessaire: elle peut soit encapsuler une adresse mémoire régulière, soit être une référence de registre. Dans le même temps, jusqu'à présent, je ne considère que les registres scalaires de 1, 2, 4 ou 8 octets, qui sont toujours lus avec un décalage nul, donc pour les registres, vous pouvez implémenter relativement calmement l'ordre des appels. Dans ce cas, il est très utile de savoir que prev
doit d'abord être soustrait et utilisé pour calculer l'indice, puis seulement réécrit.
Et maintenant l'instrumentation
À un moment donné, nous avons obtenu un accélérateur séparé et plus ou moins fonctionnel avec l'interface RoCC. Et maintenant? Réimplémenter tout de même, en poussant à travers le pipeline du processeur? Il me semblait que moins de béquilles seraient nécessaires si, en parallèle avec l'instruction indiquée, le coprocesseur avec la funct
valeur d'utilité émise automatiquement était simplement activé. En principe, j'ai également dû me tourmenter pour cela: j'ai même appris à utiliser SignalTap, car le débogage est presque aveugle, et même avec une recompilation de cinq minutes après le moindre changement (à l'exception du changement de bootrom - tout est rapide là-bas) - c'est déjà trop.
En conséquence, le décodeur de commande a été modifié et le pipeline a été légèrement «redressé» pour tenir compte du fait que, peu importe ce que le décodeur a dit à propos de l'instruction d'origine, le RoCC soudainement activé ne signifie pas qu'il y aura une longue écriture de latence dans le registre de sortie, comme pendant l'opération de division et manquer le cache de données.
En général, une description d'une instruction est une paire ([modèle de reconnaissance d'une instruction], [ensemble de valeurs configurant des blocs de chemin de données du cœur du processeur]). Par exemple, default
(instruction non reconnue) ressemble à ceci (pris tel quel à partir de IDecode.scala
, dans le bureau Habr, il semble, franchement, moche):
def default: List[BitPat] =
... et une description typique de l' une des extensions de Rocket core est implémentée comme ceci:
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),
Le fait est que dans RISC-V (non seulement dans RocketChip, mais dans l'architecture de commande en principe), le fractionnement ISA est régulièrement pris en charge sur le sous-ensemble obligatoire I (opérations entières), ainsi que facultatif M (multiplication et division entières), A (atomique) etc.
En conséquence, la méthode d'origine
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 }
a été remplacé par
le même, mais avec un décodeur pour l'instrumentation et la clarification de la raison de l'activation de 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 }
Parmi les changements dans le pipeline du processeur, le plus évident, peut-être, était le suivant:
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
Il est clair que certains paramètres de la demande à l'accélérateur doivent être corrigés: aucune réponse n'est écrite dans le registre, et funct
est égal à ce que le décodeur a renvoyé. Mais il y a un changement un peu moins évident: le fait est que cette commande ne va pas directement à l'accélérateur (quatre d'entre eux - lequel?), Mais au routeur, vous devez donc prétendre que la commande a opcode == custom0
(oui, processus, et c'est précisément l'accélérateur zéro!).
Vérifier
En fait, cet article suppose une suite dans laquelle une tentative sera faite pour amener cette approche à un niveau de production plus ou moins. Au minimum, vous devez apprendre à enregistrer et à restaurer le contexte (état des registres du coprocesseur) lors du changement de tâche. En attendant, je vais vérifier que cela fonctionne d'une manière ou d'une autre en serre:
#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; }
Maintenant, ajoutez au bootrom/sdboot/sd.c
dans la ligne main
#include "/path/to/freedom-u-sdk/riscv-pk/machine/encoding.h"
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
Code source