本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

采样数据的处理:
环形缓冲、中值滤波与迟滞控制

从 ADC 中断里拿到的一个码值,到继电器该不该吸合,中间隔着三段活: 先把样本攒起来(环形缓冲),再把一串样本变成一个可信值(滤波), 最后让这个值驱动开关时不发抖(迟滞)。这篇笔记以一块液位采集小板(HC32F030)的真实实现为例, 记录我在这三段活里的取舍、换算链上的整数陷阱,以及一个关于补偿值表示方式的坑。

数据采集 环形缓冲 迟滞控制 HC32F030 已发布

这块液位采集小板的功能很朴素:两路液位采集、两路温度采集、四路继电器输出, 对外通过 RS485 跑 Modbus RTU(设备地址 0x41,功能码 0x03 / 0x10)。 协议那一层我另外整理过一篇笔记,这里不展开,只把它当作背景。 这篇要记的是协议下面那一层:ADC 采回来的数,到底要怎么处理,才敢拿去做控制。

一、为什么“取一次 ADC 值就用”一定会出问题

我最早写采集的版本非常直接:ADC 扫描完成中断里把结果换算成工程量,赋值给一个全局变量, 主循环拿这个变量去和阈值比较。逻辑上完全说得通,但一上电就发现继电器在阈值附近“咔咔”响个不停, 读回来的数值末位也一直在跳。

后来我把原因拆成三条,都很朴素:

  • ADC 本身的量化误差与末位跳动。 12 位 ADC、参考电压 AVDD = 3300 mV、满量程 4096,1 个码值约 0.806 mV。 也就是说,即使输入电压一动不动,最后一位也可能在相邻码值之间来回跳。
  • 被测量本身在动。 液面不是静止的:进出液、搅拌、气泡都会让它有波动,这种波动是真实的,不该被当成噪声。
  • 干扰是脉冲式的。 继电器吸合的瞬间、485 总线上的收发切换,都可能在一两次采样上叠出一个明显的野值。

把这三点放在一起看,结论就清楚了:单次采样代表的是“此时此刻的一个瞬时值”, 而我真正需要的是“现在大概是多少”。这两件事不是一回事 —— 前者包含噪声,后者才是信号。 想从前者得到后者,中间至少要做三件事: 先攒一串样本(环形缓冲)、把一串样本变成一个可信值(滤波)、 再让这个值在阈值附近不抖(迟滞比较)。

还有一个约束贯穿全文:这块小板不跑 RTOS,主循环是时间片轮询。 所以“在哪里做计算”本身就是一个设计问题。这件事我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里写得更细,这里先强调一句:中断里能做的事越少越好。

二、环形缓冲:让中断只负责存,不负责算

ADC 的配置是扫描模式(AdcSCanMode),时钟分频 AdcClkSysTDiv8, 采样时间 AdcSampTime12Clk,参考电压选择 AVDD(RefVolSelAVDD),输入缓冲关闭。 一次扫描依次走三个通道:

扫描序号通道配置引脚用途
0(CH0MUX)AdcExInputCH4PA4输入电压监测
1(CH1MUX)AdcExInputCH5PA5液位 1 采集
2(CH2MUX)AdcExInputCH6PA6液位 2 采集

中断优先级设成 IrqLevel3,扫描完成回调 AdcContIrqCallback 里依次调用 Adc_GetSqrResult(&value1[vadd], 0/1/2),把三个通道的结果取出来写进环形缓冲。 缓冲深度 AD_cache = 20,三个通道各一个 u16 数组,写指针 vadd 累加到 20 归零。

/* 为按个人理解重写的最小片段:ADC 扫描完成中断只做“存” */
#define AD_cache  20

static u16 value1[AD_cache];   /* CH0:PA4 输入电压监测 */
static u16 value2[AD_cache];   /* CH1:PA5 液位 1 */
static u16 value3[AD_cache];   /* CH2:PA6 液位 2 */
static volatile u8 vadd = 0;   /* 写指针,累加到 20 归零 */

static void AdcContIrqCallback(void)
{
    (void)Adc_GetSqrResult(&value1[vadd], 0);
    (void)Adc_GetSqrResult(&value2[vadd], 1);
    (void)Adc_GetSqrResult(&value3[vadd], 2);

    vadd++;
    if (vadd >= AD_cache) {
        vadd = 0;
    }
}

这个中断函数里没有排序、没有除法、没有浮点,只有三次取值、一次自增、一次比较、一次赋值。 执行时间是恒定的,而且很短。这是我有意为之的:

  • 排序 20 个元素、再做工程量换算,在 Cortex-M0+ 上要成百上千个周期。 这类活一旦放进中断,中断占用时间就变长,其他中断(尤其是串口接收)会被推迟。
  • 串口接收被推迟到下一个字节到来之后,接收寄存器就会被覆盖,表现为丢字节; 丢一个字节,一整帧 Modbus 报文就废了 —— 而且现象是“偶发通信失败”,最难查。
  • 中断里做的事情越少,时序就越容易估算。这一条在手上只有示波器和串口助手的时候特别重要。

代价是 RAM:三个通道各 20 个 u16,一共 120 字节; 滤波时还要一个 temp[20] 的临时数组,再 40 字节。 在这块板子上,这个开销是可以接受的。

三、滤波算法:我为什么最后选了“排序后取中间三点平均”

实现很短:先把环形缓冲里的 20 个值拷到临时数组 temp[20], 用冒泡排序 U16_BubbleSort 升序排好, 然后取 temp[9] + temp[10] + temp[11] 三个值并求平均。 也就是中位数附近的 3 点滑动平均。

/* 为按个人理解重写的最小片段:排序后取中间三点平均 */
static void U16_BubbleSort(u16 *buf, u16 len)
{
    u16 i, j, t;

    for (i = 0; i < len - 1; i++) {
        for (j = 0; j < len - 1 - i; j++) {
            if (buf[j] > buf[j + 1]) {
                t        = buf[j];
                buf[j]   = buf[j + 1];
                buf[j + 1] = t;
            }
        }
    }
}

/* 返回滤波后的 ADC 码值(还没有换算成工程量) */
static u16 Ad_Filtering(const u16 *src)
{
    u16 temp[AD_cache];
    u16 i;

    for (i = 0; i < AD_cache; i++) {
        temp[i] = src[i];
    }
    U16_BubbleSort(temp, AD_cache);

    return (u16)((temp[9] + temp[10] + temp[11]) / 3u);
}

三种做法的取舍,我整理成了下面这张表:

方法对脉冲干扰输出连续性计算量我的结论
算术平均 几乎没有抵抗力:一个野值就能把结果拉偏 好,输出平滑 最小,一次累加 不用
纯中值(取 temp[10]) 最好,单个野值排序后直接被排到两端 差,输出呈阶梯状,分辨率只有 1 个码值 要先排序 不用
中间三点平均 好,野值排到两端后进不了 temp[9] ~ temp[11] 比纯中值好,判决点附近更稳 排序 + 两次加法 最后选它

先说为什么不用算术平均。它是最容易想到的,也是最不抗干扰的: 20 个样本里只要有一个野值,它就会被平均进去,而且无法被“投票”排除。 液位这种慢变量,野值出现的频率不高,但一旦出现,正好可能落在判决点附近,让继电器误动作。

再说为什么不用纯中值。中值对单个脉冲干扰的抑制是这几种里最好的 —— 野值排序后一定会跑到两端,取中间那个值天然不受影响。 但它的问题出在输出:取的终究是“某一个样本”,所以输出只能是整数码值,呈明显的阶梯状。 做小信号比较时(比如液位已经贴近阈值),输出会在相邻两个码值之间来回跳,看上去就是“抖”。 取中间三点平均,等于“先剔除极端值、再轻微平滑”: 野值进不了中间三点,抗脉冲能力基本保留;而三个样本一起参与运算, 真实值缓慢越过码值边界时,三个中间样本不会同时翻过去,判决点附近就稳得多。

这里有一个我自己记下来的细节:代码里写的是 (temp[9] + temp[10] + temp[11]) / 3, 整数除法是向下取整的,所以输出仍然是整数码值, 并没有真的拿到 1/3 码值的分辨率,只是把判决点附近的抖动压下去了。 如果想彻底用上 1/3 码值的分辨率,办法是“不除 3、保留三个数之和”, 同时把参与比较的阈值也放大三倍 —— 比较结果等价,但中间多保留了一位有效信息。 我目前没有这么做,因为要改的地方(阈值寄存器、补偿值、上位机显示)会一起变,收益不明显; 但这是一个明确的、可选的改进方向。

代价:190 次比较,以及缓冲深度怎么定

冒泡排序 20 个元素,最坏情况下比较次数是 20 × 19 ÷ 2 = 190 次。 在 Cortex-M0+ 上这不是一个小数字,但它是按需执行的 —— 不是每个中断周期都算,而是主循环轮到这一路通道时才排一次序。 在我自己的板子上、按当前的节拍实测,这个开销可以接受。 如果以后采样率更高、或者通道数更多,我会按这个顺序改:

  1. 把冒泡换成插入排序:环形缓冲前后两次的 20 个值高度重合, 近乎有序的数组上插入排序接近线性,而不是稳稳的 190 次比较。
  2. 改用硬件过采样,让 ADC 自己把多个样本累加平均,软件只读结果。
  3. 再不行才考虑降低排序频率,比如两次采集只排一次序。

缓冲深度 AD_cache = 20 也是权衡出来的:太浅(比如 5)抗干扰差, 一个野值就能占到 1/5 的权重;太深(比如 64)虽然更稳,但响应变慢, 而且每个通道的更新周期会被拉长。 对一个要驱动继电器的系统来说,“稳”和“跟得上”必须同时成立,20 是我在这两者之间找到的位置。

从左到右:中断写入 → 环形缓冲(画成 20 个格子、有写指针)→ 拷贝到临时数组并排序 → 取中间三点求平均 → 工程
图 1 · 环形缓冲与滤波流程框图

四、从 ADC 码值到工程量:一次完整的换算链

滤波之后拿到的还是 ADC 码值(0 ~ 4095),要变成能和人对话的工程量,中间还有一步换算。 基准很清楚:12 位 ADC、参考电压 AVDD = 3300 mV、满量程 4096,所以 1 个码值约 0.806 mV。 真正要小心的是每个通道前端还有自己的分压或放大系数,必须乘回去。

/* 为按个人理解重写的最小片段:ADC 码值 → 工程量 */
#define ADC_VREF_MV   3300u   /* 参考电压 AVDD = 3300 mV */
#define ADC_FULLscale 4096u   /* 12 位 ADC 满量程 */

/* 输入电压监测通道:前端是 1/12 分压,所以乘 12 还原 */
AD_Data.val1 = (u32)Ad_Filtering(value1) * ADC_VREF_MV / ADC_FULLscale * 12u;

/* 两路液位:这两路前端的分压/放大还原系数是 1.51 */
sys_data.lev_sen1_ac = Ad_Filtering(value2) * ADC_VREF_MV / ADC_FULLscale * 1.51;
sys_data.lev_sen2_ac = Ad_Filtering(value3) * ADC_VREF_MV / ADC_FULLscale * 1.51;

整数运算的顺序:这一步我踩过坑

上面这几行,乘和除的顺序是刻意的。 如果按“先化成电压、再乘系数”的直觉写成 Ad_Filtering(value2) / 4096 * 3300,结果会全部落在 0 附近: value2 最大也就 4095,先做整数除法 / 4096 得到的是 0, 后面乘什么都没用了 —— 整数截断把小信号整个吃掉了。

正确的顺序是先把 * 3300 做掉,再除以 4096。 中间结果最大约 4095 × 3300 ≈ 1.35 × 107, 这个量级 32 位无符号数(上限约 4.29 × 109)装得下,还留有很大余量。 有两条相关的注意事项:

  • 中间结果必须用 u32。 4095 × 3300 早就超过了 u16 的 65535; 如果参与运算的变量是 u16,这里会静默溢出, 得到一个“看起来像那么回事、但完全不对”的数。
  • 各通道的写法要统一。 所有通道都用同一个顺序,截断误差的方向和量级才一致,通道之间、以及与阈值比较时才有可比性。 最怕的是这一路先除后乘、那一路先乘后除,最后数值对不上却查不出是谁的错。

还有一个小细节:液位那两行里的 1.51 是浮点字面量,在 C 里会按 double 参与运算。 Cortex-M0+ 没有硬件浮点单元,这类运算会走软件浮点库,开销不小。 所以我把它留在主循环里,绝不放进 ADC 中断 —— 这和第二节里“中断只存不算”是同一条原则的两面。

温度:一套驱动、两个引脚,但不能重入

温度用的是 DS18B20 单总线器件,两路分别接在 PB0 和 PB1。 驱动只有一套:函数 Get_Tp(ch) 在调用前先切换 Ds18b20_Port / Ds18b20_Pin 两个全局变量,用同一份时序代码去操作不同的引脚。

/* 为按个人理解重写的最小片段:一套驱动、两个引脚 */
static GpioPort_t Ds18b20_Port;
static GpioPin_t  Ds18b20_Pin;

/* ch = 1 → PB0;ch = 2 → PB1 */
void Get_Tp(u8 ch)
{
    if (ch == 1u) {
        Ds18b20_Port = GpioPortB;
        Ds18b20_Pin  = GpioPin0;
    } else {
        Ds18b20_Port = GpioPortB;
        Ds18b20_Pin  = GpioPin1;
    }

    /* 下面是单总线时序:复位、跳过 ROM、启动转换、读暂存器……
       这些函数内部只认 Ds18b20_Port / Ds18b20_Pin 两个全局变量 */
}

这是省代码的常见做法:单总线时序(复位脉冲、写位、读位、CRC)只写一份,换引脚只改变量。 但它有一个必须说清楚的代价:这套驱动是不可重入的。 两个全局变量是共享状态,如果两路温度“同时”在跑(比如中断里也去调 Get_Tp), 后一次调用会把前一次的引脚改掉,前一次剩下的时序就打到了错误的引脚上。 现象是温度值随机错乱,而且很难复现。

我的做法是:把它约束在同一个任务里串行调用。 在时间片轮询的框架下,温度 1 和温度 2 各自有自己的窗口,永远不会同时执行; 中断里也绝对不会去碰单总线。这是能用“一套驱动、两个引脚”的前提。

采集顺序上,我把它排成“先液位、后温度”。原因很直接:温度转换要等, DS18B20 一次 12 位转换最长约 750 ms,是整条采集链里最慢的一环; 液位走 ADC 中断,20 点缓冲攒起来快得多。 把慢的放到时间片靠后的位置,整个大循环的节奏才不会被它拖住 —— 具体的窗口划分见 《不用 RTOS 的固件怎么写》那一篇。

五、迟滞比较:为什么阈值必须有一对而不是一个

到这一步,我手上终于有一个比较可信的工程量了。接下来是把它变成继电器动作。 每个通道有一对阈值:开阈值 open_*_val 和关阈值 stop_*_val。 以液位 1 为例,逻辑是这样的:

  • 若 open != stop:
    • 当 lev_sen1_ac <= open_lev1_val 且继电器当前是关 → 打开(置位 relay_group1_val 的 bit0);
    • 当 lev_sen1_ac >= stop_lev1_val 且继电器当前是开 → 关闭(清 bit0)。
  • 若 open == stop:强制关闭。

写成代码就是下面这样(四路完全同构,只有变量名和位不同):

/* 为按个人理解重写的最小片段:一路液位的迟滞比较 */
#define RELAY_LEV1   0x01u   /* bit0 = 液位 1 */
#define RELAY_LEV2   0x02u   /* bit1 = 液位 2 */
#define RELAY_TP1    0x04u   /* bit2 = 温度 1(加热) */
#define RELAY_TP2    0x08u   /* bit3 = 温度 2 */

if (open_lev1_val != stop_lev1_val) {
    if ((sys_data.lev_sen1_ac <= open_lev1_val) &&
        ((relay_group1_val & RELAY_LEV1) == 0u)) {
        relay_group1_val |= RELAY_LEV1;          /* 低到开阈值,且当前是关 → 打开 */
    } else if ((sys_data.lev_sen1_ac >= stop_lev1_val) &&
               ((relay_group1_val & RELAY_LEV1) != 0u)) {
        relay_group1_val &= (u8)(~RELAY_LEV1);   /* 高到关阈值,且当前是开 → 关闭 */
    }
} else {
    relay_group1_val &= (u8)(~RELAY_LEV1);       /* 阈值被设成相等:强制关闭 */
}
位对应输出宏值动作方向
bit0液位 10x01低到开阈值 → 打开;高到关阈值 → 关闭
bit1液位 20x02同上,变量换成第二路
bit2温度 1(加热)0x04低到开阈值 → 打开;高到关阈值 → 关闭
bit3温度 20x08同上,变量换成第二路

为什么必须有一对阈值

假设只有一个阈值 T:x >= T 就关、x < T 就开。听起来没毛病, 但测量值不可能刚好停在 T 上不动:ADC 末位会跳,液面本身也在晃。 于是测量值在 T 附近来回穿越,继电器就以采样周期为频率反复吸合, 现象是“咔咔咔”响个不停,触点寿命急剧下降。 更麻烦的是,这种故障在实验室未必复现 —— 要等液位刚好落在阈值附近才出现。

加入 stop > open 的区间之后,系统就有了“记忆”: 打开之后要一路涨到 stop 才会关闭,关掉之后要一路跌到 open 才会打开。 阈值附近的抖动被这个区间整个吞掉了,继电器每两次动作之间必然走完一段完整的行程。

阈值区间该留多大

我给不出一个“万能数字”,但方法是可以讲清楚的。区间宽度至少要同时满足两个条件:

  1. 大于滤波后测量值的峰峰波动。 做法是让设备在稳定工况下跑一段时间,把滤波后的值记录下来,看最大值与最小值差多少。 本文这套滤波之后,测量值的波动只剩几个码值;阈值区间应该明显大于这个峰峰值, 而不是刚好相等 —— 刚好相等等于没有迟滞,第一节里的“咔咔”声马上就会回来。
  2. 落在工艺允许的波动范围内。 区间也不能无限大:迟滞区间越大,控制越“钝”, 实际液位(或温度)围绕目标值的偏差就越大。 区间宽度本质上是“抗抖动”和“控制精度”之间的一次交换。

还有一个容易被忽略的点:判断用的是滤波后的值,所以滤波强度会影响迟滞区间的下限。 如果哪天把滤波改强了(缓冲加深、或者换成更强的平滑),波动变小,迟滞区间就可以收紧一点, 控制精度也能跟着提高。这两处要一起调,不能只动一个。

open == stop 时强制关闭:一条安全兜底

这是我在写完迟滞之后补的一条分支。阈值是上位机可以写的, 万一有人把开阈值和关阈值写成同一个数,迟滞逻辑的两个条件就可能同时成立、或者都不成立, 输出会卡在某个状态上再也翻不回来 —— 对一个控制加液或加热输出的设备来说,这是危险的。 所以我的处理是:一旦发现两个阈值相等,直接把这一路关掉。 宁可不动,也不要卡在一个无法恢复的状态里。

顺带一个设计上的收益:relay_group1_val 是一个位域变量,四路输出各占一位。 上位机读一个寄存器就能拿到四路输出的完整状态, 比按线圈一路一路读省一次往返(协议细节见 《Modbus RTU 从站实现笔记》)。 写的时候也方便:置位、清位互不影响,不需要读改写整个变量。

上半部分:测量值随时间波动的曲线,画两条水平线表示开阈值与关阈值,下半部分:对应的继电器输出高低电平,体现抖动被两条阈值
图 2 · 迟滞比较的输入输出关系图

六、补偿值的表示方式:一个我踩过的坑

每个通道都有一个 32 位的补偿值 *_er,用来让现场的人手动把读数拉正。 参与运算时的处理是:

  • 若 (er & 0x80000000) == 0x80000000 → ac -= (er & 0x7fffffff)
  • 否则 → ac += er

也就是说,用最高位当符号位、低 31 位当数值(符号-数值表示法,sign-magnitude), 而不是补码。

/* 为按个人理解重写的最小片段:符号-数值表示的补偿值 */
#define ER_SIGN_BIT    0x80000000u
#define ER_VALUE_MASK  0x7FFFFFFFu

/* ac:采集值(工程量);er:上位机下发的 32 位补偿值 */
static u32 apply_er(u32 ac, u32 er)
{
    if ((er & ER_SIGN_BIT) == ER_SIGN_BIT) {
        return ac - (er & ER_VALUE_MASK);   /* 最高位为 1:减 */
    }

    return ac + er;                          /* 最高位为 0:加 */
}

好处是直观:打开寄存器表,看到一个最高位为 1 的补偿值,一眼就知道是“减”; 而且正数区间的量程还多出了一个 bit。对一张要人工填写的参数表来说,这份直观是有价值的。

坏处有三个,我按自己踩到的顺序写:

  1. 和 C 语言的整数语义不一致。这是最要命的一条。 如果谁把它当成 int32_t 直接加减,负补偿会变成一个巨大的正数。 举个具体的例子:-50 按符号-数值编码是 0x80000032; 把它当补码 int32_t 看,它是 -2147483598。 现象就是“补偿值一填负数,读数直接跳到天上”。
  2. 参与运算前必须先剥离符号位。 多一步判断,就多一处可能写错的地方;漏掉那一步 & 0x7fffffff, 减数里就带上了符号位,结果同样是飞到天上。
  3. 上位机显示时也要按同样的规则解析。 设备端按符号-数值处理、上位机按补码显示,两边就会各说各话,而且各自的逻辑都是自洽的, 这种问题最难查。协议文档必须把这一条写清楚。

我的结论是:能用补码就用补码。 补码和 C 的整数语义天然一致,int32_t 直接加减就是对的, 不需要额外约定,也不容易写错。 如果因为“人工填写方便”而选了符号-数值,那就要在协议文档里用最大字号写清楚, 并且在上位机做同样的解析 —— 设备、文档、上位机三处必须严格一致, 任何一处偷懒,最后都会变成一次远程排查。

七、几条我反复用到的经验

  1. 中断里只做“存”。ADC 中断里只有取值、自增、比较;排序、除法、浮点全部留给主循环。
  2. 先攒样本,再谈算法。没有缓冲,任何滤波算法都无从下手; 缓冲深度本身就是参数,20 是我在抗干扰和响应速度之间找到的位置。
  3. 滤波算法的取舍要写下来。为什么不用算术平均、为什么不用纯中值, 如果当时不写,半年后自己都说不清,很容易被“优化”回原来的坑里。
  4. 整数运算先乘后除,中间量用 u32。 先除后乘会把小信号截断成 0;用 u16 装中间结果会静默溢出。
  5. 阈值成对出现。单阈值控制的抖动不是“可能发生”,而是“一定会发生”, 只是要等工况刚好落在阈值附近才暴露。
  6. 编码格式要在文档里写死。 符号-数值和补码的差别,在设备端只是一个 &,在排查现场就是半天。
  7. “一套驱动、多个引脚”的写法要标注重入限制。 省的是代码量,付出的是“只能在同一个任务里串行调用”这条约束 —— 约束不写清楚,下一个改代码的人一定会踩。

参考资料与说明

  • DS18B20 数据手册(DS18B20 Programmable Resolution 1-Wire Digital Thermometer): 12 位转换时间、单总线时序与 CRC 的公开依据。
  • Modbus 应用协议规范(Modbus Application Protocol Specification): 本文只作为背景提及寄存器与功能码,实现细节见 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》。
  • 本文涉及的 MCU 型号、外设配置与代码,均为个人学习项目中的实际做法; 示例代码是按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。