4. 串口通信协议设计:数据帧格式定义、帧头帧尾、命令字与超时重传

串口通信协议,说白了就是主机(你的PC)和从机(Bootloader)之间约定好的“黑话”。没有这套黑话,两边数据对不上,烧录就是瞎忙活。我早期做项目时,就吃过协议没定义好的亏——帧头帧尾随便选,结果数据错位,查了三天才发现是0xAA和0x55在特定波特率下容易误码。嗯,从那以后,我对协议设计就特别较真。

4.1 数据帧格式:从比特到帧的封装

一个完整的数据帧,就像一封信。你得有信封(帧头帧尾)、收件人地址(设备ID)、信件内容(数据域),还得有个防伪标记(校验码)。我个人习惯把帧结构设计成固定长度+可变长度的混合模式,这样解析起来既简单又灵活。

字段 长度(字节) 说明
帧头 2 固定值 0xAA 0x55,用于同步
设备ID 1 多设备组网时区分目标
命令字 1 擦除/写入/校验/跳转等
数据长度 2 小端模式,最大1024字节
数据域 N 实际有效数据
CRC16校验 2 从帧头到数据域的CRC
帧尾 2 固定值 0x0D 0x0A
我的经验:帧头用0xAA 0x55不是随便选的。这两个字节二进制是10101010和01010101,交替的比特模式在示波器上很容易识别。我曾经试过用0x5A 0xA5,结果在115200波特率下偶尔会跟数据域里的内容撞车,导致误同步。0xAA 0x55是我踩坑后换回来的选择。

4.2 帧头帧尾:同步与定界的艺术

帧头的作用是告诉接收端:“注意,我要开始发数据了!”帧尾则是:“这包结束了,赶紧处理!”你想想看,如果没有帧尾,接收端怎么知道一帧数据什么时候收完?靠超时?那效率太低了。

我设计的帧头是2字节,帧尾也是2字节。为什么不用1字节?因为单字节太容易在数据域里出现了。比如你传输的固件数据里正好有个0xAA,接收端就会误以为这是帧头,然后整个解析就乱套了。

避坑指南:帧头和帧尾的值,绝对不能出现在数据域中。如果数据域里恰好有0xAA 0x55,怎么办?两种办法:一是做字节填充(Byte Stuffing),在数据域中遇到帧头帧尾时插入转义字符;二是限制数据域长度,确保不会出现连续两个字节与帧头帧尾相同。我个人偏向第一种,虽然多了一点开销,但安全。

4.3 命令字定义:擦除、写入、校验、跳转

命令字是协议的“灵魂”。Bootloader通过命令字知道上位机想让它干什么。我一般用单字节命令字,0x01到0x0F,留够扩展空间。

命令字 名称 功能描述 数据域内容
0x01 CMD_ERASE 擦除指定扇区 起始地址(4B) + 扇区数(1B)
0x02 CMD_WRITE 写入数据到Flash 起始地址(4B) + 数据(NB)
0x03 CMD_VERIFY 校验已写入的数据 起始地址(4B) + 长度(2B) + CRC16(2B)
0x04 CMD_JUMP 跳转到应用程序 跳转地址(4B)
0x05 CMD_GET_INFO 获取Bootloader版本/芯片信息 空
0x06-0x0F 保留 预留扩展 —

每个命令执行后,Bootloader必须回复一个应答帧。应答帧结构简单:帧头 + 命令字 + 状态码(1B) + CRC + 帧尾。状态码0x00表示成功,0x01表示失败,0x02表示命令不支持。

注意:CMD_JUMP命令执行后,Bootloader不会再回复任何数据。因为程序计数器已经跳转到应用程序了。所以上位机发送跳转命令后,需要等待一段时间(比如500ms),然后尝试通过其他方式(比如心跳包)确认应用程序是否正常运行。

4.4 超时重传机制:让通信更可靠

串口通信不是100%可靠的。线缆干扰、波特率偏差、缓冲区溢出,都可能导致丢帧或错帧。所以,超时重传机制是必须的。

我一般设计两级超时:

  1. 字节超时(Byte Timeout):接收两个字节之间的间隔超过一定时间(比如10ms),就认为帧不完整,丢弃当前缓冲区。
  2. 帧超时(Frame Timeout):发送完一帧后,等待应答帧的最长时间(比如100ms)。超时未收到应答,就重传。

重传次数我通常设为3次。超过3次还没成功,就向上位机报告“通信失败”,让用户检查硬件连接。

重传策略:不是简单的“发了等,等不到再发”。我建议用指数退避(Exponential Backoff)。第一次重传等待100ms,第二次200ms,第三次400ms。这样既能快速恢复,又不会在干扰严重时把信道占满。

4.5 协议状态机:接收端的灵魂

接收端怎么解析这些帧?靠状态机。我画了一个简单的状态转换图,你看看就明白了。

串口接收状态机 串口接收状态机 IDLE HEADER_RECV DATA_RECV CRC_CHECK DONE ERROR 收到帧头 解析完成 数据收完 CRC正确 CRC错误 丢弃帧 处理完成,回到空闲 状态说明: • IDLE:等待帧头0xAA 0x55 • HEADER_RECV:已收到帧头,解析命令字和数据长度 • DATA_RECV:接收数据域,直到收满指定长度 • CRC_CHECK:计算CRC并与帧中CRC比较 • 任何状态遇到超时或错误,都回到IDLE

这个状态机看起来简单,但实际调试时坑不少。我记得有一次,接收端在DATA_RECV状态时,上位机突然发了一个帧头过来,结果状态机直接跳到HEADER_RECV,导致当前帧数据丢失。后来我加了一个“帧头保护”逻辑:在DATA_RECV状态下收到帧头,先缓存起来,等当前帧处理完再处理新的帧头。

4.6 代码实现要点

最后,给你看一段状态机核心代码的伪代码框架。实际项目中,我会把状态机放在定时器中断里轮询,或者用DMA+空闲中断的方式接收。

// 串口接收状态机
typedef enum {
    STATE_IDLE,
    STATE_HEADER,
    STATE_DATA,
    STATE_CRC,
    STATE_DONE,
    STATE_ERROR
} rx_state_t;

rx_state_t state = STATE_IDLE;
uint8_t rx_buffer[1024];
uint16_t rx_index = 0;
uint16_t expected_len = 0;

void uart_rx_byte_handler(uint8_t byte) {
    switch(state) {
        case STATE_IDLE:
            if (byte == 0xAA) {
                rx_buffer[0] = byte;
                rx_index = 1;
                state = STATE_HEADER;
            }
            break;
            
        case STATE_HEADER:
            rx_buffer[rx_index++] = byte;
            if (rx_index == 4) {  // 帧头(2) + 设备ID(1) + 命令字(1)
                // 解析数据长度(第5-6字节)
                expected_len = rx_buffer[4] | (rx_buffer[5] << 8);
                state = STATE_DATA;
            }
            break;
            
        case STATE_DATA:
            rx_buffer[rx_index++] = byte;
            if (rx_index == (6 + expected_len)) {  // 6字节头 + 数据
                state = STATE_CRC;
            }
            break;
            
        case STATE_CRC:
            rx_buffer[rx_index++] = byte;
            if (rx_index == (8 + expected_len)) {  // 加上2字节CRC
                // 校验CRC
                if (crc16_check(rx_buffer, rx_index - 2) == 
                    (rx_buffer[rx_index-2] | (rx_buffer[rx_index-1] << 8))) {
                    state = STATE_DONE;
                } else {
                    state = STATE_ERROR;
                }
            }
            break;
            
        default:
            state = STATE_IDLE;
            break;
    }
}
小技巧:实际项目中,我很少在中断里做CRC校验。因为CRC计算耗时,会影响下一个字节的接收。我的做法是:在中断里只收数据,放到环形缓冲区里。然后在主循环里解析帧、校验CRC。这样既保证了接收不丢数据,又不会阻塞中断。