4. UART串口驱动与DMA传输

串口,搞嵌入式的人天天打交道。但说真的,很多人用了好几年串口,还停留在“发个字符串、收个中断”的水平。我个人觉得,串口驱动写得好不好,直接决定了你整个系统的数据吞吐能力和CPU利用率。

这一章,咱们就把UART的轮询、中断、DMA三种模式掰开揉碎,再结合RT-Thread的串口设备驱动框架,手把手实现一个带DMA环形缓冲区的串口驱动。

4.1 UART三种工作模式:轮询、中断、DMA

先看一张对比表,心里有个底:

模式 CPU占用 实时性 适用场景 我的评价
轮询 极高(忙等) 差 调试打印、低速率 能用,但别认真
中断 中等 好 命令响应、小数据量 最常用,但高频时CPU扛不住
DMA 极低(几乎0) 好 大数据流、高速率 工业级标配

4.1.1 轮询模式

说白了就是CPU死盯着状态寄存器不放。代码简单,但效率极低。

// 轮询发送一个字节
void uart_poll_send(USART_TypeDef *USARTx, uint8_t data)
{
    while (!(USARTx->SR & USART_SR_TXE));
    USARTx->DR = data;
}

// 轮询接收一个字节
uint8_t uart_poll_recv(USART_TypeDef *USARTx)
{
    while (!(USARTx->SR & USART_SR_RXNE));
    return (uint8_t)(USARTx->DR & 0xFF);
}
注意:轮询模式下,如果对方不发数据,你的程序就卡死在while循环里。我在一个项目里见过有人用轮询收GPS数据,结果GPS模块掉电,整机死机——嗯,从那以后我再也不敢在正式产品里用轮询收数据了。

4.1.2 中断模式

中断模式是“有事你叫我,没事别烦我”。每收到一个字节,CPU暂停当前任务,跳进中断服务函数处理数据。

void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t data = USART1->DR;
        // 把data放入缓冲区
        ringbuf_push(&rx_ring, data);
    }
}

中断模式的问题在于:如果波特率很高(比如921600),每秒钟近10万个中断,CPU大部分时间都在进进出出中断。你想想看,这时候系统还能干别的吗?

4.1.3 DMA模式

DMA模式才是真正的“解放CPU”。DMA控制器直接在内存和外设之间搬运数据,搬完了才通知你一声。

// DMA配置示例(以STM32F4为例)
void uart_dma_init(void)
{
    // 使能DMA时钟
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE);
    
    // 配置DMA通道
    DMA_InitTypeDef DMA_InitStruct;
    DMA_InitStruct.DMA_Channel = DMA_Channel_4;
    DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
    DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)rx_dma_buf;
    DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralToMemory;
    DMA_InitStruct.DMA_BufferSize = RX_DMA_BUF_SIZE;
    DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
    DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
    DMA_InitStruct.DMA_Mode = DMA_Mode_Circular;  // 循环模式
    DMA_InitStruct.DMA_Priority = DMA_Priority_High;
    DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable;
    DMA_Init(DMA2_Stream2, &DMA_InitStruct);
    
    // 使能DMA传输完成中断
    DMA_ITConfig(DMA2_Stream2, DMA_IT_TC, ENABLE);
    
    // 使能DMA流
    DMA_Cmd(DMA2_Stream2, ENABLE);
}
关键点:DMA循环模式(Circular Mode)是实现环形缓冲区的硬件基础。DMA传输完一圈后自动从头开始,配合中断通知CPU“这一圈满了,快来取数据”。

4.2 RT-Thread串口设备驱动框架

RT-Thread把串口抽象成了标准设备,你不需要直接操作寄存器。框架分三层:

  1. 设备管理层:提供统一的open/read/write/control接口
  2. 设备驱动框架层:实现串口设备的通用逻辑
  3. BSP驱动层:对接具体芯片的寄存器操作

我画了一张框架图,帮你理清关系:

RT-Thread 串口设备驱动框架 应用层:rt_device_open/read/write 设备管理层:设备对象管理、IOCTL 串口驱动框架层:struct rt_serial_ops BSP驱动层:stm32_uart_configure/control/putc/getc 硬件:USART1/2/3 + DMA 用户只需调用 rt_device_read() 无需关心底层 BSP驱动需要实现 struct rt_serial_ops 回调函数

核心数据结构是 struct rt_serial_device,里面包含了串口配置、环形缓冲区、DMA句柄等信息。你只需要实现 struct rt_serial_ops 里的几个回调函数:

struct rt_serial_ops {
    rt_err_t (*configure)(struct rt_serial_device *serial, 
                          struct serial_configure *cfg);
    rt_err_t (*control)(struct rt_serial_device *serial, 
                        int cmd, void *arg);
    int (*putc)(struct rt_serial_device *serial, char c);
    int (*getc)(struct rt_serial_device *serial);
    rt_err_t (*dma_transmit)(struct rt_serial_device *serial,
                             rt_uint8_t *buf, rt_size_t size, 
                             enum rt_serial_dma_dir dir);
};

4.3 实现串口驱动:从零开始写一个

咱们以STM32F4为例,实现一个支持DMA的串口驱动。我会把关键步骤拆开讲。

4.3.1 设备注册

static struct rt_serial_device stm32_uart1;

int rt_hw_uart_init(void)
{
    struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;
    
    // 配置参数:115200, 8N1
    config.baud_rate = BAUD_RATE_115200;
    config.data_bits = DATA_BITS_8;
    config.stop_bits = STOP_BITS_1;
    config.parity = PARITY_NONE;
    config.bufsz = RT_SERIAL_RB_BUFSZ;  // 默认256字节
    
    // 注册串口设备
    rt_hw_serial_register(&stm32_uart1, 
                          "uart1",
                          RT_DEVICE_FLAG_RDWR | 
                          RT_DEVICE_FLAG_INT_RX |
                          RT_DEVICE_FLAG_DMA_RX,
                          &uart1_ops);
    return RT_EOK;
}
经验之谈:注册时用的标志位决定了驱动的工作模式。如果只开 RT_DEVICE_FLAG_INT_RX,那就是中断接收;加上 RT_DEVICE_FLAG_DMA_RX,框架会自动切换到DMA模式。我建议你一开始先用中断模式调通,再切DMA,这样好排查问题。

4.3.2 实现ops回调

以 putc 和 getc 为例:

static int uart1_putc(struct rt_serial_device *serial, char c)
{
    // 等待发送寄存器空
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = c;
    return 1;
}

static int uart1_getc(struct rt_serial_device *serial)
{
    if (USART1->SR & USART_SR_RXNE) {
        return (int)(USART1->DR & 0xFF);
    }
    return -1;  // 无数据
}

DMA传输的回调稍微复杂一点:

static rt_err_t uart1_dma_transmit(struct rt_serial_device *serial,
                                    rt_uint8_t *buf, rt_size_t size,
                                    enum rt_serial_dma_dir dir)
{
    if (dir == DMA_DIR_TX) {
        // 配置DMA发送
        DMA_Cmd(DMA2_Stream7, DISABLE);
        DMA_SetCurrDataCounter(DMA2_Stream7, size);
        DMA_MemoryTargetConfig(DMA2_Stream7, (uint32_t)buf, DMA_Memory_0);
        DMA_Cmd(DMA2_Stream7, ENABLE);
    } else {
        // DMA接收已经在循环模式中持续运行
        // 这里只需要更新缓冲区指针
    }
    return RT_EOK;
}

4.4 DMA环形缓冲区设计与应用

这是本章的重头戏。DMA环形缓冲区,说白了就是让DMA在一个固定大小的内存圈里循环写数据,CPU在另一个位置读数据。两者互不干扰。

4.4.1 数据结构设计

typedef struct {
    rt_uint8_t *buffer;      // 缓冲区基地址
    rt_size_t   size;        // 缓冲区大小
    volatile rt_size_t read_index;   // 读指针(CPU维护)
    volatile rt_size_t write_index;  // 写指针(DMA硬件维护)
} dma_ring_buffer_t;

这里有个关键点:write_index 不是由软件维护的,而是通过DMA的当前数据计数器(NDT)计算出来的。

// 获取DMA当前写位置
static rt_size_t dma_get_write_index(DMA_Stream_TypeDef *DMAx)
{
    rt_size_t ndt = DMA_GetCurrDataCounter(DMAx);
    return RX_DMA_BUF_SIZE - ndt;
}

4.4.2 数据读取流程

CPU读取数据的逻辑是这样的:

  1. 获取DMA当前写位置(write_index)
  2. 如果 write_index > read_index,说明有新数据,区间为 [read_index, write_index)
  3. 如果 write_index < read_index,说明发生了回绕,分两段读取
  4. 更新 read_index = write_index
rt_size_t dma_ring_read(dma_ring_buffer_t *ring, rt_uint8_t *out, rt_size_t len)
{
    rt_size_t write_idx = dma_get_write_index(DMA2_Stream2);
    rt_size_t avail;
    rt_size_t first_len;
    
    if (write_idx >= ring->read_index) {
        // 没有回绕
        avail = write_idx - ring->read_index;
        if (avail > len) avail = len;
        rt_memcpy(out, ring->buffer + ring->read_index, avail);
    } else {
        // 发生了回绕,分两段
        first_len = ring->size - ring->read_index;
        if (first_len > len) first_len = len;
        rt_memcpy(out, ring->buffer + ring->read_index, first_len);
        
        if (len > first_len) {
            rt_size_t second_len = len - first_len;
            if (second_len > write_idx) second_len = write_idx;
            rt_memcpy(out + first_len, ring->buffer, second_len);
            avail = first_len + second_len;
        } else {
            avail = first_len;
        }
    }
    
    ring->read_index = (ring->read_index + avail) % ring->size;
    return avail;
}
核心思想:DMA环形缓冲区的精髓在于“读写分离”。DMA只负责写,CPU只负责读,两者通过指针位置来同步。不需要锁,不需要关中断,性能极高。

4.4.3 中断处理

DMA传输完成中断用来通知CPU“缓冲区半满”或“缓冲区全满”:

void DMA2_Stream2_IRQHandler(void)
{
    if (DMA_GetITStatus(DMA2_Stream2, DMA_IT_TCIF2)) {
        // 传输完成(一圈结束)
        // 触发数据读取事件
        rt_serial_dma_rx_done(&stm32_uart1, RX_DMA_BUF_SIZE);
        DMA_ClearITPendingBit(DMA2_Stream2, DMA_IT_TCIF2);
    }
    
    if (DMA_GetITStatus(DMA2_Stream2, DMA_IT_HTIF2)) {
        // 半传输完成(半圈结束)
        // 可以提前处理前半部分数据
        rt_serial_dma_rx_done(&stm32_uart1, RX_DMA_BUF_SIZE / 2);
        DMA_ClearITPendingBit(DMA2_Stream2, DMA_IT_HTIF2);
    }
}

为什么要用半传输中断?说白了就是“流水线处理”。DMA在写后半段的时候,CPU已经在处理前半段了。这样延迟减半,吞吐翻倍。

避坑指南:我曾经在一个项目中,DMA缓冲区设了1024字节,但半传输中断里处理数据时用了阻塞式API,结果DMA写完了后半段,CPU还没处理完前半段——数据被覆盖了。解决办法:要么加大缓冲区,要么在中断里只做“通知”,把实际处理放到线程里。

4.4.4 应用示例

最后,看看在RT-Thread里怎么用:

// 打开串口设备
rt_device_t uart_dev = rt_device_find("uart1");
struct serial_configure config = RT_SERIAL_CONFIG_DEFAULT;
config.bufsz = 1024;
rt_device_control(uart_dev, RT_DEVICE_CTRL_CONFIG, &config);
rt_device_open(uart_dev, RT_DEVICE_FLAG_DMA_RX);

// 接收数据(非阻塞)
rt_uint8_t buf[256];
rt_size_t len = rt_device_read(uart_dev, 0, buf, sizeof(buf));
if (len > 0) {
    // 处理数据
}

// 发送数据(DMA发送)
rt_device_write(uart_dev, 0, "Hello DMA!\r\n", 12);

看到没?上层代码完全不知道底层是轮询、中断还是DMA。这就是RT-Thread设备框架的魅力——你只需要关注业务逻辑,驱动细节交给底层。

嗯,这一章的内容就到这里。串口驱动看似简单,但要把DMA环形缓冲区玩明白,还是需要下一番功夫的。建议你动手写一遍代码,调通之后再用逻辑分析仪看看CPU的负载变化——你会直观地感受到DMA带来的性能提升。