H2和H3标题必须添加数字编号?H2和H3必须编号,开头不要用#,直接## 1.开头。
1. 为什么我写了这个RTL代码提取工具
FPGA开发做到一定规模,你会遇到一个很现实的问题:Vivado工程里塞了一大堆文件,真正要交给别人复用、做仿真、或做代码走查的RTL文件反而被淹没在中间产物里。我接过好几个同事留下的工程,.xpr工程文件打开一看,IP核占了大半,Block Design的wrapper一层套一层,要找某个模块的.v源文件得在目录树里翻半天。更头疼的是,工程里那些synth_1、impl_1、.cache、.gen文件夹动不动几百MB,你总不能把这些全部打包发给别人做代码评审。
后来我养成了一个习惯:每个阶段交付时,用脚本把工程里真正的RTL代码提取出来,整理成一份干净的目录。最早用Tcl写,因为Vivado本身支持Tcl脚本,能直接从工程数据库里查询源文件列表,确实方便。但Tcl这门语言对写惯了Python的人实在不够友好——字符串处理绕、数据结构弱,写稍微复杂一点的逻辑就非常痛苦。更重要的是,我需要在没有Vivado环境的机器上也能处理别人发来的工程目录,这时候Tcl就完全没辙了。
所以我动手写了一个基于Python的RTL代码提取工具。它的核心功能很简单:给定一个Vivado工程路径(或者任意一个包含RTL源码的目录),自动识别所有Verilog/VHDL源文件、梳理模块之间的依赖关系、按依赖顺序整理输出,顺带把仿真专用的testbench、以及工程自动生成的文件过滤掉。实际用下来,配合Tcl脚本做工程级提取,能覆盖我90%以上的需求。
这篇博客把整个工具的构造过程拆开讲一遍。内容包括:工具的设计思路、依赖解析的算法细节、关键代码实现、以及我在实际使用中踩过的坑。如果你也在维护FPGA项目,相信这个工具能把“整理RTL工程”这件事从半小时的体力活变成几秒钟的自动化操作。
2. 需求拆解和功能设计
2.1 这个工具要解决什么“真问题”
先说清楚我到底想干什么。Vivado工程目录长什么样,做过FPGA的都知道,典型的工程结构是:
my_project/ ├── my_project.xpr ├── my_project.srcs/ │ ├── sources_1/ │ │ ├── bd/ # Block Design 文件 │ │ ├── ip/ # IP 核定义 │ │ └── new/ # 用户RTL文件 │ ├── constrs_1/ # 约束文件 │ └── sim_1/ # 仿真文件 ├── my_project.runs/ │ ├── synth_1/ # 综合结果 │ └── impl_1/ # 实现结果 ├── my_project.gen/ ├── my_project.cache/ └── my_project.sim/这里面,真正需要交付给下一环节的,通常只有my_project.srcs/sources_1/new/下的RTL文件,加上一部分IP核的源码(要看同事的代码规范)。但现实中我见过最混乱的工程,RTL文件放在五六个不同目录里,还有的工程师习惯把文件直接丢在工程根目录下。
依赖关系是另一个问题。A模块实例化了B模块,如果你只把A文件发给别人仿真,编译必然报错。手动一个个查找实例化关系非常反人类,尤其在顶层模块实例化二三十个子模块的时候。所以我在设计工具的时候,把“依赖解析”作为最核心的功能来做。
常做代码交付和评审的工程师都知道,代码审查时只想看RTL本身,注释掉testbench、ila调试核的例化代码、以及那些generate块里各种条件编译的碎片代码,会让阅读体验好很多。这个工具也做了对应的处理。
2.2 Python方案对比Tcl方案的优势
我在设计工具的初期,其实对比了三条技术路线。
第一条是纯Tcl方案。在Vivado的Tcl Console里执行get_files -filter {FILE_TYPE == "Verilog"},确实能拿到工程里所有Verilog文件列表。优点是不需要自己解析文件内容,Vivado已经把依赖关系在工程内部维护好了。但缺点同样明显——需要打开Vivado、需要加载工程,处理时间以“分钟”为单位;而且Tcl脚本没法独立分发,别人没有Vivado环境就跑不起来。我试过把Tcl脚本放在服务器上批量跑几十个工程,那种体验真的糟糕。
第二条是Vivado的write_project_tcl导出机制。它能生成一个.tcl重建脚本,里面包含了工程所有的源文件路径。但导出的路径是绝对路径,换一台机器全部失效,而且生成的文件极为冗长,解析起来反而更费劲。
第三条就是我最终采用的纯Python方案。只要工程目录还在,不需要安装Vivado,两三秒钟就能跑完整个提取流程。Python处理正则表达式和文本解析本来就是强项,写起来灵活得多。如果你机器上连Vivado都没有,也能用这个工具处理别人发来的源码目录。对于我这种经常在不同机器之间切换、还要处理同事发来的“半成品工程”的人来说,Python方案是最顺手的选择。
另外说一句,如果你确实需要在Vivado内联动操作(比如提取完代码后马上重新综合),可以在Tcl里调用Python脚本,两者并不冲突。我在实际项目中,Tcl负责“读工程数据库”,Python负责“整理输出”,各干各擅长的事。
3. 核心代码实现与算法解析
3.1 文件搜集:从“目录扫描”到“工程文件过滤”
这个工具的第一步,是把候选的RTL文件收集起来。我不打算只处理Vivado的srcs文件夹,因为很多场景下拿到的就是一个普通目录,里面混着一堆乱七八糟的文件。所以第一步的逻辑就是“广撒网”:递归扫描指定目录下所有文件,筛选出Verilog、SystemVerilog、VHDL这三种常见RTL类型。
# 支持的代码文件扩展名 RTL_EXTENSIONS = { '.v': 'verilog', '.sv': 'systemverilog', '.vh': 'verilog_header', '.vhd': 'vhdl', '.vhdl': 'vhdl', }这里要特别提一下.vh头文件。很多工程师会把参数定义、宏定义放在头文件里,如果漏掉它,后续编译绝对报错。所以我扫描的时候,.vh一定要收集进去。同时,还要过滤掉Vivado自动生成的文件——比如IP核的仿真模型往往在*.srcs/sources_1/ip/*/sim/*.v目录下,这些文件虽然也是合法的RTL,但通常是Xilinx自动生成的,不是我们自己写的代码,提取出去没意义。
我的策略是维护一个“忽略目录关键词”列表,路径中只要包含这些关键词就直接跳过:
IGNORE_DIR_KEYWORDS = [ '.runs', '.cache', '.gen', '.sim', 'ip', 'bd', 'docs', 'tmp', ]注意这个设计有个坑:关键词ip会误伤真实的IP相关源码目录。所以我还有一个“路径精确匹配”的白名单机制,比如srcs/sources_1/ip这个路径本身要跳过,因为那是IP核的原始定义目录;但如果项目里专门建了一个ip_rtl目录存放自己写的IP源码,这个目录不应该被跳过。这个细节我在后面章节会专门讲。
3.2 模块名提取:用正则而不是完整的语法解析
拿到文件列表之后,下一步是提取每个文件里定义的模块名。这是整个工具最关键的环节。
可能有人会说:Python有pyverilog、vparser这些现成的Verilog解析库,直接用不行吗?我试过。pyverilog的解析能力强,但它对SystemVerilog的支持并不完善,遇到稍微新一点的语法(比如always_comb、interface)就报错。而且它会做完整的语法树生成,处理大文件时耗时明显。我们的场景只是提取模块名和实例化关系,根本不需要完整的语法树——用正则做一个“轻量级解析”反而是更务实的方案。
模块定义的格式在Verilog里有两种常见的写法。不带参数的模块定义长这样:
module fifo_wrapper ( clk, rst_n, wr_en, rd_en );带参数的定义长这样:
module fifo_wrapper #( parameter DATA_WIDTH = 32, parameter DEPTH = 1024 ) ( clk, rst_n, wr_en, rd_en );我用的正则表达式要兼容这两种写法:
MODULE_DEF_RE = re.compile( r'^\s*module\s+(\w+)\s*(?:#\s*\(.*?\))?\s*(?:\(.*?\))?\s*;', re.MULTILINE | re.DOTALL, )注意这里用了re.DOTALL标志,让.能匹配换行符,这样#(...)和(...)跨多行也没问题。但DOTALL模式有个著名的坑:.*?是懒惰匹配,如果文件里在后面又出现了一个);,它可能过早截断。为了避免这个,我专门处理了圆括号嵌套的情况,用了一个小技巧:先把整个文件里所有module后面的内容截出来,再逐步匹配括号。因为参数列表里的圆括号通常只有一层嵌套(里面不会再有括号套括号),所以手工数括号深度就能搞定。这个方案的鲁棒性比纯正则好一个数量级。
提取出的模块名会保存在module_defs字典里,键是模块名,值是一个包含文件路径和行号的结构体。
3.3 实例化关系解析:追踪模块之间的引用
模块定义提取完之后,接下来要解析每个文件里实例化了哪些模块。这个逻辑是依赖分析的核心。
Verilog模块实例化的典型写法有这几种:
fifo_wrapper u_fifo ( .clk(clk), .rst_n(rst_n) ); fifo_wrapper #( .DATA_WIDTH(DATA_WIDTH) ) u_fifo ( .clk(clk), .rst_n(rst_n) ); fifo_wrapper u_fifo_inst(.clk(clk), .rst_n(rst_n));我的思路是:遍历每个文件的内容,搜索所有“看起来像模块实例化”的位置,提取出实例化的模块名。具体策略如下:
- 先从文件内容中删除
module...endmodule块(因为模块定义内部的实例化会在另一个模块中统计,不能重复记录)。 - 匹配所有形如
word+ 空白 +identifier+ 空白 +(的片段,其中identifier以u_开头或以inst结尾(这是一个启发式规则,准确率比较高)。 - 如果
word出现在已知模块定义的集合里,就把对应的依赖关系记录下来。
启发式规则听起来不太“终极”,但实测下来,只要团队代码风格统一,识别准确率在95%以上。如果遇到异常写法的代码,我会记一条warning日志,提示人工确认。
也正是这一步,让我后来扩展出了一个有用的功能:识别“悬空引用”。如果实例化的模块名在所有文件里都找不到定义,说明工程文件缺失——要么漏复制了文件,要么依赖了IP核的仿真模型。这种问题在手工整理代码时极容易发生,而工具能在一秒钟内自动报警。
3.4 拓扑排序:把文件按编译依赖顺序排好
有了模块间的依赖关系,下一步就是确定文件输出顺序。Verilog编译不是必须按依赖顺序来——综合工具一般会做两遍扫描,所以文件顺序错了也能编译过去。但顺序好的文件列表,在命令行仿真工具里能减少大量提示警告,而且人类阅读时也舒服很多。
我用经典的拓扑排序解决这个问题。先构造一个有向图:节点是文件,边从被依赖文件指向依赖文件。然后按“入度为0优先”的原则逐一输出节点。换句话说,如果B模块被A实例化,B的文件会排在A前面。这样生成的文件列表从上往下读,就是从底层基础模块到顶层模块的顺序,非常自然。
代码如下:
def topological_sort(module_dep_map): # module_dep_map: {模块名: [依赖的模块名列表]} indegree = {m: 0 for m in module_dep_map} for m, deps in module_dep_map.items(): for d in deps: if d in module_dep_map: # 只统计本工程内定义的模块 indegree[m] += 1 queue = [m for m, d in indegree.items() if d == 0] result = [] while queue: # 按字母序排序保证输出可预测 queue.sort() m = queue.pop(0) result.append(m) for m2, deps in module_dep_map.items(): if m in deps: indegree[m2] -= 1 if indegree[m2] == 0: queue.append(m2) return result这里有一个隐藏的问题:循环依赖。两个模块互相实例化在FPGA工程里虽然不常见,但确实存在——比如FIFO的读写两侧跨时钟域,有时会把状态机拆成两个模块互相引用。遇到这种情况,拓扑排序会走不完,result长度小于节点总数。我的处理方法是把剩余节点全部追加到列表末尾,并打印一个警告。这样既不会让脚本崩溃,也提醒了使用者注意代码结构问题。
3.5 代码净化:注释掉仿真和调试专用语句
很多工程里,RTL源码中会混着仿真专用的代码段。最典型的就是:
`ifdef SIMULATION // 这里是一大段仿真逻辑,比如初始化、打印信息 `endif还有一种情况:调试用的ILA核例化。
ila_0 ila_debug ( .clk(clk), .probe0(debug_signal_a) );代码评审的时候,这些内容往往会干扰阅读,但对编译没有影响。我在工具里加了一个可选的“净化模式”,把以下类型的代码块注释掉或删除:
- 在一对
\ifdef SIMULATION和`endif`之间的内容 - 顶层模块里例化ILA/VIO/ICON等调试IP的固定代码段
$display、$finish等仿真系统任务调用
这里我特意保留了ifdef SYNTHESIS块中的内容(因为它们通常包含综合专用逻辑),同时把ifdef SIMULATION块整个丢进注释。这个功能的实现方式不算复杂,逐行扫描、记录状态位、再重组输出,关键是要保证注释掉的区块不影响其他代码的语法完整性。
在实际使用中我发现,净化模式最好做成可选而不是默认开启——因为有些同事的代码里SIMULATION宏定义的使用非常不规范,有时连else分支都没有,贸然删除会造成语义偏差。所以我把它单独做成一个命令行开关,默认关闭。
4. 实操过程:从命令行到完整提取
4.1 命令行入口和参数设计
工具我用标准库里的argparse做了命令行入口,用起来很直接:
python extract_rtl.py --input ./my_project --output ./rtl_export --clean几个常用参数:
--input:必填。项目目录或单个RTL文件的路径。--output:提取后的输出目录,默认是./rtl_export。--clean:可选。开启“净化模式”,注释掉仿真专用代码段。--no-header:默认会在输出文件头部加上原文件的路径注释,加上这个参数可以关闭,便于直接Check-in版本管理。
如果你拿到的是一个.xpr工程文件而不是源码目录,也可以直接把.xpr文件路径传进来,工具会尝试从XML里解析出srcs目录的位置。不过这个功能我要说明一下:Vivado的.xpr文件本质是XML,里面的<File Path=...>节点记录了所有源文件的相对路径,我的工具会读取这些路径并映射到实际文件系统。实测对Vivado 2018.x到2024.x生成的工程都能兼容。
4.2 输出目录结构和生成文件
提取完成后,输出目录大概长这样:
rtl_export/ ├── manifest.txt # 文件清单,包含原始路径和依赖关系 ├── dep_graph.txt # 模块依赖关系文本格式 ├── src/ │ ├── 00_fifo_wrapper.v │ ├── 01_axis_mux.v │ └── 02_top.v └── warnings.txt # 解析告警汇总文件名前面的两位数字是拓扑排序的序号,这个设计是我特意加的。好处是你在文件管理器里按名称排序,就能直接看出文件的编译依赖顺序,不用打开文件逐个看。manifest.txt很重要,它记录的是每个输出文件对应的原始工程路径,方便追溯。我遇到过一次,提取完代码后,同事拿着两个同名文件(一个fifo.v在src/fifo/下,另一个fifo.v在src/axis/fifo/下)问我哪个是真身——正是靠这个清单才快速定位。
依赖关系图dep_graph.txt也很有用。它是一个纯文本的邻接表,每行格式是:
fifo_wrapper -> {axis_mux, fifo_ram, reset_sync}配合简单的grep就能快速了解模块之间的调用关系。我后来甚至写了个小脚本,把这个文本图转成DOT格式扔给Graphviz画图,做代码结构汇报时特别直观。
4.3 和Vivado Tcl脚本的联动用法
前面说我强烈建议“Python + Tcl”双轨并行,这里给一个实际场景。
有一次,同事给我一个Vivado工程,说他只改了某个IP核的配置,需要我把“所有源文件+IP核的仿真模型”一起导出来做快速仿真。这种情况,如果用Python单纯扫描目录,很容易把IP核的几千个生成文件一网打尽,输出臃肿而无用。正确的做法是:先用Tcl拿到Vivado认为真实有效的源文件列表,再用Python脚本基于这个列表做提取和整理。
Tcl侧只需三行命令:
set fp [open "filelist.txt" w] puts $fp [get_files -filter {FILE_TYPE == "Verilog" || FILE_TYPE == "SystemVerilog" || FILE_TYPE == "VHDL"}] close $fp得到的filelist.txt里面是有换行分隔的绝对路径。我的Python工具支持--input filelist.txt模式,会先读取每行路径,再从这些路径出发做模块依赖分析和提取。两者一结合,就把“Vivado对工程状态的精准管理”和“Python对文本处理的便利”同时拿到手了。
还有一个真实的生产力提升:我把它包装成了一个批处理脚本,可以一次跑完几十个工程,全部按“工程名_日期”输出到统一目录。每次做周报、版本交接的时候跑一遍,省下来的时间相当可观。
5. 常见问题与避坑指南
5.1 中文路径和编码坑
这是第一个坑。国内很多工程师的Windows用户名是中文的,比如C:\Users\张三\project。Vivado本身对中文路径支持就差,经常报错;我的工具倒是能处理,但Python在Windows下读取文件时,如果文件编码不是UTF-8,很容易出现UnicodeDecodeError。
我的解决办法是:读取文件时不指定单一编码,而是先尝试UTF-8,失败了再用gbk(Windows简体中文默认编码)回退:
def read_file_content(path): try: with open(path, 'r', encoding='utf-8') as f: return f.read() except UnicodeDecodeError: with open(path, 'r', encoding='gbk', errors='ignore') as f: return f.read()另外,输出目录和输出文件名中,我统一把原始文件里的中文注释保留(因为很多同事习惯在代码里写中文注释),但目录名和文件名强制转为ASCII。这样可以最大程度避免后期工具链再出编码问题。
5.2 IP核和自动生成文件的识别策略
这个坑我最初踩得比较深。有一天我把一个工程提取完毕,满心欢喜地生成结果,打开一看,目录里多了几百个文件——全是IP核的生成源码。Xilinx的IP核目录结构里,*.srcs/sources_1/ip/xxx/sim/下有一堆xxx_v1_0.v之类的文件,而这些文件根本不是需要我们关注的交付内容。
但直接粗暴地把所有ip目录都排除又会出问题——有些同事会把自定义IP的源码放在自建的ip_repo目录下,那些代码恰恰是必须提取的。我的最终方案是维护一个两级判定逻辑:
- 如果路径中包含
/ip/且再包含/sim/或/synth/,判定为自动生成文件,跳过。 - 如果路径中包含
ip_repo、custom_ip这类自定义关键词,必须保留。
这个策略实现了“基于规则的智能过滤”,目前还没有出现过漏提取或过度提取的情况。如果你遇到更复杂的工程结构,建议先把一次提取结果和Vivado自带的Report对比一下,再调整关键词列表。
5.3 循环依赖检测和报告
前文提到了循环依赖,这里补充一个真实案例。我处理过一个以太网MAC的工程,mac_rx和mac_tx两个模块都实例化了一个共享的状态管理模块mac_state_shared,而mac_state_shared又反过来引用mac_rx里的一个函数。这种写法虽然不推荐,但确实能在综合工具下通过。
拓扑排序遇到这种结构会卡住,输出顺序就不完整。我的工具会发出这样的警告:
WARNING: 检测到循环依赖,以下模块无法确定顺序: mac_state_shared, mac_rx看到这个警告,我一般会建议同事花几分钟把这些交叉引用理顺,而不是强行调整脚本去适配。因为循环依赖本身就是一个代码异味,它会让后续的仿真调试变得非常痛苦。
5.4 Testbench文件的自动识别
提取交付代码时,testbench文件通常是不需要打包进去的。但它的文件命名往往是tb_top.v、top_tb.sv、tb_xxx_v1_0.v等,规律并不统一。我用了两种方式复合判断:
第一,文件名匹配模式。只要文件名包含tb、testbench、sim这些关键词,就标记为仿真文件。
第二,内容特征判断。如果文件里出现initial begin且同时出现$display,大概率是仿真文件(综合用的RTL极少使用initial块初始化信号,即便有,也不会和$display共存)。两个条件都满足才判定为仿真文件,避免误杀真正的综合代码。
实测下来,第二招在区分“顶层仿真文件”和“综合顶层”时尤其好用。有一次同事的testbench文件叫test_module.v,完全没有命名特征,靠内容特征识别了出来。
6. 后续扩展和相关的几个小工具
工具本身是围绕“提取”这个主题设计的,但在使用过程中我发现,很多需求其实围绕它衍生出来。目前我自己又加了几个小功能,分享出来供你参考。
第一个是“单模块提取”。有时候你不需要整个工程的所有RTL,只需要某一层的某几个模块。我加了一个--module参数,配合依赖解析可以自动提取出实现某个模块所需的最小子集。这对做单元仿真非常有用——不用再把整个工程几百个文件全拉进仿真器。
第二个是“文档生成”。提取完成后,我可以顺便生成一份简单的Markdown格式模块清单,包含每个模块的端口列表、例化数量、所在文件路径。做设计文档、代码评审材料、新人培训资料时,这份清单直接就能用。如果再配合一个简单的模板,就能输出一份相当专业的模块说明文档。
第三个是“重复模块检测”。有些团队在代码复用过程中会把同一模块复制好几份,放置在不同目录里,内容略有差异。这个问题在多人协作中特别常见——A同事改了src/fifo/fifo.v,B同事不知道,直接改src/axis/fifo_v2.v里的逻辑,两个文件慢慢就分叉了。我的工具能检测出“同一模块名出现在多个文件中”的情况,并在warnings.txt里列出来,这个功能已经帮我提前发现了三起代码分叉问题。
这些扩展功能的核心,其实都是围绕同一份精心维护的模块依赖图。所以如果你打算自己写一个类似的工具,我建议把依赖解析这一步做扎实——后续所有的功能,无论是顺序整理、单模块提取、还是文档生成,都依赖这层数据。
7. 总结一下我在实操中的几个体会
工具写了大概三个月、迭代了七八个版本之后,我最大的感受是:真正节省时间的不是“复制文件”这个动作本身,而是“理解工程结构”这个思维过程。以前整理代码,我需要打开工程、翻目录、查实例化关系、核对文件完整性,每一步都在动用大脑做信息整合。现在脚本自动做完了,我只需要看一眼warnings.txt里的告警,就能知道哪里有问题。
如果你打算自己实现一个类似的提取工具,我的建议是:先用一个月的时间,在手边真实工程上反复测试,积累文件名规律和特殊写法,再逐步完善那套启发式规则。另外,一定要保留manifest.txt这样的可追溯信息,关键时刻能救你。
工具本身很轻量,纯Python标准库就能实现,不需要依赖任何第三方包(这在公司的离网环境里非常友好)。等到某天你也需要给同事交付一份干净的源码目录,而你只需要敲一行命令就能完成,那你会觉得这件事的思路是对的。