news 2026/9/30 11:28:36

STM32F103 流水灯三种写法对比 + HAL 库按键中断控制(暂停/恢复)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 流水灯三种写法对比 + HAL 库按键中断控制(暂停/恢复)

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_APB2ENR0x40021018bit2IOPAEN1使能 GPIOA 时钟
RCC_APB2ENR0x40021018bit0AFIOEN1使能 AFIO 时钟(用 EXTI 必须开)
GPIOA_CRL0x40010800bit3:00x2PA0 通用推挽输出 2MHz
GPIOA_CRL0x40010800bit7:40x2PA1 通用推挽输出 2MHz
GPIOA_CRL0x40010800bit11:80x2PA2 通用推挽输出 2MHz
GPIOA_ODR0x4001080Cbit0 / bit1 / bit21PA0 / PA1 / PA2 输出高(灯灭)
GPIOA_ODR0x4001080Cbit0 / bit1 / bit20PA0 / PA1 / PA2 输出低(灯亮)
GPIOA_BSRR0x40010810bit0~bit21对应引脚输出高(原子操作)
GPIOA_BRR0x40010814bit0~bit21对应引脚输出低(原子操作)
AFIO_EXTICR10x40010008bit7:40b0001EXTI1 线映射到PB1(0000=A、0001=B)
EXTI_FTSR0x4001040Cbit11EXTI1 下降沿触发
EXTI_IMR0x40010400bit11打开 EXTI1 中断屏蔽
EXTI_PR0x40010414bit11清 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...2GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PPGPIO_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。

参数计算结果
预分频 PSC72 MHz ÷ 72计数频率 1 MHz(1 个计数 = 1 μs)
自动重装 ARR1 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() ← 消抖 → 翻转暂停标志 ↓ 主循环读到标志变化 → 暂停 / 继续换灯

三个容易搞错的点:

  1. EXTI 线号 = 引脚号。PB1 走 EXTI1、PC13 走 EXTI13,跟端口没关系;
    但同一条线号同一时间只能映射到一个端口,用了 PB1 就不能再用 PA1 做中断。
  2. 要用 EXTI 必须先开 AFIO 时钟。因为AFIO_EXTICR这个寄存器负责把 EXTI 线“接线”到具体端口。
    这点 HAL 已经替我们做了 —— 只要把 Mode 配成GPIO_MODE_IT_FALLING,
    HAL_GPIO_Init()内部就会开 AFIO 时钟、写AFIO_EXTICR、开EXTI_FTSR和EXTI_IMR,
    我们只需要再打开 NVIC。
  3. 中断服务函数名必须和启动文件向量表里的写法完全一致,写错一个字母,
    中断一来就会跑到默认的死循环里,而且不报错,非常难查。

没有按键怎么办

最小系统板上只有 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 秒间隔):

版本CodeRO-dataRW-dataZI-data编译结果
标准外设库版1160260016320 Error 0 Warning
HAL 库版 + 按键中断25282923210960 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 库 + 按键中断方式控制流水灯运行的暂停、恢复继续

参考资料

  1. STM32C8T6 寄存器方式与标准库方式流水灯
  2. STM32 寄存器/标准库流水灯(CSDN)
  3. ST 官方《STM32F10xxx 参考手册 RM0008》
  4. ST 官方《STM32F1xx HAL 库用户手册 UM1850》
  5. 江协科技《STM32 入门教程》—— 工程模板与 HAL 库开发流程
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 11:26:37

HTTPS下GET与POST的区别:从幂等性到安全性,一文讲透

刚工作那两年&#xff0c;我被一个面试题问懵过&#xff1a;“说一说GET和POST的区别。”我巴拉巴拉背了一堆&#xff1a;GET参数在URL里&#xff0c;POST在body里&#xff1b;GET有长度限制&#xff0c;POST没有&#xff1b;GET比POST快……后来面试官追问了一句&#xff1a;“…

作者头像 李华
网站建设 2026/9/30 11:22:40

AutoDL+Qwen2.5-7B部署实战:Xshell+Xftp远程推理全流程

1. 项目概述&#xff1a;为什么是AutoDL Qwen2.5-7B这套组合 做大模型部署有些年头了&#xff0c;我最常被朋友问的一句话是&#xff1a;"手里想跑个7B模型&#xff0c;得买多少钱的显卡&#xff1f;"说实话&#xff0c;如果你只是想验证效果、跑微调实验、临时搭个…

作者头像 李华
网站建设 2026/9/30 11:20:38

跑爬虫总遇403、封IP?详解网站最常用的5种反爬手段及破解思路

做工业数据采集这几年,见过太多新手刚入门的时候,写个for循环就对着目标站点猛发请求,跑不了十几页要么返回403,要么IP直接被临时封禁,换个浏览器打开页面又一切正常,死活找不到问题出在哪。 其实绝大多数网站的反爬体系都是分层部署的,基础防护手段覆盖了80%以上的场景…

作者头像 李华
网站建设 2026/9/30 11:18:53

机加工自动上下料机器人物联网解决方案

一、方案背景某汽车中型零部件加工企业主要生产变速箱壳体、底盘支架等工件&#xff0c;依赖多名操作工进行上下料搬运工作&#xff0c;存在工作强度高、安全隐患大、生产节奏混乱等弊端&#xff0c;导致设备综合利用率OEE较低&#xff0c;换产停工等待时间长等诸多问题。现企业…

作者头像 李华
网站建设 2026/9/30 11:17:53

漫话大模型:反 LLM 新物种:0.1 秒出结果、零幻觉的 Jev 是什么

2026 年 9 月&#xff0c;AI 圈被一个"不会说话"的模型刷屏了。 它叫 Jev&#xff0c;由 TypeSafe AI 发布——创始人是前 OpenAI 研究员 Diogo Almeida&#xff0c;参与过 ChatGPT 的指令跟随方法。这个模型有个奇怪的特点&#xff1a;它一个 token 的文本都不会生…

作者头像 李华