最小二乘拟合在嵌入式系统中的工程实践与深度优化

在智能传感器无处不在的今天,你有没有遇到过这样的尴尬?——明明ADC读数很稳定,温度显示却像坐过山车;压力传感器标称精度±0.5%,实测误差动不动就飙到±2%。🤯 别急,这锅真不全是硬件的。问题往往出在一个被忽略的关键环节: 原始数据与物理量之间的非线性映射关系未被正确校正

比如最常见的NTC热敏电阻,它的阻值和温度之间是指数级变化的:

$$
R(T) = R_0 \cdot e^{B\left(\frac{1}{T} - \frac{1}{T_0}\right)}
$$

如果你还用简单的 temperature = adc_value * 0.1 这种线性换算(咳咳,别装作没干过),那测出来的温度大概率只能用来“参考”一下天气预报了。😅

// 危险操作⚠️:直接比例转换
float raw_temp = read_adc(CHANNEL_TEMP);
float temperature = raw_temp * 0.1; // 看似简单,实则误差惊人

这时候,最小二乘拟合就像一位经验丰富的“数学医生”,能从一堆看似杂乱的数据中找出最合理的趋势线,把你的测量精度拉回正轨。而且它特别适合STM32这类资源紧张的小型MCU——轻量、高效、无需复杂依赖,简直是嵌入式开发者的“性价比之选”。接下来咱们就一起拆解这个经典算法,看看它是如何在RAM只有几KB、主频不过几百MHz的单片机上大显身手的。


从理论到落地:最小二乘法的核心思想与工程适配

我们先抛开那些复杂的公式,想想一个实际场景:你在做一款温控设备,手头有一块NTC探头,在不同温度下记录了对应的ADC读数。现在的问题是—— 怎么找到一条“最佳直线”,让这条线尽可能贴近所有这些点?

传统插值法要求曲线必须穿过每一个数据点,听起来很完美对吧?但现实世界充满了噪声,每个采样值都可能有微小波动。如果强行拟合每个点,反而会让模型变得敏感而脆弱。而最小二乘法的哲学完全不同: 我不要完美,我要稳健 。它允许个别点偏离,但追求整体偏差最小。

残差平方和:为什么是“平方”?

衡量“偏离程度”的方式有很多种,最小二乘选择的是 残差平方和(SSR)

$$
S(a, b) = \sum_{i=1}^{n} (y_i - (ax_i + b))^2
$$

这里 $ x_i $ 是原始ADC值,$ y_i $ 是真实温度,$ a $ 和 $ b $ 就是我们要找的斜率和截距。为什么要用“平方”而不是绝对值?原因有三:

  • 放大误差影响 :大误差会被显著放大,迫使模型优先修正明显异常的点。
  • 数学友好性 :平方函数处处可导,可以用求导法轻松找到极小值。
  • 统计合理性 :在高斯噪声假设下,最小二乘解就是最大似然估计。

更重要的是,这个目标函数非常适合嵌入式实现——我们不需要存下所有历史数据,只需要维护几个累加变量即可完成在线更新。

闭式解的魅力:O(1) 更新 + O(1) 求解

通过求偏导并令其为零,可以得到正规方程组,并最终推导出闭式解:

$$
a = \frac{n\sum x_i y_i - \sum x_i \sum y_i}{n\sum x_i^2 - (\sum x_i)^2}, \quad
b = \frac{\sum x_i^2 \sum y_i - \sum x_i \sum x_i y_i}{n\sum x_i^2 - (\sum x_i)^2}
$$

看到没?整个计算只依赖五个累计量:
- sum_x
- sum_y
- sum_xy
- sum_x2
- n

这意味着无论你是采集了10个点还是1万个点,内存占用都是常数!👏 这对于RAM宝贵的STM32来说太友好了。

下面是一个典型的C语言实现框架,支持增量学习:

typedef struct {
    float sum_x;
    float sum_y;
    float sum_xy;
    float sum_x2;
    uint32_t n;
} LinearFitContext;

void linear_fit_update(LinearFitContext *ctx, float x, float y) {
    ctx->sum_x  += x;
    ctx->sum_y  += y;
    ctx->sum_xy += x * y;
    ctx->sum_x2 += x * x;
    ctx->n++;
}

int linear_fit_solve(const LinearFitContext *ctx, float *slope, float *intercept) {
    if (ctx->n < 2) return -1; // 至少两个点才能拟合

    float denom = ctx->n * ctx->sum_x2 - ctx->sum_x * ctx->sum_x;
    if (fabsf(denom) < 1e-8) return -2; // 分母接近零,说明数据几乎共线

    *slope     = (ctx->n * ctx->sum_xy - ctx->sum_x * ctx->sum_y) / denom;
    *intercept = (ctx->sum_x2 * ctx->sum_y - ctx->sum_x * ctx->sum_xy) / denom;

    return 0;
}

💡 实战提示 :你可以把这个结构体放进ADC中断服务程序里,每次拿到新样本就调用一次 update ,真正实现“边采样边拟合”的低延迟架构!

不过要注意⚠️:长时间运行时浮点累积可能导致舍入误差漂移。建议定期重置上下文或启用滑动窗口机制,保持模型新鲜度。


当线性不够用:多项式拟合的威力与陷阱

有些传感器的非线性特性太强,光靠一条直线根本压不住。比如某些压力传感器在满量程两端出现“翘尾”现象,或者NTC在高温区响应变平缓。这时就得请出更高阶的选手—— 多项式最小二乘拟合

设想我们要拟合一个二次曲线:

$$
y = ax^2 + bx + c
$$

同样可以通过构建设计矩阵 $ \mathbf{X} $ 和观测向量 $ \mathbf{Y} $,写出正规方程:

$$
\mathbf{A} = (\mathbf{X}^T\mathbf{X})^{-1}\mathbf{X}^T\mathbf{Y}
$$

听起来很美,但在STM32上直接求逆代价极高,尤其当阶数上升后,矩阵维度迅速膨胀。来看一组真实对比数据:

多项式阶数 系数数量 典型执行时间(STM32F4 @168MHz) 是否推荐
1 2 ~80 μs ✅ 强烈推荐
2 3 ~250 μs ✅ 推荐
3 4 ~700 μs ⚠️ 视情况而定
4+ ≥5 >1.5 ms ❌ 不推荐

所以实践中有个黄金法则:

对绝大多数工业传感器而言, 三阶以内足矣 ;超过四阶基本就是在拿CPU性能换边际收益,得不偿失。

举个例子:某客户反馈他们的压力传感器在0~100kPa范围内非线性误差达±3%,我们尝试用不同阶数进行补偿:

拟合阶数 RMSE(kPa) CPU占用率(@1kHz任务) 结论
一阶 1.8 5% 改善有限
二阶 0.9 12% 性价比高
三阶 0.5 23% 可接受
四阶 0.47 35% 提升微弱,拒绝

最终选择了三阶模型,在精度和效率之间取得了良好平衡。

警惕过拟合:你是在建模还是在背答案?

高阶多项式虽然拟合能力强,但也容易陷入“过拟合”陷阱——模型记住了训练数据中的噪声,导致泛化能力下降。就像学生死记硬背考题,换个题就不会做了。

判断是否过拟合的方法有很多,工程上更常用的是“肘部法则”:画出RMSE随阶数变化的曲线,观察拐点位置。

import numpy as np
import matplotlib.pyplot as plt

degrees = range(1, 6)
rmse_list = []

for d in degrees:
    coeffs = np.polyfit(x_train, y_train, deg=d)
    y_pred = np.polyval(coeffs, x_test)
    rmse = np.sqrt(np.mean((y_test - y_pred)**2))
    rmse_list.append(rmse)

plt.plot(degrees, rmse_list, 'bo-')
plt.xlabel("Polynomial Degree")
plt.ylabel("RMSE")
plt.title("Elbow Method for Optimal Order Selection")
plt.grid(True)
plt.show()

通常你会看到RMSE快速下降然后趋于平缓,那个“拐弯”的地方就是最优阶数。记住: 模型越复杂,维护成本越高,别为了那0.02°C的提升牺牲系统的稳定性


浮点危机:数值稳定性才是真正的战场 🛰️

你以为推导完公式就能直接跑通?Too young too simple!在嵌入式世界里, 浮点精度才是隐藏BOSS

以STM32F4为例,它使用IEEE 754单精度float,有效位数约7位。假设你的ADC读数集中在[3000, 4000]区间,那么当你计算三阶项时:

  • $ x \approx 3.5 \times 10^3 $
  • $ x^2 \approx 1.2 \times 10^7 $
  • $ x^3 \approx 4.3 \times 10^{10} $

这三个数量级相差巨大的数要在同一个累加器中共存,结果就是小数部分被“吞噬”:

float acc = 4.3e10f;
acc += 3.5e3;  // 实际上不会改变 acc 的值!

这就是典型的 有效数字丢失 问题。后果可能是矩阵条件数飙升至 $10^8$ 以上,轻微扰动就会让解剧烈震荡。

解决方案:归一化!归一化!归一化!

应对策略很简单粗暴但极其有效: 输入归一化

$$
x’ = \frac{x - \bar{x}}{\sigma_x}
$$

即把输入转换为均值为0、标准差为1的标准正态分布。这样所有幂次项都能落在相近的数量级,极大改善矩阵病态问题。

我们可以用Welford算法在线计算均值和方差,避免遍历两次数组:

void online_stats_update(float x, float *mean, float *M2, uint32_t *count) {
    (*count)++;
    float delta = x - *mean;
    *mean += delta / (*count);
    float delta2 = x - *mean;
    *M2 += delta * delta2;
}

float normalize_value(float x, float mean, float M2, uint32_t count) {
    float variance = M2 / (count - 1);
    float std_dev = sqrtf(variance);
    return (x - mean) / std_dev;
}

💡 聪明的做法 :在校准阶段一次性计算归一化参数,固化成常量写进Flash。运行期直接使用,零额外开销!

经过归一化处理后,原本高达 $10^8$ 的条件数可降至 $10^4$ 左右,稳定性提升近万倍!


STM32实战:软硬件协同设计的艺术

理论讲完了,现在进入真正的战场——如何让这套算法在STM32上稳稳跑起来?

ADC采集:不只是读个寄存器那么简单

很多开发者以为启动ADC连续模式就万事大吉了,其实细节决定成败。比如采样时间设置不当,会导致高阻抗信号源充电不足,引入系统误差。

以下是推荐的HAL库配置模板:

ADC_ChannelConfTypeDef sConfig = {0};

hadc1.Instance = ADC1;
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ContinuousConvMode = ENABLE;
hadc1.Init.DiscontinuousConvMode = DISABLE;
hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE;
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 1;

sConfig.Channel = ADC_CHANNEL_0;
sConfig.Rank = 1;
sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES; // 高阻源必备!

HAL_ADC_ConfigChannel(&hadc1, &sConfig);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, BUFFER_SIZE);

关键点解析👇:
- Resolution=12B → 输出范围0~4095
- ContinuousConvMode=ENABLE → 自动循环采样
- SamplingTime=480cycles → 给足RC充电时间
- Start_DMA → 使用DMA双缓冲,彻底解放CPU

每秒采集100个样本绰绰有余,还不影响主逻辑执行。


数据预处理:滤波 + 去噪 = 稳健输入

原始数据常含噪声甚至毛刺,直接参与拟合会导致系数抖动。我们需要两道防线:

第一道:滑动平均 or IIR滤波?
// 方法一:滑动平均(适合突发噪声)
float moving_average_filter(uint16_t *buf, int len) {
    float sum = 0.0f;
    for (int i = 0; i < len; i++) sum += buf[i];
    return sum / len;
}

// 方法二:一阶IIR(响应更快,内存友好)
static float y_prev = 0.0f;
float alpha = 0.2f;
y_prev = alpha * new_sample + (1 - alpha) * y_prev;

两者各有优劣:
- 滑动平均抑制随机噪声更强
- IIR延迟更低,适合动态场景

建议根据应用选型,我一般默认用IIR。

第二道:离群点剔除(Outlier Rejection)

有时候某个ADC读数突然跳变到4095,显然是干扰。可以用Z-score法识别:

int is_outlier(float val, float mean, float std_dev) {
    return fabsf(val - mean) > 2.5f * std_dev;
}

超过2.5σ的数据直接丢弃,防止污染模型。当然也可以设为NaN,后续插值补全。


内存优化:每一字节都要精打细算

在F4系列上,float运算很快,但你知道吗?换成Q15定点数还能再提速40%!

typedef int16_t q15_t;

#define FLOAT_TO_Q15(f) ((q15_t)((f) * 32768.0f))
#define Q15_TO_FLOAT(q) ((float)(q) / 32768.0f)

在一元线性拟合中,所有累加项可用int32_t保存,防溢出又省空间。实测表明:

类型 执行时间 RAM占用 Flash占用 适用芯片
float 80μs 20B 1.1KB F4/F7/H7
Q15 48μs 12B 0.8KB F1/F3/M0

特别是没有FPU的老型号,果断上定点运算!

另外提醒一句⚠️:别在栈上声明大数组!

void bad_func() {
    float temp[128]; // 占用512字节栈空间!极易溢出
}

应改为静态分配或放CCM RAM:

static float data_pool[128] __attribute__((section(".ccmram")));

利用链接脚本将关键缓冲区映射至CCM区域,速度快还安全。


实验验证:用NTC热敏电阻说话 🔥

纸上得来终觉浅,我们来搞个真实案例——基于NTC的温度测量系统。

硬件搭建:分压电路 + 高精度参考

使用10kΩ NTC与10kΩ上拉构成分压网络,供电3.3V:

VDD (3.3V)
  │
  └───[10k]───┬─── PA0 (ADC_IN)
              │
             [NTC]
              │
             GND

采集过程中同步记录:
- ADC原始值(均值)
- 标准温度计读数(Fluke 1524,精度±0.015°C)

每1°C采集一次,共101组数据,形成 (adc_val, temp_ref) 训练集。

Python端接收串口数据并保存为CSV:

import serial
import pandas as pd

ser = serial.Serial('COM3', 115200)
data_list = []

try:
    while True:
        line = ser.readline().decode().strip()
        if "DATA:" in line:
            _, adc, temp = line.split(",")
            data_list.append({'adc': int(adc), 'temp': float(temp)})
except KeyboardInterrupt:
    pd.DataFrame(data_list).to_csv('calib.csv', index=False)

模型评估:不止看一眼温度

拟合完不能只看“看起来准不准”,要有量化指标:

决定系数 $ R^2 $

反映模型解释变异的能力:

$$
R^2 = 1 - \frac{\sum{(y_i - \hat{y}_i)^2}}{\sum{(y_i - \bar{y})^2}}
$$

float calculate_r_squared(float *y_true, float *y_pred, uint16_t n) {
    float sum_sq_total = 0.0f, sum_sq_res = 0.0f, y_mean = 0.0f;

    for (int i = 0; i < n; i++) y_mean += y_true[i];
    y_mean /= n;

    for (int i = 0; i < n; i++) {
        float diff1 = y_true[i] - y_mean;
        float diff2 = y_true[i] - y_pred[i];
        sum_sq_total += diff1 * diff1;
        sum_sq_res += diff2 * diff2;
    }

    return 1.0f - (sum_sq_res / sum_sq_total);
}

结果对比👇:
- 一阶线性:$ R^2 \approx 0.987 $
- 二阶多项式:$ R^2 \approx 0.998 $

直观感受就是曲线贴合度明显更好。

RMSE与最大偏差

工程师更关心具体误差有多大:

模型类型 RMSE(°C) 最大偏差(°C) 是否达标
一阶 0.83 ±1.6
二阶 0.28 ±0.45 ✅ 是
三阶 0.25 ±0.42 ✅(边际)

最终选定二阶模型,兼顾精度与实时性。


性能瓶颈分析与极致优化

即使功能正常,也不能放过任何一处潜在瓶颈。我们用SEGGER SystemView抓了一帧:

![SystemView截图示意]

发现热点集中在两个地方:
- x[i]*y[i] 乘法运算
- x[i]*x[i] 平方运算

合计占总耗时60%以上!

加速方案一:CMSIS-DSP出场

利用ARM官方DSP库中的SIMD指令加速点积运算:

#include "arm_math.h"

float sum_xy, sum_xx;
arm_dot_prod_f32(x, y, n, &sum_xy);
arm_dot_prod_f32(x, x, n, &sum_xx);

在STM32F4上实测提速2.3倍,从680μs降到约290μs!

加速方案二:LUT查表法降维打击

对于实时性要求极高的场合(如电机过温保护),完全可以预先在PC端完成拟合并生成查找表:

typedef struct { float slope; float offset; } segment_t;

const segment_t lut[64] PROGMEM = {
    {0.021, -67.5}, {0.022, -68.1}, /* ... */ {0.035, -89.2}
};

uint8_t idx = adc_val >> 6;  // 4096 / 64 = 64
float temp = lut[idx].slope * adc_val + lut[idx].offset;

性能对比炸裂👇:

方法 执行时间 精度(RMSE) 内存占用
实时最小二乘 680μs 0.28°C ~200B
LUT + 线性插值 15μs 0.35°C 512B

牺牲0.07°C换来45倍速度提升,某些场景下简直不要太香!


跨平台移植:一套代码打天下

我们的目标是:同一套算法,能在F1/F4/F7/H7等各种型号上无缝运行。

条件编译+FPU抽象层

#ifdef __FPU_PRESENT
    typedef float fp_t;
    #define FP_MUL(a,b) ((a)*(b))
    #define FP_SQRT(x)  sqrtf(x)
#else
    typedef int32_t fp_t;  // Q16.16格式
    #define FP_MUL(a,b) (((int64_t)(a) * (b)) >> 16)
    #define FP_SQRT(x)  fixed_sqrt_q16(x)
#endif

配合编译选项 -O2 -ffast-math ,可在不同平台上自动选择最优路径。

资源裁剪策略

针对低端型号(如F030,仅8KB RAM),实施极限压缩:

模块 裁剪措施 节省空间
缓冲区 改为单样本即时处理 -2KB
拟合阶数 锁定一阶 -1.5KB
错误检测 移除R²/RMSE -800B
日志输出 关闭printf -3KB

最终整个模块可控制在<4KB Flash + <1KB RAM,超小型节点也能轻松驾驭。


工程化封装:从原型到产品

最后一步,把零散代码变成可复用的驱动模块。

模块化接口设计

// least_squares_drv.h
typedef struct {
    float slope;
    float offset;
    uint8_t valid;
    uint16_t sample_count;
    float sum_x, sum_y, sum_xy, sum_x2;
} lsq_linear_t;

void lsq_init(lsq_linear_t *ctx);
void lsq_update(lsq_linear_t *ctx, float x, float y);
uint8_t lsq_solve(lsq_linear_t *ctx);
float lsq_apply(const lsq_linear_t *ctx, float raw_value);

每个传感器通道独立管理上下文,支持多路并发。

参数持久化存储

校准完成后,将系数写入Flash并带上CRC保护:

#define CALIB_MAGIC 0x5CA1AB1E
typedef struct {
    uint32_t magic;
    float k, b;
    uint32_t timestamp;
    uint8_t sensor_id[16];
    uint32_t crc32;
} calibration_data_t;

int save_calibration(uint8_t channel, const lsq_linear_t *result) {
    calibration_data_t data = {
        .magic = CALIB_MAGIC,
        .k = result->slope,
        .b = result->offset,
        .timestamp = get_timestamp(),
    };
    snprintf((char*)data.sensor_id, 16, "CH%d", channel);
    data.crc32 = crc32_compute(&data, sizeof(data) - 4);
    return flash_write_page(channel * 256, (uint8_t*)&data, sizeof(data));
}

开机自动加载最新有效参数,失败则回退出厂设置,保障系统鲁棒性。


更进一步:多元回归与边缘智能融合

未来已来。单一变量拟合只是起点,真正的挑战在于 多维补偿

例如压力传感器受温度影响严重:

$$ P_{corrected} = a \cdot V + b \cdot T + c $$

可通过多元最小二乘同时求解三个系数。结合滑动窗口机制,还能实现 在线自适应校准 ,应对传感器老化趋势。

长远来看,随着STM32H7等高性能型号普及,甚至可以部署轻量化神经网络替代传统模型,在保持低延迟的同时捕捉更复杂的非线性关系。

但记住一句话: 不是越先进越好,而是越合适越好 。在资源受限的世界里,优雅的数学 + 扎实的工程,才是永恒的主题。✨

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐