第四节:日志与打印调试——$display/$monitor/$strobe 的区别、格式化输出、日志级别控制、日志文件管理

各位同学,咱们今天聊一个看似基础、但实际坑特别多的话题——日志与打印调试。

说实话,我刚开始做验证那几年,对打印这件事儿完全没当回事。觉得不就是往终端里扔几个字符串嘛,谁不会啊?直到有一次,我为了追一个时序相关的bug,被 $display 和 $strobe 的顺序问题坑了整整两天……嗯,从那以后,我再也不敢小看这几个系统函数了。

今天咱们就把 $display、$monitor、$strobe 这三个兄弟的区别、格式化输出的技巧、日志级别的控制,以及日志文件的管理,一次性讲透。

一、$display vs $monitor vs $strobe——三兄弟的恩怨情仇

这三个函数,说白了都是用来打印信息的。但它们的执行时机完全不同。你想想看,如果搞混了,调试的时候看到的信号值可能根本不是你想的那个时刻的值。

1. $display——最老实的大哥

$display 的执行时机很简单:遇到它就执行。它在当前仿真时间步内,按照代码顺序立即打印。我个人的习惯是,在组合逻辑的中间节点或者状态机的跳转条件里,用 $display 来观察当前值。

always @(posedge clk) begin
    if (state == IDLE && data_valid) begin
        $display("Time %0t: State transition from IDLE to WAIT", $time);
        state <= WAIT;
    end
end

这里要注意:$display 打印的是执行到这一行时的信号值。如果后面还有赋值语句改变了这个信号,$display 是看不到的。

2. $strobe——最细心的二哥

$strobe 和 $display 长得像,但执行时机完全不同。$strobe 会在当前时间步的末尾,所有赋值都完成之后,才打印一次。

我曾经在调试一个总线协议的时候,用 $display 看 ready 信号,结果总是看到 0,但波形里明明是 1。后来换成 $strobe,一下就对了。为什么呢?因为 $display 执行的时候,ready 的赋值还没生效呢。

always @(posedge clk) begin
    // 错误示范:用 $display 看非阻塞赋值的结果
    $display("Wrong: ready = %b", ready);  // 可能看到旧值
    // 正确示范:用 $strobe
    $strobe("Correct: ready = %b", ready);  // 看到新值
    ready <= 1'b1;
end
避坑指南:我曾经在验证一个多周期握手协议时,用 $display 去观察握手信号的跳变,结果死活对不上。后来发现,$display 在非阻塞赋值之前执行,看到的全是旧值。换成 $strobe 之后,问题迎刃而解。

3. $monitor——最勤快的小弟

$monitor 是自动监控。只要它监控的信号发生变化,就会自动打印一次。你只需要在 initial 块里调用一次,它就会一直工作。

initial begin
    $monitor("Time %0t: a=%d, b=%d, c=%d", $time, a, b, c);
end

我个人习惯在顶层测试环境里放一个 $monitor,监控关键的控制信号和状态机状态。这样跑仿真的时候,一眼就能看到信号的变化轨迹。

一句话总结三兄弟的区别:
$display:立即打印,适合组合逻辑或阻塞赋值场景。
$strobe:时间步末尾打印,适合观察非阻塞赋值的结果。
$monitor:信号变化时自动打印,适合长期监控。

二、格式化输出——让日志更清晰

光会打印还不够,你得让打印出来的东西一眼就能看懂。我见过太多同学打印出来的日志,一堆数字堆在一起,根本分不清哪个是哪个。

SystemVerilog 提供了丰富的格式化控制符:

格式符 含义 示例
%d 十进制整数 $display("count = %d", cnt);
%h 十六进制 $display("addr = %h", addr);
%b 二进制 $display("data = %b", data);
%s 字符串 $display("state = %s", state.name());
%t 时间格式 $display("time = %t", $time);
%m 层次路径 $display("module = %m");
我的小技巧:打印时间的时候,一定要用 %t 配合 $time,而不是自己手动算。我曾经见过有人用 $display("time = %d", $time/1000) 来换算成微秒,结果时间单位一变,全乱套了。

三、日志级别控制——别让终端变成垃圾场

仿真跑起来,如果所有打印都打开,终端里刷屏的速度比弹幕还快。你根本找不到有用的信息。所以,日志级别控制是必须的。

我个人习惯把日志分成四个级别:

  • ERROR:致命错误,仿真应该停止或报错。比如协议违例、数据比对失败。
  • WARNING:警告,不影响当前仿真,但可能有问题。比如时序违例、未预期的状态。
  • INFO:常规信息,比如测试用例开始、结束、关键配置。
  • DEBUG:调试信息,比如信号值、状态机跳转、中间变量。平时关掉,调试时打开。

实现方式很简单,用宏定义或者 `ifdef 来控制:

`define DEBUG_LEVEL 2  // 0:ERROR, 1:WARNING, 2:INFO, 3:DEBUG

`define LOG_ERROR(msg) $display("[ERROR] %m: %s", msg)
`define LOG_WARN(msg)  if(`DEBUG_LEVEL >= 1) $display("[WARN]  %m: %s", msg)
`define LOG_INFO(msg)  if(`DEBUG_LEVEL >= 2) $display("[INFO]  %m: %s", msg)
`define LOG_DEBUG(msg) if(`DEBUG_LEVEL >= 3) $display("[DEBUG] %m: %s", msg)

这样,你只需要改一个宏定义的值,就能控制整个仿真环境的日志输出量。我在做回归测试的时候,通常只开 ERROR 和 WARNING,INFO 都关掉。只有调试具体用例的时候,才把 DEBUG 打开。

注意:宏定义的方式虽然简单,但有一个缺点——所有条件判断都在编译时决定。如果你需要在仿真运行时动态调整日志级别,建议用 UVM 的 `uvm_report_handler` 或者自己写一个日志控制类。

四、日志文件管理——别让仿真结果丢了

终端打印的东西,关了窗口就没了。所以,一定要把日志写到文件里。

SystemVerilog 提供了 $fopen、$fdisplay、$fclose 等文件操作函数:

integer log_fd;
initial begin
    log_fd = $fopen("sim_log.txt", "w");
    if (log_fd == 0) begin
        $display("Error: Cannot open log file");
        $finish;
    end
end

// 打印到文件
always @(posedge clk) begin
    $fdisplay(log_fd, "Time %0t: state = %s", $time, state.name());
end

// 仿真结束时关闭文件
final begin
    $fclose(log_fd);
end

我个人习惯把日志文件名和测试用例名关联起来,比如 test_uart_rx.log、test_uart_tx.log。这样跑回归的时候,每个用例都有自己的日志文件,出了问题直接看对应的日志就行。

进阶技巧:我还会在日志文件里加上时间戳和版本号。比如 sim_20250101_v2.1.log。这样即使同一个用例跑多次,也不会覆盖之前的日志。对于调试历史回溯特别有用。

五、知识体系总览

为了让大家更直观地理解本章的知识结构,我画了一张图:

日志与打印调试知识体系 日志与打印调试 打印函数三兄弟 格式化输出 日志级别控制 日志文件管理 $display:立即打印 $strobe:末尾打印 $monitor:自动监控 %d %h %b %s %t %m 宽度对齐与填充 ERROR:致命错误 WARNING:警告 INFO:常规信息 DEBUG:调试信息 $fopen 打开文件 $fdisplay 写入 $fclose 关闭 命名与时间戳

这张图把咱们今天讲的内容串起来了。四个分支,每个分支下面都有具体的知识点。你调试的时候,遇到打印相关的问题,先看看是哪个分支的问题,再对症下药。

好了,日志与打印调试这块儿,咱们就聊到这儿。记住一句话:打印不是万能的,但不打印是万万不能的。用好这三个函数,控制好日志级别,管理好日志文件,你的调试效率至少能提升一倍。

可可精品资料库