拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入浅出:Verilog仿真通过但上板翻车的6个时序陷阱

深入浅出:Verilog仿真通过但上板翻车的6个时序陷阱

接到这个活儿的时候,我正在批改这一轮数字电路实验的结课作业。说实话,前年第一次看到学生交上来的Verilog代码,我第一反应是“能跑就行,别管那么多”。但后来在板子上调了整整三天,彻底改变了我的看法——那些看起来仿真波形完美、上板却各种抽风的代码,几乎每一个都踩在同样的几个时序坑里。

这篇内容不是写给那种刚学会assign语句的新手看的,而是给那些已经能写出“能跑的”代码,但总感觉哪里不对、一到真实硬件就翻车的同学和初级工程师的。我会把这几年在作业里见到频率最高、隐蔽性最强、让人排查到怀疑人生的6个Verilog时序陷阱全部拆开,配合代码片段、仿真逻辑和综合布线层面的分析,把“为什么看起来对了但实际是错的”讲透。

顺便提一句,我用的环境是Icarus Verilog做功能仿真,Vivado做综合实现。这两个工具链配合起来,基本可以覆盖从写代码到上板验证的完整流程。往下看之前,建议你先打开自己的工程,按照我提到的几个检查点对着看一眼,大概率能抓到一两个问题。

1. 六个陷阱的总体画像:先搞清楚“能跑”和“能上板”中间隔了什么

学生交上来的代码,十个里有八个仿真波形是“对”的。但仿真通过和硬件上跑得稳是两码事。原因很简单:仿真器(Icarus Verilog、VCS、ModelSim这类)默认情况下只做功能仿真,不关心你代码会产生多少毛刺、跨时钟域的信号有没有被可靠采样、异步信号进来会不会导致寄存器进入亚稳态。它把理想世界画给你看,真实世界却是另一套规则。

我在每一次“看起来能跑”的代码里,最常看到的问题集中在以下几个方向:

陷阱仿真表现硬件表现隐蔽指数
非阻塞赋值当成阻塞用波形对上板偶发错乱高
敏感列表不完整可能对完全不对极高
跨时钟域信号直接握手仿真正确随机性数据丢失高
异步复位不同步释放大概率对偶发复位失败极高
组合逻辑毛刺直接触发眼图阶段正确计数器乱跳高
状态机输出未做寄存波形正确输出毛刺中高

这六个问题有一个共同点:在仿真阶段你几乎看不到异常,但时序分析报告(setup/hold violation)或者示波器上的毛刺会把你拉回现实。下面我一个个展开说,每个都会给一个我在作业里真实看到过的代码样例,再给改法。

1.1 为什么仿真“看起来对”是一件危险的事

先说一个真实案例。有一个学生的出租车计费系统设计,功能是:按键模拟上车,车速脉冲输入,每米计费0.1元,停止时显示总价。他的仿真波形里,到了终点,金额正确显示出 32.1 元,看起来天衣无缝。但上了FPGA板子,金额经常变成 32.0 或者 31.9,而且发生的时机完全没有规律。

问题出在一个非常小的细节上:他用了一个enable信号去“门控”计数器的时钟,代码如下:

always @(posedge clk) begin if (enable) begin count <= count + 1; end end

这段代码在功能仿真里没问题。但enable本身来自另一段组合逻辑——状态机里的一个判断。组合逻辑输出的enable可能和clk的上升沿存在竞争,导致在时钟沿到来的瞬间,enable还没有稳定。在高速时钟下,这个不稳定会被采样进去,计数器就可能偶尔多加或者漏加一个。

这个案例,恰好就涵盖了我们要讲的第一个陷阱——但你仔细看,这根子其实不是enable本身,而是他对“信号什么时候稳定”这件事完全没有概念。

2. 陷阱一:非阻塞赋值在组合逻辑块里被滥用

这个坑,基本是初学者从入门到“会写一点”之后必然踩的。核心就一句话:时序逻辑用<=,组合逻辑用=,别混着来。我见过太多学生把整份代码所有always块里的赋值全写成<=,因为教程上写着“时序逻辑要用非阻塞赋值”,于是他以为“只要是always块就用非阻塞”。

2.1 两种赋值的本质区别

阻塞赋值=是立即生效的——执行完这一步,右侧的表达式立刻反映到左侧变量上,下一行代码读到的就是这个新值。这就像你做菜,切完葱马上就能下锅,锅里的状态立刻变了。

非阻塞赋值<=是延迟生效的——右侧的所有表达式先求值,然后统一在always块结束的那一刻、下一个时钟沿或者块结束时才更新左侧变量。就像你列了一张购物清单,等到了超市一次性统一采购,清单在被执行之前,所有条目都不会真正改变你家的冰箱。

在时序逻辑里必须用非阻塞,因为我们要模拟的是触发器的真实行为:时钟沿到来时,所有数据同时被打进寄存器,而不是一个一个串行更新。但在组合逻辑里用非阻塞,就变成了“所有赋值都等到进程释放才生效”,这会直接导致你写出来的组合逻辑行为和想要的完全不一样。

2.2 一个反面教材

学生交过一段这样的代码,意图是做一个异步复位的计数器:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin count = 0; end else begin count = count + 1; end end

他用的是阻塞赋值。功能仿真时波形是对的。但综合出来的实际电路里,这个count会被综合成一组带有反馈的组合逻辑,而不是一组真正的寄存器。如果后续逻辑里有另一个always块在同一个时钟沿去读count,读到的可能是更新之后的值,也可能是更新之前的值——取决于两条路径的传输延迟,这就是典型的仿真通过、上板翻车。

改成下面这样就对了:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin count <= 0; end else begin count <= count + 1; end end

注意:非阻塞赋值只能用在时序逻辑中,组合逻辑请用阻塞赋值。在同一个always块里,两者混用更是大忌——综合工具通常会警告,然后行为会变得不可预测。

2.3 怎么自查

在Icarus Verilog仿真完之后,打开生成的VCD波行文件,盯着“count”看它变化的时刻——如果是阻塞赋值,所有计数沿都是瞬时完成的,没有任何延迟,看起来“太顺滑了”。真正的寄存器会有时钟沿延迟,波形上能看到一个明确的采样时刻。还有更直接的方法:跑一下iverilog的lint检查,或者用Vivado综合,看综合报告中是否有Found latch或inferring latch字样,一旦出现,就是组合逻辑写成了时序逻辑的形状,或者反过来。

3. 陷阱二:敏感列表缺胳膊少腿,仿真和逻辑综合各说各话

这个坑隐蔽性极高,因为很多同学根本不知道敏感列表会影响仿真结果。我见过最多的情况是:组合逻辑块里本应该同时受几个输入影响,但敏感列表只列了一部分。

3.1 缺失敏感列表的真实案例

有一个学生写的是“出租车计费系统”里的状态切换逻辑:

always @(posedge clk) begin if (start) state = RUN; else if (stop) state = IDLE; end

这里他用了阻塞赋值,但这不是最大的问题。问题是start和stop这两个信号进来的时刻和clk没有同步关系——它们在仿真中是异步电平信号,而在真实世界里,按键信号需要经过消抖,然后同步到时钟域才能使用。他把外部信号直接塞进了同步逻辑里,而且用的是阻塞赋值。整个逻辑在仿真中“看起来对”,实际硬件中state的变化时刻取决于start和stop相对时钟沿的相位关系,完全不可预测。

而另一种常见的敏感列表问题是这样的:

always @(a or b) begin c = a & b; end

如果之后你增加了输入d,改成c = a & b & d,却忘了把d加进敏感列表——仿真器会“傻傻地”只在a或b变化时才重新计算c,d怎么变都没用。但综合工具不管你敏感列表写了什么,它按功能推断出的是c = a & b & d,于是仿真和综合结果出现了分歧。你以为你测过了,实际上仿真器测的是一个和你综合出来完全不同的逻辑。

经验:仿真器忠实执行代码中“显式”的行为,而综合工具“推断”的是逻辑功能。两者出现不一致,几乎必然导致你抓瞎。

3.2 怎么避免

最稳妥的写法,组合逻辑全部用always @(*)或者assign。不要自己列敏感列表——找不全输入是100%的事,只是时间早晚。时序逻辑全部用always @(posedge clk)加异步复位信号。这个习惯一旦建立,你这个坑就彻底绕过去了。

我自己的习惯是,写完组合逻辑块第一件事就是看一眼敏感列表里有没有@(*)。出现了手写列表,直接打回重写,不做任何妥协。

4. 陷阱三:跨时钟域信号不做同步处理,仿真永远测不出来

这个坑在课设里出现的频率不高,但一旦出现就是重灾区。比如有学生做“I2C读写EEPROM”这个经典项目,I2C的SCL时钟和系统主时钟不是同一个源,那么I2C的数据线SDA进入系统时,就属于跨时钟域信号。如果直接从外部进来的SDA信号去触发状态机,亚稳态问题会让整个通信流程随机失败。

4.1 什么是亚稳态

简单说,触发器在时钟沿采样时,如果数据信号恰好在这个沿附近变化,触发器的输出可能会处于一个既不是高电平也不是低电平的中间状态,然后在不稳定的时间里慢慢落到某一个确定值。这个“慢慢落”的过程,对后级逻辑来说就是一场灾难——后级可能采到一个逻辑0,也可能采到一个逻辑1,也可能采到一个既不是0也不是1的电压。

仿真是不会告诉你这些的。仿真器里的信号只有0、1、X、Z,它是理想的数字世界,没有亚稳态这个概率性事件。所以跨时钟域问题在仿真阶段永远测不出来,只有放到真实芯片上才会随机爆发,而且非常难复现——可能跑十分钟出一次错,也可能连续跑两小时一次错都没有。

4.2 正确的处理方式

最经典的单bit跨时钟域同步器就是两级寄存器:

always @(posedge clk) begin sync_r1 <= async_in; sync_r2 <= sync_r1; end

这里用两级寄存器做同步,作用就是给亚稳态留出足够的时间去稳定。第一级寄存器输出可能处于亚稳态,但经过一个完整的时钟周期之后,第二级寄存器采到第一级输出时,信号大概率已经稳定了。两级同步器可以把亚稳态导致的错误概率降到极低,但不能完全消除——这个概率极小,工程上可以接受。

多bit数据跨时钟域,单靠两级同步器是没用的。比如一个16位计数器要从慢时钟域传到快时钟域,直接用两级同步器同步每一位,不同bit的传输延迟不同,可能导致接收端采到一组完全错乱的数据。这种情况标准做法是格雷码、握手协议,或者用异步FIFO。课设里用到多bit跨时钟域的,通常是“OLED显示”这个方向——i2c或者spi接口的时钟和系统时钟不同步,很多人直接就把待显示数据从系统时钟域发到了显示控制器的时钟域,结果花屏、闪烁、显示错位一个一个来。

实操心得:如果只是从慢到快传单bit信号,比如按键输入、外部中断,两级寄存器就够了。如果是多bit,老老实实查一下“异步FIFO怎么设计”或者“格雷码转换怎么搞”,别硬着头皮直接连。

5. 陷阱四:异步复位信号没有做同步释放

这个坑我在作业里看到过大概三四次,每一个都是同一副面孔:异步复位,看起来对,但复位释放时崩了。

5.1 复位释放为什么是问题

先区分两件事:复位拉低的时候,寄存器的输出被异步清0,这没有任何问题——异步复位就是靠这个“不等时钟,立刻清零”的特性工作的。

问题出在复位释放那一刻,也就是复位信号从低电平变高电平的时候。如果复位释放的时刻距离时钟上升沿太近,触发器会在复位释放和时钟采样之间进入亚稳态。更糟的是,如果电路里有几百个触发器共用同一个复位信号,复位信号到达每个触发器的时间不可能完全相同,有的先释放,有的后释放——这个最小的差异可能小于一个时钟周期,导致有的寄存器已经恢复工作,有的还在复位状态,整个状态机就乱套了。

5.2 同步释放的正确姿势

标准做法是“异步拉低,同步拉高”。原理很简单:复位信号一来,立刻异步清零所有寄存器;复位信号走的时候,先用两级同步器把它同步到时钟域,再作为复位释放信号。

reg rst_n1 = 0, rst_n2 = 0; always @(posedge clk or negedge rst_async_n) begin if (!rst_async_n) begin rst_n1 <= 1'b0; rst_n2 <= 1'b0; end else begin rst_n1 <= 1'b1; rst_n2 <= rst_n1; end end // 使用 rst_n2 作为复位信号 always @(posedge clk or negedge rst_n2) begin if (!rst_n2) count <= 0; else count <= count + 1; end

这段代码的行为是:外部异步复位信号rst_async_n拉低时,rst_n1、rst_n2瞬间为0,所有寄存器立刻清零。当rst_async_n拉高释放时,rst_n1先在下个时钟沿变为1,rst_n2再下个时钟沿变为1。复位释放就“拖”到了确定的时钟沿之后,所有寄存器在同一时刻脱离复位,规避了亚稳态区间。

注意:释放同步后的复位信号不能直接接到IP核的复位端口,像PLL、高速收发器这类特殊单元有自己的复位时序要求。处理方式要把释放同步延后到这些单元的locked信号之后再做,这是另外一个深层问题了。

6. 陷阱五:组合逻辑毛刺直接驱动时序逻辑

这个坑在“计数器”、“出租车计费”这类需要对外部脉冲计数的项目里泛滥成灾。学生的思路多半是:

always @(posedge pulse) begin count <= count + 1; end

直接把pulse当成时钟用。外部脉冲进来,计数器加一次,看起来天经地义。但这里面至少藏着两个致命问题。

6.1 问题一:外部脉冲的毛刺

按键、传感器、光电编码器的输出信号,在真实世界里是充满毛刺的。一个物理按键按下去,触点会抖动几十毫秒,产生无数个脉冲边沿;一个旋转编码器在某个位置停下来,信号线上可能会因为机械振动产生一串毛刺。如果你直接把这种信号接到时钟端,计数器会把这些毛刺全部当成有效脉冲,结果就是“我只按了一下,计数却加了五次”。

功能仿真里你用的是理想脉冲,仿真器当然不会模拟触点抖动,所以你看到的波形永远是精准的。这就是“看起来能跑”的又一个典型案例。

正确的做法是把外部脉冲先消抖、同步,然后作为普通电平信号送入状态机,用系统时钟沿去采它,再转换成单周期脉冲:

// 同步 + 消抖(简单模式) reg [3:0] debounce_shreg; always @(posedge clk) begin debounce_shreg <= {debounce_shreg[2:0], external_pulse}; end wire debounced = &debounce_shreg; // 连续4拍都为高,认为有效

然后把debounced信号用一个状态机去检测上升沿,产生一个pulse_1tick信号,计数器只在pulse_1tick有效时加1。这样一来,毛刺最多让消抖移位寄存器里的数据变一下,没办法直接驱动计数动作。

6.2 问题二:把组合逻辑输出接到时钟端

更隐蔽的情况是,有人会把组合逻辑的输出去当内部时钟用。比如:

assign internal_clk = clk & enable; always @(posedge internal_clk) begin ... end

这种写法叫门控时钟,在ASIC设计里属于高危操作——enable和clk进行AND运算会产生一个带毛刺的时钟,触发器的时钟端对毛刺极其敏感。如果enable在clk为高电平期间发生变化,internal_clk就会产生一个窄脉冲,触发器可能会被这个毛刺误触发。

正确做法是保持统一的系统时钟,用enable作为使能信号:

always @(posedge clk) begin if (enable) begin count <= count + 1; end end

这样不管enable什么时候变化,都不会影响时钟信号的完整性。

实操心得:判断一个信号能不能直接做时钟,最简单的标准是——它是不是时钟树或PLL的输出。不是的话,一律先同步再当使能用,不要打时钟的主意。我在给学生代码提意见时,看到always @(posedge 某个不认识的信号)都会直接标红,这是最典型的封杀点。

7. 陷阱六:状态机输出未寄存,原则上没错但硬件一脸嫌弃

状态机是FPGA课设的重头戏,出租车计费、自动售货机、交通灯这些项目全都是状态机。但大部分学生写的状态机都是“一段式”或者“二段式”的,输出直接用组合逻辑生成。仿真波形里输出和状态变化同步发生,看起来天衣无缝,但真实硬件上组合逻辑输出会有一堆毛刺和不定态。

7.1 组合逻辑输出的问题

一个典型的二段式状态机:

always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin case (state) IDLE: next_state = START_TX ? SEND : IDLE; SEND: next_state = ...; endcase end assign tx_busy = (state == SEND);

注意这个tx_busy,它是用状态变量直接组合出来的。问题在于:state寄存器在时钟沿更新时,多位寄存器的各个bit可能不是同时变化的——虽然理论上它们属于同一组触发器,但实际上由于布线延迟不同,不同bit到达组合逻辑的时间可能有微小差异。这会让tx_busy在很短的一段时间内出现毛刺。

如果tx_busy是给外部设备(比如一个LCD控制器的busy信号)用的,毛刺可能被对方误采,导致通信异常。

7.2 输出寄存的正确姿势

在always @(posedge clk)块里,把tx_busy锁存一拍:

reg tx_busy_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) tx_busy_r <= 1'b0; else tx_busy_r <= (state == SEND); end

输出寄存之后,tx_busy_r的变化严格发生在时钟沿,不会有来自组合逻辑的毛刺。代价是多一个时钟周期的延迟——对大多数课设项目来说,这个延迟完全可以接受。

注意:三段式状态机之所以在工业界被广泛推荐,核心就是“状态寄存+次态组合+输出寄存”分离,输出寄存器兜住了所有组合逻辑毛刺,让输出波形干净利落。学生阶段写不出完美三段式没关系,但至少要在对外输出信号上补一拍寄存。

7.3 一个高频翻车点:状态机的默认态

开头提到出租车计费系统,我重点说一个设计:学生状态机有IDLE、RUN、STOP三个状态,但case语句里只写了这三个状态,没有default分支。这在功能仿真中完全没问题——仿真器里的状态变量永远只有那三个值。但真实FPGA上电瞬间,任何一种未知状态都可能出现在寄存器里,如果没有任何default兜底,状态机就会“飞”到一个未定义路径上,再也没有回来。

这个和输出寄存不是一回事,但在状态机大类里属于同族的坑。我改学生的代码,基本都会加这样一句:

default: next_state = IDLE;

防止任何异常状态卡死整个逻辑。这个习惯请务必养成,这算是见过太多“板子莫名卡死”案例后的一个标准解。

8. 现场排查实录:一个“看起来能跑”的项目是怎么救回来的

讲一个我觉得特别有代表性的真实案例。去年有个学生的“基于FPGA的波形发生器”项目,功能是分别产生方波、三角波和正弦波,通过按键切换波形,用OLED显示当前频率和波形类型。

他交上来的第一版代码,仿真完美:按键切换波形、频率加减全部正常,波形数据从ROM输出,一切理想。但上了板子,OLED上的频率显示经常会变成乱码,而且按键切换波形时经常一次跳两个波形。

当时我的排查过程是这样的:

第一步,查OLED接口时序。他用的是I2C协议控制OLED,代码里自己写了I2C主机逻辑。我先用逻辑分析仪抓I2C总线波形,发现数据确实有时会错,但抓拍到的几率很低。这个方向有点难度,于是先放过。

第二步,查按键输入。他的按键信号直接接到了状态机的case条件里,没有消抖和同步。我把按键输入改成移位寄存器消抖,同步两级寄存器再送入状态机。这一步之后就排除了一部分乱跳问题。

第三步,查时钟域。他的I2C模块的SCL是用计数器分频出来的,分频计数器和系统主时钟是同一个时钟域,这个倒没有问题。但是OLED显示的像素数据来自另一个模块,而频率显示的数字来自主状态机模块,两个模块之间的数据传递没有经过任何同步——而且传输的是一个B CD编码的频率数字,是多bit数据。多bit数据直接跨模块传递,在没有经过同步的情况下,采到错乱数值的概率非常大。

这就是OLED显示乱码的根因。我让他在两个模块之间加了一个握手信号:发送端先把数据准备好,然后拉高valid信号,接收端收到valid后读取数据,再回一个ack。握手的本质是把数据保持足够长的时间,接收端在信号稳定的窗口内去读,而不是盲目地用时钟沿去采可能正在变化的信号。改完这一版之后,显示正常了,按键切换也稳定了。

这个案例几乎覆盖了我们前面讲的所有问题:组合逻辑毛刺、跨模块数据传递、按键同步、状态机默认态。一个看似“仿真完美”的项目,上板之后要过这些关。

8.1 排查工具链怎么搭

如果你是初学者,最好别只看Vivado的仿真波形。推荐一个低成本高回报的组合:

  • 功能仿真用Icarus Verilog,快速、轻量,改完代码秒出结果
  • 看波形用GTKWave,打开VCD文件
  • 上板之后,用Vivado的ILA(集成逻辑分析仪)抓内部信号
  • 有条件的弄个USB逻辑分析仪,连外部引脚,便宜的一百多块,排查外部接口问题效率提升十倍

有了ILA之后你才真正能看见FPGA板子上跑的是什么,和仿真波形一对比,陷阱在哪一目了然。

8.2 仿真工具默认不帮你检查什么

这也是我特别要提醒的。Icarus Verilog和Vivado的功能仿真默认不做时序检查。你要想检查建立时间和保持时间违例,必须跑Vivado的post-implementation仿真,或者看时序报告。很多人写完代码直接跑功能仿真,波形对了就觉得万事大吉——应该养成看timing summary的习惯。如果时序报告里出现setup violation,哪怕仿真波形再完美,上板也大概率会出问题。

这里有个小技巧:Vivado综合之后,打开Report Timing Summary,看关键路径的WNS(Worst Negative Slack),如果是负数,说明有时序违例。不用看懂所有细节,只需要关注有没有红色负数,有的话就说明你这段代码在时序上不健康。

9. 常见问题速查表与避坑清单

最后把这六个陷阱整理成一张速查表,方便你按图索骥地排查自己代码。我自己的习惯是写完代码先过一遍这张表,再去做综合和上板,能省下至少一晚上的调试时间。

序号排查点检查方法修复手段
1always块里赋值方式混用看代码中=和<=的使用位置时序逻辑统一用<=,组合逻辑统一用=
2敏感列表缺失查看是否有手写敏感列表一律改用@(*)
3跨时钟域信号直连看信号来源和驱动时钟是否一致单bit加两级同步器,多bit加异步FIFO或握手
4复位信号未同步释放检查复位释放路径统一用异步拉低、同步拉高的复位模块
5组合逻辑输出接时钟/毛刺问题查看always块敏感列表是否是时钟,查看消抖逻辑外部信号先同步再作使能,禁止门控时钟
6状态机输出毛刺查看状态机输出路径有无寄存输出补一拍寄存,状态机加重置默认态

如果你发现自己中招了其中任何两个以上,别慌,这说明你已经进入了“真的在做硬件”的阶段,而不是还在“写C语言式Verilog”的幻觉里。仿真只是参考,综合报告才是第一道真实的审查,上板才是最终裁决。

我个人在批改学生代码时最感慨的一点是:同样一段逻辑,一个做了同步处理、用了标准三段式状态机的代码,和一个把所有信号直接连起来“看起来能跑”的代码,综合之后在布局布线上的表现差距非常大。前者在上板之前就排除了故障,后者则是把所有问题都留给示波器去审判。

还有一个小技巧分享给正在调板子的你:遇到诡异的不定时错误,第一件事先检查所有跨时钟域路径,第二件事看有没有组合逻辑输出接到触发器时钟端,第三件事看复位释放时序。这三板斧砍下去,八成问题都能定位。剩下两成,就是亚稳态概率性的搞怪,需要通过同步和寄存来消化。

课设也好、项目也好,Verilog写出来“能跑”只是及格线,“能在任何温度、任何电压下稳定跑”才是真正的目标。为了达到这个目标,六个陷阱一个都不能踩。

返回列表