第四节:日志与打印调试——$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
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"); |
三、日志级别控制——别让终端变成垃圾场
仿真跑起来,如果所有打印都打开,终端里刷屏的速度比弹幕还快。你根本找不到有用的信息。所以,日志级别控制是必须的。
我个人习惯把日志分成四个级别:
- 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 打开。
四、日志文件管理——别让仿真结果丢了
终端打印的东西,关了窗口就没了。所以,一定要把日志写到文件里。
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。这样即使同一个用例跑多次,也不会覆盖之前的日志。对于调试历史回溯特别有用。
五、知识体系总览
为了让大家更直观地理解本章的知识结构,我画了一张图:
这张图把咱们今天讲的内容串起来了。四个分支,每个分支下面都有具体的知识点。你调试的时候,遇到打印相关的问题,先看看是哪个分支的问题,再对症下药。
好了,日志与打印调试这块儿,咱们就聊到这儿。记住一句话:打印不是万能的,但不打印是万万不能的。用好这三个函数,控制好日志级别,管理好日志文件,你的调试效率至少能提升一倍。