直接进入主题,聊聊这个我最近整理完并决定开源的多功能智能温控风扇系统。项目基于STM32F103C8T6,也就是大家常说的蓝丸核心板,配合DHT11温湿度传感器、0.96寸OLED屏、L9110S电机驱动和一颗8550三极管驱动的普通5V风扇。软硬件全套开源,包含Keil工程源码、Altium Designer原理图源文件,以及Proteus仿真工程,文末会给出获取方式。
先说结论:这套系统做完,你基本就把STM32的GPIO、ADC、定时器PWM、I2C、中断、串口这些常用外设全过了一遍。对于正在学STM32、或者想做一个能放进简历里的完整项目的朋友来说,它的价值不在于“智能”两个字,而在于它是一条非常完整的嵌入式开发链路:从需求分析、器件选型、原理图绘制,到PCB焊接、驱动调试、控制策略调参,再到仿真验证,每一个环节都会逼着你踩坑,而踩坑本身就是最大的收获。
1. 项目整体设计与思路拆解
1.1 核心需求解析:这台风扇到底要“智能”在哪里
市面上几十块钱的USB小风扇,电机一转就是全速,噪音大还费电。我们这个项目的核心需求其实就三条:自动调速、状态可视、手动干预。
- 自动调速:根据环境温度变化,自动调整风扇转速。温度低时低速甚至停转,温度高时全速运转,中间过程平滑过渡,不能出现“咔哒”一下突然满速的突兀感。
- 状态可视:通过OLED屏幕实时显示当前温度、湿度、风扇转速百分比,以及当前处于自动模式还是手动模式。
- 手动干预:保留两个物理按键,一个用来切换自动/手动模式,另一个在手动模式下用来手动加减风速。自动模式下这个按键可以设定温度阈值。
这套需求在工业上其实就是典型的闭环温控系统雏形,只不过我们用的是消费级器件,控制对象是一个直流风扇而已。把“温度-转速”映射关系做好,以后你去做恒温箱、智能散热器、甚至小型温室控制系统,核心逻辑都是一模一样的。
1.2 方案选型:为什么是STM32F103C8T6而不是Arduino或51
很多新手问我,这种小项目用Arduino Nano或者STC89C52不香吗?香,但达不到学习目的。
- STM32F103C8T6:Cortex-M3内核,72MHz主频,20KB RAM,64KB Flash。在2024年的今天,这芯片已经成为嵌入式入门的“事实标准”。它的优势在于外设丰富且逻辑清晰,GPIO复用、AFIO重映射、定时器PWM输出、ADC多通道采集,这些概念一旦掌握,迁移到任何其他ARM芯片都可以快速上手。
- Arduino:封装太狠了,
analogRead()、digitalWrite()一个函数全搞定,底层寄存器一辈子接触不到。它适合做快速原型验证,但不适合用来理解计算机组成原理和嵌入式底层逻辑。 - 51单片机:结构简单到感人,但8位架构、12MHz主频、没有硬件I2C和硬件PWM,做这个项目会非常痛苦,而且学完51再学STM32等于二次入门。
说白了,这个项目选STM32,是想让你直接站在一个“准工业级”的平台上开始,虽然入门曲线陡一点,但收益上限高得多。
1.3 系统架构与工作流程
整个系统的工作流程可以用一句话概括:采集-处理-输出-显示。
传感器(DHT11)负责采集温湿度,通过单总线协议把数据发给STM32;STM32内部跑一个控制算法(我们用的是分段线性映射+PID闭环),根据当前温度计算出目标占空比;然后通过定时器输出PWM波控制电机驱动芯片,从而改变风扇转速;同时OLED通过I2C总线实时刷新显示数据。
系统支持两种工作模式:
| 模式 | 触发方式 | 行为描述 |
|---|---|---|
| 自动模式 | 系统上电默认进入 | 温度低于阈值下限(默认25℃),风扇停转;25℃-35℃区间线性调速;高于35℃满载运行 |
| 手动模式 | 按KEY1切换进入 | 通过KEY2循环调节风速占空比,20%/40%/60%/80%/100%五档 |
阈值温度存储在STM32内部Flash的最后一个扇区,通过长按KEY2进入阈值设置模式,用短按加减、长按确认的方式修改。这样即使断电重启,设定的温度阈值也不会丢失。
2. 核心硬件设计与电路解析
2.1 STM32最小系统与电源设计:晶振电容到底怎么算
这里有一个非常典型的“新手必踩坑”知识点——晶振电容计算。我用的外部8MHz晶振,两个负载电容选的是20pF。很多人直接抄参考设计用20pF,但实际上这个值应该根据晶振的负载电容参数来计算。
计算公式是:C_load = (C1 * C2) / (C1 + C2) + C_stray
其中C_stray是PCB走线和引脚产生的寄生电容,经验值在3pF到6pF之间。我们用的晶振负载电容规格是20pF,那么:
C1 = C2 = 2 * (C_load - C_stray) = 2 * (20 - 5) = 30pF
但我实测下来,用20pF电容也完全正常工作,因为STM32内部有反馈电阻和整形电路,对晶振匹配要求并不严苛。所以你在参考设计里看到20pF还是30pF都不奇怪。真正要注意的是:如果系统跑在低温环境,或者对时钟精度要求高的场合(比如CAN通信、USB),一定要按公式计算,否则可能低温起振失败,那排查起来相当痛苦。
电源部分我用了最简单可靠的方案:USB 5V输入,经过一颗AMS1117-3.3稳压到3.3V给MCU供电,风扇和电机驱动直接吃5V。注意AMS1117的最大压差是1.2V左右,输入5V输出3.3V完全没问题,但输入不能低于4.5V,否则稳压会失效。如果你打算用3.7V锂电池供电,就得换LDO或者直接用DCDC。
2.2 温度采集:DHT11还是DS18B20,我为什么选了前者
这个项目定位是“多功能智能温控风扇”,不是精密仪器,所以温度传感器选型上我做了个权衡。
DHT11的优点是:便宜(一块多钱)、单总线协议简单、同时输出温度和湿度、不需要校准。缺点是采样周期慢(1Hz)、精度只有±2℃,分辨率1℃。DS18B20的精度更高(±0.5℃),但只能测温度不能测湿度。
考虑到风扇应用本身对温度精度要求不高(35℃还是36℃对风扇转速影响微乎其微),而且DHT11多一个湿度输出,正好可以展示OLED显示更多数据,所以最终选了DHT11。如果你手头有DHT22(也叫AM2302),直接替换即可,因为两者的单总线时序是非常接近的,只需要调整延时参数。
DHT11的VCC和DATA引脚之间一定要接一个4.7k到10k的上拉电阻。我见过太多新手直接不接上拉电阻,结果读出来永远是0xFF。原理很简单,单总线协议里,主机拉低总线发起起始信号之后就要释放总线,靠上拉电阻把电平拉回到高电平,DHT11在应答的时候再去拉低总线。没有上拉电阻,总线就一直浮空,通信必失败。
2.3 电机驱动:L9110S与8550三极管的取舍
这里可能是这个项目里最有“设计感”的地方。市面上主流的风扇驱动方案有两种:
方案一:L9110S电机驱动芯片
手册上标称是“直流电机驱动/风扇驱动专用芯片”,内部集成了两个H桥驱动通道。这个芯片实际就是两个达林顿管对,支持1.5A持续电流。用法非常简单:IB引脚接PWM,IA引脚接GND(或接高电平作为刹车),OB和OA接电机两端。但是,我实际测下来,这颗芯片的导通压降比较大,5V供电时风扇两端实际电压只有3.6V左右,转速明显打了折扣。这是因为L9110S内部用的不是MOSFET,而是达林顿晶体管,饱和压降天生偏大。
方案二:纯NPN/PNP三极管驱动
我这里用了一颗8550(PNP三极管)做低速开关,通过STM32的PWM直接控制三极管的基极。5V电压几乎全部施加在风扇两端(饱和压降只有0.3V左右),风扇能跑满速。电路也极其简单:5V → 风扇正极,风扇负极 → 8550集电极,8550发射极 → GND,8550基极通过1kΩ电阻接STM32的PB1引脚。
我最终采用的是方案二。理由有三:
- 省一颗芯片,成本更低,焊接更简单。
- 5V风扇满速运行,转速更高,温控调节范围更大。
- 8550三极管基极限流电阻的计算可以顺便教学。
基极电流的计算是:IB_max = (3.3V - 0.7V) / 1kΩ ≈ 2.6mA。8550的hFE(直流放大倍数)通常在100-300之间,所以集电极最大能承受的电流是IB * hFE ≈ 260mA-780mA。我们用的5V风扇额定电流一般不超过300mA,所以1kΩ的限流电阻是完全够用的。但是有一点需要注意:PWM频率不能太高,否则三极管会频繁工作在放大区而不是饱和区,会导致三极管严重发热甚至烧毁。我用的PWM频率是10kHz,这个频率下8550工作在开关状态没有任何压力。如果你要驱动的风扇电流更大(超过500mA),建议换成MOSFET(比如AO3400A),用逻辑电平直接驱动,导通电阻小到可以忽略。
2.4 人机交互:按键处理与OLED显示
按键部分我用了两个轻触开关,接在有上拉电阻的GPIO上,按下为低电平。这种“按键+上拉”的接法是最常规的,关键是必须做软件消抖,硬件上不额外焊接RC滤波电容。消抖的方式是检测到低电平后延时20ms再确认一次,如果电平没变化才认为是有效按键。这个延时用SysTick或者简单的for循环都可以做出来,但注意在调用的主循环里,消抖延时期间MCU是阻塞的,如果这时候PWM输出会短暂卡顿——解决方案是消抖时改用定时器按键扫描,或者用状态机思想非阻塞消抖。我这个项目里因为主循环非常快(不到1ms),20ms的阻塞影响不大,所以就用了简单粗暴的延时消抖。
OLED屏用的是0.96寸I2C接口版本(SSD1306驱动),分辨率128x64。I2C地址默认0x3C,但极少数屏幕是0x3D,如果屏幕不亮,第一件事就是检查地址。接线非常简单:VCC接3.3V,GND接GND,SCL接PB6(I2C1_SCL),SDA接PB7(I2C1_SDA)。这里有个建议:优先用硬件I2C而不是软件模拟I2C。虽然网上很多人说STM32的硬件I2C有坑,但我实测F103系列的硬件I2C在标准模式(100kHz)下是非常稳定的。使用硬件I2C的好处是CPU占用率极低,OLED刷新不阻塞主循环。如果你非要软件模拟I2C,那么注意把引脚配置为开漏输出并加上拉电阻,否则通信时序会不达标。
2.5 原理图设计要点:别让布局毁了你的项目
原理图源文件是AD(Altium Designer)格式。虽然现在的年轻人很多用立创EDA(免费、在线、国产),但我早期项目都是用AD画的,所以开源出来就是AD格式。如果你没有AD,用立创EDA可以直接导入AD的.SchDoc文件。
绘制原理图时我按功能模块分区排列:左上角电源模块,右上角MCU最小系统,左下角传感器接口,右下角电机驱动和执行器。每个模块之间用网络标签连接,不直接画飞线。这样当你在PCB阶段需要修改某一部分时,可以快速定位到对应的原理图区域。
几个值得注意的细节:
- STM32F103C8T6的BOOT0引脚通过10kΩ电阻接地(即从上Flash启动)。如果你不小心把BOOT0接到了3.3V,那固件烧录完之后运行的不是你的程序,而是系统Bootloader,现象就是“程序烧进去了但是一上电没反应”,很多人会误以为芯片烧了。
- NRST引脚上的100nF电容确实可以省,但是加上它能让系统复位更可靠,尤其是在电源波动比较大的场合。成本几分钱,不建议省。
- VDDA/VSSA引脚必须接3.3V和GND,并且要在靠近引脚的地方放一个1μF的电容到地。如果VDDA悬空或者电压不稳,ADC采集的温度值会漂得你怀疑人生。
2.6 成本盘点:开源项目的钱都花在哪了
很多开源硬件项目的问题在于,作者只顾展示代码,完全不提成本核算。但实际做一个项目,成本永远是第一约束。我整理了BOM表的核心成本(批量购买价,非零售价):
| 物料 | 数量 | 单价 | 小计 |
|---|---|---|---|
| STM32F103C8T6 | 1 | ¥3.50 | ¥3.50 |
| 0.96寸OLED SSD1306 | 1 | ¥7.00 | ¥7.00 |
| DHT11温湿度模块 | 1 | ¥1.80 | ¥1.80 |
| L9110S电机驱动(未用,只作对比) | 1 | ¥0.80 | - |
| 8550三极管 | 1 | ¥0.05 | ¥0.05 |
| 5V直流风扇 | 1 | ¥2.50 | ¥2.50 |
| 按键/电阻/电容/排针等杂项 | 若干 | 约¥3.00 | ¥3.00 |
| 5V电源(USB供电/充电头) | 1 | 已有或¥5 | 不计 |
| 合计 | ¥17.85 |
整体不到20块钱,这在所有“智能”硬件项目里算是相当便宜的了。如果你自己做PCB打样,嘉立创5块钱5片的低价板足够你用,整体成本可以控制在25元以内。硬件开源项目的价值就在于这里,同样的功能,淘宝成品卖你五六十,自己做二十几块搞定,还能学到全套技术。
3. 软件架构与代码实现
3.1 Keil工程结构:模块化是嵌入式开发的底线
代码按照功能模块拆分成多个文件,而不是把所有逻辑都塞进main.c。这样做的原因是:一旦项目规模上来,单文件几千行会让你改一个bug恨不得把整个文件重写,而模块化的结构可以让你“只改需要改的地方,不影响其他模块”。
工程目录结构如下:
SmartFan/ ├── Core/ │ ├── Inc/ (头文件) │ └── Src/ (main.c, stm32f1xx_it.c等) ├── Drivers/ │ ├── Inc/ (stm32f1xx_hal_conf.h等) │ └── Src/ (stm32f1xx_hal.c等HAL库源码) ├── Hardware/ │ ├── dht11.c/.h (DHT11温湿度传感器驱动) │ ├── oled.c/.h (OLED显示驱动) │ ├── key.c/.h (按键扫描与消抖) │ ├── motor.c/.h (PWM电机控制) │ └── flash.c/.h (内部Flash读写,用于存储阈值) └── User/ └── main.c (主逻辑)这里我使用的是**标准外设库(Standard Peripheral Library)**而不是HAL库。为什么?原因很真实:标准库的函数名和寄存器操作是一一对应的,比如GPIO_WriteBit、TIM_SetCompare1,你看一眼函数名就知道它在操作什么硬件。HAL库封装得太抽象,新手调PWM出问题时,你都不知道该查哪个回调函数。标准库代码量更少更直白,虽然配置步骤繁琐一点,但每一步你都知道在干什么。等你自己用标准库把PWM和I2C调通了,再去切HAL库,会顺畅很多。
3.2 外设初始化:GPIO、定时器PWM、I2C、ADC、串口一次配齐
外设初始化这部分是STM32开发的“大头”,很多人卡在这一步就放弃了。我这里把关键配置逻辑拆开讲。
GPIO初始化(以PWM输出引脚PB1为例):
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure);注意GPIO_Mode_AF_PP,这是复用推挽输出模式,意思是这个引脚由片上外设(如定时器)控制,而不是普通的GPIO输出。如果你用了GPIO_Mode_Out_PP(普通推挽输出),那PWM波形就出不来了。
定时器PWM配置(TIM3_CH1,72MHz时钟,10kHz PWM):
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Period = 72 - 1; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = 100 - 1; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 36; // 初始占空比50% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE);PWM频率的计算公式:PWM_Freq = 72MHz / (Prescaler + 1) / (Period + 1)。代入数值:72,000,000 / 100 / 72 = 10,000Hz,正好是10kHz。为什么选10kHz而不是1kHz?因为PWM频率越高,风扇电流的纹波越小,电机运行越安静,而且听不到人耳敏感的“吱吱”声。但频率太高芯片的开关损耗会增加,10kHz是直流无刷电机风扇驱动的甜点频率。
硬件I2C配置(I2C1,标准模式100kHz):
I2C_InitTypeDef I2C_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_ClockSpeed = 100000; // 100kHz I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_OwnAddress = 0x30; I2C_Init(I2C1, &I2C_InitStructure); I2C_Cmd(I2C1, ENABLE);使用硬件I2C时,GPIO的配置必须是GPIO_Mode_AF_OD(复用开漏输出),同时外部要有上拉电阻。STM32的内部上拉太弱,带不动I2C总线,所以板上的SCL/SDA必须外接2.2k-4.7kΩ上拉电阻。
ADC配置(这里我额外增加了一个电位器接口用于模拟温度输入,以便在没有DHT11也能测试风扇调速逻辑):
ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; 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_55Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1));ADC采集完成后,读取ADC_GetConversionValue(ADC1),这个12位ADC的值范围是0-4095,对应0-3.3V。如果你用10k电位器分压,3.3V供电,那电压值 V = ADC_Value * 3.3 / 4095,温度映射值就是 V * 100 / 3.3(假设电位器0-100%对应0-100℃)。
串口重定向:调试时用串口打印信息非常方便,这里把printf重定向到USART1:
int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }USART1的PA9(TX)和PA10(RX)记得要接USB转TTL模块(比如CH340模块)才能和电脑通信。波特率我用的115200,8N1,这些要和串口助手里设置一致。
3.3 DHT11驱动时序:单总线协议的精髓
DHT11的驱动是整个项目里最考验耐心和时序精度的模块。单总线协议是一次性传输40位数据(8位湿度整数+8位湿度小数+8位温度整数+8位温度小数+8位校验和),每一位的“0”和“1”是靠高电平持续的时间长度区分的。
具体的时序是:
- 主机拉低总线至少18ms(启动信号),然后释放总线20-40μs,等待DHT11响应。
- DHT11拉低总线80μs作为应答信号,然后拉高80μs准备发送数据。
- 每发送1位数据前,DHT11先拉低50μs,然后拉高。如果高电平持续26-28μs表示“0”,持续70μs左右表示“1”。
驱动代码核心部分:
uint8_t DHT11_ReadByte(void) { uint8_t i, byte = 0; for (i = 0; i < 8; i++) { while (DHT11_IN_PIN == GPIO_PIN_SET); // 等待低电平结束 Delay_us(40); // 高电平40μs后采样 if (DHT11_IN_PIN == GPIO_PIN_SET) byte |= (1 << (7 - i)); // 高电平仍持续 -> 1 while (DHT11_IN_PIN == GPIO_PIN_RESET); // 等待高电平结束 } return byte; }这里的核心逻辑是:等待低电平结束前,总线处于低50μs的状态;低电平结束后的高电平如果是28μs,你延时40μs再采样,此时高电平已经结束变成低电平,所以读到0;如果高电平是70μs,你延时40μs后再采样,高电平还在,读到1。
有一次并行采集时发现DHT11读数始终是0x00,后来排查发现是主机启动信号的拉低时间不够18ms,只有10ms左右,DHT11根本没有可靠地识别到启动信号。所以这里强提醒:启动信号拉低时间务必大于18ms,用Delay_ms(20)最稳妥。
3.4 控制算法:从分段线性到PID闭环
温控风扇的核心是“温度-转速映射”。最基础的实现是分段线性映射,这个逻辑非常简单:
uint16_t Fan_Speed_Map(float temperature) { uint16_t duty = 0; if (temperature <= TEMP_LOW_THRESHOLD) { // 25℃以下 duty = 0; } else if (temperature >= TEMP_HIGH_THRESHOLD) // 35℃以上 { duty = 100; } else { // 线性映射 duty = (uint16_t)((temperature - TEMP_LOW_THRESHOLD) / (TEMP_HIGH_THRESHOLD - TEMP_LOW_THRESHOLD) * 1000); if(duty > 1000) duty = 1000; } return duty; // 返回0-1000,对应0%-100.0%占空比 }分段线性映射的问题在于:环境温度在阈值附近波动时,风扇转速会来回跳动。比如25.1℃时转速5%,25.0℃时转速0%,然后因为风扇停转导致局部温度上升,又到25.1℃风扇转起来,循环往复。
解决方案是加迟滞(Hysteresis):在升温和降温两个方向上使用不同的阈值曲线,比如升温到26℃启动,降温到24℃停止。这样系统就有一个“死区”,不会在阈值附近震荡。这个技巧和空调压缩机控制里的“停机温度低于启动温度”是一个道理。
另外,为了展示PID控制的实用性,我在项目中增加了PID闭环的风扇转速控制。用户设定目标温度(比如30℃),PID控制器根据“当前温度-目标温度”的误差动态调节PWM占空比。参数我调了三组,放在代码宏定义里方便切换:
#define PID_KP 30.0f #define PID_KI 0.2f #define PID_KD 80.0f这三个参数不是拍脑袋定的。F103的PWM周期是10kHz,但PID计算周期我放在了1kHz定时器中断里(每1ms执行一次控制算法)。误差单位是0.1℃(即DHT11的分辨率经过10倍放大),输出是0-1000的占空比。Kp=30表示1℃误差贡献300/1000的占空比,Kd=80用来抑制温度突变时的过冲,Ki=0.2比较小,负责消除稳态误差。
实际调参经验是:先把Kp调到一个值(比如10),让系统响应速度尚可且不超调;再加Kd把超调压下去;最后加Ki消除静差。如果你发现风扇转速一直在震荡(忽快忽慢),大概率是Kp太大或者Kd太小。我一开始Kp直接给到60,结果风扇转速像一个“哮喘病人”一样剧烈波动,后来压到30并加了适当的微分项才稳定下来。切记:PID参数调优不是一个数学问题,而是一个工程问题,最终以实际系统响应为准。
3.5 主循环逻辑:状态机的使用
主循环不用一个巨大的while(1)嵌套if-else,而是用状态机的方式管理不同模式:
typedef enum { STATE_AUTO = 0, STATE_MANUAL, STATE_SETTEMP } FanState; FanState currentState = STATE_AUTO; while (1) { Key_Scan(); // 非阻塞按键扫描 switch (currentState) { case STATE_AUTO: DHT11_Read(&temp, &humi); duty = Fan_Speed_Map(temp); Motor_SetDuty(duty); OLED_ShowStatus("AUTO", temp, humi, duty); break; case STATE_MANUAL: if (key2Pressed) { manualDuty += 20; if (manualDuty > 100) manualDuty = 20; Motor_SetDuty(manualDuty); } OLED_ShowStatus("MANUAL", temp, humi, manualDuty); break; case STATE_SETTEMP: // 阈值设置逻辑 break; default: break; } Delay_ms(100); // 主循环周期100ms,兼顾按键响应和显示刷新 }状态机的优势很明显:每个模式下的行为被隔离在不同的case里,逻辑清晰,加新功能只加一个枚举和一个case即可。主循环周期100ms,DHT11的采样需要1秒以上间隔,所以我在DHT11读取函数里加了时间戳判断,不到1秒不重新读,而是沿用上一次的缓存数据。这样既不会违反DHT11的最小采样周期,又不会因为反复读取导致通信失败。
4. Proteus仿真与实际硬件调试
4.1 Proteus仿真的意义:软件先跑通,心里才有底
Proteus仿真不是PCB的替代品,但它是代码逻辑验证的利器。你把写好的HEX文件加载到Proteus的STM32模型里,它就能跑起来。虽然Proteus仿真DHT11需要额外添加库模型,但OLED和按键、LED这些外设的仿真非常真实,你可以直观地看到每一个引脚上的电平变化。
仿真工程里我搭了这样一套环境:
- STM32F103C8T6模型
- LM35温度传感器模型(用来替代DHT11,原因是我有它的Proteus库模型而DHT11的模型在Proteus 8.9版本下不太好用)
- 一个虚拟终端(Virtual Terminal)显示串口打印数据
- 一个虚拟示波器观察PWM波形
- 4个LED灯模拟风扇转速等级(0%-25%-50%-75%-100%)
要注意的是,仿真不是万能的。Proteus的时序模型和真实硬件有差异,尤其是DHT11这种单总线协议,仿真跑通了不代表真实硬件能跑通;反过来,真实硬件有接触不良的问题,仿真里永远不会出现。所以我的建议是:仿真用于验证“大逻辑”(状态切换、PWM输出、显示刷新这些),真实硬件用于验证“小细节”(上拉电阻、波形时序、电磁干扰这些)。
4.2 硬件焊接与调试:第一次上电前必须检查的5个点
拿到PCB(或者面包板)之后,先别急着焊STM32,按以下步骤依次确认:
- 电源短路检查:用万用表蜂鸣档测3.3V和GND之间电阻,如果接近0Ω,说明有短路,此时绝对不能上电。常见短路点是AMS1117的输入输出脚焊反了、去耦电容焊错位置、ST-Link的3.3V跳线帽没拔。
- MCU供电确认:上电后用电压表测STM32的VDD引脚,应该是3.3V±0.1V。如果低于3.0V,芯片可能进入欠压复位状态,程序跑不起来。
- 复位电路检查:NRST引脚在正常工作时应为高电平,如果被拉低可能是复位电容漏电或者按钮卡住。
- 晶振振荡确认:用示波器(或逻辑分析仪)测8MHz晶振一脚,应该能看到正弦波。没有示波器的同学可以写一个闪灯程序,如果LED以预期频率闪烁,说明时钟系统正常。
- 烧录器连接:ST-Link的SWDIO(PA13)、SWCLK(PA14)、GND、3.3V四条线接好。如果是SWD模式,注意不要共用一个3.3V,直接用ST-Link给板子供电是最省心的。
我第一次在面包板上搭电路时,因为懒得焊一个排针座,直接杜邦线怼上去,结果SWCLK引脚接触不良,联调了半个小时才发现是物理接触问题。从那以后,所有调试接口一律焊排针,成本几分钱,省下的时间无数。
4.3 常见问题与排查技巧实录
在开发这个项目的过程中,我整理了以下几类高频问题,非常适合排障时对照:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 烧录时报"Error: Flash Download failed - Cortex-M3" | 芯片锁死或供电不足 | 按住复位键点烧录,出现烧录进度时再松开 |
| ST-Link连接失败,报"No STM32 Target Found" | SWD引脚被禁用/供电异常/接线有误 | 检查VBAT/RESET是否接对,BOOT0拉高进入ISP模式再连接 |
| OLED不亮或花屏 | I2C地址错误/时序过快/电压不足 | 检查0x3C还是0x3D;I2C速率降到10kHz试一下 |
| DHT11读数为0xff | 上拉电阻缺失/启动信号太短 | 确认DATA引脚是否有4.7k上拉到VCC;启动信号延时加到20ms |
| 风扇不转 | PWM占空比为0/8550没进入饱和 | 用万用表测8550集电极电压,若为5V则管子未导通 |
| 温度跳变剧烈 | 采集间隔太短/DHT11靠近热源 | 增加读取间隔到2秒;把传感器远离MCU和LDO |
这里特别说一下“烧录失败”这个问题。STM32F103C8T6的价格被卷到3块多钱之后,市面上的芯片来路复杂,有一部分是翻新片或者“剪脚片”。如果你反复遇到“Flash Download failed”,注意检查芯片的顶面丝印,如果是打磨过的(表面有明显磨砂痕迹或者丝印模糊),那大概率是拆机片,Flash可能存在某些扇区损坏。建议直接换一家信誉好的店铺买新片,别在调试环境上浪费时间。
4.4 现场实测数据:从仿真到真机的性能差异
仿真环境下,我看的是波形是否符合预期,而真实硬件环境下,我会用串口把这些数据打出来看:
- 温度从25℃升到40℃时,PWM占空比从0%线性升到100%,整个过程持续约35秒(风扇吹风影响了温度变化速率)。
- PID闭环模式下,设定目标温度30℃,环境温度28℃时,系统输出占空比稳定在51%附近,波动±3%。
- 在PWM频率10kHz、占空比50%时,用示波器测风扇两端电压,波形方波特征明显,上升沿约200ns,下降沿约300ns,说明8550的开关速度完全够用。
- 用万用表测STM32F103C8T6的工作电流大约35mA(OLED点亮、DHT11空闲),风扇满载电流约200mA。整机满载电流不超过350mA,USB供电完全无压力。
实测中我发现的唯一“反直觉”现象是:风扇转速变高后,温度反而可能不降反升,因为风扇轴承摩擦热和气流湍流混合在一起。后来我把DHT11移到了风扇吸风侧2cm处(而不是直面出风口),读数才稳定下来。这个细节在纯仿真环境里永远发现不了,只有真实硬件调多了才会积累出来。
5. 开源资源获取与后续扩展建议
5.1 开源仓库内容说明与使用建议
整个项目压缩包大约12MB,包含以下内容:
01-Schematic/ (AD格式原理图.SchDoc) 02-Code/ (Keil5工程源码) 03-Simulation/ (Proteus仿真工程) 04-Datasheet/ (DHT11, STM32F103C8T6, SSD1306数据手册) 05-BOM/ (物料清单Excel) README.md (项目说明、接线图、使用说明)拿到代码后,建议不要直接编译烧录。先打开README,把接线图对照你的板子确认一遍——尤其是DHT11的DATA引脚连的是PA0,PWM输出引脚是PB1,OLED的SCL是PB6,SDA是PB7,按键KEY1是PA1,KEY2是PA2,串口调试是PA9/PA10。接错一根线,轻则没反应,重则烧传感器或芯片。
硬件接线确认无误后,先编译烧录一遍原版代码,验证硬件链路是否正常工作。然后强烈建议做两件事:
- 看代码,改代码:把
DHT11_ReadByte的40μs延时改成30μs和50μs,看看现象有什么变化?这能帮你真正理解单总线时序的容错边界。 - 加功能:把风扇转速数据通过串口定时上报(比如每1秒打印一次
T:25.3C H:60% Fan:45%),串口助手接收数据后存成CSV,用Python画温度-转速曲线图。这样整个项目就从“单片机项目”升级成了“物联网传感器节点雏形”了。
5.2 从“能用”到“好用”:还能扩展的方向
这个项目做出来,风扇能根据温度自动转,已经是一个完整的“闭环系统”了。如果你还想继续升级,比较合理的路线有:
- 联网化:加ESP8266模块,通过UART与STM32通信,把温湿度和风扇状态发布到MQTT服务器。基础命令AT指令即可实现,核心代码可能不超过50行。
- 多传感器融合:增加第二路温度传感器(比如DS18B20挂在另一个GPIO),在OLED上同时显示两个位置的温度,做“温度场”监测。进阶一点可以用MPU6050震动传感器判断风扇是否堵转。
- 屏幕升级:换1.3寸或者2.4寸屏,用GUI库(LVGL)做更美观的界面,但F103C8T6的64KB Flash会非常吃紧,需要精细裁剪。
- 电源管理:改用3.7V锂电池供电,加TP4056充电模块和升压电路(MT3608的FP6291等),做便携版。注意如果电池电压低于3.2V,需要做低电量提示。
5.3 写在最后:做开源项目的一些心里话
这个项目前后做了一周多,焊坏过一块板子,调PID调了三个晚上,最后在风扇把桌上半个汉堡的香味吹得满屋都是的时候,我意识到这种“麻烦”其实就是嵌入式开发最真实的样子——它不像你刷短视频看到的那样酷炫,大多数时候是枯燥的datasheet阅读、示波器探笔找信号、还有一遍一遍烧录后盯着串口助手看数据。
但恰恰是这些看似枯燥的环节,让我在后来做更复杂的项目时少走了很多弯路。如果你能严格按照“看原理图-焊板子-烧代码-调参数”的流程完整走一遍这个项目,你对STM32的理解深度,会远超那些只会复制粘贴HAL库示例代码的人。
所有资料下载方式我在压缩包里都有说明。如果在复现过程中遇到问题,欢迎在评论区留言交流。尽量描述清楚你的“接法-现象-排查步骤”,这样我能更准确地帮你判断问题出在哪一环。祝各位都能顺利点亮属于自己的那块OLED屏。