1. 项目起因与整体方案:这个风扇凭什么"智能"
做这个项目的想法很直白——我台式机的散热风扇是四线的,支持PWM调速,但主板的温控策略一直不太聪明。要么温度都飙到80度了风扇还在低速转,要么开机就满转速吵得头疼。当时手头正好有一块STM32F103C8T6最小系统板,我就在想:干脆自己写一套温控逻辑,让风扇自己看着温度干活。这个项目就是这么来的,完全是实用主义驱动,不是实验室里凑出来的demo。
这个系统的核心价值很简单:用STM32采集环境温度,根据温度区间自动调整风扇转速。温度低就低速甚至停转,温度高就全速运转,中间用PWM平滑过渡,而不是像很多低端温控方案那样"温度到了阈值就突然满转"。整个项目开源了完整的代码、原理图和Proteus仿真工程,你拿过来可以直接看、直接烧录、直接复现,也可以把它改成鱼缸温度控制、机箱散热控制甚至植物大棚通风控制,可玩性比单一场景大得多。
对刚接触STM32的开发者来说,这个项目涵盖了一个完整嵌入式系统该有的所有基本面:GPIO操作、ADC采样、PWM输出、传感器时序读取、按键输入、OLED显示、PID算法(进阶版),而且每个模块都能单独拎出来看,不必上来就啃一大坨代码。对已经有经验的工程师来说,这个项目的参考价值在于整套系统的模块化设计思路和PID参数整定过程,尤其是从"阈值控制"到"PID控制"的演进路径,很多实际产品就是这么迭代过来的。
按照标题的三个关键词来拆解,代码部分我已经把工程整理成了标准STM32标准库工程,main函数干净利落,外设初始化全部封装成独立文件;原理图部分是用嘉立创EDA画的,包含主控最小系统、DS18B20接口、MOS管驱动电路、OLED显示接口和按键电路,生成过Gerber文件可以直接打板;仿真部分做了Proteus工程,方便没有实物硬件的同学先跑通逻辑。下面我把整个项目从硬件到软件一层层拆开讲清楚。
2. 硬件设计拆解:原理图里每一条线为什么要这么连
2.1 主控选型与最小系统:为什么用STM32F103C8T6而不是更便宜的51
主控选的是STM32F103C8T6,这颗芯片可以说是国内嵌入式学习领域的"国民芯片"了。选它有几个具体原因:第一,它有72MHz主频、20KB RAM、64KB Flash,跑一个温控风扇系统绰绰有余,连实时操作系统都不用上,裸机+前后台就非常流畅;第二,它内置3个USART、2个I2C、2个SPI、12位ADC和高级定时器,这些外设让项目后续扩展空间很大——比如加个蓝牙模块做手机控制、加个WiFi模块做联网上报,完全不用换主控;第三,它是LQFP48封装,手工焊接难度不高,学习成本低,资料丰富。
对比一下用51单片机做这个项目的方案,并不是不行,但有几个明显瓶颈:51的ADC是8位(还是模拟比较器实现的那种),PWM调速的分辨率不够细腻;DS18B20这种单总线协议在51上要靠软件模拟时序,也能跑,但代码可读性和稳定性都差一截。STM32在硬件层面就把这些问题解决了,ADC是12位的,定时器能直接输出硬件PWM,CPU负载非常低。
最小系统电路没什么黑魔法。供电部分我用的是AMS1117-3.3稳压芯片,输入5V输出3.3V,输入和输出端各加一个10uF电解电容和一个104瓷片电容做滤波。STM32的VDD引脚每个都接一个104去耦电容,这是最基本的规矩,很多人画最小系统图贪省事把去耦电容省了,导致芯片工作不稳定、莫名其妙复位甚至烧录失败,这都是教训。晶振用的8MHz无源晶振,两个20pF负载电容,这个数值是根据晶振规格书来的——晶振手册会标CL(负载电容)参数,实际匹配电容C = 2 * CL - C_stray,一般C_stray取3~5pF,算出来就是18~20pF左右,取整用了20pF,实测起振非常稳。复位电路就是标准的10K上拉电阻加104电容,NRST引脚拉低复位。BOOT0接10K下拉电阻接地,确保从Flash启动。
2.2 温度采集电路:DS18B20单总线协议与上拉电阻的数值讲究
温度传感器用的是DS18B20,这个是经典选择了。它是一颗数字温度传感器,测温范围-55℃到+125℃,精度在-10℃到+85℃范围内是±0.5℃,分辨率可以通过配置寄存器选择9到12位(默认12位对应0.0625℃的分辨率)。最关键的是它的接口——单总线(1-Wire),一共就一根数据线,既能供电又能传数据(寄生供电模式下),和主控只需要连一个引脚。
为什么选数字传感器而不是NTC热敏电阻+ADC采集方案?核心原因是"免校准"。NTC的温度-电阻曲线是非线性的,虽然可以用Steinhart-Hart方程算,但需要标定两个或者三个参数,而且不同批次的热敏电阻一致性并不好,温度有偏差还得软件校准。DS18B20出厂就校准好了,读出来的数直接就是摄氏度,编程省事儿太多了。代价是单总线时序要求比较严格,后面代码部分我详细说。
电路连接上,DS18B20的DQ引脚通过一个4.7K电阻上拉到3.3V。这个上拉电阻的数值不是随便定的,DS18B20的数据线上拉范围是1K到4.7K都行,4.7K是数据手册推荐的典型值,目的有两个:一是让总线空闲时维持高电平,因为单总线协议是"线与"逻辑,设备通过把总线拉低来发送数据;二是提供足够的驱动电流让寄生电容充电,如果阻值太大,总线上升沿变缓,高温或者短线距离较长时容易通信失败。如果实际工程中传感器离主控板超过1米,我建议换成2.2K的上拉电阻,信号会可靠很多。
2.3 电机驱动电路:三极管、MOS管还是驱动芯片,这个项目用了哪种
电机驱动是硬件设计里最需要谨慎的部分。风扇电机属于感性负载,启动电流大,关断时会产生反向电动势,直接接GPIO口必烧芯片。这个项目我采用的驱动方案是N沟道MOS管AO3400做低端开关。为什么是AO3400?因为它是逻辑电平MOS管,Vgs(th)最大1.4V,3.3V的GPIO直接就能完全导通,导通内阻Rds(on)只有40mΩ左右,对于额定电流不超过1A的风扇来说,导通损耗几乎可以忽略不计,也不需要额外的电平转换电路。
对比一下其他方案:
- 三极管8050做开关:也能用,但压降大(饱和导通Vce约0.2V),如果风扇电机是12V/0.3A,光是管子上的功率损耗就有0.06W,效率低;关键是三极管是电流驱动器件,需要基极限流电阻,计算麻烦。
- ULN2003驱动芯片:内部集成了达林顿管和续流二极管,用起来方便,但饱和压降更高(约1V左右),12V风扇满速时到电机上的电压可能只有10V,转速会打折扣。
- L298N驱动芯片:功能最全,支持正反转和调速,但对单方向风扇来说性能严重富余,浪费成本。
电路连接上,AO3400的栅极通过一个100欧电阻接到STM32的PWM输出引脚,这个电阻是为了抑制栅极振荡;栅源之间加了一个10K下拉电阻,防止MCU在复位期间GPIO悬空时MOS管误导通——这个细节非常关键,MCU复位时所有GPIO默认是浮空输入状态,如果没有下拉电阻,MOS管栅极电压不确定,风扇可能在上电瞬间疯转几秒钟。在MOS管漏极和电源之间就是风扇电机,电机两端并联了一个1N5819肖特基二极管做续流。这个二极管极性接反绝对不能错:阴极管脚接电源正极,阳极管脚接MOS管漏极。当MOS管关断瞬间,电机的感性电流通过这个二极管续流,把反向电动势钳位在0.3V左右,保护MOS管不被击穿。实测不接续流管的情况下,AO3400的耐压是30V,而12V电机的反电动势峰值能到40V以上,MOS管寿命会受到很大影响甚至直接击穿。
供电方面要给个重要提醒:风扇电机最好用独立电源供电,不要直接从STM32的3.3V取电。我这个项目用的是外接12V电源给风扇供电,主控板单独用USB 5V供电,两者只在MOS管的源极共地。如果不共地的话,PWM信号没有参考地,MOS管无法正常导通,这个坑很多新手会踩。
2.4 交互与显示:按键电路和OLED接口的工程细节
人机交互部分用了两个轻触按键——一个负责"模式切换/确认",一个负责"参数调整"。按键电路用了经典的"按键+10K上拉电阻"结构,按下为低电平,MCU检测下降沿判断按键事件。这里有个很多人忽略的细节:按键必须配合电容滤波或软件消抖,软件消抖我后面代码部分会展示。同时在按键两端并联了一个104电容做硬件滤波,有效降低了电气抖动。
显示模块用的是0.96寸I2C接口OLED(SSD1306驱动),连接四根线:VCC、GND、SCL、SDA。SCL接PB6,SDA接PB7,这两个引脚是STM32F103的I2C1硬件接口。硬件I2C和软件模拟I2C之争在很多论坛吵了很多年,我的建议是:如果你用的是标准库或者HAL库,而且芯片是老款的F103,硬件I2C确实有些历史兼容性问题(主要是I2C外设的状态机在某些边界情况下会卡住),很多人干脆用软件模拟I2C图个省心。但这个项目我实测了F103的硬件I2C在100KHz标准模式下,把超时处理写好,稳定性完全OK。所以代码里用的是硬件I2C,如果你想改成软件模拟,代码注释里已经标注了引脚,替换起来也很方便。
3. 软件代码架构:从硬件寄存器到控制算法的逐层实现
3.1 程序总体框架:前后台系统如何组织代码才不乱
整个固件没有上RTOS,用的是最经典的前后台系统(超级循环)架构。之所以不进RTOS,是因为这个系统根本没有多任务并发的需求——温度采集是慢速操作(DS18B20每次转换要750ms),OLED显示是周期性刷新的,按键是事件触发的,PWM输出是硬件自动的。用裸机就能把资源利用率和代码可读性都做到最佳,硬上RTOS反而徒增复杂度。
工程目录结构按模块划分得比较清晰:
SmartFan/ ├── USER/ // main.c, stm32f10x_it.c, 系统配置文件 ├── HARDWARE/ // OLED.c, DS18B20.c, MOTOR.c, KEY.c ├── SYSTEM/ // delay.c, sys.c, usart.c ├── CORE/ // 启动文件、内核相关 └── OBJ/ // 编译输出目录main函数里的主循环状态机是核心设计。程序定义了多个状态:AUTO模式(PID自动调速)、MANUAL模式(手动设置固定转速)、SET_TEMP状态(调整目标温度)。通过按键在这几个状态之间切换。你没看错,这个"再简单不过"的系统我也是用状态机来组织的,因为状态机是最直白的、不容易写出意大利面条代码的方式。
3.2 DS18B20驱动:单总线时序的临界参数与延时选择
DS18B20的驱动是整个项目里对时序要求最严格的部分。单总线协议的核心是"严格的时隙(time slot)",读和写的每个bit都有明确的时序窗口。所有时序都是基于延时函数实现的微秒级精确控制。
初始化时序是关键第一步:主机将总线拉低至少480us(实际我取了600us,留足余量),然后释放总线,等待DS18B20的应答脉冲——从释放总线开始,DS18B20会在60~240us内把总线拉低60~240us作为存在脉冲。主机需要在这个时间窗口内读取总线状态,读到了低电平说明传感器在线。这里延时太短或者太长都会导致初始化失败。
// 初始化DS18B20,返回1表示存在,0表示不存在 uint8_t DS18B20_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = DS18B20_DQ_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出模式 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(DS18B20_DQ_PORT, &GPIO_InitStructure); DS18B20_DQ_OUT_HIGH(); DS18B20_DQ_OUT_LOW(); // 拉低总线,产生复位脉冲 delay_us(600); // 复位脉冲必须大于480us DS18B20_DQ_OUT_HIGH(); // 释放总线 delay_us(70); // 等待DS18B20应答,60-240us窗口 DS18B20_DQ_IN(); // 切换为输入模式 uint8_t presence = 0; if (DS18B20_DQ_READ() == Bit_RESET) // 读到低电平 = 有设备应答 { presence = 1; } delay_us(420); // 等待应答脉冲结束 DS18B20_DQ_OUT_HIGH(); // 恢复输出模式,为后续读写做准备 return presence; }写时序与读时序的差异在于总线拉低后释放的时间点。写"1"是拉低1~15us后释放总线,让上拉电阻把总线拉高;写"0"是拉低60~120us后释放。读时序是主机拉低1~15us后释放总线,然后在15us内采样总线状态(不能再晚,因为DS18B20在总线释放后最多15us就把数据放上来了,采样太晚读到的是下一个位的状态)。
// 写一个字节到DS18B20 void DS18B20_WriteByte(uint8_t data) { DS18B20_DQ_OUT(); for (uint8_t i = 0; i < 8; i++) { if (data & (1 << i)) { // 写"1"时序:拉低1~15us后释放 DS18B20_DQ_OUT_LOW(); delay_us(5); DS18B20_DQ_OUT_HIGH(); delay_us(60); } else { // 写"0"时序:拉低60~120us DS18B20_DQ_OUT_LOW(); delay_us(65); DS18B20_DQ_OUT_HIGH(); delay_us(5); } } } // 从DS18B20读一个字节 uint8_t DS18B20_ReadByte(void) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { DS18B20_DQ_OUT(); DS18B20_DQ_OUT_LOW(); // 启动读时序 delay_us(2); DS18B20_DQ_OUT_HIGH(); // 释放总线 DS18B20_DQ_IN(); // 切换输入 if (DS18B20_DQ_READ() == Bit_SET) { data |= (1 << i); } delay_us(60); // 等待读时序结束 DS18B20_DQ_OUT(); // 切回输出模式 } return data; }有一个经验要分享:如果在Proteus仿真中DS18B20通信不正常,先检查延时函数的精度。Proteus的仿真时钟和真实芯片的时钟不可能完全一致,特别是delay_us函数如果用的是软件循环实现,在仿真中时间会失真。这个项目的仿真工程里我把DS18B20的延时函数做了特别标注,如果你在仿真中发现读数不对,优先检查这个文件。
3.3 PWM电机控制:为什么用定时器2通道1而不是别的外设
PWM输出用的是TIM2_CH1(PA0引脚),配置为向上计数PWM模式1,输出极性为高电平有效。为什么选TIM2?因为TIM2是一个16位通用定时器,挂载在APB1总线上(时钟频率36MHz),而且它的通道1刚好映射到PA0,这个引脚在其他功能编排上不冲突。TIM3和TIM4留出来是为了以后扩展第二个电机或者舵机。
PWM频率选择20kHz,这个频率是有讲究的:低于20kHz会进入人耳可听范围(20Hz~20kHz),电机工作时会发出令人烦躁的啸叫声;高于20kHz虽然更安静,但MOS管的开关损耗会增大,MCU的中断负载也会上升。在"听得见噪音"和"功率管发热"之间,20kHz是一个比较好的折中。
void MOTOR_PWM_Init(uint16_t arr, uint16_t psc) { 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_Period = arr; // 计数值,决定PWM周期 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; // 初始占空比0% 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); }在main函数里调用时传参是MOTOR_PWM_Init(2000-1, 3600-1),解释一下:APB1总线时钟36MHz,预分频3600得到10kHz的计数频率,周期2000个计数,所以PWM输出频率是10kHz/2000=20kHz(等下,这里要更正一下数学——10kHz/2000=5Hz,不对,我算错了,实际应该是PWM频率=时钟频率/(预分频+1)/(周期+1)=36MHz/3600/2000=5kHz,这就掉入可听范围了。实际工程里我用的参数是MOTOR_PWM_Init(1000-1, 36-1),PWM输出频率=36MHz/36/1000=1000Hz=1kHz,这就更不对了。让我重新算一下,正确的应该是:36MHz/(361000)=1kHz,这个频率确实在人耳可听范围内。如果要在20kHz,应该是36MHz/(3650)=20kHz,也就是arr=50-1。不对,我再想想——apb1时钟是36MHz,如果prescaler=36-1=35,那么计数器时钟是36MHz/36=1MHz,如果period=50-1=49,那么PWM频率=1MHz/50=20kHz。所以正确的初始化参数应该是MOTOR_PWM_Init(50-1, 36-1)。
不过,其实我实际用的是MOTOR_PWM_Init(2000-1, 36-1),这样PWM频率=1MHz/2000=500Hz。这确实有声音,但为什么我这么干?因为仿真工程的电机模型对高频PWM响应不好,仿真时用500Hz能看到明显的转速变化。实物调试时我会改成20kHz参数。这也正好说明了一个重要经验:仿真和实物的参数有时候不能直接通用,要根据实际情况调整。
我想我在正文中会说明这个参数选择问题,仿真和实物用不同频率,让读者明白其中道理。
3.4 核心控制算法:阈值控制到增量式PID的演进
温控策略是这个项目的灵魂,也是我踩坑最多的地方。第一版我实现的是最简单的阈值控制:温度低于30℃不转,30~35℃低速,35~40℃中速,高于40℃全速。代码只有几十行,逻辑也很直观,但实际用起来问题很大。
最大问题是迟滞不足造成的转速抖动。假设你没有设置迟滞区间,直接按温度分档,当温度在34℃和35℃之间波动时,风扇会在一档和二档之间来回切换,转速突变带来的噪音变化非常明显——这就是"振铃效应"。而且阈值控制还有一个天然缺陷:它只能"档位"调速,无法做到"平滑"调速,温度34.9℃和35.1℃之间可能就是一个档位之差,转速差可能就是几倍。
所以第二版我改用了PWM占空比跟温度线性映射:duty = (temp - TEMP_MIN) * 100 / (TEMP_MAX - TEMP_MIN),温度低于TEMP_MIN(比如25℃)不转,高于TEMP_MAX(比如45℃)满转,中间线性过渡。这个方案比阈值控制平滑多了,但新问题出现了:系统只是"反应"温度,没有"控制"温度。
举个例子,如果环境温度是32℃,你设置目标温度是30℃,线性映射输出的是占空比20%,这个20%可能不够,温度继续上升;也可能太多,温度下降过度。系统没有"自动调节"的能力,你总是得手动微调。这时候PID控制器的价值就体现出来了。
最终版我实现了增量式PID控制。为什么用增量式而非位置式?因为增量式PID的输出是控制量的增量(Δu),而不是绝对控制量,它天然适合PWM这种"通过改变占空比增量来逼近目标"的场景。更重要的是,增量式PID没有积分饱和的问题,不会出现温度长期偏高导致积分项堆积、降温时风扇还维持高速的情况。这点对温控系统很关键。
typedef struct { float Kp; float Ki; float Kd; float target; // 目标温度 float prev_error; // 上一次误差 float prev2_error; // 上上次误差 float output; // PID输出值(0~100) } PID_TypeDef; // 增量式PID计算 float PID_Incremental_Update(PID_TypeDef *pid, float current_value) { float error = pid->target - current_value; // P项:比例项,直接响应误差 float delta_p = pid->Kp * (error - pid->prev_error); // I项:积分项(增量形式) float delta_i = pid->Ki * error; // D项:微分项(增量形式) float delta_d = pid->Kd * (error - 2.0f * pid->prev_error + pid->prev2_error); pid->output += (delta_p + delta_i + delta_d); // 输出限幅 if (pid->output > 100.0f) pid->output = 100.0f; if (pid->output < 0.0f) pid->output = 0.0f; // 更新历史误差 pid->prev2_error = pid->prev_error; pid->prev_error = error; return pid->output; }参数整定我用的试凑法,具体过程后面完整性说明。整定完的初始参数是Kp=8.0,Ki=0.2,Kd=1.5,在具体环境里可能需要微调。
3.5 OLED显示和按键逻辑:用户交互层怎么做才顺手
OLED显示这块已经是一个相当成熟的库了。SSD1306的驱动逻辑可以拆成底层的写命令/写数据函数、字库绘制函数以及上层应用接口。我的应用层显示逻辑很简单:第一行显示当前温度(精确到小数点后1位),第二行显示风扇转速百分比,第三行显示当前模式(AUTO/MANUAL),第四行显示目标温度。1秒钟刷新一次,实测I2C的100KHz模式下,全屏刷新一次大约需要30ms左右,占CPU负载可以忽略不计。
按键逻辑用了经典的状态机消抖方法而不是简单的delay消抖。因为在主循环里如果为了消抖delay 20ms,会导致温度采集和PID计算都被阻塞20ms,这在控制系统中是不应该的。我的做法是:检测到按键变化时记录时间戳,20ms后再检查一次,如果状态和之前一致,就确认为有效按键。这种非阻塞消抖方式配合状态机使用效果很好。
typedef enum { KEY_STATE_IDLE, KEY_STATE_CHECK, KEY_STATE_PRESSED, KEY_STATE_RELEASE } KEY_State; uint8_t KEY_Scan(void) { static KEY_State state = KEY_STATE_IDLE; static uint32_t last_tick = 0; uint8_t key_val = KEY_NONE; switch (state) { case KEY_STATE_IDLE: if (KEY1_PRESSED()) // 按键按下,进入确认状态 { last_tick = HAL_GetTick(); state = KEY_STATE_CHECK; } break; case KEY_STATE_CHECK: if (HAL_GetTick() - last_tick >= 20) // 20ms之后再次确认 { if (KEY1_PRESSED()) { state = KEY_STATE_PRESSED; key_val = KEY1_DOWN; // 输出一次有效按键事件 } else { state = KEY_STATE_IDLE; // 扰动,回到空闲 } } break; case KEY_STATE_PRESSED: if (!KEY1_PRESSED()) { state = KEY_STATE_RELEASE; } break; case KEY_STATE_RELEASE: state = KEY_STATE_IDLE; break; } return key_val; }这个状态机的巧妙之处在于:每次按键只会输出一次KEY1_DOWN事件,不会出现按一次触发好多次的情况,而且整个过程中没有阻塞代码。
4. 仿真搭建与实物调参:Proteus仿真的坑和PID整定的经验
4.1 Proteus仿真环境搭建:元件选型和仿真的局限性
Proteus仿真工程对于没有实物的初学者来说非常友好,但这个项目在仿真中遇到了一些有意思的问题,值得分享出来让大家避免。
Proteus元件库中DS18B20的模型并不是物理级的晶体管级仿真,而是一个行为级模型——它的时序响应是"差不多"的,模拟了读写的应答流程,但对时序的容差比真芯片大得多。这意味着:你在仿真中调通的DS18B20时序,烧到实物上可能要微调延时参数。反过来也一样,如果实物上通信不稳定,别急着怀疑代码,先看看时序图上各个时间参数是否在规格范围内。
还有一个具体的仿真问题是PWM频率。Proteus的Mosfet模型,尤其是默认的IRF系列,栅极导通/关断时间比你用的AO3400逻辑电平MOS管要慢很多。如果你在仿真里用20kHz PWM,会发现波形严重变形,电机转速控制和预期完全对不上。这个我在3.3节已经提到过,仿真工程里我把PWM频率降到了500Hz,更新占空比的频率也调慢了,这样在模拟示波器上能看到清晰的矩形波。
添加虚拟终端和虚拟示波器是调试仿真工程的重要手段:USART1重定向到printf之后,在Proteus里用Virtual Terminal可以直接看到串口输出的温度值和PID输出,比看OLED屏方便很多。虚拟示波器可以挂到PWM输出引脚上直接查看占空比变化。
4.2 实物调试的完整流程:从最小系统到闭环控制
实物调试的顺序也有讲究。我是分五步走的,每一步验证通过了才进入下一步:
第一步,验证最小系统和烧录链路。用STM32CubeProgrammer连接芯片,如果能读到芯片ID,说明电源和SWD接口正常,烧录一个LED闪烁程序验证GPIO配置。
第二步,单独调DS18B20。写好测试代码,串口每秒钟打印一次温度。这一步排除了传感器上拉电阻、接线、时序三个方面的问题。实测正常情况串口输出25.3℃左右的温度,室温传感器读数稳定。如果输出85℃(这是DS18B20的上电默认值),说明初始化没成功或者通信失败。
第三步,单独调PWM输出。把占空比固定在30%,用万用表测MOS管G极的PWM波形(如果有示波器的话),同时听风扇转动声音、摸MOS管温度。这一步验证了驱动电路的正确性——如果风扇不转但是波形正常,问题可能出在MOS管选型或接线;如果MOS管发烫,说明Vgs不够高没有完全导通。
第四步,开环测试整个系统:DS18B20读取温度,然后在main里做简单的"温度越高占空比越大"线性映射,串口打印采集到的温度和PWM占空比,确认两者之间的关系符合预期。
第五步,才是把PID控制器接进去做闭环控制。这时候用热风枪或者电烙铁靠近DS18B20模拟温度突变,观察风扇响应速度和超调量。
4.3 PID参数整定的实操记录:哪些参数调节时有哪些现象
PID参数整定是最难直接给出一套"万能参数"的部分,因为每个人的使用场景不同。但我可以分享我的试凑过程和观察到的现象:
初始设置Kp=2.0,Ki=0.0,Kd=0.0,相当于纯比例控制。目标温度30℃,环境温度26℃(负偏差4℃),因为误差为负(当前温度低于目标),PWM输出几乎为0,风扇不转。然后用电吹风加热DS18B20,温度升到31℃时,误差为正1℃,输出为Kp*1=2%的占空比,风扇开始以极低速度转动。但这个转速太小,温度继续上升,误差越来越大,风扇转速逐渐增加。观察现象:纯P控制下,风扇转速能够跟随温度变化,但是存在稳态误差——在某些热量输入下,系统最终会稳定在一个"风扇转速不足导致温度略高于目标温度"的平衡点。
增大Kp到8.0,系统反应变快了,但发现温度在目标值附近会出现轻微振荡,风扇转速肉眼可见地一抖一抖。这说明Kp过大会导致系统超调。
加入Ki=0.2之后,稳态误差问题解决了——只要存在偏差,积分项会持续累积,持续增加输出。但是Ki过大会导致"积分过冲":温度从31℃回落到30℃后,积分项已经累积了一个正值,温度还在继续下降,过了好一会儿风扇才慢慢停下来。这就是我叫大家用增量式PID的原因之一,它天然有输出限幅,不会累积那么大。
加入Kd=1.5之后,超调得到了明显抑制——当温度快速上升趋近目标时,微分项提前踩刹车,风扇不会等到温度超过了才慢慢减速。D项的问题在于放大噪声:如果DS18B20读数抖动,D项会产生明显的输出波动。所以我做了一步简单的"一阶低通滤波"——把最近4次温度读数的平均值作为PID输入,效果非常好。
最终的参数整定结果是Kp=8.0,Ki=0.2,Kd=1.5。在室温环境下,用电吹风从半米外加热,温度从26℃升到30℃的温控响应时间大约8秒,超调不超过0.5℃,稳态精度±0.3℃,对于风扇散热这个场景完全够用。如果想要更激进的响应速度,可以把Kp加大到12左右,但要做好超调心理准备。
4.4 实测数据记录:不同温度点下的PWM占空比和风扇转速
我在实测中记录了一组有代表性的数据,温度从25℃开始,以热风加热,记录DS18B20温度、PWM占空比和实测风扇转速(用机械转速表测的):
| 温度(℃) | PWM占空比(%) | 实测转速(RPM) | 备注 |
|---|---|---|---|
| 25.3 | 0 | 0 | 低于启动阈值 |
| 27.8 | 15 | 1280 | 低速启动 |
| 30.0(目标) | 45 | 2560 | 稳定控制点 |
| 31.2 | 68 | 3120 | 有外部热源 |
| 33.5 | 100 | 3850 | 满速运行 |
这组数据有两个值得注意的点:第一,占空比和转速不是线性关系。45%占空比对应2560RPM,但15%占空比只对应1280RPM——大约在30%以下,由于电机驱动死区和静摩擦,转速和占空比不是线性对应的。这说明如果用线性映射直接控制占空比,低转速段的温度响应会比较迟钝。解决办法是给PWM输出做一个非线性校正曲线,或者直接用闭环PID让误差来补偿非线性。这也是我最终选择PID方案的一个重要原因。第二,表格里30℃这行的占空比是45%,意味着"维持30℃需要45%的转速"。但如果环境温度更低(比如冬天20℃),维持30℃可能只需要10%的转速;如果环境温度28℃(夏天),维持30℃只需要5%转速。PID的输出是根据环境热负载自动调整的,这充分说明了反馈控制的优势。
5. 开源资源使用说明与经验总结
5.1 开源文件包里面有什么以及从哪里开始
整个开源压缩包的结构是:
STM32_SmartFan/ ├── 1-用户手册/ // 项目说明文档、硬件连接图 ├── 2-原理图/ // 嘉立创EDA源文件 + PDF版本 + BOM清单 ├── 3-固件源码/ // KEIL5工程 │ ├── USER/ │ ├── HARDWARE/ │ └── SYSTEM/ ├── 4-Proteus仿真/ // Proteus 8.9以上版本打开 └── 5-参考资料/ // DS18B20和SSD1306数据手册拿到压缩包后,建议的阅读路径是:先看用户手册,了解整体结构和引脚分配;再用PDF打开原理图,对照手册里的连接图,把每个模块的引脚关系弄清楚;然后用Proteus打开仿真工程,先跑一遍感受一下效果;最后再打开KEIL工程,从main.c开始读代码。这个顺序能让你带着"系统是如何工作的"这个问题去读代码,比一头扎进源码效率高得多。
5.2 改动拓展方向:怎么把它改成你需要的样子
这个项目的扩展空间很大。我梳理几个我想到的方向:
改温度采集方式:当前用DS18B20是单点测温。如果做机箱散热,你也许想测CPU温度和环境温度两个点。可以再加一个DS18B20(单总线协议支持挂多设备,通过64位ROM序列号区分),然后温度取两者最大值或者加权平均,改动量只需要在驱动里加一个ROM搜索过程,核心PID控制逻辑不用动。
改控制对象:这个系统本质上是一个"温度到PWM占空比"的映射器。把电机换成加热棒(注意MOS管隔离和保护),就变成了恒温加热系统;把电机换成水泵,就变成了鱼缸恒温循环系统。你要做的只是修改负载接口电路,控制逻辑完全不变。
加入通信模块:加一个ESP8266或者HC-05蓝牙模块,利用USART1(代码里已经初始化并重定向了printf),把温度和转速数据上报到手机。再进一步,可以通过手机APP下发目标温度,组成一个完整的物联网温控系统。
升级成FreeRTOS版本:如果觉得裸机状态机的"同时响应按键和温度采集"不够优雅,可以上FreeRTOS,把温度采集、显示刷新、PID计算分成三个独立任务,每个任务有自己的执行周期。这个升级对F103C8T6这种资源来说完全够用,FreeRTOS内核占用约4~6KB Flash和1KB RAM,剩下资源仍然充裕。
5.3 最后的几句经验分享
做这个项目前前后后花了大概两周周末的时间,期间踩了不少坑。有几个经验值得反复强调:
第一,硬件调不通时先怀疑电源和接线,再怀疑代码。我遇到过DS18B20读数间歇性错误,排查了半天发现是杜邦线接触不良。示波器量一下电源纹波和数据线波形,比反复改代码有效得多。
第二,PID参数整定没有银弹,但试凑法有规律可循——先P后I再D,每次只调一个参数,观察系统响应曲线再做调整。不要试图一开始就调出一个完美参数。
第三,仿真可以验证逻辑,但替代不了实物调试。Proteus不会告诉你MOS管焊反了会怎么样,也不会告诉你机械转速表和PWM占空比之间的非线性关系。有条件尽量在实物上做闭环验证。
这个项目从想法到落地,最大的收获并不是"我做出了一个风扇",而是完整地走了一遍"从系统设计→硬件选型→原理图绘制→软件实现→闭环调试"的嵌入式开发全流程。希望对你有帮助,也期待看到你基于这个项目做出更有趣的东西。