
1. 从一颗红外粉尘传感器说起为什么选它、它能干什么红外PM2.5传感器在DIY空气质量监测圈子里算是老面孔了常见型号比如GP2Y1010AU0F、PM1006这类价格便宜、接口简单、资料多特别适合拿STM32做入门级环境监测项目。它的原理不复杂内部一个红外发光二极管和一个光电接收管空气里的粉尘颗粒经过光路时会把红外光散射到接收管上颗粒越多、浓度越高接收管输出的脉冲信号幅度就越大。STM32要做的就是把这个模拟电压或者PWM占空比读进来换算成我们看得懂的微克每立方米数值。很多人第一次接触这类传感器会有一个误区以为它跟温湿度传感器一样接上就能读出准确数字。实际上红外粉尘传感器的输出跟温湿度、供电电压、甚至传感器摆放角度都有关系出厂标定曲线也只是个参考。所以这个项目的核心价值不在于“接上线能读数”而在于如何把原始信号稳定地采集下来、如何做滤波和标定、如何让数据在串口或者屏幕上看起来靠谱。适合的读者是已经点亮过STM32的LED、跑通过串口打印想找一个完整小项目练手的人也适合做毕业设计需要空气质量检测模块的同学。我前后用STM32F103和STM32F407都做过这个方案踩过的坑从“读数一直为零”到“数值乱跳像心电图”都有。下面把整个链路拆开讲包括硬件连接、ADC配置、定时器配合、软件滤波、标定方法以及那些文档里不会写的实操细节。2. 硬件链路设计传感器、STM32与供电之间的那些事2.1 红外PM2.5传感器的引脚定义与工作时序以GP2Y1010AU0F为例它一共6个引脚但真正跟STM32打交道的就三根V-LEDLED驱动、LED-GND、Vo模拟输出。V-LED不是直接接电源就完事它需要脉冲驱动——典型做法是用一个NPN三极管或者MOS管由STM32的一个GPIO控制每隔10ms给一个约0.32ms宽的高电平脉冲让内部红外LED间歇发光。为什么要脉冲而不是常亮因为常亮会让LED发热、寿命骤降而且接收管会持续受到环境光干扰脉冲方式能让接收端在LED熄灭时采集一次“暗电平”用来做差分抵消环境光。Vo输出的是一个模拟电压范围大概在0.1V到3.5V之间不同批次有差异STM32的ADC参考电压如果是3.3V直接接进去没问题。但要注意Vo的输出阻抗比较高直接接ADC引脚在采样时间较短时会出现读数偏低的情况。我一般会在Vo和ADC引脚之间加一个电压跟随器比如用LM358搭一个或者至少把ADC的采样时间设到最长的239.5个周期。如果你用的是STM32F103ADC时钟不要超过14MHz否则采样精度会明显下降。2.2 供电与地线处理为什么你的读数一直在漂红外粉尘传感器对供电纹波很敏感。我试过用STM32最小系统板上的AMS1117-3.3直接给传感器供电结果读数每隔几秒就跳一次后来用示波器一看3.3V轨上有大约80mV的纹波主要来自STM32本身和USB供电的噪声。解决办法有两个一是给传感器单独用一个LDO供电比如AMS1117-5.0再降到3.3V中间加π型滤波二是在传感器的VCC和GND之间并一个100μF的电解电容加一个0.1μF的陶瓷电容靠近传感器引脚放置。地线的处理同样关键。很多人把传感器的GND和STM32的GND随便连在一起就完事但如果你的系统里还有电机、继电器或者WiFi模块地线上的噪声会直接串到模拟输出上。我的做法是模拟地和数字地单点连接传感器的GND先接到一个独立的模拟地铜皮再通过一个0欧姆电阻或者磁珠连到STM32的主地。这个细节在洞洞板上可能不明显但一旦你画PCB不做地平面分割的话读数波动会大到让你怀疑人生。2.3 驱动电路的三极管选型与基极电阻计算V-LED的驱动三极管我一般用S8050或者2N3904NPN型集电极接传感器V-LED发射极接地基极通过一个电阻接STM32的GPIO。基极电阻怎么算假设GPIO输出3.3V三极管Vbe约0.7V想要基极电流Ib在1mA左右那么Rb (3.3 - 0.7) / 0.001 2.6kΩ取标准值2.7kΩ或者3.3kΩ都可以。集电极电流Ic由传感器内部LED决定GP2Y1010AU0F的LED正向电流典型值约10mAS8050的hFE在100左右Ib1mA足够让它饱和导通。这里有个容易忽略的点三极管的开关速度。如果GPIO输出的脉冲上升沿太慢LED的实际发光时间会比你设定的宽导致接收管积分时间变化读数偏高。实测下来S8050在Ib1mA时开关时间在微秒级完全够用。但如果你用光耦或者达林顿管开关延迟可能到几十微秒那就需要重新调整脉冲宽度。我建议在GPIO和基极电阻之间再并一个10kΩ的下拉电阻防止STM32复位时GPIO浮空导致三极管误导通。3. STM32端的信号采集ADC、定时器与中断的配合3.1 ADC通道配置与采样时间的选择STM32F103的ADC是12位逐次逼近型最大转换速率1MHz但实际可用速率取决于采样时间设置。对于红外粉尘传感器这种高输出阻抗的信号源采样时间必须设长。我通常用ADC_SampleTime_239Cycles5这样在ADC时钟14MHz时一次转换时间大约是(239.5 12.5) / 14MHz ≈ 18μs。这个时间足够让采样电容充到接近输入电压。配置的时候用规则通道单次转换模式不要用连续转换因为我们需要在LED脉冲的特定时刻采样。具体来说时序是这样的GPIO输出高电平驱动LED延时约0.28ms让LED稳定发光然后启动ADC转换读取Vo值再延时约0.02msGPIO拉低LED熄灭。整个周期10ms对应100Hz的采样率。这个0.28ms的延时用STM32的TIM定时器或者简单的循环延时都可以但循环延时会受编译器优化影响建议用TIM做微秒级延时。注意ADC转换完成之后一定要把数据读走否则下一次转换会覆盖。用DMA的话可以自动搬运但单通道单次转换用中断或者轮询就够了没必要上DMA增加复杂度。3.2 用定时器触发ADC实现精确时序如果你想让时序更精确可以用TIM2或者TIM3的更新事件来触发ADC转换。具体做法是TIM配置成PWM模式或者输出比较模式周期10ms其中一个通道输出脉冲驱动LED另一个通道在脉冲中间触发ADC。这样CPU几乎不用干预时序由硬件保证抖动在纳秒级。我试过用TIM3的CH1输出PWM驱动LEDCH2的输出比较中断里启动ADC实测时序非常稳。但要注意STM32F103的ADC外部触发源里TIM3的TRGO是可以选的需要把TIM3的触发输出配置成更新事件或者比较事件。这个配置在标准库里的函数是TIM_SelectOutputTrigger在HAL库里是HAL_TIM_ConfigTrigger之类的具体看你的库版本。3.3 暗电平采集与差分计算前面提到LED熄灭时要采集一次暗电平这个值代表了环境光在接收管上产生的背景电压。实际计算粉尘浓度时用亮电平减去暗电平得到的就是纯粹由粉尘散射贡献的电压差。这个差分步骤非常关键尤其是在室内有日光灯或者阳光直射的情况下不做差分的话读数会偏高几十甚至上百微克。具体操作在10ms周期的前0.3ms驱动LED并采样亮电平V_bright然后在周期的后0.3msLED熄灭后采样暗电平V_dark。两个值都存下来最终的有效信号V_signal V_bright - V_dark。如果V_signal小于某个阈值比如0.05V说明空气中粉尘极少可以直接输出0。这个阈值需要根据你的传感器实测确定不同批次的暗电平不一样。4. 从原始电压到PM2.5数值标定与滤波的实战方法4.1 出厂曲线只是起点自己标定的必要性GP2Y1010AU0F的数据手册给了一条典型曲线电压差0.6V对应约100μg/m³但这条曲线是在特定条件下测的比如25°C、50%RH、标准粉尘。实际使用中香烟烟雾、炒菜油烟、灰尘的散射特性都不一样同样的电压差对应的质量浓度可能差两三倍。所以如果你只是做个定性检测有没有烟、空气好不好出厂曲线够用但如果要输出具体数值最好自己标定。标定方法我试过两种。第一种是用一个已知浓度的环境比如密闭箱里点一支烟用专业粉尘仪读一个参考值同时记录STM32的电压差做线性拟合。第二种更简单用不同数量的香烟烟雾做梯度记录电压差和对应的参考读数拟合一条y kx b的直线。我实测下来k值大概在0.5到0.8之间单位是μg/m³/mVb值接近0。这个k值跟传感器个体差异有关每颗传感器最好单独标。4.2 滑动平均与中值滤波的组合使用原始ADC读数一定会有噪声直接输出的话数值会跳得厉害。我一般用两级滤波第一级是中值滤波取最近5次采样值排序后取中间值用来剔除突发的尖峰干扰比如有人走过带起的灰尘第二级是滑动平均取最近10次中值滤波后的结果做平均让输出平滑。这两级滤波的计算量很小在STM32F103上跑起来毫无压力。具体实现定义一个长度为5的数组存原始ADC值每次新数据进来就移位存入然后冒泡排序取中间值。再定义一个长度为10的数组存中值滤波后的结果同样移位求平均。最终输出的PM2.5值 (平均电压差 - 零点电压) * k。零点电压是传感器在洁净空气中的电压差一般在0.05V到0.1V之间上电时可以先采集几十次求平均作为零点。提示滤波窗口不要设太大否则响应会变慢。我试过用20点滑动平均结果点一支烟要等五六秒数值才上来体验很差。5点中值加10点平均是个比较平衡的选择。4.3 温湿度补偿的简化处理严格来说红外粉尘传感器的输出受湿度影响很大湿度高的时候水汽会散射红外光导致读数偏高。如果你手头有DHT11或者SHT30这类温湿度传感器可以做一个简单的补偿当湿度超过70%时把PM2.5读数乘以一个小于1的系数比如0.8。这个系数不是理论推导出来的是我在实际对比中试出来的经验值。没有温湿度传感器的话至少在代码里留一个补偿接口以后想加随时能加。温度的影响相对小一些但在冬天室外或者空调出风口附近温度变化会导致LED发光效率变化进而影响电压差。我的做法是在上电时做一次零点标定之后每隔一小时重新标定一次零点假设那一小时里空气最干净的时刻作为零点。这个逻辑用定时器实现每小时的第0分钟采集100次电压差取最小值作为新的零点。5. 代码落地从工程搭建到串口输出的完整流程5.1 标准库工程的目录结构与关键文件我用的是Keil MDK加STM32标准库工程目录一般这样组织User文件夹放main.c和中断服务函数Hardware文件夹放adc.c、tim.c、usart.c、gpio.c这些外设驱动Library文件夹放标准库的inc和src。新建工程的时候记得把启动文件startup_stm32f10x_md.s加到工程里否则中断向量表不对程序跑不起来。时钟配置是第一个坑。STM32F103默认用的是内部8MHz RC振荡器要跑到72MHz必须配置PLL。标准库里的SystemInit函数已经帮你配好了但如果你用的是自己写的启动代码一定要确认HSE起振了。我遇到过晶振不起振的情况原因是负载电容选错了8MHz晶振配20pF电容有时候起不来换成10pF就稳了。用示波器看OSC_OUT引脚如果有正弦波说明起振了如果是一条直线就是没起。5.2 ADC与定时器的初始化代码要点ADC初始化的关键参数模式选独立模式扫描模式关闭连续转换关闭外部触发选软件触发或者定时器触发数据对齐选右对齐。通道配置里采样时间选239.5周期。校准步骤不能省先使能ADC延时几个毫秒然后执行校准校准完成后才能开始转换。我见过有人跳过校准结果读数偏差几十个LSB。定时器初始化TIM3配置成向上计数模式预分频器设成72-1自动重装载值设成10000-1这样更新频率是72MHz / 72 / 10000 100Hz正好10ms周期。CH1配置成PWM模式2脉冲宽度设成28对应0.28msCH2配置成输出比较模式比较值设成50对应0.5ms时刻触发ADC。这些数值需要根据你的实际时钟调整。// ADC初始化关键片段标准库 ADC_InitTypeDef ADC_InitStructure; ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T3_CC2; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_ExternalTrigConvCmd(ADC1, ENABLE);5.3 串口输出与数据格式设计串口我用USART1波特率1152008位数据位1位停止位无校验。输出格式我习惯用CSV风格方便直接复制到Excel里画曲线PM2.5: 35 ug/m3, Voltage: 0.42 V, Dark: 0.08 V。每秒钟输出一次不要每次采样都输出否则串口助手会刷屏刷到卡死。如果你用的是STM32F103C8T6这种小容量芯片注意printf函数会占用不少Flash。可以用microlib或者自己写一个简单的整数转字符串函数。我一般用sprintf到一个缓冲区然后用HAL_UART_Transmit或者标准库的USART_SendData逐字节发送。发送的时候记得等TC标志置位再发下一个字节否则会丢数据。6. 实测中遇到的典型问题与排查思路6.1 读数始终为零或满量程这个问题我遇到过三次原因各不相同。第一次是V-LED的驱动三极管基极电阻焊错了焊了一个100kΩ的三极管根本没导通LED不亮接收管自然没信号。第二次是ADC通道选错了代码里写的是ADC_Channel_0实际接的是PA1也就是Channel_1。第三次最隐蔽传感器的Vo引脚虚焊用万用表量的时候有电压但ADC采样时电流被拉低读数接近零。所以排查顺序应该是先确认LED是否发光用手机摄像头对着传感器看红外LED在摄像头里会显示为紫色光点再确认ADC通道和引脚对应关系最后补焊或者换传感器。6.2 数值周期性跳动如果读数以10ms或者100ms为周期规律跳动大概率是LED驱动脉冲和ADC采样时刻没对齐。比如LED还没完全点亮就开始采样或者LED已经熄灭了才采样。用示波器同时看GPIO脉冲和ADC的转换启动信号确认采样点落在脉冲的平坦区域中间。我一般把采样点设在脉冲开始后0.25ms处脉冲总宽0.3ms这样前后都有余量。6.3 长时间运行后数值漂移连续跑几个小时之后PM2.5读数慢慢往上飘重启又恢复正常。这是LED发热导致发光效率下降接收管收到的散射光减少但暗电平没变所以电压差变小按照公式算出来的浓度反而偏高因为k值是固定的。解决办法是降低LED驱动电流或者缩短脉冲宽度减少发热。我试过把脉冲宽度从0.3ms降到0.1ms漂移明显改善但信噪比会下降需要重新标定k值。另一个办法是定期重新标定零点用前面说的每小时一次的策略。6.4 串口数据乱码或丢失串口乱码一般是波特率不对或者时钟配置错误。STM32F103的USART1挂在APB2上时钟是72MHzUSART2和3挂在APB1上时钟是36MHz。如果你用USART2但按72MHz算波特率出来的就是乱码。丢失数据通常是发送太快前一个字节还没发完就写下一个解决办法是发送前检查USART_FLAG_TXE或者用中断发送。7. 这个方案还能怎么扩展如果你已经跑通了基本的采集和输出下一步可以往几个方向走。一是加一个OLED屏幕用I2C接口的SSD1306把PM2.5数值和电压差实时显示出来脱离电脑也能看。二是加一个SD卡模块把数据按时间戳存成CSV文件方便做长期趋势分析。三是接一个ESP8266或者ESP32做WiFi上传把数据推到本地服务器或者手机App上。四是加蜂鸣器或者LED做报警超过阈值就提醒。我自己最常用的扩展是加一个0.96寸OLED用u8g2库或者自己写一个简单的驱动显示当前PM2.5、温度和湿度。这样整个设备就是一个独立的空气质量检测仪插上充电宝就能工作。代码上只需要在原来的主循环里加一个显示刷新函数每200ms更新一次不影响采集时序。最后分享一个我在实际项目中总结的小经验传感器的摆放位置比标定更重要。不要把它放在角落或者靠近墙壁的地方空气不流通会导致读数偏低。也不要放在空调出风口或者风扇正对面风速太大会让粉尘沉降不下来。最好的位置是离地1米到1.5米、周围30厘米内没有遮挡物的地方。这个细节听起来简单但我见过太多人把传感器装在盒子里然后抱怨读数不准打开盒子就好了。