文章来源:FPGA入门到精通
本文收获:
✅ 分清三类约束文件,把异步时钟「误报违例」这件最常见的冤案先排除掉
✅ 掌握 always 的 3 条代码铁律和寄存器复制防优化写法(keep / noprune),扇出过大不再靠加 BUFG 硬扛
✅ 带走一张六症状排查速查表,可以直接贴进项目文档
做高频、大位宽设计久了,我的感受是,时序问题九成在你按下综合之前。组合逻辑直接喂 always、信号扇出拉爆、大位宽总线不打拍,这些写法,工具救不了你。
下面六个方向,按出现频率排序,每个给出症状、根因和解法。
一、违例满天飞,先查约束还是先改代码?
先查约束。误报比真违例常见得多。
约束文件按三类分开管:物理约束管管脚和电平,调试约束放 ILA/VIO 自动生成的那部分,时序约束专门放时钟周期、IO 延迟、伪路径。混在一个文件里,调试约束一改动就把主约束弄脏,这种事故我见过不止一次。
时序约束里最关键的一条,是异步时钟分组:
●不同晶振来的主时钟,一定异步,必须分不同组
●同一个 MMCM/PLL 输出的时钟,相位固定,可以同组
●不同 MMCM 输出,就算频率完全相同,相位不确定,照样是异步
第三条最容易翻车。不做异步分组,工具默认所有时钟相关,老老实实对异步路径做 STA,建立保持违例全是误报,你把代码改成花都没用。
分完组,两类路径补上伪路径:双触发器同步后的跨时钟路径,以及异步复位:
# 异步分组:不同 MMCM 输出即使同频也算异步,必须分不同组
set_clock_groups -asynchronous \
-group [get_clocks {clk_a}] \
-group [get_clocks {clk_b}]
# 伪路径:异步复位(同步器之后的跨时钟路径同理)
set_false_path -from [get_ports sys_rst_n]
二、always 块的判断条件,能写多长?
能多短就多短。四个代码写法里,这条杀伤力最大。
判断条件里塞一长串组合逻辑,等于给这个 always 的所有输入加了延时,建立时间直接爆。比如 PC_LAST_ADDR 是一条组合逻辑链的输出,拿它当判断条件,时序必然吃紧。
三条铁律,照着写就行:
1.always 的判断条件只做逻辑判断,不做逻辑运算
2.判断条件里的每个变量,直接来自某个 always 块的输出
3.赋值语句里被赋值的变量,同样直接来自 always 输出
说到底就一句话,让每个 always 的输入延时固定、可预测。顺带一提,三段式状态机里拿 n_state 当判断条件也是同类错误,该用 c_state。对照下面两种写法就明白了:
// 反例:拿组合链输出当判断条件(pc_last_addr 是组合逻辑链的输出)
always @(posedge clk)
if (pc_last_addr >= base) hit <= 1'b1;
// 正例:先打一拍,判断条件只做比较,组合链被寄存器截断
reg [31:0] pc_last_addr_d;
always @(posedge clk) pc_last_addr_d <= pc_last_addr;
always @(posedge clk)
if (pc_last_addr_d >= base) hit <= 1'b1;
三、if-else 嵌套几层该警惕?
三层。三层以上,综合出来就是一条长组合链。
处理方式跟软件里的 early-return 一个思路,提前判断提前退出,或者把深层嵌套拆成独立状态机,再或者改用 case 把逻辑摊平。写平之后长这样:
// 反例:4 层 if-else 嵌成一条长链
// 正例:提前判退 + 平铺判断,每层最多一个条件
always @(posedge clk) begin
if (!en) q <= 1'b0;
else if (busy) q <= q;
else if (mode==A) q <= d_a;
else if (mode==B) q <= d_b;
else q <= d_c;
end
四、一个信号要驱动五六十个模块,怎么办?
先说错误答案:加 BUFG。全局时钟资源就那么几根,拿去当信号扇出用,又浪费又引入大延时,只有全局复位才配用它。
正确解法是寄存器复制,把这份信号复制两三份,每份各驱动一部分负载。
关键在后半句。复制完要防止综合器把副本优化掉,Vivado 加 (* keep = "true" *),Quartus 加 (* noprune *)。普通信号扇出能力大概 15 到 20,要驱动 55 个 always 块,不复制必崩。这个坑专门坑老手,复制代码写得很漂亮,一综合副本没了,白忙一场。
// Vivado:复制两份,keep 防止综合器把副本合并回去
(* keep = "true" *) reg sig_dup0;
(* keep = "true" *) reg sig_dup1;
always @(posedge clk) begin
sig_dup0 <= sig; // 副本 0:驱动前半 28 个模块
sig_dup1 <= sig; // 副本 1:驱动后半 27 个模块
end
// Quartus 对应:(* noprune *) reg sig_dup0;
五、128 选 1 的大选择器,时序又慢又不稳怎么办?
别一次实现。直接写一个 128 选 1 的 case,综合慢、资源大、输出还不稳,全占。
拆成分级流水:第一级 16 个 8 选 1,第二级 2 个 8 选 1,第三级 1 个 2 选 1,每级输出打一拍,总共 3 拍。多花两拍延迟,换回来干净的时序和稳定的输出,这笔账怎么算都划算。落位时按级拆成小 case,别写一个 128 分支的大 case:
// 128 选 1 → 三级流水,每级打一拍(共 3 拍)
// 级 1:16 个 8 选 1,按 sel[6:3] 分组 → 16 路中间结果
// 级 2: 2 个 8 选 1,按 sel[2:1] 选择 → 2 路中间结果
// 级 3: 1 个 2 选 1,按 sel[0] 选择 → 1 路输出
六、64bit 以上的总线,偶尔采错数据还复现不出来?
大概率是布线延时。大位宽 RAM 数据总线,64bit 往上,不同 bit 的布线延时不一致,同一个时钟沿就采到错数据。现象就是偶发、难复现、查波形查到怀疑人生。
解法两步。第一步,不管数据来自 FIFO 还是用户逻辑,先打一拍再使用。第二步,打了拍还不稳,对使用这块 RAM 的时钟做 create_clock 内部时钟约束,跟外部管脚时钟同一个写法。
// 第一步:先打一拍再使用
reg [63:0] rd_data_d;
always @(posedge clk) rd_data_d <= rd_data;
# 第二步:仍不稳 → 补内部时钟约束(与管脚时钟同法)
create_clock -period 6.000 -name clk_ram [get_pins mmcm_inst/CLKOUT0]
最后:一张速查表 + 几句实话
六个症状对六个动作,可以直接贴到项目文档里:
工具层面还有几个手段:Pblock 固定关键模块位置、增量编译加速迭代、时序驱动综合、Explore 策略多试几把。但丑话说在前面,这些是止痛药,上面六问才是病因。代码风格不改,策略探到天亮也收不回来。
回到开头那几条红色违例。下次再看到它们,先别急着调实现策略,把速查表拉出来,六个问题挨个查。
参考资料:
- UG949《Vivado Design Suite 用户指南:方法论》(时序收敛官方流程)
- UG903《Vivado 使用约束》(create_clock / set_clock_groups / set_false_path 语法依据)

