简介:基于STM32单片机的智能空气净化器控制系统完整项目资料,面向嵌入式、电子信息等专业大学生,适用于课程设计、期末大作业或毕业设计等实践场景。系统以STM32为控制核心,融合空气质量检测、数据处理、净化控制和人机交互四大模块,可实时监测PM2.5、PM10与二氧化碳浓度,并依据分析结果自动调节风扇转速、滤网切换,同时支持触摸屏、遥控器及手机App远程控制,有效改善室内空气环境。资源压缩包共1063个文件,约28.73MB,内含574个C源码、258个H头文件,以及汇编启动文件、工程配置、静态库、链接脚本等,覆盖从底层驱动到应用层GUI的完整开发链路。已有71人学习下载。压缩包内配有完整开发文档和源代码,详细记录了需求分析、系统设计、编码实现、测试验证等每一步,软件采用模块化设计并预留扩展接口,方便后续加入温湿度监测、远程数据上传与智能联动功能,不仅能加深嵌入式理论理解,也为实际项目开发提供了可借鉴的完整范例。
1. 从PM2.5到风机闭环:这门STM32课设到底想让你学会什么
新装修的房子或是雾霾天不开窗的办公室,室内PM2.5轻松破百,这时候一台能自动调速的空气净化器比什么都实在。而市面上主流净化器的主控板拆开看,很多就是一颗STM32。这篇要拆的项目标题是《基于STM32单片机智能空气净化器控制系统的设计与实现》,也是大二下学期嵌入式进阶课设里常出现的一道题:用STM32读粉尘传感器浓度,按阈值或简单策略去调风机转速,再把PM2.5、温湿度、模式实时显示在屏幕上。它解决的问题很朴素——让净化器从“只有开关”变成“自动把风量调到刚好够用”,做这题的过程恰好把ADC采样、定时器PWM、状态机、显示驱动这些嵌入式基本功全部串了一遍。适合刚学完STM32外设、想拿一个完整项目练手的大二学生,也适合不想在毕设里做纯算法的同学备选。
2. 硬件选型与电路接线:为什么这套方案容错率最高
2.1 三种粉尘传感器方案对比,别一上来就选最贵的
粉尘传感器是净化器的“眼睛”,最常见的有三种方案:夏普GP2Y1010AU0F(模拟量输出)、攀藤SDS011(串口输出)、攀藤PMS5003(串口高速输出)。三者都能测PM2.5,但学习成本和电路难度差别很大,我讲一下适合什么场景。
| 型号 | 输出类型 | 精度量级 | 代码难度 | 参考价格 | 适合场景 |
|---|---|---|---|---|---|
| GP2Y1010AU0F | 模拟电压(约0.5~3.4V) | 趋势级,需标定 | 中:要用PWM触发 + ADC | 约15元 | 课设、练手,ADC和定时器都练到 |
| SDS011 | 串口9600波特率,直接输出PM2.5/PM10数值 | 较高,免标定 | 低:串口解析即可 | 约60元 | 想省事、数据要能直接看的 |
| PMS5003 | 串口,被动查询/主动上传 | 较高,数据格式丰富 | 低 | 约100元 | 毕设想做数据记录/上位机的 |
我的建议很直接:课设选GP2Y1010AU0F。原因不是它便宜,而是这颗传感器逼你去搞懂ADC和定时器PWM——答卷老师一看外设列表,至少说了五个外设被用到。毕设想往上加数据可视化的,再换PMS5003不迟。
GP2Y1010有个麻烦点:它的模拟输出不是一直接着让你读的。传感器内部的红外LED需要由PWM信号驱动,输出引脚Voltage Output会在LED脉冲结束后的采样保持窗口里给出有效电压值。新手如果直接拿ADC去轮询采样,会发现读数像心电图一样乱跳——这不是ADC坏了,是采样时刻没对上。
2.2 主控与风机驱动:为什么不能用一个IO口直接推风机
主控我推荐STM32F103C8T6,这颗芯片在课设圈里几乎是“标准答案”:72MHz主频、两个ADC、多个定时器都支持PWM输出,网上资料多到溢出。它有两个ADC通道还带DMA,拿来同时采粉尘浓度和电池电压都够用。
风机部分是新手最常翻车的地方。很多人上来就想“既然单片机引脚能输出3.3V高电平,那就直接接风机正极”,结果风机纹丝不动。这不是你人品问题,也不是IO坏了,而是单片机的GPIO驱动能力有限,STM32的引脚输出电流在几个mA到20mA级别,而一个12V直流风机满载可能需要几百mA。用IO去推风机,等于让一个小孩去推一辆抛锚的车——推不动。
常见做法是加一个NMOS管(比如AO3400)做开关,或者用ULN2003达林顿管。如果风机是AC220V供电的,必须加继电器和光耦隔离,但那套电路放在课设里比较危险,我一般只推荐直流风机方案。接线是:单片机PWM引脚串一个100Ω电阻到MOS管栅极,栅极再接一个10kΩ下拉电阻到GND防止误开,MOS管源极接GND,漏极接风机负极,风机正极接12V电源。最关键的是风机两端必须并联一个续流二极管(1N4007或SS14),否则关断瞬间感性负载会产生几十伏的反向尖峰,轻则让MCU当场复位,重则烧掉MOS管——这是血泪经验。
2.3 最小系统接线表:一次把引脚和供电画对
做课设最烦的不是代码,是杜邦线插错了查半天。我按自己常用的配置列一张最小系统接线表,你自己画原理图或面包板搭都可以照搬。需要特别注意的是,部分STM32F103C8T6最小系统板把PA1或PB2复用成了板载LED或BOOT跳线,接线前先看自己的板子原理图。
| 外设 | 信号 | STM32引脚 | 接线说明 |
|---|---|---|---|
| GP2Y1010 | V-LED | PA6(TIM3_CH1) | 输入约100Hz、脉宽320μs的PWM |
| GP2Y1010 | Vo | PA0(ADC1_IN0) | 信号输出,3.3V兼容模块可直接接 |
| DHT11 | DATA | PB10 | 接4.7kΩ上拉到3.3V |
| OLED(I2C) | SCL | PB6 | 接I2C1,若无硬件上拉则加4.7kΩ |
| OLED(I2C) | SDA | PB7 | 同上 |
| 按键1(模式) | K1 | PA2 | 另一端接GND,内部上拉 |
| 按键2(风速+) | K2 | PA3 | 同上 |
| 按键3(风速-) | K3 | PA4 | 同上 |
| 风机PWM | PWM | PA8(TIM1_CH1) | 经MOS管驱动直流风机 |
| 蜂鸣器 | BUZZ | PA5 | 三极管驱动,低电平触发 |
DHT11的DATA引脚需要上拉,这点新手经常漏。如果用的是STM32内部上拉,阻值通常在30kΩ~50kΩ之间,对DHT11来说偏弱,容易出现通讯不稳定。稳妥起见还是在外部并一个4.7kΩ电阻到3.3V,这是最省心的解法。
3. 从CubeMX到传感器驱动:把PM2.5和温湿度读准
3.1 CubeMX初始化:时钟、ADC、PWM的推荐配置
打开STM32CubeMX选芯片STM32F103C8T6,第一件事去SYS里把Debug选项从“JTAG”改成“Serial Wire”。如果不改,默认的JTAG占用了PA13、PA14、PA15、PB3、PB4,而你后面很可能用到PB3或PB4;更糟的是,程序跑起来之后JTAG引脚被软件复用成普通IO,第二次下载时Keil报错“Cannot access Target”,芯片看起来像被锁死。我用这一条能救回来一半以上的“锁死”问题。
RCC里选外部晶振(Crystal/Ceramic Resonator),时钟树把HCLK配到72MHz,APB1定时器时钟配到72MHz——注意F103里APB1最高只能到36MHz,但如果把APB1分频设为2,定时器时钟会自动翻倍到72MHz。APB2外设时钟本来就72MHz。尘埃传感器触发我用TIM3,周期10ms、脉宽320μs;风机PWM用TIM1_CH1(PA8),频率25kHz,这个频率后面会解释为什么。
ADC配置是一处容易埋雷的地方。ADC1开IN0即可,如果还要多采一个(比如第二路气体传感器),就开启扫描模式并勾选两个通道。关键在于:多通道切换采样时,第一个通道采完切到第二个通道,ADC内部的采样电容里残留着前一个通道的电荷,所以切换后的第一次转换结果往往偏大或偏小。解决办法是把两次转换都采了但丢弃第一次结果,或者把采样时间拉长(比如改成55.5周期)。网上搜“stm32 adc切换通道”能搜到大量相关报错,我这边提前给你写出来,省得你等会儿踩。
3.2 粉尘传感器采样:先给GP2Y1010一个准确的触发脉冲
GP2Y1010在一线工程里的标准读法是:周期性触发红外LED,然后选一个固定延迟点去读ADC。先写一段触发与采集的代码,再给你一个滑动平均的滤波函数。
// 触发并读取一次GP2Y1010原始ADC值 // TIM3_CH1 = PA6,周期10ms(100Hz),脉宽320us // 触发脉冲高电平区间超过0.32ms后,Vo进入采样保持窗口,此时读ADC uint16_t PM25_TriggerAndRead(void) { uint16_t raw; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 2); // 10ms周期下,占空比=320us/10ms≈3.2% HAL_Delay(1); // 延时1ms,让LED点亮并进入保持窗口 HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 5); // 等待转换完成,超时5ms raw = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); // 拉低触发脉冲,结束一个周期 return raw; }这段代码的逻辑是:先把PWM占空比改成约3.2%让脉冲输出高电平,点亮传感器内部红外LED;延时1ms后Vo已经进入采样保持的稳定段,此时启动ADC读取;读完立刻把PWM占空比清零,等待下一个10ms周期。参数说明:TIM3的ARR我设为999(即0~999,计数到1000次),PSC设为719(72MHz ÷ 720 = 100kHz),这样一次计数是10μs,1000次正好是10ms。占空比寄存器值2对应的脉宽约是2×10μs=20μs,哦这里要注意,我上面的注释里写“占空比=320us/10ms”,对应比较值为3.2即取320,但代码里写了2——写文章的时候要注意一致性。我应该说清楚,如果ARR=999代表10ms,那么320μs的脉宽对应比较值为32,不是2。如果代码里直接HAL_Delay(1)再读,其实触发脉冲只要保证能点亮LED,脉宽大小不那么苛刻。但为了严谨,我修正一下:ARR=99(1ms周期),比较大一点才合理。算了,我干脆把代码改为依赖延时,不纠结占空比数值:
// 触发与读取:PA6输出高电平点亮红外LED -> 延时1ms -> 读ADC -> 拉低 void PM25_Trigger(void) { HAL_GPIO_WritePin(LED_TRIG_GPIO_Port, LED_TRIG_Pin, GPIO_PIN_SET); HAL_Delay(1); } uint16_t PM25_ReadRaw(void) { uint16_t raw; HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 5); raw = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return raw; } void PM25_SampleTask(void) { PM25_Trigger(); // LED点亮,等待采样保持 uint16_t raw = PM25_ReadRaw(); // 在保持窗口内读ADC HAL_GPIO_WritePin(LED_TRIG_GPIO_Port, LED_TRIG_Pin, GPIO_PIN_RESET); Dust_Collect(raw); // 放入滑动平均缓冲 }这样把触发和读取拆开,逻辑上更清楚:GPIO拉高点亮LED,延时保证采样保持稳定,读ADC,再拉低,然后进入滑动平均。如果你在CubeMX里把PA6配成了普通推挽输出,就不涉及定时器PWM;如果配成TIM3_CH1,则把GPIO拉高改成__HAL_TIM_SET_COMPARE。两种方式我都用过,普通GPIO方式在课设阶段反而更好排查,因为示波器一量就知道高低电平有没有出来。
滑动平均的环形缓冲也贴出来:
#define DUST_BUF_SIZE 10 static uint16_t dust_buf[DUST_BUF_SIZE]; static uint8_t dust_idx = 0; static uint32_t dust_sum = 0; // 每次采到原始值,先进环形缓冲,用总和除以数量得到平均 void Dust_Collect(uint16_t raw) { dust_sum -= dust_buf[dust_idx]; // 先减掉最旧的值 dust_buf[dust_idx] = raw; // 覆盖最旧的值 dust_sum += raw; // 累加最新值 dust_idx = (dust_idx + 1) % DUST_BUF_SIZE; // 指针下移 } uint16_t Dust_GetAverage(void) { return (uint16_t)(dust_sum / DUST_BUF_SIZE); }这里的参数说明:缓冲大小10表示每次都重新计算最近10次采样的平均值,既能让数值平滑又不至于让响应变得太迟钝。如果你发现净化器对烟雾的反应明显变慢,就把缓冲大小降到5;如果数值还是很跳,就升到20。这个取舍在实际调参时基本就是看串口波形决定,没有绝对标准。
拿到ADC原始值之后,按你自己的模块手册公式换算成浓度。GP2Y1010的标定有不少版本流传,网上常见的是把ADC转成电压(mV)后套一个线性公式,但不同批次乃至不同供电电压下,传感器的基线电压都不一样。课设阶段不用追求绝对准确,你只要让浓度值在干净空气下显示30~60、在点一根香烟后升到200以上,答辩老师就挑不出毛病。真要做到可对比,就在同一环境下用另一台标准PM2.5设备同时记录,自己做两点校准。
3.3 DHT11时序读取:最容易翻车的一段代码
DHT11是单总线协议,所有数据交换都靠一根线,时序要求微妙到微秒级。它翻车率极高,症状通常是:读数一直是0、读出来固定是0xFF、或者每隔几次就卡死。12MHz和72MHz的主频下,网上抄来的延时函数完全不同,很多人就是栽在这上面。
我先给一个在72MHz下能用的读取函数,然后解释为什么它稳。
// 微秒延时:用NOP空转近似,72MHz下每个循环约1us // 注意:不同主频必须调整内层循环次数 static void DHT11_DelayUs(uint32_t us) { while (us--) { for (volatile uint32_t i = 0; i < 8; i++) { __NOP(); } } } // 从DHT11读取一次温湿度,成功返回0,失败返回-1 int8_t DHT11_Read(uint16_t *temp, uint16_t *humi) { uint8_t data[5] = {0}; uint8_t bit_val = 0; // 主机拉低总线至少18ms,启动通信 GPIO_InitTypeDef gpio = {0}; // 假设PB10已配置为开漏输出带上拉 HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_SET); // 释放总线后切换为输入模式,读传感器应答 // 这里切引脚模式需要在CubeMX里预先把PB10设为开漏输出并复用,或者运行时修改CRL寄存器 for (uint8_t i = 0; i < 5; i++) { uint8_t byte = 0; for (uint8_t j = 0; j < 8; j++) { // 等待低电平响应结束,出现80us低电平 while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_RESET); DHT11_DelayUs(30); // 跳过前30us,落在数据位的中段 if (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_SET) byte |= (0x80 >> j); // 仍为高电平时判为1 while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_SET); } data[i] = byte; } // 校验:前4字节之和与第5字节相等 if ((uint8_t)(data[0] + data[1] + data[2] + data[3]) != data[4]) return -1; *humi = (data[0] << 8) | data[1]; // 湿度整数+小数 *temp = (data[2] << 8) | data[3]; // 温度整数+小数 return 0; }逻辑说明:主机先把总线拉低20ms再释放,DHT11感知到起始信号后回一个80μs低电平加80μs高电平。接下来每位数据都以一个50μs低电平起始,后面跟着26~70μs的高电平;高电平持续时间短的是0,长的是1。我们延时30μs再去读电平,就是为了避开每位前面固定的50μs低电平,落点在真正区分0和1的高电平区间里。校验和判断能过滤掉大部分时序错位导致的脏数据。
参数说明:注释里那个“8次NOP近似1μs”只适用于72MHz主频且开了编译器优化的情况,如果换成别的板子或关了优化,这一段的实际延时可能偏长。所以我更推荐用DWT定时器做精确微秒延时,但那需要三行代码配置,放在课设里不一定值得;直接在CubeMX里把SYSTICK配置成1ms中断,用DWT->CYCCNT读当前周期数更准。遇到延时不精准导致的偶发读错,最省事的解决办法是读失败后连续重试三次,对净化器这种慢速系统来说完全够用。
另外提醒一句:如果DHT11接在PB3或PB4上,必须先去CubeMX的SYS里关掉JTAG,只保留SWD,否则那两个引脚被调试功能占用,GPIO配置根本不会生效。这也是很多人“代码没写错但引脚不干活”的隐藏原因。
4. 控制策略与状态机:风机转速怎么调才不会被答辩追问到卡壳
4.1 为什么课设阶段先别急着上PID
很多同学看到“智能”两个字就自动想到PID,结果调了一周也没调稳。原因在于:PM2.5传感器响应慢、风机对浓度的影响延时长,而且室内浓度本身是随机波动的,PID在这种大惯性、大噪声的系统里很容易振荡。Kp调大了来回冲,调小了半天没反应。
我的建议:先做分段阈值控制,加滞回保护,把自动模式的框架跑通之后,再决定要不要加PID。净化器用户真正关心的是“空气什么时候能好”,不是“浓度精确控制在35.5”。在课设答辩时,你能解释清楚为什么分段控制比PID更适合这套系统,这比硬调一个会振荡的PID更让老师加分。
4.2 自动/手动状态机:把“模式识别”做成代码
这里用到一个简单的状态机,分自动和手动两种模式。手动模式里用户按键加/减风速;自动模式里系统根据浓度读数自己决定风速。状态机的关键是不让每个按键事件直接改风机转速,而是改一个“目标状态”,再由统一的任务去执行状态到动作的映射。
typedef enum { MODE_AUTO, // 自动模式 MODE_MANUAL // 手动模式 } sys_mode_t; typedef enum { FAN_OFF = 0, // 风机停止 FAN_LOW, // 低速 FAN_MID, // 中速 FAN_HIGH // 高速 } fan_level_t; static sys_mode_t sys_mode = MODE_AUTO; static fan_level_t fan_level = FAN_LOW; static volatile uint16_t pm25_value = 0; // 由PM2.5换算得到,单位ug/m3 // 主循环里每隔1秒调用一次 void App_ControlLoop(void) { if (sys_mode == MODE_AUTO) { // 对应PM2.5浓度阈值:350ug/m3以上拉高速,150-350中速,75-150低速 if (pm25_value >= 350) fan_level = FAN_HIGH; else if (pm25_value >= 150) fan_level = FAN_MID; else if (pm25_value >= 75) fan_level = FAN_LOW; else fan_level = FAN_OFF; Fan_SetLevel(fan_level); } // 手动模式不在这里改fan_level,由按键处理函数修改 }逻辑说明:自动模式每秒钟读取一次最新的pm25值,按浓度区间映射到四个风速档。这样写的好处是控制逻辑和采样逻辑解耦——采样的只管采,控制的只管控制,互不打扰。参数说明:75、150、350这三个阈值不是拍脑袋定的,参考了空气质量指数(AQI)对PM2.5的分级标准,但你完全可以根据自己的传感器标定结果调整。一个常见的优化是加入滞回:浓度升到150才切中速,但降到140才切回低速,避免在临界点附近反复切换。
滞回其实是用一个简单的计数器实现的:
// 连续10次(10秒)都超阈值,才真正切换档位 static uint8_t high_count = 0; if (pm25_value >= 350) { if (high_count < 10) high_count++; } else { high_count = 0; } if (high_count >= 10) fan_level = FAN_HIGH;这个“连续N次才动作”的防抖思想,比简单阈值判断实用得多。它本质上是一种时间窗滤波,能滤掉瞬时尖峰。参数10秒是拍脑袋取的,实际中你可以根据传感器自身的波动周期来定——让传感器数据输出稳定后,观察它来回穿越阈值的频率,把防抖时间设成那个周期的两倍左右比较合理。
4.3 风机PWM频率:为什么我坚持用25kHz而不是1kHz
直流无刷风机调速,PWM频率的选择直接影响噪音和寿命。如果PWM频率落在人耳可听范围内(20Hz~20kHz),线圈会发出尖锐的啸叫声,夜里卧室里能听到明显的“滋滋”声。用1kHz是最常见的错误,很多51单片机例程里就是1kHz甚至几百赫兹。我一般直接用25kHz,这个频率超过了人耳听觉上限,听不到啸叫,对MOS管开关损耗也在可接受范围内。
STM32F103C8T6的定时器时钟是72MHz,25kHz意味着ARR=2879,占空比分辨率大约是1/2880,也就是0.03%左右,足够平滑调速。如果频率再高到50kHz,开关损耗会明显上升,MOS管摸起来发烫;25kHz是个均衡点。
下面是风机调速函数:
// 根据档位设置对应占空比 void Fan_SetLevel(fan_level_t level) { switch (level) { case FAN_OFF: Fan_SetSpeedPercent(0); break; case FAN_LOW: Fan_SetSpeedPercent(30); // 30%占空比 break; case FAN_MID: Fan_SetSpeedPercent(60); // 60%占空比 break; case FAN_HIGH: Fan_SetSpeedPercent(100); // 100%占空比 break; } } // 设置占空比,percent范围0-100 void Fan_SetSpeedPercent(uint8_t percent) { if (percent > 100) percent = 100; __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, (uint16_t)((htim1.Init.Period + 1) * percent / 100)); }参数说明:TIM1_CH1的ARR在CubeMX里设成2879,这样上面计算出来的比较值就是0~2879对应的占空比。注意__HAL_TIM_SET_COMPARE的第二个参数如果大于ARR值,输出会一直保持高电平,等于100%占空比,但这不是标准做法。我习惯提前把percent夹在0~100之间再计算,养成这个习惯能省下很多边界问题。
还有一个容易被忽略的点:TIM1是高级定时器,它在CubeMX里默认“Break”引脚是使能的,如果没在SYS配置里关掉刹车功能,PWM根本不会输出。症状就是PA8量不到波形、但代码看起来完全正确。遇到这个问题的直接解法是在main()里加一行:
__HAL_TIM_MOE_ENABLE(&htim1); // 使能TIM1主输出,否则高级定时器PWM不出来这是TIM1区别于TIM2/TIM3的最大坑。放这行代码放在所有PWM初始化之后,只要是用高级定时器做PWM输出,养成这个习惯基本能避开一堆玄学问题。
5. OLED交互与掉电保存:让课设在答辩台上拿得出手
5.1 OLED显示布局:局部刷新别把整屏当动画刷
0.96寸OLED显示区域是128×64像素,基本够用。很多人买到手就抄一个整屏刷新的例程,结果每次更新数值时屏幕都在闪。原因很简单:SSD1306的I2C传输速度才400kbps,整屏8192字节的数据要传近200ms,当然闪。解决办法是局部刷新,只更新变化的区域。
下面这段代码演示了局部刷新思路,需要配合你手头的SSD1306驱动一起用:
// 局部刷新:只把包含数字和汉字的那一页写进显存 // page是0~7,对应屏幕从上到下的8个8像素高条带 void OLED_RefreshPage(uint8_t page) { uint8_t col_start = 0; uint8_t col_end = 127; OLED_WR_Byte(0xB0 + page, OLED_CMD); // 设置页地址 OLED_WR_Byte(col_start & 0x0F, OLED_CMD); // 设置列低四位 OLED_WR_Byte(0x10 | (col_start >> 4), OLED_CMD); // 设置列高四位 for (uint8_t i = col_start; i <= col_end; i++) { OLED_WR_Byte(OLED_GRAM[page][i], OLED_DATA); } } // 主循环更新PM2.5数值时,只刷新第2页(显示数字的区域) OLED_ShowNum(2, 24, pm25_value, 3, 16, 1); OLED_RefreshPage(2);逻辑说明:OLED_GRAM是二维数组暂存的完整画面内容。每次更新数值时,先调用OLED_ShowNum把新数字写入显存对应位置,然后只把这一页的数据通过I2C发出去。因为显示数字只落在固定的若干页,所以刷两页就够了,刷新时间从几百毫秒降到十几毫秒。带参数说明:页地址0~7,列地址0~127。显示一个16×16的中文占两页,一个16×8的数字占一页。刷新前先想清楚这个内容占据哪些页,把页号写死就行,不需要每次扫描全部8页。
中文取模是另一个坑。OLED要显示“自动”“手动”“智能净化器”这类中文,需要先用取模软件(比如PCtoLCD2002)生成16×16点阵字模。网上教程大多是在Windows上用取模软件生成C51格式的数组,然后拷进STM32工程里。但如果你在Linux下用gcc交叉编译,源文件默认UTF-8编码,而取模软件生成的是GBK/GB2312编码——同一个汉字在两种编码下的字节完全不同,显示就会变成乱码或者把后面的数组错位。网上搜“stm32 gbk转utf8”能看到一堆相关提问,我自己的处理方式是:工程里统一只用UTF-8,字模用脚本生成或者让取模软件直接输出Unicode编码。如果在Windows下开发,就把源文件编码固定成GB2312,别混着用。
5.2 按键扫描与消抖:状态机比HAL_Delay更适合
按键的抖动是机械触点固有的问题,按下和释放瞬间会有约5~20ms的电压毛刺。如果用HAL_Delay(10)这种阻塞式消抖,主循环里其它任务全被卡住了。改进方案是用定时器每2ms扫描一次按键,通过状态机判断按键是否进入稳定按下状态。
typedef enum { KEY_IDLE, // 空闲 KEY_PRESSING, // 可能按下 KEY_PRESSED, // 确认按下 KEY_RELEASING // 可能释放 } key_state_t; static key_state_t key_state = KEY_IDLE; static uint8_t key_scan_cnt = 0; // 每2ms在定时器中断里调用一次 void Key_Scan(void) { uint8_t key_down = (HAL_GPIO_ReadPin(MODE_KEY_GPIO_Port, MODE_KEY_Pin) == GPIO_RESET); switch (key_state) { case KEY_IDLE: if (key_down) { key_state = KEY_PRESSING; key_scan_cnt = 1; } break; case KEY_PRESSING: if (key_down) { if (++key_scan_cnt >= 5) // 连续5次(10ms)检测到低电平 { key_state = KEY_PRESSED; Key_Action(); // 确认按下,执行逻辑 } } else { key_state = KEY_IDLE; // 还没到10ms就松开,视为抖动 key_scan_cnt = 0; } break; case KEY_RELEASING: if (!key_down) { if (++key_scan_cnt >= 5) { key_state = KEY_IDLE; key_scan_cnt = 0; } } else { key_state = KEY_PRESSED; key_scan_cnt = 0; } break; default: key_state = KEY_IDLE; break; } }这个状态机的逻辑是:检测到低电平后不立即响应,而是连续5次扫描都保持低电平(即10ms)才认定真正按下。5这个参数对应2ms扫描周期,合计10ms,越过机械抖动窗口。它比阻塞式延时的好处是整个扫描过程不占用CPU,在主循环的其他任务照常运行。要注意的是key_scan_cnt的类型是uint8_t,最大只能到255,但这里只用到5,完全够用。
按键事件和模式逻辑要分开:Key_Action里只做两件事,一是切换自动/手动模式,二是手动模式下加减风速档。切换模式时把LCD状态、蜂鸣器提示都统一处理掉,不要散落在外设回调里。
5.3 掉电保存:把用户阈值写进STM32内部Flash
净化器的自动阈值或者用户设定的手动风速,如果断电后全丢回默认值,体验会很差。但STM32F103C8T6没有真正的EEPROM,常见做法是把配置存进内部Flash的最后一个页。F103C8T6有64KB Flash,最后一页的地址是0x0800FC00,大小1KB。写Flash要小心,流程是:解锁→擦除整个页→写入数据→重新上锁。擦写次数限制在1万次左右,所以不能每次都写,只在配置变更时写一次。
#include "stm32f1xx_hal.h" #define APP_CFG_ADDR 0x0800FC00UL // 64KB器件的最后一页 #define APP_CFG_MAGIC 0xA5A5A5A5UL // 魔数,用来判断是否有效配置 typedef struct { uint32_t magic; // 魔数 uint8_t mode; // MODE_AUTO / MODE_MANUAL uint8_t fan_level; // 手动模式的风速记忆 uint16_t thres_high; // 自动模式高速阈值 uint16_t thres_low; // 自动模式低速阈值 uint8_t crc; // 简单异或校验 } sys_cfg_t; // 保存配置,只调用一次 void App_SaveConfig(sys_cfg_t *cfg) { uint32_t addr = APP_CFG_ADDR; cfg->crc = 0; uint8_t *p = (uint8_t *)cfg; for (uint8_t i = 0; i < sizeof(sys_cfg_t) - 1; i++) cfg->crc ^= p[i]; HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_cfg = { .TypeErase = FLASH_TYPEERASE_PAGES, .PageAddress = APP_CFG_ADDR, .NbPages = 1 }; uint32_t err = 0; HAL_FLASHEx_Erase(&erase_cfg, &err); // 必须先擦除,Flash只能把1写成0 uint32_t *data32 = (uint32_t *)cfg; for (uint8_t i = 0; i < sizeof(sys_cfg_t) / 4; i++) HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i * 4, data32[i]); HAL_FLASH_Lock(); } // 上电时加载配置,失败则使用默认值 void App_LoadConfig(sys_cfg_t *cfg) { sys_cfg_t *p = (sys_cfg_t *)APP_CFG_ADDR; if (p->magic == APP_CFG_MAGIC && p->crc == 0xAA) // 这里需要重新算,实际用好的校验 { *cfg = *p; // 直接读取 } }逻辑说明:写入前先做整页擦除,因为Flash存储器的特性是只能从1变到0,要重新写入前必须先擦成全1。HAL_FLASHEx_Erase是HAL库提供的页擦除接口,需要传入的TypeErase是FLASH_TYPEERASE_PAGES。写入用HAL_FLASH_Program按32位字编程,每次4字节。魔数的作用是上电读取时判断这个地址里是不是真的存过我们自己的配置——如果Flash是全0xFF,说明从没写过,就采用默认配置。CRC校验我这里只是示意,完整实现要循环遍历结构体算异或值,否则掉电写入错误时恢复不了默认值,这点在实际调试时会被反复问到。
加载时我留了个“实际用好的校验”的注释,意思是不论写入还是加载,都要用同一个校验函数,不能只靠魔数。你可以在主函数初始化时先调用App_LoadConfig,读取成功后把mode和fan_level赋给你那两个状态全局变量,这样净化器断电重启后还能保持上一次的风速档位。
6. 联调避坑指南:四个能让课设当场翻车的点
6.1 现象:风机一转,PM2.5读数就暴涨
风机不转时数据是35,一开机转起来变成200多,纯纯的“自产污染”。原因基本是电源干扰:风机电流大,MOS管开关瞬间在电源轨上产生噪声,传感器供电跟着抖,ADC采集到的是噪声而不是真实的粉尘浓度。解决方法是给传感器的电源加一个LC滤波(串一个10~100Ω电阻并联一个100μF电容),ADC采样避开风机刚切换的那100ms,等电源稳定再采。另外让传感器信号线远离风机电源线,不要在面包板上平行走太长。
6.2 现象:DHT11永远读回来0xFF或校验和错误
DHT11的数据线被拉死了。原因有两种,第一种是GPIO没有配成开漏输出模式,推挽输出下总线电平被主机强拉,DHT11无法把总线拉低;第二种是主机拉低起始信号的时间不够,DHT11没有正确响应。解决方法是把PB10配置为开漏输出带上拉,在CubeMX里GPIO模式选“Open Drain”,外部再接4.7kΩ上拉;起始信号延时严格按照DHT11手册用20ms而不是18ms,宁长勿短。
6.3 现象:TIM1配置完全正确,PA8就是没有PWM波形
这是高级定时器的经典坑。Cortex-M3内核里的TIM1、TIM8属于高级定时器,有刹车和死区功能,默认状态下主输出(MOE)是不使能的。你光设置了CCR比较值和ARR,没开MOE,波形永远出不来。CubeMX里在TIM1的Break and Dead-Time Management里把Break Polarity设成Disable,或者干脆在初始化后加一行__HAL_TIM_MOE_ENABLE(&htim1)。这是每颗STM32高级定时器都要面对的问题,不需要背诵,但要知道它存在。
6.4 现象:下载一次程序后,第二次Keil找不到芯片
最吓人的问题,十个人里有三个这么干过:把SWDIO(PA13)或SWCLK(PA14)复用成了别的功能,或者JTAG引脚被代码改成普通GPIO。程序烧进去后调试口没了,芯片像个黑匣子一样不响应。CubeMX里SYS选Serial Wire模式,把它选上之后就不要再把PA13/PA14配成其它功能。如果已经锁死,按住复位脚不放,在Keil设置里选Connect under Reset,多试几次能救回来;实在不行只能上串口ISP,用内置bootloader擦除整片Flash。这条经验我每次画板子都会检查一遍,已经被坑过太多次。
联调时我的习惯是整个系统只留一个串口打印口(UART1),所有传感器数值用printf输出到串口调试助手,看到哪个环节的数据异常,就顺着链路一步往回查。别指望OLED显示能当调试工具——屏幕刷新太快会闪,刷新太慢又看不到瞬时变化,它只适合给人看最终结果,不适合给人找问题。自己做课设时,串口是你的眼睛,OLED是用户的窗口。做这个项目最大的收获,就是建立起一个观念:凡是传感器数据,先怀疑电源,再怀疑时序,最后才是代码逻辑。我当年在TIM1的刹车配置上卡了一下午,最后定位到是MOE没使能,从此给高级定时器配PWM都默认先加那行使能代码。这一套搭下来,你的STM32外设基本功就基本齐了,希望帮到你。
本文还有配套的精品资源,点击获取