硬件抽象层(HAL)设计原理

硬件抽象层,说白了就是给上层软件一个"干净"的接口。不管底下是Cortex-A53还是A72,上层驱动看到的都是一套统一的寄存器读写、中断注册、定时器配置的API。我最早接触这个概念是在做一款车机芯片的BSP时,当时芯片手册还没定稿,硬件寄存器地址天天变。如果没有HAL这一层隔离,整个驱动栈都得跟着改,那画面太美我不敢想。

HAL的层次结构

我个人习惯把HAL分成三层,每层干每层的活,互不越界:

层次 职责 典型内容
芯片级HAL 直接操作寄存器、内存映射、GIC配置 MMU页表、异常向量表、时钟门控
外设级HAL 封装UART/SPI/I2C等外设的寄存器操作 波特率设置、FIFO控制、中断使能
接口级HAL 向上层OS或中间件提供标准API hal_uart_send()、hal_spi_transfer()

你想想看,芯片级HAL是跟硬件绑死的,换芯片就得重写。但接口级HAL一旦定下来,上层驱动基本不用动。我在一个项目里见过有人把寄存器地址直接写在应用代码里——嗯,后来那颗芯片改版了,地址偏移变了,整个团队加班两周改代码。从那以后,我再也不敢跳过HAL分层。

核心原则:芯片级HAL可以"脏",但接口级HAL必须"干净"。接口级HAL的API签名一旦发布,尽量只增不改。

设备驱动模型

ARM平台上的设备驱动模型,我建议你直接参考Linux的设备树(Device Tree)思路。每个外设都是一个节点,节点里描述寄存器基址、中断号、时钟频率。驱动通过HAL去解析这些信息,而不是硬编码。

举个例子,一个UART设备的描述:

/* 设备节点结构体 */
struct hal_device {
    const char *name;        /* 设备名,如 "uart0" */
    uintptr_t reg_base;      /* 寄存器基址 */
    uint32_t irq_num;        /* 中断号 */
    uint32_t clock_hz;       /* 时钟频率 */
    void *priv_data;         /* 私有数据,驱动自己用 */
};

/* 驱动注册接口 */
int hal_device_register(struct hal_device *dev);
struct hal_device *hal_device_find(const char *name);

这样做的好处是,换板子只需要改设备树文件,驱动代码一行不动。我曾经接手过一个项目,前任把所有外设基址都写在头文件里,每个板子一个头文件,维护起来简直是噩梦。后来我花了两天时间把设备模型重构出来,再也没人抱怨"板子不兼容"了。

寄存器抽象与内存映射

ARM的寄存器访问,说白了就是往特定内存地址读写数据。但直接操作地址太危险,我习惯封装成这样的宏或内联函数:

/* 寄存器读写抽象 */
#define REG_READ(addr)        (*(volatile uint32_t *)(addr))
#define REG_WRITE(addr, val)  (*(volatile uint32_t *)(addr) = (val))

/* 位域操作:更安全的方式 */
static inline void reg_set_bits(uintptr_t addr, uint32_t mask) {
    uint32_t val = REG_READ(addr);
    val |= mask;
    REG_WRITE(addr, val);
}

static inline void reg_clear_bits(uintptr_t addr, uint32_t mask) {
    uint32_t val = REG_READ(addr);
    val &= ~mask;
    REG_WRITE(addr, val);
}

为什么要加volatile?因为编译器会优化掉看似"无用"的重复读取。我在调试一个DMA传输问题时,发现数据死活不对,最后定位到是编译器把寄存器读取优化成了缓存值——加了volatile立刻解决。

注意:ARM架构下,外设寄存器通常映射到非缓存(Device-nGnRnE)内存域。如果MMU配置不对,CPU可能会把寄存器写入缓存起来,导致外设收不到数据。配置页表时,记得把外设地址空间设为强序(Strongly Ordered)或设备内存(Device Memory)。

中断控制器抽象(GIC)

ARM GIC(Generic Interrupt Controller)是中断管理的核心。GIC v2/v3/v4的寄存器布局不同,但抽象出来的接口可以统一:

GIC版本 特点 典型寄存器
GICv2 最多8个PE,SPI中断,不支持虚拟化 GICD_ISENABLER, GICC_IAR
GICv3 支持更多PE,LPI中断,支持虚拟化 GICD_ISENABLER, GICR_ISENABLER, ICC_IAR0_EL1
GICv4 直接注入虚拟中断(vLPI) GICR_VPENDBASER, GICD_VSETSPI_NSR

我建议的抽象接口长这样:

/* GIC HAL接口 */
void hal_gic_init(void);                    /* 初始化GIC */
void hal_gic_enable_irq(uint32_t irq_num);  /* 使能中断 */
void hal_gic_disable_irq(uint32_t irq_num); /* 关闭中断 */
void hal_gic_set_priority(uint32_t irq_num, uint8_t priority);
uint32_t hal_gic_ack_irq(void);             /* 获取当前中断号 */
void hal_gic_eoi(uint32_t irq_num);         /* 结束中断 */

为什么要把EOI单独抽出来?因为多核系统里,中断处理可能跨核。我在一个AMP(非对称多核)项目里遇到过,Core0处理完中断后EOI,但中断实际是Core1响应的——GIC直接挂掉。后来我改成在中断处理函数末尾统一调用hal_gic_eoi,才把问题解决。

经验之谈:GIC的优先级分组(Priority Group)配置一定要在系统启动早期完成。我见过有人在任务创建后才配置GIC,结果高优先级中断把低优先级中断的处理上下文搞乱了,系统随机死机。配置GIC的黄金法则是:先配GIC,再开中断,最后创建任务。

时钟与定时器抽象

ARM平台上的时钟源五花八门:系统定时器(System Timer)、通用定时器(Generic Timer)、PWM定时器、看门狗定时器。但上层软件只关心两件事:获取当前时间和设置定时回调。

我常用的抽象接口:

/* 定时器HAL接口 */
uint64_t hal_timer_get_us(void);            /* 获取微秒级时间戳 */
int hal_timer_start(uint32_t us, void (*callback)(void *), void *arg);
int hal_timer_stop(void);
uint64_t hal_timer_get_freq(void);          /* 获取定时器频率 */

这里有个坑:ARM Generic Timer的计数器频率在不同芯片上可能不一样。有的芯片是24MHz,有的是100MHz。如果你硬编码频率,换芯片就崩。我建议在hal_timer_init()里通过读取CNTFRQ_EL0寄存器来获取实际频率,而不是写死。

/* 获取ARM Generic Timer频率 */
static uint64_t timer_freq;
void hal_timer_init(void) {
    __asm__ volatile("mrs %0, cntfrq_el0" : "=r"(timer_freq));
    /* 如果频率为0,使用默认值 */
    if (timer_freq == 0) {
        timer_freq = 24000000; /* 保守默认值 */
    }
}

为什么会读到0?有些模拟器或早期芯片的CNTFRQ_EL0还没实现。我踩过这个坑,所以加了回退逻辑。

总结一下:HAL设计的核心就是"隔离变化"。芯片级HAL可以随硬件变,但接口级HAL要像磐石一样稳定。寄存器抽象用volatile+位域操作,GIC抽象要处理好跨核EOI,定时器抽象要动态获取频率。做到这几点,你的HAL就能扛住芯片迭代和板级变化。

HAL层次结构与核心抽象 应用 / RTOS / Linux 接口级HAL(标准API) 外设级HAL(UART / SPI / I2C / DMA) 芯片级HAL(寄存器 / GIC / 定时器 / 时钟) ARM SoC 硬件(Cortex-A / GIC / Timer) 核心抽象:寄存器读写(volatile)→ GIC中断使能/EOI → 定时器频率动态获取 芯片级HAL可随硬件变化,接口级HAL保持稳定,上层驱动不感知硬件差异