3、SystemVerilog基础:数据类型、接口(interface)、时钟块(clocking block)、断言(assertion)入门

好,咱们进入正题。这一章是SystemVerilog的入门基础,但你别小看它。我见过太多验证工程师,写了好几年代码,数据类型还在用regwire,接口写得像一团乱麻。说白了,这些基础没打牢,后面写复杂验证环境时,你会被自己坑得很惨。

我个人习惯,学任何语言先看它的数据类型。SystemVerilog在这方面比Verilog强太多了。咱们一个一个来。

3.1 数据类型:从reg/wire到logic和2-state类型

先问个问题:你在Verilog里用regwire时,有没有搞混过?

我刚开始做设计时,经常纠结:这个信号到底该用reg还是wire?后来SystemVerilog引入了logic类型,这个问题就简单了。你想想看,logic可以代替reg,也能在连续赋值里用。除非你明确需要多驱动源(比如双向总线),否则一律用logic

核心原则:能用logic,就别用reg和wire。省心。

举个例子:

// 老式Verilog写法
reg [7:0] data_reg;
wire [7:0] data_wire;

// SystemVerilog写法
logic [7:0] data_logic;  // 既能当reg用,也能当wire用

嗯,这里要注意。SystemVerilog还引入了2-state类型,比如bitintbyte。这些类型只有0和1,没有X和Z。仿真速度更快,但你要小心——如果你用bit去接收一个高阻态信号,它会默默变成0,你根本不知道。

我在项目中遇到过这种事:一个同事用bit类型去采样总线数据,结果总线浮空时,他采到的全是0,查了三天bug才发现。所以我的建议是:

  • RTL设计:用logic(4-state),保留X态用于仿真检查
  • 验证环境:用bitint等2-state类型,仿真快,内存省
  • 接口信号:用logic,因为你不知道对方会驱动什么值

小技巧:我习惯在验证环境里用typedef定义自己的数据类型。比如typedef logic [31:0] word_t;,这样改位宽时只改一处,方便得很。

3.2 接口(interface):把信号捆在一起

接口这东西,说白了就是把一组相关的信号打包成一个“包裹”。你想想看,以前写Verilog时,一个AXI总线要传十几个信号,每个模块的端口列表长得像购物清单。用接口之后,清爽多了。

我最早接触接口时,觉得它就是个结构体。后来发现,接口比结构体强太多了——它里面可以包含方法、时钟块、断言,甚至参数化。

看个简单的例子:

interface bus_if (input logic clk);
  logic [31:0] addr;
  logic [31:0] data;
  logic        wr_en;
  logic        rd_en;

  // 时钟块,后面会讲
  clocking cb @(posedge clk);
    output addr, wr_en, rd_en;
    input  data;
  endclocking

  // 断言
  property no_x_on_data;
    @(posedge clk) disable iff (!rst_n)
    !$isunknown(data);
  endproperty
endinterface

你看,一个接口把信号、时序关系、检查规则全包进去了。这在大型项目中特别有用。我曾经在一个SoC项目里,用接口把CPU、DMA、外设之间的总线全部统一管理,改协议时只需要改接口定义,所有模块自动适配。

避坑指南:接口里的信号默认是logic类型,但如果你在多个地方驱动同一个接口信号,会出竞争。我曾经犯过这个错——两个模块同时往接口的同一个信号赋值,仿真结果时对时错。解决方案:用modport明确每个模块的驱动方向。

接口的modport用法:

interface bus_if;
  logic [31:0] addr;
  logic [31:0] data;
  logic        wr_en;

  modport master (output addr, wr_en, inout data);
  modport slave  (input  addr, wr_en, inout data);
endinterface

这样,master模块只能驱动addr和wr_en,slave模块只能接收它们。清晰,安全。

3.3 时钟块(clocking block):让时序不再混乱

时钟块,我愿称之为“时序同步的瑞士军刀”。它的作用很简单:告诉仿真器,信号在时钟的哪个边沿采样,哪个边沿驱动。

你想想看,如果没有时钟块,你写@(posedge clk) a <= b;,仿真器怎么知道a和b的时序关系?有了时钟块,一切变得明确。

基本语法:

clocking cb @(posedge clk);
  default input #1step output #0;
  input  data;
  output addr, wr_en;
endclocking

这里#1step表示在时钟沿之前采样,#0表示在时钟沿之后驱动。这样就能避免竞争。

我在项目中遇到过最典型的场景:验证一个SPI接口,master和slave的时序要求很严格。用时钟块之后,我只需要定义好采样和驱动时机,剩下的交给仿真器。代码量减少一半,bug也少了很多。

重要提醒:时钟块里的信号,访问时要加前缀。比如cb.data。别直接写data,那样访问的是接口里的原始信号,没有时序同步。

时钟块还有一个妙用:在测试程序里,你可以用cb来驱动DUT的输入,采样DUT的输出。这样写出来的测试代码,时序关系一目了然。

// 测试程序中使用时钟块
initial begin
  // 复位
  cb.rst_n <= 0;
  repeat(5) @(cb);
  cb.rst_n <= 1;

  // 写操作
  cb.addr  <= 32'h1000;
  cb.data  <= 32'hA5A5;
  cb.wr_en <= 1;
  @(cb);
  cb.wr_en <= 0;
end

你看,每个操作都同步在时钟沿上,不会出现时序错乱。

3.4 断言(assertion)入门:让代码自己检查自己

断言,说白了就是“代码里的交警”。它帮你检查信号是否满足预期。比如“写使能时,数据不能是X态”、“读请求后,必须在3个时钟内返回数据”。

我刚开始做验证时,全靠波形里肉眼找bug。后来学了断言,才发现以前有多傻。断言能自动检查,24小时不休息,比人靠谱多了。

SystemVerilog断言分两种:

  • 立即断言:像if语句一样,立即检查。用assert关键字。
  • 并发断言:基于时钟沿,检查时序关系。用propertyassert property

先看立即断言:

always_comb begin
  // 如果写使能有效,数据不能为X
  if (wr_en) assert (!$isunknown(data))
    else $error("Data has X when write enable is high!");
end

再看并发断言,这个才是重头戏:

property p_write_data_valid;
  @(posedge clk) disable iff (!rst_n)
  wr_en |-> !$isunknown(data);
endproperty

assert property (p_write_data_valid);

这里|->是“蕴含”操作符,意思是“如果wr_en为真,那么data不能有X”。

我的经验:断言最好写在接口里,或者写在单独的断言模块里。别散落在各个测试用例中。这样复用性高,改一处全改。

断言还有一些常用操作符:

操作符 含义 示例
##n 延迟n个时钟周期 req ##2 gnt(请求后2周期,授权必须为真)
|-> 蕴含(立即检查) a |-> b(a为真时,b必须为真)
|=> 蕴含(下一周期检查) a |=> b(a为真后,下一周期b必须为真)
and 两个序列同时满足 seq1 and seq2
or 两个序列任一满足 seq1 or seq2

举个例子,检查一个握手机制:

property p_handshake;
  @(posedge clk) disable iff (!rst_n)
  req |=> ##[1:3] gnt;  // 请求后,1到3周期内必须收到授权
endproperty

这个断言的意思是:如果req为真,那么下一周期开始,在1到3个周期内,gnt必须为真。超过3个周期就算失败。

注意:断言写多了,仿真会变慢。我建议只检查关键协议和边界条件。别什么都断言,否则仿真跑一天都跑不完。

好了,这一章的内容就这些。数据类型、接口、时钟块、断言,这四个东西是SystemVerilog验证的基石。你把这四个搞明白了,后面学UVM、学覆盖率驱动,都会轻松很多。

下一章咱们讲面向对象编程,那才是SystemVerilog真正发力的地方。到时候你会看到,用类来建模验证环境,比用模块爽太多了。