4. SystemVerilog基础回顾(二):过程块、任务与函数、事件、断言基础

好,咱们接着聊SystemVerilog。上一章我们把数据类型、操作符这些基础过了一遍。这一章,我挑几个在验证环境里天天打交道的核心语法来讲:过程块、任务函数、事件同步,还有断言。

这些东西,说白了就是验证代码的骨架和血肉。你写testbench,写driver,写monitor,哪哪都离不开它们。我个人习惯是,先把这些基础搞扎实了,后面学UVM才不至于一头雾水。

4.1 过程块:always、initial、always_comb、always_ff

过程块是Verilog时代就有的老伙计了。SystemVerilog在它基础上加了几把新刀,用起来更顺手。

4.1.1 initial 与 always

initial 块只执行一次,适合做初始化。 always 块循环执行,适合描述时序逻辑或组合逻辑。

module tb_basic;
    logic clk = 0;
    logic rst_n;

    // initial块:只跑一次
    initial begin
        rst_n = 0;
        #20 rst_n = 1;
        #100 $finish;
    end

    // always块:一直翻转
    always #5 clk = ~clk;
endmodule

嗯,这里要注意:always 太通用了,容易写出 latch。我见过不少新手用 always @(*) 写组合逻辑,漏了敏感列表,仿真和综合结果不一致。所以SystemVerilog给了我们更明确的替代品。

4.1.2 always_comb 与 always_ff

always_comb 专门用来描述组合逻辑。它自动推导敏感列表,仿真器还会在0时刻自动执行一次,避免初始值X态问题。

always_ff 专门用来描述时序逻辑。你必须在括号里指定时钟沿和复位沿,写错了编译器直接报错。

logic [3:0] cnt;
logic       clk, rst_n, en;

// 组合逻辑:用 always_comb
always_comb begin
    if (rst_n)
        cnt_next = cnt + 1;
    else
        cnt_next = 0;
end

// 时序逻辑:用 always_ff
always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        cnt <= 0;
    else if (en)
        cnt <= cnt_next;
end
我的习惯: 写新代码时,我基本不用 always @(*) 了。全部换成 always_combalways_ff。代码意图一目了然,工具检查也更严格。我在一个项目里吃过亏——综合前仿真全绿,综合后多出一堆 latch,就是因为 always @(*) 的敏感列表没写全。从那以后,我就彻底改了这个习惯。

4.2 任务与函数

任务和函数,是验证代码模块化的基础。SystemVerilog对它们做了不少增强,尤其是 void functionautomatic 关键字,用起来比Verilog舒服多了。

4.2.1 function 与 void function

传统Verilog的函数必须有返回值。SystemVerilog允许你定义 void function,没有返回值,纯粹执行操作。这在写验证组件时特别常用。

// 传统函数:必须有返回值
function automatic bit is_valid_packet(byte data[]);
    return (data.size() > 0) && (data[0] == 8'hAA);
endfunction

// void function:没有返回值,适合做操作
function automatic void print_packet(byte data[]);
    $display("Packet: %p", data);
endfunction

为什么用 automatic?因为Verilog默认函数里的变量是静态的,递归调用会出问题。SystemVerilog里,automatic 让每次调用都有独立的变量空间。我建议你写函数时,一律加上 automatic,省心。

4.2.2 task 与带参数传递

任务可以消耗仿真时间(带时序控制),函数不行。这是最根本的区别。

task automatic spi_write(input  bit [7:0] addr, data,
                         output bit       ack);
    // 模拟SPI写操作
    cs_n = 0;
    #100;
    sdi  = addr;
    #100;
    sdi  = data;
    #100;
    cs_n = 1;
    ack  = 1;
    #10;
    ack  = 0;
endtask

调用时,你可以用 ref 关键字传递引用,避免大数组的拷贝开销。这在UVM的sequence里经常用到。

task automatic process_data(ref byte data_q[$]);
    // 直接操作原队列,不拷贝
    foreach (data_q[i])
        data_q[i] = data_q[i] ^ 8'hFF;
endtask
避坑指南: 我曾经在任务里用 input 传了一个大数组,仿真速度慢得离谱。后来改成 ref,速度快了10倍。但注意:ref 意味着你可能会修改原始数据,如果不想被改,加个 const ref

4.3 事件:triggered 与 wait_order

事件是线程间同步的老办法。SystemVerilog在Verilog的 @(event) 基础上,加了 triggered() 函数,解决了事件竞争的问题。

4.3.1 传统事件 vs triggered()

传统写法:@(evt); 如果事件在 @ 之前就触发了,你就永远等不到了。这叫“事件丢失”。

triggered() 函数会返回一个布尔值,告诉你事件是否在 当前仿真时间片 内被触发过。即使你晚了一步检查,也能知道事件发生过。

event data_ready;

// 生产者线程
task automatic producer();
    #10;
    -> data_ready;  // 触发事件
endtask

// 消费者线程
task automatic consumer();
    #5;
    // 用 triggered() 检查:事件是否在0时刻内触发过?
    if (data_ready.triggered())
        $display("事件已触发,开始处理数据");
    else
        $display("事件未触发,等待中...");
endtask

我个人习惯是:新代码一律用 triggered(),不再用 @(event)。因为事件竞争在验证环境里太常见了,尤其是多个driver同时驱动同一个事件时。

4.3.2 wait_order

wait_order 用来等待多个事件按指定顺序发生。如果顺序错了,它会报错。这在检查协议时序时特别有用。

event evt1, evt2, evt3;

task automatic check_sequence();
    wait_order(evt1, evt2, evt3)
        $display("事件顺序正确");
    else
        $error("事件顺序错误!期望 evt1->evt2->evt3");
endtask

嗯,这里要注意:wait_order 是阻塞的,它会一直等下去,直到所有事件按序发生,或者超时(如果你加了超时控制)。

4.4 断言基础:立即断言与并发断言

断言是验证的“眼睛”。没有断言,你就像在黑夜里开车——出了错也不知道。SystemVerilog提供了两种断言:立即断言和并发断言。

4.4.1 立即断言

立即断言是过程性的,写在过程块里,执行到这一行时立即检查。语法是 assert(expression)

always_ff @(posedge clk) begin
    // 检查:复位后第一个时钟,计数器必须为0
    if (!rst_n)
        cnt <= 0;
    else begin
        cnt <= cnt + 1;
        // 立即断言:cnt 不能超过 15
        assert (cnt <= 15) else
            $error("cnt 溢出!当前值 = %0d", cnt);
    end
end

立即断言的好处是简单直接。坏处是它只能检查当前时刻的值,不能检查跨时钟周期的时序关系。

4.4.2 并发断言

并发断言用 propertysequence 来描述,可以检查跨越多个时钟周期的行为。它基于时钟沿采样,不是过程性的。

// 定义一个序列:req 拉高后,2个时钟内 ack 必须拉高
sequence req_ack_seq;
    @(posedge clk) req ##[1:2] ack;
endsequence

// 定义属性:每次 req 拉高,序列必须成立
property req_ack_prop;
    @(posedge clk) $rose(req) |-> req_ack_seq;
endproperty

// 实例化断言
assert property (req_ack_prop)
    else $error("req-ack 协议违例!");

并发断言的核心符号:

  • ##n:延迟 n 个时钟周期
  • ##[m:n]:延迟 m 到 n 个时钟周期
  • |->:蕴含操作符,左边条件成立时,右边必须成立
  • $rose$fell$stable:边沿检测函数
我的经验: 并发断言是验证效率的倍增器。我在一个PCIe项目中,用断言覆盖了80%的协议检查点。每次回归跑完,断言报告直接告诉我哪里出了问题,根本不用去翻波形。但要注意:断言不要写得太复杂,否则仿真速度会下降。我一般把断言控制在10行以内,超过这个长度就拆成多个小断言。

4.5 本章知识体系

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。

SystemVerilog 基础回顾(二) 过程块 initial always / always_comb always_ff 任务与函数 function / void function task (可消耗时间) ref / const ref 参数 事件 -> 触发事件 triggered() 检查 wait_order 顺序等待 断言 立即断言 并发断言 property / sequence 核心:过程块是骨架,任务函数是肌肉,事件是神经,断言是眼睛 验证环境搭建中,这四个要素缺一不可 UVM 的 driver、monitor、scoreboard 都建立在这些基础之上

好了,这一章的内容就这些。过程块、任务函数、事件、断言——这四个东西,你在后续搭建UVM验证环境时会反复用到。尤其是断言,我建议你从现在开始就养成习惯:每写一个接口,至少加一个并发断言。相信我,这会帮你省下大量调试时间。


公众号:蓝海资料掘金营,微信deep3321