第4章 UART串口通信驱动:数据收发与中断处理

UART,说白了就是串口通信。这东西在嵌入式开发里太常见了,几乎每个板子都会用到。我刚开始做驱动时,觉得UART挺简单的——不就是发数据收数据嘛。但真正深入进去才发现,这里面的门道还真不少。

今天咱们就聊聊UART驱动的核心:数据收发和中断处理。我会结合自己踩过的坑,把这块讲透。

4.1 UART硬件基础回顾

先简单过一下UART的硬件结构。UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它用两根线通信:TX发送,RX接收。

为什么叫异步?因为没有独立的时钟线。收发双方得约定好相同的波特率,比如115200、9600这些常见的。我见过不少新手在这上面栽跟头——两边波特率没设对,数据全是乱码。

UART的数据帧格式是这样的:

起始位 数据位(5-8位) 校验位(可选) 停止位(1/1.5/2位)
0 LSB...MSB 奇/偶校验 1

我个人习惯用8位数据位、无校验、1位停止位,也就是常说的8N1。这个配置兼容性最好,大部分设备都支持。

4.2 轮询模式的数据收发

最简单的收发方式就是轮询。CPU不停地检查状态寄存器,看看有没有数据可读,或者能不能发数据。

发送一个字节的流程:

  1. 等待发送缓冲区空(TXE标志位)
  2. 把数据写入数据寄存器
  3. 等待发送完成(TC标志位)

接收一个字节的流程:

  1. 等待接收数据就绪(RXNE标志位)
  2. 从数据寄存器读取数据

代码实现大概是这样的:

// 轮询发送一个字节
void uart_send_byte(uint8_t data)
{
    // 等待发送缓冲区空
    while (!(UART->SR & UART_SR_TXE));
    // 写入数据
    UART->DR = data;
    // 等待发送完成
    while (!(UART->SR & UART_SR_TC));
}

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

这种写法简单直接,但有个致命问题:CPU被死死占住了。你想想看,如果波特率是9600,接收一个字节就要等大约1ms。这期间CPU啥也干不了。

注意:轮询模式只适合在系统初始化阶段或者调试时使用。正式产品里千万别这么干,除非你的系统真的闲得没事做。

4.3 中断驱动的数据收发

实际项目中,我们几乎都用中断方式。数据来了,硬件自动触发中断,CPU在中断服务程序里处理。这样CPU就能腾出手来做其他事情。

UART中断主要有这么几种:

  • RXNE中断:接收数据寄存器非空,也就是收到数据了
  • TXE中断:发送数据寄存器空,可以发下一个字节
  • TC中断:发送完成,最后一个字节已经发出去了
  • IDLE中断:总线空闲,常用于帧结束判断
  • 错误中断:溢出、帧错误、校验错误等

我一般只使能RXNE中断和TC中断。TXE中断用得少,因为它的触发条件太频繁了——只要缓冲区空就触发,容易造成中断风暴。

4.3.1 接收中断处理

接收中断是最常用的。每收到一个字节,硬件就触发一次中断。我们在中断里把数据读出来,存到环形缓冲区里。

为什么要用环形缓冲区?因为中断处理要快,不能在里面做复杂操作。把数据先存起来,等主循环再去处理,这是典型的「生产者-消费者」模式。

// 环形缓冲区结构体
#define RX_BUF_SIZE 256

typedef struct {
    uint8_t buffer[RX_BUF_SIZE];
    volatile uint16_t head;
    volatile uint16_t tail;
} ring_buffer_t;

ring_buffer_t rx_ring;

// 接收中断服务函数
void UART_IRQHandler(void)
{
    uint8_t data;
    
    // 检查是否是接收中断
    if (UART->SR & UART_SR_RXNE) {
        data = (uint8_t)(UART->DR);
        
        // 存入环形缓冲区
        uint16_t next_head = (rx_ring.head + 1) % RX_BUF_SIZE;
        if (next_head != rx_ring.tail) {  // 缓冲区没满
            rx_ring.buffer[rx_ring.head] = data;
            rx_ring.head = next_head;
        }
        // 如果缓冲区满了,数据直接丢弃
        // 实际项目中可以加个溢出计数
    }
}

// 从环形缓冲区读取一个字节
uint8_t ring_read_byte(uint8_t *data)
{
    if (rx_ring.head == rx_ring.tail) {
        return 0;  // 缓冲区空
    }
    
    *data = rx_ring.buffer[rx_ring.tail];
    rx_ring.tail = (rx_ring.tail + 1) % RX_BUF_SIZE;
    return 1;
}
经验之谈:head和tail变量一定要加volatile关键字。我在项目中遇到过,优化等级开高了,编译器把变量优化到寄存器里,导致缓冲区状态判断出错。查了两天才找到原因,教训深刻啊。

4.3.2 发送中断处理

发送中断稍微复杂一点。我们一般用「中断+状态机」的方式来实现。主循环里把要发的数据放到发送缓冲区,然后使能TXE中断。中断里逐个字节往外发,发完了就关中断。

// 发送缓冲区
#define TX_BUF_SIZE 128

typedef struct {
    uint8_t buffer[TX_BUF_SIZE];
    volatile uint16_t count;    // 待发送字节数
    volatile uint16_t index;    // 当前发送位置
    volatile uint8_t busy;      // 发送忙标志
} tx_buffer_t;

tx_buffer_t tx_buf;

// 启动发送
void uart_send_data(uint8_t *data, uint16_t len)
{
    // 等待上一次发送完成
    while (tx_buf.busy);
    
    // 拷贝数据到发送缓冲区
    uint16_t copy_len = (len > TX_BUF_SIZE) ? TX_BUF_SIZE : len;
    memcpy(tx_buf.buffer, data, copy_len);
    tx_buf.count = copy_len;
    tx_buf.index = 0;
    tx_buf.busy = 1;
    
    // 使能TXE中断,开始发送
    UART->CR1 |= UART_CR1_TXEIE;
}

// 发送中断服务函数
void UART_IRQHandler(void)
{
    // 发送中断
    if (UART->SR & UART_SR_TXE) {
        if (tx_buf.index < tx_buf.count) {
            // 还有数据要发
            UART->DR = tx_buf.buffer[tx_buf.index++];
        } else {
            // 数据发完了,关中断
            UART->CR1 &= ~UART_CR1_TXEIE;
            tx_buf.busy = 0;
        }
    }
    
    // 接收中断
    if (UART->SR & UART_SR_RXNE) {
        // ... 接收处理代码
    }
}

这里有个细节:为什么不用TC中断而用TXE中断?因为TXE中断在缓冲区空时立即触发,而TC中断要等到最后一个字节的停止位发送完才触发。用TXE中断,字节之间没有间隙,发送效率更高。

4.4 中断优先级与嵌套

UART中断的优先级设置也很关键。我一般把UART中断设成中等优先级,比系统滴答定时器低,但比普通外设高。

为什么?因为UART数据接收有实时性要求。如果优先级太低,被其他中断长时间阻塞,可能会丢数据。但也不能太高,否则会影响系统调度。

具体设置要看芯片的NVIC(嵌套向量中断控制器)。以STM32为例:

// 设置UART中断优先级
NVIC_SetPriority(UART_IRQn, 2);  // 优先级值越小,优先级越高
NVIC_EnableIRQ(UART_IRQn);
注意:中断服务程序里千万不要做耗时操作,比如打印日志、动态内存分配、延时等。我曾经见过有人在中断里调用printf,结果系统直接卡死。中断里只做最必要的事——读写寄存器、更新缓冲区状态。

4.5 常见问题与避坑指南

做UART驱动这么多年,踩过的坑真不少。挑几个典型的说说:

  • 数据丢失:最常见的原因是中断处理太慢,下一个字节来了,上一个还没读走。解决办法是提高中断优先级,或者用DMA。
  • 乱码:波特率不匹配、时钟配置错误、数据位/停止位设置不一致。先检查硬件连接,再用示波器看波形。
  • 中断风暴:TXE中断使能后没及时关掉,或者接收中断里没清标志位。每次中断处理完都要检查标志位是否清除。
  • 缓冲区溢出:环形缓冲区太小,或者主循环处理太慢。适当增大缓冲区,或者用流控机制。

我曾经在一个项目里,UART接收偶尔丢数据。查了三天,最后发现是中断里用了全局变量没加volatile,编译器优化后读取了错误的值。从那以后,所有中断共享的变量我都加volatile,成了强迫症。

4.6 小结

UART驱动看似简单,但要做好真不容易。轮询模式适合调试,中断模式才是产品级的做法。环形缓冲区是中断处理的好搭档,volatile关键字不能忘,中断优先级要合理设置。

下一章咱们聊聊DMA+UART的高性能收发方案。那个更刺激,能把CPU彻底解放出来。到时候见。