☰
基于STM32的智能风扇:PWM调速与人体感应闭环实战
2026/10/29 16:42:10 网站建设 项目流程

简介:这份资源面向嵌入式初学者与电子设计竞赛选手,提供一套以STM32F103为核心、融合PWM调速与多传感器联动的智能风扇完整工程。项目通过HCSR501人体红外感应实现有人自动开启、无人自动关闭,借助DHT11采集温湿度数据动态调节风扇转速,并利用STM32的PWM脉宽调制功能精确控制电机占空比,是学习微控制器编程、外设驱动与传感器数据采集的典型实例。压缩包共315个文件,约16.96MB,包含50个c源文件、52个h头文件、55个crf与56个o编译中间文件,以及hex烧录文件、uvprojx与uvoptx工程配置、pdf与doc说明文档、jpg实物图等,覆盖源码、工程配置与调试产物,目录结构完整。目前已有8891人学习下载,适合希望快速搭建STM32风扇控制系统、理解PWM调速与红外感应联动逻辑的读者参考与二次开发。

1. 基于STM32的智能风扇:从PWM调速到人体感应的最小闭环

夏天最烦的不是热,是风扇一直吹。开一档嫌弱,开三档嫌吵,半夜还得爬起来关。我去年给卧室做了一台基于 STM32F103 的智能风扇,核心就三件事:PWM 调速、HC-SR501 人体红外感应、以及一套能落地的状态机。整机成本不到四十块,用到的模块全是淘宝能买到的通用件。它解决的不是“让风扇转起来”这种玩具级问题,而是让风扇根据“有没有人”和“人离多远”自动决定转不转、转多快。适合有 C 语言基础、玩过 STM32F103 最小系统、想拿一个完整项目练手的人。如果你正在找 STM32 项目练手,或者被“基于 STM32 的毕业设计”折磨,这套方案可以直接复现。

2. 硬件选型与接线:为什么是 STM32F103 + HC-SR501 + MOS 管

2.1 主控为什么锁定 STM32F103C8T6

选 STM32F103C8T6 的理由很直接:便宜、资料多、PWM 资源够用。这颗芯片有 3 个通用定时器(TIM2/3/4),每个能出 4 路 PWM,驱动一个风扇绰绰有余。市面上大量 STM32F103 最小系统板把 GPIO 全引出来,接 HC-SR501 和 MOS 管驱动不需要飞线。相比 STM32F4 系列,F103 的主频 72MHz 对风扇控制这种任务完全过剩,没必要多花钱。

时钟配置上,用外部 8MHz 晶振经 PLL 倍频到 72MHz,APB1 定时器时钟 72MHz,这样 TIM3 的计数频率和 PWM 分辨率都好算。如果你用 CubeMX 建工程,时钟树里把 HSE 选 Crystal/Ceramic Resonator,PLL MUL 选 9 倍频,APB1 Prescaler 选 2 分频,就能得到 72MHz 的 TIM 时钟。

2.2 HC-SR501 的接线与输出特性

HC-SR501 是三引脚模块:VCC、GND、OUT。供电范围 4.5V 到 20V,输出是 3.3V TTL 电平,可以直接接 STM32 的 GPIO 输入,不需要电平转换。这一点很关键,很多人以为 5V 模块必须加电阻分压,其实 HC-SR501 内部有稳压,输出高电平就是 3.3V 左右,直接接 PA0 就行。

模块上有两个可调电位器:一个调延时时间(0.3 秒到 5 分钟),一个调感应灵敏度(3 米到 7 米)。我一般把延时拧到最小,灵敏度拧到中间偏左,这样人走开后约 3 秒输出低电平,不会出现“人走了风扇还转五分钟”的尴尬。

2.3 风扇驱动:为什么用 MOS 管而不是继电器

风扇电机是感性负载,启动电流大。用继电器控制只能开关,没法调速;用三极管如 S8050 驱动,电流超过 500mA 就发热严重。我选的是 AO3400 N 沟道 MOS 管,导通电阻 28mΩ,持续电流 5.7A,驱动一个 12V 0.3A 的风扇毫无压力。栅极串一个 100Ω 电阻限流,栅源之间并一个 10kΩ 下拉电阻,防止 STM32 复位时 MOS 管误导通。

接线逻辑:STM32 的 TIM3_CH1(PA6)接 100Ω 电阻到 AO3400 栅极,风扇正极接 12V,负极接 MOS 管漏极,源极接地。风扇两端反并一个 1N5819 肖特基二极管续流,吸收电机断电时的反向电动势。这个二极管不能省,否则 MOS 管容易被反压打穿。

2.4 完整接线表

STM32F103 引脚连接对象说明
PA6 (TIM3_CH1)100Ω 电阻 → AO3400 栅极PWM 输出
PA0HC-SR501 OUT人体感应输入
PA1按键一端手动/自动切换
3.3VHC-SR501 VCC模块供电
GNDHC-SR501 GND、MOS 源极、风扇地共地
12V风扇正极外部电源

注意:STM32 和 12V 电源必须共地,否则 PWM 信号没有参考电平,MOS 管会一直导通或完全不导通。

3. PWM 调速与定时器配置:从寄存器到 HAL 库的完整实现

3.1 PWM 频率和分辨率的取舍

风扇 PWM 频率不能太低,否则电机线圈会发出人耳能听到的啸叫;也不能太高,否则 MOS 管开关损耗大。我实测 20kHz 到 25kHz 最合适,超出人耳听觉范围,MOS 管发热也在可接受范围。STM32F103 的 TIM3 挂在 APB1 上,时钟 72MHz。要得到 20kHz PWM,预分频器 PSC 和自动重装载值 ARR 满足:

72,000,000 / ((PSC+1) * (ARR+1)) = 20,000

取 PSC=0,则 ARR=3599。这样 PWM 分辨率是 3600 级,调速足够细腻。如果你想要更高分辨率,可以取 PSC=1,ARR=1799,频率还是 20kHz,分辨率 1800 级,也够用。

3.2 用 HAL 库配置 TIM3 PWM 输出

下面是 TIM3_CH1 的初始化代码,用 CubeMX 生成后手动补全关键部分:

// tim.c 中 TIM3 初始化 void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; // 预分频 0,计数时钟 72MHz htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 3599; // ARR=3599,PWM 频率 20kHz htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim3); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); HAL_TIM_PWM_Init(&htim3); sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 0; // 初始占空比 0 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 启动 PWM }

逻辑说明:Prescaler=0表示不分频,计数器每个时钟周期加一。Period=3599表示计数到 3599 后归零,一个完整周期 3600 个时钟。Pulse是比较值,决定高电平持续时间。占空比 = Pulse / (Period+1)。初始 Pulse=0,风扇不转。

参数怎么改:如果你换用 TIM1 或 TIM4,只需改htim3.Instance和对应的时钟使能。如果风扇有异响,把 Period 改成 1799、Prescaler 改成 1,频率不变但分辨率减半,有时能避开机械共振点。

3.3 占空比映射:从档位到比较值

我定义了 5 个档位,对应占空比 0%、30%、50%、70%、100%。映射函数如下:

// 档位 0~4 映射到 PWM 比较值 void Fan_SetSpeed(uint8_t level) { uint16_t pulse; switch(level) { case 0: pulse = 0; break; // 停 case 1: pulse = 1080; break; // 30% case 2: pulse = 1800; break; // 50% case 3: pulse = 2520; break; // 70% case 4: pulse = 3600; break; // 100% default: pulse = 0; break; } __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse); }

逻辑说明:__HAL_TIM_SET_COMPARE是宏,直接写 TIM3->CCR1 寄存器,比调 HAL 函数快。pulse 值按 3600 的百分比算:30% 就是 1080,50% 是 1800,以此类推。

参数说明:如果你改过 ARR,这里的 pulse 上限要跟着改。比如 ARR=1799,100% 对应 1800,其他档位按比例缩放。不要直接写死 3600,否则改配置后占空比全错。

3.4 软启动:避免风扇猛地一冲

直接给 100% 占空比,风扇会“嗡”一声猛转,体验很差。我加了一个软启动:从当前占空比逐步加到目标值,每 10ms 加 2%。

void Fan_SoftStart(uint8_t target_level) { static uint8_t current = 0; uint16_t target_pulse = Fan_GetPulse(target_level); uint16_t current_pulse = __HAL_TIM_GET_COMPARE(&htim3, TIM_CHANNEL_1); while(current_pulse < target_pulse) { current_pulse += 72; // 每次加 2% (3600*0.02) if(current_pulse > target_pulse) current_pulse = target_pulse; __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, current_pulse); HAL_Delay(10); } }

逻辑说明:每次增加 72 个计数,对应 2% 占空比,延时 10ms。从 0 到 100% 需要 50 次,约 500ms,听感上就是风扇平稳加速。

参数说明:HAL_Delay(10)不能放在中断里,否则会阻塞其他任务。如果你用 FreeRTOS,改成vTaskDelay(10)。加速步长 72 可以调,步长越大启动越快但冲击感越强。

4. HC-SR501 人体感应与状态机:让风扇自己决定转不转

4.1 HC-SR501 的输出时序与消抖

HC-SR501 输出高电平表示检测到人体,低电平表示无人。但它的输出不是干净的方波,人走动时会有抖动。我实测在感应边缘区域,输出会在高低之间跳变,周期约 100ms。如果直接读 GPIO 做判断,风扇会频繁启停。

解决办法是软件消抖:连续 3 次读到高电平才认为“有人”,连续 5 次读到低电平才认为“无人”。采样周期 50ms,这样响应延迟在 150ms 到 250ms 之间,人感觉不到。

// 每 50ms 调用一次,返回消抖后的人体状态 uint8_t PIR_ReadDebounced(void) { static uint8_t high_cnt = 0, low_cnt = 0; static uint8_t stable_state = 0; uint8_t raw = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0); if(raw) { high_cnt++; low_cnt = 0; if(high_cnt >= 3) { stable_state = 1; high_cnt = 3; } } else { low_cnt++; high_cnt = 0; if(low_cnt >= 5) { stable_state = 0; low_cnt = 5; } } return stable_state; }

逻辑说明:high_cnt和low_cnt分别计数连续高和连续低。达到阈值后锁定状态,并把计数器钳位,防止溢出。stable_state就是消抖后的结果。

参数说明:3 次和 5 次是经验值。如果你把 HC-SR501 的延时电位器调得很短,可以适当减小 low_cnt 阈值,否则人走了风扇要等很久才停。

4.2 状态机设计:待机、低速、高速、延时关闭

我定义了四个状态:

  • STATE_IDLE:无人,风扇停。
  • STATE_LOW:有人且距离远(PIR 刚触发),风扇 30% 占空比。
  • STATE_HIGH:有人且持续活动,风扇 70% 占空比。
  • STATE_DELAY:人刚走,风扇继续转 10 秒后停。

状态转移条件:

当前状态条件下一状态动作
IDLEPIR 高LOW软启动到 30%
LOWPIR 持续高 5 秒HIGH软启动到 70%
LOWPIR 低DELAY保持 30%,启动延时计时
HIGHPIR 低DELAY保持 70%,启动延时计时
DELAY延时到IDLE软停止
DELAYPIR 高LOW取消延时,回到 LOW

这个状态机的好处是:人短暂经过不会触发高速档,只有持续停留才加速;人走后有 10 秒缓冲,不会立刻停。

4.3 状态机代码实现

typedef enum { STATE_IDLE, STATE_LOW, STATE_HIGH, STATE_DELAY } FanState_t; FanState_t fan_state = STATE_IDLE; uint32_t delay_start_tick = 0; uint32_t high_start_tick = 0; void Fan_StateMachine(void) { uint8_t pir = PIR_ReadDebounced(); uint32_t now = HAL_GetTick(); switch(fan_state) { case STATE_IDLE: if(pir) { Fan_SoftStart(1); // 30% high_start_tick = now; fan_state = STATE_LOW; } break; case STATE_LOW: if(pir) { if(now - high_start_tick > 5000) { Fan_SoftStart(3); // 70% fan_state = STATE_HIGH; } } else { delay_start_tick = now; fan_state = STATE_DELAY; } break; case STATE_HIGH: if(!pir) { delay_start_tick = now; fan_state = STATE_DELAY; } break; case STATE_DELAY: if(pir) { Fan_SoftStart(1); high_start_tick = now; fan_state = STATE_LOW; } else if(now - delay_start_tick > 10000) { Fan_SetSpeed(0); fan_state = STATE_IDLE; } break; } }

逻辑说明:HAL_GetTick()返回毫秒级系统滴答,用来做非阻塞延时。high_start_tick记录进入 LOW 状态的时间,持续 5 秒后升到 HIGH。delay_start_tick记录人离开的时间,10 秒后停机。

参数说明:5 秒和 10 秒可以按需调整。如果你希望人一进来就高速,把 5000 改成 0。如果你希望人走后立刻停,把 10000 改成 0。但建议保留至少 3 秒缓冲,否则人坐着不动时 PIR 偶尔误判会导致风扇频繁启停。

4.4 主循环与任务调度

主循环里每 50ms 调用一次状态机和 PIR 消抖,其他时间处理按键和串口调试。

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM3_Init(); MX_USART1_UART_Init(); uint32_t last_tick = 0; while(1) { if(HAL_GetTick() - last_tick >= 50) { last_tick = HAL_GetTick(); Fan_StateMachine(); Key_Scan(); // 按键扫描 } // 其他低优先级任务 } }

逻辑说明:用HAL_GetTick()做时间片,避免HAL_Delay阻塞。50ms 周期对风扇控制和人体感应都足够快。

参数说明:如果你用 FreeRTOS,可以把状态机放在一个 50ms 周期的任务里,优先级设为中。注意Fan_SoftStart里有HAL_Delay,在 RTOS 下要改成vTaskDelay,否则会阻塞调度器。

5. 避坑与排查:调试智能风扇时最容易翻车的 5 个点

5.1 风扇不转,但 MOS 管发热

现象:上电后风扇不转,手摸 AO3400 烫手。

原因:PWM 信号没有真正到达栅极,或者栅极一直高电平导致 MOS 管完全导通但风扇回路断开。常见的是 PA6 没有配置成复用推挽输出,而是普通 GPIO,导致 TIM3 的 PWM 出不来。

解决:检查 GPIO 初始化代码,PA6 必须配置为GPIO_MODE_AF_PP(复用推挽),速度设为GPIO_SPEED_FREQ_HIGH。用示波器或 LED 接 PA6 看有没有 PWM 波形。如果没有,检查HAL_TIM_PWM_Start是否调用。

5.2 HC-SR501 一直输出高电平

现象:风扇一直转,PIR 输出始终是高。

原因:HC-SR501 有两种触发模式:可重复触发(H)和不可重复触发(L)。模块背面有一个跳线,默认在 H 位置。在 H 模式下,只要有人在感应范围内,输出就保持高,人走后延时一段时间才变低。如果你把延时电位器拧到最大,可能 5 分钟才变低。

解决:把跳线帽拔下来插到 L 位置,或者把延时电位器逆时针拧到底。另外,HC-SR501 上电后有约 1 分钟初始化时间,这期间输出可能不稳定,属于正常现象。

5.3 PWM 调速时风扇有啸叫

现象:低速档时风扇发出刺耳的高频声。

原因:PWM 频率落在人耳敏感范围(2kHz 到 5kHz),或者风扇机械共振。我一开始用 1kHz PWM,啸叫非常明显。

解决:把 PWM 频率提高到 20kHz 以上。改htim3.Init.Period和Prescaler,保持乘积不变即可。如果还有轻微声音,尝试微调频率到 22kHz 或 25kHz,避开共振点。

5.4 状态机卡在 DELAY 状态不退出

现象:人走了很久,风扇还在转,串口打印显示状态一直是 DELAY。

原因:delay_start_tick在进入 DELAY 时赋值,但如果HAL_GetTick()溢出(约 49 天),now - delay_start_tick会变成负数(无符号数回绕),条件判断失效。另外,如果 PIR 消抖函数一直返回高,状态机会从 DELAY 跳回 LOW,看起来像卡住。

解决:用(now - delay_start_tick) > 10000这种写法本身能处理溢出,因为无符号减法回绕后结果仍然正确。检查 PIR 消抖逻辑,确认人走后stable_state能变成 0。可以在串口打印pir和fan_state辅助调试。

5.5 下载程序后风扇狂转,复位也不停

现象:烧录完程序,风扇直接全速转,按复位键也没用。

原因:STM32 复位后 GPIO 默认是浮空输入,但 AO3400 栅极如果有干扰或者下拉电阻没接,MOS 管可能误导通。另外,如果 PA6 在复位期间被外部电路拉高,也会导致风扇转。

解决:在 AO3400 栅极和源极之间并一个 10kΩ 下拉电阻,确保复位时栅极被拉到地。检查 PA6 有没有被其他外设占用。如果用了 ST-Link 调试,注意调试引脚配置,别把 PA6 复用成其他功能。

6. 进阶技巧:用串口调参和 OLED 显示把调试效率翻倍

6.1 串口命令行:不重新烧录就能改参数

每次改延时时间都要重新编译烧录,效率太低。我在 USART1 上做了一个简单的命令行,支持set low_delay 3000、set high_delay 8000这样的命令,参数存在 Flash 的最后一页,掉电不丢。

// 解析串口命令,格式:set <param> <value> void UART_CommandHandler(char *cmd) { char param[16]; uint32_t value; if(sscanf(cmd, "set %15s %lu", param, &value) == 2) { if(strcmp(param, "low_delay") == 0) { g_low_delay_ms = value; Save_Config(); printf("low_delay set to %lu\r\n", value); } else if(strcmp(param, "high_delay") == 0) { g_high_delay_ms = value; Save_Config(); printf("high_delay set to %lu\r\n", value); } } }

逻辑说明:sscanf解析命令,Save_Config把参数写入 Flash 指定地址。下次上电时从 Flash 读取。

参数说明:Flash 写入前要先擦除整页,STM32F103C8T6 的页大小是 1KB。写入地址选 0x0800FC00(最后一页),不要和程序空间冲突。如果你用 CubeMX 生成工程,注意链接脚本里的 Flash 长度,别把配置区覆盖了。

6.2 OLED 显示实时状态

用 0.96 寸 I2C OLED 显示当前状态、PWM 占空比、PIR 原始值,调试时一眼就能看出问题。我用的 SSD1306 驱动,I2C 接 PB6/PB7。

void OLED_UpdateStatus(void) { char buf[32]; OLED_Clear(); sprintf(buf, "State: %d", fan_state); OLED_ShowString(0, 0, buf); sprintf(buf, "PWM: %d", __HAL_TIM_GET_COMPARE(&htim3, TIM_CHANNEL_1)); OLED_ShowString(0, 2, buf); sprintf(buf, "PIR: %d", HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0)); OLED_ShowString(0, 4, buf); OLED_Refresh(); }

逻辑说明:每 200ms 刷新一次 OLED,显示状态机状态、当前 PWM 比较值和 PIR 原始电平。OLED_Refresh把显存推到屏幕。

参数说明:I2C 速率设 400kHz,刷新一屏约 20ms,不会影响主循环。如果你没有 OLED,可以用串口打印代替,效果一样。

6.3 实测数据与调参建议

我在 12 平米卧室实测了一周,记录了几组数据:

场景PIR 触发距离低速档响应高速档响应人走后停机
坐在书桌前2.5 米立即5 秒后10 秒
走动经过4 米立即不触发10 秒
躺在床上3 米立即5 秒后10 秒

调参建议:如果你希望人躺着不动时风扇不降速,把high_delay设大一点,比如 15000。如果你希望人一进房间就高速,把low_delay设成 0。但注意 PIR 对静止人体不敏感,躺着不动时可能误判为无人,这是 HC-SR501 的物理限制,不是代码问题。

6.4 一个让我后悔药都没得吃的教训

最早我把 PIR 消抖阈值设成连续 10 次高才认为有人,结果人走到风扇跟前了风扇还没启动。后来改成 3 次,响应快了,但偶尔会误触发。最后折中到 3 次高、5 次低,用了半年没出过问题。我的习惯是:任何和人体感应相关的阈值,先在串口里实时打印原始值,观察一天的真实波形再定,别拍脑袋。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询