4. Verilog基础(二):运算符、条件语句、循环语句、任务与函数

好,我们接着往下聊。上一章我们把Verilog的基本结构、数据类型和模块化设计捋了一遍。这一章,咱们要深入一点,聊聊那些真正让代码“活”起来的元素——运算符、条件判断、循环,还有任务和函数。

这些东西,说白了就是Verilog的“语法肌肉”。你光知道怎么声明一个wire、一个reg,那只是搭了个空架子。真正让电路按照你的意图去工作,靠的就是这些控制结构和运算逻辑。

我个人习惯,在写RTL代码之前,会先在脑子里过一遍:这个功能,用组合逻辑还是时序逻辑?用if-else还是case?需不需要写个function来复用?想清楚了再动键盘,效率会高很多。

4.1 运算符:不只是加减乘除

Verilog的运算符,跟C语言很像,但有些地方你得特别小心。我刚开始学的时候,就吃过“位宽不匹配”的亏。

4.1.1 算术运算符

加减乘除(+、-、*、/)、取模(%)、幂运算(**)。这些都很直观。但有一点要注意:除法器和取模器在硬件里非常昂贵。你写个“a / b”,综合工具会给你生成一个庞大的组合逻辑除法器,面积大、延迟高。

警告: 在RTL代码中,尽量避免使用“/”和“%”运算符,除非你能接受它带来的面积和时序代价。我见过有人用“/16”来实现右移4位,其实写成“>> 4”就完事了。

4.1.2 位运算符与归约运算符

位运算符:按位与(&)、按位或(|)、按位异或(^)、按位取反(~)。这些是数字电路的基础,没什么好说的。

归约运算符:这是Verilog的特色。它把一个向量的所有位“归约”成1位。比如:

wire [7:0] data;
wire all_ones;
assign all_ones = &data;  // 等价于 data[7] & data[6] & ... & data[0]

我个人特别喜欢用归约异或(^data)来检查奇偶校验。在项目中,我曾经用一行代码就搞定了整个数据包的奇偶校验逻辑,简洁又高效。

4.1.3 逻辑运算符与关系运算符

逻辑运算符(&&、||、!)返回的是布尔值(0或1)。关系运算符(>、<、>=、<=、==、!=)也一样。这里有个坑:全等运算符(===)和不全等运算符(!==)

普通相等(==)在比较时,如果某一位是x或z,结果就是x(未知)。而全等运算符(===)会把x和z当作具体的值来比较。这在仿真调试时非常有用。

小技巧: 在testbench里检查状态机状态时,我习惯用“===”来比较,这样能准确捕捉到“状态跳到了未知态”这种bug。

4.1.4 移位运算符

左移(<<)、右移(>>)、算术右移(>>>)。算术右移会保留符号位,这在有符号数运算中很关键。

嗯,这里要注意:移位运算符综合出来的就是连线,不占任何逻辑资源。所以,能用移位实现的乘除,千万别用乘除运算符。

4.1.5 拼接与复制运算符

拼接运算符({})和复制运算符({{}})是Verilog的“瑞士军刀”。

wire [7:0] a, b;
wire [15:0] c;
assign c = {a, b};  // 拼接
assign d = {4{a}};  // 复制4次

你想想看,用一行代码就能把多个信号拼成一个总线,或者把一个信号扩展成多位,是不是很方便?

4.2 条件语句:if-else 与 case

条件语句是RTL设计的灵魂。没有它们,你的电路就是一条直线,不会“思考”。

4.2.1 if-else 语句

if-else的用法跟C语言几乎一样。但在硬件里,它综合出来的是优先级编码器

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

这段代码综合出来,sel[0]的优先级最高,sel[1]次之。为什么?因为if-else是串行判断的。这在某些场景下是好事,但如果你想要一个“并行选择”的效果,就得用case。

关键点: if-else生成优先级逻辑,case生成并行逻辑(除非用了priority关键字)。这是RTL设计的基本常识。

4.2.2 case 语句

case语句综合出来的是多路选择器(MUX),所有分支是并行的。

always @(*) begin
    case (sel)
        2'b00: out = a;
        2'b01: out = b;
        2'b10: out = c;
        2'b11: out = d;
        default: out = '0;
    endcase
end

这里有个非常重要的点:case语句必须写全所有分支,或者加上default。否则,综合工具会给你生成一个“锁存器(latch)”。

我曾经在项目中排查过一个诡异的bug:某个信号在仿真时正常,但芯片回来后就是不对。最后发现,是case语句少写了一个分支,综合工具默默地生成了一个latch。从那以后,我写case必加default。

4.2.3 casex 与 casez

这两个是case的变种,允许在比较时忽略某些位。casez把z当作“无关位”,casex把x和z都当作“无关位”。

说实话,我在实际项目中很少用casex和casez。因为它们容易掩盖设计中的x态传播问题。如果你真的需要“无关位”比较,我建议用casez,并且只在仿真中使用。

4.3 循环语句:for、while、repeat、forever

循环语句在Verilog里,主要用于生成语句(generate)testbench。在RTL代码里,for循环用得比较多,但要注意它的综合行为。

4.3.1 for 循环

for循环在RTL中是可综合的,但前提是循环次数必须是常数。

always @(*) begin
    integer i;
    for (i = 0; i < 8; i = i + 1) begin
        parity = parity ^ data[i];
    end
end

这段代码综合出来,就是8个异或门串联。for循环在综合时会被“展开”成并行逻辑。所以,循环次数不能太大,否则会生成巨大的组合逻辑。

个人经验: 我一般只在两种场景下用for循环:一是生成重复的寄存器或连线(用generate for),二是在组合逻辑里做简单的迭代运算(如奇偶校验、加法树)。循环次数超过16次,我就会考虑换一种设计思路。

4.3.2 while、repeat、forever

while和repeat在RTL中不可综合,只能在testbench里用。forever也一样,主要用于生成时钟和激励。

initial begin
    forever #5 clk = ~clk;  // 生成周期为10的时钟
end

你想想看,while循环的条件在硬件里没法“动态判断”,所以综合工具直接拒绝。记住这个原则:RTL代码里,循环次数必须是编译时就能确定的常数

4.4 任务(task)与函数(function)

任务和函数,是Verilog里实现代码复用的两种方式。它们很像,但有几个关键区别。

4.4.1 函数(function)

函数的特点:

  • 必须返回一个值
  • 不能包含时序控制(如#delay、@posedge)
  • 至少有一个输入参数
  • 在RTL中可综合
function [7:0] parity_gen;
    input [7:0] data;
    integer i;
    begin
        parity_gen = 0;
        for (i = 0; i < 8; i = i + 1)
            parity_gen = parity_gen ^ data[i];
    end
endfunction

调用时:assign parity = parity_gen(data_in);

我个人习惯,把一些简单的、组合逻辑的、需要复用的计算,写成function。比如奇偶校验、格雷码转换、位宽匹配等。

4.4.2 任务(task)

任务的特点:

  • 可以有多个输出,也可以没有返回值
  • 可以包含时序控制
  • 可以有input、output、inout参数
  • 在RTL中不可综合(除非是纯组合逻辑的任务)
task spi_write;
    input [7:0] addr;
    input [7:0] data;
    begin
        cs_n = 0;
        sclk = 0;
        // 发送地址
        for (i = 7; i >= 0; i = i - 1) begin
            mosi = addr[i];
            #10 sclk = 1;
            #10 sclk = 0;
        end
        // 发送数据
        for (i = 7; i >= 0; i = i - 1) begin
            mosi = data[i];
            #10 sclk = 1;
            #10 sclk = 0;
        end
        cs_n = 1;
    end
endtask

这个任务只能在testbench里用,因为它有时序控制(#10)。在RTL里,你不能这么写。

注意: 很多新手会把任务和函数混用。记住一个简单的判断标准:如果代码里有时序控制(#、@、wait),就用task;否则,能用function就用function。因为function更简洁,而且可综合。

4.4.3 自动(automatic)任务与函数

默认情况下,任务和函数里的变量是静态的(static)。如果你在多个地方同时调用同一个任务或函数,静态变量会互相干扰。

解决办法:加上automatic关键字。

function automatic [7:0] parity_gen;
    // 内部变量是自动的,每次调用独立
endfunction

嗯,这里要注意:在SystemVerilog里,默认就是automatic的。但如果你还在用老式的Verilog-95/2001,建议养成加automatic的习惯。

4.5 避坑指南与实战建议

聊了这么多,最后总结几条我踩过的坑:

  • if-else不写全,综合出latch:这是最常见的RTL bug。记住,组合逻辑里,所有条件分支都必须覆盖。
  • case不加default,同样出latch:哪怕你觉得所有情况都覆盖了,也请加上default。因为仿真时可能会有x态。
  • 在RTL里用除法器:除非你明确知道自己在做什么,否则别用“/”和“%”。用移位和加法替代。
  • 循环次数不是常数:综合工具会直接报错,或者生成一个巨大的组合逻辑。
  • 任务里有时序控制,却用在RTL中:综合工具会报错,或者忽略时序控制,导致仿真和综合不一致。

我曾经在一个项目中,因为case语句少写了一个default,导致芯片在特定条件下输出错误。排查了整整两天,最后用形式验证工具才找到问题。从那以后,我写代码时,每写一个case,就立刻补上default,已经成了肌肉记忆。

好了,这一章的内容就到这里。运算符、条件语句、循环、任务与函数,这些是Verilog的“语法肌肉”。多写、多练、多踩坑,你就能慢慢找到感觉。下一章,我们会聊聊更高级的话题——状态机设计与时序控制。