刚入行那会儿,我一直有个疑问:RTL代码写完,仿真波形也跑通了,为什么不能直接拿去流片?后来在项目里被现实教育了几次才明白,从行为级描述到真正能画版图的晶体管网络之间,横着一条必须靠逻辑综合来填平的沟。逻辑综合这个词听起来挺学术,说白了就是让工具替你把"人话"翻译成"电路话"——你写的是a & b | c,工具给你吐出来的是一堆具体工艺库里真实存在的与非门、或非门、触发器,还要保证时序、面积、功耗三方面都能交差。这篇文章我打算把逻辑综合里最常被含糊带过的那些概念拆开讲清楚:它到底在流程里站什么位置、内部做了哪几件事、约束文件为什么长成那个样子、脚本怎么写、报告怎么读、以及最容易翻车的地方在哪。不管你是刚学完Verilog想上手工具的学生,还是从FPGA转ASIC、第一次看到compile_ultra一脸懵的工程师,把这篇看完,至少能少走两三个月的弯路。
1. 逻辑综合到底在做什么——从RTL到门级网表的这场翻译
1.1 综合在数字设计流程里站的位置
一个完整的数字芯片设计流程,从需求到GDSII,大致会走这么一条线:架构设计、RTL编码、功能仿真、逻辑综合、形式验证、DFT插入、布局布线、时序签核、物理验证、流片。综合卡在RTL和物理实现之间,是整条链路上第一个"不可逆"的关口——RTL阶段你想怎么改都行,综合之后交付的是门级网表,后面所有环节都建立在它之上。
这个位置的特殊性在于:它既要有"全局视野",又要做"局部决策"。所谓全局视野,是指工具得同时看时序、面积、功耗、可测性、时钟结构;所谓局部决策,是指每一条路径上到底用几级逻辑、用哪个驱动强度的单元、要不要做buffer,都得在毫秒级内定下来。一次编译动辄处理几十万甚至上百万门,这个规模下人是不可能手工优化的,所以综合本质上是一场"用工具替代人工经验"的成规模决策。
我个人习惯把综合理解成"带着约束去猜物理实现会怎么收拾你"。因为在综合阶段,布局布线还没做,真实的走线长度是未知的。工具只能用线负载模型(Wire Load Model)或者物理感知综合(Physical Synthesis)来估算互连延迟。估算准不准,直接决定了综合出来的网表在后端会不会"崩"。这就是为什么很多团队会把综合和布局布线放在同一个工具链里做,或者干脆做top-level的物理综合——猜得越准,返工越少。
1.2 三大输入、一大输出,记住这个骨架
不管用哪个工具,逻辑综合的输入永远是三样东西,输出永远是一样东西,这个骨架记住了,看任何脚本都不慌。
输入一:RTL代码。可以是Verilog、SystemVerilog或者VHDL,通常是可综合子集的代码。注意"可综合"三个字很关键——initial块里写延时、用#10、动态数组、实型运算,这些仿真能过但综合工具会直接报错或者静默忽略。
输入二:工艺库。也就是标准单元库,一般以.lib(文本)和.db(二进制)两种形式存在,里面记录了每个单元的功能、面积、引脚电容、各工艺角下的延迟表。没有库,工具甚至不知道"与门"长什么样。
输入三:约束文件。行业标准是SDC(Synopsys Design Constraints),本质是一堆Tcl命令,告诉工具时钟周期是多少、输入输出延迟多大、哪些路径不用管。约束写错比代码写错更可怕,因为代码错了仿真能抓到,约束错了工具还会"尽职尽责"地给你优化出一个错的电路。
输出:门级网表,通常是.v格式的Verilog结构网表,加上配套的.sdf延时文件和一堆时序、面积、功耗报告。
提示:这三样输入里,RTL和库是"客观事实",约束是"主观意图"。项目里出问题,八成是主观意图表达得不对,而不是客观事实有问题。
1.3 为什么仿真器替代不了综合
经常有新手问:既然仿真能把功能验证通过,为什么还要综合一遍?因为仿真做的是功能层面的等价性检查,它完全不管这条路径能不能在一个时钟周期内算完,也不管你写了a*b工具要给你塞一个乘法器还是几百个加法器。仿真器眼里只有0和1,没有"延迟",没有"面积",没有"功耗"。
而综合要回答的恰恰是仿真不回答的问题:这个设计跑100MHz够不够?面积多少平方微米?动态功耗多少毫瓦?扫描链能不能插进去?这些问题在RTL层面根本没有答案,必须映射到具体的工艺库上才能算出来。所以综合是一个"从抽象走向具体"的过程,它的产物是后续所有环节的物理依据。
2. 综合内部发生了什么——翻译、优化、映射三步走
2.1 翻译阶段:把RTL拍扁成通用逻辑
综合的第一步叫翻译(Translation),也有的工具叫Elaborate。这一步做的事情是把你的RTL描述变成工具内部的通用逻辑表示,通常是一张布尔网络或者叫通用门级网表(GTECH)。在这个阶段,工具还没开始考虑工艺,也不知道你用的是28nm还是7nm,它就是单纯地把always @(posedge clk)展开成触发器加组合逻辑,把case语句展开成多路选择器的雏形。
这一步里最值得说的是参数化和generate的展开。你写的parameter WIDTH = 8,到了这一步会变成实实在在的8位宽逻辑。这也是为什么综合日志里经常出现"Elaborating module xxx"的提示——工具正在把层次化的、带参数的代码展开成扁平的结构。层次保留策略(set_ungroup、set_boundary_optimization)就是在这里生效的,层次保留得好,后面调试时序问题时能一眼看出是哪个模块拖后腿。
翻译阶段还有一个隐藏动作:推断(Inference)。工具会从代码风格里推断出你想要什么硬件。看到always @(posedge clk)就推断触发器;看到时钟门控风格的代码就推断集成时钟门控单元(ICG);看到case全覆盖但没写default就推断出组合逻辑加锁存器。推断这个东西是双刃剑,写得好的代码工具能推断出漂亮的硬件,写得含糊的代码工具就会给你塞一堆意料之外的东西。我见过最典型的是敏感列表写always @(a)却在里面用了b,仿真时波形看着没问题,综合出来直接多一个latch,后面调了半天才发现。
2.2 逻辑优化:布尔化简与资源共享
翻译完了,工具手里是一坨功能正确但极其臃肿的通用逻辑。接下来是优化(Optimization),这一步是综合工具的"看家本领"所在,也是各家工具拉开差距的地方。
优化的第一层是布尔化简:利用卡诺图、奎因-麦克拉斯基算法之类的数学手段,把冗余的逻辑项合并掉。比如你写了y = a&b | a&b&c,从逻辑上第二项完全被第一项覆盖,工具会直接删掉。这一层是纯数学的,跟工艺无关,各家工具做出来的差异不大。
第二层是资源共享与结构优化。这是真正体现代价的地方。假设你写了两个加法器分别在两个时钟周期用,工具可以考虑让它们共用一个加法器加多路选择器,代价是要多加控制逻辑。类似的还有公因子提取、公共子表达式消除、算符重排。这些东西工具默认会做,但做的激进程度需要你来控制——set_resource_allocation、set_structure这一类命令就是干这个的。
第三层是逻辑重构,也叫技术无关优化。工具会尝试改变逻辑的实现结构,比如把一棵深逻辑树改成平衡树以降低延迟,把宽扇入的与门拆成多级。这一步的目标是让电路在时序和面积上更接近"理想形态",为后面的映射做准备。
实操心得:逻辑优化阶段,工具的默认策略永远偏向"多下功夫换性能",代价是编译时间。第一次跑综合建议先用默认设置摸清楚设计的时序基线,再去调优化力度,别一开始就把
compile_ultra的所有选项拉满。
2.3 工艺映射:落到具体的标准单元
优化完的通用逻辑,最后还是得变成具体工艺库里真实存在的单元,这一步叫工艺映射(Technology Mapping)。工具会拿着库文件,为每一个逻辑功能挑选合适的单元组合。挑的过程很讲究:同一个逻辑功能可能有十几种实现方式,用一个三输入与门实现,还是用两个二输入与门串起来实现?驱动能力选X1还是X4?要不要插buffer?
这里的核心概念是单元驱动强度和负载匹配。标准单元库里同名逻辑功能往往有多个版本,驱动能力从X1到X16甚至更大。驱动弱了带不动后级负载,延迟大;驱动强了输入电容大,前一级又要费劲,同时面积和功耗都上去了。工具会基于负载估算做一个平衡,但估算的准确性完全取决于线负载模型或者物理信息。
映射阶段的另一个重要动作是时钟树综合前的准备:工具会识别所有触发器的时钟端,把它们连到同一个时钟网络上,为后面的时钟树综合做准备。如果你在约束里写了时钟不确定性(uncertainty),工具会在这一阶段就把这部分余量扣掉。
2.4 时序驱动与面积驱动,选哪个
工具跑综合时有个全局策略的选择,粗暴点说就是"直径优先"还是"速度优先"。默认情况下,只要你的约束里有时钟定义,工具走的就是时序驱动综合(Timing-Driven Synthesis),目标是在满足时序的前提下尽量省面积。如果约束里压根没有时钟,工具就退化成面积驱动的,它会疯狂复用逻辑、压缩单元数量,跑出来的电路大概率时序惨不忍睹。
这里有个新手特别容易踩的坑:只在脚本里link了库,却没source约束就编译。工具不报错,反而跑得飞快,报告里面积还特别漂亮。等你拿着网表去跑形式验证或者后端布局,才发现所有路径都没约束,全是"未约束路径",工具根本不知道要优化什么。所以每次跑完综合,第一件事应该是report_clock和report_timing,确认约束确实生效了。
3. 综合的输入文件体系——库、RTL、约束三件套
3.1 标准单元库:.lib与.db的关系
.lib是文本格式的库描述,人能读;.db是Synopsys工具用的二进制编译格式,工具读得快。两者内容等价,.db由.lib用lc_shell或者read_lib+write_lib编译而来。工程里一般两个都留,.lib用于查阅和版本管理,.db用于实际读入。
一个完整的库文件里,跟综合强相关的字段主要有这些:
- cell:单元定义,包含area、pin、function。
- pin:每个引脚的方向、电容、驱动能力。
- timing:延时表,按输入转换时间(slew)和输出负载(load)二维查表,也就是NLDM模型。
- power:功耗信息,有些库放在单独的文件里。
- operating_conditions:工作条件,定义了电压和温度。
工艺角(Corner)是绕不开的话题。同一个库会提供多个角:SS(慢管、低电压、高温度)用于建立时间检查,FF(快管、高电压、低温度)用于保持时间检查,TT是典型角。综合阶段通常读SS角做时序优化,但保持时间一般不在这时候修,因为时钟树还没建,修了也白修。这一点我在第6节会再展开。
3.2 SDC约束体系:时钟、IO、例外
SDC这套东西看着命令多,其实逻辑很清晰,分成四类:
- 时钟定义:
create_clock、create_generated_clock。这是所有时序分析的起点,时钟没定义,后面全是空谈。 - 时钟特性:
set_clock_uncertainty、set_clock_latency、set_clock_transition。描述时钟本身有多"脏"。 - 边界条件:
set_input_delay、set_output_delay、set_driving_cell、set_load。描述芯片外部世界对内部的影响。 - 时序例外:
set_false_path、set_multicycle_path、set_max_delay、set_min_delay。告诉工具哪些路径不用按默认规则检查。
还有一类是设计规则约束:set_max_transition、set_max_capacitance、set_max_fanout。这些不直接管时序,但违反了会让后端很难受。转换时间太大,后级单元延迟会非线性上升;扇出太大,时钟树不好平衡。我个人的习惯是把这三条当成硬性指标,宁可在综合阶段多插几个buffer,也不要留给后端去救。
3.3 setup与hold的算账方式
理解时序检查的最好办法是自己算一遍。以建立时间(setup)为例,一条从触发器U1到触发器U2的路径,要满足的条件是:
T_launch_edge + T_cq + T_comb + T_setup <= T_capture_edge + T_period - T_uncertainty把它换算成"留给组合逻辑的时间":
T_comb_max = T_period - T_uncertainty - T_setup - T_cq + (T_capture_edge - T_launch_edge)举个具体数字。时钟周期10ns(100MHz),不确定性设为0.15ns(含时钟抖动和偏斜预算),触发器自身的setup时间是0.10ns,时钟到输出的延迟T_cq是0.20ns。同一时钟沿发射和捕获,那么:
T_comb_max = 10 - 0.15 - 0.10 - 0.20 = 9.55ns也就是说,这条路径上的所有组合逻辑(包括走线延迟)必须压在9.55ns以内。工具在综合时的目标就是这个数,它会把超出部分通过逻辑重构、单元升级、插buffer等方式压下去。
保持时间(hold)的公式是另一套:
T_cq + T_comb_min >= T_hold + T_uncertainty_hold可以看到hold检查跟时钟周期无关,只跟路径本身的最短延迟有关。这就是为什么hold违例不能靠降频解决,而setup违例可以通过降频缓解。也是为什么综合阶段通常不修hold——真实的最短路径延迟取决于时钟树插入后的偏斜,综合阶段估不准,修了大概率是白做工。
3.4 一份可以直接抄的SDC骨架
下面这份约束我用了很多年,改改时钟名字和端口就能上手,结构上覆盖了绝大多数同步设计的需求:
# 1. 定义主时钟 create_clock -name clk_core -period 10.0 -waveform {0 5.0} [get_ports clk] set_clock_transition 0.12 [get_clocks clk_core] set_clock_uncertainty -setup 0.15 [get_clocks clk_core] set_clock_uncertainty -hold 0.05 [get_clocks clk_core] set_clock_latency -source 0.60 [get_clocks clk_core] set_clock_latency 0.30 [get_clocks clk_core] # 2. 输入延时(相对时钟沿,注意区分max/min) set_input_delay -clock clk_core -max 2.00 [remove_from_collection [all_inputs] [get_ports clk]] set_input_delay -clock clk_core -min 0.50 [remove_from_collection [all_inputs] [get_ports clk]] # 3. 输出延时 set_output_delay -clock clk_core -max 2.50 [all_outputs] set_output_delay -clock clk_core -min 0.30 [all_outputs] # 4. 驱动与负载 set_driving_cell -lib_cell BUF_X4 -pin Z [all_inputs] set_load 0.05 [all_outputs] # 5. 设计规则 set_max_transition 0.30 [current_design] set_max_capacitance 0.20 [current_design] set_max_fanout 24 [current_design] # 6. 时序例外 set_false_path -from [get_ports rst_n] set_false_path -from [get_ports test_mode] set_multicycle_path 2 -setup -from [get_cells u_ctrl/*_reg*] -to [get_cells u_dsp/*_reg*] set_multicycle_path 1 -hold -from [get_cells u_ctrl/*_reg*] -to [get_cells u_dsp/*_reg*]注意:
set_multicycle_path必须成对写,setup设成N,hold就要设成N-1,否则hold检查会跟着一起放宽,工具会漏掉真实的违例。这个坑我在两个项目上都见过。
4. 工具选型与工程组织
4.1 主流综合工具横向对比
工具选型这件事,其实很多时候不是你选的,是公司买的。但了解各自的脾气对排查问题很有帮助。
| 工具 | 厂商 | 主要特点 | 典型适用场景 |
|---|---|---|---|
| Design Compiler | Synopsys | 生态最成熟,脚本资料最多,与Formality、ICC2衔接顺 | 中大规模ASIC,团队协作项目 |
| Genus | Cadence | 编译速度快,与Innovus同源,物理感知能力强 | 大规模SoC,物理综合流程 |
| Yosys | 开源 | 免费,脚本灵活,配合ABC做映射 | 教学、小规模设计、开源硬件 |
| Vivado Synthesis | AMD | 面向FPGA,与实现工具深度集成 | FPGA项目,算法验证 |
如果是学习阶段,我建议先用Yosys把整个流程跑一遍,因为它开源、日志透明、你能看到中间每一步的网表长什么样。等理解了翻译-优化-映射这三步,再切到DC或者Genus,会发现脚本命令只是换了名字,思路完全一样。
4.2 目录结构与脚本分层
综合工程的目录结构如果不规范,后期维护会非常痛苦。我一般按下面的方式组织:
syn/ ├── scripts/ │ ├── setup.tcl # 变量定义:库路径、顶层名、工艺角 │ ├── read.tcl # 读RTL、读库、link │ ├── constrain.tcl # 加载SDC │ ├── compile.tcl # 编译策略 │ └── report.tcl # 输出报告和网表 ├── rtl/ # RTL源文件列表(.f文件) ├── lib/ # .db/.lib ├── sdc/ # 约束文件 ├── work/ # 工具运行目录 └── reports/ # 输出的时序、面积、功耗报告关键点是setup.tcl单独抽出来放变量。因为一个项目往往要跑多个工艺角、多个配置,如果路径写死在每个脚本里,换个角就要改五六个文件,出错概率陡增。抽出来之后,换个角只需要改一个变量,甚至可以写个循环批量跑。
4.3 三种工具的脚本骨架
Synopsys DC的典型骨架:
set_app_var search_path [list . ./lib] set_app_var target_library "sc9_base_ss_0p81v_125c.db" set_app_var link_library "* $target_library dw_foundation.sldb" set_app_var symbol_library "sc9_base.sdb" analyze -format sverilog -vcs "+define+SYNTHESIS" [list top.sv sub.sv] elaborate top -architecture verilog -update current_design top link source ./sdc/top.sdc check_timing compile_ultra -gate_clock -no_autoungroup report_timing -max_paths 20 -nworst 3 > ./reports/timing.rpt report_area -hierarchy > ./reports/area.rpt report_power > ./reports/power.rpt write -format verilog -hierarchy -output ./work/top_syn.v write_sdc ./work/top_syn.sdc write_sdf ./work/top_syn.sdfCadence Genus的对应写法:
set_db lib_search_path ./lib set_db library {sc9_base_ss_0p81v_125c.lib} set_db init_hdl_search_path ./rtl read_hdl -sv {top.sv sub.sv} elaborate top read_sdc ./sdc/top.sdc check_design -unresolved syn_generic syn_map syn_opt report_timing -nworst 20 > ./reports/timing.rpt report_area > ./reports/area.rpt write_hdl -mapped > ./work/top_syn.v write_sdc > ./work/top_syn.sdcYosys的极简版本:
read_verilog -sv top.sv sub.sv hierarchy -top top proc; opt; fsm; opt; memory; opt techmap; opt abc -liberty ./lib/sc9_base_ss_0p81v_125c.lib write_verilog ./work/top_syn.v stat三个脚本对比着看,能很明显感受到:DC和Genus是"命令驱动、分阶段执行",Yosys是"流程管道、逐步下沉"。理解了syn_generic对应synth、syn_map对应abc这种映射关系,换工具基本没什么成本。
5. 完整跑一遍综合:从读库到出网表
5.1 环境与库的读入
第一步永远是设置搜索路径和目标库。目标库(target_library)是映射时真正使用的库,链接库(link_library)用于解析设计中的实例引用,一般写成"* $target_library",星号表示先搜内存里已有的设计再搜库。
工艺角的选取有个容易忽略的细节:综合用SS角,但不要用最极端的角。有些团队直接把SS角拉到-40℃或者125℃的最坏组合,结果综合时为了压时序插了一堆大驱动单元,面积翻倍,实际签核时却发现另一个角更紧。合理做法是先跟后端确认签核用的角,综合就用同一个角去对齐。
读RTL时有个实用技巧:用-vcs参数把宏定义传进去,这样RTL里的ifdef SYNTHESIS分支能正常生效。很多设计会在这个宏下把不可综合的仿真代码屏蔽掉,不加这个参数会直接报语法错误。
5.2 约束加载与检查
source完SDC之后,千万别急着编译,先跑check_timing。这个命令会吐出一堆报告,重点看这几项:
- unconstrained_endpoints:有多少寄存器的输入端没有被任何路径约束到。理想值是0。
- no_clock:有多少寄存器的时钟端没接到时钟。这个极其危险,意味着这些寄存器完全没被优化。
- no_input_delay / no_output_delay:端口有没有设置延时。
- generated_clocks:分频时钟有没有正确推导出来。
我自己的经验是,check_timing报出来的warning里,no_clock和no_input_delay这两类必须清零才能往下走,其他的视情况处理。有一次我拿到同事的脚本,check_timing报了几百个no_clock,一开始还以为是时钟名写错了,排查半天发现是他把时钟定义在一个被条件注释掉的if块里,工具根本没执行到。
5.3 编译策略与增量优化
compile_ultra是DC里最强的一档编译,包含逻辑重构、时序驱动映射、自动取消分组等动作。它有几个常用开关:
-gate_clock:在满足条件的地方自动插入集成时钟门控单元。这个对降低动态功耗非常有效,实测在一颗中规模SoC上能省10%~25%的时钟树功耗。代价是增加了可测性设计的复杂度,扫描链插入时要额外处理。-no_autoungroup:禁止自动打散层次。默认情况下工具会把小的层次打散以获取更好的优化自由度,但打散之后调试时序问题会很难定位。我一般在上线前的最后一次综合才允许打散。-retime:寄存器重定时,把组合逻辑跨越触发器边界重新分配。这个对某些路径特别有用,但会让网表和RTL的寄存器边界不一致,形式验证会更麻烦,慎用。
编译跑完如果还有违例,不要直接改代码,先走增量优化。compile_ultra -incremental会保留已有的映射结果,只针对违例路径继续优化,速度快而且对面积影响小。有时候来回跑两三次增量就能把违例清掉。
5.4 输出文件与报告
编译通过之后要落盘的东西不止网表:
| 文件 | 命令 | 用途 |
|---|---|---|
| 门级网表 | write -f verilog | 给后端和形式验证用 |
| SDF | write_sdf | 门级仿真用,含延时信息 |
| SDC | write_sdc | 传递给后端的约束 |
| 时序报告 | report_timing | 自查和交付 |
| 面积报告 | report_area | 面积预算核对 |
| 功耗报告 | report_power | 功耗预算核对 |
| 设计规则报告 | report_constraint -all_violators | 检查max_transition等 |
网表输出建议加-hierarchy保留层次,除非后端明确要求扁平网表。层次保留的好处是后期和RTL对照方便,形式验证也更容易定位不匹配点。
5.5 时序报告怎么读
report_timing的输出看着密密麻麻,其实结构很固定。一份标准的路径报告包含这几块:
Startpoint: u_ctrl/state_reg[2] (rising edge-triggered flip-flop clocked by clk_core) Endpoint: u_dsp/acc_reg[15] (rising edge-triggered flip-flop clocked by clk_core) Path Group: clk_core Path Type: max然后是路径明细,每一行是一个单元或线网,列出Incr(该段延迟)和Path(累计延迟)。最后是data arrival time、data required time和slack。slack是负的就是违例。
读报告时,我习惯按这个顺序看:
- Path Type——是max还是min,对应setup还是hold。
- Path Group——属于哪个时钟域,跨时钟域路径要特别留意。
- slack数值——先看差多少,差0.1ns和差2ns的应对策略完全不同。
- Incr最大的几行——延迟集中在哪个单元或哪段线网上,这就指向了优化目标。
有个细节很容易被忽略:报告里的Incr如果某一级特别大,可能是扇出过大或者驱动能力不足。如果整条路径的延迟分布很均匀,那问题多半在逻辑级数太深,需要从RTL层面重构。同样是违例,这两种情况的处理方式完全相反,一个是在工具里加buffer,一个是要回去改代码。
6. 常见问题与排查技巧实录
6.1 时序违例:先分类再动手
拿到负slack别急着改代码,先分类。我一般按下面这个判断树走:
第一类:单条路径违例,其余都在正余量。这通常是局部问题,用compile_ultra -incremental或者手工加几个buffer就能解决。如果路径最后一级的负载特别大,用set_max_fanout局部约束一下也能缓解。
第二类:某个模块整体违例,且违例路径都经过同一个中间信号。这大概率是那个中间信号扇出太大,成了瓶颈。解决办法是加一级buffer把扇出分流,或者在RTL层面把广播信号改成局部解码。
第三类:所有路径都差一点点,slack分布很均匀地都在-0.2ns左右。这种情况通常是约束写得乐观了——时钟不确定性给太小、输入输出延迟写得比实际小、或者线负载模型选得不合适。先怀疑约束,再怀疑设计。
第四类:跨时钟域路径报违例。除非两个时钟有确定的相位关系,否则这些路径应该用set_false_path或者set_clock_groups隔开。没隔开的话工具会拼命优化这些本来不该检查的路径,白白浪费面积。
6.2 面积爆炸与拥塞风险
面积超预算是综合阶段很常见的问题,原因往往不在工具,而在代码。几个典型场景:
场景一:位宽溢出。你写assign y = a + b + c;,如果a、b、c都是16位,结果也声明成16位,工具会老老实实做两个16位加法器。但如果结果只需要16位,多出来的进位链就该被截掉。很多新手写的代码里全是全精度运算,工具照单全收,面积自然下不来。
场景二:重复的逻辑被实例化多次。比如一个译码逻辑在八个地方各写了一遍,工具虽然能做公共子表达式消除,但前提是它能看到全局。如果这八处分布在不同的层次里而你又开了-no_autoungroup,工具就没法合并了。
场景三:case写成了优先级结构。综合工具看到不带parallel_case的case,默认按优先级处理,生成的是级联的比较器而不是并行的译码器。对于真正互斥的case,加上parallel_case综合指示能大幅减小面积。但这个指示是"你保证互斥",如果实际不互斥,仿真和综合就会不一致,所以要谨慎。
面积大了之后,后端的拥塞风险也会上升。单元密度高的地方走线资源被占满,布线绕不通,最后还是要回头降面积。所以面积预算最好留10%~15%的余量,不要顶格交差。
6.3 综合与仿真对不上:latch和不完整敏感列表
形式验证报不匹配(non-equivalent),最常见的原因是锁存器被意外推断出来。前面提过,组合逻辑的always块如果敏感列表不完整,或者if/case没有覆盖所有分支,工具就会推断出latch来保持原值。仿真时因为事件调度机制,波形看起来是对的,但综合出来的latch会让功能彻底变样。
排查方法很直接:跑report_latch或者看综合日志里的latch warning,把所有推断出的latch列出来。真正需要的latch应该显式用always_latch或者带使能的写法;不需要的就必须补全分支或者改写逻辑。
另一个对不上的原因是综合指令和实际逻辑冲突,比如前面说的parallel_case和full_case。这两个指示本质上是"向工具承诺",承诺不兑现就会出错。我的建议是:除非你在RTL里已经用if-else把互斥性写得很清楚,否则不要用这两个指示。
还有一种情况是初始化值。RTL里写了reg [7:0] cnt = 8'd0;,仿真时上电就是0,但综合出来的网表里触发器没有初值,上电状态是随机的。这个在FPGA上可以通过配置位解决,在ASIC上必须靠复位逻辑。所以所有需要确定初值的寄存器都必须有复位,不能依赖声明时的初始化。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| check_timing报no_clock | 时钟未定义或被条件注释 | 检查SDC是否被执行 | 补时钟定义 |
| 所有路径无约束 | 编译前未source SDC | report_clock确认 | 重跑,先加载约束 |
| setup违例集中在某模块 | 逻辑级数深或扇出大 | 看Incr分布 | 重构或分流扇出 |
| hold违例 | 时钟树未插入,估算不准 | 确认是否综合阶段 | 留给后端修 |
| 面积超预算30%以上 | 全精度运算、重复逻辑 | report_area -hierarchy | 优化RTL位宽 |
| 形式验证不匹配 | 推断出latch | report_latch | 补全分支 |
| 功耗超预期 | 无时钟门控 | 检查是否加-gate_clock | 重编译加门控 |
| 网表仿真出现X | 寄存器无复位 | 检查复位覆盖 | 补复位逻辑 |
7. 几个踩过坑之后才明白的道理
综合这件事,工具本身的作用其实只有一半,另一半全在工程师对约束和设计的理解上。我见过太多项目,RTL写得漂漂亮亮,脚本也跑得飞快,结果到了后端处处爆雷,回头一查全是综合阶段埋的雷。
第一个体会是:约束是合同,不是注释。很多团队把SDC当成"能过就行"的东西,随便抄一份改改周期就用了。但SDC里每一条命令都在告诉工具"我要什么",你写错了,工具不会提醒,只会照着错的做。我有一个习惯,每次综合完成后必做的三件事是:看check_timing、看report_clock、比对report_area和上次的差异。这三件事花五分钟,能挡住八成的事故。
第二个体会是:别迷信工具的默认设置。默认的compile_ultra适合标准的同步设计,但对异步、多时钟域、低功耗设计来说,默认设置经常不是最优甚至不合适。set_max_transition这种设计规则约束,工具在没有约束时根本不会管,你得自己提出来。同样,工艺角的选取、线负载模型的精度、时钟门控的插入策略,这些都是要基于项目实际情况做取舍的,没有一套参数能吃遍所有项目。
第三个体会是:综合和布局布线要联合考虑。早期我总想着综合阶段先把时序压到全正,后面就轻松了。实际做下来发现,综合阶段过度优化会导致面积膨胀、单元分布不合理,后端反而更难收敛。后来学乖了,综合阶段只要保证没有大的违例(比如超过周期10%的),小幅度的负余量留给后端去收敛,反而整体周转更快。这个"留一点空间给后面"的思路,是我做了两三个项目之后才慢慢有感觉的。
第四个体会是:报告要存档,而且要能对比。每次综合跑完,把时序、面积、功耗、约束检查这四份报告按版本号归档。碰到回归问题时,能直接和上一版对比,很快就知道是本次改动引入的问题还是历史遗留。这个习惯看起来笨,但真的省时间。
最后一个想说的是:动手跑一遍胜过看十篇文档。逻辑综合的概念其实不多,翻译、优化、映射,加上约束的三四类命令,核心就这些。但每个概念背后都有大量细节,只有真正跑过、报过错、修过违例,才能形成自己的判断。找个开源的小设计,用Yosys跑一遍看中间网表,再找台有DC或者Genus的机器跑一遍对比报告,收获会比单纯读手册大得多。
对了,还有个实用的小技巧:如果你手头只有RTL没有库,可以用Yosys自带的cmos或者开源PDK里的库先跑通流程,把脚本框架搭起来。等拿到真实库,只需要换掉target_library这一行,整个流程不用动。这样在项目前期就能验证RTL的可综合性,早发现早修改,比等到集成阶段再返工要划算得多。