第3章 GPIO驱动开发:从寄存器到中断
GPIO驱动,说白了就是让芯片的引脚听话。你想让它输出高电平就输出高电平,想让它检测按键就检测按键。这章我带你从寄存器映射开始,一步步把GPIO驱动写完整。
本章核心脉络:寄存器映射 → 结构体封装 → 输出驱动(LED) → 输入驱动(按键) → 中断式按键
3.1 GPIO模块寄存器映射
每个GPIO模块都有一组寄存器。它们控制引脚的模式、输出值、输入值、上下拉等。我刚开始做BSP时,第一件事就是翻开芯片手册,找到GPIO寄存器的基地址。
以STM32F4为例,GPIOA的基地址是0x40020000。每个GPIO端口有10个32位寄存器。它们的偏移量是固定的:
| 偏移量 | 寄存器名称 | 功能描述 |
|---|---|---|
| 0x00 | GPIOx_MODER | 模式寄存器(输入/输出/复用/模拟) |
| 0x04 | GPIOx_OTYPER | 输出类型寄存器(推挽/开漏) |
| 0x08 | GPIOx_OSPEEDR | 输出速度寄存器 |
| 0x0C | GPIOx_PUPDR | 上下拉寄存器 |
| 0x10 | GPIOx_IDR | 输入数据寄存器 |
| 0x14 | GPIOx_ODR | 输出数据寄存器 |
| 0x18 | GPIOx_BSRR | 位设置/复位寄存器 |
| 0x1C | GPIOx_LCKR | 配置锁定寄存器 |
| 0x20 | GPIOx_AFRL | 复用功能低寄存器 |
| 0x24 | GPIOx_AFRH | 复用功能高寄存器 |
我的经验:别死记偏移量。把手册的寄存器映射表截图存下来,开发时随时对照。我踩过最大的坑就是记错了BSRR的偏移量,结果点灯变成了灭灯。
3.2 使用结构体封装寄存器
裸机开发里,最优雅的方式就是用结构体把寄存器组映射到基地址。C语言的结构体内存布局和寄存器偏移量天然对齐——前提是你得注意编译器不要加填充。
// GPIO寄存器结构体封装
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 AFRL; // 0x20
volatile uint32_t AFRH; // 0x24
} GPIO_TypeDef;
// 映射到外设基地址
#define GPIOA_BASE 0x40020000
#define GPIOB_BASE 0x40020400
#define GPIOC_BASE 0x40020800
#define GPIOA ((GPIO_TypeDef *) GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *) GPIOB_BASE)
#define GPIOC ((GPIO_TypeDef *) GPIOC_BASE)
为什么要加volatile?因为寄存器的值可能被硬件修改。编译器如果优化掉读操作,你的代码就废了。我见过有人忘了加volatile,调试了一整天。
注意:结构体封装依赖编译器不插入填充字节。GCC/ARMCC默认不会在32位字段间加填充,但如果你用了__packed属性会更安全。跨平台时一定要检查sizeof(GPIO_TypeDef)是否等于0x28。
3.3 GPIO输出驱动——点亮LED
点灯是嵌入式界的"Hello World"。但别小看它,这里面藏着GPIO配置的完整流程。
驱动LED的步骤其实就四步:
- 使能GPIO时钟——寄存器不使能时钟,写什么都没用
- 配置模式为输出——MODER寄存器对应位设为01
- 配置输出类型——推挽还是开漏?LED一般用推挽
- 写输出值——ODR或BSRR寄存器
// 点亮PA0上的LED
// 1. 使能GPIOA时钟(RCC_AHB1ENR的bit0)
RCC->AHB1ENR |= (1 << 0);
// 2. 配置PA0为输出模式(MODER[1:0] = 01)
GPIOA->MODER &= ~(0x3 << (0 * 2));
GPIOA->MODER |= (0x1 << (0 * 2));
// 3. 配置为推挽输出(OTYPER bit0 = 0)
GPIOA->OTYPER &= ~(1 << 0);
// 4. 输出高电平点亮LED
GPIOA->BSRR = (1 << 0); // 用BSRR比ODR更安全,不会干扰其他位
我个人习惯用BSRR操作输出。为什么?BSRR是"写1有效",写0不影响。你用ODR的话,读-改-写中间可能被中断打断,造成其他引脚误动作。
避坑指南:我曾经在点灯时忘了使能时钟,结果寄存器写进去的值根本没用。调试器看寄存器全是0x00000000。从那以后,我每次配置外设前第一件事就是检查时钟是否使能。
3.4 GPIO输入驱动——按键扫描
按键检测比LED输出多了一个麻烦:抖动。你按下去那几十毫秒,电平会跳来跳去。不做消抖的话,一次按键可能触发十次动作。
按键扫描的基本流程:
- 配置GPIO为输入模式(MODER = 00)
- 配置上下拉(PUPDR),一般用上拉,按键接地
- 读取IDR寄存器获取电平
- 软件消抖——延时10~20ms再读一次
// 按键初始化(PA1,上拉输入)
void key_init(void) {
RCC->AHB1ENR |= (1 << 0); // 使能GPIOA时钟
GPIOA->MODER &= ~(0x3 << (1 * 2)); // 输入模式
GPIOA->PUPDR |= (0x1 << (1 * 2)); // 上拉
}
// 按键扫描(返回1表示按下,0表示未按下)
uint8_t key_scan(void) {
if ((GPIOA->IDR & (1 << 1)) == 0) { // 检测到低电平
delay_ms(10); // 消抖延时
if ((GPIOA->IDR & (1 << 1)) == 0) {
while ((GPIOA->IDR & (1 << 1)) == 0); // 等待释放
return 1;
}
}
return 0;
}
你想想看,这个while等待释放的写法有个大问题——它会卡死CPU。如果按键一直按着,CPU就一直在那转圈。实际产品里很少这么干,一般用状态机或者中断来处理。
3.5 中断式按键驱动
轮询扫描太浪费CPU了。中断式按键才是正经做法。按键按下时触发中断,CPU去处理,平时该干嘛干嘛。
配置中断式按键的步骤:
- GPIO配置为输入模式(同上)
- 配置外部中断线(EXTI)——选择引脚和触发边沿
- 配置NVIC——使能中断通道,设置优先级
- 编写中断服务函数——读IDR确认电平,软件消抖,置标志位
// 配置PA1为中断输入(下降沿触发)
void key_interrupt_init(void) {
// 1. GPIO配置(上拉输入)
RCC->AHB1ENR |= (1 << 0);
GPIOA->MODER &= ~(0x3 << (1 * 2));
GPIOA->PUPDR |= (0x1 << (1 * 2));
// 2. 将PA1连接到EXTI1
SYSCFG->EXTICR[0] &= ~(0xF << (1 * 4));
SYSCFG->EXTICR[0] |= (0x0 << (1 * 4)); // PA组
// 3. 配置EXTI1:下降沿触发,中断使能
EXTI->FTSR |= (1 << 1); // 下降沿触发
EXTI->IMR |= (1 << 1); // 中断屏蔽使能
// 4. 配置NVIC
NVIC_SetPriority(EXTI1_IRQn, 2);
NVIC_EnableIRQ(EXTI1_IRQn);
}
// 中断服务函数
void EXTI1_IRQHandler(void) {
if (EXTI->PR & (1 << 1)) { // 检查中断标志
// 软件消抖:延时10ms再读一次
delay_ms(10);
if ((GPIOA->IDR & (1 << 1)) == 0) {
key_press_flag = 1; // 置位按键标志
}
EXTI->PR = (1 << 1); // 清除中断标志
}
}
重要提醒:中断服务函数里不要做耗时操作。消抖延时10ms已经算长了,但还能接受。千万别在中断里做printf、延时100ms这种事——你会把整个系统卡死的。
中断式驱动有个经典问题:中断标志位必须在最后清除。如果你先清标志再处理,处理过程中又来了新的中断,标志位可能被错误清除。我踩过这个坑,调试了整整一个下午。
3.6 三种驱动方式的对比
我把三种方式放在一起对比,你根据实际场景选:
| 驱动方式 | CPU占用率 | 实时性 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 轮询输出 | 极低 | 高 | 低 | LED指示、状态显示 |
| 轮询输入 | 高(一直查) | 中 | 低 | 简单按键、调试用 |
| 中断输入 | 极低(事件触发) | 高 | 中 | 产品级按键、唤醒 |
说白了,产品里按键驱动几乎都用中断。轮询只适合做原型验证。我见过有人把轮询按键直接用在量产产品里,结果电池两天就没电了——CPU一直在跑循环,根本没法休眠。
我的建议:中断服务函数里只做两件事——清标志、置位一个全局标志位。真正的按键处理逻辑放在主循环里判断这个标志位。这样既保证了实时性,又不会阻塞中断系统。
嗯,GPIO驱动这块就这些。从寄存器映射到结构体封装,从点灯到中断按键,每一步都是BSP开发的基本功。你把这些代码跑通了,后面学I2C、SPI这些复杂外设就会顺手很多。