简介:面向嵌入式初学者的STM32智能空气净化器完整工程方案,源自大二嵌入式开发进阶期末大作业。内容涵盖传感器数据采集、控制算法、电路设计、无线远程监控等关键环节,适合正在学习C语言与STM32开发的在校生作为课程设计或综合实践参考。压缩包共1063个文件,包含574个C源码、258个头文件、51个汇编文件以及大量工程配置文件,并集成标准外设库、CMSIS DSP库与Keil/IAR工程框架,便于直接编译和二次开发;另有目标文件、链接脚本、日志等辅助理解编译与调试流程。资源包大小约28.51MB,已有126人学习下载。整套资料结构清晰、模块覆盖完整,既可在原工程基础上快速还原项目,也能借鉴其传感器驱动、电机控制与UI交互的代码思路,为后续嵌入式系统开发提供扎实的参考。
1. 智能空气净化器的控制系统:STM32 课程设计的“传感器+执行器+状态机”的进阶题
大二学期的嵌入式进阶大作业选空气净化器,很多人一开始以为是做“硬件组装”,等真正拆完需求才发现,它其实是把模电采样、定时器 PWM、串口协议、状态机设计全部串在一起的综合题。这篇文章讨论的是一套基于 STM32 单片机的智能空气净化器控制系统,覆盖从传感器选型到 PWM 风机调速再到自动档位决策的完整落地路径。这套系统对正在准备基于 STM32 的毕业设计、毕设方向要选嵌入式开发的同学,或者刚开始规划嵌入式开发学习路线的人来说,是一个性价比极高的进阶练习——因为它不要求画 PCB,不涉及复杂算法,却能让 STM32 的多数外设真正转起来。下文按照数据采集、执行驱动、软件控制、联调标定四个阶段展开,侧重每个环节里课程设计中最常栽跟头的地方。
2. 传感器数据采集:粉尘、温湿度与气体传感器的选型和 STM32 读取
2.1 三种检测模块怎么选:GP2Y1010、PMS5003 与 MQ-135
智能空气净化器的“智能”首先体现在感知层。课程设计里最常见的三种传感器是:夏普 GP2Y1010AU0F 红外粉尘传感器、攀藤 PMS5003 激光颗粒物传感器和 MQ-135 半导体气体传感器。三者输出方式和精度差异很大,选型直接决定后续代码的写法。
| 模块型号 | 输出方式 | 量程与精度 | 成本 | 适合场景 |
|---|---|---|---|---|
| GP2Y1010AU0F | 模拟电压,0~3.6V | 粉尘浓度,精度有限 | 很低 | 低成本课设、模拟信号链练习 |
| PMS5003 | UART 数字帧 | 0~1000μg/m³,分辨率 1μg/m³ | 中等 | 需要真实读数、串口解析练习 |
| MQ-135 | 模拟电压 / TTL 电平 | 定性判断燃气、气味 | 很低 | 辅助“空气变差”判断,不宜定量 |
选型逻辑上,GP2Y1010 更贴近嵌入式底层——运放调理、ADC 采样、线性回归换算,从硬件到代码都能训练到;PMS5003 则是一包一包的串口数据,考察的是 DMA 接收、缓冲管理和帧校验能力。课程设计如果只做一个“能亮能转”的演示,GP2Y1010 就够;如果想让答辩时有真实数据支撑,PMS5003 更可靠。两者在 STM32 上的读取方式完全不同,建议先确认题目侧重再做决定。MQ-135 只需要一路 ADC,适合放在辅助通道上判断异味,不作为主控制依据。
2.2 GP2Y1010 的脉冲采样时序与 ADC 采样
GP2Y1010 的驱动时序比较特殊:每隔 10ms 需要把 LED 引脚拉高 0.28ms,并在脉冲开始后的 0.14ms 处采样输出电压。直接在 while(1) 主循环里用 delay_us 控制这段时序,会遇到两个问题:一是主循环里其它任务如按键扫描、OLED 刷新会打断 0.28ms 的脉冲窗口;二是延时函数在开启不同编译优化等级后,时间抖动变大,ADC 采样值毛刺明显。稳妥做法是放在定时器中断里执行,课程设计里的常见实现如下:
// TIM4 每 10ms 进入一次中断,控制 GP2Y1010 的 LED 脉冲与采样时刻 void TIM4_IRQHandler(void) { if (TIM_GetITStatus(TIM4, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); GPIO_SetBits(GPIOC, GPIO_Pin_6); // LED 引脚拉高 delay_us(140); // 等待到脉冲中点 ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 启动一次 ADC 转换 delay_us(140); // 维持完整 0.28ms GPIO_ResetBits(GPIOC, GPIO_Pin_6); // LED 引脚拉低 } }这段代码在中断里用延时函数换取时序精准,优点是逻辑直白,适合课程设计快速调通。但其代价是中断服务函数被占用了接近 0.3ms,系统里其它中断的实时性会被拉低。如果想做得更工程化,可以用定时器 PWM 直接生成 LED 脉冲波形,再把 ADC 配置为注入触发模式,由 PWM 输出的中点触发采样,全程不需要 CPU 参与延时。这种方案会让代码复杂度上升一个台阶,但恰好是答辩时能拿出来讲的亮点。无论选哪种方式,GP2Y1010 的模拟输出引脚都要注意电平匹配:传感器 5V 供电时输出电压可能超过 STM32 的 3.3V ADC 参考电压,需要用电阻分压后送入 PA1。
2.3 PMS5003 的串口帧接收与校验
换用 PMS5003 后,代码侧重点从脉冲时序转移到数据帧解析。PMS5003 以连续 32 字节为一帧,帧头固定为 0x42 0x4D,PM2.5 浓度值位于第 5、6 字节。帧尾两字节是校验和,等于前 30 字节的累加。串口接收建议用 DMA 配合空闲中断,但课程设计里环形缓冲区已经够用。帧解析核心如下:
uint8_t buf[32]; uint16_t pm25 = 0; // 收到完整 32 字节后调用,返回 1 表示校验通过 uint8_t pms_parse_frame(uint8_t *buf) { uint16_t sum = 0; if ((buf[0] != 0x42) || (buf[1] != 0x4D)) return 0; // 帧头不对 for (int i = 0; i < 30; i++) { sum += buf[i]; } if (sum != ((buf[30] << 8) | buf[31])) return 0; // 校验和错误,帧被截断或干扰 pm25 = (buf[4] << 8) | buf[5]; // PM2.5 浓度,单位 μg/m³ return 1; }帧头判断和累加校验缺一不可。只判断帧头不做校验,串口一个字节错位就可能导致整帧数据解析错乱。PMS5003 的校验和方式与常见的 modbus 帧接收数据程序里的 CRC 校验不同,PMS5003 用的是简单一字节累加和,实现代价小,但应对连续噪声的能力弱。接收逻辑上注意拆帧问题:串口中断里收到的第一个字节不一定是帧头,所以解析流程需要带上帧状态判断,收到半帧数据时要暂存等待剩余部分。
2.4 DHT11 单总线读取与常见失败原因
温湿度模块一般选 DHT11,单总线通信只需要一个 GPIO 口,但时序要求严格。读取过程是先拉低总线 18ms 唤醒传感器,然后释放总线等待传感器拉低响应,再按位读取 40 位数据。课程设计里 DHT11 读取失败的案例,多数不是时序算错,而是 GPIO 模式切换遗漏:读数据期间引脚必须配置为浮空输入,发出起始信号时又得切回推挽输出。另一个高频错误是忘记接 4.7kΩ 上拉电阻,或者外部上拉和 STM32 内部上拉同时工作导致电平识别异常。DHT11 的温湿度数据只在低速控制里有用,比如湿度超过 80% 时强制提高风机转速,不要把它的实时性要求和粉尘传感器混为一谈。
3. 执行机构驱动与 PWM 调速:风机、继电器与负离子模块的嵌入式控制
3.1 执行机构选型与驱动方式对比
净化器的执行层通常由三部分组成:风机、负离子发生器或 UV 灯、以及指示灯或蜂鸣器。风机负责风道循环,负离子发生器负责主动沉降颗粒物,UV 灯负责杀菌。从 STM32 的控制角度看,它们分别对应 PWM 输出和普通 GPIO 输出,但驱动能力和安全要求差别很大:
| 执行器 | 电气特性 | STM32 侧控制方式 | 关键注意点 |
|---|---|---|---|
| 直流无刷风机 | 24V / 0.2~0.5A | TIM PWM + MOS 管或驱动芯片 | PWM 频率决定了是否产生啸叫 |
| 交流离心风机 | 220V | GPIO 控制继电器 / 可控硅 | 必须光电隔离,软件上严禁误触发 |
| 负离子发生器 | 高压 | GPIO 控制继电器 | 高压线远离传感器信号线 |
| UV 灯 | 220V | GPIO 控制继电器 | 继电器线圈需要续流二极管 |
课程设计大多选用直流无刷风机,原因很简单:用 N-MOS 管就能直接驱动,STM32 输出 PWM 信号到 MOS 栅极,风机两端接电源和 MOS 漏极即可。交流风机则需要额外处理过零检测、可控硅触发和市电隔离,风险和工作量都不在同一个量级。做毕业设计想体现“设计与实现”深度,可以在直流风机方案外加一个继电器控制的负离子模块,安全风险低,控制逻辑也容易说清楚。
3.2 TIM PWM 参数计算:如何确定风机频率与占空比
PWM 调速的核心是三个寄存器参数:预分频系数 PSC、自动重装载值 ARR 和比较值 CCR。以 STM32F103C8T6 为例,系统时钟 72MHz,TIM2 挂在 APB1 总线上,要使能时钟后配置。下面是 20kHz 风机 PWM 的初始化代码:
// TIM2_CH1 输出 PWM,频率 20kHz,初始占空比 50% void Fan_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; 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_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz / (71+1) = 1MHz TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 49; // 1MHz / (49+1) = 20kHz TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_Pulse = 25; // 占空比 25/50 = 50% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }PSC 设 71 得到 1MHz 的计数时钟,ARR 设 49 意味着计数器从 0 数到 49,实际周期是 50 个计数单位,所以 PWM 频率是 1MHz / 50 = 20kHz。Pulse 值决定占空比,改起来只需要喂一个变量。修改占空比时用TIM_SetCompare1(TIM2, ccr),ccr 取值范围是 0 到 ARR,即 0 到 49,对应 0% 到 100%。这套参数组合不是唯一的,关键是先确定目标频率,再反推 PSC 和 ARR。不同场景下的常用 PWM 频率如下:
| PWM 频率 | 适用场景 | 优缺点 |
|---|---|---|
| 1kHz | 普通直流电机、舵机 | 低端驱动兼容性好,但可听到电流噪声 |
| 20kHz | 直流无刷风机 | 超过人耳听觉上限,静音,MOS 开关损耗略增 |
| 100kHz 以上 | 精密电机驱动、部分 DC-DC | 对栅极驱动要求高,课程设计不建议 |
我一般会把风机 PWM 直接设为 20kHz,因为 1kHz 驱动下风机线圈会发出明显的滋滋声,放进净化器外壳后会有共振,听起来像产品故障。选 20kHz 时要注意 MOS 管栅极电阻不能太大,否则开关边沿变缓,MOS 会长时间工作在放大区导致发热。
3.3 负离子与 UV 灯的继电器控制顺序
负离子发生器和 UV 灯用继电器控制,控制端就是一个 GPIO,但开关顺序很有讲究。上电初始化阶段,所有继电器输出引脚都要先置低,确保系统启动瞬间负离子不工作。开机流程建议按下面顺序执行:先启动风机 PWM,延时 500ms 待风机稳定运转后再吸合负离子继电器;关机时先断开负离子继电器,再关闭风机。这个顺序的原因是负离子发生器工作时会产生高压电场,如果风机没有转动,高压离子会在净化器内部积聚,干扰粉尘传感器的读数,甚至通过电源线耦合进 MCU。做继电器控制时,GPIO 输出端接三极管或达林顿管驱动继电器线圈,线圈两端必须反向并联续流二极管,否则继电器断开瞬间的感应电动势有可能打坏 STM32 引脚。
4. 控制系统状态机:自动模式切换、按键防抖与串口帧解析
4.1 主循环 + 任务切分的状态机结构
净化器的软件层不复杂,但代码组织方式直接决定了后期排错难度。课程设计里最常见的坏味道是在 while(1) 里把传感器读取、OLED 刷新、按键检测全写成一坨。比较清爽的结构是把每个功能拆成独立的任务函数,由一个固定周期的主循环按顺序调度:
int main(void) { SystemInit(); Sensor_Init(); // ADC、UART、DHT11 初始化 Fan_PWM_Init(); // TIM2 PWM 初始化 Relay_Init(); // 继电器 GPIO 初始化 Key_Init(); // 按键 GPIO 初始化 OLED_Init(); while (1) { Sensor_Update(); // 读取粉尘、温湿度,更新时间戳 Auto_Control_Task(); // 根据模式计算目标动作 Fan_SetLevel(auto_level); Key_Scan_Task(); // 10ms 周期扫描按键 OLED_Show_Data(); delay_ms(10); // 整个循环周期约 10ms } }主循环的周期不是严格固定,因为各任务函数执行时间不同,但整体节奏基本稳定。Sensor_Update 里读取 PMS5003 或 GP2Y1010 的 ADC 值,ADC 转换和帧接收是通过中断完成的,任务函数只从缓冲区拿结果,不在任务内部等待传感器响应。这样设计后,按键扫描即使晚几毫秒也不影响净化器的核心功能,传感器数据也不会因为显示刷新而被阻塞。
4.2 自动模式阈值决策与防抖算法
自动模式的档位决策看起来是“PM2.5 高了就提速”,但直接拿瞬时值做判断会有两个问题:传感器读数在风机高速运转时波动很大,尤其是 GP2Y1010;同时颗粒物浓度在阈值边界反复跳动时,电机会频繁换挡,听起来像抽风。常见做法是在决策前加一层滤波和防抖,代码如下:
// 自动模式:EMA 滤波 + 防抖切换 #define LEVEL_1_THRESHOLD 35 #define LEVEL_2_THRESHOLD 75 #define LEVEL_3_THRESHOLD 150 static uint16_t smooth_pm25 = 0; static uint8_t change_count = 0; uint8_t current_level = FAN_LEVEL_1; void Auto_Control_Task(void) { uint16_t raw = Get_PM25(); // 从传感器缓冲区取原始浓度 uint8_t target_level; if (smooth_pm25 == 0) smooth_pm25 = raw; // 首值直接进入滤波器 // 指数移动平均,alpha 取 0.2,越新数据权重越低 smooth_pm25 = (uint16_t)(0.8f * smooth_pm25 + 0.2f * raw); if (smooth_pm25 < LEVEL_1_THRESHOLD) target_level = FAN_LEVEL_1; else if (smooth_pm25 < LEVEL_2_THRESHOLD) target_level = FAN_LEVEL_2; else if (smooth_pm25 < LEVEL_3_THRESHOLD) target_level = FAN_LEVEL_3; else target_level = FAN_LEVEL_4; if (target_level != current_level) { if (++change_count >= 5) { current_level = target_level; Fan_SetLevel(current_level); change_count = 0; } } else { change_count = 0; } }EMA 滤波器的作用是把瞬态尖峰平滑掉,系数 0.2 表示新采样值对结果的贡献占两成,更早的数据占八成;系数越小曲线越平滑,但响应越慢。change_count 是切换防抖,连续 5 次判断保持同一目标档位才真正执行切换,这能避免传感器值在阈值边缘抖动时引起风机频繁变速。阈值 35、75、150 是基于常见室内颗粒物浓度评估区间给出的经验值,实际使用要根据应用场景调整:有人吸烟的客厅建议把第二档阈值往下调,出风口附近的传感器需要把采样位置避开风机直吹。
4.3 按键扫描与长短按判定
按键模块的常见需求是:短按切换手动/自动模式,自动模式下长按关机。课程设计的按键消抖代码往往写成“检测到按下就延时 10ms 再读一次”,这种阻塞式消抖在带 OLED 和多传感器的系统里会让主循环卡顿。更合理的做法是 10ms 周期扫描,将按键状态做成类似状态机的方式:
typedef struct { uint8_t current; // 当前 GPIO 电平 uint8_t prev; // 上一次确认电平 uint8_t press_cnt; // 按下持续计数 } Key_t; // 每 10ms 调用一次 void Key_Scan_Task(void) { key.current = GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN); if (key.current != key.prev) { key.press_cnt = 0; key.prev = key.current; return; } if (key.current == 0) // 低电平有效 { key.press_cnt++; if (key.press_cnt >= 50) // 500ms 判定为长按 { key.press_cnt = 255; // 防止重复触发 System_PowerOff(); } } }如果当前读到的 GPIO 电平和上次确认值不一致,说明电平正在变化,大概率是抖动,此时只复位计数。只有连续多次扫描都读到稳定低电平,press_cnt 才会累计到位。按键抬起后可以通过 press_cnt 的范围来判断是短按还是长按:计数小于 50 是短按,等于 255 是长按已处理。这种非阻塞扫描不占用主循环时间,10ms 周期下 50 次计数对应 500ms,阈值可调。
4.4 UART 帧接收状态机:自定义协议与 Modbus 风格帧的移植点
空气净化器经常需要向上位机发送实时数据,课程设计里一般是接串口助手查看。直接按字节处理接收会面临“数据半包”“丢字节”的问题,推荐用一个状态机维护接收流程。下面这段代码可以处理任意以帧头 + 长度 + 数据组成的自定义协议,也是学习 modbus 单片机帧接收数据程序时最容易上手的框架:
typedef enum { FRAME_HEAD1 = 0, FRAME_HEAD2, FRAME_LEN, FRAME_DATA } FrameState; static FrameState rx_state = FRAME_HEAD1; static uint8_t rx_buf[32]; static uint8_t rx_len = 0; static uint8_t rx_index = 0; // 串口中断里逐字节调用 void UART_Rx_Parse(uint8_t dat) { switch (rx_state) { case FRAME_HEAD1: if (dat == 0xAA) rx_state = FRAME_HEAD2; break; case FRAME_HEAD2: if (dat == 0x55) rx_state = FRAME_LEN; else rx_state = FRAME_HEAD1; // 帧头错误回到初始 break; case FRAME_LEN: rx_len = dat; rx_index = 0; rx_state = (rx_len <= 32) ? FRAME_DATA : FRAME_HEAD1; break; case FRAME_DATA: rx_buf[rx_index++] = dat; if (rx_index >= rx_len) { Process_Frame(rx_buf, rx_len); rx_state = FRAME_HEAD1; } break; default: rx_state = FRAME_HEAD1; break; } }这个状态机的核心好处是:一帧数据可以分多次到达,不会因为字节间隔被拆开而丢失;帧头匹配失败时自动回到初始状态,不会卡死在错误帧里。帧格式是AA 55 len data,len 表示数据区长度,最大不超过 32 字节。收到完整帧后统一交给处理函数,如解析出命令码和数据,再决定是切换模式还是回复当前 PM2.5 值。Modbus 风格的帧接收原理与此一致,区别只是在帧头、长度计算和 CRC16 校验上更严格,把这个状态机改成 Modbus RTU 的从机接收时也只需要在收到完整帧后加一步 CRC 校验。
5. 系统联调与标定:传感器零点校准、干扰解耦与答辩验证方法
5.1 粉尘传感器两点标定与数据记录
GP2Y1010 这类模拟粉尘传感器的输出电压和颗粒物浓度呈近似线性关系,直接用原始电压值显示会让人看不懂,需要换算成浓度。标定分两步:先测零点电压,再测一个已知高浓度点,最终得到一条直线关系:
浓度 = (Vout - V0) / K
其中 V0 是洁净环境下的输出电压,K 是灵敏度系数。课程设计没有专业设备做多点标定,常见的做法是:在净化器无尘室内密闭运行 10 分钟后记录 V0;然后点燃一小段烟头,待 PM2.5 明显升高后用 PMS5003 作为参考仪器记录电压和浓度对应点。两点确定一条直线,把 V0 和灵敏度系数写死在配置区。这样处理后的数据至少具备参考价值,比直接显示 ADC 原始值要专业很多。注意断电重启后要让传感器预热 2 分钟再开始标定读数,光路积灰会导致零点漂移,长时间运行后需要重新标定。
5.2 联调阶段反复出现的干扰问题
| 故障现象 | 可能原因 | 排查与处理 |
|---|---|---|
| ADC 读数整体偏高且跳动 | GP2Y1010 和风机共用一个电源 | 传感器电源独立,或加 LC 滤波隔离 |
| 负离子开启后 PM2.5 数值飙升 | 高压电场干扰粉尘传感器 | 调整传感器位置,或打开负离子时暂停采样 |
| PWM 输出正常但风机不转 | MOS 管栅极缺少下拉电阻 | 栅极对地接 10kΩ,避免上电瞬间误导通 |
| 继电器吸合但净化器不工作 | 继电器触点容量不够或压降过大 | 测量触点两端电压,更换更大容量继电器 |
| STM32 上电后程序偶尔跑飞 | 外部晶振不起振或负载电容不匹配 | 检查两个 20pF 负载电容,确认晶振焊接 |
粉尘传感器采样最容易踩的坑是电源耦合。风机是感性负载,启动电流是稳态的好几倍,如果传感器和风机共用同一条 5V 供电线,PWM 导通瞬间的电流变化会直接反映在 ADC 采样值上。我给这类系统的建议是:风机电源单独一路,传感器电源用 LC 滤波或至少串一个磁珠,ADC 采样引脚加 100nF 电容到地。
5.3 用一组实验数据证明系统有效
答辩时讲“我的系统能自动调速”不如放一组真实实验数据。一个容易复现的验证方法:在密闭房间内关闭门窗,点燃一小段烟头让 PM2.5 浓度冲高到 200μg/m³ 以上,等浓度稳定后开启净化器自动模式,每 10 秒记录一次显示浓度和多档风速切换时间点。记录 5 分钟内 PM2.5 下降曲线和档位切换顺序,整理成表格:
| 时间(s) | PM2.5(μg/m³) | 风机档位 | 事件 |
|---|---|---|---|
| 0 | 210 | 4档 | 启动自动模式 |
| 30 | 162 | 4档 | 持续高速运行 |
| 60 | 120 | 4档 | 浓度低于150后可降档,但防抖中 |
| 90 | 88 | 3档 | 连续5次低于150,降为3档 |
| 120 | 64 | 2档 | 连续5次低于75,降为2档 |
这组数据能同时证明三件事:传感器采集稳定、档位切换逻辑按预设阈值执行、系统确实把颗粒物浓度降了下来。串口数据导出后可以直接用串口助手或 python 脚本绘图,曲线图放进答辩 PPT 的可信度远高于“系统运行正常”一句话。如果还想往纵深扩展,下一步可以把这套逻辑移植到 freeRTOS 上做任务调度,或者接入 Wi-Fi 模块上报数据。嵌入式开发的价值在于先把底层数据调准,再谈上层的控制策略。
本文还有配套的精品资源,点击获取