
写时序逻辑这章的时候我脑子里其实有很多想说的。在Chisel里待久了你会慢慢发现组合逻辑只是热身真正让电路“活”起来的永远是时钟沿上那一下跳动。这个框架最舒服的地方在于它把寄存器变成了和Wire一样“一等公民”的硬件原语你可以像声明变量一样声明一个触发器然后专心描述它下一拍该是什么值剩下的复位、时钟、使能这些基础设施都由Chisel帮你接好了。这篇文章我会按自己从入门到实际写RTL的顺序来梳理不是照着文档念概念而是把每一段代码背后“为什么要这么写”都摊开讲清楚。适合刚接触Chisel、已经知道基本组合逻辑语法、准备开始写状态机和数据通路的同学也适合已经在用Chisel但想系统整理一下时序逻辑写法的人。1. 从组合逻辑到时序逻辑Chisel中的Reg到底怎么用1.1 为什么Chisel把寄存器设计成“声明式”而不是“过程式”如果你写过Verilog一定熟悉这种模式脑子里先把always块画出来想清楚敏感列表是posedge clk还是negedge clk复位是高有效还是低有效然后在begin end里面写非阻塞赋值。这套流程本身不难难的是当设计规模变大几百个always块堆在一起信号之间的驱动关系要靠人肉去追踪改一个使能条件就可能漏改某个分支。Chisel换了个思路。它不再让你描述“时钟沿到来时该做什么”而是让你直接描述“这个信号下一拍的值是什么”时钟和复位由框架统一管理。具体到代码上就是实例化一个Reg然后用:给它赋下一个周期的值。这个表述方式更接近硬件本身对新手来说反而比Verilog的always块更容易理解。// 这是一个最简单的寄存器 val r Reg(UInt(8.W)) r : io.d io.q : r这段代码生成的硬件就是8个D触发器时钟连接全局clock每个时钟上升沿把io.d采到r里。注意这里没有出现posedge没有出现时钟信号名但生成的Verilog里其实都补齐了。你用Chisel写时序逻辑时心里其实一直有一个隐形的时钟域全局只有一个时钟在驱动所有寄存器除非你刻意去写多时钟域否则框架默认所有Reg共享同一个clock和reset。1.2 Reg、RegInit、RegNext三个基础原语的区别Chisel里创建寄存器有三种常用姿势新手特别容易搞混。我刚开始用的时候也绕了一阵后来总结出一个记忆方法找Reg前缀看后面的修饰词。原语行为典型场景Reg(UInt(8.W))不带复位值的寄存器数据流中的打拍、可以被外部条件控制的临时缓存RegInit(0.U(8.W))带复位值的寄存器复位后为指定值计数器、状态机、控制寄存器RegNext(io.d)输入信号直接打一拍输出流水线寄存器、同步输入信号RegNext其实是一个语法糖。RegNext(io.d)完全等价于先声明一个Reg(UInt(8.W))再把输入接上最后把寄存器值作为输出声明和赋值一步到位。这种写法最大的价值在写流水线时可以极大减少样板代码。// RegNext和手动写法的区别 val a RegNext(io.en) // 等价于下面三行 val b Reg(UInt(1.W)) b : io.en io.q : b有一个容易忽略的细节RegNext默认没有复位值上电后的初始值是未定义的。如果你的设计里这个信号被用于控制逻辑可能会出现和仿真不一致的行为最好根据需求改用RegInit。1.3 寄存器赋值的“last connect”语义Chisel的赋值规则和普通编程语言不太一样一个信号可以在一段逻辑里被多次赋值最终生效的是最后一次赋值。这个规则在时序逻辑里尤其好用也特别容易踩坑。核心记忆点只有一句话后面的连接覆盖前面的连接。val cnt RegInit(0.U(4.W)) cnt : cnt // 默认保持原值 when(io.en) { cnt : cnt 1.U // 如果en为高覆盖上面的赋值 }这段代码读起来就很自然默认情况下计数器保持只有使能时才加一。如果你用Verilog写逻辑上是一样的但表达上要绕一层if else的嵌套。Chisel这个特性让我在描述有时序优先级的时候特别舒服先给默认值再在最内层做高优先级覆盖。不过要小心一个陷阱如果你在多个并列的when里给同一个寄存器赋值Chisel的“覆盖”规则会按照出现在源码中的先后顺序来决定而不是让你随意排列。这要求代码里必须保持清晰的分支结构不能指望Chisel帮你判断逻辑优先级。优先级是你通过代码顺序表达的。2. 时序逻辑的“积木”计数器、移位寄存器与脉冲生成器2.1 计数器最简单的时序逻辑最容易写错的边界计数器是所有时序逻辑的基础小到分频大到状态机里的拍数统计到处都有它。Chisel里写一个N位计数器非常直接但边界条件一定要想清楚尤其是“什么时候回绕”和“回绕到哪个值”。class Counter(width: Int) extends Module { val io IO(new Bundle { val en Input(Bool()) val clear Input(Bool()) val value Output(UInt(width.W)) }) val cnt RegInit(0.U(width.W)) when(io.clear) { cnt : 0.U } .elsewhen(io.en) { cnt : cnt 1.U } io.value : cnt }我实际用过很多种计数器的写法这个版本是我觉得最舒展的clear拥有最高优先级en次之默认保持。注意cnt : cnt 1.U这里cnt是width位宽的UInt加1之后如果溢出会自动回绕比如4位计数器到15再加1会变回0这是硬件行为的正常表现。如果你需要的是到某个值“停下来”而不是回绕记得自己加比较判断。// 到100就不再增加 when(cnt 100.U) { cnt : cnt } .otherwise { cnt : cnt 1.U }2.2 移位寄存器理解数据在时钟下逐拍流动移位寄存器在数字电路里就像排队买东西——每个人往前挪一个位置队尾补上新来的人。数据在寄存器阵列里逐拍移动是理解时序逻辑“延迟”属性的最直观例子。Chisel里的移位寄存器写起来非常自然因为你可以直接索引一个寄存器向量。class ShiftRegister(val length: Int, val width: Int) extends Module { val io IO(new Bundle { val in Input(UInt(width.W)) val out Output(UInt(width.W)) }) val regs RegInit(VecInit(Seq.fill(length)(0.U(width.W)))) regs(0) : io.in for (i - 1 until length) { regs(i) : regs(i - 1) } io.out : regs(length - 1) }这里RegInit初始化了一个长度为length的向量每一位都是width位宽的寄存器。每次时钟沿到来io.in进入regs(0)原来的regs(0)挪到regs(1)依次类推。最后一级的输出就是经过length个周期延迟的原始输入。我在实际项目里常用移位寄存器做同步器把外部异步信号先打两拍再进内部逻辑用于消除亚稳态。Chisel写这个只需要一行val syncIn RegNext(RegNext(io.in))连续两级打拍中间信号自动生成。这种抽象能力是Verilog很难比的你根本不需要关心中间那级寄存器的名字。2.3 脉冲生成器边沿检测与单周期脉冲时序逻辑中有一类高频需求是生成单周期脉冲。比如按键按下时产生一个时钟周期的高电平或者某一个外部使能信号的上升沿触发一次总线操作。Chisel里检测上升沿的逻辑可以浓缩为“当前值与非当前前一拍的值相与”。val d RegNext(io.in) val risingEdge io.in !d // 上升沿脉冲 val fallingEdge !io.in d // 下降沿脉冲这个模式一旦用熟你会发现自己几乎不用再写独立的边沿检测模块。任何地方需要触发信号直接声明一个RegNext然后比较当前值和寄存值。生成单周期脉冲的要点是寄存器d保存的是上一拍的值当前值等于1且上一拍等于0则必然是一个刚发生的上升沿。脉冲只持续一个周期因为下一拍d会变成1条件不成立。我在写UART接收逻辑的时候就用这种方式检测起始位的下降沿然后启动波特率计数器。如果没有这个边沿脉冲接收状态机就不知道该从哪个时刻开始采样数据。3. 状态机和流水线从“有什么状态”到“怎么切状态”3.1 枚举状态用ChiselEnum或Enum定义状态集合状态机是时序逻辑最典型的应用场景。在Chisel里写状态机第一步是定义状态集合。我强烈推荐使用ChiselEnum而不是Enum前者是类型安全的编译器会帮你检查比较是否合法后者只是整型别名用起来容易出类型不匹配的低级错误。object FsmState extends ChiselEnum { val idle, run, done Value } class SimpleFsm extends Module { val io IO(new Bundle { val start Input(Bool()) val finish Input(Bool()) val state Output(FsmState()) }) val state RegInit(FsmState.idle) switch(state) { is(FsmState.idle) { when(io.start) { state : FsmState.run } } is(FsmState.run) { when(io.finish) { state : FsmState.done } } is(FsmState.done) { state : FsmState.idle } } io.state : state }这段代码看起来很像普通的程序语言但你要时刻提醒自己状态寄存器在时钟沿更新switch里的所有分支都是组合逻辑它们共同决定的是“下一个时钟沿之后状态变成什么”。理解这个区别是写状态机的基础。3.2 三段式状态机为什么要分开写状态转移和输出早期写Verilog状态机前辈总强调“三段式”状态转移、次态逻辑、输出逻辑分开。Chisel天然鼓励这种写法而且比Verilog更规整。我用下来的体会是三段式不是为了代码好看而是为了让你不会在状态多时把自己绕晕。第一段负责描述状态机的跳转条件第二段负责计算输出组合逻辑第三段用寄存器把输出打一拍。// 状态转移 val state RegInit(FsmState.idle) // 次态逻辑 val nextState WireDefault(state) switch(state) { is(FsmState.idle) { when(io.start) { nextState : FsmState.run } } is(FsmState.run) { when(io.finish) { nextState : FsmState.done } } is(FsmState.done) { nextState : FsmState.idle } } state : nextState // 输出组合逻辑 val dataEn state FsmState.run // 输出寄存器打拍多周期信号对齐 val dataEnReg RegNext(dataEn)这种写法的好处有两个第一状态转移逻辑集中在一个地方改条件时不容易遗漏第二组合输出和寄存输出分开你可以灵活决定输出是纯组合还是需要对齐到时钟周期避免毛刺传播。3.3 流水线寄存器的本质把组合逻辑切段流水线可能是时序逻辑最优雅的应用。核心思想很简单一个大的组合逻辑路径延迟太高会拉低时钟频率所以把它切成几段每段之间用寄存器隔开。数据从一段流到下一段虽然单个数据经过全部路径的延迟变大了但整条电路可以更高的时钟频率运转吞吐量跟着上去了。Chisel里加一级流水线寄存器实在太轻松了就是加一个RegNext的事。写乘加单元时我常常先在组合逻辑里写完功能验证正确后再用RegNext把关键路径拦住这一步优化在Verilog里需要改动always块结构在Chisel里只加一行。val mulResult RegNext(a * b) // 乘法结果打一拍 val addResult RegNext(mulResult c) // 再加一次打拍要特别注意的是流水线会引入延迟数据的“因果关系”会跨周期。如果你后面有控制逻辑依赖于运算结果必须把对应的控制信号也打同样多的拍数对齐否则会出现数据和控制错位的幽灵bug。4. 时序逻辑的物理现实复位、亚稳态与多时钟域4.1 复位的两种风格同步复位和异步复位怎样选写时序逻辑逃不开复位。Chisel默认的复位是同步复位也就是说复位信号在时钟上升沿到来时被采样然后才作用到寄存器上。生成Verilog时看到的会是if (reset)写在always (posedge clock)内部。同步复位的优点是可以被综合工具当作普通数据信号处理时序分析简单缺点是复位信号必须满足建立保持时间如果复位信号本身有毛刺可能采不到。实际项目里经常用异步复位因为复位不依赖时钟系统上电时即使时钟还没稳定复位也能生效。Chisel也支持异步复位只要定义一个AsyncReset类型的复位信号然后把寄存器的复位类型匹配上。class AsyncResetCounter extends Module { val io IO(new Bundle { val en Input(Bool()) val out Output(UInt(8.W)) }) val rst IO(Input(AsyncReset())) withReset(rst) { val cnt RegInit(0.U(8.W)) when(io.en) { cnt : cnt 1.U } io.out : cnt } }不过我不建议在同一个设计里混用两种复位风格统一才有可维护性。而且异步复位释放的时候要注意同步处理不然可能引起亚稳态。4.2 亚稳态为什么外部信号必须打两拍跨时钟域信号进入本地时钟域时如果建立时间或保持时间得不到满足触发器的输出会进入一个不确定的高频振荡状态称为亚稳态。这个过程不可消除只能降低发生概率。最经典的解法就是两级同步器把外部信号连续打两拍再去使用。Chisel里写两级同步器我前面提到了就一个RegNext(RegNext(...))。有很多人问为什么不用三级本质上可以但多一级只是把概率再压低几个数量级同时多引入一个周期的延迟。对于绝大多数单bit控制信号两级足够了如果还不够那这个系统本身就需要重新思考。val syncA RegNext(RegNext(io.asyncIn))要注意leo.asyncIn如果本来就是一个多bit总线不能用简单的打拍同步因为总线各位之间的到达时间不一致会导致采样到错误数据。多bit跨时钟域要用异步FIFO或者握手协议。4.3 多时钟域Chisel里时钟应该显式管理Chisel有withClock、withReset、withClockAndReset这几个域管理工具可以在一个模块里创建多个时钟域。但我要给出的建议是不到万不得已不要自己手动切时钟复杂性远远超过你省掉的那个FIFO。多时钟域本质上是异步设计问题除了亚稳态问题还涉及复位同步、时钟门控、时序约束等多方面。真正需要多时钟域的场景比如CPU前端和后端频率不同、和外部低速设备通信老老实实生成不同时钟域的模块然后用异步FIFO对接。在Chisel里写异步FIFO本身是完全可行的但绝不能把两个时钟域的寄存器直接混在同一段代码里那会让生成的硬件不可综合仿真也不可预测。5. 从Chisel看生成的Verilog反查时序设计的关键路径5.1 用一条命令导出Verilog养成自查习惯Chisel的一个优势是你随时能导出等价Verilog看一眼生成结果就能反推自己写的时序逻辑到底映射成了什么硬件。(new ChiselStage).emitVerilog(new Counter(4), Array(--target-dir, generated))这段代码会打印出一个4位计数器的Verilog。我强烈建议每个初学者在写完时序模块后都执行一下这个命令看看自己的寄存器是否真的如预期被推断为带复位的触发器。有一次我以为自己写了一个寄存器结果导出后发现原语是RegEnable那其实是一个带使能的寄存器风格功能没错但它没有寄存器复位差点让上电后的行为不可控。5.2 常见映射关系速查Reg到always块Chisel的表达和Verilog有一一对应的关系。看清这个映射关系后你在调试的时候就不会再怀疑框架帮你做了多余的事。// Chisel: val cnt RegInit(0.U(4.W)) reg [3:0] cnt; always (posedge clock) begin if (reset) begin cnt 4h0; end else if (io_en) begin cnt cnt 1h1; end endObserveChisel生成的always块敏感列表只有时钟沿复位是内部的if。如果你设置了异步复位生成的会是always (posedge clock or posedge reset)。用这个差异可以快速确认你当前的复位风格。5.3 通过Verilog排查时序逻辑的三种异常第一类是寄存器被优化掉了如果你对一个寄存器的输出什么都没接生成Verilog中会被综合工具优化掉但Chisel阶段不会它还是会生成对应的reg。如果你的设计里某个寄存器在生成的Verilog里消失那一定是后面没有任何逻辑使用它。第二类是优先级反了Chisel的last connect语义在生成Verilog后表现为前一个分支的if嵌套在外层后一个分支嵌套在内层。查看生成代码时注意复位、enable的逻辑顺序和你的预期是否一致。第三类是组合逻辑锁存器当你用Wire在when里条件赋值时生成Verilog可能会有一个latch这种锁存器设计上要避免Chisel时报warning务必不要忽略。6. 我踩过的Chisel时序逻辑坑附排查速查表6.1 给Wire赋默认值时时序逻辑也容易中招组合逻辑里的Wire如果没有默认值或不全分支赋值Chisel会推断成锁存器。时序逻辑里Reg本身没有这个问题因为Reg在未激活时保持旧值正好符合寄存器语义。但时序逻辑里用Wire来作中间信号时就容易翻车特别是一个Wire在when的分支中只部分赋值。我一般遵循两条规则可以避免绝大多数锁存器问题第一所有组合逻辑Wire在创建时立刻赋一个默认值比如WireDefault(0.U)第二没有任何分支能覆盖到的路径必须有一个显式赋值不要依赖“刚好没被assign”的情况。val nextCnt WireDefault(cnt) when(io.en) { nextCnt : cnt 1.U } cnt : nextCnt6.2 复位之后忘记赋值仿真和上板行为不一致RegInit(0.U)可以保证复位后是0但Reg(UInt(8.W))没有复位值上电后的值是未知的。在仿真时默认是x如果你有一段逻辑依赖这个寄存器行为很难预测。上板时FPGA芯片往往有全局初始值有时是0和不依赖初始值的逻辑碰在一起就会出现仿真正确、上板偶发失效的怪现象。我在写跨模块接口时吃过一次亏一个状态标志寄存器用了Reg本来以为每次使用前都会赋值结果上电后的第一次判断被这个未定义的值带偏了。从那以后凡是不是纯数据流的寄存器我都用带复位的写法。6.3 问题排查速查表现象可能原因排查方法寄存器输出上电为x用了Reg没有初始化改用RegInit带复位值时序提前或延迟了一拍流水线寄存器没有对齐控制信号把控制信号打同样拍数上板行为与仿真不一致复位释放不同步使用两级同步的复位释放电路组合逻辑出现latchWire创建时未给默认值用WireDefault时钟频率上不去组合路径关键路径延迟太高插入RegNext流水线多bit总线乱码跨时钟域采样改用异步FIFO生成代码里没有寄存器输出没有被任何逻辑使用检查寄存器后面是否接入io6.4 给时序逻辑写测试时的小技巧ChiselTest里测时序逻辑比Verilog的testbench舒服很多你可以直接用step(1)前进一个时钟周期然后用expect断言某个寄存器的值。但是有个细节如果测一个需要等待好几拍的电路我会先写一个Scala层的辅助函数把“等待并使能”这种重复序列封装起来。另一个很重要的小技巧是利用println打印关键寄存器的值虽然很多人觉得打印不是严谨验证但波形配合打印调试复杂时序状态机找bug效率极快。test(new Counter(4)) { c c.io.en.poke(true.B) c.clock.step(5) c.io.value.expect(5.U) }7. 回到实践给状态机加一个超时看门狗最后分享一个我实际在单片机风格外设里用过的结构给一个状态机包一个超时计数器。组合逻辑里的状态跳转等待外部信号如果外部信号一直不来整个系统卡住。这时就需要一个独立于主状态机的寄存器在每个状态都检查超时。val timeoutCnt RegInit(0.U(16.W)) val timeout timeoutCnt 10000.U when(state FsmState.run !io.finish) { timeoutCnt : timeoutCnt 1.U }.otherwise { timeoutCnt : 0.U } when(timeout) { state : FsmState.idle }这个例子看起来很简单但它同时用到了带复位寄存器、条件更新、比较判断还有和主状态机的并行更新。你可以体会一下主状态机的跳转条件和看门狗计数器的更新是同时发生的它们互不干扰但都挂在同一个全局时钟下。这正是Chisel时序逻辑的核心心智模型——并行声多个时序过程除法给它们各自的更新规则而不是像软件那样一行行串行执行。有人问我学了Chisel是不是就不用关心寄存器传输级原理了恰恰相反。Chisel只是把基础设施的繁琐隐藏了建立时间、保持时间、亚稳态、跨时钟域、组合逻辑深度这些东西一个都不会少。你在Chisel里写一条RegNext心里必须清楚它在物理上是什么只是不需要在乎它是什么时候被更新那种手写always块的细节了。这是我用了几年Chisel之后觉得它最值得称赞的地方——省去重复劳动逼你去思考真正的电路结构。