4. Verilog组合逻辑设计:always块、if-else、case语句、三目运算符

组合逻辑,说白了就是输出只取决于当前输入,跟历史状态没关系。你给它什么,它立马给你什么,不带任何记忆。今天咱们就把Verilog里实现组合逻辑的几种常用手段掰扯清楚。

4.1 always块:组合逻辑的“工作台”

always块是Verilog里最核心的语句块之一。我刚开始学的时候,总觉得它神秘兮兮的。其实它就是个“敏感列表驱动”的代码块——只要敏感列表里的信号变了,它就执行一次。

对于组合逻辑,我们用的是 always @(*) 这种写法。星号表示“所有输入信号”,省得你一个一个列,还容易漏掉。我个人习惯,写组合逻辑一律用 @(*),干净利落。

核心要点:组合逻辑的always块里,必须用阻塞赋值(=),不能用非阻塞赋值(<=)。这是无数新手踩过的坑。

// 正确的组合逻辑写法
always @(*) begin
    y = a & b;  // 阻塞赋值
end

// 错误的写法(会导致仿真行为异常)
always @(*) begin
    y <= a & b;  // 非阻塞赋值,综合结果可能正确,但仿真会出问题
end

我在项目中遇到过一位同事,用非阻塞赋值写组合逻辑,仿真怎么跑都对,一上板子就出问题。查了两天才发现是赋值方式用错了。嗯,从那以后我每次看到always块,第一眼先看赋值符号。

4.2 if-else语句:条件分支的“老大哥”

if-else是写条件逻辑最直观的方式。你想想看,如果条件A成立,就做这件事;否则做那件事。跟日常思维一模一样。

always @(*) begin
    if (sel) begin
        out = a;
    end else begin
        out = b;
    end
end

这里有个关键点:if-else必须写完整。如果你只写了if,没写else,综合器会认为“条件不满足时保持原值”。但组合逻辑没有保持能力,于是综合器会给你生成一个锁存器(latch)。这玩意儿在组合逻辑里是毒瘤,时序难控,功耗还大。

避坑指南:我曾经因为一个if后面忘了写else,导致综合出来一堆latch。后端的哥们跑过来问我:“你这设计里怎么这么多latch?”我一看,脸都绿了。从那以后,我写if-else必检查else分支是否覆盖完整。

if-else还有个特点:优先级结构。先判断的条件优先级高,后判断的优先级低。这跟硬件上的级联选择器是一一对应的。

// 优先级编码器:a的优先级最高,c最低
always @(*) begin
    if (a)      out = 2'b00;
    else if (b) out = 2'b01;
    else if (c) out = 2'b10;
    else        out = 2'b11;
end

4.3 case语句:多路选择的“瑞士军刀”

当条件分支比较多的时候,if-else嵌套会变得又臭又长。这时候就该case上场了。case语句本质上是一个并行选择器,所有分支是平等的,没有优先级。

always @(*) begin
    case (sel)
        2'b00: out = a;
        2'b01: out = b;
        2'b10: out = c;
        2'b11: out = d;
        default: out = 4'b0;  // 务必加上default
    endcase
end

注意看,我加了个 default 分支。为什么?因为case语句如果不写default,当sel出现未列出的值时,综合器又会给你生成latch。道理跟if-else不写else是一样的。

小技巧:写case时,如果所有可能的值都列全了,可以不加default。但我建议你永远加上default。为什么?因为仿真时如果出现X态(未知态),default能帮你兜底。我吃过这个亏,仿真跑得好好的,一上硬件就随机出错,最后发现是某个信号在初始化阶段是X,case没匹配到任何分支。

case还有两个变种:casezcasex。它们允许在比较时忽略某些位(用z或x表示“不关心”)。

// casez:忽略高阻态位
always @(*) begin
    casez (sel)
        3'b1??: out = a;  // 只要最高位是1,其他位不管
        3'b01?: out = b;  // 最高位0,次高位1,最低位不管
        3'b001: out = c;
        default: out = 4'b0;
    endcase
end

我个人习惯,能用普通case解决的,尽量不用casez/casex。因为“不关心”位容易引入歧义,代码可读性下降。除非是做地址译码这类明确需要掩码的场景。

4.4 三目运算符:一行代码搞定选择

三目运算符 条件 ? 真值 : 假值 是Verilog里最简洁的条件表达式。它本质上就是一个2选1选择器。

// 三目运算符写法
assign out = sel ? a : b;

// 等价于always块写法
always @(*) begin
    if (sel) out = a;
    else     out = b;
end

三目运算符可以嵌套使用,但我不建议超过两层。你想想看,a ? b ? c : d : e 这种写法,读代码的人得在脑子里画括号,太累了。

// 两层嵌套,勉强能看
assign out = (sel == 2'b00) ? a :
             (sel == 2'b01) ? b :
             (sel == 2'b10) ? c : d;

// 三层以上?别这么干,老老实实用case

实战建议:三目运算符最适合写简单的条件赋值,比如数据路径上的选择。复杂的多路选择,用case更清晰。我在项目中定了一条规矩:超过3个分支,禁止用三目运算符嵌套,一律改用case。

4.5 四种方式的对比与选择

方式 适用场景 硬件结构 注意事项
always @(*) 所有组合逻辑 取决于内部语句 必须用阻塞赋值
if-else 优先级逻辑、少量分支 级联选择器 else分支必须完整
case 多路选择、译码器 并行选择器 default分支必须完整
三目运算符 简单条件赋值 2选1选择器 避免深层嵌套

说白了,没有哪种方式绝对好。我个人的选择原则是:代码可读性优先。你写的代码,三个月后自己还能看懂,同事也能看懂,这就是好代码。

4.6 一个完整的例子:4选1多路选择器

咱们用四种方式实现同一个功能,你感受一下差异。

// 方式1:always + case
module mux4_case (
    input  [1:0] sel,
    input  [3:0] a, b, c, d,
    output reg [3:0] out
);
    always @(*) begin
        case (sel)
            2'b00: out = a;
            2'b01: out = b;
            2'b10: out = c;
            2'b11: out = d;
            default: out = 4'b0;
        endcase
    end
endmodule

// 方式2:always + if-else
module mux4_if (
    input  [1:0] sel,
    input  [3:0] a, b, c, d,
    output reg [3:0] out
);
    always @(*) begin
        if (sel == 2'b00)      out = a;
        else if (sel == 2'b01) out = b;
        else if (sel == 2'b10) out = c;
        else                   out = d;
    end
endmodule

// 方式3:三目运算符嵌套
module mux4_ternary (
    input  [1:0] sel,
    input  [3:0] a, b, c, d,
    output [3:0] out
);
    assign out = (sel == 2'b00) ? a :
                 (sel == 2'b01) ? b :
                 (sel == 2'b10) ? c : d;
endmodule

这三种方式综合出来的硬件是一样的——都是4选1选择器。但代码风格不同。我个人最喜欢case版本,因为结构清晰,每个分支一目了然。if-else版本隐含了优先级,虽然这里sel的四种取值互斥,优先级不影响结果,但读代码的人会多想一层“为什么用if-else而不是case”。

最后说一句:写组合逻辑,心里要时刻想着硬件。你写的每一行代码,最终都会变成门电路。if-else变成级联的MUX,case变成并行的MUX,三目运算符变成2选1MUX。想清楚硬件结构,代码自然就写对了。