4、UART驱动封装(上):UART协议基础与帧格式、HAL_UART源码分析、环形缓冲区设计与实现、实现UART_Handle结构体

UART,说白了就是串口。搞嵌入式的人,没人离得开它。调试打印、跟传感器通信、对接蓝牙模块……哪哪都有它。我刚开始做项目那会儿,总觉得UART驱动嘛,HAL库调一调就完事了。后来发现,裸机环境下,HAL库那套阻塞式收发,稍微跑点复杂逻辑就卡死。嗯,从这章开始,我们一步步把它封装成可靠、可复用的驱动模块。

4.1 UART协议基础与帧格式

UART是异步串行通信,没有时钟线。收发双方得提前约定好波特率、数据位、校验位和停止位。这就是我们常说的“帧格式”。

参数 常见取值 说明
波特率 9600, 115200, 921600 每秒传输的符号数,单位bps
数据位 7, 8, 9 一帧中有效数据的位数,常用8位
校验位 无、奇校验、偶校验 用于简单的错误检测
停止位 0.5, 1, 1.5, 2 帧结束标志,常用1位

一帧数据在总线上的样子是这样的:

UART帧格式(8N1为例)

起始位(1) + 数据位(8) + 校验位(0) + 停止位(1) = 10位

发送一个字节 'A' (0x41 = 0b01000001):

空闲 → 0(起始) → 1→0→0→0→0→0→1→0(数据LSB→MSB) → 1(停止) → 空闲

注意,数据是从最低位(LSB)开始发送的。这一点跟I2C、SPI不一样,我第一次调UART时就被这个坑过——逻辑分析仪抓出来的波形跟预期反着,查了半天才发现是字节序搞反了。

4.2 HAL_UART源码分析

STM32的HAL库把UART操作封装成了三层:

  1. 轮询模式:HAL_UART_Transmit / HAL_UART_Receive。CPU死等,简单但低效。
  2. 中断模式:HAL_UART_Transmit_IT / HAL_UART_Receive_IT。每次中断处理一个字节,适合小数据量。
  3. DMA模式:HAL_UART_Transmit_DMA / HAL_UART_Receive_DMA。批量传输,CPU几乎零负担。

我们重点看中断模式。HAL库内部维护了一个状态机:

// HAL_UART_StateTypeDef 定义
#define HAL_UART_STATE_RESET     0x00U  // 未初始化
#define HAL_UART_STATE_READY     0x01U  // 就绪
#define HAL_UART_STATE_BUSY      0x02U  // 忙
#define HAL_UART_STATE_BUSY_TX   0x12U  // 发送中
#define HAL_UART_STATE_BUSY_RX   0x22U  // 接收中

中断服务函数里,每收到一个字节就把它塞进pRxBuffPtr指向的缓冲区,同时递增RxXferCount。当接收计数达到设定的RxXferSize时,触发回调函数HAL_UART_RxCpltCallback。

这里有个问题:HAL库的接收缓冲区是线性的,一次只能处理一个固定长度的接收任务。如果你不知道对方会发多少字节,或者数据是变长的,HAL库就有点力不从心了。我在做一个GPS解析项目时就遇到了这个情况——GPS模块每秒发一串不定长的NMEA语句,用HAL库的固定长度接收根本没法搞。

避坑指南:HAL_UART_Receive_IT 不能连续调用两次。如果你在回调里再次调用它,中间会有窗口期导致数据丢失。我曾经因为这个丢了一帧关键数据,定位了整整两天。

4.3 环形缓冲区设计与实现

解决上面问题的办法,就是引入环形缓冲区(Ring Buffer)。它本质上是一个固定大小的数组,配合两个指针(读指针和写指针)实现FIFO操作。

环形缓冲区结构图 0 1 2 3 4 5 6 7 写指针 (head) 读指针 (tail) 已填充数据 (3~5) 空闲区域 (6~7, 0~2) 核心操作:写指针负责存入新数据,读指针负责取出数据。 当 head == tail 时缓冲区为空;当 (head + 1) % size == tail 时为满。

环形缓冲区的核心代码其实很短:

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

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

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

// 读取一个字节
int ring_buffer_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) % rb->size;
    return 0;
}

设计要点:缓冲区大小建议取2的幂次方(如64、128、256)。这样可以用位运算代替取模运算:(head + 1) & (size - 1),速度更快。我习惯在头文件里用宏定义缓冲区大小,方便不同项目调整。

4.4 实现UART_Handle结构体

有了环形缓冲区,我们就可以设计自己的UART句柄结构体了。目标是:把HAL库的底层操作包起来,对外提供简洁的接口。

// 自定义UART句柄
typedef struct {
    UART_HandleTypeDef *hal_uart;   // HAL库句柄指针
    ring_buffer_t rx_ring;          // 接收环形缓冲区
    ring_buffer_t tx_ring;          // 发送环形缓冲区
    uint8_t rx_buf[UART_RX_BUF_SIZE];  // 接收缓冲区内存
    uint8_t tx_buf[UART_TX_BUF_SIZE];  // 发送缓冲区内存
    void (*rx_callback)(uint8_t data); // 字节接收回调
    uint32_t baudrate;              // 波特率
    uint8_t parity;                 // 校验位
    uint8_t stopbits;               // 停止位
} uart_handle_t;

这个结构体把硬件配置、数据缓冲、回调函数都整合在一起了。使用的时候,只需要定义一个uart_handle_t变量,初始化后就能用。

初始化函数长这样:

int uart_init(uart_handle_t *huart, UART_HandleTypeDef *hal_uart, 
              uint32_t baudrate, uint8_t parity, uint8_t stopbits) {
    // 保存HAL句柄
    huart->hal_uart = hal_uart;
    
    // 初始化环形缓冲区
    ring_buffer_init(&huart->rx_ring, huart->rx_buf, UART_RX_BUF_SIZE);
    ring_buffer_init(&huart->tx_ring, huart->tx_buf, UART_TX_BUF_SIZE);
    
    // 保存配置参数
    huart->baudrate = baudrate;
    huart->parity = parity;
    huart->stopbits = stopbits;
    
    // 默认回调为空
    huart->rx_callback = NULL;
    
    // 启动HAL库中断接收(每次接收1字节)
    HAL_UART_Receive_IT(hal_uart, &huart->rx_temp, 1);
    
    return 0;
}

这里有个关键点:我们让HAL库每次中断只接收1个字节,收到后立即存入环形缓冲区。这样不管对方发多少数据,我们都不会丢。

中断回调函数这样写:

// 在stm32xx_it.c中调用
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *hal_uart) {
    // 通过hal_uart找到对应的uart_handle_t
    uart_handle_t *huart = (uart_handle_t *)hal_uart->pMspInit;  // 需要额外关联
    
    // 把收到的字节塞入环形缓冲区
    ring_buffer_put(&huart->rx_ring, huart->rx_temp);
    
    // 如果有回调,调用它
    if (huart->rx_callback) {
        huart->rx_callback(huart->rx_temp);
    }
    
    // 重新启动中断接收
    HAL_UART_Receive_IT(hal_uart, &huart->rx_temp, 1);
}

注意:上面的代码里,我通过hal_uart->pMspInit来关联自定义句柄,这其实是个取巧的办法。更规范的做法是在全局维护一个映射表,或者直接用HAL库的huart实例指针做强制转换。具体用哪种,看你的项目风格。我个人倾向于在初始化时把自定义句柄的地址存到hal_uart的MspInit字段里,简单直接。

最后,封装一个读取接口:

// 从环形缓冲区读取一个字节(非阻塞)
int uart_read_byte(uart_handle_t *huart, uint8_t *data) {
    return ring_buffer_get(&huart->rx_ring, data);
}

// 查询缓冲区中还有多少字节未读
uint32_t uart_available(uart_handle_t *huart) {
    return (huart->rx_ring.head - huart->rx_ring.tail) & (UART_RX_BUF_SIZE - 1);
}

这样一来,主循环里只需要轮询uart_available,有数据就取出来处理。完全不用关心底层中断是怎么工作的。代码清晰,逻辑解耦,后期维护也方便。

本章小结:我们从UART协议帧格式讲起,分析了HAL库中断模式的局限性,然后引入环形缓冲区解决数据堆积问题,最后封装了统一的UART_Handle结构体。这套设计思路,不仅适用于UART,稍加改造也能用在SPI、I2C等外设上。下一节我们会继续完善这个驱动,加入发送缓冲和超时处理机制。