2. 嵌入式开发基础回顾

做嵌入式开发久了,你会发现一个很头疼的问题:明明代码逻辑一模一样,换个MCU就得重写驱动。我早期做项目时,就吃过这个亏——产品要换主控,结果底层驱动全得改,加班改了一个月。从那以后,我就开始认真琢磨HAL的设计。

说白了,硬件抽象层就是给上层业务逻辑和底层硬件之间加一层“翻译官”。业务代码只管调用API,不用管底层是STM32还是GD32,是I2C还是SPI。

2.1 什么是硬件抽象层?

HAL的核心思想很简单:把硬件相关的操作封装起来,对外提供统一的接口。这样上层代码只依赖接口,不依赖具体硬件。

HAL的核心价值:

  • 可移植性:换MCU只需重写HAL层,业务代码不动
  • 可测试性:可以Mock硬件接口做单元测试
  • 可维护性:硬件变更影响范围被限制在HAL层内
  • 代码复用:同一套业务逻辑可跨平台使用

我习惯把嵌入式软件分成三层:应用层、HAL层、硬件层。应用层只管业务逻辑,HAL层提供抽象接口,硬件层直接操作寄存器。

嵌入式软件三层架构 应用层(Application Layer) 业务逻辑、状态机、协议处理、算法 硬件抽象层(HAL Layer) 统一API接口、设备抽象、平台适配 硬件层(Hardware Layer) 寄存器操作、外设驱动、中断处理 上层只调用HAL接口,不直接操作寄存器。换MCU只需重写HAL层。 业务代码零改动,这就是HAL的价值。

2.2 HAL接口设计原则

设计HAL接口时,我总结了几条铁律。这些原则是我在多个项目里踩坑踩出来的。

原则 说明 反面案例
接口统一 同一类设备提供相同的API签名 I2C读函数参数顺序不统一
参数抽象 使用枚举/结构体,不用裸数值 直接传0x00表示读操作
错误码规范 统一返回标准错误码 有的返回-1,有的返回NULL
无硬件依赖 头文件中不包含MCU寄存器定义 在hal_gpio.h里包含stm32f4xx.h
初始化/反初始化 成对提供init/deinit接口 只提供init不提供deinit

我的习惯:每个HAL接口都返回int32_t类型的错误码。0表示成功,负数表示错误。这样上层可以用统一的错误处理逻辑。

2.3 实战:GPIO HAL接口设计

拿GPIO来举例。这是最基础的外设,但设计不好照样会出问题。我曾经见过一个项目,GPIO的HAL接口里直接传GPIO_Pin_0这种宏定义——换平台全得改。

正确的做法是这样:

/* hal_gpio.h - 硬件抽象层GPIO接口 */

/* 引脚电平定义 */
typedef enum {
    HAL_GPIO_LOW  = 0,
    HAL_GPIO_HIGH = 1
} hal_gpio_level_t;

/* 引脚方向定义 */
typedef enum {
    HAL_GPIO_DIR_INPUT  = 0,
    HAL_GPIO_DIR_OUTPUT = 1
} hal_gpio_dir_t;

/* 引脚配置结构体 */
typedef struct {
    uint8_t         port;       /* 端口号,0=A, 1=B, ... */
    uint8_t         pin;        /* 引脚号,0-15 */
    hal_gpio_dir_t  direction;  /* 方向 */
    hal_gpio_level_t init_level;/* 初始电平 */
} hal_gpio_cfg_t;

/* 标准错误码 */
#define HAL_OK      0
#define HAL_ERROR   -1
#define HAL_TIMEOUT -2
#define HAL_BUSY    -3

/* HAL接口声明 */
int32_t hal_gpio_init(hal_gpio_cfg_t *cfg);
int32_t hal_gpio_deinit(hal_gpio_cfg_t *cfg);
int32_t hal_gpio_write(hal_gpio_cfg_t *cfg, hal_gpio_level_t level);
int32_t hal_gpio_read(hal_gpio_cfg_t *cfg, hal_gpio_level_t *level);
int32_t hal_gpio_toggle(hal_gpio_cfg_t *cfg);

你看,这个头文件里没有任何MCU相关的定义。换平台时,只需要在.c文件里实现这些接口就行。

2.4 平台适配层的实现

HAL接口定义好了,接下来就是针对具体平台实现。我习惯把每个平台的实现放在单独的目录里。

/* hal_gpio_stm32f4.c - STM32F4平台实现 */

#include "hal_gpio.h"
#include "stm32f4xx.h"  /* 只在.c文件里包含硬件头文件 */

static GPIO_TypeDef* port_map[] = {
    [0] = GPIOA,
    [1] = GPIOB,
    [2] = GPIOC,
    /* ... */
};

int32_t hal_gpio_init(hal_gpio_cfg_t *cfg)
{
    GPIO_InitTypeDef gpio_init = {0};
    GPIO_TypeDef *gpio;

    if (cfg == NULL) return HAL_ERROR;
    if (cfg->port >= sizeof(port_map)/sizeof(port_map[0]))
        return HAL_ERROR;

    gpio = port_map[cfg->port];

    /* 使能时钟 */
    RCC_AHB1PeriphClockCmd(1 << cfg->port, ENABLE);

    /* 配置GPIO */
    gpio_init.GPIO_Pin   = 1 << cfg->pin;
    gpio_init.GPIO_Mode  = (cfg->direction == HAL_GPIO_DIR_OUTPUT) ?
                           GPIO_Mode_OUT : GPIO_Mode_IN;
    gpio_init.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(gpio, &gpio_init);

    /* 设置初始电平 */
    if (cfg->direction == HAL_GPIO_DIR_OUTPUT) {
        GPIO_WriteBit(gpio, 1 << cfg->pin,
                      (cfg->init_level == HAL_GPIO_HIGH) ? Bit_SET : Bit_RESET);
    }

    return HAL_OK;
}

int32_t hal_gpio_write(hal_gpio_cfg_t *cfg, hal_gpio_level_t level)
{
    if (cfg == NULL) return HAL_ERROR;
    GPIO_WriteBit(port_map[cfg->port], 1 << cfg->pin,
                  (level == HAL_GPIO_HIGH) ? Bit_SET : Bit_RESET);
    return HAL_OK;
}

int32_t hal_gpio_read(hal_gpio_cfg_t *cfg, hal_gpio_level_t *level)
{
    if (cfg == NULL || level == NULL) return HAL_ERROR;
    *level = (GPIO_ReadInputDataBit(port_map[cfg->port], 1 << cfg->pin) == Bit_SET)
             ? HAL_GPIO_HIGH : HAL_GPIO_LOW;
    return HAL_OK;
}

注意:HAL接口的.c文件里可以包含硬件头文件,但.h文件绝对不能包含。这是HAL设计的底线——一旦.h里有了硬件依赖,上层代码就被污染了。

2.5 多平台管理策略

项目里同时支持多个平台怎么办?我常用的做法是用条件编译或者构建系统选择。

方法 优点 缺点 适用场景
条件编译 一个文件,编译时选择 代码可读性差,宏定义满天飞 平台差异小,临时方案
目录分离 代码清晰,平台独立 需要构建系统支持 正式项目,推荐做法
函数指针表 运行时动态切换 性能有损耗,调试复杂 需要热切换的场景

我个人推荐目录分离的方式。项目结构大概这样:

project/
├── app/                    # 应用层代码
│   ├── main.c
│   └── task_manager.c
├── hal/                    # HAL接口定义
│   ├── hal_gpio.h
│   ├── hal_i2c.h
│   └── hal_uart.h
├── platform/               # 平台适配
│   ├── stm32f4/
│   │   ├── hal_gpio.c
│   │   └── hal_i2c.c
│   └── gd32f3/
│       ├── hal_gpio.c
│       └── hal_i2c.c
└── build/
    └── CMakeLists.txt      # 通过构建系统选择平台

避坑指南:我曾经在一个项目里用条件编译同时支持3个平台,结果代码里全是 #ifdef PLATFORM_A / #elif PLATFORM_B / #endif。后来加第四个平台时,我果断重构成了目录分离。那之后,加新平台只需要新建一个目录,写一套实现就行。

2.6 HAL接口的测试策略

HAL层设计好了,怎么验证它是对的?我的做法是写一个Mock平台。

/* hal_gpio_mock.c - 用于测试的Mock实现 */

#include "hal_gpio.h"

static uint8_t mock_pin_state[16] = {0};

int32_t hal_gpio_init(hal_gpio_cfg_t *cfg)
{
    if (cfg == NULL) return HAL_ERROR;
    mock_pin_state[cfg->pin] = cfg->init_level;
    return HAL_OK;
}

int32_t hal_gpio_write(hal_gpio_cfg_t *cfg, hal_gpio_level_t level)
{
    if (cfg == NULL) return HAL_ERROR;
    mock_pin_state[cfg->pin] = level;
    return HAL_OK;
}

int32_t hal_gpio_read(hal_gpio_cfg_t *cfg, hal_gpio_level_t *level)
{
    if (cfg == NULL || level == NULL) return HAL_ERROR;
    *level = mock_pin_state[cfg->pin];
    return HAL_OK;
}

有了Mock实现,应用层的逻辑就可以在PC上做单元测试了。不用烧录,不用接硬件,跑一遍测试用例就知道业务逻辑对不对。

2.7 总结一下

HAL设计的核心就三句话:

  • 接口统一:同一类设备提供相同的API
  • 隔离硬件:头文件里不出现硬件相关定义
  • 平台分离:每个平台的实现独立管理

做到这三点,你的代码就能轻松跨平台。换MCU?改HAL层就行,业务代码一行都不用动。这就是模块化设计带来的底气。