4. UART 串口驱动:轮询模式与中断模式收发、波特率计算、FIFO 与流控制、环形缓冲区实现

UART 串口,说白了就是嵌入式世界里最基础的「对话通道」。我做了这么多年驱动,调试新板子第一件事就是先把串口打通——没有它,你连系统在干什么都不知道。

这一章我们聚焦 ARMv7 平台上的 UART 驱动开发。我会从最底层的波特率计算讲起,然后带你实现轮询模式和中断模式两种收发方式,最后聊聊 FIFO、流控制和环形缓冲区这些实战中绕不开的东西。

4.1 波特率计算:时钟分频的数学

波特率(Baud Rate)就是每秒传输的符号数。对于 UART 来说,一个符号通常就是一个数据位。所以 115200 bps 意味着每秒传 115200 个位。

ARMv7 的 UART 外设通常有一个时钟源,比如 50 MHz。我们需要通过分频器把它降到目标波特率。公式很简单:

目标波特率 = 时钟频率 / (16 × 分频值)

为什么是 16?因为 UART 接收器会在每个位周期内采样 16 次,取中间 3 次的值做多数表决,这样能抗噪声。嗯,这是硬件设计上的考量,我们驱动工程师只需要记住这个系数。

举个例子:时钟 50 MHz,想要 115200 bps:

分频值 = 50,000,000 / (16 × 115200) ≈ 27.1267

分频值必须是整数,所以取 27。实际波特率 = 50,000,000 / (16 × 27) ≈ 115,740 bps。误差约 0.47%,在 ±2% 的容忍范围内,没问题。

关键点:波特率误差超过 ±2% 时,数据可能会频繁出错。我在项目中遇到过一块板子,串口偶尔丢字节,查了半天发现是晶振频率偏差导致分频值取整后误差到了 3.2%。换了个精度更高的晶振就解决了。

下面是一个波特率配置函数的实现:

void uart_set_baudrate(UART_TypeDef *UARTx, uint32_t clk_hz, uint32_t baud) {
    uint32_t divisor;
    uint32_t remainder;
    uint32_t fraction;

    // 计算整数部分
    divisor = clk_hz / (16 * baud);
    // 计算小数部分(用于分数分频)
    remainder = clk_hz % (16 * baud);
    fraction = (remainder * 16) / (16 * baud);

    // 写入分频寄存器
    UARTx->IBRD = divisor;
    UARTx->FBRD = fraction;

    // 使能 UART
    UARTx->CR |= (1 << 0);  // UARTEN
}

注意有些 UART 支持分数分频(Fractional Baud Rate),能更精确地逼近目标值。ARM PrimeCell UART (PL011) 就有这个功能,IBRD 存整数部分,FBRD 存小数部分。

4.2 轮询模式收发:简单但低效

轮询模式是最直接的收发方式。发送时不断检查发送 FIFO 是否为空,接收时不断检查接收 FIFO 是否有数据。

发送一个字节:

void uart_send_byte(UART_TypeDef *UARTx, uint8_t data) {
    // 等待发送 FIFO 不满
    while (UARTx->FR & (1 << 5));  // TXFF 标志
    UARTx->DR = data;
}

接收一个字节:

uint8_t uart_recv_byte(UART_TypeDef *UARTx) {
    // 等待接收 FIFO 非空
    while (UARTx->FR & (1 << 4));  // RXFE 标志
    return (uint8_t)(UARTx->DR & 0xFF);
}

轮询模式的优点是实现简单、代码量小、没有中断上下文切换的开销。缺点也很明显——CPU 被占死了。你想想看,如果波特率是 115200,接收一个字节大约 87 μs,如果 CPU 一直在轮询,这 87 μs 里它什么都干不了。

什么时候用轮询?我个人习惯在系统启动阶段、调试信息输出、或者低数据量的控制命令交互时用轮询模式。比如 Bootloader 里打印启动日志,用轮询就够了,没必要引入中断。

4.3 中断模式收发:解放 CPU

中断模式才是正经的驱动该有的样子。接收中断来了,CPU 暂停当前任务,进 ISR 把数据读走;发送中断来了,ISR 把下一个字节塞进 FIFO。

配置步骤大致如下:

  1. 使能 UART 接收中断(RXIM)和发送中断(TXIM)
  2. 设置中断优先级(NVIC)
  3. 编写中断服务函数
  4. 在主循环或 RTOS 任务中处理接收到的数据

中断服务函数示例:

void UART0_IRQHandler(void) {
    uint32_t status = UART0->MIS;  // 读取中断状态

    // 接收中断
    if (status & (1 << 4)) {  // RXMIS
        while (!(UART0->FR & (1 << 4))) {  // RXFE 非空
            uint8_t data = UART0->DR & 0xFF;
            // 把数据放入环形缓冲区
            ringbuf_put(&rx_ringbuf, data);
        }
    }

    // 发送中断
    if (status & (1 << 5)) {  // TXMIS
        uint8_t data;
        if (ringbuf_get(&tx_ringbuf, &data) == 0) {
            UART0->DR = data;
        } else {
            // 没有数据要发送了,关闭发送中断
            UART0->IMSC &= ~(1 << 5);
        }
    }
}

这里有个细节:发送中断是「边沿触发」还是「电平触发」?ARM UART 的发送中断是电平触发——只要发送 FIFO 低于阈值,中断就一直挂着。所以发送完最后一个字节后,一定要关掉发送中断,否则会无限进 ISR。

我曾经踩过这个坑:调试一个串口驱动,发现系统一直卡在中断里出不来。查了半天,发现是发送中断没关,ISR 里又没数据可发,结果无限触发。加了个标志位控制发送中断的开关就解决了。

4.4 FIFO 与流控制

FIFO(First In First Out)是 UART 内部的一个硬件缓冲区。ARM PL011 的 FIFO 深度是 16 字节。有了 FIFO,CPU 不需要每个字节都响应中断,可以攒一批再处理,大大降低了中断频率。

FIFO 的触发阈值可以配置。比如设置接收 FIFO 触发阈值为 8 字节,那么只有 FIFO 里攒够 8 个字节才会触发接收中断。这对于大数据量传输非常有用。

FIFO 触发阈值 中断频率 适用场景
1 字节 高 低延迟、交互式通信
4 字节 中 通用场景
8 字节 低 大数据块传输
14 字节 极低 DMA 配合使用

流控制(Flow Control)解决的是「发送方太快,接收方来不及处理」的问题。硬件流控制用 RTS(Request to Send)和 CTS(Clear to Send)两根线来实现。

  • RTS:接收方告诉发送方「我准备好了,你可以发了」
  • CTS:发送方检查接收方是否准备好

配置流控制时,需要同时使能 UART 的 RTS 和 CTS 功能,并且硬件上要连接这两根线。软件上只需要设置寄存器:

UART0->CR |= (1 << 7);  // 使能 RTS
UART0->CR |= (1 << 8);  // 使能 CTS

流控制不是万能的。如果接收方的处理速度远低于发送方,即使有流控制,数据也可能溢出。这时候就需要环形缓冲区来兜底了。

4.5 环形缓冲区实现

环形缓冲区(Ring Buffer)是驱动开发中最常用的数据结构之一。它本质上是一个固定大小的数组,用两个指针(读指针和写指针)来管理数据。

为什么用环形缓冲区?因为中断 ISR 里不能做复杂操作(比如动态内存分配),而环形缓冲区只需要数组和指针,操作是 O(1) 的,非常适合在中断上下文中使用。

下面是一个完整的环形缓冲区实现:

#define RINGBUF_SIZE 256

typedef struct {
    uint8_t buffer[RINGBUF_SIZE];
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} ringbuf_t;

// 初始化
void ringbuf_init(ringbuf_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

// 判断是否为空
int ringbuf_empty(ringbuf_t *rb) {
    return rb->head == rb->tail;
}

// 判断是否为满
int ringbuf_full(ringbuf_t *rb) {
    return ((rb->head + 1) % RINGBUF_SIZE) == rb->tail;
}

// 写入一个字节
int ringbuf_put(ringbuf_t *rb, uint8_t data) {
    if (ringbuf_full(rb)) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = (rb->head + 1) % RINGBUF_SIZE;
    return 0;
}

// 读取一个字节
int ringbuf_get(ringbuf_t *rb, uint8_t *data) {
    if (ringbuf_empty(rb)) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RINGBUF_SIZE;
    return 0;
}

注意 head 和 tail 都加了 volatile 关键字。为什么?因为 head 在 ISR 里被修改,tail 在主循环里被修改,编译器可能会优化掉这些变量的读取。加上 volatile 能防止这种优化。

设计要点:环形缓冲区的大小最好是 2 的幂次方(如 256、512、1024)。这样取模运算可以用位运算替代:(head + 1) & (SIZE - 1),效率更高。我在项目中习惯用 512 字节的环形缓冲区,对于 115200 波特率来说,能缓冲大约 44 ms 的数据,足够应对大部分场景。

下面这张图展示了 UART 驱动中环形缓冲区与中断的协作关系:

UART 中断驱动 + 环形缓冲区数据流 应用层发送请求 发送环形缓冲区 发送中断 ISR UART 发送 FIFO UART 接收 FIFO 接收中断 ISR 接收环形缓冲区 应用层读取数据 环形缓冲区解耦了 ISR 和 应用层的处理速度 ISR 只负责快速存取,不阻塞

从这张图可以看出,环形缓冲区就像是一个「缓冲池」,ISR 往里面放数据,应用层从里面取数据。两者互不阻塞,这就是中断驱动设计的核心思想。

4.6 综合实战:一个完整的 UART 驱动框架

把上面所有知识点串起来,一个完整的 UART 驱动应该包含以下模块:

模块 功能 关键函数
初始化 配置 GPIO、时钟、波特率、FIFO、中断 uart_init()
发送 将数据写入发送环形缓冲区,触发发送中断 uart_send()
接收 从接收环形缓冲区读取数据 uart_recv()
中断处理 响应收发中断,操作环形缓冲区 UART_IRQHandler()
流控制 管理 RTS/CTS 信号 uart_flow_ctrl()

发送函数的实现思路:应用层调用 uart_send() 时,先把数据放入发送环形缓冲区,然后使能发送中断。ISR 检测到发送中断后,从缓冲区取数据写入 FIFO。当缓冲区空了,关闭发送中断。

void uart_send(UART_TypeDef *UARTx, uint8_t *data, uint32_t len) {
    for (uint32_t i = 0; i < len; i++) {
        // 等待环形缓冲区有空间
        while (ringbuf_put(&tx_ringbuf, data[i]) != 0) {
            // 可以加超时处理
        }
    }
    // 使能发送中断
    UARTx->IMSC |= (1 << 5);  // TXIM
}

接收函数的实现更简单:直接从接收环形缓冲区读取,如果没有数据就返回错误或阻塞等待。

int uart_recv(UART_TypeDef *UARTx, uint8_t *data, uint32_t timeout_ms) {
    uint32_t start = get_tick();
    while (ringbuf_empty(&rx_ringbuf)) {
        if (get_tick() - start > timeout_ms) {
            return -1;  // 超时
        }
    }
    ringbuf_get(&rx_ringbuf, data);
    return 0;
}

个人经验:在 RTOS 环境下,接收函数可以用信号量来优化。ISR 里每收到一个字节就释放一个信号量,接收任务等待信号量。这样就不需要轮询环形缓冲区了,CPU 利用率更高。