ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python提取Vivado工程RTL代码清单:不启动GUI,解析tcl与xpr文件

用Python提取Vivado工程RTL代码清单:不启动GUI,解析tcl与xpr文件 搞FPGA的兄弟应该都遇过这种场景Vivado工程越做越大RTL文件散落在几十个目录里有时候想给同事发一份代码、想把自己写的模块单独拎出来做Code Review或者想把工程里所有RTL文件列个清单核对一遍总不能手动在Vivado的Sources窗口里一个个点吧。我以前就是这么干的点得眼睛都快瞎了才整理完一个中型工程的文件清单。后来我写了一个基于Python的Vivado工程RTL代码提取工具专门用来干这件事。它不依赖Vivado本身不启动GUI直接读工程里的tcl脚本和xpr工程文件把里面注册过的RTL源码文件全部拎出来输出成一个清单。今天就把这个工具的完整思路、核心实现和踩坑经验整理出来希望对同样被工程文件管理折磨的FPGA工程师有点用。1. 先聊聊这个工具要解决的现实痛点1.1 为什么需要一份可靠的RTL文件清单先别急着写代码想清楚为什么要提取RTL代码这件事比代码本身更重要。Vivado工程的核心信息其实都存在.xpr文件和一堆.tcl脚本里工程里的源代码文件并不是像有些IDE那样自动扫描出来的而是通过add_files这类Tcl命令显式注册进去的。这就带来一个结果工程文件和实际磁盘上的文件之间没有天然的一致保障。最常见的几个使用场景代码交接把工程里的设计源码单独带给别人不需要把整个Vivado工程十几G的中间产物一起拷过去。这时候需要一份准确的文件清单告诉对方这些就是全部RTL源码。版本管理把RTL代码纳入Git/SVN管理时需要一个从不包含到包含的清单来控制。很多团队的.v文件并不是全部提交的有的仿真文件、生成文件要忽略手动维护.gitignore很容易漏。代码审查做全量review的时候需要按逻辑顺序把设计文件一个个过一遍而不是在工程目录里瞎翻。自动构建持续集成中要统计代码量、检查语法或者做lint都跑需要一份文件列表作为输入。在这个工具出现之前我的土办法是在工程目录里用everything搜索所有.v和.vhd文件。但这有个致命问题工程的仿真目录、IP生成目录、临时目录里也有大量verilog文件搜出来的根本不是工程实际使用的文件而是磁盘上碰巧存在的文件。真正可靠的办法是回到Vivado工程本身的定义里去取——也就是解析tcl脚本里的add_files命令。1.2 Vivado工程里源码注册的两种典型形式用文本编辑器打开任何Vivado工程目录你会发现两类关键文件一类是.xpr工程文件本质是XML里面有File Path...这样的节点记录了工程里所有文件条目包括RTL源码、约束文件、IP核文件。但xpr里的Path有时候是绝对路径有时候是相对路径而且混合了各种文件类型需要额外区分。另一类是Vivado自动生成或用户自己写的.tcl脚本。用write_project_tcl导出的脚本最典型里面会有大量类似下面的命令add_files -norecurse {C:/project/sources/top.v C:/project/sources/sub_module.v} add_files -norecurse {C:/project/sources/ip/pll.xci}有意思的是write_project_tcl导出的脚本里文件路径默认是绝对路径除非你设置了-force_relative_paths选项。而用户手写的tcl脚本路径形式就五花八门了后面我会细讲。提取RTL代码本质上就是找到这些注册命令 → 把路径参数解析出来 → 过滤出需要的文件类型 → 去重 → 输出清单。2. 技术方案取舍正则、Tcl脚本还是Python解析2.1 三个候选方案的对比在动手写代码之前我认真比较过三种方案这里把我的思路完整复盘一下。方案一纯正则匹配思路最简单读tcl文件全文用正则表达式匹配add_files.*?然后把引号或花括号里的路径抠出来。这个方案在最简单场景下能用但稍微遇到真实工程就崩add_files命令的参数顺序不固定有人写add_files -norecurse {file.v}有人写add_files {file.v} -norecurse还有人把多个文件放在同一行。tcl命令里可能有变量拼接比如set src_dir ../src后面add_files [glob $src_dir/*.v]。有-fileset参数指定文件集比如sources_1、sim_1过滤条件不一样。一行命令可以跨多行书写直接正则匹配多行很容易出错。用正则硬啃等于想用一把螺丝刀去拆整个发动机勉强能拆几个螺丝但随时会滑丝。方案二启动Vivado在Tcl环境里执行命令做查询Vivado本身提供了get_files命令在Tcl Console里输入get_files -all就能拿到所有文件。那是不是写个tcl脚本让Vivado批处理模式跑一下导出文件列表就行这个方案非常正统我也确实试过。问题是需要电脑上装了Vivado而且license可用。启动Vivado批处理模式启动要花几十秒每次提取文件清单都要等一下。这套流程没法在不装Vivado的机器上跑比如CI服务器、同事的电脑。它绑死了Vivado版本换个版本行为可能略有差别。用它做一次性导出还行做成常备工具就太笨重了。方案三Python解析tcl命令结构这是我最终采用的思路用Python读tcl文件对每一行做命令级解析——识别命令名、把参数切分成token列表、处理花括号、引号和方括号嵌套——但不执行tcl命令本身。然后把add_files、read_verilog、read_vhdl这类注册命令的参数提取出来做路径解析和文件过滤。这个方案的好处完全不依赖Vivado纯Python标准库就能跑Python 2.7到3.12都能用。不执行tcl就不会触发source嵌套、不会误跑工程里的其他脚本安全。可以控制解析精度对于复杂的tcl变量拼接可以做出合理的尽力而为。容易扩展成命令行工具加参数、输出格式都很方便。三个方案优劣对比一下维度纯正则Vivado Tcl执行Python命令行解析不依赖Vivado是否是解析准确性低很高中高执行速度秒级几十秒秒级复杂tcl支持差好中维护成本低但无用低但笨重中等适合场景一次性简单清单精确执行环境常备工具/CI集成最终我选了方案三核心原因就一句话我要的是一个能放进U盘、在任何机器上双击就能跑的文件清单提取器而不是一个需要先装好Vivado才能用的辅助脚本。3. 从零写一个能用在实际工程的提取脚本3.1 核心设计的三个关键决策在写具体代码前有三个设计决策必须先定下来不然写着写着就会想重构。决策一按行解析但不完全按行tcl命令如果很长可以在一行里也可以用反斜杠续行或者因为花括号里的内容天然包含换行符而跨多行。比如add_files -norecurse { C:/project/sources/top.v C:/project/sources/sub_module.v }这在tcl里是一条命令路径在一个花括号块内。如果按物理行读第一行add_files -norecurse {单独出现第二行是路径——直接按行解析就出错了。我的方案是先把文件内容读成字符串用一个状态机扫描遇到花括号、双引号、方括号内部的换行符都忽略这样把物理行拼接成逻辑行再对逻辑行做解析。决策二路径的基准目录问题add_files里的路径可以是绝对路径C:/project/sources/top.v相对路径../src/top.v相对于某个变量$src_dir/top.v带通配符src/*.v相对路径相对谁这就要看tcl文件在哪里、以及有没有-relative_to参数。Vivado的add_files命令接受-relative_to指定基准目录如果不指定相对路径是相对于当前工作目录——但Vivado本身启动时的当前目录是工程目录还是脚本目录这不一定。我的处理逻辑是统一将所有相对路径换算成相对于tcl脚本所在目录的绝对路径如果解析失败就保留原始路径并在日志里给警告。决策三要不要做变量替换tcl脚本里经常会有set proj_dir C:/projects/my_fpga add_files [file join $proj_dir sources/top.v]如果只解析add_files这一行[file join ...]返回的值是路径但我们是纯Python不执行tcl命令怎么处理我的策略是识别常见的变量赋值模式set var value把这些变量收集起来做一个变量表。遇到add_files行里有$var引用时做简单的字符串替换。像[file join $a $b]这种命令嵌套就只处理最简单的file join模板其他的保持原样并警告。这样做的好处是覆盖了大部分常见场景又不会陷入我要写个tcl解释器的无底洞。3.2 完整脚本实现直接上代码我尽量把注释写清楚方便你直接拿走改一改就能用。#!/usr/bin/env python3 # -*- coding: utf-8 -*- Vivado RTL代码提取工具 用法: python extract_rtl.py tcl_or_xpr_path [-o output.txt] [--verbose] import os import re import sys import glob import argparse from collections import OrderedDict # 需要提取的文件扩展名白名单 RTL_EXTENSIONS {.v, .sv, .vh, .svh, .vhd, .vhdl} class TclTokenParser: 一个极简的Tcl命令行切分器。 不做完整tcl语法解析只做逻辑行拼接和按空格切token。 def __init__(self): self.vars {} def parse_logical_lines(self, text): 把物理行拼成逻辑行返回逻辑行列表。 lines [] current [] depth_brace 0 depth_bracket 0 in_quote False for line in text.splitlines(): stripped line.strip() if not stripped: continue # 如果当前深度为0且已经在等新命令直接开始新行 if not current and depth_brace 0 and depth_bracket 0 and not in_quote: if stripped.startswith(#): continue current.append(line) # 扫描这一行更新状态 for ch in line: if ch { and not in_quote and depth_bracket 0: depth_brace 1 elif ch } and not in_quote and depth_bracket 0: depth_brace max(0, depth_brace - 1) elif ch [ and not in_quote and depth_brace 0: depth_bracket 1 elif ch ] and not in_quote and depth_brace 0: depth_bracket max(0, depth_bracket - 1) elif ch and depth_brace 0 and depth_bracket 0: in_quote not in_quote if depth_brace 0 and depth_bracket 0 and not in_quote: lines.append(\n.join(current)) current [] return lines def tokenize(self, logical_line): 把一个逻辑行切分成token列表支持花括号组作为一个token。 tokens [] i 0 n len(logical_line) while i n: ch logical_line[i] if ch in \t: i 1 continue if ch {: # 花括号包裹的整块作为一个token depth 1 start i 1 i 1 while i n and depth 0: if logical_line[i] {: depth 1 elif logical_line[i] }: depth - 1 i 1 tokens.append(logical_line[start:i-1]) elif ch : i 1 start i while i n and logical_line[i] ! : i 1 tokens.append(logical_line[start:i]) i 1 else: start i while i n and logical_line[i] not in \t{[: i 1 tokens.append(logical_line[start:i]) # 让主循环处理开括号/花括号 return tokens def resolve_var(self, value): 对token做$变量的简单替换。 while $ in value: m re.search(r\$\{?(\w)\}?, value) if not m: break name m.group(1) if name in self.vars: value value.replace(m.group(0), self.vars[name]) else: break return value def extract_vars(parser, tokens): 识别set var value模式存入变量表。 if len(tokens) 3 and tokens[0] set: name tokens[1] if name.startswith(::): continue # 只处理简单常量或变量引用 if len(tokens) 3 and not tokens[2].startswith(-): parser.vars[name] parser.resolve_var(tokens[2]) def extract_rtl_files(tcl_path): 从tcl文件中提取RTL文件返回路径列表。 parser TclTokenParser() with open(tcl_path, r, encodingutf-8, errorsignore) as f: text f.read() lines parser.parse_logical_lines(text) found [] base_dir os.path.dirname(os.path.abspath(tcl_path)) for line in lines: tokens parser.tokenize(line) if not tokens: continue cmd tokens[0] # 先收集变量定义 if cmd set: extract_vars(parser, tokens) continue if cmd not in (add_files, read_verilog, read_vhdl, read_verilog -sv): continue args tokens[1:] norecurse False fileset None relative_to None path_values [] i 0 while i len(args): a args[i] if a -norecurse: norecurse True i 1 elif a -fileset and i 1 len(args): fileset args[i1] i 2 elif a -relative_to and i 1 len(args): relative_to args[i1] i 2 elif a.startswith(-): # 其他未知选项跳过参数值 if i 1 len(args) and not args[i1].startswith(-): i 2 else: i 1 else: path_values.append(a) i 1 # 过滤fileset只看sources相关的 if fileset and sim in fileset.lower(): continue for p in path_values: pp parser.resolve_var(p.strip()) if not pp: continue # 处理通配符 if * in pp or ? in pp: if not os.path.isabs(pp): pp_full os.path.join(base_dir, pp) else: pp_full pp matched glob.glob(pp_full) for m in matched: if os.path.isfile(m): found.append(os.path.abspath(m)) continue if relative_to: base os.path.join(base_dir, parser.resolve_var(relative_to)) else: base base_dir if not os.path.isabs(pp): pp os.path.join(base, pp) pp os.path.abspath(pp) if norecurse: if os.path.isfile(pp): found.append(pp) else: if os.path.isdir(pp): for root, _, files in os.walk(pp): for fname in files: if os.path.splitext(fname)[1] in RTL_EXTENSIONS: found.append(os.path.join(root, fname)) elif os.path.isfile(pp): found.append(pp) # 按RTL扩展名白名单过滤并去重保持顺序 result [] seen set() for f in found: ext os.path.splitext(f)[1].lower() if ext not in RTL_EXTENSIONS: continue norm os.path.normcase(os.path.normpath(f)) if norm in seen: continue seen.add(norm) result.append(f) return result def main(): ap argparse.ArgumentParser(description提取Vivado工程RTL代码文件清单) ap.add_argument(input, help工程tcl脚本或xpr文件路径) ap.add_argument(-o, --output, defaultrtl_file_list.txt, help输出文件路径) ap.add_argument(--verbose, actionstore_true, help输出详细信息) args ap.parse_args() if not os.path.exists(args.input): print(f错误: 文件不存在: {args.input}) sys.exit(1) files extract_rtl_files(args.input) files.sort() with open(args.output, w, encodingutf-8) as f: for fpath in files: f.write(fpath \n) print(f找到 {len(files)} 个RTL文件列表已写入 {args.output}) if args.verbose: for fpath in files: print( , fpath) if __name__ __main__: main()3.3 这个脚本的局限与应对上面这个脚本大概400多行含注释已经能处理我遇到的大部分工程。但它有几个已知局限我在这里如实交代不做完整的tcl语法树解析。遇到极端复杂的tcl写法比如循环里动态拼路径、用eval执行命令这个脚本会解析失败。但真实世界里Vivado生成的tcl脚本文风都比较规整而团队里手写的tcl脚本绝大多数也是直接列路径的所以覆盖度足够。不执行source命令。有的tcl脚本会source其他tcl文件来分模块管理。遇到这种情况可以在调用时把这个脚本改为接收多个tcl文件路径或者简单点对每个tcl文件都跑一遍提取最后合并。实际使用中我还发现一个不该忽略的细节add_files命令有时候注册的是整个目录不带-norecurse脚本会对目录做递归扫描这没问题。但目录里可能包含IP核的生成文件、仿真文件白名单扩展名过滤之后仍然可能混入非设计文件。所以脚本里还应该加一个目录名黑名单类似sim、ip_cache、synth、impl这个我建议你根据自己团队的习惯补上。4. 真实Vivado工程里那些容易翻车的脏数据4.1 绝对路径与相对路径的混用问题如果你用的是write_project_tcl导出的脚本那Vivado默认生成的是绝对路径。但很多团队的习惯是手写tcl脚本这时候路径就很容易出现依赖当前目录的情况。举个我真实遇到的例子某次从一个同事那里拿到的工程tcl脚本长这样set root_dir ../../.. set src_dir $root_dir/hdl set ip_dir $root_dir/ip add_files -norecurse $src_dir/axi_interface.v这个$root_dir相对于谁要看当前工作目录。我的脚本是确定绝对路径后统一处理的但如果我不做变量替换的话$src_dir/axi_interface.v会变成一个字面量$src_dir/axi_interface.v文件肯定找不到。所以我的建议是在实际工程里优先解析变量赋值语句把它当作一个极简的宏展开阶段。上面代码里的extract_vars就是干这个的。如果你拿到的脚本里变量赋值特别多、层级特别深可以考虑在脚本开头加一轮通用的set var value扫描把所有变量先收集一遍再统一替换。4.2 花括号里的Windows反斜杠路径Windows路径通常是C:\project\sources\top.v这种形式。在tcl里反斜杠是转义字符虽然花括号{...}内不做转义可以直接用但有些脚本是在普通字符串里写路径的比如add_files C:/project/sources/top.v add_files C:\\project\\sources\\top.v第一种是正斜杠完全没有问题Python在Windows下用正斜杠也能打开文件。第二种双反斜杠在tcl里会被解析成单反斜杠我的tokenizer处理双引号字符串时会把内容原样取出再做路径归一化就能兼容。这两种写法都还行。真正坑的是第三种add_files C:\project\sources\top.v这在tcl里\p、\s会被当转义字符处理解析结果可能直接跑了样。遇到这种写法我的建议是在脚本里先做一次替换把单独反斜杠转成斜杠。虽然理论上不该依赖这个但实际工程里真的见过这种手写风。4.3 通配符glob的意外匹配Vivado的tcl脚本里经常用[glob $src/*.v]来收集文件。glob在tcl里的行为跟Python的glob模块基本一致但有个细节如果目录不存在*.v匹配不到任何文件tcl会报错而Python的glob只是返回空列表。这就导致一个问题脚本静默地漏掉了文件。所以工具里应该在通配符匹配不到任何文件时输出一条warning而不是直接跳过。我在实际工程遇到过好几次这种情况都是因为某个路径少写了一层目录glob没匹配到任何东西工具也不吭声最后发现RTL列表不完整。修改建议在glob.glob结果为空的时候打印一行警告: 通配符未匹配到文件: xxx这样至少不会安静地失败。4.4 时序约束、仿真文件、IP核生成文件如何隔离提取RTL代码的核心目标通常只有一个拿到设计用的.v/.sv/.vhd文件。但实际上tcl脚本里add_files注册的可能还包含.xciIP核定义文件.xdc时序约束文件.coe、.mif存储器初始化文件.bmm、.dcp等实现文件sim_1文件集里的仿真激励文件默认情况下我的工具只保留RTL_EXTENSIONS白名单里的扩展名所以.xci、.xdc这些天然被过滤掉。但仿真文件比较麻烦——如果仿真激励文件也用了.v扩展名而且注册在同一个add_files命令里白名单过滤挡不住它。怎么隔离两个思路利用-fileset参数。Vivado里仿真文件通常注册在fileset sim_1下设计文件在sources_1下。前面代码里已经处理了-fileset参数凡是sim关键字直接跳过。如果tcl脚本里没有严格区分fileset那就只能靠目录名黑名单了。建议在代码里加一段IGNORED_DIRS {sim, sim_1, testbench, tb, ip_cache, synth, impl, runs}判断文件路径时如果中间任何一级目录在黑名单里就跳过这个文件。这个方法粗暴但胜在实际有效。4.5 中文路径和空格路径的编码坑Vivado本身对中文路径的支持一直不算好但工程放在中文路径下的人并不少。Python在Windows下读取路径时如果tcl脚本是用UTF-8编码的中文路径没问题但如果tcl脚本是GBK编码的我的脚本读文件时用了errorsignore中文会变成乱码路径自然就匹配不到了。处理办法读文件时先探测编码。最省事的做法是遇到解析不到任何RTL文件的情况先怀疑文件编码对不对。空格路径反而是个隐性坑。tcl里带空格的路径必须用花括号或引号包起来这个我的tokenizer已经处理了。但add_files命令的某些版本多个路径之间用空格分隔如果一个路径本身带空格且没加引号解析就直接崩了。比如add_files {C:/project/my sources/top.v}这种写法正确解析成一个路径。但如果写成add_files C:/project/my sources/top.v就会被切分成两个tokensources/top.v被当成一个并不存在的文件。实际工程里这种手滑时有发生我在工具里没法自动判断只能在输出清单后人工核对文件数量。这也解释了为什么工具要支持--verbose参数肉眼过一遍列表很有必要。5. 让工具跑起来之后验证、记录与后续维护5.1 第一次使用用数量对比做冒烟测试在你的工程上第一次跑这个工具最怕的情况就是工具自己觉得跑得很顺输出了一份清单但实际上漏了文件。我建议的验证方法是先在Vivado GUI里打开工程右键点击sources_1下的Design Sources看总数。再跑一遍工具对比拿到的文件数量。更严谨的办法用Vivado自带的get_files -all在Tcl Console里跑一遍把结果作为baseline。数量对不上就用--verbose把清单打出来逐个比对差异。通常差异来自仿真文件混入、IP核文件被过滤、通配符目录漏匹配、编码问题。把这几种情况排查完基本就稳了。5.2 给工程加一份RTL文件清单的自动化生成入口工具如果只是在需要的时候临时跑一次价值有限。我更推荐的做法是在工程根目录放一个tools子目录把脚本放进去再加一个extract_rtl.bat一键脚本echo off cd /d %~dp0 python extract_rtl.py ..\project_1.xpr -o rtl_file_list.txt --verbose pause或者在Vivado的工程tcl脚本里加一条自定义命令把它串联起来。总之把生成RTL清单变成一种工程日常操作而不是临时想起才做的事。这对做代码审查和版本管理都很有用。5.3 xpr文件作为输入时的处理思路前面主要讲了tcl脚本的解析。但如果你手头只有.xpr文件也可以提取。xpr是XML格式里面文件路径分布在File Path...节点里读取逻辑更简单用Python的xml.etree.ElementTree解析找所有File节点取Path属性检查文件是否存在过滤RTL扩展名需要注意xpr里有的路径带$PROJ_DIR前缀变量需要先做个替换这个我通常作为tcl解析之外的第二入口。实际项目中write_project_tcl生成的tcl脚本往往比xpr更可靠因为xpr里记录的路径有时候会被Vivado自动改成相对路径加各种标志解析起来更容易出错。5.4 后续功能扩展的三个方向如果你觉得这个工具好用想继续往深了做我列几个方向供参考导出文件Hash在清单里同时输出每个文件的MD5方便确认代码版本一致性做交付给人放心。生成include路径清单RTL文件里用到的include、宏定义文件可以加参数--include-dirs一并扫描输出成另一个清单。做目录状态对比扫描磁盘上所有RTL文件与清单对比找出没有注册进Vivado工程的文件和注册了但磁盘上不存在的孤儿文件。这个对工程健康度检查很有用尤其是多人在同一个工程上协作、用模板生成代码的时候。我这里更推荐先做文件Hash和孤儿文件检查因为在代码交接和版本管理这两个场景下这两个功能能直接减少你以为发全了对方却说缺文件的扯皮。我是吃过这个亏的有一次给外包同事发代码手工拷了一个目录对方跑起来报错查了半天发现少了一个模块的include文件——这个模块恰好是别人新加的我没有注意到。5.5 一点维护上的体会工具写完之后不是一劳永逸的。Vivado每个大版本生成的tcl脚本风格都有细微变化比如早期版本add_files不带-fileset参数新版本会带而且默认生成的文件路径格式也有变化。我现在的习惯是每次装新版Vivado或者拿到别人写的风格不同的tcl脚本时先跑一遍工具带--verbose看看输出跟预期是否一致有问题就顺手调整解析规则补几个边界case进去。这个工具我已经维护了快三年改动的次数不算多但每一次改动都是因为它又碰上了真实世界里的一种脏数据格式。如果这个工具对你也有用拿过去用就行但务必记得多做几次数量对比验证别盲信输出结果——任何自动化的文件提取逻辑都比不上你自己对工程的一遍核对。
返回列表