第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);

中断模式的处理流程是这样的:

  1. 收到一个字节,触发中断
  2. 中断服务程序把数据写入环形缓冲区
  3. 通知上层应用(通过回调或信号量)
  4. 上层从缓冲区读取数据

DMA模式稍微复杂一点:

  1. 配置DMA通道,指定源地址、目的地址、传输长度
  2. 启动DMA传输
  3. DMA传输完成,触发完成中断
  4. 在完成中断里处理数据

我的经验: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层的整体架构。你可以看到,从最底层的硬件寄存器,到最上层的应用接口,中间经过了哪些抽象层。

UART HAL层架构图 应用层(Application) HAL API层(hal_uart_init/send/receive) 配置抽象层(波特率/数据位/停止位/校验) 传输模式层(中断模式 / DMA模式) 中断处理(ISR + 环形缓冲区) DMA处理(DMA通道 + 缓存管理) 硬件寄存器层(UARTx->DR, UARTx->CR1...)

这张图里,每一层都只依赖它的下一层。应用层不知道底层用的是中断还是DMA,配置抽象层不知道具体寄存器的地址。这就是分层设计的好处——解耦。

4.5 避坑指南

最后,分享几个我踩过的坑:

我曾经犯过的错:

  • 中断优先级没配好:有一次UART接收中断和定时器中断冲突,导致数据丢失。后来把所有UART中断优先级设成相同,用硬件自动仲裁,问题解决。
  • DMA传输长度没对齐:有些DMA控制器要求传输长度是4字节对齐的。我传了3个字节,结果最后一个字节没发出去。解决办法是补一个填充字节。
  • 环形缓冲区溢出:中断里写入缓冲区,主循环里读取。如果主循环处理太慢,缓冲区就满了。我后来加了溢出计数,方便调试。

我的建议:刚开始做UART HAL层时,先实现中断模式,跑通了再上DMA。中断模式调试起来方便,DMA模式一旦出问题,排查起来很头疼。另外,一定要加超时机制——我见过太多因为没有超时,系统卡死在等待UART数据上的案例。

好了,UART驱动的HAL设计就聊到这儿。说白了,就是把那些繁琐的寄存器操作、中断处理、DMA配置,封装成几个简单的接口。上层应用只管调用,底层怎么实现,它不关心。这就是HAL层的价值所在。

可可精品资料库