多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Chisel时序逻辑入门:寄存器原语与呼吸灯实战

Chisel时序逻辑入门:寄存器原语与呼吸灯实战 Chisel系列写到这儿已经到了一个真正分水岭的位置时序逻辑。前面的组合逻辑再漂亮本质上只是对输入做即时计算但一旦你开始处理状态、计数、流水就要靠寄存器来“记住”东西。Chisel对寄存器的表达方式比Verilog顺手太多——不用背always块模板不用反复检查敏感列表复位逻辑也自动接好了。这篇我会从最基础的Reg三个原语讲起用一个可复用的计数器和一个呼吸灯例子走完设计、生成Verilog、仿真验证的完整流程最后再盘点实际用Chisel做小处理器时踩过的时序相关坑。不管你是刚接触Chisel还是用Verilog想换个思路这篇都值得花二十分钟慢慢看。1. 时序逻辑的本质为什么Chisel把寄存器当“一等公民”1.1 组合逻辑与时序逻辑从“即算即走”到“记账本”组合逻辑像开关电灯输入一变输出立刻跟着变不依赖任何历史信息。时序逻辑不一样它像记账本输出不仅看当前输入还要看之前记下来的状态。这个“状态”由寄存器来保存寄存器在时钟边沿采样并锁存数据每个时钟周期把记忆更新一次。寄存器加组合逻辑就构成了有限状态机、计数器、流水线这些数字电路的核心骨架。CPU能一步步执行指令靠的就是程序计数器PC每拍加一本质是一个时序计数器通信协议能按字节解析帧靠的也是移位寄存器和状态机。可以说不会写时序逻辑就还没真正进入数字设计的大门。从HDL表达角度看Verilog描述一个最简单的D触发器你得写完整的always块模板、理清敏感列表、判断异步复位还是同步复位。写十个寄存器就要复制十遍模板signals一多代码里全是样板。Chisel的思路完全不同寄存器是构造硬件的基本单元模块本身就隐式携带时钟和复位只需要描述“什么条件下更新成什么值”剩下的交给编译器。1.2 时钟和复位Chisel帮你省掉的样板代码在Chisel模块里clock和reset端口是自动存在的不需要你显式声明。用RegInit创建的寄存器会自动接上复位逻辑复位后恢复你指定的初值。这意味着你不需要关心Verilog里那个敏感的“复位电平是1还是0、异步还是同步”的细节至少在代码层不用操心。举个例子Verilog写一个带同步复位的8位计数器少说要十行模板Chisel只需要val cnt RegInit(0.U(8.W)) cnt : cnt 1.U这两行生成的硬件逻辑等效于一个8位寄存器加一个加法器加一个复位电路。模块的reset信号有效时cnt被复位成0之后每个上升沿加1。你不再需要写always (posedge clk)因为Chisel的Reg系列天生就是时序元件。这里有个新手易忽略的点如果用Reg而不是RegInit创建寄存器复位信号对该寄存器不生效仿真初始值直接是X未知。一旦X参与运算会像墨水一样染遍整条数据通路。后文第4节我会专门展开这个问题。1.3 写时序逻辑前要先想清楚的三个问题用Chisel写时序逻辑之前我会习惯先在草稿上回答三个问题能够避免大部分返工第一哪些信号是需要“记住”的状态把它们全部列出来这是寄存器的候选名单。计数器、使能标志、当前状态编码都需要寄存器单纯由输入组合出的临时结果用Wire就够。第二每个状态变量的初值是什么上电复位后它们应该是什么状态计数器从0开始、状态机从IDLE开始、使能标志默认False这些值对应RegInit的参数。第三状态什么时候更新是每个时钟都更新还是条件满足才更新条件更新就套when无条件更新就直接赋值。想清楚这三点再动手写代码基本不会出现“写完仿真一团糟”的情况。2. Chisel三个寄存器原语Reg、RegInit、RegNext怎么选2.1 Reg最朴素的寄存器Reg是Chisel最基础的寄存器原语用法很简单val reg Reg(UInt(8.W)) reg : io.in每个时钟上升沿reg锁存一次io.in的值。它不带复位值含义是“这个寄存器的初值我不关心反正每次上电后前几拍数据就会被刷掉”。这种场景常见于流水线打拍比如数据通路中间某级只需要把前一级结果往后传不介意最初几拍是什么。用Reg要特别注意一点由于没有复位值仿真器初始状态下它的值是不确定的X。如果复位的时刻无法保证覆盖到它或者它的下游逻辑在前几个周期就要用到有效数据那么仿真波形就会出现X传播。所以我的习惯是除非明确知道这是一个“不需要初始状态”的临时寄存器否则一律用RegInit。2.2 RegInit带复位值的“万金油”RegInit是写时序逻辑时我最常用的原语。它的第一参数是复位值类型同时决定了寄存器位宽val cnt RegInit(0.U(8.W)) val state RegInit(IDLE) val flag RegInit(false.B)复位信号有效时这些寄存器会立刻恢复成指定的初值复位释放后开始正常更新。带初值的寄存器在仿真里行为干净上板后状态也可控是整个时序逻辑的地基。有一点容易踩复位值必须和寄存器类型匹配你不能把一个8位的UInt寄存器初始化成1.U默认1位。位宽不匹配在Chisel编译期就会报错虽然安全性好但也要养成书写规范的习惯比如0.U(8.W)写完整不省位宽。2.3 RegNext打一拍的最佳工具RegNext是延迟一拍的语法糖逻辑等价于“每拍把输入采样一次下一拍输出”val delayed RegNext(io.in)上面的写法会让delayed比io.in慢一个时钟周期。打一拍在数字设计里太常见了比如组合逻辑路径太长导致时序违例插一级寄存器断开路径或者两个模块握手时用打拍对齐信号。RegNext还有带复位值的版本val delayed RegNext(io.in, 0.U)第二个参数就是复位初值相当于RegInit加延迟一拍。如果你既要给信号打拍又希望复位后输出是确定的0就用这个写法。要留意的是RegNext很容易被误用成“组合逻辑直连”仿真波形一看发现慢了一拍才反应过来排查方法我会在第4节写。为了帮你快速决策三个原语的选择可以这样看原语是否带复位初值典型用途Reg否仿真初值X流水线临时打拍不关心初值RegInit是复位后恢复初值计数器、状态机、所有需要可控初始状态的场景RegNext可选默认无初值把信号延迟一拍的快捷方式2.4 实战一个参数化计数器计数器是时序逻辑的Hello World也是所有复杂时序的基础。很多模块里都藏着计数器分频器、定时器、PWM、状态机里的超时等待本质都是“数到N就做点什么”。我写一个参数化的上限计数器max由外部传入数到max-1后回绕并输出一个脉冲import chisel3._ import chisel3.util._ class Counter(max: Int) extends Module { val io IO(new Bundle { val en Input(Bool()) val count Output(UInt(log2Ceil(max).W)) val done Output(Bool()) }) val cnt RegInit(0.U(log2Ceil(max).W)) when(io.en) { when(cnt (max - 1).U) { cnt : 0.U }.otherwise { cnt : cnt 1.U } } io.count : cnt io.done : io.en cnt (max - 1).U }这里有个细节计数器位宽用log2Ceil(max)计算而不是手写一个拍脑袋的宽度。max1000时log2Ceil(1000)10因为2的9次方是512装不下9992的10次方是1024刚好够。位宽不够会溢出位宽太宽浪费寄存器用函数自动算才靠谱。这段代码的时序行为是io.en为高时每个时钟沿计数值加1数到max-1就回0并拉高doneio.en为低时cnt保持原值。注意这里没有任何else分支去更新cnt这正体现了寄存器的保持特性——条件不满足时它天然保存状态。2.5 进阶移位寄存器用Vec写移位寄存器也是时序逻辑的经典场景Chisel里用Vec写格外清晰。下面是一个4级8位延时链输入信号经过4个寄存器后输出class ShiftReg extends Module { val io IO(new Bundle { val in Input(UInt(8.W)) val out Output(UInt(8.W)) }) val pipe RegInit(VecInit(Seq.fill(4)(0.U(8.W)))) pipe(0) : io.in for (i - 1 until 4) { pipe(i) : pipe(i - 1) } io.out : pipe(3) }RegInit(VecInit(Seq.fill(4)(0.U(8.W))))这条语句创建了4个带复位初值的8位寄存器所有复位值都是0。之后用for循环把前一级输出接到后一级输入硬件上就是级联的4个D触发器。这种写法比Verilog里手动展开4个寄存器声明要清爽得多而且如果你要改成8级、16级只需要把Seq.fill里的4改成对应数字。这就是Chisel参数化生成硬件的典型优势循环和函数在编译期展开成电路而不是在运行期消耗时钟周期。3. 完整实操用Chisel实现可配置呼吸灯3.1 需求拆解一个呼吸灯需要几个计数器上板跑一个呼吸灯是很多人的入门项目。呼吸灯的效果是LED亮度从暗到亮、再亮到暗周期性变化。数字电路实现亮度变化本质是靠PWM调节占空比一个周期内高电平的比例越高LED看起来越亮。要让占空比缓慢变化需要两个计数器配合第一个计数器产生PWM周期数到N就回绕波形高电平时间的比例由比较值决定第二个计数器不直接数时钟而是在每个PWM周期结束时改变比较值本身的数值让占空比逐步递增或者递减。第二个计数器本质上也是一个寄存器只是它的更新频率比PWM慢N倍。如果只有一个计数器你只能产生固定占空比的PWMLED亮度固定谈不上“呼吸”。所以呼吸灯设计里两个寄存器的分工是pwmCnt负责快周期duty负责慢变化。理解了这个层次再去看代码就不会晕。3.2 工程搭建与完整代码Chisel工程最省事的方案是sbt。build.sbt里配置好依赖核心就两行scalaVersion : 2.13.10 libraryDependencies edu.berkeley.cs %% chisel3 % 3.6.0 libraryDependencies edu.berkeley.cs %% chiseltest % 0.6.0 % test然后新建一个BreathLED.scala文件把下面的模块拷进去import chisel3._ import chisel3.util._ class BreathLED(period: Int 1000, step: Int 1) extends Module { val io IO(new Bundle { val led Output(Bool()) }) val pwmCnt RegInit(0.U(log2Ceil(period).W)) val duty RegInit(1.U(log2Ceil(period).W)) val rising RegInit(true.B) pwmCnt : pwmCnt 1.U when(pwmCnt (period - 1).U) { pwmCnt : 0.U when(rising) { when(duty (period - 2).U) { rising : false.B }.otherwise { duty : duty step.U } }.otherwise { when(duty 1.U) { rising : true.B }.otherwise { duty : duty - step.U } } } io.led : pwmCnt duty }逐行解释一下。pwmCnt每个时钟加1数到period-1时回0这就是PWM的周期。duty是占空比比较值rising表示当前是亮到暗还是暗到亮的阶段。外部没有输入端口所有状态都在内部循环更新。重点看duty的更新逻辑只有pwmCnt数完一个完整周期也就是pwmCnt period-1那一刻才动一次duty。否则duty保持原值不动。这样LED的占空比是阶梯式变化的每个台阶持续period个时钟周期看起来就是平滑的呼吸效果。step参数控制每个台阶跳多少step越大呼吸越快period越大PWM频率越低呼吸粒度越细。边界条件我做了两层保护duty最高只到period-2最低只到1防止占空比到0后LED完全熄灭也防止到period后高电平占满整个周期那样边界判断会出错。如果你改这个模块这几个边界值一定要重新推理别直接抄。3.3 生成Verilog并检查关键逻辑Chisel写完模块最终还是要生成Verilog交付给FPGA工具或后端流程。用下面这段代码可以导出Verilogimport chisel3.stage.ChiselStage object BreathLEDMain extends App { (new ChiselStage).emitVerilog( new BreathLED(period 1000, step 2), Array(--target-dir, generated) ) }运行后generated目录下会生成BreathLED.v。打开这个文件你会看到Chisel已经把寄存器、复位逻辑、比较器都搭好了。我建议每个写完的模块都生成Verilog看一眼重点确认三件事复位信号接的是不是预期电平寄存器的初值是不是写对了时钟边沿是不是上升沿触发。Chisel帮你自动做的事情不代表你可以完全不校验尤其是要流片或者对接已有RTL时这一眼能帮你省下大量后端联调时间。生成的Verilog里还会有模块级的clock和reset端口这是Chisel为每个Module自动生成的。如果你的SoC顶层已经有自己的时钟复位树接进来就行。Resp信号、使能信号的命名也会基本对齐你在Scala里的代码可读性比一些工具生成的乱码RTL好很多。3.4 chiseltest仿真验证的三个关键点RTL不仿真就上板等于闭眼开车。Chisel官方配套的chiseltest用起来很顺手我以呼吸灯模块为例用一个小period加快仿真验证内部占空比是否在变化import chiseltest._ import org.scalatest.flatspec.AnyFlatSpec class BreathLEDTest extends AnyFlatSpec with ChiselScalatestTester { BreathLED should vary duty cycle in { test(new BreathLED(period 8, step 1)) { dut var high 0 var low 0 for (_ - 0 until 8) { if (dut.io.led.peek().litToBoolean) high 1 else low 1 dut.clock.step(1) } println(sfirst period high$high low$low) for (_ - 0 until 8) { if (dut.io.led.peek().litToBoolean) high 1 else low 1 dut.clock.step(1) } println(ssecond period high$high low$low) } } }这里的关键是把period设得很小8这样仿真几个周期就能看到duty变化。实际工程中period可能是几千几万如果直接仿真完整呼吸过程会跑很多时钟周期浪费调试时间。我的习惯是写一个“小参数版”专门做功能验证确认逻辑没毛病后再用真实参数上板。chiseltest验证时序逻辑有三个关键点要记住第一每个clock.step(1)才推进一个时钟周期组合逻辑的poke要配合step才能看到时序效果第二peek返回的是当前拍的值如果你想看“打一拍后的值”必须在step之后去peek第三内部状态如果不好观察可以临时把关键寄存器接到IO上仿真完再删掉这是最笨但最有效的调试手段。4. 常见问题与排查技巧实录4.1 仿真波形一片X十有八九是寄存器初值刚用Chisel写时序逻辑最容易遇到的问题仿真一跑波形全是X从某个寄存器开始一路红到底。原因八成是用了Reg而不是RegInit导致寄存器没有复位初值。仿真器的X一旦进入加法器、比较器、多路选择器会迅速扩散到所有相关信号上看起来就像整个模块都坏了。排查方法是先看产生X的源头寄存器在代码里搜Reg(确认哪些没有复位值再判断这个寄存器是不是真的不需要初值。我的建议很直接除非你能拍胸脯说“这个寄存器前几拍的值不会影响功能”否则一律改成RegInit。这也是为什么我在第2节反复强调RegInit是主力原语Reg只是特殊场景的补充。4.2 last connect顺序问题when位置不对会“吞掉”分支Chisel的信号连接遵循last-connect语义对同一个信号多次赋值最终生成硬件时写在后边的连接会覆盖写在前边的连接。这个特性在when和普通赋值混用时特别容易踩坑。回到呼吸灯的pwmCnt更新pwmCnt : pwmCnt 1.U when(pwmCnt (period - 1).U) { pwmCnt : 0.U }这段是正确的因为先做无条件加1再在条件分支里清零条件是满足时清零覆盖加1。但如果有人把顺序写反when(pwmCnt (period - 1).U) { pwmCnt : 0.U } pwmCnt : pwmCnt 1.U编译不会报错但功能完全错了无条件加1写在最后导致when里的清零被覆盖计数器永远在加1。这类bug最大的迷惑性在于代码看上去“逻辑上是对的”EEG生成Verilog也不会报警。排查时如果发现某个寄存器的when分支完全没生效先检查它后面的连接顺序。4.3 RegNext多打一拍握手信号错位RegNext用起来很爽但它的语义是“延迟一拍”不是“组合直连”。我在一个握手模块里给valid信号加了RegNext结果对端在同一个时钟沿采样时valid已经被推到下一拍了数据对齐就乱了。这属于典型的“一拍之差天壤之别”。用RegNext打拍之前先画一下时序图源信号在第几个时钟沿变化打拍后在第几个沿被采到目标模块期望在哪个沿看到valid如果目标模块就在下一拍采样那你打了一拍反而错过。数据通路里的打拍可以随便加握手控制信号必须谨慎尤其是valid和ready组合的时序加一拍等于改变了整个握手协议。调试这类问题我习惯把关键信号同时引到IO上观察。比如加两个临时输出一个看原始valid一个看RegNext后的valid仿真一跑就知道错位了几个周期肉眼可见不用瞎猜。4.4 复位策略生成的Verilog里到底是哪种复位Chisel的RegInit能帮你省掉复位逻辑的书写但省掉的只是“写法”不是“选择”。生成Verilog后你需要确认复位类型是不是满足目标平台要求。不同的FPGA或ASIC流程可能偏好异步复位或同步复位这不是一个代码层面的问题而是一个实现层面的约束。最稳妥的做法是生成Verilog后搜索reset相关代码看寄存器复位是posedge reset还是if (reset)。如果你发现默认生成的和项目规范不一致可以用Chisel提供的withReset或自定义复位信号来调整。这块内容展开可以单独写一整篇但新手记住一点就够不要以为RegInit生成的复位方式在所有平台都最优检查一下再集成。4.5 Vec(Reg)用法与多时钟域提醒用Vec写一组寄存器时我推荐始终用VecInit配合Seq.fill做初始化格式统一位宽也清楚val regs RegInit(VecInit(Seq.fill(4)(0.U(8.W))))这里生成的是4个8位寄存器读起来一目了然。有些老版本写法用Reg(Vec(4, UInt(8.W)))在当前Chisel版本里已经不太推荐也容易和Vec(Reg(...))混淆。我的原则是“能明确表达位宽和初值的写法就是好写法”VecInit是绕不开的标准姿势。多时钟域是另一个大坑。Chisel允许在模块内用withClock局部切换时钟比如给某个子模块用PLL输出的慢时钟。但跨时钟域的信号不能直接拍一拍就完事需要专门的同步器结构。Chisel能帮你生成漂亮的寄存器但生成不了时序约束和跨时钟域的工程判断。新手阶段先老老实实跑单时钟域把时序逻辑的基本功打扎实再考虑多时钟设计。写到这里我发现Chisel时序逻辑的学习路线其实很线性先理解寄存器是“带记忆的元件”再掌握Reg、RegInit、RegNext三个原语的选取然后用计数器练手最后用一个呼吸灯把“计数器比较器状态寄存器”串起来这一套走完时序逻辑的地基就算打牢了。我个人每次写新的时序模块都会先在纸上列出状态变量和它们的复位值再动手写代码看似多花五分钟实际能省掉后面两个小时的仿真调试。你接下来如果要学状态机会发现这一章的寄存器思维就是全部的地基地基稳了上面盖什么楼都好办。
返回列表