Verilog描述电路与信号流动:从赋值方式到SPI仿真实践
说到Verilog很多刚入门的同学第一反应是“编程语言”敲代码、写if-else、做循环。但干过几年数字IC或FPGA的人心里都清楚Verilog本质上是用来“画电路”的只不过我们画的不是原理图而是用文本去描述硬件结构、描述信号在硬件里的流动轨迹。这也是项目标题“Verilog描述电路 Verilog描述信号的流动”想表达的核心一门硬件描述语言两个观察维度。你既可以站在结构视角把一个模块实例化成另一个模块的子模块层层例化搭出整块芯片也可以站在行为视角把每个always块、每个assign语句看作信号传播的通道描述数据从输入引脚到输出引脚之间经过的每一步变换。这篇文章我想认真聊透这两条主线。我会从设计思路讲到代码实操再到仿真波形分析最后把我在实际项目里踩过的几个典型问题一并整理出来。适合正在学Verilog语言入门教程的同学也适合已经写完不少模块、但对“为什么这么写能综合成电路”还有点模糊的工程师。看完之后你再回头去看自己写的计数器、SPI Slave、按键消抖这些模块会有不一样的体感。1. 从“描述电路”到“描述信号流动”两种设计思路的本质区别1.1 电路视角Verilog是结构化的硬件装配图最早接触Verilog时我总喜欢把它类比成C语言这是最大的认知误区。C语言是顺序执行的写完一条语句再执行下一条而Verilog里所有的always块、assign语句在硬件上都是并行执行的每一行代码最终都会映射成具体的逻辑门、触发器和连线。所谓“Verilog描述电路”最直观的体现就是模块例化。比如你设计了一个UART模块又设计了一个FIFO模块然后在顶层模块里把UART的接收输出接到FIFO的写端口这就是在描述电路连接关系。这个时候你的思维模式应该是我在画一张装配图每一个module是图纸上的一个元件端口就是元件的引脚wire信号就是引脚之间的飞线。实际工程里结构化的描述方式适合做层次化设计。我之前做过一个DDR3读写控制项目顶层模块里例化了时钟管理单元、命令解析单元、数据通路单元和PHY接口单元每个单元内部又有更小的子模块。这种自顶向下的拆解方式配合Verilog的模块例化语法能让整个工程结构清晰、便于团队协作。如果全部写在一个巨大的always块里不仅综合工具跑起来吃力后期调试更是灾难。1.2 信号流动视角数据在时间与空间上的传播“描述信号的流动”是Verilog另一个更抽象、也更有深度的视角。芯片内部没有“调用函数”这回事只有信号从一个寄存器跑到另一个寄存器经过组合逻辑的变换在时钟边沿被采样锁存。Verilog的行为级描述——也就是always块配合时序控制——本质上是在描述信号随着时间推移发生的变化。举个例子你写一个计数器always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 8d0; else if (cnt_en) cnt cnt 1b1; end这段代码描述的并不是“计数器这个对象每来一个时钟就加一”而是描述了一个8位寄存器组在每个时钟上升沿如果复位信号无效且计数使能有效那么寄存器组的输入端口D端接的是当前值加1的运算结果于是下一个时钟沿到来时新值被锁存进寄存器。数据就是这样一步步“流动”起来的Q端输出当前值组合逻辑算出下一拍的值D端等待采样。理解这个视角对调试电路太重要了。很多时候波形不对不是因为逻辑写错了而是因为信号流动的时序没有对。比如你期望A信号先变化B信号再根据A信号变化如果B的赋值用的是阻塞赋值它的变化时机可能和预期完全不同。这就是我们接下来要细聊的重头戏。2. 核心细节解析阻塞赋值与非阻塞赋值如何影响信号流动2.1 两种赋值的硬件含义关于阻塞赋值和非阻塞赋值教科书上讲过无数遍但我在面试候选人和带新人的时候发现真正理解透的人还不多。这两者的区别不只是“写代码的风格问题”而是直接决定了综合出来的电路结构。阻塞赋值在always块内是立即生效的也就是说下一条语句再读取这个变量时拿到的是刚赋完的新值。综合工具会把这种写法映射成组合逻辑网络信号像水流一样从赋值语句的左端流向右端中间没有寄存器的隔断。非阻塞赋值则不同它采用的是“先统一采样、后统一更新”的语义。在一个时钟沿到来时所有非阻塞赋值的右端表达式先被计算并暂存然后才把暂存值赋给左端变量。这就保证了同一个always块里多个非阻塞赋值之间不会互相干扰每个寄存器采到的都是上一拍的值更新发生在同一时刻。综合工具会把这种写法映射成真正的D触发器。2.2 用移位寄存器案例讲透非阻塞赋值我经常用移位寄存器来解释两赋值的区别。假设要实现一个4位移位寄存器输入是din每个时钟沿把数据从低位移入第4拍从dout输出。用非阻塞赋值写是这样的always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 4b0; end else begin shift_reg[0] din; shift_reg[1] shift_reg[0]; shift_reg[2] shift_reg[1]; shift_reg[3] shift_reg[2]; end end assign dout shift_reg[3];关键点来了第四行的shift_reg[1] shift_reg[0]右边的shift_reg[0]到底是本拍刚赋入的din还是上一拍的值非阻塞赋值的答案永远是后者。所有右端表达式的值都在时钟沿到来之前就已经确定了赋值动作统一发生在沿之后。所以这段代码从功能上等价于4个串联的D触发器每个触发器都只是把自己上一拍的值传给下一个。如果不懂这个语义有人可能会以为写完第4行后shift_reg[1]拿到的是新输入的din那就是把移位寄存器理解成了并行寄存器完全错了。我在Modelsim仿真时遇到过一次很诡异的现象移位寄存器输出每个周期都是同一个值排查半天才发现某位用了阻塞赋值导致数据在一个always块里“提前穿透”了两级寄存器。这就是信号流动时序被打乱的典型症状。2.3 阻塞赋值的适用场景不是说阻塞赋值不能用它在组合逻辑建模里是主力。比如用always块写一个多路选择器、译码器、加法器这些没有时序要求的纯组合逻辑用阻塞赋值会很顺手代码也直观always (*) begin if (sel) y a b; else y a - b; end组合逻辑描述的是信号流经运算器的过程没有寄存器的参与阻塞赋值完全符合这种物理特性。但要注意组合逻辑的敏感列表建议直接用(*)不要手写敏感信号列表漏一个信号会导致仿真和综合结果不一致这种坑我踩过不止一次。我的习惯是时序逻辑全部用非阻塞赋值组合逻辑用阻塞赋值一个always块里绝不混用两种赋值方式。这个习惯让我在项目调试中省下了大量时间也推荐你严格遵守。2.4 读写同地址RAM的信号竞争问题再展开一个和信号流动强相关的场景在同一个时钟周期内对一个双端口RAM的同一地址同时进行读写操作。如果你用的是同步写、异步读的简单RAM模型写数据是在时钟上升沿打入而读数据是组合逻辑直接输出存储单元的值那么在这个沿上读端口拿到的是旧数据还是新数据取决于RAM内部实现和Verilog仿真器的调度顺序。这种问题在仿真阶段偶尔出现但到了FPGA上行为又可能不一致因为真实硬件里的RAM单元有内部的时序约束。解决思路通常有两种一是设计上避免同周期同地址读写二是使用带输出寄存器的RAM模型让读操作也走一拍寄存器这样读写冲突被拍开信号的流动链路变得清晰可控。我在做FIFO设计时就坚持让读数据输出打一拍虽然多了一拍延迟但换来的是极高的稳定性。3. 实操过程从按键消抖到SPI接口完整走一遍信号流动路径3.1 按键消抖如何让抖动信号不再干扰后续逻辑机械按键的按下和释放过程伴随着几十毫秒的抖动如果不做处理直接接进逻辑会导致一次按键被检测成多次脉冲。常规做法是“采样加计数”每隔一定时间采样按键电平连续多次采样到同一电平才认为状态稳定。用Verilog实现按键消抖最常用的方案是计数器延时。假设系统时钟是50MHz周期20ns抖动时间按10ms算需要一个20位计数器才能计数到10ms。代码结构大致如下reg [19:0] cnt; reg key_sync1, key_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync1 1b1; key_sync2 1b1; end else begin key_sync1 key_in; key_sync2 key_sync1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; key_out 1b1; end else if (key_sync2 ! key_out) begin cnt cnt 1b1; if (cnt 20d499_999) key_out key_sync2; end else begin cnt 20d0; end end注意在使用单bit异步信号时第一步永远是用两级触发器同步消除亚稳态风险然后再进消抖逻辑。这里的信号流动路径是原始按键信号 - 两级同步触发器 - 计数比较器 - 稳定的输出电平。每一级都是信号在时间轴上的精确定位漏掉任何一级后续逻辑都可能被毛刺击中。我在实际项目里还会在消抖之后再加一个边沿检测模块这样输出就是一个单时钟周期的脉冲用来触发状态机跳转或者事件计数。边沿检测的核心思路是把信号打一拍然后用当前值和上一拍的值做组合判断“当前为高且上一拍为低”就是上升沿“当前为低且上一拍为高”就是下降沿。这段逻辑不复杂但它是信号流动“时间差”概念的绝佳练习。3.2 SPI Slave模块一位一位观察信号的移入过程SPI是嵌入式世界里最常见的接口之一如果不亲手用Verilog实现一次SPI Slave你对“信号流动”的理解始终会隔着一层。SPI有四个信号SCK时钟、MOSI主出从入、MISO主入从出、CS片选。Slave端的核心任务就是在SCK的驱动下把MOSI上的数据一位一位收进来同时在SCK的驱动下把要发送的数据一位一位推出去。我实现过一个8位SPI Slave核心代码如下只列接收部分reg [2:0] bit_cnt; reg [7:0] rx_shift; reg rx_valid; always (posedge sck or negedge cs_n) begin if (!cs_n) begin bit_cnt 3d0; rx_shift 8d0; rx_valid 1b0; end else begin rx_shift[7:1] rx_shift[6:0]; rx_shift[0] mosi; if (bit_cnt 3d7) rx_valid 1b1; else begin bit_cnt bit_cnt 1b1; rx_valid 1b0; end end end这段代码你看明白了吗每当SCK上升沿到来rx_shift寄存器整体向右移动一位新进来的MOSI电平被移入最低位。这就是最纯粹的信号流动数据像水流一样从最低位流入经过8个SCK周期把8位数据依次填满整个寄存器。这里的敏感列表用的是posedge sck or negedge cs_n意思是片选撤销时立即复位接收过程。仿真时你会发现rx_shift这个变量在波形上会呈现出漂亮的“左移”效果每来一个SCK上升沿数据就向右挪一位位与位之间像是接力赛一样往前跑。在写SPI Slave时我犯过两个新手错。第一个是直接用SCK做系统时钟去驱动状态机结果SCK上如果有毛刺数据就会错位。后来的做法是用两级触发器同步SCK后再做上升沿检测生成一个周期的SCK_rise脉冲用这个脉冲作为移位使能而不是直接用SCK做时钟。第二个是忽略了CPOL和CPHA两种极性的差异导致和主机的时序对不上。解决方式是在模块内部加一个配置寄存器可配置在SCK的上升沿还是下降沿采样这样同一个模块就能兼容市面上大多数SPI主机。3.3 计数器模块最普通的电路里藏着信号流动的本质如果你觉得SPI有点复杂那回到最基础的计数器。别小看它几乎所有数字电路里都有计数器的身影分频器、定时器、状态机里的拍数标记、总线超时判断统统离不开它。一个带使能和清零的计数器如下module counter #( parameter WIDTH 8, parameter MAX_VAL 8d200 )( input wire clk, input wire rst_n, input wire en, input wire clear, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (clear) cnt {WIDTH{1b0}}; else if (en) begin if (cnt MAX_VAL) cnt {WIDTH{1b0}}; else cnt cnt {{WIDTH-1{1b0}}, 1b1}; end end endmodule这段代码里cnt信号在时间轴上的流动路径非常清晰复位时清零 - 使能时累加 - 到达最大值回绕。你可以把cnt想象成一圈跑道上的运动员时钟是裁判的发令枪每响一次跑一步return到起点的时刻由MAX_VAL这个参数控制。我建议初学者一定要在Modelsim里仿真这个模块把en、clear、cnt全部拉出来观察感受使能信号撤销时cnt停在原地、清零信号拉高时cnt立刻归零这两个瞬间这对建立时序概念极有帮助。3.4 FIFO设计中的读写指针信号流动我再展开一个有代表性的场景FIFO的读写指针。异步FIFO是跨时钟域处理的基础组件它的核心是写指针和读指针分别由写时钟域和读时钟域控制通过比较两个指针的格雷码来判断空满状态。这里的“信号流动”不再局限于单个时钟域而是跨越了时钟域的边界。普通同步FIFO相对简单读写指针都在同一个时钟域判断空满就是比较读地址和写地址是否相等。但异步FIFO中写指针要经过两级同步器传到读时钟域进行比较这中间就产生了两个问题同步器的延迟导致空满判断不是实时的以及二进制指针在多bit变化时可能出现采样到中间态的问题。解决后者常用格雷码因为格雷码每次只有1bit变化同步器采样出错的概率被降到最低。做FIFO设计给我最大的体会就是信号的流动有时不是单向直线的它可能绕了一个大圈写指针从写时钟域出发经过同步器进入读时钟域和读指针比较出空满状态再把状态信号传回写时钟域。设计者必须对每一条路径上经过了几级寄存器、每级寄存器引入了几拍延迟了如指掌否则很容易在边界条件下判断出错。4. 工具链与常用模块用仿真波形反向验证信号流动是否正确4.1 Modelsim仿真写好testbench是基本功写Verilog不仿真的话就像在黑暗里走路代码写完根本不知道信号到底有没有按预期流动。我之前用Modelsim做仿真比较多虽然现在EDA工具越来越多但Modelsim在入门阶段依然是最好用的一个。关键在于testbench要写得规范仿真才有意义。一个标准testbench包含四部分时钟生成、复位生成、被测模块例化、激励输入。以按键消抖模块为例reg clk; reg rst_n; reg key_in; wire key_out; initial begin clk 0; forever #10 clk ~clk; end initial begin rst_n 0; key_in 1; #100; rst_n 1; // 模拟按下时的抖动 #10; key_in 0; #1; key_in 1; #2; key_in 0; #3; key_in 1; #4; key_in 0; // 保持按下状态 #15_000_000; $finish; end debounce u_debounce ( .clk(clk), .rst_n(rst_n), .key_in(key_in), .key_out(key_out) );测试的关键就是把抖动的瞬间模拟出来然后跑到波形里去看key_out是不是在抖动结束后的10ms才稳定切换。如果你在波形里看到key_out提前变了那说明计数器计时的起点不对或者是同步级数有问题。仿真不是走过场每一段波形都要能自圆其说这是信号流动可视化验证的基本功。我也建议初学者学会把仿真数据存成文件用脚本自动比对。比如SPI模块仿真时把发送数据和接收数据各自存成文本跑完后用脚本比对两者是否一致几百个随机用例跑下来模块的正确性就有保证了。纯靠肉眼盯波形简单模块还行复杂模块真盯不过来。4.2 轮询仲裁器让多个请求方有序地“流动”多路请求方争用同一个资源时需要一个仲裁器来调度最简单的就是轮询仲裁器。它的思想是每个时钟周期优先把资源让给上一次被服务者的下一个请求方实现公平调度。用Verilog实现轮询仲裁器时我习惯先用one-hot的grant信号表示当前授权然后用一个base指针记录下一个要仲裁的起始位置。判断逻辑是从base位置开始向高位方向找到第一个请求为高的信号把grant赋给它。这里的信号流动路径是“grant_base 的变化”引导“grant的产生”grant又反过来影响base的取值形成循环调度。写这个模块时最容易出的问题是有多个请求同时为高时grant选择了错误的位置。我的排查经验是拉出波形逐一核对base指针、每个请求信号和grant信号的时序关系。轮询的逻辑很快就能定位问题因为它的“流动”路径太清晰了每一拍grant都指向当前优先级最高的请求者下一拍base更新继续寻找下一个。4.3 滑动窗口滤波与CRC组合逻辑里的数据流工程FPGA做数据采集时经常要处理传感器输出的噪声信号滑动窗口滤波是简单又常用的一种方法。它的本质是维护一个长度为N的窗口每个周期把新采样值加入窗口、最旧的那个值移出窗口输出窗口内所有值的平均值。reg [15:0] window_sum; reg [7:0] window_buf [0:15]; reg [3:0] wr_index; always (posedge clk or negedge rst_n) begin if (!rst_n) begin window_sum 16d0; // 初始化window_buf略 end else begin window_sum window_sum data_in - window_buf[wr_index]; window_buf[wr_index] data_in; wr_index wr_index 1b1; end end这个设计里信号的流动很有意思老数据从窗口尾部“流出”新数据从头部“流入”和总和始终维持着窗口内的信息。这让我想到SPI移位寄存器里的数据流只不过滑动窗口的流动是环形的依赖索引的循环实现“覆盖式”流动。CRC校验的Verilog实现也是信号流动的经典应用。CRC的核心是线性反馈移位寄存器输入数据逐bit进入移位网络经过多项式的异或反馈最终得到校验余数。它的流动路径是一个闭环加一个输入支路写代码的时候只要把多项式结构翻译成寄存器间的异或连线综合出来的电路就天然正确。5. 常见问题与排查技巧实录问题现象排查思路复位时序异常上电后寄存器值不变检查复位信号是否同步释放复位宽度是否满足要求建议使用异步复位同步释放电路信号出现毛刺波形上有异常窄脉冲检查是否直接把外部信号接入组合逻辑未做两级同步或组合逻辑未加输出寄存器计数器不计数cnt恒为0检查使能信号是否有效时钟是否连接正确复位是否一直拉低FIFO空满标志异常读写数据错乱检查跨时钟域同步是否完整格雷码转换是否正确读写指针位宽是否足够仿真结果和综合不匹配波形逻辑正确但上板错误检查是否把信号当作时钟使用是否有时钟域跨越未做同步非阻塞赋值误用数据时序延迟异常检查是否在时序逻辑里混用了阻塞赋值是否同一个变量在多个always块里赋值按键按下检测多次一次按键被识别成多次消抖定时时间太短建议提高到10ms以上同步级数不足也可能引入毛刺SPI数据错位接收到的数据整体偏移1位检查CPOL和CPHA配置是否与主机一致检查采样沿是否选择正确5.1 仿真中出现高阻态或未知态先查复位和初始化我在Modelsim仿真的时候最常遇到的诡异问题就是波形里出现一片红色的未知态。新手容易被吓到其实原因多半就两个要么是寄存器没有被复位初始值是X要么是内存数组没有完成初始化就被读取了。解决思路很简单仿真开始前全局复位拉低若干个时钟周期所有寄存器先被强制清零RAM类存储器件要么在顶层用系统任务读取初始化文件要么在testbench里写一个初始化的for循环。这条经验在我做的所有仿真项目里都适用省下的排查时间不可估量。5.2 多驱动问题同一信号被多个always块赋值这是初学者极容易踩的坑。Verilog里一个reg变量不能出现在两个不同的always块里做赋值否则综合时会出现多驱动错误。比如你为了写起来方便在时钟上升沿的always块里给cnt赋值又在另一个组合逻辑的always块里给cnt赋值仿真时两个块都在驱动同一个变量结果就是波形出现竞争最后综合工具直接报错。正确做法是一个信号只允许一个always块驱动需要组合逻辑和时序逻辑分工时拆成两个信号再单独用assign做连接。5.3 cache实现中的信号流动命中和缺失是路径分叉点热搜词里有一个“cache的verilog实现”我想多说两句。Cache的核心是局部性原理硬件上要维护标记阵列和数据阵列还要实现替换策略。信号流动在这里分成了两条路径命中时读请求直接命中数据阵列输出数据信号路径很短缺失时读请求要访问下一级存储比如DDR把数据块搬进cache信号路径变长还要处理行替换和状态更新。这种“路径分叉”的设计对理解信号流动特别有帮助——同一个输入请求因为状态不同会走上完全不同的流动路径这对写状态机和地址译码逻辑的训练价值极高。5.4 AI Agent写Verilog代码的得与失看到热词里有“ai agent verilog代码”我想结合最近的实际体验说几句。我用AI辅助写过一些模块的骨架比如I2C读写EEPROM的控制器、CRC校验模块AI确实能在几秒钟内给出一个看起来像模像样的版本。但真正干活的时候AI写的代码在时序细节上经常有隐患敏感列表遗漏、赋值方式混用、跨时钟域不做同步、状态机缺默认态。我不否认AI能当帮手但从我的经验看用AI写Verilog有一个前提你必须自己先能判断这段代码的综合结果是否合理信号流动的每一拍是否站得住。如果你还在入门阶段建议先老老实实自己手写等你理解了“描述电路”和“描述信号流动”的本质再考虑让AI帮你搭架子。6. 我的几个经验总结做了这么久数字逻辑设计我对“Verilog描述电路”和“Verilog描述信号的流动”这两句话的理解也在不断加深。刚开始写代码脑子里只有语法写完能综合通过就觉得万事大吉后来项目做多了看到一段代码脑子里浮现的是它的触发器结构、组合逻辑深度、时序路径延迟甚至是它在FPGA上的布线走向。几个实用经验分享给你。第一写代码前先画信号流动图。不需要多正式自己看得懂就行。把输入信号、中间信号、输出信号按时间顺序标出来哪里打一拍、哪里走组合逻辑、哪里进状态机画完再写代码思路会异常清晰。我带的实习生里凡是养成先画图习惯的后来独立做模块都没有出过方向性的问题。第二坚持“一组信号一个时钟域”的原则。跨时钟域的信号一定过两级同步器多bit跨时钟域必须走异步FIFO或者握手协议。不要嫌麻烦去省这些同步寄存器省下的逻辑资源迟早会变成调试时熬掉的夜。第三仿真波形是信号流动的最直观证据。不要只在功能仿真时看波形时序仿真也要看。功能仿真通过只能说明逻辑功能对时序仿真才能暴露组合逻辑延迟超标、建立时间不满足这类真实问题。我在一个SPI Slave项目里就碰到过功能仿真全对、时序仿真输出数据有毛刺的情况最后发现是组合逻辑的级数太深在FPGA上布线延迟超过了SCK周期的一半改成输出寄存器打一拍后问题才消失。第四Verilog的代码审查和普通软件评审不一样。软件评审看的是逻辑和接口硬件审查要看的是每个信号的综合含义是不是有组合逻辑环路、有没有不必要的latch、寄存器的时钟来源是什么、复位策略是否统一。我习惯在写完模块后做一轮自审把每个always块里赋值的变量都列出来检查有没有多驱动、有没有用变量做时钟、有没有组合逻辑反馈。回到标题本身“描述电路”和“描述信号的流动”是同一个硬币的两面。结构化的实例化描述了电路在空间上的组织行为级的always块描述了信号在时间上的轨迹。真正的高手写代码的时候这两条线是同时走的一个模块例化下去立刻能在脑子里推演出信号从入口到出口完整跑一遍的时序图。这个能力没有捷径只能在一次次仿真、一次次上板调试中磨出来。最后再提一句实操中的小建议仿真的时候把关键信号名统一加前缀比如所有同步信号加sync_所有脉冲信号加pulse_所有计数信号加cnt_。我看波形的时候一眼就能分辨信号类型排查问题效率高很多。这算不上什么大技巧但非常管用算是多年实践下来沉淀的一个小习惯吧。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →