Chapter 04 · ARM 嵌入式 C 开发

中断系统

深入理解 Cortex-M NVIC 架构与中断优先级机制,正确编写 ISR,实现安全的临界区保护。

课程进度40%

关键概念

中断(Interrupt)
当硬件事件(按键按下、定时器溢出、串口收到数据)发生时,CPU 暂停当前代码,跳转到预设的处理函数(ISR),处理完后恢复现场继续原来的代码。中断使 MCU 可以"及时响应"外部事件,而不必轮询等待。
NVIC(嵌套向量中断控制器)
Nested Vectored Interrupt Controller,Cortex-M 内核内置的中断管理模块。负责中断使能/禁止、优先级配置、中断挂起/清除,以及最关键的"嵌套"——高优先级中断可以打断正在执行的低优先级 ISR。STM32F4 支持最多 240 个外部中断。
抢占优先级 vs 子优先级
STM32 将 4 位优先级分成两部分(由 PRIGROUP 决定):抢占优先级(PreemptPriority)决定是否可以打断其他 ISR;子优先级(SubPriority)在抢占优先级相同时决定先处理哪个。数值越小,优先级越高。通常用 4 位全作抢占(PRIGROUP_4 = 16级抢占,0级子)。
EXTI(外部中断/事件控制器)
External Interrupt/Event Controller,将 GPIO 引脚的电平变化映射为中断请求。PA0-PK0 复用 EXTI0,PA1-PK1 复用 EXTI1,依此类推——同一编号的不同端口只能选一个(例如同时用 PA1 和 PB1 的 EXTI1 中断是不可能的)。通过 SYSCFG_EXTICR 寄存器选择端口。
中断向量表(Vector Table)
存储在 Flash 起始地址(0x0800_0000)的一组函数指针,每 4 字节一个条目,对应一个中断处理函数的地址。当中断触发时,CPU 硬件自动从向量表查找对应 ISR 的地址并跳转。启动文件(startup_stm32f407xx.s)中定义了默认弱链接的所有 ISR 名称。
临界区(Critical Section)
不能被中断打断的代码段。若主循环和 ISR 共享一个变量,中间被中断可能导致数据不一致(竞态条件)。进入临界区前关中断(__disable_irq()),退出后开中断(__enable_irq())。另一种方法是使用原子操作或 FreeRTOS 的互斥量。
ISR 编写规范
ISR 必须尽量短小快速,只设置标志位或存入缓冲区,主逻辑放在主循环处理。ISR 中禁止用 HAL_Delay()(依赖 SysTick 中断),禁止动态内存分配(malloc),禁止调用可能阻塞的函数。共享变量必须声明为 volatile,防止编译器优化掉对它的读取。
volatile 关键字
告诉编译器:该变量可能在编译器"看不见"的地方被修改(如 ISR、硬件寄存器、DMA)。编译器不能把它缓存在寄存器里,每次使用都必须从内存重新读取。ISR 与主循环之间共享的变量必须声明为 volatile,否则编译优化可能导致主循环永远读到旧值。

Cortex-M 中断处理流程

中断响应硬件流程(约 12 个时钟周期) ═══════════════════════════════════════════════════════ 主程序正常执行 │ │ 硬件事件(GPIO边沿/定时器溢出/UART接收等) ▼ ┌────────────────────────────────┐ │ NVIC 检查中断是否使能 + 优先级 │ │ 若满足,发出中断请求给 CPU Core │ └────────────────────────────────┘ │ ▼ CPU 自动压栈(硬件完成,约8个寄存器) ┌──────────────────────────────────────────┐ │ 入栈顺序(从高地址向低地址): │ │ xPSR → PC → LR → R12 → R3 → R2 → R1 → R0 │ │ ↑ 这是为了让 ISR 可以用 C 编写,不破坏调用约定 │ └──────────────────────────────────────────┘ │ ▼ 从向量表读取 ISR 地址 → 跳转执行 ISR │ ▼ ┌─────────────────────────────────┐ │ ISR 执行 │ │ - 清除中断标志(必须!) │ │ - 设置标志变量 / 存数据到缓冲区 │ │ - 尽快返回 │ └─────────────────────────────────┘ │ ▼ CPU 自动出栈,恢复现场,继续主程序 中断嵌套:若 ISR 执行期间,更高优先级中断到来 → 再次压栈 → 执行更高优先级 ISR → 出栈 → 返回低优先级 ISR

NVIC 优先级配置

优先级位分配(STM32 使用4位优先级,最高16级) PRIGROUP = 3 时(默认,HAL 初始化设置): ┌──────┬──────┐ │ bit3 │ bit2 │ bit1 │ bit0 │ └──────┴──────┘ ├─────────────┤├─────────────┤ 抢占优先级 子优先级 (0-15, 4bit) (无,0位) 例:EXTI0 抢占=1,TIM2 抢占=2,UART1 抢占=3 → EXTI0 可打断 TIM2 ISR → TIM2 可打断 UART1 ISR → UART1 不能打断任何人 特殊优先级(不可配置): Reset : -3(最高) NMI : -2 HardFault: -1 SysTick: 可配置,HAL 设为最低(0xFF) 原则:优先级数值越小,级别越高,可抢占数值更大的 ISR
/* NVIC 优先级配置示例 */

/* 第一步:设置优先级分组(整个程序只做一次,HAL_Init 已默认设置)*/
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
/* NVIC_PRIORITYGROUP_4: 4位全给抢占, 0位子优先级 */
/* NVIC_PRIORITYGROUP_2: 2位抢占(0-3), 2位子(0-3) */

/* 第二步:配置各中断的优先级 */
HAL_NVIC_SetPriority(EXTI0_IRQn,    1, 0);  /* 最高优先级 */
HAL_NVIC_SetPriority(TIM2_IRQn,     2, 0);
HAL_NVIC_SetPriority(USART1_IRQn,   3, 0);
HAL_NVIC_SetPriority(DMA1_Stream0_IRQn, 2, 0);

/* 第三步:使能中断 */
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
HAL_NVIC_EnableIRQ(TIM2_IRQn);
HAL_NVIC_EnableIRQ(USART1_IRQn);

EXTI 外部中断配置(按键触发)

/**
 * @brief 配置 PA0 为外部中断(上升沿触发)
 *        STM32F407-Discovery 用户按钮:按下 = PA0 高电平
 */
void EXTI0_Config(void) {
  GPIO_InitTypeDef GPIO_InitStruct = {0};

  __HAL_RCC_GPIOA_CLK_ENABLE();

  GPIO_InitStruct.Pin  = GPIO_PIN_0;
  GPIO_InitStruct.Mode = GPIO_MODE_IT_RISING;  /* 上升沿触发中断 */
  /* GPIO_MODE_IT_FALLING  : 下降沿 */
  /* GPIO_MODE_IT_RISING_FALLING : 双边沿 */
  GPIO_InitStruct.Pull = GPIO_NOPULL;
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
  HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

/* ── 中断服务程序 ──────────────────────────────────────── */
/* 函数名必须与启动文件(startup_stm32f407xx.s)中的弱定义完全一致 */

volatile uint8_t btn_pressed = 0;  /* 必须声明 volatile! */

void EXTI0_IRQHandler(void) {
  /* HAL 框架:先调 HAL 处理(清标志),再调用户回调 */
  HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

/* HAL 回调函数(弱函数,用户重写)*/
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
  if (GPIO_Pin == GPIO_PIN_0) {
    btn_pressed = 1;  /* 仅设置标志,主循环处理 */
    HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_12);  /* LED 切换 */
  }
}

/* 主循环中检测并处理 */
void main_loop(void) {
  while (1) {
    if (btn_pressed) {
      btn_pressed = 0;
      /* 执行按键相关的耗时操作 */
    }
  }
}

临界区保护

/* ── 方法1:关/开全局中断(最简单,影响所有中断)── */
#define ENTER_CRITICAL()  __disable_irq()
#define EXIT_CRITICAL()   __enable_irq()

/* 示例:保护 64 位变量(读-改-写必须原子)*/
volatile uint64_t timestamp = 0;

void Update_Timestamp(uint64_t new_val) {
  ENTER_CRITICAL();
  timestamp = new_val;  /* 64位赋值不是原子操作! */
  EXIT_CRITICAL();
}

uint64_t Read_Timestamp(void) {
  uint64_t val;
  ENTER_CRITICAL();
  val = timestamp;
  EXIT_CRITICAL();
  return val;
}

/* ── 方法2:保存/恢复中断状态(嵌套安全)── */
uint32_t Enter_Critical_Save(void) {
  uint32_t primask = __get_PRIMASK();
  __disable_irq();
  return primask;
}

void Exit_Critical_Restore(uint32_t primask) {
  if (!primask) __enable_irq();  /* 仅在之前是开中断状态才恢复 */
}

/* ── 方法3:FreeRTOS 中使用 taskENTER_CRITICAL() ── */
/* 见第9章,FreeRTOS 提供任务级临界区,不影响中断优先级 >= configMAX_SYSCALL_INTERRUPT_PRIORITY */
ISR 中最常见的 Bug

① 忘记清中断标志:ISR 执行完,中断标志仍然置位,CPU 立刻再次进入 ISR,无限循环。
② 共享变量未 volatile:编译器优化,主循环永远读不到 ISR 写的新值。
③ ISR 中调用 HAL_Delay():SysTick 被屏蔽或优先级不够时,系统死锁。
④ 栈溢出:ISR 嵌套过深,或 ISR 中声明大型局部数组,超出 Cortex-M 的中断栈空间(默认 1KB)。

EXTI 引脚复用规则

EXTI0 只能来自 PA0、PB0、PC0 ... 中的一个,通过 SYSCFG→EXTICR 选择端口。若需要 PA1 和 PB1 同时产生外部中断,是不可能的——它们都映射到 EXTI1,只能选一个。解决方案:把其中一个引脚换到不冲突的编号,或用轮询替代中断。