Dans la partie précédente, nous avons assemblé un microcontrôleur sans RAM basé sur le FPGA Altera / Intel. Cependant, la carte a un connecteur avec SO-DIMM DDR2 1Gb installé, que je veux évidemment utiliser. Pour ce faire, nous devons envelopper le contrôleur DDR2 avec l'interface ALTMEMPHY
dans un module qui est compréhensible pour le protocole de mémoire TileLink utilisé dans RocketChip. Sous la coupe - débogage tactile, programmation en force brute et RAKE.
Comme vous le savez, l'informatique a deux problèmes principaux: l'invalidation du cache et la dénomination des variables. Chez KDPV, vous voyez un moment rare - les deux principaux problèmes rencontrés par CS et complotent quelque chose .
AVERTISSEMENT: En plus de l'avertissement de l'article précédent, je vous recommande fortement de lire l'article à la fin avant de répéter les expériences, afin d'éviter d'endommager le FPGA, le module de mémoire ou les circuits d'alimentation.
Cette fois, je voulais, sinon démarrer Linux, puis au moins connecter la RAM, qui sur ma carte a déjà un gigaoctet entier (ou vous pouvez en mettre jusqu'à quatre). Un critère de succès est proposé pour considérer la capacité de lire et d'écrire à travers un tas de GDB + OpenOCD, y compris les adresses qui ne sont pas alignées sur 16 octets (la largeur d'une requête en mémoire). À première vue, il vous suffit de corriger un peu la configuration, le générateur SoC ne peut pas prendre en charge la RAM hors de la boîte. Il le prend en charge, mais via l'interface MIG (enfin, et, éventuellement, une autre interface de Microsemi). Grâce à l'interface standard, AXI4 le prend également en charge, mais, si je comprends bien, il n'est pas si facile de l'obtenir (du moins, ne pas maîtriser Platform Designer).
Digression lyrique: Autant que je sache, il existe une série d'interfaces AXI «intra-puce» assez populaires développées par ARM. Ici, on pourrait penser que tout est breveté et fermé. Mais après m'être inscrit (sans «programme universitaire» et rien d'autre - juste par e-mail et avoir rempli le questionnaire) et avoir eu accès au cahier des charges, j'ai été agréablement surpris. Bien sûr, je ne suis pas avocat, mais il semble que la norme soit assez ouverte: soit vous devez utiliser des noyaux sous licence d'ARM, soit vous ne prétendez pas du tout être compatible avec ARM, puis tout semble OK . Mais en général, bien sûr, lisez la licence, lisez avec les avocats, etc.
Monkey et TileLink (fable)
La tâche semblait assez simple, et j'ai ouvert la description de la ddr2_64bit
module ddr2_64bit
déjà disponible dans le projet auprès du fournisseur:
Propriété Intel et généralement module ddr2_64bit ( local_address, local_write_req, local_read_req, local_burstbegin, local_wdata, local_be, local_size, global_reset_n, pll_ref_clk, soft_reset_n, local_ready, local_rdata, local_rdata_valid, local_refresh_ack, local_init_done, reset_phy_clk_n, mem_odt, mem_cs_n, mem_cke, mem_addr, mem_ba, mem_ras_n, mem_cas_n, mem_we_n, mem_dm, phy_clk, aux_full_rate_clk, aux_half_rate_clk, reset_request_n, mem_clk, mem_clk_n, mem_dq, mem_dqs); input [25:0] local_address; input local_write_req; input local_read_req; input local_burstbegin; input [127:0] local_wdata; input [15:0] local_be; input [2:0] local_size; input global_reset_n; input pll_ref_clk; input soft_reset_n; output local_ready; output [127:0] local_rdata; output local_rdata_valid; output local_refresh_ack; output local_init_done; output reset_phy_clk_n; output [1:0] mem_odt; output [1:0] mem_cs_n; output [1:0] mem_cke; output [13:0] mem_addr; output [1:0] mem_ba; output mem_ras_n; output mem_cas_n; output mem_we_n; output [7:0] mem_dm; output phy_clk; output aux_full_rate_clk; output aux_half_rate_clk; output reset_request_n; inout [1:0] mem_clk; inout [1:0] mem_clk_n; inout [63:0] mem_dq; inout [7:0] mem_dqs; ...
La sagesse populaire dit: "Toute documentation en russe doit commencer par les mots:" Donc, cela ne fonctionne pas. "" Mais l' interface ici n'est pas entièrement intuitive , nous la lisons donc . Dans la description, on nous dit immédiatement que travailler avec DDR2 n'est pas une tâche facile. Vous devez configurer PLL, effectuer un étalonnage, crack-fex-pex , le signal local_init_done
est local_init_done
, vous pouvez travailler. En général, la logique de dénomination ici est approximativement la suivante: les noms avec les préfixes local_
sont l'interface "utilisateur", les ports mem_
être directement sortis vers les jambes connectées au module de mémoire, pll_ref_clk
doit recevoir un signal d'horloge avec la fréquence spécifiée lors de la configuration du module - le reste sera reçu de lui les fréquences, enfin, toutes sortes d'entrées et de sorties réinitialisées et les sorties de fréquence, avec lesquelles l'interface utilisateur devrait fonctionner de manière synchrone.
Créons une description des signaux externes à la mémoire et à l'interface du module ddr2_64bit
:
memif trait trait MemIf { val local_init_done = Output(Bool()) val global_reset_n = Input(Bool()) val pll_ref_clk = Input(Clock()) val soft_reset_n = Input(Bool()) val reset_phy_clk_n = Output(Clock()) val mem_odt = Output(UInt(2.W)) val mem_cs_n = Output(UInt(2.W)) val mem_cke = Output(UInt(2.W)) val mem_addr = Output(UInt(14.W)) val mem_ba = Output(UInt(2.W)) val mem_ras_n = Output(UInt(1.W)) val mem_cas_n = Output(UInt(1.W)) val mem_we_n = Output(UInt(1.W)) val mem_dm = Output(UInt(8.W)) val phy_clk = Output(Clock()) val aux_full_rate_clk = Output(Clock()) val aux_half_rate_clk = Output(Clock()) val reset_request_n = Output(Bool()) val mem_clk = Analog(2.W) val mem_clk_n = Analog(2.W) val mem_dq = Analog(64.W) val mem_dqs = Analog(8.W) def connectFrom(mem_if: MemIf): Unit = { local_init_done := mem_if.local_init_done mem_if.global_reset_n := global_reset_n mem_if.pll_ref_clk := pll_ref_clk mem_if.soft_reset_n := soft_reset_n reset_phy_clk_n := mem_if.reset_phy_clk_n mem_odt <> mem_if.mem_odt mem_cs_n <> mem_if.mem_cs_n mem_cke <> mem_if.mem_cke mem_addr <> mem_if.mem_addr mem_ba <> mem_if.mem_ba mem_ras_n <> mem_if.mem_ras_n mem_cas_n <> mem_if.mem_cas_n mem_we_n <> mem_if.mem_we_n mem_dm <> mem_if.mem_dm mem_clk <> mem_if.mem_clk mem_clk_n <> mem_if.mem_clk_n mem_dq <> mem_if.mem_dq mem_dqs <> mem_if.mem_dqs phy_clk := mem_if.phy_clk aux_full_rate_clk := mem_if.aux_full_rate_clk aux_half_rate_clk := mem_if.aux_half_rate_clk reset_request_n := mem_if.reset_request_n } } class MemIfBundle extends Bundle with MemIf
classe dd2_64bit class ddr2_64bit extends BlackBox { override val io = IO(new MemIfBundle { val local_address = Input(UInt(26.W)) val local_write_req = Input(Bool()) val local_read_req = Input(Bool()) val local_burstbegin = Input(Bool()) val local_wdata = Input(UInt(128.W)) val local_be = Input(UInt(16.W)) val local_size = Input(UInt(3.W)) val local_ready = Output(Bool()) val local_rdata = Output(UInt(128.W)) val local_rdata_valid = Output(Bool()) val local_refresh_ack = Output(Bool()) }) }
Ici, le premier tas de râteaux m'attendait: premièrement, ROMGenerator
la classe ROMGenerator
, je pensais que le contrôleur de mémoire pouvait être retiré des profondeurs de la conception via une variable globale, et Chisel ferait avancer les câbles lui-même. Ça n'a pas marché. Par conséquent, j'ai dû créer un MemIfBundle
câbles MemIfBundle
qui a MemIfBundle
dans toute la hiérarchie. Pourquoi ne sort-il pas de la BlackBox
et ne se connecte-t-il pas immédiatement? Le fait est que, avec BlackBox
tous les ports externes sont placés dans val io = IO(new Bundle { ... })
. Si le MemIfBundle
entier MemIfBundle
transformé en une variable dans le bundle, le nom de cette variable sera transformé en préfixe pour les noms de tous les ports, et les noms ne coïncideront pas avec l'interface du bloc. Probablement, cela peut être fait d'une manière plus adéquate , mais pour l'instant, laissons cela de cette façon.
De plus, par analogie avec d'autres appareils TileLink (vivant principalement dans rocket-chip/src/main/scala/tilelink
), et en particulier BootROM
, nous décrirons notre interface avec le contrôleur de mémoire:
class AltmemphyDDR2RAM(implicit p: Parameters) extends LazyModule { val MemoryPortParams(MasterPortParams(base, size, beatBytes, _, _, executable), 1) = p(ExtMem).get val node = TLManagerNode(Seq(TLManagerPortParameters( Seq(TLManagerParameters( address = AddressSet.misaligned(base, size), resources = new SimpleDevice("ram", Seq("sifive,altmemphy0")).reg("mem"), regionType = RegionType.UNCACHED, executable = executable, supportsGet = TransferSizes(1, 16), supportsPutFull = TransferSizes(1, 16), fifoId = Some(0) )), beatBytes = 16 ))) override lazy val module = new AltmemphyDDR2RAMImp(this) } class AltmemphyDDR2RAMImp(_outer: AltmemphyDDR2RAM)(implicit p: Parameters) extends LazyModuleImp(_outer) { val (in, edge) = _outer.node.in(0) val ddr2 = Module(new ddr2_64bit) val mem_if = IO(new MemIfBundle)
ExtMem
clé ExtMem
standard ExtMem
nous extrayons les paramètres de la mémoire externe de la ExtMem
SoC ( cette étrange syntaxe me permet de dire «je sais que j'obtiendrai une instance de la classe de cas MemoryPortParameters
(cela est garanti par le type de la clé au stade de la compilation du code Scala, par condition) que nous ne tomberons pas en runtime en prenant un contenu de l' Option[MemoryPortParams]
égal à None
, mais alors il n'y avait rien pour créer un contrôleur de mémoire dans System.scala
...), donc, je n'ai pas besoin de la classe de cas, et certains de ses champs sont nécessaires "). Ensuite, nous créons le port gestionnaire de l'appareil TileLink (le protocole TileLink assure l'interaction de presque tout ce qui concerne la mémoire: le contrôleur DDR et d'autres appareils mappés en mémoire, les caches de processeur, peut-être autre chose, chaque appareil peut avoir plusieurs ports, chacun l'appareil peut être à la fois gestionnaire et client). beatBytes
, si je comprends bien, définit la taille d'une transaction, et nous avons 16 octets échangés avec le contrôleur. HasAltmemphyDDR2
et HasAltmemphyDDR2Imp
nous HasAltmemphyDDR2Imp
aux bons endroits dans System.scala
, écrivez la config
class BigZeowaaConfig extends Config ( new WithNBreakpoints(2) ++ new WithNExtTopInterrupts(0) ++ new WithExtMemSize(1l << 30) ++ new WithNMemoryChannels(1) ++ new WithCacheBlockBytes(16) ++ new WithNBigCores(1) ++ new WithJtagDTM ++ new BaseConfig )
Après avoir fait un «croquis d'un hibou» dans AltmemphyDDR2RAMImp
, j'ai synthétisé le design (quelque chose uniquement à ~ 30 MHz, c'est bien que j'horloge à partir de 25 MHz) et, en mettant mes doigts sur les modules de mémoire et la puce FPGA, je l'ai téléchargé sur la carte. Ensuite, j'ai vu ce qu'est une véritable interface intuitive: c'est quand vous donnez une commande en gdb pour écrire dans la mémoire, et par un processeur figé et brûlé les doigts ressentant une forte chaleur, vous devez appuyer d'urgence sur le bouton de réinitialisation de la carte et fixer le contrôleur.
Lisez la documentation du contrôleur DDR2
Apparemment, il est temps de lire la documentation sur le contrôleur au-delà de la liste des ports. Alors, qu'avons-nous ici? .. Oups, il s'avère que les E / local_
avec le préfixe local_
ne doivent pas être définies de manière synchrone non pas avec pll_ref_clk
, qui est de 25 MHz, mais soit avec phy_clk
qui phy_clk
moitié de la fréquence de mémoire pour le contrôleur à demi-débit, ou, dans notre cas, aux_half_rate_clk
(peut-être après tout aux_full_rate_clk
?), qui aux_full_rate_clk
la fréquence de mémoire complète, et elle, pendant une minute, est de 166 MHz.
Par conséquent, il est nécessaire de franchir les limites des domaines de fréquence. Selon l' ancienne mémoire, j'ai décidé d'utiliser des loquets, ou plutôt une chaîne d'entre eux:
+-+ +-+ +-+ +-+ --| |--| |--| |--| |---> +-+ +-+ +-+ +-+ | | | | ---+ | | | inclk | | | | | | --------+----+ | outclk | | ------------------+ output enable
Mais, après avoir bricolé l'heure, je suis arrivé à la conclusion que je ne pouvais pas gérer deux files d'attente sur les verrous «scalaires» (dans le domaine des hautes fréquences et vice versa), dont chacun aura des signaux antidirectionnels ( ready
et valid
), et même ainsi, pour être sûr que certains beatik ne seront pas à la traîne un ou deux le long de la route. Après un certain temps, j'ai réalisé que la description de la synchronisation sur ready
- valid
sans signal d'horloge commun - est également une tâche similaire à la création de structures de données non bloquantes dans le sens où vous devez penser et prouver formellement beaucoup, il est facile de faire une erreur, il est difficile de le remarquer, et surtout, tout est déjà implémenté avant nous: Intel a une primitive dcfifo
, qui est une file d'attente de longueur et de largeur configurables, qui est lue et écrite à partir de différents domaines de fréquence. En conséquence, j'ai profité de l'opportunité expérimentale du Chisel frais, à savoir les boîtes noires paramétrées:
class FIFO (val width: Int, lglength: Int) extends BlackBox(Map( "intended_device_family" -> StringParam("Cyclone IV E"), "lpm_showahead" -> StringParam("OFF"), "lpm_type" -> StringParam("dcfifo"), "lpm_widthu" -> IntParam(lglength), "overflow_checking" -> StringParam("ON"), "rdsync_delaypipe" -> IntParam(5), "underflow_checking" -> StringParam("ON"), "use_eab" -> StringParam("ON"), "wrsync_delaypipe" -> IntParam(5), "lpm_width" -> IntParam(width), "lpm_numwords" -> IntParam(1 << lglength) )) { override val io = IO(new Bundle { val data = Input(UInt(width.W)) val rdclk = Input(Clock()) val rdreq = Input(Bool()) val wrclk = Input(Clock()) val wrreq = Input(Bool()) val q = Output(UInt(width.W)) val rdempty = Output(Bool()) val wrfull = Output(Bool()) }) override def desiredName: String = "dcfifo" }
Et il a écrit une simple petite jumelle de types de données arbitraires:
object FIFO { def apply[T <: Data]( lglength: Int, output: T, outclk: Clock, input: T, inclk: Clock ): FIFO = { val res = Module(new FIFO(width = output.widthOption.get, lglength = lglength)) require(input.getWidth == res.width) output := res.io.q.asTypeOf(output) res.io.rdclk := outclk res.io.data := input.asUInt() res.io.wrclk := inclk res } }
Débogage
Après cela, le code s'est transformé en transfert de messages entre domaines via deux files d'attente déjà unidirectionnelles: tl_req
/ ddr_req
et ddr_resp
/ tl_resp
(celui avec le préfixe tl_
est synchronisé avec TileLink, le fait que ddr_
est avec le contrôleur de mémoire). Le problème est que tout était dans l'impasse de toute façon, et parfois il faisait assez chaud. Et si la cause de la surchauffe était le réglage simultané de local_read_req
et local_write_req
, il n'était pas si facile de gérer les blocages. Le code en même temps était quelque chose comme
class AltmemphyDDR2RAMImp(_outer: AltmemphyDDR2RAM)(implicit p: Parameters) extends LazyModuleImp(_outer) { val addrSize = log2Ceil(_outer.size / 16) val (in, edge) = _outer.node.in(0) val ddr2 = Module(new ddr2_64bit) require(ddr2.io.local_address.getWidth == addrSize) val tl_clock = clock val ddr_clock = ddr2.io.aux_full_rate_clk val mem_if = IO(new MemIfBundle) class DdrRequest extends Bundle { val size = UInt(in.a.bits.size.widthOption.get.W) val source = UInt(in.a.bits.source.widthOption.get.W) val address = UInt(addrSize.W) val be = UInt(16.W) val wdata = UInt(128.W) val is_reading = Bool() } val tl_req = Wire(new DdrRequest) val ddr_req = Wire(new DdrRequest) val fifo_req = FIFO(2, ddr_req, ddr_clock, tl_req, clock) class DdrResponce extends Bundle { val is_reading = Bool() val size = UInt(in.d.bits.size.widthOption.get.W) val source = UInt(in.d.bits.source.widthOption.get.W) val rdata = UInt(128.W) } val tl_resp = Wire(new DdrResponce) val ddr_resp = Wire(new DdrResponce) val fifo_resp = FIFO(2, tl_resp, clock, ddr_resp, ddr_clock)
Pour localiser le problème, j'ai décidé de commenter banalement tout le code à l'intérieur de withClock(ddr_clock)
(n'est-ce pas, cela ressemble visuellement à la création d'un flux) et de le remplacer par un stub qui fonctionne à coup sûr:
withClock (ddr_clock) { ddr_resp.rdata := 0.U ddr_resp.is_reading := ddr_req.is_reading ddr_resp.size := ddr_req.size ddr_resp.source := ddr_req.source val will_read = Wire(!fifo_req.io.rdempty && !fifo_resp.io.wrfull) fifo_req.io.rdreq := will_read fifo_resp.io.wrreq := RegNext(will_read) }
Comme je l'ai réalisé plus tard, ce talon n'a pas fonctionné non plus parce que la construction Wire(...)
, que j'ai ajoutée «pour la fiabilité», pour montrer qu'il s'agissait d'un fil nommé, n'a utilisé l'argument que comme prototype pour créer type de sens, mais ne l'a pas lié à l'argument expression . De plus, lorsque j'ai essayé de lire ce qui était encore généré, j'ai réalisé qu'en mode simulation, il existe une large sélection d'assertions concernant la non-conformité avec le protocole TileLink. Ils me seront probablement utiles plus tard, mais jusqu'à présent, il n'y a eu aucune tentative pour exécuter la simulation - pourquoi la lancer? Verilator ne connaît probablement pas les cœurs IP d'Alter, ModelSim Starter Edition refusera très probablement de simuler un projet aussi énorme, mais j'ai également juré l'absence d'un modèle de contrôleur pour la simulation. Et pour le générer, vous devrez probablement d'abord passer à la nouvelle version du contrôleur (car l'ancien a été configuré dans l'ancien Quartus).
En fait, les blocs de code provenaient d'une version presque fonctionnelle, et non de celle qui avait été activement déboguée quelques heures auparavant. Mais vous feriez mieux;) Au fait, vous pouvez constamment réassembler le design plus rapidement si le WithNBigCores(1)
est remplacé par WithNSmallCores(1)
- du point de vue des fonctionnalités de base du contrôleur de mémoire, il ne semble pas y avoir de différence. Et une petite astuce: afin de ne pas conduire les mêmes commandes dans gdb à chaque fois (au moins, je n'ai pas d'historique de commandes entre les sessions), vous pouvez simplement taper quelque chose comme ça sur la ligne de commande
../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "x/x 0x80000000" ../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set variable *0x80000000=0x1234"
et exécuter au besoin en utilisant les moyens réguliers de la coquille.
Résumé
Par conséquent, le code suivant a été obtenu pour travailler avec le contrôleur:
withClock(ddr_clock) { val rreq = RegInit(false.B)
Ici, nous allons encore légèrement modifier le critère de réalisation: j'ai déjà vu comment, sans aucun travail avec la mémoire, les données enregistrées sont comme lues, car il s'agit d'un cache. Par conséquent, nous compilons un simple morceau de code:
#include <stdint.h> static volatile uint8_t *x = (uint8_t *)0x80000000u; void entry() { for (int i = 0; i < 1<<24; ++i) { x[i] = i; } }
../../rocket-tools/bin/riscv64-unknown-elf-gcc test.c -S -O1
Par conséquent, nous obtenons le fragment suivant de liste d'assembleurs, initialisant les 16 premiers Mo de mémoire:
li a5,1 slli a5,a5,31 li a3,129 slli a3,a3,24 .L2: andi a4,a5,0xff sb a4,0(a5) addi a5,a5,1 bne a5,a3,.L2
bootrom/xip/leds.S
au début de bootrom/xip/leds.S
Il est désormais peu probable que tout puisse être conservé dans un seul cache. Il reste à exécuter le Makefile, à reconstruire le projet dans Quartus, à le remplir dans le tableau, à connecter OpenOCD + GDB et ... Vraisemblablement, bravo, victoire:
$ ../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" Remote debugging using :3333 warning: No executable has been specified and target does not support determining executable automatically. Try using the "file" command. 0x0000000000010014 in ?? () (gdb) x/x 0x80000000 0x80000000: 0x03020100 (gdb) x/x 0x80000100 0x80000100: 0x03020100 (gdb) x/x 0x80000111 0x80000111: 0x14131211 (gdb) x/x 0x80010110 0x80010110: 0x13121110 (gdb) x/x 0x80010120 0x80010120: 0x23222120
Est-ce le cas, nous le découvrirons dans la prochaine série (je ne peux pas non plus dire sur les performances, la stabilité, etc.).
Code: AltmemphyDDR2RAM.scala .