STM32F103 流水灯三种写法对比 + HAL 库按键中断控制(暂停/恢复)
写在前面:这次要交的是"用 HAL 库 + 按键中断控制流水灯"。
但在动手之前,我把同一个流水灯用三种方式各写了一遍 —— 直接操作寄存器、调用标准外设库、调用 HAL 库,
想搞清楚GPIO_Init()、HAL_GPIO_Init()这些函数背后到底替我们干了什么。
三种写法代码看着完全不一样,最后写进去的却是同一批寄存器。所有代码都在 Keil MDK 5.24 上编译下载实测过:
0 Error(s), 0 Warning(s)。
目录
- 这次要做什么
- 三种写法到底差在哪
- ① 直接操作寄存器——自己算地址、自己拼位域
- ② 调用标准外设库——地址和位域都由库函数代劳
- ③ 调用 HAL 库 —— 写法类似,函数名和宏换了一套
- 三种写法的对应关系
- 该怎么选
- 为什么要把延时改成定时器中断
- TIM3 的 1ms 是怎么算出来的
- 按键中断是怎么串起来的
- 没有按键怎么办
- 消抖为什么要两级
- 完整代码
- LED 初始化与显示
- TIM3 初始化
- 主循环
- 按键初始化
- 中断服务函数与消抖
- 实测结果
- 编译结果
- 运行现象
- 参考资料
摘要:本文用 STM32F103C8T6(Blue Pill)实现三只 LED 流水灯,每只亮 1 秒,并对比了直接操作寄存器、标准外设库、HAL 库三种写法的异同——代码看似不同,最终写进去的却是同一批寄存器。随后把
Delay()死等改为TIM3 每 1ms 中断计数,让主循环不再阻塞;再用EXTI 外部中断接一个按键(板上没有按键,用杜邦线代替),实现按一下暂停、再按一下继续。全文代码均在 Keil MDK 5.24 上编译下载实测通过(0 Error(s), 0 Warning(s)),并附完整代码、编译结果与运行现象。
这次要做什么
- 用 STM32F103C8T6 最小系统板(Blue Pill)+ 面包板 + 3 只 LED 做流水灯,每只亮 1 秒;
- 三只灯接 PA0 / PA1 / PA2,低电平点亮;
- 同一个功能用寄存器 / 标准外设库 / HAL 库三种写法各实现一遍,对比差别;
- 把
Delay()死等换成TIM3 每 1ms 中断计数,让主循环不阻塞; - 用EXTI 外部中断接一个按键(板上没有按键,用杜邦线代替),实现按一下暂停、再按一下继续。## 用到的硬件和软件
| 类别 | 内容 |
|---|---|
| 开发环境 | Keil MDK 5.24 + ARM Compiler 5.06 update 5 |
| 库 | ST 官方 STM32F1xx HAL 库;对照用标准外设库 STM32F10x_StdPeriph_Lib V3.5 |
| 硬件 | STM32F103C8T6 最小系统板(Blue Pill)、面包板、3 只 LED、330Ω 电阻 3 个、杜邦线 |
| 芯片参数 | Cortex-M3,72 MHz,64 KB Flash,20 KB SRAM |
面包板接线和 Keil 编译界面可以直接对着板子拍两张照片贴在这一节下面,图文更直观。
三种写法到底差在哪
同一个“点亮 PA0”的动作,三种写法如下。
① 直接操作寄存器——自己算地址、自己拼位域
*(unsignedint*)0x40021018|=(1<<2);// 开 GPIOA 时钟(RCC_APB2ENR)*(unsignedint*)0x40010800&=0xFFFFFFF0;// 清 PA0 的配置位(GPIOA_CRL)*(unsignedint*)0x40010800|=0x00000002;// PA0 通用推挽输出 2MHz*(unsignedint*)0x4001080C=(1<<0);// PA0 输出高(GPIOA_ODR)这些地址是从哪来的
先把丑话说在前面:0x4001080C这种数没人能背下来,都是查手册两张表拼出来的。
- 第一张表:参考手册《STM32F10xxx RM0008》的外围基地址表 —— 查到
GPIOA = 0x40010800; - 第二张表:同一本手册里 GPIO 的寄存器偏移表 —— 查到
CRL = +0x00、ODR = +0x0C。
基地址 + 偏移,才是最终的绝对地址。本项目用到的都在下面这张表里:
| 寄存器 | 绝对地址 | 位 / 位域 | 写入值 | 含义 |
|---|---|---|---|---|
RCC_APB2ENR | 0x40021018 | bit2IOPAEN | 1 | 使能 GPIOA 时钟 |
RCC_APB2ENR | 0x40021018 | bit0AFIOEN | 1 | 使能 AFIO 时钟(用 EXTI 必须开) |
GPIOA_CRL | 0x40010800 | bit3:0 | 0x2 | PA0 通用推挽输出 2MHz |
GPIOA_CRL | 0x40010800 | bit7:4 | 0x2 | PA1 通用推挽输出 2MHz |
GPIOA_CRL | 0x40010800 | bit11:8 | 0x2 | PA2 通用推挽输出 2MHz |
GPIOA_ODR | 0x4001080C | bit0 / bit1 / bit2 | 1 | PA0 / PA1 / PA2 输出高(灯灭) |
GPIOA_ODR | 0x4001080C | bit0 / bit1 / bit2 | 0 | PA0 / PA1 / PA2 输出低(灯亮) |
GPIOA_BSRR | 0x40010810 | bit0~bit2 | 1 | 对应引脚输出高(原子操作) |
GPIOA_BRR | 0x40010814 | bit0~bit2 | 1 | 对应引脚输出低(原子操作) |
AFIO_EXTICR1 | 0x40010008 | bit7:4 | 0b0001 | EXTI1 线映射到PB1(0000=A、0001=B) |
EXTI_FTSR | 0x4001040C | bit1 | 1 | EXTI1 下降沿触发 |
EXTI_IMR | 0x40010400 | bit1 | 1 | 打开 EXTI1 中断屏蔽 |
EXTI_PR | 0x40010414 | bit1 | 1 | 清 EXTI1 挂起标志(写 1 清除) |
一个省事的办法:每个 GPIO 端口有 7 个寄存器,偏移量是固定的
——CRL+0x00、CRH+0x04、IDR+0x08、ODR+0x0C、BSRR+0x10、BRR+0x14、LCKR+0x18。
所以只要记住端口基地址(GPIOA = 0x40010800、GPIOB = 0x40010C00、GPIOC = 0x40011000),
加个偏移就够了,不用每个寄存器都去翻手册。
为什么别人博客里写的是
GPIOA->CRL?那其实是"查表查得更彻底"的写法。
ST 官方头文件里已经把这些地址定义好了,连加法都替你写好了:#defineGPIOA_BASE(APB2PERIPH_BASE+0x00000800UL)// → 0x40010800#defineGPIOA((GPIO_TypeDef*)GPIOA_BASE)typedefstruct{__IOuint32_tCRL;// 偏移 0x00__IOuint32_tCRH;// 偏移 0x04__IOuint32_tIDR;// 偏移 0x08__IOuint32_tODR;// 偏移 0x0C...}GPIO_TypeDef;于是
GPIOA->ODR就等于0x40010800 + 0x0C = 0x4001080C,和手写硬地址完全等价,
只是让编译器替你做那个加法,可读性还更好。
本节用硬地址是为了把"两张表 + 一次加法"摆到明面上看,实际写代码推荐用GPIOA->CRL这种指针写法。
② 调用标准外设库——地址和位域都由库函数代劳
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA,ENABLE);// 开 GPIOA 时钟GPIO_InitTypeDef GPIO_InitStructure;GPIO_InitStructure.GPIO_Mode=GPIO_Mode_Out_PP;// 推挽输出GPIO_InitStructure.GPIO_Pin=GPIO_Pin_0;GPIO_InitStructure.GPIO_Speed=GPIO_Speed_50MHz;GPIO_Init(GPIOA,&GPIO_InitStructure);GPIO_SetBits(GPIOA,GPIO_Pin_0);// PA0 输出高③ 调用 HAL 库 —— 写法类似,函数名和宏换了一套
__HAL_RCC_GPIOA_CLK_ENABLE();// 开 GPIOA 时钟GPIO_InitTypeDef GPIO_InitStructure={0};GPIO_InitStructure.Mode=GPIO_MODE_OUTPUT_PP;// 推挽输出GPIO_InitStructure.Pin=GPIO_PIN_0;GPIO_InitStructure.Pull=GPIO_NOPULL;GPIO_InitStructure.Speed=GPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOA,&GPIO_InitStructure);HAL_GPIO_WritePin(GPIOA,GPIO_PIN_0,GPIO_PIN_SET);// PA0 输出高三种写法的对应关系
代码完全不同,但最终写进去的是同一批寄存器:
| 要做的事 | 直接操作寄存器 | 标准外设库 | HAL 库 |
|---|---|---|---|
| 开 GPIOA 时钟 | *(uint32_t*)0x40021018 |= (1<<2) | RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE) | __HAL_RCC_GPIOA_CLK_ENABLE() |
| 配成推挽输出 | GPIOA_CRL = 0x...2 | GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP | GPIO_InitStructure.Mode = GPIO_MODE_OUTPUT_PP |
| 输出高电平 | GPIOA_ODR |= (1<<0) | GPIO_SetBits(GPIOA, GPIO_Pin_0) | HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET) |
| 输出低电平 | GPIOA_ODR &= ~(1<<0) | GPIO_ResetBits(GPIOA, GPIO_Pin_0) | HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET) |
结论:HAL 库并没有让“底层”消失,它只是把寄存器操作封装成了函数。
寄存器该配的还是要配,只是不用自己算地址、不用自己记位域了。
不信可以翻HAL_GPIO_WritePin()的源码,它内部就是在写 BSRR 寄存器:
voidHAL_GPIO_WritePin(GPIO_TypeDef*GPIOx,uint16_tGPIO_Pin,GPIO_PinState PinState){if(PinState!=GPIO_PIN_RESET)GPIOx->BSRR=GPIO_Pin;// 置位 → 输出高elseGPIOx->BSRR=(uint32_t)GPIO_Pin<<16u;// 写高 16 位 → 输出低}该怎么选
| 方式 | 优点 | 缺点 |
|---|---|---|
| 寄存器 | 最贴近硬件,代码最小 | 要查手册算地址,可读性差、易出错 |
| 标准外设库 | 函数名直观,网上资料特别多 | 已停止维护,ST 不再更新 |
| HAL 库 | ST 主推、跨系列移植方便、能配 CubeMX 生成 | 代码层次多、体积稍大 |
我这次选 HAL 库,因为它是 ST 现在主推的方式,后面学 CubeMX 会直接生成 HAL 代码。
为什么要把延时改成定时器中断
标准外设库那个版本是用Delay_ms(1000)让 CPU 空转着数 1 秒,这 1 秒里 CPU 什么都干不了,按键按下去也没人理。所以必须换思路:
TIM3 每 1ms 中断一次 → 在中断里给计数器 +1 → 主循环数够 1000 次就换下一只灯Delay_ms()是"阻塞等待",定时器中断是"到点了通知我",两种完全不同的思路。
换成中断之后主循环不再阻塞,按键中断随时都能被响应 —— 这是能加上按键控制的前提。
TIM3 的 1ms 是怎么算出来的
STM32F103 系统时钟 72 MHz。TIM3 挂在 APB1 上,APB1 分频系数是 2,所以 APB1 时钟是 36 MHz;
但只要 APB1 分频系数不为 1,定时器时钟就要再乘 2(这条规则我第一次看手册时直接漏了),
所以 TIM3 的实际时钟还是72 MHz。
| 参数 | 计算 | 结果 |
|---|---|---|
| 预分频 PSC | 72 MHz ÷ 72 | 计数频率 1 MHz(1 个计数 = 1 μs) |
| 自动重装 ARR | 1 MHz 数 1000 次 | 1000 μs =1 ms |
代码里 PSC 是按SystemCoreClock实时算的,没写死 72,这样即使外部晶振没起振、系统退回内部 8 MHz,1 ms 也不会跑偏。
按键中断是怎么串起来的
PB1 碰到 GND(电平由高变低,产生下降沿) ↓ 硬件 EXTI1 产生中断请求 ↓ NVIC 放行 EXTI1_IRQHandler() ← 中断服务函数 ↓ HAL_GPIO_EXTI_IRQHandler() ← HAL 库:清中断标志 ↓ HAL_GPIO_EXTI_Callback() ← 消抖 → 翻转暂停标志 ↓ 主循环读到标志变化 → 暂停 / 继续换灯三个容易搞错的点:
- EXTI 线号 = 引脚号。PB1 走 EXTI1、PC13 走 EXTI13,跟端口没关系;
但同一条线号同一时间只能映射到一个端口,用了 PB1 就不能再用 PA1 做中断。 - 要用 EXTI 必须先开 AFIO 时钟。因为
AFIO_EXTICR这个寄存器负责把 EXTI 线“接线”到具体端口。
这点 HAL 已经替我们做了 —— 只要把 Mode 配成GPIO_MODE_IT_FALLING,HAL_GPIO_Init()内部就会开 AFIO 时钟、写AFIO_EXTICR、开EXTI_FTSR和EXTI_IMR,
我们只需要再打开 NVIC。 - 中断服务函数名必须和启动文件向量表里的写法完全一致,写错一个字母,
中断一来就会跑到默认的死循环里,而且不报错,非常难查。
没有按键怎么办
最小系统板上只有 RESET 和 BOOT0 两个按键,这两个都不能用:
- RESET直接接芯片 NRST 引脚,按下整个芯片复位,程序从
main()重跑; - BOOT0只在复位瞬间被采样(决定启动模式),运行中按它没有反应。
所以直接用一根杜邦线代替按键:
杜邦线一头 → PB1(插在排针上不动) 杜邦线另一头 → 碰一下 GND 再拿开,相当于"按了一下"不需要外接上拉电阻,也不需要消抖电容 —— 程序把 PB1 配成内部上拉输入,
不碰时被内部电阻拉到高电平,碰到 GND 变成低电平,这个由高变低的瞬间就是下降沿。
操作要点:碰一下就拿开,别一直按着。程序是靠下降沿触发的,
一直按着只有最开始那一次下降沿,只会暂停一次;拿开再碰才有第二次。
消抖为什么要两级
杜邦线插拔和机械按键一样,触点接触的瞬间会连续弹跳,一次触碰可能产生一串下降沿。
如果每个都处理,就会出现"碰一下就暂停、又立刻恢复"。所以做了两级:
| 级别 | 参数 | 作用 |
|---|---|---|
| ① 电平确认 | 20ms | 检测到下降沿后,等 20ms 再读一次 PB1,仍是低电平才认 |
| ② 间隔限制 | 200ms | 距上次有效触发不足 200ms 的边沿直接丢掉 |
完整代码
LED 初始化与显示
staticvoidLed_GPIO_Init(void){GPIO_InitTypeDef GPIO_InitStructure={0};__HAL_RCC_GPIOA_CLK_ENABLE();// 不开时钟后面全白配GPIO_InitStructure.Pin=GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2;GPIO_InitStructure.Mode=GPIO_MODE_OUTPUT_PP;GPIO_InitStructure.Pull=GPIO_NOPULL;GPIO_InitStructure.Speed=GPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(LED_GPIO_PORT,&GPIO_InitStructure);HAL_GPIO_WritePin(LED_GPIO_PORT,GPIO_PIN_0|GPIO_PIN_1|GPIO_PIN_2,LED_OFF_STATE);}staticvoidLed_Show(uint8_tindex)// 点亮第 index 只,其余熄灭{uint8_ti;for(i=0U;i<LED_NUM;i++){HAL_GPIO_WritePin(LED_GPIO_PORT,LED_PIN_TABLE[i],LED_OFF_STATE);}if(index<LED_NUM){HAL_GPIO_WritePin(LED_GPIO_PORT,LED_PIN_TABLE[index],LED_ON_STATE);}}TIM3 初始化
staticvoidLed_Timer_Init(void){__HAL_RCC_TIM3_CLK_ENABLE();htim3.Instance=TIM3;htim3.Init.Prescaler=(SystemCoreClock/1000000U)-1U;// 72-1 → 1MHzhtim3.Init.CounterMode=TIM_COUNTERMODE_UP;htim3.Init.Period=1000U-1U;// 1000 次 = 1mshtim3.Init.ClockDivision=TIM_CLOCKDIVISION_DIV1;htim3.Init.AutoReloadPreload=TIM_AUTORELOAD_PRELOAD_DISABLE;HAL_TIM_Base_Init(&htim3);HAL_NVIC_SetPriority(TIM3_IRQn,1,0);// 分组 2,取值 0~3HAL_NVIC_EnableIRQ(TIM3_IRQn);HAL_TIM_Base_Start_IT(&htim3);}主循环
while(1){if(Led_IsPaused()==0U){if(g_led_elapsed_ms>=LED_INTERVAL_MS){g_led_elapsed_ms=0U;g_led_index++;if(g_led_index>=LED_NUM){g_led_index=0U;}Led_Show(g_led_index);}}else{g_led_elapsed_ms=0U;// 暂停时清零,恢复后重新亮满 1 秒}}按键初始化
voidBsp_Key_Init(void){GPIO_InitTypeDef GPIO_InitStructure={0};__HAL_RCC_GPIOB_CLK_ENABLE();GPIO_InitStructure.Pin=BSP_KEY_GPIO_PIN;GPIO_InitStructure.Mode=GPIO_MODE_IT_FALLING;// 下降沿触发中断GPIO_InitStructure.Pull=GPIO_PULLUP;// 内部上拉HAL_GPIO_Init(BSP_KEY_GPIO_PORT,&GPIO_InitStructure);HAL_NVIC_SetPriority(BSP_KEY_IRQn,1,1);HAL_NVIC_EnableIRQ(BSP_KEY_IRQn);}中断服务函数与消抖
/* 中断入口 */voidTIM3_IRQHandler(void){HAL_TIM_IRQHandler(&htim3);}voidBSP_KEY_IRQHandler(void){HAL_GPIO_EXTI_IRQHandler(BSP_KEY_GPIO_PIN);}/* TIM3 每 1 ms 进一次:流水计时 + 按键消抖 */voidHAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef*htim){if(htim->Instance==TIM3){g_led_elapsed_ms++;Bsp_Key_Tick();}}/* 按键中断回调:只记一笔,不做延时 */voidHAL_GPIO_EXTI_Callback(uint16_tGPIO_Pin){if(GPIO_Pin!=BSP_KEY_GPIO_PIN)return;if((HAL_GetTick()-key_last_ms)<BSP_KEY_MIN_GAP_MS)return;// 防止弹跳连发key_pending=1U;key_ticks=0U;}/* 消抖:由 TIM3 的 1 ms 中断调用 */voidBsp_Key_Tick(void){if(key_pending==0U)return;if(++key_ticks<BSP_KEY_DEBOUNCE_MS)return;key_pending=0U;if(HAL_GPIO_ReadPin(BSP_KEY_GPIO_PORT,BSP_KEY_GPIO_PIN)==GPIO_PIN_RESET){key_last_ms=HAL_GetTick();g_key_paused=(uint8_t)(!g_key_paused);// 暂停 ⇄ 恢复}}注意:消抖不能直接写在按键中断里用
HAL_Delay()等待,否则会死锁——原因见下面“踩过的坑”。
实测结果
编译结果
三种写法的代码量对比(同样三只灯、同样 1 秒间隔):
| 版本 | Code | RO-data | RW-data | ZI-data | 编译结果 |
|---|---|---|---|---|---|
| 标准外设库版 | 1160 | 260 | 0 | 1632 | 0 Error 0 Warning |
| HAL 库版 + 按键中断 | 2528 | 292 | 32 | 1096 | 0 Error 0 Warning |
HAL 库版的完整输出:
*** Using Compiler 'V5.06 update 5 (build 528)', folder: '<Keil 安装目录>\ARM\ARMCC\Bin' Build target 'Target 1' Program Size: Code=2528 RO-data=292 RW-data=32 ZI-data=1096 ".\Objects\Project.axf" - 0 Error(s), 0 Warning(s).两个版本现象完全一样,但 HAL 版多出了定时器配置、中断处理和按键逻辑,
所以 Code 从 1160 字节涨到 2528 字节。即使这样也只占 64 KB Flash 的 4%,资源很宽裕。
运行现象
| 操作 | 现象 |
|---|---|
| 上电复位 | 三只 LED 依次点亮,每只亮 1 秒,3 秒一循环 |
| 杜邦线碰一下 GND 再拿开 | 流水灯暂停,当前那只灯保持亮着不动 |
| 再碰一下再拿开 | 流水灯恢复,并且当前这只灯重新亮满 1 秒才换下一只 |
用标准库外设控制的流水灯
用 HAL 库 + 按键中断方式控制流水灯运行的暂停、恢复继续
参考资料
- STM32C8T6 寄存器方式与标准库方式流水灯
- STM32 寄存器/标准库流水灯(CSDN)
- ST 官方《STM32F10xxx 参考手册 RM0008》
- ST 官方《STM32F1xx HAL 库用户手册 UM1850》
- 江协科技《STM32 入门教程》—— 工程模板与 HAL 库开发流程