第三章 硬件抽象层(HAL)设计:GPIO、UART、SPI、定时器底层驱动封装、中断管理、DMA配置
说实话,很多工程师拿到一款新芯片,第一件事就是对着寄存器手册开始写驱动。我年轻时候也这么干过,结果项目做到一半,换了个芯片型号,代码几乎全废。从那以后,我养成了一个习惯——先搭好硬件抽象层,也就是HAL。
HAL说白了就是给底层硬件穿上一件“通用外衣”。上层应用不用管你用的是STM32还是GD32,甚至国产某款RISC-V内核的片子,调用同样的API就能干活。今天我就把这几年在工业通信芯片上折腾HAL的经验,掰开了揉碎了讲给你听。
核心思想:HAL层存在的意义,就是让协议栈代码与芯片厂商解耦。你想想看,如果Modbus协议栈里直接操作了某个寄存器的地址,换芯片时得改多少地方?
3.1 GPIO底层驱动封装
GPIO看似简单,但工业场景下坑不少。我遇到过最典型的问题:某次现场设备频繁重启,查了三天,最后发现是GPIO初始化时,某个未使用的引脚悬空,导致电平漂移触发了看门狗。
所以我的GPIO封装一般长这样:
/* hal_gpio.h */
typedef struct {
uint8_t port; /* 端口号,比如GPIOA=0, GPIOB=1 */
uint8_t pin; /* 引脚号,0-15 */
uint8_t mode; /* 输入/输出/复用/模拟 */
uint8_t pull; /* 上拉/下拉/浮空 */
uint8_t speed; /* 低速/中速/高速/极速 */
uint8_t alt_func; /* 复用功能号,仅复用模式有效 */
} gpio_config_t;
/* 统一的初始化接口 */
int32_t hal_gpio_init(gpio_config_t *cfg);
/* 读写接口 */
void hal_gpio_write(gpio_config_t *cfg, uint8_t level);
uint8_t hal_gpio_read(gpio_config_t *cfg);
这里有个细节:alt_func字段。很多芯片的复用功能编号不统一,有的用数字0-15,有的用字母加数字。我建议在HAL层内部做一张映射表,把芯片原厂的编号转成统一的逻辑编号。这样上层协议栈永远只认逻辑编号。
我的习惯:每个GPIO配置结构体里,我都会预留一个reserved字段。别小看这个占位符,后期芯片升级、增加新功能时,你不需要改结构体大小,兼容性会好很多。
3.2 UART驱动封装——工业通信的命脉
UART在工业通信里太常见了,RS232、RS485、甚至某些自定义协议都基于它。我做过一个项目,波特率要支持从1200到921600自适应检测。嗯,这里要注意,UART驱动不能只做简单的收发。
我推荐的UART HAL层设计:
/* hal_uart.h */
typedef struct {
uint32_t baudrate;
uint8_t data_bits; /* 5,6,7,8 */
uint8_t stop_bits; /* 1,2 */
uint8_t parity; /* NONE/ODD/EVEN */
uint8_t flow_ctrl; /* 无/硬件RTSCTS */
uint32_t rx_timeout_ms; /* 接收超时,单位毫秒 */
} uart_config_t;
/* 非阻塞收发 */
int32_t hal_uart_send(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t hal_uart_receive(uint8_t port, uint8_t *buf, uint32_t max_len, uint32_t timeout_ms);
/* 注册接收回调——这是关键 */
int32_t hal_uart_set_rx_callback(uint8_t port, void (*cb)(uint8_t *data, uint32_t len));
为什么我强调rx_timeout_ms?因为工业通信中,一帧数据可能不是连续到达的。比如Modbus RTU要求3.5个字符时间间隔判断帧结束。如果你在HAL层就把这个超时机制做好,上层协议栈会轻松很多。
我曾经踩过的坑:UART的FIFO深度不同芯片差异很大。有的芯片只有16字节FIFO,有的有128字节。如果你在HAL层里硬编码了FIFO阈值,换芯片后可能丢数据。我的做法是:在初始化时动态读取FIFO深度,然后自适应调整中断触发阈值。
3.3 SPI驱动封装——速度与稳定性的平衡
SPI在工业芯片里常用于ADC、DAC、外部存储器通信。速度可以跑到几十MHz,但也是噪声敏感型接口。我记得有一次,SPI通信偶尔出错,示波器一看,时钟线上有毛刺。后来加了软件滤波——在HAL层对MISO采样点做了调整。
SPI的HAL封装,我建议这样:
/* hal_spi.h */
typedef struct {
uint8_t mode; /* 0,1,2,3 对应CPOL/CPHA组合 */
uint32_t freq_hz; /* 目标频率 */
uint8_t data_size; /* 8位或16位 */
uint8_t bit_order; /* MSB_FIRST或LSB_FIRST */
uint8_t cs_pin; /* 片选引脚,用gpio_config_t描述 */
} spi_config_t;
/* 标准SPI收发 */
int32_t hal_spi_transfer(uint8_t bus, uint8_t *tx_data, uint8_t *rx_data, uint32_t len);
/* 针对工业场景的16位连续读写 */
int32_t hal_spi_write_read16(uint8_t bus, uint16_t reg_addr, uint16_t *data, uint32_t count);
这里有个设计取舍:片选引脚要不要放在SPI驱动里?我个人习惯是放在HAL层统一管理。因为很多工业芯片的片选时序有特殊要求——比如某些ADC需要在CS拉低后等待几个时钟周期才能开始传输。如果你把CS交给应用层,很容易出现时序错乱。
关键点:SPI的hal_spi_transfer函数,我建议用全双工模式实现。即使你只需要发送,也把接收缓冲区传进去(可以传NULL)。这样底层驱动可以统一处理,避免半双工切换带来的额外开销。
3.4 定时器底层驱动封装
定时器在协议栈里用途太多了:超时检测、周期性任务调度、PWM生成、输入捕获。我见过最糟糕的设计是——每个模块都自己开一个定时器,结果一个项目用了8个定时器,芯片资源直接爆了。
我的做法是:HAL层只提供“定时器资源管理器”。
/* hal_timer.h */
typedef enum {
TIMER_MODE_ONESHOT, /* 单次触发 */
TIMER_MODE_PERIODIC, /* 周期性触发 */
TIMER_MODE_FREERUN /* 自由运行,用于时间戳 */
} timer_mode_t;
typedef void (*timer_callback_t)(void *arg);
/* 申请一个定时器,返回定时器句柄 */
int32_t hal_timer_create(uint32_t period_us, timer_mode_t mode,
timer_callback_t cb, void *arg);
/* 启动/停止/修改周期 */
int32_t hal_timer_start(int32_t handle);
int32_t hal_timer_stop(int32_t handle);
int32_t hal_timer_set_period(int32_t handle, uint32_t period_us);
/* 获取系统滴答(微妙级) */
uint64_t hal_timer_get_tick_us(void);
注意hal_timer_get_tick_us这个函数。工业通信协议栈里,很多地方需要精确的时间戳——比如报文往返时间测量、超时判断。我建议底层用一个32位或64位的硬件定时器做自由运行计数器,精度至少到微秒级。
我的经验:定时器回调函数里不要做耗时操作。我曾经在回调里直接调用UART发送函数,结果导致定时器漂移严重。正确的做法是:回调里只设置一个标志位,或者往消息队列里扔一个事件,真正的处理放到主循环或任务里。
3.5 中断管理——别让中断成为噩梦
中断管理是HAL层最容易被忽视的部分。很多工程师觉得“中断来了就处理呗”,但工业现场电磁环境复杂,中断频繁触发时,处理不好就会导致系统崩溃。
我总结的中断管理三原则:
- 中断服务函数(ISR)要极简——只做最必要的操作,比如读数据寄存器、清标志位、触发DMA。
- 中断优先级要分层——工业通信中,UART接收中断通常最高,定时器次之,GPIO外部中断最低。
- 可嵌套中断要谨慎——有些芯片支持中断嵌套,但嵌套深度超过3层后,栈空间消耗很大。我建议在HAL层统一管理嵌套深度。
/* hal_interrupt.h */
typedef enum {
INT_PRIO_CRITICAL = 0, /* 最高,用于UART接收 */
INT_PRIO_HIGH = 1, /* 用于定时器 */
INT_PRIO_NORMAL = 2, /* 用于SPI/DMA */
INT_PRIO_LOW = 3 /* 用于GPIO */
} int_priority_t;
/* 注册中断处理函数 */
int32_t hal_interrupt_register(uint32_t irq_num, int_priority_t prio,
void (*handler)(void));
/* 全局中断开关 */
void hal_interrupt_global_enable(void);
void hal_interrupt_global_disable(void);
我曾经犯过的错:在ISR里调用printf打印调试信息。结果中断频繁触发时,printf占用了大量时间,导致其他中断丢失。后来我改用DMA+环形缓冲区的方式,在ISR里只把数据丢进缓冲区,打印放到后台任务里做。
3.6 DMA配置——让数据飞起来
DMA是工业通信芯片的加速器。没有DMA,UART接收一个字节就要进一次中断,CPU占用率直接拉满。有了DMA,数据可以在后台自动搬运,CPU只管处理业务逻辑。
DMA的HAL封装,我建议关注这几个点:
/* hal_dma.h */
typedef struct {
uint8_t channel; /* DMA通道号 */
uint8_t direction; /* MEM_TO_PERIPH / PERIPH_TO_MEM / MEM_TO_MEM */
uint8_t transfer_size; /* 字节/半字/字 */
uint8_t circular_mode; /* 是否循环模式 */
uint32_t src_inc; /* 源地址是否递增 */
uint32_t dst_inc; /* 目的地址是否递增 */
} dma_config_t;
/* 配置并启动DMA传输 */
int32_t hal_dma_transfer(dma_config_t *cfg,
uint32_t src_addr, uint32_t dst_addr, uint32_t len);
/* 注册DMA完成中断回调 */
int32_t hal_dma_set_complete_callback(uint8_t channel, void (*cb)(void));
/* 查询DMA传输状态 */
uint32_t hal_dma_get_remaining(uint8_t channel);
这里有个工业场景的典型用法:UART接收使用DMA循环模式。配置一个环形缓冲区,DMA自动把接收到的数据填进去。当DMA半满或全满时触发中断,CPU去处理数据。这样即使数据量很大,中断频率也大大降低。
重要提醒:DMA的地址对齐问题。有些芯片要求源地址和目的地址必须按传输宽度对齐。比如传输32位数据时,地址必须是4的倍数。我在HAL层里加了一个地址对齐检查函数,如果不对齐就返回错误码,避免运行时出现奇怪的问题。
3.7 本章知识体系总览
下面这张图是我自己整理的HAL层设计逻辑。你可以看到,所有外设驱动都通过统一的接口向上层提供服务,而底层则通过寄存器操作或芯片厂商的HAL库来实现。中间这一层,就是我们要精心设计的硬件抽象层。
这张图里,我特意把中断管理和DMA配置放在了中间位置。为什么?因为它们不是独立的外设,而是服务于GPIO、UART、SPI这些外设的“加速器”和“调度器”。设计HAL时,一定要把这几个模块的耦合关系想清楚。
最后说一句:HAL层设计没有标准答案。不同芯片、不同应用场景,封装粒度都不一样。但有一条原则我始终遵守——让上层代码永远不知道底层芯片的具体型号。做到这一点,你的协议栈移植工作就会轻松很多。
公众号:蓝海资料掘金营,微信deep3321