4、UART驱动HAL封装:轮询、中断、DMA三种模式下的HAL接口设计

UART这个外设,说简单也简单,说复杂也复杂。简单是因为它就是个串行收发,复杂在于不同场景下对吞吐量和实时性的要求天差地别。我这些年调过的UART驱动少说也有几十个,从8位MCU到多核ARM SoC,踩过的坑真不少。

今天咱们就聊聊UART的HAL层该怎么封装。核心就三件事:轮询模式、中断模式、DMA模式。这三种模式不是互斥的,而是互补的。好的HAL设计,应该让上层应用能无缝切换,而不需要关心底层寄存器细节。

核心设计原则:HAL层要提供统一的接口语义,屏蔽硬件差异。上层调用的API签名不变,底层根据配置选择不同的传输路径。

4.1 三种模式的核心差异

先看一张对比表,把三种模式的区别理清楚。我习惯在设计前先列这个表,避免后面接口设计走偏。

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(忙等) 低(仅进出中断) 极低(硬件搬运)
实时性 取决于轮询频率 中断响应延迟 DMA传输完成中断
吞吐量 低(受CPU速度限制) 中(受中断频率限制) 高(接近总线极限)
适用场景 调试、低速率、简单系统 中低速、事件驱动 大数据块、高速传输
代码复杂度 最低 中等 较高(需配置DMA控制器)
内存开销 无额外 中断栈 DMA描述符、缓冲区

说白了,没有银弹。轮询模式适合调试阶段快速验证,中断模式是大多数产品的默认选择,DMA模式则是高性能场景的必选项。我见过不少工程师一上来就上DMA,结果小数据包场景下DMA配置开销比传输本身还大,得不偿失。

4.2 HAL接口设计:统一抽象层

接口设计的关键在于分层清晰。我一般把UART的HAL分成三层:

  • 底层硬件操作层:直接操作寄存器,与具体芯片绑定
  • 模式调度层:根据配置选择轮询/中断/DMA路径
  • 上层应用接口层:提供Send/Receive/Control等标准API

来看核心的数据结构和接口定义。这是我个人比较喜欢的一种风格,清晰且易于扩展。

/* UART配置结构体 */
typedef struct {
    uint32_t baud_rate;       /* 波特率 */
    uint8_t  data_bits;       /* 数据位:5/6/7/8 */
    uint8_t  stop_bits;       /* 停止位:1/2 */
    uint8_t  parity;          /* 校验:NONE/ODD/EVEN */
    uint8_t  mode;            /* 模式:POLL/IRQ/DMA */
    uint32_t tx_buf_size;     /* 发送缓冲区大小(中断/DMA模式) */
    uint32_t rx_buf_size;     /* 接收缓冲区大小(中断/DMA模式) */
    void     (*rx_callback)(uint8_t data);  /* 接收回调 */
    void     (*tx_callback)(void);          /* 发送完成回调 */
} uart_config_t;

/* UART句柄结构体 */
typedef struct {
    uart_regs_t    *regs;      /* 寄存器基地址 */
    uart_config_t   config;    /* 配置参数 */
    uint8_t        *tx_buf;    /* 发送环形缓冲区 */
    uint8_t        *rx_buf;    /* 接收环形缓冲区 */
    volatile uint32_t tx_head, tx_tail;  /* 发送缓冲区指针 */
    volatile uint32_t rx_head, rx_tail;  /* 接收缓冲区指针 */
    volatile uint8_t  tx_busy;           /* 发送忙标志 */
    volatile uint8_t  rx_busy;           /* 接收忙标志 */
} uart_handle_t;

个人经验:句柄结构体里的volatile关键字不能省。我在一个项目里就因为漏了tx_busy的volatile,导致中断和主循环之间数据竞争,排查了两天才找到问题。中断服务程序和主循环共享的变量,一定要加volatile。

标准API接口设计如下。注意看,三个模式共用同一套API签名,上层代码完全不需要知道底层用的是哪种模式。

/* 标准API接口 */
hal_status_t uart_init(uart_handle_t *handle, uart_config_t *config);
hal_status_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout);
hal_status_t uart_receive(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout);
hal_status_t uart_send_async(uart_handle_t *handle, uint8_t *data, uint32_t len);
hal_status_t uart_receive_async(uart_handle_t *handle, uint8_t *data, uint32_t len);
uint32_t     uart_available(uart_handle_t *handle);  /* 获取可读数据长度 */
hal_status_t uart_flush(uart_handle_t *handle);      /* 清空缓冲区 */
hal_status_t uart_deinit(uart_handle_t *handle);

你可能会问,为什么要有同步和异步两套接口?嗯,这里有个设计考量:同步接口(带timeout)适合轮询模式和简单的阻塞调用;异步接口(不带timeout)适合中断和DMA模式,数据收发在后台进行,通过回调通知上层。

4.3 轮询模式实现要点

轮询模式最简单,但容易写错。核心就是死等状态寄存器。我见过有人用while(1)不加超时保护,一旦硬件异常,整个系统就卡死了。

/* 轮询模式发送实现 */
hal_status_t uart_send_poll(uart_handle_t *handle, uint8_t *data, uint32_t len)
{
    uint32_t timeout;
    
    for (uint32_t i = 0; i < len; i++) {
        /* 等待发送缓冲区空,带超时保护 */
        timeout = UART_POLL_TIMEOUT;
        while (!(handle->regs->SR & UART_SR_TXE)) {
            if (--timeout == 0) {
                return HAL_TIMEOUT;  /* 超时返回 */
            }
        }
        handle->regs->DR = data[i];
    }
    
    /* 等待发送完成(最后一个字节) */
    timeout = UART_POLL_TIMEOUT;
    while (!(handle->regs->SR & UART_SR_TC)) {
        if (--timeout == 0) {
            return HAL_TIMEOUT;
        }
    }
    
    return HAL_OK;
}

避坑指南:我曾经在一个项目中,轮询发送时只检查了TXE(发送缓冲区空),没检查TC(发送完成)。结果最后一个字节还没从移位寄存器发出去,我就关了UART时钟,导致数据丢失。记住:TXE表示数据进了移位寄存器,TC表示数据已经从引脚上发出去了。

4.4 中断模式实现要点

中断模式的核心是环形缓冲区。生产者(中断服务程序)往缓冲区写数据,消费者(主循环)从缓冲区读数据。设计好环形缓冲区的读写指针是关键。

/* 中断模式接收中断服务程序 */
void uart_rx_irq_handler(uart_handle_t *handle)
{
    uint8_t data;
    uint32_t next_tail;
    
    /* 读取数据 */
    data = handle->regs->DR;
    
    /* 计算下一个尾指针位置 */
    next_tail = (handle->rx_tail + 1) % handle->config.rx_buf_size;
    
    /* 检查缓冲区是否满 */
    if (next_tail != handle->rx_head) {
        handle->rx_buf[handle->rx_tail] = data;
        handle->rx_tail = next_tail;
        
        /* 调用上层回调 */
        if (handle->config.rx_callback) {
            handle->config.rx_callback(data);
        }
    } else {
        /* 缓冲区溢出,记录错误 */
        handle->overflow_flag = 1;
    }
}

中断模式有个常见问题:中断频率过高。如果波特率是115200,每接收一个字节就触发一次中断,那每秒要进中断约11520次。每次中断都有上下文切换开销,CPU占用率不容忽视。这时候就该考虑DMA了。

4.5 DMA模式实现要点

DMA模式把数据搬运工作交给硬件,CPU只在传输完成时收到一次中断。对于大数据块传输,效率提升非常明显。

/* DMA模式配置示例 */
hal_status_t uart_dma_config(uart_handle_t *handle)
{
    /* 配置DMA通道 */
    dma_channel_config_t dma_cfg;
    
    /* 发送通道:内存到外设 */
    dma_cfg.src_addr    = (uint32_t)handle->tx_buf;
    dma_cfg.dst_addr    = (uint32_t)&handle->regs->DR;
    dma_cfg.src_inc     = DMA_ADDR_INC_ENABLE;    /* 源地址递增 */
    dma_cfg.dst_inc     = DMA_ADDR_INC_DISABLE;   /* 目标地址固定 */
    dma_cfg.data_width  = DMA_DATA_WIDTH_8BIT;
    dma_cfg.transfer_mode = DMA_MODE_NORMAL;
    dma_channel_init(DMA_CH_UART_TX, &dma_cfg);
    
    /* 接收通道:外设到内存 */
    dma_cfg.src_addr    = (uint32_t)&handle->regs->DR;
    dma_cfg.dst_addr    = (uint32_t)handle->rx_buf;
    dma_cfg.src_inc     = DMA_ADDR_INC_DISABLE;
    dma_cfg.dst_inc     = DMA_ADDR_INC_ENABLE;
    dma_cfg.data_width  = DMA_DATA_WIDTH_8BIT;
    dma_cfg.transfer_mode = DMA_MODE_CIRCULAR;     /* 循环模式,持续接收 */
    dma_channel_init(DMA_CH_UART_RX, &dma_cfg);
    
    /* 使能UART的DMA请求 */
    handle->regs->CR3 |= UART_CR3_DMAT | UART_CR3_DMAR;
    
    return HAL_OK;
}

关键点:DMA接收建议使用循环模式(Circular Mode),配合半传输中断和完成中断,可以实现双缓冲机制。上层在处理一半数据时,DMA继续往另一半写数据,互不干扰。

4.6 三种模式的统一调度

现在来看HAL层如何根据配置自动选择模式。我习惯在uart_send和uart_receive内部做一个模式分发。

/* 统一发送接口 */
hal_status_t uart_send(uart_handle_t *handle, uint8_t *data, uint32_t len, uint32_t timeout)
{
    if (handle == NULL || data == NULL || len == 0) {
        return HAL_INVALID_PARAM;
    }
    
    switch (handle->config.mode) {
        case UART_MODE_POLL:
            return uart_send_poll(handle, data, len, timeout);
            
        case UART_MODE_IRQ:
            return uart_send_irq(handle, data, len);
            
        case UART_MODE_DMA:
            return uart_send_dma(handle, data, len);
            
        default:
            return HAL_ERROR;
    }
}

这种设计的好处是:上层应用只需要调用uart_send,底层模式切换对上层完全透明。我在一个项目中,调试阶段用轮询模式,性能测试时切到DMA模式,上层代码一行没改。

4.7 知识结构总览

下面这张图把UART HAL封装的整体结构画出来了。三种模式从配置到接口,再到底层实现,一目了然。

UART HAL封装知识结构图 上层应用接口层 uart_send / uart_receive / uart_control 模式调度器(根据config.mode分发) 轮询模式实现 中断模式实现 DMA模式实现 寄存器操作 中断服务程序 + 环形缓冲区 DMA控制器配置 设计要点 1. 统一API接口,上层不感知底层模式 2. 环形缓冲区是中断模式的核心,注意读写指针的原子操作 3. DMA循环模式配合双缓冲,实现零拷贝接收

4.8 性能对比与选型建议

最后给个实际测试数据。我在一个Cortex-M4平台上,用1MHz的UART波特率,传输1KB数据,测了三种模式的性能。

模式 CPU占用率 传输时间 中断次数 推荐场景
轮询 100% ~10ms 0 调试、初始化自检
中断 ~15% ~10ms 1024 通用、小数据包
DMA <1% ~10ms 2 大数据块、低功耗

选型建议:如果数据包小于64字节,中断模式比DMA模式更高效。因为DMA的配置开销(描述符初始化、通道使能、中断处理)在小数据量下占比太高。我一般以128字节为分界线:小于128用中断,大于等于128用DMA。