做环境监测、智能家居或者毕业设计时,“STM32连接红外PM2.5传感器”这个需求出现频率极高。很多人拿着GP2Y1010AU0F这类模块接上单片机,却发现读数要么不动、要么乱跳,最后只能对着串口发呆。这个项目说难不难,但坑很典型:一部分人不知道红外传感器需要脉冲驱动,一部分人以为是直接模拟量输入就完事,还有一部分人死在ADC的基准电压和量程换算上。这篇文章就把我从接线到串口输出跑出稳定PM2.5数值的完整过程写出来,适合刚做完STM32入门、想上手第一个正经环境监测小项目的人参考。
1. 项目拆解:这个传感器接进STM32到底要做什么
1.1 红外式PM2.5传感器的工作原理
红外PM2.5传感器,最常见的型号是夏普GP2Y1010AU0F,以及它的改进版GP2Y1014AU0F。这类传感器看起来是一个带小风扇扩散口的黑盒子,内部结构并不复杂:一侧是红外LED,一侧是光电二极管,中间形成一个小的测量腔。空气里的颗粒物飘过测量腔时,会把红外光散射到光电二极管上,散射光越强,输出电压就越高。
所以它测的并不是“质量浓度”,而是一种“散射光强度相关的相对浓度”。颗粒物越多,光线散射越多,输出电压从大约0.5V到3.5V之间摆动。这就是所有工作基础,后面的接线、采样时序、换算公式全围绕这个物理过程展开。
明白了这一点,也就知道为什么它叫“红外式”而不是“激光式”:因为光源是红外LED,成本低、功耗低,但测量精度和重复性都比较有限。它更适合做室内空气质量趋势判断、新风系统联动这种场景,不适合拿去做实验室级别的颗粒物统计。
1.2 为什么拿STM32来做这个采集
说实话,这个项目用Arduino也能做,51单片机也能做,但STM32仍然是最合适的选择之一。原因有三个:
第一,STM32F103C8T6这种“小蓝板”价格便宜,板载USB转串口,调试环境成熟,网上资料多到用不完。对嵌入式新手来说,这几乎是万金油平台。
第二,这个项目要同时用到ADC、UART、定时器,恰好覆盖STM32最基础的三类外设。做完这个项目,你对GPIO输出、ADC采样、串口通信、中断延时的理解会比只看教程深很多。
第三,后续想扩展,比如加OLED屏幕、加WiFi模块、加继电器控制风扇,STM32的资源完全够用,而且CubeMX生成代码后扩展方便。
所以如果你是做课程设计、毕设、或者想给自己弄个简易空气质量监测盒子,STM32 + 红外PM2.5传感器是个非常合适的组合。
1.3 红外传感器和激光传感器,你手里的是哪一种
这个问题很容易被忽略,但非常关键。市面上叫“PM2.5传感器”的东西太多了,便宜的十几块,贵的上百块。它们的接口和精度差异巨大。
| 类型 | 典型型号 | 输出接口 | 价格参考 | 精度 | 典型用途 |
|---|---|---|---|---|---|
| 红外式 | GP2Y1010AU0F / GP2Y1014AU0F | 模拟电压输出 | 10~30元 | 中等,适合趋势判断 | 廉价空气质量盒子、新风联动 |
| 激光式 | PMS5003 / PMS7003 | UART串口输出 | 60~150元 | 较高,接近工业级 | 小米净化器同款、较严谨监测 |
如果你手里的是PMS5003这种带串口输出的激光传感器,那项目思路完全不同,需要做的是串口数据帧解析,并且是用3.3V电源,跟红外式传感器的模拟量读法完全不是一回事。很多人买的时候没注意,买回来发现封装不一样,这时候千万不要硬套代码。
另外一个容易混淆的点是:有些淘宝模块把红外传感器做成“免PWM驱动”的版本,模块内部已经自带振荡电路,只要给电、读模拟量就行;有些则是裸传感器,需要你在外面做脉冲驱动。判断方法很简单:看模块上有没有V-LED引脚,或者看商品描述里写不写“内置驱动电路”。这个区分直接影响硬件接线,下一节细说。
2. 硬件接线与测量原理
2.1 先分清模块和裸传感器,接线完全不同
这一节可能是整篇文章最有价值的一段,因为我看过太多人卡在这里。
GP2Y1010AU0F这颗传感器的原始形态是六脚元件,引脚包括V-LED、LED-GND、LED、S-GND、Vout、VCC。如果你直接拿到的是这种裸传感器,想接STM32,就必须自己做LED脉冲驱动电路,一般要用一个NPN三极管或MOS管去控制LED的供电。裸传感器的接线大约是这样:VCC接5V,S-GND和LED-GND接GND,V-LED通过限流电阻接5V,LED脚接驱动管的控制端,驱动管的基极/栅极由STM32的GPIO输出脉冲信号,Vout接ADC引脚。
如果你买的是成品模块,情况就简单得多。多数模块已经把驱动电路做进去了,甚至有些模块内部自己振荡,你只需要给模块接5V和GND,然后直接把AOUT输出接到STM32的ADC引脚。部分模块的AOUT输出范围可能只有0~3.3V,可以直接接;有些输出最大可能超过3.3V,需要分压。这个我在后面的电平匹配部分专门讲。
所以第一步一定是打开商品页或者数据手册,看模块是否有V-LED引脚、是否内置驱动电路。不要拿到手就瞎接,不然你会看到Vout一直稳定在0.6V左右——那是传感器LED没点亮的状态。
2.2 LED脉冲驱动时序是把测量做对的前提
裸传感器为什么要脉冲驱动?因为红外LED不能一直开着。数据手册上给的是周期10ms、脉冲宽度0.32ms的驱动方式,也就是大约3.2%的占空比。这样既能让LED以足够的电流点亮,又不会因为连续工作导致发热漂移,还能延长LED寿命。
这0.32ms并不是拍脑袋定的。LED点亮之后,光强要经过一段时间才能稳定,光电二极管的输出也要跟着稳定下来。如果一开始就采样,读到的电压偏低、不稳定;如果等太久,LED又关掉了。夏普数据手册建议的采样点大约是LED点亮后280us,也就是在0.32ms脉冲的靠后位置读数。
所以整个测量的标准时序是:点亮LED -> 等280us -> 读ADC -> 关掉LED -> 让总周期凑够10ms。这个时序用示波器看最直观,但代码里用延时函数也能实现。很多教程不强调这一点,直接“上电读ADC”,结果数值一直很低且不动,原因就在这里。
如果你是GitHub或者开源社区看到有人用“PWM波驱动 + ADC采样”的写法,本质也是这个时序,只是改用定时器硬件产生脉冲,不占用CPU延时而已。
2.3 接线表和5V/3.3V电平匹配
我以下面的硬件清单为例:STM32F103C8T6最小系统板、GP2Y1010AU0F成品模块(内置驱动电路)、一个USB转TTL串口模块(或者用板载串口)。
| 传感器模块引脚 | 接到STM32 | 说明 |
|---|---|---|
| VCC | 外部5V正极 | 模块供电,不要接3.3V |
| GND | 系统GND | 和STM32共地 |
| AOUT | PA1 | ADC1通道1 |
| V-LED(如果有) | 不接或者按模块说明接5V | 模块内置驱动则无需外接 |
这里最关键的坑是AOUT的电压范围。GP2Y1010AU0F的输出电压典型范围是0.5V到3.5V左右,而STM32F103的ADC参考电压是VDDA,也就是3.3V。如果你直接把3.5V的信号接到PA1,ADC会读到满量程4095,然后被钳住,数据完全失真。
我建议在AOUT和PA1之间加一个1k串联电阻 + 10k下拉电阻到GND的分压电路。分压比是10/(10+1)≈0.909,这样3.5V会被拉低到3.18V左右,安全落在3.3V以内。软件里再把读数乘回11倍还原真实电压。
如果你确认手头的模块输出不会超过3.3V,也可以直接把AOUT接PA1。但我自己一般是宁可多两个电阻,图个放心。
2.4 供电和地线的细节处理
红外传感器的LED虽然是脉冲点亮,但点亮瞬间的电流尖峰还是不小。如果供电是从STM32板上3.3V引过去的,不仅可能带不动,还会让3.3V电压发生波动,ADC精度直接受影响。
正确的做法是给模块单独供电5V,而且尽量用外部电源或者USB的5V,不要从STM32的3.3V稳压输出取电。我实测下来,在模块的5V引脚旁边并联一个100uF电解电容和一个0.1uF陶瓷电容,能明显减少读数抖动,效果比单纯在软件里使劲滤波来得实在。
地线也要注意。传感器地和STM32地必须可靠共地,如果共地电阻太大,ADC读数的零点和偏移都会漂。像我做的这块板,GND直接在排针附近汇合,尽量缩短走线,别让信号线和脉冲驱动线并排走长距离。
3. 软件实现:从GPIO脉冲到串口输出
软件这部分,我按标准库的写法来说,HAL库的初始化逻辑其实差不多,核心逻辑完全一致。
3.1 用GPIO模拟驱动的核心读时序
最简单的方案是用PA8输出一个脉冲,PA1读ADC。先使能DWT周期计数器,再写一个微秒级延时函数:
void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }初始化时别忘了这两行:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;然后核心读数函数长这样:
uint16_t PM25_ReadOne(void) { uint16_t raw; GPIO_SetBits(GPIOA, GPIO_Pin_8); // 点亮LED驱动脉冲 delay_us(280); // 等信号稳定 ADC_SoftwareStartConvCmd(ADC1, ENABLE); while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET); raw = ADC_GetConversionValue(ADC1); GPIO_ResetBits(GPIOA, GPIO_Pin_8); // 关LED delay_us(9700); // 补足10ms周期 return raw; }这里有几个细节值得说。第一,采样点必须在280us左右,不能太早。第二,ADC的软件触发最好放在LED点亮之后,不要在点亮之前就启动转换。第三,读完之后不用精确补到10.00ms,稍微超过一点点没关系,但不要频繁地短周期开启LED,否则输出会偏移。
如果你用的是内置驱动电路的模块,那PA8这整个脉冲驱动就不需要了,PM25_ReadOne里直接做ADC读取就行,时序由模块自己产生。
3.2 ADC配置:让每一次采样更干净
ADC初始化也不复杂,但有两个参数很影响结果。一个是采样时间,我习惯用ADC_SampleTime_239Cycles5,也就是尽量让采样保持电容多充一会儿,对高阻抗信号友好。另一个是ADC时钟,F103最高可以到14MHz,但实际用72MHz主频除以6得到12MHz比较稳。如果时钟太快,转换值会偏飘。
void ADC1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AN; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); RCC_ADCCLKConfig(RCC_PCLK2_Div6); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }注意,连续转换模式我这里是关掉的,每次读取都用软件触发。当然也可以用DMA配合连续转换,但单路低速采样用软件触发更直观,减少踩坑。
3.3 数字滤波:平均值、中值、滑动平均怎么取舍
红外传感器输出本身就有噪声,哪怕你采样时序完全正确,单次读数也可能上下乱跳。一颗颗粒子飘过测量腔本来就是概率事件,所以数值波动是物理现象,不是程序错了。
最简单的处理是多次采样取平均。比如每1秒输出一次,一秒内做20次采样,20个原始ADC值加起来除以20,然后换算电压。这种做法能让读数平滑很多,但遇到突发尖峰时会被拉高不少。
如果想要更强的抗尖峰能力,可以先取中值再平均。具体做法是每5次读一组,排序后取中间值,再把这些中间值做平均。这个在工业采集里很常见,叫中值平均滤波。实测下来,用“5次中值 + 4组平均”的组合,也就是大约每秒20个点,室内PM2.5的数值变化已经非常平滑,不会出现那种一个点突然飙到200再掉回30的鬼样。
代码上不用写得多高级:
float PM25_GetAveraged(void) { uint32_t sum = 0; uint8_t i; for (i = 0; i < 20; i++) { sum += PM25_ReadOne(); } return (float)sum / 20.0f; }至于要不要用滑动平均,看你的需求。滑动平均能实时跟随变化,适合每秒持续输出数据;如果只是每秒取一组值,上面的普通平均就够了。
3.4 电压换算成PM2.5浓度:一条经验公式
把原始ADC值转成电压很简单:12位ADC满量程4095对应3.3V,所以电压等于raw * 3.3 / 4095。如果你加了1k/10k分压,真实传感器输出电压应该是读到的电压乘1.1倍。
PM2.5浓度和电压的关系,数据手册给出的典型值是:无尘环境下输出约0.6V,灵敏度约0.5V对应每0.1mg/m³。也就是说,电压斜率大约是每增加1V,浓度增加200µg/m³。写成经验公式就是:
float PM25_Calc(uint16_t raw) { float vout = raw / 4095.0f * 3.3f; float vout_real = vout * 1.1f; // 如果没加分压,去掉这一项 float density = (vout_real - 0.60f) * 200.0f; if (density < 0.0f) density = 0.0f; return density; }这里面0.60V的零点偏移是个体差异比较大的参数,每个传感器不完全一样。有条件的话,可以拿到干净的室外环境先测一下自己模块的“底噪电压”,把0.60替换成实测值,公式准确度会好不少。室内的零点偏移可能在0.5~0.9V之间,直接用手册值会带来几十µg/m³的误差。
要特别提醒一句:红外传感器的这个公式只在低浓度范围内还算线性,浓度超过几百µg/m³以后输出电压会明显饱和,测量值只能当趋势看,不能当真数值。
3.5 printf重定向,串口直接看数据
数据算出来了,总要能看。STM32F103的USART1是最常用的调试串口,PA9、PA10分别接TX、RX。初始化波特率115200,然后重定向printf。
如果你用的是Keil,最简单的方式是勾选MicroLIB,然后在工程里写一个fputc函数:
int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }主循环里直接用:
printf("PM2.5: %.1f ug/m3, Vout: %.3f V\r\n", pm25, voltage_sum / sample_count);注意把\n写成\r\n,否则很多串口助手会出现换行不对齐的情况。另外,如果调用printf频繁,会对主程序产生少量阻塞,但对1秒一条的数据来说毫无压力。
4. 从工程搭建到问题排查
4.1 完整落地流程:CubeMX初始化到跑通
如果你习惯用CubeMX,流程是这样:
第一步,选STM32F103C8,配置PB?不,配置PA1为ADC1_IN1,PA8为GPIO输出,PA9/PA10为USART1 TX/RX,时钟树配到72MHz。如果不加分压电路,ADC引脚的输入模式选Analog。
第二步,生成工程后,把上面说的ADC初始化代码核对一遍,重点看采样时间和触发方式。CubeMX默认可能把ADC配置成连续扫描,对单通道单次读取来说没必要,可以直接改成单次转换。
第三步,复制delay_us函数和PM25_ReadOne函数,在主循环里组合滤波、换算、printf。
第四步,上电看串口。如果数值在几十µg/m³上下轻微波动,说明基本通了。然后用手在传感器进气口附近扇一扇,或者拿到灰尘大的地方,数值应该有明显变化。
如果是从零搭标准库工程,核心区别就是要在RCC初始化里打开AFIO和对应外设时钟,初始化顺序建议是:延时和GPIO -> USART -> ADC,最后进入主循环。
4.2 常见问题速查表
做这个项目最常见的故障,我整理成了一张表,按现象排查会快很多。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 输出电压一直0.6V左右且不变 | 红外LED没被脉冲驱动 | 接PA8驱动波形,或确认模块是否内置驱动 |
| 读数上下乱跳幅度很大 | 没有滤波 / 供电纹波大 | 加中值平均滤波,5V并联电容,信号线远离LED线 |
| 数值稳定但始终很低 | 传感器检测窗脏了 | 用棉签蘸无水乙醇轻轻擦拭窗口,不要硬刮 |
| ADC一直读到4095 | 输入电压超过3.3V了 | 加分压电阻,软件乘回分压比 |
| 串口输出乱码 | 波特率不匹配 / 没共地 | 统一115200,确认STM32GND和串口工具GND连通 |
| 程序跑着跑着卡死 | ADC时钟配置过快或无休止等待EOC | 用RCC_PCLK2_Div6,关闭连续转换,加超时保护 |
4.3 调试技巧:先信电压,再信浓度
我的调试习惯是先把裸ADC电压打出来,而不是直接看PM2.5浓度。原因很简单:电压是物理量,也是最直接反映传感器状态的信号。如果电压稳定,浓度公式的锅再大也就那几个系数;如果电压本身乱跳,那先解决采样和供电,别急着调滤波参数。
还有一个非常实用的验证方法:用万用表直接量传感器模块的AOUT引脚,对比串口打印出来的分压前电压。理论值应该非常接近,如果差很多,先查分压电阻是不是拿错了阻值,再查ADC参考电压是不是3.3V。
测试环境也很讲究。想验证传感器有没有响应,可以在室内先测底数,然后拿一根点燃的蚊香放在传感器旁边十几厘米的位置,注意通风和防火。你会看到电压明显上升。移动蚊香后,数值应该逐渐回落到原来的水平。如果连这样都不变,那硬件大概率没通。
5. 后续扩展与项目经验
5.1 加上显示屏、继电器和联网模块
这个项目最自然的扩展方向有三个。
第一个是加OLED显示屏。0.96寸的I2C OLED很便宜,代码也不复杂,可以在屏幕上显示实时PM2.5浓度、电压和空气等级。如果做成桌面小摆件,这个方案很完整。
第二个是加继电器或风扇。房间PM2.5超过设定阈值时自动开启新风风扇或空气净化器,低于阈值再关掉。这个过程只需要一个GPIO控制继电器,在主循环里做阈值判断就行,别用delay函数去做长时间等待,不然串口输出会卡顿。
第三个是联网上报。接一个ESP8266模块,用AT指令把PM2.5数据发到手机或MQTT服务器。这个扩展能把项目做成真正的智能家居节点。如果传感器只有一个、采样频率不高,直接用UART透传就行,没必要上RTOS。
5.2 几点后知后觉
这个项目做完之后,我有几个很深的体会。
第一,买模块之前一定要把“内置驱动”还是“裸传感器”问清楚。我第一次就把裸传感器当模块接,Vout一直不变,排查了半天才发现是驱动问题。看似小细节,实际是最耗时的地方。
第二,红外传感器的零点偏移个体差异比较大。同一个型号,不同的板子实测零点电压可能差出0.2V,对应到浓度公式能差几十µg/m³。所以有条件一定要做一次零点标定,拿自己的模块实测V0,不要死记手册里的0.6V。
第三,采样时序这种东西,看着不起眼,却是整个项目的灵魂。就像红外传感器,你连接线都对了,供电也稳了,就是没把LED脉冲和ADC采样点对齐,数据照样是废的。嵌入式采集项目里,“时序”这两个字永远值得多花点时间。
最后说一个很多人忽略的小细节:传感器检测窗口要保持干净。红外传感器没有风扇除尘机构,窗口蒙灰之后零点和灵敏度都会慢慢漂,我吃过不少亏。定期清洁、定期校准,比写多少行花哨的滤波代码都管用。