16位ADC如何榨出24位精度?硬核拆解采集卡的软件过采样算法与三重缓冲区架构

zlinear开源电子

前言

大家好,我是ZLinear的硬件工程师。

在前面的系列博文中,我们从FPGA双核架构聊到QSPI四线DMA,从RT-Thread多线程调度聊到上位机协议解析。但有一个问题,几乎每隔几天就会有读者在后台问我:

"张工,你们的采集卡标称16bit/24bit双模,可AD7606芯片明明就是16位的ADC,那个24位是怎么来的?是不是营销噱头?"

这个问题问得好,而且非常犀利。今天我就不卖关子了——24位不是硬件给的,是软件"算"出来的。这背后用到的核心技术,叫做软件过采样配合滑动均值滤波

这篇文章,我们就以DABL-7606和DABM-D223的固件代码为蓝本,硬核拆解这套算法的工程实现:看看一个16位的ADC原始值,是怎样经过三重缓冲区的接力、256点滑动窗口的累加,最终"蜕变"成24位高精度数据的。我会带你逐行读懂那些看似枯燥的结构体定义和定时器中断,看清楚每一步数据流向。


一、 精度的天花板:为什么16位硬件"不够用"?

在讲算法之前,我们先搞清楚一个基本问题:16位ADC到底差在哪?

AD7606是一颗非常优秀的16位同步采样ADC,±5V量程下,1个LSB(最小分辨电压)约为:

±5V ÷ 32768 ≈ 152.6μV

这意味着,任何低于152.6μV的电压变化,ADC都无法分辨。对于一般的工业变送器(4-20mA、0-10V)来说,这个精度绰绰有余。

但是,当你去采集热电偶的mV级信号、应变片的微伏级电桥输出、或者对缓慢温度过程做高精度监测时,152.6μV的台阶就太粗糙了。信号的变化可能只有几十微伏,直接被量化噪声淹没了。

换一颗24位ADC?且不说成本飙升,真正高性能的24位ADC(如ADS1256)采样率往往只有几kSPS甚至几十SPS,根本做不到AD7606那样的8通道同步40kSPS。

ZLinear的工程解法:不换芯片,用软件过采样把16位"撑"到接近24位的有效精度。代价是——采样率会降低。这本质上是一种"以速度换精度"的信号处理策略。


二、 三重缓冲区架构:数据蜕变的"三级火箭"

打开《7606代码分析》文档,你会看到一个结构体定义,它是整个算法的核心数据容器:

typedef struct { int16 getAdc_Buf200[200]; // 200点实时波形缓冲(用于USB上传显示) int32 getAdc_Buf256[256]; // 256点滑动平均滤波缓冲 int32 getAdcFilter16[8]; // 16位滤波结果(256点均值) int32 getAdcFilter24[8]; // 24位滤波结果(256点累加和) u16 getAdc_BufIndex200; // 200点缓冲区写索引 u8 recentRecordSampleNeedTimsMs; // 实际记录间隔 } adcDataStruct; adcDataStruct _uadc; // 全局ADC数据实例

这个结构体里藏着三重缓冲区,每一重都有明确的使命。我把它称为"三级火箭":

第一级:getAdc_Buf256 —— 256点滑动窗口("原料仓")

int32 getAdc_Buf256[256];

这是一个8通道×256深度的二维数组。每一次ADC转换完成后,最新的16位原始采样值会被写入这个缓冲区。它的工作方式是环形滑动窗口——写满256个点后,新数据会从位置0开始覆盖最老的数据,始终保持窗口内是最新的256个采样值。

为什么是256而不是255或200?因为256 = 2的8次方。在嵌入式系统中,后续的除法运算可以用"右移8位"来替代,这在没有硬件除法器的MCU上能节省大量运算周期。这个数字不是随便选的,是工程上精心设计的。

第二级:getAdcFilter24 / getAdcFilter16 —— 精度提升("炼丹炉")

int32 getAdcFilter24[8]; // 24位滤波结果(256点累加和) int32 getAdcFilter16[8]; // 16位滤波结果(256点均值)

这是算法最精妙的地方。文档中给出了清晰的数据流路径:

AD7606原始数据(16位) → getAdc_Buf256[256] (滑动窗口)
↓ (256点累加)
getAdcFilter24[8] (24位精度提升)
↓ (÷256)
getAdcFilter16[8] (16位滤波值)

24位是怎么"变"出来的?

原理说穿了并不神秘。当你把256个16位的采样值累加在一起时,结果是一个最多需要24位才能表示的数(因为 256 = 2^8,16 + 8 = 24)。这个累加和_uadc.getAdcFilter24[ch]就是所谓的"24位精度结果"。

但这并不是真正的"免费午餐"。这种精度提升成立的前提条件是:

  1. 信号上必须叠加了足够随机的高斯白噪声。如果信号是纯粹干净的直流,256次采样值完全相同,累加后只是简单的256倍放大,有效位数不会增加。
  2. 噪声必须是不相关的随机噪声,不能是固定的偏置或周期性干扰。

好在现实世界中,ADC的量化噪声本身就带有一定的随机性,加上电源纹波、热噪声等,通常能满足这个条件。这就是为什么我们说"在低采样率下,有效位数可近似达到20位"——不是满24位,而是实际有效位数约20位左右,但相比16位已经是质的飞跃。

16位滤波值则是把累加和再除以256(右移8位),得到的是256点的算术平均值。它没有提升位宽,但通过均值滤波显著降低了噪声方差,使数据更平滑稳定。

第三级:getAdc_Buf200 —— 波形显示缓存("展台")

int16 getAdc_Buf200[200];

经过滤波后的数据,最终被写入这个200点的环形缓冲区,专门用于USB上传给上位机做实时波形显示。

为什么是200点?这是在"显示流畅度"和"数据延迟"之间权衡的结果。200个点足以在屏幕上画出一段连续且平滑的波形;如果太大,从采集到显示的延迟会变长,用户会感觉波形"跟不上";如果太小,波形会出现明显的锯齿和断裂。


三、 1ms节拍器:定时器中断里的精密调度

有了三重缓冲区这个"容器",还需要一个"推手"来驱动数据流动。这个推手就是定时器中断。

在DABM-D223的代码解析中,我们可以看到定时器中断回调函数的全貌:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if(htim == &htim1) TIM1_task(); // PWM通道1输出 else if(htim == &htim2) TIM2_task(); // PWM通道6输出 // ... TIM3~TIM8 PWM通道 else if(htim == &htim12) { usbMsg_timer_task(); // USB接收超时检测(1ms周期) dio_tim_task(); // DIO状态更新 + PWM加减速计算 } else if(htim == &htim13) { HAL_GPIO_WritePin(GPIOC,GPIO_PIN_6,1); // 示波器调试引脚拉高 qspiAdcTimTask(); // ★核心:QSPI读取ADC数据(按采样率触发) HAL_GPIO_WritePin(GPIOC,GPIO_PIN_6,0); // 调试引脚拉低 } else if(htim == &htim14) { ddsDac_tim_task(); // DAC信号输出(按DAC输出率触发) } }

而在DABL-7606的固件中,对应的是adcFunction_tim_task(),每1ms执行一次:

void adcFunction_tim_task() { static u16 tim_count = 0; // 采样率分频控制 if(++tim_count >= _framDatas._adcParam.samplingRate_divider) { tim_count = 0; // 启动新一轮ADC转换 ad7606_startConvert(); // 将最新数据写入200点环形缓冲区 for(u8 ch=0; ch<8; ch++) { _uadc.getAdc_Buf200[ch][_uadc.getAdc_BufIndex200] = _uadc.getAdc_Buf256[ch][255]; } _uadc.getAdc_BufIndex200 = (_uadc.getAdc_BufIndex200 + 1) % 200; // Flash/SRAM记录模式处理 localRecord_tick++; } }

这里有几个极其精妙的工程设计:

1. 采样率分频器:samplingRate_divider

定时器的基础节拍是固定的1ms。但用户的采样率需求千差万别——50Hz、1kHz、10kHz各不相同。怎么用同一个1ms节拍满足不同需求?

答案就是这个samplingRate_divider(采样率分频系数)。

  • 如果用户要1kHz采样率(每1ms采一次):divider = 1,每次中断都触发ADC转换。
  • 如果用户要500Hz采样率(每2ms采一次):divider = 2,每两次中断触发一次。
  • 如果用户要50Hz采样率(每20ms采一次):divider = 20,每20次中断触发一次。

这种设计的好处是:定时器本身的配置永远不用改,只需要改一个软件变量就能动态调整采样率,极其灵活。

2. 调试引脚的"示波器思维"

HAL_GPIO_WritePin(GPIOC,GPIO_PIN_6,1); // 拉高 qspiAdcTimTask(); // 执行QSPI读取 HAL_GPIO_WritePin(GPIOC,GPIO_PIN_6,0); // 拉低

这两行GPIO翻转代码看似多余,实则是老练工程师的"必备习惯"。在PCB上把PC6引脚引出一个测试点,用示波器探头一搭,就能直接量出qspiAdcTimTask()执行了多长时间。当采样率拉到500KSPS时,如果这个脉冲宽度超过了采样周期,就说明CPU来不及读完数据,需要优化。这种"硬件可观测性"的设计思维,是工业级固件和实验室代码的本质区别。

3. 8通道循环写入的简洁之美

for(u8 ch=0; ch<8; ch++) { _uadc.getAdc_Buf200[ch][_uadc.getAdc_BufIndex200] = _uadc.getAdc_Buf256[ch][255]; }

注意这里取的是getAdc_Buf256[ch][255]——即256点滑动窗口中最新写入的那个点(索引255是最新位置)。这意味着200点波形显示缓存里存的是"原始最新值",而不是滤波后的值。

为什么要这样设计?因为波形显示追求的是实时性和细节,如果用滤波后的值画波形,会丢失高频信息,工频干扰的毛刺就看不到了。而滤波值(getAdcFilter16/24)则通过另一条路径单独上传,用于数值显示和数据记录。"看的归看的,算的归算的"——显示和计算走两条数据通路,这是采集卡软件设计中一个极其重要的工程原则。


四、 上位机侧的"最后一公里":电压换算

数据从下位机通过USB/以太网上传到上位机后,还需要做最后一步转换——从原始ADC码值换算成实际电压值。

在《7606上位机代码分析》文档中,我们可以看到这行C#代码:

// 数据转换: ADC原始值 → 电压值 adcWave1.Add(Convert.ToDouble(SWAP16(_prxData.adcData[i])) * 5.0 / 32768.0 - 5.0);

拆解这个公式(不展开数学推导,只讲工程含义):

步骤操作含义
第1步SWAP16(_prxData.adcData[i])大端转小端,解决通信字节序问题
第2步× 5.0 / 32768.0将16位码值(0~65535)映射到0~5V范围
第3步- 5.0偏移到±5V双极性量程(-5V~+5V)

上位机使用ScottPlot图表库进行实时波形绘制:

waveform.Plot.Clear(); if (adcCheck1.Checked) waveform.Plot.Add.ScatterLine(xAxisIndex, adcWave1, ScottPlot.Colors.Brown); waveform.Plot.Axes.SetLimits(0, axisMaxLen, -5.5, 5.5); // ±5V量程 waveform.Refresh();

特性包括:支持8通道同时显示、实时刷新波形、可选通道显示/隐藏、X轴为时间轴(根据采样率计算)。

这里有一个细节值得注意:上位机接收的波形数据是16位原始码值(来自200点缓存),而不是下位机滤波后的24位值。这意味着上位机看到的波形是"未经滤波的原始信号",用户可以看到包括噪声在内的全部细节。如果需要高精度的数值分析,则通过另一条通道读取24位滤波值。这种"波形看原始、数值看滤波"的双轨设计,和下位机的数据分流策略是一脉相承的。


五、 实战对比:不同采样率下你看到的世界完全不同

为了让大家有更直观的感受,我用一个实际场景来说明这套算法的价值。

假设你要监测一个50Hz的工频电压信号(有效值220V,经变压器和分压后送入采集卡为±5V范围内的正弦波):

采样率设置256点窗口耗时有效精度你看到的波形适合的分析
50Hz(divider=20)约5.1秒≈20bit只能看到极其缓慢的直流漂移趋势长时间温度/压力漂移监测
1kHz(divider=1)256ms≈18bit波形轮廓清晰,高频细节被滤除工频有效值测量、基本波形监视
10kHz25.6ms≈16bit波形细节丰富,但精度无提升电机振动基频分析、谐波初步观察
35kHz7.3ms16bit波形细节最丰富,可看到高频毛刺谐波详细分析、瞬态捕获

可以看到一个清晰的规律:采样率越低,256点窗口覆盖的时间越长,滤波效果越强,精度提升越大,但能看到的高频细节越少。这不是缺陷,而是物理法则决定的必然权衡。

工程选型建议

  • 如果你的被测信号变化缓慢(温度、压力、液位),大胆地把采样率降到50Hz甚至更低,你会获得近乎20位的高精度,远超16位硬件的极限。
  • 如果你的被测信号是振动、瞬态冲击,不要降采样率,保持几十kHz才能看到细节,此时精度就是硬件的16位,但波形保真度最高。

六、 总结:精度不是芯片给的,是算法"榨"出来的

设计维度核心机制工程价值
三重缓冲区256点滑动窗口 + 24位累加 + 200点显示缓存数据分流:滤波归滤波,显示归显示,互不干扰
256点累加16位原始值 × 256次 = 24位累加和以速度换精度,低频下有效位数提升约4位
1ms节拍+分频器定时器固定1ms,软件分频调整采样率无需重配硬件即可动态调采样率,灵活且稳定
调试引脚可观测GPIO翻转标记任务执行时间示波器直接量出CPU负载,工业级可测试性设计
双轨数据通路波形上传原始16位,数值上传滤波24位波形看细节,数值看精度,各取所需

写到这里,相信大家已经明白:所谓"16bit/24bit双模",不是一块芯片里装了两种ADC,而是一颗16位ADC配合精妙的软件过采样算法,在不同的采样率档位下,呈现出不同的精度表现。这不是营销话术,而是经过严格数学论证和工业现场验证的信号处理工程实践。

ZLinear之所以把这套算法的源码和结构体定义完全开源,是因为我们坚信:真正的精度不藏 在芯片的数据手册里,而藏在工程师对数据流的每一步精心设计里。当你理解了256点滑动窗口的意义、理解了1ms节拍分频器的灵活性、理解了"波形看原始、数值看滤波"的双轨哲学,你就掌握了数据采集系统中最核心的软件设计密码。

如果你在自己的采集中遇到了"低速信号精度不够"或"高速信号细节丢失"的问题,或者对过采样算法的窗口大小选择有疑问,欢迎在评论区留言交流。我们一起把"以速度换精度"这门手艺琢磨透!


我是 ZLinear 开源电子。我们坚信,好的算法能让平凡的芯片绽放不平凡的光芒。如果觉得今天的分享对你有帮助,欢迎点赞、收藏、关注三连,我们下期再见!