简介:本资源是一套轻量级C语言实现的偏最小二乘回归(PLS)算法代码,专为嵌入式场景优化,可直接部署于STM32等资源受限的单片机平台,面向嵌入式AI开发者、工业传感器数据分析工程师及低功耗边缘计算学习者,解决高维多变量共线性数据在端侧实时建模与预测的难题。压缩包共4个文件(2个C源码 + 2个头文件),总大小仅8KB,其中pls.c封装PLS核心迭代逻辑,matrix.c提供精简矩阵运算支持,配套头文件定义接口与数据结构,整体设计兼顾数值稳定性与内存效率。已有1599人学习下载,适合希望深入理解PLS数学原理、掌握嵌入式机器学习算法移植方法、并获取可直接编译运行的轻量化参考实现的中高级开发者。 最近我在做一个便携式近红外光谱检测项目,硬件部分用的STM32F407,需要在设备端实时估算样品成分含量。理论上这活儿应该在PC上跑Python,但真要交付到现场设备上,就得把回归模型塞进单片机里。我选的是偏最小二乘回归(PLS),这是光谱分析领域最常用的线性建模方法之一,优点是能处理特征多、样本少、变量之间强相关性的数据。把PLS移植到STM32上,核心思路其实很清晰:训练在PC端完成,STM32只负责做预测推理,把模型参数固化成C数组,用纯C实现矩阵运算和预测函数。
这篇内容适合做嵌入式传感器方案、光谱/气体检测设备的人,也适合想熟悉嵌入式端机器学习的人。里面涉及的代码和思路都能直接抄作业,不是那种只能看的原理文。我会从模型选型、算法化简、C代码实现、STM32适配到问题排查,把整个流程完整走一遍。
1. 嵌入式场景下的回归建模与整体设计
1.1 回归模型在MCU上能解决什么问题
先聊一下应用场景。你在MCU上做回归预测,多半是遇到这几类需求:第一是传感器标定,比如MQ135这类气体传感器,读数受温湿度影响很大,直接拿ADC值换算浓度误差能到30%以上,这时候用多元回归拟合补偿模型就很划算;第二是光谱/近红外检测,特征波段有几十上百个,必须用偏最小二乘或主成分回归这类能降维的方法;第三是设备健康预测,比如根据电机电流、振动特征估计剩余寿命,这类问题特征不多但对实时性有要求。
MCU和PC做回归的本质区别,不是“能不能算”的问题,而是“内存够不够、时间来得及来不及”的问题。STM32F4主频168MHz,带FPU,单精度浮点乘加大概几个周期能完成,跑一次几十个特征的线性预测根本不在话下。真正的挑战是算法实现是否简单可靠、模型参数怎么组织、怎么烧进Flash、怎么和现有的采集逻辑对接。这些才是本文要解决的重点。
1.2 为什么选PLS而不是神经网络或其他算法
做嵌入式算法的选型,我一直坚持一个原则:能用线性模型解决的,坚决不引入非线性模型。PLS回归本质上是一个带降维步骤的线性模型,计算量小、参数少、行为可解释,比神经网络更适合资源受限的MCU。神经网络动辄几千上万个权重,光存储就够呛,而且推理时的激活函数、矩阵运算复杂度也高,在单片机上跑只能说“能跑”,谈不上“好用”。
MLR(多元线性回归)倒是更简单,但特征多的时候要满足无多重共线性,实际操作里光谱数据相邻波长的相关性极高,MLR求逆矩阵时经常数值不稳定。PCA回归做一个折中方案是可以的,先降维再做回归,不过它只看X的方差,不管和Y的相关性,降维后的主成分可能和预测目标没什么关系。PLS同时考虑X和Y的方差与协方差,提取的潜变量和Y的相关性最强,所以同样的维度下预测精度通常更好。这也是为什么光谱分析行业里PLS是事实标准。
1.3 工程结构:训练在PC,预测在MCU
初次接触嵌入式机器学习的人容易犯一个错误——想把训练过程一起搬到单片机上。训练需要迭代、需要求逆、需要大量样本,一套十几个特征的PLS训练在PC上毫秒级完成,在STM32上就可能要几秒甚至更久,还会占用大量RAM。除非你的设备有特殊需求(比如板载在线学习),否则不推荐在MCU上做训练。
我的工程结构是两段式的:PC端用Python的scikit-learn或自定义的PLS算法训练模型,导出回归系数、均值、标准差和截距;MCU端用纯C实现预测函数,把模型参数以const数组或struct的方式放在Flash里,上电加载之后直接调用预测。这样PC端可以灵活调整模型、评估精度,MCU端保持代码简单、逻辑可控,两边的优势都发挥出来了。
2. PLS回归原理与C语言实现前的核心认知
2.1 数学本质:抓主要矛盾
PLS的核心思想,用一句话概括:在X矩阵里找出一组潜变量(也叫得分向量),这组潜变量不仅尽量保留X的方差信息,同时还要求与Y的协方差最大化。你可以把它理解成“既要代表X,又要解释Y”的折中策略。和PCA只关心X的方差不同,PLS在迭代过程中每一步都想办法让提取出来的成分与Y对齐,所以预测Y的时候效率更高。
PLS有多种实现算法,最常用的有NIPALS(非线性迭代偏最小二乘法)和SIMPLS。NIPALS历史最悠久、容易理解,适合教学和验证;SIMPLS在单输出回归上计算更稳定、速度更快,主流统计软件里基本都用它。无论哪种算法,最终产出的核心东西是一样的:一组可以用于预测的回归系数B和截距项b0,预测公式就是 y_pred = X · B + b0。
2.2 预测公式的推导与标准化处理
如果训练时用了标准化(z-score,即减去均值除以标准差),那么预测时就要对输入X做同样的变换。标准化的价值在于消除不同特征的量纲差异,否则量级大的特征会在模型里占主导。假设训练时X的均值为mean_i、标准差为std_i,Y的均值为y_mean、标准差为y_std,PLS内部得到的标准化系数为c_i,那么预测步骤是:
先对x_i做标准化:x_std_i = (x_i - mean_i) / std_i 再算标准化Y的预测值:y_std_pred = sum(c_i * x_std_i) + b 最后还原为真实Y:y_pred = y_std_pred * y_std + y_mean
这里有个工程上的小技巧:MCU上每次预测都要做减法、除法,很啰嗦,而且除法在嵌入式上很贵。你完全可以把标准化和系数合并成一个总的线性系数:
a_i = c_i / std_i b_total = b - sum(mean_i * c_i / std_i)
最终预测就变成 y = sum(x_i * a_i) * y_std + y_mean。
如果你不想在MCU里保存y_std和y_mean,还能再进一步:直接把a_i乘上y_std,把b_total也乘上y_std再加y_mean,最终合并成 y_pred = sum(x_i * w_i) + bias。这就在MCU端完全消灭了除法和平方根运算,只剩下乘加累加,预测耗时可以压到微秒级。
2.3 回归系数的离线求取
那这套系数怎么来?我建议在PC端用Python完成训练,然后导出参数。我自己测试过两种方式:一是直接用sklearn的PLSRegression,训练完读取model.coef_和model.intercept_,再配合自己算的mean/std手动合并;二是手写一个简化的SIMPLS算法,完全控制每一处输出。前者省事、适合快速迭代,后者适合你想深入理解算法、或者需要特殊定制的场景。
无论哪种方式,训练完成后你手里会有一套数组:mean[features]、std[features]、coef[features](已经合并标准化之后的系数)、bias(合并后的总截距)。接下来就是把这些数组转成C语言的static const数组,放进头文件或单独的.c文件里。
3. C语言实现:矩阵运算与PLS预测引擎
3.1 嵌入式友好的矩阵数据结构
在MCU上写矩阵运算,我强烈建议不要用动态分配(malloc/free),原因是ST芯片的堆默认不大,长期运行容易产生碎片,而且MCU上出内存泄漏问题非常难排查。我推荐用固定大小的结构体加静态数组,结构体里只保存行列数和数据指针,数据本身放在静态区或全局数组里。
下面是我在项目里使用的矩阵头文件定义:
// matrix.h #ifndef MATRIX_H #define MATRIX_H #include <stdint.h> #define MATRIX_MAX_ROWS 16 #define MATRIX_MAX_COLS 64 typedef struct { uint16_t rows; uint16_t cols; float data[MATRIX_MAX_ROWS * MATRIX_MAX_COLS]; } Matrix; void mat_init(Matrix *m, uint16_t rows, uint16_t cols); void mat_set(Matrix *m, uint16_t r, uint16_t c, float val); float mat_get(const Matrix *m, uint16_t r, uint16_t c); void mat_mul(const Matrix *a, const Matrix *b, Matrix *out); void mat_print(const Matrix *m); #endif这里把最大行列数写死,是为了让编译器分配静态内存时算得准,不会出现越界或分配失败的问题。如果你要用的特征维度超过了64,把MATRIX_MAX_COLS改大就行,代价是多占一些RAM,自己评估一下。
3.2 矩阵乘法的C语言实现
矩阵乘法是PLS预测里最核心的运算之一。虽然预测阶段最终可以化简成乘加,但训练阶段、联合测试阶段或者你要在MCU上做交叉验证的时候,还是离不开通用矩阵乘法。实现时注意三重循环的内层顺序,尽量让内存访问连续,Cortex-M4有FPU但缓存很小,访存模式不好速度差异明显。
// matrix.c #include "matrix.h" void mat_init(Matrix *m, uint16_t rows, uint16_t cols) { m->rows = rows; m->cols = cols; for (uint16_t i = 0; i < rows * cols; i++) { m->data[i] = 0.0f; } } void mat_set(Matrix *m, uint16_t r, uint16_t c, float val) { m->data[r * m->cols + c] = val; } float mat_get(const Matrix *m, uint16_t r, uint16_t c) { return m->data[r * m->cols + c]; } void mat_mul(const Matrix *a, const Matrix *b, Matrix *out) { // 确保out与a、b不是同一个矩阵,否则会有覆盖问题 if (a->cols != b->rows) return; uint16_t M = a->rows; uint16_t N = b->cols; uint16_t K = a->cols; mat_init(out, M, N); for (uint16_t i = 0; i < M; i++) { for (uint16_t j = 0; j < N; j++) { float sum = 0.0f; for (uint16_t k = 0; k < K; k++) { sum += a->data[i * K + k] * b->data[k * N + j]; } out->data[i * N + j] = sum; } } }注意Matrix结构体里自带data数组,所以矩阵变量本身比较大。如果你在函数里临时定义Matrix局部变量,会占不少栈空间,建议矩阵都定义为全局或static变量,函数只操作指针。
3.3 预测函数与模型结构体
预测函数的核心是一个乘加循环。我把模型结构体定义成下面这样,直接映射训练后的参数:
// pls_model.h #ifndef PLS_MODEL_H #define PLS_MODEL_H #include <stdint.h> #define PLS_MAX_FEATURES 32 typedef struct { uint16_t n_features; float coef[PLS_MAX_FEATURES]; // 已合并标准化的最终系数 float bias; // 合并后的最终偏置 } PLS_Model; float pls_predict(const PLS_Model *model, const float *x); #endif// pls_model.c #include "pls_model.h" float pls_predict(const PLS_Model *model, const float *x) { float y = model->bias; for (uint16_t i = 0; i < model->n_features; i++) { y += x[i] * model->coef[i]; } return y; }这个函数简单到让人怀疑是不是少写了什么,但实际上PLS预测阶段就是这么简单:线性加权和。整个算法的复杂度集中在训练阶段,而推理阶段不过是一条乘加指令流。如果特征数是32,在168MHz的F407上跑一次预测大概在几百纳秒到一两微秒之间,完全不用担心性能。
3.4 模型参数如何导入与固化
模型参数的固化方式有两种,视你的开发习惯而定。第一种是直接写在C源码里,用static const数组初始化,适合模型不常变化的场景。第二种是存成二进制文件放外部Flash或SD卡,设备启动时读入RAM,适合希望后期在线升级模型的场景。
直接写在源码里的方式最简单:
// pls_model_data.c #include "pls_model.h" const PLS_Model g_model = { .n_features = 32, .coef = { 0.1524f, -0.0872f, 0.2103f, 0.0731f, 0.0964f, -0.0318f, 0.1802f, -0.0647f, // 剩余系数... }, .bias = 2.3471f };如果你的单片机Flash空间够,建议把模型数组放到单独的.c文件里,这样修改模型参数时只需要重新编译这个文件,不影响其他代码。如果模型较大,还可以用__attribute__((section(".model")))把数组放到特定Flash扇区,方便做OTA更新。实际上,我在做批量标定的时候,经常通过串口下发一组新的系数,直接在RAM里更新g_model结构体内容,然后校验CRC,这样现场调参就不用反复烧录固件了。
4. STM32平台适配与性能优化
4.1 资源评估与编译环境准备
我用的开发环境是STM32CubeIDE加HAL库,编译器是arm-none-eabi-gcc,开启-O2优化。目标芯片是STM32F407VET6,Flash 512KB,RAM 128KB。一个32特征的PLS模型,模型数组占约4×32+4=132字节,运行时的栈开销几十字节,对这样一颗芯片来说简直可以忽略不计。如果你用的是STM32F103这种Cortex-M3,没有FPU,浮点运算全靠软件模拟,速度慢一些,但跑几十个特征的预测同样没问题,只是要注意编译选项不要开过度优化。
CubeIDE新建工程时,确保在Project Settings里启用了浮点打印支持(如果你要printf调试的话),在C/C++ Build的Settings里把优化级别设成-O2。F4系列默认启用硬件FPU,CubeIDE会自动加-mfloat-abi=hard -mfpu=fpv4-sp-d16。如果你是在旧工程或裸机工程里折腾,记得检查这组编译选项。另外,如果你在VS Code或命令行里自己搭环境,链接器脚本里也要确保堆栈大小足够,虽然预测函数用不到多少栈,但其他外设和协议栈可能吃栈很凶。
4.2 内存组织:Flash存模型,RAM存临时数据
STM32的内存结构里,Flash和RAM是分开的。模型参数是只读的,放Flash最合适,不占RAM,加载也快。但如果你的模型参数需要在线更新,就不能放const区,要把参数放到RAM,或者放到外部Flash,启动时拷贝到RAM。这个设计决策要在项目初期就定下来,否则后面改起来很痛苦。
我的设计习惯是:模型参数全部放Flash const区,预测函数只读不写。涉及临时矩阵运算时,在函数内部定义static的临时数组,避免每次调用都重新初始化,顺便也避开了栈溢出风险。比如预测函数本身不申请任何临时空间,直接遍历模型结构体里的coef数组,所以它天然是零内存分配的,这在嵌入式里是一个很好的特性。
有个细节值得注意:如果你用scanf、printf这类标准库函数,HAL库和newlib的默认堆设置可能被改变,导致malloc行为异常。为了避免踩坑,建议在代码里禁用malloc,或者排查链接脚本里的堆大小设置,确认不会在运行时出现内存不足。对于只需要printf输出的场景,我一般直接把堆大小设到0,反正不用动态分配。
4.3 浮点运算优化与编译器选项
Cortex-M4F的FPU是单精度的,这意味着double运算会被拆成多个指令,速度明显变慢。所以我在整个工程里统一用float类型,包括模型参数、中间变量和返回值。有一个常见的坑是:在C语言里写小数常量时,3.14会被默认成double,运算时触发类型转换和精度损失警告,解决办法是使用f后缀,写成3.14f。
编译器优化选项上,我建议用-O2而不是-Os。-Os为了减小代码体积,可能会牺牲部分性能,而-O2在平衡代码体积和性能上更合适。如果你的Flash空间特别紧张,再考虑-Os,同时配合-fomit-frame-pointer这类选项。
如果要在MCU里临时算方差或标准差,直接调用sqrtf等CMSIS-DSP函数。CMSIS-DSP库里还有矩阵运算函数arm_mat_mult_f32、arm_mean_f32等,实测比手写循环要快一些,不过会引入额外的库依赖,适合你对性能和代码体积都有要求的情况。对于简单的乘加预测,我建议不要用CMSIS-DSP的向量乘加函数,因为编译器在-O2下会把简单循环优化得很好,没必要增加调用开销。
4.4 与ADC采集、外设的集成流程
真正让PLS跑起来的场景,一定是模型输入来自传感器采集。以近红外光谱为例,一般是光谱仪通过ADC输出一组光强值,你要把这组值换算成浓度。集成的时候,我建议把采集、预处理、PLS预测、结果输出拆成四个独立模块,每个模块用函数接口隔离,方便单独测试。
下面是一个简化的主程序框架:
// main.c 片段 #include "pls_model.h" #include "adc.h" extern const PLS_Model g_model; // 假设有16个波段的原始ADC值 float raw_features[16]; float final_value; // ADC采集并换算为特征 float read_feature(uint8_t channel); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); while (1) { for (uint8_t i = 0; i < g_model.n_features; i++) { raw_features[i] = read_feature(i); } final_value = pls_predict(&g_model, raw_features); // 输出到串口、OLED或控制外部设备 printf("predict: %.3f\r\n", final_value); HAL_Delay(100); } }整个流程清晰简单,每一层都能独立验证。我一般会先写一个test函数,用一组已知的输入和期望输出直接比数值,确认预测引擎没问题后,再接入真实的传感器数据流。这样定位问题时能清楚知道是采集的锅、预处理的锅,还是模型的锅。
5. 模型验证与常见问题排查
5.1 离线与在线预测一致性验证
移植完代码,第一件事不是接传感器,而是做一致性验证。具体做法是:在PC上用测试集数据跑一遍Python预测,得到一组结果;再把同样的数据数组喂给STM32的pls_predict函数,打印结果,两者逐项对比。如果浮点精度控制在1e-3以内,说明C移植没问题。
我踩到过一个典型的坑:PC端sklearn默认输入是二维数组,Y是一维的;如果你用自定义的训练脚本,容易把mean和std的方向搞反,导致导出的系数对不上。建议导出参数后,先在PC端手动按公式算一遍合并系数,再对比C代码里的预期输出,这个步骤能省掉大量后面联调的时间。
还有一次遇到过很隐蔽的问题:sklearn的PLSRegression返回的coef_形状是(1, n_features),用model.coef_[0]和model.coef_在数组维度上不同,导致我当时对两者求均值时数据错位。这个问题在Python里不报错,但结果全错,非常坑。所以实际导出时最好显式保存coef_[0],并且写一段脚本打印前几个系数,肉眼核对后再嵌到C文件里。
5.2 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 预测结果全为常数 | 模型系数或bias为0,导入时数据错位 | 检查数组元素个数,用单元测试逐值比对 |
| 输出值和PC端差异大 | 特征输入顺序不一致,或没做标准化 | 确认特征顺序,检查PC端标准化是否合并在系数里 |
| 系统卡死或HardFault | 数组越界、栈溢出,或用了未初始化的指针 | 静态分配矩阵,检查内存大小,开启MPU保护测试 |
| 浮点输出精度异常 | double和float混用,或编译选项未开软浮点 | 统一float类型,检查-mfloat-abi设置 |
| 模型更新后不生效 | 编译器把const数组优化掉了或Flash没更新 | 重新编译下载,确认链接脚本里的Flash段地址 |
| printf输出全是0 | 浮点printf未启用 | CubeIDE里勾选Use float with printf,或用整数拆分打印 |
5.3 实测效果与优化心得
以我这个项目的实测数据为例:16个特征(对应16个波长点),训练集样本60个,PLS潜变量数取5,预测一个样本在STM32F407上耗时不到2微秒,RAM占用增加不超过200字节,Flash占用增加不超过1KB。同样的流程放在STM32F103上,因为没有FPU,耗时大约在10微秒左右,但依然可以轻松满足100Hz以上的采集刷新率。
如果觉得预测结果不够准,优先检查的不是代码,而是模型本身。潜变量个数选多少合适,可以通过交叉验证来确定;特征输入是否包含了环境扰动变量(温度、湿度等),也会显著影响精度。模型侧的调整比优化代码带来的收益大得多,这是我在多个项目里验证过的经验。
另外还有一些小细节:如果用printf打印浮点,在CubeIDE里要勾选“使用浮点printf”,否则输出全是0;ADC采集要注意多通道切换后的稳定时间,转换数据要等通道稳定后再读取;如果MCU还有其他中断任务,预测函数要关中断或使用临界区保护,避免数据被采样中断篡改。
最后再分享一个小技巧:把预测函数写成inline形式,或者放到头文件里定义为static inline,能减少一次函数调用开销。虽然这对单次预测影响不大,但如果你的设备一秒钟要预测几千次,累积起来还是比较可观的。我实际测试过,在-O2下inline后预测耗时能再缩个20%左右。这个项目最后跑下来的整体感受是:PLS这类线性模型天生就是为单片机准备的,关键是处理好训练和预测的边界,把模型参数合理固化到Flash,剩下的代码量其实很小,排查起来也很有底气。
本文还有配套的精品资源,点击获取