3. UART通信与中断驱动:从协议到实战

UART,说白了就是嵌入式世界里最常用的“串口”。我刚开始做嵌入式那会儿,第一个调通的通信外设就是它。别看它协议简单,但用好了,它就是调试利器;用不好,丢数据、卡死、中断风暴,哪个都能让你头疼一整天。

这一章,咱们就把它彻底吃透。从协议基础到HAL库的轮询/中断模式,再到环形缓冲区的实现,最后来一个完整的收发实战。嗯,跟着我的节奏来。

3.1 UART协议基础:你该知道的那些事

UART(Universal Asynchronous Receiver/Transmitter)是异步串行通信。什么叫异步?就是没有单独的时钟线,收发双方靠约定的波特率来同步。

一个完整的数据帧长这样:

起始位 数据位(5~9位) 校验位(可选) 停止位(1/1.5/2位)
1位低电平 LSB先发,MSB后发 奇校验/偶校验/无 高电平,表示帧结束

最常用的配置是:115200波特率、8位数据位、无校验、1位停止位,也就是我们常说的“115200 8N1”。

我的习惯:调试阶段一律用115200。9600太慢,打印多了卡顿明显;460800以上有些USB转串口工具不稳定。115200是个黄金平衡点。

3.2 HAL库UART轮询模式:简单但别滥用

轮询模式,就是CPU死等。发送一个字节,等发送完成;接收一个字节,等数据到来。

代码很简单:

// 发送
HAL_UART_Transmit(&huart1, (uint8_t *)"Hello\r\n", 7, HAL_MAX_DELAY);

// 接收
uint8_t rx_byte;
HAL_UART_Receive(&huart1, &rx_byte, 1, HAL_MAX_DELAY);

但我要说句实话:轮询模式在实战中几乎不用。为什么?

  • 发送时CPU空转等待,浪费算力
  • 接收时阻塞,如果对方不发数据,程序就卡死了
  • 无法处理不定长数据

我见过一个项目,新手用轮询收GPS数据,结果GPS模块偶尔丢一帧,程序就永远卡在接收函数里。嗯,后来改成中断就好了。

避坑:HAL_MAX_DELAY这个参数,在轮询模式下意味着“死等”。如果对方不响应,你的系统就“死”了。我曾经在一个产品中因为这个参数导致看门狗复位,排查了两天才找到根因。

3.3 HAL库UART中断模式:这才是实战该用的

中断模式的核心思想:发完或收到数据后,硬件自动触发中断,CPU在中断服务函数里处理。这样CPU不用空等,可以干别的活。

HAL库的中断收发API:

// 中断发送(非阻塞)
HAL_UART_Transmit_IT(&huart1, tx_buf, len);

// 中断接收(非阻塞)
HAL_UART_Receive_IT(&huart1, rx_buf, len);

注意看第二个参数——len。HAL库的中断接收是定长接收:它必须收满len个字节,才会调用回调函数。这就引出一个问题:如果对方只发了3个字节,而你设了10个字节,那回调永远不会触发。

所以,实战中我很少直接用HAL_UART_Receive_IT。我更喜欢用单字节中断接收,配合环形缓冲区。

3.4 环形缓冲区:解决数据粘包和丢失的利器

环形缓冲区(Ring Buffer),说白了就是一个固定大小的数组,加上读指针和写指针。数据从写指针写入,从读指针读出。当指针到达数组末尾时,自动绕回开头。

为什么需要它?

  • 中断服务函数里不能做复杂处理,只能快速存数据
  • 主循环或任务可以慢慢取数据,两者互不干扰
  • 解决数据粘包:先存起来,等完整的一帧到了再处理

一个简单的环形缓冲区实现:

#define RING_BUF_SIZE 256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

// 写入一个字节
int ring_buf_put(ring_buffer_t *rb, uint8_t data) {
    uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE;
    if (next_head == rb->tail) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 读取一个字节
int ring_buf_get(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}
关键点:head和tail用volatile修饰,因为中断和主循环都会访问它们。否则编译器优化可能导致数据不一致——我踩过这个坑,调试了整整一个下午。

3.5 串口收发实战:中断+环形缓冲区

好了,理论说完了,咱们来实战。目标是:用中断方式接收不定长数据,存入环形缓冲区,主循环里解析处理。

第一步:初始化UART和环形缓冲区

ring_buffer_t uart_rb;
uint8_t rx_byte;

void uart_init(void) {
    // MX_USART1_UART_Init() 由CubeMX生成
    // 启动单字节中断接收
    HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
}

第二步:中断回调函数

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 把收到的字节存入环形缓冲区
        ring_buf_put(&uart_rb, rx_byte);
        // 重新启动单字节接收
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

注意:回调里必须重新调用HAL_UART_Receive_IT,否则只收一个字节就停了。这是HAL库的设计——每次中断接收完成后,接收功能就关闭了。

第三步:主循环里处理数据

void main_loop(void) {
    uint8_t data;
    while (1) {
        if (ring_buf_get(&uart_rb, &data) == 0) {
            // 处理收到的数据
            process_byte(data);
        }
        // 其他任务...
    }
}

这个架构的好处是:中断只负责存,主循环负责处理。中断服务函数执行时间极短(微秒级),不会影响系统实时性。

我的经验:环形缓冲区大小要根据数据量和处理速度来定。如果数据量大、处理慢,缓冲区要设大一些。我一般设256字节起步,对于115200波特率,256字节能缓冲约22ms的数据,足够大多数场景了。

3.6 知识结构图:UART中断驱动架构

下面这张图展示了整个架构的流程和数据流向:

UART中断驱动架构流程图 UART外设 中断服务函数 环形缓冲区 主循环/任务 硬件中断 存入数据 读取数据 发送数据 说明: 中断服务函数只做“存”操作,执行时间极短;主循环做“取”和“处理”,两者通过环形缓冲区解耦。 虚线表示发送路径,实线表示接收路径。

3.7 常见问题与避坑指南

做UART驱动开发,有几个坑我几乎每次都会遇到:

  • 中断优先级设置不当:如果UART中断优先级太低,可能被其他高优先级中断长时间打断,导致数据丢失。我一般把UART中断设为中等优先级(如NVIC优先级分组4,抢占优先级2)。
  • 缓冲区溢出:主循环处理速度跟不上中断接收速度,缓冲区满了就丢数据。解决办法:增大缓冲区,或者用DMA(下一章会讲)。
  • 临界区保护:如果主循环和中断同时访问环形缓冲区,可能读到脏数据。我的做法:在读取时关中断,或者用原子操作。
我曾经踩过的坑:有一次做多串口项目,两个UART共用同一个中断回调函数,结果一个串口的数据被另一个串口的回调处理了。排查了半天才发现——回调函数里没有判断huart->Instance。从那以后,我每个串口的回调都加这个判断,成了肌肉记忆。

3.8 总结:记住这三句话

好了,这一章的内容就这些。总结一下核心要点:

  1. 轮询模式只适合调试,实战必须用中断或DMA
  2. 环形缓冲区是中断驱动架构的基石,中断只存、主循环只取
  3. HAL库的中断接收是定长的,实战中要用单字节接收+环形缓冲区