基于Python的FPGA RTL代码提取工具:模块依赖分析与实战

发布时间:2026/9/29 20:59:05
基于Python的FPGA RTL代码提取工具:模块依赖分析与实战 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失败了再用gbkWindows简体中文默认编码回退def read_file_content(path): try: with open(path, r, encodingutf-8) as f: return f.read() except UnicodeDecodeError: with open(path, r, encodinggbk, errorsignore) 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.vB同事不知道直接改src/axis/fifo_v2.v里的逻辑两个文件慢慢就分叉了。我的工具能检测出“同一模块名出现在多个文件中”的情况并在warnings.txt里列出来这个功能已经帮我提前发现了三起代码分叉问题。这些扩展功能的核心其实都是围绕同一份精心维护的模块依赖图。所以如果你打算自己写一个类似的工具我建议把依赖解析这一步做扎实——后续所有的功能无论是顺序整理、单模块提取、还是文档生成都依赖这层数据。7. 总结一下我在实操中的几个体会工具写了大概三个月、迭代了七八个版本之后我最大的感受是真正节省时间的不是“复制文件”这个动作本身而是“理解工程结构”这个思维过程。以前整理代码我需要打开工程、翻目录、查实例化关系、核对文件完整性每一步都在动用大脑做信息整合。现在脚本自动做完了我只需要看一眼warnings.txt里的告警就能知道哪里有问题。如果你打算自己实现一个类似的提取工具我的建议是先用一个月的时间在手边真实工程上反复测试积累文件名规律和特殊写法再逐步完善那套启发式规则。另外一定要保留manifest.txt这样的可追溯信息关键时刻能救你。工具本身很轻量纯Python标准库就能实现不需要依赖任何第三方包这在公司的离网环境里非常友好。等到某天你也需要给同事交付一份干净的源码目录而你只需要敲一行命令就能完成那你会觉得这件事的思路是对的。