3. MCU外设驱动封装实战(GPIO)
好,咱们直接进入正题。这一章我带你手把手做一件事:把寄存器操作封装成HAL接口。说白了,就是让你写的代码能在不同型号的单片机之间轻松移植。
为什么要拿GPIO开刀?因为GPIO最简单,也最典型。你学会了GPIO的封装套路,后面UART、SPI、I2C全都是一个模子刻出来的。
核心思想:上层应用只调用HAL接口,底层驱动隔离硬件差异。换芯片?只改底层,不动上层。
3.1 分层架构长什么样?
先看一张图,把整体结构印在脑子里。
看到没?一共四层。应用层只管调用,硬件层是芯片厂家的事。我们真正要写的,是中间那两层。
3.2 寄存器封装层——先搞定底层
我个人习惯,先把寄存器定义写成结构体。这样代码可读性高,也方便移植。
以STM32F103为例,GPIO寄存器映射是这样的:
/* 寄存器封装结构体 */
typedef struct {
volatile uint32_t CRL; // 端口配置低寄存器
volatile uint32_t CRH; // 端口配置高寄存器
volatile uint32_t IDR; // 输入数据寄存器
volatile uint32_t ODR; // 输出数据寄存器
volatile uint32_t BSRR; // 位设置/清除寄存器
volatile uint32_t BRR; // 位清除寄存器
volatile uint32_t LCKR; // 配置锁定寄存器
} GPIO_TypeDef;
/* 基地址定义 */
#define GPIOA_BASE 0x40010800
#define GPIOB_BASE 0x40010C00
#define GPIOC_BASE 0x40011000
/* 方便访问的宏 */
#define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)
#define GPIOB ((GPIO_TypeDef *)GPIOB_BASE)
#define GPIOC ((GPIO_TypeDef *)GPIOC_BASE)
小技巧:volatile关键字不能丢。编译器优化会吃掉你的寄存器读写,我踩过这个坑,debug了一下午才发现。
3.3 HAL接口层——让上层用着舒服
接下来是重头戏。我们要封装四个接口:初始化、输出、输入、中断配置。
3.3.1 枚举和结构体定义
先定义好参数类型,让调用者不用记数字。
/* GPIO引脚号 */
typedef enum {
GPIO_PIN_0 = 0x0001,
GPIO_PIN_1 = 0x0002,
GPIO_PIN_2 = 0x0004,
/* ... 以此类推 ... */
GPIO_PIN_15 = 0x8000,
GPIO_PIN_ALL = 0xFFFF
} GPIO_Pin;
/* GPIO模式 */
typedef enum {
GPIO_MODE_INPUT = 0x00, // 输入模式
GPIO_MODE_OUTPUT_PP = 0x01, // 推挽输出
GPIO_MODE_OUTPUT_OD = 0x02, // 开漏输出
GPIO_MODE_AF_PP = 0x03, // 复用推挽
GPIO_MODE_AF_OD = 0x04, // 复用开漏
GPIO_MODE_IT_RISING = 0x05, // 上升沿中断
GPIO_MODE_IT_FALLING = 0x06, // 下降沿中断
GPIO_MODE_IT_RISING_FALLING = 0x07 // 双边沿中断
} GPIO_Mode;
/* 初始化结构体 */
typedef struct {
GPIO_Pin Pin; // 引脚
GPIO_Mode Mode; // 模式
uint8_t Speed; // 速度(输出模式有效)
} GPIO_InitTypeDef;
3.3.2 初始化函数
初始化是最复杂的,因为要配置CRL/CRH寄存器。我把它拆成两步:先算寄存器地址,再写配置值。
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *Init)
{
uint32_t pinpos;
uint32_t pos;
uint32_t currentpin;
uint32_t crl_mask, crh_mask;
uint32_t *crl, *crh;
/* 遍历所有引脚 */
for(pinpos = 0; pinpos < 16; pinpos++)
{
pos = 1 << pinpos;
currentpin = Init->Pin & pos;
if(currentpin == pos)
{
/* 确定是CRL还是CRH */
if(pinpos < 8)
{
crl = &GPIOx->CRL;
crl_mask = ~(0x0F << (pinpos * 4));
*crl &= crl_mask;
*crl |= (Init->Mode << (pinpos * 4));
}
else
{
crh = &GPIOx->CRH;
crh_mask = ~(0x0F << ((pinpos - 8) * 4));
*crh &= crh_mask;
*crh |= (Init->Mode << ((pinpos - 8) * 4));
}
}
}
}
注意:写CRL/CRH之前一定要先清对应位,再写新值。我曾经直接赋值,结果把其他引脚配置冲掉了,板子上的LED乱闪,排查了好久。
3.3.3 输出函数
输出用BSRR寄存器,一次操作就能完成置位和复位,不需要读-改-写。
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t Pin, uint8_t State)
{
if(State != 0)
{
GPIOx->BSRR = Pin; // 置位
}
else
{
GPIOx->BRR = Pin; // 复位
}
}
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
GPIOx->ODR ^= Pin; // 异或翻转
}
3.3.4 输入函数
输入更简单,直接读IDR寄存器。
uint8_t HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
return (uint8_t)((GPIOx->IDR & Pin) ? 1 : 0);
}
3.3.5 中断配置
中断配置涉及NVIC和EXTI,这里只展示GPIO相关的部分。
void HAL_GPIO_EnableIT(GPIO_TypeDef *GPIOx, uint16_t Pin)
{
uint32_t exti_line = 0;
/* 计算EXTI线号 */
while(Pin >>= 1) exti_line++;
/* 使能EXTI中断 */
EXTI->IMR |= (1 << exti_line);
/* 配置触发方式(在初始化时已设置) */
/* 这里只使能中断线 */
}
/* 中断服务函数示例 */
void EXTI0_IRQHandler(void)
{
if(EXTI->PR & (1 << 0))
{
/* 清除中断标志 */
EXTI->PR = (1 << 0);
/* 用户回调 */
HAL_GPIO_EXTI_Callback(GPIO_PIN_0);
}
}
/* 弱定义回调,用户可重写 */
__weak void HAL_GPIO_EXTI_Callback(uint16_t Pin)
{
/* 用户实现 */
}
3.4 接口对比表
我把封装前后的差异整理了一下,方便你理解为什么要这么做。
| 功能 | 原始寄存器操作 | HAL接口 | 优势 |
|---|---|---|---|
| 输出高电平 | GPIOA->BSRR = 0x0001 |
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, 1) |
参数语义化,不用记位号 |
| 读取输入 | val = GPIOA->IDR & 0x0002 |
val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) |
返回值标准化,0/1清晰 |
| 配置模式 | GPIOA->CRL |= 0x33333333 |
HAL_GPIO_Init(...) |
不会误改其他引脚 |
| 中断使能 | NVIC->ISER[0] |= 0x00000001 |
HAL_GPIO_EnableIT(...) |
屏蔽NVIC细节 |
3.5 实际项目中的避坑指南
做项目这么多年,有些坑我替你们踩过了,直接列出来:
- 初始化顺序不能乱:先开RCC时钟,再配GPIO。我曾经反过来写,寄存器写不进去,查了半天才发现时钟没开。
- 中断标志必须软件清除:硬件不会自动清,不清除会一直进中断。我见过有人把整个系统卡死在中断里。
- BSRR和BRR不要同时用:BSRR的低16位置位,高16位复位。如果同时操作同一引脚,结果不确定。
- 输出模式要配速度:默认速度可能很低,导致波形畸变。我遇到过I2C因为速度配太低,通信失败的案例。
我的建议:刚开始做封装时,先只封装最常用的接口。等项目稳定了,再慢慢加高级功能。一口吃不成胖子,封装也是迭代出来的。
3.6 移植性验证
封装得好不好,换个芯片试试就知道。假设从STM32F103换到GD32F103:
/* 只需要改寄存器封装层 */
/* STM32F103版本 */
#define GPIOA_BASE 0x40010800
/* GD32F103版本 */
#define GPIOA_BASE 0x40010800 /* 地址一样,不用改 */
/* 如果地址不同,只改这一个宏 */
/* #define GPIOA_BASE 0x40020000 */
你看,只要底层地址映射对了,上层的HAL_GPIO_WritePin一行代码都不用动。这就是分层封装的价值。
嗯,这一章的内容就到这。GPIO封装是基础,但也是最重要的。你把这个套路练熟了,后面封装其他外设就是复制粘贴加微调的事。