1. 从一颗传感器说起:为什么选择红外PM2.5方案
PM2.5监测这两年从专业设备慢慢下沉到了家用和创客圈,我最早接触这个需求是帮朋友做一个室内空气质量看板,要求成本压到百元以内、能实时刷新、数据还得靠谱。翻了一圈方案,激光散射的传感器精度确实好,但价格普遍在几十到上百,而且对气路结构有要求,稍微进点灰数据就飘;红外原理的PM2.5传感器价格能压到十几块,结构简单,驱动也省事,特别适合STM32这类MCU直接对接。
红外PM2.5传感器的核心原理其实不复杂:内部一个红外发射管和一个光电接收管,空气从预留的风道流过,颗粒物把红外光散射到接收管上,散射光强和颗粒物浓度正相关,传感器内部把这个光强信号转成PWM占空比或者模拟电压输出。你拿STM32去读这个输出,再按厂商给的公式换算一下,就能得到浓度值。听起来简单,但实际做的时候坑不少——输出信号抖动、温漂、供电噪声、采样时机不对,都会让数据没法看。
这篇内容我打算把整个链路拆开讲:从传感器选型、STM32的GPIO和定时器配置、PWM捕获或者ADC采样的具体实现,到数据滤波和标定,最后给一份可以直接抄的代码框架。适合正在做STM32环境监测类项目的人,也适合拿这个当毕业设计或者课程设计的同学。不管你是刚点亮LED的新手,还是已经用过标准库做过几个项目的老手,这里面的配置细节和避坑经验应该都能用得上。
提示:红外PM2.5传感器和激光传感器在数据可信度上差距明显,如果你的项目对精度要求高(比如需要区分优良中差等级并对外发布),建议直接上激光方案;红外方案更适合做趋势判断、联动控制(比如超标自动开风扇)这类场景。
2. 硬件选型与电路连接:别让供电毁掉数据
2.1 红外PM2.5传感器的常见型号与输出类型
市面上常见的红外PM2.5传感器,典型代表是GP2Y1010AU0F这一类,还有各种国产替代型号。它们的输出方式主要分两种:一种是模拟电压输出,浓度越高电压越高,典型范围在0.5V到3.5V之间;另一种是PWM输出,浓度对应占空比,比如低浓度时占空比小,高浓度时占空比大。两种方式各有优劣,选哪种直接决定你STM32这边怎么配置。
模拟输出的好处是接线简单,直接进ADC就行,缺点是电压信号容易被电源噪声干扰,尤其是传感器和MCU共用一路电源的时候,电机、继电器一动作,ADC读数就跳。PWM输出的好处是抗干扰能力强,数字信号只有高低电平,噪声容限大,缺点是占用一个定时器通道做输入捕获,而且PWM频率通常不高(几百Hz到几kHz),捕获配置要算清楚预分频和重装载值。
我个人的建议是:如果你板子上有电机、继电器这类大电流负载,优先选PWM输出的传感器;如果是纯监测、供电干净,模拟输出也够用,但ADC那边一定要做滤波。
2.2 供电与滤波电路:一个电容能救活一半的数据
红外传感器内部有红外LED,工作时会有脉冲电流,这个电流会在供电线上产生纹波。如果你直接用STM32板子上的3.3V或者5V给传感器供电,纹波会顺着电源线串到MCU的ADC参考电压上,读数就会周期性抖动。解决办法很简单:在传感器供电引脚旁边并一个100nF陶瓷电容加一个10uF电解电容,陶瓷电容滤高频,电解电容滤低频,两个一起上。
另外,传感器的红外LED驱动脚(有些型号叫LED脚或者IR脚)需要周期性开关,这个开关信号最好由STM32的一个GPIO或者定时器PWM来驱动,而不是直接接电源常亮。常亮会让LED发热、寿命下降,而且厂商的标定曲线通常是在特定驱动占空比下测的,常亮会导致读数偏移。
接线的时候注意几点:传感器的VCC和GND要尽量短、尽量粗,最好单独走线到电源入口,不要和电机共用一段细线;模拟输出脚到STM32的ADC输入脚之间,如果距离超过5厘米,建议加一个RC低通,比如1k电阻加100nF电容,截止频率大概1.6kHz,能滤掉大部分开关噪声。
2.3 STM32最小系统与引脚分配
我一般用STM32F103C8T6这块最小系统板做这类项目,资源够用、价格便宜、资料多。引脚分配大概是这样:传感器模拟输出接PA0(ADC1_IN0),传感器LED驱动接PA1(普通GPIO或者TIM2_CH2做PWM),串口用PA9和PA10做数据输出,方便在电脑上看读数。如果你用的是PWM输出的传感器,那就把输出脚接到PA0对应的定时器通道上,比如TIM2_CH1,配置成输入捕获模式。
这里有个细节:STM32F103的ADC输入阻抗不是无限大,传感器输出阻抗如果比较高(比如几十k欧),直接接ADC会导致采样保持电容充不满,读数偏低。解决办法是在传感器输出和ADC之间加一个电压跟随器,用运放搭一个,或者选输出阻抗低的传感器型号。我实测过某款国产红外传感器,输出阻抗大概10k欧,直接接ADC读数比万用表测的低了0.2V左右,加了个LM358跟随器之后就一致了。
注意:STM32的ADC参考电压默认是VDDA,如果你用USB供电,VDDA大概在3.3V左右,但会有波动。如果对精度要求高,建议用外部基准芯片,比如REF3033,或者至少在软件里做一次VDDA校准。
3. STM32端配置:ADC采样与PWM捕获的实操细节
3.1 ADC采样配置:时钟、通道与采样时间
STM32F103的ADC时钟最高不能超过14MHz,系统时钟72MHz的话,ADC预分频要设成6分频,得到12MHz。这个细节很多人会忽略,ADC时钟超了会导致转换结果不准甚至死机。在标准库里的配置大概是:RCC_ADCCLKConfig(RCC_PCLK2_Div6),然后ADC_InitStructure.ADC_ContinuousConvMode设成DISABLE,用单次转换模式,每次要读的时候手动触发。
采样时间的选择很关键。红外传感器输出阻抗高,采样时间太短的话采样保持电容充不满。STM32F103的ADC采样时间可选1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个ADC时钟周期。我一般选55.5或者71.5,给电容足够的充电时间。实测下来,采样时间从7.5改成71.5,读数稳定性提升很明显,波动从±0.1V降到±0.02V。
通道配置方面,如果你用PA0,那就是ADC_Channel_0,规则组里放一个通道就行。转换完成后读ADC_GetConversionValue,得到0到4095的值,再换算成电压:电压 = 值 * 3.3 / 4096。这个电压再根据传感器手册的公式换算成浓度,比如某型号的公式是浓度 = (电压 - 0.5) * 200,单位是微克每立方米。
3.2 多次采样与软件滤波:让数据能看
单次ADC采样出来的数据基本没法直接用,抖动太大。我通常的做法是连续采16次,去掉最大和最小的各2个,剩下的12个求平均。这个算法在STM32上跑起来很快,16次采样加上排序和平均,72MHz的核大概几十微秒就完事了。如果你用DMA加定时器触发,还可以做成后台自动采样,主循环只管读结果。
除了去极值平均,还可以加一阶低通滤波:新值 = 旧值 * 0.9 + 当前值 * 0.1。这个系数根据你的刷新率调,刷新率10Hz的话,0.9对应的时间常数大概1秒,数据会平滑很多,但响应会变慢。如果要做超标报警,建议用去极值平均加一个滑动窗口,窗口大小取10到20个样本,既能平滑又能及时响应。
有个坑要注意:ADC采样的时候如果CPU在干别的事,比如刷OLED或者跑串口中断,采样结果可能会受干扰。我试过在ADC转换期间关中断,数据会稳一点,但会影响串口接收。折中方案是用DMA搬运ADC结果,CPU不参与转换过程,干扰最小。
3.3 PWM输入捕获:测占空比的正确姿势
如果你用的是PWM输出的传感器,那就需要配置定时器输入捕获。以TIM2_CH1为例,PA0接传感器输出,配置成浮空输入或者上拉输入,定时器设成PWM输入模式。具体步骤是:TIM_ICInitStructure.TIM_Channel设成TIM_Channel_1,TIM_ICPolarity设成TIM_ICPolarity_Rising,TIM_ICSelection设成TIM_ICSelection_DirectTI,然后调用TIM_PWMIConfig,它会自动把通道1和通道2配成上升沿和下降沿捕获。
捕获到之后,TIM_GetCapture1得到周期,TIM_GetCapture2得到高电平时间,占空比 = 高电平时间 / 周期。这里有个细节:TIM_PWMIConfig会把两个通道都配好,你只需要读CCR1和CCR2就行。预分频和重装载值要根据传感器PWM频率来算,比如传感器输出1kHz,定时器时钟72MHz,预分频设成71,得到1MHz计数频率,重装载值设成65535,这样1kHz信号的周期是1000个计数,分辨率足够。
实测中我发现,有些传感器的PWM输出边沿不够陡,尤其是长线传输的时候,上升沿可能几百纳秒,这时候捕获到的周期会有误差。解决办法是在传感器输出和STM32之间加一个施密特触发器,比如74HC14,把边沿整形之后再进定时器。另外,输入捕获的中断优先级不要设太高,否则频繁中断会影响主循环,用DMA或者定时器从模式自动复位的方式可以减少中断次数。
4. 数据换算与标定:从电压到浓度的最后一公里
4.1 厂商公式与实际偏差
传感器手册一般会给一个换算公式,比如浓度 = (Vout - Voffset) * K,其中Voffset是洁净空气下的输出电压,K是灵敏度系数。但实际用的时候你会发现,这个公式在低浓度段偏差很大,因为红外传感器的低浓度分辨率有限,而且Voffset会随温度和湿度漂移。我实测某款传感器,手册给的Voffset是0.5V,但在25度、50%湿度下实测是0.6V,差了0.1V,换算成浓度就是20微克每立方米的误差。
解决办法是做一次现场标定:把传感器放在洁净空气里(比如刚下过雨的室外,或者用HEPA滤网过滤后的空气),读一段时间的数据,取平均作为Voffset。这个值存在STM32的Flash里,每次上电读出来用。如果环境温湿度变化大,还可以加一个温湿度传感器,比如DHT22或者SHT30,做温湿度补偿,补偿系数根据实测数据拟合。
4.2 分段线性化:让低浓度段也能看
红外传感器的输出和浓度关系不是完全线性的,低浓度段斜率小,高浓度段斜率大。如果直接用一条直线拟合,低浓度段误差会很大。我的做法是分三段做线性化:0到50微克每立方米用一段斜率,50到200用一段,200以上用一段。每段的斜率和截距通过实测几个点拟合出来,比如用香烟烟雾做高浓度标定,用洁净空气做零点标定。
具体操作是:把传感器放在密闭箱里,用标准粉尘或者香烟烟雾逐步增加浓度,同时用一台参考仪器读数值,记录传感器输出电压和参考浓度的对应关系。然后把这些点画在Excel里,分段拟合直线,得到每段的斜率和截距。STM32里用if-else判断浓度区间,套用对应的公式。这个方法虽然土,但实测下来比单条直线准很多,低浓度段的误差能从±30微克降到±10微克以内。
4.3 温度漂移与长期稳定性
红外传感器的红外LED发光效率随温度变化,接收管暗电流也随温度变化,所以输出电压会有温漂。我实测过,从20度升到40度,Voffset漂了大概0.15V,换算成浓度就是30微克每立方米。如果项目要在户外或者温度变化大的环境用,温补是必须的。简单做法是:在标定时记录不同温度下的Voffset,拟合一条Voffset随温度变化的曲线,运行时用当前温度查表补偿。
长期稳定性方面,红外传感器的风道容易积灰,积灰之后散射光路改变,读数会慢慢偏高。我一般建议每三个月用气吹清理一次风道,或者加一个防尘网。软件上可以做一个长期零点跟踪:如果连续几个小时读数都在低位徘徊,就认为当前是洁净空气,把Voffset慢慢往当前读数靠,这样能自动补偿一部分积灰和老化。
5. 常见问题与排查技巧实录
5.1 读数一直为零或者满量程
这是最常见的问题,先查供电。用万用表量传感器VCC脚,看是不是在手册要求的范围内,比如5V或者3.3V。如果供电正常,再查输出脚电压,洁净空气下应该在Voffset附近,如果一直是0V或者VCC,可能是传感器坏了或者接线反了。还有一种情况是STM32的ADC引脚配置错了,比如配成了输出模式,或者通道选错了,读出来一直是0。
如果用的是PWM输出传感器,读数一直为零可能是捕获配置有问题。先示波器看传感器输出有没有波形,有波形的话再查定时器配置,重点看预分频和重装载值是不是把频率算错了。我遇到过把预分频设成0,结果定时器时钟72MHz直接计数,1kHz的信号周期只有72个计数,分辨率太低,占空比算出来一直是0或者100%。
5.2 数据跳动大,像随机数
数据跳动大通常有三个原因:电源噪声、采样时机不对、传感器本身输出抖动。先查电源,用示波器看传感器VCC脚,如果有几十毫伏的纹波,加电容。再查采样时机,如果ADC采样和传感器LED驱动同步了,比如LED刚点亮就采样,读数会偏低,因为LED亮度还没稳定。解决办法是让LED驱动和ADC采样错开,比如LED驱动用PWM,在PWM高电平中间时刻触发ADC采样。
传感器本身输出抖动的话,只能靠软件滤波。我一般用去极值平均加一阶低通,效果够用。如果还不行,检查一下传感器是不是离电机或者继电器太近,电磁干扰也会导致读数跳。把传感器挪远一点,或者加个屏蔽罩,能改善不少。
5.3 串口输出乱码或者没数据
串口乱码一般是波特率不对,STM32和电脑端的波特率要一致,常用9600或者115200。如果波特率对了还是乱码,检查晶振频率和系统时钟配置,标准库的SystemInit默认是72MHz,如果你用的是8MHz晶振,倍频系数要设对。没数据的话,先查串口初始化有没有调用,再查发送函数有没有执行,可以在发送前翻转一个LED,看程序有没有跑到发送那一步。
还有一种情况是USB转串口模块的驱动问题,电脑端设备管理器里看有没有识别到COM口。如果用的是STM32的USB虚拟串口,那配置更复杂,需要装VCP驱动,而且USB时钟要设成48MHz,这个在时钟树配置里容易搞错。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读数恒为0 | 供电缺失或ADC通道错 | 万用表量VCC和输出脚 | 检查接线,确认ADC通道 |
| 读数恒为满量程 | 输出脚短路或传感器损坏 | 断开传感器单独量输出 | 更换传感器,检查焊接 |
| 数据跳动大 | 电源噪声或采样时机不对 | 示波器看VCC纹波 | 加滤波电容,错开采样 |
| 串口乱码 | 波特率或时钟配置错 | 核对波特率和晶振 | 统一波特率,检查时钟树 |
| PWM捕获值不对 | 预分频或重装载值错 | 示波器看输入波形 | 重算定时器参数 |
| 低浓度段误差大 | 线性拟合不合适 | 对比参考仪器 | 分段线性化,现场标定 |
实操心得:我踩过最大的坑是ADC采样时间和传感器输出阻抗不匹配,读数一直偏低,换了三个传感器才发现是采样时间太短。后来养成习惯,拿到新传感器先量输出阻抗,再算采样时间,省了很多事。
6. 完整代码框架与工程组织
6.1 工程目录与文件划分
我一般把工程分成几个文件:main.c放主循环和业务逻辑,adc.c和adc.h放ADC初始化和读取函数,sensor.c和sensor.h放传感器换算和滤波,uart.c和uart.h放串口输出。这样分工明确,以后换传感器只需要改sensor.c,换MCU只需要改底层驱动。标准库新建工程的时候,记得把启动文件、库文件、头文件路径都配好,Keil5里在Options for Target的C/C++选项卡里加头文件路径,在Target里选对晶振频率。
如果你用VSCode加PlatformIO或者STM32CubeMX,工程组织会更自动一些,但底层配置还是建议手动过一遍,尤其是时钟树和ADC分频,自动生成的代码有时候默认值不是最优的。
6.2 ADC读取与滤波函数
#define SAMPLE_COUNT 16 u16 adc_read_filtered(u8 ch) { u16 buf[SAMPLE_COUNT]; u8 i, j; u16 temp; u32 sum = 0; for (i = 0; i < SAMPLE_COUNT; i++) { ADC_RegularChannelConfig(ADC1, ch, 1, ADC_SampleTime_71Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); buf[i] = ADC_GetConversionValue(ADC1); } // 冒泡排序,去掉最大最小各2个 for (i = 0; i < SAMPLE_COUNT - 1; i++) { for (j = 0; j < SAMPLE_COUNT - 1 - i; j++) { if (buf[j] > buf[j + 1]) { temp = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = temp; } } } for (i = 2; i < SAMPLE_COUNT - 2; i++) { sum += buf[i]; } return sum / (SAMPLE_COUNT - 4); }这个函数每次调用采16次,排序后去掉两端各2个,剩下12个求平均。实测下来比单次采样稳定一个数量级。如果你嫌排序慢,可以用选择法只找最大最小,但16个数据冒泡也就几百个周期,72MHz下几微秒的事,没必要优化。
6.3 浓度换算与串口输出
float adc_to_voltage(u16 adc_val) { return (float)adc_val * 3.3f / 4096.0f; } float voltage_to_density(float voltage) { float density; if (voltage < 0.6f) { density = 0; } else if (voltage < 1.0f) { density = (voltage - 0.6f) * 125.0f; } else if (voltage < 2.0f) { density = 50.0f + (voltage - 1.0f) * 150.0f; } else { density = 200.0f + (voltage - 2.0f) * 200.0f; } return density; }换算函数里用了分段线性化,0.6V以下认为是0,0.6到1.0V对应0到50微克,1.0到2.0V对应50到200微克,2.0V以上对应200以上。这些分段点和斜率是根据实测拟合的,你换传感器之后要重新标定。串口输出用printf重定向就行,在uart.c里实现fputc,然后主循环里每500毫秒打印一次浓度值。
6.4 主循环与定时器调度
主循环里不要用delay做长时间等待,用定时器中断做调度。比如TIM3配成1毫秒中断,在中断里计数,每500毫秒置一个标志位,主循环检测到标志位就采一次数据、算一次浓度、发一次串口。这样主循环不阻塞,以后加按键、OLED、报警逻辑都方便。TIM3的配置和TIM2类似,预分频72-1,重装载1000-1,得到1kHz中断频率。
如果你用FreeRTOS,那就更简单了,建一个任务专门采数据,vTaskDelay(500)就行。不过对于这种小项目,裸机加定时器调度足够了,上RTOS反而增加复杂度。
7. 项目扩展与实战建议
7.1 加OLED显示和报警联动
数据采出来之后,最直观的展示方式是加一个0.96寸OLED,I2C接口,接PB6和PB7。OLED显示当前浓度、等级(优、良、轻度污染等)、以及一个简单的趋势图。报警联动可以用一个蜂鸣器或者继电器,浓度超过阈值就动作。阈值可以设两档,比如75微克每立方米蜂鸣器间歇响,150微克每立方米继电器断开风扇或者空气净化器。
这里有个细节:OLED刷新和ADC采样如果都在主循环里做,刷新OLED的时候ADC采样可能会受影响。解决办法是把OLED刷新放在定时器中断里,或者用DMA刷OLED,减少CPU占用。我一般用u8g2库,刷一屏大概几毫秒,500毫秒刷一次完全够用。
7.2 数据上传与远程监测
如果需要远程看数据,可以加一个ESP8266或者ESP32模块,通过串口和STM32通信,把数据传到云平台。STM32这边只需要把浓度值格式化成字符串,通过串口发给WiFi模块就行。WiFi模块那边用AT指令连路由器、连MQTT服务器,这些配置可以存在模块的Flash里,上电自动连。
要注意的是,WiFi模块发送数据的时候电流会突然增大,如果和传感器共用电源,传感器读数会跳。解决办法是给WiFi模块单独一路LDO供电,或者在发送数据的时候暂停ADC采样,发完再恢复。我试过用STM32的USB虚拟串口直接连电脑,省掉USB转串口模块,但USB时钟配置麻烦,而且虚拟串口驱动在某些电脑上不稳定,量产项目还是用CH340这类专用芯片省心。
7.3 低功耗与电池供电
如果要做电池供电的便携版本,低功耗是重点。STM32F103的低功耗模式有睡眠、停止、待机三种,停止模式功耗大概几十微安,待机模式几微安。传感器本身功耗也不小,红外LED驱动电流大概几十毫安,不能一直开着。我的做法是每10秒唤醒一次,开LED、采数据、算浓度、发出去,然后关LED、进停止模式。这样平均电流能降到1毫安以下,一节2000mAh的电池能用几个月。
唤醒用RTC闹钟或者外部中断都行,RTC闹钟更准,但配置麻烦一点。停止模式唤醒后时钟会切回HSI,需要重新配置系统时钟,这个在标准库里有现成的函数,调用SystemInit就行。传感器供电可以用一个MOS管控制,不用的时候彻底断电,进一步降低功耗。
7.4 标定与长期维护的实战体会
最后说标定。红外传感器出厂标定是在标准条件下做的,实际使用环境千差万别,不标定的话数据只能看趋势,不能看绝对值。我一般建议至少做一次零点标定和一次跨度标定。零点标定用洁净空气,跨度标定用香烟烟雾或者标准粉尘。如果没有参考仪器,可以用一个已知精度的激光传感器做对比,把两个传感器放在同一个密闭空间里,读一段时间的数据,拟合出红外传感器的修正曲线。
长期维护方面,风道清理是必须的,我一般三个月清一次。软件上可以做零点自动跟踪,但要注意别把真实的高浓度误判成零点漂移。我的做法是:只有当读数连续低于某个阈值(比如10微克)超过1小时,才启动零点跟踪,而且每次只把Voffset往当前读数靠一点点,比如0.01V,慢慢修正。这样既能补偿老化,又不会把真实数据带偏。
这个项目我从最早的单片机读ADC,到后来加滤波、标定、OLED、WiFi,前后迭代了五六版,踩过的坑基本都写在这了。如果你正在做类似的东西,建议先把ADC采样和串口输出跑通,看到稳定数据之后再往上加功能,别一上来就堆一堆模块,出了问题不好定位。传感器这东西,硬件底子打好了,软件稍微滤一滤就能用;硬件没弄好,软件再怎么滤波都是白搭。