4. ARM中断与异常处理

GPIO,说白了就是芯片的“手脚”。你要让LED亮,要检测按键按下,要跟外设做简单的握手信号,都得靠它。很多初学者觉得GPIO太简单,不就是写个0和1吗?嗯,真到了项目里,GPIO的坑可不少。我见过有人因为GPIO模式配错了,整块板子功耗飙到几百毫安,电池半天就耗光了。

这一章,咱们把GPIO从寄存器层面彻底讲透。你跟着我走一遍,以后不管换什么ARM芯片,GPIO这块你都能快速上手。

4.1 寄存器映射:从物理地址到C语言指针

ARM芯片的GPIO外设,本质上就是一组寄存器。每个寄存器有固定的物理地址。你要操作GPIO,就得往这些地址读写数据。

但你不能在C代码里直接写 *(uint32_t*)0x40020C00 = 0x01 这种代码——太丑了,而且容易写错。我们得用结构体把寄存器组映射起来。

以STM32F4的GPIOA为例,它的基地址是 0x40020000。每个GPIO端口有10个寄存器,排列顺序是固定的。我习惯这样定义:

typedef struct {
    volatile uint32_t MODER;    // 0x00 模式寄存器
    volatile uint32_t OTYPER;   // 0x04 输出类型
    volatile uint32_t OSPEEDR;  // 0x08 输出速度
    volatile uint32_t PUPDR;    // 0x0C 上下拉
    volatile uint32_t IDR;      // 0x10 输入数据
    volatile uint32_t ODR;      // 0x14 输出数据
    volatile uint32_t BSRR;     // 0x18 置位/复位
    volatile uint32_t LCKR;     // 0x1C 锁定
    volatile uint32_t AFR[2];   // 0x20-0x24 复用功能
} GPIO_TypeDef;

#define GPIOA_BASE      0x40020000
#define GPIOA           ((GPIO_TypeDef *)GPIOA_BASE)

注意那个 volatile 关键字。不加的话,编译器优化可能把你的赋值语句直接优化掉——我在调试一个电机驱动时就吃过这个亏,寄存器写进去读出来还是0,查了半天才发现是 volatile 漏了。

核心要点:寄存器映射就是把外设的物理地址空间,用C语言的结构体和指针重新“翻译”一遍。翻译对了,后面所有操作都顺;翻译错了,整个驱动都是错的。

4.2 GPIO模式配置:别让引脚“裸奔”

每个GPIO引脚都有好几种模式。你得告诉芯片:这个引脚是当普通输出用,还是当输入用,还是当外设的复用功能用。

模式配置主要在 MODER 寄存器里,每2个bit控制一个引脚:

MODER[2n+1:2n] 模式 典型用途
00 输入 按键检测、传感器读取
01 通用输出 LED控制、蜂鸣器
10 复用功能 UART、SPI、I2C等外设
11 模拟 ADC输入、DAC输出

举个例子,把PA5配成推挽输出,驱动LED:

// 先使能GPIOA时钟(这一步很多人忘!)
RCC->AHB1ENR |= (1 << 0);

// 清掉PA5的模式位,再设为01(输出)
GPIOA->MODER &= ~(0x3 << (5 * 2));
GPIOA->MODER |= (0x1 << (5 * 2));

// 输出类型设为推挽(默认就是推挽,可以不配)
GPIOA->OTYPER &= ~(1 << 5);

// 速度设为50MHz(够用就行,别盲目开最高)
GPIOA->OSPEEDR &= ~(0x3 << (5 * 2));
GPIOA->OSPEEDR |= (0x2 << (5 * 2));

// 不上拉也不下拉
GPIOA->PUPDR &= ~(0x3 << (5 * 2));

踩坑提醒:我曾经在一个低功耗项目里,把所有未使用的GPIO都设成了“浮空输入”。结果芯片在休眠时,这些引脚电平不确定,导致漏电流高达200µA。后来全部改成“模拟模式”或“上拉输入”,漏电流才降到5µA以下。

4.3 输出控制:点灯是门学问

输出控制有两种方式:直接写ODR寄存器,或者用BSRR寄存器。我建议你养成用BSRR的习惯。

ODR是输出数据寄存器,你写0或1,引脚就输出低或高。但问题是,如果你用 GPIOA->ODR |= (1 << 5) 这种方式,它其实是“读-改-写”操作,不是原子的。如果在多线程或中断里操作,可能出问题。

BSRR就好多了。低16位写1置位,高16位写1复位:

// PA5输出高电平
GPIOA->BSRR = (1 << 5);

// PA5输出低电平
GPIOA->BSRR = (1 << (5 + 16));

// 同时操作:PA5高,PA6低
GPIOA->BSRR = (1 << 5) | (1 << (6 + 16));

BSRR的写操作是“写1有效,写0不影响”,所以它是原子的。你不用担心别的线程或中断干扰。

我的习惯:所有GPIO输出操作,一律用BSRR。ODR只用来读取当前输出状态。这个习惯帮我避免了好几次中断竞争导致的bug。

4.4 输入读取:别信一次采样

读取输入很简单,读IDR寄存器就行:

// 读取PA0的电平
uint8_t level = (GPIOA->IDR >> 0) & 0x01;

但实际项目里,机械按键、开关都有抖动。你只读一次,可能读到的是抖动期间的毛刺。我见过有人因为没做消抖,按键按一次触发了七八次中断。

软件消抖的经典做法是“延时再读”:

uint8_t read_key_debounced(void) {
    if (GPIOA->IDR & (1 << 0)) {   // 第一次读到高电平
        delay_ms(10);                 // 等10ms
        if (GPIOA->IDR & (1 << 0)) { // 第二次还是高
            return 1;                 // 确认是高
        }
    }
    return 0;
}

当然,延时消抖会阻塞CPU。在RTOS里,我更喜欢用定时器轮询或硬件消抖(有些芯片的GPIO模块自带数字滤波器)。

4.5 中断触发配置:让CPU别空等

轮询读取太浪费CPU了。更好的方式是让引脚电平变化时,自动触发中断,CPU再去处理。

ARM Cortex-M的中断控制器叫NVIC。GPIO中断的配置分三步:

  1. 配置GPIO为输入模式(跟上面一样)
  2. 配置外部中断线(EXTI)
  3. 使能NVIC中的对应中断

以PA0的上升沿中断为例:

// 1. PA0配成输入(略)

// 2. 配置EXTI
SYSCFG->EXTICR[0] &= ~0x0F;          // 选择PA0连接到EXTI0
EXTI->IMR |= (1 << 0);               // 不屏蔽中断线0
EXTI->RTSR |= (1 << 0);              // 上升沿触发
// EXTI->FTSR |= (1 << 0);          // 下降沿触发(二选一或都选)

// 3. 使能NVIC
NVIC_EnableIRQ(EXTI0_IRQn);
NVIC_SetPriority(EXTI0_IRQn, 2);      // 优先级设低一点

中断服务函数里,记得清标志位:

void EXTI0_IRQHandler(void) {
    if (EXTI->PR & (1 << 0)) {
        // 处理按键事件
        // ...

        // 清标志位(写1清除)
        EXTI->PR = (1 << 0);
    }
}

重要:EXTI的标志位必须手动清除。不清除的话,中断会反复触发,CPU就卡死在中断里了。我刚开始学的时候,忘了清标志位,点一下按键,串口疯狂打印,差点以为板子坏了。

4.6 知识体系总览

下面这张图把GPIO驱动开发的完整流程串起来了。你对照着看,心里就有谱了。

GPIO驱动开发知识体系 寄存器映射(结构体+指针) 模式配置(MODER/OTYPER/PUPDR) 中断配置(EXTI + NVIC) 输出控制(BSRR/ODR) 输入读取(IDR + 消抖) 中断处理(ISR + 清标志) 应用层:LED控制 / 按键检测 / 外设握手 核心思路:寄存器映射 → 模式配置 → 输出/输入/中断 → 应用

4.7 实战要点总结

最后,我把GPIO开发中最容易出问题的几个点列出来,你写代码时多留个心眼:

环节 常见错误 正确做法
时钟使能 忘了开GPIO时钟,寄存器写不进去 先配RCC,再配GPIO
模式配置 清位时用了 |= 而不是 &= ~ 先清零,再置位
输出操作 用ODR做位操作,被中断打断 用BSRR实现原子操作
输入读取 不消抖,读到毛刺 延时10~20ms再读第二次
中断配置 忘了清EXTI标志位 ISR里写1清除PR寄存器
中断优先级 所有中断优先级一样,导致嵌套混乱 按实时性要求分配优先级

GPIO这部分,说简单也简单,说复杂也复杂。简单的是操作本身,复杂的是各种边界情况。你只要把寄存器映射搞清楚了,模式配对了,输出输入用对了方法,中断处理写规范了,那GPIO这块就算真正拿下了。