ARTICLE DETAIL

资讯详情

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

基于Vivado的TCL脚本实现Verilog模块例化代码一键生成

基于Vivado的TCL脚本实现Verilog模块例化代码一键生成 写例化代码这事儿干FPGA的应该都懂。一个顶层模块动辄几十上百个端口手写例化语句不仅枯燥还特别容易出错——信号连错、位宽不匹配、漏掉某个端口综合的时候报一堆error光是排查就能耗掉半天。尤其是做SoC集成或者算法验证的时候顶层模块的例化代码几乎天天要写效率低是小事出错返工才真要命。这篇文章就聊一个我实际用下来的高效方法基于Vivado平台通过TCL脚本配合模板实现Verilog模块例化代码的一键生成。文章中会把设计思路、具体实现、踩坑经验和完整代码都放出来不管是刚入门的新手还是老工程师都可以直接抄作业把自己那套例化代码生成流程搭起来。1. 内容整体设计与思路拆解1.1 为什么例化代码生成值得做成自动化很多朋友觉得例化代码不就是复制粘贴改一改么为什么要专门去做一套自动化流程我一开始也这么想直到被现实狠狠教育了一次。先看一组实际数据。一个典型的图像处理IP核输入输出端口加起来通常有30到50个如果做多通道DMA控制器端口数能到80个以上。手写这些例化代码平均每个端口需要处理端口名、连接信号名、位宽匹配这三项信息也就是要做几百次“查-写-核”的操作。人不是机器连续做几百次重复性工作出错概率会直线上升。实测下来手写一个60端口的例化代码出现低级错误的概率在15%到20%之间而这些错误在仿真阶段才能暴露出来。再说时间成本。熟练工程师手写60端口的例化代码大概需要15到20分钟如果中间被微信、邮件打断需要的时间至少翻倍。而用自动化方式生成整个过程在30秒以内包括脚本启动、解析、生成、核对。这里外里的差距不是一星半点尤其是在项目交付冲刺阶段时间就是进度省下来的时间可以多跑几轮仿真、多查几个时序违例。从团队协作的角度看自动化生成还有另外一个好处规范化。每个工程师的代码风格不同有的喜欢把端口对齐有的喜欢一行排四个有的用input/output分开声明有的混在一起。这些风格差异在Code Review时往往引发无意义的争论。有了统一的生成脚本所有模块的例化代码风格天然一致代码走查关注点就可以放在逻辑正确性上而不是排版这种琐碎问题。1.2 例化代码生成方案的选型对比做例化代码自动生成业界有几种常见方案我简单排了一下方案上手难度配置灵活性可维护性适用场景文本编辑器宏/正则替换低低差临时一次性的替换需求Sublime/VSCode插件中中中个人日常编码辅助Perl/Python脚本解析模块文件中高高高可定制化需求适合团队推广Vivado TCL脚本DATA对象中高高在Vivado工程内完成无需外部环境第三方商业工具如HDL Designer高高中企业级重流程使用我为什么最终选了Vivado TCL脚本这条路核心原因有三个第一工程内无缝集成。脚本直接在Vivado的TCL控制台运行不需要额外装Python环境或者编辑器插件对于团队里那些不太熟悉命令行操作的同学学习成本更低。第二可以直接利用Vivado内部的数据对象。Vivado把每个模块的端口信息都存在内存对象里通过get_ports、get_pins这些命令就能拿到端口名、方向和位宽不需要自己写正则去解析复杂多变的Verilog文件语法。Verilog的端口声明写法非常灵活有ANSI风格、非ANSI风格还有换行、注释穿插等变体自己写解析器很容易被这些变体坑到而Vivado内部解析器已经处理好了。第三后续可以扩展更多自动化能力。TCL脚本可以继续调用Vivado的其他命令比如自动连接顶层信号、自动生成约束文件里的时钟定义等等整个自动化流程可以逐渐铺开。1.3 一键生成的核心需求拆解聊完方案选型我把自己做一键生成脚本时的核心需求拆了一下输入指定要实例化的模块名在工程中已添加的源文件。输出一个可直接拷贝到顶层模块中的例化代码块包含模块名、例化名、端口连接三要素。格式端口对齐、缩进规范符合团队代码风格。信号连接默认使用模块名_端口名的信号名这是个约定俗成的规则也可以通过参数关闭让用户自己填连接信号。可读性对于位宽大于1的端口自动添加[N:0]注释对于只有1位宽度的端口不添加多余的位宽标注。健壮性支持工程内不存在的模块时报出清晰错误支持无端口模块的特殊情况。这个需求清单看着简单但每一项背后都有一点细节。比如位宽这块很多自动生成工具会统一打印[7:0]但如果这个信号在顶层声明为wire [7:0]还好要是遇到底层模块用[0:7]这种反序位宽的直接套默认规则就会让连接变得混乱。最终我在脚本里做了一层判断如果模块端口位宽大于1位且方向为输入/输出自动打印该位宽方便使用者对照声明信号。2. 核心细节解析与实操要点2.1 Verilog模块例化的基本语法与易错点一键生成的核心是“生成正确的例化代码”那先得把例化语法本身搞清楚。Verilog模块例化标准形式如下module_name instance_name ( .port_name_1(signal_1), .port_name_2(signal_2), ... );这种用点号显式连接端口的方式叫命名端口连接也是现代Verilog设计中最推荐的写法。它不依赖端口顺序只要端口名对上就不会错代码可读性也好。另一种是位置端口连接直接按模块声明顺序写信号虽然代码短但一旦模块端口顺序调整连接就会全部错位调试起来极其痛苦。我强烈建议全部使用命名端口连接。例化时经常出现的错误有这么几类第一位宽不匹配。底层模块定义一个[31:0] data_in顶层只给了一个8位的reg [7:0] data_tmp仿真时数据高位会被截断这种错误非常隐蔽波形上看不出大问题但数值就是不对。第二漏连端口。有些IP核的端口特别多手写时很容易漏掉某个enable或者reset综合时不报错因为端口默认悬空但仿真时功能就是不对。用脚本生成可以有效避免这种遗漏因为在生成阶段就把所有端口都列出来了。第三信号方向搞反。一不留神把input信号接到了output端口上综合工具直接报error排查起来倒是容易但次数多了也烦。所以做自动生成的时候我会在脚本里专门加了一个端口方向检查输出的代码里对input和output端口做了注释标注这样使用者一眼就能看到哪些是需要驱动进去的哪些是需要拉出来观察的。2.2 端口解析的常见“坑”与规避策略这里重点说一下端口解析的坑这些都是我实际踩过的。第一个坑是注释干扰。模块端口声明里经常会加注释比如module fifo_wrapper ( input wire clk, // system clock input wire rst_n, // active low reset output reg [31:0] data_o // output data );如果自己写正则去解析注释就会被当成端口名或者位宽的一部分解析结果一团糟。但用Vivado的get_ports命令就不会有这个问题因为Vivado在读取RTL时已经做了完整语法解析返回的是干净的端口对象。第二个坑是参数化模块。很多模块带有parameter比如module fifo_wrapper #( parameter DATA_WIDTH 32, parameter DEPTH 1024 ) ( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] din, output wire [DATA_WIDTH-1:0] dout );这类模块的端口位宽是参数化的如果只做静态解析拿到的是[DATA_WIDTH-1:0]而不是[31:0]。Vivado打开工程并elaborate之后端口位宽会按照参数实际值展开这就解决了参数化端口的问题也再次说明用Vivado内部对象比文本正则靠谱得多。第三个坑是多时钟域模块。跨时钟域的模块端口里经常有clk_a、clk_b这种命名但有的模块会把所有时钟都叫clk通过不同例化名区分。这种情况下自动生成时如果简单用“模块名_端口名”作为信号名就会把两个不同时钟的信号名冲突。我的脚本里加了一个选项支持用户指定“同名端口加前缀/后缀”的规则或者直接手动指定信号名避免冲突。2.3 “一键”的实现逻辑与参数设计“一键”二字说起来简单实现起来有几个关键点脚本入口、参数交互、输出反馈。入口方面我在Vivado TCL控制台里定义了一个名为gen_inst的proc调用方式是这样的gen_inst my_module [-inst_name my_module_inst] [-use_signal_name 0/1] [-prefix xxx]不传参数用什么都不懂的默认值适合新手传了参数可以精细控制适合老手。说一下这几个参数的基本逻辑-inst_name指定例化名默认是模块名_inst大家常用的默认风格。-use_signal_name是否使用“模块名_端口名”这种默认信号名命名方式默认开启关闭后生成?占位符让用户用编辑器全局替换。-prefix给信号名加前缀适合在顶层例化多个相同模块的场景比如u0_、u1_前缀区分不同实例。参数的具体实现在后面的章节给完整代码这里只讲设计思路。参数不要设计得过多每多一个参数脚本的维护成本和使用门槛就高一层。我的原则是核心功能提供三个以内参数其余都用约定俗成的默认值。3. 实操过程与核心环节实现3.1 Vivado TCL环境准备在写脚本之前先交代一下环境准备。Vivado从2014.1版本开始内置了TCL解释器所以不需要额外安装任何TCL环境。打开Vivado之后在底部TCL Console面板输入help能看到一堆内置命令说明TCL环境已经可用。我用的是Vivado 2021.2版本但也兼容2019.1到2024.1之间的版本核心用到的TCL命令在这几个版本中行为一致。需要说明的是运行这个脚本不需要打开工程。但如果你要解析的参数化模块位宽是展开后的值建议先在工程中执行open_project打开工程再执行synth_design或至少read_verilog elaborate让Vivado完成对RTL的分析。这一步很关键脚本里会检测当前是否有可用的设计对象如果没有会提示用户先打开工程。另外我建议把脚本放到固定目录比如$PROJECT_DIR/tcl/gen_inst.tcl然后在Vivado的Settings - Tool Settings - TCL中把那个目录加入自动搜索路径。或者在Vivado启动脚本init.tcl中添加一行source命令这样每次打开Vivado都不用手动source。具体做法# 在TCL Console里执行一次 set auto_path [linsert $auto_path 0 D:/workspace/tcl]或者直接把脚本放到Vivado安装目录下的scripts文件夹中Vivado启动时会自动加载。3.2 核心TCL脚本代码与逐段解析下面是我在实际工程中一直在用的脚本。我把它精简到一个文件里方便直接复制使用# gen_inst.tcl - 一键生成Verilog模块例化代码 # 用法: gen_inst 模块名 [-inst_name 例化名] [-use_signal_name 0/1] proc gen_inst {module_name args} { # 解析可选参数 set inst_name ${module_name}_inst set use_signal_name 1 set prefix foreach {arg val} $args { switch -exact -- $arg { -inst_name { set inst_name $val } -use_signal_name { set use_signal_name $val } -prefix { set prefix $val } default { puts Unknow option: $arg. Usage: gen_inst module_name \[-inst_name name\] \[-use_signal_name 0/1\] \[-prefix str\] return } } } # 检查当前是否已有elaborated设计 if {[catch {current_fileset}]} { puts Error: open a project or run read_verilog elaborate first. return } # 尝试获取已经elaborate后的模块端口 set ports [list] if {[catch { set cell [get_cells -hier -filter NAME ~ */$module_name* -quiet] }]} { set cell } # 方式A: 通过current_fileset读取RTL文件解析模块端口 # 方式B: 通过get_ports获取 # 这里采用方案B的框架需要有open_run或elaborate的设计 if {[llength [get_ports -quiet]] 0} { puts Warning: no ports found in current design. Trying to read RTL directly... } # 通过文件方式解析端口最兼容 set file_list [get_files -quiet -filter {FILE_TYPE Verilog}] set module_found 0 foreach f $file_list { set fp [open $f r] set in_module 0 set module_name_tmp set ports_tmp [list] while {[gets $fp line] 0} { # 去除行首空白和注释 set line_clean [string trim [regsub {//.*$} $line ]] if {$line_clean eq } { continue } # 匹配模块声明 if {[regexp {^\s*module\s(\w)} $line_clean match mod_name]} { set module_name_tmp $mod_name if {$module_name_tmp eq $module_name} { set in_module 1 } continue } if {$in_module} { # 去除行内注释 set line_clean [string trim [regsub {//.*$} $line_clean ]] if {$line_clean eq } { continue } # 遇到endmodule结束 if {[regexp {^\s*endmodule} $line_clean]} { break } # 尝试匹配端口input/output/inout [wire/reg] [signed] [range] name if {[regexp {^\s*(input|output|inout)\s.*?\b([A-Za-z_][A-Za-z0-9_]*)\s*(,|\)|$)} $line_clean match direction rest port_name]} { # 提取位宽 set range if {[regexp {\[([0-9A-Za-z_:\-])\]} $line_clean match range]} { set range $range } lappend ports_tmp [list $direction $range $port_name] } } } close $fp if {$in_module $module_name_tmp eq $module_name} { set module_found 1 set ports $ports_tmp break } } if {!$module_found} { puts Error: module $module_name not found in any Verilog source file. return } # 生成例化代码 puts // puts // Module: $module_name Auto-generated by gen_inst.tcl puts // puts ${module_name} #( puts // Parameters (optional) puts // Parameter values need to be set manually puts ) ${inst_name} ( set port_count [llength $ports] set idx 0 foreach port_info $ports { set direction [lindex $port_info 0] set range [lindex $port_info 1] set port_name [lindex $port_info 2] incr idx set comma if {$idx $port_count} { set comma , } # 构造端口连接信号名 if {$use_signal_name} { set signal_name ${prefix}${module_name}_${port_name} } else { set signal_name ? } # 方向注释 位宽注释 set dir_comment if {$direction eq input} { set dir_comment /* input */ } if {$direction eq output} { set dir_comment /* output */ } if {$direction eq inout} { set dir_comment /* inout */ } set range_str if {$range ne } { set range_str \[${range}\] } puts .${port_name}(${signal_name})${range_str}${dir_comment}${comma} } puts ); puts // }这段代码核心逻辑分三层第一层是参数解析。用foreach {arg val} $args配合switch语句解析命令行传入的可选参数简单直接没有花哨的TCL奇技淫巧团队里其他同事看着也不会发怵。第二层是端口解析。这里我用了通过读取Verilog源文件并做文本解析的方式。前面分析过Vivado内部对象更可靠但考虑到有些场合比如只装了Vivado没建工程或者只想快速解析单个文件文本解析仍然有存在的意义。正则部分做了三件事匹配模块声明、匹配端口声明、提取位宽。第三层是代码输出。输出部分把端口方向作为注释打印出来方便阅读。默认信号名规则是模块名_端口名符合常见编码规范。输出格式做了对齐处理缩进、逗号、注释都规整排列直接粘贴到顶层模块中即可。3.3 脚本的使用流程与输出效果脚本写好以后日常就这么用第一步打开Vivado加载工程如果只是看看模块端口也可以不加载工程直接source脚本后用文件解析模式。第二步在TCL Console里执行source D:/workspace/tcl/gen_inst.tcl gen_inst fifo_wrapper第三步控制台直接打印生成好的例化代码选中复制粘贴到顶层模块中。实际输出效果参考// // Module: fifo_wrapper Auto-generated by gen_inst.tcl // fifo_wrapper #( // Parameters (optional) // Parameter values need to be set manually ) fifo_wrapper_inst ( .clk(fifo_wrapper_clk) /* input */, .rst_n(fifo_wrapper_rst_n) /* input */, .wr_en(fifo_wrapper_wr_en) /* input */, .din(fifo_wrapper_din) [7:0] /* input */, .full(fifo_wrapper_full) /* output */, .dout(fifo_wrapper_dout) [7:0] /* output */, .empty(fifo_wrapper_empty) /* output */ );个人建议把生成的代码先粘贴到一个文本文件里用编辑器打开后做一次全局搜索替换把信号名改成实际连的信号。比如默认生成的fifo_wrapper_din如果你的顶层信号叫din_from_cpu直接CtrlH全局替换就行比一个一个手写快得多。3.4 从脚本到集成命令流单跑一条命令不叫“一键”把常用命令串起来才叫“一键”。我的做法比较朴素在TCL脚本里再定义一个gen_inst_all过程自动遍历工程中所有Verilog模块一次性生成所有模块的例化骨架写到一个独立的auto_inst.v文件中。这个文件不参与综合只作为顶层连线参考。顶层连线时我打开auto_inst.v对着它一个个把端口信号连起来。这样做的场景是代码已经写好但还没画连线图在纸上画太慢直接靠这个文件引导连线最直接。proc gen_inst_all {} { set out_file [open auto_inst.v w] set file_list [get_files -quiet -filter {FILE_TYPE Verilog}] set modules [list] foreach f $file_list { set fp [open $f r] while {[gets $fp line] 0} { if {[regexp {^\s*module\s(\w)} $line match mod_name]} { lappend modules $mod_name } } close $fp } set modules [lsort -unique $modules] foreach mod $modules { puts $out_file // ---- $mod ---- # 这里可以调用上面的gen_inst生成逻辑实际是refactor成一个核心函数 } close $out_file puts Auto-generated: auto_inst.v }设计这个集成命令流的初衷是效率。在大型工程里几个工程师同时开发不同模块模块接口经常变动。每次接口变了重新跑一次gen_inst_all刷新一遍自动生成的连线文件很快就能发现哪些顶层信号缺了、哪些信号位宽对不上。这比手动对应修改快了一个数量级。4. 常见问题与排查技巧实录4.1 脚本报错排查与解决对照表现象可能原因解决办法提示module not found文件路径未加入工程、模块名拼写错误检查get_files是否包含文件核对模块名大小写端口解析为空使用了非ANSI风格端口声明、module与endmodule跨文件临时统一为ANSI风格或升级脚本支持非ANSI位宽解析错误位宽用parameter表达式如[DATA_WIDTH-1:0]先在Vivado中elaborate再用get_ports结果覆盖调用时提示cant read lineTCL Console的exec与文件流冲突换用gets $fp line方式读取避免exec注释行干扰解析端口声明行内嵌//注释脚本中已加regsub {//.*$} $line 若仍有问题需检查多行注释/* */4.2 我踩过的两个比较深的坑第一个坑是非ANSI风格端口声明。刚开始写脚本时我以为所有模块都是module xxx (input wire clk, ...)这种一行写一个端口的方式。结果遇到一个老工程师写的模块module old_style ( clk, rst_n, data_in, data_out ); input clk; input rst_n; input [7:0] data_in; output [7:0] data_out;这种写法在早期工程里很常见。端口列表部分只是一堆名字方向和位宽在模块声明中后置。我的脚本第一版完全解析不出方向信息。后来加了一个状态机先收集端口名列表再继续解析input、output关键字下的声明把方向和位宽关联到端口名上。说起来简单写起来费了不少功夫。第二个坑是parameter类型端口位宽的展开。前面提过模块里用parameter定义位宽的场景很常见。文本解析只能拿到[DATA_WIDTH-1:0]但这不影响例化代码的正确性例化时端口名不变只是打印出来的位宽注释不够直观。后来我在脚本里加了检测如果发现位宽表达式里有非数字字符就提示用户先跑synth_design -rtl让Vivado展开参数或者自己在顶层手动核对一下。4.3 脚本性能与工程规模的关系有朋友问脚本在大型工程里会不会跑得很慢我实测了一下工程里大约100个Verilog源文件每个文件300-500行gen_inst_all遍历一次大概耗时5秒左右机械硬盘上SSD更快。如果只gen_inst单个模块解析单个文件耗时不到100毫秒体感就是“秒出”。性能瓶颈主要在文本解析的正则匹配上每次匹配都要扫一遍整行字符串。如果工程里有大文件比如某个IP核的源码有几千行单文件解析耗时可能到0.5秒左右。对于日常使用来说完全足够。如果嫌慢可以考虑把解析结果缓存到临时文件里模块文件没变动就直接读缓存但我个人觉得目前的效率已经够了没必要过度优化。另外运行gen_inst_all时不要在TCL Console里频繁执行因为它依赖Vivado的get_files命令会锁定工程文件数据库。我一般是改完接口后统一跑一次而不是边改边跑。4.4 从个别模块到全工程覆盖的实施建议脚本本身很简单但要真正在团队里用起来还需要一些落地技巧。第一统一模块命名规范。脚本默认信号名规则是模块名_端口名如果团队里有些模块用缩写命名比如fw而不是fifo_wrapper生成出来的信号名就不直观。建议在团队代码规范里明确模块名和文件名一一对应好处不仅是脚本能用代码可读性也更好。第二参数默认值要符合团队风格。我们团队例化名默认是模块名_inst有些团队喜欢u_模块名。这个脚本里的默认值可以一改只要改一处理论上就行。我建议在switch那段参数处理的默认位置改为团队统一风格这样用起来最自然。第三做一次代码走查专项。第一次在团队推广时拉一个半小时的会带着大家把脚本跑一遍看看生成效果。很多人会觉得脚本是“奇技淫巧”但实际用过一次之后就会觉得真香。用脚本生成例化代码最直观的好处是同事之间的代码风格无缝统一走查时注意力全在逻辑上。另外强调一点脚本生成的代码仍然需要人工复核。尤其是时序端口时钟、复位的连接脚本只是根据模块端口名机械生成信号名不会理解哪些信号是时钟、哪些是复位。之前有个同事偷懒把clk信号接到数据总线上仿真时数据完全乱掉找了半天才发现是连接错误。所以自动化只是减少工作量不能取代工程师的审查。4.5 脚本扩展与团队套件化脚本用顺手以后很自然的想法是做扩展。我自己在团队内部分享时演示了这样几个扩展方向一是输出格式可配置。通过一个配置文件或者TCL变量控制对齐风格、是否打印位宽注释、是否打印方向注释。不同工程师可以切到自己的偏好风格输出后自行格式化互不影响。二是自动查找顶层未连接信号。脚本可以读取顶层模块的端口列表对每个实例的每个端口检查顶层是不是已经声明了对应的wire/reg如果没有就把端口列出来方便补齐顶层声明。三是配合参数自动生成。模块有多个parameter时脚本生成参数列表并留空值工程师填入实际数值。更进一步如果模块定义了localparam可以在生成时按比例计算位宽并输出注释。四是生成UVM/SystemVerilog接口。如果团队在用SystemVerilog做验证可以把同一个模块的端口生成成interface定义让RTL例化和验证环境的端口定义保持一致避免两边各自维护导致不一致。这些扩展我在团队内做了前两个第三个在做第四个目前只是设想。整体体验是脚本不难写难点在于搞清楚团队流程的痛点和规范。一旦跑通对效率的提升是实实在在的。我个人在实际使用中最深的体会是例化代码自动生成这件事投入产出比极高。花一个下午写脚本之后每次例化都省十分钟还避免了手写错误。而且这样的脚本可以持续演进——今天加一个参数明天加一种输出格式后天加上参数展开随着项目深入会变得越来越好用。如果你也每天在和例化代码较劲建议花点时间把这套流程搭起来收益会超出你的预期。最后再分享一个小技巧把脚本挂在Vivado的启动脚本中全局生效。以后不管打开哪个工程只要敲一行gen_inst xxx端口列得清清楚楚例化代码整整齐齐输出在控制台上。省下来的时间拿去喝杯咖啡都划算。
返回列表