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操作封装成了三层:
- 轮询模式:HAL_UART_Transmit / HAL_UART_Receive。CPU死等,简单但低效。
- 中断模式:HAL_UART_Transmit_IT / HAL_UART_Receive_IT。每次中断处理一个字节,适合小数据量。
- 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操作。
环形缓冲区的核心代码其实很短:
// 环形缓冲区结构体
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等外设上。下一节我们会继续完善这个驱动,加入发送缓冲和超时处理机制。