第4章:UART驱动HAL设计
UART这个外设,说简单也简单,说复杂也复杂。我刚开始做嵌入式那会儿,觉得UART不就是发几个字节嘛,有啥好封装的?直到后来接手一个项目,需要在多任务环境下同时处理三路UART数据,还要兼顾低功耗——嗯,那时候我才意识到,一个好的HAL层设计有多重要。
这一章,咱们就聊聊UART驱动的HAL层该怎么设计。说白了,就是把那些寄存器操作、中断处理、DMA传输这些底层细节,封装成一套干净、好用的接口。
4.1 收发缓冲机制
先说说缓冲。你想想看,CPU处理数据的速度和UART收发速度,完全不在一个量级上。如果没有缓冲,CPU就得一直等着,啥也干不了。
我个人习惯用环形缓冲区(Ring Buffer)。为什么不用队列?因为环形缓冲区不需要动态内存分配,在嵌入式系统里,这太重要了。
环形缓冲区的核心设计:
- 读指针(read_idx)和写指针(write_idx)
- 缓冲区大小是2的幂次,方便取模运算
- 空判断:read_idx == write_idx
- 满判断:(write_idx + 1) % size == read_idx
// 环形缓冲区结构体
typedef struct {
uint8_t *buffer;
uint32_t size;
volatile uint32_t read_idx;
volatile uint32_t write_idx;
} ring_buffer_t;
// 写入一个字节
bool ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
uint32_t next = (rb->write_idx + 1) & (rb->size - 1);
if (next == rb->read_idx) {
return false; // 缓冲区满了
}
rb->buffer[rb->write_idx] = data;
rb->write_idx = next;
return true;
}
// 读取一个字节
bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data) {
if (rb->read_idx == rb->write_idx) {
return false; // 缓冲区空了
}
*data = rb->buffer[rb->read_idx];
rb->read_idx = (rb->read_idx + 1) & (rb->size - 1);
return true;
}
小技巧:缓冲区大小用2的幂次,比如64、128、256。这样取模运算可以用位与操作代替,效率高很多。我在一个STM32项目里,把缓冲区从128改成256后,丢包率直接降为零。
4.2 波特率、数据位、停止位抽象
不同芯片的UART寄存器配置千差万别。有的用分数波特率发生器,有的用整数分频。但上层应用不关心这些,它只想知道:我要115200波特率,8位数据,1位停止位,无校验。
所以,HAL层要做的就是把这些参数抽象出来。
// UART配置结构体
typedef struct {
uint32_t baudrate; // 波特率:9600, 19200, 115200...
uint8_t data_bits; // 数据位:5, 6, 7, 8
uint8_t stop_bits; // 停止位:1, 2
uint8_t parity; // 校验:0=无, 1=奇校验, 2=偶校验
uint8_t flow_ctrl; // 流控:0=无, 1=硬件流控
} uart_config_t;
// HAL层初始化接口
int32_t hal_uart_init(uint8_t port, uart_config_t *config);
这里有个坑。我曾经在一个项目里,把波特率直接写死了115200。结果客户那边用的晶振不准,实际波特率差了3%,通信时好时坏。后来我改成动态计算分频系数,问题才解决。
注意:波特率误差要控制在2%以内。超过这个范围,数据就可能出错。尤其是高速通信(如921600波特率),对时钟精度要求更高。
4.3 DMA与中断模式封装
UART的数据传输,主要有两种方式:中断和DMA。中断适合小数据量、不定长的情况;DMA适合大数据量、固定长度的传输。
我的做法是,在HAL层提供统一的接口,底层自动选择模式。
// 发送接口
int32_t hal_uart_send(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout);
// 接收接口
int32_t hal_uart_receive(uint8_t port, uint8_t *data, uint32_t len, uint32_t timeout);
// 中断接收回调
typedef void (*uart_rx_callback_t)(uint8_t port, uint8_t data);
void hal_uart_set_rx_callback(uint8_t port, uart_rx_callback_t callback);
中断模式的处理流程是这样的:
- 收到一个字节,触发中断
- 中断服务程序把数据写入环形缓冲区
- 通知上层应用(通过回调或信号量)
- 上层从缓冲区读取数据
DMA模式稍微复杂一点:
- 配置DMA通道,指定源地址、目的地址、传输长度
- 启动DMA传输
- DMA传输完成,触发完成中断
- 在完成中断里处理数据
我的经验:DMA传输有个容易忽略的问题——缓存一致性。如果CPU有Cache,DMA直接访问内存,而Cache里的数据还没写回去,就会读到脏数据。解决办法是:在DMA传输前后,手动刷新Cache区域。
// DMA传输完成回调
void uart_dma_tx_complete(void *arg) {
uart_device_t *dev = (uart_device_t *)arg;
// 刷新Cache,确保数据被写入内存
cache_flush(dev->tx_buf, dev->tx_len);
// 通知上层
if (dev->tx_callback) {
dev->tx_callback(dev->port, dev->tx_len);
}
}
4.4 整体架构图
下面这张图,是我设计UART HAL层的整体架构。你可以看到,从最底层的硬件寄存器,到最上层的应用接口,中间经过了哪些抽象层。
这张图里,每一层都只依赖它的下一层。应用层不知道底层用的是中断还是DMA,配置抽象层不知道具体寄存器的地址。这就是分层设计的好处——解耦。
4.5 避坑指南
最后,分享几个我踩过的坑:
我曾经犯过的错:
- 中断优先级没配好:有一次UART接收中断和定时器中断冲突,导致数据丢失。后来把所有UART中断优先级设成相同,用硬件自动仲裁,问题解决。
- DMA传输长度没对齐:有些DMA控制器要求传输长度是4字节对齐的。我传了3个字节,结果最后一个字节没发出去。解决办法是补一个填充字节。
- 环形缓冲区溢出:中断里写入缓冲区,主循环里读取。如果主循环处理太慢,缓冲区就满了。我后来加了溢出计数,方便调试。
我的建议:刚开始做UART HAL层时,先实现中断模式,跑通了再上DMA。中断模式调试起来方便,DMA模式一旦出问题,排查起来很头疼。另外,一定要加超时机制——我见过太多因为没有超时,系统卡死在等待UART数据上的案例。
好了,UART驱动的HAL设计就聊到这儿。说白了,就是把那些繁琐的寄存器操作、中断处理、DMA配置,封装成几个简单的接口。上层应用只管调用,底层怎么实现,它不关心。这就是HAL层的价值所在。