3、硬件抽象层(HAL)设计:HAL分层原则、寄存器操作封装、中断管理抽象、DMA控制器抽象、时钟与定时器抽象

各位同学,咱们今天聊聊硬件抽象层。说白了,HAL 就是给上层软件穿的一件「防弹衣」。芯片底层的寄存器地址、位域定义、外设时序,这些东西又碎又容易变。我做了这么多年通信芯片,最怕的就是上层协议栈直接操作寄存器——那简直是给自己埋雷。

嗯,咱们先定个调子:HAL 设计得好,后续移植就像换衣服;设计得烂,那就是拆房子。我个人习惯,把 HAL 分成三层来搞。

3.1 HAL 分层原则

我一般把 HAL 拆成三层:

  • 芯片寄存器层(HAL_LL):最底层,直接跟芯片手册打交道。定义寄存器地址、位域掩码、读写宏。这一层基本是「死代码」,芯片不变它就不变。
  • 外设驱动层(HAL_Driver):封装具体外设的操作,比如 SPI 发一个字节、UART 收一个包。这一层会调用 HAL_LL 的宏,但对外提供函数接口。
  • 硬件抽象接口层(HAL_IF):定义纯虚接口,比如 spi_transfer()timer_start()。上层代码只依赖这个接口,不关心底层是哪个芯片。

核心原则:上层不能直接看到寄存器地址。谁要是敢在协议栈里写 *(volatile uint32_t *)0x40001000,我建议他重写。

我在项目中遇到过一件事:某团队把寄存器地址直接写死在应用代码里,后来换了芯片版本,地址偏移了 0x100,结果全盘崩溃。你想想看,这种问题查起来多痛苦?

3.2 寄存器操作封装

寄存器操作,说白了就是读、写、置位、清零、读取位域。我习惯用宏来封装,因为内联函数在某些编译器下可能被优化掉,而宏是铁定展开的。

// 寄存器操作宏封装
#define REG_READ(addr)          (*(volatile uint32_t *)(addr))
#define REG_WRITE(addr, val)    (*(volatile uint32_t *)(addr) = (val))
#define REG_SET_BIT(addr, bit)  (REG_WRITE(addr, REG_READ(addr) | (1UL << (bit))))
#define REG_CLR_BIT(addr, bit)  (REG_WRITE(addr, REG_READ(addr) & ~(1UL << (bit))))

// 位域操作:提取和赋值
#define REG_GET_FIELD(addr, mask, shift)  ((REG_READ(addr) >> (shift)) & (mask))
#define REG_SET_FIELD(addr, mask, shift, val) \
    REG_WRITE(addr, (REG_READ(addr) & ~((mask) << (shift))) | (((val) & (mask)) << (shift)))

避坑指南:我曾经在 REG_SET_BIT 里忘了加括号,结果宏展开后优先级全乱了。记住,宏参数一定要加括号,每个参数都要加!

另外,我建议把寄存器地址定义成枚举,而不是宏。枚举在调试器里能直接看到符号,宏就只能看到数字了。

typedef enum {
    UART0_BASE = 0x40001000,
    UART1_BASE = 0x40002000,
    UART_DR    = 0x000,  // 数据寄存器偏移
    UART_FR    = 0x018,  // 标志寄存器偏移
} UART_RegMap;

3.3 中断管理抽象

中断管理,是 HAL 里最容易出幺蛾子的地方。为什么?因为中断是异步的,而且不同芯片的中断控制器完全不一样。NVIC、GIC、VIC……每个都有自己的脾气。

我的做法是:把中断抽象成三个操作——注册、使能、清除。

// 中断抽象接口
typedef void (*irq_handler_t)(void *arg);

typedef struct {
    irq_handler_t handler;
    void         *arg;
    uint32_t      irq_num;   // 芯片相关的中断号
} irq_entry_t;

// 接口声明
void hal_irq_register(uint32_t irq_num, irq_handler_t handler, void *arg);
void hal_irq_enable(uint32_t irq_num);
void hal_irq_disable(uint32_t irq_num);
void hal_irq_clear_pending(uint32_t irq_num);

嗯,这里有个细节:中断处理函数里尽量不要做复杂操作。我见过有人直接在 ISR 里做浮点运算,结果堆栈溢出,系统直接挂了。中断里只做标志位设置,具体处理放到主循环或任务里。

注意:不同芯片的中断优先级分组方式不同。有的用 4 位表示抢占优先级,有的用 3 位。我在移植时吃过这个亏——在芯片 A 上跑得好好的,换到芯片 B 上中断嵌套就乱了。所以,优先级分组也要抽象出来。

3.4 DMA 控制器抽象

DMA 这东西,说白了就是「数据搬运工」。但不同芯片的 DMA 控制器差异巨大:有的支持链表描述符,有的只支持单次传输;有的有 8 个通道,有的有 32 个。

我建议把 DMA 抽象成三个要素:通道描述符传输配置

// DMA 传输配置
typedef struct {
    uint32_t src_addr;
    uint32_t dst_addr;
    uint32_t size;          // 传输字节数
    uint8_t  src_width;     // 源数据宽度: 1/2/4 字节
    uint8_t  dst_width;     // 目的数据宽度
    uint8_t  src_inc;       // 源地址是否递增
    uint8_t  dst_inc;       // 目的地址是否递增
    uint8_t  channel;       // DMA 通道号
    uint8_t  priority;      // 优先级
} dma_transfer_t;

// 接口
int32_t hal_dma_transfer(dma_transfer_t *cfg);
int32_t hal_dma_abort(uint8_t channel);
int32_t hal_dma_wait_done(uint8_t channel, uint32_t timeout_ms);

我在项目中遇到过一个问题:某款芯片的 DMA 在传输完成后,如果不手动清除完成标志,下次传输就不会触发中断。这种坑,手册里往往写得很隐晦。所以,我建议在 hal_dma_transfer() 里自动做一次状态清除。

经验之谈:DMA 描述符最好用链表结构。这样你可以做「乒乓缓冲」——一个描述符在传输,另一个在准备数据,效率翻倍。

3.5 时钟与定时器抽象

时钟和定时器,是嵌入式系统的「心跳」。但不同芯片的时钟树复杂程度天差地别:有的用 PLL 倍频,有的用 RC 振荡器,有的还带 RTC。

我的抽象思路是:把时钟源、分频系数、使能控制分开。

// 时钟源枚举
typedef enum {
    CLK_SRC_HSE,    // 外部高速晶振
    CLK_SRC_HSI,    // 内部高速 RC
    CLK_SRC_LSE,    // 外部低速晶振
    CLK_SRC_LSI,    // 内部低速 RC
    CLK_SRC_PLL,    // 锁相环
} clk_source_t;

// 时钟配置
typedef struct {
    clk_source_t source;
    uint32_t     freq_hz;       // 目标频率
    uint32_t     pll_mult;      // PLL 倍频系数(仅 PLL 源有效)
    uint32_t     pll_div;       // PLL 分频系数
} clk_config_t;

// 定时器抽象
typedef struct {
    uint32_t     timer_id;
    uint32_t     period_us;     // 周期,微秒
    uint8_t      auto_reload;   // 是否自动重载
    irq_handler_t callback;     // 超时回调
    void         *arg;
} timer_config_t;

int32_t hal_clk_init(clk_config_t *cfg);
int32_t hal_clk_set_source(clk_source_t src);
uint32_t hal_clk_get_freq(clk_source_t src);

int32_t hal_timer_init(timer_config_t *cfg);
int32_t hal_timer_start(uint32_t timer_id);
int32_t hal_timer_stop(uint32_t timer_id);

嗯,这里要注意:定时器的回调函数是在中断上下文执行的。所以回调里不能做阻塞操作,不能调用 printf,不能申请内存。我曾经有个同事在定时器回调里调了 malloc,结果系统跑着跑着就死机了——因为 malloc 不是可重入的。

小技巧:如果你需要高精度的微秒级延时,别用软件循环。用定时器的硬件捕获模式,精度能到纳秒级。我在做基带芯片时,就是用定时器捕获来做符号同步的。

最后,我想说一句:HAL 设计没有银弹。每个芯片都有自己的脾气,但只要你把分层做好了,把接口抽象干净了,后续的移植工作就是改改底层宏和寄存器地址而已。说白了,HAL 就是给未来的自己留一条活路。

好,这一章就到这里。下一章咱们聊聊「内存管理与 DMA 缓冲区设计」,那又是一个大坑。