☰
STM32 HAL库开发实战:从F103点灯到时钟树与调试避坑
2026/10/29 16:32:24 网站建设 项目流程

简介:《STM32 HAL库开发实战指南—基于F103》面向零基础及初级嵌入式开发者,围绕STM32F103与HAL库展开,帮助读者从开发环境搭建逐步过渡到外设配置与项目实战。内容覆盖KEIL5安装与芯片包配置、DAP仿真器硬件连接及下载调试参数设置,并系统讲解GPIO、中断、定时器、串口通信、ADC等常用外设的HAL库用法,配合LED闪烁、串口协议实现和简单控制系统等案例,强调可迁移的标准化API编程思路。资源为1个PDF文件,约43.41MB,章节结构完整,便于按学习顺序查阅,也适合作为开发过程中的速查手册。目前已有1881人学习,适合希望系统掌握STM32 HAL库开发、补齐工具链与调试环节的读者使用。

1. 一块板子上电只亮电源灯,把我卡了两天

一块 F103 最小系统板上电之后只有电源灯亮着,Keil 里编译零错误零警告,点下载也弹出了成功提示,可板子就是一动不动——我第一次碰 STM32 时在这个状态里耗了整整两天,最后发现是仿真器的 Reset and Run 没勾、程序压根没自动跑起来。这份《STM32 HAL 库开发实战指南(基于 F103-MINI)》要解决的正是这类问题:它不急着教你写业务代码,而是把「程序怎么从电脑进到芯片里」「寄存器怎么被一层层包成 HAL 库函数」「时钟树怎么配才对」这几条链路完整摊开。全书从 Keil5 安装、DAP 仿真器配置一路讲到 GPIO、RCC、EXTI、SysTick,中间还专门插了一段手写库函数的过渡章节,把 stm32 arm hal库 那层窗户纸捅破。适合刚接触 ARM Cortex-M 内核、想把 HAL 库真正用明白的在校学生和转岗工程师,也适合已经会点灯但说不清 HAL_GPIO_Init 究竟写了哪些寄存器的人。

2. 技术底座拆解:CMSIS、HAL 驱动与启动文件的三层结构

很多人拿到这份 PDF 直接从第 12 章「GPIO 输出—使用固件库点亮 LED」翻起,结果代码抄下来了,工程却编不过——因为第 9 章讲 CMSIS 标准与库层次关系那几页被跳过了。STM32 的软件栈不是一坨代码,它是有明确边界的四层,搞清楚谁调用谁,后面所有外设的实验都是同一套模板。

2.1 一次 HAL_GPIO_WritePin 到底跨了几层

从你敲下HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET)到 PA5 真正输出高电平,中间要穿过四层。最上面是你的应用代码,往下是 HAL 驱动层(stm32f1xx_hal_gpio.c),再往下是 CMSIS Device 层(stm32f1xx.h 里定义的外设结构体和基地址),最底下是 CMSIS Core 层(core_cm3.h,负责 NVIC、SysTick 这些内核外设的寄存器访问)。

层次典型文件职责谁在调用它
应用层main.c、bsp_xxx.c业务逻辑、状态机无(最上层)
HAL 驱动层stm32f1xx_hal_gpio.c、stm32f1xx_hal_rcc.c外设初始化、读写、状态机应用层
CMSIS Devicestm32f103xb.h、system_stm32f1xx.c外设寄存器结构体、基地址、SystemInitHAL 层
CMSIS Corecore_cm3.hNVIC、SysTick、__WFI 等内核操作HAL 层、应用层

这张表最有用的一点是:HAL 并没有绕开寄存器,它只是把寄存器操作收拢到函数里。HAL_GPIO_WritePin内部最终写的是GPIOx->BSRR,你翻到 stm32f1xx_hal_gpio.c 里去看,实现就是几行位运算。理解这一层,调试的时候就不会把 HAL 当成黑匣子——出问题可以直接在函数里下断点,看它到底往哪个地址写了什么值。

2.2 工程目录:CubeMX 生成的骨架该怎么读

第 10 章用 STM32CubeMX 新建工程,第 11 章又手把手教了一遍「新建工程—库函数版」,两条路径生成的目录结构基本一致。第一次看这个树容易懵,其实只需要盯四个位置:

MyProject/ ├── Core/ │ ├── Inc/ # 用户头文件:main.h、gpio.h、stm32f1xx_hal_conf.h │ └── Src/ # 用户源文件:main.c、gpio.c、stm32f1xx_it.c ├── Drivers/ │ ├── CMSIS/ # 内核与器件支持:core_cm3.h、system_stm32f1xx.c、startup_stm32f103xb.s │ └── STM32F1xx_HAL_Driver/ │ ├── Inc/ # HAL 头文件 │ └── Src/ # HAL 源文件,只把用到的 .c 加进工程 ├── MDK-ARM/ # Keil 工程文件、.uvprojx、输出目录 └── MyProject.ioc # CubeMX 工程配置,所有引脚和时钟都在这里

Core/Src/main.c里有一段约定俗成的结构:USER CODE BEGIN和USER CODE END之间的内容是你可以放心写的,每次用 CubeMX 重新生成代码,其他部分会被覆盖,只有这段受保护。这个约定很多新手不知道,写了半天业务逻辑,改个引脚重新生成一次,代码全没了。

Drivers/STM32F1xx_HAL_Driver/Src下面有几十个 .c 文件,但不需要全加进工程。Keil 里加多了会拖慢编译,加少了会报 undefined symbol。经验做法是:用到哪个外设就加哪个,比如只用 GPIO 和 RCC,加stm32f1xx_hal.c、stm32f1xx_hal_cortex.c、stm32f1xx_hal_gpio.c、stm32f1xx_hal_rcc.c、stm32f1xx_hal_rcc_ex.c这五个就够跑起来。

2.3 启动文件与向量表:程序入口不在 main

第 14 章花了一整章讲启动文件,很多人觉得这是「汇编,看不懂,跳过」,结果后面调试时遇到 HardFault 完全无从下手。启动文件 startup_stm32f103xb.s 做三件事:定义栈和堆、建立中断向量表、把控制权交给 SystemInit 和 main。

; 栈和堆的大小,单位字节,0x400 = 1024 字节 Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x00000200 AREA HEAP, NOINIT, READWRITE, ALIGN=3 __heap_base Heap_Mem SPACE Heap_Size __heap_limit ; 复位入口:先跑 SystemInit,再进 __main Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT __main IMPORT SystemInit LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0 ENDP

这里有两个参数值得记住。Stack_Size是栈大小,默认 1KB,函数调用层次深、局部数组大时容易栈溢出,表现为程序莫名跑飞或者进 HardFault,我一般按项目复杂度调到 0x800。Heap_Size是堆,只有用 malloc 才需要,裸机项目里很多人直接改成 0,能省一点 RAM,但 HAL 里如果用了 malloc 就会挂。

Reset_Handler的顺序是固定的:先SystemInit(配置时钟到默认状态、设置向量表偏移),再__main(C 库初始化,把 .data 段从 Flash 拷到 RAM,把 .bss 段清零),最后才进main。所以你写在全局变量上的初值能被正确保留,靠的就是__main这一步。

2.4 配置文件 stm32f1xx_hal_conf.h:HAL 的裁剪开关

这个头文件是 HAL 库的门面,每个外设模块都有对应的使能宏,注释掉对应行,那个模块的 .c 就不用编译进工程,能省下不少 Flash。

/* 外部晶振频率,必须和板子上实际焊的晶振一致,否则串口波特率全错 */ #if !defined (HSE_VALUE) #define HSE_VALUE ((uint32_t)8000000U) /* 8MHz,常见值 */ #endif /* 模块裁剪开关:用到哪个开哪个 */ #define HAL_GPIO_MODULE_ENABLED #define HAL_RCC_MODULE_ENABLED #define HAL_CORTEX_MODULE_ENABLED #define HAL_UART_MODULE_ENABLED /* 断言开关:调试阶段打开,发布时关掉 */ /* #define USE_FULL_ASSERT 1U */ #ifdef USE_FULL_ASSERT #define assert_param(expr) ((expr) ? (void)0U : assert_failed((uint8_t *)__FILE__, __LINE__)) #else #define assert_param(expr) ((void)0U) #endif

HSE_VALUE这个参数是血泪经验级的存在。板子上的晶振是 8MHz,但配置文件里写成 12MHz,编译下载都没事,程序也能跑,就是 UART 输出的全是乱码、定时器周期差 1.5 倍。因为 HAL 用它来算 PLL 倍频和波特率分频,源头的数错了,后面全错。

USE_FULL_ASSERT打开后,HAL 函数入口处会检查参数合法性,传了非法引脚或者未初始化的句柄会直接跳进assert_failed,你能立刻知道是哪一行出错,而不是等程序跑飞。我一般只在调试阶段打开,发布时注释掉,避免多出来的代码影响时序。

3. 环境跑通:Keil5、芯片包、DAP 仿真器与 CubeMX

这份教程的工程能不能跑起来,一半取决于代码,另一半取决于工具链配置。第 2 章讲 Keil5 安装、第 3 章讲 DAP 仿真器配置,这两章的步骤如果你漏了任何一步,后面所有实验都会卡在「编译成功但板子没反应」上。

3.1 Keil5 与 DFP 芯片包:先保证能编译

Keil5(MDK-ARM)安装本身没什么坑,一路下一步即可,真正容易漏的是芯片支持包 Device Family Pack。没有它,新建工程时器件列表里根本找不到 STM32F103,或者找到了但编译时报找不到stm32f103xb.h。

两种装法:一是打开 Keil 的 Pack Installer,在 STM32F1 系列里双击安装;二是离线下载 .pack 文件后双击,它会自动关联到已安装的 Keil。装完之后在 Options for Target 的 Device 页能看到 STM32F103RC 之类的型号,说明装对了。

接下来是三个必须检查的编译选项,位置在 Options for Target:

Target 页 -> 勾选 Use MicroLIB # 精简 C 库,减小体积,printf 重定向需要它 C/C++ 页 -> Define: USE_HAL_DRIVER,STM32F103xB # 声明芯片型号和 HAL 驱动 C/C++ 页 -> Include Paths 加入: ../Core/Inc ../Drivers/STM32F1xx_HAL_Driver/Inc ../Drivers/CMSIS/Device/ST/STM32F1xx/Include ../Drivers/CMSIS/Include

STM32F103xB这个宏不是随便写的,它决定 CMSIS 头文件里去包含哪个 memory map 文件、中断向量表有多少个入口。写错了会报大量「未定义标识符」错误,比如GPIO_TypeDef找不到、USART1_IRQn找不到。

Include Paths 少加一条,最常见的报错是cannot open source input file "core_cm3.h"。这四个路径对应 CMSIS Core、CMSIS Device、HAL 驱动和用户代码,一个都不能少。

3.2 DAP 仿真器的三个选项卡:Debug、Utilities、Settings

DAP 仿真器是 CMSIS-DAP 协议,免驱,插上就能被 Keil 识别。但识别到不等于能下载,配置要分三处:

第一处是 Debug 选项卡,Use 下拉框选 CMSIS-DAP Debugger,右边的 Settings 里 Port 选 SW(不是 JTAG),Max Clock 先设成 1MHz 或 2MHz,等稳定了再往上调。SWDIO 接 PA13、SWCLK 接 PA14,接反了会提示 No Cortex-M Device found。

第二处是 Utilities 选项卡,勾选 Use Debug Driver,然后点 Settings 进 Flash Download 页,确认 Programming Algorithm 里已经列出了 STM32F10x High-density Flash 之类的算法。如果这里是空的,下载一定失败,点 Add 手动加进去。

第三处还是 Debug 的 Settings 对话框,这次看 Flash Download 页的两个勾:

  • Reset and Run:下载完自动复位运行。不勾的话,下载成功后程序停在复位向量处不动,必须手动按一下板子上的复位键,或者重新上电。这就是我开头卡两天的那件事。
  • Verify Code Download:下载后校验,建议勾上,能提前发现 Flash 写入异常。

3.3 CubeMX 建工程:选型、时钟源、引脚与工程属性

第 10 章用 STM32CubeMX 从零建了一个工程,流程是固定的:选芯片型号、配时钟源、配 IO 口、配系统时钟、配工程属性、生成代码。

选型这一步要精确,不是选「STM32F1 系列」,而是选到具体型号,比如 STM32F103RCT6。因为不同型号的 Flash 大小、引脚数、外设数量不同,选错了生成的启动文件和链接脚本都不对。

时钟源这一步,在 Pinout 视图的 System Core → RCC 里,把 High Speed Clock (HSE) 设成 Crystal/Ceramic Resonator。这一步不做,后面时钟树里 PLL 的输入源就没有 8MHz,只能跑内部 HSI 的 8MHz,主频上不到 72MHz。

引脚配置这一步,点一下 PA5 选 GPIO_Output,然后在 GPIO 面板里设置:

参数取值说明
GPIO output levelLow初始电平,配合 LED 低电平点亮
GPIO modeOutput Push Pull推挽输出,能拉能灌
GPIO Pull-up/Pull-downNo pull-up and no pull-down输出模式不用上下拉
Maximum output speedLow点灯用低速就够,减少 EMI
User LabelLED1生成宏 LED1_Pin / LED1_GPIO_Port

User Label 这一项很多人忽略,填了之后 CubeMX 会自动生成#define LED1_Pin GPIO_PIN_5和#define LED1_GPIO_Port GPIOA,代码里写HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_RESET),比硬编码 GPIOA、GPIO_PIN_5 可读性好太多。改板子换引脚的时候,只要在 .ioc 里改一下 Label 重新生成,业务代码一行都不用动。

时钟树那一页(Clock Configuration)填三个值:HSE 输入 8、PLL Source 选 HSE、PLLMul 选 x9,然后 System Clock Mux 选 PLLCLK,AHB 分频 /1、APB1 分频 /2、APB2 分频 /1。右侧会显示 SYSCLK 72MHz、HCLK 72MHz、PCLK1 36MHz、PCLK2 72MHz,对得上就说明配对了。

工程属性页(Project Manager)有一个选项值得单独说:Toolchain/IDE 选 MDK-ARM V5,然后 Code Generator 页勾上「Generate peripheral initialization as a pair of .c/.h files per peripheral」。勾上之后,每个外设的初始化代码会单独生成 gpio.c、usart.c,而不是全塞进 main.c,工程规模一大,这个选项能救命。

3.4 编译下载:第一个能看得见的现象

生成代码之后,Keil 里 F7 编译,应该零错误。如果有警告说last line of file ends without a newline,那只是格式问题,不影响运行。

下载之前先确认板子上电、DAP 的 3.3V 和 GND 已经和板子共地。点 Download(F8),Output 窗口出现Application running ...或Programming Done. Verify OK.就说明成功。

如果勾了 Reset and Run,下载完 LED 应该立刻亮起来。如果不亮,先按一下板子上的复位键,如果按了才亮,那就是 Reset and Run 没勾;如果按了也不亮,别急着怀疑代码,往下看第 5 章的排查清单。

4. 核心代码拆解:从寄存器点灯到 HAL_GPIO_WritePin

第 7 章用寄存器点灯、第 8 章手写库函数雏形、第 12 章用 HAL 库点灯,这三章的编排顺序是有讲究的:先让你看见寄存器,再让你自己包一层,最后告诉你 HAL 就是这么包出来的。跳过前面直接抄第 12 章能跑通,但遇到问题就无从下手。

4.1 寄存器版点灯:三个动作,六个寄存器

点亮一个 LED,本质只有三个动作:给 GPIO 端口开时钟、把引脚配成输出模式、把引脚电平拉高或拉低。用寄存器做,代码是这样的:

#include <stdint.h> /* 用绝对地址定义,避免引头文件,方便对照参考手册 */ #define RCC_BASE 0x40021000UL #define GPIOA_BASE 0x40010800UL typedef struct { volatile uint32_t CR; /* 0x00 时钟控制 */ volatile uint32_t CFGR; /* 0x04 时钟配置 */ volatile uint32_t CIR; /* 0x08 中断寄存器 */ volatile uint32_t APB2RSTR; /* 0x0C */ volatile uint32_t APB1RSTR; /* 0x10 */ volatile uint32_t AHBENR; /* 0x14 */ volatile uint32_t APB2ENR; /* 0x18 APB2 外设时钟使能 */ volatile uint32_t APB1ENR; /* 0x1C */ volatile uint32_t BDCR; /* 0x20 */ volatile uint32_t CSR; /* 0x24 */ } RCC_TypeDef; typedef struct { volatile uint32_t CRL; /* 0x00 引脚 0-7 配置 */ volatile uint32_t CRH; /* 0x04 引脚 8-15 配置 */ volatile uint32_t IDR; /* 0x08 输入数据 */ volatile uint32_t ODR; /* 0x0C 输出数据 */ volatile uint32_t BSRR; /* 0x10 位设置/清除 */ volatile uint32_t BRR; /* 0x14 位清除 */ volatile uint32_t LCKR; /* 0x18 配置锁定 */ } GPIO_TypeDef; #define RCC ((RCC_TypeDef *) RCC_BASE) #define GPIOA ((GPIO_TypeDef *) GPIOA_BASE) int main(void) { /* 动作 1:使能 GPIOA 时钟,APB2ENR 的 bit2 是 IOPAEN */ RCC->APB2ENR |= (1U << 2); /* 动作 2:PA5 配成推挽输出、2MHz,占据 CRL 的第 20-23 位 */ GPIOA->CRL &= ~(0xFU << 20); /* 先清零 */ GPIOA->CRL |= (0x2U << 20); /* 0b0010: MODE=10(2MHz), CNF=00(推挽) */ /* 动作 3:BSRR 低 16 位写 1 置高,高 16 位写 1 置低 */ GPIOA->BSRR = (1U << 5); /* PA5 输出高电平 */ while (1) { } }

第一个参数RCC->APB2ENR |= (1U << 2),为什么是 bit2?因为参考手册的 APB2 外设时钟使能寄存器里,bit0 是 AFIOEN、bit1 是 保留、bit2 才是 IOPAEN。GPIOB 是 bit3、GPIOC 是 bit4,以此类推。这一步漏了,后面所有配置都写进空气——时钟没开,外设寄存器写不进去,读出来永远是 0。

第二个参数CRL的第 20-23 位对应 PA5,因为每个引脚占 4 位,5 乘以 4 等于 20。这 4 位里高 2 位是 CNF(配置),低 2 位是 MODE(模式)。推挽输出 2MHz 的组合是 CNF=00、MODE=10,拼起来就是 0b0010,也就是 0x2。如果要 50MHz,MODE 是 11,值变成 0x3。

第三个参数BSRR是 F1 系列最好用的寄存器。它叫「位设置/清除寄存器」,低 16 位写 1 对应引脚置高,高 16 位写 1 对应引脚置低,写 0 无影响。好处是单条指令完成,原子操作,不会像ODR |= (1<<5)那样先读再写,中间被中断打断就可能出错。BRR 寄存器只干清除这一件事,功能上等价于 BSRR 的高 16 位。

4.2 手写库雏形:结构体映射与初始化结构体

第 8 章做的事,就是把你刚才手写的那三行,包成函数。先是外设寄存器结构体定义(上面已经写了),然后是外设基址映射成指针,接着定义一个初始化结构体:

typedef enum { GPIO_MODE_OUT_PP_LOW = 0x02, /* 推挽输出 2MHz */ GPIO_MODE_OUT_PP_MID = 0x01, /* 推挽输出 10MHz */ GPIO_MODE_OUT_PP_HIGH = 0x03, /* 推挽输出 50MHz */ GPIO_MODE_IN_FLOATING = 0x04, /* 浮空输入 */ GPIO_MODE_IPU = 0x08, /* 上拉输入 */ } GPIOMode_TypeDef; typedef struct { uint16_t GPIO_Pin; /* 要配置的引脚,可以用 | 组合 */ GPIOMode_TypeDef GPIO_Mode; /* 模式 */ } GPIO_InitTypeDef; void GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *init) { uint32_t pinpos, currentmode, pos; for (pinpos = 0; pinpos < 16; pinpos++) { pos = 1U << pinpos; if ((init->GPIO_Pin & pos) == 0) { continue; /* 该引脚不在配置列表里,跳过 */ } currentmode = GPIOx->CRL; currentmode &= ~(0xFU << (pinpos * 4)); /* 清 4 位 */ currentmode |= ((uint32_t)init->GPIO_Mode << (pinpos * 4)); GPIOx->CRL = currentmode; } } #define LED_PIN (1U << 5) int main(void) { GPIO_InitTypeDef gpio; RCC->APB2ENR |= (1U << 2); gpio.GPIO_Pin = LED_PIN; gpio.GPIO_Mode = GPIO_MODE_OUT_PP_LOW; GPIO_Init(GPIOA, &gpio); GPIOA->BSRR = LED_PIN; while (1) { } }

GPIO_Init里那个 for 循环遍历 16 个引脚,是因为GPIO_Pin允许用按位或组合,比如GPIO_Pin_5 | GPIO_Pin_6。每次循环算一个位置的掩码pos,和GPIO_Pin做与运算,命中才配置。这个写法不高效但极通用,HAL 库里的HAL_GPIO_Init用的也是同一个套路,只是把 CRL 和 CRH 拆开判断了:pinpos 小于 8 走 CRL,大于等于 8 走 CRH,位移量分别算。

手写这一遍的价值在于,你以后再看到GPIO_MODE_OUTPUT_PP、GPIO_SPEED_FREQ_LOW这些枚举名,脑子里能立刻翻译成0x01、0x02,而不是死记硬背。调试时如果发现引脚模式不对,也知道该去 CRL/CRH 的哪个位段找问题。

4.3 HAL 版点灯:HAL_GPIO_Init 替你写了什么

到第 12 章,代码变成这样:

static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; /* 1. 开时钟:__HAL_RCC_GPIOA_CLK_ENABLE 实际就是 RCC->APB2ENR |= (1<<2) */ __HAL_RCC_GPIOA_CLK_ENABLE(); /* 2. 先把输出电平定下来,避免初始化瞬间出现抖动 */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); /* 3. 填参数结构体 */ GPIO_InitStruct.Pin = GPIO_PIN_5; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } int main(void) { HAL_Init(); /* 含 SysTick 初始化、Flash 预取配置 */ SystemClock_Config(); /* 配到 72MHz */ MX_GPIO_Init(); while (1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); HAL_Delay(500); /* 依赖 SysTick 的 1ms 累加 */ } }

四个参数的语义要和寄存器对上:Pin是位掩码不是编号,GPIO_PIN_5展开就是0x0020;Mode决定 CNF 和 MODE 的组合;Pull只有在输入模式下才有效,输出模式下写什么都无所谓,但建议老实写GPIO_NOPULL别留悬念;Speed影响输出驱动能力和 EMI,点灯场景用 LOW 就行,驱动 SPI、SDIO 这种高速信号才需要 HIGH。

HAL_GPIO_TogglePin的实现值得看一眼:它是先读ODR寄存器的对应位,然后写BSRR的相反位。读 ODR 这一步不需要额外操作,因为 ODR 是「输出数据寄存器」,无论引脚配成输入还是输出都能读。

HAL_Delay(500)的单位是毫秒,但它不是死循环延时,而是靠 SysTick 每 1ms 中断里给全局变量uwTick加 1,然后HAL_Delay里循环比较。这意味着两件事:SysTick 中断必须正常工作,并且优先级不能太低,否则被其他中断长期挡住会延时不准。

4.4 按键输入与 EXTI:从轮询到中断

第 13 章讲按键检测,分两个层次。第一个层次是轮询:

/* 按键接 PA0,按下为低电平,配置成上拉输入 */ GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); while (1) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { HAL_Delay(20); /* 软件消抖,等抖动过去 */ if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { } } } }

轮询的问题很明显:HAL_Delay(20)和最后那个while都在原地死等,CPU 干不了别的事。按键响应也不及时,主循环里有别的耗时任务就漏键。

第二个层次是第 17 章的 EXTI 外部中断。配置分四步:GPIO 配成输入模式、EXTI 线映射到该引脚、NVIC 使能对应中断通道、写中断回调函数。

/* 步骤 1、2:GPIO 配好,EXTI 通过 AFIO 映射到 PA0 */ GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; /* 下降沿触发 */ GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 步骤 3:设置优先级并使能中断 */ HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn); /* 步骤 4:用户回调,HAL 已经在 EXTI0_IRQHandler 里调用了它 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); } }

GPIO_MODE_IT_FALLING这个模式名里的 IT 是 Interrupt 的缩写,HAL 会在HAL_GPIO_Init里同时配置 GPIO 模式和 EXTI 触发边沿,不需要你手动写 AFIO->EXTICR。这是 HAL 相对标准库的一个明显改进,标准库时代要单独调GPIO_EXTILineConfig。

回调函数里加if (GPIO_Pin == GPIO_PIN_0)这个判断不能省。所有 EXTI 线共用同一个HAL_GPIO_EXTI_Callback,你在里面用了几条线,就要有几个分支,否则按键和传感器中断会互相串。

另外,F1 系列的 EXTI 线是复用的:EXTI0 只能接 PA0/PB0/PC0 中的某一个,不能两个同时用。如果工程里 PA0 和 PB0 都要中端,只能把其中一个改成轮询。这个限制在选型阶段就要考虑进去,板子画完了才发现就来不及了。

4.5 RCC 与 SysTick:时钟树和延时是两条命脉

第 15 章的 RCC 配置,是整本书里最容易被低估的一章。所有外设的时序都挂在时钟树上,时钟错了,UART 波特率、定时器周期、SPI 速率全错,但代码能编译能下载,现象是「能跑但不对」,最难查。

void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; /* 第一步:开 HSE,配 PLL,8MHz x 9 = 72MHz */ RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); /* HSE 起振失败会走到这里 */ } /* 第二步:切换系统时钟源到 PLL,并设置各总线分频 */ RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; /* HCLK = 72MHz */ RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; /* PCLK1 = 36MHz */ RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; /* PCLK2 = 72MHz */ /* FLASH_LATENCY_2:72MHz 下必须 2 个等待周期,少了会跑飞 */ if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); } }

PLLMUL = RCC_PLL_MUL9和HSE_VALUE = 8000000是绑定的。如果板子焊的是 12MHz 晶振,要么改HSE_VALUE为 12000000,要么改 PLLMUL 为 6,两者取其一,不能只改一处。

FLASH_LATENCY_2这个参数新手最容易漏。Flash 的读取速度跟不上 72MHz 的 CPU,必须插入等待周期,少插或者多插都会出问题:少了会读到错误指令,表现为随机跑飞;多了只是慢一点,不影响功能。F103 在 48MHz 以下用 LATENCY_1,48 到 72MHz 用 LATENCY_2。

APB1CLKDivider = RCC_HCLK_DIV2是因为 F103 的 APB1 总线最高只能到 36MHz。这条总线上挂着 USART2/3、I2C、SPI2、TIM2-7 等外设。APB2 能到 72MHz,挂 USART1、SPI1、GPIO、ADC。用串口时算波特率,如果挂在 USART1 上就按 72MHz 算,挂在 USART2 上按 36MHz 算,算错了就是乱码。

SysTick 是内核里的一个 24 位递减计数器,HAL 在HAL_Init()里调HAL_InitTick(TICK_INT_PRIORITY)把它配成 1ms 中断。中断服务函数在 stm32f1xx_it.c 里:

void SysTick_Handler(void) { HAL_IncTick(); /* 这一行不能删,删了 HAL_Delay 就永远等下去 */ HAL_SYSTICK_IRQHandler(); }

很多人在做低功耗或者自己写调度器时会把SysTick_Handler整个重写,忘了调HAL_IncTick(),结果HAL_Delay变成死循环,程序卡在那里。这是 HAL 库最典型的「静默失败」之一,没有报错,就是不动。

5. 避坑排查:连不上、不跑、不亮、不进中断、串口乱码

前面几章讲的都是顺利路径,真到板子上的时候,十有八九会撞上下面这几件事。每一条我都按「现象 → 原因 → 解决」记,照着对一遍,大部分问题十分钟内能定位。

5.1 仿真器连不上,提示 No Cortex-M Device found

现象:Keil 点下载,Output 窗口报No Cortex-M Device found in JTAG chain或RDDI-DAP Error,压根到不了编译下载那一步。

原因:按概率排序,一是 SWDIO/SWCLK 两根线接反或者接触不良;二是目标板没供电,仿真器的 3.3V 只做参考不给板子供电;三是 Max Clock 设太高,杜邦线长一点就通信失败;四是芯片被读保护或者之前下载的程序把 SWD 引脚复用掉了。

解决:先用万用表确认板子 3.3V 正常、仿真器 GND 和板子 GND 导通。然后 Keil 的 Debug Settings 里把 Port 改成 SW、Max Clock 降到 1MHz 再试。如果还是不行,把 BOOT0 拉高、BOOT1 拉低,复位后芯片从系统存储器启动,这时候 SWD 引脚一定是可用的,用 Keil 的 Flash → Erase 全片擦除一次,再把 BOOT0 拉回低电平重新上电。

还有一种隐蔽情况:程序里把 PA13、PA14 配成了普通 GPIO 输出。这两个脚就是 SWDIO 和 SWCLK,一旦被复用,仿真器立刻失联,而且下次上电还是失联。所以任何时候都不要碰这两个脚的 GPIO 配置,除非你确定自己会用软件重新映射回来。

5.2 下载提示成功,板子毫无反应

现象:Output 窗口明明显示Programming Done. Verify OK.,但 LED 不亮、串口没输出,按复位键才有反应。

原因:Reset and Run没勾,程序烧进去了但停在复位向量没执行;或者工程里的启动文件、链接地址配置错了,程序被烧到了错误的 Flash 区域;再或者时钟配置里 HSE 起振失败,卡在Error_Handler()的死循环里。

解决:先勾上 Reset and Run,重新下载一次。如果按复位键能跑,那就是这个原因。如果按了复位也不跑,在main函数第一行打个断点,单步走一遍,看是卡在HAL_Init、SystemClock_Config还是MX_GPIO_Init。卡在SystemClock_Config里,八成是HSE_VALUE和实际晶振不匹配,或者晶振虚焊,把HSEState改成RCC_HSE_OFF用内部 HSI 跑一下,能跑就说明是外部晶振的问题。

5.3 LED 配了引脚,就是不亮

现象:代码里HAL_GPIO_WritePin也调了,GPIO_MODE_OUTPUT_PP也配了,用万用表量引脚电压就是不动。

原因:一是 GPIO 端口时钟没使能,__HAL_RCC_GPIOx_CLK_ENABLE()漏了,寄存器写不进去;二是电平极性搞反了,板子上的 LED 是阳极接 3.3V、阴极接引脚,写高电平反而是灭;三是限流电阻太大或者 LED 焊反;四是引脚被 CubeMX 的别的功能占用了,比如配成了 ADC 输入。

解决:先确认时钟使能那一行在HAL_GPIO_Init之前。然后用HAL_GPIO_TogglePin反复翻转,拿万用表量引脚,如果能测到电平跳变说明 GPIO 没问题,问题在 LED 那一侧;如果测不到跳变,回头查 CRL/CRH 里那 4 位是不是被别的代码覆盖了。极性问题上,最省事的办法是先用HAL_GPIO_WritePin(port, pin, GPIO_PIN_SET)和RESET各试一次,哪个亮算哪个。

5.4 中断进不去,或者一进就死

现象:按键按下去没反应,或者在HAL_GPIO_EXTI_Callback里打了断点从来没命中,或者命中了但程序卡死、反复重启。

原因:NVIC 没使能对应通道;优先级分组没设置,HAL_NVIC_SetPriority的参数含义和你以为的不一样;EXTI 线被同编号的其他端口占用;回调函数名写错,写成了EXTI0_IRQHandler或者拼错了大小写;中断里调用了HAL_Delay,而 SysTick 优先级低于当前中断,直接死锁。

解决:先确认HAL_NVIC_EnableIRQ(EXTI0_IRQn)这行在。然后在 stm32f1xx_it.c 里确认EXTI0_IRQHandler存在并且调用了HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0)。回调函数必须严格叫HAL_GPIO_EXTI_Callback,参数类型uint16_t,这个名字在 HAL 里是__weak定义的,拼错一个字母就变成了你自己的新函数,永远不会被调用。

中断里绝对不要调HAL_Delay。要延时就用一个计数器变量在 SysTick 里累加,或者干脆把耗时逻辑挪到主循环里,中断只置一个标志位。

5.5 串口输出乱码,或者 CubeMX 重新生成后代码没了

现象:串口助手收到的全是烫烫烫或者随机字符;或者改了个引脚重新生成代码,之前写的业务逻辑全被清空。

原因:乱码九成是波特率算错,根源是HSE_VALUE和实际晶振不一致,或者外设挂在 APB1 上却按 APB2 的频率算分频。代码丢失是因为写在了USER CODE BEGIN和USER CODE END之外,CubeMX 重新生成时整段覆盖。

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

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

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

立即咨询