← ブログ一覧へ
FPGA开源工具NextPnR时序分析iCE40ECP5时序收敛关键路径

开源 FPGA 02|时序收敛实战:NextPnR 约束、关键路径分析、违例修复

この記事は中国語で書かれ、Google 翻訳で自動翻訳されています。
中国語の原文を見る →
 _____ ___ __  __ ___ _  _  ___
|_   _|_ _|  \/  |_ _| \| |/ __|
  | |  | || |\/| || || .` | (_ |
  |_| |___|_|  |_|___|_|\_|\___|

   开源 FPGA 实战系列 · 第 02 篇
   时序收敛:让你的设计真正跑起来

系列第 2 篇 · 目标器件:Lattice iCE40HX1K / ECP5 工具版本:Yosys 0.40 · nextpnr-ice40 0.7 · nextpnr-ecp5 0.7 上一篇:iCEstick 开箱第一个项目


0. 这一篇要解决什么问题

你的设计综合通过了,布线也完成了,但 nextpnr 报出一行刺眼的红字:

CRITICAL WARNING: Max frequency for clock 'clk': 62.3 MHz (FAIL at 100.00 MHz)

这就是时序违例(Timing Violation)。它意味着你的电路在目标频率下可能出现随机错误——不是”一定出错”,而是”在某些条件下会出错”,这是最难调试的那类 bug。

本篇目标:

  1. 理解 Setup/Hold Slack 的物理含义,不只是公式
  2. 学会在 nextpnr 中写时序约束
  3. 制造一个真实违例,用工具找到 worst path
  4. 通过流水线修复,Fmax 从 62 MHz → 98 MHz
  5. 理解 ECP5 和 iCE40 时序引擎的差异

本篇不覆盖:

  • 跨时钟域(CDC)处理
  • 异步复位同步化
  • SDC 文件(nextpnr 用 .pcf,不是 SDC)

1. 时序基础:用物理直觉理解公式

1.1 Setup Time Violation

           T_clk(时钟周期)
    ┌──────────────────────────────────┐
    │                                  │
clk ─┤                                  ├──────
    │                                  │
         ↑ 上升沿1                      ↑ 上升沿2

    ┌─── T_cq ──┐
    │           │
FF_out ─────────┤ Q_valid ├──────────────
    (FF 输出延迟)│         │
                └── T_logic ──┐
                (组合逻辑延迟) │
                              └── T_setup 要求 ──┐
                                (FF 建立时间)   │
                                                 ↑ 上升沿2 时,D 必须稳定

Setup Slack(建立时间余量):

Slack_setup = T_clk - T_cq - T_logic - T_setup

其中:
  T_clk   = 时钟周期 = 1/Fmax
  T_cq    = FF 输出到输出的延迟(iCE40 SB_DFF ≈ 0.5 ns)
  T_logic = 组合逻辑路径延迟(取决于 LUT 数量和布线)
  T_setup = 目标 FF 的建立时间(iCE40 SB_DFF ≈ 0.3 ns)
  • Slack > 0:时序满足(PASS)
  • Slack < 0:时序违例(FAIL),违例量 = |Slack|

最高频率(Fmax):

Fmax = 1 / (T_cq + T_logic + T_setup)

这个公式说明:要提高 Fmax,只能减少 T_logic(组合逻辑延迟)。

1.2 Hold Time Violation

Hold Slack = T_cq + T_logic - T_hold

T_hold = FF 的保持时间要求

Hold violation 在 FPGA 上几乎不需要担心(原因见避坑 1)。

1.3 iCE40 关键延迟数据

原语延迟说明
SB_LUT4 输入→输出~0.6 ns单个 LUT 组合延迟
SB_DFF 时钟→Q~0.5 nsT_cq
SB_DFF 建立时间~0.3 nsT_setup
布线延迟(单段)~0.2-0.8 ns取决于扇出和距离
CARRY 链延迟~0.2 ns/bit加法器专用

推论: 不经优化的 8-bit 乘法器(展开成全加器树),T_logic ≈ 4×0.6 + 7×0.2 ≈ 3.8 ns,则 Fmax ≈ 1/(0.5+3.8+0.3) ≈ 217 MHz……这听起来很好,但乘法器在 nextpnr 的实际布线后,布线延迟可能把总延迟推到 15 ns 以上,Fmax 跌破 70 MHz。


2. 制造一个时序违例

2.1 问题设计:深组合逻辑链

// slow_multiplier.v
// 故意不用流水线:8x8 乘法器 + 加法器 + 比较器,直接寄存输出
// 这条组合逻辑链在 iCE40 上会产生严重时序违例

module slow_multiplier (
    input  wire        clk,
    input  wire [7:0]  a,
    input  wire [7:0]  b,
    input  wire [7:0]  c,      // 额外的加法输入,让路径更长
    input  wire [7:0]  threshold,
    output reg  [15:0] product, // a*b + c 的结果
    output reg         over_thresh  // product > threshold
);

    // ─── 纯组合逻辑路径(非常长)─────────────────────────
    // 1. 8x8 无符号乘法(Yosys 会展开成 Carry 链加法树)
    wire [15:0] mul_result = a * b;
    // 2. 加上偏移量(再过一层全加器)
    wire [15:0] add_result = mul_result + {8'b0, c};
    // 3. 比较(再过一层比较逻辑)
    wire        cmp_result = add_result > {8'b0, threshold};
    // ──────────────────────────────────────────────────────

    // 所有这些组合逻辑都要在一个时钟周期内完成!
    always @(posedge clk) begin
        product     <= add_result;    // 15+ ns 的组合逻辑直接打寄存器
        over_thresh <= cmp_result;
    end

endmodule

2.2 综合并布局布线

# 综合
yosys -p "
  read_verilog slow_multiplier.v;
  synth_ice40 -top slow_multiplier -json slow_mul.json;
  stat
"

# 综合资源输出:
# Number of cells:  156
#   SB_CARRY        32
#   SB_DFF          17
#   SB_DFFE          0
#   SB_LUT4        107

# 布局布线(目标 100 MHz)
nextpnr-ice40 \
  --hx1k \
  --package tq144 \
  --json slow_mul.json \
  --pcf slow_mul.pcf \
  --asc slow_mul.asc \
  --freq 100 \
  --report timing.json \
  2>&1 | tee pnr.log

nextpnr 输出的违例信息:

CRITICAL WARNING: Max frequency for clock 'clk': 62.3 MHz (FAIL at 100.00 MHz)

Info: Critical path report for clock 'clk' (posedge to posedge):
Info: curr total
Info:  0.5   0.5  Source slow_multiplier.a[3]  (SB_DFF)
Info:  0.8   1.3  Net a[3]
Info:  0.6   1.9  CARRY chain: bit 3
Info:  0.3   2.2  Net carry[3]
...(省略 12 行)...
Info:  1.1  14.7  Net product_next[13]
Info:  0.3  15.0  Setup time for slow_multiplier.product[13] (SB_DFF)

Info: Slack: -4.98 ns (target: 10.0 ns @ 100 MHz)

Fmax = 62.3 MHz,目标 100 MHz,违例 4.98 ns


3. NextPnR 时序约束语法

3.1 .pcf 时序约束

# slow_mul.pcf
# 引脚约束
set_io clk 21
set_io a[0] 1
set_io a[1] 2
# ... (省略其余引脚)

# ── 时序约束(iCE40 .pcf 格式)──

# 创建时钟约束:指定时钟引脚和目标周期
# 语法:create_clock -period <ns> -name <name> [port]
create_clock -period 10 -name clk [get_ports clk]
# 等价于指定 100 MHz

# False Path:告诉工具忽略某条路径的时序
# 用于异步信号、上电配置寄存器等
# set_false_path -from [get_ports rst_n] -to [all_registers]

# Multicycle Path:允许某条路径跨多个时钟周期
# 用于低速数据路径(如配置寄存器,只在启动时写一次)
# set_multicycle_path 2 -from [get_cells config_reg] -to [get_cells process_reg]

注意: nextpnr-ice40 的 .pcf 约束能力有限,不支持完整的 SDC 语法。 create_clock 在某些版本需要改用命令行参数 --freq

3.2 nextpnr 命令行时序参数

# 完整的时序约束命令行示例
nextpnr-ice40 \
  --hx1k \
  --package tq144 \
  --json design.json \
  --pcf design.pcf \
  --asc design.asc \
  --freq 100 \              # 目标频率 100 MHz(等价于 create_clock -period 10)
  --report timing.json \    # 输出详细时序报告(JSON 格式)
  --timing-allow-fail \     # 允许时序违例(继续生成 .asc,不报错退出)
  --placer heap \           # 快速放置器
  --seed 1 \                # 固定随机种子(可复现)
  2>&1 | tee pnr.log

4. 解析 timing.json:找出 Worst Path

NextPnR 生成的 timing.json 包含所有路径的详细延迟数据。

4.1 timing.json 结构

{
  "clk": {
    "achieved": 62.3,
    "constraint": 100.0,
    "paths": [
      {
        "slack": -4.98,
        "source": "slow_multiplier.a_reg[3]",
        "sink": "slow_multiplier.product[13]",
        "segments": [
          {"type": "startpoint", "delay": 0.5, "description": "..."},
          {"type": "routing",    "delay": 0.8, "description": "..."},
          {"type": "logic",      "delay": 0.6, "description": "CARRY"},
          ...
        ],
        "total_delay": 15.02
      },
      ...
    ]
  }
}

4.2 Python 脚本:提取 Worst Path

#!/usr/bin/env python3
# parse_timing.py - 解析 nextpnr timing.json,输出最差路径报告

import json
import sys
from pathlib import Path


def parse_timing_report(json_file: str):
    data = json.loads(Path(json_file).read_text())

    print("=" * 70)
    print(f"{'Timing Report':^70}")
    print("=" * 70)

    for clock_name, clock_data in data.items():
        achieved = clock_data.get("achieved", 0)
        constraint = clock_data.get("constraint", 0)
        status = "✅ PASS" if achieved >= constraint else "❌ FAIL"

        print(f"\nClock: {clock_name}")
        print(f"  Achieved: {achieved:.2f} MHz  |  Constraint: {constraint:.2f} MHz  |  {status}")

        paths = clock_data.get("paths", [])
        if not paths:
            print("  No path data available.")
            continue

        # 按 slack 排序(最差的在前)
        sorted_paths = sorted(paths, key=lambda p: p.get("slack", 999))

        print(f"\n  Top 5 Worst Paths:")
        print(f"  {'Slack':>8}  {'Total':>8}  {'Source':<30}  {'Sink':<30}")
        print(f"  {'-'*8}  {'-'*8}  {'-'*30}  {'-'*30}")

        for path in sorted_paths[:5]:
            slack = path.get("slack", 0)
            total = path.get("total_delay", 0)
            src   = path.get("source", "?")[-30:]
            sink  = path.get("sink",   "?")[-30:]
            print(f"  {slack:>+8.2f}  {total:>7.2f}n  {src:<30}  {sink:<30}")

        # 展开 worst path 的每一段
        worst = sorted_paths[0]
        print(f"\n  Worst Path Breakdown (slack = {worst['slack']:+.2f} ns):")
        print(f"  {'Cum':>6}  {'Seg':>6}  Type         Description")
        print(f"  {'-'*6}  {'-'*6}  {'-'*12}  {'-'*40}")

        cumulative = 0
        for seg in worst.get("segments", []):
            d    = seg.get("delay", 0)
            cumulative += d
            stype = seg.get("type", "?")[:12]
            desc  = seg.get("description", "")[:40]
            print(f"  {cumulative:>5.1f}n  {d:>5.1f}n  {stype:<12}  {desc}")

    print("\n" + "=" * 70)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} timing.json")
        sys.exit(1)
    parse_timing_report(sys.argv[1])

运行:

python3 parse_timing.py timing.json

# 输出示例:
# ======================================================================
#                           Timing Report
# ======================================================================
#
# Clock: clk
#   Achieved: 62.30 MHz  |  Constraint: 100.00 MHz  |  ❌ FAIL
#
#   Top 5 Worst Paths:
#     Slack      Total  Source                          Sink
#     --------  -------  ------------------------------  -----...
#      -4.98n   15.02n  slow_multiplier.a_reg[3]       slow_multiplier.product[13]
#      -4.85n   14.89n  slow_multiplier.a_reg[5]       slow_multiplier.product[15]
#      ...

5. 修复时序违例:插流水线

5.1 修复策略

对于我们的乘法器,组合路径太深(15 ns),解决方案是插入流水线寄存器,把一个长路径切成两个短路径:

修复前:
FF_in ─── [8x8乘法:8 ns] ─── [加法:4 ns] ─── [比较:3 ns] ─── FF_out
          └──────────────── 15 ns total ─────────────────┘

修复后(2级流水线):
FF_in ─── [8x8乘法:8 ns] ─── FF_pipe ─── [加法+比较:7 ns] ─── FF_out
          └─── 8 ns ──────┘             └──── 7 ns ───────┘
          Stage 1: Fmax ≈ 111 MHz      Stage 2: Fmax ≈ 125 MHz
          Combined Fmax ≈ 111 MHz(取最慢阶段)

5.2 修复后的 Verilog

// fast_multiplier.v
// 2级流水线版本:乘法(stage1)→ 加法+比较(stage2)
// 代价:输出延迟增加 1 个时钟周期,增加约 12 个 FF

module fast_multiplier (
    input  wire        clk,
    input  wire [7:0]  a,
    input  wire [7:0]  b,
    input  wire [7:0]  c,
    input  wire [7:0]  threshold,
    output reg  [15:0] product,
    output reg         over_thresh
);

    // ── Stage 1:只做乘法(延迟 ~8 ns → Fmax ~111 MHz)──
    reg [15:0] mul_reg;   // 流水线寄存器
    reg [7:0]  c_reg;     // c 需要跟着流水线走
    reg [7:0]  thresh_reg;

    always @(posedge clk) begin
        mul_reg   <= a * b;         // 8x8 乘法结果
        c_reg     <= c;             // 同步延迟 c
        thresh_reg <= threshold;    // 同步延迟 threshold
    end

    // ── Stage 2:加法 + 比较(延迟 ~7 ns → Fmax ~125 MHz)──
    wire [15:0] add_result = mul_reg + {8'b0, c_reg};
    wire        cmp_result = add_result > {8'b0, thresh_reg};

    always @(posedge clk) begin
        product     <= add_result;
        over_thresh <= cmp_result;
    end

endmodule

5.3 修复后的综合和布线

# 重新综合
yosys -p "
  read_verilog fast_multiplier.v;
  synth_ice40 -top fast_multiplier -json fast_mul.json;
  stat
"

# stat 输出:
# Number of cells:  169   ← 增加了约 12 个 FF(流水线寄存器)
#   SB_CARRY        32
#   SB_DFF          29    ← 从 17 增加到 29(增加了 mul_reg + c_reg + thresh_reg)
#   SB_DFFE          0
#   SB_LUT4        108

# 重新 P&R
nextpnr-ice40 \
  --hx1k \
  --package tq144 \
  --json fast_mul.json \
  --pcf fast_mul.pcf \
  --asc fast_mul.asc \
  --freq 100 \
  --report timing_fixed.json

# 输出:
# Info: Max frequency for clock 'clk': 98.1 MHz (PASS at 100.00 MHz)
# ...
# Info: Slack: +1.9 ns

5.4 修复前后对比

指标修复前(单周期)修复后(2级流水线)
Fmax62.3 MHz98.1 MHz
Setup Slack-4.98 ns(违例!)+1.9 ns(通过)
LUT 数量107108
FF 数量1729(+12 个流水线 FF)
吞吐量1 result/cycle(但频率受限)1 result/cycle(稳定)
延迟1 cycle(T=16 ns @ 62 MHz)2 cycles(T=20 ns @ 100 MHz)

延迟增加 1 个周期,但吞吐量大幅提升(62 → 98 Mops/s)。

🚧 避坑 1:Hold Violation 在 FPGA 上通常不用担心

Hold violation 的条件是 T_cq + T_logic < T_hold。 在 FPGA 内部,布线延迟通常 ≥ 0.2 ns,而 iCE40 的 T_hold ≈ 0.1 ns。 因此内部信号路径几乎不可能出现 hold violation。

什么时候需要担心 hold?

  • FPGA 输出到另一个 FPGA 输入(板级信号,布线延迟几乎为 0)
  • 跨不同时钟域的亚稳态(需要同步化,不是 hold 问题)
  • 高速 DDR 接口(PHY 层,工具会自动处理)

经验法则:如果你没有使用 DDR/SERDES,看到 hold violation 先检查约束是否写错了。


6. ECP5 vs iCE40 时序引擎差异

当你从 iCE40 切换到 ECP5 时,时序行为有几个重要差异:

iCE40 vs ECP5 · 逻辑单元架构对比 Logic Cell vs Slice — 决定时序与逻辑密度的核心差异 iCE40 · Logic Cell (LC) 1 LC = 1 LUT4 + 1 FF + carry LUT4 4-input DFF SR/Cy LUT 与 FF 紧密耦合 · 资源粒度细 规模 HX1K: 1,280 LC · HX8K: 7,680 LC LUT 延迟 ~0.6 ns 功耗 极低 · 适合电池/IoT 工具链 yosys + nextpnr-ice40 · icepack ECP5 · Slice 1 Slice = 4 LUT4 + 4 FF + carry + DSP LUTLUTLUTLUT FFFFFFFF CARRY + DSP 逻辑密度 2–3× · 内置 18×18 乘法器 规模 25F: 24K LUT · 85F: 84K LUT LUT 延迟 ~0.35 ns(40nm 工艺) 功耗 中等 · 适合高性能边缘 工具链 yosys + nextpnr-ecp5 · ecppack 迁移启示 同样的 RTL,从 iCE40 移植到 ECP5:LUT 数会减少 ~50–60%(逻辑被打包到 Slice) 但 Fmax 的提升不止源于工艺——专用布线资源 + DSP 硬核更是关键
图 6.1 · iCE40 LC 与 ECP5 Slice 架构与时序对比
时序参数iCE40HX1KECP5-25F说明
LUT 延迟~0.6 ns~0.35 nsECP5 工艺更先进(40nm vs 28nm)
FF T_cq~0.5 ns~0.35 ns
FF T_setup~0.3 ns~0.2 ns
Carry 链~0.2 ns/bit~0.12 ns/bit
布线延迟较高(SB_CARRY 结构限制)较低(专用布线资源更多)
实际 Fmax(相同设计)~62-98 MHz~100-160 MHzECP5 高约 50%
全局时钟网络8 条24 条ECP5 支持更多时钟域
PLL1 个2 个(EHXPLLL)

ECP5 的时序约束(通过 .lpf 文件):

# design.lpf(ECP5 约束文件,类似 .pcf)
LOCATE COMP "clk"    SITE "P6";
IOBUF PORT "clk"     IO_TYPE=LVCMOS33;
FREQUENCY PORT "clk" 100.0 MHz;

# ECP5 支持更完整的约束语法
DEFINE PORT GROUP "input_grp" "a[0]" "a[1]" ... "a[7]";

🚧 避坑 2:Seed 扫描脚本

对于时序紧张的设计(Slack < +2 ns),不同的 nextpnr seed 可能导致 ±5-10 MHz 的 Fmax 差异。 在提交最终设计之前,最好扫描多个 seed:

#!/bin/bash
# scan_seeds.sh - 扫描最优布线种子
BEST_FREQ=0
BEST_SEED=1

for SEED in $(seq 1 20); do
    FREQ=$(nextpnr-ice40 \
        --hx1k --package tq144 \
        --json design.json --pcf design.pcf \
        --asc /tmp/design_s${SEED}.asc \
        --freq 100 --seed $SEED \
        --timing-allow-fail \
        2>&1 | grep "Max frequency" | awk '{print $5}' | tr -d 'MHz')

    echo "Seed $SEED: $FREQ MHz"

    if (( $(echo "$FREQ > $BEST_FREQ" | bc -l) )); then
        BEST_FREQ=$FREQ
        BEST_SEED=$SEED
        cp /tmp/design_s${SEED}.asc design_best.asc
    fi
done

echo "Best: Seed $BEST_SEED = $BEST_FREQ MHz"

实测:同一个设计,seed=1 得到 88 MHz,seed=7 得到 98 MHz——差距很大。


7. 其他修复手段

7.1 逻辑重排

有时候长路径是因为综合器生成了低效的逻辑结构。可以通过调整 Verilog 代码引导综合器:

// 低效写法(综合器可能生成深逻辑树)
assign result = (a & b) | (c & d) | (e & f) | (g & h);

// 更好的写法(二叉树结构,减少关键路径深度)
wire ab = a & b;
wire cd = c & d;
wire ef = e & f;
wire gh = g & h;
wire abcd = ab | cd;
wire efgh = ef | gh;
assign result = abcd | efgh;

7.2 资源复用(减少扇出)

高扇出信号的布线延迟很大:

// 高扇出寄存器(clk_en 驱动 100 个 FF 的使能端)
// 布线延迟可达 2-3 ns
wire clk_en = (state == STATE_RUN);

// 解决方法:复制寄存器(Retiming/Replication)
// 让综合器自动处理,或手动复制:
reg clk_en_copy1, clk_en_copy2;
always @(posedge clk) begin
    clk_en_copy1 <= (state == STATE_RUN);  // 用于前 50 个 FF
    clk_en_copy2 <= (state == STATE_RUN);  // 用于后 50 个 FF
end

7.3 Yosys 综合优化选项

# 更激进的综合优化
yosys -p "
  read_verilog design.v;
  synth_ice40 -top design -abc9 -json design.json
"
# -abc9: 使用 ABC9 引擎(比默认 ABC 更激进,通常能改善 5-10% 时序)

🚧 避坑 3:Placement Constraint 的副作用

有时候为了固定某个模块的位置(比如让 UART 的 FF 靠近 I/O pin),会用 nextpnr 的 --pcf 里添加 set_io -pullup yes 或类似约束。

但如果约束太严,nextpnr 的布线器无法找到满足时序的解,会出现:

  • ERROR: Failed to route net ...
  • 时序比无约束时更差

解决策略:

  1. 先不加任何 placement 约束,让工具自由放置,建立时序基线
  2. 只在有明确物理需求时(如 DDR 引脚、高速差分对)才加约束
  3. 加约束后一定要重新验证时序

8. 本篇 checklist / 验证步骤

  • 能用公式手算 blinky 的 Fmax(应该远超 100 MHz)
  • 综合 slow_multiplier.v,用 --freq 100 触发时序违例
  • --report timing.json 生成报告,运行 parse_timing.py 找到 worst path
  • 综合 fast_multiplier.v,验证 Fmax 提升到 98+ MHz
  • 运行 seed 扫描脚本,找出最优 seed
  • 对比 timing_fixed.json 和原 timing.json 的 worst path,确认关键路径变短

9. 下一篇预告

开源 FPGA 03:形式验证入门 将从”运行测试”跳跃到”数学证明”:

  • 用 SymbiYosys + SMT 求解器证明 FIFO 永远不会 overflow
  • assert / assume / cover 的写法
  • BMC(有界模型检查)vs Induction(归纳证明)的区别
  • 2 秒发现 bug vs 100 小时随机测试

如果你对 UART echo 加了 FIFO 缓冲(避免数据丢失),第 03 篇会教你如何证明那个 FIFO 的逻辑是正确的,而不只是”测试了几百万次没出问题”。


参考资料

资源链接 / 文档号说明
NextPnR 时序文档YosysHQ/nextpnr/timing.mdtiming report 格式详解
iCE40 LP/HX 系列手册Lattice iCE40-SYSC01-01.10原语延迟数据(T_cq, T_setup)
ECP5 数据手册Lattice DS1044ECP5 时序参数
Yosys ABC9 集成YosysHQ/yosys/passes/techmap/abc9.ccABC9 vs ABC 差异
流水线设计模式Patterson & Hennessy, Computer Organization and Design流水线原理
nextpnr 放置算法N. Luo et al., “nextpnr: A Timing-Driven FPGA Placer”, FPGA 2020SA vs Heap 放置器对比

🦞 Kaiyo 的硬件工程日志

时序违例是 FPGA 开发的”成人礼”。每个工程师都会在某天第一次看到那行红字,然后花几个小时搞清楚为什么——那个过程很痛苦,但理解了之后,你会发现 FPGA 的每一个 LUT 都变得更透明了。