Verilog实现TDC时间数字转换器:从延迟链设计到FPGA仿真与调试 📅 发布时间:2026/8/31 17:10:53 👁 浏览次数: 简介本资源是面向FPGA开发工程师与数字电路研究者的TDC时间数字转换器核心IP设计实现聚焦纳秒至皮秒级高精度时间测量需求适用于光子探测、量子实验同步、高速通信时序分析等场景。压缩包共190个文件以42个Verilog源文件.v构成主体逻辑辅以27个头文件.h、22个VHDL封装.vhd及9个Python脚本.py用于仿真与数据后处理另有Makefile构建配置、UCF约束文件及PDF/Tex文档说明整体985KB结构完整便于移植与二次开发。已有444人学习下载提供从TDC量化电路、时间插值模块到Spartan6平台验证的全链路代码包含tdc.c等关键驱动与CRC校验、UART异步通信等配套固件可直接部署于Xilinx Spartan6 FPGA并支持硬件在环调试。1. 这块TDC核心代码到底解决什么问题1.1 文件名里的信息量最近拿到一个叫做 tdc-core-master.zip 的代码包从文件名就能读出不少信息tdc-core 是主目录master 说明这是仓库的主分支后面跟着的 TDC代码、tdc、tdc core、tdc verilog 这些标签多半是下载站点自动抓取打包时加上去的。里面其实就是一个用 Verilog 写的 TDCTime-to-Digital Converter时间数字转换器IP 核。搞 FPGA 的同学应该不陌生激光测距、PET 扫描仪、高能物理实验里的时间戳测量都要靠这种东西把模拟时间量变成数字码。我自己做过多年的 FPGA 数据采集正想找一套能直接用的 TDC 核心做参考于是花了两天时间把它解压、整理、仿真再搬到自己的工程里跑了一遍。这篇文章就把这套代码背后的设计思路、关键代码段、仿真的完整流程以及最后调通时踩到的坑一次性讲清楚。一个压缩包命名成tdc-core-master.zip_TDC代码_tdc_tdc core_tdc verilog_tdc_core_mast看起来乱糟糟其实是托管平台打包下载时自动追加的搜索标签。master 分支意味着代码还在活跃维护期tdc-core 是项目名后面补的 TDC 和 Verilog 是关键词。这种命名方式能让你在密密麻麻的下载列表里一眼定位到技术栈。我的经验是看到_core和_master这两个后缀基本就能判断这是一个可综合的 RTL 模块而不是仿真测试平台里面通常会有一个顶层文件、若干子模块以及一个 README。下载下来先别急着打开 Vivado我习惯先看目录树和文件扩展名。Verilog 工程常见 .v、.vh、.sv、.do、.xdc 这些后缀碰到 .sv 就要注意混用 SystemVerilog 时的语法兼容问题碰到 .mif 或者 .hex 多半是初始化数据。这个包里碰巧既有 .v 也有 .sv后面我会提一下怎么在工具链里把它们放一起。1.2 高精度时间测量TDC到底怎么用TDC 要解决的核心问题是把一个物理事件发生的时间点或者两个事件之间的时间间隔转换成一个二进制数。举个最常见的例子激光测距模块里你需要记录激光发射脉冲和反射回波到达光电探测器之间的时间差。光速是 3×10^8 m/s1 纳秒的时间差对应约 15 厘米的距离。如果要做毫米级精度时间分辨率就得做到几十皮秒。普通计数器用 100 MHz 时钟一个周期是 10 ns连厘米级都达不到这时候就需要 TDC 的细测量结构来补足。这套代码把测时间的方法分成两步第一步用一个自由运行的计数器做粗计数记录整周期个数第二步用一条延迟链做细测量把信号通过延迟单元的时间差量化出来。粗计数解决范围细计数解决精度。读完代码你会发现真正的 TDC core 其实就是一个“细粒度”的时间戳采样器外面包上编码逻辑和校准逻辑。这个思路在 FPGA 平台上已经非常成熟很多商用 TDC 芯片内部也是同样的架构。1.3 这套代码适合谁如果你正在做 FPGA 数据采集或者需要进一批时间测量相关的项目这套代码值得花时间读一读。它不像教学代码那样把每个模块都写成 demo而是更贴近工程化有参数可配有异步处理有输出接口甚至带了必要的性能约束注释。对初学者来说可以把它当成一个学习 Xilinx FPGA 进位链用法的范本对老手来说可以直接在上面改分辨率、改测量窗口、改输出协议省去从零写原语的时间。我最后把它集成到一块 Artix-7 的板子上配合一个外部脉冲源测到的时间分辨率稳定在几十皮秒量级这个结果已经能满足不少非高能物理场景的需求。2. Verilog实现TDC的核心设计思路2.1 粗计数与细延迟链的组合测量先说说整个算法结构。实际数据路径只有一条输入脉冲 hit 信号进入一组级联延迟单元同时一个高频计数器在自由跑。当系统检测到 hit 上升沿后锁存当前计数器的值作为粗时间T_coarse同时锁存延迟链上每个抽头的状态形成一根温度计码再编码成小数时间T_fine。最终输出时间戳 T_coarse × T_clk - T_fine × τ其中τ是单级延迟单元的延迟。这里有一个容易搞混的点延迟链测的并不是信号绝对走到哪而是信号在当前时钟沿之后还“剩”了多少。当时钟上升沿来的时候输入信号已经穿过了多少个延迟单元穿过得越多说明信号相对时钟沿越早到来。因此编码结果和真实时间成反比。代码里T_fine的极性处理很容易出错我见过好几个工程在这里把正负搞反结果测出来的距离成负数。建议写完后拿一组已知间隔的脉冲做标定而不是靠肉眼盯波形。2.2 延迟链在FPGA上的实现方式这套代码用的是 Xilinx FPGA 的 CARRY4 进位链来做延迟单元。为什么不用普通 LUT 或者布线延迟因为 CARRY4 在 FPGA 内部是级联专用路径延迟相对固定且物理上是一条直线排列抽头之间的延迟偏差很小更容易保证每个 bin 的一致性。原理上有点像用一排路况相同的减速带车通过每条减速带的时间几乎一样你数经过了多少条就能知道过去多长时间。代码里会实例化大量 CARRY4 原语同时每个抽头接一个 FDRE 或 FDCE 触发器做快照。这里必须注意 CARRY4 和触发器的布局要尽量靠近否则布线抖动直接吃掉精度。实现时可以在时序约束里给延迟链区域加上 BEL 位置约束或者直接使用 Pblock 把这片逻辑锁在一个时钟区域内。如果代码里没有约束文件建议你自己补一个物理位置约束否则综合器可能会把进位链打散时序报告一塌糊涂。2.3 参数化设计parameter 和 localparam 的写法Verilog 参数化是扩展 TDC 精度的关键。代码里通常会定义两个非常重要的参数一条链的延迟级数CHAIN_LEN以及编码后输出的二进制位宽DATA_WIDTH。比如想要测 010 ns 的范围、单级延迟约 20 ps你就需要至少 500 级延迟链但延迟链太长会占用大量触发器和布线资源所以一般不会一次拉满而是用两条延迟链交替采样或者分粗测和细测两段。写参数时有一个细节很多人忽略parameter 允许外部例化时通过#()覆盖而 localparam 只能在模块内部使用。TDC 链长这种一旦综合后基本固定、外部不应该改的参数最好声明成 localparam对外接口位宽、超时计数值这种需要被上层例化时调节的才用 parameter。我见过把链长写成 parameter 后被上层误传了一个值导致仿真可以通过、综合却报资源爆炸的情况。一个典型的外壳可以写成这样module tdc_core #( parameter CHAIN_LEN 128, parameter DATA_WIDTH 16 )( input wire clk, input wire rst_n, input wire hit, output wire [DATA_WIDTH-1:0] time_tag ); localparam FINE_WIDTH $clog2(CHAIN_LEN 1); // ... endmodule另外运算符优先级也容易踩坑比如dout (coarse FINE_WIDTH) | fine;如果忘记加括号移位和按位或的优先级会让结果完全不对。建议所有混合运算都先加满括号这不是风格问题是可靠性问题。3. 把tdc-core-master.zip里的代码跑起来3.1 代码目录结构与关键文件先花一分钟看一下目录。解压之后通常会有 rtl、sim、doc 这几个顶层文件夹也可能因为打包工具不同全部平铺在根目录。这套代码里的核心文件我按重要程度列一下tdc_core.v顶层封装包含延迟链采样、温度计码转二进制、FIFO 写接口。tdc_delayline.v延迟链和采样触发器最关键的文件。thermo2bin.v温度计码到二进制码转换常用折叠法和查表法。tdc_calib.v在线校准逻辑负责统计每个 bin 的宽度并做校正。tdc_fifo_wr.v把测量结果写入异步 FIFO 的接口。sim/tb_tdc.v一套相对完整的测试平台。我打开 RTL 文件之后习惯先搜always (posedge clk)和assign粗略估算一下组合逻辑深度。TDC 这类模块对时序极敏感组合逻辑一旦超过两级很容易被工具插入大量缓冲器导致延迟链不一致。好的设计是延迟链部分几乎只有触发器和进位链编码部分放在测量结束后一拍再做这样能最大程度保住测量精度。模块例化时我也建议全部使用名字连接像tdc_delayline #(.LEN(CHAIN_LEN)) u_delay(.clk(clk), .rst_n(rst_n), .hit(hit_sync), .tap(tap_out));这种方式端口顺序变了也不会出问题调试时看代码也一目了然。3.2 用Vivado/Questa Sim搭仿真环境仿真 TDC 的第一个难点不是写 testbench而是生成一条可控延迟的输入脉冲。你需要用到#延时表达式比如#3.7这是 Verilog 标准的一部分但有些综合工具不支持只能在仿真文件里用。我习惯在 TB 里同时产生快时钟和 hit 信号让 hit 相对时钟沿有 3.7 ns 的偏移这样粗计数器肯定会加一个固定值细延迟链也会锁到一个可预测的档位方便对照结果。Vivado 里建完工程可以直接用自带的 xsim也支持 Questa Sim。XSim 跑这种纯数字仿真完全够用但如果你开了较长延迟链仿真时间会暴涨。一个 512 级延迟链的 TDC每个抽头有一个触发器在 100 ps 级仿真精度下跑 100 us 的仿真可能需要几分钟。改用 Questa 或 VCS 的编译优化选项会快很多。如果只在 Windows 上做验证建议直接用 Questa Sim 的 10.7c 以上版本编译 SystemVerilog 文件不会有兼容问题。3.3 仿真结果怎么检查跑完仿真后最直观的是看波形里time_tag信号是否存在规律性变化。当你把 hit 延迟从 1 ns 增加到 9 ns步进 1 ns正常情况下最终输出的时间戳应等间隔增长。如果出现跳变、重复、甚至突变为 0大概率是温度计码编码有气泡bubble没有消除。温度计码转二进制过程中经常用相邻抽头的异或结果来检测信号到达位置比如xor_reduce tap[i] ^ tap[i-1];这里用到的异或逻辑必须严格按抽头顺序展开不能让综合器自由优化成并行线。再检查一个关键信号hit 捕获使能capture_en的时序。这个信号通常由状态机产生要保证延迟链的快照和粗计数器的锁存在同一拍完成。如果状态机里少了一个延时捕捉窗口会开得不对测量结果就会整体平移。我通常会在 TB 里添加两个打印语句分别打印coarse_code和fine_code再手工计算预期值去核对。这一步虽然土但是最能暴露问题。4. 后处理滤波与数据读出4.1 为什么输出需要滤波TDC 原始输出实际上充满了噪声噪声来源有三个延迟链 bin 宽度不均匀、触发器的亚稳态、以及时钟抖动。单次测量结果可能在一两个 LSB 范围里跳动直接拿原始值去做距离解算会出现肉眼可见的毛刺。一套成熟的 TDC 系统必须配合数字后处理最常见的做法就是滑动平均滤波和滑动窗口滤波。滑动平均滤波的思路非常朴素把连续 N 次测量结果求和取平均然后输出。很多新手在这里直接写sum sum new - old;但要注意位宽N 个 16 bit 数据累加后最多需要 16ceil(log2(N)) bit不提前扩位高位会溢出仿真时不报错但输出全乱。除了滑动平均如果目标有运动规律也可以用阿尔法贝塔滤波器它在滤波的同时还能估计速度在激光雷达和雷达数据处理里尤其好用。不过先用简单滑动平均把通路调干净再上更复杂的滤波这个顺序别反了。4.2 滑动平均滤波Verilog实现这里贴一段我修改后的滑动平均滤波核心代码注意它只是一个窗口大小为 4 的简单版本实际工程中窗口大小建议用 parameter 定义module moving_avg #( parameter DATA_WIDTH 16, parameter WINDOW_LOG 2 )( input wire clk, input wire rst_n, input wire valid_in, input wire [DATA_WIDTH-1:0] data_in, output reg valid_out, output reg [DATA_WIDTHWINDOW_LOG-1:0] data_out ); localparam WINDOW 1 WINDOW_LOG; reg [DATA_WIDTHWINDOW_LOG-1:0] sum; reg [WINDOW_LOG-1:0] ptr; reg [DATA_WIDTH-1:0] buf [0:WINDOW-1]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum 0; ptr 0; valid_out 0; data_out 0; end else if (valid_in) begin sum sum data_in - buf[ptr]; buf[ptr] data_in; ptr ptr 1; data_out sum WINDOW_LOG; valid_out 1; end else begin valid_out 0; end end endmodule关键点有两个一是buf[ptr]必须在更新前被使用所以非阻塞赋值里先减旧值、再存新值顺序不能反二是最终平均结果是sum WINDOW_LOG但sum在累加过程中没有截断输出时再截断这样窗口内所有数据都对结果有贡献。如果你用更激进的截断方式到窗口边界时会看到台阶式跳变。这块代码直接例化到 TDC 输出端就能用仿真和上板表现都比较稳定。4.3 通过I2C接口读取TDC结果后处理做完数据总得送往外面。很多 TDC 系统会配一个 I2C 从设备或读寄存器接口方便 MCU 通过 I2C 周期性读取测量结果。I2C 的 Verilog 实现本身不难难点在于时序和状态机要写稳。一个简化版的读寄存器状态机大概分这几步等待 START接收地址接收寄存器地址接收读命令然后把 TDC 结果按位送到 SDA 上最后收 STOP。我在实际工程里踩过不少 SCL 高电平采样 SDA 导致亚稳态的坑所以建议在 I2C 输入路径上加两级同步器状态机的每个状态转换都加上超时保护防止总线被拉死。如果只是读 TDC 结果可以不实现完整 EEPROM 读写协议但如果你需要保存一组校准表到 EEPROM就得把写时序、页写等待和 ACK 轮询都写清楚。EEPROM 读写代码的核心就是 ACK 检测和字节序控制别小看它总线一旦不回 ACK后面的数据全部白费。5. 从单模块到完整系统容易忽略的细节5.1 用状态机做自动校准TDC 的延迟链 bin 宽度会随温度、电压漂移所以必须在系统启动时做一次自动校准。校准的思路是向延迟链输入一个已知周期的方法用统计方法得到每级延迟的平均宽度。这套代码里 tdc_calib.v 就是干这个的。状态机一般包含 IDLE、MEASURE、CALC、UPDATE 四拍先让测量通道跑一段时间收集大量 hit然后算每个 bin 的命中次数最后把得到的权重写回校准 RAM。校准信号我直接用了系统时钟四分频产生的方法频率并不重要关键是周期已知用计数器分频后的沿作为 hit 就能得到均匀分布的样本。状态机的编写建议用一段式还是三段式这类精度敏感模块我强烈推荐三段式写法第一段组合逻辑算次态第二段时序更新状态第三段组合逻辑输出控制信号。三段式的好处是状态转移和输出分开综合后不容易生成锁存器而且输出寄存器不会引入额外组合延迟。很多从教学视频里学来的“一段式状态机”在普通逻辑里够用但在 TDC 的测量链路里额外的输出级延迟会直接影响采样时刻。5.2 异步信号处理与跨时钟域TDC 输入信号往往是异步的和系统时钟完全没关系。如果直接用这个信号去拉高使能几乎必然出现亚稳态。标准做法是先把外部 hit 打两拍同步再产生一个单周期的hit_clean。但要注意两级同步器会引入 23 个时钟周期的延迟这种固定延迟可以在最终时间戳里减掉所以不影响精度。还有种更高级的方法直接把异步 hit 接到延迟链上然后利用时钟沿采样正是 TDC 的核心思路这里并不需要额外同步。跨时钟域输出也是容易翻车的地方。TDC 测量结果通常要跨到 AXI 总线的时钟域或者 DDR3 写控制模块的时钟域。如果你只是简单地打两拍输出一个多 bit 总线那数据可能会错位。正确做法是先把测量结果在测量时钟域里写入异步 FIFOFIFO 的读侧用目标时钟域时钟标准库 FIFO 可以处理格雷码指针同步。如果你追求极低延迟也可以用手写的一次性握手寄存器但新手不建议容易漏掉数据。后面如果你想接 DDR3 做连续采样只需要把 FIFO 的读端接 DDR3 写控制模块的写请求信号数据位宽对齐即可。5.3 工具链与语法检查配置写 Verilog 最烦的是语法错误不能在编码阶段发现。我周围很多同事用 gvim 写 RTL早期全是纯文本后来配合插件才实现了关键字高亮、代码折叠和语法纠错。如果你用 gvim推荐两个插件vim-system-verilog 和 verilog_systemverilog.vim。配置好后.sv 文件里的关键字比如 module、endmodule、always_ff都会被高亮注释和宏定义也有不同颜色找错会轻松很多。如果习惯 VS Code可以装 Verilog-HDL/SystemVerilog 扩展再加 Verilog-Format 做格式化语言服务推荐 verible。Verible 不仅支持语法检查还能检查命名规范是一个被低估的免费工具。把工程的 include 路径和库文件路径配置好之后保存瞬间就能看到红色波浪线甚至能自动补全模块例化端口。这个配置花十分钟写代码时省下的时间按天算。语法基础越扎实越不容易在 TDC 这种精度敏感的模块里让 bug 溜到仿真阶段。6. 常见问题与调试经验6.1 仿真时读不到文件、路径错误热词里有两条非常真实“verilog读文件时不存在”和“verilog文件是否存在”。这两个问题的本质是 testbench 里用了$fopen或readmemh读初始化文件但文件的相对路径不是以工程目录为基准而是以仿真运行目录为基准。Vivado 里没配好仿真时经常报 could not open file。我解决这个问题的一个习惯是在 TB 顶部用include 加绝对路径或者直接用$fopen(../../rtl/data/coeff.txt, r)但更稳妥的是把数据文件复制到仿真目录下再在代码里用$fscanf 去读避免路径问题。对于“文件是否存在”这类检查Verilog 可以用$fopen返回句柄是否等于 0 来判断SystemVerilog 里可以做更复杂的文件操作。不过别在综合代码里用这些系统函数它们只能出现在 testbench 或仿真模型中。遇到文件读不到先看当前工作目录是什么再看$display(%s, path)打印出来的实际路径基本就能定位问题。6.2 时序约束不过怎么办TDC 时序约束和普通逻辑不同你不能只写一个 create_clock 就完事。延迟链上每个抽头的保持时间必须满足否则采样触发器会进亚稳态。入门级做法是给延迟链输入信号设置一个set_false_path到触发器的路径但这样做会牺牲精度因为 false path 不会保证延迟均匀。更好的做法是约束延迟链上的触发器为同一时钟域且用set_multicycle_path指定足够的建立时间窗口。上板实测时如果发现温度计码偶尔出现气泡大概率是某个抽头的触发器采到了竞争状态。这时回头查物理位置约束确认 CARRY4 和触发器在同一 CLB 内。很多综合工具默认不保证这一点需要在 XDC 里写物理约束或建 Pblock。这部分我不想给具体命令因为不同器件族差异很大但思路是一样的把延迟链电路和编码电路物理隔开别让综合器自由发挥。跑完时序收敛之后再用频率固定的脉冲源做多组重复测量看方差是否在预期范围内。6.3 亚稳态问题与工程取舍有一回我把 hit 信号只打了一拍就接入状态机结果输出时间戳抖动特别大查看波形发现捕信号提前了半个时钟周期出现。后来把同步器加长到三拍并把同步后的信号作为延迟链采样触发抖动才消失。这个教训说明TDC 工程不是光看 RTL 语法更重要的是理解时序裕度和亚稳态概率。对于 100 MHz 时钟两级同步器的亚稳态 MTBF 已经非常高但延迟链输出不能贸然全用同步器因为那会破坏编码一致性。工程上经常要做取舍延迟链长度和资源、测量范围和分辨率、滤波窗口和响应速度。我个人的习惯是先根据项目需求确定最大测量范围再算最小分辨率然后留出 20% 的裕量选择链长。不要一开始就追求顶级分辨率否则布线压力、功耗和调试时间都会成倍增长。这套tdc-core-master.zip里的代码给了很好的起点但每个项目的约束不一样直接照搬很难用舒服。把 RTL 读熟把参数调成自己系统的样子再花一个下午跑通仿真和上板验证你对 TDC 的理解会比只看文档深刻得多。本文还有配套的精品资源点击获取