06 HAL 实例:STM32 外设接口中的封装设计

C 语言面向对象系列

  1. 总纲:C 语言面向对象设计概览
  2. 封装:结构体与多实例接口
  3. 信息隐藏:公共接口与内部实现
  4. 模块组织:命名约定与对象生命周期
  5. 数据组织:实例状态、模块状态与常量
  6. HAL 实例:STM32 外设接口中的封装设计(当前篇)
  7. 结构体嵌套:公共数据与行为的复用
  8. 函数指针:可替换行为与回调
  9. 操作表:使用 ops 组织对象接口
  10. 多态:统一接口、行为分发与类型转换
  11. 接口约定:必选操作、可选操作与默认实现
  12. 框架整合:应用、驱动与平台的分层
  13. 工程扩展:自动注册与 Linux 内核中的对象组织

前面用 LED 示例介绍了结构体、对象指针、函数前缀和初始化接口。本节对照 STM32 HAL 的 GPIO 接口,看看这些写法在外设库中如何使用。

下文的结构体和函数是教学简化版,用于说明数据与接口的关系。实际项目应使用对应芯片的头文件和 HAL 实现。

先辨认接口中的类型和参数

GPIO 模块有初始化、反初始化、读写和翻转等接口。下面只列出函数名,省略调用参数:

1
2
3
4
5
HAL_GPIO_Init();
HAL_GPIO_DeInit();
HAL_GPIO_WritePin();
HAL_GPIO_ReadPin();
HAL_GPIO_TogglePin();

阅读这些函数时,还会遇到结构体类型和宏:

1
2
3
4
5
GPIO_TypeDef
GPIO_InitTypeDef
GPIOA
GPIOB
GPIO_PIN_5

可以先按用途区分:

名称 用途
GPIO_TypeDef 描述 GPIO 端口的寄存器布局
GPIO_InitTypeDef 保存初始化配置
GPIOA/GPIOB 指定具体 GPIO 端口
GPIO_PIN_5 指定端口中的引脚
GPIO_TypeDef *GPIOx 向函数传入要操作的端口
HAL_GPIO_ 标明函数属于 HAL 的 GPIO 模块

先找到类型定义和函数参数,比逐个记住函数名更容易理解接口之间的关系。

用结构体描述寄存器布局

GPIO 的寄存器可以用结构体成员表示。简化后如下:

1
2
3
4
5
6
7
8
9
10
typedef struct {
uint32_t MODER;
uint32_t OTYPER;
uint32_t OSPEEDR;
uint32_t PUPDR;
uint32_t IDR;
uint32_t ODR;
uint32_t BSRR;
uint32_t LCKR;
} GPIO_TypeDef;

前面的 LED 对象也使用结构体:

1
2
3
4
5
typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
} Led_t;

两者都能通过成员名访问数据,但数据的含义不同。Led_t 保存程序维护的软件状态,GPIO_TypeDef 描述硬件寄存器的布局。

实际芯片头文件还规定了成员偏移和访问限定。例如 STM32F407 的定义使用 __IO 标注寄存器成员。上面的简化定义省略了这些内容,不能直接替换芯片定义。具体可对照 ST 的 STM32F407 头文件。

通过指针区分 GPIO 端口

先用普通变量模拟三个端口,可以直观看到它们共用同一类型:

1
2
3
4
5
6
7
GPIO_TypeDef gpioa_regs;
GPIO_TypeDef gpiob_regs;
GPIO_TypeDef gpioc_regs;

#define GPIOA (&gpioa_regs)
#define GPIOB (&gpiob_regs)
#define GPIOC (&gpioc_regs)

这与创建多个 LED 对象的写法有相似之处:

1
2
3
Led_t red_led;
Led_t green_led;
Led_t blue_led;

相同类型可以对应不同实例,函数通过指针区分当前操作的实例。区别在于,实际芯片上的 GPIOA、GPIOB 等指向固定的外设地址,而不是上面声明的普通 RAM 变量。

GPIOx 参数指定操作端口

下面用简化的写引脚函数说明参数如何参与操作:

1
2
3
4
5
6
7
8
9
10
11
12
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t pin, bool value)
{
if (GPIOx == NULL) {
return;
}

if (value) {
GPIOx->ODR |= pin;
} else {
GPIOx->ODR &= ~pin;
}
}

GPIOx 指定端口,pin 指定端口内的引脚或引脚组合,value 表示要写入的电平。函数通过 GPIOx->ODR 访问选定端口的输出寄存器。

这与 Led_t *me 的共同点是通过指针选择操作对象:

LED 示例 GPIO 示例
Led_t *me 指向 LED 数据 GPIO_TypeDef *GPIOx 指向端口寄存器
从 me->pin 读取引脚配置 通过 GPIOx->ODR 访问输出寄存器
led_on(&red) 操作红灯 示例中的 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, true) 操作端口 A 的引脚 5

实际 STM32F4 HAL 的第三个参数是 GPIO_PinState,使用 GPIO_PIN_SET 或 GPIO_PIN_RESET,实现通过 BSRR 写入。上面的 bool 参数和 ODR 读改写只用于简化讲解。参见 ST 的 GPIO 驱动实现。

用配置结构体完成初始化

初始化时,可以将引脚、模式、上下拉和速度等参数放在配置结构体中:

1
2
3
4
5
6
7
typedef struct {
uint32_t Pin;
uint32_t Mode;
uint32_t Pull;
uint32_t Speed;
uint32_t OType;
} GPIO_InitTypeDef;

初始化函数接收端口指针和配置指针,逐项设置对应寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *init)
{
if (GPIOx == NULL || init == NULL) {
return;
}

for (int pin = 0; pin < 16; pin++) {
if ((init->Pin & (1U << pin)) == 0) {
continue;
}

set_2bit_field(&GPIOx->MODER, pin, init->Mode);
set_1bit_field(&GPIOx->OTYPER, pin, init->OType);
set_2bit_field(&GPIOx->OSPEEDR, pin, init->Speed);
set_2bit_field(&GPIOx->PUPDR, pin, init->Pull);
}
}

这里的两个参数分工明确:GPIOx 指定配置哪个端口,init 指定如何配置。它与前面的 led_init() 都承担使用前的准备工作,但寄存器配置和软件对象初始化的具体步骤需要分别理解。

将公共声明与内部实现分开

按前面介绍的模块组织方式,简化接口可以声明在头文件中:

1
2
3
4
5
void HAL_GPIO_Init(GPIO_TypeDef *GPIOx, GPIO_InitTypeDef *init);
void HAL_GPIO_DeInit(GPIO_TypeDef *GPIOx, uint32_t pin);
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx, uint16_t pin, bool value);
bool HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t pin);
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t pin);

实现所需的辅助函数可以放在源文件内:

1
2
3
static void set_2bit_field(uint32_t *reg, int pin, uint32_t value);
static void set_1bit_field(uint32_t *reg, int pin, uint32_t value);
static bool is_pin_valid(uint32_t pin);

调用方使用公开接口,具体的寄存器位操作由实现处理。这里的辅助函数同样用于教学示意,阅读实际 HAL 源码时,应以对应版本的实现为准。

按数据和调用关系阅读外设库

阅读其他外设模块时,可以沿着下面的顺序查找:

阅读内容 查找位置
寄存器布局或对象状态 核心结构体定义
本次操作的设备 函数中的端口或句柄参数
对外支持的操作 头文件中的函数声明
内部处理步骤 源文件中的实现和辅助函数
使用前的准备 Init 接口
结束使用时的处理 DeInit 接口

UART、SPI、TIM 等接口也可以从类型和参数入手。以 UART 为例,UART_HandleTypeDef huart1 声明句柄,HAL_UART_Init(&huart1) 初始化,HAL_UART_Transmit(&huart1, buf, 10, 100) 通过句柄指定本次发送使用的外设。GPIO 的寄存器指针与 UART 的句柄各有职责,阅读时需要结合各自的类型定义。