第4章 UART驱动HAL封装:轮询、中断与DMA三模式设计

UART这个外设,说简单也简单,说复杂也复杂。我做了这么多年嵌入式,几乎每个项目都离不开它。调试打印、协议通信、固件升级……UART就像嵌入式世界的"通用语言"。

但说实话,很多工程师写UART驱动就是"能用就行"。轮询模式一把梭,中断处理写得稀里糊涂,DMA更是碰都不碰。这样搞,小项目还行,一旦上了产品级系统,问题就全冒出来了。

这一章,我就带你把UART的HAL封装彻底讲透。三种模式——轮询、中断、DMA,每种模式的接口怎么设计,波特率怎么配,数据收发怎么搞,咱们一个一个来。

核心观点:好的HAL设计,应该让上层应用"无感切换"三种模式。也就是说,应用层调用的API接口不变,底层驱动根据场景自动选择轮询/中断/DMA。

4.1 UART HAL的接口抽象

先说说接口设计。我个人习惯,把UART的HAL接口分成三层:

  • 底层硬件操作层——直接读写寄存器,跟芯片绑死
  • HAL抽象层——封装成统一的API,屏蔽芯片差异
  • 应用层——只管调用,不管底层是STM32还是GD32

咱们重点在中间这层。来看一个典型的UART HAL接口定义:

/* UART HAL 接口定义 */
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;     /* 流控:NONE/RTS_CTS */
    uint8_t  mode;          /* 模式:POLL/IRQ/DMA */
} uart_config_t;

/* HAL API 原型 */
int32_t uart_init(uart_handle_t *handle, uart_config_t *config);
int32_t uart_deinit(uart_handle_t *handle);
int32_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_send_it(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv_it(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_send_dma(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_recv_dma(uart_handle_t *handle, uint8_t *data, uint32_t len);
void    uart_irq_handler(uart_handle_t *handle);
void    uart_dma_tx_cplt_cb(uart_handle_t *handle);
void    uart_dma_rx_cplt_cb(uart_handle_t *handle);

你可能会问:为什么要把三种模式分开成不同的API?统一成一个不行吗?

嗯,这里有个坑。我曾经在一个项目里试图用"万能接口",传个参数决定用哪种模式。结果呢?代码耦合得一塌糊涂,中断回调里还要判断模式,DMA完成回调里还要处理轮询逻辑……最后我自己都看不下去了。

我的建议是:接口可以统一命名规范,但底层实现必须分开。轮询就是轮询,中断就是中断,DMA就是DMA。上层调用时,根据场景选对应的API就行。

4.2 波特率配置:看似简单,实则暗藏玄机

波特率配置,说白了就是算一个分频系数。但不同芯片的算法不一样,这也是HAL层要屏蔽的差异。

以常见的ARM Cortex-M芯片为例,波特率计算公式一般是:

/* 波特率计算(以STM32 USART为例) */
/* 公式:baud = fclk / (16 * usartdiv) */
/* 其中 usartdiv 包含整数部分和小数部分 */

uint32_t uart_calc_bauddiv(uint32_t fclk, uint32_t baudrate)
{
    uint32_t div = 0;
    uint32_t tmp = 0;
    
    /* 整数部分 */
    div = fclk / (16 * baudrate);
    /* 小数部分(取整到最近的1/16) */
    tmp = (fclk * 16) / (16 * baudrate) - div * 16;
    
    return (div << 4) | (tmp & 0x0F);
}

这里有个细节:小数部分的舍入。我遇到过一个问题,某个芯片的UART在115200波特率下,小数部分舍入误差导致实际波特率偏差超过2%。这在普通通信中可能没问题,但如果你在做Modbus或者DMX512这种对时序敏感的应用,就会丢帧。

避坑指南:波特率误差超过±2%时,建议换一个晶振频率或者用PLL重新分频。我曾经在一个项目中,为了省成本用了内部RC振荡器,结果波特率偏差3.5%,通信时好时坏,排查了两天才找到原因。

所以,HAL层在配置波特率时,最好加一个误差检查:

/* 波特率误差检查 */
int32_t uart_check_baudrate(uint32_t fclk, uint32_t baudrate)
{
    uint32_t actual = fclk / (16 * (fclk / (16 * baudrate)));
    int32_t error = (int32_t)(actual - baudrate) * 100 / (int32_t)baudrate;
    
    if (error > 2 || error < -2) {
        return -1;  /* 误差超限 */
    }
    return 0;
}

4.3 轮询模式:简单但别滥用

轮询模式,说白了就是CPU死等。发送时循环检查发送完成标志,接收时循环检查数据到达标志。

代码很简单:

/* 轮询模式发送 */
int32_t uart_send_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
    for (uint32_t i = 0; i < len; i++) {
        /* 等待发送缓冲区空 */
        while (!(handle->reg->SR & UART_SR_TXE));
        /* 发送数据 */
        handle->reg->DR = data[i];
    }
    /* 等待发送完成 */
    while (!(handle->reg->SR & UART_SR_TC));
    return 0;
}

/* 轮询模式接收 */
int32_t uart_recv_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
    for (uint32_t i = 0; i < len; i++) {
        /* 等待数据到达 */
        while (!(handle->reg->SR & UART_SR_RXNE));
        /* 读取数据 */
        data[i] = (uint8_t)(handle->reg->DR & 0xFF);
    }
    return 0;
}

轮询模式最大的问题是什么?阻塞!CPU在等待期间啥也干不了。你想想看,如果波特率是9600,发送1KB数据,CPU要白白等差不多1秒钟。这在实时系统里简直是灾难。

我的建议:轮询模式只用在两个场景——系统初始化阶段的调试打印,或者低优先级的数据收发。其他情况,老老实实用中断或DMA。

4.4 中断模式:最常用的方案

中断模式,就是让硬件在数据到达或发送完成时通知CPU。CPU不用死等,可以干别的事。

中断模式的HAL设计,核心在于环形缓冲区。我习惯在handle结构体里放两个环形缓冲区,一个收一个发:

/* UART handle结构体(中断模式) */
typedef struct {
    uart_reg_t *reg;            /* 寄存器基址 */
    
    /* 发送环形缓冲区 */
    uint8_t     tx_buf[UART_TX_BUF_SIZE];
    uint32_t    tx_head;
    uint32_t    tx_tail;
    uint32_t    tx_count;
    
    /* 接收环形缓冲区 */
    uint8_t     rx_buf[UART_RX_BUF_SIZE];
    uint32_t    rx_head;
    uint32_t    rx_tail;
    uint32_t    rx_count;
    
    /* 回调函数 */
    void (*tx_cplt_cb)(void *arg);
    void (*rx_cplt_cb)(void *arg, uint8_t data);
    void *cb_arg;
} uart_handle_t;

中断服务函数怎么写?我见过很多工程师把整个收发逻辑都塞进ISR里,结果中断响应时间飙到几十微秒。正确的做法是:ISR里只做最必要的事——把数据搬进缓冲区,然后清标志位。

/* UART中断服务函数(精简版) */
void uart_irq_handler(uart_handle_t *handle)
{
    uint32_t sr = handle->reg->SR;
    
    /* 接收中断 */
    if (sr & UART_SR_RXNE) {
        uint8_t data = (uint8_t)(handle->reg->DR & 0xFF);
        /* 放入环形缓冲区 */
        uint32_t next = (handle->rx_head + 1) % UART_RX_BUF_SIZE;
        if (next != handle->rx_tail) {
            handle->rx_buf[handle->rx_head] = data;
            handle->rx_head = next;
            handle->rx_count++;
        }
        /* 调用回调 */
        if (handle->rx_cplt_cb) {
            handle->rx_cplt_cb(handle->cb_arg, data);
        }
    }
    
    /* 发送中断(发送缓冲区空) */
    if (sr & UART_SR_TXE) {
        if (handle->tx_count > 0) {
            handle->reg->DR = handle->tx_buf[handle->tx_tail];
            handle->tx_tail = (handle->tx_tail + 1) % UART_TX_BUF_SIZE;
            handle->tx_count--;
        } else {
            /* 没有数据要发了,关闭发送中断 */
            handle->reg->CR1 &= ~UART_CR1_TXEIE;
            /* 发送完成回调 */
            if (handle->tx_cplt_cb) {
                handle->tx_cplt_cb(handle->cb_arg);
            }
        }
    }
}
经验之谈:环形缓冲区的头尾指针操作,一定要用"取模"或者"与运算"来防止溢出。我见过有人直接用++,结果缓冲区满了之后数据覆盖得一塌糊涂。另外,ISR里尽量不要调用复杂的回调函数,尤其是那些可能触发调度器的回调——搞不好就死锁了。

4.5 DMA模式:高性能的终极方案

DMA模式,就是把数据搬运的工作交给DMA控制器,CPU彻底解放。适合大数据量、高速率的场景,比如用UART做固件升级、日志输出等。

DMA模式的HAL设计,比中断模式多了一个东西——DMA通道的配置和管理。来看代码:

/* DMA模式发送 */
int32_t uart_send_dma(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
    /* 配置DMA */
    dma_channel_config_t dma_cfg;
    dma_cfg.src_addr      = (uint32_t)data;
    dma_cfg.dst_addr      = (uint32_t)&handle->reg->DR;
    dma_cfg.transfer_size = len;
    dma_cfg.src_inc       = DMA_ADDR_INC_ENABLE;   /* 源地址递增 */
    dma_cfg.dst_inc       = DMA_ADDR_INC_DISABLE;  /* 目标地址固定 */
    dma_cfg.circular      = DMA_MODE_NORMAL;       /* 非循环模式 */
    
    /* 启动DMA传输 */
    dma_channel_start(handle->dma_tx_ch, &dma_cfg);
    
    /* 使能UART的DMA发送 */
    handle->reg->CR3 |= UART_CR3_DMAT;
    
    return 0;
}

这里有个关键点:DMA传输完成后,一定要在回调里关闭UART的DMA使能位。不然下次传输时,硬件可能还在用上次的DMA配置。

避坑指南:DMA模式下的UART接收,要特别注意"空闲中断"的处理。因为DMA是连续搬运数据的,你不知道一帧数据什么时候结束。我常用的做法是:使能UART的空闲中断(IDLE),当总线空闲超过一个字节时间时,触发中断,然后在中断里处理接收完成的数据。

4.6 三种模式的对比与选择

说了这么多,到底什么时候用哪种模式?我整理了一个表格,方便你对照:

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(阻塞等待) 低(仅ISR处理) 极低(硬件搬运)
实时性 差(阻塞) 好(中断响应) 好(后台传输)
数据吞吐量 低 中 高
代码复杂度 低 中 高
适用场景 调试打印、低速率 常规通信、协议交互 大数据量、高速率
内存开销 无 环形缓冲区 DMA描述符+缓冲区

我个人习惯是:调试阶段用轮询,产品阶段用中断,大数据场景用DMA。但这不是绝对的,具体还得看你的系统需求。

4.7 整体架构图

下面这张图,把UART HAL的整体架构和三种模式的数据流画出来了。你可以对照着理解:

UART HAL三模式架构图 UART HAL 三模式架构与数据流 应用层(Application) HAL API 统一接口 轮询模式 中断模式 DMA模式 CPU 循环等待标志位 ISR + 环形缓冲区 DMA 控制器搬运数据 UART 硬件外设(寄存器层) 物理层(TX/RX 引脚) 三种模式共享同一套HAL API接口,上层应用无需关心底层实现方式

4.8 实战中的几个关键点

最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路:

  1. 波特率切换时要复位UART——有些芯片在运行中改波特率会导致数据错乱,最好先停掉UART,配置完再重新使能。
  2. 中断优先级要合理设置——UART中断优先级不要太高,否则会阻塞其他更紧急的中断(比如系统滴答定时器)。我一般设在中等级别。
  3. DMA缓冲区对齐——有些DMA控制器要求缓冲区地址按4字节对齐,不然会触发总线错误。这个在芯片手册里一般会写,但很容易被忽略。
  4. 环形缓冲区的大小要合理——太小容易溢出,太大浪费内存。我一般根据波特率和最大帧长度来算:缓冲区大小 = 波特率 / 10 * 最大响应时间(秒)。
一个小技巧:调试UART驱动时,可以用逻辑分析仪抓波形。看起始位、数据位、停止位的时序对不对,比看代码排查快得多。我每次调UART驱动,第一件事就是上逻辑分析仪。