ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA实现Gardner环定时同步:从算法原理到Verilog代码与仿真验证

FPGA实现Gardner环定时同步:从算法原理到Verilog代码与仿真验证 FPGA项目圈子里定时同步一直是个绕不开的话题。无论你做的是卫星通信、软件无线电还是高速有线传输接收端只要涉及符号恢复就离不开在正确的时刻采样这个动作。而Gardner环也就是Gardner定时误差检测器加环路滤波的结构是这里面最经典、最容易被FPGA开发者接受的一种方案。这篇文章我就拿一个实际跑过的项目做例子把FPGA实现Gardner环定时同步的完整思路讲清楚从算法原理到Verilog代码再到Testbench怎么搭每一步该怎么想、怎么写、怎么调争取让看完的人能直接照着自己写一版出来。如果你正准备做毕业设计、通信课程项目或者刚接触FPGA数字信号处理方向这篇内容应该能帮你省掉不少翻手册和试错的精力。我会尽量用做工程的思路来讲不堆公式但该有的推导和参数计算一步不少因为你后面做硬件调试时真正卡你的往往就是这些看起来简单的细节。1. Gardner环定时同步先搞懂它到底在干什么1.1 定时同步的本质什么时候下采样才对先说一个容易被新手忽略的问题。通信接收机收到的信号本质上是一个连续的模拟波形经过ADC采样后得到的离散序列。理论上如果我在每个符号的最佳采样点取值那么判决出来的符号错误概率最小。但问题在于发射机和接收机的时钟不可能完全同频同相即使标称都是10MHz实际晶体总会有几十ppm的偏差再加上信道传输延迟是未知的接收端根本不知道最佳采样时刻落在哪。定时同步要做的就是从接收到的样点序列里估计出这个最佳采样时刻的偏移量然后通过插值或者调整采样时钟把这个偏移修正掉。放在FPGA里我们一般不会去物理调整ADC的采样时钟而是用全数字的方式固定用某个过采样率采样然后在数字域做插值等效于在最佳时刻重新采样一次。这就是全数字定时同步的基本思路。1.2 Gardner算法为什么是FPGA圈子的主流选择Gardner算法全名叫Gardner定时误差检测算法它是目前在FPGA里用得最多的一种定时同步方案之一。它为什么受欢迎我总结了几点对载波相位不敏感。这意味着在定时同步阶段你甚至不需要先完成载波同步Gardner环可以单独工作这个特性极大简化了接收机的设计流程。每个符号只需要两个采样点。一个是在最佳采样点附近symbol点另一个是相邻两个符号中点midpoint2倍过采样就够了对ADC速率和FPGA资源都很友好。实现结构简单没有乘法密集的复杂运算非常适合在FPGA里流水线化。算法本身自带符号判决功能既不依赖训练序列也不依赖数据的统计特性所以也能用在盲均衡或者突发通信的场景里。当然它的缺点也有比如对滚降系数比较小的信号误差检测曲线增益会下降环路收敛变慢。但工程上一般不会因为这个问题放弃它而是会在滤波器设计和环路带宽上做补偿。1.3 环路整体架构四个模块各司其职在FPGA里实现Gardner环定时同步本质上是搭一个数字锁相环路。我的顶层设计里面包含四个核心模块定时误差检测器TEDTiming Error Detector根据当前插值输出和相邻符号的中点采样值计算出当前定时误差。环路滤波器Loop Filter对误差信号进行滤波决定环路的带宽和稳定性输出频率控制字。数控振荡器NCO/小数控制器累加频率控制字产生插值所需要的小数间隔和使能脉冲。插值器Interpolator根据小数间隔从原始采样序列中计算最佳时刻的采样值。这四个模块串起来就形成一个闭环反馈结构。输入是ADC采样的过采样数据输出是同步好的、每个符号一个值的序列。这个过程在通信原理里叫反馈型定时同步因为它是靠误差信号不断修正逐渐收敛到稳态的。2. 算法设计从公式到可综合的Verilog模型2.1 定时误差检测器2倍过采样与中点采样Gardner误差检测器的公式不长但每个变量都有明确物理意义( e(n) [y(n) - y(n-1)] \cdot y(n - 1/2) )这里y(n)是当前符号的最佳采样值y(n-1)是上一个符号的最佳采样值y(n-1/2)是这两个符号中间那个采样值。这个公式的直观理解是如果定时准确中间点应该正好落在眼图张开的最大位置附近而相邻两个符号采样值的差值符号与中间点大小之间应该有确定关系当定时有偏差时这个乘积就会反映出一个正比于定时误差的量。不过实际FPGA实现时大多数人不会直接用浮点。这里我给出定点实现的关键步骤把误差信号归一化到某个固定范围比如Q格式下用16位有符号数表示范围控制在正负1之间。公式里那个乘法用DSP48乘法器实现结果截位时注意保留足够的有效位否则环路增益太小会导致收敛慢。误差检测器一般还需要做一次累加平均也就是把连续多个符号的误差求和或求平均抑制噪声。我习惯做16个符号累加一次这个参数具体可以仿真调。我在实际项目中选择的是把TED放在插值器之后直接使用插值出来的符号值与中点值。这样做的好处是误差信号反映的是真实采样时刻的偏差环路收敛后锁定更准。2.2 环路滤波器二阶环路还是纯积分器Gardner环的环路滤波器通常有两种选择一阶环路只有比例支路和二阶环路比例加积分。对于存在固定频偏的场景比如收发时钟频率有固定偏差必须用二阶环路才能做到稳态无静差。天线接收的载波信号大多存在多普勒频偏所以通信接收机里的定时环我基本都做成二阶。环路滤波器的典型结构是( F(z) K_p K_i / (1 - z^{-1}) )在FPGA里面这个滤波器实现起来很简单一个乘法器加一个累加器。但参数Kp和Ki的选择不能拍脑袋。它们的取值直接决定环路带宽和阻尼系数。工程经验公式是这样环路带宽 ( B_n ) 一般取符号速率的1%到5%太高容易引入噪声太低收敛太慢。阻尼系数 ( \zeta ) 取0.707附近这是锁定速度和过冲的折中。我之前做一个符号速率2Msps的项目ADC采样率是4Msps2倍过采样环路带宽选20kHz算出来Kp大概是0.0028Ki大概是0.0003。这个数值是经过多次仿真微调出来的。注意在Verilog里这些小数要转换成对应定点数精度不够的话环路可能在某个稳态附近震荡。2.3 插值器与NCO小数间隔怎么来的插值器要做的事情是给定原始采样序列x(m)和小数间隔u(k)算出在最佳时刻的采样值。最常用的两种插值结构是线性插值和Farrow结构的三次插值。线性插值公式是( y(k) (1 - u) x(m) u x(m1) )这个实现最简单两个乘法一个加法但插值精度低对滚降系数小的高阶调制信号性能不够。三次插值性能好但系数生成复杂一点。NCO部分实际上是一个小数分频器。它累加环路滤波器输出的频率控制字产生溢出脉冲。溢出时刻对应的就是当前应该输出一个符号的时刻。同时NCO内部累加值的小数部分就是插值所需的小数间隔u(k)。这个设计非常巧妙它把符号定时和插值小数间隔统一到了一个模块里避免了显式计数器可能带来的相位跳变。我实现NCO时累加器位宽取32位频率控制字是32位有符号数。这样做的原因很简单位宽足够大频率分辨率高环路稳定后相位抖动小。但要注意累加器的溢出时刻与数据时钟之间的关系要仔细算否则符号率对不上环路永远锁不住。带宽、阻尼、增益、位宽这些参数每一个都影响整个环路的收敛特性。我建议先在Matlab或Python里把浮点模型跑通再转定点。直接上Verilog问题定位会非常痛苦。3. Verilog实现RTL代码逐段拆解3.1 顶层模块端口设计我设计的顶层模块端口如下信号命名尽量直观module gardner_sync #( parameter DATA_WIDTH 16, parameter ACCUM_WIDTH 32 )( input wire clk, // 系统时钟等于ADC采样率 input wire rst_n, // 异步复位低有效 input wire signed [DATA_WIDTH-1:0] din_i, // ADC输入的I路数据 input wire signed [DATA_WIDTH-1:0] din_q, // ADC输入的Q路数据 input wire din_valid, // 输入数据有效信号 output reg signed [DATA_WIDTH-1:0] dout_i, // 定时同步后的I路符号 output reg signed [DATA_WIDTH-1:0] dout_q, // 定时同步后的Q路符号 output reg dout_valid, // 输出符号有效脉冲 output wire signed [ACCUM_WIDTH-1:0] freq_ctrl_debug // 调试用 );这里我把I/Q两路都做了处理因为Gardner算法虽然可以在单路信号上工作但实际通信都是复基带信号两路对称处理是标准做法。输入数据与系统时钟同步din_valid标记每个新的ADC采样点。dout_valid是符号率脉冲有效时dout_i/dout_q才表示一个同步好的符号。3.2 TED模块实现误差提取的定点和时序处理TED模块的输入是插值器输出的符号序列因为插值器是流水线输出所以我要保存前一个符号值和中间点值。这里有个小技巧由于是2倍过采样插值器在连续的符号输出之间恰好会输出一个中点值。也就是说插值器实际上是按2倍符号率工作的每个输入周期它都在更新奇数周期输出midpoint偶数周期输出symbol。判断哪个是符号哪个是中点最稳妥的办法是看NCO的溢出标志。TED模块的Verilog核心代码module ted #( parameter DATA_WIDTH 16, parameter TAP_NUM 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] sym_i, // 当前符号值 input wire signed [DATA_WIDTH-1:0] sym_q, input wire signed [DATA_WIDTH-1:0] mid_i, // 当前中点值 input wire signed [DATA_WIDTH-1:0] mid_q, input wire sym_valid, // 符号有效 output reg signed [DATA_WIDTH*2-1:0] error_i, output reg signed [DATA_WIDTH*2-1:0] error_q ); reg signed [DATA_WIDTH-1:0] sym_i_d1, sym_q_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sym_i_d1 0; sym_q_d1 0; error_i 0; error_q 0; end else if (sym_valid) begin sym_i_d1 sym_i; sym_q_d1 sym_q; error_i (sym_i - sym_i_d1) * mid_i; error_q (sym_q - sym_q_d1) * mid_q; end end endmodule误差信号直接乘出来可能位宽很大我在实际项目里会再做一次截位把高16位取出来给环路滤波器。截位时要保留符号避免直流偏置。3.3 环路滤波器与NCO实现二阶环路的Verilog写法环路滤波器我习惯写成单独模块输入是TED的误差输出是NCO的频率控制字。比例支路和积分支路的位宽分别设置积分器累加时必须做饱和处理否则环路长时间工作后积分器溢出输出频率字会突然跳变导致环路失锁。环路滤波核心代码module loop_filter #( parameter DATA_WIDTH 16, parameter KP_WIDTH 16, parameter KI_WIDTH 24 )( input wire clk, input wire rst_n, output reg [31:0] freq_ctrl, // NCO频率控制字 input wire signed [DATA_WIDTH-1:0] err_in, input wire err_valid ); parameter signed [KP_WIDTH-1:0] KP 16d187; // 定点KpQ15格式约0.0057 parameter signed [KI_WIDTH-1:0] KI 24d2048; // 定点KiQ22格式约0.0005 reg signed [KI_WIDTH-1:0] integral; reg signed [DATA_WIDTHK1-1:0] kp_out; wire signed [KI_WIDTH-1:0] ki_out; wire signed [31:0] loop_out; assign ki_out integral (err_in * KI); always (posedge clk or negedge rst_n) begin if (!rst_n) integral 0; else if (err_valid) integral ki_out; end assign loop_out (err_in * KP) integral; always (posedge clk or negedge rst_n) begin if (!rst_n) freq_ctrl 32h1000_0000; // 初始符号率对应频率字 else if (err_valid) freq_ctrl loop_out; end endmoduleNCO模块实现其实就是一个累加器加上一个溢出判断module nco #( parameter ACCUM_WIDTH 32 )( input wire clk, input wire rst_n, input wire signed [ACCUM_WIDTH-1:0] freq_ctrl, output reg accum_overflow, output reg signed [ACCUM_WIDTH-1:0] accum_value ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin accum_value 0; accum_overflow 0; end else begin accum_value accum_value freq_ctrl; accum_overflow accum_value freq_ctrl accum_value ? 1b1 : 1b0; end end endmodule注意初始频率控制字的计算如果ADC采样率是符号速率的2倍累加器位宽是32位那么累加器每2个时钟周期要溢出一次也就是累加器从0加满到2^32需要2个时钟周期所以初始频率控制字约等于2^31。误差为0时环路就锁定在这个初始点。3.4 插值器实现从线性插值到Farrow结构线性插值器代码非常清晰直接根据小数间隔u在两个样点之间取加权平均module interpolator #( parameter DATA_WIDTH 16 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] x0, // 前一个样点 input wire signed [DATA_WIDTH-1:0] x1, // 当前样点 input wire signed [DATA_WIDTH-1:0] mu, // 小数间隔Q15格式 output reg signed [DATA_WIDTH-1:0] y ); wire signed [DATA_WIDTH*2-1:0] prod0, prod1; assign prod0 x0 * ((115) - mu); // (1-mu)*x0 assign prod1 x1 * mu; // mu*x1 always (posedge clk or negedge rst_n) begin if (!rst_n) y 0; else y (prod0 prod1) 15; end endmodule这里mu由NCO累加器的小数部分转换而来。注意累加器位宽32位但mu只需要高16位直接用累加值的高16位做线性插值足够。如果你的系统对性能要求高建议使用三次Farrow插值器。Farrow结构的好处是插值系数只跟小数间隔mu有关可以提前算好而且它的实现不会随着采样率倍数变化而改动太大。但代价是消耗3个乘法器和多级寄存器。我给个选择建议QPSK、滚降系数0.5以上线性插值够用16QAM以上或者滚降系数小于0.3直接上Farrow三次插值别纠结。4. Testbench实战怎么验证定时同步环路4.1 测试激励产生带定时偏差的QPSK基带信号做Testbench之前你需要先在PC端用脚本生成一组带定时偏差的基带信号数据存成文本或hex文件然后在Testbench里用$readmemh读进来。我一般用Python生成因为调整参数太方便了。生成测试信号的关键步骤生成随机的QPSK符号序列符号速率设为2Msps。用平方根升余弦滤波器成形滚降系数0.35。在2倍符号率也就是4Msps上采样输出I/Q数据量化成16位有符号数。人为加入定时偏差最简单的办法是把采样时钟稍微偏移一点点比如让每个符号的采样点向一侧偏移0.1个符号周期。数据写入文本时每一行一个采样点格式化成十六进制或者安全十进制。我用的是有符号十进制因为$readmemh不支持负数用十进制读入再转signed最省事。Testbench框架大致如下module gardner_sync_tb(); parameter DATA_WIDTH 16; parameter CLK_PERIOD 10; // 4MHz采样率250ns周期 reg clk, rst_n; reg signed [DATA_WIDTH-1:0] din_i, din_q; reg din_valid; wire signed [DATA_WIDTH-1:0] dout_i, dout_q; wire dout_valid; reg signed [DATA_WIDTH-1:0] mem_i [0:8191]; reg signed [DATA_WIDTH-1:0] mem_q [0:8191]; integer idx; initial begin $readmemh(qpsk_i.hex, mem_i); $readmemh(qpsk_q.hex, mem_q); end initial begin clk 0; forever #(CLK_PERIOD/2) clk ~clk; end initial begin rst_n 0; din_valid 0; #1000 rst_n 1; #100; for (idx 0; idx 8192; idx idx 1) begin (posedge clk); din_i mem_i[idx]; din_q mem_q[idx]; din_valid 1; end (posedge clk); din_valid 0; #10000 $finish; end gardner_sync uut ( .clk(clk), .rst_n(rst_n), .din_i(din_i), .din_q(din_q), .din_valid(din_valid), .dout_i(dout_i), .dout_q(dout_q), .dout_valid(dout_valid) ); endmodule这里有个经常被忽略的点$readmemh只认十六进制很多人拿十进制文件去读仿真报错半天找不到原因。我习惯在Python生成数据时直接用format(i, X)输出十六进制正负数先转成对应补码表示再写进文件。这样读进Verilog正好就是补码不用做转换。4.2 仿真收敛判断与观测信号跑完仿真之后判断环路是否收敛不要只盯着波形看眼花缭乱。我的标准流程是观察dout_valid脉冲间隔是不是稳定地等于2个采样时钟周期。这个是最直观的锁定标志。观察频繁输出的误差信号error看它是否从初始的较大值逐渐收敛到0附近一个小区间内。叠加观察dout_i/q的星座图。在仿真波形窗口你没法直接画星座图但可以把dout数据dump到文本再用Python画。锁定后的星座图应该是四个清晰的簇没有明显的旋转和发散。看NCO频率控制字是否稳定在一个固定值附近并记录了最终的锁定值这个值就是当前输入信号定时偏差的反映。我在实际项目里发现如果环路带宽选得太大误差信号收敛后波动大星座图看起来有一圈雾状扩散。这时减小Kp增大Ki或者增加TED累加长度就能明显改善。如果环路带宽太小收敛时间会超过仿真数据长度看起来就是环路始终没锁定。4.3 常见仿真坑我用ModelSim和Vivado Simulator都踩过不少坑。简单列举几个高频问题未定义信号导致X态传播。任何reg如果没有初始化仿真初始就是X乘法器一碰X整个环路输出全是X。解决办法是复位后把所有状态寄存器清零且确保复位时间足够长。时钟频率设置和采样率不一致。如果你的DDS或测试激励以4MHz发数据但Testbench时钟给的是10MHz端口时序会错乱。实际工程中FPGA内部经常不止一个时钟域但初级项目请务必把整个环路放同一个时钟域里跑先把功能调通再考虑跨时钟域。数据文件路径不对。vivado或者modelsim打开工程后工作目录和你写文件时的目录不一致$readmemh找不到文件就会报错。解决办法是用相对路径或者把数据文件直接放到仿真工程根目录下。仿真时间过长导致波形文件巨大电脑卡死。可以只在关心的区间dump波形或者减少采样点数。我一般用2048或者4096个符号就够验证功能了。5. 常见问题与排查技巧实录5.1 环路不收敛先查数据通路再查参数这个问题我在协助其他人调试时遇到最多。现象就是跑了一两万个时钟周期误差信号还是大范围波动dout_valid的间隔也不是稳定的2个时钟。大多数时候问题不是环路参数而是数据通路上的BUG。我建议按这个顺序查先看输入din_valid和dout_valid的时序关系确认插值器输出与NCO溢出是否对齐。再单独给TED模块加固定输入检查误差输出是否符合预期。然后把环路滤波器的积分支路断开看误差信号是否正常减小到某个值。最后才去调KpKi参数。很多人在第2步就发现问题了——TED的计算时序错了半个周期导致反馈信号全部对不上。5.2 锁相后抖动大定点位宽和环路带宽的锅环路锁定了但输出符号抖动大星座图模糊。这种情况优先看环路滤波器的位宽。我实测下来误差信号在进入乘法器之前至少保留14到16位有效位如果截位太多环路相当于在量化噪声里工作输出抖动很难压下去。其次检查Ki是不是设得偏大积分支路增益过高会让环路出现极限环震荡现象就是锁定后输出仍然有周期性波动。另外一个常见问题是我之前提到的滚降系数。如果你发送端用的是滚降系数很小的成形滤波器Gardner环的S曲线增益会显著下降。解决办法是适当增大Kp或者让输入信号先通过一个带宽稍宽的匹配滤波器提升误差检测增益。5.3 资源与时序问题别在MCU思维里写FPGA有的同学喜欢在always块里写很长的if-else导致组合逻辑路径过长100MHz以上时序容易跑不过。Gardner环整体数据通路不长但插值器、乘法器是组合逻辑关键路径。我的做法是乘法器尽量用FPGA自带的DSP48宏不要靠综合工具推测。关键路径上多打一拍寄存器付出一个时钟周期的流水延迟换取时序收敛。环路滤波器累加器用饱和累加避免无符号数溢出导致环路崩溃。NCO和插值器之间的时序关系通过寄存器级联保持严格同步不要在中间插异步逻辑。5.4 硬件实测补充仿真通过只是第一步上板调试我踩过最大的坑是复位不同步。FPGA上电后如果外部给了一个异步复位信号而ADC数据已经持续输入环路第一次启动时可能因为状态未初始化而跑飞。解决办法是做一个上电复位延迟让复位拉高之后至少等输入信号稳定几百个周期再开始处理同时把NCO的初始频率控制字配合实际采样率配置正确。实际测下来只要仿真中能锁定板上大概率问题就是复位时序和初始频率字这两处。另外建议上板时把NCO频率控制字和误差信号通过DAC或者调试接口读出来用串口或者示波器观察。我在调试时就发现仿真里环路输出看起来收敛了但实际上NCO频率控制字跑到了很偏的位置最终定位到是测试激励的符号率标称值和实际不符导致的。这提醒我仿真时用的测试向量最好能反映真实偏频范围别太理想化。Gardner环定时同步这个项目说难不难但真正做透需要你从算法、定点、RTL、仿真到板级联调整个路径都熟悉一遍。做完这一个项目很多数字通信的基础概念比如过采样、滚降因子、环路带宽、星座图就不再是书本上空洞的名词而是你真真切切调过、测过的东西。我自己做下来最大的体会是一定要先在软件里把算法跑通再动RTL。刚开始我图省事直接拿别人的Verilog代码改结果参数不会调仿真怎么都不收敛后来回头老老实实用Python搭了个浮点模型把每个中间信号都打印出来慢慢看才彻底明白环路里每个参数的作用。如果你也是第一次做建议用同样的思路先软后硬先浮点后定点每一步有意识地记录中间结果这套方法在后续做任何复杂信号处理算法时都适用。
返回列表