STM32红外PM2.5传感器驱动:定时器输入捕获测占空比与浓度换算

发布时间:2026/9/27 10:53:25
STM32红外PM2.5传感器驱动:定时器输入捕获测占空比与浓度换算
1. 项目缘起与整体设计思路1.1 为什么选红外PM2.5传感器而不是激光款做环境监测类项目PM2.5传感器基本绕不开两个选择红外散射式和激光散射式。激光款精度高、能测到0.3微米颗粒但价格普遍在几十到上百元而且需要风扇或加热电阻持续工作功耗和体积都上去了。红外款用的是红外LED加光电接收管靠颗粒物对红外光的散射强度反推浓度成本能压到十块钱以内体积也小特别适合嵌入式入门项目和批量部署场景。我这次选红外款核心原因是给一个室内空气监测节点做原型。节点本身用STM32F103C8T6最小系统板整机预算控制在五十块以内还要留出温湿度、显示屏和无线模块的位置。激光传感器光一个就超预算了红外款虽然精度差一些但用来判断“空气好不好、要不要开净化器”完全够用。这里要说明一点红外PM2.5传感器的输出是PWM占空比或者模拟电压不是直接的数字浓度值需要STM32做换算这也是这个项目最核心的技术点。1.2 整体方案架构与数据流整个系统的数据流是这样的红外传感器内部的红外LED发出光束空气中的颗粒物把光散射到光电接收管上接收管输出的脉冲信号经过内部电路处理后从输出引脚给出一个PWM波形。颗粒物浓度越高散射光越强PWM的占空比就越大。STM32要做的就是测量这个PWM的高电平时间算出占空比再根据传感器手册给出的对应关系换算成微克每立方米。硬件连接上传感器一般有四根线VCC、GND、输出脚、可能还有一个LED控制脚。VCC接5V因为内部红外LED需要5V驱动但输出信号是3.3V电平可以直接接STM32的GPIO或者定时器输入捕获引脚。我选的是PA0对应TIM2的通道1用输入捕获模式测占空比。为什么不用外部中断加延时因为PWM频率大概在1kHz到10kHz之间用中断测高电平时间会有较大误差而定时器输入捕获是硬件自动记录边沿时刻精度能到微秒级这是最稳妥的做法。1.3 关键设计决策与取舍第一个决策是测占空比还是测频率。有些红外传感器输出的是频率变化的信号浓度越高频率越高。但我手上这款是PWM占空比输出低浓度时占空比约20%高浓度能到80%以上。所以必须用输入捕获测占空比不能测频率。第二个决策是用标准库还是HAL库。网上很多教程用HAL库配置起来图形化方便但HAL库的输入捕获回调机制对初学者不太透明出了问题不好查。我选标准库因为寄存器操作直观中断服务函数里直接读CCR寄存器就行代码量小实时性也好。当然如果你习惯HAL库逻辑是一样的只是API不同。第三个决策是滤波策略。红外传感器输出抖动比较大尤其是空气流动时读数会跳。我用了滑动平均滤波取最近16次采样值做平均既能平滑数据又不会太滞后。这个在后面的实操部分会详细说。2. 硬件连接与传感器原理深挖2.1 红外PM2.5传感器内部结构解析红外PM2.5传感器的核心是一个光学腔体。腔体一端是红外LED另一端是光电三极管两者之间有一个遮光结构正常情况下的红外光不会直接照到接收管上。当空气进入腔体颗粒物经过光路时会把红外光散射到接收管接收管产生光电流。这个光电流非常微弱所以传感器内部还有一个运放电路做放大和整形最终输出一个干净的PWM方波。这里有个关键点传感器输出的PWM占空比和颗粒物浓度不是线性关系而是一条曲线。低浓度区间占空比变化快高浓度区间变化慢。厂家手册一般会给一个表格比如占空比20%对应浓度050%对应100微克每立方米80%对应500微克每立方米。实际使用时需要做分段线性插值不能简单按比例算。我实测下来用三段折线拟合误差能控制在正负15%以内对于红外传感器来说已经不错了。2.2 STM32最小系统板与传感器接线实操接线之前先确认传感器引脚定义。我这款是四针接口丝印分别是VCC、GND、OUT、LED。VCC接5VGND接GNDOUT接PA0LED脚悬空或者接3.3V都可以它是用来控制内部红外LED开关的不接默认常亮。注意VCC一定要接5V我试过接3.3V传感器完全不工作因为红外LED的正向压降就要1.2V左右加上内部电路3.3V驱动不起来。STM32这边PA0配置成浮空输入或者上拉输入都行因为传感器输出是推挽的不需要外部上拉。但如果你用的传感器输出是开漏的就必须加上拉电阻一般4.7k到10k。我建议不管什么情况都在PA0和3.3V之间焊一个10k上拉成本几分钱能避免很多莫名其妙的读数问题。电源部分要特别注意传感器工作时内部LED是脉冲驱动的瞬时电流能到几十毫安如果和STM32共用一路LDO可能会引起电源波动导致STM32复位。我的做法是传感器单独走一路5V从USB直接取电STM32的3.3V由板载LDO提供两者共地但不共电源轨。实测这样最稳连续跑72小时没有复位。2.3 定时器输入捕获模式配置要点TIM2挂在APB1总线上STM32F103的APB1默认36MHz但定时器时钟是72MHz这个在时钟树配置时要注意。我用的配置是预分频器PSC71这样计数器时钟就是1MHz每个计数代表1微秒。自动重装载值ARR设成0xFFFF让计数器自由跑溢出周期约65.5毫秒足够覆盖PWM周期。输入捕获配置成PWM输入模式这是STM32定时器的一个特殊功能一个通道测周期另一个通道测高电平时间。具体来说TIM2通道1配置成上升沿捕获通道2配置成下降沿捕获两者映射到同一个引脚PA0。这样硬件自动把周期值存到CCR1高电平时间存到CCR2中断里直接读就行不需要软件反复切换边沿极性。这个模式很多人不知道用普通输入捕获要写状态机容易出错。中断优先级方面TIM2捕获中断设成中等优先级不要太高因为PM2.5数据变化慢每秒采一次都够没必要抢占其他任务。我设的抢占优先级2子优先级0和串口中断错开就行。3. 代码实现与核心算法拆解3.1 标准库工程搭建与定时器初始化先建一个标准库工程把启动文件、系统文件、外设驱动都加进去。我习惯用Keil5器件包选STM32F10x_StdPeriph_Lib。新建工程后在main.c里先写RCC配置使能GPIOA和TIM2时钟。注意APB1外设要开AFIO时钟吗这里不需要因为没用重映射。定时器初始化代码大概长这样void TIM2_Capture_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_PWMIConfig(TIM2, TIM_ICInitStructure); TIM_SelectInputTrigger(TIM2, TIM_TS_TI1FP1); TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Reset); TIM_ITConfig(TIM2, TIM_IT_CC1, ENABLE); TIM_Cmd(TIM2, ENABLE); }这里TIM_PWMIConfig是关键它会自动把通道2配成相反的极性。TIM_ICFilter设成0x0F滤波强度最大能滤掉传感器输出上的毛刺。TIM_SelectSlaveMode设成复位模式这样每次上升沿到来时计数器自动清零CCR1里就是周期值CCR2里就是高电平时间读出来的数直接就是微秒数省去了减法运算。3.2 中断服务函数与占空比计算中断服务函数里要做的事情很简单读CCR1和CCR2算占空比存到全局变量清中断标志。volatile uint32_t g_period 0; volatile uint32_t g_high 0; volatile float g_duty 0.0f; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { g_period TIM_GetCapture1(TIM2); g_high TIM_GetCapture2(TIM2); if (g_period 0) { g_duty (float)g_high / (float)g_period; } TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); } }注意g_period和g_high要声明成volatile因为它们在中断里被修改主循环里读取编译器优化时可能会把它们缓存到寄存器导致读不到新值。这个坑我踩过调试了半天才发现是编译器优化的问题。占空比算出来是0到1之间的小数。实测传感器在洁净空气中占空比约0.18到0.22对应浓度0到10微克每立方米。随着浓度升高占空比逐渐增大到500微克每立方米时约0.78。这个关系不是线性的需要做曲线拟合。3.3 浓度换算算法与滑动平均滤波浓度换算我用的是分段线性插值。根据手册和实测数据我定了几个关键点占空比浓度微克每立方米0.2000.35500.501000.652500.78500代码里写一个查表函数找到占空比落在哪个区间然后线性插值。比如占空比0.42落在0.35和0.50之间浓度就是50加上(0.42-0.35)/(0.50-0.35)乘以50约73微克每立方米。float duty_to_concentration(float duty) { const float duty_table[] {0.20f, 0.35f, 0.50f, 0.65f, 0.78f}; const float conc_table[] {0.0f, 50.0f, 100.0f, 250.0f, 500.0f}; uint8_t i; if (duty duty_table[0]) return 0.0f; if (duty duty_table[4]) return 500.0f; for (i 0; i 4; i) { if (duty duty_table[i] duty duty_table[i1]) { float ratio (duty - duty_table[i]) / (duty_table[i1] - duty_table[i]); return conc_table[i] ratio * (conc_table[i1] - conc_table[i]); } } return 0.0f; }滑动平均滤波用一个长度为16的环形缓冲区每次新数据进来就替换最旧的数据然后求平均。这样既能平滑抖动又不会引入太大延迟。16次采样在1秒内完成平均后每秒更新一次浓度值对于空气质量监测来说响应速度足够了。#define FILTER_LEN 16 float filter_buf[FILTER_LEN] {0}; uint8_t filter_idx 0; float filter_update(float new_val) { float sum 0; filter_buf[filter_idx] new_val; filter_idx (filter_idx 1) % FILTER_LEN; for (uint8_t i 0; i FILTER_LEN; i) { sum filter_buf[i]; } return sum / FILTER_LEN; }这里有个细节缓冲区初始全是0前16次采样的平均值会偏低。我的做法是上电后先连续采样16次填满缓冲区再开始正常输出。或者你也可以在初始化时把缓冲区全部填成第一次采样的值这样启动就稳定。4. 调试过程与常见问题排查4.1 串口打印调试与数据观察调试阶段我强烈建议把数据通过串口打印出来看。STM32F103的USART1接PA9和PA10配置成115200波特率用printf重定向。注意要勾选Keil的Use MicroLIB否则printf会卡死。重定向代码int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); return ch; }打印格式我习惯用printf(duty%.3f, conc%.1f\r\n, g_duty, concentration);这样在串口助手里能直接看到占空比和浓度的变化。实测在室内正常环境下占空比稳定在0.21左右浓度显示0到5之间跳动。点一根烟靠近传感器占空比几秒内升到0.6以上浓度显示300多响应非常明显。4.2 常见问题速查表现象可能原因排查方法解决方法占空比始终为0传感器未供电或接线错误万用表测VCC是否为5V检查接线确保VCC接5V占空比始终为1输出脚悬空或短路测OUT脚电压检查PA0连接加上拉电阻读数跳动大电源纹波或气流干扰示波器看电源纹波传感器单独供电加滤波电容浓度值偏高传感器未预热观察上电后读数变化预热3到5分钟再读数据中断不触发定时器未使能或优先级配置错误检查TIM_Cmd和NVIC配置使能定时器和中断检查优先级编译报错undefined标准库文件未添加检查工程文件列表添加stm32f10x_tim.c等驱动文件4.3 独家避坑经验分享第一个坑传感器预热。红外PM2.5传感器上电后需要预热内部红外LED要稳定工作温度。我一开始上电就读数发现浓度显示80多以为空气差结果等了五分钟降到10以下。所以正式使用前一定要预热我一般等3分钟。第二个坑气流影响。传感器进气口如果对着风扇或者空调出风口读数会剧烈波动。我的做法是在传感器外面套一个小的防风罩用海绵或者无纺布做一个缓冲腔让空气缓慢扩散进去。实测加罩后读数稳定性提升明显波动从正负30降到正负5以内。第三个坑定时器溢出。虽然ARR设了0xFFFF溢出周期65毫秒但PWM周期一般只有1到2毫秒不会溢出。但如果传感器故障输出低电平计数器会一直累加到溢出CCR1读到的是溢出后的值算出来的占空比就乱了。我在中断里加了一个判断如果g_period大于10000即10毫秒就认为信号异常丢弃这次数据。这个保护很有必要。第四个坑Keil编译优化。前面提到过volatile的问题这里再强调一次所有在中断和主循环之间共享的变量必须加volatile。我有个朋友没加调试了一整天最后发现是编译器把变量优化到寄存器了。另外如果开了最高优化等级有些延时循环会被优化掉建议调试阶段用Level 0或Level 1。5. 功能扩展与项目延伸方向5.1 加OLED显示屏做本地显示有了浓度数据最自然的扩展就是加一个0.96寸OLED用I2C接口接PB6和PB7。显示内容可以包括当前浓度、平均值、最大值还可以画一个简单的柱状图。OLED驱动用SSD1306网上库很多移植过来改一下I2C引脚就行。注意OLED刷新不要放在中断里主循环里每500毫秒刷一次就够了否则I2C通信会阻塞中断。5.2 通过无线模块上传数据如果要组网监测可以加一个无线模块比如常见的2.4G或者433M模块通过串口和STM32通信。STM32把浓度数据打包成固定格式的帧比如0xAA 0x55 浓度高字节 浓度低字节 校验和然后发给无线模块。接收端再用另一个STM32或者USB转串口模块接收上位机用Python或者串口助手解析。这里要注意无线模块的供电有些模块发射瞬间电流能到100毫安最好单独供电或者加一个大电容。5.3 做成带报警功能的空气质量监测仪加一个有源蜂鸣器和两个LED浓度超过75微克每立方米时黄灯亮超过150时红灯亮加蜂鸣器间歇报警。报警阈值可以通过按键调整用两个按键分别做加和减长按进入设置模式。这个功能适合放在卧室或者办公室提醒及时通风。代码上就是加一个状态机主循环里轮询按键根据浓度值控制GPIO输出。5.4 低功耗优化思路如果要做电池供电的版本低功耗是必须考虑的。STM32F103本身功耗不算低但可以通过降低主频、关闭外设、使用停止模式来省电。传感器也可以间歇工作比如每10秒采样一次采样时给传感器供电采完就断电。这样平均电流能降到毫安级用一节18650电池能跑好几天。不过要注意传感器每次上电都需要预热间歇工作会导致读数不准所以这个方案适合对精度要求不高的场景。6. 个人实操体会与建议这个项目我从画板子到调通代码大概花了两个晚上硬件焊接半小时代码调试一个半小时。最大的感受是红外PM2.5传感器虽然便宜但要想读准确实要花点心思。手册上的曲线只是参考实际每个传感器都有差异最好自己用标准仪器标定一下。我没有专业仪器就用了一个已知精度的激光传感器做对比在几个浓度点上记录占空比重新拟合了曲线精度提升了不少。另外STM32的定时器输入捕获功能非常强大PWM输入模式更是测占空比的利器。很多人用外部中断加定时器延时来测代码复杂还容易出错。建议直接上PWM输入模式配置一次就稳定工作。如果以后要测频率或者编码器信号也是同样的思路用定时器的硬件捕获功能不要用软件轮询。最后说一个调试小技巧如果手头没有示波器可以用STM32自己的ADC去采传感器输出脚虽然PWM信号用ADC采不准但能看到大概的波形和频率判断传感器有没有输出。或者用另一个定时器做频率计也能辅助判断。总之嵌入式调试就是多动手、多观察、多记录数据不会骗人。