如果你在芯片验证这行干过三五年,大概都会有同样的感受:真正烧时间的从来不是"发现问题",而是"定位问题"。仿真日志几万行,指向线索的可能只有三五行;波形文件动辄几个GB,你拖拽缩放半天无非是想确认某个握手信号在哪一拍被拉低;信号连接关系更不用说,顶层例化名改一次、底层端口重排一次,全靠人脑搜索加问同事。我花了一些业余时间做了个 RTL 仿真调试工具,思路很直接:把日志、波形、信号连接这三种调试现场的数据全部整理好,让 AI 来自动查询和分析,最终给出 root cause 的推断链路。这篇文章就把整个工具的设计思路、实现细节、实测效果和踩坑记录完整整理出来,希望对正在被仿真调试逼疯的验证工程师、做 EDA 工具链的开发者有点参考价值。
1. 调试RTL的日常:日志、波形、信号连接为什么这么难查
先说清楚我做这个工具的动机,不然你很难理解后面那些设计取舍是从哪来的。
1.1 一个仿真挂掉的典型场景
假设你正在跑一个 AXI 总线的验证环境,monitor 突然报了个严重错误:读数据通道超时。仿真停了,你面前有几样东西:
- 仿真日志,大概五万行,报错前后有大量无关信息,有些严重级别命中的模块根本不是问题源头。
- 一个 FSDB 波形文件,3.4GB,你需要在 waveform viewer 里把若干信号拖进来,缩放到报错时间点附近,一根一根地看。
- RTL 源码仓库,你需要确认这个报错信号到底是谁驱动的、经过了哪些层次、由哪个 reg 产生。
普通工作流是什么样呢?你先 grep 日志里的关键报错,找到 time;然后去波形里打标记,找出相关信号;接着打开 RTL 代码,沿着信号名往上追,翻两三个 module,找到驱动源。如果运气好,半小时内能定位;如果不好,你还要怀疑是不是环境配置问题还是时序边界问题,一整天就没了。
1.2 为什么直接问通用AI不行
我自己试过直接把日志复制给通用大模型,效果很差,原因有三个:
第一,日志太长。一次仿真日志动辄几万行,超过上下文窗口,直接被截断,关键的报错可能根本没进去。
第二,波形不是文本。通用模型根本看不到波形数据,你只能说"帮我看看 xxx 信号为什么为低",它只能猜。
第三,连接关系在模型懒得读的代码库里。虽然可以把 RTL 贴给模型,但几十个 module、几百兆的代码,没人会真的全贴,贴了上下文也爆炸。
所以问题的关键不是"AI 傻",而是"调试数据根本没有被结构化地暴露给 AI"。只要把日志变成结构化查询接口,把波形变成可查询的事件序列,把信号连接变成一张可追溯的图,AI 的能力是完全可以使上劲的。
1.3 我要解决的问题边界
在动手之前,我给自己划了个边界,这很重要:
- 这个工具不负责"自动修 bug",只负责"自动缩小 root cause 范围"。
- 目标是让 AI 能回答三类问题:日志里发生了什么、波形上某个时间段信号表现如何、某个信号从哪来、连到哪去。
- 最终的输出是"分析结论 + 证据链",不是玄学,不是纯猜测,每一条判断都要能对应到具体的时间点或具体的层次路径。
这个边界划清楚之后,后面每一步的实现都变得很直接。
2. 工具整体架构与选型思路
整个工具我起名叫 RTLScope,一个 Python 写的本地工具,架构核心就是"先结构化、再让AI查"。
2.1 工具链全景速览
工具由四层组成,每一层解决的问题都不同:
| 层级 | 组件 | 核心职责 |
|---|---|---|
| 数据接入层 | 日志解析器、波形解析器、RTL解析器 | 把三种原始数据转成结构化数据 |
| 存储层 | SQLite + 内存索引 | 保存结构化日志、波形事件、连接图节点 |
| 接口层 | 函数工具集 | 把查询逻辑封装成 AI 可调用的工具函数 |
| 推理层 | LLM 编排器 | 让 AI 按计划调用工具,组合证据,输出 root cause |
工具链的核心逻辑是:日志转成结构化记录,波形转成事件流,RTL转成连接图,然后三个数据源共享同一个时间轴和同一个信号命名空间,AI 通过函数调用拿到它们。
2.2 为什么选这些组件
Python 是我最熟悉的选择,数据处理库生态齐全,写起来也快。SQLite 用来存日志和信号事件,轻量、单机、查询快,不需要像数据库那样搭服务。RTL 解析用的是 slang 导出的 AST JSON,这个后面会细说。AI 部分,我原本想直接吊一个大模型做自由对话,但实测下来自由对话的幻觉率太高,改成 function calling 之后效果稳定了很多,AI 必须先调用工具拿到数据再说话。这是全项目最值得的一个决策。
我的工程目录大概长这样:
rtlscope/ ├── parsers/ │ ├── log_parser.py │ ├── waveform_parser.py │ └── rtl_parser.py ├── query/ │ ├── log_query.py │ ├── waveform_query.py │ └── connection_query.py ├── ai/ │ ├── tools.py │ ├── agent.py │ └── prompts.py ├── db/ │ └── schema.sql └── main.py2.3 数据流的一个核心设计:时间轴对齐
日志里有仿真时间,波形的每个事件也有仿真时间,这两个时间戳天然可以对齐。因为日志时间戳和 RTL 仿真时间在同一个坐标系里,我统一用字符串格式t=125000ns存储,按整数纳秒做比较。
信号命名空间的统一也很关键。RTL 里的层次路径,比如tb_top.u_axi_master.axi_data,波形里的信号名如果是 FSDB 通常是同一个完整路径。我在解析的时候把两边都转成标准路径格式hier.path.to.signal,这样连接图里查出来的驱动信号可以直接去波形里查它的跳变历史。
3. 日志查询是怎么做的:从刷屏文本到可检索数据
日志是调试中最常见但也最容易被忽视的数据源。很多人的工作流就是"grep 报错关键字 + 看上下文",但真到了复杂场景,单单 grep 是远远不够的。
3.1 普通日志为什么AI读不动
直接给 LLM 读日志的问题,前面提过,会因为上下文窗口被截断。还有个更隐蔽的问题:大多数仿真日志的格式是非标准的,每一行的时间戳格式都不统一,有的带@,有的带t=,有的模块名是一长串路径。就算 AI 拿到一小段日志,也很难做跨行的关联分析,因为它需要在几分钟内理解你们公司的日志风格。加一个解析层把格式统一掉,是唯一靠谱的路。
3.2 日志结构化解析规则
我针对 UVM 和常见的自家仿真日志写了个解析器。每一条日志会按下面的规则解析成结构化字段:
- 字段: timestamp, 类型: int, 说明: 仿真时间戳, 解析规则: 提取 t=123456ns 中的整数部分 - 字段: severity, 类型: enum, 说明: INFO/WARNING/ERROR/FATAL, 解析规则: 匹配 UVM_{SEVERITY} - 字段: reporter, 类型: str, 说明: 报出的模块路径, 解析规则: 提取 [uvm_test_top.u_axi_env...] 中的路径 - 字段: message, 类型: str, 说明: 原始消息文本, 解析规则: 取最后一个冒号之后的部分 - 字段: file_line, 类型: str, 说明: 来源文件和行号, 解析规则: 匹配 .sv:123 格式实际解析过程中的坑是:不同验证环境的日志格式差异比想象中大,有的时间戳是@ 125000,有的直接没有时间戳。我后来把解析器设计成"规则 + 兜底"模式,解析失败的行会保留到原始字段,但不会丢掉。这样至少不会因为一行解析失败就中断整条日志链路。
3.3 AI查询日志的交互设计
AI 查询日志的函数定义大概是这样的:
def query_logs( time_range: tuple[int, int] | None = None, severity: list[str] | None = None, reporter: str | None = None, keyword: str | None = None, limit: int = 100, ) -> list[dict]: """根据时间范围、级别、模块路径和关键字查询日志"""这个函数会被注册成一个 tool,LLM 在回答"报错前发生了什么"的时候,会自己拼 filters,连续调用多次。比如 AI 发现 ERROR 在 t=125000ns,它会先查这个时间点前后 2000ns 里的 FATAL 和 ERROR,再看这些报错对应的 reporter 模块,再用 reporter 路径去追更早的相关日志。这个过程不需要人肉去 grep,AI 自己就可以完成。
我的实测体会是:给 AI 一个"分步查日志"的 prompt 模板很重要。如果你只说"帮我找 bug",AI 会一次性试图拿一大堆数据,反而容易混乱。但如果你在 system prompt 里写清楚:"每次查询限制在 200 条以内,优先查 ERROR/FATAL,再往前追上下文",它的表现会稳定很多。
3.4 日志查询的真实效果
我给 AI 喂过一次真实的空指针类报错场景。日志里报错在t=100000ns,是一个null handle访问。AI 的处理链路是:
- 查该时间点前后 1000ns 的 FATAL 日志,拿到具体报错
uvm_reg_field::write: null handle。 - 往上追 5000ns 的 ERROR 和 WARNING,发现同一个模块有一个
reset_deassert后的前 100ns 内反复访问寄存器。 - 判断:复位释放后环境还没稳定就开始操作寄存器,很可能是环境里没有等
reset_done标志。
这整个过程大概用了 8 次 tool call,耗时不到 20 秒。换成人工操作,至少要 grep 三次加读代码才能得出结论。这个案例让我觉得日志结构化这步走得很值。
4. 波形查询的实现:让AI看懂时序变化而不是死盯波形
波形是所有调试数据里最"重"的:文件大、信号多、时间跨度长。直接展示给 AI 是绝对不行的,上下文爆掉只是第一步,模型根本没法从几十万个跳变沿里读出一根信号在这 20 微秒里经历了什么。
4.1 波形解析的三种格式选择
波形格式我跟大家一样,最早碰到的是 VCD。VCD 是文本格式,最简单的解析方式就是逐行读,按$var声明信号名,再按#时间戳分组记录每个信号的跳变。
但 VCD 有个毛病,文件太大,一个稍复杂的 SOC 仿真,VCD 轻易上几百 GB。所以实际项目中更多用 FSDB 或 FST。FSDB 是商业工具格式,解析要用 Synopsys 的库;FST 是开源的压缩格式,可以用pyfst或wavedrom的底层库读取。我最后选择了 FST,性能足够,而且压缩率高,几个 GB 的 FSDB 转成 FST 后能小很多。
4.2 波形事件化与降采样
波形的核心数据结构是"信号名 + 时间点 + 值"。VCD 或 FST 解析出来天然是这个格式,但直接给 AI 还是太多。我做了两步处理:
第一步是事件化:只保留信号发生跳变的时刻点,中间不变的冗余段全部去掉。比如一根信号在 1000ns 是 0,2000ns 是 1,那我就存两个事件(t=1000, val=0)和(t=2000, val=1),中间 1000-1999ns 的全部省略。这个优化对大部分信号都有效,一个 10 万行的 VCD 文件,事件化后可能只剩几百行。
第二步是信号画像:对每个信号计算一些统计特征,包括跳变次数、第一次跳变时间、最后一次跳变时间、占空比估计。这些画像信息可以帮助 AI 快速判断一根信号是不是"一直为低"、"周期性翻转"、"只在某个窗口内跳变"。
4.3 AI查询波形的工具设计
我定义了三个波形相关的工具函数:
def query_signal_value(signal_path: str, time_ns: int | None = None, time_range: tuple[int, int] | None = None) -> list[dict]: """查询信号在特定时间点的值或某段范围的跳变历史""" def get_signal_stats(signal_path: str, time_range: tuple[int, int] | None = None) -> dict: """获取跳变次数、电平占比等统计""" def find_signal_transitions(signal_path: str, time_range: tuple[int, int], value: int) -> list[int]: """查找某段时间内信号变为指定值的所有时间点"""这三个函数配合起来,AI 可以回答这样的问题:"在 t=120000ns 到 t=130000ns 之间,valid信号有几次拉高,每次持续时间多长?"
这个设计的核心是:AI 不需要看到所有波形点,它只需要按需查询某个信号在某个时间窗的行为。就像人看波形图也是先 zoom in 到报错点附近,再逐根信号看状态,AI 也是同样的思路,只是更快、更自动。
4.4 一个握手超时的AI判断案例
有一次实测,验证环境报write_data_channel_timeout,我让 AI 分析。它先看日志拿到超时时间点 t=250000ns,然后查了这个时间附近axi_awvalid、axi_wvalid、axi_wready这三个信号的行为。统计结果显示axi_wready在 200000ns 到 250000ns 之间一直保持高电平,而axi_awvalid拉高后 50 微秒都没有等到axi_awready。AI 判断:从波形表现看,写地址通道迟迟没有响应,数据通道其实已经准备好了,问题应该出在地址通道的上游逻辑。
这个结论我是很认可的。人查的话,要拖三根信号到 viewer 里,慢慢缩放看,至少几分钟;AI 用了不到十秒。
4.5 波形查询的边界
当然也要说清楚边界。波形查询本质上是"你有几千个信号,我可以帮你查任何一个信号的任何时间段"。但如果信号量巨大(比如几万个信号),每次都全量扫描是很慢的。我的优化是给信号名建了索引,还支持前缀匹配。另外,AI 并不能"看到"波形本身的美感,它只能看到抽象出来的事件和统计,所以像"这个毛刺有多尖"这类问题,它回答不好。我更建议把波形工具定位成"基于数学统计的判断",而不是"基于视觉的观察"。
5. 信号连接查询:从RTL结构里提取一张可追溯的网表
如果说日志和波形是"软件层"的调试手段,信号连接就是"硬件层"的debug 利剑。RTL 调试里经常遇到的问题是:你看到一个信号异常,但它到底是谁驱动的?它在例化层次里叫什么名?中间经过了几层 module?这恰恰是传统波形查看器弱项,需要在代码树上人工溯源。
5.1 为什么需要信号连接图
举个例子,你在波形里看到tb_top.u_dut.data_ready一直为低,想查源头。如果你只搜data_ready,在同一层可能有好几个同名信号,都不是你要的。你需要沿着例化关系一层层往上走,找到这个信号的实际驱动模块,再找它的上游。这个过程在大的 RTL 设计里非常耗时,尤其是存在 generate 块和宏定义的情况下。
但我如果先把 RTL 解析成一棵例化树再加一张端口连接图,这个问题就变成一个图查询。AI 可以用三段式的思路去查:
- 根据信号完整路径
tb_top.u_dut.data_ready找到它所在的模块实例。 - 查询该实例的端口映射,找到
data_ready实际上接的是上一层模块的哪个信号。 - 逐层往上追,直到找到真正的驱动源。
5.2 RTL解析的选型和实现
解析 SystemVerilog 没那么简单,主要是语法太复杂,interface、generate、struct、parameter这些东西混在一起,自己写正则分分钟崩。我试过 pyverilog 和 slang,最后选择了 slang 的 AST JSON 输出。slang 是一个很成熟的 SystemVerilog 编译器实现,跑完slang --ast-json output.json 顶层文件.sv之后,会得到一棵完整的语法树。我只需要在这棵语法树上做几个提取逻辑:
# 伪代码示意 for node in ast_nodes: if node.kind == "ModuleInstance": instances.append({ "instance_name": node.name, "module_name": node.module_name, "path": build_hier_path(node), "port_maps": extract_port_maps(node) }) elif node.kind == "ModuleDeclaration": modules[node.name] = { "ports": node.ports, "internal_signals": extract_internal_signals(node) }这个方案对现代 SystemVerilog 的兼容性极好,generate 块也会被展开成具体实例,省了我不少功夫。
5.3 端口连接与信号溯源
端口映射的提取是信号连接查询的核心。我把它做成了一组工具函数:
def trace_signal_driver(signal_path: str) -> list[dict]: """向上追查信号的实际驱动源,返回驱动链路上的每一级""" def get_downstream_signals(signal_path: str, max_depth: int = 3) -> list[dict]: """向下查找该信号驱动了哪些模块的哪些端口""" def get_module_instances(module_name: str) -> list[dict]: """查找某个模块在当前设计里被例化在哪几个位置"""这里有一个细节很重要:连接关系必须区分"直接连线和同名信号"。SystemVerilog 例化时支持端口简写u(.data_ready(data_ready)),也支持.data_ready(data_rdy)这种重命名。我的解析器会把这两种都映射到实际的上下层信号名上,而不是只按同名匹配。否则 AI 很容易顺着错误的同名信号查半天一无所获。
5.4 连接查询的实测效果
有个实际案例:一个 reset 信号在波形上表现异常,直接连到了顶层,但通过工具查询发现它在u_pll例化里被接到了pll_lock上,而pll_lock在u_pll内部是由一个锁相环状态机生成的。AI 通过连接图查了三条链路:
tb_top.rst_n -> u_pll.pll_lock -> pll_lock_out- 发现
pll_lock模块内部有一个lock_cnt寄存器,只有当其计数达到阈值才拉高 lock。 - 结合波形查
lock_cnt,发现它一直没到满值,所以 reset 一直拉低。
这个案例不仅找到了信号源头,还找到了"为什么源头没满足条件"的直接证据。要是人工查,至少得翻三到五个文件,还未必能抓住这个计数器的阈值条件。
5.5 连接图的保存和性能
我用了 NetworkX 来保存连接图,节点是信号和模块实例,边是"驱动关系"和"端口连接关系"。对整个中型设计来说,图节点能到十万级,NetworkX 的内存开销有点大。后来我精简了存储,只保留驱动关系和端口映射关系,用两个 SQLite 表来存,查询速度反而更快。图保存成文本后,AI 每次查询只需要查一小段路径,不需要载入全图。
6. Root Cause 实测:AI分析三个真实调试场景
前面讲的都是底层能力,这一部分才是重点:把三者组合起来,AI 能不能给出靠谱的 root cause?我分别测了三个有代表性的场景。
6.1 场景一:AXI 总线死锁分析
问题描述:AWREADY一直为低,导致主设备无法发起写地址传输,仿真实测卡死。AI 的处理链路:
第一步,日志查询。AI 发现报错都集中在uvm_axi_master,在 t=300000ns 附近有连续三次 timeout,且每次 timeout 前都有一条waddr queue full警告。
第二步,信号连接查询。AI 沿着AWREADY信号向上追,发现它来自axi_slave_wrapper,里面有一个awready_gen模块,驱动条件是awchannel_busy == 1'b0。
第三步,波形查询。AI 查到awchannel_busy从 t=120000ns 之后一直为高,通过get_signal_stats确认在 300000ns 前 180 微秒内没有下降沿。
结论:slave 的awchannel_busy始终为高,导致AWREADY无法拉高,主设备侧 queue 满后重传积压,最终 timeout。AI 给的修复方向是查awchannel_busy被谁置位后没有清零。整个过程 30 秒内完成,给出的证据链完全自洽。
6.2 场景二:状态机跳飞联动日志与波形
有个设计在某个仿真用例里行为异常,行为像是进入了非法状态。AI 分析时,先通过连接图找到状态机的三个状态寄存器state[1:0],然后查波形统计,发现它在 t=50000ns 时刻从 2'b01 直接跳到了 2'b11。而 RTL 解析的结果里,状态机定义里IDLE=2'b00,RUN=2'b01,DONE=2'b10,2'b11是保留状态。接着 AI 查日志,发现跳变前 100ns 有一条unexpected interrupt的警告,中断信号的源头在连接图里指向了 GPIO 模块。
结论:保留状态 2'b11 被意外进入,可疑的根本原因是中断信号作用于状态转移逻辑的优先级问题。这个案例里,AI 最精彩的部分是把波形跳变、日志警告、连接图指向的模块三者串成了一条完整的证据链,而不是简单地报一句"状态机跑飞"。
6.3 场景三:X态传播分析
X 态是仿真里最让人头疼的问题之一。AI 用连接图直接找到所有接收fifo_wr_data这个信号的模块列表,然后逐个查每个接收模块的波形统计。最后发现某个模块的fifo_wr_data[7:4]从 t=0 开始就是 X,而该模块的上游驱动是ram_model,数据来自一个未初始化的 SRAM 区域。日志查询确认在这个地址写入前有write_enable信号异常为 X 的记录。
总线上的 X 态往往藏得很深,因为 X 会在组合逻辑里传播。人工追查可能要花很久,连接图 + 波形统计的组合让 AI 能快速把嫌疑范围缩小到具体模块。
6.4 效果评估与局限
我自己对这些案例的总体评价是:如果只问"找出 bug",AI 大概有六成到七成的准确率;但如果把问题引导成"给出三条最可疑的链路,附带证据",AI 的表现会稳定很多,基本不会胡说八道。
局限也有几点:一是对复杂跨时钟域问题的分析还比较弱,因为这类问题需要同时关联多个时钟域的波形,描述起来很容易乱;二是对"不常见的 bug 类型",AI 往往会往最常见的错误模式上靠,会产生一定程度的过度拟合;三是功耗这类需要专门分析方法的问题,现有工具链支持不了。这个工具更适合做"缩小范围"和"排除法",而不是做"最终判案"。
7. 踩过的坑与几个关键经验
最后讲讲开发过程中遇到的真实问题,这部分内容是文档里找不到的。
7.1 文件大、token超限:一切都要先降维
我第一次直接把 20 万行日志塞给 AI,结果当然是上下文超限,模型直接拒绝回答。后来我学乖了,所有数据都必须经过过滤和降采样。日志查询默认给限制条数,波形查询必须指定时间窗,连接查询限制最大深度。宁可让 AI 多调用几次工具,也不要一次喂太多数据。实际经验是:每次函数调用返回的数据量控制在 5KB 以内,整体效果最好。
7.2 AI幻觉的根源是"它不确定自己在说什么"
AI 在连接查询上最容易幻觉。它可能明明没查到sig_a的驱动源,但为了凑答案,会编一个看似合理的模块路径。这个坑是在设计 prompt 时没有强调"查不到就说查不到"。后来我在工具说明里加了一条:若查询结果为空,必须明确告诉用户未找到该信号,不得尝试猜测或类比。加了这一句之后,幻觉率明显下降。
7.3 RTL解析比想象中难:宏定义与generate块
没有商业 EDA 工具加持的情况下,自己解析 SystemVerilog 的宏定义和 generate 块是最大的坑。slang 的 AST JSON 能展开 generate 块,但对于ifdef宏,你需要自己定义宏开关。我给解析器加了个配置文件,手动指定-Dxxx宏定义,否则一个模块在不同的宏条件下例化结果完全不同,解析出来的连接图就会错。
7.4 时间戳的对齐陷阱
日志和波形的时间戳虽然都是仿真时间,但单位可能不一样。有的环境里日志是 ps,波形是 ns,还有的是相对时间而非绝对时间。我统一在解析阶段就换算成 ns,并且给每个时间戳字段加了一个 source 标记。不这样做,AI 很容易在跨数据源分析时把时间窗口算差一个数量级,这会让整个结论直接跑偏。
7.5 建议的扩展方向
如果你也想做类似的工具,我的建议是:千万不要一开始就奔着"全自动 debug"去做,那是无底洞。先做"半自动查询",把日志、波形、连接关系三类工具都打通,让 AI 能独立回答事实型问题,再逐步加分析能力。我已经在计划加入覆盖率数据的查询接口,让 AI 在分析问题时还能参考"哪些路径没走到",这样 root cause 的推断会更有依据。
我自己用这个工具最大的体会是:它没有让我变成一个更聪明的验证工程师,但它让我把大量重复的、机械的信息查找工作甩给了机器,我只需要把注意力放在真正的分析判断上。如果你也经常被仿真调到半夜,建议试试把这个思路落地到自己的流程里,哪怕只是先把日志结构化这一步做掉,调试效率都能提升一大截。