1. 项目概述与方案选型
做嵌入式开发这几年,最常被问到的一句话就是“想做个空气质量监测,从哪里下手”。大家的首选几乎都是STM32,而传感器则绕不开PM2.5。用STM32连接红外PM2.5传感器,是一个成本不高、逻辑清晰、又能把GPIO、定时器、ADC、滤波算法全串起来的典型项目。这篇文章就以夏普GP2Y1010AU0F这颗红外粉尘传感器和STM32F103C8T6最小系统板为例,把从硬件接线、驱动原理、PWM脉冲时序、ADC采样、数据滤波到浓度标定的完整流程逐个拆开讲。适合正在做传感器课程设计、单片机入门练习,或者想给家里DIY一台低成本空气质量监测仪的同学参考。
这项目最大的好处在于:它不依赖昂贵的模块,不需要复杂的通信协议,核心就是你用单片机把一个模拟小信号读进来,再换算成人能理解的数字。整个过程走一遍,你对ADC采样时机、定时器PWM、滤波算法和数据标定的理解都会上一个台阶。
1.1 红外PM2.5传感器的工作原理
得先把原理讲透。GP2Y1010AU0F这类红外粉尘传感器,用的是光散射法。传感器内部有一个红外LED和一个光电二极管,两者成一定角度布置。红外LED把一束光打向采样腔,空气里的颗粒物会把光散射到各个方向,其中一部分就落到了光电二极管上。颗粒物越多、越密集,散射光越强,光电二极管输出的电压就越高。
你可以这样理解:黑屋子里拿手电筒往前照,你能清晰看到光柱里灰尘飞舞,就是因为灰尘把光散射到了你的眼睛方向。汽车大灯在大雾天能看到光柱,也是同样的原理。传感器做的事情就是把“你的眼睛”换成“光电二极管”,再把“光强变化”折算成“颗粒物浓度”。
这里特别提醒一点:很多同学一看到“红外传感器”就去搜“38kHz红外发射芯片参数”,这是被红外遥控器带偏了。遥控器那种红外是载波调制通信,38kHz是载波频率。而PM2.5传感器的红外LED是低频脉冲驱动,周期10ms,频率只有100Hz,驱动方式和载波调制完全是两码事。选型的时候别被“红外”两个字搅浑了。
1.2 为什么选GP2Y1010AU0F和STM32F103C8T6
GP2Y1010AU0F几乎是红外粉尘传感器里最经典的一颗。六脚直插,模拟电压输出,灵敏度约0.5V/(0.1mg/m³),输出范围大概在0.6V到3.7V,对应0到0.6mg/m³的粉尘浓度。它便宜,散件二十到四十块钱,资料多,课程设计用再合适不过。
有人问,为什么不直接上激光传感器,比如攀藤PMS5003或者SDS011?激光传感器精度高、响应快,但价格翻一倍,输出是UART数字信号,内部自带风扇和算法,单片机这边基本不涉及信号调理,学到的底层知识反而少。所以我的建议很直接:如果是做产品、要精度,上激光;如果是课程设计、学习原理,红外模拟输出的GP2Y1010AU0F更能帮你把传感器数据链路的每个环节吃透。
控制端选STM32F103C8T6,理由也很朴素:板子十块钱左右,72MHz主频,12位ADC,多个定时器,64KB Flash,20KB RAM,应对这个项目绰绰有余。再加上资料铺天盖地,遇到问题随便一搜就有答案。工具链方面,Keil MDK5配合ST-Link Utility烧录是常规操作,网上关于Keil5兼容C51和STM32的安装教程也很多,这里就不展开细说了。
2. 硬件连接与电路设计
GP2Y1010AU0F一共六个引脚,接线并不复杂,但有几个细节不注意就是一路坑。下面先列出引脚定义和推荐连接方式。
| 引脚号 | 名称 | 功能 | 推荐连接 |
|---|---|---|---|
| 1 | V-LED | LED供电正极 | 串联150Ω电阻接5V,并接220μF电解电容到GND |
| 2 | LED-GND | LED地 | 接GND |
| 3 | LED | LED控制端 | 通过NPN三极管接STM32 GPIO |
| 4 | GND | 传感器地 | 接GND |
| 5 | Vout | 模拟电压输出 | 经分压电阻接STM32 ADC引脚 |
| 6 | Vcc | 传感器电源 | 接5V,并接33μF和0.1μF去耦电容到GND |
典型电路里,V-LED要通过150Ω限流电阻接5V,旁边并一颗220μF的大电解电容,这颗电容的作用是稳住LED脉冲瞬态电流。Vcc则需要并联33μF和0.1μF,去耦高频和低频噪声。LED控制端推荐用NPN三极管驱动,GPIO输出高电平让三极管导通,LED回路接通点亮;GPIO输出低电平则关闭。如果你手头没有三极管,拿GPIO直接灌电流去控LED也能实验,但STM32 GPIO灌电流能力有限,长期运行不够可靠,不推荐作为正式方案。
Vout是模拟输出,不能直接接到3.3V供电的STM32 ADC上。这颗传感器的Vcc是5V,Vout最大能到3.6V左右,超过STM32的3.3V量程。稳妥做法是加一级电阻分压,比如10k和20k串联,分压比1.5,把3.6V降到2.4V,再进ADC。同时ADC引脚对地并联一颗0.1μF电容,能滤掉一部分高频噪声。
2.1 接线速查表
为了方便照抄,我直接把完整接线列出来。STM32F103C8T6最小系统板为例:
| STM32引脚 | 连接对象 |
|---|---|
| PB0 | NPN三极管基极(经1k电阻),控制传感器Pin3 LED |
| PA1 | ADC通道1,接分压中点,读取Vout |
| PA9 (TX) | USB-TTL的RX,串口打印数据 |
| PA10 (RX) | USB-TTL的TX |
| 3.3V | 不接传感器电源,只给MCU供电 |
| 5V | 经150Ω接传感器Pin1,直接接Pin6 |
| GND | 传感器Pin2、Pin4,USB-TTL地线,必须全部共地 |
传感器Vout分压的具体做法:Vout串一颗10k电阻,再接一颗20k电阻到GND,两颗电阻的连接点引入PA1。这样Vout电压在ADC端就是原来的2/3,换算的时候乘回1.5即可。ADC端再并一颗0.1μF电容到GND。
2.2 容易踩坑的电路细节
第一个坑是共地。传感器GND、STM32 GND、USB-TTL的GND必须连在一起。很多同学串口打印出来全是一堆乱码,排查半天发现是TTL模块没跟开发板共地,这种低级错误最让人哭笑不得。
第二个坑是电源走线。LED脉冲点亮时瞬态电流比平均电流大不少,如果传感器和开发板共用一条又细又长的杜邦线供电,电压跌落会在Vout上体现出来,读数忽高忽低。建议传感器供电从稳压输出端单独拉线,尽量短,别跟电机、继电器之类的感性负载共用电源。
第三个坑是Vout直接接ADC。这不是不能用,而是有概率读坏。STM32 ADC引脚虽然有一定耐压能力,但长期超过3.3V会影响采样精度,严重时损伤引脚。分压只多两颗电阻,别省。
第四个坑是安装位置。光电传感器靠近热源时,内部电路温漂会让零点电压移动,哪怕颗粒物浓度没有变化,读数也会慢慢飘。安装时让传感器进风口避开芯片散热区域,探头侧面留出气流通道。另外光学镜头沾灰后读数会明显偏高,定期清洁是必须的。
3. 软件实现:从PWM脉冲到浓度换算
软件部分是这个项目的核心。线路接对了、电源稳了,但如果采样时序不对,读出的数据照样不能用。
3.1 为什么必须用320μs脉冲驱动
打开GP2Y1010AU0F的数据手册,可以看到LED驱动要求:脉冲周期10ms,脉冲宽度0.32ms,也就是320μs。很多人不理解,LED直接常亮不行吗?答案是不行。
红外LED如果常亮,第一发热严重,连续工作一段时间后光电二极管的漂移会大得离谱;第二寿命会缩短;第三功耗也高。所以传感器设计成低占空比脉冲驱动,每次点亮只有320μs,隔10ms再点一次。这样做还有一个额外好处:采样可以同步在LED点亮窗口内进行,避开环境光的干扰。
时序上还有一个关键点:LED点亮之后,散射光信号不会瞬间稳定,需要一小段时间建立。数据手册上的输出电压特性表明,在点亮后约0.28ms左右,输出进入稳定区间。因此采样时机应该放在点亮后的280μs到320μs之间。我一般取300μs,留出余量。
这个机制可以类比成相机的闪光灯:你要拍暗处静物,闪光灯闪一下,在闪光的瞬间按下快门,得到一张受环境光干扰最小的照片。传感器也是这个逻辑,LED闪一下,ADC在闪光的稳定期内采一次。
3.2 STM32定时器PWM配置
产生10ms周期、320μs高电平的脉冲,最直观的做法是定时器PWM输出。我用TIM3通道1举例,STM32F103C8T6主频72MHz。
先算分频:系统时钟72MHz,PSC设为71,得到1MHz的计数频率,也就是每个计数1μs。再算周期:ARR设为9999,则计数周期为10000μs,正好10ms。通道比较值CCR设为320,这样PWM高电平持续320μs。占空比只有3.2%,在示波器上看起来就是一条很窄的脉冲,别以为没输出。
void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 71; // 72MHz / 72 = 1MHz htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 9999; // 10ms htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 320; // 320us sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); }如果你不想让LED一直以100Hz频率闪烁,也可以只在需要采样时开启PWM,采样完关闭。这样更省电,也方便后续做成低功耗版本。
3.3 采样时序与ADC读取
PWM脉冲已经产生,接下来要让ADC在正确的时间窗口里采样。有两种做法。
第一种是高级做法:用PWM的比较事件去同步触发ADC。TIM3通道1的比较输出可以作为ADC的外部触发源,这样PWM翻转的时刻会自动启动一次ADC转换,时间误差极小。不过这种配置对初学者来说有点绕,需要对STM32的ADC触发机制比较熟。
第二种是简单做法:软件控制一个GPIO,自己模拟脉冲时序。在需要采样时,GPIO拉高LED开始点亮,延时300μs,启动ADC,读完数据后拉低LED,再延时9.7ms凑满10ms周期。这种做法足够课程设计用,而且整个过程都在自己的掌控里,遇到问题好排查。我推荐先用这个方案跑通,理解了时序再回去优化。
GPIO模拟采样的核心代码:
uint16_t read_pm25_raw(void) { uint16_t adc_val = 0; HAL_GPIO_WritePin(LED_CTRL_GPIO_Port, LED_CTRL_Pin, GPIO_PIN_SET); delay_us(300); // 等散射光稳定 HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 100); adc_val = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); HAL_GPIO_WritePin(LED_CTRL_GPIO_Port, LED_CTRL_Pin, GPIO_PIN_RESET); delay_us(9700); // 补全10ms周期 return adc_val; }这里有个细节:HAL_ADC_Start和HAL_ADC_Stop每次调用都有寄存器操作开销,但在这个频率下完全不影响性能。如果你追求效率,可以省略Stop,等下一次直接用软件触发启动也行。
3.4 滤波算法与浓度换算公式
拿到ADC原始值后,先换算成真实的传感器输出电压。设ADC读数为raw,STM32的ADC参考电压是3.3V,12位分辨率满量程4095,分压比是1.5,则:
float vout = (float)raw / 4095.0f * 3.3f * 1.5f;接下来算浓度。查数据手册:灵敏度0.5V对应0.1mg/m³,也就是100μg/m³。所以0.5V→100μg/m³,1V→200μg/m³。换算公式:
float density_ugm3 = (vout - v0) * 200.0f; if (density_ugm3 < 0) density_ugm3 = 0;v0是传感器零点电压,也就是在干净空气中的输出值。数据手册典型值给的是0.6V附近,但不同个体差异很大,最好自己实测标定。这个后面专门讲。
浓度值算出来之后别直接用,一次采样波动太大。我用滑动平均滤波:维护一个长度为20的环形缓冲区,每来一个新值就挤掉一个旧值,然后取平均值。这样做的好处是响应够快,平均20次也就200ms,同时能明显压住随机噪声。
滑动平均参考实现:
#define FILTER_SIZE 20 float filter_buf[FILTER_SIZE]; float filter_sum = 0; uint8_t filter_idx = 0; uint8_t filter_filled = 0; float moving_average(float new_value) { if (!filter_filled) { for (int i = 0; i < FILTER_SIZE; i++) filter_buf[i] = new_value; filter_sum = new_value * FILTER_SIZE; filter_filled = 1; return new_value; } filter_sum -= filter_buf[filter_idx]; filter_buf[filter_idx] = new_value; filter_sum += filter_buf[filter_idx]; filter_idx = (filter_idx + 1) % FILTER_SIZE; return filter_sum / FILTER_SIZE; }第一次调用时先把缓冲区填满,避免前面20个值全是0导致输出被压低。这个细节很多人会漏掉。
4. 完整示例工程框架(HAL库)
下面给一套能直接用的工程骨架,基于STM32CubeMX生成的HAL库代码,开发环境是Keil MDK5。
4.1 初始化代码要点
用CubeMX创建工程时要选对芯片:STM32F103C8Tx。时钟配置选择外部8MHz晶振,主频拉到72MHz。需要开启的外设:GPIO、ADC1、USART1、TIM3。ADC1使用PA1,采样时间设置到239.5周期,这样采样保持时间足够长,读数稳定些。USART1设置115200-8-N-1。
初始化顺序没有严格要求,但建议遵循:时钟 → GPIO → UART → ADC → TIM。GPIO层面,PB0配置为推挽输出,初始输出低电平;PA1配置为模拟输入。TIM3先初始化但不需要启动PWM,如果你采用GPIO模拟方案,TIM3可以不用初始化。下面的代码按GPIO模拟方案来写,结构更简单。
void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // LED控制脚 PB0 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // PA1 模拟输入 GPIO_InitStruct.Pin = GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); }ADC初始化用CubeMX生成即可,注意关闭连续转换模式,因为我们需要每次手动触发一次。如果开了连续转换,ADC会不停采样,虽然也能读,但和LED脉冲时序对不上,读数就会乱飘。
4.2 主循环与串口输出
主循环的逻辑很直接:间隔一段时间,采集一次原始值,换算成浓度,滤波,然后通过串口发出去。为了观察传感器本身的稳定性,我在调试阶段会同时打印原始ADC值、电压和浓度三个数据。
串口打印用printf重定向,在工程里加一个fputc函数:
#include <stdio.h> int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }注意在Keil里面要勾选Use MicroLIB,否则printf会占用太多资源。
主循环示例:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); while (1) { uint16_t raw = read_pm25_raw(); float vout = (float)raw / 4095.0f * 3.3f * 1.5f; float concentration = (vout - 0.6f) * 200.0f; if (concentration < 0) concentration = 0; if (concentration > 999) concentration = 999; float filtered = moving_average(concentration); printf("ADC:%4d, Vout:%.2fV, PM2.5:%.1f ug/m3\r\n", raw, vout, filtered); HAL_Delay(500); } }这里每500ms采样打印一次。要说明的是,500ms间隔里会错过很多LED脉冲周期,但由于read_pm25_raw函数内部自己模拟了完整的10ms时序,所以每次调用读到的都是有效数据。如果你想让数据更平滑,可以缩短打印间隔,多采几次再平均。
实际调试时,我习惯用串口助手观察波形。把采样间隔改成100ms,然后复制数据贴到Excel里画折线图,能非常直观地看到传感器响应和滤波效果。这一步做完,对数据的把握会强很多。
5. 常见问题与排查技巧实录
这个项目踩坑点比较集中,我把最常见的现象、原因和解决办法整理成一张表,方便对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| ADC读数一直为0 | LED没点亮、三极管接反、ADC引脚配置错 | 用万用表测LED两端电压,确认PB0电平变化 |
| 输出电压乱跳 | 采样时机不对、电源纹波大、传感器未预热 | 稳定电源、加滤波电容、预热5分钟后再读 |
| 浓度一直显示负值 | 零点电压V0设得偏高 | 在干净空气中实测V0,写入代码 |
| 数值比实际高很多 | 光学镜头沾灰、有水汽烟雾干扰 | 清洁透镜,远离蒸汽源 |
| 串口输出乱码 | 波特率不匹配、TTL没共地 | 检查串口参数,连好GND线 |
| 两根传感器读数差异大 | 个体零点差异、灵敏度差异 | 逐个标定V0和灵敏度系数 |
| 数据稳定但响应慢 | 滑动平均窗口太大 | 减小滤波窗口到5~10次 |
5.1 手把手排障路径
如果你第一次上电发现数据不对,别急着改代码,按下面的顺序来。
先测硬件。万用表切到直流电压档,直接量传感器Vout到GND。在干净空气里,正常应该有零点几伏的输出。如果量出来接近0V,说明LED可能没亮,检查PB0到三极管基极的电路,确认GPIO输出波形正常。也可以写一段测试代码,让PB0固定输出高电平,LED常亮几秒钟,同时看Vout是否上升。能上升就说明传感器通路是好的,问题出在时序或配置。
再测数据链路。串口打印原始ADC值,用手在传感器进风口前面扇一扇,ADC值应该有明显抖动。如果原始值纹丝不动,大概率ADC没配好,或者PA1被复用成别的功能了。如果原始值有变化但换算后不敏感,检查分压比系数是不是写错了。
然后遮光测试。用一个不透光的盖子套住传感器进风口,读数应该很接近零。这一步是排除环境光干扰。如果遮住后数值还是很高,多半是透镜污染或者传感器本身有问题。
最后做烟雾测试。点一根香烟,把烟轻轻吹向进风口,浓度值应该迅速飙升到几百甚至爆表。注意别把烟直接吸进去,传感器内部会积灰。测试完用电吹风冷风档吹一下采样腔。
5.2 实测中容易被忽略的三个小问题
第一,传感器预热。GP2Y1010AU0F刚上电的前几分钟,输出电压会有明显漂移。我的做法是程序启动后先空跑30秒,只让LED脉冲工作但不记录数据,等热稳定了再开始正式采样。如果你不加这个预热逻辑,前几分钟打印出来的数据会呈现一条缓慢下降的曲线,容易误判成传感器故障。
第二,电源纹波。传感器对Vcc上的纹波非常敏感,尤其是LED脉冲瞬间的电流冲击。有一次我图省事,用一个面包板电源给传感器和舵机共用供电,结果舵机一转,PM2.5读数就开始跳。后来把舵机挪走,单独用LDO给传感器供电,问题立刻消失。凡是发现读数和设备启停强相关,先怀疑电源。
第三,线材长度。Vout模拟信号线如果走得太长,又没有屏蔽,很容易耦合干扰。尽量让传感器靠近STM32,杜邦线控制在10cm以内。实在要拉长线,用屏蔽线或者双绞线,ADC引脚对地电容加大到1μF试试。
6. 实测校准与项目扩展
代码跑通、数据能打印出来,这只是第一步。要让数据在真实场景里有参考价值,标定这一步省不了。
6.1 自己动手标定传感器
先理解为什么必须标定。数据手册给的是典型参数,但每个传感器实际出厂零点电压和灵敏度都不一样。你手里这颗可能Vout在干净空气里是0.63V,我手里这颗可能是0.55V。直接用0.6V带入公式,误差可能达到20%。标定的目的就是把自己这颗传感器的两个关键参数测出来。
标定分两步。第一步测零点V0。找一个相对干净的室内环境,最好远离窗户、油烟和灰尘源,把传感器放进去,等五分钟稳定后连续采样20次,取平均值就是V0。实测我手里这颗V0是0.62V,比手册典型值略高。
第二步做单点校准。找一个参考源,最简单的是点一根香烟,让它自然燃烧,把烟引导到传感器进风口附近,等读数稳定后记录电压V1。同时用手头真正靠谱的设备测一下当前环境的参考浓度C_ref。然后算出实际灵敏度系数:
float k = (v1 - v0) / C_ref; // 单位 V/(ug/m3)把代码里的200换成1/k即可。举个例子,如果测得v1=1.58V,参考浓度为60μg/m³,则k = (1.58-0.62)/60 = 0.016V/(μg/m³),换算系数就是1/k = 62.5。这个系数跟手册给的200差了不少,说明这颗传感器个体差异确实明显。标定完成后,浓度计算公式改为:
float density_ugm3 = (vout - 0.62f) * 62.5f;如果你手头没有参考仪器,用国家标准限值做个半定量粗标也行:把香烟烟雾稀释到体感“轻度污染”的程度,对应读数调到75~110μg/m³区间。这不算精确,但至少能让设备读数在一个合理的量级。
6.2 从课程设计到智能家居小端
传感器数据能稳定输出了,后续扩展方向非常多。最基础的是加一块0.96寸I2C OLED屏,实时显示PM2.5浓度,做一个桌面空气质量小摆件。OLED走I2C,SCL和SDA分别接PB6、PB7,用SSD1306驱动库,几十行代码就能显示数字。
想把数据发到手机上看,可以加一个ESP8266或ESP32模块,通过串口和STM32通信,在上位机端跑MQTT协议,数据上报到Home Assistant或者腾讯云、阿里云物联网平台。STM32这边只需要把浓度值打包成固定格式发给WiFi模块,云端解析后就能展示历史曲线。这个方向很适合作为毕业设计的前期基础。
还有一个同学们问得很多的方向是低功耗。GP2Y1010AU0F的LED不采样的时候可以完全关闭。STM32F103C8T6进入STOP模式后电流能降到微安级别。设计成每60秒醒来一次,采样3秒,算个均值,然后继续休眠。一块18650电池撑几个月没问题。低功耗改造的要点是:采样前唤醒,采样完毕关闭传感器电源和MCU外设时钟,进入STOP模式前把所有GPIO配置成低功耗状态。
我个人在实际操作中的体会是:GP2Y1010AU0F这类红外传感器的绝对精度确实拼不过激光传感器,但它最大的价值在于把模拟信号采集的全链路都暴露在你面前。LED脉冲时序、ADC采样窗口、滤波平滑、电压转浓度、标定修正,这一整套流程走下来,你对“传感器数据是怎么来的”理解会变得非常完整。之后再接激光传感器、接数字传感器,本质上就是换一个数据源,处理套路完全一致。这也是我建议新手从红外PM2.5传感器起步的原因——它足够简单,但也足够让你学到真东西。