4. 串口通信协议设计:Ymodem协议详解、自定义协议帧格式、CRC校验实现、超时重传机制

串口通信协议,说白了就是Bootloader和上位机之间约定好的“黑话”。双方得按照同一套规则来收发数据,否则就是鸡同鸭讲。这一章我们重点聊四个东西:Ymodem协议怎么用、怎么自己定义一套帧格式、CRC校验怎么写、以及超时重传怎么处理。

我个人习惯,在资源允许的情况下优先用Ymodem。但如果MCU的RAM太小,或者你想完全掌控传输过程,那就得自己设计协议了。下面我们一个一个拆开讲。

4.1 Ymodem协议详解

Ymodem是Xmodem的改进版,支持批量文件传输,而且每个数据包带128字节或1K字节的数据。它的核心流程其实不复杂:发送方先发一个文件名和大小,然后一包一包发数据,最后发结束包。

我记得第一次调Ymodem的时候,被它的握手顺序搞晕了。后来画了个流程图才理清楚。你看下面这张图:

Ymodem 传输流程 接收方(Bootloader) 发送方(上位机) 发送 'C' (0x43) SOH + 序号 + 文件名包 ACK + C STX + 序号 + 数据包 ACK 循环直到文件结束 EOT ACK

Ymodem的包结构是这样的:

字节偏移内容说明
0SOH (0x01) 或 STX (0x02)SOH=128字节包,STX=1024字节包
1包序号 (0x00~0xFF)从0开始,循环使用
2包序号取反用于校验序号正确性
3~N+2数据128或1024字节
N+3~N+4CRC16校验高字节在前
我的经验:Ymodem的“C”握手信号其实是告诉发送方“我用CRC校验,不用简单的检查和”。如果你用Xmodem的检查和,发送方会发NAK来握手。这个细节当年让我调了整整一个下午。

4.2 自定义协议帧格式

Ymodem虽好,但有时候太臃肿了。比如你的Bootloader只有4KB空间,塞不下Ymodem的解析代码。或者你想加入一些私有功能,比如加密、压缩、断点续传。这时候就得自己设计协议了。

我设计自定义协议时,一般遵循“短帧头、定长数据、强校验”的原则。下面是我在某个项目里用过的帧格式:

字段长度(字节)说明
帧头2固定为0xAA 0x55,用于同步
命令字10x01=开始升级,0x02=传输数据,0x03=结束升级
数据长度2小端模式,表示数据域字节数
数据域0~1024实际有效数据
CRC162从帧头到数据域的CRC校验
帧尾1固定为0x0A,用于帧结束检测

你想想看,为什么帧头要用两个字节?因为单字节容易误判。串口上随便一个噪声字节如果是0xAA,就会导致帧同步错乱。两个字节的组合概率就低多了。

注意:帧尾不要用0x0D或0x00。0x0D是回车,有些串口工具会过滤掉。0x00在数据域里太常见了,容易造成提前结束判断。我吃过这个亏,后来改成0x0A就稳了。

4.3 CRC校验实现

CRC校验说白了就是多项式除法。但你别真去手算除法,有现成的查表法,又快又准。我一般用CRC16-CCITT,多项式是0x1021。

下面是我常用的CRC16实现,查表法,速度很快:

// CRC16-CCITT 查表法
static const uint16_t crc16_table[256] = {
    0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
    0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
    // ... 省略中间数据,实际使用时需要完整256项
};

uint16_t crc16_calculate(const uint8_t *data, uint32_t len)
{
    uint16_t crc = 0x0000;  // 初始值
    for (uint32_t i = 0; i < len; i++) {
        crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF];
    }
    return crc;
}

关键点:

  • 初始值:Ymodem用0x0000,有些协议用0xFFFF,要统一
  • 结果异或:Ymodem不异或,有些协议最后异或0xFFFF
  • 字节序:发送时高字节在前还是低字节在前,双方要约定好

我曾经在一个项目里,上位机用Python的binascii.crc_hqx,下位机用自己写的查表法,结果怎么都对不上。后来发现是Python的crc_hqx初始值是0x0000,而我用的初始值是0xFFFF。改过来就通了。

4.4 超时重传机制

串口通信不像以太网有硬件流控,丢包是家常便饭。没有超时重传,传输就是碰运气。

我一般设计三层超时:

  1. 字节超时:接收一个字节后,超过10ms没收到下一个字节,认为帧不完整,丢弃
  2. 帧超时:发送一帧后,超过500ms没收到应答,认为丢包,重传
  3. 整体超时:整个升级过程超过30秒没有进展,强制退出

重传策略我建议用“指数退避”。第一次重传等500ms,第二次等1秒,第三次等2秒,最多重传5次。为什么?因为如果连续丢包,说明信道质量很差,频繁重传只会让情况更糟。

避坑指南:我曾经把重传次数设成无限,结果串口线松了,Bootloader一直在重传,看门狗也没喂,最后芯片死锁了。后来我加了“最大重传次数”和“看门狗喂狗”两个机制,再也没出过问题。

下面是一个简单的超时重传伪代码:

#define MAX_RETRY  5
#define TIMEOUT_MS 500

int send_frame_with_retry(uint8_t *frame, uint32_t len)
{
    for (int retry = 0; retry < MAX_RETRY; retry++) {
        uart_send(frame, len);
        uint32_t wait = TIMEOUT_MS << retry;  // 指数退避
        if (wait_ack(wait) == SUCCESS) {
            return SUCCESS;
        }
        // 喂看门狗,防止死锁
        watchdog_feed();
    }
    return ERROR_TIMEOUT;
}

嗯,这里要注意:wait_ack函数里不能是死等。要用定时器或者硬件超时中断,否则整个系统都被阻塞了。我习惯用SysTick做超时基准,每毫秒检查一次。

最后总结一下这一节的核心:协议设计的关键不是“用哪个”,而是“怎么用稳”。Ymodem适合快速实现,自定义协议适合深度定制。CRC是保底防线,超时重传是最后一道保险。把这四个东西配合好,你的串口升级就成功了一大半。