从1024点滑动平均到EMA:双路5GS/s ADC Offset校准的FPGA工程实现

1. 先说项目背景

项目里有两路独立的5GS/s ADC:

复制代码
adc0_data[255:0]
adc1_data[255:0]

FPGA工作时钟为:

复制代码
312.5 MHz

每路数据宽度256bit,一个采样点16bit,因此每拍包含:

复制代码
256 / 16 = 16 samples

对应数据率:

复制代码
312.5 MHz × 16 = 5 GS/s

两路ADC经过前级FIFO对齐后,同时给出:

复制代码
adc_data_vld
adc0_data[255:0]
adc1_data[255:0]

每路数据从低位开始依次为:

复制代码
adc0_data[15:0]    -> sample0
adc0_data[31:16]   -> sample1
adc0_data[47:32]   -> sample2
...
adc0_data[255:240] -> sample15

adc1_data同理。

后级最终按照下面顺序手动交织:

复制代码
adc0_s0, adc1_s0,
adc0_s1, adc1_s1,
adc0_s2, adc1_s2,
...

得到10GS/s数据流。

本文最终采用的数据通路是:

复制代码
             RFDC
              │
              ▼
          FIFO 对齐
              │
              ▼
      Offset Correction
              │
              ▼
      Gain/Phase Correction
              │
              ▼
        手动交织成10GS/s

这里要特别说明一下。

前期讨论里曾出现过一句"Offset在Gain/Phase之后",但按照实际接口描述:

复制代码
Offset模块输出
    ↓
Gain/Phase模块输入

所以真正的数据流应该是:

复制代码
Offset → Gain/Phase

本文全部按照这个顺序讨论。


2. 为什么一开始想用1024点滑动平均?

MATLAB离线处理最容易想到的方法是:

复制代码
当前数据 - 前1024点平均值

数学表达式:

yn=xn−11024∑k=11024xn−kyn = xn - \frac{1}{1024} \sum_{k=1}^{1024}xn-k

这种方法的问题不在于"不能实现",而是它实际上做的是:

把每一路自己的DC分量全部去掉。

但对于交织ADC,我们真正关心的是:

不同采样相位之间的Offset差值。

假设四个逻辑采样相位的平均值分别为:

μ0,μ1,μ2,μ3\mu_0,\mu_1,\mu_2,\mu_3

真正需要消除的是:

μ1−μ0\mu_1-\mu_0 μ2−μ0\mu_2-\mu_0 μ3−μ0\mu_3-\mu_0

而不是把:

μ0,μ1,μ2,μ3\mu_0,\mu_1,\mu_2,\mu_3

全部减成0。

这两个目标差别很大。

比如输入信号本身真的带有一个+100 LSB的公共DC:

复制代码
真实信号DC = +100

如果每一路都减自己的平均值:

复制代码
最终DC ≈ 0

真实信号DC也被一起干掉了。

而如果只消除通道间的相对Offset:

复制代码
四路最终仍然保持在约 +100 LSB

这才是本文需要的结果。


3. 为什么两路ADC要拆成4个"逻辑相位组"?

每个ADC一拍有16个采样点。

我们把每路的偶数点和奇数点分开。

对于adc0_data:

复制代码
P0:
s0, s2, s4, s6, s8, s10, s12, s14

P2:
s1, s3, s5, s7, s9, s11, s13, s15

对于adc1_data:

复制代码
P1:
t0, t2, t4, t6, t8, t10, t12, t14

P3:
t1, t3, t5, t7, t9, t11, t13, t15

于是每个312.5MHz周期,可以得到4组数据:

逻辑相位 数据来源 每拍采样点数
P0 adc0偶数点 8
P1 adc1偶数点 8
P2 adc0奇数点 8
P3 adc1奇数点 8

最终10GS/s交织后的逻辑顺序实际上就是:

复制代码
P0 → P1 → P2 → P3 → P0 → P1 → P2 → P3 ...

所以从FPGA算法角度,可以把它看成一个4相位周期。

但是这里不要犯一个概念错误:

P0/P1/P2/P3是我们根据PL输出数据人为定义的"逻辑相位组",不能直接说它们就是RFSoC内部4个物理sub-ADC。

RFSoC内部实际还有自己的sub-ADC交织结构。

本文只讨论:

复制代码
从PL看到的数据里,
按照even/odd拆分以后,
这种4相位统计方法能够改善实际Offset失配。

如果板上测试发现还有更高周期的Offset杂散,那么说明只做even/odd拆分还不够,需要继续细分。


4. 一个关键修正:不能只做两个Offset

前面讨论中曾有一个版本:

复制代码
adc0:
odd 对齐 even

adc1:
odd 对齐 even

这样只有两个Offset系数。

看起来很省资源,但实际上漏了一个问题:

复制代码
adc0整体DC
    和
adc1整体DC

仍然可能不一样。

假设校准后:

复制代码
adc0_even ≈ adc0_odd ≈ +20

adc1_even ≈ adc1_odd ≈ -15

虽然两个ADC内部已经分别对齐,但最终10GS/s交织后仍然是:

复制代码
+20, -15, +20, -15 ...

显然还有35 LSB的ADC间Offset mismatch。

所以最终应该使用:

1个参考相位 + 3个相对Offset系数。

本文选择:

复制代码
P0 = adc0_even

作为参考。

定义:

Offset1=μ1−μ0Offset_1=\mu_1-\mu_0 Offset2=μ2−μ0Offset_2=\mu_2-\mu_0 Offset3=μ3−μ0Offset_3=\mu_3-\mu_0

校正:

复制代码
P0:保持不变

P1:sample - Offset1
P2:sample - Offset2
P3:sample - Offset3

最后:

μ0≈μ1,corr≈μ2,corr≈μ3,corr\mu_0 \approx \mu_{1,corr} \approx \mu_{2,corr} \approx \mu_{3,corr}

这样既消除了ADC内部even/odd偏差,也消除了adc0和adc1之间的整体Offset差。


5. 每拍先求8点和,不要逐点估计Offset

这里是整个设计降低资源的第一步。

以P0为例:

复制代码
P0 = adc0_s0
     adc0_s2
     adc0_s4
     ...
     adc0_s14

先计算:

B0=s0+s2+s4+...+s14B_0= s_0+s_2+s_4+...+s_{14}

同理:

B1,B2,B3B_1,B_2,B_3

然后计算:

D1=B1−B0D_1=B_1-B_0 D2=B2−B0D_2=B_2-B_0 D3=B3−B0D_3=B_3-B_0

因为每个B都包含8个采样点,所以:

Di8\frac{D_i}{8}

就是这一拍对应两个逻辑相位之间的平均Offset差估计。

注意:

FPGA里面没必要马上真的除8。

后面统一利用移位处理即可。


6. 8输入加法树怎么写才适合312.5MHz?

千万不要写成一长串:

复制代码
sum = a+b+c+d+e+f+g+h;

虽然综合工具可能会帮你优化,但312.5MHz下没必要赌工具。

直接做三级加法树。

复制代码
Level 1:

x0 + x1 ─┐
          ├─
x2 + x3 ─┘

x4 + x5 ─┐
          ├─
x6 + x7 ─┘


Level 2:

sum01 + sum23

sum45 + sum67


Level 3:

sum0123 + sum4567

对应位宽:

复制代码
16bit sample

16 + 16
    ↓
17bit

17 + 17
    ↓
18bit

18 + 18
    ↓
19bit

所以8个16bit有符号数求和,19bit signed就够。

四个相位一共需要:

复制代码
4 × 7 = 28个加法器

完全没有必要使用DSP。


7. 为什么不用真正的1024点滑动窗口?

如果严格按照1024点矩形滑动平均:

Mn=xn−1024+...+xn−11024Mn = \frac{xn-1024+...+xn-1}{1024}

硬件需要保存历史数据。

但注意,现在一个逻辑相位每拍已经有8个采样点。

因此:

复制代码
1024 samples / 8 samples/clk
=
128 clk

所以即使实现块级滑动平均,也可以只保存最近128个D_i:

Sik+1=Sik+Dik−Dik−128S_ik+1 = S_ik+D_ik-D_ik-128

然后:

Offseti=Si>>10Offset_i=S_i>>10

为什么右移10?

因为:

128×8=1024=210128\times8=1024=2^{10}

三个通道需要保存:

复制代码
3 × 128 × 20bit
≈ 7680bit

其实也不算大。

所以这里要说清楚:

EMA不是因为1024点滑窗"做不了"才使用,而是因为Offset本身属于慢变化参数,没有必要为了矩形窗口特性保存历史数据。


8. EMA到底是什么?

标准EMA公式:

yk=yk−1+α(xk−yk−1)yk = yk-1 + \alpha (xk-yk-1)

也可以写成:

yk=(1−α)yk−1+αxkyk = (1-\alpha)yk-1 + \alpha xk

展开以后:

yk=αxk+α(1−α)xk−1+α(1−α)2xk−2+⋯yk = \alpha xk + \alpha(1-\alpha)xk-1 + \alpha(1-\alpha)^2xk-2 +\cdots

也就是说,EMA同样是在做平均。

区别是:

滑动平均

最近N个数据权重完全一样:

复制代码
********
********
********
********

超过窗口以后,权重直接变成0。

EMA

越新的数据权重越大,越老的数据权重指数下降:

复制代码
********
 ******
   ****
     **
      *
      .
      .

所以:

EMA和1024点滑动平均并不是数学意义上的完全等价。

这是一定要说清楚的。

两者:

复制代码
冲激响应不同
阶跃响应不同
历史数据权重不同

我们这里只要求:

对一个缓慢变化的Offset参数,达到接近的统计平滑效果。


9. 为什么这里选择α=1/64?

EMA对白噪声的等效平均长度可以近似写成:

Neq=2−αα=2α−1N_{eq} = \frac{2-\alpha}{\alpha} = \frac{2}{\alpha}-1

如果:

α=164\alpha=\frac1{64}

那么:

Neq=127N_{eq}=127

这里一个EMA update代表8个逻辑相位采样点,因此:

127×8=1016127\times8=1016

与原来的:

复制代码
1024 samples

非常接近。

因此第一版RTL与MATLAB做效果对比时,可以直接选择:

复制代码
K = 6

α = 2^-6 = 1/64

注意,这里的"1016点等效"指的是:

对白噪声平均效果或者噪声带宽意义上的近似等效。

不是说:

复制代码
EMA输出 == 1024点滑动平均输出

两条波形不会逐点完全一样。


10. EMA在FPGA里不要直接这么写

最直观的写法是:

复制代码
error  = input - offset;
offset = offset + (error >>> 6);

但如果offset只是整数LSB,这种写法有一个问题。

比如:

复制代码
error = 20

那么:

复制代码
20 >>> 6 = 0

EMA直接不更新。

也就是说,会出现明显的:

定点死区。

所以更推荐保留EMA内部的小数精度。


11. 推荐的EMA硬件形式:缩放累加器

我们已经有:

Di=Bi−B0D_i=B_i-B_0

不要先做:

复制代码
D_i >>> 3

而是直接维护:

Aik+1=Aik+Dik−Aik2KA_ik+1 = A_ik + D_ik - \frac{A_ik}{2^K}

FPGA就是:

复制代码
ema_acc <= ema_acc
         + diff
         - (ema_acc >>> K);

最终真正用于样点校正的Offset:

Offseti=Ai2K×8Offset_i = \frac{A_i}{2^K\times8}

因为8等于:

232^3

所以:

复制代码
offset = ema_acc >>> (K + 3);

以K=6为例:

复制代码
offset = ema_acc >>> 9;

这个形式有几个好处:

复制代码
1. 不需要乘法器
2. 不需要除法器
3. 不需要BRAM
4. 保留了EMA内部的小数信息
5. 不会因为过早右移3bit和6bit损失大量精度

这是我更推荐的RTL实现形式。


12. 为什么这个公式是对的?

标准EMA:

Mk+1=Mk+Dk−Mk2KMk+1 = Mk + \frac{Dk-Mk}{2^K}

定义:

Ak=2KMkAk=2^K Mk

两边乘:

2K2^K

得到:

Ak+1=Ak+Dk−Ak2KAk+1 = Ak + Dk - \frac{Ak}{2^K}

也就是:

复制代码
A_new = A + D - (A >>> K);

而这里的D是8个采样点的Offset差之和。

所以:

Offset=A2K×8Offset=\frac{A}{2^K\times8}

最终就是:

复制代码
offset = A >>> (K+3);

整个算法只剩:

复制代码
加法
减法
算术右移
寄存器

非常适合FPGA。


13. Foreground + Background,比纯EMA更实用

实际工程里我不建议上电以后直接让EMA从0慢慢爬。

更实用的方式是:

复制代码
Foreground快速建立初始值
        ↓
Background EMA慢速跟踪

13.1 Foreground校准

每个逻辑相位一拍有8个采样点。

累计128个有效拍:

复制代码
128 × 8 = 1024 samples

所以:

ACCi=∑k=0127DikACC_i = \sum_{k=0}^{127}D_ik

最终:

Offseti=ACCi/1024Offset_i = ACC_i/1024

即:

复制代码
offset_i = acc_i >>> 10;

这里不要先:

复制代码
diff >>> 3

再累计。

推荐:

复制代码
直接累计原始20bit diff
最后一次性 >>> 10

这样定点误差更小。


13.2 注意一个非常常见的RTL Bug

假设计数器:

复制代码
0 ~ 127

当:

复制代码
fg_cnt == 127

时,如果直接:

复制代码
offset <= fg_acc >>> 10;

实际上会漏掉当前这一次diff。

正确思路应该是:

复制代码
fg_acc_next = fg_acc + diff;

if (fg_cnt == 127)
    offset <= fg_acc_next >>> 10;

这是累加类RTL里非常常见的off-by-one错误。


14. Background校准

Foreground结束以后进入Background。

三个EMA分别运行:

复制代码
EMA1:P1 - P0

EMA2:P2 - P0

EMA3:P3 - P0

即:

复制代码
ema1 <= ema1 + diff1_ext - (ema1 >>> K);
ema2 <= ema2 + diff2_ext - (ema2 >>> K);
ema3 <= ema3 + diff3_ext - (ema3 >>> K);

最终:

复制代码
offset1 = ema1 >>> (K+3);
offset2 = ema2 >>> (K+3);
offset3 = ema3 >>> (K+3);

然后实时用于数据校正。


15. Freeze信号一定建议保留

简单EMA有一个天然问题:

它不知道当前看到的"相位均值差",到底来自ADC Offset,还是输入信号本身。

如果输入信号在某些和交织周期强相关的频率上有明显能量,那么Background EMA可能会:

复制代码
把真实信号的一部分
误认为Offset

所以建议增加:

复制代码
cal_freeze

逻辑非常简单:

复制代码
cal_freeze = 0:
    正常更新EMA

cal_freeze = 1:
    Offset系数保持不变
    数据仍然继续使用当前系数校正

注意:

Freeze只冻结"系数更新",不要冻结数据通路。

以后可以根据:

复制代码
TDD状态
训练序列
输入信号状态
系统工作模式

控制它。


16. 状态机没必要做得很复杂

第一版建议只有:

复制代码
IDLE/RESET
     │
     ▼
FOREGROUND
     │
     │ 128个有效数据拍
     ▼
BACKGROUND

而:

复制代码
cal_freeze

不要单独做一个状态。

直接作为Background内部的更新使能即可。

例如:

复制代码
if (state == BACKGROUND) begin
    if (stat_vld && !cal_freeze) begin
        // update EMA
    end
end

这样状态机会简单很多。


17. 一个更重要的FPGA优化:估计通路和数据通路分开

早期最容易画成下面这样:

复制代码
输入数据
   ↓
拆分
   ↓
三级SUM
   ↓
diff
   ↓
EMA
   ↓
Offset
   ↓
校正当前输入数据

如果这样做,为了等Offset计算出来,就要把两路256bit原始数据跟着延迟很多拍:

复制代码
512bit × 多级pipeline

会消耗大量寄存器。

实际上完全没有必要。

因为Offset是一个非常慢的参数。

当前这一拍的数据:

完全可以使用"上一拍已经得到的Offset系数"进行校正。

因此推荐把模块拆成两条并行路径。


17.1 数据校正主通路

复制代码
                ┌───────────────┐
adc0/adc1 ─────>│ Offset减法器   │─────> 输出
                └───────▲───────┘
                        │
                  当前Offset系数

只需要:

复制代码
输入寄存
↓
减法
↓
饱和
↓
输出寄存

两三拍就够。


17.2 Offset估计旁路

复制代码
输入数据
   │
   ├─> P0/P1/P2/P3
   │
   ├─> 4组SUM8
   │
   ├─> 3组diff
   │
   └─> FG / EMA
          │
          ▼
      更新Offset寄存器

这条通路即使需要:

复制代码
5~6拍

也完全没有问题。

因为它只是在更新下一时刻的校准参数。


18. 最终推荐架构

整个模块可以理解成:

复制代码
                         ┌─────────────────────────────┐
                         │      Offset Estimator       │
                         │                             │
adc0_data ───────┬──────>│ even/odd → SUM8 ──┐        │
                 │       │                   ├─diff──EMA│
adc1_data ───────┼──────>│ even/odd → SUM8 ──┘        │
                 │       │                             │
                 │       └──────────────┬──────────────┘
                 │                      │
                 │              offset1/2/3
                 │                      │
                 ▼                      ▼
            ┌────────────────────────────────┐
            │      Offset Corrector          │
            │                                │
            │ P0:保持                       │
            │ P1:- offset1                  │
            │ P2:- offset2                  │
            │ P3:- offset3                  │
            └──────────────┬─────────────────┘
                           │
                           ▼
                  adc0_corr[255:0]
                  adc1_corr[255:0]
                           │
                           ▼
                  Gain/Phase Correction

这比"数据跟着估计器跑完整条pipeline"更合理。


19. Offset校正具体落在哪些sample上?

定义:

复制代码
P0 = adc0_even
P1 = adc1_even
P2 = adc0_odd
P3 = adc1_odd

P0作为参考。

那么:

adc0

复制代码
s0  -> P0 -> 不处理
s1  -> P2 -> -offset2
s2  -> P0 -> 不处理
s3  -> P2 -> -offset2
...

adc1

复制代码
t0  -> P1 -> -offset1
t1  -> P3 -> -offset3
t2  -> P1 -> -offset1
t3  -> P3 -> -offset3
...

最终:

复制代码
adc0偶数:不减
adc0奇数:减offset2

adc1偶数:减offset1
adc1奇数:减offset3

所以一共需要:

复制代码
3 × 8 = 24个并行sample减法器

20. Offset正负方向不要写反

这里一定要把定义固定死。

本文统一定义:

Offseti=Mean(Pi)−Mean(P0)Offset_i=Mean(P_i)-Mean(P_0)

所以:

Pi,corr=Pi−OffsetiP_{i,corr}=P_i-Offset_i

举个例子。

假设:

复制代码
P0平均值 = +20
P1平均值 = -10

则:

复制代码
offset1 = -10 - 20
        = -30

校正:

复制代码
P1_corr = P1 - (-30)
        = P1 + 30

因此:

复制代码
-10 + 30 = +20

正好和P0对齐。

以后MATLAB、RTL、ILA、寄存器文档都使用同一个定义:

复制代码
offset = phase - reference
corrected = phase - offset

这样最不容易出错。


21. 位宽怎么定?

以下假设输入为:

复制代码
16bit signed two's complement

如果实际RFDC接口格式不同,第一步应该先转换成统一的signed格式。


21.1 SUM8

16bit signed范围:

复制代码
-32768 ~ +32767

8点相加:

复制代码
19bit signed

足够。

推荐:

复制代码
Level1:17bit
Level2:18bit
Level3:19bit

21.2 diff

两个19bit SUM相减:

复制代码
20bit signed

所以:

复制代码
logic signed [19:0] diff1;
logic signed [19:0] diff2;
logic signed [19:0] diff3;

21.3 Foreground accumulator

需要累计128个20bit数据。

理论增加:

复制代码
log2(128)=7bit

所以27bit刚好够。

工程里建议留1bit保护:

复制代码
28bit signed

21.4 EMA accumulator

EMA采用:

复制代码
A <= A + D - (A >>> K);

大致需要:

复制代码
DIFF_WIDTH + K

位。

建议再留一个保护位:

复制代码
EMA_WIDTH = 20 + K_MAX + 1

如果:

复制代码
K_MAX = 16

则:

复制代码
EMA_WIDTH = 37bit

三个37bit寄存器几乎不算什么资源。


22. K到底要不要运行时可配置?

算法上我建议K可调。

比如:

复制代码
K = 6
K = 8
K = 10
K = 12
K = 14
K = 16

但是RTL实现要注意:

如果直接写:

复制代码
ema_acc >>> k_reg

而k_reg是运行时变量,综合器通常会做一个:

复制代码
barrel shifter

相比固定移位,它会多消耗一些LUT,也可能增加路径延迟。

所以有两种选择。

第一版,资源优先

直接写成parameter:

复制代码
parameter integer EMA_K = 6;

编译时固定。

这是最省资源的方式。

上板调试版

只允许几个离散值,例如:

复制代码
case(k_sel)
    3'd0: ema_shift = ema_acc >>> 6;
    3'd1: ema_shift = ema_acc >>> 8;
    3'd2: ema_shift = ema_acc >>> 10;
    3'd3: ema_shift = ema_acc >>> 12;
endcase

这样可控一些。


23. 为什么最终上板可能不应该继续用K=6?

现在每个逻辑相位的等效采样率大约是:

10GS/s/4=2.5GS/s10GS/s / 4 = 2.5GS/s

1024个采样点只对应:

1024/2.5G=409.6ns1024/2.5G = 409.6ns

也就是说:

原来的1024点窗口实际上只有约0.41微秒。

对于温度漂移之类的慢Offset变化,这个时间已经非常短。

所以我的建议是:

复制代码
仿真阶段:
K=6
方便和原1024点MATLAB结果比较

上板阶段:
逐渐测试K=8/10/12/14/16
观察spur和跟踪速度

不要因为MATLAB最初用了1024,就认为硬件最终必须永远等效1024。


24. 312.5MHz下推荐的流水结构

估计器可以这样流水。

复制代码
Cycle N
输入32个sample
      │
      ▼
Stage 1
8点分组 + 第一层pair sum
      │
      ▼
Stage 2
第二层sum
      │
      ▼
Stage 3
最终SUM8
      │
      ▼
Stage 4
diff1 / diff2 / diff3
      │
      ▼
Stage 5
Foreground累加 或 EMA更新

每一级基本只有:

复制代码
一次加减法

312.5MHz时钟周期:

1/312.5MHz=3.2ns1/312.5MHz=3.2ns

这种流水压力不大。

真正需要注意的反而是:

复制代码
512bit宽总线的布线
+
24个并行校正减法器

所以数据校正通路最好单独做2~3级pipeline。


25. Valid怎么处理?

这里必须建立一个原则:

所有统计更新都以adc_data_vld为准,而不是单纯数FPGA时钟。

比如Foreground需要:

复制代码
128个有效数据拍

不是:

复制代码
复位以后过128个系统时钟

正确逻辑:

复制代码
if (stat_vld) begin
    fg_cnt <= fg_cnt + 1'b1;
end

当:

复制代码
vld = 0

时:

复制代码
FG counter保持
FG accumulator保持
EMA accumulator保持

如果输入valid中间有空洞,那么校准的"样本数量"仍然是正确的。


26. 数据通路和Estimator的valid不要混在一起

由于我们已经把两条路径拆开:

复制代码
Correction Datapath

和

Offset Estimator

所以实际上有两套延迟。

例如:

复制代码
adc_data_vld
     │
     ├────────> correction valid delay ────> adc_corr_vld
     │
     └────────> estimator valid delay ─────> stat_vld

adc_corr_vld只和输出数据对齐。

stat_vld只和:

复制代码
SUM8
diff

对齐。

两者没有必要延迟相同的拍数。

这种写法比强行把所有valid做成一个总流水线清楚得多。


27. RTL:数据拆分示例

下面使用SystemVerilog写法,主要为了代码更清楚。

复制代码
logic signed [15:0] adc0_s [0:15];
logic signed [15:0] adc1_s [0:15];

generate
    for (genvar i = 0; i < 16; i++) begin : GEN_UNPACK
        assign adc0_s[i] = $signed(adc0_data[i*16 +: 16]);
        assign adc1_s[i] = $signed(adc1_data[i*16 +: 16]);
    end
endgenerate

逻辑分组:

复制代码
P0 = adc0_s[0], adc0_s[2], ..., adc0_s[14]

P1 = adc1_s[0], adc1_s[2], ..., adc1_s[14]

P2 = adc0_s[1], adc0_s[3], ..., adc0_s[15]

P3 = adc1_s[1], adc1_s[3], ..., adc1_s[15]

28. RTL:一个SUM8三级流水示意

以P0为例:

复制代码
logic signed [16:0] p0_l1 [0:3];
logic signed [17:0] p0_l2 [0:1];
logic signed [18:0] p0_sum;

always_ff @(posedge clk) begin
    if (rst) begin
        p0_l1[0] <= '0;
        p0_l1[1] <= '0;
        p0_l1[2] <= '0;
        p0_l1[3] <= '0;

        p0_l2[0] <= '0;
        p0_l2[1] <= '0;

        p0_sum   <= '0;
    end
    else begin
        // Level 1
        p0_l1[0] <= adc0_s[0]  + adc0_s[2];
        p0_l1[1] <= adc0_s[4]  + adc0_s[6];
        p0_l1[2] <= adc0_s[8]  + adc0_s[10];
        p0_l1[3] <= adc0_s[12] + adc0_s[14];

        // Level 2
        p0_l2[0] <= p0_l1[0] + p0_l1[1];
        p0_l2[1] <= p0_l1[2] + p0_l1[3];

        // Level 3
        p0_sum <= p0_l2[0] + p0_l2[1];
    end
end

P1/P2/P3完全同样的结构。

这里只需要注意valid同步延迟3拍即可。


29. RTL:diff计算

得到:

复制代码
p0_sum
p1_sum
p2_sum
p3_sum

以后:

复制代码
logic signed [19:0] diff1;
logic signed [19:0] diff2;
logic signed [19:0] diff3;

always_ff @(posedge clk) begin
    if (rst) begin
        diff1 <= '0;
        diff2 <= '0;
        diff3 <= '0;
    end
    else begin
        diff1 <= $signed(p1_sum) - $signed(p0_sum);
        diff2 <= $signed(p2_sum) - $signed(p0_sum);
        diff3 <= $signed(p3_sum) - $signed(p0_sum);
    end
end

方向固定为:

复制代码
phase - reference

不要一会儿写:

复制代码
P1-P0

一会儿又写:

复制代码
P0-P1

30. RTL:EMA核心

以一个通道为例。

复制代码
localparam integer EMA_K    = 6;
localparam integer DIFF_W   = 20;
localparam integer EMA_W    = DIFF_W + EMA_K + 1;

logic signed [EMA_W-1:0] ema_acc;
logic signed [EMA_W-1:0] diff_ext;

always_comb begin
    diff_ext =
        {{(EMA_W-DIFF_W){diff1[DIFF_W-1]}}, diff1};
end

always_ff @(posedge clk) begin
    if (rst) begin
        ema_acc <= '0;
    end
    else if (bg_enable &&
             stat_vld  &&
             !cal_freeze) begin

        ema_acc <= ema_acc
                 + diff_ext
                 - (ema_acc >>> EMA_K);
    end
end

对应Offset:

复制代码
wire signed [EMA_W-1:0] offset_ext;

assign offset_ext =
    ema_acc >>> (EMA_K + 3);

另外两个相位完全一样。

实际工程建议把三个EMA封装成一个参数化模块。


31. 为什么一定要用>>>?

有符号数右移一定要明确使用:

复制代码
>>>

即:

复制代码
arithmetic right shift
算术右移

不要不加思考地写:

复制代码
>>

因为Offset和EMA内部都会出现负数。

例如:

复制代码
-64 >>> 6 = -1

这才符合有符号定点计算。

所有参与运算的数据也建议明确声明:

复制代码
logic signed

不要完全依赖Verilog自动推断符号类型。


32. 校正以后不要直接截低16bit

这一点也很重要。

假设输入:

复制代码
sample = 32760

Offset:

复制代码
offset = -20

校正:

复制代码
32760 - (-20)
=
32780

已经超过16bit signed最大值:

复制代码
32767

如果直接:

复制代码
corrected[15:0]

会发生:

回绕,而不是饱和。

波形会直接跳到负数附近。

所以正确做法是:

复制代码
先扩位减法
↓
判断上下溢
↓
饱和到16bit

33. 一个简单的16bit饱和函数

例如:

复制代码
function automatic signed [15:0] sat16;
    input signed [17:0] din;

    begin
        if (din > 18'sd32767)
            sat16 = 16'sh7FFF;
        else if (din < -18'sd32768)
            sat16 = 16'sh8000;
        else
            sat16 = din[15:0];
    end
endfunction

内部校正减法建议使用18bit。


34. RTL:最终Sample校正关系

伪代码如下:

复制代码
for (int i = 0; i < 16; i++) begin

    // adc0
    if ((i % 2) == 0) begin
        // P0,参考相位
        adc0_corr_sample[i] = adc0_sample[i];
    end
    else begin
        // P2
        adc0_corr_sample[i]
            = sat16(adc0_sample[i] - offset2);
    end


    // adc1
    if ((i % 2) == 0) begin
        // P1
        adc1_corr_sample[i]
            = sat16(adc1_sample[i] - offset1);
    end
    else begin
        // P3
        adc1_corr_sample[i]
            = sat16(adc1_sample[i] - offset3);
    end

end

重新pack以后:

复制代码
adc0_corr_data[255:0]
adc1_corr_data[255:0]

接口和原输入保持完全一致。

Offset模块不负责10GS/s交织。


35. 整个设计大概需要多少资源?

Estimator部分:

SUM8

复制代码
4组 × 7个adder
=
28个加法器

Difference

复制代码
3个20bit减法器

EMA

复制代码
3个约27~37bit accumulator
+
3组移位

如果K固定:

复制代码
移位基本等于接线

Correction

非参考相位共:

复制代码
3 × 8
=
24个sample

所以需要大约:

复制代码
24个16~18bit校正减法器

整个设计可以:

复制代码
DSP = 0
BRAM = 0

主要消耗:

复制代码
LUT
Carry Chain
Flip-Flop

实际上EMA估计器本身非常小。

真正占资源的主要是:

复制代码
24路并行Offset减法
+
宽总线pipeline寄存器

所以前面把"Estimator和Correction Datapath拆开"很重要。


36. 如果一定要求矩形1024点窗口怎么办?

那也没必要保存1024个原始16bit sample。

仍然先计算:

复制代码
D1
D2
D3

每个D已经代表8个采样点。

只保存:

复制代码
最近128个D

维护:

Sk+1=Sk+Dk−Dk−128Sk+1 = Sk + Dk - Dk-128

然后:

复制代码
offset = S >>> 10;

这种方案需要的历史数据约:

复制代码
3 × 128 × 20bit
=
7680bit

仍然很小。

所以如果系统需求明确写着:

必须使用矩形窗口。

那我会选择这种实现,而不是保存4×1024个原始采样点。

但对于Offset后台跟踪,我仍然优先选择EMA。


37. 怎么验证这个模块?

这种算法最容易出现的问题通常不是:

复制代码
代码编译不过

而是:

复制代码
代码能跑
但方向、延迟、定点或者统计方法错了

所以验证最好分三层。


37.1 第一层:纯算法验证

自己构造四个逻辑相位:

复制代码
P0 offset =   0
P1 offset = +12
P2 offset = -20
P3 offset =  +7

再加同一个真实信号。

例如:

复制代码
signal + phase_offset + noise

检查EMA收敛以后:

复制代码
offset1 ≈ +12
offset2 ≈ -20
offset3 ≈ +7

同时四路校正后均值应该接近一致。


37.2 第二层:专门验证公共DC不会被消掉

例如人为加入:

复制代码
signal_dc = +500

然后:

复制代码
P0 = signal + 0
P1 = signal + 12
P2 = signal - 20
P3 = signal + 7

正确结果应该是:

复制代码
四路校正后均值 ≈ +500

而不是:

复制代码
≈ 0

这个测试可以直接证明:

当前算法是在消Offset mismatch,不是在做DC blocker。


37.3 第三层:验证Offset突变

运行一段时间以后:

复制代码
P2 offset:
-20 → +30

观察EMA。

应该看到:

复制代码
offset2

以指数形式逐渐从:

复制代码
-20

收敛到:

复制代码
+30

而不是突然跳过去。


38. Freeze测试也要单独做

例如EMA已经收敛:

复制代码
offset1 = 12

然后:

复制代码
cal_freeze = 1;

此时即使输入统计量发生变化:

复制代码
offset1

也必须完全保持。

但是:

复制代码
adc_corr_vld
adc0_corr_data
adc1_corr_data

仍然继续正常输出。

这是两个完全不同的概念。


39. MATLAB和RTL对比时最容易踩的坑

不要直接拿:

复制代码
MATLAB 1024点moving mean

和:

复制代码
EMA RTL

逐点做:

复制代码
RTL == MATLAB

然后发现不一样就认为RTL错了。

因为两种滤波器本来就不是同一个系统。

正确方法有两个。

方法一

MATLAB也实现完全相同的EMA:

复制代码
A = A + D - floor_shift(A, K);
offset = A >> (K+3);

然后进行bit-accurate比较。

方法二

如果MATLAB仍然使用1024点滑窗,就比较:

复制代码
最终Offset估计值
收敛趋势
稳态方差
校正后的spur

不要比较每一个采样点完全相等。


40. FPGA定点模型必须和RTL使用同样的舍入方法

RTL里的:

复制代码
>>>

本质是定点截断。

尤其对负数:

复制代码
算术右移的行为

必须在MATLAB/Python模型里模拟一致。

否则可能出现:

复制代码
算法趋势完全对
但始终差1LSB

然后查RTL查半天。

这种问题在定点DSP设计里非常常见。


41. 上板以后重点看什么?

我会重点抓四类数据。

1. 三个Offset系数

复制代码
offset1
offset2
offset3

通过ILA或者寄存器读回。

看:

复制代码
是否收敛
是否乱跳
温度变化时是否缓慢变化

2. 校正前四个逻辑相位的均值

复制代码
mean(P0)
mean(P1)
mean(P2)
mean(P3)

3. 校正后的均值

理想情况下:

复制代码
mean(P0)
≈
mean(P1_corr)
≈
mean(P2_corr)
≈
mean(P3_corr)

4. 最终10GS/s数据频谱

真正评价Offset校准有没有价值,最后还是要看:

复制代码
交织相关的固定spur

是否下降。

不要只看:

复制代码
offset寄存器看起来挺稳定

就认为算法有效。


42. 为什么简单EMA不是万能的?

本文这个方案的优点是:

复制代码
简单
资源少
容易调试
容易高速流水

代价是:

它依赖"不同逻辑相位看到的真实输入统计均值长期一致"这个假设。

如果输入本身在交织相关频率存在强确定性成分:

复制代码
P0/P1/P2/P3

的统计均值差未必全部来自ADC Offset。

此时简单EMA可能出现误估。

所以工程上最好配合:

复制代码
Foreground训练
+
Background freeze机制

使用。

不要把一个简单均值估计器想成可以在任何输入条件下无条件工作的万能后台校准算法。


43. Foreground阶段最好在什么条件下做?

如果系统允许,最好选择:

复制代码
无输入信号
前端静默
50Ω终端
已知训练状态

之一。

这时:

复制代码
不同逻辑相位之间的均值差

更能直接反映ADC自身的Offset mismatch。

先运行128个有效beat:

复制代码
1024 sample/phase

得到一个可靠初值。

然后正常工作时再切换到慢速EMA跟踪。

这个结构比"上电以后EMA从0开始一直跑"更加稳定。


44. 一个实际工程上更合理的参数配置

第一版RTL我会这样定:

复制代码
DATA_WIDTH      = 16
ADC_NUM         = 2
SAMPLE_PER_CLK  = 16

LOGICAL_PHASE   = 4
PHASE_SAMPLE    = 8

FG_BLOCK_NUM    = 128

EMA_K           = 6

并提供:

复制代码
cal_enable
cal_start
cal_freeze
cal_done

以及调试寄存器:

复制代码
offset1_readback
offset2_readback
offset3_readback

fg_cnt_readback
cal_state_readback

后面上板调试会方便很多。


45. 最终建议的模块划分

不要把所有逻辑塞进一个上千行模块。

可以拆成:

复制代码
adc_offset_correct_top
│
├── phase_sum
│
│   ├── P0 SUM8
│   ├── P1 SUM8
│   ├── P2 SUM8
│   └── P3 SUM8
│
├── offset_estimator
│
│   ├── diff
│   ├── foreground accumulator
│   ├── background EMA
│   └── freeze/control
│
└── offset_corrector
    │
    ├── P0 bypass
    ├── P1 correction
    ├── P2 correction
    ├── P3 correction
    └── saturation + pack

这种结构有几个好处:

复制代码
算法和数据通路分开
仿真方便
ILA定位方便
后面修改EMA不影响接口
后面修改饱和方式不影响估计器

对于刚开始做FPGA工程的人,这比"少写几个module"更重要。


46. 最后把整个算法浓缩成几行

每个有效时钟:

复制代码
P0 = sum(adc0 even 8 samples)
P1 = sum(adc1 even 8 samples)
P2 = sum(adc0 odd  8 samples)
P3 = sum(adc1 odd  8 samples)

计算:

复制代码
D1 = P1 - P0
D2 = P2 - P0
D3 = P3 - P0

Foreground:

复制代码
累加128个有效beat

Offset1 = ACC1 >> 10
Offset2 = ACC2 >> 10
Offset3 = ACC3 >> 10

Background:

复制代码
A1 = A1 + D1 - (A1 >>> K)
A2 = A2 + D2 - (A2 >>> K)
A3 = A3 + D3 - (A3 >>> K)

Offset1 = A1 >>> (K+3)
Offset2 = A2 >>> (K+3)
Offset3 = A3 >>> (K+3)

数据校正:

复制代码
adc0 even:
    不处理

adc0 odd:
    sample - Offset2

adc1 even:
    sample - Offset1

adc1 odd:
    sample - Offset3

最后:

复制代码
18bit扩位减法
→ 饱和
→ 16bit
→ 重新拼回两路256bit

47. 总结

这个工程真正值得记住的并不是"EMA公式怎么写",而是下面几个FPGA实现思路。

第一:

Offset mismatch校准应该优先估计相对Offset,而不是把每一路自己的DC全部减掉。

第二:

高速ADC虽然采样率很高,但FPGA看到的是并行数据。应该先利用并行结构做block统计,而不是按单sample思维设计。

第三:

EMA和1024点滑动平均不是完全等价,只是在本文这种慢参数估计问题中,可以用非常低的资源获得接近的平滑效果。

第四:

EMA一定要考虑定点精度。直接对整数Offset做error >>> K容易形成死区,使用缩放累加器更合适。

第五:

估计器和数据校正主通路应该解耦。没必要为了等待当前拍的EMA结果,把512bit数据总线延迟五六拍。

第六:

两路ADC最终还要交织,所以只校正各自内部even/odd是不完整的;4个逻辑相位至少需要1个参考和3个相对Offset系数。

第七:

高速RTL里,算法正确只是第一步。signed、位宽、valid、最后一个累加样本、算术右移、溢出饱和和pipeline,任何一个没处理好,都可能让板上结果完全不对。

最终这套架构的Estimator部分只需要:

复制代码
4组SUM8加法树
3个diff
3个EMA accumulator
少量状态机和移位逻辑

可以做到:

复制代码
BRAM = 0
DSP  = 0

同时保留:

复制代码
Foreground快速初始化
Background慢速跟踪
Freeze
Offset系数读回

对于312.5MHz、两路256bit输入的ADC Offset校准,这比直接照搬MATLAB的1024点逐样点滑动mean更适合作为第一版FPGA工程实现。


参考资料

  1. AMD Zynq UltraScale+ RFSoC RF Data Converter Product Guide,PG269

  2. RF-ADC Calibration Mechanism

  3. Time Interleaved Offset Calibration Block(OCB)

  4. Foreground Calibration Process

  5. Background Calibration Process

相关推荐
NamoAmitabha1281 天前
xilinx/MMCM/DRP/使用
fpga·xilinx·mmcm
北城笑笑3 天前
FPGA 55 ,FPGA 行业 2026 发展分析与报告
fpga开发·fpga
小灰灰搞电子5 天前
指数移动平均 EMA(Exponential Moving Average)算法详解
ema
FPGA小徐7 天前
通信系统核心技术概念解析
fpga
神电测控9 天前
神电新型PG-RFSOC-27DR/47DR/67DR sbRIO板卡(内置8路上GS/s AD/DA模块,兼容LabVIEW FPGA开发)
labview·fpga·usrp·sdr·神电测控·王电令·rfsoc
Eloudy21 天前
开源 gpunetio examples 01 解析 gpunetio_verbs_put_bw
gpu·fpga·rdma·roce·doca
博览鸿蒙21 天前
FPGA开发使用的语言
fpga开发·fpga
明德扬21 天前
机器人多摄像头如何做FPGA多路视频聚合?
学习·fpga开发·fpga
Eloudy21 天前
GPUNetIO开源实现与FPGA的 RoCEv2 通信延迟实验
gpu·fpga·rdma·roce·doca