简介:《野火EmbedFire STM32 HAL库开发实战指南—基于F103.pdf》面向STM32嵌入式初学者及需要从标准库转向HAL库的开发者,围绕F103平台讲解HAL库外设配置与工程实践,帮助读者建立从开发环境搭建、程序下载调试到GPIO、中断、定时器、串口通信等模块的完整学习路径。资源包共1个PDF文件,大小约43.41MB,内容以图文教程形式组织,覆盖KEIL5安装与芯片包配置、DAP仿真器连接调试、HAL库API使用及实战项目设计,并保留技术论坛交流入口。已有1881人学习,适合按章节系统阅读,也可作为外设配置和排错时的查阅手册;通过LED闪烁、串口通信、数据采集等案例,读者可逐步掌握可移植代码编写与硬件调试思路。
1. 从寄存器到 HAL:一本 F103 实战教程能替你省掉哪几周试错
很多人第一次碰 STM32 是抱着「用 HAL 库点个灯」的念头开工的,结果灯没亮,回头一查,问题既不在 hal 库也不在 C 语言,而是时钟还停在内部 8MHz 的 HSI 上、下载器配置里 Flash 算法选错了容量、或者启动文件压根没加进工程。这份基于 STM32F103 的 HAL 库开发实战指南,价值就在于把散落在参考手册、数据手册和零散笔记里的前置条件按顺序串成一条线:KEIL5 与器件包安装、DAP 仿真器下载链路、寄存器映射、GPIO 输入输出、外部中断、时钟树与 SysTick,一路走到能跑起来的最小系统。它适合刚跨进 arm cortex-m 门槛、手上有一块 F103 板子的初学者,也适合从标准库迁移过来、想弄明白 HAL 这层抽象到底封装了什么的熟手。
2. 环境一次配到位:KEIL5 器件包与 DAP 下载链路的三处对齐
环境这一步翻车的人比想象中多。代码写得再对,只要下载链路里有一处没对齐,表现就是「编译 0 Error 0 Warning,板子毫无反应」,新手很容易误判成程序逻辑问题,然后开始乱改 main 函数。这一章把 KEIL5、器件支持包、仿真器配置和工程骨架四件事拆开讲,目标是让你第一次点下载就能看到现象。
2.1 KEIL5 与 F103 器件包的版本对齐
MDK 装完之后是「空」的,它不自带任何芯片支持,必须在 Pack Installer 里装对应系列的 Device Family Pack。打开 Pack Installer,左侧 Devices 树里搜 STM32F1,如果 STM32F1 Series 下面没有出现具体型号,说明器件包没装;有型号但右侧 Packs 栏是灰的,说明装了但版本和当前 MDK 不匹配。离线场景下拿到.pack文件直接双击安装即可,装完重启 IDE 才会刷新器件列表。
第二个高频问题是编译器版本。较新的 MDK 默认只带 ARM Compiler 6,而大量存量工程是按 AC5 写的,一打开就报一堆error: unknown type name。这时候要么在 Target 页把 ARM Compiler 下拉切成 V5.06 update 系列(需单独安装),要么按 AC6 的语法改代码。我的习惯是新建工程前先把这条定死,别写到一半再切。
/* 编译期自检:把配置错误挡在编译阶段,而不是烧进去才发现 */ #include "stm32f1xx_hal.h" #if !defined(USE_HAL_DRIVER) #error "未定义 USE_HAL_DRIVER,HAL 的驱动源文件会被条件编译掉" #endif #if !defined(STM32F103xB) #error "器件宏不匹配,stm32f1xx.h 不会包含对应型号的寄存器定义" #endif /* 运行期自检:进入 main 后确认主频已经是 72MHz,而不是默认的 8MHz HSI */ void clock_selfcheck(void) { if (SystemCoreClock != 72000000UL) { /* 最常见原因:HSE 没起振,HAL_RCC_OscConfig 已经返回 HAL_ERROR */ Error_Handler(); } }这段代码有兩個作用。编译期的两个#error对应Options for Target → C/C++ → Define里必须存在的宏:USE_HAL_DRIVER决定 HAL 的.c文件是否被真正编译进去,STM32F103xB里的xB是容量代号,写错会导致stm32f1xx.h选错寄存器头文件。运行期的SystemCoreClock是 HAL 维护的全局变量,时钟初始化成功后它会等于实际主频;如果你打算用 72MHz 却在这里读到 8000000,说明 PLL 根本没锁上,后面所有基于主频的波特率、延时计算全是错的。
| 项目 | 存量工程常用 | 新装 MDK 默认 | 处理方式 |
|---|---|---|---|
| 编译器 | ARM Compiler 5 | ARM Compiler 6 | 单独安装 AC5 后在 Target 页切回 |
| 器件支持 | 手动安装 DFP | 无 | Pack Installer 安装 STM32F1 系列包 |
| C 标准 | gnu89 | gnu11 | 迁移时留意__weak和内联函数语义差异 |
| 微库 | 勾选 Use MicroLIB | 不勾 | 用 printf 重定向时勾上可省 Flash |
2.2 DAP 仿真器三处联动配置
下载失败十有八九不是硬件坏,而是 Debug、Utilities、Debug Settings 三处配置互相打架。这三处的关系是:Debug 页决定「用哪个调试器连芯片」,Utilities 页决定「下载时用哪个 Flash 算法」,Debug Settings 决定「物理链路上怎么握手」。只配其中一两处,就会出现「能连上、能单步,但一下载就报错」的典型症状。
Debug 页选 CMSIS-DAP Debugger,点 Settings 进 Details,Port 选 SW 而不是 JTAG,Max Clock 先降到 1MHz 甚至 100kHz。如果 SW Device 框里能看到 IDCODE(F103 一般是0x1BA01477这一类),说明线序、供电、共地都没问题;显示空白就是物理层的事,别在软件里折腾。Utilities 页勾上 Use Debug Driver,再进 Settings,Flash Download 里勾 Reset and Run、Erase Full Chip 按需选择,Programming Algorithm 必须添加与容量匹配的一项——64KB 或 128KB 的 F103 选STM32F10x Med-density Flash,512KB 的选High-density。
| 配置位置 | 关键项 | 推荐值 | 配错的典型现象 |
|---|---|---|---|
| Debug 页 | Use | CMSIS-DAP Debugger | No Cortex-M Device found |
| Debug → Settings | Port | SW | JTAG 链扫描超时 |
| Utilities 页 | Use Debug Driver | 勾选 | Flash Download failed - Target DLL cancelled |
| Utilities → Settings | Programming Algorithm | 与容量匹配的 Flash 算法 | 擦除越界或下载后不运行 |
还有一个容易被忽略的细节:Reset and Run 勾上之后,下载完芯片会自动复位运行,这时候如果你在 main 开头打了断点,看起来就像「程序没进去」。调试阶段我一般先取消这个勾,手动复位,确认现象可复现再打开。
2.3 工程目录骨架与启动文件的位置
HAL 工程的文件数量比标准库多,目录不规划好,编译选项会越来越乱。我一般按职责分四层:Start放启动文件、system_stm32f1xx.c、CMSIS 内核头文件;Drivers放 HAL 的Src和Inc;User放main.c、stm32f1xx_it.c、stm32f1xx_hal_conf.h;Output放生成的.hex和中间文件。关键点是 Headers 路径要一次性加全,否则stm32f1xx_hal.h能编过、stm32f1xx_hal_gpio.h却找不到,报错信息会误导你去怀疑库文件缺失。
启动文件的选择是最容易埋雷的地方。F103 的启动文件后缀与容量对应:x6是 32KB 以下小容量,x8/xB是 64KB/128KB 中容量,xE是 512KB 大容量,xC是互联型。选错的直接后果是中断向量表长度和实际 Flash 页不匹配,表现为中断进不去或者一上电就停在HardFault_Handler。
; startup_stm32f103xb.s 片段:栈和堆的大小在这里定 Stack_Size EQU 0x00000400 ; 1KB 栈,局部变量深、递归多就往上加 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp Heap_Size EQU 0x00000200 ; 512B 堆,不用 malloc 可以设 0 AREA HEAP, NOINIT, READWRITE, ALIGN=3 __heap_base Heap_Mem SPACE Heap_Size __heap_limitStack_Size决定主栈大小,函数调用层级深、局部数组大、用printf系列函数时都要往上加,我见过在中断里调sprintf直接把栈撑爆的案例。Heap_Size只有调用malloc/free才需要,纯静态分配的工程设成 0 还能省一点 RAM。这两处改完必须重新编译整个工程,增量编译有时不会重新链接启动文件,症状是「改了没效果」,血泪经验。
3. 把 0x40010800 拆开看:寄存器点灯和 HAL 点灯走的是同一条路
跳过寄存器直接学 HAL 的人,遇到「HAL_GPIO_Init 调完引脚还是没反应」时是无从下手的,因为他不知道这行函数最终改了哪几个比特。这一章把 GPIOA 的地址拆开,先用手写寄存器点一次灯,再用 HAL 点一次,把两条路对上,之后看任何 HAL 外设函数都能猜到它背后动了什么。
3.1 存储器映射:0x40010800 意味着什么
Cortex-M3 把 4GB 线性地址空间做了固定划分,其中0x40000000到0x5FFFFFFF是片上外设区,F103 又把 APB2 外设挂在0x40010000起始的位置,GPIOA 相对 APB2 基址的偏移是0x0800,于是 GPIOA 的寄存器组从0x40010800开始。这个地址不是随便定的,参考手册的存储器映射表里写得清清楚楚,记住推导方式比背地址有用。
把地址变成可读代码的方式是用结构体「贴」上去,成员顺序必须和手册里的偏移严格一致:
#include <stdint.h> #define PERIPH_BASE 0x40000000UL #define APB2PERIPH_BASE (PERIPH_BASE + 0x10000UL) #define GPIOA_BASE (APB2PERIPH_BASE + 0x0800UL) /* 成员顺序 = 手册偏移顺序:CRL 0x00, CRH 0x04, IDR 0x08, ODR 0x0C, BSRR 0x10, BRR 0x14, LCKR 0x18 */ typedef struct { volatile uint32_t CRL; volatile uint32_t CRH; volatile uint32_t IDR; volatile uint32_t ODR; volatile uint32_t BSRR; volatile uint32_t BRR; volatile uint32_t LCKR; } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)volatile不能省,它告诉编译器这个地址的值会被硬件改变,禁止把读写优化掉或缓存到寄存器里。少写一个volatile,开-O2之后循环里读IDR判断按键的代码会被优化成只读一次,现象就是「按键怎么按都不响应」,这类玄学问题查起来非常费时间。结构体里成员的类型统一用uint32_t,因为 F103 的 GPIO 寄存器都是 32 位宽,混用uint16_t会让后续成员的偏移全部错位。
3.2 寄存器版点灯与 BSRR 的原子性
点亮一个 LED 要三步:开时钟、配模式、给电平。第一步最容易被跳过——F103 的外设时钟默认关闭,往一个没开时钟的外设寄存器写值不会报错,也不会有任何效果,这是「代码完全正确但灯不亮」的头号原因。
#define RCC_APB2ENR (*(volatile uint32_t *)0x40021018UL) #define GPIOA_CRL (*(volatile uint32_t *)0x40010800UL) #define GPIOA_BSRR (*(volatile uint32_t *)0x40010810UL) #define GPIOA_BRR (*(volatile uint32_t *)0x40010814UL) static void led_reg_init(void) { /* 1. 打开 GPIOA 时钟,APB2ENR 的 bit2 */ RCC_APB2ENR |= (1UL << 2); /* 2. PA5 配成通用推挽输出、50MHz:CRL 的 bit23:20 = 0011 */ GPIOA_CRL &= ~(0xFUL << 20); /* 先清零这四位,避免复位后的脏值 */ GPIOA_CRL |= (0x3UL << 20); /* MODE=11 输出50MHz,CNF=00 推挽 */ /* 3. 给低电平(LED 阴极接引脚、阳极接 3.3V 的接法) */ GPIOA_BRR = (1UL << 5); } static inline void led_reg_on(void) { GPIOA_BRR = (1UL << 5); } static inline void led_reg_off(void) { GPIOA_BSRR = (1UL << 5); }CRL管理 Pin0 到 Pin7,每个引脚占 4 位,Pin5 对应 bit23:20。这 4 位里高两位是CNF(配置),低两位是MODE(速度/方向):MODE=11表示输出模式、最大 50MHz;CNF=00表示通用推挽。如果要做开漏输出(比如驱动 I2C 总线),CNF改成01。
电平操作这里刻意用了BSRR和BRR而不是ODR |= (1<<5)。BSRR低 16 位写 1 置高、高 16 位写 1 置低,BRR低 16 位写 1 置低,两者都是单次写操作,硬件保证原子性。而ODR |= x在汇编层面是读、或、写三步,中间如果被中断打断且中断里也改了同一个ODR,就会出现状态丢失。这种 bug 只在特定时序下复现,属于典型的黑匣子问题,能一开始就避开就别给自己留后悔药。
3.3 HAL 版点灯:结构体字段对应哪个寄存器
同样的功能用 HAL 写,代码短了,但要清楚它替你做了什么:
#include "stm32f1xx_hal.h" static void led_hal_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* 等价于 RCC_APB2ENR |= (1<<2) */ gpio.Pin = GPIO_PIN_5; /* 第 5 号引脚 */ gpio.Mode = GPIO_MODE_OUTPUT_PP; /* 推挽输出,写 CRL 的 CNF/MODE */ gpio.Pull = GPIO_NOPULL; /* 输出模式下无效,仅输入模式有意义 */ gpio.Speed = GPIO_SPEED_FREQ_HIGH; /* 50MHz,写 MODE 位 */ HAL_GPIO_Init(GPIOA, &gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); }字段和寄存器的对应关系是这样的:Pin和Mode一起决定CRL/CRH里哪四位、写成什么值——HAL 内部会先算出引脚编号,小于 8 用CRL,否则用CRH,再按Mode查表得到CNF和MODE的组合。Speed在输入模式下被忽略,只有输出模式生效。HAL_GPIO_WritePin最终也是落到BSRR或BRR上,所以它同样具备原子性,不会出现标准库那种GPIO_SetBits/GPIO_ResetBits的读改写风险。
值得注意的是 HAL 在HAL_GPIO_Init里加了参数断言。如果你在stm32f1xx_hal_conf.h里打开了USE_FULL_ASSERT,传入非法引脚号会直接进assert_failed死循环。调试阶段建议开着,发布时关掉,不然现场出问题只能靠猜。
3.4 CubeMX 生成代码与手写代码的边界
用 STM32CubeMX 生成初始化代码能省掉大量查表工作,但必须清楚它的覆盖规则:重新生成时,只有/* USER CODE BEGIN */和/* USER CODE END */之间的内容会被保留,其余全部重写。所以业务逻辑一律写在 BEGIN/END 区间,或者干脆另建.c文件,在main.c里只留一句调用。
另一个常见误解是 CubeMX 生成的SystemClock_Config一定正确。它默认按你选的时钟树生成,但如果 HSE 起振失败(晶振负载电容不匹配、板子没焊晶振),HAL_RCC_OscConfig会返回HAL_ERROR,生成的代码会进Error_Handler()死循环,表现就是「程序停在那儿不动」。所以外面那层返回值判断不要图省事删掉,它是唯一能告诉你时钟没配上的信号。
4. 时钟、中断与时基:72MHz 是怎么一步步配出来的
时钟是 F103 的命门,几乎所有外设的时序都从它派生:串口波特率、定时器周期、SysTick 的 1ms 时基、SPI 速率,全部依赖主频正确。这一章把 RCC 的 PLL 路径、NVIC 的优先级分组、EXTI 的线映射和 SysTick 的时基放在一起讲,因为它们在你按下复位的那一刻就已经互相耦合了。
4.1 PLL 倍频路径与 APB 分频
F103 的最高主频是 72MHz,路径是:HSE 8MHz 晶振 → PLL 输入 → 9 倍频 → 72MHz 系统时钟。系统时钟再分三路:AHB 不分频得到 HCLK 72MHz;APB1 二分频得 36MHz(APB1 的上限就是 36MHz,超了外设会不稳定);APB2 不分频得 72MHz。
Flash 也要跟着配。当 SYSCLK 在 48MHz 到 72MHz 之间时,Flash 需要 2 个等待周期,同时打开预取缓冲区,否则 CPU 取指会被 Flash 拖慢,等效主频上不去。
| 时钟节点 | 配置值 | 上限 | 超限后果 |
|---|---|---|---|
| SYSCLK | 72MHz | 72MHz | 直接锁死或运行不稳 |
| HCLK (AHB) | 72MHz | 72MHz | 同上 |
| PCLK1 (APB1) | 36MHz | 36MHz | 串口、I2C、定时器 2-7 异常 |
| PCLK2 (APB2) | 72MHz | 72MHz | GPIO、ADC、定时器 1 异常 |
4.2 HAL_RCC_OscConfig 与 HAL_RCC_ClockConfig
HAL 把时钟配置拆成两步:先配振荡器和 PLL,再配总线和分频。
RCC_OscInitTypeDef osc = {0}; RCC_ClkInitTypeDef clk = {0}; /* 第一步:打开 HSE,并把 PLL 配成 HSE×9 */ osc.OscillatorType = RCC_OSCILLATORTYPE_HSE; osc.HSEState = RCC_HSE_ON; /* 用外部晶振,比内部 RC 准 */ osc.HSEPredivValue = RCC_HSE_PREDIV_DIV1; /* F103 的 PLL 源只能是 HSE 或 HSE/2 */ osc.PLL.PLLState = RCC_PLL_ON; osc.PLL.PLLSource = RCC_PLLSOURCE_HSE; osc.PLL.PLLMUL = RCC_PLL_MUL9; /* 8MHz × 9 = 72MHz */ if (HAL_RCC_OscConfig(&osc) != HAL_OK) { Error_Handler(); /* HSE 起振失败会走到这里 */ } /* 第二步:切换系统时钟源到 PLL,并配好各条总线的分频 */ clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; clk.AHBCLKDivider = RCC_SYSCLK_DIV1; clk.APB1CLKDivider = RCC_HCLK_DIV2; /* 36MHz */ clk.APB2CLKDivider = RCC_HCLK_DIV1; /* 72MHz */ if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); }几个参数需要点出来。HSEPredivValue在 F103 上只有 1 分频和 2 分频两个选择,它的作用是在进 PLL 之前先把晶振频率降一半,用于 8MHz 以上晶振的场合;板子上是 8MHz 就填DIV1。PLLMUL的可选倍数从 2 到 16,但最终 SYSCLK 不能超过 72MHz,8MHz 晶振下最多选MUL9。HAL_RCC_ClockConfig的第二个参数是 Flash 延迟,这个值写少了是最隐蔽的翻车方式:程序能跑,但跑快一点就随机 HardFault,看起来像内存越界,实际是取指错误。
4.3 EXTI 线映射与 NVIC 优先级分组
F103 的外部中断有个硬约束:EXTI0 到 EXTI15 一共 16 条线,每条线同一时刻只能接一个端口的同号引脚。也就是说 PA0、PB0、PC0 都对应 EXTI0,但只能选其中一个。这个选择由 AFIO 的EXTICR寄存器完成,HAL 在你把引脚配成GPIO_MODE_IT_FALLING时自动写好,不用手工操作。
NVIC 的优先级要分清两个概念。F103 的每个中断有 4 位优先级,可以按抢占和子优先级两种方式切分,切分方案由HAL_NVIC_SetPriorityGrouping决定。常用的NVIC_PRIORITYGROUP_4是 4 位全给抢占优先级、0 位给子优先级,这样 16 个等级每一级都能打断比自己低的。
/* 按键接 PA0,下降沿触发 */ GPIO_InitTypeDef key = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); key.Pin = GPIO_PIN_0; key.Mode = GPIO_MODE_IT_FALLING; /* 内部会自动配 AFIO 的 EXTI 映射 */ key.Pull = GPIO_PULLUP; /* 按键另一端接 GND,必须上拉 */ HAL_GPIO_Init(GPIOA, &key); /* 抢占优先级 5,子优先级 0,允许被 0-4 打断 */ HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0); HAL_NVIC_EnableIRQ(EXTI0_IRQn);中断服务函数的链路要接完整:stm32f1xx_it.c里的EXTI0_IRQHandler调用HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0),后者清挂起位并回调HAL_GPIO_EXTI_Callback。业务写在回调里,不要直接改it.c。
4.4 SysTick 时基与 HAL_Delay 的边界
HAL_Init()里会调HAL_InitTick(),后者用SysTick_Config(SystemCoreClock / 1000)把 SysTick 配成 1ms 中断一次。每次中断把全局变量uwTick加一,HAL_Delay(n)就是循环比较uwTick的差值。这个设计简单,但有一条硬边界:SysTick 的优先级默认被设成最低,所以在任何中断服务函数里调用HAL_Delay都会卡死——uwTick涨不上去,循环永远出不来。这不是 bug,是设计使然。
同样地,如果你自己改了SystemCoreClock却没重新初始化 SysTick,HAL_Delay(1000)就不再是一秒。HAL 在HAL_RCC_ClockConfig内部会重新调一次HAL_InitTick,所以走标准流程没问题;手工改寄存器切时钟的工程就要自己补这一句。
5. 排查五连:工程编译通过却停在原地时先看这几处
调试阶段最耗时的不是写代码,是把「没现象」翻译成「哪个环节断了」。下面五条是我在实际项目里反复遇到的,按现象、原因、解决三段写,遇到了可以当检查表用。
5.1 现象:下载成功,复位后 LED 不亮,调试器显示停在 HardFault_Handler
原因通常有两个。一是启动文件与芯片容量不匹配,向量表长度按大容量写的,实际是小容量芯片,中断向量错位后一进中断就飞。二是中断服务函数名拼错,比如把TIM2_IRQHandler写成TIM2_IRQhandler,C 是大小写敏感的,链接器不会报错,但中断发生时跳到了默认的死循环。
解决方式:核对型号与启动文件后缀,对照参考手册的中断向量表检查服务函数名;在HardFault_Handler里加一段打印LR和PC的代码,能直接定位到出错指令地址,比盲猜快得多。
5.2 现象:KEIL 报 No Cortex-M Device found 或 Cannot access target
原因多半在物理层:SWDIO 和 SWCLK 接反、目标板与调试器没有共地、目标板由外部电源供电而调试器只接了信号线。另一种情况是芯片被读保护锁住,连得上但访问不了。
解决方式:先用万用表量 SWDIO/SWCLK 到排针的通断;把调试器设成给板子供电,或者确保两块地短接;把 SW 时钟降到 100kHz 再试;读保护用脱机下载工具做一次全片擦除即可解除,代价是 Flash 内容清空。
5.3 现象:串口打印全是乱码,换波特率也没用
原因通常不是波特率本身,而是时钟源不对。程序按 72MHz 算的分频值,实际 APB1 只有 36MHz,或者 HSE 没起振退回了 8MHz 的 HSI,实际波特率和设定值差出几倍。
解决方式:在SystemClock_Config的每个返回值上加判断,失败就点亮一个错误指示灯;用 MCO 引脚把 SYSCLK 输出到示波器上量实际频率;实在没有仪器,就发一个固定的0x55字节,用示波器量单个位的宽度,倒推波特率。
5.4 现象:HAL_Delay 卡死,程序停在 while 循环里出不来
原因基本就是前面提过的:在中断服务函数里调用了HAL_Delay,而 SysTick 优先级低于当前中断,uwTick不再累加。另一种是误关了全局中断__disable_irq()之后忘了恢复。
解决方式:中断里只置标志位,业务放主循环处理;确实需要短延时的场合,用 DWT 的周期计数器做忙等,不依赖任何中断。
5.5 现象:EXTI 按键中断触发一次后再也不进
原因是没有清挂起位,或者重写了EXTIx_IRQHandler却没在里面调用HAL_GPIO_EXTI_IRQHandler。HAL 的清挂起动作在后者内部完成,绕过它就等于把中断源一直挂着。
解决方式:检查stm32f1xx_it.c里的中断函数是否调用了 HAL 的处理函数;回调里只做标志置位,别在里面做长耗时操作;如果是机械按键,还要考虑抖动导致的一次按下触发多次的问题,硬件加 RC 或者软件加 10ms 时间窗都行。
6. 上电自检三件事:把能跑的工程验到可信
「灯亮了」不等于工程可信。我现在的习惯是每次拿到一块新板子或者换一份 HAL 工程,先做三件小事,做完心里才有底。
第一件,用 DWT 做微秒级计时。SysTick 只有 1ms 粒度,测 I2C 时序、SPI 片选间隔根本不够用,而 DWT 的周期计数器是 CPU 主频级的:
static void dwt_delay_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 使能跟踪单元 */ DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 启动周期计数 */ } static inline void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000UL); while ((DWT->CYCCNT - start) < ticks) { } /* 差值比较天然处理溢出 */ }CYCCNT是 32 位自由计数器,72MHz 下大约 59 秒溢出一次,所以用「当前减起点」的差值比较而不是直接比大小,溢出时也正确。第二件是用 MCO 把系统时钟输出到 PA8,示波器直接量频率,比看代码可靠。第三件是用逻辑分析仪抓一段HAL_GPIO_TogglePin的波形,确认周期和你算的一致——如果算出来是 500ms、抓出来是 4s,那一定是主频没配到 72MHz。
| 观测点 | 引脚/手段 | 预期 | 判读 |
|---|---|---|---|
| 系统主频 | MCO 输出到 PA8 | 72MHz 或 72/2 | 偏差说明 PLL 或晶振有问题 |
| GPIO 翻转周期 | 逻辑分析仪抓目标引脚 | 与代码中延时一致 | 偏慢即主频不足 |
| 微秒延时 | DWT 忙等 + 翻转 | 误差小于 1% | 偏差大检查 SystemCoreClock |
| 上电时间 | 复位后拉高一个测试脚 | 由时钟初始化耗时决定 | 明显 |
本文还有配套的精品资源,点击获取