3. 分层架构设计原则

做嵌入式这些年,我见过太多代码被硬件绑定死的案例。换一颗MCU,整个项目重写——这种痛,经历过一次就够了。硬件抽象层(HAL)就是来解决这个问题的。说白了,它是一层“翻译官”,把千奇百怪的寄存器操作,翻译成统一的接口。

你想想看,GPIO、UART、SPI、I2C、定时器、ADC,这些外设每家芯片厂商的实现都不一样。但它们的功能本质是一样的。我们要做的,就是把这些共性抽出来,定义一套稳定的接口契约。

核心原则:上层代码只依赖HAL接口,不依赖具体芯片。换芯片时,只换HAL实现层,上层逻辑纹丝不动。

3.1 GPIO抽象:最简单的接口,最深的坑

GPIO看似简单,其实最容易出问题。我在项目中遇到过,有人把推挽输出和开漏输出混用,导致I2C总线死活拉不低。嗯,这里要注意。

我建议的GPIO抽象接口如下:

/* gpio_hal.h */
typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT_PP,    /* 推挽输出 */
    GPIO_MODE_OUTPUT_OD,    /* 开漏输出 */
    GPIO_MODE_AF_PP,        /* 复用推挽 */
    GPIO_MODE_AF_OD,        /* 复用开漏 */
    GPIO_MODE_ANALOG
} gpio_mode_t;

typedef enum {
    GPIO_PULL_NONE,
    GPIO_PULL_UP,
    GPIO_PULL_DOWN
} gpio_pull_t;

typedef struct {
    uint8_t  port;      /* 端口号 */
    uint8_t  pin;       /* 引脚号 */
    gpio_mode_t mode;
    gpio_pull_t pull;
    uint8_t  speed;     /* 0:low, 1:medium, 2:high */
} gpio_cfg_t;

/* 接口函数 */
void    gpio_init(const gpio_cfg_t *cfg);
void    gpio_set(uint8_t port, uint8_t pin, uint8_t level);
uint8_t gpio_get(uint8_t port, uint8_t pin);
void    gpio_toggle(uint8_t port, uint8_t pin);

为什么用结构体传参?因为配置项多了,函数参数会爆炸。我曾经见过一个GPIO初始化函数有8个参数,调用时根本分不清哪个是哪个。结构体传参,可读性翻倍。

个人习惯:所有HAL接口的返回值统一用int8_t,0表示成功,负数表示错误码。这样上层做错误处理时,逻辑非常统一。

3.2 UART抽象:流式接口的典范

UART是最常用的串行通信接口。它的抽象核心是“流”——数据像水流一样,收进来、发出去。我建议用回调机制来处理接收,而不是轮询。

/* uart_hal.h */
typedef void (*uart_rx_cb_t)(uint8_t data);

typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    /* 5/6/7/8 */
    uint8_t  stop_bits;    /* 1/2 */
    uint8_t  parity;       /* 0:none, 1:odd, 2:even */
    uart_rx_cb_t rx_callback;
} uart_cfg_t;

int8_t uart_init(uint8_t ch, const uart_cfg_t *cfg);
int8_t uart_send(uint8_t ch, const uint8_t *data, uint16_t len);
int8_t uart_send_byte(uint8_t ch, uint8_t data);
int8_t uart_receive(uint8_t ch, uint8_t *buf, uint16_t len, uint32_t timeout_ms);

这里有个关键点:uart_receive是阻塞式接收,适合简单场景。而rx_callback是中断回调,适合实时响应。两种方式都要提供,让上层自己选。

我曾经踩过的坑:回调函数里做耗时操作,导致中断溢出。记住,回调里只做“标记”,不做“处理”。把数据扔进队列,让主循环去处理。

3.3 SPI与I2C抽象:总线协议的差异与统一

SPI和I2C虽然都是串行总线,但差异很大。SPI是主从全双工,I2C是半双工带地址。抽象时,要保留各自的特点,不能强行统一。

特性 SPI I2C
通信方式 全双工 半双工
从机选择 片选线(CS) 设备地址(7/10bit)
速度 可达几十MHz 标准100kHz/快速400kHz
硬件复杂度 简单(4线) 中等(2线+上拉)
典型应用 显示、ADC、SD卡 传感器、EEPROM、RTC
/* spi_hal.h */
typedef struct {
    uint8_t  mode;      /* 0~3, CPOL/CPHA组合 */
    uint32_t freq;      /* 目标频率 */
    uint8_t  data_order; /* 0:MSB first, 1:LSB first */
} spi_cfg_t;

int8_t spi_init(uint8_t ch, const spi_cfg_t *cfg);
int8_t spi_transfer(uint8_t ch, const uint8_t *tx, uint8_t *rx, uint16_t len);
int8_t spi_cs_set(uint8_t ch, uint8_t level);

/* i2c_hal.h */
typedef struct {
    uint32_t freq;      /* 目标频率 */
    uint8_t  addr_mode; /* 0:7bit, 1:10bit */
} i2c_cfg_t;

int8_t i2c_init(uint8_t ch, const i2c_cfg_t *cfg);
int8_t i2c_write(uint8_t ch, uint16_t addr, const uint8_t *data, uint16_t len);
int8_t i2c_read(uint8_t ch, uint16_t addr, uint8_t *buf, uint16_t len);
int8_t i2c_write_reg(uint8_t ch, uint16_t addr, uint8_t reg, uint8_t data);
int8_t i2c_read_reg(uint8_t ch, uint16_t addr, uint8_t reg, uint8_t *data);

注意I2C接口里我加了i2c_write_reg和i2c_read_reg。为什么?因为90%的I2C设备都是寄存器寻址的。把这个高频操作封装好,上层代码能少写一半。

3.4 定时器抽象:从硬件到逻辑的映射

定时器是最灵活的外设,可以当计数器、PWM、输入捕获、输出比较用。抽象时,我倾向于按“功能角色”来分,而不是按“硬件通道”来分。

/* timer_hal.h */
typedef enum {
    TIMER_MODE_ONESHOT,     /* 单次 */
    TIMER_MODE_PERIODIC,    /* 周期 */
    TIMER_MODE_PWM,
    TIMER_MODE_INPUT_CAPTURE
} timer_mode_t;

typedef void (*timer_cb_t)(void);

typedef struct {
    timer_mode_t mode;
    uint32_t     period_us;   /* 周期,微秒 */
    uint8_t      duty_cycle;  /* PWM占空比 0~100 */
    timer_cb_t   callback;
} timer_cfg_t;

int8_t timer_init(uint8_t ch, const timer_cfg_t *cfg);
int8_t timer_start(uint8_t ch);
int8_t timer_stop(uint8_t ch);
uint32_t timer_get_count(uint8_t ch);

这里有个设计取舍:period_us用微秒还是用时钟周期?我建议用微秒。因为上层逻辑不关心时钟频率,只关心时间。底层实现时再换算成计数值。

关键设计:定时器回调里绝对不能做malloc、printf等耗时操作。我见过有人直接在回调里打印日志,结果系统直接卡死。回调里只做“标记-处理”模式中的“标记”部分。

3.5 ADC抽象:采样与转换的分离

ADC的抽象核心是“采样-转换-读取”三个阶段的分离。不同芯片的ADC触发方式、转换时间、分辨率都不一样,但上层只关心“我要读一个通道的电压值”。

/* adc_hal.h */
typedef struct {
    uint8_t  channel;       /* ADC通道 */
    uint8_t  resolution;    /* 8/10/12/16 bit */
    uint32_t sample_time;   /* 采样时间,单位ADC时钟周期 */
} adc_cfg_t;

int8_t  adc_init(uint8_t ch, const adc_cfg_t *cfg);
int8_t  adc_start(uint8_t ch);
int8_t  adc_wait_complete(uint8_t ch, uint32_t timeout_ms);
uint32_t adc_get_value(uint8_t ch);
float   adc_get_voltage(uint8_t ch, float vref);

我特意加了adc_get_voltage这个函数。为什么?因为上层很多时候要的是“电压值”,不是“原始码值”。把转换公式封装在HAL层,上层就不用关心Vref和分辨率了。

避坑指南:ADC的采样时间不能太短,否则采样电容充不满,结果会偏小。我曾经在STM32上采样时间设了1.5个周期,结果10位ADC只跳了8位的范围。后来改成13.5个周期,问题解决。

3.6 寄存器操作与库函数封装:两种实现策略

HAL接口定义好了,接下来是实现。有两种主流方式:直接寄存器操作,或者封装厂商库函数。

对比项 寄存器操作 库函数封装
代码体积 小 较大
执行速度 快 稍慢(函数调用开销)
可读性 差(需要查手册) 好(函数名即文档)
移植性 差(完全绑定芯片) 中等(依赖厂商库)
调试难度 高 低

我个人习惯:性能敏感的部分用寄存器操作,比如定时器PWM、ADC高速采样;非性能敏感的部分用库函数封装,比如初始化配置、UART收发。

举个例子,GPIO的寄存器操作实现:

/* gpio_hal_stm32.c */
void gpio_set(uint8_t port, uint8_t pin, uint8_t level) {
    GPIO_TypeDef *gpio = (GPIO_TypeDef *)(GPIOA_BASE + port * 0x400);
    if (level) {
        gpio->BSRR = (1 << pin);        /* 置位 */
    } else {
        gpio->BSRR = (1 << (pin + 16)); /* 复位 */
    }
}

而库函数封装版本:

/* gpio_hal_stm32_ll.c */
void gpio_set(uint8_t port, uint8_t pin, uint8_t level) {
    GPIO_TypeDef *gpio = (GPIO_TypeDef *)(GPIOA_BASE + port * 0x400);
    LL_GPIO_SetOutputPin(gpio, (1 << pin));  /* 使用LL库 */
}

两种方式都可以,关键是接口要统一。上层代码调用gpio_set时,根本不知道底层是寄存器还是库函数。这就是抽象的力量。

重要提醒:不要在一个HAL实现里混用寄存器和库函数。要么全用寄存器,要么全用库函数。混用会导致维护噩梦——你永远不知道某个接口到底用了哪种方式。

3.7 HAL设计全景图

下面这张图展示了HAL层的整体结构。你可以看到,上层应用只和HAL接口打交道,底层实现可以随时切换。

硬件抽象层(HAL)架构图 硬件抽象层(HAL)架构 上层应用 / 协议栈 HAL 统一接口层 gpio_init | uart_send | spi_transfer | i2c_write | timer_start | adc_get_value 寄存器操作实现 直接操作内存映射寄存器 库函数封装实现 封装厂商SDK/LL库/HAL库 MCU 寄存器 / 外设 MCU 寄存器 / 外设 设计要点 • 接口层与实现层完全解耦,通过函数指针或条件编译切换 • 寄存器实现:性能优先,适合高频外设(定时器、ADC) • 库函数实现:可读性优先,适合初始化、配置等低频操作

这张图的核心思想是:接口是契约,实现是细节。上层应用只认契约,不认细节。换芯片时,只需要重新实现HAL接口层下面的部分,上面的所有代码都不用动。

3.8 小结:HAL设计的三个黄金法则

  1. 接口要稳定:一旦发布,尽量不改。改接口意味着改所有上层代码。
  2. 实现要隔离:寄存器操作和库函数封装不要混在同一个文件里。用条件编译或独立文件来区分。
  3. 错误要传递:所有HAL函数都要返回错误码。不要吞掉错误,上层需要知道底层发生了什么。

最后说一句:HAL层不是一次就能设计完美的。我做了十年嵌入式,每次换平台都会微调接口。但大方向不变——让上层代码永远不碰寄存器。做到这一点,你的架构就成功了一半。