🧩 第 8 章:让改造真正跑起来 —— 约束文件与时序收敛

🧩 安路FPGA 入门 · 第 8 / 8 章
🗨️ 代码写得对,不代表板子跑得起来。中间还隔着两道门:引脚得告诉工具「接哪根线」,时钟得告诉工具「跑多快」。
这一章在干嘛? 第 7 章给了三个改造方案(消融实验 / OSD / 淡入淡出)。但很多同学改完代码一点「Run」,收获的是满屏红字。这一章补上从 RTL 代码到 bitstream 之间最容易被跳过的那一环:约束文件怎么写、编译流程每一步在做什么、时序报告怎么读、违例了怎么改。学完你能独立把改造方案推到「下载验证」这最后一步。
为什么改完代码会报错编译四步在做什么引脚约束:告诉工具接哪根线时钟约束:告诉工具跑多快例外约束:该放过的路径I/O 延迟约束读懂时序报告时序违例的五种解法报错对照表

为什么改完代码会报错

先建立一个正确的心智模型,后面所有操作都围绕它展开。

写 MCU 的 C 语言时,编译器知道芯片型号、知道主频、知道外设挂在哪个引脚——这些信息都写在工程配置里。FPGA 不一样:Verilog 只描述「逻辑」,不描述「物理」。你写 assign blend_r = ...,工具完全不知道这个信号将来从芯片的哪个脚出去、也不知道时钟是 25 MHz 还是 100 MHz。

信息类型MCU 开发FPGA 开发
代码含义你写 C你写 Verilog
引脚位置图形化配置 Pin Mux必须手写约束文件
时钟频率配置时钟树自动生效必须手写 create_clock
速度是否达标主频固定,基本不用管要看时序报告,可能不收敛
一句话记住 MCU 是「买一套精装房,家具往里摆」;FPGA 是「拿到一片空地,你得自己画图纸说清哪儿是门、哪儿是窗」。约束文件就是那份图纸。

所以第 7 章的三个改造,代码层面的思路已经清楚了,但要落地还差这份「图纸」。下面按编译流程的顺序讲,因为每一步需要的约束不一样。

编译四步在做什么

点一次「Run All」,工具内部其实串了四个独立阶段。搞清楚每阶段的输入输出,报错时才知道该查哪儿。

① 综合Synthesis ② 布局Place ③ 布线Route ④ 生成比特流Bitstream RTL → 门级网表 网表 → 落到具体 LE 连上走线资源 产出 .bit 文件 数据沿着流水线一路向前,任何一环卡住都会停在这一步
阶段输入输出典型报错
① 综合.v 源码 + 时钟约束门级网表(逻辑表达式)语法错误、多驱动、组合环
② 布局网表 + 引脚约束每个逻辑单元落到具体位置引脚冲突、BANK 电平冲突
③ 布线布局结果实际走线连接布线拥塞、资源超限
④ 比特流布线结果.bit / .bin 下载文件配置模式不匹配
关键认识:引脚约束在②才用得上,时钟约束在①③④都要用 所以「没写引脚约束」通常不会让综合失败,而是卡在布局;「没写时钟约束」则更隐蔽——它能一路跑到最后,但时序报告是空的,工具根本没检查过你的电路跑不跑得动。后者危险得多。

引脚约束:告诉工具接哪根线

安路 TD 的约束写在 .adc 文件里(Anlogic Design Constraint,语法接近业界通用的 SDC)。引脚约束用 set_pin_assignment

# 基本格式:set_pin_assignment { 信号名 } { 属性列表; }
set_pin_assignment { clk_50m }   { LOCATION = C3;  IOSTANDARD = LVCMOS33; }
set_pin_assignment { rst_n }     { LOCATION = B5;  IOSTANDARD = LVCMOS33; PULLTYPE = PULLUP; }
set_pin_assignment { hdmi_clk_p } { LOCATION = P1;  IOSTANDARD = LVDS25; }
set_pin_assignment { led[0] }    { LOCATION = A12; IOSTANDARD = LVCMOS33; DRIVESTRENGTH = 8; }

三个要素缺一不可:

  1. 信号名:必须和顶层模块的端口名完全一致,大小写敏感。总线用 led[0] 这种下标形式。
  2. LOCATION:物理引脚编号,直接写裸编号C3A12 这种)。注意别写成 PIN_C3PACKAGE_PIN——那是 Vivado 的 XDC 写法,安路 TD 不认。编号来自开发板原理图,不是你随便编的。
  3. IOSTANDARD:电平标准。常见的 LVCMOS33(3.3V)、LVCMOS25(2.5V)、LVCMOS18(1.8V)、LVDS25(差分)。

另外三个常用可选属性,按键、LED、高速信号上基本都会用到:

属性取值什么时候用
PULLTYPEPULLUP / PULLDOWN按键、复位脚,避免悬空误触发
DRIVESTRENGTH4 / 8 / 12 / 16 / 20(mA)驱动 LED、长走线时提高电流
SLEWRATEFAST / SLOW低速场合选 SLOW 降 EMI,高速选 FAST
引脚号去哪儿查 两条路:① 开发板厂商给的原理图 PDF,找到接插件丝印(如 P1_13)对应的 FPGA 引脚名(如 IO_L13P_T2_13),再查芯片 Pinout 表换成 TD 用的裸编号(如 C3);② 更简单——直接抄官方例程的 .adc 文件,把不需要的信号删掉、改成你自己的信号名。做改造型项目时,方法②能省掉九成的查表时间。

差分信号要写两根

HDMI 的 TMDS 是差分对,P 和 N 各占一个物理引脚,两边都得约束:

# 差分对:P/N 必须成对出现,且必须是芯片支持的差分对引脚
set_pin_assignment { hdmi_clk_p } { LOCATION = P1; IOSTANDARD = LVDS25; }
set_pin_assignment { hdmi_clk_n } { LOCATION = P2; IOSTANDARD = LVDS25; }
set_pin_assignment { hdmi_d0_p }  { LOCATION = M4; IOSTANDARD = LVDS25; }
set_pin_assignment { hdmi_d0_n }  { LOCATION = M5; IOSTANDARD = LVDS25; }
不是任意两个引脚都能当差分对 芯片内部把特定的两个引脚硬连到同一个差分比较器上,配对关系是固定的。随便挑两个相邻引脚会让布局阶段直接报错。同样,照抄官方例程的差分对分配最稳妥。

时钟约束:告诉工具跑多快

这是最重要、也最容易被初学者忽略的一步。没有它,工具会「假装」你的电路能跑任意频率,然后板子上出现各种玄学故障。

# 主时钟:周期 20ns = 50MHz,占空比 50%(0ns 上升,10ns 下降)
create_clock -name { sys_clk } -period 20.000 -waveform { 0.000 10.000 } \
             [get_ports { clk_50m }]

我们这套 HDMI 例程里有三个时钟域(第 1 章讲过),PLL 出来的派生时钟用 create_generated_clock 描述:

# 派生时钟:声明来源是 sys_clk,经 PLL 倍频/分频得到
create_generated_clock -name { sd_clk }   -source [get_ports { clk_50m }] \
                       -multiply_by 2 [get_pins { pll_inst/CLKOUT0 }]   # 100 MHz
create_generated_clock -name { video_clk } -source [get_ports { clk_50m }] \
                       -divide_by 2 [get_pins { pll_inst/CLKOUT1 }]     # 25 MHz

一条 derive_pll_clocks 就能让 TD 自动扫描工程里所有 PLL,按 PLL 内部参数批量生成派生时钟约束,不用你一个个手写:

# 自动生成所有 PLL 输出的派生时钟约束(放在 create_clock 之后)
derive_pll_clocks

生成的时钟名形如 pll_inst.CLKOUT0。之后在时序报告里确认列出了 sd_clkvideo_clk 且频率和预期一致即可,不必再手写 create_generated_clock

建议流程 先用 derive_pll_clocks 懒人一把梭,再查时序报告;发现某个输出时钟频率不对时,单独为它补一条 create_generated_clock 覆盖即可。

例外约束:该放过的路径

不是所有跨时钟域路径都需要工具去优化。第 1 章讲过三个时钟域之间的握手,那些已经用两级同步器处理过的路径,应该明确告诉工具「别管它」:

# 假路径:已用同步器处理的跨域信号,不做时序分析
set_false_path -from [get_clocks { sd_clk }] -to [get_clocks { video_clk }]

# 多周期路径:允许数据用 2 个周期才稳定(比如慢速握手信号)
set_multicycle_path 2 -setup -from [get_clocks { sd_clk }] -to [get_clocks { video_clk }]
set_false_path 是双刃剑 它能让时序报告瞬间变干净,但也会掩盖真实问题。只对「确实做了同步处理」的信号使用,并且要写注释说明为什么安全。滥用 false_path 的结果就是:报告全绿,板子跑飞。

I/O 延迟约束:芯片与外部器件的握手

前面讲的时钟约束管的是芯片内部。但我们的信号最终要和外面打交道——SD 卡、SDRAM、HDMI 接收端。工具不知道外部器件什么时候把数据送过来、又要求多快收到,这部分也得你告诉它。

# 输入延迟:外部器件在时钟沿之后多久把数据送到 FPGA 引脚
set_input_delay  -clock { sys_clk } -max 3.5 [get_ports { sd_miso }]
set_input_delay  -clock { sys_clk } -min 1.0 [get_ports { sd_miso }]

# 输出延迟:FPGA 送出的数据,需要在时钟沿之前多久稳定下来
set_output_delay -clock { sys_clk } -max 2.5 [get_ports { sd_mosi }]
set_output_delay -clock { sys_clk } -min 0.5 [get_ports { sd_mosi }]

-max 用于建立时间检查(数据别来太晚),-min 用于保持时间检查(数据别走太早)。数值从外部器件的数据手册里查——通常是手册里的 Tsu(建立时间)和 Th(保持时间),再减去 PCB 走线延时。

不写会怎样? 工具会默认外部延迟为 0,也就是假设「数据在时钟沿瞬间就位」。这在低速接口(按键、LED、UART)上通常也能跑,但在 SD 卡 SPI、SDRAM、HDMI 这类接口上就会出问题——明明仿真全对、时序报告也全绿,上板却偶发丢数据。症状越随机,越要先怀疑 I/O 约束。
值拿不准怎么办 先按目标频率周期的一半给个保守值跑一版(比如 100 MHz 时钟就给 5 ns),看时序报告里这部分路径的裕量。留足余量后再按手册数字收紧。宁可先宽松验证功能,再逐步收紧,别一上来就用手册的极限值。

读懂时序报告

编译完成后,第一件事是打开时序报告看三个数字。它们的含义必须刻进脑子里:

时序裕量(Slack)= 要求到达时间 − 实际到达时间 0 ns 正裕量 负裕量 +3.2 ns ✅ 收敛 −1.8 ns ❌ 违例 数据提前到达,安全 数据迟到,会采错
指标全称含义健康标准
WNSWorst Negative Slack最差的那条路径差多少≥ 0 ns,留 0.5ns 余量更稳
TNSTotal Negative Slack所有违例路径的负裕量之和= 0 ns(一条都不该违例)
WHSWorst Hold Slack保持时间最差余量≥ 0 ns

用一句话理解:

Slack(裕量)就是「时间余量」 数据从寄存器 A 出发,穿过组合逻辑,要在下一个时钟沿之前到达寄存器 B。提前到达 = 正裕量,安全;迟到 = 负裕量,会采到错误数据。WNS = −1.8ns 意味着最慢的那条路径迟到了 1.8 纳秒。

在 TD 里查看:Reports → Timing Report → Summary。如果图表里一条路径都没有,先别高兴——那通常说明你忘了写时钟约束,工具无米下锅。

时序违例的五种解法

看到负数别慌,按下面顺序逐个试,从「改一行」到「改架构」成本递增。

解法 1:降频(最快验证)

把时钟周期放宽,看违例是否消失。如果 100 MHz 违例、降到 80 MHz 就过了,说明逻辑本身没错,只是这条路径太长。这能帮你快速定位问题性质。

# 原来是 -period 10.000(100MHz),改成 12.500(80MHz)试试
create_clock -name { sys_clk } -period 12.500 -waveform { 0.000 6.250 } \
             [get_ports { clk_50m }]

解法 2:插流水寄存器(最常用)

这是 FPGA 优化的第一板斧。一条路径上组合逻辑太多,就在中间切一刀,用一个寄存器把它分成两段,每段的时间压力减半。

// 改前:三级运算串在一条路径上,延时 = A + B + C
always @(posedge clk) begin
    y <= (a * b) + (c * d) + (e * f);   // 三次乘法 + 两次加法,全部挤在一个周期
end

// 改后:打两拍,每周期只做一次乘法
always @(posedge clk) begin
    p1 <= a * b;          // 第 1 拍
    p2 <= c * d;          // 第 1 拍(并行)
    p3 <= e * f;          // 第 1 拍(并行)
    s1 <= p1 + p2;        // 第 2 拍
    y  <= s1 + p3;        // 第 3 拍
end
代价:延迟增加 2 个周期,吞吐不变 这正是第 7 章 OSD 三级流水的设计思路——坐标命中 → 字模生成 → 像素混合,每一级只干一件事。当时讲它是为了「时序干净」,这里给出了量化理由:单级延时从三级串联降到一级,能轻松跑满 25 MHz 像素时钟。

解法 3:逻辑复制降扇出

一个信号驱动太多负载(高扇出),布线会拖得很长。复制一份寄存器,让两半各带一半负载:

// 高扇出的使能信号,复制成两份驱动不同区域
(* keep *) reg en_r1, en_r2;
always @(posedge clk) begin
    en_r1 <= enable;   // 驱动 A 区域
    en_r2 <= enable;   // 驱动 B 区域
end

解法 4:改用专用硬核资源

用 LUT 搭乘法器又慢又占资源,芯片里的 DSP 硬核是专门干这个的。第 7 章淡入淡出里那个 alpha * (new_r - old_r),如果综合器没自动推断成 DSP,可以显式指定:

(* use_dsp = "yes" *) module blend (
    input  wire [7:0] alpha, new_r, old_r,
    output wire [7:0] blend_r
);
    assign blend_r = ((alpha * (new_r - old_r)) >> 8) + old_r;
endmodule

同理,大块存储用 BRAM(第 7 章的字模 ROM 就该放 BRAM),不要拿寄存器堆。

解法 5:检查约束是否合理

有时候违例是约束写错了,不是代码写错了。回头看第 1 章的三个时钟域:如果 sd_clk 和 video_clk 之间确实有异步 FIFO 做隔离frame_read_write 里有),那这两个域之间的路径就该设 false_path——工具不该去优化它们。

排查顺序建议 ⑤(检查约束,零成本)→ ①(降频,验证问题性质)→ ②(插流水,最有效)→ ④(换 DSP/BRAM)→ ③(逻辑复制,最后手段)。先确认约束对不对,再动代码。

报错对照表

下面这些是改造过程中最高频的报错,按出现阶段归类:

报错关键词出现阶段原因怎么改
Multiple Driver综合同一个信号在两个 always 块里赋值合并到一个 always,或拆成两个信号
Combinational Loop综合组合逻辑输出绕回自己输入在环路中插一级寄存器打断
Port not found布局约束里的信号名和顶层端口对不上检查大小写、总线下标写法
Pin already assigned布局两个信号占了同一个引脚查原理图,改掉其中一个
IOSTANDARD conflict布局同一 BANK 里混了 3.3V 和 1.8V把同电平标准的信号归到同一 BANK
Unconstrained clock时序有时钟网络没写 create_clock补上约束,否则时序根本没被检查
Routing congestion布线局部资源用得太满,走线挤不下降低局部逻辑密度或换更大芯片
特别提醒 Unconstrained clock 这条经常只是个 Warning 而不是 Error,容易被忽略。但它的后果是整个时钟域的时序完全没被验证。编译通过 ≠ 时序正确,每次编译后都该扫一眼这个警告。

改造落地上板清单

把第 7 章的三个方案真正推到板子上,按这个清单走:

  1. 建约束文件:从官方例程复制 .adc,删掉无关信号。
  2. 改引脚:只改你新增模块的端口(比如 OSD 不需要新引脚,但若加了按键控制就要补)。
  3. 确认时钟约束:检查 create_clock 周期和实际输入晶振一致。
  4. 单独综合:先跑①,确认无语法/多驱动错误。
  5. 全编译:跑完四步,打开时序报告看 WNS。
  6. 看 WNS:≥ 0 继续;< 0 按上一节五种解法处理。
  7. 下载:JTAG 连板,烧 .bit 验证功能。
  8. 固化:功能没问题后,生成 .bin 烧进外部 Flash,掉电不丢失。
每次只改一个变量 第 7 章末尾说过,这里再强调一次并给出工程理由:同时改代码 + 改约束 + 改时钟,出问题你无法归因。一次动一处,编译一次,记录一次 WNS,这样出问题时能立刻定位。
🧠 小测验
为什么说「没写引脚约束」和「没写时钟约束」的危险程度不一样?
前者会让布局阶段直接报错、编译失败,你一定会发现;后者只是 Warning,工具跳过时序检查一路编译通过,但电路可能跑不稳,问题会潜伏到上板阶段
WNS = −1.8 ns 表示什么?
最慢的那条路径比要求时间晚了 1.8 纳秒,数据到达时已经错过时钟沿,会采到错误值
插流水寄存器为什么能在吞吐不变的前提下改善时序?
把一条长组合路径切成多段,每段延时降低、能跑更高频率;数据延迟几个周期输出,但每个周期仍能进出一个新数据,吞吐率不变
什么时候该用 set_false_path?
只对已经用两级同步器或异步 FIFO 做了跨时钟域处理的路径使用,并写注释说明;滥用会掩盖真实时序问题
差分对引脚可以自己挑两个相邻的吗?
不可以。差分对是芯片内部硬连到同一个比较器的固定配对,必须查 Pinout 表或照抄官方例程
为什么「仿真全对、时序报告全绿」,上板仍可能偶发丢数据?
多半是漏了 set_input_delay / set_output_delay。工具默认外部延迟为 0,SD 卡、SDRAM、HDMI 这类接口的建立/保持时间实际并未被检查
← 上一篇🏠 顶层目录回总览 →