一、先把问题说清楚:什么是逻辑竞争

写Verilog的老哥们肯定都遇过这种事:仿真波形明明好好的,一上板子就“抽风”,有时候输出对,有时候输出错,甚至时钟一快直接罢工。你查了半天,代码逻辑看着没问题,那问题大概率出在“逻辑竞争”上。说白了,就是两个或多个信号同时要改变一个值,但谁先谁后说不准,电路在那一瞬间“打架”了。数字电路讲究的是“确定性”,一打架就乱套。今天咱们就把这个捣蛋鬼揪出来,看看它藏在哪,怎么治。

二、为啥仿真和实际硬件会不一样

2.1 仿真里的“理想世界”

仿真工具跑的时候,默认每个门电路都有固定的延迟,但这个延迟是模型里预先设好的,而且大多数情况下,仿真器按代码顺序和事件队列来调度。你写了个assign语句,仿真器会按顺序“排队”更新信号,看起来就像有个隐形的裁判在指挥。但真实芯片不是这样的,每个门的延迟跟温度、电压、制造工艺都有关系,两个门之间谁快谁慢,根本没法绝对保证。所以仿真里“碰巧”顺序对的,硬件上可能就反了。

2.2 竞争就藏在“同时变化”里

咱们举个生活例子:两个人同时伸手去拿桌上唯一的一支笔,谁拿到?取决于谁手臂更长、谁反应更快。在电路里,两个信号同时到达一个逻辑门的输入端,输出是什么?取决于谁先到。如果这个门的输出又反馈回去影响输入,那整个电路就在那抖啊抖,直到稳定。但数字电路不能“抖”,它必须在时钟边沿到来之前稳定下来。

三、门级建模里的常见“打架”场景

3.1 组合逻辑反馈环

这是最典型的“死循环”。你把一个门的输出接回自己的输入,比如:

// verilog 技术栈示例:反馈环导致的竞争
module feedback_bug(
    input  wire a,
    output wire y
);
    wire n1;
    // 把输出y和输入a经过与非门后,再送回去和a做与运算
    assign n1 = ~(a & y);   // y被n1驱动,n1又依赖y,形成环
    assign y  = a & n1;
    // 当a=1时,n1=~y,y=~y?这可不是逻辑,这是振荡
endmodule

你看这个例子,当a为1时,整个电路变成y = ~y,仿真器会卡住或者给出未知值,真实电路则直接振荡发热。这种代码一眼就能看出来,但实际工程里,中间隔了七八个门,你就不容易发现了。排查技巧:把组合逻辑的依赖图画出来,看有没有从某个输出绕一圈又回到自身的路径。一旦发现,必须打断这个环,要么加寄存器,要么重新设计逻辑结构。

3.2 多个信号到达时间不一致

这类竞争在门级尤其常见,因为门级模型里每个门都有延迟,你搭了一个加法器,从输入到输出的路径有很多条,有的路径经过3个门,有的经过5个门。当输入同时变化时,输出端各个位的变化时刻就错开了。如果这个输出又被当作时钟或者异步控制信号,那就惨了。

看个具体例子,两位加法器的进位链:

// verilog 技术栈示例:进位链上的竞争
module adder2_gate(
    input  wire a1, a0,
    input  wire b1, b0,
    input  wire cin,
    output wire sum1, sum0,
    output wire cout
);
    // 半加器:s0 = a0 ^ b0;c0 = a0 & b0
    wire c0;
    wire s0_t, a0_b, b0_a;
    not  u0 (a0_b, a0);
    not  u1 (b0_a, b0);
    and  u2 (c0, a0, b0);
    // 异或门用与非门搭:t = ~(a0 & b0); s0 = t & (a0 | b0)
    nand u3 (s0_t, a0, b0);     // s0_t = ~(a0 & b0)
    or   u4 (tmp_or, a0, b0);
    and  u5 (sum0, s0_t, tmp_or);

    // 全加器的高位:sum1 = a1 ^ b1 ^ c0; cout = (a1 & b1) | (c0 & (a1 ^ b1))
    wire t1, t2, t3, t4;
    xor  u6 (t1, a1, b1);       // 先算 a1 ^ b1
    xor  u7 (sum1, t1, c0);     // 再和进位异或
    and  u8 (t2, a1, b1);
    and  u9 (t3, c0, t1);
    or   u10(cout, t2, t3);
endmodule

这里c0从低位的两个输入产生,然后直接送到高位作为输入。在真实电路里,c0要经过and门和or门才能稳定,同时t1也需要时间。如果sum1被当作时钟或者复位信号,那么sum1可能会先变成中间态,再跳到最终值,这个中间态就叫毛刺。在门级仿真里,如果你给每个门指定了标准延迟(比如#1),仿真器会忠实地把这些毛刺也模拟出来。但如果你用行为级别的assign #1,可能就看不见毛刺——因为你对整条逻辑只给了一个延迟,掩盖了内部竞争。

3.3 异步复位信号竞争

很多同学写复位喜欢用异步复位,因为简单。但异步复位信号如果和时钟边沿“撞车”,就有可能导致寄存器进入亚稳态——既不是0也不是1,就像人半睡半醒。看这个:

// verilog 技术栈示例:异步复位与时钟竞争
module rst_bug(
    input  wire clk,
    input  wire rst_n,
    input  wire d,
    output reg  q
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;    // 复位优先
        else
            q <= d;       // 正常数据
    end
endmodule

你可能会说,这代码不是标准写法吗?没错,写法是标准的,但门级实现里,复位一般是通过复位树直接连到寄存器的RN端。当rst_n撤销(变高)的时候,如果刚好在时钟上升沿附近,寄存器内部会竞争:一个是时钟让数据打进来,一个是复位端释放,两个信号同时在打架。结果就是q可能乱跳一下,甚至保持错误值。解决思路:要么用同步复位,要么对异步复位做“异步复位、同步释放”处理。什么叫同步释放?就是复位信号先经过两级触发器同步,再作为内部复位。这样保证复位撤销时避开时钟边沿。

四、门级建模的优势和踩坑点

4.1 先说好处

门级建模最贴近真实硬件,每个门、每条线延迟都在你的掌控里。你可以精确地模拟关键路径的时序,找出哪些组合逻辑可能产生毛刺,哪些信号在时钟沿前不能稳定。这对搞高速电路的人来说特别重要。而且,很多FPGA厂商的工具会把你写的RTL综合成门级网表,你拿着网表做仿真,能发现综合前后行为不一致的地方,这就是门级仿真的价值。

4.2 再说坑

门级建模最大的坑就是“太啰嗦”。一个加法器,行为级一条+就完事,门级要写十几个门,还容易写错。而且门级仿真速度慢,因为你每个门都要算延迟。更关键的是,门级仿真里竞争问题的后果会被放大——因为你的门是有延迟的,所以只要你路径没算好,毛刺一抓一大把。很多新人一看波形上全是毛刺就慌,其实有些毛刺只要不落在时钟边沿上就没影响,但有些就会导致功能错乱,你得学会区分。

五、实战排查技巧:从波形到代码

5.1 看波形要会“抠细节”

仿真波形里,如果一个信号在逻辑0和1之间快速抖动好几次,那就是毛刺。毛刺出现的位置通常在输入信号变化后的几个皮秒内。你需要把光标放在毛刺上,看看它是由哪个输入变化触发的。顺着触发信号的来源往上追,就能找到竞争的源头。记住,多个输入同时变化时,最容易产生毛刺。比如一个或门的两个输入,一个从0变1,同时另一个从1变0,这个门输出本来应该保持1,但因为两个变化有先后,输出可能会突然掉到0再弹回1,这个“掉到0”就是毛刺。

5.2 用穷举法验证每个输入组合

写个testbench把所有可能的输入组合都跑一遍,尤其关注两个输入同时翻转的情况。比如一个简单的与门,输入a和b,如果a从0到1,b从1到0,理论上a&b应该始终为0,但如果a先到高,b还没变低,那一瞬间a&b就变成1了,这就是毛刺。怎么模拟这种“同时翻转”?在testbench里给a和b分别设置不同的延迟,让它们的翻转边沿相差一点点,比如1皮秒。看看输出有没有意外的脉冲。如果发现了,说明你的逻辑需要加约束或者加缓冲,保证信号走线长度一致,或者干脆用时钟打一拍,把毛刺隔绝在寄存器之外。

5.3 提防“隐性闩锁”

组合逻辑的always块里,如果某些分支没有把所有输出都赋值,综合工具会给你生成一个锁存器,这个锁存器是异步的。异步锁存器极其容易产生竞争。比如:

// verilog 技术栈示例:不完整的if造成的锁存器
module latch_bug(
    input  wire a,
    input  wire b,
    output reg  y
);
    always @(*) begin
        if (a) begin
            y = b;
        end
        // 没有else!当a=0时,y保持原来的值,于是变成了锁存器
    end
endmodule

这种锁存器在门级网表里会表现为一个内部反馈结构。如果你的设计中本来不需要锁存器,那就把所有情况都写全。如果确实需要锁存器,也要明确它是电平敏感,并且要保证控制信号a不能有毛刺。否则a一旦闪一下,锁存器就可能捕获一个错误的b值。

六、真正的实战示例:用门级模型重现竞争并修复

咱们来做一个完整的例子。假设要设计一个“安全的使能信号”:当输入en为高时,输出out跟随输入data;当en为低时,输出强制为0。很多新手会直接写assign out = en & data;,这在逻辑上没错,但门级实现里,en和data可能来自不同的路径。如果en和data同时变化,out容易出毛刺。咱们用门级实现来演示:

6.1 不加防护的门级版本

// verilog 技术栈示例:门级使能逻辑(存在竞争风险)
module enable_gate_bug(
    input  wire en,
    input  wire data,
    output wire out
);
    // 直接用一个与门完成
    and u0 (out, en, data);
    // 问题:当en从0->1,data从1->0同时发生,且en变高稍快时,
    // out会短暂为1,产生一个不应出现的尖峰脉冲。
endmodule

看这个bug,如果en先到1,data还没到0,那out就等于1 & 1,输出高。等data变成0后,输出才变低。这个尖峰如果控制的是时钟门控或复位,那就完蛋。下面我们写一个testbench来复现。

6.2 用testbench让竞争现形

// verilog 技术栈示例:模拟en与data同时翻转的testbench
`timescale 1ns/1ps
module tb_enable_gate_bug;

    reg  en;
    reg  data;
    wire out;

    // 实例化被测试的门级模块
    enable_gate_bug u_dut(
        .en   (en),
        .data (data),
        .out  (out)
    );

    // 给与门内部增加延迟,放大竞争效果
    // 这里用门级模块的实例路径强指定延迟,为了仿真演示
    // 实际工程中,延迟来自工艺库,这里为了演示,用一个与门加#1延迟
    // 但enable_gate_bug里没有写延迟,我们只能通过强制延时来模拟
    // 为了更直观,我们直接在激励中制造时间差

    initial begin
        // 初始状态:en=0, data=1,输出为0
        en = 0;
        data = 1;
        #10;

        // 在10ns时刻,让en变1,同时data变0
        // 但是我们在同一时刻赋值,仿真器会按事件队列处理
        // 两个赋值语句按顺序执行,但门级的更新会有额外延迟
        en = 1;
        data = 0;
        #10;

        // 再测试一种情况:en变1,data保持1,输出应该是1
        data = 1;
        #10;

        // 结束仿真
        $finish;
    end

    // 监视输出变化
    initial begin
        $monitor("time=%0t en=%b data=%b out=%b", $time, en, data, out);
    end

endmodule

因为enable_gate_bug里的and门没有指定延迟,仿真器默认它还是零延迟,所以你在波形上可能看不到尖峰。真正的门级网表里每个门都有延迟,我们这里为了演示,可以临时给and门加个延迟,但这样要改模块。更聪明的做法是在门级网表里用specify块声明路径延迟,这属于时序建模的范畴,咱们不展开。你只需要记住:门级仿真如果每个门都有延迟,竞争就会暴露得非常明显。我这里给你一个带延迟的门级模块:

// verilog 技术栈示例:带延迟的门级与门,便于观察竞争
module enable_gate_delayed(
    input  wire en,
    input  wire data,
    output wire out
);
    and #1 u0 (out, en, data);
    // 与门的输出延迟1个时间单位
endmodule

如果你把测试模块里的enable_gate_bug换成enable_gate_delayed,并且让en和data在同一时刻变化,你会在波形上看到out在1ns后变成1,然后又变0,这个1ns的脉冲就是竞争毛刺。

6.3 怎么修复?加一个“握手信号”

最稳妥的办法是避免让组合逻辑直接输出关键控制信号。把en和data都经过D触发器同步,然后在时钟边沿之后让它们一起进入与门。因为触发器输出只在时钟沿变化,所以en_q和data_q在同一个时钟沿之后才稳定,电路不会在毛刺期间采到中间态。修改后的代码:

// verilog 技术栈示例:用两级触发器同步后消除竞争
module enable_safe(
    input  wire clk,
    input  wire rst_n,
    input  wire en,
    input  wire data,
    output wire out
);
    reg en_d1, en_d2;   // en的同步打拍
    reg data_d1, data_d2; // data的同步打拍

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            en_d1   <= 1'b0;
            en_d2   <= 1'b0;
            data_d1 <= 1'b0;
            data_d2 <= 1'b0;
        end
        else begin
            en_d1   <= en;
            en_d2   <= en_d1;
            data_d1 <= data;
            data_d2 <= data_d1;
        end
    end

    // 使用同步后的信号做与逻辑
    assign out = en_d2 & data_d2;
endmodule

这样out的输出就只跟时钟同步后的信号有关,不会出现en和data同时翻转的情况。当然,打拍会引入两个时钟周期延迟,你需要根据实际场景取舍。这就是典型的“用时间换稳定”。

七、再聊一个高级技巧:使用SDF反标

门级仿真如果手写延迟那太累了。工程上真正做门级仿真时,你的工具(比如Quartus、Vivado)会把网表和SDF文件一起给你。SDF(Standard Delay Format)是一种标准文件,里面写了每个门和连线的延迟。你把这个文件用$sdf_annotate读进testbench,门级仿真就会严格按照真实走线延迟来跑。这时候,竞争问题会原形毕露。我给你看个用法:

// verilog 技术栈示例:在testbench中反标SDF延迟
module tb_sdf;

    reg  clk;
    reg  rst_n;
    reg  data_in;
    wire data_out;

    // 假设有一个门级网表模块
    gate_level_design u_dut(
        .clk    (clk),
        .rst_n  (rst_n),
        .data_in (data_in),
        .data_out(data_out)
    );

    initial begin
        // 反标SDF文件到门级网表
        $sdf_annotate("gate_level_design.sdf", u_dut);
        // 初始化信号...
    end

    // 时钟生成等...
endmodule

做完这步之后,你再跑仿真,看到那些毛刺,别慌。先用美元的眼睛看一下:毛刺出现在什么时候?它有没有碰到时钟上升沿?如果毛刺发生在时钟边沿之后到下一个边沿之前,并且没有干扰到其他信号的建立/保持时间,那它只是个“幻影”,不影响功能。但如果毛刺正好卡在时钟沿上,那你必须去改数据路径。

八、应用场景和注意事项

8.1 什么场合必须做门级仿真

只要你的设计会被真实烧进芯片或FPGA,最好都做。尤其是以下几类:跨时钟域的数据传递,异步复位设计,高扇出的控制信号,还有各种使能、清零、加载信号。这些信号一旦出毛刺,后果很严重。另外,如果你的设计用了大量组合逻辑做数据选择,门级仿真也能帮你确认译码器输出有没有毛刺。

8.2 注意事项清单

第一,门级仿真比RTL仿真慢很多,你要学会用模块级的仿真替代全芯片仿真,只针对关键路径或者有疑点的模块做。第二,SDF文件一定要选对版本,不同工艺库对应不同SDF格式,选错了会报一堆警告,结果也不对。第三,不要追求零毛刺,组合逻辑在一定条件下必然有毛刺,只要这些毛刺不穿过时钟、不复位、不使能,就可以接受。第四,如果你用了锁存器,那一定要检查锁存器的控制信号质量,这个信号哪怕只有一个皮秒的毛刺,都能让锁存器采错。第五,做异步处理时,千万不要相信“这个信号变化很慢没事”,数字电路里没有“慢信号”,只有“未同步信号”。

8.3 门级建模和RTL建模的取舍

门级建模精确但费劲,RTL建模抽象但可能掩盖细节。实际做法是:RTL做前期的功能验证,门级网表做后期的时序验证。你不要试图用门级建模去实现所有功能,那是自己找罪受。正确的姿势是:写完RTL,综合出门级网表,然后门级网表加上SDF做后仿真。如果后仿真出现功能错误,再回到RTL里分析竞争根源。这样效率最高。

九、文章总结

逻辑竞争是数字设计里最让人头疼的问题之一,但它不是无迹可寻。你只要抓住几个要害:避免给一个信号同时安排多个驱动源,避免组合逻辑反馈环,避免异步信号在时钟边沿附近乱跳,尽量使用同步电路。门级建模因为更贴近硬件,所以会把这些“暗病”暴露出来。你要学会用工具、用波形、用枚举法去定位问题,而不是盯着代码干瞪眼。每一次竞争问题的排查,都是对数字电路理解的一次加深。搞明白了这些,你的设计离“不管仿真还是上板都跑得稳”就不远了。

最后记住一句话:仿真通过不代表硬件没问题,竞争问题就像咳嗽,不查不知道,一查吓一跳。预防为主,同步为大,门级仿真做辅助,你的芯片才能睡得安稳。