简介:这份毕业设计文档围绕基于STM32单片机的办公室空气净化器控制系统展开,面向电子信息、电气工程及自动化等专业的本科毕业生与嵌入式入门者,可用于课程设计、毕业设计选题参考及STM32综合项目练手。压缩包内为1个docx文件,约2.19MB,内容涵盖系统总体方案、硬件电路设计、软件模块划分与整机测试等完整章节,并涉及TFT_LCD显示、L298N驱动、DHT11温湿度采集、SDS011激光粉尘检测以及PWM输出、触屏控制、无线通信等具体实现。文档按毕业设计规范组织,含中英文摘要、目录、方案论证与测试结果,便于读者梳理从需求分析到硬件搭建、再到模块化编程与联调验证的完整思路,也可作为答辩材料与论文排版的参照。目前已有4245人学习下载,适合需要快速理解STM32空气净化器项目框架、查找传感器与驱动模块选型依据的读者参考。
1. 一份能过答辩的净化器毕设:从 PM2.5 采样到触屏档位控制
答辩现场最容易翻车的不是原理讲不清,是评委一句"插上电让它动一下"。这份基于 STM32 单片机的办公室空气净化器控制系统毕业设计,交付的是一套从 PM2.5 采样、温湿度采集、触屏显示到风机档位调节的完整闭环:主控选 STM32F103ZET6,显示走 320×240 的 TFT_LCD,风机由 L298N 调压调速,传感端是 DHT11 加激光散射式 SDS011,另外配一套 C# 写的上位机串口控制台。它适合三类人:正在选题、需要一份可复现软硬件骨架的毕设同学;想拿现成工程练手 STM32 外设与模块化编程的初学者;以及要快速搭一个环境监测小系统原型的工程师。下面把选型理由、接线方式、代码骨架和踩过的坑逐层拆开。
2. 方案定型:主控、显示、驱动、传感器四项选型的取舍
选型这一章不是走过场。答辩时被追问最多的就是"为什么不用更便宜的""为什么不用更快的",答不上来就等于告诉评委这块不是你做的。更实际的一点是,选型决定了后面所有代码的写法:主控没有 FSMC,你的刷屏函数就得靠 GPIO 模拟时序,一屏刷下来几百毫秒,触屏手感直接废掉;传感器输出的是模拟电压而不是数字帧,你就得额外做标定曲线和温漂补偿。所以这一章按"先算资源账、再算成本账"的顺序把四项定下来。
2.1 主控为什么落到 STM32F103ZET6 而不是增强型 51
增强型 51(STC12 那一类)看上去很美:工作频率能分频到 0–35MHz,36 个通用 IO 可配四种输入输出模式,有 PCA 高速输出,价格便宜,开发环境熟悉。但把本设计的资源需求摆出来,它立刻顶不住:要驱动一块 320×240、16 位真彩的 TFT_LCD,还要接电阻触屏控制器、激光颗粒物传感器串口、上位机通信串口、DHT11 单总线,光是串口就要两路以上同时工作,再加上定时器输出 PWM 控制风机。STC12 的串口数量、内部 RAM 和 GPIO 翻转速度都不足以支撑这种并发。
具体算一笔账。TFT_LCD 一帧 320×240×2 字节 ≈ 150KB,而 STM32F103ZET6 的 SRAM 只有 64KB,这意味着哪怕换成 F103,也不可能整屏双缓冲,必须走局部刷新——这一点在软件章会反复用到,也是很多人第一次做屏显时最容易想歪的地方。F103ZET6 的价值在于:72MHz 主频、512KB Flash、64KB SRAM、144 引脚、5 路 USART、片上 FSMC 外设、多路通用/高级定时器。FSMC 把并口 LCD 当成外部存储器来读写,写一个像素就是一条存储指令,速度比软件模拟时序快一个数量级;5 路串口让"传感器 + 上位机 + 调试口"三路并存毫无压力;定时器足够分出 PWM、系统节拍和输入捕获。
代价也要说清楚。144 引脚封装焊接和布线难度高,LQFP144 的引脚间距 0.5mm,手工焊容易连锡;芯片价格比 51 贵一个量级;FSMC 占用大量 IO,PCB 一旦画错改起来很痛苦。如果只是做个最小演示,F103C8T6 加 SPI 屏也能跑,但触屏响应和刷新流畅度会明显下降。本设计选了"性能留余量"的路线,理由是办公室里环境光变化大、界面元素多,屏幕刷新跟不上会直接影响使用体验。
2.2 显示与驱动的组合:TFT_LCD 320×240 加 L298N
显示模块的两个候选是 LCD12864 和 TFT_LCD。LCD12864 是 128×64 点阵,串行接口省 IO,带中文字库,功耗低;但分辨率摆在那里,要在一屏里同时显示 PM2.5 数值、温湿度、档位、开关状态四个信息块就已经很挤,更别说画趋势曲线。它也不支持触控,意味着所有交互都得靠独立按键,面板开孔和布线成本反而上升。TFT_LCD 是 320×240、16 位真彩,字体颜色和大小都能改,配上电阻触屏控制器就能实现点按切档、长按开关机,人机界面这一项的分数直接拉开。
驱动模块的选择更值得说。LM2596 是一颗降压型开关电源芯片,输入 3–40V、输出 1.5–35V 连续可调,体积小、带大电容滤波,但它调压靠的是手拧电位器,想用单片机动态调输出,得再加一级数字电位器或 DAC,电路反而复杂。L298N 是双 H 桥电机驱动,逻辑电平 5V,能同时驱动两路直流电机,通过 PWM 占空比连续调节输出电压,正好对应"风机转速分档"这个需求,而且模块自带 5V 输出可以给单片机供电(虽然我一般不用它给 MCU 供电,原因见避坑章)。
L298N 的短板必须写进设计说明里:内部是双极型晶体管,导通压降约 1.4–2V,驱动电流大时发热明显,必须装散热片;效率低,同样的风量比 MOS 方案多耗电;PWM 频率不能太高,否则开关损耗迅速上升。所以 PWM 频率定在 20kHz 附近——高于人耳听觉上限,避免风机发出啸叫,又没高到让 L298N 烫手。这个数字不是拍脑袋来的,20Hz–20kHz 是人耳敏感区,低于 20kHz 会听到明显的"嗡"声,实测 8kHz 时在安静的办公室里非常刺耳。
2.3 颗粒物传感器:激光散射对比红外反射
红外反射式粉尘传感器(GP2Y 那一类)的工作原理是红外发光二极管照射气流,感光管接收颗粒反射光,输出与粉尘浓度成正比的模拟电压。优点是便宜、外围简单;缺点很致命:它对大颗粒响应好,对 PM2.5 这种亚微米颗粒灵敏度不足,输出的是"烟尘趋势"而不是可信的浓度值,而且受环境光干扰大,需要遮光结构。用它做毕设,标定曲线这一关就很难自圆其说。
SDS011 走的是激光散射路线:激光照射悬浮颗粒产生散射,感光部件根据散射波形反推颗粒直径与数量,内部风扇主动抽气保证气流稳定,串口按 9600 波特率输出 10 字节定长帧,直接给出 μg/m³ 单位的 PM2.5 和 PM10 数值。省掉了 ADC 采样和标定曲线两大块工作量,测量下限能到 0.3μm,响应速度快。代价是价格高、内部风扇有机械寿命(连续跑几千小时后风量下降,读数会偏低),所以工程上常见做法是间歇采样而不是 7×24 全速运转。
帧格式是解析代码的依据,先摆出来:
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 帧头 0xAA | 固定值 |
| 1 | 帧头 0xC0 | 固定值,命令应答帧则为 0xC5 |
| 2 | PM2.5 低字节 | 单位 μg/m³,需除以 10 |
| 3 | PM2.5 高字节 | 高字节在前拼接 |
| 4 | PM10 低字节 | 同上 |
| 5 | PM10 高字节 | 同上 |
| 6 | 设备 ID 低字节 | 多机并联时用于区分 |
| 7 | 设备 ID 高字节 | 单机可忽略 |
| 8 | 校验和 | 字节 2 到 7 累加取低 8 位 |
| 9 | 帧尾 0xAB | 固定值 |
2.4 模块清单与引脚预算
把接口先分配好,画原理图和写初始化代码时就不用反复改。下表是本设计的实际分配,其中串口和定时器是稀缺资源,优先锁定:
| 模块 | 接口类型 | 占用资源 | 备注 |
|---|---|---|---|
| TFT_LCD | FSMC 16 位并口 | PD0–PD1、PD4–PD5、PD8–PD15、PE7–PE15 等 | 数据线 + 片选 + 读写 + 复位 |
| 电阻触屏 | SPI | SPI1(PA5/PA6/PA7)+ 两个 GPIO | 中断引脚接 PB0 |
| DHT11 | 单总线 | PA0 | 需 4.7kΩ 上拉 |
| SDS011 | UART | USART2(PA2/PA3) | 9600 8N1 |
| 上位机/无线 | UART | USART1(PA9/PA10) | 115200 8N1 |
| 风机 PWM | 定时器 | TIM3_CH1(PA6) | 20kHz,ARR=999 |
| 蜂鸣器 | GPIO | PB5 | 按键提示音 |
| 调试口 | SWD | PA13/PA14 | 保留,别挪用 |
引脚预算的原则是"能硬件复用的不软件模拟"。触屏如果也拿 GPIO 模拟 SPI,刷屏和扫触摸会互相抢占 CPU 时间,界面就会卡顿;同理 LCD 一定要走 FSMC,靠软件翻转 IO 写 320×240 个像素,一帧下来几十万次操作,CPU 什么都干不了。
3. 硬件搭台:最小系统、传感器接口与风机驱动的电路细节
硬件这一层,图纸画对只是及格线,真正决定能不能稳定跑的是供电域划分、地线走向和退耦。资源包里的附录 A 给了完整的主电路原理图,但图纸上不会写"这根线为什么这么走",所以这一章把几个关键节点的设计理由补上。新手照图连能亮,熟手看的是这些约束条件在什么范围内成立。
3.1 供电与最小系统:3.3V 和 5V 双域怎么分
整个系统有两个电压域。5V 域供给 L298N 的逻辑电源、TFT_LCD 背光、SDS011(传感器要求 5V 供电)、DHT11(宽压 3.3–5.5V 都能工作);3.3V 域供给 STM32 主体、LCD 的逻辑部分、触屏控制器。两个域必须共地,但共地点要选对:电机大电流回流路径不能穿过 MCU 的模拟地,否则采样值会跟着风机转速跳舞,这是后面避坑章里一条真实故障的根因。
最小系统部分是标准配置:8MHz 无源晶振配两个 20pF 负载电容作为 HSE,32.768kHz 晶振给 RTC 用(本设计用它打时间戳),复位电路 10kΩ 上拉加 100nF 电容,BOOT0 下拉到地保证从 Flash 启动。退耦电容每个电源引脚配一颗 100nF,另外在 3.3V 入口放 10μF 钽电容吸收低频波动。这些数值看着琐碎,但少了 100nF,高频开关噪声就会顺着电源线灌进 ADC 和串口,表现为"串口偶发乱码"这种极难定位的问题。
下面是时钟与调试口的初始化,用的是标准外设库,逻辑很直白但要理解每一行的作用:
/* sys_init.c —— 时钟树与调试口,HSE 8MHz 倍频到 72MHz */ void SystemClock_Config(void) { RCC_DeInit(); RCC_HSEConfig(RCC_HSE_ON); /* 等 HSE 起振,超时就直接卡住——起振失败多半是晶振或负载电容焊错 */ if (RCC_WaitForHSEStartUp() != SUCCESS) { while (1); } RCC_HCLKConfig(RCC_SYSCLK_Div1); /* AHB = 72MHz */ RCC_PCLK1Config(RCC_HCLK_Div2); /* APB1 = 36MHz,上限 36MHz 不能超 */ RCC_PCLK2Config(RCC_HCLK_Div1); /* APB2 = 72MHz */ RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); /* 8MHz × 9 = 72MHz */ RCC_PLLCmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); while (RCC_GetSYSCLKSource() != 0x08); /* 确认切换成功再往下走 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC | RCC_APB2Periph_GPIOD | RCC_APB2Periph_GPIOE | RCC_APB2Periph_AFIO, ENABLE); }这段代码的关键约束在 APB1 分频:F103 的 APB1 总线最高 36MHz,如果直接给 72MHz,定时器和串口的时钟会全部错乱,表现为波特率翻倍、PWM 频率不对。很多人抄初始化代码时把这一行漏了,然后花两天怀疑晶振。倍频系数 9 是配合 8MHz 外部晶振算出来的,换成 12MHz 晶振就要改成 6,否则超频跑飞。
3.2 DHT11 单总线与 SDS011 串口的接法
DHT11 是单总线器件,数据线既要输出又要输入,所以必须外接上拉电阻,典型值 4.7kΩ–10kΩ,接 3.3V。阻值选小了总线拉不低,选大了上升沿太缓,读出来全是校验错。线长控制在 1 米以内,超过就要考虑加屏蔽或者改成板载布局——单总线对分布电容很敏感,杜邦线拉两米长基本就废了。
SDS011 的接线要注意交叉:传感器的 TX 接 MCU 的 RX,传感器的 RX 接 MCU 的 TX。它由 5V 供电,但数据线输出电平是 3.3V 兼容的,可以直接接 STM32 的 USART2,不需要电平转换。有一件事必须做:传感器上电后有约 30 秒的风扇预热期,这段时间内输出的数据会从高值缓慢回落,属于正常现象,代码里应该在上电后丢弃前若干帧,而不是一上来就拿着预热值去触发报警。
/* bsp_uart.c —— USART2 给 SDS011,中断方式收字节,不做阻塞读 */ void uart2_init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); gpio.GPIO_Pin = GPIO_Pin_2; /* PA2 = USART2_TX */ gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_3; /* PA3 = USART2_RX,必须上拉输入 */ gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = baud; /* SDS011 固定 9600 */ usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &usart); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel = USART2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); USART_Cmd(USART2, ENABLE); } /* 中断里只做一件事:把字节塞进环形缓冲,解析放到主循环 */ void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t b = USART_ReceiveData(USART2); ring_put(&g_uart2_ring, b); /* 缓冲满了就丢弃,绝不阻塞 */ USART_ClearITPendingBit(USART2, USART_IT_RXNE); } }参数上只有波特率是可调的,其余 8N1 无校验无流控必须与传感器一致,改任何一项都会收到垃圾数据。中断优先级设成抢占 1、子优先 1,是为了让串口接收能打断主循环里的显示刷新——显示慢一点没关系,串口丢字节就得整帧重来。这里用环形缓冲而不是在中断里直接解析,是因为解析涉及校验和循环,中断里做长耗时操作会拖慢其他外设响应。
3.3 L298N 驱动风机:PWM 频率、续流与地线走向
L298N 模块上有三个需要留意的地方。ENA 是通道 A 的使能脚,接单片机 PWM 输出;IN1/IN2 决定转向,本设计风机只需要单向转,固定成一高一低即可;OUT1/OUT2 接风机。模块自带的 5V 输出是从内部稳压器引出的,能给逻辑电路供电,但不要用它给 MCU 供电,因为电机启停瞬间的电流冲击会顺着这条线倒灌,轻则复位重则烧稳压器。
PWM 频率定 20kHz,对应定时器配置为 72MHz 时钟、预分频 0、自动重装值 999,得到 72MHz/(0+1)/(999+1) = 72kHz?这里要修正:72MHz 直接分频到 20kHz 需要 ARR ≈ 3600。实际取值是预分频 0、ARR=3599,得到 20kHz,占空比分辨率 1/3600,足够细腻。如果为了省事用 ARR=999 配预分频 71,频率是 1kHz,落在人耳敏感区,风机会发出持续的"滴——"声,这是我在第一次调试时踩过的坑。
地线走向单独说一段。电机的地和 MCU 的地最终要连在一起,但连接点必须在电源入口处单点汇合,不能出现"电机电流经过 MCU 地平面再回电源"的路径。做法是 PCB 上把功率地和信号地分区铺铜,中间用一个 0Ω 电阻或磁珠连接。这条规则听起来像老生常谈,但它直接决定了 SDS011 的读数稳不稳——激光传感器的光电接收部分对地电位波动极其敏感。
3.4 TFT_LCD 与触屏的接线要点
TFT_LCD 走 FSMC 的 Bank1 NOR/SRAM 模式,16 位数据线接 PD0/PD1/PD4/PD5/PD8–PD15,地址线用 PD7 之类做片选区分"写命令"和"写数据"两个地址。背光单独用一路 GPIO 控制三极管,好处是可以做熄屏省电和呼吸灯效果,坏处是多一根线,而且如果三极管基极电阻选得太小,开关瞬间的浪涌会让屏幕闪一下。
触屏是电阻屏,配一颗 SPI 接口的控制器,通过中断引脚告诉 MCU"有触摸事件"。这里有个容易忽略的细节:触摸中断引脚必须上拉,否则悬空时会随机触发,表现为界面自己乱跳。校准值要存在 Flash 的最后一页,掉电不丢,否则每次上电都要重新点四个角,体验极差。
4. 软件分层:时间片主循环、帧解析与局部刷新的实现路径
资源里的软件部分按"嵌入式操作系统 + 模块化编程"组织,主程序、显示子程序、PWM 输出子程序、数据采集子程序、上位机控制台子程序各自成文件。实际落地的代码骨架更接近带时间片调度的前后台结构——没有真正的任务切换和优先级抢占,但用系统节拍把不同周期的工作错开,效果上等价于一个极简调度器。对毕设这个量级来说,裸机时间片比上 RTOS 更好调试,出问题能单步跟进去,不用猜任务栈。
4.1 主程序骨架:时间片轮询替代 delay
新手写单片机最常见的写法是采集 → delay(1000) → 显示 → delay(100) → 发串口 → delay(500),功能单看都对,凑在一起就废了:delay 期间触屏完全没响应,按键按下去像没反应,风机档位要等两秒才变。解决办法是用 SysTick 产生 1ms 节拍,主循环只做"到点了吗"的判断,每个任务自己记录上次执行的时刻。
/* main.c —— 1ms 节拍驱动的多周期任务,主循环永不阻塞 */ volatile uint32_t g_tick = 0; void SysTick_Handler(void) { g_tick++; } int main(void) { uint32_t t_sample = 0, t_ui = 0, t_report = 0; sds011_data_t air = {0}; uint8_t temp = 0, humi = 0; SystemClock_Config(); uart1_init(115200); /* 上位机 / 无线透传 */ uart2_init(9600); /* SDS011 */ dht11_init(); lcd_init(); touch_init(); fan_init(); SysTick_Config(SystemCoreClock / 1000); /* 1ms 一次中断 */ while (1) { if (g_tick - t_sample >= 1000) { /* 1Hz 采温湿度 */ t_sample = g_tick; dht11_read(&temp, &humi); } if (g_tick - t_ui >= 100) { /* 10Hz 刷界面,人眼够用 */ t_ui = g_tick; ui_refresh(&air, temp, humi); } if (g_tick - t_report >= 500) { /* 2Hz 上报上位机 */ t_report = g_tick; proto_report(&air, temp, humi); } auto_ctrl(&air); /* 内部带迟滞,见 4.3 */ touch_poll(); /* 非阻塞扫描触摸事件 */ } }这段骨架的精髓在g_tick - t_sample >= 1000这个减法写法。如果用g_tick == t_sample + 1000判断,一旦某次循环错过了那个节拍点,这个任务就永远不会再执行;用减法配合无符号数的回绕特性,即使 tick 溢出也能正确计算时间差。三个任务的周期分别是 1s、100ms、500ms,选择依据是:DHT11 本身转换就要几十毫秒,采太快没意义;界面 10Hz 已经看不出卡顿;串口 2Hz 是为了让上位机的曲线既能实时滚动又不会把缓冲区刷爆。
4.2 数据采集:DHT11 时序与 SDS011 帧解析
DHT11 是严格的单总线时序器件,主机拉低至少 18ms 作为起始信号,然后释放总线等从机应答。从机先拉低 80μs 再拉高 80μs,之后逐位传输:每一位以 50μs 低电平开始,高电平持续 26–28μs 表示 0,持续 70μs 表示 1。判别的关键是在高电平开始后约 40μs 处采样,此时 0 已经回落、1 还在高位。
/* dht11.c —— 读一次温湿度,返回 0 成功,1 无应答,2 校验错 */ uint8_t dht11_read(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] = {0}; uint8_t i, j; dht11_set_output(); GPIO_ResetBits(DHT11_PORT, DHT11_PIN); delay_ms(18); /* 起始低电平必须 ≥18ms */ GPIO_SetBits(DHT11_PORT, DHT11_PIN); delay_us(30); /* 拉高 20~40us 后释放 */ dht11_set_input(); if (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) != Bit_RESET) return 1; /* 没等到 80us 低电平应答 */ while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == Bit_RESET); while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == Bit_SET); for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == Bit_RESET); delay_us(40); /* 40us 处采样:高位是 1,低位是 0 */ if (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == Bit_SET) { buf[i] |= (uint8_t)(1 << (7 - j)); while (GPIO_ReadInputDataBit(DHT11_PORT, DHT11_PIN) == Bit_SET); } } } if ((uint8_t)(buf[0] + buf[1] + buf[2] + buf[3]) != buf[4]) return 2; /* 校验和不符,整帧丢弃重读 */ *humi = buf[0]; *temp = buf[2]; return 0; }参数上只有两个硬约束:起始低电平 18ms 不能短,40μs 采样点不能偏。剩下的都可以放宽。代码里每次读之前都要切换 GPIO 方向,这是单总线的特点——主机发命令时是推挽输出,收数据时必须切回上拉输入,不切的话总线电平被主机拉死,从机根本说不了话。返回值设计成三个状态而不是布尔值,是为了让上层区分"传感器没接"和"接了就偶尔出错"这两种情况,前者要报警,后者重读一次就行。
SDS011 的解析要用状态机而不是缓冲区搬运。10 字节定长帧,帧头 AA C0,帧尾 AB,中间 6 字节数据加 1 字节校验和。用状态机的好处是即使中途丢了一个字节,最多丢一帧就能自动重新同步,不会像 memmove 那样越错越乱。
/* sds011.c —— 字节流喂进状态机,凑满一帧就校验并解析 */ typedef struct { uint8_t frame[10]; uint8_t idx; } sds011_ctx_t; static sds011_ctx_t s_ctx; void sds011_feed(uint8_t byte, sds011_data_t *out) { if (s_ctx.idx == 0 && byte != 0xAA) return; /* 等帧头第一字节 */ if (s_ctx.idx == 1 && byte != 0xC0) { s_ctx.idx = 0; return; } s_ctx.frame[s_ctx.idx++] = byte; if (s_ctx.idx < 10) return; s_ctx.idx = 0; /* 无论成败都重来 */ if (s_ctx.frame[9] != 0xAB) return; uint8_t sum = 0; for (uint8_t i = 2; i <= 7; i++) sum += s_ctx.frame[i]; if (sum != s_ctx.frame[8]) return; /* 校验失败整帧丢 */ out->pm25 = ((uint16_t)s_ctx.frame[3] << 8 | s_ctx.frame[2]) / 10.0f; out->pm10 = ((uint16_t)s_ctx.frame[5] << 8 | s_ctx.frame[4]) / 10.0f; out->valid = 1; }高字节在前是这个小协议里唯一的反直觉点,搞反了会得到大 256 倍的荒唐数值。除以 10 是因为传感器以 0.1μg/m³ 为单位上报整数,这一点数据手册里写了但很多人不看,直接拿原始值当浓度,显示出来永远是一千多。valid标志的作用是让上层知道"这一帧是不是新的",界面在没收到有效帧时显示占位符而不是上一次的旧值,避免用户看到假数据。
4.3 PWM 档位映射与软启动
四档设计:0 档停机、1 档低速、2 档中速、3 档高速。占空比不是线性均分的,因为风机的风量-转速关系和风道阻力共同作用,低速段变化剧烈、高速段趋于饱和。实测下来 35%、70%、95% 三档在手感上区分度最好。
/* fan.c —— TIM3_CH1,20kHz,ARR=3599 */ static const uint16_t k_duty[4] = {0, 1260, 2520, 3420}; /* 35% / 70% / 95% */ void fan_set_level(uint8_t level) { if (level > 3) level = 3; if (level == 0) { /* 0 档直接停定时器,避免低速啸叫 */ TIM_Cmd(TIM3, DISABLE); GPIO_ResetBits(GPIOA, GPIO_Pin_6); return; } TIM_Cmd(TIM3, ENABLE); TIM_SetCompare1(TIM3, k_duty[level]); } void fan_soft_start(uint8_t level) { /* 每 20ms 抬 5%,防止大电流突加把 5V 拉塌导致 MCU 复位 */ uint16_t target = k_duty[level]; TIM_Cmd(TIM3, ENABLE); for (uint16_t d = 0; d < target; d += 180) { TIM_SetCompare1(TIM3, d); delay_ms(20); } TIM_SetCompare1(TIM3, target); }软启动这段不是可有可无的装饰。风机是感性负载,启动瞬间电流能达到稳态的 3–5 倍,如果直接给满占空比,5V 电源会被瞬间拉低,STM32 的掉电复位电路动作,整机重启。表现就是"一切到高档就死机",而你会以为是软件 bug,查半天代码。软启动把电流上升沿摊到 200ms 左右,电源就有时间响应了。
自动模式还需要迟滞,否则浓度恰好卡在阈值附近时,档位会以秒为单位来回跳,风机嗡嗡作响像抽风。
/* ctrl.c —— 带迟滞的自动档位判定 */ void auto_ctrl(const sds011_data_t *air) { static uint8_t level = 0; if (!air->valid) return; /* 取 PM2.5 与 PM10 的较大值,单路漂移时不至于误判 */ float v = air->pm25 > air->pm10 ? air->pm25 : air->pm10; switch (level) { case 0: if (v > 75) level = 1; break; /* 升档阈值 */ case 1: if (v > 115) level = 2; else if (v < 55) level = 0; break; case 2: if (v > 150) level = 3; else if (v < 95) level = 1; break; case 3: if (v < 130) level = 2; break; /* 降档阈值更低 */ } fan_set_level(level); }升档阈值和降档阈值之间留了 20–40 的间隔,这就是迟滞带。间隔太小起不到防抖作用,太大则响应迟钝;实测 20–40 在办公室场景下比较合适,因为室内浓度变化是分钟级的,不需要秒级响应。
4.4 显示与触屏:局部刷新而不是整屏重绘
前面算过,一整屏 150KB 远超 64KB SRAM,双缓冲不可能,所以策略是"只重绘变化的区域"。更进一步,如果数值没变就不写屏——LCD 的写入操作本身就耗时,无谓的刷新既拖慢主循环又会让画面轻微闪烁。
/* ui.c —— 数值变化才刷对应区域,静态框架只在开机画一次 */ void ui_refresh(const sds011_data_t *air, uint8_t t, uint8_t h) { static char last_pm[8] = ""; if (!air->valid) { LCD_ShowString(60, 120, 120, 16, 16, "---"); /* 无效值显示占位符 */ return; } char buf[8]; sprintf(buf, "%5.1f", air->pm25); if (strcmp(buf, last_pm) != 0) { /* 值没变不写屏 */ strcpy(last_pm, buf); LCD_Fill(60, 120, 180, 136, WHITE); /* 先清旧值再写新值 */ LCD_ShowString(60, 120, 120, 16, 16, buf); } LCD_ShowNum(200, 120, t, 2, 16); LCD_ShowNum(200, 150, h, 2, 16); }LCD_Fill那一步不能省。如果直接覆盖写,新数字比旧数字短的时候会留下残影,比如从 "123.4" 变成 "98.7",屏幕上就会显示 "98.74" 这种鬼东西。填充区域的大小要精确覆盖旧值可能占据的范围,宁可多填一点。
触屏扫描放在主循环里做非阻塞轮询,读控制器返回坐标,转换成像素坐标后判断落在哪个按钮矩形内,再触发对应动作。长按开关机可以用"按下时刻 + 持续判定"实现,不需要额外定时器。校准值存 Flash 最后一页,上电时读出来,读失败就用默认值并提示重新校准。
4.5 上位机 C# 串口控制台
上位机这部分是资源包里附录 C 的内容,作用是远程看数据、远程切档。C# 的SerialPort有个坑:DataReceived事件在辅助线程触发,直接更新 UI 控件会抛跨线程异常,必须Invoke回主线程。
// ConsoleForm.cs —— 按行拆包,滚动窗口限点数 private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { string line = serialPort1.ReadLine(); // 依赖 NewLine 属性设置正确 string[] p = line.Split(','); if (p.Length < 4) return; double pm25 = double.Parse(p[1], CultureInfo.InvariantCulture); Invoke(new Action(() => { lblPm25.Text = pm25.ToString("F1") + " μg/m³"; chart1.Series[0].Points.AddY(pm25); if (chart1.Series[0].Points.Count > 200) chart1.Series[0].Points.RemoveAt(0); // 只留最近 200 点 })); } catch (TimeoutException) { } // 设备拔掉时静默退出 catch (FormatException) { } }ReadLine依赖NewLine属性,下位机发的是\r\n就要设成"\r\n",不一致的话永远读不出完整行。Points.RemoveAt(0)是必需品而不是优化项,图表点数无上限增长的话跑两小时内存就上百兆,界面开始卡。串口参数要和下位机严格对齐:115200、8 位数据、无校验、1 位停止位。协议帧建议用标识,PM25,PM10,温度,湿度\r\n这种纯文本,调试时拿串口助手一眼能看懂,比二进制协议省事得多。
5. 避坑与排查:五条从现场攒下来的血泪记录
这一章的五条都是调试阶段真实撞上的,不是从手册里抄的。每一条按"现象 → 原因 → 解决"写,方便你对号入座。有个共同规律:看起来像软件 bug 的问题,一半以上根子在电源和地线上,所以第一条从供电说起。
5.1 上电与供电类:白屏和反复复位
现象一:上电后屏幕背光亮但全白,程序像是没跑。第一反应是 LCD 初始化代码写错,查了半天时序和寄存器都对。原因是 FSMC 的读写时序参数配得太激进,FSMC_DataSetupTime设成了 1 个 HCLK 周期,这块屏要求至少 3 个。改用示波器看片选和写信号的间隔就能确认。解决是把数据建立时间和地址建立时间各放宽到 5 和 3,代价是刷屏速度降一点,但肉眼完全看不出来。
现象二:一切到高档风机,整机就重启,屏幕闪一下重来。这个是典型的电源跌落。原因有两个叠加:L298N 用模块自带的 5V 给 MCU 供电,风机启动瞬间浪涌把这条线拉到 3V 以下触发掉电复位;同时 5V 输入端的滤波电容只有 100μF,储能不够。解决是把 MCU 供电改成独立的 3.3V LDO,5V 入口并 470μF 电解电容,再配合 4.3 节的软启动。改完之后连续切档五十次没再复位过。
5.2 通信类:读出全 0 和一直校验错
现象三:SDS011 串口收到的帧头对、帧尾对,但 PM2.5 一直是 0。用串口助手直连传感器,数据是正常的,说明传感器没坏。问题出在解析代码把高低字节拼反了——frame[2]是低字节,frame[3]是高字节,写成frame[2] << 8 | frame[3]就得到错误结果。另一个容易忽略的点是上电预热:传感器前 30 秒输出的是风扇还没稳定的值,代码里应该丢弃前 10 帧左右再开始使用。
现象四:DHT11 时好时坏,失败率大概三成,报校验错。先怀疑时序,把延时调来调去没用。原因是单总线走杜邦线拉了一米五,线间电容把上升沿拖慢,40μs 采样点落在过渡区,读出来的位随机翻转。解决是把传感器改到板载、线长压到 10cm 以内,上拉电阻从 10kΩ 换成 4.7kΩ。如果结构上必须拉长线,就得换成 I2C 接口的温湿度传感器,单总线在长距离上确实不靠谱。
5.3 驱动与干扰类:一开风机数据就飘
现象五:风机停转时 PM2.5 读数稳定在 20 左右,一切到中高档,读数就开始在 20 到 200 之间乱跳。这个最折磨人,因为你会以为是传感器坏了或者算法有问题。真实原因是电机换向产生的传导干扰顺着地线窜进了传感器的供电回路,加上功率地和信号地共用了一段铜皮,地电位随电机电流波动。验证方法很简单:用示波器看传感器的 5V 供电脚,能看到几十毫伏的尖峰。
解决分三步走。第一步,在 SDS011 的电源脚就近并 100μF 电解加 100nF 陶瓷电容,把尖峰旁路掉。第二步,PCB 上把功率地和信号地分开铺铜,单点通过 0Ω 电阻汇合。第三步,软件上加中值滤波——连续采 5 帧,去掉最大最小取平均,这能压掉大部分偶发跳变。三步做完读数波动在 ±3 以内,属于可接受范围。
提示:如果风机和 MCU 共用一组电源,先解决电源隔离再调软件,顺序反了会浪费大量时间。
6. 交付前的自检:把"能跑"变成"敢交"的一组验证动作
功能调通和能交差是两件事。答辩现场评委不会按你的演示脚本走,他会拔电源、会用手挡住传感器、会把风机档位连按五次。所以交付前我会强制跑一遍下面这张表,每项都过了才敢把板子装进外壳:
| 测试项 | 操作方法 | 通过标准 |
|---|---|---|
| 冷启动一致性 | 断电 10 秒后上电,重复 10 次 | 10 次全部正常进入主界面,无白屏无复位 |
| 传感器预热 | 上电后连续观察 60 秒读数 | 30 秒后读数稳定,无 0 值无跳变 |
| 档位切换耐久 | 0→3→0 循环 50 次 | 每次风量变化明显,无死机,无重启 |
| 触摸响应 | 连续点击各按钮 30 次 | 每次都有响应,无漏点无错位 |
| 通信连续性 | 上位机连续记录 2 小时 | 无断帧,曲线无突刺,内存占用平稳 |
| 遮挡响应 | 用纸板挡住传感器进气口 | 30 秒内浓度上升并触发升档 |
跑表的时候有两个技巧值得说。冷启动测试一定要真断电,用复位键不算,因为复位不复位电源,很多电源相关的隐患测不出来。通信连续性测试要开着任务管理器看上位机进程的内存占用,如果曲线涨内存也涨,说明Points.RemoveAt那行没生效或者别的地方在漏。
还有个更省事的办法:写一段自检代码,上电后自动跑一遍传感器通信、屏幕填充、PWM 输出、串口回环,把结果打到调试串口。这样每次改完硬件,插上电看串口打印就知道哪
本文还有配套的精品资源,点击获取