
Verilog 学到前两篇组合逻辑的那点东西已经练得差不多了。键盘敲熟了always块也知道了assign该怎么用但心里总有个坎真正的芯片设计里哪儿能只靠一堆与或非门就把功能做出来于是第三篇我决定直接上时序电路。这次选了一个最朴素的例子——连加也就是累加器每个时钟周期把输入的数据累加到一个结果寄存器里。看起来简单但时钟、复位、使能、寄存器更新、位宽设计全都会碰到非常适合当成时序入门的第一个动手项目。读完这篇我建议你打开仿真环境跟着跑一遍再找一块入门级开发板点几个灯对这个例子的理解会和只看文章完全不同。1. 连加这个例子为什么值得当成时序入门第一课1.1 从组合逻辑到时序逻辑思路上有哪些变化前两篇写的多半是assign和组合always块。组合逻辑的输出只取决于当前输入给什么信号就出什么结果没有“记忆”。而时序电路最大的不同是输出不仅和当前输入有关还和过去的状态有关。这个“过去的状态”从哪来从寄存器里来。实现连加最自然的需求就是“记住之前加出来的总和”。第1次加5得到5第2次加10得到15寄存器必须把5这个中间结果先存住否则第2次无从算起。这个“存住”的动作就是时序电路的基本功能。所以连加看似简单实际上已经把时序电路最核心的几个要素全占了时钟、复位、使能、寄存器更新。我当初学到这里最大的感受是脑子要从“逻辑表达”切换到“事件驱动”。组合逻辑是数据流输入一变输出就变时序逻辑是事件流只在某个特定时刻时钟边沿发生一次采样和更新。理解了这个转换后面看状态机、计数器、FIFO都不会太吃力。1.2 连加背后要掌握的三个基本概念用生活的例子打比方累加器像是一个自动记账本。时钟就是节拍器每“叮”一下会计把新入账的数据加到这个月的总额上。复位就是老板说“重新记”把所有账目一笔勾销。使能则是“暂停记账”的指令使能无效时哪怕节拍器响一百次账本也不会被改动。第一个概念是时钟。写代码时最常见的形式是always (posedge clk)也就是“当时钟上升沿到来时做某件事”。时钟是一个周期性的方波信号所有时序逻辑都靠它统一节奏。如果没有时钟各个寄存器更新时机不一致整个电路就会乱套。第二个概念是寄存器和复位。寄存器负责存状态一位寄存器就是一个触发器。为了让它有一个确定的起始值必须有复位信号。常见的写法是always (posedge clk or negedge rst_n)这里rst_n是低有效复位平时为高一旦拉低寄存器立刻清成初值这个初值我们在例子里设为0。第三个概念是使能。并不是每个时钟周期都允许累加。有些场景下数据还没准备好或者外部没有“加一次”的指令此时累加器应该保持原样。所以给模块加一个en输入只有en1的时钟上升沿result才会更新。这三个概念没有一个是连加独有的但连加把它们全部串起来了。一个模块跑通等于把时序电路的最小骨架完整搭了一遍。2. RTL实现步骤一个完整可用的累加器怎么写2.1 需求分析和模块引脚设计动手写代码之前先把需求想清楚。假设我希望做一个8位数据输入的累加器输入data_in是每次要加的数输出result是累加结果。因为连续累加多次结果很容易超过8位所以输出宽度设计为16位。模块引脚设计如下信号名方向位宽作用clkinput1全局时钟上升沿触发rst_ninput1异步复位低电平时清0eninput1累加使能高电平有效data_ininput8每次要累加的输入数据resultoutput16累加结果寄存器这里有一个初学者容易忽略的点为什么累加结果位宽要特意放大假设输入数据最大是255理论上连续累加64次最大结果就是255×6416320这个数已经超过14位。如果输出也做成8位累加到256就会自动回绕成0数据直接丢了。所以我选了16位给实际使用留足余量。真正的工程里位宽设计要根据输入最大值和最高累加次数计算不能随手一拍就定。我还习惯把位宽写成参数方便以后复用DATA_WIDTH 8输入数据位宽ACC_WIDTH 16累加结果位宽这样下次想做一个16位输入、32位累加的版本只需要改参数不用改逻辑代码。2.2 RTL代码实现下面是一份完整可用的累加器模块风格是Verilog-2001推荐的入门写法module adder_accumulator #( parameter DATA_WIDTH 8, parameter ACC_WIDTH 16 )( input wire clk, input wire rst_n, input wire en, input wire [DATA_WIDTH-1:0] data_in, output reg [ACC_WIDTH-1:0] result ); // 异步复位时钟上升沿更新 always (posedge clk or negedge rst_n) begin if (!rst_n) begin result {ACC_WIDTH{1b0}}; end else if (en) begin result result {{(ACC_WIDTH-DATA_WIDTH){1b0}}, data_in}; end end endmodule核心逻辑其实只有三句话复位发生时result全清0。没有复位、且en1时result result data_in。en0时什么都没写寄存器保持之前的值。重点解释一下加法那行。result是16位data_in是8位为了做加法我把data_in左边补了ACC_WIDTH - DATA_WIDTH个0变成{{8{1b0}}, data_in[7:0]}这种形式。看起来有点绕实际作用就是把8位数据正确扩展到16位。如果不补位仿真工具通常会按上下文自动扩展也能算对但养成显式扩展的习惯后处理有符号数、乘法、拼接等场景时不容易踩坑。另外注意result声明为output reg因为它在always块里被赋值。如果写成output wire编译阶段就会直接报错。2.3 阻塞赋值和非阻塞赋值的区别这个区分是整个时序电路学习过程中第一个必须掰扯清楚的知识点。我当年在这里栽过跟头仿真结果看起来像“猜谜”。是阻塞赋值是非阻塞赋值。写组合逻辑的always块时用写时序逻辑的always块时用这是行业里默认的规则。赋值方式符号行为特征典型场景阻塞赋值立即生效后面的语句立刻能看到新值组合逻辑非阻塞赋值同一时刻的所有赋值并行调度块结束时才统一更新时序逻辑举个具体例子。如果我在累加器里写成always (posedge clk) begin result result data_in; end逻辑上仿真可能也能“跑过”但一旦代码变复杂比如同一个always块里要同时更新几个寄存器并且彼此之间有先后依赖阻塞赋值就会让各个寄存器的更新顺序变得非常隐晦综合出来的硬件很可能和你脑子里的设计不是一回事。正确的理解是时序逻辑里所有非阻塞赋值在时钟上升沿到来时先统一读取右侧的值然后统一写入左侧。所以哪怕代码里写了两个非阻塞赋值它们的更新在同一拍内是并行的没有先来后到。这也是为什么结果寄存器result能自然地实现“先读旧值再写新值”加号右侧的result读的是时钟沿之前的旧值左侧写的是时钟沿之后的新值。3. 先仿真正验证testbench和工作波形3.1 testbench怎么搭写完RTL别急着上板先把仿真跑通。仿真的作用是在设计早期发现逻辑错误如果直接下载到开发板一旦波形不对你根本不知道是代码问题、接线问题还是时序问题调试成本高得多。仿真需要一个testbench作用是产生时钟、产生复位、产生激励数据然后观察输出。我习惯把时钟周期设置为50ns方便观察。上升沿出现在25ns、75ns、125ns、175ns这些位置我们可以在这些时间点核对累加结果。timescale 1ns/1ps module tb_adder_accumulator; reg clk 0; reg rst_n 0; reg en 0; reg [7:0] data_in 0; wire [15:0] result; // 时钟周期 50ns上升沿位于 25/75/125/175... always #25 clk ~clk; initial begin // 0~60ns保持复位 #60 rst_n 1; // 65ns拉高使能并给出第一个数据5 #5 en 1; data_in 8d5; // 100ns切换数据为10等待125ns时钟沿采样 #35 data_in 8d10; // 135ns切换数据为13等待175ns时钟沿采样 #35 data_in 8d13; // 176ns关闭使能之后保持结果 #41 en 0; #100 $finish; end adder_accumulator #( .DATA_WIDTH(8), .ACC_WIDTH(16) ) dut ( .clk(clk), .rst_n(rst_n), .en(en), .data_in(data_in), .result(result) ); endmoduletestbench里我做了一个很关键的设计所有激励信号的变化都刻意避开了时钟上升沿。比如en和data_in在65ns设置而最近的上升沿在75ns中间留了10ns稳定时间数据从5换成10是在100ns离125ns的上升沿也有25ns间隔。这样做的好处是仿真波形的变化原因清清楚楚不会出现“复位和使能同时变化结果差了一拍”的争议。如果激励信号和时钟沿同时变化仿真时会出现竞争工具可能采样到旧值也可能采样到新值一次能过换一次可能就崩了。这是写testbench最容易忽略、也最不值得踩的坑。3.2 波形和预期对照仿真跑完后直接在波形窗口里拉高clk、rst_n、en、data_in、result这几根信号线预期结果如下时间点事件result 期望值0~60nsrst_n0复位有效065nsen1data_in50时钟沿未到75ns上升沿采样5100nsdata_in1015125ns上升沿采样15135nsdata_in1328175ns上升沿采样28176nsen028 保持这里要特别提一个新手看波形时容易困惑的点**result的变化并不是严格画在时钟上升沿那一瞬间的**。你在波形图上往往会看到时钟沿之后一点点位置result才从旧值跳成新值。这不是电路延迟而是仿真器的“delta cycle”机制非阻塞赋值在上升沿被调度但实际的寄存器更新发生在一个极小的仿真时间片之后。波形图上看起来像是“沿后变化”其实是正常的不用怀疑自己代码写错。如果仿真窗口里出现大片的红色X态首先检查是不是rst_n没有拉高或者data_in没有赋初值。寄存器没有复位、没有初值的时候仿真器没法确定它存在0还是1就会显示为X。我们这份testbench里复位的低电平保持了60ns足够让result稳定到已知值。4. 上板调试实际跑通和遇到的小问题4.1 板级验证方案设计仿真通过了接下来就该让电路在开发板上“活”起来。但有一个现实问题开发板上的晶振通常是50MHz甚至更高如果直接把时钟接进累加器累加速度会快到肉眼完全看不出来LED上的变化就等于一片模糊。所以需要一个分频模块把时钟降到1Hz左右让每次累加都能被肉眼捕捉。分频最简单的做法是用计数器我写了下面这段reg [25:0] div_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 26d0; end else begin div_cnt div_cnt 1b1; end end wire slow_clk div_cnt[25];div_cnt[25]翻转一次的周期大约是 $2^{26}$ 个原始时钟周期。以50MHz估算大约67毫秒翻转一次也就是约1.5Hz的慢时钟。把slow_clk作为累加器的时钟输入就能看到LED按每秒1~2次的速度变化。板级接线建议如下8个拨码开关接到data_in1个按键接到en按下时允许累加1个按键接到复位rst_nresult的低8位接到8个LED如果嫌LED显示二进制不够直观可以外接数码管模块把16位结果转换成BCD码再显示十进制。这块代码逻辑不复杂但涉及比较多的组合逻辑建议等连加跑通了再单独练。4.2 按键消抖和输入抖动实战上板之后最常见的现象是明明只按了一下“累加”按键结果却连续加了好几次。这是因为机械按键在按下和释放的瞬间会有几毫秒至几十毫秒的抖动硬件上表现为电平反复跳变。如果把这些跳变全部当成按键事件累加器自然会被多次触发。解决思路是消抖用计数器连续采样按键电平只有电平稳定一段时间后才认为按键真的按下了再通过边沿检测生成一个单脉冲。写起来并不复杂各家开发板的按键电路不同有的按下为低有的按下为高先看原理图确认极性再决定逻辑。拨码开关其实也有类似问题。人的手拨动开关时金属触点同样会抖动虽然不像按键那么严重但如果恰好在时钟沿附近切换累加器仍然可能采到一个不稳定值。稳妥的做法是在外部打一拍或者保证只在按键按下之后、拨码切换之前完成读数。4.3 慢时钟与真实设计的差别上面用slow_clk作为累加器时钟在演示场景下没有问题但我必须提醒工程上不推荐随便派生时钟。真正的设计里时钟树是全局资源插入过多由计数器生成的慢时钟会带来时序约束混乱、异步跨时钟域等一堆问题。更规范的做法是保持全局时钟不动用“时钟使能”脉冲来控制逻辑。比如分频计数器产生一个高电平仅持续一个周期的脉冲累加器仍使用原始时钟只是en由这个脉冲控制reg [25:0] div_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 26d0; end else begin div_cnt div_cnt 1b1; end end wire en_slow (div_cnt 26d0);然后把en接到en_slow。这样累加器还是工作在统一的时钟域只是每隔 $2^{26}$ 个周期才真正累加一次。这个思路在遇到更大设计时能省掉很多麻烦我强烈建议从入门就建立“单一主时钟 时钟使能”的概念。5. 我踩过的坑希望你绕开5.1 复位和使能同时变化导致丢拍我头一次写testbench时把复位释放和使能拉高放在了同一个时间点结果波形出来result第一拍永远是0感觉“第一个数据丢了”。后来才明白那是信号变化和时钟沿之间产生了竞争复位释放那一瞬间使能信号甚至还没有稳定时序逻辑根本不知道该不该采样。提醒所有刚入门的朋友激励信号不要在时钟沿上做文章。要么在沿之前很早就把电平摆好要么在沿之后几纳秒再切。把变化点放在远离时钟沿的位置可以节省你大量排查时间。5.2 仿真里的X态和位宽回绕第二个坑是仿真窗口满屏X。一开始我以为代码写错了后来排查发现只是data_in在testbench里没有赋初值仿真器一开始就不知道它是0还是1。解决办法很简单reg [7:0] data_in 0;声明时赋初值或者在initial块里一开始就赋值。位宽回绕的问题也值得单独说。如果把ACC_WIDTH改成8位累加结果超过255后无符号数会直接从0重新开始比如255加10结果不是265而是9。Verilog不会报错也不会自动扩容因为位宽是设计者自己定的。是否要做饱和处理取决于应用需求。做数字信号处理时这个现象通常叫“溢出回绕”有时候是致命的有时候反而是期望中的模运算。5.3 时序逻辑里混用阻塞赋值这个坑在简单例子里不一定暴露但一旦代码复杂起来症状就非常诡异。比如在一个always (posedge clk)块里两个寄存器之间用阻塞赋值产生了数据依赖仿真时可能每次结果都一样但综合后的门级电路行为却和仿真不一致。根本原因是阻塞赋值破坏了“时钟沿并行更新”的语义。我的经验是从学习的第一天就把规则定死时序逻辑一律组合逻辑才用。等后面了解SDF仿真、DFT、后仿真这些概念时你会感谢当初这个“死板”的习惯。真要是遇到必须混用的场景先把设计停下来想一想是不是这块逻辑应该拆成组合逻辑单独处理。如果把上面这几个坑都避开连加这个例子就可以算真正跑通了。接下来在它的基础上我计划继续把累加器改造成带饱和处理的版本顺带聊一聊计数器与状态机的雏形。有了这次的经验下一篇应该会轻松不少。