4、UART驱动设计:轮询模式与中断模式、环形缓冲区实现、波特率配置

UART,串口通信,嵌入式开发里最基础也最常用的外设之一。说实话,我见过不少工程师写了几年代码,UART驱动还是停留在「能发能收就行」的阶段。但实际项目中,UART驱动设计的好坏,直接决定了系统的稳定性和响应速度。

今天我们就来聊聊UART驱动的三个核心话题:轮询与中断模式怎么选、环形缓冲区怎么实现、波特率配置有哪些坑。嗯,这些都是我踩过坑的地方。

4.1 轮询模式 vs 中断模式

先说说轮询模式。说白了,就是CPU不停地去查UART的接收寄存器有没有数据。代码写起来简单,但代价很大——CPU被死死占住了。

轮询模式的典型场景:系统初始化阶段、调试信息输出、低功耗要求不高的简单应用。

// 轮询发送一个字节
void uart_poll_send(uint8_t data) {
    // 等待发送缓冲区空
    while (!(UART->SR & UART_SR_TXE));
    UART->DR = data;
}

// 轮询接收一个字节
uint8_t uart_poll_recv(void) {
    // 等待接收数据就绪
    while (!(UART->SR & UART_SR_RXNE));
    return (uint8_t)(UART->DR & 0xFF);
}

你看,两个while循环就把CPU锁死了。如果系统里还有其他任务要跑,这种写法就很不合适。

中断模式就不一样了。数据来了,硬件自动触发中断,CPU在中断服务程序里处理数据。处理完继续干别的事。这样CPU利用率高得多。

我个人习惯:调试阶段先用轮询模式,快速验证硬件没问题。正式版本再切到中断模式。这样定位问题更快。

// UART中断服务程序(简化版)
void UART_IRQHandler(void) {
    uint8_t data;
    
    // 检查接收中断标志
    if (UART->SR & UART_SR_RXNE) {
        data = (uint8_t)(UART->DR & 0xFF);
        // 把数据放入环形缓冲区
        ringbuf_put(&rx_ringbuf, data);
    }
    
    // 检查发送完成中断(如果需要)
    if (UART->SR & UART_SR_TC) {
        // 可以在这里处理发送完成后的逻辑
    }
}

中断模式也有代价——中断服务程序要尽量短。你想想看,如果中断里做太多事,会影响其他中断的响应。所以,中断里通常只做数据搬运,真正的处理逻辑放在主循环里。

我曾经踩过的坑:在中断服务程序里调用了printf(),结果系统直接卡死。因为printf内部可能用了轮询方式等待发送完成,而发送完成中断又被自己占着...死锁了。所以,中断里千万别做复杂操作。

4.2 环形缓冲区实现

为什么要用环形缓冲区?说白了,就是解决「生产者-消费者」问题。中断服务程序是生产者,往缓冲区里放数据。主循环是消费者,从缓冲区里取数据。两者速度不匹配,就需要一个缓冲区来解耦。

环形缓冲区,也叫循环队列。它用固定大小的数组,通过头尾指针来管理数据。数据写满了就覆盖旧数据,或者阻塞等待。实现起来不复杂,但细节要注意。

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;    // 数据缓冲区
    uint32_t size;      // 缓冲区大小
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
} ringbuf_t;

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

// 写入一个字节
int ringbuf_put(ringbuf_t *rb, uint8_t data) {
    uint32_t next_head = (rb->head + 1) % rb->size;
    
    // 缓冲区满了?
    if (next_head == rb->tail) {
        return -1;  // 满了,返回错误
    }
    
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

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

这里有个关键点:head和tail用了volatile关键字。为什么?因为中断和主循环会同时访问这两个变量。不加volatile,编译器可能优化掉一些读写操作,导致数据不一致。

避坑指南:环形缓冲区的大小要选好。太小了容易丢数据,太大了浪费内存。我一般根据波特率和数据量来估算。比如115200波特率,每秒最多约11.5KB数据。如果主循环每10ms处理一次,缓冲区至少要有115字节。实际项目中我通常会留2-3倍余量。

还有一个细节:判断缓冲区满和空。上面的代码用了一个技巧——head追上tail表示空,tail追上head表示满。但这样会浪费一个字节的空间(因为满的时候不能写)。你也可以用计数方式,但要注意原子操作。

4.3 波特率配置

波特率配置,看起来就是往寄存器里写个值。但实际项目中,波特率不准会导致通信失败,而且问题很难排查。

波特率的计算公式:

波特率 = 时钟频率 / (16 * 分频系数)

分频系数就是我们要配置的值。不同的芯片,时钟源可能不同。有的用APB时钟,有的用专门的UART时钟。配置前一定要确认清楚。

时钟频率 目标波特率 理论分频系数 实际分频系数 实际波特率 误差
16 MHz 115200 8.68 9 111111 3.5%
16 MHz 9600 104.17 104 9615 0.16%
8 MHz 115200 4.34 4 125000 8.5%

你看,8MHz时钟下跑115200波特率,误差高达8.5%。这种误差下,通信基本不可靠。我遇到过这种情况,调试了整整一天,最后发现是时钟源选错了。

我曾经踩过的坑:某次项目用了内部RC振荡器,标称16MHz,实际只有15.8MHz。波特率配置按16MHz算,结果误差超过了5%。通信时好时坏,最后用示波器一量才发现问题。所以,波特率配置前,一定要用示波器或逻辑分析仪确认实际时钟频率。

配置代码示例:

// 波特率配置函数
void uart_set_baudrate(uint32_t periph_clock, uint32_t baudrate) {
    uint32_t divisor;
    uint32_t remainder;
    uint32_t fraction;
    
    // 计算分频系数
    divisor = periph_clock / (16 * baudrate);
    remainder = periph_clock % (16 * baudrate);
    
    // 计算小数部分(如果硬件支持)
    fraction = (remainder * 16) / (16 * baudrate);
    
    // 写入寄存器
    UART->BRR = (divisor << 4) | (fraction & 0x0F);
    
    // 实际波特率验证
    uint32_t actual_baud = periph_clock / (16 * divisor + fraction);
    uint32_t error = (actual_baud > baudrate) ? 
                     (actual_baud - baudrate) : (baudrate - actual_baud);
    uint32_t error_percent = (error * 100) / baudrate;
    
    if (error_percent > 3) {
        // 误差超过3%,建议报警
        // 实际项目中可以打印警告信息
    }
}

这里我加了一个误差检查。如果误差超过3%,就报警。为什么是3%?因为UART通信的容错能力一般在3-5%之间。超过这个范围,数据就容易出错。

我建议:配置完波特率后,用示波器量一下TX引脚的波形。看一个bit的宽度是否准确。比如115200波特率,一个bit应该是8.68微秒。这个方法比任何计算都靠谱。

4.4 综合设计建议

最后,总结一下UART驱动设计的几个要点:

  • 模式选择:调试用轮询,正式用中断。如果数据量特别大,可以考虑DMA模式。
  • 缓冲区大小:根据波特率和处理周期计算,留2-3倍余量。
  • 波特率精度:误差控制在3%以内,用示波器验证。
  • 中断服务程序:只做数据搬运,不做复杂处理。
  • 临界区保护:中断和主循环共享的变量,用volatile关键字,必要时关中断保护。

嗯,UART驱动看起来简单,但要做好,细节真不少。希望这些经验能帮你少走弯路。下一章我们聊聊SPI驱动设计,那个又有新的坑等着我们。