4、数据预处理与校准:数字滤波算法、线性校准与查表法、温度补偿策略

各位同学,咱们继续往下聊。上一节我们把蓝牙模块的硬件和底层驱动搞定了,数据也能收上来了。但问题来了——你拿万用表测个1.5V的电池,结果读数在1.48V到1.52V之间来回跳,这谁敢信?

嗯,这就是我们今天要解决的核心问题:数据预处理与校准。说白了,就是让采集到的数据更准、更稳、更可信。

4.1 数字滤波算法:让数据不再“抖”

我刚开始做嵌入式采集时,遇到过一件特尴尬的事。测一个稳定的5V电源,结果串口打印出来的数据像心电图一样上下波动。后来才明白,这不是硬件坏了,是噪声在捣乱。

数字滤波,就是软件层面的“降噪器”。这里我重点讲两种最实用的:中值滤波滑动平均滤波

4.1.1 中值滤波:专治“毛刺”

中值滤波的原理很简单:连续采样N次,排序后取中间那个值。比如你采了5次:1.48, 1.52, 1.50, 1.49, 1.51,排序后是1.48, 1.49, 1.50, 1.51, 1.52,中间值就是1.50。

这招对付突发性的脉冲干扰特别管用。比如电机启动瞬间的电磁干扰,或者开关电源的尖峰噪声。

核心要点:

  • 采样次数N一般取奇数,3、5、7都行
  • N越大,滤波效果越好,但响应速度变慢
  • 适合变化缓慢的信号,比如温度、电压
// 中值滤波示例代码
#define FILTER_N 5

float median_filter(float new_value) {
    static float buffer[FILTER_N];
    static uint8_t index = 0;
    float temp[FILTER_N];
    float median;
    
    // 存入新数据
    buffer[index] = new_value;
    index = (index + 1) % FILTER_N;
    
    // 拷贝到临时数组并排序
    memcpy(temp, buffer, sizeof(temp));
    // 简单冒泡排序(N小的时候够用)
    for (int i = 0; i < FILTER_N - 1; i++) {
        for (int j = 0; j < FILTER_N - 1 - i; j++) {
            if (temp[j] > temp[j + 1]) {
                float t = temp[j];
                temp[j] = temp[j + 1];
                temp[j + 1] = t;
            }
        }
    }
    
    // 取中间值
    median = temp[FILTER_N / 2];
    return median;
}

我的经验:实际项目中,我一般把N设为5。再大就影响实时性了,尤其是蓝牙传输时,数据更新频率本来就有限。

4.1.2 滑动平均滤波:平滑如丝

滑动平均滤波,就是维护一个固定长度的队列,每次取队列里所有数据的平均值。新数据进来,旧数据出去,像个“滑动窗口”。

这招对周期性噪声和随机噪声都有不错的效果。我做过一个项目,测一个缓慢变化的液位信号,用了滑动平均后,曲线平滑得像画出来的一样。

// 滑动平均滤波示例
#define AVG_N 10

float moving_average(float new_value) {
    static float buffer[AVG_N] = {0};
    static uint8_t index = 0;
    static float sum = 0;
    static uint8_t count = 0;
    
    // 减去旧值,加上新值
    sum -= buffer[index];
    sum += new_value;
    buffer[index] = new_value;
    index = (index + 1) % AVG_N;
    
    // 未填满时按实际数量平均
    if (count < AVG_N) count++;
    
    return sum / count;
}

注意:滑动平均有个“滞后效应”。窗口越大,滞后越明显。如果你要测快速变化的信号(比如过零检测),窗口别设太大,3~5个点就够了。

4.2 线性校准与查表法:让读数“准”起来

滤波搞定了稳定性,接下来就是精度问题。你想想看,ADC采集到的原始值,跟实际物理量之间,往往不是完美的线性关系。这时候就需要校准了。

4.2.1 线性校准:两点定天下

最简单的校准方法,就是两点校准。比如你测电压,用标准源给两个已知电压值V1和V2,记录ADC读数为D1和D2。然后就能算出斜率和截距:

// 线性校准公式
// 实际值 = 斜率 * ADC值 + 截距
// 斜率 = (V2 - V1) / (D2 - D1)
// 截距 = V1 - 斜率 * D1

float calibrate_linear(float adc_value) {
    // 假设校准参数已存储在EEPROM中
    static float slope = 0.0;
    static float offset = 0.0;
    static uint8_t calibrated = 0;
    
    if (!calibrated) {
        // 从EEPROM读取校准参数
        slope = read_eeprom_float(0x00);
        offset = read_eeprom_float(0x04);
        calibrated = 1;
    }
    
    return slope * adc_value + offset;
}

避坑指南:我曾经犯过一个低级错误——校准点的范围没覆盖实际测量范围。比如你只校准了1V和5V,结果去测0.1V的信号,误差大得离谱。记住:校准范围一定要覆盖你的实际测量范围

4.2.2 查表法:非线性也不怕

有些传感器天生就是非线性的,比如热电偶、NTC热敏电阻。这时候线性校准就不够用了。怎么办?查表法。

查表法的思路很简单:把已知的标准值做成一个表格,实际测量时,根据ADC值去表格里找对应的物理量。如果落在两个点之间,就用线性插值。

// 查表法 + 线性插值示例
// 假设是NTC热敏电阻的温度-电阻对照表

typedef struct {
    uint16_t adc_value;  // ADC原始值
    float temperature;   // 对应温度(°C)
} lookup_table_t;

// 简化的查表数据(实际项目中可能有几十个点)
const lookup_table_t temp_table[] = {
    {100,  -20.0},
    {200,    0.0},
    {400,   25.0},
    {600,   50.0},
    {800,   75.0},
    {1000, 100.0}
};
#define TABLE_SIZE (sizeof(temp_table) / sizeof(temp_table[0]))

float lookup_interpolate(uint16_t adc_val) {
    // 边界处理
    if (adc_val <= temp_table[0].adc_value) {
        return temp_table[0].temperature;
    }
    if (adc_val >= temp_table[TABLE_SIZE - 1].adc_value) {
        return temp_table[TABLE_SIZE - 1].temperature;
    }
    
    // 查找区间
    for (int i = 0; i < TABLE_SIZE - 1; i++) {
        if (adc_val >= temp_table[i].adc_value && 
            adc_val <= temp_table[i + 1].adc_value) {
            // 线性插值
            float ratio = (float)(adc_val - temp_table[i].adc_value) / 
                         (temp_table[i + 1].adc_value - temp_table[i].adc_value);
            return temp_table[i].temperature + 
                   ratio * (temp_table[i + 1].temperature - temp_table[i].temperature);
        }
    }
    
    return 0.0; // 不应该到这里
}

我的建议:查表法的表格点不要太多,够用就行。我一般每10°C或每20°C取一个点,插值后的精度完全够用。表格太多反而浪费Flash空间。

4.3 温度补偿策略:让精度“稳”住

最后聊一个容易被忽略的问题——温度漂移。你想想看,万用表在25°C的实验室里校准得好好的,拿到零下10°C的户外,读数可能就偏了。

为什么会这样?因为ADC参考电压、运放偏置、电阻值都会随温度变化。这就是温度漂移。

4.3.1 硬件补偿 vs 软件补偿

硬件补偿成本高、电路复杂,我一般优先考虑软件补偿。软件补偿的核心思路是:先测温度,再根据温度修正读数

具体做法是:在电路板上加一个温度传感器(比如DS18B20或NTC),实时监测环境温度。然后根据预先测好的温度-误差曲线,对测量结果进行修正。

// 温度补偿示例
// 假设在25°C时校准,温度每偏离1°C,读数偏差0.02%

float temperature_compensate(float measured_value, float current_temp) {
    const float REF_TEMP = 25.0;           // 参考温度
    const float TC = 0.0002;               // 温度系数 (0.02%/°C)
    
    float temp_diff = current_temp - REF_TEMP;
    float correction = measured_value * TC * temp_diff;
    
    return measured_value - correction;
}

注意:温度系数不是一成不变的。不同量程、不同档位,温度特性可能不一样。我建议你针对每个量程单独做温度测试,建立各自的补偿参数。

4.3.2 实战中的温度补偿策略

我做过一个手持式万用表项目,温度补偿这块折腾了挺久。最后总结了几条经验:

  • 分段补偿:把温度范围分成几段(比如-20~0°C、0~25°C、25~50°C、50~70°C),每段用不同的补偿系数
  • 动态校准:每次开机时,如果检测到温度变化超过5°C,自动触发一次内部校准
  • 记录温度:在蓝牙传输的数据包中,把当前温度也带上。这样上位机可以二次修正

总结一下:

  • 数字滤波:中值滤波治毛刺,滑动平均治噪声
  • 线性校准:两点搞定,简单粗暴
  • 查表法:非线性传感器的救星
  • 温度补偿:别让温度毁了你的精度

这些方法组合使用,你的万用表蓝牙模块就能输出稳定、准确的数据了。下一节我们聊聊蓝牙协议栈的配置和实际传输调试,到时候见。