简介:本资源是一份面向嵌入式初学者与STM32实践者的完整项目工程,聚焦于通过物理按键实时调节LED亮度的核心功能,覆盖GPIO输入检测、定时器PWM输出、中断响应及按键消抖等关键知识点,适用于课程设计、毕业设计及IoT硬件开发入门训练。压缩包共202个文件,含36个C源文件(如stm32f10x_tim.c、stm32f10x_usart.c等外设驱动)、36个头文件(h)、36个编译中间文件(o)及35个汇编相关文件(d/crf/s),结构清晰体现标准STM32F103固件库工程组织,整体大小为6.76MB。已有19504人学习下载,热度高、实操性强。读者可直接导入Keil MDK(含uvprojx、uvoptx工程文件)编译运行,获取完整可调试代码、定时器PWM配置范例、六档亮度映射逻辑、中断服务函数实现细节,以及典型去抖处理方案,是理解STM32底层外设协同控制的优质实践样本。
1. 按下按键,LED亮度像呼吸一样渐变:这不是示波器波形图,而是你手边那块STM32开发板的真实输出
你刚焊好最小系统,烧录完第一个点灯程序,发现LED只有“亮”和“灭”两种状态——像老式电闸,咔哒一下全开或全关。但现实里,调光从来不是二值的:台灯要柔光,车灯要防眩目,RGB氛围灯得平滑过渡。这个项目就是专治这种“数字僵直症”的实战切口:用最基础的 STM32F103C8T6(蓝 pill)开发板,仅靠 2 个机械按键 + 1 颗 LED,不加任何外部芯片,纯靠片内资源实现8 级可调亮度 + 呼吸灯模式切换 + 按键消抖抗干扰。它没用 HAL 库的封装黑盒,全程基于标准外设库(SPL)操作寄存器,所有 PWM 占空比计算、定时器重装载值、GPIO 复用配置都摊开在代码里。适合刚学完《STM32权威指南》第 5 章、正对着 Reference Manual 发懵的新手;也适合想快速验证某个低功耗 PWM 方案、需要抄底级参考的嵌入式老手。项目压缩包里只有 4 个核心文件(main.c / stm32f10x_it.c / led_pwm.c / key_scan.c),没有 CMSIS 启动文件之外的第三方依赖,连 keil 工程都给你配好了 target 设置——插上 ST-Link,点下载,30 秒后你就能看到 LED 在你指尖按压下,一明一暗地“呼吸”。
2. 硬件与软件协同设计:从 GPIO 复用到 TIM2_CH1 的完整链路
2.1 硬件连接:为什么必须接 PA0?为什么不能用任意 GPIO?
这个项目看似简单,但硬件选型直接决定能否跑通。LED 阴极接地,阳极必须接到一个支持复用推挽输出且能映射到高级定时器通道的 GPIO 引脚。我们选的是PA0,原因有三:
- PA0 默认复位状态是浮空输入,但可通过
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE)关闭 JTAG,腾出 PA0 作普通 GPIO; - 更关键的是,PA0 可复用为TIM2_CH1(定时器 2 的通道 1),而 TIM2 是唯一一个在 F103C8T6 上支持向上计数 + PWM 输出模式 + 自动重装载的基础定时器;
- 对比之下,PB6 虽也能复用为 TIM4_CH1,但 TIM4 在该型号中无影子寄存器(shadow register),无法实现占空比动态更新时的无缝切换,会出现亮度跳变。
提示:若你手头是 STM32F103RCT6(大容量版),可改用 PB6+TIM4,但本项目为兼容最常见蓝 pill 板,强制锁定 PA0+TIM2。
接线表如下(务必对照你的开发板丝印):
| 开发板引脚 | 连接目标 | 说明 |
|---|---|---|
| PA0 | LED 阳极 | 经 220Ω 限流电阻 |
| GND | LED 阴极 | 直接接地 |
| PC13 | 按键 K1 下拉端 | 按键另一端接 VCC,实现“按下=高电平” |
| PC14 | 按键 K2 下拉端 | 同上,K1 控制增亮,K2 控制减亮/切换模式 |
2.2 定时器 PWM 模式配置:TIM2 的 4 个关键寄存器怎么填
PWM 的本质是“高频开关 + 占空比控制”。在 STM32 中,这由定时器的计数器(CNT)、自动重装载寄存器(ARR)、捕获/比较寄存器(CCR1)三者协同完成。以 TIM2 为例,其工作流程是:
- 系统时钟(72MHz)经预分频器(PSC)降频 → 得到计数时钟;
- 计数器(CNT)从 0 开始向上计数,直到等于 ARR 值 → 触发更新事件,CNT 清零;
- 当 CNT < CCR1 时,CH1 输出高电平;当 CNT ≥ CCR1 时,CH1 输出低电平;
- 因此,占空比 = CCR1 / (ARR + 1)(注意:ARR 是最大计数值,所以周期是 ARR+1 个计数周期)。
本项目中,我们设定:
- 目标 PWM 频率:1kHz(人眼无频闪感的下限);
- 系统时钟:72MHz;
- PSC = 71 → 计数时钟 = 72MHz / (71+1) = 1MHz;
- ARR = 999 → 计数周期 = 1000 个计数 → PWM 频率 = 1MHz / 1000 = 1kHz;
- CCR1 初始值 = 100 → 初始占空比 = 100 / 1000 = 10%。
对应初始化代码(摘自led_pwm.c):
void LED_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 使能 GPIOA 和 TIM2 时钟 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_TIM2, ENABLE); // 2. 配置 PA0 为复用推挽输出(AF_PP) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 必须是 AF_PP! GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置 TIM2 基础参数:1kHz PWM TIM_TimeBaseStructure.TIM_Period = 999; // ARR = 999 → 1000 个计数周期 TIM_TimeBaseStructure.TIM_Prescaler = 71; // PSC = 71 → 分频系数 72 TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // 4. 配置 CH1 为 PWM 模式 1(高电平有效) TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // 关键!非 TIM_OCMode_Active TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 100; // 初始 CCR1 = 100 → 10% 占空比 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); // 启用影子寄存器,避免突变 // 5. 使能 TIM2 TIM_Cmd(TIM2, ENABLE); }参数说明:
TIM_OCMode_PWM1:当 CNT < CCR1 时输出高,CNT ≥ CCR1 时输出低(即“高电平有效”);TIM_OCPreload_Enable:启用影子寄存器。这意味着修改TIM_SetCompare1()时,新值不会立即生效,而是在下一个更新事件(CNT=ARR)时同步载入,彻底消除占空比突变;GPIO_Mode_AF_PP:必须是复用推挽,而非普通推挽(GPIO_Mode_Out_PP),否则无法触发定时器输出功能。
2.3 按键扫描与状态机:为什么不用延时消抖?如何避免阻塞主循环?
两个按键(K1/K2)不仅要检测按下,还要支持长按连发(持续按住 K1,亮度逐级增加)和短按切换(单击 K2 切换呼吸灯模式)。如果用Delay_ms(20)消抖,主循环会卡死,PWM 波形失真。本项目采用状态机 + 时间戳的非阻塞方案,核心逻辑在key_scan.c中:
#define KEY_SCAN_INTERVAL 10 // 每 10ms 扫描一次,单位 ms typedef enum { KEY_STATE_IDLE, KEY_STATE_DOWN, KEY_STATE_LONG_PRESS, KEY_STATE_UP } KeyState_t; typedef struct { uint8_t pin; KeyState_t state; uint32_t last_press_time; uint32_t long_press_start; uint8_t is_long_press; } KeyInfo_t; static KeyInfo_t key1_info = {GPIO_Pin_13, KEY_STATE_IDLE, 0, 0, 0}; static KeyInfo_t key2_info = {GPIO_Pin_14, KEY_STATE_IDLE, 0, 0, 0}; void KEY_Scan(void) { static uint32_t last_scan_time = 0; uint32_t current_time = GetSysTickTime(); // 假设已实现 SysTick 获取毫秒时间 if ((current_time - last_scan_time) < KEY_SCAN_INTERVAL) return; last_scan_time = current_time; // 扫描 K1(PC13) if (GPIO_ReadInputDataBit(GPIOC, key1_info.pin) == Bit_SET) { switch (key1_info.state) { case KEY_STATE_IDLE: key1_info.state = KEY_STATE_DOWN; key1_info.last_press_time = current_time; break; case KEY_STATE_DOWN: if (current_time - key1_info.last_press_time > 500) { key1_info.state = KEY_STATE_LONG_PRESS; key1_info.long_press_start = current_time; key1_info.is_long_press = 1; } break; case KEY_STATE_LONG_PRESS: if ((current_time - key1_info.long_press_start) % 300 < 10) { // 每 300ms 触发一次 LED_BrightnessUp(); // 亮度+1级 } break; } } else { if (key1_info.state == KEY_STATE_DOWN) { // 短按:单次触发 LED_BrightnessUp(); } key1_info.state = KEY_STATE_IDLE; key1_info.is_long_press = 0; } // K2(PC14)逻辑同理,此处省略... }关键设计点:
KEY_SCAN_INTERVAL = 10ms:远小于人手抖动周期(通常 5~20ms),既能滤除抖动,又不影响响应速度;GetSysTickTime():基于 SysTick 定时器的毫秒级时间戳,避免使用for()延时;- 长按判定阈值
500ms:经验值,短于 300ms 易误判,长于 800ms 用户感知延迟; key1_info.is_long_press标志位:防止长按期间重复进入KEY_STATE_DOWN分支。
3. 核心控制逻辑拆解:8 级亮度调节与呼吸灯模式的数学实现
3.1 8 级亮度:为什么不是线性映射?Gamma 校正的嵌入式简化版
人眼对亮度的感知是非线性的——光强翻倍,人眼只觉得“稍亮一点”。若将 CCR1 值从 100 线性增至 900(对应 10%→90% 占空比),你会明显感觉前几级变化剧烈,后几级几乎看不出区别。本项目采用查表法 + 近似 Gamma 校正,定义 8 级亮度对应的 CCR1 值:
| 亮度等级 | 占空比(理论) | CCR1 实际值 | 人眼感知效果 |
|---|---|---|---|
| Level 0 | 0% | 0 | 完全熄灭 |
| Level 1 | ~3% | 30 | 微光可见 |
| Level 2 | ~7% | 70 | 暗淡但清晰 |
| Level 3 | ~12% | 120 | 中等偏暗 |
| Level 4 | ~20% | 200 | 中等亮度 |
| Level 5 | ~32% | 320 | 明亮 |
| Level 6 | ~48% | 480 | 较亮 |
| Level 7 | ~70% | 700 | 接近全亮 |
该映射表(brightness_table[8])定义在led_pwm.c中:
const uint16_t brightness_table[8] = { 0, // Level 0: OFF 30, // Level 1: ~3% 70, // Level 2: ~7% 120, // Level 3: ~12% 200, // Level 4: ~20% 320, // Level 5: ~32% 480, // Level 6: ~48% 700 // Level 7: ~70% }; uint8_t current_level = 4; // 初始 Level 4 void LED_BrightnessUp(void) { if (current_level < 7) { current_level++; TIM_SetCompare1(TIM2, brightness_table[current_level]); } } void LED_BrightnessDown(void) { if (current_level > 0) { current_level--; TIM_SetCompare1(TIM2, brightness_table[current_level]); } }为什么不用浮点运算做 Gamma 计算?
在 Cortex-M3 上,powf(x, 2.2)调用会链接大量 math 库,增加 Flash 占用 2KB+,且每次计算耗时 >10μs。而查表法仅需 1 次数组索引(<100ns),内存开销仅 16 字节(8×uint16_t),是资源受限 MCU 的黄金选择。
3.2 呼吸灯模式:用正弦波查表实现平滑渐变
呼吸灯的本质是让占空比按正弦规律变化:duty = A × sin(ωt) + B。为免实时计算三角函数,我们预先生成一个 64 点的正弦波表(覆盖 0~2π),并映射到 0~700 的 CCR1 范围:
// 64 点正弦波表,值域 [0, 1000],用于缩放 const uint16_t sine_table[64] = { 0, 98, 195, 290, 383, 472, 556, 634, 704, 766, 820, 865, 901, 927, 944, 951, 949, 937, 916, 885, 845, 797, 741, 678, 609, 535, 457, 376, 293, 209, 126, 45, 0, 45, 126, 209, 293, 376, 457, 535, 609, 678, 741, 797, 845, 885, 916, 937, 949, 951, 944, 927, 901, 865, 820, 766, 704, 634, 556, 472, 383, 290, 195, 98 }; uint8_t sine_index = 0; uint8_t breath_mode_enabled = 0; void LED_BreathModeToggle(void) { breath_mode_enabled = !breath_mode_enabled; if (breath_mode_enabled) { sine_index = 0; // 重置相位 } } void LED_BreathUpdate(void) { if (!breath_mode_enabled) return; // 每 20ms 更新一次相位(≈ 50Hz 呼吸频率,人眼舒适) static uint32_t last_breath_time = 0; uint32_t now = GetSysTickTime(); if (now - last_breath_time < 20) return; last_breath_time = now; // 查表 + 缩放:sine_table[0~63] ∈ [0,1000] → 映射到 [0,700] uint16_t ccr_val = (sine_table[sine_index] * 700) / 1000; TIM_SetCompare1(TIM2, ccr_val); sine_index = (sine_index + 1) % 64; // 循环查表 }参数说明:
sine_table值域 [0,1000]:预留缩放空间,方便后续调整呼吸幅度;(sine_table[x] * 700) / 1000:整数缩放,避免浮点;最大值 700 对应 Level 7,确保不超限;20ms 更新间隔:64 点 × 20ms = 1.28s 一个完整呼吸周期,符合人体工学(过快则像频闪,过慢则无呼吸感)。
3.3 模式协同:按键如何同时控制亮度级与呼吸开关?
K1/K2 的功能不是互斥的,而是分层控制:
- K1(增亮键):在非呼吸模式下,每按一次提升一级亮度;在呼吸模式下,长按 K1 可暂停呼吸,进入当前亮度保持状态;
- K2(功能键):短按切换呼吸模式开关;长按 K2 则重置为 Level 4 + 关闭呼吸(出厂默认态)。
该逻辑在main.c的主循环中调度:
int main(void) { SystemInit(); LED_PWM_Init(); KEY_Init(); // 初始化按键 GPIO while (1) { KEY_Scan(); // 每 10ms 扫描 if (breath_mode_enabled) { LED_BreathUpdate(); // 呼吸模式下,由定时器驱动占空比 } // K1 长按检测(在 KEY_Scan 中已标记) if (key1_info.is_long_press && breath_mode_enabled) { breath_mode_enabled = 0; // 暂停呼吸 TIM_SetCompare1(TIM2, brightness_table[current_level]); // 锁定当前级 } // K2 长按重置 if (key2_info.is_long_press) { current_level = 4; breath_mode_enabled = 0; TIM_SetCompare1(TIM2, brightness_table[4]); key2_info.is_long_press = 0; // 清标志 } Delay_ms(5); // 主循环最小延时,避免空转耗电 } }注意:
keyX_info.is_long_press是在KEY_Scan()中设置的标志位,主循环只读不写,保证状态机边界清晰。
4. 编译与烧录实操:Keil MDK 工程配置避坑指南
4.1 Keil 工程关键设置:Target 页的三个致命选项
很多新手烧录后 LED 不亮,90% 出在 Keil 的 Target 页配置错误。以下是蓝 pill(STM32F103C8T6)必须核对的三项:
| 设置项 | 正确值 | 错误后果 | 血泪经验 |
|---|---|---|---|
| Xtal(MHz) | 8 | 若填 72,编译器会按 72MHz 生成时钟树,但实际 HSE 是 8MHz 晶振,导致所有定时器频率错乱 9 倍(1kHz 变成 111Hz,肉眼可见频闪) | 某开发者曾调了 3 小时,最后发现 Xtal 写成了 72 |
| Use MicroLIB | ✅ 勾选 | 若未勾选,printf等函数会链接标准 libc,导致 Flash 不足(本工程仅 64KB,标准库占 8KB+);且sprintf在无 OS 下易栈溢出 | 我现在新建工程第一件事就是勾 MicroLIB |
| ARM Compiler | V5.06 update 6 (build 750)或更高 | 旧版本(如 V5.04)对__attribute__((section("")))支持不全,可能导致中断向量表偏移 | 蓝 pill 板烧录失败,80% 是编译器版本太老 |
提示:在 Project → Options for Target → Target 页底部,点击
Manage Run-Time Environment,确认Device已选STMicroelectronics::STM32F103C8,且Startup文件已勾选startup_stm32f10x_md.s(MD = Medium-density,对应 C8T6)。
4.2 ST-Link V2 烧录故障排查:SWD 接线与固件升级
ST-Link V2 是最常用的调试器,但接线错误或固件过旧会导致“Cannot connect to target”:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Keil 提示 “No Debug Unit detected” | SWDIO/SWCLK 线接反(SWDIO 接了 SWCLK,反之亦然) | 对照开发板丝印:SWDIO → PA13,SWCLK → PA14;用万用表通断档测线缆两端 |
| 烧录时提示 “Target not halted” | 目标芯片处于低功耗模式(如 Sleep),或复位电路异常 | 按住开发板 RESET 键不放 → Keil 点 Download → 松开 RESET;或检查 R10/R11(复位上拉电阻)是否虚焊 |
| ST-Link Utility 识别为 “Unknown device” | ST-Link 固件过旧(< V2.J27.S4) | 下载 STSW-LINK007,运行ST-LinkUpgrade.exe升级固件;升级后需重启 Keil |
4.3 常见问题与排查:3 条真实翻车记录
现象 1:LED 常亮不灭,按键无反应
原因:GPIO_Init()中漏写了GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE),导致 PA0 被 JTAG 占用,无法作为 GPIO 输出。
解决:在LED_PWM_Init()开头添加该行,并确保RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_AFIO, ENABLE)已使能 AFIO 时钟。
现象 2:呼吸灯闪烁剧烈,像接触不良
原因:sine_table数组定义在.bss段(未初始化 RAM),但启动文件未将其复制到 RAM,导致查表时读到随机值。
解决:在main.c顶部添加__attribute__((section(".data")))强制放入.data段:
const uint16_t sine_table[64] __attribute__((section(".data"))) = { ... };现象 3:K2 短按切换呼吸模式,但 K1 长按无效
原因:KEY_Scan()中 K1 的长按判定逻辑写在if (GPIO_ReadInputDataBit(...) == Bit_SET)大括号内,而 K2 的长按标志key2_info.is_long_press在else分支被清零,导致 K1 长按状态未被主循环读取。
解决:将keyX_info.is_long_press = 0移至主循环处理完后统一清零,或在KEY_Scan()结尾添加key1_info.is_long_press = 0;(仅当本次未检测到长按)。
5. 二次开发改造指南:从呼吸灯到工业调光控制器的 3 个跃迁路径
5.1 路径一:接入 ADC 实现光敏自动调光
当前亮度由按键手动调节,但工业场景常需根据环境光自动适配。只需增加一个光敏电阻(GL5528)分压电路接至 PA1(ADC1_IN1),即可扩展为闭环系统:
硬件改动:
- PA1 配置为模拟输入:
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; - 使能 ADC1 时钟:
RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_ADC1, ENABLE);
软件新增逻辑(插入main.c主循环):
uint16_t adc_value; uint8_t auto_level; void ADC_Init(void) { ADC_InitTypeDef ADC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_ADC1, ENABLE); ADC_DeInit(ADC1); 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_1, 1, ADC_SampleTime_55_5Cycles); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); } // 主循环中添加 adc_value = ADC_GetConversionValue(ADC1); // GL5528 暗阻 >1MΩ,亮阻 ~2kΩ,分压后 ADC 值范围约 100~900 auto_level = (adc_value - 100) / 100; // 映射到 0~7 级(100→0, 900→7) if (auto_level > 7) auto_level = 7; if (auto_level < 0) auto_level = 0; if (!breath_mode_enabled) { current_level = auto_level; TIM_SetCompare1(TIM2, brightness_table[current_level]); }关键点:ADC 采样需加均值滤波(如 8 次采样取平均),否则光敏电阻噪声会导致亮度抖动。我一般在ADC_Init()后加一个ADC_Filter()函数,用环形缓冲区存最近 8 次值。
5.2 路径二:用 UART 输出调试信息,把黑匣子变成透明系统
裸机开发最痛苦的是“不知道程序跑到哪了”。给本项目加上 UART 打印,成本几乎为零(PA9/PA10 复用 USART1):
// 在 main.c 中添加 void USART1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA | RCC_APB2PERIPH_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); USART_Cmd(USART1, ENABLE); } // 重定向 printf(需勾选 MicroLIB) int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t) ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); return ch; }从此,你在LED_BrightnessUp()里加一句printf("Level up to %d\r\n", current_level);,用串口助手就能实时看到按键动作——这比示波器看 PA0 波形直观十倍。
5.3 路径三:移植到 FreeRTOS,把单任务变成多任务调度器
当项目复杂度上升(比如要同时处理温湿度传感器、OLED 显示、蓝牙透传),裸机状态机就力不从心。FreeRTOS 移植本项目仅需 3 步:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1. 添加 RTOS 内核 | 将FreeRTOS/Source文件夹拷入工程,添加portable/GCC/ARM_CM3/port.c和portmacro.h | 使用官方 Cortex-M3 GCC port,无需修改 |
| 2. 创建任务 | xTaskCreate(LED_Control_Task, "LED", 128, NULL, 2, NULL); | 任务栈 128 words 足够(原裸机代码仅占 40 words) |
| 3. 替换延时 | 将Delay_ms(5)改为vTaskDelay(5 / portTICK_PERIOD_MS); | portTICK_PERIOD_MS = 1(1ms tick),故 5ms = 5 ticks |
此时,LED 控制、按键扫描、ADC 采集可拆分为独立任务,用队列(xQueueSend())通信,彻底解耦。我一般会把KEY_Scan()单独建一个Key_Task,检测到按键后发消息到LED_Queue,由LED_Task统一处理亮度更新——这样即使按键任务被阻塞,LED 也不会熄灭。
从那以后我每次接到新 MCU 项目,第一件事就是先跑通这个按键调光 demo:它像一把手术刀,能立刻剖开芯片的时钟树、GPIO 复用、定时器 PWM、中断优先级这些核心脉络。只要 PA0 能按预期亮灭,剩下的外设驱动,不过是把同样的逻辑套用过去。希望帮到你。
本文还有配套的精品资源,点击获取