4. 串口接收不定长数据(一)—— 固定结束符方案
各位同学,咱们今天聊一个嵌入式开发里特别常见的需求——接收不定长数据。说白了,就是不知道对方会发多少字节过来,但你又得完整地收下来。我刚开始做项目那会儿,也在这上面栽过跟头。
这一章我们先讲最经典、也最容易上手的方法:固定结束符方案。你想想看,既然不知道数据有多长,那就在尾巴上做个标记呗。收到这个标记,就说明一帧数据结束了。
4.1 设计思路与协议约定
固定结束符的思路其实很简单。发送方在每帧数据的末尾加上一个或几个特定的字节,接收方不停地收,直到碰到这个结束符,就把前面收到的所有字节当作一帧完整数据。
常见的结束符有哪些?我列个表给你看:
| 结束符 | ASCII 表示 | 常见场景 |
|---|---|---|
'\n' |
0x0A | 文本协议、命令行、AT 指令 |
'\r' |
0x0D | 老式打印机、某些工业协议 |
'\r\n' |
0x0D 0x0A | HTTP 协议、Windows 文本文件 |
0x00 |
NUL | C 字符串风格、某些私有协议 |
0xFF |
— | Modbus 等二进制协议 |
我个人习惯在文本协议里用 '\n' 或者 '\r\n'。为什么?因为调试方便啊!串口助手直接敲回车就能发,不用算字节数。我在一个物联网项目里用过 '\n' 做结束符,上位机那边用 Python 的 readline() 直接对接,省了不少事。
4.2 基于 '\n' 或 '\r\n' 的接收实现
好,思路有了,咱们直接上代码。下面是一个用 HAL 库实现的、基于 '\n' 结束符的串口接收函数:
// 串口接收缓冲区
#define RX_BUF_SIZE 256
uint8_t rx_buffer[RX_BUF_SIZE];
uint16_t rx_index = 0;
// 串口中断接收回调(单字节中断方式)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 收到一个字节
uint8_t ch = rx_buffer[rx_index];
// 判断是否结束符
if (ch == '\n')
{
// 把结束符也存进去,或者不存,看协议约定
rx_buffer[rx_index] = ch;
rx_index++;
// 置位接收完成标志
uart_rx_complete = 1;
// 重新开始接收下一帧
rx_index = 0;
}
else
{
// 普通数据,继续存
rx_index++;
// 防溢出保护
if (rx_index >= RX_BUF_SIZE)
{
rx_index = 0; // 溢出则丢弃整帧
}
}
// 继续接收下一个字节
HAL_UART_Receive_IT(&huart1, &rx_buffer[rx_index], 1);
}
}
这段代码的逻辑很直白:每收到一个字节就判断是不是 '\n',是的话就标记完成,不是的话继续收。注意那个防溢出保护——我曾经在项目里忘了加这个,结果数据一长就把缓冲区写穿了,系统直接跑飞。嗯,从那以后我再也不敢省这行代码了。
如果你要支持 '\r\n' 双字节结束符,稍微改一下就行:
// 支持 \r\n 结束符
uint8_t last_byte = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
uint8_t ch = rx_buffer[rx_index];
if (ch == '\n' && last_byte == '\r')
{
// 收到 \r\n,帧结束
uart_rx_complete = 1;
rx_index = 0;
last_byte = 0;
}
else
{
rx_index++;
last_byte = ch;
}
HAL_UART_Receive_IT(&huart1, &rx_buffer[rx_index], 1);
}
}
4.3 环形缓冲区设计与实现
上面那个方案有个问题:如果主循环处理数据的速度赶不上串口接收速度,数据就会被覆盖。你想想看,一边收一边处理,处理到一半新数据又来了,这不就乱套了吗?
解决方案就是——环形缓冲区。它像一个循环队列,收数据只管往里写,处理数据只管往外读,互不干扰。
先看数据结构:
// 环形缓冲区结构体
typedef struct {
uint8_t *buffer; // 缓冲区指针
uint16_t head; // 写指针
uint16_t tail; // 读指针
uint16_t size; // 缓冲区大小
} ring_buffer_t;
// 初始化
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buf, uint16_t size)
{
rb->buffer = buf;
rb->head = 0;
rb->tail = 0;
rb->size = size;
}
// 写入一个字节
uint8_t ring_buffer_write(ring_buffer_t *rb, uint8_t data)
{
uint16_t next_head = (rb->head + 1) % rb->size;
if (next_head == rb->tail)
{
return 0; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 1;
}
// 读取一个字节
uint8_t ring_buffer_read(ring_buffer_t *rb, uint8_t *data)
{
if (rb->head == rb->tail)
{
return 0; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return 1;
}
这个环形缓冲区有个关键点:实际可用大小是 size - 1。为什么?因为 head == tail 表示空,如果 head + 1 == tail 表示满,必须留一个空位来区分这两种状态。我见过有人把这个搞混,结果缓冲区明明没满却写不进去,查了半天。
结合结束符方案,中断回调里改成这样:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
uint8_t ch;
HAL_UART_Receive_IT(&huart1, &ch, 1);
ring_buffer_write(&rx_ring, ch);
if (ch == '\n')
{
uart_rx_complete = 1;
}
}
}
主循环里再从环形缓冲区里把数据读出来处理。这样即使主循环被其他任务卡住几毫秒,数据也不会丢。
4.4 实战:接收以换行符结尾的字符串
咱们来一个完整的实战例子。假设你要通过串口接收一条以 '\n' 结尾的字符串,比如 "AT+OK\n",然后把它打印出来。
整体流程我用一张图给你画清楚:
主循环里的处理代码大概长这样:
// 主循环
while (1)
{
if (uart_rx_complete)
{
uart_rx_complete = 0;
// 从环形缓冲区读取一帧数据
uint8_t frame[256];
uint16_t frame_len = 0;
uint8_t ch;
while (ring_buffer_read(&rx_ring, &ch))
{
frame[frame_len++] = ch;
if (ch == '\n')
break;
}
// 处理完整帧(去掉结束符)
frame[frame_len - 1] = '\0'; // 替换结束符为字符串结束符
printf("收到: %s\r\n", frame);
// 这里可以加你的业务逻辑
// 比如解析 AT 指令、处理传感器数据等
}
// 其他任务...
}
好了,这一章的内容就到这儿。固定结束符方案虽然简单,但非常实用。你掌握了环形缓冲区的设计,后面学 DMA 传输时也会轻松很多。记住:协议约定要清晰,缓冲区要防溢出,中断里只做最轻量的事。这三点做到了,串口接收基本不会出大问题。