news 2026/9/29 3:02:56

FPGA实现简易CDR:8倍过采样原理与Verilog代码详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA实现简易CDR:8倍过采样原理与Verilog代码详解

CDR(Clock Data Recovery,时钟数据恢复)这个话题,做高速串行通信的FPGA工程师早晚都要撞上。不管是网口、PCIe、USB还是光模块,数据在传输的时候都不带伴随时钟,接收端必须自己想办法从码流里把时钟信息恢复出来,再用恢复出的时钟去对准数据、完成采样。用户的主贴标题点得挺准——用FPGA实现简易CDR,这其实是一块非常经典的“练手级”数字逻辑项目:不依赖PLL、不碰模拟电路,纯靠Verilog就能把CDR的核心机制跑通。

本篇我打算用8倍过采样的方案做一套可仿真、可上板的最小CDR系统,从原理拆到代码、从testbench写到调试心得,标题里提到的“从原理到Verilog代码(附仿真)”我会全部覆盖到。适合两类读者:一类是刚学完Verilog语法、想找一个有实际工程味道的小项目练手的FPGA初学者;另一类是正准备做高速串行接口,但被模拟CDR那套锁相环体系弄得头疼,想先用数字方式理解底层机制的朋友。读完你应该能独立写出自己的过采样CDR模块,并且能讲清楚每个设计参数是怎么定出来的。

1. 项目概述与原理拆解

1.1 为什么需要CDR:串行通信的时钟困境

并行通信大家很熟悉,数据线旁边专门拉一根时钟线,接收端直接拿这根时钟去采数据就行。但到了高速场景,并行方式会遇到两个麻烦:一是线间时延差会让时钟和数据到达接收端的相位关系变得不可预测;二是线束太粗、成本太高,远距离传输不现实。于是串行通信成了主线方案——收发之间通常只有一对差分线,数据一位一位地串着发。

问题也随之而来:既然没有单独的时钟线,接收端怎么知道每个bit从哪里开始、到哪里结束?发送端不是没有时钟,而是这个时钟只存在于发送端内部。接收端手里只有一串流过来的电平翻转信号,它需要从翻转边沿里“推断”出时钟的频率和相位,再用这个时钟去对准数据窗口。这个过程就是时钟数据恢复,CDR。

举个更容易理解的例子:你站在马路对面看一对跑步的人,他们保持固定步频但没告诉你节奏。你要判断的不仅是他们现在谁在前谁在后,还要预先判断下一步落地的时间点。观察步频、卡准落地瞬间,这就是CDR在做的事情。

1.2 CDR的常见实现路线

CDR的实现方式大致可以分成三类。

第一类是模拟PLL型CDR,这是SerDes里最主流的方式。接收端用压控振荡器和鉴相器构成负反馈环路,让本地振荡频率和相位持续跟踪输入数据边沿。优点是恢复出的时钟抖动小、频率精度高,能支持非常高的数据率(几十Gbps)。缺点是模拟电路设计门槛高,对工艺、电压、温度都很敏感,普通数字工程师很难直接上手调。

第二类是数字PLL型CDR,把部分模拟功能数字化。鉴相器输出的是数字差值,通过数字环路滤波器控制高频时钟的分频比或相位选择器,最终生成跟踪数据相位的恢复时钟。这种方案在FPGA的收发器IP内部大量使用,灵活性和稳定性比纯模拟方案好一些。

第三类是过采样型CDR,也是本篇的主角。它不直接“恢复”一个物理时钟,而是用一路远高于数据率的时钟(比如8倍)反复采样输入信号,通过对跳变边沿的定位来确定数据窗口,然后在窗口中心直接采样数据。恢复出的“时钟”实际是一个数据有效指示脉冲,并不严格要求占空比。过采样方案实现最简单,纯数字逻辑,非常适合教学和低频串行接口。

三者的取舍很清楚:过采样胜在简单可控,适合数据率几十Mbps到几百Mbps、连续相同bit数量不夸张的场景;一旦数据率上到Gbps,或者需要承受较大的收发频偏,就必须上PLL类CDR。

1.3 为什么FPGA学习先从过采样CDR入手

很多初学者一接触CDR就直奔PCIe或者千兆以太网,结果死在模拟PLL的公式推导上。我的建议是:想理解CDR的本质,先忘掉PLL。过采样CDR用最琐碎的逻辑把“如何从边沿中找到采样点”这个核心问题暴露得很清楚,而且全程能用普通FPGA的通用IO和仿真工具验证,不需要任何专用硬核。

换句话说,这个方法能让你在仿真波形里“看见”时钟恢复的过程——看见边沿检测信号、看见采样相位计数器的回绕、看见数据在中心点被稳定捕获。这种可见性对建立直觉非常重要。等搞透了数字过采样思路,再回去看PLL型CDR的框图,那些环路参数的意义一下子就顺了。

2. 方案设计与架构选型

2.1 整体方案:8倍过采样

先定指标,再做设计,这是工程的正常顺序。本设计的目标是恢复10Mbps的串行数据流,换句话说每一个bit的持续时间是100ns。采样时钟取多少倍合适?我选了8倍,也就是采样时钟频率80MHz,采样周期12.5ns。

为什么是8倍不是4倍、16倍?多一个bit周期里多几个采样点,边沿检测的分辨率就高一些,但逻辑频率也会上去,资源占用同步增加。4倍过采样每个bit只有4个采样点,理论上可行,但跳变检测的相位误差最大会到一个采样周期(25ns),留给采样中心的稳定裕量太小,稍有点信号抖动就容易在边界附近误采。16倍过采样当然更稳,但对80Mbps这种测试场景,系统工作频率要拉到160MHz,没太大必要,还会让初学者在布局布线阶段多操心。

用8倍,每个bit周期内有8个采样点,边沿定位误差不超过12.5ns,数据中心点和跳变边沿之间有约37.5ns的相位余量,对于10Mbps数据来说非常宽裕。这个安全裕量对初学者非常友好,即使上板时信号质量一般,也不容易失败。

2.2 系统架构与模块划分

整个CDR模块我分成三个逻辑部分。

第一部分是输入同步链。外部进来的din是异步信号,直接采会有亚稳态风险,必须先用两级D触发器打拍,得到同步后的信号。第二部分是边沿检测,把同步后的当前值和前一个值做异或,一旦两者不同,说明两个采样时钟之间发生了电平翻转,产生一个跳变脉冲。第三部分是采样相位控制,用一个3位计数器配合跳变脉冲做周期回绕,在合适的计数值上产生采样使能信号。

模块划分的原则很简单:单点功能、单一职责、接口清晰。同步链单独写还可以方便后续加时序约束;采样逻辑单独写则方便在调试时单独观察采样点是否落在正确位置。这里我不建议把同步链和采样逻辑揉在一个always块里,那会严重降低代码可读性。

2.3 采样点的计算与容差分析

这是整个设计中最需要讲清楚“为什么”的地方。

假设某个bit边沿跳变发生在t0时刻,过采样下我们不一定能精确知道t0,只能知道跳变发生在上一个采样点和当前采样点之间的某个位置。边沿检测脉冲有效时,我们直接把相位计数器清零,约定此刻为0。接下来采样点就按每周期递增1走,第k个采样点距离检测到边沿的时刻是k个采样周期。

因为一个bit一共8个采样周期,那么数据窗口的中心应该在第4个采样点。所以采样使能就设在计数器值等于4的时刻。这段逻辑的本质是:用计数器重建一个虚拟的位时钟,然后把采样点强制放到bit窗口的正中央。

这里的容差值得算一笔账。允许的相位误差由三部分构成:采样时钟自身的抖动、输入数据的抖动、边沿检测的量化误差。边沿检测的量化误差最多1个采样周期,即12.5ns。采样点定在中心,左右各有约50ns的冗余,刨掉量化误差后还有约37.5ns,换算成采样周期约3个。换句话说,输入数据的边沿抖动只要不超过3个采样周期,本设计就不会采错。

再考虑收发频偏。假设发送端频率是10.001MHz而非标称10MHz,接收端采样仍是80MHz,那么每个bit实际占用的采样周期数是8000ns的实际bit周期/12.5ns,约为7.9992个。每个bit都会积累约0.0008个采样周期的相位误差。连续2500个bit不跳变,误差积累会达到约2个采样周期,接近容差边界。所以这类纯过采样CDR对长时间连续相同bit并不友好,实际工程中要么限制连续同码长度,要么改用PLL类方案。这个结论在后面章节还会展开。

3. Verilog代码实现与关键细节

3.1 顶层模块端口定义

代码从一个参数化顶层开始,我尽量保持可读性。输入是80MHz采样时钟、异步复位和串行数据,输出是恢复的1bit数据以及数据有效脉冲。

module cdr_top #( parameter OVS = 8 // 过采样倍数 )( input wire clk, // 采样时钟,80MHz input wire rst_n, // 异步复位,低有效 input wire din, // 串行数据输入,10Mbps output reg dout, // 恢复出的数据 output reg dout_valid // 数据有效脉冲,每bit周期出现一次 );

参数OVS保留了调整余地。本篇按8实现,如果想改成4倍或16倍,把参数和计数器位宽调整一下即可。

3.2 亚稳态处理与边沿检测

亚稳态是异步信号进同步域的经典坑。din从外部进来,和采样时钟clk没有任何确定的相位关系,直接采可能会采到正在变化中的电平,导致后续逻辑判断错乱。处理办法很朴素:连打两拍,让第一拍的亚稳态在多出来的一个周期里稳定下来。

这里有个细节:很多初学者只打两拍就完事,但对于边沿检测来说,我们不仅关心当前电平,还关心上一个周期的电平,所以实际上需要三拍。前两拍负责同步和稳定,第二拍和第三拍用于异或比较,检测跳变。

reg din_sync0; reg din_sync1; reg din_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin din_sync0 <= 1'b0; din_sync1 <= 1'b0; din_sync2 <= 1'b0; end else begin din_sync0 <= din; din_sync1 <= din_sync0; din_sync2 <= din_sync1; end end wire edge_detected = din_sync1 ^ din_sync2;

edge_detected拉高表示在最近一个采样周期里,信号发生了电平切换。注意它不是瞬时边沿,而是滞后了一个到两个采样周期的“检测结果”。这个滞后在设计中会被计数器自然消化,不影响最终采样结果,后面仿真会验证这点。

3.3 相位计数与中心采样逻辑

相位计数器是CDR的“虚拟时钟源”。8倍过采样对应3位计数器,范围0到7。每当检测到数据跳变,就把计数器强制清零,表示重新锁定相位,从跳变后的第一个采样点开始计时;如果没有跳变,计数器继续循环,维持对位周期的推算。

reg [2:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 3'd0; else if (edge_detected) cnt <= 3'd0; else cnt <= cnt + 1'b1; end

计数器值等于4时,对应数据窗口中心,此时采样。采样结果直接作为恢复数据输出,同时拉高有效脉冲。

wire sample_point = (cnt == 3'd4); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout <= 1'b0; dout_valid <= 1'b0; end else if (sample_point) begin dout <= din_sync2; dout_valid <= 1'b1; end else begin dout_valid <= 1'b0; end end

注意到这里采样的是din_sync2,也就是已经同步稳定后的数据。打拍导致的数据时机偏移已经在设计考量之内,因为计数器重置和采样使能都是在采样时钟上升沿发生的,而我们在该时刻看到的是同步链路输出端的电平,它与外部数据之间有固定的相位关系,只要这个关系稳定,恢复就没问题。

3.4 完整代码与注释

把上述片段拼起来,就是一份完整的、可以直接放进Vivado或Quartus工程的cdr_top模块。

module cdr_top #( parameter OVS = 8 )( input wire clk, input wire rst_n, input wire din, output reg dout, output reg dout_valid ); // 1. 两级同步,消除亚稳态 reg din_sync0; reg din_sync1; reg din_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin din_sync0 <= 1'b0; din_sync1 <= 1'b0; din_sync2 <= 1'b0; end else begin din_sync0 <= din; din_sync1 <= din_sync0; din_sync2 <= din_sync1; end end // 2. 边沿检测 wire edge_detected = din_sync1 ^ din_sync2; // 3. 相位计数器 reg [2:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 3'd0; else if (edge_detected) cnt <= 3'd0; else cnt <= cnt + 1'b1; end // 4. 中心采样 wire sample_point = (cnt == 3'd4); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin dout <= 1'b0; dout_valid <= 1'b0; end else if (sample_point) begin dout <= din_sync2; dout_valid <= 1'b1; end else begin dout_valid <= 1'b0; end end endmodule

这份代码没有用到任何厂商专用原语,移植性很好。在Spartan-7、Cyclone IV这类入门级芯片上综合后资源占用都在个位数逻辑单元级别,几乎不占资源。做PCB板级调试时,可以把dout_valid引到示波器上观察,每个bit周期应该出现一个周期性的窄脉冲。

4. 仿真验证流程

4.1 testbench设计:数据源与抖动注入

仿真要做的是验证CDR模块能否把一串已知的串行数据正确恢复出来。我设计了一个带发送侧时钟的数据源,以100ns为bit周期发送一个固定序列。为了增加可信度,我还向数据里注入了随机的边沿抖动——每次bit翻转时间比理想时刻提前或滞后几个纳秒,模拟真实信道的不确定性。

`timescale 1ns/1ps module tb_cdr_top; reg clk; reg rst_n; reg din_tx; wire dout; wire dout_valid; // 采样时钟 80MHz always #6.25 clk = ~clk; cdr_top u_cdr ( .clk(clk), .rst_n(rst_n), .din(din_tx), .dout(dout), .dout_valid(dout_valid) ); // 发送侧:10Mbps数据源 localparam BIT_PERIOD = 100; localparam DATA_SEQ = 8'b10110010; reg [2:0] bit_index; reg [9:0] tx_delay; initial begin clk = 1'b0; rst_n = 1'b0; din_tx = 1'b1; bit_index = 3'd0; tx_delay = 0; // 复位 repeat(10) @(posedge clk); rst_n = 1'b1; // 起始位,拉低一个bit周期表示开始 tx_delay = $urandom_range(0, 10); repeat(BIT_PERIOD) @(posedge clk); din_tx = 1'b0; #tx_delay; // 发送数据:从低位开始 repeat(8) begin din_tx = DATA_SEQ[bit_index]; tx_delay = $urandom_range(0, 10); #(BIT_PERIOD - tx_delay); tx_delay = $urandom_range(0, 10); #(tx_delay); bit_index = bit_index + 1; end // 停止位 din_tx = 1'b1; repeat(4) @(posedge clk); $finish; end // 监视恢复数据 integer err_count; always @(posedge clk) begin if (dout_valid) begin $display("time=%0t dout=%b dout_valid=%b", $time, dout, dout_valid); end end endmodule

testbench里故意在数据边沿处做了随机的脉冲延迟,这样din不是严格的100ns周期跳变,而是带抖动的数据流。仿真时会看到采样点仍然稳定地落在数据中心,这就是CDR在起作用。

4.2 仿真波形与结果分析

仿真运行后,重点是观察三个信号:din_sync2、cnt和dout_valid。

波形里能清楚看到cnt在每次数据跳变后被清零,随后递增。当cnt等于4时,dout_valid拉高,此时的din_sync2必定等于当前的发送数据位。如果在data sequence是10110010的情况下,接收端依次恢复出1、0、1、1、0、0、1、0,说明CDR功能正确。

实际仿真中,我见过一个常见现象:刚开始复位结束后,如果数据线上电平没有跳变,cnt会一直空转,dout_valid也照常出现,采到的数据可能是错误的。但一旦出现第一个跳变边沿,计数器立即被同步到正确相位,之后所有采样点都恢复正常。这个现象说明过采样CDR需要“看见”边沿才能锁定,不存在任何提前锁定的机制。在后续章节我会专门说这个坑。

如果想让恢复效果更具说服力,可以把DATA_SEQ改成长一点的伪随机序列,比如16位的0xB40D,再在testbench里加一个自动比对模块,实时统计恢复错误数。正常情况下的错误数应该是0。

5. 常见问题与调试技巧实录

5.1 为什么我的输出总是差一拍?

这是做CDR最容易遇到的“相位错位”问题。逻辑上没问题,代码也没问题,但仿真时恢复的bit串整体比发送序列晚了一个bit周期,或者采样点恰好落在bit边界上,导致恢复异常。

排查的思路是,先在波形里看edge_detected和cnt的关系。如果edge_detected出现那拍,cnt没有被清零,检查是不是边沿信号和计数器复位逻辑不在同一个时钟域,或者复位优先级被占用。再看sample_point位置,它应该在cnt等于4时出现,如果等于0或者7,那就是计数器初值或者触发条件写错了。

还有一个容易忽略的点:testbench里给din赋值用的是阻塞赋值,而且紧跟在某个事件后,容易与采样时钟沿产生竞争。仿真看不出问题、上板出问题时,优先检查外部输入数据与采样时钟的相位关系,而不是怀疑CDR核心逻辑。

5.2 数据有频偏时如何处理?

前面容差分析里算了,纯过采样CDR能容忍的频偏和连续相同bit数量成正比。实际项目中遇到频偏大的情况,通常有三种应对思路。

第一种,提高过采样倍数。从8倍提到16倍,相位误差和容差都会改善,但代价是逻辑频率翻倍,对FPGA器件等级要求更高。第二种,在CDR后面接FIFO。发送端和接收端各自独立时钟,数据恢复出来后先写入异步FIFO,用本地系统时钟读出,频率差异由FIFO深度吸收。这是很多低速串口协议的标准做法。

第三种,改用带PLL的数字CDR架构。用锁相环产生更高频的时钟,再通过数字逻辑在环路里精细调整相位。这个方案复杂度有明显提升,但也是高频SerDes门最基本的工作原理。当前这个简易CDR的意义在于把核心概念跑通,真正上高速时再叠加PLL是自然的演进路线。

5.3 过采样倍数选择:8倍还是16倍?

选择过采样倍数,本质是在相位精度和逻辑速度之间做权衡。

4倍过采样每个bit只有4个采样点,CDR能给出的相位分辨率只有25Mbps这个量级,处理数据抖动很差,不推荐。8倍是低速率串行接口的常见起点,逻辑不复杂,容差也可观。16倍适合数据率更高或信号完整性更差的场景,代价是工作频率要求翻倍,比如想恢复100Mbps数据,采样时钟就要1600MHz,这在大多数FPGA里已经不可能用普通IO实现了。

在FPGA选型时还要考虑IO布局限制。如果采样时钟频率过高,布局布线时序收敛会很困难,初学者很容易在这一步卡住。我的建议是,在10Mbps数据率下用8倍,先把整个流程吃透,再逐步提高数据率验证不同过采样倍数的影响。

5.4 硬件调试心得

板级调试时,我习惯把din、dout_valid和dout用ILA或者逻辑分析仪抓下来,一遍一遍和仿真波形对照。曾经遇到过一个情况:仿真完全正确,上板后采样点却不稳定,最后抓波形发现是外部信号经过连接线后带了比较大的毛刺,边沿检测逻辑一遇到毛刺就误触发,计数器频繁复位,造成采样点跳来跳去。解决办法是在输入管脚端加上拉或施密特整形,或者在逻辑里延长边沿脉冲的消抖窗口来过滤毛刺。

还有一点经验:复位电路必须可靠。CDR的计数器依赖复位来确定初始相位,如果复位信号本身有毛刺或释放时序不规范,CDR会进入不可预测的状态。上板调试时优先确认rst_n释放时间是否符合采样时钟的复位时序要求,否则CDR模块再正确也会输在起跑线上。

关于采样到的数据如何与外部系统对接,建议在dout_valid使能时直接把dout打入一个移位寄存器或异步FIFO。很多初学者只盯着“恢复出bit”就以为结束了,实际上完整的串行接收链路还包括字节对齐、流量控制等,CDR只是其中最核心的数据采集环节。先把这一节吃透,后面的模块化开发就顺理成章了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 3:02:55

Echarts饼图配置详解:从基础绘制到常见问题避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 3:02:16

基于MATLAB GUI的FIR数字降噪器设计与窗函数对比实现

1. 项目背景与设计思路夏天最烦人的声音&#xff0c;大概就是窗外那一片没完没了的蝉鸣。高频、持续、穿透力强&#xff0c;你关窗都能听见。我一开始想用耳机主动降噪&#xff0c;但转念一想&#xff0c;与其靠硬件&#xff0c;不如直接在信号处理层面把这个问题干掉——用MAT…

作者头像 李华
网站建设 2026/9/29 3:01:57

PostgreSQL增删改核心语法与进阶实战:INSERT/UPDATE/DELETE

1. 为什么我建议先把增删改彻底吃透1.1 这篇教程的定位与前置基础说句实在话&#xff0c;PostgreSQL 入门最容易被高估的是 SELECT&#xff0c;最容易被低估的是 INSERT、UPDATE、DELETE 这一组增删改操作。SELECT 写错了大不了重查一次&#xff0c;但插入、更新、删除这三条语…

作者头像 李华
网站建设 2026/9/29 3:01:08

OpenClaw 3.8升级实战:解决session锁与npm/Yarn混装问题

我的 OpenClaw 升级实战系列第二篇来了。这次记录的是把 OpenClaw 从 3.6.x 一路升级到 3.8 正式版的完整排障过程。和第一篇讲干净部署不同&#xff0c;这次我的开发机环境相当乱——npm 和 Yarn 混着装&#xff0c;全局包和项目包互相打架&#xff0c;session 文件被锁到超时…

作者头像 李华