
传感器数据校准这件事说大不大说小不小。但凡做过嵌入式采集或者物联网项目的朋友大概率都遇到过这样的场景传感器手册上写着精度正负百分之一实际装到板子上跑起来读数飘得让人怀疑人生。温度传感器在恒温箱里显示 26.3 度标准温度计明明指着 25.0称重模块空载时输出 12 而不是 0加载 5 公斤又变成 4987 而不是 5000。这种问题靠改硬件电路往往收效甚微真正管用的手段是在软件层面做一次线性拟合校准而最小二乘法就是干这个活最趁手的工具。这篇文章面向的是有一定 C 语言基础、正在做嵌入式采集或者传感器数据处理的朋友。我会从最小二乘法的数学原理讲起然后一步步推导出适合在单片机上跑的 C 语言实现最后给出一个完整的、可以直接拿去用的校准代码框架。代码不依赖任何第三方库纯标准 CKeil、IAR、GCC 都能编译。如果你之前没接触过最小二乘法也不用担心我会用最直白的方式把公式拆开讲清楚。1. 传感器为什么需要校准从物理误差到数学模型1.1 传感器误差的真实来源很多人以为传感器不准是因为质量不好其实大部分情况下问题出在几个非常具体的环节。第一是零点漂移也就是输入为零时输出不为零。比如一个压力传感器在无压力状态下输出 0.48V 而不是理论上的 0.5V这个偏差可能来自电桥电阻的微小失配也可能来自运放失调电压。第二是灵敏度偏差传感器实际输出斜率跟手册标称值不一致。手册说 1kPa 对应 1mV 输出实际可能是 1kPa 对应 0.93mV。第三是非线性传感器的输入输出关系不是完美直线而是一条略带弯曲的曲线。对于大多数中低精度应用来说非线性误差远小于前两项所以用一条直线去拟合实际数据就足够了。这就是所谓的线性校准也是最小二乘法在传感器领域最典型的应用场景。1.2 线性校准的数学表达假设我们有一个传感器它的原始输出记为 x我们想要得到的物理量记为 y。理想情况下 y 和 x 之间是线性关系y k * x b其中 k 是斜率b 是截距。但实际传感器的输出跟理想值之间存在偏差所以我们采集到的一组数据点 (x1, y1), (x2, y2), ..., (xn, yn) 不会完美落在一条直线上。最小二乘法的任务就是找到一组 (k, b)使得所有数据点到直线 y kx b 的垂直距离平方和最小。为什么是平方和而不是绝对值和这个问题我在刚开始学的时候也纠结过。简单说平方和函数是可导的能直接求解析解绝对值和在零点不可导处理起来麻烦得多。另外平方和会放大较大偏差的影响这正好符合我们不希望出现大误差的直觉。1.3 最小二乘法的解析解推导设误差平方和为 SS Σ(yi - k*xi - b)²对 k 和 b 分别求偏导并令其为零可以得到k (nΣxiyi - ΣxiΣyi) / (nΣxi² - (Σxi)²) b (Σyi - k*Σxi) / n这两个公式就是最小二乘法的核心。你不需要记住推导过程但需要理解一点k 和 b 完全由五个累加量决定——n、Σxi、Σyi、Σxi²、Σxi*yi。这意味着在 C 语言里我们只需要在采集数据的过程中不断累加这五个量最后代入公式就能算出校准系数。这个特性非常适合在资源受限的单片机上运行因为不需要存储所有数据点只需要几个累加器变量。2. 用 C 语言实现最小二乘拟合从公式到可运行代码2.1 数据结构设计在动手写代码之前先想清楚数据怎么组织。最直接的方式是定义一个结构体来保存累加量typedef struct { double sum_x; // Σxi double sum_y; // Σyi double sum_xx; // Σxi² double sum_xy; // Σxi*yi int count; // n } LsqAccumulator;这里用 double 而不是 float原因是累加过程中数值可能比较大float 的有效位数只有 6 到 7 位累加几十个数据后精度损失会很明显。在 STM32F103 这类没有硬件浮点单元的芯片上double 运算会慢一些但校准过程通常只在出厂或上电时做一次对实时性没有要求所以用 double 换精度是划算的。2.2 累加与拟合函数累加函数很简单就是把每个数据点喂进去void lsq_accumulate(LsqAccumulator *acc, double x, double y) { acc-sum_x x; acc-sum_y y; acc-sum_xx x * x; acc-sum_xy x * y; acc-count; }拟合函数负责计算 k 和 bint lsq_fit(const LsqAccumulator *acc, double *k, double *b) { if (acc-count 2) { return -1; // 数据点不足无法拟合 } double n (double)acc-count; double denom n * acc-sum_xx - acc-sum_x * acc-sum_x; if (fabs(denom) 1e-12) { return -2; // 所有 x 相同无法拟合 } *k (n * acc-sum_xy - acc-sum_x * acc-sum_y) / denom; *b (acc-sum_y - (*k) * acc-sum_x) / n; return 0; }这里有两个边界条件必须处理。第一个是数据点少于 2 个一条直线至少需要两个点才能确定这个不用多解释。第二个是分母接近零的情况当所有采集到的 x 值都相同比如传感器卡死在某个读数分母会变成零除法会产生 inf 或 nan。我在实际项目中就遇到过因为传感器接触不良导致连续采到同一个值结果校准系数变成 nan后面整个系统都疯了。所以这个检查绝对不能省。2.3 拟合质量评估算出 k 和 b 之后我们还需要知道拟合效果好不好。最常用的指标是决定系数 R²它表示拟合直线能解释多少比例的数据变异double lsq_r_squared(const LsqAccumulator *acc, double k, double b) { double n (double)acc-count; double mean_y acc-sum_y / n; double ss_tot 0.0; // 总平方和 double ss_res 0.0; // 残差平方和 // 这里需要重新遍历数据点实际使用时需要保存原始数据 // 或者在累加阶段同时计算 Σyi² // ss_tot Σ(yi - mean_y)² // ss_res Σ(yi - k*xi - b)² // R² 1 - ss_res / ss_tot return 1.0 - ss_res / ss_tot; }R² 越接近 1 说明拟合越好。一般来说传感器校准的 R² 应该在 0.999 以上如果低于 0.99说明要么传感器非线性严重要么采集过程中有异常点。我在调试一款称重模块时R² 只有 0.97后来发现是其中一个数据点采集时有人在旁边走动导致振动干扰剔除那个点之后 R² 立刻回到 0.9998。为了计算 R²我们需要在累加阶段额外记录 Σyi²这样 ss_tot 可以通过公式 ss_tot Σyi² - n*mean_y² 快速算出不需要保存所有原始数据。这是一个很实用的技巧在内存受限的嵌入式环境里特别有用。3. 完整校准流程从采集数据到验证结果3.1 数据采集的实操要点校准的第一步是采集一组标准输入-传感器输出数据对。这里有几个坑我必须提前说清楚。采集点的分布要均匀覆盖量程。我见过有人校准电子秤只在 0kg 和 5kg 两个点采集数据然后拟合出一条直线。这样做理论上也能算出 k 和 b但完全没有验证能力万一传感器在中间量程有非线性你根本发现不了。正确的做法是在量程内均匀取 5 到 10 个点比如 0、1、2、3、4、5kg 各采一组。每个点要多次采集取平均。传感器输出总有噪声单次采集的随机误差可能达到满量程的 0.1% 甚至更多。在每个标准点采集 10 到 20 次去掉最大最小值后取平均能显著降低噪声影响。如果时间允许采集 50 次以上效果更好。标准值要足够准确。校准的本质是拿一个已知准确的标准去修正传感器如果标准本身就不准校准结果只会更糟。砝码要用经过检定的温度计要用标准温度计压力源要用标准压力表。这一点听起来是废话但我在实际工作中确实见过用厨房秤去校准工业称重传感器的操作结果可想而知。3.2 校准系数的存储与加载算出 k 和 b 之后需要把它们保存到非易失性存储器里这样下次上电就不用重新校准。在 STM32 上通常用 Flash 模拟 EEPROM在 AVR 上直接用内置 EEPROM在 Linux 嵌入式系统上可以写文件。存储的时候有个细节要注意同时保存一个校验值。因为 Flash 或 EEPROM 有可能因为掉电、老化等原因出现位翻转如果 k 或 b 被改坏了系统会输出完全错误的结果。简单的做法是保存 k、b 和它们的异或校验值加载时验证一下typedef struct { double k; double b; uint32_t checksum; } CalibData; uint32_t calc_checksum(const CalibData *d) { uint32_t sum 0; const uint8_t *p (const uint8_t *)d; for (size_t i 0; i offsetof(CalibData, checksum); i) { sum (sum 1) ^ p[i]; } return sum; }加载时如果校验不通过就回退到默认系数通常是 k1, b0即不做校准并给出警告。这比直接用错误系数导致系统失控要好得多。3.3 校准结果的验证方法校准完成后必须验证。最直接的方法是用另一组没有参与拟合的数据点来测试。比如你用 0、1、2、3、4、5kg 六个点做了拟合那就用 0.5、1.5、2.5、3.5、4.5kg 这五个点来验证。把传感器读数经过 y kx b 转换后跟标准值比较计算最大误差和平均误差。如果最大误差超过你的应用允许范围说明要么传感器本身精度不够要么校准流程有问题。这时候不要急着调参数先回头检查数据采集环节。我个人的经验是90% 的校准问题都出在数据采集阶段而不是拟合算法本身。4. 进阶话题从线性校准到分段校准4.1 什么时候线性校准不够用线性校准的前提是传感器输出跟物理量之间近似线性。对于热电偶、某些气体传感器、大量程压力传感器来说这个前提不成立。热电偶的输出电压跟温度之间是多项式关系用一条直线去拟合整个量程中间段的误差可能达到十几度。判断是否需要分段校准的方法很简单做完线性拟合后看 R² 和残差分布。如果 R² 很高0.999但残差在量程两端明显偏大说明存在系统性的非线性需要考虑分段处理。4.2 分段线性校准的实现思路分段校准的思路是把量程分成若干段每段单独做线性拟合。比如把 0 到 1000 度分成 0-200、200-500、500-1000 三段每段有自己的 k 和 b。使用时根据原始读数 x 落在哪个区间选择对应的系数。这样做的好处是每段的非线性误差都被限制在小范围内整体精度大幅提升。代价是需要存储多组系数并且段与段之间的衔接处可能出现不连续。处理衔接问题的方法有两种一是让相邻段在分界点处强制连续二是接受微小跳变并在输出端做平滑滤波。4.3 多项式拟合的 C 语言实现如果分段线性还是不够可以考虑多项式拟合。二次多项式 y ax² bx c 的拟合需要解一个三元一次方程组用高斯消元法就能搞定。更高次的多项式虽然拟合能力更强但在嵌入式环境里容易出现过拟合和数值不稳定问题一般不建议超过三次。多项式拟合的代码比线性拟合复杂不少核心是构造正规方程组并求解。这里给出二次拟合的关键步骤// 需要累加的量Σx, Σx², Σx³, Σx⁴, Σy, Σxy, Σx²y // 构造 3x3 正规方程组 // [n Σx Σx² ] [c] [Σy ] // [Σx Σx² Σx³ ] [b] [Σxy ] // [Σx² Σx³ Σx⁴ ] [a] [Σx²y] // 然后用高斯消元求解实际项目中我通常先用线性拟合看 R²如果 R² 低于 0.995 再考虑二次拟合。大多数消费级传感器的线性度都能满足线性校准的要求真正需要多项式拟合的场景并不多。5. 实战中容易踩的坑与排查方法5.1 数值精度问题前面提到过用 double 而不是 float这里再展开说一下。在累加 Σxi² 的时候如果 xi 的量级是 1000 左右xi² 就是 10^6累加 100 个点就是 10^8。float 的有效位数大约是 7 位十进制在 10^8 量级上只能精确到个位后面的小数全丢了。而 double 有 15 到 16 位有效数字完全够用。另一个精度陷阱是大数相减。公式 k (nΣxy - ΣxΣy) / (nΣx² - (Σx)²) 中分子和分母都是两个大数相减。如果数据点的 x 值都很大比如传感器输出在 10000 附近nΣx² 和 (Σx)² 可能非常接近相减之后有效位数会大幅减少。解决办法是在采集数据前先减去一个基准值让 x 在零附近分布。这个技巧叫做数据中心化是数值计算里的标准操作。5.2 异常数据点的识别与剔除采集数据时难免混入异常点。可能是电磁干扰导致的一次跳变也可能是操作人员不小心碰了传感器。这些异常点对最小二乘拟合的影响很大因为平方和会放大它们的权重。识别异常点的简单方法是3σ 准则计算所有残差的标准差把残差超过 3 倍标准差的数据点标记为异常。更稳健的方法是使用中位数绝对偏差MAD它对异常值的敏感度更低。在实际项目中我通常先做一次拟合计算每个点的残差把残差最大的点剔除后再拟合一次对比两次的 R²。如果 R² 明显提升说明剔除是合理的。5.3 温度漂移与长期稳定性传感器校准不是一劳永逸的事情。很多传感器的零点会随温度漂移灵敏度也会随时间和使用环境变化。对于高精度应用需要定期重新校准或者加入温度补偿。温度补偿的基本思路是把温度也作为一个输入变量建立 y f(x, T) 的模型。最简单的做法是在不同温度下分别校准得到 k(T) 和 b(T)然后用线性插值计算当前温度下的系数。这已经超出了最小二乘法的范畴但思路是一脉相承的。6. 完整代码框架与使用说明6.1 头文件与宏定义#ifndef LSQ_CALIB_H #define LSQ_CALIB_H #include math.h #include stdint.h #include stddef.h #define LSQ_MAX_POINTS 32 typedef struct { double sum_x; double sum_y; double sum_xx; double sum_xy; double sum_yy; int count; } LsqAccumulator; typedef struct { double k; double b; double r_squared; int valid; } CalibResult; void lsq_init(LsqAccumulator *acc); void lsq_accumulate(LsqAccumulator *acc, double x, double y); int lsq_fit(const LsqAccumulator *acc, CalibResult *result); double lsq_apply(const CalibResult *calib, double raw); #endif6.2 实现文件#include lsq_calib.h void lsq_init(LsqAccumulator *acc) { acc-sum_x 0.0; acc-sum_y 0.0; acc-sum_xx 0.0; acc-sum_xy 0.0; acc-sum_yy 0.0; acc-count 0; } void lsq_accumulate(LsqAccumulator *acc, double x, double y) { acc-sum_x x; acc-sum_y y; acc-sum_xx x * x; acc-sum_xy x * y; acc-sum_yy y * y; acc-count; } int lsq_fit(const LsqAccumulator *acc, CalibResult *result) { result-valid 0; result-k 1.0; result-b 0.0; result-r_squared 0.0; if (acc-count 2) { return -1; } double n (double)acc-count; double denom n * acc-sum_xx - acc-sum_x * acc-sum_x; if (fabs(denom) 1e-12) { return -2; } double k (n * acc-sum_xy - acc-sum_x * acc-sum_y) / denom; double b (acc-sum_y - k * acc-sum_x) / n; // 计算 R² double mean_y acc-sum_y / n; double ss_tot acc-sum_yy - n * mean_y * mean_y; double ss_res 0.0; // ss_res 需要通过原始数据计算这里用近似公式 // 实际使用时建议保存原始数据点 // ss_res Σ(yi - k*xi - b)² // 展开后可以用累加量表示 // ss_res Σyi² - 2kΣxiyi - 2bΣyi k²Σxi² 2kbΣxi n*b² ss_res acc-sum_yy - 2.0 * k * acc-sum_xy - 2.0 * b * acc-sum_y k * k * acc-sum_xx 2.0 * k * b * acc-sum_x n * b * b; if (ss_tot 1e-12) { result-r_squared 1.0 - ss_res / ss_tot; } result-k k; result-b b; result-valid 1; return 0; } double lsq_apply(const CalibResult *calib, double raw) { if (!calib-valid) { return raw; } return calib-k * raw calib-b; }6.3 使用示例#include stdio.h #include lsq_calib.h int main(void) { LsqAccumulator acc; CalibResult calib; lsq_init(acc); // 模拟采集数据标准值 vs 传感器原始读数 double std_val[] {0.0, 1.0, 2.0, 3.0, 4.0, 5.0}; double raw_val[] {12.5, 1013.2, 2015.8, 3018.1, 4020.5, 5022.9}; for (int i 0; i 6; i) { lsq_accumulate(acc, raw_val[i], std_val[i]); } int ret lsq_fit(acc, calib); if (ret 0) { printf(k %.10f\n, calib.k); printf(b %.10f\n, calib.b); printf(R² %.10f\n, calib.r_squared); // 验证 double test_raw 2517.0; double result lsq_apply(calib, test_raw); printf(raw %.1f - calibrated %.4f\n, test_raw, result); } else { printf(Fit failed with code %d\n, ret); } return 0; }这段代码可以直接编译运行。在 PC 上用 gcc 编译时记得加 -lm 链接数学库。在单片机上使用时把 printf 换成你的串口输出函数即可。6.4 在嵌入式项目中的集成建议实际项目中我通常把校准功能做成一个独立模块通过串口命令触发。比如发送 CAL START 进入校准模式然后依次输入标准值和对应的传感器读数最后发送 CAL END 计算并保存系数。这样不需要重新烧录固件就能完成校准产线操作也方便。校准模式要有超时退出机制。如果操作员进入校准模式后忘记退出设备会一直处于非正常工作状态。我一般设置 60 秒无操作自动退出并且退出时不保存任何未完成的校准数据。还有一点很重要校准模式要有权限保护。不是任何人都应该能修改校准系数。简单的做法是要求输入一个密码或者特定的按键组合才能进入校准模式。在工业设备上误校准可能导致严重的安全问题。7. 关于最小二乘法在传感器校准中的边界思考最小二乘法不是万能的。它假设误差只存在于 y 方向x 方向是精确的。在传感器校准场景中这意味着我们假设标准值是准确的传感器读数带有误差。这个假设在大多数情况下成立因为标准器具的精度通常比被校准传感器高一个数量级。但如果标准值本身也有不可忽略的误差就需要用正交距离回归或者总体最小二乘法这些方法同时考虑 x 和 y 方向的误差。不过这些方法在嵌入式环境里实现起来复杂得多除非你的应用对精度要求极高否则没必要上这么重的工具。另一个边界是异常值的鲁棒性。最小二乘法对异常值非常敏感一个偏离很远的点就能把整条直线拉偏。如果采集环境干扰严重可以考虑用RANSAC或者Theil-Sen估计器它们对异常值的容忍度更高。代价是计算量更大而且结果带有随机性。我在实际项目中一般先用最小二乘法加异常值剔除如果效果不理想再考虑换更鲁棒的方法。最后说一个我踩过的坑不要用校准后的数据去校准。有一次我在调试时不小心把已经校准过的输出又喂给了校准模块结果系数被反复叠加输出完全失控。校准模块的输入必须是原始传感器读数这个原则要写进代码注释里防止后来的人搞错。