__weak 弱引用、中断标志位、 异常处理与 HAL_Delay 优先级
1. __weak 弱引用:HAL 库回调函数的"占位符"
1.1 什么是 __weak
__weak是编译器关键字(ARM Compiler、GCC 均支持),把一个函数/变量声明为弱符号。弱符号与普通全局符号(强符号)的区别在于链接器的处理规则:
场景 强符号 弱符号(__weak) 只有一个定义 正常链接 正常链接 与强符号同名 强符号生效 弱符号被覆盖,不报错 多个弱符号同名 --- 链接器任选其一(不报错,但结果不确定) 没有定义但被引用 链接报错 不报错(取值为 0,函数则指向空实现/地址 0)
1.2 从汇编和链接器视角看弱符号
__weak在编译后会生成汇编指令.weak 函数名,把该符号的绑定属性(binding)标记为 WEAK,写入 ELF 符号表。链接器(ld)在解析符号引用时,优先选择强定义,找不到强定义才用弱定义。所以:
- 用户没写回调 → 链接器使用库里的弱定义(空函数),程序正常运行;
- 用户写了同名回调 → 链接器丢弃弱定义,用户版本生效。
1.3 HAL 库为什么大量使用 __weak
打开 HAL 库源码,几乎所有回调函数都是弱定义的空实现:
cpp/* stm32f1xx_hal_gpio.c */ __weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { /* 默认什么都不做 */ } /* stm32f1xx_hal_uart.c */ __weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { /* 默认什么都不做 */ } /* stm32f1xx_hal_tim.c */ __weak void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { /* 默认什么都不做 */ }HAL 库是"通用库",它不知道你的业务逻辑,但又必须给你留"挂钩子"的地方:
- 没有弱引用 → 库必须强制你实现回调,否则链接报错,用户被绑架;
- 有了弱引用 → 库自带空实现,你不写也能编译通过,写了就自动覆盖,互不干扰。
注意:
__weak和 GCC 的__attribute__((weak))是等效写法,只是不同编译器下的语法糖:
cpp/* GCC 等效写法 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) __attribute__((weak));
1.4 覆盖机制:一个完整的例子
以按键触发 EXTI 中断为例,整个回调链是:
cppEXTI0_IRQHandler(中断向量) └─ HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0) (HAL 库实现) ├─ 检查挂起标志 __HAL_GPIO_EXTI_GET_IT ├─ 清除挂起标志 __HAL_GPIO_EXTI_CLEAR_IT └─ 调用回调 HAL_GPIO_EXTI_Callback (弱定义 → 被你覆盖)用户代码里只需要写:
cpp/* main.c:用户强定义,同名同签名即可覆盖库中的弱定义 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); /* 按键翻转 LED */ } }库文件一个字都不用改,这就是弱引用的价值。
1.5 startup 启动文件里的弱引用
除了回调函数,启动文件
startup_stm32f1xx.s里所有异常向量(Reset、HardFault、SysTick 等)也普遍用.weak声明:
cpp.weak HardFault_Handler ; 弱符号:用户可覆盖 .word HardFault_Handler所以当用户定义了
HardFault_Handler函数,链接器就用用户的;没定义就用 startup 里的默认死循环(
B .指令)。这和你覆盖回调是同一套机制。
1.6 使用注意事项(血泪坑)
- 签名必须完全一致(函数名、参数类型、返回类型)****。签名不同会被编译器当成两个不同的函数------库的弱定义还在,你的强定义也还在,回调永远不会进你的函数,而且不报任何错,这是最隐蔽的坑。
- 一个工程只能有一个强定义**。如果有两个
.c文件都定义了HAL_GPIO_EXTI_Callback,链接报"多重定义"错误。**- 不要改库文件里的弱函数**。覆盖逻辑写在自己代码里,升级库、移植工程都不受影响。**
- 头文件里的 extern 声明**:如果回调定义在一个
.c文件、在另一个.c文件被调用,需要正确的头文件声明,否则编译警告/行为异常。**- 不要加 static**。
static会把符号变成局部符号,链接器根本无法用强符号去覆盖弱符号,覆盖直接失效。**
1.7 __weak 与宏定义回调的对比
方式 生效时机 机制 灵活性 宏定义回调 编译期 文本替换 只能替换整个函数体,写死在编译期 __weak 弱引用 链接期 符号覆盖 可运行时按需选择实现,可多文件协作
2. 中断的标志位处理
2.1 一次完整的中断流程
bashflowchart LR A[外设事件产生] --> B[硬件将标志位置1] B --> C[NVIC 挂起 + 优先级仲裁] C --> D[跳转进入中断服务函数] D --> E[执行业务处理] E --> F[清除标志位] F --> G[退出中断<br>返回主循环]
2.2 标志位不清除的后果
中断服务函数执行完后,硬件会检查标志位**:**
如果标志位还是 1,会立即再次触发中断。
不清标志的典型现象:
- 程序"卡死"在中断里反复进出(看起来像死循环);
- 主循环几乎得不到执行时间(LED 不闪、任务不跑);
- 严重时栈溢出、看门狗复位。
2.3 EXTI 的寄存器级拆解
EXTI 外设有一组寄存器,理解标志位处理必须认识它们(以 STM32F1 为例):
寄存器 作用 关键点 IMR 中断屏蔽 对应位写 1 才允许产生中断 EMR 事件屏蔽 事件模式使能 RTSR 上升沿触发选择 对应位写 1 表示上升沿触发 FTSR 下降沿触发选择 对应位写 1 表示下降沿触发 SWIER 软件中断事件 软件写 1 模拟触发 PR 挂起标志位 写 1 清除,读 1 表示有挂起 HAL 库对 PR 的操作:
cpp/* 查询挂起:读 PR 且与 IMR 相与 */ #define __HAL_GPIO_EXTI_GET_IT(__EXTI_LINE__) (EXTI->PR & (__EXTI_LINE__)) /* 清除挂起:写 1 清除(W1C) */ #define __HAL_GPIO_EXTI_CLEAR_IT(__EXTI_LINE__) (EXTI->PR = (__EXTI_LINE__))EXTI 的中断处理函数:
cppvoid HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != RESET) /* 有挂起 */ { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin); /* 写1清挂起(先清) */ HAL_GPIO_EXTI_Callback(GPIO_Pin); /* 再执行回调(后处理) */ } }注意顺序:
先清标志位,再执行回调。
因为回调里若有耗时操作,这段时间内新事件来了还能重新置位,不会丢事件。
2.4 硬件清除方式的三种"姿势"
不同外设的标志位清除机制完全不同,这是嵌入式最容易翻车的点:
清除类型 含义 典型外设 W1C(写 1 清除) 向该位写 1 才清除 EXTI 的 PR、USART 的 ORE 读自动清除 读数据寄存器后硬件自动清 USART 的 RXNE(读 DR) W0C(写 0 清除) 向该位写 0 才清除 USART/TIM 的部分状态位 具体速查:
外设 标志 清除方式 EXTI PR 挂起位 写 1 清除 USART RXNE(接收非空) 读数据寄存器 DR 自动清除 USART TC(发送完成) 写 0 清除(读 SR 后再写 DR 亦可) TIM UIF(更新中断) 读 SR 后写 0 清除 **结论:**清标志的"姿势"千奇百怪,动手前先查参考手册,别想当然。
2.5 轮询模式:只查不中断
不用中断时,可以在主循环里"查标志位":
cppwhile (1) { if (__HAL_GPIO_EXTI_GET_FLAG(GPIO_PIN_0) != RESET) /* 查询,不清除 */ { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); /* 处理完手动清除 */ /* 业务逻辑 */ } }查询(GET_FLAG)和清除(CLEAR_IT)是两回事:
前者只读标志位,后者才真正改写寄存器。
2.6 轮询 vs 中断对比
维度 轮询 中断 CPU 占用 一直查,占用高 无事件时零开销 实时性 依赖主循环周期 事件立刻响应 标志位处理 自己查、自己清 硬件触发,ISR 里清 适用场景 低频、简单事件 高频、强实时
2.7 清标志的最佳时机总结
- 先清后处理:适合处理快的场景(EXTI 标准写法);
- 处理完再清:适合轮询场景;
- 原则:标志位必须在退出 ISR 前清除,且不能误清新事件(读一次、处理一次、清一次)。
3. 异常处理:Cortex-M 内核级"中断"
3.1 异常和中断的区别
- 异常:内核产生的,编号 1~15,如复位、HardFault、SysTick;
- 中断:外设产生的,编号 16 起,如 EXTI、USART、TIM;
- 两者统一由 NVIC 管理优先级。
3.2 异常类型速查表
编号 异常 含义 优先级(可配?) 1 Reset 复位 -3(固定最高) 2 NMI 不可屏蔽中断 -2 3 HardFault 硬故障 -1 4 MemManage 存储器管理故障(MPU) 可配 5 BusFault 总线故障(非法地址访问) 可配 6 UsageFault 用法故障(未对齐、非法指令) 可配 11 SVCall SVC 指令触发 可配 14 PendSV 可挂起系统服务调用(RTOS 用) 可配 15 SysTick 系统节拍 可配 注意:
Reset / NMI / HardFault 的优先级是负数,不可配置,永远比任何可配置中断(0~15)更紧急。
这也解释了为什么故障发生时,即使中断全开,程序也会跳进 HardFault_Handler。
3.3 优先级分组(NVIC 优先级到底怎么算)
STM32F1 的 NVIC 有 4 位优先级(0~15,共 16 级,数值越小越紧急)。
HAL 库默认把优先级分组设为 Group4:4 位全部是抢占优先级,没有子优先级。
分组 抢占优先级位数 子优先级位数 说明 Group0 0 4 无抢占,仅子优先级 Group2 2 2 常见组合 Group4 4 0 HAL 默认 抢占优先级决定能否打断别人:
数值小的可以抢占数值大的;
同级不能互相抢占,必须等对方退出中断。
3.4 故障升级规则
MemManage / BusFault / UsageFault 在未使能或处理过程中再次出错时,会统一升级为 HardFault。所以程序跑飞最常见的落点就是
HardFault_Handler------你看到的不一定是原始故障,而是被升级后的 HardFault。
3.5 HardFault 常见诱因
- 空指针 / 野指针访问(最常见);
- 数组越界写;
- 未对齐访问(如强制类型转换);
- 栈溢出(压栈失败 → BusFault);
- 除零(需使能 DIV_0_TRP 才触发 UsageFault,否则静默返回 0)。
3.6 故障寄存器逐个拆解
寄存器 地址 含义 CFSR 0xE000ED28 可配置故障状态寄存器(含 MMFSR/BFSR/UFSR 三个字节) HFSR 0xE000ED2C 硬故障状态寄存器 MMFAR 0xE000ED34 存储器管理故障地址 BFAR 0xE000ED38 总线故障地址 HFSR 关键位:
- bit30 FORCED:有故障被强制升级为 HardFault(最常见,说明下面还有 CFSR 里的原因);
- bit1 VECTTBL:向量表读取错误。
CFSR = MMFSR + BFSR + UFSR(低字节 MMFSR、中字节 BFSR、高字节 UFSR):
- MMFSR:bit0 IACCVIOL(指令访问违规)、bit1 DACCVIOL(数据访问违规)、bit7 MMARVALID(MMFAR 有效);
- BFSR:bit0 IBUSERR(指令总线错误)、bit1 PRECISERR(精确数据总线错误)、bit2 IMPRECISERR(非精确)、bit7 BFARVALID(BFAR 有效);
- UFSR:bit0 UNDEFINSTR(未定义指令)、bit1 INVSTATE(无效状态)、bit2 INVPC(无效 PC)、bit8 UNALIGNED(未对齐)、bit9 DIVBYZERO(除零)。
3.7 异常压栈:硬件自动做了什么事
进入异常时,硬件自动压栈(无 FPU 的 M3/M4):
[低地址] R0 R1 R2 R3 R12 LR(旧) PC(出事指令) xPSR [高地址] ↑ 重点:PC 就是出错前的指令地址共 8 个字(32 字节)。
带 FPU 的 M4 若使用了浮点,还会再压 26 个字(S0~S15、FPSCR 等)。
怎么知道用的是 MSP 还是 PSP?
看进入异常时 LR 里的 EXC_RETURN 值:
EXC_RETURN 含义 用哪个栈 0xFFFFFFF1 返回 Handler 模式(嵌套) MSP 0xFFFFFFF9 返回 Thread 模式 MSP 0xFFFFFFFD 返回 Thread 模式 PSP 实用判断规则:
LR 的 bit2 = 0 → MSP;
bit2 = 1 → PSP(对照上表:F1/F9 的 bit2 是 0,FD 的 bit2 是 1,全部吻合)。
3.8 定位 HardFault 的三板斧(实操)
- 断点停在 HardFault_Handler,在调试器里看调用栈(Call Stack),回溯到触发点;
- 读故障寄存器:
SCB->HFSR、SCB->CFSR、SCB->BFAR、SCB->MMFAR,判断是哪一类故障、出错的地址是多少;- 分析异常栈帧:根据 LR 的 bit2 确定 MSP/PSP,从对应栈指针处读出压栈的 PC------这个 PC 就是出事前的指令地址,反汇编过去就能看到是哪行代码。
也可以在 HardFault_Handler 里主动打印:
cppvoid HardFault_Handler(void) { volatile uint32_t hfsr = SCB->HFSR; volatile uint32_t cfsr = SCB->CFSR; volatile uint32_t bfar = SCB->BFAR; volatile uint32_t mmfar = SCB->MMFAR; if (hfsr & (1UL << 30)) printf("FORCED: 故障被升级为 HardFault\n"); if (cfsr & (1UL << 9)) printf("DIVBYZERO: 除零\n"); if (cfsr & (1UL << 8)) printf("UNALIGNED: 未对齐访问\n"); if (bfar) printf("BFAR = 0x%08lX\n", bfar); /* 出错地址 */ while (1); }3.9 预防措施
- 指针用前判空;
- 数组写边界检查;
- 栈空间给足(尤其 RTOS 任务栈,可以用
uC/OS、FreeRTOS 的栈高水位检查);- 使能 UsageFault 便于提前暴露问题(
SCB->SHCSR |= (1<<18))。
4. HAL_Delay 原理与优先级测试
4.1 整条依赖链
HAL_Delay依赖三件事:SysTick 定时器 → SysTick 中断 → 全局变量 uwTick。
cppSysTick 定时器(硬件) └─ 每 1ms 触发 SysTick 中断 └─ SysTick_Handler → HAL_IncTick() └─ uwTick++(全局变量,毫秒计数) └─ HAL_Delay 读取 uwTick 差值忙等
4.2 初始化源码拆解
cpp/* HAL_InitTick 的核心逻辑 */ HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority) { /* ① 配置 SysTick:每 1ms 一次中断 */ SysTick_Config(HAL_RCC_GetHCLKFreq() / 1000); /* 72MHz / 1000 = 72000 */ /* ② 设置 SysTick 中断优先级(默认最低 0x0F) */ HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0); uwTickFreq = uwTickFreqDefault; /* 默认 1kHz */ return HAL_OK; }
SysTick_Config内部实际是配置三个寄存器:
寄存器 作用 本例值 LOAD 重装载值(倒计时) 71999(72MHz 下 1ms) VAL 当前值 0(写 0 清计数) CTRL 控制位(使能、时钟源、中断) 使能 + 内核时钟 + 中断使能
4.3 SysTick 中断与 uwTick 源码
cpp/* SysTick 中断服务函数:每 1ms 进一次 */ void SysTick_Handler(void) { HAL_IncTick(); } /* HAL 库内部 */ static volatile uint32_t uwTick; /* 毫秒计数器 */ uint32_t uwTickFreq = 1; /* 频率倍率,默认 1 */ void HAL_IncTick(void) { uwTick += uwTickFreq; } uint32_t HAL_GetTick(void) { return uwTick; }新版 HAL 还支持改节拍频率(
HAL_TICK_FREQ_10HZ等),把uwTickFreq改成对应值即可,原理不变。
4.4 HAL_Delay 源码拆解
cpp__weak void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); /* ① 记录起始时刻 */ uint32_t wait = Delay; if (wait < HAL_MAX_DELAY) /* ② 按节拍倍率修正 */ { wait += (uint32_t)(uwTickFreq); } while ((HAL_GetTick() - tickstart) < wait) /* ③ 差值忙等 */ { } }
4.5 为什么用差值而不是相等判断
uwTick是 32 位变量,最大 0xFFFFFFFF,理论上会回绕(溢出归 0)。若写成:
while (HAL_GetTick() == tickstart + Delay); /* ❌ 危险 */
- 延时期间 tick 可能被跳过(高优先级中断长时间占用 CPU,SysTick 被延迟执行),
==永远不成立 → 死循环;- 溢出时
tickstart + Delay本身可能已经溢出,==直接失效。而
(HAL_GetTick() - tickstart)用无符号减法差值,回绕时依然正确:
/* 例:tickstart = 0xFFFFFFF0,延时 100ms,tick 回绕到 0x00000010 */ (0x00000010 - 0xFFFFFFF0) = 0x20 = 32 → 数学上等价于 (2^32 + 16 - 4294967280) = 32这是嵌入式里非常经典的无符号时间差技巧,同样适用于超时判断、按键消抖、非阻塞调度。
4.6 HAL_Delay 是阻塞式的
HAL_Delay里是一个空转 while 循环,CPU 一直在原地转圈:
- 优点:实现简单、精度稳定(1ms 粒度);
- 缺点:期间 CPU 不干别的活,只有更高优先级的中断能插进来。
4.7 优先级测试:中断里调用 HAL_Delay 为什么卡死(课程核心实验)
默认配置下 SysTick 中断优先级是 0x0F(数值最大 = 优先级最低)。
Cortex-M 规则:低优先级(数值大)的中断不能抢占高优先级(数值小)的中断,同级也不能互相抢占。
在一个普通外设中断里调用
HAL_Delay(100)的执行链:
cpp当前在 EXTI 中断里(抢占优先级数值 < 15,更紧急) ├─ 调用 HAL_Delay(100) ├─ while 等待 uwTick 增长 ├─ 但 SysTick 优先级 15 最低,抢不进当前 ISR ├─ uwTick 永远不会 +1 └─ while 条件永远成立 → 卡死(死锁)实验设计(三组对照):
实验 SysTick 优先级 在 ISR 里调 HAL_Delay 结果 ① main 里延时 0x0F 否 正常 ② ISR 里延时 0x0F(默认) 是 卡死(LED 停住、主循环不跑) ③ ISR 里延时 0x00(改成最高) 是 恢复正常 实验③代码:
cpp/* stm32f1xx_hal_conf.h 中修改 */ #define TICK_INT_PRIORITY ((uint32_t)0x00) /* 默认 0x0F,改成 0x00 测试 */ /* 中断里延时 */ void EXTI0_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0); /* 内部会调回调 */ } void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { HAL_Delay(500); /* ① 默认优先级:卡死;② 改成 0x00:正常翻转 */ HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); }两次对比,优先级规则彻底理解:
优先级数值越小越紧急,SysTick 必须是"能被别人打断"的那个,延时才能推进。
为什么 HAL 默认把 SysTick 设为最低优先级?
设计哲学:SysTick 只是"计时员",不应阻塞任何外设中断。
把计时员放在最低优先级,任何中断都能打断它,
代价是"中断里不能依赖 HAL_Delay"。
4.8 中断里的延时正确姿势
- 尽量不在 ISR 里延时(ISR 越短越好,这是嵌入式铁律);
- 一定要延时,用非阻塞超时写法:
cppuint32_t t0 = HAL_GetTick(); while ((HAL_GetTick() - t0) < 500) { /* 空转期间可做点别的事,或干脆让出 CPU */ }
- 更规范的做法:
- 把延时逻辑搬到主循环(ISR 只置标志位,主循环检测);
- 用定时器 + 标志位(TIM 中断里翻转标志,主循环处理);
- RTOS 的
vTaskDelay(让出 CPU,由调度器管理)。
4.9 非阻塞延时完整示例(主循环版)
cppvolatile uint8_t flag_1s = 0; /* ISR 置位,主循环清零 */ void SysTick_Handler(void) { HAL_IncTick(); /* uwTick++(必须保留) */ } void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { flag_1s = 1; /* ISR 只置标志,立刻退出 */ } int main(void) { HAL_Init(); /* ...初始化 GPIO、EXTI... */ while (1) { if (flag_1s) { flag_1s = 0; /* 主循环里才做耗时的事 */ HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } } }
5. 总结:一张表串起四个知识点
课程 核心问题 一句话结论 __weak 回调函数怎么写? HAL 用弱引用预置空实现, 用户强定义同名函数即可覆盖, 签名必须一致 标志位 中断为什么反复进? 处理完必须按手册姿势清标志(W1C/读自动清/W0C), 先清后处理防丢事件 异常 程序跑飞怎么办? 低级故障会升级成 HardFault, 用栈帧(LR bit2 判栈)+ 故障寄存器定位 PC HAL_Delay 中断里延时为何卡死? SysTick 优先级最低导致 uwTick 不涨, 差值忙等死锁;避免在 ISR 延时
面试题
一、__weak 弱引用(4题)
Q1:
__weak是什么?HAL 库为什么大量使用它?答:
__weak是编译器关键字(ARMCC/GCC 均支持),把函数声明为弱符号。弱符号与强符号同名时,链接器优先选强符号,弱符号被覆盖且不报错。
HAL 库用它把所有回调函数**(
HAL_GPIO_EXTI_Callback、HAL_UART_RxCpltCallback等)****预置为空实现------**用户不写能编译,写了自动覆盖,无需改库文件。
Q2:强符号和弱符号同名,链接器怎么处理?如果没有定义却被引用呢?
答:同名时强符号生效,弱符号丢弃,不报错;
没有强定义时用弱定义(空函数);
只有弱定义被引用也能链接通过。
但若出现两个强定义,链接报"多重定义"错误。
Q3:覆盖 HAL 回调时最常见的坑是什么?
答:函数签名不一致(参数、返回类型、函数名)。
签名不同会被编译器当成两个不同函数,库的弱定义还在、你的强定义也在,回调永远不生效,而且不报任何错,最难排查。
另外注意不要加
static(变成局部符号无法覆盖)、一个工程只能有一个强定义。
Q4:
__weak和宏定义回调有什么区别?答:宏在编译期做文本替换,写死、无法多文件协作;
__weak在链接期做符号覆盖,灵活、可多文件协作。宏替换的是代码,弱引用替换的是符号。
二、中断标志位处理(5题)
Q1:中断标志位不清除会怎样?为什么?
答:标志位保持置位,硬件会再次触发中断,造成反复进出 ISR 的死循环现象,主循环得不到执行,严重时栈溢出/看门狗复位。
Q2:EXTI 的标志位如何清除?原理是什么?
答:写挂起寄存器
EXTI->PR,写 1 清除(W1C 类型)。HAL 里是
__HAL_GPIO_EXTI_CLEAR_IT,底层就是EXTI->PR = (EXTI_PIN_x)。
Q3:不同外设的标志清除方式有哪些类型?举例说明。
答:三种:
- 写 1 清除(W1C):EXTI 的 PR;
- 读自动清除:USART 的 RXNE,读数据寄存器 DR 后自动清;
- 写 0 清除(W0C):USART 的 TC、TIM 的 UIF(读 SR 后写 0)。
面试加分:强调"动手前查参考手册",不能想当然。
Q4:为什么 HAL 的 EXTI 中断处理是"先清标志,再执行回调"?
答:先清标志后,回调执行期间新事件来了还能重新置位,不会丢事件;
如果反过来先处理再清,处理期间的边沿可能被清标志动作误清。
Q5:查询标志位(GET_FLAG)和清除标志位(CLEAR_IT)有什么区别?
答:查询是只读标志寄存器判断事件是否发生;
清除是改写寄存器消除挂起状态。
两者独立,轮询模式下"查完必须清",否则下一次查询永远为真。
三、异常处理(6题)
Q1:Cortex-M 中"异常"和"中断"有什么区别?
答:异常是内核级,编号 1~15(Reset、NMI、HardFault、SysTick 等);
中断是外设级,编号 16 起(EXTI、USART、TIM 等)。
统一由 NVIC 管理优先级。
Q2:Reset、NMI、HardFault 的优先级是多少?为什么?
答:分别是 -3、-2、-1,负数优先级,不可配置,永远高于任何可配置中断(0~15)。
所以故障发生时,即使中断全开也会跳进 HardFault_Handler。
Q3:HardFault 常见诱因有哪些?
答:空指针/野指针访问(最常见)、数组越界写、未对齐访问、栈溢出(压栈失败)、除零(需使能 DIV_0_TRP 才触发 UsageFault,否则静默返回 0)。
Q4:什么是"故障升级"规则?
**答:MemManage / BusFault / UsageFault 在未使能或处理中再次出错时,**统一升级为 HardFault。所以看到 HardFault 不代表原始故障就是它。
Q5:如何定位 HardFault?
答:三板斧:
- 断点停在
HardFault_Handler,看调用栈回溯;- 读故障寄存器
SCB->HFSR(bit30 FORCED)、SCB->CFSR(含 MMFSR/BFSR/UFSR)、SCB->BFAR/SCB->MMFAR;- 分析异常栈帧:硬件自动压栈 8 个字(xPSR、PC、LR、R12、R3~R0),PC 就是出事前的指令地址。
Q6:如何判断异常用的是 MSP 还是 PSP?
答:看进入异常时 LR 里的 EXC_RETURN 值:
0xFFFFFFF1返回 Handler 模式用 MSP;
0xFFFFFFF9返回 Thread 模式用 MSP;
0xFFFFFFFD返回 Thread 模式用 PSP。实用规则:LR 的 bit2 = 0 → MSP,= 1 → PSP。
四、HAL_Delay 原理与优先级(5题)
Q1:HAL_Delay 的实现原理是什么?
答:依赖链路:
SysTick 定时器每 1ms 触发 SysTick 中断 →
SysTick_Handler调HAL_IncTick()使全局变量uwTick++→
HAL_Delay记录起始 tick,while ((HAL_GetTick() - tickstart) < Delay)差值忙等。
Q2:为什么用差值
(tick - tickstart)而不是相等判断?答:两个原因:
①延时期间 tick可能被高优先级中断挤占导致跳数,
==永远不成立 → 死循环;②
uwTick是 32 位,回绕溢出时==直接失效。无符号减法差值在回绕时依然数学正确(如
0x10 - 0xFFFFFFF0 = 0x20)。
Q3:为什么在中断服务函数里调用 HAL_Delay 会卡死?
答:HAL 默认把 SysTick 中断优先级设为
0x0F(最低)。**在更紧急的外设中断里,**SysTick 无法抢占当前 ISR →
uwTick不增长 → 忙等条件永远成立 → 死锁。同级也不能互相抢占,所以默认配置下在任意 ISR 里调 HAL_Delay 基本都会卡死。
Q4:中断里需要延时怎么办?(开放性)
答:① 尽量不在 ISR 延时,ISR 越短越好;
② 非阻塞超时:****
t0 = HAL_GetTick();
while ((HAL_GetTick() - t0) < 500) { /* 做别的事 */ };③ ISR 只置标志位,主循环检测处理;
④ 用定时器+标志位;
⑤ RTOS 的
vTaskDelay让出 CPU。
Q5:为什么 HAL 把 SysTick 优先级默认设为最低?
答:设计哲学------SysTick 只是"计时员",不应阻塞任何外设中断。
代价就是"中断里不能依赖 HAL_Delay",这也是面试官想听你说出来的权衡。
综合开放题(高频追问)
Q:写一个按键消抖 + 非阻塞延时的方案?
答要点:
① ISR 里
HAL_GPIO_EXTI_Callback只置标志位 + 记录HAL_GetTick()时间戳;② 主循环检测标志,用
(HAL_GetTick() - t0) > 20ms差值判断做消抖,配合读取 GPIO 电平确认;③ 全程不使用阻塞式
HAL_Delay。考察点:差值技巧 + ISR 极简化 + 状态机思想。

