3. 硬件抽象层设计

硬件抽象层(HAL)位于设备驱动与上层业务逻辑之间,它的核心职责是把“具体芯片怎么操作”与“业务想做什么”解耦。没有 HAL 时,应用代码里会散落大量 *(volatile uint32_t*)0x40021000 |= (1<<4) 这类寄存器操作;一旦换 MCU 或改板卡,业务层就要跟着重写。HAL 的目标是让上层只看到“打开阀门”“读取温度”这样的语义接口,而把地址、位域、时序、片选、总线差异全部封装在下面。

寄存器映射:从地址到可读的符号

寄存器映射是 HAL 最底层的工作。它把芯片手册里的基地址、偏移量、位域定义,转成 C 语言可访问的结构体或宏。常见做法是用结构体指针覆盖外设地址空间:

typedef struct {
    volatile uint32_t CR;    // 0x00 控制寄存器
    volatile uint32_t SR;    // 0x04 状态寄存器
    volatile uint32_t DR;    // 0x08 数据寄存器
    volatile uint32_t BRR;   // 0x0C 波特率寄存器
} UART_TypeDef;

#define UART1_BASE  (0x40013800UL)
#define UART1       ((UART_TypeDef *)UART1_BASE)

这样 UART1->BRR = 0x683 就等价于向 0x4001380C 写入分频值。关键点有三:一是 volatile 必须加,防止编译器优化掉“看似无用”的读写;二是位域操作要区分读改写与写 1 清零,例如状态寄存器常是写 1 清标志,不能简单用 |=;三是保留位必须按手册要求写 0 或保持默认,否则可能触发未定义行为。工程上通常再包一层内联函数,如 uart_set_baud(UART1, 115200),把“算分频值”和“写寄存器”分开,便于测试和移植。

板级支持包:把“芯片”与“板子”分开

同一颗 MCU 可以焊在不同板卡上:UART1 可能接 RS485 收发器,也可能接调试串口;某个 GPIO 可能驱动继电器,也可能只是 LED。板级支持包(BSP)负责描述这些板级事实,而不是芯片事实。BSP 通常包含:引脚分配表、时钟树配置、外设实例到板级功能的映射、上下拉与驱动能力设置、以及板级初始化顺序。

一个实用的 BSP 接口可以这样组织:

typedef struct {
    const char *name;          // "rs485_a"
    UART_TypeDef *uart;        // UART2
    GPIO_TypeDef *de_port;     // 方向控制端口
    uint16_t de_pin;           // 方向控制引脚
    uint32_t baud;             // 115200
} bsp_uart_cfg_t;

const bsp_uart_cfg_t g_uart_map[] = {
    { "debug",  UART1, NULL,      0,  115200 },
    { "rs485_a",UART2, GPIOB,     7,   9600  },
};

上层通过名字查找配置,而不是直接写 UART2。换板时只改这张表,驱动和业务不动。BSP 还应集中处理时钟使能顺序:先开 GPIO 时钟,再配引脚复用,最后开外设时钟,顺序错了会出现“寄存器写了没反应”的典型问题。

传感器与执行器抽象:统一语义接口

传感器和执行器的抽象重点不是“读寄存器”,而是定义稳定的语义契约。对传感器,典型接口是 init、read、get_status;对执行器,是 init、set、get、stop。以温度传感器为例,底层可能是 I2C 的 LM75、SPI 的热电偶芯片或 ADC 采样的 NTC,但上层只关心“摄氏度浮点值 + 有效标志”。

抽象时要注意量纲和范围。建议在接口层统一为国际单位制:温度用摄氏度,压力用帕斯卡,阀门开度用 0.0~1.0 的归一化值。原始码值到物理量的换算放在驱动内,并保留校准参数。例如 NTC 用 Steinhart-Hart 或查表法,LM75 直接读 16 位寄存器高 9 位。执行器侧要处理“设定值—实际值”的差异:继电器只有开/关,比例阀有 0~100%,抽象接口应允许上层查询能力描述,避免把开关量当模拟量用。

硬件抽象层分层结构与跨平台适配关系 应用/业务逻辑层 只调用语义接口:read_temperature()、set_valve(0.65)、start_motor() 传感器/执行器抽象接口层 统一量纲与状态:温度(°C)、压力(Pa)、开度(0~1)、有效标志、错误码 能力描述:量程、分辨率、开关量/模拟量、响应时间 设备驱动层 I2C/SPI/ADC/PWM 时序、寄存器读写、校准换算、超时与重试 LM75 驱动、NTC 查表驱动、继电器驱动、比例阀 PWM 驱动 板级支持包 BSP 引脚分配、时钟树、外设实例映射 上下拉/驱动能力、板级初始化顺序 寄存器映射 基地址+偏移、位域定义、volatile 访问 读改写/写1清零、保留位处理 MCU 外设与板级硬件 UART / I2C / SPI / ADC / GPIO / 定时器 / 收发器 / 传感器 / 执行器 跨平台适配:替换 BSP 映射表与驱动实现,保持抽象接口不变,上层业务零修改 稳定契约 语义统一 可替换

跨平台适配策略

跨平台不是“写一套代码到处编译”,而是“接口稳定、实现可换”。常用策略有四种。第一种是编译期条件编译,用 #ifdef STM32F4 选择不同实现,简单但容易让代码变乱,适合差异小的场景。第二种是函数指针表,把驱动操作注册成结构体,运行时选择实现:

typedef struct {
    int (*init)(void *cfg);
    int (*read)(float *value);
    int (*set)(float value);
} hal_device_ops_t;

extern const hal_device_ops_t lm75_ops;
extern const hal_device_ops_t ntc_ops;

第三种是弱符号加覆盖,在公共层提供默认空实现,具体平台用强符号覆盖,适合启动代码和中断向量。第四种是配置表驱动,把板级差异收敛到 BSP 表里,驱动代码完全通用。工程上常混合使用:接口层用函数指针,板级差异用配置表,芯片差异用条件编译。

常见实现模式与落地建议

实际项目中,HAL 常见三种组织模式。一是“薄 HAL”,只做寄存器封装,驱动仍直接操作外设,移植时改动大但性能好。二是“厚 HAL”,把总线、设备、语义全部抽象,移植只换底层,但调用层次多、调试链路长。三是“中间件式 HAL”,按总线类型分文件,如 hal_i2c.c、hal_spi.c,设备驱动基于总线接口编写,兼顾复用与效率。

落地时建议遵守几条规则:接口函数返回统一错误码,不要用全局变量传错误;所有阻塞操作带超时,超时值可配置;初始化分“总线初始化”和“设备初始化”两阶段,便于定位问题;对写 1 清零、只读、保留位等特殊寄存器,在驱动内集中处理,不暴露给上层。验证时可以先写一个“回环测试”:对 UART 自发自收,对 I2C 读器件 ID,对 ADC 读已知基准电压,确认 HAL 通路正确后再接业务逻辑。这样,HAL 才真正成为可移植、可测试、可维护的工程基础。