
DDCA 这门课我从学生时代啃到工作后用第三课时序逻辑是整本书的分水岭。前两课的组合逻辑再厉害输出也只看当前输入说白了电路没有“记性”。到第三课时钟一进来触发器一拍一拍地存状态计算机里那些能干活的电路才真正立起来。这篇文章我按自己理解的 DDCA 第三课主线来展开先讲为什么需要时序逻辑再把锁存器、触发器、寄存器这些核心概念拆开揉碎之后用 SystemVerilog 实际写几个时序电路最后聊聊状态机设计和仿真排查时最容易踩的坑。不管你是刚学数字设计还是正在补体系结构的基础这篇应该都能帮你把时序逻辑这条线串起来。1. 为什么数字电路必须要有时序逻辑1.1 组合逻辑解决不了“记忆”问题前两课里我们搭的加法器、多路选择器、译码器输入一变输出马上变这叫组合逻辑。组合逻辑确实快但它有个致命短板它不记得任何历史。你给它一个 1它输出一个结果你把这个 1 撤走输出立刻没了仿佛一切都没发生过。可真实世界的计算根本不是这么回事。程序计数器要记得下一条指令在哪寄存器堆要保存中间计算结果CPU 要记得当前执行到哪条流水线阶段网络缓冲区要留住一帧还没处理完的报文。这些东西全要求电路具备“记忆”能力。而记忆的本质就是把某个时刻的状态锁住让它在下一次需要更新之前保持不变。时序逻辑就是为这个而生的。它的核心套路是用时钟作为全局节拍在每个时钟沿把输入采样进来存入存储单元输出的变化就发生在时钟沿这个瞬间其他时间输出保持稳定。这样一来电路就能按节拍一步一步往前走一步一个脚印也就有了“状态”的概念。1.2 从“算得块”到“走得稳”的设计观转变学组合逻辑的时候关注的是延迟有多大、用了多少门、能不能化简。到了时序逻辑关注点变成了另一套时钟频率能跑多高、触发器之间能不能满足建立时间、状态转移对不对、复位能不能可靠地回到初态。这套关注点的转变我称之为“从算得快到走得稳”。为什么这么说因为时序逻辑把整个系统切成了一个个小的组合逻辑块块与块之间用触发器隔开。时钟沿一到所有触发器同时锁存新值然后组合逻辑开始计算算完结果在下一个时钟沿再次被锁存。整个设计能不能正常工作取决于两件事组合逻辑的运算时间必须小于一个时钟周期数据在时钟沿前后必须稳定够长的时间满足触发器的建立时间和保持时间。这就像接力赛跑前一棒必须把接力棒稳稳交到后一棒手里交早了不行交晚了更不行。第三课把时序逻辑讲透后面学寄存器文件、ALU、存储器、流水线 CPU 的时候才会顺。很多同学学到第五六课突然开始看不懂回头一查基本都是第三课里触发器、建立时间、状态机这些概念没真正吃透基础没打牢后面全是空中楼阁。2. DDCA 第三课的几个核心概念逐个拆开说2.1 锁存器和触发器电平敏感与边沿敏感的差异锁存器和触发器都是存储元件但工作方式完全不同。锁存器对电平敏感使能信号有效时输出跟随输入使能信号无效时输出锁存触发器对边沿敏感只在时钟上升沿或者下降沿的那一刻采样输入其余时间输出完全不受输入影响。DDCA 里花了不小篇幅讲这两者的区别我认为这是第三课最重要的概念分岔口。为什么这么说因为工程上几乎只推荐用触发器锁存器在可综合的数字设计中是尽量要避免的。原因很直接锁存器的透明窗口太随意容易产生毛刺而且时序分析困难触发器只有沿触发这一个瞬间采样时序关系清清楚楚建立时间和保持时间一算就知道能不能收敛。实际动手的时候尽量不要自己写锁存器综合工具在某些情况下会自动推断出锁存器这往往意味着代码风格有问题比如在 always_comb 块中某个分支漏写了赋值。看到综合报告里出现 latch 警告第一反应应该是检查代码而不是觉得“有个锁存器也不错”。我在实际项目里凡是出现 latch 地方八九成都是写漏了 else 分支。2.2 D 触发器最常用的存储单元为什么是它触发器家族里有 SR、JK、D、T 好几种DDCA 里重点讲 D 触发器因为它是现代数字设计事实上的标准存储单元。原因不复杂D 触发器输入只有一个 D当时钟沿到来时Q 无条件更新为 D 的值规则极其简单没有 SR 触发器那种“S 和 R 同时为 1 是非法输入”的麻烦也没有 JK 触发器那种需要背功能表的负担。D 触发器的行为描述起来就一句话Q D而且只在时钟沿更新。这个简单特性让它特别适合放在组合逻辑块之间当“关卡”。你用任何 HDL 写时序逻辑最后综合出来的存储元件基本都是 D 触发器。FPGA 中每个逻辑单元里也内建了 D 触发器想避开都用不掉。D 触发器还有一个重要特性是采样瞬间对数据稳定性的要求前面提到过这里再展开说时钟沿到来之前数据需要提前 Tsetup 时间稳定下来这叫建立时间时钟沿到来之后数据还需要继续保持 Thold 时间不变这叫保持时间。如果这两个条件不满足触发器输出可能进入亚稳态也就是既不是 0 也不是 1甚至振荡不定。第三课反复强调这两个参数是因为它们直接决定了最高工作频率的上限也决定了跨时钟域设计要如何处理。2.3 寄存器多个触发器打包干活单个触发器只能存 1 比特一组触发器放在一起共享时钟和复位就能同时锁存多位数据这就是寄存器。比如一个 32 位的寄存器内部就是 32 个 D 触发器并行时钟沿一到32 个 D 输入同时被打进各自触发器里。寄存器在数字电路里到处都是指令寄存器、状态寄存器、通用寄存器、流水线寄存器。DDCA 第三课讲寄存器其实是在为后面讲寄存器文件和 CPU 数据通路做铺垫。你写 HDL 时无论用一个logic [31:0]还是定义一堆单 bit 信号只要放进always_ff块里更新综合出来就是一组触发器。写寄存器的时候有个细节要养成习惯所有寄存器都复位到一个已知状态。否则上电以后寄存器里可能是随机值状态机可能跑到“黑洞”状态导致整个系统卡死。复位方式有同步复位和异步复位之分后面我会专门写。2.4 同步时序逻辑的设计原则DDCA 第三课反复强调“同步时序逻辑”这个词组值得好好理解。同步的意思是所有触发器共享同一个时钟每个时钟沿大家都在同一时刻更新不同步的电路叫异步逻辑处理起来就麻烦得多。同步设计的好处是可以用一个统一的时序尺度去分析整套电路。时钟周期定了你只需要确保每个触发器之间的组合逻辑路径延迟小于周期减去建立时间即可其余的事情都可以交给工具。而异步逻辑没有统一时钟信号到达的时间可能千差万别分析复杂且容易出问题。所以我一直和新手强调能写同步逻辑就写同步逻辑跨时钟域时再使用专用的同步器或者异步 FIFO 来处理不要试图在一个 always 块里混两个时钟更不要用组合逻辑产生时钟去驱动其他寄存器。那样做会把系统的时序收敛变成一个噩梦。3. SystemVerilog 动手写时序逻辑从计数器到序列检测器3.1 计数器时序逻辑的 Hello World计数器是时序逻辑里最经典的入门例子也是实际工程里用得最多的模块之一。分频、定时、统计、生成地址全都要计数。我先写一个带同步使能和异步复位的 4 位计数器module counter_4bit #( parameter N 4 ) ( input logic clk, input logic rst_n, input logic en, output logic [N-1:0] q ); always_ff (posedge clk or negedge rst_n) begin if (!rst_n) q 0; else if (en) q q 1b1; end endmodule这个代码里有几个关键点always_ff是 SystemVerilog 里专门用来描述时序逻辑的过程块(posedge clk or negedge rst_n)表示在时钟上升沿或者复位下降沿触发rst_n是低电平有效的异步复位端点。写法上有几个容易踩坑的地方我和大家详细说说。3.2 非阻塞赋值到底为什么非用不可上面的计数器里我用的是非阻塞赋值。这是时序逻辑 HDL 的绝对底线规则。非阻塞赋值的语义是右侧表达式的值先计算但不会立即更新到左侧变量而是放到一个“待更新事件”里等到当前时间步结束时统一赋值。这样写出来的结果就是一个 always_ff 块里的所有寄存器在看到的值都是时钟沿“之前”的旧值更新行为就像一组真正的 D 触发器同时动作。如果换成阻塞赋值赋值会立即生效后面的语句马上就能看到新值这就破坏了“同一时钟沿所有触发器同时更新”的语义仿真结果会和实际硬件行为不一致。举个最简单的例子两个寄存器做互传信号a b; b a;在阻塞赋值下两个变量都变成 b 的旧值这在硬件上根本不可能发生用非阻塞赋值a b; b a;才能正确实现互换。我知道很多同学一开始总是记不住这个规则我当时的笨办法是只要在 always_ff 里看到寄存器赋值就条件反射写写完再回头检查为什么合法、为什么必须这样。这个习惯后来一直带到了现在写 RTL。3.3 同步复位与异步复位的区别代码怎么看计数器代码里用的是异步复位or negedge rst_n出现在敏感列表里意味着复位信号一变触发器就立刻被复位不需要等时钟沿。如果只写always_ff (posedge clk)复位信号放在 if 里判断那就是同步复位只有时钟沿到来时复位条件才被检查。两格比较一下// 异步复位 always_ff (posedge clk or negedge rst_n) begin if (!rst_n) q 0; else ... end // 同步复位 always_ff (posedge clk) begin if (!rst_n) q 0; else ... end从代码上看差别只在敏感列表但实际行为差别很大。异步复位的好处是复位响应快不受时钟限制适合在系统上电时快速把电路拉回初始状态缺点是对复位信号的毛刺很敏感容易误复位。同步复位更干净能够滤掉毛刺但必须保证复位信号足够宽至少要超过一个时钟周期不然可能漏采。工程上常见做法是用异步复位做全局上电复位在复位释放时做同步释放处理兼顾快速上电和去除毛刺两个目标。不过作为第三课的学习重点你先把这两种写法都能看懂、能自己写出来就足够了。3.4 移位寄存器体会数据的“打拍”流动移位寄存器是另一个很有教学意义的时序电路。它是把一串触发器串联每个时钟沿数据从上一个触发器流到下一个就像一排人传话每拍传一格。module shift_reg #( parameter W 8 ) ( input logic clk, input logic rst_n, input logic sin, output logic sout ); logic [W-1:0] regs; always_ff (posedge clk or negedge rst_n) begin if (!rst_n) regs 0; else regs {regs[W-2:0], sin}; end assign sout regs[W-1]; endmodule这个模块做的事很直白每个时钟沿把低 W-1 位整体往高位移一位新的 sin 数据填进最低位最高位输出出去。你可以把它理解成数据的延迟线——输入从进去到出来经历了 W 个时钟周期。移位寄存器在信号处理、串并转换、流水线打拍这些场景里特别常用。比如在跨时钟域同步器里最常见的做法就是两个触发器串联打两拍本质就是一个 2 位移位寄存器。我经常用这种思路给新手解释为什么跨时钟域要打拍不是随便打两拍而是要让亚稳态在不引发错误传播的前提下有足够时间稳定下来。4. 状态机设计实操状态转移表、编码风格与三段式写法4.1 从状态转移图到可综合代码有限状态机是第三课的重头戏。DDCA 里给出了非常标准的设计流程先根据需求画状态转移图然后整理成状态转移表确定状态编码最后把状态机用 HDL 写出来。这个流程任何时候都不要跳步尤其在状态比较多的情况下先画图再写码能省大量调试时间。画状态转移图的时候每个状态看成一个节点每条转移边标上输入条件和输出。我先用一个例子走一遍设计一个检测输入序列 101 的 Moore 型状态机要求重叠检测。所谓重叠就是说输入序列 10101 时应该在出现 101 和出现第二个重叠的 101 时各输出一次。状态分 4 个S0还没有匹配到任何有效前缀S1已经匹配到前缀 1S2已经匹配到前缀 10S3已经匹配到完整序列 101输出 1转移关系这样定S0 遇到 1 进 S1遇到 0 留在 S0S1 遇到 1 继续留在 S1遇到 0 进 S2S2 遇到 1 进 S3遇到 0 回 S0S3 遇到 1 回 S1遇到 0 回 S0。这里要注意 S3 收到 1 时回 S1是因为“101”后面这个 1 可以当作下一个序列的起始 1实现重叠检测。4.2 状态编码方式二进制码、独热码和格雷码的选择状态编码是状态机设计里一个实际优化问题。常见方式有二进制编码、独热码和格雷码。二进制编码就是给状态依次分配连续整数的二进制值状态数少的时候最省触发器比如 4 个状态用 2 bit 就够。缺点是相邻转换时可能有多位同时翻转组合逻辑稍复杂。独热码是每个状态用单独一个 bit状态数是多少就需要多少 bit4 个状态就是 4 bit但任意时刻只有一个 bit 为 1。独热码的优点是次态逻辑特别简单常常不需要译码速度更快代价是浪费触发器。格雷码是相邻状态间只有一位变化适合连续递增递减的状态机能减少毛刺但状态分布不规则。在 FPGA 里触发器资源相对充裕很多设计喜欢用独热码在 ASIC 里面积敏感更多用二进制编码。DDCA 第三课对这部分涉及不算太深但作为从业者这个取舍你是早晚要面对的。我的建议是状态少且逻辑复杂时优先独热码状态多而转移规律时优先二进制涉及连续计数且对噪声敏感的场合再考虑格雷码。4.3 两段式状态机代码示例我习惯用两段式写状态机第一段时序逻辑负责状态寄存器的更新第二段组合逻辑负责计算次态和输出。这里的 101 检测器可以这样实现module seq_detector ( input logic clk, input logic rst_n, input logic x, output logic z ); typedef enum logic [1:0] {S0, S1, S2, S3} state_t; state_t state, next_state; // 第一段状态寄存器 always_ff (posedge clk or negedge rst_n) begin if (!rst_n) state S0; else state next_state; end // 第二段次态组合逻辑 always_comb begin unique case (state) S0: next_state x ? S1 : S0; S1: next_state x ? S1 : S2; S2: next_state x ? S3 : S0; S3: next_state x ? S1 : S0; default: next_state S0; endcase end // Moore 型输出只由当前状态决定 assign z (state S3); endmodule两段式的好处是时序逻辑和组合逻辑分得清楚状态转移的意图一目了然。次态逻辑用always_comb和unique case综合工具能检查是否有并列分支冲突。输出直接 assign 组合逻辑适合 Moore 型状态机。如果是 Mealy 型状态机输出还依赖当前输入那就需要把输入也感知进去实现时要多留意时序上会不会产生毛刺。4.4 Moore 与 Mealy 状态机的取舍DDCA 第三课一定会对比 Moore 机和 Mealy 机。Moore 机的输出只和当前状态有关好处是输出稳定在一个完整周期内不会因输入跳变而抖动缺点是同样功能往往需要更多状态实时性略差。Mealy 机的输出不但看状态还看输入可以用更少的状态完成同样任务而且对输入的反应更及时但组合逻辑输出容易产生毛刺。实际工程里怎么选我的经验是需要稳定输出、不会因为组合逻辑竞争冒险影响后续电路时优先 Moore需要快速响应输入、状态数敏感时考虑 Mealy但输出最好用寄存器打一拍再用也就是“寄存器输出”的技巧能有效去除组合毛刺。这个思路写代码时可以在状态机之外再加一个 always_ff 块对输出打拍代价是多一个周期的延迟但可靠性提升很明显。5. 时序电路常见问题与排查技巧实录5.1 仿真器里全是 X寄存器没复位这是新手最容易遇到的情况。代码写好了仿真一跑q 信号从头到尾都是红色的 X逻辑跟死了一样。原因十有八九是寄存器没有复位或者复位写法有问题。X 意味着未知值综合成电路后对应寄存器上电不确定的实际状态。排查方法很直接查每个 always_ff 块最前面有没有复位逻辑查复位信号有没有正确连到模块端口查激励文件里复位有没有被拉低再拉高。如果代码里用了异步复位别忘了在仿真时序模型里复位信号需要足够宽而且最好在时钟沿附近远离建立保持时间窗口的位置释放仿真结果才稳定。我遇到过一种很隐蔽的情况复位信号本身在顶层是好的但内部模块在例化时忘记接rst_n仿真器默认给悬空输入一个高阻 Z复位永远无效于是整个模块都是 X。这种问题用波形里查模块实例端口有没有连接一查一个准。5.2 阻塞赋值和非阻塞赋值混用仿真结果和预期不一致混用阻塞和非阻塞赋值是时序仿真里最经典的坑。比如在同一个 always_ff 块里对同一个信号先阻塞赋值再非阻塞赋值或者两个信号分别用不同赋值方式很容易导致仿真行为不可预测而综合出来的网表可能和你仿真时看到的完全是两回事。这里有一个通用规则always_ff 的时序逻辑块里所有寄存器赋值全部用非阻塞always_comb 的组合逻辑块里全部用阻塞。规则简单到不需要动脑按这个来绝大多数赋值相关的仿真问题都不会出现。如果非要在时序逻辑里写阻塞赋值请务必在代码注释里写明意图但说实话我很少看到合法需要这么做的场景。5.3 建立时间和保持时间不够系统跑起来偶尔出错时序收敛问题是时序逻辑进阶阶段绕不开的坎。一个系统功能仿真完全正确但上了板或者在高速场景下就是偶尔出错时间随机复现困难这种时候往往就是时序问题。建立时间不够通常是因为某个组合逻辑路径太长延迟超过了一个时钟周期。解决思路是把组合逻辑拆分插寄存器也就是流水线化或者降低时钟频率或者优化组合逻辑深度。保持时间不够通常是因为触发器之间的路径太短数据变化太快常见于移位寄存器、并联接口等场景解决手段往往不是插寄存器而是调整综合约束和布局。实际项目中建立时间违例比保持时间违例更常见因为大多数设计是性能受限的。DDCA 第三课讲的时序参数公式很简单最快时钟周期大于等于触发器延迟、组合逻辑延迟和建立时间的总和。这个公式千万别觉得只是考试用很多系统跑不起来的根因都可以从它推导出来。5.4 亚稳态和跨时钟域处理最好从第三课就建立意识亚稳态是时序逻辑的“物理诅咒”。当触发器的建立时间或者保持时间被违反时输出可能停留在既不是 0 也不是 1 的中间态并且这个状态会向下游传播可能导致一系列逻辑错误。在单时钟域同步设计里只要时序约束正确亚稳态理论上不会出现但一旦涉及跨时钟域亚稳态就成了必然要考虑的问题因为另一个时钟域的信号边沿和本地时钟沿的关系是随机的总有概率落在建立保持窗口内。后面课程讲异步 FIFO、握手协议、脉冲同步器时都会回到这里。我现在想提前强调一个习惯在写时序逻辑前先问自己这个信号是不是从别的时钟域进来的如果是就要先用两级触发器同步或者走异步 FIFO不要直接接进 always_ff。这个意识越早建立后面踩的坑越少。5.5 仿真工具和波形排查的基本操作思路最后给新手一个非常实用的排查流程。时序逻辑出问题最好的工具不是盯代码而是看波形。我平时的节奏是代码写完先跑功能仿真然后把内部关键信号全部拉出来看。看波形时重点看三样东西时钟沿和数据变化是否能对上状态机的 state 变量是否按预期转移控制信号使能是否在正确周期有效。用开源工具的组合是 iverilog 加 GTKWave写 SystemVerilog 的也可以用 Verilator 跑仿真或者用它转 C 做快速验证。如果仿真能跑但结果不对建议一行行单步看每个 always_ff 块里各寄存器的旧值和新值。时序逻辑的调试本质上就是在时间轴上对表对不上的地方就是问题所在。从第三课往后数字设计的学习重心就渐渐从“搭电路”转向“管理时间”。组合逻辑管的是逻辑正确时序逻辑管的是节拍正确。很多同学学到这里会觉得抽象我觉得最好的办法就是多动手、多画波形图把 D 触发器的特性真正变成直觉。等到后面学到流水线 CPU你会发现所有复杂的架构设计本质上都是在做时序逻辑的排列组合那时候你会感谢当年把第三课啃透的自己。