最近我在折腾室内空气监测的小板子,最初想直接用现成的串口PM2.5模块,后来发现成本压不住——好一点的激光传感器模块动不动几十上百,而手上正好还有一堆STM32F103C8T6最小系统板和一片夏普GP2Y1010AU0F,于是干脆用STM32把这块红外PM2.5传感器接了起来,做一个低成本粉尘浓度检测方案。这个方案最适合三类人:一是要做课程设计或者毕业设计的同学,想要一个能跑起来、能出数据、还能讲清楚原理的环境监测项目;二是搞智能家居DIY,想给新风系统加个空气检测前端的工程师;三就是跟我一样手头有闲置物料、想废物利用的嵌入式玩家。整套实现思路是:用定时器产生脉冲信号驱动传感器内部的红外LED,在指定时刻用ADC采集光电二极管转换后的输出电压,再按传感器的标定曲线换算成mg/m³或者μg/m³浓度值,最后从串口发出去。下面我把选型、接线、驱动时序和调参踩坑整个复盘一遍。
1. 红外PM2.5传感器到底是个什么原理
1.1 灰尘是怎么被"看见"的
讲这个之前先想想天空中的丁达尔效应——光束在浑浊空气里会呈现一条清晰的光路,就是因为微小颗粒把光散射了。红外PM2.5传感器用的就是这个散射原理,只是把"人眼看到光路"这件事换成光电二极管转成电压。GP2Y1010AU0F内部结构其实很简单:一个红外LED,波长大约880nm,一个光电二极管,再加一级信号处理电路。LED朝检测腔发射红外光,当空气里的颗粒物经过检测腔时,部分入射光被颗粒散射,散射光打到光电二极管上,转换成电压信号输出。颗粒越多,散射光越强,输出电压就越高。
需要注意一个很关键的细节:红外传感器是靠"气流中的颗粒被动扩散进入检测腔"来检测的,它没有内部风扇。也就是说传感器周围的空气必须流动,检测腔里面的颗粒才会持续更新。如果你把它放在完全密封的盒子里,读数会慢慢漂移甚至越来越低。但用于室内环境监测,只要有正常的空气流通,基本够用。
1.2 红外传感器和激光传感器怎么选
很多人一上来就问为什么不用激光的。激光传感器走的是另一条路子——光散射加颗粒计数,内置风扇主动抽气,通过测量颗粒反射激光的脉冲数量来统计不同粒径范围的颗粒数。它的优点非常明显:精度高,能区分PM1.0、PM2.5、PM10,数据曲线平滑,直接输出数字量。缺点是贵、功耗高、有风扇噪音,睡眠场景下特别出戏。
红外传感器便宜、功耗低、体积小、电路简单,缺点同样明显:第一,它输出的是模拟电压,不是颗粒数,只能按浓度拟合曲线去换算,误差天然存在;第二,它没法区分颗粒大小,实际测的是"粉尘质量浓度"的粗略估算,不是严格意义上的PM2.5计数浓度;第三,对粒径大于0.8μm的颗粒灵敏度高,对小颗粒的响应差。
选型建议比较直接:做产品原型验证、低成本环境监测、课程设计,或者只需要"大致知道空气好不好"的场景,红外方案足够。如果做空气质量监测仪拿去卖,对精度有要求,老老实实上激光传感器,别在红外方案上死磕。
1.3 传感器关键参数
拿最常见的GP2Y1010AU0F来说,真正需要关心的参数就这几个,我列在下面供参考:
- 无尘环境输出电压:0.9V左右,部分个体在0.6V左右
- 灵敏度:0.5V / (0.1mg/m³),换算一下就是5V/(mg/m³)
- 量程:最大约0.5mg/m³,再高电压会接近饱和,线性变差
- LED脉冲周期要求:10ms,即100Hz
- LED高电平脉宽:0.32ms
- 采样窗口:在LED点亮后0.28ms开始采样,持续0.04ms
这个时序是整个项目的核心,搞错了数据一定乱跳。后面我会专门讲怎么在STM32上把这段时序精确做出来。
2. 硬件接线:别急着焊,先理清每一个引脚
2.1 芯片引脚定义与连接
GP2Y1010AU0F是个六脚插针的传感器模块,引脚定义如下,不同批次丝印可能有差异,拿到实物先对着手册确认,别凭印象接:
- 1脚 VLED:红外LED电源正极,一般串限流电阻后接5V
- 2脚 LED-GND:LED电源地
- 3脚 LED:LED脉冲控制脚,由MCU的IO或PWM驱动
- 4脚 S-GND:信号地
- 5脚 VCC:传感器供电正极,典型5V
- 6脚 AOUT:模拟信号输出
我这次的具体接法是:VLED通过一个150Ω限流电阻接到5V,LED引脚接STM32的某个GPIO或PWM输出脚,VCC接5V,S-GND接GND,AOUT接STM32的ADC输入脚,我用的是PA0。电源部分强烈建议用AMS1117-5.0这类线性稳压芯片单独给传感器供电,因为传感器对电源纹波非常敏感。我实际对比过,用开关电源的5V给传感器供电,AOUT上的噪声明显变大,数据跳得厉害。
2.2 关于电平匹配的一个大坑
GP2Y1010AU0F数据手册里的标定曲线是在5V供电下给出的,AOUT输出范围大约0.9V到3.6V。而STM32F103的ADC参考电压是3.3V,如果直接把AOUT接到PA0,电压一旦超过3.3V,ADC就饱和了,读出来永远是4095。这个问题我在第一次调试时撞了个正着,一开始还以为是传感器坏了。
解决办法有两个:一是传感器改用3.3V供电,输出最高不会超过3.3V,但灵敏度会随供电电压下降,实测大概打七折,无尘输出电压也会掉到0.6V左右;二是保持5V供电,把AOUT做电阻分压后再进ADC,软件里按比例还原。我最后用了方案二,目的是尽量保住传感器手册上的灵敏度指标。具体分压做法:从AOUT串一个10kΩ电阻到PA0,PA0对地再接一个20kΩ电阻。这样分压比是20/(10+20)=2/3,传感器AOUT最高3.6V时,PA0上只有2.4V,远远低于3.3V,安全得很。
注意:两个分压电阻的精度会直接影响最终浓度换算结果,如果手头有精度1%的电阻最好,实在没有就用普通电阻,然后拿万用表量一下实际分压比,把实测值写进代码,比理论值靠谱。
2.3 电磁干扰和走线
这个项目看起来简单,但传感器输出的本质是微弱电流转成的电压信号,而LED驱动脉冲电流又有几十毫安,如果布局走线太随意,AOUT会被串扰得很惨。我现在的做法是把传感器放在距离MCU不超过15cm的位置,AOUT信号线用屏蔽双绞线,地线单独走,绝对不和LED驱动线并在一起。电源线也尽量短,每个电源引脚处放一个100nF去耦电容。这些小细节平时不显眼,但在数据稳定性上差别很大。
3. 软件实现:PWM驱动、ADC采样和浓度换算
3.1 驱动时序详解
GP2Y1010AU0F的数据手册给了非常明确的驱动时序:每10ms给LED发一次高电平脉冲,脉冲宽度0.32ms;在脉冲开始后0.28ms到0.32ms之间完成采样。也就是说,PWM频率是100Hz,占空比3.2%,采样点必须落在LED高电平的末尾段。听起来简单,实际很容易翻车:如果直接让ADC自由运行、随机时刻采样,采到的很多数据点根本不在有效窗口内,结果自然乱七八糟。
我之前看到过一些人的做法,用一个普通GPIO拉高LED,延时280us后读ADC,再拉低。这种办法思路没问题,但要保证延时足够精确,别用那种会被编译器优化掉的空循环。另外,如果用标准PWM输出,计算也有讲究。72MHz主频下,如果PSC设为719,ARR设为999,PWM频率就是72000000/(720×1000)=100Hz,周期10ms;CCR设为32,占空比是32/1000=3.2%,高电平时间正好0.32ms。这个配置刚好匹配传感器要求。
3.2 CubeMX初始化和配置
我这次项目用的是STM32CubeMX生成工程,MCU是STM32F103C8T6。关键配置分三块:ADC、定时器、串口。
ADC这块,PA0配置为ADC1_IN0,单通道。采样时间我选了最长的239.5周期,目的是降低信号源内阻对采样精度的影响。连续转换不启用,我们希望在确定的时刻触发单次转换,而不是让ADC一直跑。
定时器方面,如果走PWM方案,PA6配置为TIM3_CH1,输出100Hz PWM,Pulse设为32。串口USART1配置为115200、8-N-1,用来往外发结果。
CubeMX里生成MDK-ARM工程后,再手动加DWT延时函数,用于精确控制280us的采样窗口。
3.3 采样代码实现
先说一个很容易踩的坑:一开始我用HAL_TIM_PWM_Start和HAL_ADC_PollForConversion来做采样,逻辑上看起来没问题,代码大概是下面这样:
void read_dust_pwm(void) { HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 等待280us,让采样点进入有效窗口 delay_us(280); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t adc_val = HAL_ADC_GetValue(&hadc1); // 等待40us,窗口结束 delay_us(40); HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); }实测发现,HAL库函数内部包含大量寄存器操作和状态判断,执行时间不确定,PWM启动后到ADC采样之间的精确间隔很难保证。后来我换成了更直接的方式:把LED驱动引脚当成普通GPIO,手动拉高,用DWT Cycle Counter做精确延时,这样从拉高到采样的时间完全由自己控制。DWT延时的代码如下:
// 启用DWT计数器 void dwt_delay_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } // 精确延时,单位us,适用于72MHz主频 void delay_us_dwt(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 72; while ((DWT->CYCCNT - start) < ticks); }然后这样读取一次浓度对应的ADC值:
uint16_t read_dust_adc(void) { // 拉高LED,开始脉冲 HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // 等待到采样窗口,手册要求0.28ms delay_us_dwt(280); // 启动单次ADC转换并读取 HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t adc_val = HAL_ADC_GetValue(&hadc1); // 保持到0.32ms后拉低LED delay_us_dwt(40); HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); return adc_val; }这个方案的缺点是采样一次要占用CPU大约0.32ms,对低功耗场景不太友好,但绝大多数学习类和产品原型项目完全够用。想要低功耗,可以在每次采样前临时把LED引脚复用为定时器PWM输出,等窗口结束后立即关掉,MCU再进入休眠。思路是一致的,只是把"软件延时"换成"硬件PWM波形"。
3.4 浓度换算公式
ADC读出来的是0到4095的原始值,得先转成电压,再还原分压比例。假设PA0上读到的ADC值是adc_val:
// PA0处电压 float pa0_voltage = (float)adc_val / 4096.0f * 3.3f; // 还原分压,前面用的是2/3分压 float sensor_voltage = pa0_voltage / 0.6667f;然后按手册标定曲线换算浓度。GP2Y1010AU0F的常见公式有这么几种,我实测下来最贴合的是:
float density_mg = (sensor_voltage - g_volt_no_dust) / 5.0f; float density_ug = density_mg * 1000.0f;其中g_volt_no_dust是无尘环境下的输出电压,典型值0.9V左右。也有资料给过一个拟合公式:浓度(mg/m³) = 0.17 × 输出电压 - 0.1。这个公式在0.1到0.4mg/m³区间表现还行,但低浓度段容易算出负值,我不太推荐。
这里要特别强调一句:红外传感器本身精度有限,与其纠结用哪个公式,不如拿到模块后自己标定一次。做法也不复杂,找一个相对干净的环境记录无尘输出电压V0,再用一支激光传感器做参照物,测几组不同空气环境下两个设备的数据,做两点或三点校准,效果比套用任何厂家公式都稳。
3.5 滤波处理:不要直接拿原始值显示
红外传感器的模拟输出噪声非常明显,尤其在室内有人走动、空气净化器运行或者开窗通风的时候,瞬时电压尖峰很多。如果你直接把每次采样的值换算后扔到屏幕上,会看到一个疯狂跳动的数字,跟心电图似的。滤波处理必须做。
我常用的组合方案是这样的:每100ms采一次,连续采16次取平均,这样大概1.6秒更新一次结果;对平均后的序列再取窗口为3的中值滤波,消掉偶发尖峰;最后做限幅处理,连续两次结果差超过100μg/m³时,用前一次的值代替。平均加中值加限幅,三件套下来数据表现就很稳了。核心代码大概长这样:
#define FILTER_N 16 uint16_t adc_buf[FILTER_N]; uint8_t buf_idx = 0; float filter_and_calc(void) { uint32_t sum = 0; for (uint8_t i = 0; i < FILTER_N; i++) sum += adc_buf[i]; float avg_adc = (float)sum / FILTER_N; float pa0_voltage = avg_adc / 4096.0f * 3.3f; float sensor_voltage = pa0_voltage / 0.6667f; float density_mg = (sensor_voltage - g_volt_no_dust) / 5.0f; if (density_mg < 0.0f) density_mg = 0.0f; return density_mg * 1000.0f; }3.6 串口输出和上位机联动
数据算出来了,得想办法看结果。最简单的办法是重定向printf到USART1,直接在串口助手里看。重定向代码是嵌入式老传统的玩法:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }主循环里直接打印:
printf("PM2.5: %.1f ug/m3, raw: %d, volt: %.2f\r\n", density, adc_raw, sensor_voltage);如果是想接到自己的上位机、家庭网关或者Node-RED这类平台,建议直接输出JSON结构,省得解析麻烦:
printf("{\"pm25\":%.1f,\"adc\":%d}\r\n", density, adc_raw);这里有一个小教训:HAL_UART_Transmit阻塞发送虽然简单,但每次输出都会占住CPU一小段时间。如果后续要同时采集温度、湿度等其他传感器,或者要处理按键、显示刷新,最好改成DMA发送,或者把数据先放进环形缓冲区,让发送在后台完成。
4. 实测数据、常见问题与排查经验
4.1 实测数据:红外传感器的真实表现
我在办公桌环境实测了一组数据,密闭办公室、空调房开窗前后对比:开窗前红外读数大概在30到60μg/m³之间浮动,噪声较大,同一环境下手里一支激光传感器对照读数是35μg/m³左右;开窗通风两分钟后,红外读数升到80到120μg/m³,趋势非常明显;靠近燃着的香烟附近时读数能冲到200μg/m³以上,峰值到过350μg/m³。结论是:当作趋势级监测完全够用,但别指望它和几千块的专业仪器数值完全一致。个体偏差大,数值仅供参考,如果拿它来做超标报警或者比较级的显示,效果是可以接受的。
4.2 常见问题速查表
我把这次调试中遇到和预料过的问题整理成了一张表,方便大家快速对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| ADC读数一直是0 | LED引脚没驱动,或接线错误 | 先写个简单的LED翻转程序验证引脚;再查传感器各脚电压 |
| ADC读数一直是4095 | 传感器输出电压超过3.3V | 加分压电阻;或传感器改用3.3V供电 |
| 数据噪声极大 | 电源纹波大、接线过长 | 改用线性稳压;缩短信号线;加屏蔽 |
| 数据缓慢下降 | 检测腔积灰 | 气吹清理,或用酒精棉签轻轻擦拭光敏窗口 |
| 数据瞬间跳高 | 人对传感器呼吸吹气、采样窗口没对准 | 保持距离;重新确认时序 |
| 数据与激光传感器对不上 | 红外方案固有误差、个体差异 | 两点标定;只做趋势对比 |
| 用3.3V供电后数据明显偏低 | 灵敏度下降 | 重新标定;或者改回5V供电加分压 |
4.3 排查心得:最让我头疼的一个坑
这次项目卡了最久的坑,说出来可能没人信,是延时函数。最开始我用HAL_Delay,但它只支持毫秒级,没法精确控制280us的窗口。后来换了一个常见的循环延时函数,开-O0优化时一切正常,改成-O2优化后数据全乱套,原因是编译器把那个空循环直接优化没了,延时几乎变成零。这个坑在调试中被我找了整整一个晚上,最后用DWT的Cycle Counter才彻底解决。
启用方式很简单,前面代码里已经写了,这里再单独列一下,避免有人翻不到:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;用DWT的计数器做延时,完全不受编译优化级别影响,在Cortex-M3和M4系列上都是稳定性很高的做法。以后但凡遇到微秒级的精确延时需求,我都建议直接用DWT,别再写空循环了。
4.4 关于校准和长期稳定性
最后说校准。红外传感器出厂个体差异非常大,同一型号的两个模块放在同一环境下,读数相差20%以上都是正常的。如果要做正式产品,每台设备出厂都需要独立标定;如果只是自己做着玩,建议定期在干净环境里校准一次无尘电压值。另外,传感器内部的红外LED会随使用时间老化,发光强度会衰减,AOUT电压会整体变低,表现就是读数整体偏低。解决方法也很朴素:每隔几个月重新校准一次。
5. 这套方案还能往哪些方向扩展
5.1 从红外传感器换到激光传感器
如果后续想提高精度,建议直接把模块换成PMS5003这类串口输出的激光传感器。STM32这边不需要再保留PWM加ADC的逻辑,只用把USART接到模块的串口上,解析它的数据包即可,协议通常以0x42、0x4D开头,后面跟数据长度和校验值。代码结构可以完全保留,只是把采集入口从ADC换成了串口解析。
5.2 加显示和物联网
接一块0.96寸OLED显示实时浓度,用I2C接口,代码量不大,几十行就能搞定。想上云的话,可以加一块ESP8266或者ESP32做Wi-Fi透传模块,STM32把数据通过USART发给ESP,ESP上报到MQTT broker。这样一套下来,一个低成本的室内空气质量监测节点就完整了,后续还能扩展甲醛、温湿度等传感器,拼成一个多参数环境监测站。
5.3 低功耗改进
如果要做电池供电的便携设备,可以用定时器定时唤醒,采集一次立即进入Stop模式,采一次数据大约只占0.4ms,剩下时间MCU都在睡觉。传感器本身功耗也很低,配合一颗小锂电池,做成一个随身携带的空气质量提醒小挂件是完全可行的。
6. 一些值得记录的实操经验
6.1 调试模拟传感器,波形比代码更重要
这次做下来,我最大的感触是:代码逻辑其实不难,难的是把硬件时序和采样窗口对齐。强烈建议拿到传感器后先看数据手册,把LED脉冲时序、采样窗口、供电电压都吃透再写代码,否则后面全是返工。调试过程中,示波器是最好的朋友。把AOUT接到示波器上,同步触发LED脉冲信号,能清清楚楚看到输出电压是否在正确的时间点跳变。没有示波器的话,逻辑分析仪抓GPIO电平,或者用ADC连续采样模式观察数据变化规律,也比闷头猜强得多。
6.2 别忽视传感器个体差异
红外传感器不是精密仪器,两个模块放在同一个环境里读数都可能差20%以上,而且会随时间和积灰状态漂移。如果只是做着玩,量级对得上就行;如果要做产品,每台设备出厂前都要标定。我在实际使用中的体会是,红外PM2.5传感器适合做趋势检测和低成本提醒,不适合做严格意义上的计量仪器,认清这个定位,项目推进起来会顺畅很多。