4、UART驱动封装:异步串口收发环形缓冲区设计与接口封装

UART 驱动,说白了就是让单片机跟外界“说话”的通道。很多初学者写串口程序,都是轮询收、轮询发,CPU 被死死绑住。我早年做第一个产品时就这么干,结果上位机一秒钟发几十包数据,单片机直接卡死——从那以后,我再也不敢不用环形缓冲区了。

这一章,我们就来聊聊怎么把 UART 驱动封装好,核心就是环形缓冲区。你想想看,收数据时不丢字节,发数据时不阻塞主循环,这才是正经的嵌入式做法。

4.1 为什么需要环形缓冲区?

先看一个场景:你的单片机通过 UART 接收传感器数据,每 10ms 来一帧,每帧 20 字节。如果用最简单的查询方式,主循环里必须不停地读寄存器。一旦主循环被其他任务拖了几毫秒,数据就丢了。

环形缓冲区(Ring Buffer)能解决这个问题。它本质上是一块固定大小的内存,配合两个指针(读指针和写指针),实现先进先出的数据暂存。中断服务程序只管往缓冲区里塞数据,主循环只管从缓冲区里取数据,互不干扰。

核心思想:生产者和消费者模式。中断是生产者,主循环是消费者。缓冲区就是那个“仓库”。

4.2 环形缓冲区的数据结构设计

我习惯用一个结构体来管理环形缓冲区。这样封装性好,移植也方便。

/* ring_buffer.h */
#ifndef RING_BUFFER_H
#define RING_BUFFER_H

#include <stdint.h>

/* 缓冲区大小,必须是2的幂,方便取模运算 */
#define RBUF_SIZE  256

typedef struct {
    uint8_t buffer[RBUF_SIZE];
    volatile uint16_t head;   /* 写指针,中断中修改,加volatile */
    volatile uint16_t tail;   /* 读指针,主循环中修改 */
} ring_buffer_t;

/* 接口函数声明 */
void    rbuf_init(ring_buffer_t *rb);
uint8_t rbuf_is_empty(ring_buffer_t *rb);
uint8_t rbuf_is_full(ring_buffer_t *rb);
uint8_t rbuf_write(ring_buffer_t *rb, uint8_t data);
uint8_t rbuf_read(ring_buffer_t *rb, uint8_t *data);
uint16_t rbuf_available(ring_buffer_t *rb);

#endif /* RING_BUFFER_H */

这里有个细节:head 和 tail 我加了 volatile。为什么?因为 head 在中断里被修改,编译器可能优化掉对它的读取。不加 volatile,主循环可能永远看不到新数据。嗯,这里要注意。

4.3 环形缓冲区的实现

实现部分其实不复杂,但有几个坑。我一个个说。

4.3.1 初始化与空满判断

/* ring_buffer.c */
#include "ring_buffer.h"

void rbuf_init(ring_buffer_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

uint8_t rbuf_is_empty(ring_buffer_t *rb) {
    return (rb->head == rb->tail);
}

uint8_t rbuf_is_full(ring_buffer_t *rb) {
    /* 当 tail 追上 head 时,认为满 */
    return (((rb->head + 1) & (RBUF_SIZE - 1)) == rb->tail);
}

注意看 rbuf_is_full 的实现。我没有用 head + 1 == tail,而是用了 & (RBUF_SIZE - 1)。这是因为缓冲区大小是 2 的幂,位与运算比取模快得多。我在项目中测试过,在 72MHz 的 STM32 上,这个优化能省下几十个时钟周期。

我曾经踩过的坑:一开始我用 head == tail 判断空和满,结果发现空和满的状态完全一样!后来才意识到,必须牺牲一个存储单元来区分。也就是说,缓冲区实际可用大小是 RBUF_SIZE - 1。

4.3.2 写入与读取

uint8_t rbuf_write(ring_buffer_t *rb, uint8_t data) {
    uint16_t next_head;

    next_head = (rb->head + 1) & (RBUF_SIZE - 1);
    if (next_head == rb->tail) {
        return 0;  /* 缓冲区满,写入失败 */
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 1;  /* 写入成功 */
}

uint8_t rbuf_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return 0;  /* 缓冲区空,读取失败 */
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (RBUF_SIZE - 1);
    return 1;  /* 读取成功 */
}

uint16_t rbuf_available(ring_buffer_t *rb) {
    return (uint16_t)((rb->head - rb->tail) & (RBUF_SIZE - 1));
}

写入时,先计算下一个 head 位置,如果等于 tail,说明满了。读取时,先判断是否为空,不为空才取数据。这两个函数都是 O(1) 的,效率很高。

4.4 UART 驱动封装:分层设计

有了环形缓冲区,我们就可以封装 UART 驱动了。我个人喜欢分两层:

  • 硬件抽象层(HAL):直接操作寄存器,提供最底层的收发函数。
  • 驱动层:基于环形缓冲区,提供中断驱动的收发接口。

下面这张图展示了整体架构:

UART驱动分层架构图 应用层(用户代码) UART驱动层 uart_send() / uart_receive() / uart_irq_handler() 环形缓冲区层 rbuf_write() / rbuf_read() / rbuf_available() 硬件抽象层(寄存器操作) 数据流向:中断 → 环形缓冲区 → 驱动层 → 应用层

4.5 UART 驱动接口封装

下面是我常用的 UART 驱动封装。注意,这里只展示核心接口,具体寄存器操作因芯片而异。

/* uart_driver.h */
#ifndef UART_DRIVER_H
#define UART_DRIVER_H

#include "ring_buffer.h"

/* 定义两个环形缓冲区:一个收,一个发 */
typedef struct {
    ring_buffer_t rx_buf;   /* 接收缓冲区 */
    ring_buffer_t tx_buf;   /* 发送缓冲区 */
    uint8_t tx_busy;        /* 发送忙标志 */
} uart_t;

/* 初始化UART硬件和缓冲区 */
void uart_init(uart_t *uart, uint32_t baudrate);

/* 发送一字节(非阻塞,放入缓冲区) */
void uart_send_byte(uart_t *uart, uint8_t data);

/* 发送多字节(非阻塞) */
void uart_send_data(uart_t *uart, uint8_t *data, uint16_t len);

/* 接收一字节(从缓冲区读取) */
uint8_t uart_receive_byte(uart_t *uart, uint8_t *data);

/* 查询接收缓冲区中可用字节数 */
uint16_t uart_available(uart_t *uart);

/* 中断服务函数(由UART中断调用) */
void uart_irq_handler(uart_t *uart);

#endif /* UART_DRIVER_H */

4.6 中断服务函数的实现

中断服务函数是驱动的心脏。我把它单独拎出来讲。

/* uart_driver.c 中的中断处理 */
void uart_irq_handler(uart_t *uart) {
    uint8_t data;
    uint8_t status;

    /* 假设寄存器读取函数 */
    status = UART->SR;   /* 状态寄存器 */
    data   = UART->DR;   /* 数据寄存器 */

    /* 接收中断 */
    if (status & UART_SR_RXNE) {
        /* 把收到的字节塞入接收缓冲区 */
        if (!rbuf_write(&uart->rx_buf, data)) {
            /* 缓冲区满了,可以在这里做溢出计数 */
            uart->rx_overflow++;
        }
    }

    /* 发送中断(发送保持寄存器空) */
    if (status & UART_SR_TXE) {
        if (rbuf_read(&uart->tx_buf, &data)) {
            /* 还有数据要发,继续发送 */
            UART->DR = data;
        } else {
            /* 没有数据了,关闭发送中断 */
            UART->CR1 &= ~UART_CR1_TXEIE;
            uart->tx_busy = 0;
        }
    }
}

个人经验:发送中断不要一直开着。只有在有数据要发时才使能,发完立刻关闭。否则中断会一直触发,浪费 CPU。我见过有人开着发送中断空转,CPU 占用率直接飙到 30%。

4.7 发送函数的实现细节

发送函数需要配合中断。看代码:

void uart_send_byte(uart_t *uart, uint8_t data) {
    /* 关中断,防止竞争 */
    __disable_irq();

    if (uart->tx_busy == 0) {
        /* 发送器空闲,直接写入寄存器 */
        uart->tx_busy = 1;
        UART->DR = data;
        /* 使能发送中断 */
        UART->CR1 |= UART_CR1_TXEIE;
    } else {
        /* 发送器忙,放入缓冲区 */
        rbuf_write(&uart->tx_buf, data);
    }

    __enable_irq();
}

这里有个关键点:先判断发送器是否空闲。如果空闲,直接写寄存器,省掉一次中断。如果忙,才放入缓冲区。这样能提高小数据量时的效率。

4.8 接口封装总结

我把整个 UART 驱动的接口整理成一张表,方便你对照:

接口函数 功能 调用位置 是否阻塞
uart_init() 初始化硬件和缓冲区 主循环初始化 否
uart_send_byte() 发送一字节 任意位置 否
uart_send_data() 发送多字节 任意位置 否
uart_receive_byte() 接收一字节 主循环 否
uart_available() 查询可读字节数 主循环 否
uart_irq_handler() 中断服务函数 中断向量表 —

4.9 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • 缓冲区大小选择:不是越大越好。256 字节通常够用,但如果你处理高速数据(比如 1Mbps),建议用 512 或 1024。我有个项目用 128 字节,结果在高负载下频繁溢出。
  • 临界区保护:中断和主循环共享的变量,一定要关中断保护。我见过有人忘了关中断,结果 head 指针被中断修改到一半,主循环读到了错误值。
  • 发送中断使能时机:不要在初始化时就使能发送中断。等有数据要发时再使能,发完立刻关闭。否则你会收到一堆空中断。
  • 缓冲区满的处理:不要直接丢弃数据。我习惯加一个溢出计数器,调试时通过串口打印出来,能帮你发现缓冲区是否偏小。

我曾经犯过的错:有一次我把环形缓冲区的 head 和 tail 定义成了 uint8_t,缓冲区大小 256。结果 head 从 255 加 1 变成 0,但 tail 还是 255,逻辑全乱了。从那以后,我统一用 uint16_t,缓冲区大小也严格限制为 2 的幂。

好了,这一章的内容就到这里。环形缓冲区加上中断驱动,是 UART 封装的标准做法。你可以在自己的项目里直接套用这套代码,稍作修改就能跑起来。记住,好的驱动设计,应该是“拿来就能用,用起来不出错”。