4、UART驱动封装:轮询模式与中断模式、环形缓冲区设计、DMA传输封装

UART,说白了就是串口。做单片机开发的人,没有谁离得开它。调试打印、跟传感器通信、对接蓝牙模块……哪哪都有它。但很多人写UART驱动,就是对着寄存器一顿操作,能用就行。等要换芯片平台了,或者要加个DMA功能,代码就得重写。

我个人习惯,从一开始就把UART驱动拆成三层:硬件抽象层、传输模式层、数据缓冲层。这样不管你是用轮询、中断还是DMA,上层调用的接口都一样。今天咱们就把这三层怎么搭,掰开揉碎了讲清楚。

核心思路:UART驱动封装的目标是——上层应用不关心底层用哪种传输模式,只调用 uart_send() / uart_recv() 就行。

4.1 轮询模式:最基础,但别小看它

轮询模式最简单,CPU一直盯着状态寄存器,等数据来了就收,等发送缓冲区空了就发。我在项目中遇到过一种情况:系统刚启动时,中断还没初始化好,这时候只能用轮询来打印启动日志。

轮询模式的封装,我建议做成这样:

// 硬件抽象层接口
typedef struct {
    void (*init)(uint32_t baud);
    void (*send_byte)(uint8_t data);
    uint8_t (*recv_byte)(void);
    uint8_t (*is_tx_idle)(void);
    uint8_t (*is_rx_ready)(void);
} uart_hw_t;

// 轮询发送
void uart_poll_send(uart_hw_t *hw, const uint8_t *buf, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        while (!hw->is_tx_idle());  // 等发送缓冲区空
        hw->send_byte(buf[i]);
    }
}

// 轮询接收
uint8_t uart_poll_recv(uart_hw_t *hw, uint8_t *buf, uint32_t len, uint32_t timeout_ms) {
    uint32_t start = get_tick();
    uint32_t i = 0;
    while (i < len) {
        if (hw->is_rx_ready()) {
            buf[i++] = hw->recv_byte();
            start = get_tick();  // 收到数据就重置超时
        } else if (get_tick() - start > timeout_ms) {
            return i;  // 超时,返回已收字节数
        }
    }
    return len;
}

你看,这里我把硬件操作抽象成了函数指针。换芯片时,只需要重新实现 uart_hw_t 里的几个函数,上层代码完全不用动。

我的经验:轮询接收一定要加超时机制。我曾经有个项目没加超时,结果对方设备掉线了,程序就卡死在 while 循环里,看门狗都救不回来。

4.2 中断模式:效率提升的关键

轮询模式在数据量小的时候还行,一旦数据量大或者系统要处理多任务,CPU就被UART占死了。这时候就得用中断。

中断模式的核心是:收数据靠中断,发数据也靠中断。发送时,先把数据放到缓冲区,然后使能发送中断,每次中断发一个字节,发完自动关中断。

我建议的中断驱动结构:

typedef struct {
    uart_hw_t *hw;
    uint8_t tx_buf[UART_TX_BUF_SIZE];
    volatile uint32_t tx_head;
    volatile uint32_t tx_tail;
    uint8_t rx_buf[UART_RX_BUF_SIZE];
    volatile uint32_t rx_head;
    volatile uint32_t rx_tail;
    void (*tx_cplt_cb)(void);
    void (*rx_cplt_cb)(uint8_t data);
} uart_int_t;

// 中断发送启动
void uart_int_send_start(uart_int_t *uart) {
    if (uart->tx_head != uart->tx_tail) {
        uart->hw->send_byte(uart->tx_buf[uart->tx_tail]);
        uart->tx_tail = (uart->tx_tail + 1) % UART_TX_BUF_SIZE;
        uart->hw->enable_tx_int();  // 使能发送中断
    }
}

// 中断服务函数(示例)
void UART_IRQHandler(void) {
    uart_int_t *uart = &g_uart1;
    if (uart->hw->is_tx_int()) {
        if (uart->tx_head != uart->tx_tail) {
            uart->hw->send_byte(uart->tx_buf[uart->tx_head]);
            uart->tx_head = (uart->tx_head + 1) % UART_TX_BUF_SIZE;
        } else {
            uart->hw->disable_tx_int();  // 发完了关中断
            if (uart->tx_cplt_cb) uart->tx_cplt_cb();
        }
    }
    if (uart->hw->is_rx_int()) {
        uint8_t data = uart->hw->recv_byte();
        uart->rx_buf[uart->rx_head] = data;
        uart->rx_head = (uart->rx_head + 1) % UART_RX_BUF_SIZE;
        if (uart->rx_cplt_cb) uart->rx_cplt_cb(data);
    }
}

注意:中断服务函数里不要做复杂处理,比如协议解析、内存分配。我曾经见过有人在中断里做 printf,结果系统直接崩了。中断里只做数据搬运,处理逻辑放到主循环或任务里。

4.3 环形缓冲区:中断模式的基石

环形缓冲区,说白了就是一个固定大小的数组,用两个指针(头指针和尾指针)来管理。头指针指向下一个要写入的位置,尾指针指向下一个要读取的位置。数据写满时,新数据会覆盖旧数据。

为什么用环形缓冲区?因为中断是异步的,CPU可能在任意时刻收到数据。如果没有缓冲区,数据来了你没及时读走,就丢了。环形缓冲区能暂存数据,让CPU有空的时候再处理。

我常用的环形缓冲区实现:

typedef struct {
    uint8_t *buf;
    uint32_t size;
    volatile uint32_t head;
    volatile uint32_t tail;
} ring_buf_t;

// 初始化
void ring_buf_init(ring_buf_t *rb, uint8_t *buf, uint32_t size) {
    rb->buf = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
uint8_t ring_buf_put(ring_buf_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) % rb->size;
    if (next == rb->tail) {
        return 0;  // 缓冲区满
    }
    rb->buf[rb->head] = data;
    rb->head = next;
    return 1;
}

// 读取一个字节
uint8_t ring_buf_get(ring_buf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return 0;  // 缓冲区空
    }
    *data = rb->buf[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return 1;
}

// 查询已用空间
uint32_t ring_buf_used(ring_buf_t *rb) {
    return (rb->head - rb->tail + rb->size) % rb->size;
}

避坑指南:我曾经把环形缓冲区的大小设成256,结果头尾指针用 uint8_t 存,取模运算时出了bug。后来我统一用 uint32_t,缓冲区大小必须是2的幂次方,这样取模可以用位运算 (head + 1) & (size - 1),效率高很多。

4.4 DMA传输封装:解放CPU

中断模式虽然比轮询好,但每个字节都要进一次中断,数据量大时CPU开销还是不小。DMA(直接存储器访问)能彻底解放CPU——数据从内存到外设,或者从外设到内存,全程不需要CPU参与。

DMA传输的封装,我建议分两步:配置DMA通道和启动传输。

先看配置:

typedef struct {
    uint32_t src_addr;      // 源地址
    uint32_t dst_addr;      // 目的地址
    uint32_t length;        // 传输长度
    uint8_t src_inc;        // 源地址是否递增
    uint8_t dst_inc;        // 目的地址是否递增
    uint8_t dir;            // 方向:内存到外设 / 外设到内存
    void (*cplt_cb)(void);  // 传输完成回调
} dma_cfg_t;

// DMA发送封装
void uart_dma_send(uart_hw_t *hw, dma_t *dma, const uint8_t *buf, uint32_t len) {
    dma_cfg_t cfg;
    cfg.src_addr = (uint32_t)buf;
    cfg.dst_addr = hw->get_tx_reg_addr();  // UART发送数据寄存器地址
    cfg.length = len;
    cfg.src_inc = 1;   // 源地址递增
    cfg.dst_inc = 0;   // 目的地址不变(UART寄存器)
    cfg.dir = DMA_MEM_TO_PERIPH;
    cfg.cplt_cb = uart_dma_tx_cplt;
    dma->start(&cfg);
}

// DMA接收封装
void uart_dma_recv(uart_hw_t *hw, dma_t *dma, uint8_t *buf, uint32_t len) {
    dma_cfg_t cfg;
    cfg.src_addr = hw->get_rx_reg_addr();  // UART接收数据寄存器地址
    cfg.dst_addr = (uint32_t)buf;
    cfg.length = len;
    cfg.src_inc = 0;   // 源地址不变
    cfg.dst_inc = 1;   // 目的地址递增
    cfg.dir = DMA_PERIPH_TO_MEM;
    cfg.cplt_cb = uart_dma_rx_cplt;
    dma->start(&cfg);
}

你看,DMA的封装跟轮询、中断一样,都基于 uart_hw_t 这个硬件抽象层。上层调用时,只需要选择用哪个模式:

// 统一的上层接口
void uart_send(uart_t *uart, const uint8_t *buf, uint32_t len) {
    switch (uart->mode) {
        case UART_MODE_POLL:
            uart_poll_send(uart->hw, buf, len);
            break;
        case UART_MODE_INT:
            uart_int_send(uart->int, buf, len);
            break;
        case UART_MODE_DMA:
            uart_dma_send(uart->hw, uart->dma, buf, len);
            break;
    }
}

注意:DMA传输时,源数据缓冲区必须是静态的或者全局的,不能是局部变量。因为DMA是异步的,函数返回后局部变量就释放了,DMA还在读那块内存,数据就乱了。我踩过这个坑,调试了一整天才发现。

4.5 三种模式对比与选型建议

三种模式各有优劣,我整理了一个对比表,方便你选型:

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(一直等) 中(每字节中断) 低(几乎不占)
实时性 差(阻塞) 好(异步) 好(异步)
代码复杂度 低 中 高
适用场景 调试、低速率 中速率、多任务 高速率、大数据量
内存开销 无 需缓冲区 需缓冲区+DMA描述符

我个人建议:调试阶段用轮询,简单可靠;产品阶段用中断,兼顾效率和复杂度;高速通信(如1Mbps以上)用DMA,否则CPU会被中断淹死。

4.6 整体架构图

下面这张图展示了UART驱动封装的整体架构,从硬件到应用层的分层关系:

UART驱动封装架构图 UART驱动封装分层架构 应用层(上层调用) uart_send() / uart_recv() (统一接口,不关心底层模式) 传输模式层 轮询模式 uart_poll_send/recv 中断模式 uart_int_send/recv DMA模式 uart_dma_send/recv 数据缓冲层(环形缓冲区) ring_buf_put / ring_buf_get 硬件抽象层(uart_hw_t) init / send_byte / recv_byte / is_tx_idle / is_rx_ready 硬件(UART外设寄存器) 换芯片只需重写 硬件抽象层

从这张图可以看得很清楚:硬件抽象层在最底下,换芯片时只需要重写这一层;数据缓冲层提供环形缓冲区,给中断和DMA模式用;传输模式层实现三种模式的具体逻辑;最上层是统一接口,应用层调用时完全不用关心底层细节。

嗯,这就是UART驱动封装的完整思路。你想想看,如果一开始就这么分层设计,后面不管是加协议解析、加流控、还是换芯片,都只需要动对应的那一层,其他代码完全不用改。这就是封装的价值。