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() 直接对接,省了不少事。

协议约定小建议: 结束符本身不要出现在数据载荷里。如果数据里可能包含结束符,那就得用转义机制(比如把 0x0A 转成 0x1A 0x0A),或者换用其他方案。这个后面章节会讲。

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 表示满,必须留一个空位来区分这两种状态。我见过有人把这个搞混,结果缓冲区明明没满却写不进去,查了半天。

核心优势: 中断里只管写,主循环只管读。不需要关中断,也不需要互斥锁。只要保证读写指针是原子操作(16位单片机上一个指令就能完成),就不会出问题。

结合结束符方案,中断回调里改成这样:

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",然后把它打印出来。

整体流程我用一张图给你画清楚:

固定结束符方案接收流程 串口中断接收一个字节 写入环形缓冲区 判断是否为 '\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 指令、处理传感器数据等
    }

    // 其他任务...
}
调试小技巧: 刚开始调试时,可以在中断里加个 GPIO 翻转,用示波器看中断频率。如果中断太频繁,说明数据量太大,单字节中断方式可能扛不住。这时候就该考虑 DMA 了——下一章我们会讲。

好了,这一章的内容就到这儿。固定结束符方案虽然简单,但非常实用。你掌握了环形缓冲区的设计,后面学 DMA 传输时也会轻松很多。记住:协议约定要清晰,缓冲区要防溢出,中断里只做最轻量的事。这三点做到了,串口接收基本不会出大问题。