1. TDC设计思路拆解:为什么延时链能测出皮秒级时间差
做高精度时间测量的人,基本都绕不开TDC(Time-to-Digital Converter,时间数字转换器)这个词。激光测距、PET扫描、高能物理粒子探测、示波器触发系统,甚至手机上的激光对焦模块,核心都在比谁的时间差小、谁测得更准。传统方案用ADC采样模拟电压来推算时间间隔,分辨率受限于ADC的位数和带宽,做到几百皮秒已经压力很大;而TDC的思路完全不同,它是直接把时间间隔量化成数字码,不经过模拟电压这一层,天然就比ADC路线更适用于“纯时间差”测量场景。
那FPGA和TDC有什么关系?关键在于FPGA内部有一种专用硬件结构——进位链(Carry Chain)。常规逻辑单元LUT的延迟不是为精密延时设计的,受布局布线影响很大,你不能指望把它串联起来做高一致性延时;但进位链不一样,它是FPGA里一条物理上已经固定好的快速传输路径,从相邻逻辑单元一个接一个往下传,每个进位单元本身的传播延迟非常稳定,而且基本由硅片工艺决定。业界常说的“抽头延迟线法”(Tapped Delay Line)落在FPGA里,就是把这串进位链当精密延时线:信号进入延时链后,每个进位节点用触发器打一拍,记录信号走到了哪个位置,从而反推出时间差。
这篇博文要解决的,就是用FPGA进位链把TDC做到几十皮秒级别的延时分辨率。适合正在做精密时间测量相关项目、但又不想直接用专用TDC芯片(比如ACAM的GPX系列)而被成本和供货卡脖子的朋友;也适合对FPGA内部结构好奇、想把手里的开发板玩出更高价值的同学。后面所有内容都以Xilinx 7系列为例子展开(我用的是Artix-7),但原理和流程对其他厂商FPGA同样通用,Altera/Intel的LE进位链、Lattice的Carry Chain大同小异,稍微调整原语名称和布局约束就行。
设计的第一步不是写代码,而是把需求量化。TDC的几个核心指标:分辨率(LSB尺寸,即一个延时单元多少皮秒)、测量范围(最大可测时间差)、非线性(DNL/INL)、死区时间(两次测量之间的最小间隔)。这些指标会直接决定你选用多少级进位链、跑多高频率、编码逻辑怎么做。我当时的指标需求是:分辨率优于50ps,单次测量范围不小于50ns(应对超声飞行时间测量的场景),死区小于1μs。
这里先插一个非常重要的认知:FPGA进位链的延时并不是无限可分的,它的最小单位是一个CARRY4模块内部MUX的传输延迟。在Xilinx 7系列里,一个CARRY4由4个进位单元组成,每个进位单元(从CIN到COUT或从CIN到O)的延迟大约在20~30ps之间(不同速度等级、温度电压下有差异)。也就是说,如果你把300个进位单元串起来,理想情况下可以得到约6~9ns的总延时——这刚好对应一个200~150MHz时钟周期。所以在设计测量范围时,你要先确认:延时链的总长度必须覆盖至少一个系统时钟周期,否则会有测量盲区,信号会在时钟采样前就已经跑丢。
这背后还有一个更本质的问题:为什么非要用进位链?不用普通的查找表LUT级联或者直接用组合逻辑延迟做延时线?答案是:可重复性和温度一致性。LUT到LUT的布线延迟由布局布线决定,你无法控制路径长短,信号可能走长线也可能走短线,跑出来的延时分布很乱,而且温度一变化各路径的漂移还不一致;进位链则是一条物理上等间距排列的专用路径,单元之间距离固定、走线固定、每级延迟相对均匀,这是做精密TDC的基础前提。没有这个前提,后面所有校准和补偿技巧都无从谈起。
2. 进位链延时原理与性能评估
2.1 为什么CARRY4天然适合做精密延时
Xilinx 7系列FPGA的每个CLB(可配置逻辑块)里有一个CARRY4模块,它的结构比较复杂,但我们只需要关注它的核心功能:实现快速进位传播。它内部由若干MUX和专用路由组成,信号从CIN引脚进入后,经过每个进位单元产生COUT输出,同时还有对应的O输出引脚可以接触发器采样。
关键点在于:这条CIN到COUT的路径是硬连线的,不经过通用布线资源(GRM),所以延迟非常小且稳定。每个进位单元从CIN到COUT的延迟约20~30ps,从CIN到O的延迟也差不多同一量级(略大一点)。这个数值不是由你在代码里控制,而是由硅片工艺和电压温度环境决定的,所以每颗FPGA芯片的绝对值会有差异,这也是为什么后面必须做“码密度校准”的原因。
使用进位链做延时链的基本拓扑是这样的:信号从CIN进入,经过N级进位单元后,每一级的COUT/O引脚接D触发器(FDRE)的D端,在同一个系统时钟沿对所有触发器采样,得到一个N位的温度计码(Thermometer Code)。信号传到的位置越深,说明信号相对采样时钟沿越早到达,时间间隔也越大。
用Verilog例化CARRY4原语时,要明确每个端口的作用。CARRY4的端口包括:
CARRY4 #( .INIT(16'h0000) ) carry_inst ( .CO (carry_co), // 4位进位输出,接下一级CIN或触发器 .O (carry_o), // 4位求和输出,通常接触发器D端 .CI (carry_ci), // 级联进位输入,上一级CO[3]接这里 .CYINIT (1'b0), // 第一级的初始化进位输入 .DI (4'b0000), // 数据输入,用于实现加法,TDC可固定为0 .S (4'b0000) // 选择输入,TDC可固定为0 );这里有个经验:TDC场景下CARRY4的DI和S都可以接0,只让信号纯粹从CI往CO传播,不要混入逻辑功能,这样延时路径最干净。有些设计为了省触发器资源,把O输出和CO输出都接触发器一起采,这样同一个CARRY4可以得到8个采样点(4个O + 4个CO),分辨率提升一倍但逻辑复杂度也翻倍。我的建议是先从单输入开始,跑通整体架构后再考虑这种资源倍增的技巧,否则编码逻辑会把你绕晕。
2.2 延时单元一致性评估与码密度校准
进位链的每一个单元延迟并不完全相等,工艺偏差、电压梯度、温度分布在芯片内部都会造成码宽差异。如果把每个进位单元看作一个“量化桶”,理想情况下每个桶宽度相等,但实际做出来有的桶偏宽、有的桶偏窄,这就是微分非线性(DNL)。你无法在物理上消除这种偏差,但可以在逻辑上校准,这就是“码密度测试”(Code Density Test)要做的事。
码密度校准的原理很简单:给延时链输入端一个与系统时钟完全异步的随机信号(通常用外部噪声源或高速伪随机序列),大量采样后,统计每个进位单元被“命中”的次数。由于信号到达时刻在时间轴上近似均匀分布,每个单元被命中的概率应该正比于它的实际延时宽度。被命中次数多的单元,说明它实际占的时间宽度大;命中少的,说明它更窄。通过统计结果,你可以给每个单元计算一个修正权重,后续所有测量结果用它加权换算成实际时间。
实测时需要注意:采样次数至少要在10万次以上,否则统计涨落带来的误差会大到让校准失去意义。我的做法是用FPGA产生一个计数器,每采样10万次做一次统计并输出结果,用串口或JTAG读回PC分析。第一次看到每个单元的命中次数分布时,你会发现有的单元宽度是平均值的两倍,有的只有平均值的50%——不用慌,这是正常现象,FPGA工艺就是这样,不是你的设计坏了。
单从性能评估来说,除了码密度测试,还要做“温度-延时漂移”的摸底。FPGA进位链的延时随温度升高而增大,典型值在几十到上百ppm/℃的量级。如果一个延时单元标称25ps,温度从25℃升到65℃,40℃的温漂可能让整条链的延时变化几百ps,这个漂移量足以毁掉你的测量精度。所以任何实用的TDC设计都必须有校准机制,纯靠出厂标定一次用到底的方案在工业现场不现实,后面第三章会详细说补偿策略。
3. 高精度延时链的工程实现:从原语例化到布局约束
3.1 硬件平台与开发环境选择
在动手写代码之前,先确认手里的FPGA资源够不够,这能帮你避免写到一半发现资源不够、推倒重来的尴尬。做TDC至少需要:足够的进位链长度(对应你要的测量范围)、对应的触发器数量(每个进位单元至少一个)、以及后续编码逻辑使用的普通逻辑资源。
拿我的Artix-7 XC7A35T举例,它大约有5200个Slice,每个Slice含一个CARRY4即4个进位单元。如果我要做200级进位链(约4~6ns覆盖,配合250MHz系统时钟刚好一个周期以上),只占用50个Slice,资源完全不是瓶颈。真正占资源的是数据采集和缓存逻辑:每次采样得到一个200位的温度计码,如果连续做平均或直方图统计,会消耗大量Block RAM和DSP资源,这里要提前规划好。
开发环境我用的Vivado 2018.3(版本不太重要,新的都行),关键是综合选项中要把“-keep_hierarchy”打开,防止综合器把进位链结构优化掉。有些设计者直接在RTL里写加法器,期望综合器推断出进位链,但对TDC这种对结构有严格要求的场景,我强烈建议直接例化原语,不要依赖推断——综合器可能会插入额外的逻辑门或改变级联顺序,你完全不可控。
3.2 200级进位链的例化与级联约束
代码层面,最直接的方式是用generate语句把多个CARRY4级联起来,每级的CO[3]接到下一级的CI。同时每一级的CO和O输出都要接到触发器上。下面给出一个我实际使用过的可参考代码框架:
module tdc_delay_line #( parameter NUM_TAPS = 200, parameter CARRY4_NUM = 50 )( input wire clk, input wire signal_in, output wire [NUM_TAPS-1:0] taps_out ); wire [CARRY4_NUM*4-1:0] carry_co; wire [CARRY4_NUM*4-1:0] carry_o; wire [CARRY4_NUM-1:0] carry_ci; assign carry_ci[0] = 1'b0; genvar i; generate for (i = 0; i < CARRY4_NUM; i = i + 1) begin : gen_carry CARRY4 #( .INIT(16'h0000) ) u_carry ( .CO (carry_co[i*4 +: 4]), .O (carry_o[i*4 +: 4]), .CI (carry_ci[i]), .CYINIT (1'b0), .DI (4'b0000), .S (4'b0000) ); if (i < CARRY4_NUM-1) begin : gen_ci_next assign carry_ci[i+1] = carry_co[i*4 + 3]; end end endgenerate reg [NUM_TAPS-1:0] taps_reg; always @(posedge clk) begin taps_reg[0] <= carry_o[0]; taps_reg[1] <= carry_o[1]; taps_reg[2] <= carry_o[2]; taps_reg[3] <= carry_co[0]; taps_reg[4] <= carry_o[4]; // ... 依此类推,每个CARRY4的4个O加上CO的输出都采进来 end assign taps_out = taps_reg; endmodule上面代码里我把O[0..2]和CO[0]都采了,而跳过O[3]是因为在级联结构里,O[3]和下一级的CI在时序上是重合的,采样会冗余。实际操作中你可以只采CO输出,也可以O和CO混合采,关键是每一级采样点的相对位置要跟实际物理位置一一对应,不能让编码逻辑猜。
写完RTL后,最关键的步骤是布局约束。你要让Vivado把整个进位链放在一片连续的Slice区域,否则进位链可能在物理上被拆到距离较远的位置,产生额外的路径延迟或布线拥塞。Vivado里可以通过set_property LOC约束把第一个CARRY4放在指定位置,然后利用面积约束(Pblock)把整个generate块锁在一个矩形区域内。
约束示例:
set_property LOC SLICE_X0Y0 [get_cells {tdc_delay_line/gen_carry[0].u_carry}] create_pblock pblock_tdc add_cells_to_pblock pblock_tdc [get_cells {tdc_delay_line/gen_carry[*].u_carry}] resize_pblock pblock_tdc -add {SLICE_X0Y0 SLICE_X0Y99 SLICE_X5Y0 SLICE_X5Y99}这里的X0Y0是你自己根据芯片布局选的起始位置,别硬抄。放置完成后,务必打开Vivado的Device视图检查一下:进位链是否连续、有没有绕路、是否全部落在你设定的Pblock范围内。这一步查一次,后面能省下大量调试时间。
3.3 温度计码转二进制:编码逻辑的正确打开方式
采样得到的200位数据理论上是一个温度计码:信号到达位置之前的触发器为1,之后为0(具体极性取决于设计,这里假设信号到了就是1)。你要做的核心编码工作,是找到“最后一个1”或“第一个0”的位置,即边沿位置。
二十年前的做法是用比较器树或优先级编码器逐级找,逻辑延迟大、资源消耗高。现代FPGA更推荐的思路是“分块降维”:先把200位切成若干段(比如每段16位),找出段内边沿位置,再通过段级优先级网络找到段号,最后相加得到全局位置。这样做可以把组合逻辑路径控制在两级以内,编码延迟小,适合高速流水处理。
还有一种经典的“气泡”修正技巧:由于触发器存在建立保持时间,温度计码的边沿附近可能出现一个或几个不合法的码型,比如“...11101000...”这种中间有个0的“气泡”。直接找最后一个1会把位置卡在气泡处,导致测量值偏小。常用的处理方法是找最后一个连续的1区间的起始位置,或者利用查找表把非法码型映射到附近合法码型。我在工程中用的是“只找第一个0之前的最长连续1串”的思路:用一个简单的移位比较逻辑,把所有“1后面紧跟着0但不满足连续”的情况视为气泡,统一修正到最后一个连续1的位置再进位。
3.4 关键时序约束:多比特采样与亚稳态的对抗
TDC设计中有一对天然的矛盾:信号到达时刻相对采样时钟沿是任意的,所以当采样沿到来时,信号可能正好处于进位单元输出变化的窗口内,触发器就会进入亚稳态(Metastability),输出既不是稳定的0也不是稳定的1,或者在不同触发器间表现为不一致的采样结果。
亚稳态没法消除,只能降低概率和降低影响。标准做法是每个采样点用两级触发器同步,第一级采样原始信号,第二级打一拍得到稳定值。FPGA的FDRE原语天然支持级联,你只需要例化两个触发器即可。这里要注意:两级触发器同步增加了一个时钟周期的延迟,编码逻辑拿到的是延迟后的数据,但这不影响测量原理,只需要在换算时统一偏移即可。
时序约束层面,要给进链上的触发器设置合理的set_false_path或set_max_delay?我的经验是:不要对进位链的采样触发器设太严格的约束,因为信号沿在整个链上是逐渐传播的,如果你约束所有触发器都在同一个clock edge前稳定建立,综合器会认为这是一个不可能满足的路径,疯狂优化甚至报错。正确做法是只约束链上相邻节点的最大延时关系(保证边沿传播的连续性),并对采样触发器关闭时序检查(设为set_false_path),让布局布线自由安排。
不过“关闭时序检查”不等于“不关心时序”,这里真正要关心的是链上每个采样点触发器相对系统时钟的建立时间一致性。如果某个触发器的时钟偏斜特别大,它采到的位置会系统性偏移,给测量带来固定偏差。好在FPGA内部时钟网络(BUFG/CR)的偏斜非常小(皮秒级),远小于进位单元的延时,所以这个问题影响有限,不必过度焦虑。
3.5 死区时间控制与连续测量策略
死区时间指的是两次采样之间的最小间隔。由于同一根延时链在采样沿到来时会被“冻结”在某个状态,你需要在下一次信号进来之前把链复位清空,否则前一次残留的信号会污染下一次测量。实现上通常有两种方式:
- 串联复位:在采样完成并把数据缓存到寄存器后,用复位信号把所有进位单元清0,再把CIN输入置为可接受新信号的状态。这种方式简单,但复位信号本身要传播整个链,如果链太长,复位时间会拉高死区。
- 双链交替:两条完全相同的延时链交替工作,一条在采样复位时另一条正好接受新信号。这种方式可以把死区几乎压到0,适合需要连续高吞吐测量的场景,但资源翻倍、校准也要做两份。
我在实测中先使用了“单链+快速复位”方案,死区约30ns(主要由编码逻辑和复位信号传播决定)。如果后续要做高速率应用,建议直接上双链架构,工程复杂度上升不多但性能上限明显更高。
4. 实测数据与调优过程实录
4.1 实测分辨率和非线性表现
把所有逻辑下板之后,用信号发生器输出一个比系统时钟慢一点的方波(相当于让信号沿在时间轴上均匀游走),采集10万次码密度数据,然后统计每个码元(即每个进位单元)的实际宽度。我这块XC7A35T-1速度等级的实测结果:平均码宽约23ps,最大码宽37ps,最小码宽11ps,DNL的峰峰值超过±8ps。这个数据在文献里算中等偏上水平,如果你的芯片速度等级是-2或-3,平均码宽会再小一点,DNL也会稍好一些。
如果你要跟商用TDC芯片比,差距主要在长稳和温漂上:专用TDC芯片内部有复杂的非线性校准电路,FPGA方案需要自己做。但从系统集成度、灵活性和成本看,FPGA方案在需要同时做信号处理、控制逻辑和通信的场景里是完胜的。
4.2 温漂补偿:一种实用的动态校准框架
前面提到温漂是FPGA-TDC最大的敌人。我的做法是构建一个“参考事件校准”机制:在每次正常测量间隙,自动插入一个已知时间间隔的参考信号,通过测量这个已知间隔在延时链上的位置变化,推算出当前温度下整个链的延时漂移系数,再对所有测量值做比例修正。
参考信号怎么产生?最简单的方案是用FPGA内部的另一个计数器生成一个固定的时间脉冲,比如每100μs产生一个间隔为10ns的参考脉冲对,经过与主测量信号相同的路径进入延时链。因为10ns是已知值,测量到的码位置差了可以反推出当前码宽的变化倍数。实测中,这种动态校准可以把温漂从几百ps压低到30ps以内(在20~60℃范围内),完全满足常规超声测距和激光测距需求。
4.3 布局调优的记录与心得体会
第一版设计我直接让工具自由布局,结果打开Device视图发现进位链被拆成了三段,中间隔着别的逻辑块,总延时直接比预期大了30%。解决办法是用Pblock把进位链锁到一列Slice中,并且手动指定了起始位置。第二次布局后,链的连续性就好了,实测码宽分布也均匀了很多。
还有一个容易忽略的点:给进位链旁边留出“空地”。如果进位链周围塞满了其他逻辑,布局工具可能会为了绕开拥塞而把链拆散。我的做法是在Pblock里只放进位链及其采样触发器,把编码逻辑放到另一片区域,用管线寄存器做缓冲。
5. 常见问题排查与工程避坑指南
5.1 为什么采样输出全是0或全是1
这个现象最直接的原因通常是进位链没有形成有效的传播路径,信号根本没有进到链里。检查顺序很明确:先确认CIN输入是否有数据,用ILA(集成逻辑分析仪)抓一下信号;再确认CYINIT是不是接地了,如果悬空会导致初始进位状态未知;最后确认综合后CARRY4原语有没有被优化掉,在综合报告里搜索CARRY4关键字,看数量是否跟你例化的一致。
如果综合报告里都正常,就要检查布局。打开Device视图,确认所有CARRY4都按你设想的方向排列。我曾经遇到过一次约束写错坐标,导致整条链被放到了芯片角落,远离了采集逻辑,结果采样触发器全部采到无效值。
5.2 温度计码出现大量气泡,边沿定位不准
气泡数量超过正常范围时,优先排查时钟质量。如果系统时钟抖动太大,每个触发器的有效采样时间点会在不同周期之间漂移,码型就会变得混乱。用频谱仪或示波器看一下系统时钟的相噪,确认抖动在几十皮秒以内。如果时钟没问题,再检查触发器采样的数据路径上是否有额外的组合逻辑延迟——比如我不小心在O输出和触发器D端之间加了一个缓冲器,导致不同采样点的路径延迟不一致,气泡立刻变多。TDC设计里采样路径必须精简,不允许有任何多余的逻辑门。
5.3 校准后精度仍然不够理想
这种情况最常见的原因是校准数据本身不干净。码密度测试要求输入信号在时间轴上均匀分布,如果你用的信号源跟系统时钟有固定相位关系(比如用同一个晶振分频出来的),那么每个码元的命中次数就会带上信号的周期性,统计结果严重失真。正确做法是使用独立的、频率略微偏离的异步信号源(例如系统时钟125MHz,校准信号用13.0001MHz),保证信号沿在每个码元上均匀游走。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查手段 | 解决思路 |
|---|---|---|---|
| 输出全0/全1 | CIN没接对或CYINIT悬空 | ILA抓CIN波形 | 检查原语端口连接 |
| 码型气泡严重 | 采样路径有额外逻辑延迟 | 查看网表中采样路径 | 移除多余逻辑,直接O接FDRE |
| 码宽分布极不均匀 | 进位链被布局拆散 | 打开Device视图查看 | 用Pblock锁定连续Slice区域 |
| 测量值温漂过大 | 缺少动态校准 | 记录不同温度下码密度统计 | 增加参考事件校准机制 |
| 校准信号无效 | 校准信号与时钟相关 | 检查信号源频率关系 | 改用独立异步源做码密度测试 |
| 死区时间不达标 | 复位信号传播太慢 | 时序报告查复位路径 | 换双链交替或快速复位逻辑 |
5.5 我踩过的几个坑,你绝对可以避开
第一个坑:Vivado综合时默认会做“寄存器重定时”(Register Retiming),它可能会把采样触发器的位置挪动,破坏采样点与进位单元的对应关系。解决方案是在综合选项里将重定时功能关掉(-retiming off),并约束为keep_equivalent_registers。
第二个坑:编码逻辑用了if-else级联,导致组合逻辑链太长。500MHz下时序直接炸了。后来改成查表法加分段编码,逻辑级数从十几级降到三级,时序余量一下子充裕了。
第三个坑:一开始图省事直接用assign taps_out = taps_reg,没有做跨时钟域处理。后来编码逻辑跑在另一个时钟域时采样数据经常出错。TDC的采样结果是高频信号,跨时钟域必须用异步FIFO或寄存器数组同步,别偷懒。
6. 用真实项目经验做更远的设计延伸
如果你已经成功跑通了上面这套基础TDC,不妨再往下想一步。先用它做单通道测量积累信心,然后可以往两个方向扩展:
一个是多通道TDC阵列。在很多物理实验和成像设备里,需要同时测量多个探测器信号的时间差,这就要在FPGA里例化多条并行进位链,每条链独立采样,共享同一个编码和校准模块。这样对资源的规划、时钟的分配、布线的压力都会有新的挑战,我第一次做8通道时因为布局不当,通道间串扰明显,最后是靠给每个通道的进位链之间插入隔离列才解决。
另一个方向是把它跟示波器前端或者激光雷达结合。前面只说了延时链做TDC,但实际工程里信号通常要先经过比较器或放大器整形,才能变成干净的数字沿。这里面的噪声整形、阈值调节、延时匹配同样对整体精度影响巨大。FPGA-TDC不是孤立的,它需要跟模拟前端联合设计,才能发挥皮秒级的潜力。
我个人做完这个项目后最大的体会是:TDC调试跟普通数字逻辑不一样,它更像模拟电路调试,你盯着波形和统计数据让自己相信“这个结果是对的”。不能用“仿真过了就完事”的心态做它,上板实测、统计数据、排查气泡、调整布局——每一步都得沉下心来把数据吃透。但一旦你把这条链路走通,它带来的精度提升和设计空间,会让你觉得之前所有的坑都踩得值。