☰
从零开始学RISC-V核设计:用Chisel打通指令集与电路结构
2026/10/14 17:49:21 网站建设 项目流程

先给结论:RISC-V核设计这件事,越早把“指令集”和“电路结构”在脑子里接上线,后面学起来越顺。而Chisel恰恰是帮你完成这条对接的利器——它是嵌在Scala里的一种硬件构造语言,用写代码的方式生成电路。这篇文章记录了我从零开始学RISC-V核设计与Chisel的全过程,包括工具链搭建、语法要点、一颗最小单周期RV32I内核的拆解、验证方法和我踩过的坑。如果你有数字电路基础、会一点编程,正准备入门处理器设计,跟着这篇文章的思路走,会比在资料海里乱翻快很多。

先说点题外话。我一开始也是按老路子来,打算拿Verilog硬写一个能跑指令的内核。写了两周,状态机勉强搭起来了,但每次改一个功能就要翻一堆文件,信号多了连自己都分不清归属。后来一个做开源芯片的朋友把Chisel的文档甩给我,我花了一个周末重写了一遍同样的计数器加简单ALU,立刻明白为什么有人愿意“劝人转语言”:同样是描述硬件,Chisel更像在写有结构、有类型的程序,Verilog更像在手工抄网表。这篇文章不是劝你站队,而是把我亲测能走通的一条路完整讲给你。

1. 为什么偏偏是RISC-V加Chisel:选型背后的实际考量

决定学RISC-V核设计之前,我把几条主流路线对比过一遍。用Verilog硬写一颗内核,优点是资料多、工具链成熟、绝大多数现有IP都是Verilog;缺点是代码复用和参数化能力太弱,写一个五级流水线时,你要手动同步一堆控制信号,改一个功能往往要牵连七八个模块,验证代码和设计代码又是两套语法体系,写testbench和写RTL完全是两种思维。

RISC-V吸引我的地方其实很实在:指令集是开放标准,文档直接可读,基础指令集RV32I只有四十多条指令,一个周末就能过完;模块化的设计允许你先做最简子集,再逐步加上乘除法扩展、原子操作、压缩指令。对学习者来说,它就像一个规格清晰的作业题,你可以拿到一份公开的参考实现来对照。而封闭商业架构里那些处理器型号、私有扩展和授权文档,对自学的人既不友好,也无法真正拿来练手。

Chisel刚好补上了Verilog的短板。它本质上是Scala内部的一层领域特定语言(DSL),用写软件的方式描述硬件——你可以用循环批量生成电路、用函数封装模块、用类型系统保证位宽一致。对“造处理器”这种工作量集中在重复结构和参数化控制的场景,Chisel的表述效率高不少。我实际体感是:同样一个RV32I单周期内核,我用Verilog写了一遍,又用Chisel写了一遍,Chisel版代码行数少了将近四成,改数据位宽、改寄存器数量这类操作,不需要全文搜索再小心改造成。

但Chisel不是万能的。它的报错可读性、生态文档相比成熟了几十年的Verilog还是差一截,某些工具链衔接也要自己折腾。我的建议很直接:如果你已经能用Verilog完成基本的时序逻辑设计,或者你本身有点Scala基础,直接上Chisel完全可行;但如果你是第一次接触硬件描述语言,建议先花两周把Verilog的“模块、时序块、连续赋值”手感建立起来,再来学Chisel,否则你分不清哪层是硬件概念、哪层是语言抽象。我当时就是Verilog打底,再切Chisel,痛苦感小很多。

2. Chisel的底层工作方式:从Scala程序到电路网表之间发生了什么

2.1 硬件构造语言到底是什么

很多人第一眼看到Chisel代码会误以为“这不就是用Scala写RTL嘛”。这句话只对了一半。更准确的说法是:Scala程序在运行的时候“构造”了一张硬件电路图,Chisel只是提供了一个工具箱让你方便地描述电路结构。传统RTL语言是编译器直接把代码翻译成电路,而Chisel是当你运行程序时,在内存里建立一棵电路对象的树,再经过中间表示导出成标准的Verilog。

这个差异有一个非常实用的推论:你可以在生成电路之前做任何软件层面的计算。想要32个加法器?写个for循环就好。想按配置文件生成不同位宽的寄存器堆?用Scala的普通函数和参数就行。这在Verilog里要靠预处理宏和generate块勉强实现,在Chisel里就是再普通不过的过程式代码。我写内核时最爽的一个瞬间,就是用一个参数控制整个数据通路位宽,编译三次就拿到32位、16位、8位三份完全不同的核,这在手写Verilog的阶段想都不敢想。

2.2 FIRRTL中间表示与仿真流程

Chisel生成的Verilog并不是“手写风格”的Verilog。它先经过一层被称为FIRRTL(中间表示)的环节,做规范化、优化等处理,再输出Verilog。这也是为什么Chisel产生的Verilog有些地方看起来“很笨”——模块层次多、临时信号多,但综合工具恰恰擅长处理这种结构规整的代码。

后面的流程大家就比较熟了:生成的Verilog可以送进FPGA综合工具,或者交给开源的仿真器做逻辑仿真;如果目标是ASIC数字前端,一样交给综合工具。对学习RISC-V内核来说,你至少需要跑通“生成Verilog → 仿真 → 看波形”这条线。Chisel自带的测试框架会在后台自动完成生成和仿真,你只需要关注测试代码本身。

2.3 关键数据类型与连接规则

Chisel里打交道最多的是Bool、UInt、SInt、Bundle、Vec这几种类型。特别要注意:位宽是类型的一部分,UInt(32.W)和UInt(16.W)是不同类型,赋值时类型不匹配编译器会直接报错,这比Verilog里默默截断要友好得多。Bundle是端口和结构体的合体,用来打包一组信号;Vec则是寄存器堆和存储阵列的基础。

连接规则是最容易搞混的点。默认连接用:=,它把右侧信号按位连到左侧,要求位宽和方向匹配;<>用于两个Bundle之间的整体双向连接,在模块IO之间很常用。另外还有一个“最后连接生效”的原则:同一个信号被多次赋值时,最后一次赋值有效。新手最常见的时序理解错误就是想要“累加”效果时,因为最后连接语义把该保留的旧值覆盖了。这个坑我在第七部分还会专门展开。

3. 环境搭建与第一个Chisel模块:把“Hello World”跑成仿真波形

3.1 工具链清单和版本差异

想在本地跑Chisel,核心依赖其实只有三样:JDK、sbt、Scala编译器。Chisel不维护自己的独立构建系统,sbt会按配置文件自动拉取依赖。我当时的组合是JDK 17、sbt 1.9.x、Scala 2.13.x,搭配Chisel 3.6系列,整个过程没遇到大的版本坑。

这里要特别提醒:网上大量旧教程写的是老Chisel API,比如io = new Bundle {...}的写法,在3.5以上版本已经被移除了,统一用IO(new Bundle {...})。Chisel 5系列又改了一些依赖坐标。所以与其照抄旧代码,不如以官方文档标注的最新稳定版为准。我下面给的代码示例尽量用3.6和5都能识别的风格,个别差异会点出来。

3.2 最小工程文件:build.sbt

一个最小Chisel工程只需要两个文件:build.sbt和源码目录下的Scala文件。build.sbt本质上告诉sbt三件事:项目名、Scala版本、要引用的Chisel库版本。

scalaVersion := "2.13.10" libraryDependencies ++= Seq( "edu.berkeley.cs" %% "chisel3" % "3.6.0", "edu.berkeley.cs" %% "chiseltest" % "0.6.0" ) scalacOptions ++= Seq( "-language:reflectiveCalls", "-Ymacro-annotations" )

如果你是Chisel 5,依赖坐标会变化,具体以官方文档为准。建好文件后,在项目根目录执行sbt test,首次运行会下载一大包依赖,慢是正常的。等进度条走完,整个环境就算通了。

3.3 第一个模块:带使能的计数器

我习惯用“带使能的计数器”作为Chisel入门第一课,因为它同时涉及寄存器、时序赋值和条件逻辑,麻雀虽小五脏俱全。

package demo import chisel3._ class Counter extends Module { val io = IO(new Bundle { val en = Input(Bool()) val out = Output(UInt(8.W)) }) val reg = RegInit(0.U(8.W)) when(io.en) { reg := reg + 1.U } io.out := reg }

这里RegInit(0.U(8.W))声明一个带复位初值的8位寄存器;when是Chisel的条件连接语句,翻译成电路就是一个多路选择器加寄存器,而不是Verilog里那种过程块的概念。生成Verilog的入口也很简单:

package demo import chisel3.stage.ChiselStage object CounterMain extends App { (new ChiselStage).emitVerilog(new Counter(), Array("--target-dir", "generated")) }

在sbt里运行这个入口,generated目录下就会多出一个Counter.v。我通常会先打开这个Verilog看一眼,对照Chisel代码理解生成结果,这是新手建立“源码和电路对应关系”最有效的手段。

3.4 用ChiselTest写第一个断言测试

验证可以直接用Chisel自带的测试框架,代码风格和ScalaTest一致。下面这个测试验证:使能拉高时每个时钟周期加一,拉低之后保持不变。

package demo import chisel3._ import chiseltest._ import org.scalatest.flatspec.AnyFlatSpec class CounterTest extends AnyFlatSpec with ChiselScalatestTester { "Counter" should "count when enabled" in { test(new Counter) { c => c.io.en.poke(true.B) c.io.out.expect(0.U) c.clock.step() c.io.out.expect(1.U) c.clock.step(3) c.io.out.expect(4.U) c.io.en.poke(false.B) c.clock.step(2) c.io.out.expect(4.U) } } }

poke是给输入打值,expect是断言输出,step是推进时钟周期。跑sbt test,看到绿色通过,你第一次“用程序生成硬件并用程序验证硬件”的完整闭环就建立了。这个体验很关键,因为它直接决定你后面愿不愿意写更多测试。

4. Chisel核心语法速通:我实际写内核时用到的高频表达

这一节不求覆盖全部语法,只把我写RV32I内核时真正高频使用的东西整理出来,按使用频率排序。

4.1 寄存器三兄弟:Reg、RegInit、RegNext

Reg是裸寄存器,初值不保证,综合后可能是未定义;RegInit带复位初值,比如上面的RegInit(0.U(8.W));RegNext是它的语法糖,等价于“下一个周期取输入值”,写延迟打拍非常顺手。

val regA = RegInit(0.U(16.W)) val regB = RegNext(regA + 1.U)

我在内核里划分流水级或者给信号打拍时,几乎只用RegNext;真正需要复位语义的模块复位状态,才用RegInit。这是个简单但重要的习惯:哪些状态需要复位后就有确定值,哪些只是延迟数据,要想清楚,否则后期综合会出问题。

4.2 条件与选择:when、Mux、switch

条件逻辑在Chisel里有好几种写法。when适合多条件优先级场景;Mux(cond, a, b)适合单条件二选一;MuxLookup适合查表选择;switch适合对同一个信号做多分支。

val y = Wire(UInt(8.W)) when(cond1) { y := a }.elsewhen(cond2) { y := b }.otherwise { y := c } val z = Mux(sel, x, y)

我写控制逻辑时,指令译码表就是典型的switch场景。Chisel把这几种选择都翻译成等价的组合逻辑多路选择器,你不必担心性能差异,挑可读性最好的写法就行。

4.3 Bundle、Vec 与参数化

Bundle用来聚合同一模块的端口,比如把译码结果打包:

class DecodedInst extends Bundle { val aluOp = UInt(3.W) val regWrite = Bool() val memWrite = Bool() }

Vec用来生成定长数组,寄存器堆就是它的典型场景:val regFile = Reg(Vec(32, UInt(32.W)))。参数化在Chisel里就是普通Scala函数和类的参数,不需要特殊的宏。写寄存器堆这种按参数生成重复结构的模块,我明显感受到比手写Verilog的generate块舒服。

4.4 常用类型速查表

类型含义常用初始化写法
Bool单比特布尔true.B/false.B
UInt无符号整数0.U(8.W)/"hff".U
SInt有符号整数(-1).S(8.W)
Bundle结构体IO(new Bundle {...})
Vec定长数组Vec(32, UInt(32.W))

5. 手写一个简化版单周期RV32I内核:模块划分与代码骨架

热身完毕,正戏开始。单周期内核的意思是每条指令在一个时钟周期内完成取指、译码、执行、访存、写回,结构简单,能完整展示指令集到硬件的映射,是入门处理器的经典起点。这里我选的是RV32I的一个子集:基础算术逻辑、加载存储、两条分支和一条跳转,足够跑冒烟程序。

5.1 单周期结构

我把内核拆成五个部分:程序计数器PC、取指用的指令ROM、译码逻辑、寄存器堆、ALU和控制信号生成。数据通路简图大概是:PC送到ROM取指,指令被译码器拆出操作码和各字段,寄存器堆读两个源操作数,ALU计算,结果或访存数据写回寄存器堆。

Chisel里表达这个结构非常直白:

class Core extends Module { val io = IO(new Bundle { val imemAddr = Output(UInt(32.W)) val debugOut = Output(UInt(32.W)) }) val pc = RegInit(0.U(32.W)) // 取指:用程序数组初始化一个只读存储 val rom = VecInit(program.map(_.U(32.W))) val inst = rom(pc >> 2) val opcode = inst(6, 0) val rd = inst(11, 7) val rs1 = inst(19, 15) val rs2 = inst(24, 20) val funct3 = inst(14, 12) val funct7 = inst(31, 25) ... }

program是普通Scala数组,里面放的是预编译好的指令字。这里再次体现“Scala程序生成硬件”的思路:你可以用任何Scala代码把汇编指令翻译成机器码,也可以直接在数组里写十六进制。

5.2 立即数生成:最容易错的模块

RISC-V按指令类型把立即数打散在指令字段里,需要重新拼接。I型是最高位符号扩展加低12位;B型把分散的几个字段拼起来再左移一位;J型类似。我当时的实现长这样:

val immI = Cat(Fill(20, inst(31)), inst(31, 20)) val immB = Cat(Fill(19, inst(31)), inst(31), inst(7), inst(30, 25), inst(11, 8), 0.U(1.W)) val immJ = Cat(Fill(11, inst(31)), inst(31), inst(19, 12), inst(20), inst(30, 21), 0.U(1.W))

Cat是拼接,Fill(n, x)是复制符号位。这个模块的测试价值极高,我建议单独写一个测试把每种指令类型的立即数字段都验证一遍,因为后续所有指令都依赖它。

5.3 ALU与译码控制

ALU本身不复杂,复杂的是控制信号怎么来。我用一个3位的aluOp表示运算类型,用指令的opcode和funct3、funct7组合出控制信号。R型指令的aluOp由funct字段决定,I型指令由opcode决定,加载和存储都要算地址,所以统一用加法。

switch(aluOp) { is(0.U) { aluOut := srcA + srcB } is(1.U) { aluOut := srcA - srcB } is(2.U) { aluOut := srcA & srcB } is(3.U) { aluOut := srcA | srcB } is(4.U) { aluOut := srcA ^ srcB } is(5.U) { aluOut := srcA < srcB } // slt }

分支指令也要算目标地址,我的做法是把分支判断拆成两部分:ALU比较两个源操作数得出分支条件,另一个加法器(或复用ALU)计算PC加上立即数得到跳转目标。初学者容易把“比较”和“算地址”混在同一个ALU运算里,结果控制逻辑越写越乱,这里提前拆开会清爽很多。

5.4 寄存器堆:注意0号寄存器的硬连线

寄存器堆是RV32I的另一个基础模块:32个32位寄存器,两个读口一个写口,组合读、同步写。实现要点是0号寄存器必须恒为0,写回时要判断目标寄存器不能是0号。

val regFile = Reg(Vec(32, UInt(32.W))) when(regWrite && (rd =/= 0.U)) { regFile(rd) := writeData } srcA := regFile(rs1) srcB := regFile(rs2)

如果在Chisel上调用了读端口,而写入和读取发生在同一边沿,注意这是单周期设计,不需要旁路逻辑;读的是“写之前”的值还是“写之后”的值,要按Verilog语义理解寄存器在同一周期内先读后写的规则,写测试时会踩到。这个点我在第七节会详细展开。

5.5 控制信号的生成:一张简单的表

我建议把控制逻辑列成一张表再写代码,比边写边想靠谱得多。以下是我内核里使用的控制主表:

opcode指令类型aluOpregWritememWrite分支/跳转
0110011R型计算按funct字段1000
0010011I型计算按funct31000
0000011加载加法1000
0100011存储加法0100
1100011分支按funct30001
1101111JAL加法1010

写控制模块时,先把这张表翻译成switch(opcode)里的若干分支,再逐位生成控制信号。我实测下来,这样出错率最低,也方便后续加新指令。

6. 验证环节:ChiselTest如何让内核“可跑可查”

6.1 先测小模块,再测整颗核

我在写完ALU、寄存器堆、立即数生成器之后,全部单独写了测试,等模块级测试全绿了才拼Core。这个习惯救了我很多次:因为整颗核一旦出问题,定位范围太大了;而模块级测试能精确把问题限定到某个模块内部。比如ALU的测试长这样:

class AluTest extends AnyFlatSpec with ChiselScalatestTester { "ALU" should "compute add and and" in { test(new Alu) { c => c.io.a.poke(8.U) c.io.b.poke(3.U) c.io.op.poke(0.U) c.io.out.expect(11.U) c.io.op.poke(2.U) c.io.out.expect(8.U & 3.U) } } }

6.2 整颗核的测试方案:固定周期跑完再断言结果

整颗核的测试不能像ALU那样直接poke内部信号,因为Core的对外IO有限。我的方案是:在测试里给指令ROM初始化一段小汇编程序,让内核跑固定周期,然后通过一个调试输出观察结果。比如程序是“把两个立即数相加存入寄存器”,跑几个周期后断言debugOut出现加法的结果。

class CoreTest extends AnyFlatSpec with ChiselScalatestTester { "Core" should "run simple add program" in { test(new Core(Seq(...))) { c => c.reset.poke(true.B) c.clock.step(2) c.reset.poke(false.B) c.clock.step(10) c.io.debugOut.expect(42.U) } } }

这里注意复位处理:必须先拉高复位再拉低,让PC和寄存器回到确定状态。ChiselTest里的reset.poke(true.B)配合step(2)是我验证过的标准复位套路。

6.3 仿真里的调试技巧

Chisel模块内部可以直接写printf,在仿真时输出信号值,这是我最常用的调试手段:

when(io.en) { printf(p"pc=$pc inst=${Hexadecimal(inst)} aluOut=$aluOut\n") }

printf只在仿真里生效,综合时会被剥离,所以放心大胆用。配合step的控制,你能完整追踪每条指令在每一拍做了什么。等调试稳定后,再把调试用printf删掉或者用条件开关包起来。

7. 实测踩坑记录:新手最容易卡住的几个位置

以下是我实际学Chisel写内核时遇到的高频问题,按“症状、原因、处理”列出来,省得你重复翻文档。

症状原因处理方式
类型不匹配报错UInt(8.W)赋值给UInt(16.W)显式扩宽:x := a.asUInt或x := Cat(0.U(8.W), a)
“last connect”导致旧值被覆盖多条件赋值时没看清最后连接优先用when / elsewhen / otherwise配套结构,避免散乱重复赋值
仿真结果比预期晚一拍把组合逻辑和寄存器打拍方向搞反检查RegNext的输入输出顺序,必要时画时间波形
寄存器堆同周期读写不对忽略同步写“先读后写”语义写测试前先确认端口时序,必要时加旁路逻辑模拟
Chisel 3/5 API不兼容依赖旧教程代码统一按当前稳定版API写,以官方文档为准
生成Verilog后综合工具报多驱动Chisel封装多级模块后信号有隐含连接用顶层IO命名明确层级,避免在内部create同名Wire

7.1 组合逻辑环

这是我最容易犯的错误:反手写了一个io.out := io.out + 1.U之类的组合自反馈,Chisel会直接报组合环错误。首先明确:只有寄存器才能构成跨周期的状态,Wire和普通io不能参与组合环路。我在ALU里犯过一次,把分支条件和跳转目标通过同一个Wire互相推导,结果报错,最后才意识到需要把比较结果寄存一拍再参与目标计算。

7.2 Mem与SyncReadMem的读写时序

内核里放数据存储器时,我一开始用的Mem,结果读出总是不稳定。后来搞清楚:Mem提供异步读,适合寄存器堆这种组合读;而真正的SRAM应该用SyncReadMem,读地址在时钟边沿后一个周期才稳定输出。写内核时,指令ROM我用纯组合逻辑的VecInit,数据存储用SyncReadMem,这也是和FPGA上真实BRAM行为最接近的选择。

7.3 仿真通过不代表可以直接上板

ChiselTest里所有信号默认初值是0,很多“未初始化”的错误在仿真里看不出来,因为0恰好是你想要的值。我后来把复位做得显式化:所有关键状态全部用RegInit,而不是裸Reg。这样上板之后行为才和仿真一致。还有一点:printf里打印的是仿真值,不是综合后的电路行为,确认功能时以断言和波形为主,别太依赖打印。

8. 一套实测有效的学习路线:给“边学边做”的人排个序

最后说说顺序问题。我见过不少朋友一上来就下载开源整核工程,对着几千行代码研究了一个月,结果连BasicBlock都追不完。我的经验是:必须按能“产出并验证”的里程碑推进,每一步都跑得动、看得见。

8.1 我推荐的里程碑顺序

  1. 花一周左右读Chisel官方开源电子书前几章,只要求看懂并能复现计数器、移位寄存器、简单状态机。
  2. 独立实现并测试一个ALU(至少支持加减、逻辑、比较)。
  3. 实现寄存器堆和立即数生成器,并写完整测试。
  4. 搭建单周期RV32I最简子集,先从算术逻辑指令开始,跑通“加载立即数→计算→写回”。
  5. 加入分支和跳转指令,写一个小循环程序验证PC跳转逻辑。
  6. 用冒烟程序做整核验证,比如算累加和、求阶乘、递归小函数。
  7. 再考虑五级流水线、中断、MMIO这些进阶内容。

每完成一个里程碑,记得把测试代码留下来。这些测试以后就是你的回归基线,改内核时跑一遍就知道哪里又坏了。

8.2 文档阅读的正确姿势

RISC-V规范不要从头读到尾,那会把热情耗光。我建议只读第一卷(非特权指令集)的第二章基础整数指令集,重点是你当前实现的子集范围。看的时候手边放一份指令格式图,把opcode、funct3、funct7、立即数分布对照着机器码一起看,比干啃文字有效十倍。Chisel的语法文档适合当字典查,不适合当书读,核心概念清楚后,遇到不认识的就查一下,很快就能上手。

8.3 开源参考实现怎么用

网上有大量教学级内核项目,我的建议是:先自己写,遇到卡住的地方再“偷看”对应模块。全抄一遍能跑,但你对结构细节的理解是残缺的;自己写到卡住再对照参考,才能真正明白“原来这里要这样拆”。我就是这么干的:自己实现到分支指令时PC更新逻辑绕晕了,翻开参考实现看了十行,立刻醒悟,后面流水线设计再没卡过类似问题。

最后再分享一个实用小技巧

如果你看完这篇文章决定开始学,我建议养成一个习惯:每完成一个模块,就用几句话在代码注释里写清楚“这个模块解决什么问题、为什么这样设计”。比如ALU里为什么要单独拆出比较分支、寄存器堆为什么要忽略rd等于0的写回,这些当时觉得“理所当然”的细节,过两周回头看全是财富——因为等你做流水线时,每个模块边界就是从这里来的。我后来教A同学、B同学入门,都是先让他们做这件事,效果比推荐任何资料都好。

还有一条个人体会:学习RISC-V核设计的真正瓶颈,往往不是语法,也不是工具链,而是“你对自己设计的模块有没有把握”。Chisel把这种把握的门槛降低了不少,因为它让验证变简单了,让参数化变自然了。先把单周期跑通、跑对、跑得明白,再谈流水线和性能优化,这条路我亲自试过,稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询