2026/9/30 13:16:34

【实战】STM32挂4路传感器数据全乱?从同步采到卡尔曼融合的调坑笔记(附HAL完整代码)

【实战】STM32挂4路传感器数据全乱?从同步采到卡尔曼融合的调坑笔记(附HAL完整代码) 四路传感器轮询采出来的数据对不上时间戳温漂还大——这个问题笔者在沧州某危化品仓储项目上结结实实踩过坑。当时用SHT30BMP390MPXV7007DPADXL355四颗传感器挂在STM32F407上传统轮询方式读出来的数据时序能差30多毫秒夏天高温时压力传感器误差跑到±3.5%FS误报一天能跳4次。后来换成了TIM2定时触发DMA搬运卡尔曼滤波融合的架构时序偏差压到0.8ms以内温度融合精度做到±0.15℃误报率直接降了86%。本文把这套方案从硬件分配、固件架构到融合算法完整记录下来全是量产验证过的工程细节。轮询方案为什么在多传感器面前翻车刚做这个项目的时候图省事直接在main函数的while循环里按顺序读四个传感器读完一发到上位机就完事了。跑了三天问题就全出来了。最头疼的是时序对不上。SHT30单次转换15msBMP390要24ms两个挂在同一条I2C上轮询的结果就是两路温度数据实际采样时刻差了快30ms。现场储罐区夏天中午卷帘门一打开温度骤变那几分钟两路温度能差出2℃多算法那边根本没法做关联分析。典型翻车现场某智慧农业大棚项目更早的一个坑BMP280和SHT30同I2C总线轮询早晨开卷帘门那10分钟两路温度数据跑出2.7℃的假梯度被系统误判为传感器故障连续三天凌晨发短信把工程师叫醒。第二个坑是温漂。MPXV7007DP这款差压传感器数据手册写的±2.5%FS精度那是25℃恒温下测的。到了夏天储罐区地表温度50多度零点漂移加灵敏度漂移叠在一起实测误差直奔±3.5%FS去了压力报警阈值设得低了天天误报设高了又起不到监测作用。还有个容易忽略的问题是交叉干扰。振动传感器ADXL355贴在储罐支架上设备启动时的振动通过支架传导到压力传感器会在压力波形上叠出几百Hz的小毛刺不做滤波根本没法用。所以做多传感器采集核心结论就一句话同步是基础补偿是前提融合是目的三步缺一步出来的数据都没法信。硬件资源怎么分配才不打架四颗传感器三种接口I2C、SPI、ADC模拟量挂在一颗STM32F407上。资源分配本身就是个工程题。传感器型号测量量标称精度接口挂在哪条总线SHT30-DIS温度/湿度±0.3℃ / ±2%RHI2CI2C1PB6/PB7地址0x44BMP390气压/温度±0.5hPa / ±0.5℃I2C/SPII2C1 地址0x77MPXV7007DP差压±2.5%FS模拟输出ADC1_IN0PA0ADXL355三轴振动±0.5%非线性SPISPI1PA5/6/7片选PA4说几个分配时的考量省得大家走弯路SHT30和BMP390都挂I2C1是因为两者速率都不高SHT30最快1MHz我们设400kHz留余量而且都是环境类传感器数据量小。共用总线可以省引脚代价是要处理好两者转换时间不同步的问题——这个后面讲固件架构时细说。ADXL355独占SPI1不跟别的挤。振动传感器采样率高我们设100Hz三轴各2字节加上状态寄存器一次读9字节用DMA搬的话效率最高挂在总线上跟其他设备抢资源反而麻烦。MPXV7007DP走ADC1通道0模拟量直接进DMA循环采样。这颗传感器输出0.54.5V对应±7kPa3.3V参考电压的话范围不够覆盖全量程这里有个细节我们实际只用到01.6MPa的储罐正压折算到差压输出范围在2.5V上下3.3V参考刚好够用。如果要测负压范围得换参考电压或者加分压电路。TIM2DMA状态机把采样时刻钉死在1ms以内要做到同步轮询肯定不行。我们的方案是用TIM2做心跳每1ms进一次中断状态机逐步推进传感器的触发和读取ADC那边让DMA自己循环跑不占CPU。第一段代码定时器中断驱动的采集状态机这段代码是整个采集系统的骨架。TIM2配置为1kHz中断1ms一次每10ms触发一轮完整的采集序列。I2C的读写用HAL库的中断模式IT不阻塞中断服务函数。/* sensor_sync.c - 多传感器同步采集状态机STM32 HAL库 */#includestm32f4xx_hal.h#includesensor_sync.h#includestring.h/* 状态机状态定义 */typedefenum{STATE_IDLE0,STATE_TRIG_SHT,// 触发SHT30单次转换STATE_TRIG_BMP,// 触发BMP390强制模式转换STATE_WAIT_CONV,// 等待转换完成占多个tickSTATE_READ_SHT,// 读SHT30数据STATE_READ_BMP,// 读BMP390数据STATE_READ_ADXL,// 读ADXL355振动数据STATE_FUSION,// 执行融合计算STATE_DONE}sensor_state_t;staticsensor_state_tg_stateSTATE_IDLE;staticuint32_tg_tick_ms0;staticuint32_tg_conv_wait0;/* 采集数据缓存 */sensor_data_tg_sensor_data;/* 外部句柄由CubeMX生成的main.c中定义 */externI2C_HandleTypeDef hi2c1;externSPI_HandleTypeDef hspi1;externADC_HandleTypeDef hadc1;externDMA_HandleTypeDef hdma_adc1;/* SHT30 单次高精度测量命令时钟拉伸高重复性 */staticconstuint8_tSHT30_CMD_MEASURE_HIGH[]{0x2C,0x06};/* BMP390 强制模式命令先写CMD寄存器(0x7E)0x11 */staticconstuint8_tBMP390_CMD_FORCED[]{0x7E,0x11};/* ADXL355 读FIFO入口0x11 连续读9字节X/Y/Z各3字节 */#defineADXL355_READ_FIFO0x11#defineADXL355_CS_PORTGPIOA#defineADXL355_CS_PINGPIO_PIN_4/* DMA ADC缓存循环模式16个采样点做平均 */#defineADC_DMA_BUF_SIZE16staticuint16_tg_adc_dma_buf[ADC_DMA_BUF_SIZE];/* 启动ADC DMA循环采样 */voidSensor_ADC_Init(void){// 配置ADC1连续转换DMA循环模式// 注意CubeMX里要把ADC设为连续转换模式DMA设为CircularHAL_ADC_Start_DMA(hadc1,(uint32_t*)g_adc_dma_buf,ADC_DMA_BUF_SIZE);}/* TIM2更新中断回调每1ms调用一次 */voidSensor_TIM_IRQ_Handler(void){g_tick_ms;/* 每10ms启动一轮采集 */if(g_stateSTATE_IDLE(g_tick_ms%100)){g_stateSTATE_TRIG_SHT;}switch(g_state){caseSTATE_TRIG_SHT:// 发SHT30单次测量命令带时钟拉伸高重复性约15msHAL_I2C_Master_Transmit_IT(hi2c1,0x441,(uint8_t*)SHT30_CMD_MEASURE_HIGH,2);g_stateSTATE_TRIG_BMP;break;caseSTATE_TRIG_BMP:// 等上一个I2C传输完成再发BMP390状态机推进由I2C回调触发更好这里简化// 实测中用的是I2C TX完成回调推进状态避免在中断里空等break;caseSTATE_WAIT_CONV:// 等待BMP390转换完成典型24ms留余量设26msif(g_conv_wait0)g_conv_waitg_tick_ms;if(g_tick_ms-g_conv_wait26){g_conv_wait0;g_stateSTATE_READ_SHT;}break;caseSTATE_READ_SHT:{// 读6字节温度MSB/LSB/CRC 湿度MSB/LSB/CRCstaticuint8_tsht_buf[6];HAL_I2C_Master_Receive_IT(hi2c1,0x441,sht_buf,6);// 实际代码中在I2C RX回调里解析并推进状态break;}caseSTATE_READ_BMP:{// 读BMP390数据从0x04开始读6字节pressure 3B temp 3Bstaticuint8_tbmp_reg0x04;staticuint8_tbmp_buf[6];HAL_I2C_Mem_Read_IT(hi2c1,0x771,0x04,1,bmp_buf,6);break;}caseSTATE_READ_ADXL:{// 读ADXL355 FIFO数据9字节X3BY3BZ3Bstaticuint8_tadxl_tx[10]{0};staticuint8_tadxl_rx[10]{0};adxl_tx[0](ADXL355_READ_FIFO1)|0x01;// 读命令HAL_GPIO_WritePin(ADXL355_CS_PORT,ADXL355_CS_PIN,GPIO_PIN_RESET);HAL_SPI_TransmitReceive_IT(hspi1,adxl_tx,adxl_rx,10);// SPI回调里拉高CS解析数据break;}caseSTATE_FUSION:// 取ADC DMA平均值16点滑动平均抗干扰{uint32_tadc_sum0;for(inti0;iADC_DMA_BUF_SIZE;i){adc_sumg_adc_dma_buf[i];}g_sensor_data.adc_rawadc_sum/ADC_DMA_BUF_SIZE;}// 执行卡尔曼融合放在主循环里也行这里只是打标记g_sensor_data.new_data_flag1;g_stateSTATE_DONE;break;caseSTATE_DONE:g_stateSTATE_IDLE;break;default:break;}}/* 获取最新数据主循环调用 */intSensor_GetLatest(sensor_data_t*out){if(g_sensor_data.new_data_flag){g_sensor_data.new_data_flag0;memcpy(out,g_sensor_data,sizeof(sensor_data_t));return0;}return-1;}这段代码里有几个踩过的坑单独说一下不要在中断里做阻塞式I2C读写。一开始图省事用了HAL_I2C_Master_Receive阻塞版结果中断里卡个十几毫秒其他外设的中断全被推迟了。换成_IT结尾的中断版状态机在I2C完成回调里推进TIM2中断本身只占几微秒。ADC用DMA循环模式别手动触发。一开始也是在中断里HAL_ADC_Start然后等转换完麻烦得很。后来改成连续转换DMA循环ADC自己不停采样往内存里写要数据的时候直接读数组最新值就行16点平均顺便把高频噪声滤了。等待转换时间别硬编码。SHT30和BMP390的转换时间跟重复性设置有关高重复性慢低重复性快。代码里最好做成可配置的宏不然换了传感器参数忘了改时间读出来全是旧数据。实测时序精度我们用逻辑分析仪抓了I2C的SCL边沿和SPI的SCK起始沿统计了1000轮采集的四路数据时间戳最大偏差0.8ms平均偏差0.3ms。对于100Hz采样率的工业监测系统来说这个同步精度完全够用。温度补偿先把单传感器校准好再谈融合很多人上来就做融合忽略了最基本的一步——每颗传感器自己的温漂还没补偿呢。带着原始误差去融合等于把误差也一起融合了结果再漂亮也不是真值。以MPXV7007DP为例说说补偿怎么做。这颗传感器零点温漂±0.5%FS/℃灵敏度温漂±1.0%FS/℃在-10℃到60℃全温区飘下来误差不小。我们的补偿思路很直接用BMP390内置的高精度温度±0.5℃当参考温度源做分段线性补偿。BMP390就在同一块PCB上温度跟MPXV7007DP基本一致不用额外加温度传感器。第二段代码压力温度补偿卡尔曼温度融合这段代码包含两个核心算法MPXV7007DP的温度补偿函数以及SHT30BMP390两路温度的卡尔曼滤波融合。/* sensor_calib.c - 温度补偿与卡尔曼融合算法 */#includesensor_calib.h#includemath.h/* MPXV7007DP 温度补偿 * 25℃为基准温度实测标定以下参数 * 注意这些系数需要用恒温槽实测标定别直接抄数据手册的典型值 */#defineVOFF_BASE2.500f// 25℃时零点输出电压V#defineSPAN_BASE4.000f// 25℃时满量程输出跨度V—— 0.5~4.5V#defineK_VOFF0.0015f// 零点温漂系数V/℃实测标定#defineK_SPAN0.0008f// 灵敏度温漂系数1/℃实测标定#definePRESSURE_FS7.0f// 满量程kPa±7kPa 14kPa FS看你怎么定义/* * 压力温度补偿函数 * raw_v: ADC换算后的原始电压V * temp_c: 参考温度℃来自BMP390 * 返回: 补偿后的压力值kPa * * 这段代码解决的问题 * 环境温度偏离25℃时压力传感器零点和灵敏度同时漂移 * 直接用25℃标定的系数换算高温下误差可达±3.5%FS。 * 通过实测的温漂系数做线性补偿全温区误差控制在±1.2%FS以内。 */floatPressure_TempCompensate(floatraw_v,floattemp_c){// 温度偏离基准的差值floatt_deltatemp_c-25.0f;// 补偿零点当前温度下的零点电压 基准零点 温漂floatvoff_at_tempVOFF_BASEK_VOFF*t_delta;// 补偿灵敏度当前温度下的满量程跨度 基准跨度 × (1 温漂系数×温差)floatspan_at_tempSPAN_BASE*(1.0fK_SPAN*t_delta);// 换算成压力值floatpressure((raw_v-voff_at_temp)/span_at_temp)*PRESSURE_FS;returnpressure;}/* 卡尔曼滤波双温度传感器融合 * 状态向量 X [温度, 温度变化率]^T * 观测值有两个SHT30温度响应快但噪声大和 BMP390温度响应慢但稳定 * * 为什么用卡尔曼而不是简单加权平均 * 因为温度是有惯性的——温度变化率不会突变。 * 卡尔曼利用这个物理规律把变化率也作为状态变量一起估计 * 结果比单纯的加权平均更平滑动态响应也更快。 */typedefstruct{floatx[2];// 状态: x[0]温度(℃), x[1]变化率(℃/s)floatp[2][2];// 协方差矩阵floatq[2];// 过程噪声协方差温度噪声、变化率噪声floatr1;// SHT30观测噪声℃^2floatr2;// BMP390观测噪声℃^2floatdt;// 采样周期s}kalman_temp_t;statickalman_temp_tg_kf_temp;/* 卡尔曼初始化 */voidKalmanTemp_Init(floatinit_temp){g_kf_temp.x[0]init_temp;g_kf_temp.x[1]0.0f;// 初始协方差温度不确定±1℃变化率不确定±0.5℃/sg_kf_temp.p[0][0]1.0f;g_kf_temp.p[0][1]0.0f;g_kf_temp.p[1][0]0.0f;g_kf_temp.p[1][1]0.25f;// 过程噪声温度过程噪声0.01℃²变化率噪声0.001(℃/s)²// 这两个值要根据实际系统调——Q越大跟踪越快但噪声越大g_kf_temp.q[0]0.01f;g_kf_temp.q[1]0.001f;// 观测噪声SHT30标称±0.3℃取0.09BMP390±0.5℃取0.25// 实测中可以根据现场情况微调环境气流大就把SHT30的R设大一点g_kf_temp.r10.09f;// SHT30g_kf_temp.r20.25f;// BMP390g_kf_temp.dt0.01f;// 10ms采样周期}/* * 卡尔曼预测 双观测更新 * sht_temp: SHT30测得的温度℃ * bmp_temp: BMP390测得的温度℃ * 返回: 融合后的温度估计值℃ */floatKalmanTemp_Update(floatsht_temp,floatbmp_temp){floatdtg_kf_temp.dt;// ----- 预测步骤 -----// 状态预测X(k|k-1) A * X(k-1|k-1)// A [1, dt; 0, 1] 匀速运动模型floatx0_predg_kf_temp.x[0]g_kf_temp.x[1]*dt;floatx1_predg_kf_temp.x[1];// 协方差预测P(k|k-1) A*P*A^T Qfloatp00_predg_kf_temp.p[0][0]dt*(g_kf_temp.p[1][0]g_kf_temp.p[0][1]dt*g_kf_temp.p[1][1])g_kf_temp.q[0];floatp01_predg_kf_temp.p[0][1]dt*g_kf_temp.p[1][1];floatp10_predg_kf_temp.p[1][0]dt*g_kf_temp.p[1][1];floatp11_predg_kf_temp.p[1][1]g_kf_temp.q[1];// ----- 更新步骤1用SHT30温度更新 -----// 观测矩阵 H [1, 0]floaty1sht_temp-x0_pred;// 残差floats1p00_predg_kf_temp.r1;// 残差协方差floatk0_1p00_pred/s1;// 卡尔曼增益温度floatk1_1p10_pred/s1;// 卡尔曼增益变化率floatx0_upd1x0_predk0_1*y1;floatx1_upd1x1_predk1_1*y1;// 更新协方差Joseph形式更稳定但这里简化floatp00_u1(1-k0_1)*p00_pred;floatp01_u1(1-k0_1)*p01_pred;floatp10_u1p10_pred-k1_1*p00_pred;floatp11_u1p11_pred-k1_1*p01_pred;// ----- 更新步骤2用BMP390温度再更新一次 -----floaty2bmp_temp-x0_upd1;floats2p00_u1g_kf_temp.r2;floatk0_2p00_u1/s2;floatk1_2p10_u1/s2;g_kf_temp.x[0]x0_upd1k0_2*y2;g_kf_temp.x[1]x1_upd1k1_2*y2;g_kf_temp.p[0][0](1-k0_2)*p00_u1;g_kf_temp.p[0][1](1-k0_2)*p01_u1;g_kf_temp.p[1][0]p10_u1-k1_2*p00_u1;g_kf_temp.p[1][1]p11_u1-k1_2*p01_u1;returng_kf_temp.x[0];}/* 获取当前估计的温度变化率可用于故障诊断变化率异常说明传感器可能有问题 */floatKalmanTemp_GetRate(void){returng_kf_temp.x[1];}代码里的几个注意事项温漂系数一定要自己标定。别直接抄数据手册的典型值每批传感器都有差异。我们的做法是拿10颗样品放恒温槽里从-10℃到60℃每5℃记一组数据线性拟合出K_VOFF和K_SPAN取平均值作为量产系数。卡尔曼的Q和R参数得现场调。Q是过程噪声设大了滤波器跟踪快但噪声大设小了平滑好但滞后。我们的经验是先把Q设小让输出尽可能平滑然后看阶跃响应够不够快不够再一点点加Q。双传感器顺序更新就行不用搞复杂的联合观测矩阵。卡尔曼滤波支持串行更新先拿SHT30更新一次再拿BMP390更新一次效果跟同时更新是一样的代码还简单。实测融合效果我们在50℃恒温油槽里跑了1000个采样点对比了几种处理方式的结果处理方式标准差℃最大偏差℃只看SHT30原始值0.180.52只看BMP390原始值0.120.38滑动平均N160.090.28卡尔曼双传感器融合0.050.15卡尔曼融合后的温度估计标准差只有0.05℃比单传感器原始值降低了大约72%。别小看这零点零几度的提升在一些对温度敏感的工业监测场景里这意味着报警阈值可以设得更紧凑不会因为传感器噪声而频繁误报。PCB上的坑噪声控制决定算法上限固件和算法调得再漂亮PCB没布好底层噪声一大什么滤波都白搭。说说我们踩过的几个PCB坑。第一个坑是地没分好。一开始模拟地和数字地直接连在一起ADC采样值的峰峰值噪声有12个LSB12位ADC3.3V参考相当于9.6mV。后来改成单点连接——ADC参考地AGND和系统数字地DGND在电源入口处通过0Ω电阻单点连噪声直接降到3个LSB等效精度提升了2位。第二个坑是传感器电源没做独立滤波。四颗传感器都从3.3V总线上取电ADXL355工作时的电流跳变会在电源线上产生纹波反过来影响模拟传感器。后来每颗传感器的VCC引脚都就近放了10μF钽电容0.1μF陶瓷电容ADXL355还额外串了个10μH的磁珠隔离高频噪声效果立竿见影。第三个坑是I2C和SPI走线没考虑端接。一开始SPI时钟线上振铃很严重10MHz频率下波形边缘都糊了。后来在SCK线上串了个22Ω的端接电阻振铃基本就没了。I2C因为速率不高400kHz问题不大但也要注意上拉电阻的取值根据总线电容选合适的阻值。还有个细节是ADC输入端的RC滤波。MPXV7007DP的输出我们加了1kΩ电阻10nF电容的低通截止频率约16kHz把高频毛刺滤掉。这个RC值不能乱选截止频率太低会把真实的压力变化也滤掉太高又起不到滤波作用得根据被测信号的带宽来定。量产部署数据连续跑30天的结果这套方案在沧州某危化品仓储企业的环境监测终端上跑了30天24小时不间断采集累计数据超过2.5亿条几个关键指标如下采样同步率四路传感器时间戳对齐精度≤1ms满足99.7%的数据关联分析需求温度测量精度全温区-15℃~55℃融合后温度误差≤±0.15℃优于客户要求的±0.5℃压力测量精度温度补偿后储罐压力监测误差≤±0.8kPa量程±7kPa折合±0.14%FS系统功耗STM32F407四传感器整体平均功耗约28mA3.3V太阳能锂电池供电没问题误报率比原来的独立模块方案下降86%安全联锁误触发从日均4.2次降到0.6次数据来源笔者团队在2025年河北沧州某危化品仓储项目中的实测统计12台监测终端连续运行30天数据存储于本地SQLite并通过NB-IoT定期同步到云平台。最后说几句掏心窝子的做多传感器系统这几年有三条经验我觉得比任何具体技术都重要时序就是精度。再贵的传感器采样时刻对不齐融合出来的数据都是摆设。DMA加定时器中断这俩组合拳打出去同步性基本就有保障了。补偿先于融合。别上来就堆算法先把每颗传感器自己的温漂、非线性误差处理好。地基没打牢上面盖再高的楼都是歪的。硬件噪声决定了算法的天花板。PCB上的电源纹波、地弹、串扰这些东西是任何数字滤波都没法完全消除的。Layout的时候多花两天琢磨地怎么分、电源怎么滤比后期写几百行滤波代码管用多了。如果你也在做多传感器采集系统建议把80%的精力放在同步机制和温度补偿这两件事上。前期打好基础后面的调试和标定会顺利很多。