先建立一个正确的心智模型,后面所有操作都围绕它展开。
写 MCU 的 C 语言时,编译器知道芯片型号、知道主频、知道外设挂在哪个引脚——这些信息都写在工程配置里。FPGA 不一样:Verilog 只描述「逻辑」,不描述「物理」。你写 assign blend_r = ...,工具完全不知道这个信号将来从芯片的哪个脚出去、也不知道时钟是 25 MHz 还是 100 MHz。
| 信息类型 | MCU 开发 | FPGA 开发 |
|---|---|---|
| 代码含义 | 你写 C | 你写 Verilog |
| 引脚位置 | 图形化配置 Pin Mux | 必须手写约束文件 |
| 时钟频率 | 配置时钟树自动生效 | 必须手写 create_clock |
| 速度是否达标 | 主频固定,基本不用管 | 要看时序报告,可能不收敛 |
所以第 7 章的三个改造,代码层面的思路已经清楚了,但要落地还差这份「图纸」。下面按编译流程的顺序讲,因为每一步需要的约束不一样。
点一次「Run All」,工具内部其实串了四个独立阶段。搞清楚每阶段的输入输出,报错时才知道该查哪儿。
| 阶段 | 输入 | 输出 | 典型报错 |
|---|---|---|---|
| ① 综合 | .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; }
三个要素缺一不可:
led[0] 这种下标形式。C3、A12 这种)。注意别写成 PIN_C3 或 PACKAGE_PIN——那是 Vivado 的 XDC 写法,安路 TD 不认。编号来自开发板原理图,不是你随便编的。LVCMOS33(3.3V)、LVCMOS25(2.5V)、LVCMOS18(1.8V)、LVDS25(差分)。另外三个常用可选属性,按键、LED、高速信号上基本都会用到:
| 属性 | 取值 | 什么时候用 |
|---|---|---|
PULLTYPE | PULLUP / PULLDOWN | 按键、复位脚,避免悬空误触发 |
DRIVESTRENGTH | 4 / 8 / 12 / 16 / 20(mA) | 驱动 LED、长走线时提高电流 |
SLEWRATE | FAST / SLOW | 低速场合选 SLOW 降 EMI,高速选 FAST |
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_clk、video_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 }]
前面讲的时钟约束管的是芯片内部。但我们的信号最终要和外面打交道——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 走线延时。
编译完成后,第一件事是打开时序报告看三个数字。它们的含义必须刻进脑子里:
| 指标 | 全称 | 含义 | 健康标准 |
|---|---|---|---|
| WNS | Worst Negative Slack | 最差的那条路径差多少 | ≥ 0 ns,留 0.5ns 余量更稳 |
| TNS | Total Negative Slack | 所有违例路径的负裕量之和 | = 0 ns(一条都不该违例) |
| WHS | Worst Hold Slack | 保持时间最差余量 | ≥ 0 ns |
用一句话理解:
在 TD 里查看:Reports → Timing Report → Summary。如果图表里一条路径都没有,先别高兴——那通常说明你忘了写时钟约束,工具无米下锅。
看到负数别慌,按下面顺序逐个试,从「改一行」到「改架构」成本递增。
把时钟周期放宽,看违例是否消失。如果 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 }]
这是 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
一个信号驱动太多负载(高扇出),布线会拖得很长。复制一份寄存器,让两半各带一半负载:
// 高扇出的使能信号,复制成两份驱动不同区域
(* keep *) reg en_r1, en_r2;
always @(posedge clk) begin
en_r1 <= enable; // 驱动 A 区域
en_r2 <= enable; // 驱动 B 区域
end
用 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),不要拿寄存器堆。
有时候违例是约束写错了,不是代码写错了。回头看第 1 章的三个时钟域:如果 sd_clk 和 video_clk 之间确实有异步 FIFO 做隔离(frame_read_write 里有),那这两个域之间的路径就该设 false_path——工具不该去优化它们。
下面这些是改造过程中最高频的报错,按出现阶段归类:
| 报错关键词 | 出现阶段 | 原因 | 怎么改 |
|---|---|---|---|
| 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 | 布线 | 局部资源用得太满,走线挤不下 | 降低局部逻辑密度或换更大芯片 |
把第 7 章的三个方案真正推到板子上,按这个清单走:
.adc,删掉无关信号。create_clock 周期和实际输入晶振一致。.bit 验证功能。.bin 烧进外部 Flash,掉电不丢失。