2. 硬件抽象层(HAL)基础:GPIO的抽象设计——结构体封装、接口定义与多平台实现(STM32/ESP32)
好,我们直接进入正题。
做嵌入式开发,尤其是要跑多个芯片平台的时候,最头疼的是什么?我告诉你,不是业务逻辑有多复杂,而是换个芯片,所有底层代码都得重写。GPIO点个灯,在STM32上是一套寄存器操作,到了ESP32上又是另一套。代码里到处都是 #ifdef,维护起来简直想骂人。
那怎么办?抽象。把硬件的差异,用一层软件包起来。这层就叫硬件抽象层,也就是HAL。今天我们就拿GPIO开刀,看看怎么设计一个低耦合、高内聚的抽象层。
2.1 为什么GPIO需要抽象?
你想想看,GPIO的本质是什么?无非就是:输入、输出、中断。不管哪家芯片,功能都一样。但实现方式千差万别。
我举个例子。STM32的GPIO初始化,你得配置MODER、OTYPER、OSPEEDR、PUPDR这一堆寄存器。ESP32呢?它用的是 gpio_config_t 结构体,直接填字段。你要是每个项目都对着寄存器手册写,那换个平台,代码基本等于重来。
所以,我们要做的,就是定义一套通用的接口。这套接口只描述“做什么”,不关心“怎么做”。具体的实现,留给各个平台的驱动文件去完成。
核心思想: 接口与实现分离。上层代码只依赖接口,不依赖具体硬件。
2.2 结构体封装:把GPIO的属性打包
我个人习惯,第一步先把GPIO的“属性”抽象成一个结构体。这样,所有配置信息都放在一个地方,清晰明了。
这个结构体应该包含哪些东西?
- 端口号: 是GPIOA还是GPIOB?
- 引脚号: 第0脚还是第15脚?
- 模式: 输入、输出、还是复用功能?
- 上下拉: 上拉、下拉、还是浮空?
- 驱动能力: 推挽还是开漏?
- 速度: 低频还是高频?
你看,这些属性是通用的。不管哪家芯片,都得有。那我们就可以定义一个这样的结构体:
/* gpio_common.h */
#ifndef _GPIO_COMMON_H_
#define _GPIO_COMMON_H_
#include <stdint.h>
/* GPIO端口枚举 */
typedef enum {
GPIO_PORT_A = 0,
GPIO_PORT_B,
GPIO_PORT_C,
GPIO_PORT_D,
/* ... 根据平台扩展 */
GPIO_PORT_MAX
} gpio_port_t;
/* GPIO引脚号枚举 */
typedef enum {
GPIO_PIN_0 = 0,
GPIO_PIN_1,
GPIO_PIN_2,
/* ... */
GPIO_PIN_15,
GPIO_PIN_MAX
} gpio_pin_t;
/* GPIO模式枚举 */
typedef enum {
GPIO_MODE_INPUT = 0,
GPIO_MODE_OUTPUT,
GPIO_MODE_ALT_FUNC, /* 复用功能 */
GPIO_MODE_ANALOG /* 模拟模式 */
} gpio_mode_t;
/* 上下拉配置 */
typedef enum {
GPIO_PULL_NONE = 0,
GPIO_PULL_UP,
GPIO_PULL_DOWN
} gpio_pull_t;
/* 输出类型 */
typedef enum {
GPIO_OTYPE_PUSH_PULL = 0,
GPIO_OTYPE_OPEN_DRAIN
} gpio_otype_t;
/* GPIO配置结构体 */
typedef struct {
gpio_port_t port; /* 端口 */
gpio_pin_t pin; /* 引脚 */
gpio_mode_t mode; /* 模式 */
gpio_pull_t pull; /* 上下拉 */
gpio_otype_t otype; /* 输出类型 */
uint8_t speed; /* 速度等级 (平台相关, 用数字表示) */
} gpio_cfg_t;
#endif /* _GPIO_COMMON_H_ */
嗯,这里要注意。枚举值虽然通用,但具体数值在不同平台下可能不一样。比如STM32的GPIOA可能是0,但ESP32的GPIOA可能是1。所以,这个结构体里的 port 和 pin,我们只把它当作一个逻辑编号。真正的物理映射,在实现层去处理。
2.3 接口定义:只描述行为
结构体定义好了,接下来就是接口。接口要尽量精简,功能单一。我一般只定义这几个核心操作:
- 初始化: 根据配置结构体初始化GPIO。
- 写电平: 设置引脚输出高或低。
- 读电平: 读取引脚当前电平。
- 翻转电平: 把当前输出电平取反。
- 中断配置: 使能/失能中断,设置触发方式。
接口函数命名,我习惯用 hal_gpio_ 作为前缀,一看就知道是抽象层的东西。
/* hal_gpio.h */
#ifndef _HAL_GPIO_H_
#define _HAL_GPIO_H_
#include "gpio_common.h"
/* 中断触发方式 */
typedef enum {
GPIO_INT_DISABLE = 0,
GPIO_INT_RISING, /* 上升沿触发 */
GPIO_INT_FALLING, /* 下降沿触发 */
GPIO_INT_BOTH_EDGE /* 双边沿触发 */
} gpio_int_mode_t;
/* 中断回调函数类型 */
typedef void (*gpio_isr_callback_t)(void *arg);
/**
* @brief 初始化GPIO引脚
* @param cfg GPIO配置结构体指针
* @return 0: 成功, -1: 失败
*/
int hal_gpio_init(const gpio_cfg_t *cfg);
/**
* @brief 设置引脚输出电平
* @param port 端口号
* @param pin 引脚号
* @param level 0: 低电平, 1: 高电平
*/
void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level);
/**
* @brief 读取引脚输入电平
* @param port 端口号
* @param pin 引脚号
* @return 0: 低电平, 1: 高电平
*/
uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin);
/**
* @brief 翻转引脚输出电平
* @param port 端口号
* @param pin 引脚号
*/
void hal_gpio_toggle(gpio_port_t port, gpio_pin_t pin);
/**
* @brief 配置引脚中断
* @param port 端口号
* @param pin 引脚号
* @param mode 中断触发方式
* @param callback 中断回调函数
* @param arg 回调函数参数
* @return 0: 成功, -1: 失败
*/
int hal_gpio_irq_config(gpio_port_t port, gpio_pin_t pin,
gpio_int_mode_t mode,
gpio_isr_callback_t callback,
void *arg);
#endif /* _HAL_GPIO_H_ */
你看,这些接口里没有任何平台相关的关键字。没有 GPIOA,没有 RCC,没有 GPIO.out1_val。上层代码只需要调用 hal_gpio_write(GPIO_PORT_A, GPIO_PIN_5, 1) 就能点灯。至于这个操作在底层是怎么实现的,上层完全不用管。
我的经验: 接口参数尽量用枚举,不要用魔法数字。比如电平用 0 和 1,虽然简单,但可读性差。我后来习惯定义成 GPIO_LEVEL_LOW 和 GPIO_LEVEL_HIGH,代码一看就懂。
2.4 多平台实现:STM32与ESP32的落地
接口定义好了,接下来就是真刀真枪地实现了。我们拿两个最常用的平台举例:STM32和ESP32。
2.4.1 STM32实现
STM32的HAL库其实已经封装了一层,但它的接口还是太“ST”了。我们要做的是把我们的抽象接口,映射到ST的HAL库上。
/* hal_gpio_stm32.c */
#include "hal_gpio.h"
#include "stm32f4xx_hal.h" /* 假设使用STM32F4系列 */
/* 端口映射表: 逻辑端口号 -> 实际GPIO_TypeDef指针 */
static GPIO_TypeDef *port_map[] = {
[GPIO_PORT_A] = GPIOA,
[GPIO_PORT_B] = GPIOB,
[GPIO_PORT_C] = GPIOC,
/* ... */
};
int hal_gpio_init(const gpio_cfg_t *cfg)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* 使能GPIO时钟 (这里需要根据端口号使能对应的RCC时钟) */
/* ... 省略RCC使能代码 ... */
/* 映射模式 */
switch(cfg->mode) {
case GPIO_MODE_INPUT: GPIO_InitStruct.Mode = GPIO_MODE_INPUT; break;
case GPIO_MODE_OUTPUT: GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT; break;
/* ... 其他模式映射 ... */
}
/* 映射上下拉 */
switch(cfg->pull) {
case GPIO_PULL_NONE: GPIO_InitStruct.Pull = GPIO_NOPULL; break;
case GPIO_PULL_UP: GPIO_InitStruct.Pull = GPIO_PULLUP; break;
case GPIO_PULL_DOWN: GPIO_InitStruct.Pull = GPIO_PULLDOWN; break;
}
GPIO_InitStruct.Pin = (1 << cfg->pin); /* STM32用位掩码表示引脚 */
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(port_map[cfg->port], &GPIO_InitStruct);
return 0;
}
void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level)
{
HAL_GPIO_WritePin(port_map[port], (1 << pin), (GPIO_PinState)level);
}
uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin)
{
return (uint8_t)HAL_GPIO_ReadPin(port_map[port], (1 << pin));
}
/* ... 其他函数实现类似 ... */
2.4.2 ESP32实现
ESP32用的是乐鑫的ESP-IDF框架,它的GPIO驱动是另一套风格。
/* hal_gpio_esp32.c */
#include "hal_gpio.h"
#include "driver/gpio.h"
/* 引脚映射: 逻辑引脚号 -> ESP32的gpio_num_t */
/* 注意: ESP32的引脚号是连续的, 可以直接转换 */
static gpio_num_t pin_map[] = {
[GPIO_PIN_0] = GPIO_NUM_0,
[GPIO_PIN_1] = GPIO_NUM_1,
/* ... */
[GPIO_PIN_15] = GPIO_NUM_15,
};
int hal_gpio_init(const gpio_cfg_t *cfg)
{
gpio_config_t io_conf = {};
/* 配置引脚位掩码 */
io_conf.pin_bit_mask = (1ULL << pin_map[cfg->pin]);
/* 映射模式 */
switch(cfg->mode) {
case GPIO_MODE_INPUT: io_conf.mode = GPIO_MODE_INPUT; break;
case GPIO_MODE_OUTPUT: io_conf.mode = GPIO_MODE_OUTPUT; break;
/* ... */
}
/* 映射上下拉 */
switch(cfg->pull) {
case GPIO_PULL_NONE:
io_conf.pull_up_en = 0;
io_conf.pull_down_en = 0;
break;
case GPIO_PULL_UP:
io_conf.pull_up_en = 1;
io_conf.pull_down_en = 0;
break;
/* ... */
}
io_conf.intr_type = GPIO_INTR_DISABLE;
gpio_config(&io_conf);
return 0;
}
void hal_gpio_write(gpio_port_t port, gpio_pin_t pin, uint8_t level)
{
gpio_set_level(pin_map[pin], level);
}
uint8_t hal_gpio_read(gpio_port_t port, gpio_pin_t pin)
{
return (uint8_t)gpio_get_level(pin_map[pin]);
}
/* ... */
我曾经踩过的坑: ESP32的引脚号不是所有都能用的。比如GPIO6-11被用于Flash,不能当普通IO用。所以,在实现映射表的时候,一定要加上有效性检查。否则,程序跑起来莫名其妙,查半天才发现是引脚冲突。
2.5 知识结构:抽象层的层次关系
说了这么多,我们用一张图来总结一下整个抽象层的结构。这样你看起来会更直观。
这张图很清楚地展示了分层的思路。应用层在最上面,它只跟抽象接口层打交道。抽象接口层下面是具体的平台实现。每个平台实现,再调用各自厂商的底层驱动。这样,当你从STM32换到ESP32时,只需要换掉中间那一层实现文件,上面的应用代码一行都不用改。
2.6 对比总结:不同实现方式的差异
为了让你更清楚地看到两个平台实现的差异,我整理了一个表格。
| 对比项 | STM32 实现 | ESP32 实现 |
|---|---|---|
| 初始化方式 | 使用 GPIO_InitTypeDef 结构体 |
使用 gpio_config_t 结构体 |
| 引脚表示 | 位掩码 (1 << pin) |
引脚号 GPIO_NUM_X |
| 时钟使能 | 需要手动使能 RCC 时钟 |
驱动内部自动处理 |
| 中断配置 | 需要配置NVIC和EXTI | 使用 gpio_isr_handler_add() |
| 驱动能力 | 通过 GPIO_InitTypeDef.Speed 设置 |
通过 gpio_set_drive_capability() 设置 |
你看,差异其实挺大的。但通过我们的抽象层,这些差异都被隐藏在了 hal_gpio_stm32.c 和 hal_gpio_esp32.c 这两个文件里。上层代码根本感觉不到。
我的建议: 刚开始做抽象的时候,不要追求一步到位。先抽象你最常用的几个功能,比如初始化、读写。等后面遇到新需求,再慢慢往接口里加。过度设计也是坑。
好了,这一章的内容就到这里。GPIO的抽象设计,说白了就是定义一套通用的结构体和接口,然后为每个平台写一份实现。这个思路不光适用于GPIO,也适用于UART、I2C、SPI等所有外设。掌握了这个思想,你的代码就能真正做到“一次编写,到处编译”。