简介:这份PDF面向STM32嵌入式开发的入门与进阶读者,基于F103系列芯片讲解ST(意法半导体)HAL库的标准API用法,帮助初学者跨越寄存器编程门槛,建立可移植的固件开发思路。内容按学习路径组织:先认识STM32与Cortex-M内核背景,再完成KEIL5与STM32芯片包的安装配置,随后介绍DAP仿真器的硬件连接与Debug、Utilities等下载调试选项,进而展开GPIO、中断、定时器、串口通信等外设的HAL库配置,并以LED闪烁、串口协议、数据采集等实战项目收束,还给出配套硬件说明、参考资料与野火技术论坛的提问渠道。资源包共1个PDF文件,约43.41MB,为2021年版文档,章节编号与目录完整,便于按模块检索和打印查阅。目前已有1881人学习下载,适合配合开发板动手实践、系统补齐HAL库基础的读者参考。
1. 一块 F103 最小系统板,卡在生成的 main.c 前面
手上有一块 F103 最小系统板,Keil 装好了,CubeMX 也点开了,可生成的 main.c 里一堆 MX_GPIO_Init、SystemClock_Config,改哪一行、删哪一行心里没底——这大概是 stm32 入门最典型的卡点。这份《STM32 HAL 库开发实战指南—基于 F103》走的是另一条路:先把 ARM Cortex-M 内核的存储映射和 HAL 库的分层讲清楚,再让你用 CubeMX 把工程骨架搭起来,最后落到 GPIO、EXTI、RCC、SysTick 这些具体外设上。它适合刚上手 F103、被寄存器手册劝退、又想用 HAL 库把项目做出来的人;如果你已经能徒手写 LL 库,这本书的信息密度对你偏低。真正值钱的地方在于,它把「工具链配置—代码生成—外设调用—上板验证」串成了一个闭环,而不是散着讲知识点。
2. 工程骨架:Keil5、DAP 与 CubeMX 生成的第一版 F103 代码
2.1 HAL 库的目录分层决定了你该改哪个文件
很多人拿到 HAL 库源码的第一反应是「文件太多,不知道从哪看」。先把分层关系捋顺,后面所有操作才有落点。CMSIS 层是 ARM 定的标准,负责内核寄存器定义、启动文件、系统初始化,跟具体芯片厂商无关;HAL 驱动层是芯片厂商按 CMSIS 规范写的外设抽象,把「配置 GPIO 的哪个位」翻译成「填一个结构体再调一个函数」;应用层才是你写业务的地方。
放到工程里就是三个目录:Drivers/CMSIS放内核头文件和 startup 汇编,Drivers/STM32F1xx_HAL_Driver放各外设的 .c/.h,Core放 CubeMX 生成的初始化和你的代码。这条分层的实际意义是:换一颗同系列但引脚更多的芯片,CMSIS 层几乎不动,HAL 层换型号,只有 Core 里的引脚定义要重配。理解这一点,你就不会去改stm32f1xx_hal_gpio.c里的实现,而是老老实实在main.c的 User Code 区里加自己的逻辑。
我一般会先打开stm32f1xx_hal_conf.h看一眼裁剪开关。CubeMX 默认把所有外设模块都打开,编译出来 Flash 占用不小;如果你的板子只用到 GPIO 和 EXTI,把HAL_ADC_MODULE_ENABLED、HAL_SPI_MODULE_ENABLED这类用不上的宏注释掉,能省下可观的代码空间。这不是玄学,是实打实的链接期裁剪。
2.2 Keil5 装完必须补的一步:芯片支持包
Keil5 本体装完只是有了 IDE 和编译器,里面并不带任何芯片型号。新建工程时如果 Device 列表里找不到 STM32F103C8,不是软件坏了,是缺 DFP 支持包。常见做法是从 Keil 的 Pack Installer 里搜 STM32F1 系列安装,或者用离线 pack 双击导入。
| 步骤 | 动作 | 容易忽略的点 |
|---|---|---|
| 1 | 安装 Keil5 本体 | 安装路径不要带中文和空格,后面脚本调用会出问题 |
| 2 | 打开 Pack Installer 装 STM32F1xx_DFP | 装完要重启 Keil,Device 列表才刷新 |
| 3 | 新建工程选芯片型号 | 型号选错,启动文件里的中断向量数量就对不上 |
| 4 | 确认 Options 里 Target 的晶振频率 | 默认 12MHz,实际板子是 8MHz 的话,仿真里的时间全是错的 |
第 4 步是最容易被跳过的一步。Keil 里 Target 页的 Xtal 值只影响软件仿真时的时间计算,不影响真实硬件,但很多人第一次用软件仿真看延时,发现对不上就是栽在这。真实硬件跑起来靠的是 SystemClock_Config 里配的 PLL,这个后面会细说。
2.3 DAP 的四个设置项,少一个都下不进程序
DAP 仿真器接线只有四根:3V3、GND、SWCLK、SWDIO。接反 SWDIO 和 SWCLK 不会烧器件,但会一直报「No Cortex-M Device found」,新手容易以为是板子坏了。接好线之后,Keil 的 Options → Debug 页要选 CMSIS-DAP Debugger,点 Settings 进去还有四个地方要对。
Port 选 SW 而不是 JTAG,F103 的 JTAG 引脚和普通 GPIO 复用,用 SW 能少占引脚。Max Clock 建议第一次先降到 1MHz,排线长或者板子供电不稳时,高时钟容易出现连上了但下载校验失败。Flash Download 页勾上 Reset and Run,不然每次下载完还得手动按复位。Debug 页的 Dialog DLL 参数一般保持默认,-pSTM32F103C8这种是给软件仿真用的,硬件调试不用管。
提示:如果 Settings 里能识别到 IDCODE 但下载报错,先查板子供电。DAP 的 3V3 是参考电平,不是给整板供电的,目标板得单独上电。
2.4 CubeMX 里填错的三个参数,后面要用调试器还债
CubeMX 建工程有几个参数一旦填错,编译能过、下载能进,但运行行为不对,排查起来非常费时间。第一个是 SYS 里的 Debug 选项,必须选 Serial Wire,不选的话 CubeMX 会把 PA13、PA14 配成普通 GPIO,第二次下载就连不上了。第二个是 RCC 里的 HSE,板上有 8MHz 晶振就选 Crystal/Ceramic Resonator,选错成 Disable 就只能跑内部 HSI,主频上不到 72MHz。
第三个是 Clock Configuration 页的时钟树。F103 的约束是:HCLK 最高 72MHz,APB1 最高 36MHz,APB2 最高 72MHz,ADC 时钟不能超过 14MHz。填的时候先在 HCLK 框里输入 72 回车,让工具自动算分频,再回头检查 APB1 那一栏是不是已经变成 36。手工硬填 72 到 APB1,编译下载都正常,但串口波特率会整体偏一倍,这种坑不跑一遍根本发现不了。
| 参数 | 典型值 | 超限后果 |
|---|---|---|
| HCLK | 72MHz | 超过 72 芯片不稳定,Flash 等待周期不够会跑飞 |
| PCLK1 (APB1) | 36MHz | 超频后挂在这条总线上的串口、I2C 时序全错 |
| PCLK2 (APB2) | 72MHz | 上限就是 72,不能再高 |
| ADC Prescaler | /6 | 72/6=12MHz,超过 14MHz 采样值会飘 |
Project Manager 页里,Toolchain 选 MDK-ARM,版本按实际装的填;Code Generator 里勾上「Generate peripheral initialization as a pair of .c/.h files」,这样每个外设的初始化会单独成文件,后期往工程里加模块不会把 main.c 撑爆。
2.5 生成代码先读启动文件的向量表
工程生成完,别急着编译。先打开MDK-ARM/startup_stm32f103xb.s,这张向量表决定了芯片上电后第一跳去哪。
; startup_stm32f103xb.s 片段:栈顶和复位向量必须排在最前 Stack_Size EQU 0x00000400 ; 1KB 栈,HAL 深调用链勉强够 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp AREA RESET, DATA, READONLY EXPORT __Vectors __Vectors DCD __initial_sp ; 第 0 项:初始栈顶地址 DCD Reset_Handler ; 第 1 项:复位入口 DCD NMI_Handler DCD HardFault_Handler这段汇编的逻辑是:Cortex-M3 上电后硬件自动从 0x08000000 取第一个字当栈顶,从 0x08000004 取第二个字当复位向量跳过去。所以__initial_sp的值必须是一个合法的 RAM 地址,Reset_Handler必须在 Flash 里真实存在。如果你改过链接脚本把程序挪到别的地址,这两项不跟着改,芯片上电就是 HardFault。
参数上,Stack_Size默认 1KB。HAL 库里如果有sprintf、浮点运算或者递归调用,1KB 很容易爆。我一般的做法是直接改成 0x800(2KB),代价只是多占 2KB RAM,F103C8 有 20KB,完全给得起。爆栈的表现很隐蔽,可能是某个局部数组的值莫名其妙变了,也可能是进了 HardFault 但调用栈看不出来,提前给足比事后查强。
主函数骨架长这样:
int main(void) { HAL_Init(); /* 设 NVIC 分组为 4、配 SysTick 1ms、复位外设 */ SystemClock_Config(); /* HSE 8MHz 经 PLL×9 得到 72MHz */ MX_GPIO_Init(); /* 使能 GPIO 时钟并写入模式寄存器 */ while (1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); /* 依赖 SysTick 中断,主循环里才安全 */ } }HAL_Init()这一步不能省。它内部会调HAL_InitTick(TICK_INT_PRIORITY),把 SysTick 配成 1ms 周期并设一个较低的优先级,HAL_Delay的计数基础全靠它。有人把 HAL_Init 删了直接调 HAL_Delay,表现是程序不延时、LED 闪得飞快,查半天以为是时钟配错。
TICK_INT_PRIORITY默认是 0x0F,也就是 15,最低优先级。如果你的业务里有一个抢占优先级比 15 高的中断,并且在里面调了HAL_Delay,SysTick 中断永远打断不了它,程序就死等在那里。这是后面避坑章要展开的一条。
3. GPIO 的两种脾气:输出推挽与输入上拉,从点灯到按键消抖
3.1 八种模式不是随便选的,选错就是电平不对
F103 每个 GPIO 引脚有输入浮空、输入上拉、输入下拉、模拟输入、推挽输出、开漏输出、复用推挽、复用开漏八种模式。名字看着多,实际按「方向 + 电气结构」两维就能记住:输入侧决定内部有没有接上拉或下拉电阻,输出侧决定是高边低边都驱动(推挽)还是只有低边驱动(开漏)。
推挽输出的高电平由 PMOS 拉到 VDD,低电平由 NMOS 拉到 GND,两个管子轮流导通,所以能主动输出高也能主动输出低,驱动 LED 这种负载够用。开漏输出只有一个 NMOS,高电平是「悬空」,必须外接上拉电阻才能得到高电平,好处是能做电平转换和 I2C 这类多设备共线的总线。点 LED 用推挽,接 I2C 用开漏,这是最基本的分工。
复用模式是引脚交给片上外设控制,比如 USART 的 TX 脚。这时候你写HAL_GPIO_WritePin是无效的,因为输出由串口外设接管了。很多人调串口没波形,查到最后是把 TX 脚配成了普通推挽输出。
3.2 输出的原子写:BSRR 和 HAL_GPIO_WritePin 的关系
配置一个 LED 输出的完整代码:
static void LED_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); /* 不开时钟,写寄存器全是空操作 */ gpio.Pin = GPIO_PIN_13; gpio.Mode = GPIO_MODE_OUTPUT_PP; /* 推挽:能拉高也能拉低 */ gpio.Pull = GPIO_NOPULL; /* 板上已有外部上拉/下拉就别再开内部 */ gpio.Speed = GPIO_SPEED_FREQ_LOW; /* 2MHz,驱动 LED 足够,EMI 也小 */ HAL_GPIO_Init(GPIOC, &gpio); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); /* 上电默认灭 */ }这段的逻辑顺序不能反:先开时钟,再填结构体,最后调 Init。__HAL_RCC_GPIOC_CLK_ENABLE()本质是置位 RCC_APB2ENR 的对应位,时钟没开的时候,对 GPIOC 的任何寄存器写入都会被硬件丢弃,而且不报错,属于典型的「代码没错但没反应」。
参数上,Speed有三档:2MHz、10MHz、50MHz。它控制的是输出驱动器的翻转速率,不是限制你能翻转多快,而是限制边沿的陡峭程度。驱动 LED、继电器这类慢速负载选 LOW 就够,边沿缓一点反而 EMI 小;如果是 SPI 的 SCK 或者 PWM 输出,要按实际频率往高选,否则波形上升沿被压塌,通信误码。
代码里最值得说的是HAL_GPIO_WritePin的实现,它最终写的是 BSRR 寄存器:
/* 不推荐:读改写 ODR,中断打断会丢位 */ GPIOA->ODR |= GPIO_PIN_5; /* 推荐:写 BSRR,单条指令完成置位或复位 */ GPIOA->BSRR = GPIO_PIN_5; /* 低 16 位写 1 置位 */ GPIOA->BSRR = GPIO_PIN_5 << 16; /* 高 16 位写 1 复位 */ODR |=这种写法是读—改—写三步,如果在读完之后、写回之前来了个中断,中断里也改了同一个端口,回来写回时就把中断的修改覆盖掉了。BSRR 是只写寄存器,写 1 生效写 0 无影响,单条指令原子完成。这也是为什么中断服务程序里操作引脚,用 HAL 库的 WritePin 是安全的。
3.3 输入的电气选择:上拉、下拉与浮空
按键接法决定了你要配哪种输入模式。如果按键一端接引脚、另一端接 GND,那么引脚平时必须被拉到高电平,按下才变低,所以要配GPIO_PULLUP。反过来按键接 VCC,就配GPIO_PULLDOWN。悬空输入(GPIO_NOPULL)只适合外部已经有明确上下拉的场合,否则引脚电平随环境漂移,读出来是随机值。
static void KEY_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_INPUT; /* 普通输入,轮询用 */ gpio.Pull = GPIO_PULLUP; /* 按键另一端接 GND,按下读到低 */ HAL_GPIO_Init(GPIOA, &gpio); }GPIO_PULLUP打开的是芯片内部约 40kΩ 的上拉电阻,够用但不强。如果板子上按键离 MCU 很远、走线又长,内部上拉抵不过干扰,读数会抖,这时候要在外部并一个 4.7k 到 10k 的电阻。这是硬件层面的事,软件配得再对也救不了。
3.4 按键消抖:阻塞式消抖只适合单任务裸机
机械按键按下和松开的瞬间会有 5 到 20ms 的抖动,直接读会一次按下触发好几次。最简单的处理是延时确认:
static uint8_t KEY_Scan(void) { static uint8_t pressed = 0; 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 && !pressed) { pressed = 1; /* 标记已按下,防止长按重复触发 */ return 1; } } else { pressed = 0; /* 松开后清标志 */ } return 0; }这段代码的关键在两处。一是 20ms 的延时覆盖了绝大多数轻触按键的抖动窗口,二是pressed这个静态标志实现了「松手才算一次」的逻辑,不然按住不放会连续返回 1。
HAL_Delay是阻塞的,它靠 SysTick 中断累加计数,在主循环里调用没问题,但绝对不能在中断服务程序里调,原因和 2.5 节说的一样。如果系统里已经有定时器在跑,更稳妥的做法是每 10ms 扫一次引脚,连续三次读到同一个电平才确认状态变化,这就是状态机消抖,不占 CPU 也不阻塞。
3.5 下载验证:先看现象再看波形
程序下载进去之后,别只看 LED 亮没亮。用万用表量一下引脚电压,推挽输出高应该是 3.3V 左右,低是 0V。如果量出来是 1.8V 这种中间值,多半是配成了开漏又没接上拉,或者是引脚被别的外设复用了。
按键这边,可以在按下时翻转另一个 LED 来验证。如果 LED 偶尔闪两下,说明消抖没生效;如果完全没反应,先确认__HAL_RCC_GPIOA_CLK_ENABLE()有没有漏,再确认按键另一端真的接了 GND。血泪经验是:接线问题占调试时间的一大半,代码反而通常是对的。
4. 时钟树与中断优先级:把 72MHz 和 NVIC 分组一次配对
4.1 时钟树:为什么 APB1 不能超过 36MHz
F103 的时钟源有四个:HSI 内部 8MHz RC、HSE 外部 4 到 16MHz 晶振、LSI 内部 40kHz、LSE 外部 32.768kHz。系统主时钟可以从 HSI 直接来,也可以走 HSE 经 PLL 倍频。PLL 的输入先除一个预分频(HSE 时通常不分频),再乘 2 到 16 倍,得到 PLLCLK,最高 72MHz。
得到 72MHz 之后还要分频到三条总线:AHB 直接给 HCLK,APB1 最高 36MHz,APB2 最高 72MHz。这个限制不是建议,是芯片手册写死的电气特性,超了之后挂在这条总线上的外设时序会全部错乱。串口波特率、I2C 速率、定时器计数频率全都由总线时钟推导,总线错了它们全错。
有个细节容易忽略:当 APB 预分频系数不为 1 时,挂在该总线上的定时器时钟会自动乘 2。比如 APB1 分频到 36MHz,挂在上面的 TIM2 实际计数时钟是 72MHz。配 PWM 的时候如果按 36MHz 算周期,出来的频率会差一倍。
4.2 HSE 起振失败,程序会死在 Error_Handler
CubeMX 生成的时钟配置函数大致如下:
void SystemClock_Config(void) { RCC_OscInitTypeDef osc = {0}; RCC_ClkInitTypeDef clk = {0}; osc.OscillatorType = RCC_OSCILLATORTYPE_HSE; osc.HSEState = RCC_HSE_ON; /* 8MHz 晶振,起振失败返回 HAL_ERROR */ osc.PLL.PLLState = RCC_PLL_ON; osc.PLL.PLLSource = RCC_PLLSOURCE_HSE; osc.PLL.PLLMUL = RCC_PLL_MUL9; /* 8 × 9 = 72MHz */ if (HAL_RCC_OscConfig(&osc) != HAL_OK) { Error_Handler(); } clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; clk.AHBCLKDivider = RCC_SYSCLK_DIV1; /* HCLK 72MHz */ clk.APB1CLKDivider = RCC_HCLK_DIV2; /* PCLK1 36MHz,卡在上限 */ clk.APB2CLKDivider = RCC_HCLK_DIV1; /* PCLK2 72MHz */ if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); } }HAL_RCC_OscConfig内部会启动 HSE 并等一个超时,超时时间内没等到起振就返回 HAL_ERROR。这时程序进Error_Handler(),而 CubeMX 生成的这个函数是个关中断的while(1),表现就是板子像死了一样,连调试器都可能连不上。判断方法很简单:在Error_Handler里加一句点灯,如果灯常亮不闪,就是卡在这。
FLASH_LATENCY_2这个参数也不能乱填。Flash 的读取速度跟不上 72MHz 的内核,必须插入等待周期,48 到 72MHz 对应 2 个等待周期。填 0 的话,代码从 Flash 取指会读到错误数据,程序跑飞的方式千奇百怪,可能进 HardFault,也可能跳到一个不存在的地址。
如果板子上根本没有晶振,就得改用 HSI。HSI 是内部 RC,精度差(全温度范围可能偏几个百分点),但不用外部器件,起振必成功。用 HSI 的时候 PLL 的输入可以选 HSI/2 再倍频到 64MHz,虽然到不了 72,但胜在稳。
4.3 NVIC 优先级分组:抢占和子优先级不是一回事
Cortex-M3 的 NVIC 给每个中断一个 8 位优先级寄存器,但芯片厂商一般只实现高 4 位。这 4 位怎么分配给「抢占优先级」和「子优先级」,由优先级分组决定。
| 分组 | 抢占位数 | 子优先级位数 | 抢占级数 | 典型用途 |
|---|---|---|---|---|
| GROUP_0 | 0 | 4 | 1 | 等于关掉抢占,很少用 |
| GROUP_1 | 1 | 3 | 2 | 极简系统 |
| GROUP_2 | 2 | 2 | 4 | 中小项目 |
| GROUP_3 | 3 | 1 | 8 | 常用折中 |
| GROUP_4 | 4 | 0 | 16 | HAL_Init 默认 |
抢占优先级决定「能不能打断正在执行的中断」,子优先级只在两个中断同时挂起时决定谁先被响应,不能打断。HAL_Init 里默认设成 GROUP_4,也就是 4 位全给抢占,最多 16 级嵌套,没有子优先级。
中断服务程序里要尽量短。HAL 库的HAL_GPIO_EXTI_Callback是弱函数,你在自己的文件里重定义就能接管,但里面只该做置标志、翻转引脚这类微秒级操作,耗时逻辑丢回主循环处理。
4.4 EXTI 外部中断的完整链路
从引脚到回调要经过四级:GPIO 配成中断模式,AFIO 把引脚映射到对应的 EXTI 线,EXTI 配触发边沿并开中断屏蔽,NVIC 使能对应通道并设优先级。
void KEY_EXTI_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_IT_FALLING; /* 下降沿触发,按下为低 */ gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &gpio); HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0); /* 抢占 2、子优先级 0 */ HAL_NVIC_EnableIRQ(EXTI0_IRQn); } /* stm32f1xx_it.c 里由 CubeMX 生成,最终调到 weak 回调 */ void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); } void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); /* 别在这里 HAL_Delay */ } }GPIO_MODE_IT_FALLING这个宏内部已经处理了 AFIO->EXTICR 的映射,不用你再手写。中断服务函数里必须调HAL_GPIO_EXTI_IRQHandler,它负责清 EXTI 的挂起位并分发到回调,漏了这一步的表现是进一次中断后程序卡死,因为挂起位没清,中断会被反复触发。
HAL_NVIC_SetPriority的两个参数分别是抢占优先级和子优先级,取值上限取决于 4.3 节的分组。在 GROUP_4 下子优先级参数直接失效,写多少都一样。
5. 避坑与排查:五个上板必翻车点及对应解法
5.1 Debug 没选 Serial Wire,第二次下载连不上
现象:第一次下载正常,改完代码再下,Keil 报「Cannot access target」或者读到 IDCODE 全是 0。
原因:CubeMX 的 SYS 页里 Debug 选项留成了 Disable,生成的MX_GPIO_Init把 PA13、PA14 配置成了普通 GPIO。这两个脚正是 SWDIO 和 SWCLK,被改成普通 IO 之后调试通道就断了。
解决:把板子上的 BOOT0 拉高,重新上电让芯片从系统存储器启动,此时 SWD 引脚回到默认状态,能连上。连上后把工程里的 SYS Debug 改成 Serial Wire 重新生成,再用 BOOT0 拉低正常启动刷一遍。从那以后我建工程第一件事就是先确认这一项。
5.2 HSE 不起振,程序停在 Error_Handler
现象:下载完成,板子毫无反应,调试器暂停后发现 PC 指在Error_Handler的while(1)。
原因:HAL_RCC_OscConfig等 HSE 起振超时。常见于板子上根本没焊晶振、晶振虚焊、或者负载电容选得不对。
解决:先量晶振两端有没有起振波形,没有就用示波器看是不是完全没信号。临时验证可以把时钟源改成 HSI,如果改完能跑,就坐实是 HSE 的问题。长期方案是补焊晶振和匹配电容,或者干脆在工程里固定用 HSI。
5.3 中断里调 HAL_Delay,程序死等
现象:按键触发一次之后主循环不再执行,LED 停在某个状态。
原因:把HAL_Delay写进了HAL_GPIO_EXTI_Callback。HAL_Delay 靠 SysTick 中断累加,而 SysTick 默认优先级是 15,是最低的。当前中断的抢占优先级若高于 15,SysTick 永远打断不了它,计数不增加,HAL_Delay就死循环。
解决:中断里只置标志位,延时和业务逻辑全部挪回主循环。如果非要在中断上下文中等待,用 DWT 的周期计数器做忙等,它不依赖任何中断。
5.4 两个引脚共用一个 EXTI 线,只有一个能进中断
现象:PA0 和 PB0 都配了下降沿中断,实际只有其中一个能触发。
原因:F103 的 EXTI0 线只能同时映射到端口 A 到 G 中的某一个,AFIO->EXTICR里同一线号只有一个选择位。后配的会覆盖先配的,所以只有一个引脚真正连到中断线上。
解决:选引脚时就避开同号复用,比如一个用 PA0,另一个用 PB1。如果硬件已经画死了,只能改成轮询或者用定时器扫描。
5.5 变量定义在头文件里,链接报重复定义
现象:编译单个文件没问题,链接时报multiply defined symbol,指向某个全局变量。
原因:在 .h 里写了uint8_t flag = 0;这种带初始化的定义,每个包含该头文件的 .c 都会生成一份实体。
解决:头文件里只写extern uint8_t flag;做声明,定义放到某一个 .c 里。或者用static限定作用域,但那样每个文件各有一份副本,语义就变了。这类问题在多人协作的工程里尤其常见,改的时候要确认清楚是要共享还是各用各的。
6. 验证技巧:用 DWT 周期计数把 HAL 调用的代价量出来
调试 GPIO 和延时逻辑,光看 LED 闪不闪信息量太少。我习惯用内核自带的 DWT 周期计数器给关键代码段计时,不需要额外接示波器,精度到单周期。
/* 使能 DWT 周期计数器,用来量 HAL 调用的真实耗时 */ static void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 先开跟踪单元总开关 */ DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 启动周期计数 */ } uint32_t t0 = DWT->CYCCNT; HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); uint32_t cycles = DWT->CYCCNT - t0; /* 72MHz 下 72 个 cycle 就是 1us */DEMCR的 TRCENA 位是 DWT 和 ITM 的总使能,不开这一位,后面写 CYCCNT 全是无效的。CYCCNT是 32 位向上计数,72MHz 下大约 59.6 秒溢出一次,单次测量完全够用。算时间就是 cycles 除以 72 得到微秒数。
这个技巧在验证 3.4 节那个阻塞消抖时特别有用。量一下KEY_Scan单次执行的实际耗时,如果远大于 20ms,说明HAL_Delay的精度有问题,往上查 SysTick 的配置;如果明显偏小,可能是编译器把延时优化掉了,得把相关变量加volatile。
另一个值得养成的习惯是随手加断言。HAL 库里每个HAL_xxx_Init开头都有assert_param(IS_xxx(...)),默认是空宏。在stm32f1xx_hal_conf.h里把USE_FULL_ASSERT打开,并实现assert_failed打印文件名和行号,参数填错时能第一时间定位,比事后猜强得多。Doxygen 风格的函数注释和头文件的#ifndef保护也是同样的道理,写的时候多花两分钟,接手的人少花两小时。
从那以后我每次新建 F103 工程,都强制先走一遍:SYS 确认 Serial Wire、时钟树核对 APB1 不超过 36、栈改到 2KB、断言打开,四步做完再动业务代码。希望帮到你。
本文还有配套的精品资源,点击获取