%Warning-UNOPTFLAT: Signal unoptimizable: circular logic:Verilator 组合逻辑成环速查
更新时间:2026-10-24。FAQ 速查页,属于 Verilator 报错排查系列。
verilator --lint-only -Wall一跑,迎面一条Signal unoptimizable: Feedback to clock or circular logic,还附带一串 Example path——这不是 Verilator 在抱怨性能,而是它发现你的 RTL 里存在组合逻辑环:某个信号不经寄存器、绕一圈又依赖回自己。Verilator 的调度器排不出合法的求值顺序,只能把信号拆字(unoptflat 即此意)并告警。同系列的宽度、case 不全问题见 %Warning-WIDTH 位宽不匹配 与 CASEINCOMPLETE 告警。
答案块(赶时间先看这里)
- 报错原文:
%Warning-UNOPTFLAT: xxx.v:N:M: Signal unoptimizable: Feedback to clock or circular logic: 'top.sig' - 根因:某个 wire/reg 的组合逻辑驱动绕一圈又回到自己——典型是两条互斥 assign 互相引用,或 latch 输出反馈回输入
- 快速定位:顺着告警里 Example path 列出的信号链走一遍,首尾相同的那条线就是环
- 修复:环上任意一点打一拍寄存器把环切断;或者重写逻辑消掉互依赖;不推荐用
lint_off UNOPTFLAT一遮了之 - 验证:
verilator --lint-only -Wall重跑,无告警、exit 0 即修复成功
本文要回答的问题
- UNOPTFLAT 这个告警名字是什么意思?为什么「不可优化」其实是「有环」?
- 告警里那串 Example path 怎么读,怎么用它定位环?
- 组合逻辑环在真实 RTL 里长什么样(不是教科书里的例子)?
- 打拍、重写、lint_off 豁免三条路各自什么代价,怎么选?
- 这个环对综合器和仿真器分别意味着什么,能放着不管吗?
一、报错原文与现象
最小复现:一个二选一选择器写顺手了,两条 assign 互相引用对方:
module loop_bad (
input wire sel,
input wire a,
input wire b,
output wire y
);
wire x, z;
assign x = sel ? a : z; // x 依赖 z
assign z = sel ? b : x; // z 依赖 x —— 环闭合
assign y = x & z;
endmoduleverilator --lint-only -Wall loop_bad.v 的真实输出(Verilator 4.038):
%Warning-UNOPTFLAT: loop_bad.v:7:13: Signal unoptimizable: Feedback to clock or circular logic: 'loop_bad.z'
7 | wire x, z;
| ^
... Use "/* verilator lint_off UNOPTFLAT */" and lint_on around source to disable this message.
loop_bad.v:7:13: Example path: loop_bad.z
loop_bad.v:8:14: Example path: ASSIGNW
loop_bad.v:7:10: Example path: loop_bad.x
loop_bad.v:9:14: Example path: ASSIGNW
loop_bad.v:7:13: Example path: loop_bad.z
%Error: Exiting due to 1 warning(s)这张实录抓住三个事实:告警钉在信号 loop_bad.z 的声明处;Example path 按 z → ASSIGNW → x → ASSIGNW → z 走了一圈、首尾都是 z,环被完整画了出来;最后 %Error: Exiting due to 1 warning(s) 说明 -Wall 下告警即失败(exit 1),不是可以滑过去的水告。顺带一提,告警名里的 unoptimizable 是 Verilator 内部术语:它的求值调度要求组合逻辑拓扑无环,有环就得把信号按位拆开来迭代求值直到收敛,性能大跌,所以叫「不可优化」——对你而言只需要记住一件事:有环。这个环还不只是 Verilator 的事:事件驱动的仿真器(VCS、XSim)遇到组合环会在零时间内振荡、仿真挂死;综合器(Design Compiler、yosys)遇到组合环则报 timing loop 或直接推断出意外 latch。Verilator 是第一个把它喊出来的工具,顺着它修,后面的工具链一起受益。
二、根因:组合逻辑环是怎么闭合的
读这张图抓住主线:蓝色是正常输入,红色是环的成员——x 和 z 互为对方的组合逻辑输入,数据流在两者之间无限打转,没有起点;灰色 y 是环的下游受害者。Verilator 的 Example path 就是沿着这两条红边走的。真实工程里这种环很少写得这么直白,更常见的三种伪装:latch 环(两个 latch 的 D 和 Q 交叉连接,always 块里看不出环)、位拼接自引用(把 assign bus = {bus 低位, new_bit} 当成移位写,其实输出每位都组合依赖自己)、跨模块环(A 模块的输出组合逻辑连到 B,B 的输出又组合回 A 的输入,单看每个文件都无辜)。latch 环的代码长这样:
always @* if (en_a) q_a = q_b; // q_a 组合依赖 q_b
always @* if (en_b) q_b = q_a; // q_b 又组合依赖 q_a —— 环两个 always 块各自看都人畜无害,Verilator 把它们展平成数据流图后环才现形——这也是为什么 Example path 里会出现 ASSIGNW 节点:那是连续赋值在内部图里的样子,跨 always 块的环同样以这种边呈现。判别标准只有一个:从某个信号出发只走组合边(assign、always @*、latch),能不能回到自己。
三、拆环三法对比
环找到了,怎么拆有三条路,代价完全不同:
这张对比卡按代价从低到高读:绿色「打拍」是工程上的默认答案——在环上任意一点把组合信号换成寄存器输出,物理环立刻消失,代价只是该路径多一拍延迟,控制逻辑里这通常可接受,数据通路上则要先确认协议允许。紫色「重写」适合环本身就是笔误的情况:比如上面那个例子,真实意图往往只是 assign x = sel ? a : b; 一层选择,两条 assign 是复制粘贴事故,重写后零延迟零面积,代码还更好读。红色「lint_off」是下策:告警被注释压住了,但环物理存在——Verilator 会对这类信号按位拆分迭代求值(仿真明显变慢),综合器那边则可能推断出意外 latch 或直接报时序环,等于把问题从 lint 阶段推到更贵的阶段。它唯一合法的用途是改不了的第三方 IP:这时按 lint waiver 管理流程 把豁免收窄到具体信号、写清理由,而不是整文件一盖。一个经验法则:能重写就重写,重写有语义风险就打拍,前两路都走不通才考虑豁免。
四、修复示例:打拍拆环
对复现例用打拍法:把 z 换成寄存器输出 z_q,环即被时序单元切断。
module loop_fix (
input wire clk,
input wire sel,
input wire a,
input wire b,
output reg y
);
reg z_q;
wire x;
assign x = sel ? a : z_q; // x 依赖的是上一拍的 z
always @(posedge clk) begin
z_q <= sel ? b : x; // 环在寄存器处断开
y <= x & z_q;
end
endmodule重跑 verilator --lint-only -Wall loop_fix.v,无任何输出、exit 0,告警消失。注意语义变化:z_q 是上一拍算出来的值,下游要多容忍一拍延迟——这正是拆环前要先和时序/协议确认的点。
还有两个容易踩的细节。一是打拍的位置选在环上扇出最小的边:环上的每条边都连着一个组合锥,打在信号驱动多个下游的位置,等于把这一整片逻辑都推晚一拍,时序压力和功能偏差一起放大;打在只服务单一用途的边上,影响面最小。二是打完拍之后回头跑一遍原有仿真和等价性检查:拆环改变了微架构时序,功能仿真里依赖原组合行为的断言可能失败,这恰恰是验证「这一拍延迟业务上能不能接受」的直接证据。如果延迟不可接受,回到三法对比里的第二条路——重写消互依赖,逻辑上没有环就不存在延迟代价。
五、病因速查表
同一条 UNOPTFLAT,背后的环形态不同,处置也不同:
| 报错现象 | 可能原因 | 快速验证 | 处置 |
|---|---|---|---|
| Example path 首尾同信号,只跨 1 个文件 | 两条 assign 互引用(复制粘贴笔误) | 把 path 里的信号逐个 grep 驱动源 | 重写成单层逻辑,多半不需要打拍 |
| path 里有 always 块、无 clk 沿 | latch 环(D/Q 交叉反馈) | verilator --lint-only 伴随 LATCH 告警 | 改寄存器或重写状态机 |
path 里信号带位选 [x:y] | 位拼接自引用伪移位 | 检查 assign 右值是否含自身位选 | 改成分段赋值或真移位寄存器 |
| path 跨模块端口 | A→B→A 跨模块组合环 | hierarchy 里顺 path 走两跳 | 在模块边界打拍切断 |
| 报在第三方 IP 内部 | vendored 代码里的既有环 | 确认该文件不可改 | 局部 lint_off + waiver 登记 |
| 大量 UNOPTFLAT 同时爆 | generate 循环里实例互连出错 | 缩小 generate 范围二分定位 | 修 generate 互连索引 |
| 告警指向时钟信号 | 组合逻辑反馈进了时钟树 | 检查门控时钟写法 | 用时钟使能替代门控,或走专用 clock gating cell |
实验环境
- 机器/系统:云主机 4C4G / CentOS 7(Docker 容器内运行)
- 工具版本:Verilator 4.038 2020-07-11 rev v4.038(官方镜像
verilator/verilator:4.038) - 日期:2026-10-24
- 完整命令:
docker run --rm -v $PWD:/work -w /work verilator/verilator:4.038 --lint-only -Wall loop_bad.v(修复版同命令换 loop_fix.v)
代码获取:复现源码与原始日志已同步到公开仓 experiments/ts-verilator-unoptflat-circular-logic,本机装有 Verilator 4.x 时 sh run.sh 一键复现,或用 README 里的 docker 命令。
这一篇在知识体系里的位置
本篇是 Verilator 报错排查系列的一环:位宽类问题看 %Warning-WIDTH 位宽不匹配,case 覆盖不全看 CASEINCOMPLETE 告警,时序延迟语法看 %Error-UNSUPPORTED t_fall。告警多了之后的治理方法(豁免基线、 waiver 登记)见 RTL lint 豁免基线建立 与 lint waiver 管理流程;跨模块环涉及时钟域交叉时,再接 CDC 结构检查签核。
一句话总结
%Warning-UNOPTFLAT: Signal unoptimizable: circular logic 就是 Verilator 告诉你「这条组合逻辑信号绕一圈又回到自己,我排不出求值顺序」——顺着告警里的 Example path 找到环,优先在环上打一拍寄存器真正拆环(确认下游容忍一拍延迟),环若是笔误就重写消互依赖,lint_off UNOPTFLAT 只留给改不了的第三方 IP 并走 waiver 登记。