2. HAL设计原则:模块化、接口分离、可移植与可测试

好,咱们直接切入正题。HAL设计不是拍脑袋的事,它背后有四个核心原则撑腰。我这些年做嵌入式项目,踩过的坑、翻过的车,十有八九都是因为没把这四条原则当回事。今天咱们一条一条掰开揉碎了讲。

2.1 模块化设计原则

什么叫模块化?说白了,就是把一个大系统拆成一个个独立的小积木。每个积木只管自己的事,不越界、不串门。

核心思想:高内聚、低耦合

高内聚,就是一个模块内部的功能要高度相关。比如你写一个UART驱动,那就只做UART的事——初始化、发送、接收、中断处理。别把什么定时器、GPIO的逻辑也塞进去。

低耦合,就是模块之间依赖要少。A模块改了一行代码,B模块不用跟着改,这才叫好设计。

我个人的习惯是:每个硬件外设对应一个独立的.c和.h文件。比如uart.c、uart.h、spi.c、spi.h。这样找代码、改代码都方便。

举个例子,我之前做一个STM32的项目,需要同时用UART1和UART2。如果我把两个UART的代码写在一个文件里,那后期调试的时候,改一个就得小心别碰坏另一个。拆成两个独立模块,各管各的,清爽多了。

// uart_module.h
#ifndef UART_MODULE_H
#define UART_MODULE_H

typedef struct {
    uint32_t baudrate;
    uint8_t data_bits;
    uint8_t stop_bits;
    uint8_t parity;
} uart_config_t;

void uart_init(uart_config_t *config);
void uart_send_byte(uint8_t data);
uint8_t uart_receive_byte(void);

#endif

你看,这个头文件只暴露了三个接口:初始化、发送、接收。内部怎么实现、用了哪个寄存器、怎么配置时钟,调用者一概不管。这就是模块化的好处——你只管用,别管我怎么干。

小技巧:模块化设计时,每个模块的全局变量尽量用static修饰,只在文件内部可见。对外只暴露必要的函数接口。这样别人想乱动你的内部数据都动不了。

2.2 接口与实现分离原则

这个原则,我愿称之为HAL设计的灵魂。你想想看,硬件是会变的——今天用STM32F4,明天可能换成GD32,后天又换成NXP的芯片。如果代码里到处都是硬件寄存器操作,那换一次芯片就得重写一遍代码,累不累?

接口与实现分离,就是定义一个稳定的接口层,把具体的硬件实现藏在后面。

接口层只声明“做什么”,不关心“怎么做”。实现层负责“怎么做”,但必须遵守接口层的约定。

我记得有一次,客户要求把产品从STM32F103移植到GD32F103上。因为两个芯片的寄存器地址和配置方式有差异,但外设功能基本一致。幸好我们当初做了接口与实现分离,只需要重写底层实现文件,上层应用代码一行没改。整个移植工作两天就搞定了。

// hal_gpio.h —— 接口层
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

typedef enum {
    GPIO_PIN_0 = 0,
    GPIO_PIN_1,
    // ...
    GPIO_PIN_15
} gpio_pin_t;

typedef enum {
    GPIO_MODE_INPUT,
    GPIO_MODE_OUTPUT,
    GPIO_MODE_AF,
    GPIO_MODE_ANALOG
} gpio_mode_t;

void hal_gpio_init(gpio_pin_t pin, gpio_mode_t mode);
void hal_gpio_set(gpio_pin_t pin);
void hal_gpio_reset(gpio_pin_t pin);
uint8_t hal_gpio_read(gpio_pin_t pin);

#endif
// hal_gpio_stm32.c —— 实现层(STM32版)
#include "hal_gpio.h"

void hal_gpio_init(gpio_pin_t pin, gpio_mode_t mode) {
    // 操作STM32的GPIO寄存器
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    GPIOA->MODER &= ~(0x3U << (pin * 2));
    GPIOA->MODER |= (mode << (pin * 2));
}

void hal_gpio_set(gpio_pin_t pin) {
    GPIOA->BSRR = (1U << pin);
}

void hal_gpio_reset(gpio_pin_t pin) {
    GPIOA->BSRR = (1U << (pin + 16));
}

uint8_t hal_gpio_read(gpio_pin_t pin) {
    return (GPIOA->IDR & (1U << pin)) ? 1 : 0;
}
// hal_gpio_gd32.c —— 实现层(GD32版)
#include "hal_gpio.h"

void hal_gpio_init(gpio_pin_t pin, gpio_mode_t mode) {
    // 操作GD32的GPIO寄存器(注意地址不同)
    RCU->AHB1EN |= RCU_AHB1EN_GPIOAEN;
    GPIOA->CTL &= ~(0x3U << (pin * 2));
    GPIOA->CTL |= (mode << (pin * 2));
}

void hal_gpio_set(gpio_pin_t pin) {
    GPIOA->BOP = (1U << pin);
}

void hal_gpio_reset(gpio_pin_t pin) {
    GPIOA->BC = (1U << pin);
}

uint8_t hal_gpio_read(gpio_pin_t pin) {
    return (GPIOA->ISTAT & (1U << pin)) ? 1 : 0;
}

看到没?上层应用调用hal_gpio_set()的时候,根本不知道底层是STM32还是GD32。这就是接口与实现分离的威力。

注意:接口一旦定义好,就不要轻易修改。改接口意味着所有实现层和所有调用层都要跟着改,代价巨大。我见过一个项目,因为接口设计不合理,后期改了三次接口,每次都是牵一发而动全身,团队差点崩溃。

2.3 可移植性设计原则

可移植性,说白了就是你的代码能不能轻松搬到另一个平台上。这跟接口与实现分离是孪生兄弟,但侧重点不同。

可移植性设计的关键点:

  • 避免硬编码:不要写死寄存器地址、时钟频率、引脚号。用宏定义或者配置文件来管理。
  • 使用标准数据类型:里的uint8_t、uint16_t、uint32_t,别用int、unsigned long这些不跨平台的东西。
  • 隔离编译器特性:用宏封装__attribute__、#pragma等编译器特有的东西。

我曾经接手过一个项目,代码里到处都是*(volatile uint32_t *)0x40020014 = 0x01;这种硬编码。想移植到新芯片?对不起,你得把所有地址重新查一遍、改一遍。那感觉,就像在垃圾堆里找宝贝。

// 不好的做法:硬编码
#define GPIOA_BASE 0x40020000
#define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00))

// 好的做法:用宏封装,方便修改
// 在芯片头文件中定义
#define STM32F4_GPIOA_BASE 0x40020000
#define STM32F4_GPIOA_MODER_OFFSET 0x00

// 在HAL层使用
#define GPIO_REG(port, offset) (*(volatile uint32_t *)((port) + (offset)))
#define GPIO_MODER(port) GPIO_REG(port, STM32F4_GPIOA_MODER_OFFSET)

这样,移植的时候只需要改芯片头文件里的基地址和偏移量,HAL层的代码基本不用动。

我的经验:可移植性设计要趁早。项目初期花一天时间搭好可移植的框架,后期能省下一个月的时间。别等到代码写了几万行才想起来要移植,那时候改起来真要命。

2.4 可测试性设计原则

嵌入式软件测试难,这是公认的。硬件依赖性强、实时性要求高、调试手段有限。但正因为难,才更要在设计阶段就把可测试性考虑进去。

可测试性设计的核心:

  • 依赖注入:把硬件依赖通过接口注入,而不是在函数内部直接调用硬件。
  • 模拟接口:提供模拟实现,方便在PC上做单元测试。
  • 状态可观测:模块内部的关键状态要能通过接口读取,方便调试。

举个例子,你写了一个温度传感器驱动,如果直接在驱动里调用I2C硬件接口,那测试的时候就必须接上真实的传感器和I2C总线。但如果把I2C操作抽象成一个接口,测试时注入一个模拟的I2C实现,那在PC上就能跑测试了。

// 可测试的设计:依赖注入
typedef struct {
    void (*i2c_write)(uint8_t dev_addr, uint8_t reg_addr, uint8_t data);
    uint8_t (*i2c_read)(uint8_t dev_addr, uint8_t reg_addr);
} i2c_bus_t;

typedef struct {
    i2c_bus_t *bus;
    uint8_t dev_addr;
} temp_sensor_t;

void temp_sensor_init(temp_sensor_t *sensor, i2c_bus_t *bus, uint8_t addr) {
    sensor->bus = bus;
    sensor->dev_addr = addr;
}

float temp_sensor_read(temp_sensor_t *sensor) {
    uint8_t raw = sensor->bus->i2c_read(sensor->dev_addr, 0x00);
    return (float)raw * 0.5f;
}

// 测试时,注入模拟I2C
void mock_i2c_write(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
    // 模拟写入,不操作真实硬件
}

uint8_t mock_i2c_read(uint8_t dev_addr, uint8_t reg_addr) {
    // 返回预设的测试数据
    return 0x1A;
}

void test_temp_sensor() {
    i2c_bus_t mock_bus = {mock_i2c_write, mock_i2c_read};
    temp_sensor_t sensor;
    temp_sensor_init(&sensor, &mock_bus, 0x48);
    float temp = temp_sensor_read(&sensor);
    // 断言 temp 是否等于 0x1A * 0.5
}

避坑指南:我曾经在一个项目里,把所有硬件操作都直接写在业务逻辑里。结果到了系统集成测试阶段,发现一个bug,但根本没法在PC上复现,必须连上真实硬件才能调试。每次改代码都要烧录、重启、看波形,一天下来也就能调两三个问题。后来重构代码,把硬件操作都抽象成接口,测试效率提升了十倍不止。

可测试性设计还有一个容易被忽略的点:日志和状态输出。在HAL层预留一个调试接口,可以输出当前模块的状态、配置参数、错误码。这样在系统出问题的时候,不用接仿真器,通过串口打印就能定位问题。

// 预留调试接口
typedef struct {
    uint32_t init_count;
    uint32_t error_count;
    uint32_t last_error_code;
    uint8_t is_initialized;
} hal_uart_debug_t;

void hal_uart_get_debug_info(hal_uart_debug_t *info);

嗯,这四个原则讲完了。模块化让你代码结构清晰,接口与实现分离让你换芯片不慌,可移植性让你跨平台轻松,可测试性让你调试不抓狂。这四个原则不是孤立的,它们是相互支撑的。你设计的时候,要同时考虑它们,缺一个都可能埋下隐患。

下一章咱们聊聊HAL的分层架构,看看这些原则在实际项目中是怎么落地的。