嵌入式基础第三课——标准外设库 / HAL 库控制 GPIO 流水灯
本课是上一课《寄存器方式控制 GPIO 流水灯》之后的两个进阶实验。上一课已经用直接操作寄存器的方式点亮了三盏 LED、搞清了 GPIO 的底层原理;本课在此基础上,分别改用 ST 官方的**标准外设库(StdPeriph)**和HAL 库重新实现同一个流水灯,并加入两个新内容:软件延时标定(Keil 逻辑分析仪)与按键中断(暂停/恢复)。
三盏灯的接线、闪烁顺序(绿灯 PB0 → 蓝灯 PC13 → 红灯 PA0,同一时刻只有一盏灯亮)与上一课完全相同,这里不再赘述电路。
一、先搞清楚:三种写法是什么关系
写 STM32 程序一共有三层「由低到高」的写法,这三课正好各对应一层:
| 写法 | 怎么配 GPIO | 特点 |
|---|---|---|
| 寄存器方式(第二课) | 直接往RCC_APB2ENR、GPIOx_CRL/CRH这些地址写数 | 最短最快,但每个地址每个位都要查手册 |
| 标准外设库(本课实验二) | 调GPIO_Init()、RCC_APB2PeriphClockCmd()这类函数 | 封装了一层,可读性好,但要自己搭工程 |
| HAL 库(本课实验三) | CubeMX 图形界面点几下,代码自动生成 | 最省事,代码量最大、层次最多 |
一句话总结:越往上越方便,越往下越透明。本课做的就是从中间两层各实现一遍。
二、实验二:标准外设库方式(软件延时 + 逻辑分析仪)
1. 实验目的
- 掌握用 STM32F10x 标准外设库(StdPeriph)配置 GPIO 的方法,理解它和寄存器方式的异同;
- 掌握软件空循环延时的原理,学会估算它的周期;
- 学会用Keil 软件仿真的逻辑分析仪观察 GPIO 引脚波形,测量并分析实际时序。
2. 硬件连接
与上一课完全一样:
| LED | 引脚 | 接法 | 点亮方式 |
|---|---|---|---|
| 🟢 绿灯 | PB0 | PB0 → 330Ω → LED → GND | 高电平点亮 |
| 🔵 蓝灯 | PC13 | 3.3V → 1kΩ → LED → PC13(与板载蓝灯同亮) | 低电平点亮 |
| 🔴 红灯 | PA0 | PA0 → 330Ω → LED → GND | 高电平点亮 |
3. 标准外设库工程怎么建
标准外设库工程要手动搭,容易漏的几点:
新建工程选器件STM32F103C8,加启动文件
startup_stm32f10x_md.s(md= 中容量,C8T6 用这个)、system_stm32f10x.c,以及外设驱动stm32f10x_gpio.c、stm32f10x_rcc.c、misc.c;魔术棒 → C/C++ →Define填两个宏,漏了必报错:
STM32F10X_MD(告诉库是中容量芯片)USE_STDPERIPH_DRIVER(打开标准外设库头文件,不填的话stm32f10x_conf.h里那些#include全被注释掉,GPIO 函数全是「未定义」);
Include Paths把库的
inc、CMSIS、Device\Include目录加进去;先用Use Simulator做逻辑分析仪仿真,要烧录时再切成 ST-Link。
4. 标准外设库 C 代码
#include"stm32f10x.h"/* 标准外设库总头文件 *//* 软件延时粗调系数:72MHz、-O0 下空循环每轮约 4 个周期, 1ms = 72000 周期,所以 1ms ≈ 72000/4 = 18000 次(估算值,需实测微调) */#defineDELAY_LOOP_PER_MS18000U/* ===== 供 Keil 逻辑分析仪观察的"软信号" ===== * Keil 软件仿真器不模拟 STM32 的 GPIO 外设,PORTB.0 / GPIOB_ODR 这类 * 内存映射寄存器在仿真器里既不是变量、也不是内核虚拟寄存器,逻辑分析仪 * 认不出(报 Unknown Signal)。所以用三个普通全局变量代替引脚电平, * 逻辑分析仪里直接看这三个变量。统一按 1=亮、0=灭 记录。 */volatileunsignedintg_led_green=0;/* 绿灯 PB0 */volatileunsignedintg_led_blue=0;/* 蓝灯 PC13 */volatileunsignedintg_led_red=0;/* 红灯 PA0 *//* 软件空循环延时:不依赖 SysTick,周期不精确 */voidDelay_ms(uint32_tnms){uint32_ti,j;for(i=0;i<nms;i++)for(j=0;j<DELAY_LOOP_PER_MS;j++);}voidLED_GPIO_Config(void){GPIO_InitTypeDef GPIO_InitStructure;/* 使能 GPIOA/B/C 时钟(标准库函数,底层还是写 RCC_APB2ENR) */RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA|RCC_APB2Periph_GPIOB|RCC_APB2Periph_GPIOC,ENABLE);/* PB0(绿灯):50MHz 通用推挽输出 */GPIO_InitStructure.GPIO_Pin=GPIO_Pin_0;GPIO_InitStructure.GPIO_Mode=GPIO_Mode_Out_PP;GPIO_InitStructure.GPIO_Speed=GPIO_Speed_50MHz;GPIO_Init(GPIOB,&GPIO_InitStructure);/* PA0(红灯):参数相同,复用结构体 */GPIO_Init(GPIOA,&GPIO_InitStructure);/* PC13(蓝灯):备份域引脚,速度只能 2MHz */GPIO_InitStructure.GPIO_Pin=GPIO_Pin_13;GPIO_InitStructure.GPIO_Speed=GPIO_Speed_2MHz;GPIO_Init(GPIOC,&GPIO_InitStructure);/* 上电全灭:PA0/PB0 输出 0(灭),PC13 输出 1(低电平点亮,置 1=灭) */GPIO_ResetBits(GPIOB,GPIO_Pin_0);GPIO_ResetBits(GPIOA,GPIO_Pin_0);GPIO_SetBits(GPIOC,GPIO_Pin_13);}intmain(void){LED_GPIO_Config();while(1){/* 第1拍:绿灯亮 */GPIO_SetBits(GPIOB,GPIO_Pin_0);GPIO_ResetBits(GPIOA,GPIO_Pin_0);GPIO_SetBits(GPIOC,GPIO_Pin_13);g_led_green=1;g_led_blue=0;g_led_red=0;Delay_ms(1000);/* 第2拍:蓝灯亮 */GPIO_ResetBits(GPIOC,GPIO_Pin_13);GPIO_ResetBits(GPIOB,GPIO_Pin_0);GPIO_ResetBits(GPIOA,GPIO_Pin_0);g_led_green=0;g_led_blue=1;g_led_red=0;Delay_ms(1000);/* 第3拍:红灯亮 */GPIO_SetBits(GPIOA,GPIO_Pin_0);GPIO_ResetBits(GPIOB,GPIO_Pin_0);GPIO_SetBits(GPIOC,GPIO_Pin_13);g_led_green=0;g_led_blue=0;g_led_red=1;Delay_ms(1000);}}5.【重点】用 Keil 逻辑分析仪测时序
软件仿真不需要硬件,用 Keil 自带的逻辑分析仪就能看 GPIO 波形、测周期。
操作步骤:
- 编译通过后,魔术棒 → Debug,左上角选Use Simulator;
- 点Debug(放大镜 / Ctrl+F5)进入仿真;
- 菜单View → Analysis Windows → Logic Analyzer;
- 点窗口左上角Setup,添加三个信号:
g_led_green、g_led_blue、g_led_red,每个的Display Type 选 Bit; - 点Run(F5)跑几秒再Stop,用光标卡两个跳变沿读时间差。
⚠️ 这里有两个大坑,本课花了很久才解决:
坑 1:直接添加
PORTB.0/GPIOB_ODR会报 Unknown Signal。原因是 Keil 软件仿真器不模拟 STM32 的 GPIO 外设,这些引脚是内存映射寄存器,在仿真器里既不是变量、也不是它认识的内核虚拟寄存器。解决办法就是上面代码里定义的三个全局变量g_led_green / g_led_blue / g_led_red——它们是真实符号,逻辑分析仪 100% 能看。坑 2:一运行就报
access violation at 0x40021000(RCC 寄存器)。原因是仿真器默认没把外设地址段映射进内存。在Command 窗口输入MAP 0x40000000, 0x400FFFFF READ WRITE即可(推荐写成debug.ini,在魔术棒 Debug → Initialization File 指定,每次进仿真自动执行)。
6. 软件延时的实际周期是多少
软件空循环延时本质上不精确,取决于「空循环每轮执行几个时钟周期」,而这又由主频和编译器优化等级决定。粗略估算:
- 主频 72MHz,1ms = 72000 个时钟周期;
for(j=0;j<N;j++);空循环在 -O0 下每轮约 4 个周期;- 所以 1ms 需要 N ≈ 72000/4 =18000,
Delay_ms(1000)约 1 秒。
用逻辑分析仪实测,把结果填入下表(数值以实测为准):
| 测量项 | 理论值 | 实测值 | 结论 |
|---|---|---|---|
| 单拍(LED 点亮)时长 | 1000 ms | 约 1000 ms(略有偏差) | 接近 1s,但不完全相等 |
| 完整流水灯循环周期 | 3000 ms | 约 3 × 单拍 | 与三拍逻辑一致 |
| 时序是否错开 | 任何时刻只有一路亮 | 三路方波互不重叠 | 时序正确 |
结论:只要三路波形错开、任何时刻只有一路处于「点亮电平」,时序就正确;单拍实测值和 1000ms 的偏差就是软件延时的误差,偏大就调小DELAY_LOOP_PER_MS、偏小就调大。这正是软件延时的特点:只能靠实测标定,不像 SysTick 天生精确。
7. 实验现象
烧到真板后,绿灯 1 秒 → 蓝灯 1 秒 → 红灯 1 秒循环,任何时刻只有一盏亮。
三、实验三:HAL 库 + 按键中断(暂停/恢复)
1. 实验目的与思路
- 掌握用HAL 库 + STM32CubeMX快速配置工程的方法;
- 理解**外部中断(EXTI)**原理,掌握按键中断的配置;
- 实现用按键中断控制流水灯的暂停与恢复。
思路:
- 三盏灯与实验二相同,轮流闪 1 秒(HAL 里用
HAL_Delay(1000),内部是 SysTick,周期天生精确); - 加一个按键接PB1(另一端接 GND,按下为低电平),用芯片的外部中断EXTI1检测;
- 用一个全局标志
g_paused:初始 0(运行),每按一下按键在中断回调里把它取反——按一下暂停、再按一下恢复; - 主循环里用「可暂停延时」代替
HAL_Delay,每毫秒检查一次g_paused,按下就立刻冻住当前灯。
2. 硬件连接
| 器件 | 引脚 | 接法 |
|---|---|---|
| 🟢 绿灯 | PB0 | PB0 → 330Ω → LED → GND |
| 🔵 蓝灯 | PC13 | 3.3V → 1kΩ → LED → PC13(与板载蓝灯同亮) |
| 🔴 红灯 | PA0 | PA0 → 330Ω → LED → GND |
| 🔘 按键 | PB1 | PB1 → 按键 → GND,按下为低电平 |
3. STM32CubeMX 是什么、怎么生成工程
STM32CubeMX是 ST 官方的免费图形化配置工具:以前要手写的 GPIO/时钟/中断初始化代码,现在在界面上点一点,它自动生成完整的 HAL 工程。从官网(st.com)下载,需要注册 myST 账号,还要先装 Java。
在 CubeMX 里的操作:
- 新建工程,器件选STM32F103C8;
- 引脚图上把PB0、PA0、PC13设为GPIO_Output,把PB1设为GPIO_EXTI1;
- System Core → GPIO里把 PB1 的 Mode 设为External Interrupt Mode with Falling edge trigger detection(下降沿)、Pull 设为Pull-up;
- System Core → NVIC勾选EXTI line1 interrupt;
- System Core → SYS的 Debug 选Serial Wire(不设的话 SWD 下载口可能被关掉,之后烧不进去);
- 时钟:因为板子外部晶振起振不了(见实验二踩坑),不启用 HSE,直接用内部 HSI 时钟;
- Project Manager填工程名、路径(别带中文和空格),Toolchain 选MDK-ARM,点GENERATE CODE。
💡 CubeMX 生成完会弹窗问是否打开,点Open Project就用 Keil 打开了。工程里文件一大堆,我们只需要改
main.c(和中断相关的stm32f1xx_it.c是自动生成的,不用动)。
4. HAL 库代码
生成的main.c里,只需在USER CODE区域补几段(放在这些区域里,重新生成时不会被覆盖):
① 全局标志(USER CODE BEGIN PV):
volatileuint8_tg_paused=0;/* 0=运行 1=暂停 */volatileuint32_tg_last_press=0;/* 上次按键时间,用于消抖 */② 可暂停延时(USER CODE BEGIN 0):
/* 带暂停功能的延时:等待 nms 毫秒,期间若按下暂停则原地等待直到恢复 */staticvoidPauseable_Delay(uint32_tnms){uint32_tstart=HAL_GetTick();while(HAL_GetTick()-start<nms){while(g_paused)/* 暂停:冻结在当前灯,直到按键恢复 */{}}}③ PB0 初始化(MX_GPIO_Init里,注意必须是输出):
/*Configure GPIO pin : PB0 */GPIO_InitStruct.Pin=GPIO_PIN_0;GPIO_InitStruct.Mode=GPIO_MODE_OUTPUT_PP;/* 千万别是 GPIO_MODE_IT_RISING */GPIO_InitStruct.Pull=GPIO_NOPULL;GPIO_InitStruct.Speed=GPIO_SPEED_FREQ_LOW;HAL_GPIO_Init(GPIOB,&GPIO_InitStruct);④ 主循环(USER CODE BEGIN 3):
while(1){/* 第1拍:绿灯亮 */HAL_GPIO_WritePin(GPIOB,GPIO_PIN_0,GPIO_PIN_SET);HAL_GPIO_WritePin(GPIOA,GPIO_PIN_0,GPIO_PIN_RESET);HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_SET);Pauseable_Delay(1000);/* 第2拍:蓝灯亮 */HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_RESET);HAL_GPIO_WritePin(GPIOB,GPIO_PIN_0,GPIO_PIN_RESET);HAL_GPIO_WritePin(GPIOA,GPIO_PIN_0,GPIO_PIN_RESET);Pauseable_Delay(1000);/* 第3拍:红灯亮 */HAL_GPIO_WritePin(GPIOA,GPIO_PIN_0,GPIO_PIN_SET);HAL_GPIO_WritePin(GPIOB,GPIO_PIN_0,GPIO_PIN_RESET);HAL_GPIO_WritePin(GPIOC,GPIO_PIN_13,GPIO_PIN_SET);Pauseable_Delay(1000);}⑤ 按键中断回调(USER CODE BEGIN 4,带 50ms 消抖):
voidHAL_GPIO_EXTI_Callback(uint16_tGPIO_Pin){uint32_tnow;if(GPIO_Pin==GPIO_PIN_1)/* 按键 PB1 */{now=HAL_GetTick();if(now-g_last_press>50)/* 50ms 内只认一次,滤掉抖动 */{g_last_press=now;g_paused=!g_paused;/* 按一下:暂停/恢复切换 */}}}💡
EXTI1_IRQHandler是 CubeMX 自动生成在stm32f1xx_it.c里的,它会调用HAL_GPIO_EXTI_IRQHandler(),最终转到上面的回调函数,无需手写。
5. 实验现象
- 上电后流水灯正常跑:绿灯 → 蓝灯 → 红灯,每 1 秒切换;
- 按一下按键:流水灯立即停在当前那盏灯上(暂停);
- 再按一下:从停的位置继续往后闪(恢复)。
四、这两次实验踩过的坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 逻辑分析仪加信号报 Unknown Signal | Keil 仿真器不模拟 GPIO 外设,PORTB.0是内存映射寄存器 | 用普通全局变量当「软信号」观察 |
| 仿真报 access violation at 0x40021000 | 仿真器默认没映射外设地址段 | MAP 0x40000000, 0x400FFFFF READ WRITE |
| 烧录报 Connection refused due to device mismatch | 板子芯片是国产仿片 CS32F103C8T6(ID 0x2BA01477) | Debug→Settings→Pack 取消 Enable 校验 |
| 真板延时比 1 秒长 9 倍 | 外部 8MHz 晶振没起振,退回内部 8MHz HSI | DELAY_LOOP_PER_MS改小,或改用 HSI 时钟 |
| 下载后要按复位才运行 | 没勾 Reset and Run | Debug→Settings→Flash Download 勾选 Reset and Run |
| 编译报 Failed to check out a license(compiler5) | ARM Compiler 5 的 license 失效 | 换成 ARM Compiler 6(V6) |
| 烧录后只有蓝灯亮 | 流水灯代码没加进主循环,停在初始状态 | 把代码加进USER CODE 3 |
| PB0 不亮 | CubeMX 里 PB0 误配成中断输入 | 改成 GPIO_Output /GPIO_MODE_OUTPUT_PP |
| 按键不灵敏、只能停红灯 | ① 按键抖动;② 暂停判断只在整轮循环开头检查一次 | ① 50ms 软件消抖;② 用可暂停延时替代HAL_Delay |
五、实验心得
这两次实验把同一个流水灯用标准外设库和 HAL 库各实现了一遍,加上第二课的寄存器方式,三种写法都走了一遍,收获很大。
最大的体会还是**「越往上越方便,越往下越透明」**。寄存器方式每个地址每个位都要自己查手册,写起来最累,但也让我最清楚芯片到底在做什么;标准外设库把寄存器包成GPIO_Init这类函数,读起来舒服多了;到了 HAL 库,CubeMX 点几下就生成一整个工程,省事是省事,但出问题时那一层层函数点进去看,反而更晕。所以这门课安排三个实验从底层往上走,是有道理的。
第二个深刻的点是软件延时是真的不可靠。它的周期取决于主频和编译器,只能靠逻辑分析仪实测标定;而 HAL 的HAL_Delay基于 SysTick,天生精确。这一课我还亲身撞上了一个更隐蔽的坑——主频不对延时差 9 倍:板子外部晶振没起振,芯片就静默退回 8MHz 内部时钟,灯一下慢了 9 倍。这让我真正意识到硬件和软件是分不开的,不看板子实际状态、不测时钟,光改代码是没用的。
第三个点是中断和消抖。按键中断让程序不用一直轮询,响应快了很多,但机械按键会抖动,不消抖就「时灵时不灵」。而且我还发现暂停判断的位置很关键:放在整轮循环开头,按下后要等一轮走完才生效,结果「只能停在红灯」;改成在延时里每毫秒检查一次,才真正做到「停在当前灯」。这个 bug 是我自己观察现象一步步定位出来的,印象特别深。
最后还有一个「意外收获」:这一路踩的坑里,有好几个其实不是代码的错——仿片报 device mismatch、ARM Compiler 5 的 license 失效,都会挡住编译下载。这让我学会了一个排查习惯:出问题时先分清是代码错、还是工具/硬件错,能少走很多弯路。