时序约束基础:时钟

时钟,是数字设计的脉搏。做时序约束,说白了就是先把时钟这颗“心脏”给定义清楚。很多新手觉得 create_clock 不就是写个周期嘛?但实际项目中,时钟树一复杂,坑就来了。

时钟从哪里来?

先记一个原则:所有时序路径,都必须有一个已知的时钟起点。在设计中,时钟的来源主要有两种:

  • 外部输入:晶振、时钟发生器,通过管脚进来。
  • 内部生成:PLL、MMCM,从已有的时钟分频或倍频得到。

对于外部时钟,我们给它套上 create_clock。对于内部生成的时钟,我们用 create_generated_clock 来描述它和源时钟的关系。

个人习惯:我通常会在项目一开始,就先拉一张表格,把板级送进来的所有时钟频率、占空比、相位列清楚。这样后续写约束就不会乱。记住,create_clock 只定义时钟的物理波形,不改变设计的功能。

核心命令:create_clock

这个命令,是时序约束的基石。它的基本语法就像这样:

create_clock -name clk_sys \
             -period 10.000 \
             [get_ports clk_in]

-period 10.000 含义是时钟周期为 10ns,对应的频率就是 100MHz。get_ports clk_in 是指出这个时钟是从哪个顶层管脚进来的。

我刚开始做设计时,总觉得周期给得越紧,工具优化越卖力。结果有一次,我随手写了个 -period 8.6(约116MHz),但板级晶振其实是 100MHz。综合工具为了“满足”这个不存在的快时钟,白白塞了很多冗余逻辑进去,功耗和面积都超标。从那以后,我每次都先跟硬件工程师对一遍晶振型号。

时钟周期和占空比

这两个参数,直接决定了时间窗口的长短。

  • 时钟周期:一个完整上升沿到下一个上升沿的时间。它定义了逻辑必须在多快的时间内完成计算。
  • 占空比:高电平持续时间占整个周期的比例。默认是 50%,也就是高、低电平各占一半。

万一你的时钟不是 50% 占空比怎么办?create_clock

create_clock -name clk_ddr \
             -period 5.000 \
             -waveform {0.000 2.500}

-waveform 的第一个数是上升沿时间,第二个数是下降沿时间。上面这个例子,上升沿在 0ns,下降沿在 2.5ns(周期的一半),占空比就是 50%。
如果改成 {0.000 1.800},那么高电平 1.8ns,低电平 3.2ns,占空比 36%。

在 DDR 接口约束中,占空比不对称会直接影响数据眼图的中心位置。我个人习惯在这种场景下,把 waveform 写得非常精确,不要偷懒用默认值。

时钟源:谁才是真正的「根」?

当设计中存在多个时钟域,或者有多个同频不同相的时钟时,必须清楚它们的源点在哪里。先看一段 SDC:

create_clock -name clk_a -period 10.000 [get_ports clk_in_a]
create_clock -name clk_b -period 10.000 [get_ports clk_in_b]

假设 clk_in_a 和 clk_in_b 来自同一块晶振的两个输出管脚。在理想世界里,它们完全同频同相。但在实际布局布线中,管脚到触发器时钟端的路径长度不同,会产生不同的时钟插入延迟(Clock Latency)。你不能默认它们天生对齐,必须在约束中明确它们的关系。

我曾经吃过一个亏:板上有两个 PLL,输出时钟通过专用时钟网络送到 FPGA 的两个 BANK。我以为它们同频且相位对齐,就没做跨时钟域同步。结果上板测试,数据隔三差五就错一次。后来用示波器一抓,两个时钟的相位差随着温度和电压在漂移。嗯,硬件没说,我也没问,这个锅我背了。

避坑指南:如果一个时钟是通过 ODDR、BUFGMUX、BUFIO 等原语转出来的,它的“源”仍然是原始的 create_clock 点。不要重复创建,要用 create_generated_clock 来描述它的生成逻辑。

生成时钟:create_generated_clock

当内部逻辑对时钟做了分频、倍频、相移或反相后,衍生出的新时钟,就叫生成时钟。如果你只定义源时钟,不定义生成时钟,STA 工具就不知道新时钟的存在,导致很多路径不被检查。

来看一个典型的毫米波(MMCM)输出:

create_clock -name clk_100m -period 10.000 [get_ports ref_clock]

create_generated_clock -name clk_200m \
                       -source [get_ports ref_clock] \
                       -multiply_by 2 \
                       -divide_by 1 \
                       [get_pins mmcm_inst/clk_out1]
create_generated_clock -name clk_div2 \
                       -source [get_pins mmcm_inst/clk_out1] \
                       -divide_by 2 \
                       [get_pins some_ff/Q]

看到没?第一个生成时钟的 -source 是 ref_clock 管脚,第二个生成时钟的 -source 是 mmcm_inst/clk_out1。这串链条,必须完整。

还有一种特殊情况:时钟频率不变,只做移相。比如读取源同步数据时,需要把采样时钟偏移 90°:

create_generated_clock -name clk_shift_90 \
                       -source [get_pins pll/clk_0] \
                       -edges {1 2 3} \
                       -edge_shift {0.000 1.250 2.500} \
                       [get_pins pll/clk_90]

-edges 表示波形编号(1:上升沿、2:下降沿、3:下一个上升沿),-edge_shift 就是针对每个沿的偏移量。这个写法,我当初看手册时也晕,后来对着波形图比划了两次就明白了——说白了就是手动把整个周期切了一刀。

一点经验:在含有大量异步 FIFO 的设计中,生成时钟的 -source 选哪个点,直接决定了跨时钟域路径的分析范围。选错了,工具要么漏报,要么把不相关的路径也算进来。我会把 get_pins 的路径打印出来,跟 RTL 里的一一对照。

为了把今天这几个概念串起来,我画了一张图,把主时钟、生成时钟和它们之间的关系讲明白:

时钟约束知识体系:主时钟与生成时钟 时钟约束核心逻辑 主时钟 create_clock 分频 / 倍频 create_generated_clock 移相 / 反相 -edges / -edge_shift STA 工具检查 setup/hold 时序路径 时钟传播延迟 跨时钟域约束 关键规则 1. 所有生成时钟必须有 source 2. 每一条生成路径只能一个源 3. 时钟名在 SDC 内必须唯一 4. 不要对生成时钟再 create_clock

最后,我想强调一点:

做时钟约束,不只是写几行命令。 你得先搞清楚:你的设计里有多少个独立的时钟域?哪些是异步的?哪些是同步但相位不同?把这些画成一张时钟关系图,再动笔写 SDC。我每次评审别人代码时,第一步就是让他把他的时钟拓扑画出来。画不清楚的,代码多半也有问题。