
接手FPGA工程的时候最烦的一件事就是从Vivado工程里把RTL代码整理出来。你可能会说直接打开工程目录复制不就完了真不是这么简单。一个中等规模的工程里源码、IP核生成文件、仿真文件、约束文件全混在.srcs目录下手写代码可能散落在sources_1/new、sources_1/imports、sources_1/ip好几个子目录里中间还夹杂着Xilinx自动生成的一大堆wrapper和网表相关文件。你要是做过一次对外交付源码或者要把项目归档到Git仓库一定体验过那种翻目录翻到头大、最后还担心漏文件的焦虑感。这篇文章要说的就是怎么用Python写一个Vivado工程RTL代码提取工具自动把工程里的Verilog、SystemVerilog、VHDL文件摘出来保持原有目录层级顺手过滤掉IP核生成代码。工具本身只依赖Python标准库不需要装任何第三方包也不需要打开Vivado图形界面。适合三类人一是经常做代码交付和源码归档的FPGA工程师二是要用Git管理Vivado工程但不想把整个工程塞进仓库的团队三是刚开始折腾Vivado工程结构、想搞清楚源代码到底放在哪的新手。看完这篇文章你会拿到一份可以直接跑的脚本以及几个我从实际工程里踩坑踩出来的注意事项。1. Vivado工程里RTL代码的“藏身之处”1.1 一个典型Vivado工程的目录解剖先花点时间搞清楚Vivado工程目录到底长什么样工具的核心逻辑都是围绕目录结构设计的。用Vivado创建一个名为led_flow的工程后默认会生成这样的目录led_flow/ ├── led_flow.xpr ├── led_flow.srcs/ │ ├── sources_1/ │ │ ├── new/ │ │ │ ├── top.v │ │ │ ├── data_path.v │ │ │ ├── counter.v │ │ ├── imports/ │ │ │ ├── external_ip.v │ │ ├── ip/ │ │ │ ├── clk_wiz_0/ │ │ │ │ ├── clk_wiz_0.xci │ │ │ │ ├── synth/ │ │ │ │ │ ├── clk_wiz_0.v │ │ │ │ │ ├── clk_wiz_0_clk_wiz.v │ │ │ ├── vio_0/ │ │ ├── bd/ │ │ │ ├── system/ │ │ │ │ ├── system.bd │ │ │ │ ├── hdl/ │ │ │ │ │ ├── system_wrapper.v │ ├── sim_1/ │ ├── constrs_1/ │ │ └── new/ │ │ └── led_flow.xdcnew目录通常放的是你直接在Vivado里新建的源码文件imports是外部添加进来的源码它们才是真正要提取的目标。ip目录下虽然也有一堆.v文件但那些是Xilinx IP核自动生成的代码正常情况下不需要手动维护也不应该作为交付源码的一部分。bd目录是Block Design相关的里面的system_wrapper.v是自动生成的顶层wrapper见仁见智大部分时候也不纳入RTL交付范围。sim_1是仿真文件目录constrs_1是约束文件目录这两个跟RTL提取关系不大。1.2 为什么不能直接整个复制.srcs最直观的办法是复制整个.srcs目录我早期也这么干过后来发现代价挺高。首先是体积问题一个带三四个IP核的工程.srcs目录随随便便几十兆里面大量是IP核的生成产物真正手写的RTL可能不到几百KB。其次是噪音问题仿真文件、约束文件、IP配置信息混在一起接收方要自己分辨哪些是源码、哪些是自动生成非常痛苦。更麻烦的是.srcs里的很多文件是依赖工程环境的比如IP核的.xci文件单独拷走之后如果不同时拷对应IP目录基本无法使用。所以提取工具的核心价值不在于“复制”而在于“筛选”。你得精确地知道哪些文件是工程真正需要的RTL源码哪些是冗余生成物然后带着这个判断去复制。这个判断规则如果靠人来记每次手工操作都会出岔子用脚本固化下来反而一劳永逸。1.3 什么场景下你特别需要一个提取工具我自己是被两次事故逼着写脚本的。一次是给客户交付源码包手工从.srcs里挑文件漏了顶层模块下挂的一个子模块文件对方编译时报了一堆“module not found”最后排查半天才发现少文件非常尴尬。另一次是项目归档整个工程塞进SVN仓库结果仓库体积直接爆炸每次提交都要等半天。后来我把“提取RTL代码”固化成常规操作每次对外交付、每次提交Git、每次做代码审查之前先跑一遍提取脚本把干净的RTL代码包生成出来。这个习惯帮我把很多低级失误扼杀在交付之前。如果你也有类似的场景这篇文章的脚本可以直接拿来用。2. 技术路线选型三条路怎么走2.1 方案A解析 .xpr 工程文件.xpr文件是Vivado工程的核心描述文件本质是一个XML文档里面记录了工程全部文件索引、器件型号、综合策略、仿真设置等信息。我们要提取RTL代码最直接的思路就是解析这个XML文件把所有File节点下Path属性对应的文件路径读出来再做过滤。这个方案最大的优点是精确度高从.xpr里读到的文件列表就是Vivado自己登记在册的源代码集合跟GUI工程浏览器里看到的文件树一一对应。缺点也有.xpr里的路径表达方式不统一有相对路径、绝对路径还有$PSRCDIR这类变量开头的形式不同Vivado版本之间格式也有细微差异脚本必须做兼容处理。Python解析XML是基本功标准库里的xml.etree.ElementTree就够了不需要额外安装任何东西。2.2 方案B直接扫描 .srcs 目录第二种思路更简单粗暴用os.walk或pathlib的rglob遍历整个.srcs目录按文件扩展名过滤出.v、.sv、.vhd等文件再根据路径关键词排除掉ip、bd、sim这些目录。这个方案的优点是实现极其简单而且不依赖工程文件是否完整。比如工程是从别人那里拷来的.xpr里记录的路径已经失效了但.srcs目录里的文件还在这时候扫描目录就是唯一能用的手段。缺点是无法区分文件是否真的参与了综合IP核目录下的生成代码会被误收进来需要配合更精细的目录规则过滤。2.3 方案C通过Vivado的Tcl接口导出文件列表Vivado自带Tcl解释器可以在工程打开状态下执行get_files命令拿到底层的文件对象再通过get_property location拿到物理路径最后把清单导出为文本文件。这个方案拿到的是Vivado内部最权威的文件列表不依赖人工判断适合工程结构特别乱、或者由脚本自动化生成的特殊工程。缺点也很明显必须安装Vivado并且能正常启动自动化程度受限于Tcl脚本的批量调用方式不适合轻量级场景。但它的定位是兜底和校验跟方案A配合使用效果很好。我一般在遇到.xpr解析异常、拿不准过滤规则对不对时用Tcl导一份清单来做交叉验证。2.4 我最终选择的组合方案三个方案各有取舍我最后用的组合方式是主用方案A解析.xpr拿不到有效文件列表时自动降级到方案B扫描.srcs目录方案C作为高级进阶场景的补充工具单独写脚本。这个组合不是拍脑袋定的。日常场景中一台机器上同时装了Vivado和Python但本身不想每次提取都启动Vivado图形界面也不想在批处理模式里等工程加载方案A最省事。方案B是为了兼容那些已经被移动、损坏或由其他工具生成的“不完整”工程。方案C则更像是人工核查时的背书工具。三个方案相互配合覆盖场景基本完整。方案优点缺点适用场景解析 .xpr精确、快速、依赖少路径格式需兼容常规工程提取扫描 .srcs简单、不受工程文件状态影响容易混入IP核生成文件工程文件缺失或损坏Tcl 导出清单最权威、自动化程度最高依赖Vivado环境复杂工程、交叉验证3. Python脚本实操从零到能用的完整代码3.1 运行环境准备工具只依赖Python标准库理论上Python 3.6以上都能跑我测试用的版本是3.8建议至少3.6。不需要pip install任何第三方包也不用配置虚拟环境。如果你机器上还没装Python去 python.org 下载安装包Windows安装时记得勾选“Add Python to PATH”否则命令行里打不开python。装完之后打开终端输入python --version验证一下。脚本里用到的库只有这么几个xml.etree.ElementTree负责解析.xprpathlib负责处理路径shutil负责复制文件os和sys是常规操作。所有这些都在标准库里不需要额外安装。3.2 读取 .xpr 并解析文件列表解析.xpr的核心代码可以写成这样import xml.etree.ElementTree as ET from pathlib import Path def collect_from_xpr(xpr: Path): tree ET.parse(xpr) root tree.getroot() files [] for node in root.iter(): if node.tag.endswith(File): raw node.get(Path) if raw: files.append(raw) return files这里有个细节值得注意root.iter()遍历所有子节点然后判断tag是否以File结尾而不是直接用root.iter(File)。原因很简单不同版本Vivado生成的.xpr可能带不同的XML命名空间标签名可能是{http://www.xilinx.com/...}File这种情况下iter(File)匹配不到而tag.endswith(File)写法对命名空间免疫实测兼容性最好。3.3 处理 .xpr 里的路径变量拿到文件路径字符串之后下一个坑就是路径格式。.xpr里常见的Path值有三种绝对路径D:/projects/led_flow/led_flow.srcs/sources_1/new/top.v相对路径led_flow.srcs/sources_1/new/top.v变量路径$PSRCDIR/sources_1/new/top.v$PSRCDIR通常指向工程根目录下的工程名.srcs目录不同Vivado版本的具体指向略有差异。我把路径解析逻辑单独抽出来兼容这三种情况def resolve_path(raw: str, base: Path): raw raw.strip().replace(\\, /) if raw.startswith($PSRCDIR/): srcs find_srcs_dir(base) if srcs: return (srcs / raw[len($PSRCDIR/):]).resolve() if raw.startswith($PSSRCDIR/): srcs find_srcs_dir(base) if srcs: return (srcs / raw[len($PSSRCDIR/):]).resolve() p Path(raw) if p.is_absolute(): return p return (base / p).resolve()查找.srcs目录的逻辑也简单遍历工程根目录下所有子目录找后缀为.srcs的就行def find_srcs_dir(base: Path): for d in base.iterdir(): if d.is_dir() and d.name.endswith(.srcs): return d return None3.4 RTL文件过滤规则按三个维度筛路径解析下来后还不能直接照单全收必须做过滤。我的过滤规则分三个维度每个维度解决一类问题。第一个维度是扩展名过滤。Verilog文件一般是.vSystemVerilog 是.sv和.svhVHDL 是.vhd和.vhdlVerilog头文件是.vh。把这些扩展名放进一个集合里匹配的时候统一转小写避免Windows上大小写不统一导致漏掉文件。第二个维度是目录关键词过滤。路径中包含/ip/、/bd/、/sim、/constrs/的从提取清单里剔除。这一条是过滤IP核生成代码的主力规则。第三个维度是文件名关键词过滤根据实际情况微调。有些IP核生成的wrapper文件叫xxx_wrapper.v、xxx_bd_wrapper.v这类文件虽然不在ip目录下但确实是自动生成的如果你不希望把它们当作手写RTL交付可以加一个_wrapper关键词过滤。RTL_EXTS {.v, .sv, .svh, .vh, .vhd, .vhdl} SKIP_KEYWORDS [/ip/, /bd/, /sim, /constrs, /docs, /ipshared] def is_rtl_file(p: Path, keep_ip: bool False): ext p.suffix.lower() if ext not in RTL_EXTS: return False if keep_ip: return True norm p.as_posix().lower() for kw in SKIP_KEYWORDS: if kw in norm: return False return True这个keep_ip参数是有用的有些工程需要把IP核的生成代码一起交付比如做仿真复现这时候加--keep-ip参数就能保留这些文件工具不至于太死板。3.5 文件复制与重名处理过滤完成之后就是复制。如果所有RTL文件都平铺复制到同一个目录很容易出现同名覆盖问题比如data_path.v在new和imports各有一个平铺复制后前一个直接被后一个覆盖代码就悄悄丢了。我的处理办法是保留源文件相对于工程根目录的路径结构。复制目标路径等于输出目录加上相对路径的中间目录。这样top.v复制到sources_1/new/top.v同名文件由于原路径不同也不会冲突。对于工程目录之外的文件比如imports引用了共享盘上的代码无法计算相对的层级的我用文件名加短哈希的方式避免重名。import hashlib def build_dst_rel(src: Path, base: Path): try: rel src.relative_to(base) parts rel.parts if len(parts) 1 and parts[0].endswith(.srcs): parts parts[1:] return Path(*parts) except ValueError: h hashlib.sha1(str(src).encode()).hexdigest()[:8] return Path(f{h}_{src.name})复制动作本身用shutil.copy2会保留文件的修改时间和权限信息。复制前先mkdir(parentsTrue, exist_okTrue)创建目标目录。3.6 完整脚本与运行效果把上面的碎片拼成一个完整脚本大概百来行贴上可以直接用import sys import os import hashlib import shutil import argparse from pathlib import Path import xml.etree.ElementTree as ET RTL_EXTS {.v, .sv, .svh, .vh, .vhd, .vhdl} SKIP_KEYWORDS [/ip/, /bd/, /sim, /constrs, /docs, /ipshared] def parse_args(): parser argparse.ArgumentParser(description从Vivado工程中提取RTL代码) parser.add_argument(project, helpVivado工程文件(.xpr)或工程目录) parser.add_argument(output, help输出目录) parser.add_argument(--keep-ip, actionstore_true, help保留IP核目录下的文件) return parser.parse_args() def find_srcs_dir(base: Path): for d in base.iterdir(): if d.is_dir() and d.name.endswith(.srcs): return d return None def resolve_path(raw: str, base: Path): raw raw.strip().replace(\\, /) if raw.startswith($PSRCDIR/) or raw.startswith($PSSRCDIR/): prefix raw.split(/)[0] srcs find_srcs_dir(base) tail raw[len(prefix) 1:] if srcs: return (srcs / tail).resolve() p Path(raw) if p.is_absolute(): return p return (base / p).resolve() def collect_from_xpr(xpr: Path): try: tree ET.parse(xpr) except Exception as e: print(f[警告] 解析 .xpr 失败: {e}) return [] base xpr.parent files [] for node in tree.getroot().iter(): if node.tag.endswith(File): raw node.get(Path) if not raw: continue p resolve_path(raw, base) if p and p.exists(): files.append(p) return files def collect_from_src_dir(base: Path): srcs find_srcs_dir(base) if not srcs: return [] files [] for p in srcs.rglob(*): if p.is_file() and p.suffix.lower() in RTL_EXTS: files.append(p.resolve()) return files def is_rtl_file(p: Path, keep_ip: bool False): if p.suffix.lower() not in RTL_EXTS: return False if keep_ip: return True norm p.as_posix().lower() for kw in SKIP_KEYWORDS: if kw in norm: return False return True def build_dst_rel(src: Path, base: Path): try: rel src.relative_to(base) parts rel.parts if len(parts) 1 and parts[0].endswith(.srcs): parts parts[1:] return Path(*parts) except ValueError: h hashlib.sha1(str(src).encode()).hexdigest()[:8] return Path(f{h}_{src.name}) def main(): args parse_args() project_path Path(args.project) xpr_path project_path if project_path.is_file() else None base project_path if project_path.is_dir() else project_path.parent out_dir Path(args.output) out_dir.mkdir(parentsTrue, exist_okTrue) files [] if xpr_path: files collect_from_xpr(xpr_path) if files: print(f[信息] 从 .xpr 中解析到 {len(files)} 个文件) else: print([信息] .xpr 解析结果为空回退到目录扫描模式) files collect_from_src_dir(base) if not files: print([错误] 没有找到任何RTL文件请检查工程路径是否正确) sys.exit(1) exported skipped 0 for src in files: if not is_rtl_file(src, args.keep_ip): skipped 1 continue dst out_dir / build_dst_rel(src, base) dst.parent.mkdir(parentsTrue, exist_okTrue) shutil.copy2(src, dst) exported 1 print(f[导出] {src} - {dst}) print(f完成共导出 {exported} 个文件跳过 {skipped} 个非目标文件) if __name__ __main__: main()运行方式很简单打开终端进入脚本所在目录python extract_rtl.py D:/projects/led_flow/led_flow.xpr D:/exports/led_flow_rtl如果只传目录脚本会自动在目录下找.xpr文件python extract_rtl.py D:/projects/led_flow D:/exports/led_flow_rtl跑完后的输出大概长这样[信息] 从 .xpr 中解析到 32 个文件 [导出] D:/projects/led_flow/led_flow.srcs/sources_1/new/top.v - D:/exports/led_flow_rtl/sources_1/new/top.v [导出] D:/projects/led_flow/led_flow.srcs/sources_1/new/counter.v - D:/exports/led_flow_rtl/sources_1/new/counter.v ... 完成共导出 19 个文件跳过 13 个非目标文件同一个工程手工从.srcs目录挑文件可能要翻十几分钟脚本跑完不到一秒效率差距非常大。4. 让工具适配更多使用场景4.1 按IP核和手写RTL分类输出有些工程对交付内容要求更细希望IP核生成代码和手写RTL分开打包。默认的--keep-ip参数只能二选一其实可以把过滤逻辑改成多级分类输出目录下分成hand_rtl和ip_auto两个子目录。实现方式是在build_dst_rel之前加一层目录前缀判断。路径中包含/ip/或 IP核相关关键词的目标路径放在ip_auto下其余放在hand_rtl下。这个扩展很实用我见过不少团队交付代码时要求“手写代码和自动生成代码必须分开目录存放”Vivado工程本身不会帮你做这个区分脚本顺手就搞定了。4.2 用Tcl脚本在Vivado里导出文件清单遇到.xpr路径变量解析不出来、或者工程结构特别怪的情况自底向上的目录扫描和自顶向下的XML解析都可能失效。这时候最保险的办法是直接问Vivado要答案。在Vivado的Tcl Console里执行下面的脚本可以导出一份工程实际使用的RTL文件清单set files [get_files] set fp [open rtl_file_list.txt w] foreach f $files { set loc [get_property location $f] set ext [file extension $loc] if {$ext eq .v || $ext eq .sv || $ext eq .svh || $ext eq .vhd || $ext eq .vhdl || $ext eq .vh} { puts $fp $loc } } close $fp puts RTL file list exported.执行完之后会生成一个rtl_file_list.txt每一行是一个绝对路径。这个清单可以直接喂给前面的Python脚本做复制只要在脚本里加一个--filelist参数从文本文件读路径而不是解析.xpr就行。逻辑不复杂我把这部分留给有需要的朋友自己扩展核心代码就上面这几行。4.3 顺手做个代码行数统计和目录树提取出RTL代码之后很多时候还要统计代码量比如做工作量评估、代码审查报告。可以在同一份脚本上追加一个小函数遍历输出目录统计每个模块文件的行数def count_lines(directory: Path): total 0 detail [] for f in sorted(directory.rglob(*)): if f.is_file() and f.suffix.lower() in RTL_EXTS: lines sum(1 for _ in f.open(encodingutf-8, errorsignore)) total lines detail.append((f, lines)) return total, detail这个函数可以顺便生成一个rtl_summary.txt把每个文件的路径和行数写进去方便对照。我自己实际用的时候还会把输出做成一棵目录树结构配合Git提交记录一起归档整个交付包看起来就很完整。5. 常见问题速查与避坑技巧实录5.1 .xpr 解析不到任何文件脚本输出“解析结果为空”先别急着怀疑代码。第一步用文本编辑器直接打开.xpr文件搜索File标签看里面有没有Path属性。如果连Path属性都没有说明这个工程文件不是标准Vivado格式可能是别的工具生成或者导出时损坏了。如果Path存在但脚本读不到多半是XML命名空间问题把root.iter(File)改成root.iter()再配合tag.endswith(File)判断基本能解决。还有一种情况是.xpr里存的路径指向的文件已经被迁移走了比如从同事那里拷来的工程.xpr里记的是同事机器上的绝对路径。这时候脚本解析不出能用的文件列表回退到目录扫描模式下通常能找到实际存在的文件。5.2 提取结果里混入了IP核生成代码默认的SKIP_KEYWORDS规则能过滤大多数IP核文件但总有漏网之鱼。有次我提取一个工程发现clk_wiz_0_stub.v还是被复制出来了原因是它放在sources_1/imports目录下路径里没有/ip/关键词。解决方法很简单把这个文件名里的_stub.v或者IP核特有后缀加进过滤关键词列表里。另外需要注意过滤关键词的粒度。/sim这个关键词会把simulation、sim_1、sim_tb全都匹配掉如果某个手写文件名里恰好包含sim就可能被误杀。建议关键词带斜杠比如/sim/匹配路径片段而不是文件名片段误杀率会低很多。5.3 复制后文件重名或目录层级不对如果输出目录下出现a1b2c3d4_counter.v这种带哈希前缀的文件名说明原文件在工程目录之外无法保留完整相对路径。这种情况不算bug但需要知道原因。工程外文件通常是通过imports引用的共享源码或者是多人协作时从公共库拖进来的模块。如果你希望这类文件的目录层级更直观可以在build_dst_rel里改成“以文件所属顶层模块名建文件夹”的方式比如从文件内容里解析module名称再按模块名归档。不过坦白说哈希前缀方案是最稳妥的虽然不好看但至少不会丢文件。工程外文件的路径本来就不可控强行按目录归档反而可能冲突。5.4 工程文件被移动后路径失效Vivado工程整体移动之后.xpr里记录的绝对路径大概率会失效。我遇到过好多次从别人那里拿到工程包直接跑脚本提取结果导出的文件列表是空的或者全是“文件不存在”的警告。遇到这个情况先优先试试回退到目录扫描模式。.srcs目录内部的相对结构在工程移动后一般不会变扫描模式不受.xpr路径记录影响提取成功率很高。如果.srcs目录本身都没了那就只能重新打开Vivado工程另存一份或者找对方要原始工程包了。5.5 几个亲测有效的使用习惯最后分享几个我在实际使用中沉淀下来的习惯不一定对每个人都适用但至少帮我少踩了不少坑。第一提取脚本放进工程仓库的tools目录和工程代码一起版本管理。这样任何成员clone完仓库都能用同一份脚本提取代码不会出现“我机器上的脚本和你机器上的不一样”这种扯皮事。第二每次改动RTL代码后先跑一遍提取脚本再把提取结果提交到Git。我实际用下来这个流程比直接提交.srcs目录干净得多Git仓库体积小代码Review也直观。第三Windows环境跑脚本注意路径分隔符。脚本里统一用replace(\\, /)做了归一化但如果你要自己扩展最好别依赖硬编码分隔符多用pathlib提供的 API。这个工具我用了快两年从最开始只有解析.xpr的小脚本慢慢发展成带目录降级、哈希防重名、IP核过滤的完整程序。每次对外交付源码包之前跑一遍已经成了肌肉记忆。如果你还在手工翻目录整理RTL代码真心建议花半小时把脚本跑起来。第一次可能觉得多此一举等你经历过一次交付漏文件的尴尬就知道自动化永远比记忆力可靠。