
最近在弄一套激光测距的接收板核心指标是时间分辨率要做到几十皮秒量级。外挂专用TDC芯片当然省事但物料成本、板级走线和调试周期都压在那边想来想去还是在现有的XC7A35T开发平台上直接用FPGA内部资源做TDC更划算。XC7A35T是Artix-7里很常见的中端芯片不少开发板都拿它当主力它CLB里的CARRY4原语天生就是一条能做高精度时间量化的进位链。这篇文章就把我在XC7A35T上基于Carry4进位链实现TDC的完整过程、代码思路、校准方法和踩坑记录一次说清楚给同样在做飞行时间测量、单光子计数、PET或高速数据采集的工程师一个可以直接上手的参考。1. 时间测量为什么盯上FPGA里的Carry4进位链1.1 从“飞行时间”到TDC到底在测什么先想清楚TDC解决的问题是什么。激光测距里发射脉冲打到目标再反射回来接收电路产生一个hit脉冲这个hit相对激光器触发时刻的时间差就是飞行时间。光速约3e8 m/s1厘米的距离对应大约33皮秒的时间差。用TDC测量这个时间差再乘上光速就得到了距离。PET、单光子计数、超声飞行时间、高能物理里的漂移室读取本质都是同一件事精确测量两个事件之间的时间间隔。这类应用对时间分辨率的要求很典型亚纳秒到几十皮秒。普通单片机里的定时器计数器只能到几十纳秒完全不够。FPGA里的粗计数器即使跑500MHz分辨率也只有2ns还是不够。所以需要TDC——用芯片内部逻辑单元的传播延迟来细分时间把一个时钟周期切成几十份甚至上百份。1.2 为什么直接用计数器做不细有人会问FPGA内部有PLL和MMCM能不能把时钟提上来用高频计数器直接数理论上行但实际受限于芯片的时钟树。Artix-7的全局时钟网络走几百兆没问题但上GHz就吃力了而且芯片IO和内部逻辑的功耗会迅速上升。更重要的是计数器只能在一个时钟周期边界上计数时间分辨率被时钟周期死死卡住除非用多相时钟交叉采样但多相时钟的相位一致性、温度漂移都很难保证。如果改用延迟线的方式情况就完全不一样了。FPGA里每个LUT、每个MUX都有固定的传播延迟这些延迟本身就构成了一个天然的时间量化刻度。我们不需要提高时钟频率只需要让被测信号通过一段已知延迟的逻辑链然后在某个时钟沿把这些延迟单元的状态拍下来就能知道信号走到哪一级从而反推出时间。这就是延迟线TDC的核心思想。1.3 Carry4进位链怎么变成一把“游标卡尺”FPGA里的延迟线有很多种做法可以在LUT里穿行也可以走布线资源但效果最稳定、级间延迟最小的还是CARRY4的专用进位链。CARRY4是Xilinx 7系列器件CLB里的硬核结构专门用来实现快速加法器的进位传播。它的级间延迟通常在几十皮秒量级比一般LUT路径短得多而且由于是专用布线路径确定性很好。用生活化的类比来说普通计数器像一把最小刻度1厘米的直尺时间分辨率就是时钟周期而Carry4进位链TDC像一把游标卡尺它不增加直尺的总长度而是把每个时钟周期内部再精细分割。每经过一级CARRY4的MUX信号就往后挪一个固定的延迟这个延迟就是时间刻度。采样时钟沿到来时信号走到第几级就对应时间上的第几个刻度。这把“游标卡尺”的读数就是我们需要的高精度时间值。2. XC7A35T的CARRY4硬件细节分辨率、量程与布线前提2.1 CARRY4原语与Artix-7 slice结构Artix-7的CLB由两个slice组成每个slice里有4个LUT、8个触发器以及相关的算术逻辑。CARRY4就是每个slice里的4位快速进位链硬件块。看UG474可以发现一个CARRY4内部其实是4个级联的专用MUX输入端包括CI、CYINIT和DI输出包括CO和O。进位信号从CI进入经过第一级MUX后送到CO[0]再继续送到第二级MUX这样一级一级往下走。多个CARRY4可以通过CI/CO[3]首尾相连组成任意长度的进位链。由于这是芯片专门为快速进位设计的路径级间延迟非常稳定而且CARRY4在slice中的物理位置决定了进位链是在同一列CLB上纵向级联的。这个特性对TDC至关重要——如果路径经过了普通布线资源延迟会变得不可控也就没法用延迟作为时间刻度了。在XC7A35T这种规模不大的器件上整列CLB的数量足够做几十级甚至上百级进位链。实际工程里常用的链长在32~128级之间对应覆盖范围从几个纳秒到十几个纳秒。链太长了容易出现延迟非线性和布局困难不如用“细链粗计数器”的组合扩大量程。2.2 单级延迟与bin宽度参数上能算出多少CARRY4每一级MUX从CIN到COUT的传播延迟在Artix-7不同速度等级上大约在0.03~0.06ns之间。我手头这块XC7A35T是-1速度等级实测平均bin宽度约为40皮秒左右。注意这只是一个统计平均值不同CARRY4之间、不同温度电压下个体差异很大这也是后面必须做码密度校准的根本原因。40皮秒是什么概念一个标准的250MHz系统时钟周期是4ns一条32级进位链能覆盖大约1.28ns的精细时间范围。也就是说只要在时钟上升沿附近出现一个hit脉冲我们就能知道它相对时钟沿的时间分辨率远高于时钟周期本身。实际项目中如果目标分辨率是50ps左右用Carry4进位链是够的如果要做20ps以内的超高性能TDC单靠裸链就吃力了通常要配合wave union、双链游标甚至在线校准迭代。这里先不展开后面会提扩展方向。2.3 量程怎么扩细时间戳粗计数器的分工延迟线再好也是有长度的一条32级链只能覆盖1.28ns而实际飞行时间往往是几十纳秒甚至微秒量级。所以量程必须靠粗计数器来撑。经典做法是系统里跑一个自由运行的粗计数器时钟由BUFG驱动的全局时钟提供同时把hit信号同时送进两条路径一条进粗计数器锁存当前的计数值另一条进Carry4进位链在同一个时钟沿锁存链上各级状态。最终时间戳 粗计数值 * 时钟周期 - 进位链细时间。这里注意方向hit信号越早于采样时钟沿到达链上传播的位置越深细时间值越大而粗计数值反映的是采样时刻对应的整数周期。两者相减才能得到hit相对起始时刻的精确时间。这个“粗计数减细值”的组合逻辑虽然简单却是整个TDC系统量程设计的骨架。3. 核心Verilog实现延迟线、采样阵列与二分编码3.1 顶层链路与时钟设计写代码前先把顶层通路理清楚。hit信号从外部引脚进来经过IBUF后直接接到CARRY4链的入口系统时钟clk经过BUFG后同时给粗计数器、进位链采样DFF和后续编码逻辑使用。hit不需要经过任何LUT或组合逻辑这一点后面再强调为什么。链上各级CO引出后用同一批DFF在clk上升沿采样。采样得到的是一串温度计码如果hit信号还没传播到某级那级输出为0传播到了就变成1。所以温度计码的形态是低位连续一堆1高位连续一堆01和0的分界点就是hit信号的位置。后续编码模块的任务就是找到这个分界点。整个TDC核心可以分成三块Carry4延迟线、采样DFF阵列、编码逻辑。下面分别给代码。3.2 CARRY4延迟线例化代码这是最核心的一段。用generate循环例化多个CARRY4把它们首尾相接成一条长链parameter N_STAGES 32; // 必须为4的倍数 wire [N_STAGES-1:0] carry_tap; wire [N_STAGES-1:0] carry_sum; genvar g; generate for (g 0; g N_STAGES / 4; g g 1) begin : gen_tdc_cell CARRY4 #( .IS_C_IN_INVERTED(1b0), .IS_CLK_INVERTED(1b0) ) u_carry4 ( .CO (carry_tap[g*4 : 4]), // 每级4个抽头输出 .O (carry_sum[g*4 : 4]), // O端子不用但必须接 .CI (g 0 ? 1b0 : carry_tap[g*4 - 1]), // 级间级联 .CYINIT (g 0 ? hit_in : 1b0), // hit从第一级CYINIT进入 .DI (4b0000), .S (4b1111) ); end endgenerate关键点全在端口接法上。S端全部置1DI端全部置0这样每个MUX都会把输入从CI传到CO完成纯粹的延迟传播。CARRY4的CO输出就是各级抽头直接送给DFF采样。CYINIT只接第一级的hit_in其余级都用CI与上一级CO[3]首尾相连。这样从hit_in进入后信号会在整条链上单向传播。采样DFF阵列很简单reg [N_STAGES-1:0] thermo_code; always (posedge clk) begin thermo_code carry_tap; end注意这里用的是非阻塞赋值保证所有DFF在同一时刻采样。采样瞬间可能出现亚稳态导致温度计码里出现气泡这个问题放到编码部分讲。3.3 温度计码的“折半”编码模块拿到thermo_code后需要把“连续1的最高位置”找出来转换成二进制时间码。最直觉的方法是for循环从高位往下扫找到第一个1的位置reg [BIN_WIDTH-1:0] time_code; integer k; always * begin time_code 0; for (k N_STAGES - 1; k 0; k k - 1) begin if (thermo_code[k]) time_code k; end end这段代码确实能工作但综合器会把它实现成一条很长的优先级链编码延迟随链长线性增加。N_STAGES到64时编码逻辑可能成为时序瓶颈。工程上我更推荐用折半查找的思路先检查最高一半是否全是1如果是说明信号已经传播到了上半段把结果加上一半长度然后继续在上半段内折半否则在下半段继续。这样只需要log2(N_STAGES)级比较逻辑延迟要小得多。折半查找的具体Verilog可以通过循环展开或函数实现这里给出一个参数化的函数内核function [BIN_WIDTH-1:0] thermo_to_bin; input [N_STAGES-1:0] t; reg [BIN_WIDTH-1:0] result; integer half; begin result 0; half N_STAGES / 2; while (half 0) begin if (t[N_STAGES-1 -: half] {(half){1b1}}) begin result result half; end half half / 2; end end endfunction注意这段代码里的切片方式只是原理示意实际参数化切片要写成generate或固定分支否则综合器可能不认动态切片。但思路是对的N_STAGES32时只需要5级判断就能完成编码时序比优先级链好很多。3.4 布局与关键信号约束远远比功能更卡精度代码写得再对如果布局布线把CARRY4和采样DFF放得乱七八糟精度依然归零。这是FPGA TDC和普通逻辑设计最大的区别。首先所有采样DFF必须和对应的CARRY4在同一列的slice里并且尽量贴近。如果DFF经过了普通布线资源那一段路径延迟就不是固定的无法作为时间刻度的一部分。Vivado里最直接的做法是综合后用tcl脚本给链上的CARRY4和DFF加BEL约束让它们严格对齐。批量生成约束的脚本思路大概长这样set cell_list [get_cells {tdc_inst/gen_tdc_cell[*].u_carry4}] foreach cell $cell_list { set loc [get_property LOC $cell] set bel [get_property BEL $cell] # 把相邻DFF约束到这个CARRY4所在的slice }如果不想一开始就这么折腾可以先让Vivado自由布局跑通功能后再看deviation报告。但对精度有要求的系统位置约束是绕不过去的。其次核心hit信号路径上绝对不能出现LUT、DSP等额外逻辑。hit从IBUF进来必须直接到CARRY4的CYINIT中间任何组合逻辑都会给时间测量增加不确定延迟。可以在综合时对hit_in加max delay约束并检查时序报告确认路径符合预期。4. 码密度校准把非均匀bin拉回线性时间轴4.1 为什么出厂后bin宽天然不一致很多第一次接触FPGA TDC的人会被“40ps分辨率”这个数字误导以为每个延迟级都是精确的40.0ps。实际情况是CARRY4每一级的MUX延迟受到工艺偏差、电压、温度、布局位置的影响同一个芯片不同位置的bin宽度差异可能达到20%甚至更多。直接拿温度计码位置当作时间误差会非常大。所以FPGA TDC必须做校准最常用的是码密度测试。原理很简单向TDC输入大量与采样时钟不相关、相位随机分布的hit信号统计每个code出现的次数。如果所有bin宽度相等每个code出现的次数应该大致相等。如果某个bin比较宽对应的信号落入这个bin的概率就更大计数就更多。因此每个bin的计数正比于它的实际时间宽度。4.2 码密度测试的实际操作流程校准操作可以分几步走。第一步产生随机相位分布的hit信号。最简单的方法是用FPGA内部的LFSR产生伪随机周期脉冲或者用MMCM分频出一个与采样时钟不成整数倍数关系的脉冲信号比如3.37倍分频这样hit沿相对采样时钟的相位会均匀覆盖整个时钟周期。注意不能直接用采样时钟的2分频、4分频那样相位完全对齐码密度统计毫无意义。第二步连续采集大量hit时间戳记录每个code出现的次数。一般需要几十万到上百万个样本统计误差才能压到百分之一以下。代码上就是开一个RAM数组按code作为地址累加reg [COUNTER_WIDTH-1:0] hist [0:N_STAGES-1]; always (posedge clk) begin if (capture_valid) begin hist[time_code] hist[time_code] 1; end end第三步根据统计结果计算每个bin的宽度并建立查找表。设总样本数为N_total第i个code的计数为H[i]那么归一化后的相对宽度就是H[i]/mean(H)。把累计相对宽度累加就能得到该code对应的时间偏移查找表。实际测量时先用编码器得到原始code再去查找表里查出校正后的时间值。4.3 DNL、INL与最终单发精度怎么评估校准做得好不好要用积分非线性INL和微分非线性DNL来衡量。DNL描述单个bin宽度相对平均bin宽度的偏离INL描述累计误差。公式不复杂DNL[i] width[i]/avg_width - 1INL[i] sum(DNL[0]到DNL[i])。好的Calibration之后INL应该控制在个位数的bin宽度以内也就是几十皮秒到百皮秒级别。单发测量精度单次测量的随机抖动受两个量限制一是量化误差理论下限大约为bin_width/sqrt(12)二是链上各级延迟的随机抖动累积。在XC7A35T上用40ps左右的平均bin宽度经过码密度校正后实测单发RMS精度能做到30~80ps量级这个数字对大多数飞行时间应用都够用了。如果系统里还有MMCM引入的jitter最终精度还要把时钟jitter考虑进去。5. 用直方图验证TDC从单次测量到统计应用5.1 内部自检信号没有外部仪器也能测拿到TDC核心后怎么确认它真的工作最简单的自检方式是内部生成一个已知的周期信号本来不想写这个段落但写到这里发现正好可以补上直方图验证的路子。用MMCM产生一个固定频率的测试脉冲或直接用计数器分频出一个相对采样时钟相位关系的脉冲送给TDC测量。比如系统时钟250MHz用LFSR产生一个约37MHz的脉冲信号TDC测到的hit相对clk沿的时间差应该随机均匀分布在0~4ns之间。把几千个测量值做成直方图如果分布呈近似矩形说明TDC的bin基本均匀编码和采样链路正常。如果信号频率和采样时钟有严格的相位关系比如用同一个MMCM倍频出来的整数分频直方图就不是矩形而是集中在某几个bin上的窄峰。这种情况下也可以用峰位来验证TDC是否工作正常但无法做码密度校准因为相位不随机。5.2 直方图统计模块的设计思路直方图不只是自检工具它本身就是TDC最常见的应用形态。激光雷达里每次激光发射后接收光子到达的时间就是信号前沿多次测量得到直方图后峰位就是目标距离峰宽反映了时间抖动峰的幅度反映了反射率。PET里则用符合事件的两个TDC时间戳做直方图找到符合峰。直方图统计模块的设计核心是一个RAM计数器阵列。hit到来时TDC计算出时间戳time_code然后把这个time_code作为地址对对应存储单元执行读改写加一。为了避免读出和写入冲突一般用简单的单端口RAM配合两级流水即可或者直接使用UltraRAM/BRAM配置为“读-修改-写”模式。采样率不高时还可以用寄存器数组实现更简单直观。5.3 从直方图直接读出时间抖动与峰位当测试脉冲是固定周期信号时直方图上会形成一个窄峰。如果TDC本身线性度和抖动都好峰宽主要反映信号源抖动和采样时钟jitter如果TDC的非线性严重峰看起来会不对称或出现多个子峰这时就要检查校准和布局。直方图峰位的读取也很简单找到计数最大的bin在附近做质心插值能得到比单个bin更精细的峰位置。质心计算就是sum_i sum(time_code * hist[time_code]); sum_n sum(hist[time_code]); peak_time sum_i / sum_n;质心法对低计数场景尤其管用它相当于对多个bin做了加权平均等效分辨率可以明显好于单个bin宽度。这也是为什么很多FPGA直方图TDC测距方案里精度能做得很高的原因——单次差一点没关系统计可以弥补。6. 工程踩坑记录从综合约束到下载器驱动6.1 综合器偷偷改掉我的进位链怎么办第一次跑TDC工程最大的坑是综合器认为CARRY4链是冗余逻辑直接优化掉了。因为S全1、DI全0的CARRY4在普通数据通路上看起来就像一条常数信号综合器不知道它是一条延迟线。结果测出来的时间全是0链上所有bit都被常量折叠了。解决办法有两个一是用(* KEEP TRUE *)标记所有进位链相关的wire和cell二是对CARRY4例化加上dont_touch属性。在综合属性里写(* KEEP TRUE *) wire [N_STAGES-1:0] carry_tap;然后对gen_tdc_cell模块设置set_property DONT_TOUCH true [get_cells tdc_inst/gen_tdc_cell[*]]这样综合工具就会把这根链当成黑盒结构保留下来不再做常数传播优化。6.2 Cable驱动、Vivado连接和固件那点事工程调试阶段还有一个特别烦的事电脑上插上Xilinx Platform Cable USB设备管理器显示驱动黄色感叹号Vivado Hardware Manager识别不到下载器。这个问题我以前在ISE14.7和Vivado上用同一根Cable都碰到过。多数情况不是Cable坏了而是Windows没有正确安装驱动。解决办法是用管理员权限运行Vivado安装目录下自带的驱动安装脚本路径一般在Vivado安装目录的data/xicom/cable_drivers/nt64下面。ISE14.7则在安装目录下的common/bin/nt64里找install_drivers.bat。运行完重新插拔Cable等Windows识别就好。这里提醒一句别看到“Platform Cable USB Firmware Loader”就去点固件升级。除非Cable是真的坏了否则固件升级有风险中途断电会直接把下载器刷成砖。很多人在这个功能上交过学费。6.3 温度漂移与多通道布局的实操建议CARRY4的延迟对温度和电压非常敏感。我实测过芯片温度从35℃变化到45℃时同一个code对应的时间偏移能跑掉几十皮秒。所以做高精度应用时TDC模块周围不要放高翻转率的大功耗逻辑板上TDC核心电压最好单独用低噪声LDO供电。多通道TDC布局上每个通道使用独立的Carry4链链与链之间至少隔一列CLB避免进位链之间的串扰和布局拥挤。采样时钟必须统一走BUFG分配否则不同链之间的时钟偏斜会直接变成通道间的时间偏移。好在多通道的固定偏移可以通过校准剔除但抖动偏斜是剔除不掉的所以布局时就要尽量保证所有链的时钟路径长度接近。最后再分享一个实操技巧第一次上手别急着拉64级长链和花哨的编码算法。先用8级CARRY4跑通码密度测试观察bin宽度分布直观感受一下温度计码的长相。走完这个过程后再去扩链长、做折半编码、上直方图每一步都有据可查不会一出问题就到处抓瞎。Carry4进位链TDC在7系列FPGA上依然是个性价比很高的方案后续想进一步压精度还可以试试wave union多沿输出、双链游标法和基于延迟锁定环的自校准结构但万变不离其宗——核心还是把这条专用进位链的延迟吃透。