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 缓冲区设计」,那又是一个大坑。