【STM32】 __weak 弱引用、中断标志位、异常处理与 HAL_Delay 优先级


__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 中断为例,整个回调链是:

cpp 复制代码
EXTI0_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 使用注意事项(血泪坑)

  1. 签名必须完全一致(函数名、参数类型、返回类型)****。签名不同会被编译器当成两个不同的函数------库的弱定义还在,你的强定义也还在,回调永远不会进你的函数,而且不报任何错,这是最隐蔽的坑。
  2. 一个工程只能有一个强定义**。如果有两个 .c 文件都定义了 HAL_GPIO_EXTI_Callback,链接报"多重定义"错误。**
  3. 不要改库文件里的弱函数**。覆盖逻辑写在自己代码里,升级库、移植工程都不受影响。**
  4. 头文件里的 extern 声明**:如果回调定义在一个 .c 文件、在另一个 .c 文件被调用,需要正确的头文件声明,否则编译警告/行为异常。**
  5. 不要加 static**。static 会把符号变成局部符号,链接器根本无法用强符号去覆盖弱符号,覆盖直接失效。**

1.7 __weak 与宏定义回调的对比

方式 生效时机 机制 灵活性
宏定义回调 编译期 文本替换 只能替换整个函数体,写死在编译期
__weak 弱引用 链接期 符号覆盖 可运行时按需选择实现,可多文件协作

2. 中断的标志位处理

2.1 一次完整的中断流程

bash 复制代码
flowchart 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 的中断处理函数:

cpp 复制代码
void 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 轮询模式:只查不中断

不用中断时,可以在主循环里"查标志位":

cpp 复制代码
while (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 的三板斧(实操)

  1. 断点停在 HardFault_Handler,在调试器里看调用栈(Call Stack),回溯到触发点;
  2. 读故障寄存器:SCB->HFSR、SCB->CFSR、SCB->BFAR、SCB->MMFAR,判断是哪一类故障、出错的地址是多少;
  3. 分析异常栈帧:根据 LR 的 bit2 确定 MSP/PSP,从对应栈指针处读出压栈的 PC------这个 PC 就是出事前的指令地址,反汇编过去就能看到是哪行代码。

也可以在 HardFault_Handler 里主动打印:

cpp 复制代码
void 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。

cpp 复制代码
SysTick 定时器(硬件)
   └─ 每 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 中断里的延时正确姿势

  1. 尽量不在 ISR 里延时(ISR 越短越好,这是嵌入式铁律);
  2. 一定要延时,用非阻塞超时写法:
cpp 复制代码
uint32_t t0 = HAL_GetTick();
while ((HAL_GetTick() - t0) < 500)
{
  /* 空转期间可做点别的事,或干脆让出 CPU */
}
  1. 更规范的做法:
    • 把延时逻辑搬到主循环(ISR 只置标志位,主循环检测);
    • 用定时器 + 标志位(TIM 中断里翻转标志,主循环处理);
    • RTOS 的 vTaskDelay(让出 CPU,由调度器管理)。

4.9 非阻塞延时完整示例(主循环版)

cpp 复制代码
volatile 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?

答:三板斧:

  1. 断点停在 HardFault_Handler,看调用栈回溯;
  2. 读故障寄存器 SCB->HFSR(bit30 FORCED)、SCB->CFSR(含 MMFSR/BFSR/UFSR)、SCB->BFAR/SCB->MMFAR;
  3. 分析异常栈帧:硬件自动压栈 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 极简化 + 状态机思想。


相关推荐
驭渊的小故事1 小时前
SpringBoot 配置文件详解:properties 与 yml 从入门到实战
java·开发语言·笔记
郝学胜-神的一滴1 小时前
游戏引擎原理与实践 02:揭开3A游戏背后的技术面纱
开发语言·计算机网络·程序人生·游戏·游戏引擎
m0_380743871 小时前
Qt实现调用相机进行拍照并进行图像处理
开发语言·c++
wangqiaowq1 小时前
AST 安全求值
java·开发语言
我们从未走散2 小时前
AI 生图平台如何避免资损:用“扣款与凭据同事务”构建三态账本
开发语言·数据库·php
weixin_440730502 小时前
字符编码笔记-utf-8 、unicode、gbk,ascii编码格式
开发语言·笔记
离凌寒2 小时前
一、关于正点原子stm32mp157上的hdmi显示效果补充演示
stm32·单片机·嵌入式硬件
励志不掉头发的内向程序员3 小时前
【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View
开发语言·c++·qt·学习·系统架构
PHP实战开发录3 小时前
PHP接口大整数为什么变了
开发语言·php