4、串口通信协议设计:自定义Ymodem协议、帧结构定义、CRC校验实现

串口通信协议,说白了就是收发双方约定好的「说话方式」。你发一个字节,对方得知道这个字节是数据、是命令、还是校验码。我在Bootloader项目里吃过不少亏——协议没定好,升级到一半就卡死,板子变砖。后来我学乖了,老老实实把协议层做扎实。

这一章,我们重点讲三件事:Ymodem协议的裁剪与定制、帧结构怎么定义才靠谱、以及CRC校验到底怎么算。

4.1 为什么选Ymodem?

市面上串口协议很多:Xmodem、Ymodem、Zmodem、还有各种私有协议。我个人习惯在Bootloader里用Ymodem,原因很简单:

  • 支持批量传输:Ymodem可以传多个文件,适合固件+配置一起烧录
  • 有完整的应答机制:每个包都有ACK/NAK确认,不会丢包
  • 128字节/1024字节可变包长:小包适合低速链路,大包适合高速链路
  • 开源实现多:网上能找到现成的C代码,改改就能用
我的经验:Ymodem虽然老,但稳定。我在一个工业控制器的项目里用了Ymodem做升级协议,跑了三年没出过问题。别盲目追求新协议,稳定才是硬道理。

4.2 自定义Ymodem协议:裁剪与增强

标准的Ymodem协议有完整的文件传输流程,但Bootloader场景下我们不需要那么复杂。我一般会做以下裁剪:

标准Ymodem功能 Bootloader裁剪方案 原因
多文件传输 保留,但限制最多2个文件(固件+配置) 减少协议复杂度
文件名/时间戳 只保留文件名,去掉时间戳 Bootloader不需要时间信息
1024字节包 固定使用128字节包 RAM有限,128字节更安全
取消/暂停 去掉,Bootloader不需要交互 简化状态机
CRC16校验 升级为CRC32 提高检错能力

你想想看,Bootloader的ROM通常只有几KB到几十KB,塞不下完整的Ymodem协议栈。所以我们要做的,是取其精华,去其糟粕。

4.3 帧结构定义:每一比特都要算清楚

帧结构是协议的核心。我见过很多新手把帧结构定义得特别复杂,结果调试的时候自己都搞不清楚。我的原则是:够用就好,别炫技。

下面是我在项目中常用的帧结构:

// 帧结构定义
typedef struct {
    uint8_t  soh;        // 帧起始符 (0x01)
    uint8_t  seq;        // 包序号 (0x00-0xFF)
    uint8_t  seq_inv;    // 包序号取反
    uint8_t  data[128];  // 数据负载
    uint32_t crc;        // CRC32校验值
} YmodemFrame_t;

每个字段的含义:

  • soh (0x01):帧起始符,告诉接收端「我要发数据了」
  • seq:包序号,从0开始递增,用于检测丢包
  • seq_inv:包序号取反,用于双重校验
  • data[128]:实际数据,不够128字节的用0xFF填充
  • crc:对整个帧(从soh到data末尾)的CRC32校验
注意:seq和seq_inv必须成对出现。我曾经遇到过一个项目,接收端只检查seq不检查seq_inv,结果因为干扰导致seq跳变,接收端误判为丢包,整个升级流程卡死。后来我加上了seq_inv校验,问题就解决了。

4.4 控制帧:不只是数据

除了数据帧,我们还需要控制帧来管理传输流程。常用的控制帧有:

控制符 值 含义
SOH 0x01 数据帧起始
EOT 0x04 传输结束
ACK 0x06 确认收到
NAK 0x15 未确认,请求重发
CAN 0x18 取消传输
C 0x43 请求发送(接收端发起)

传输流程是这样的:

  1. 接收端发送字符 'C'(0x43),表示「我准备好了,开始吧」
  2. 发送端收到 'C' 后,发送第一个数据帧(包序号0)
  3. 接收端校验CRC,正确则回复ACK,错误则回复NAK
  4. 发送端收到ACK后发下一帧,收到NAK则重发当前帧
  5. 所有数据发完后,发送端发送EOT
  6. 接收端回复ACK,传输结束
关键点:重发机制是Bootloader的保命符。我建议设置最大重发次数为3次,超过3次就判定为通信失败,进入错误处理流程。

4.5 CRC校验实现:别用软件算,用硬件

CRC校验是协议的最后一道防线。很多MCU都内置了CRC硬件模块,速度比软件快几十倍。我个人习惯优先使用硬件CRC,只有在硬件不支持时才用软件实现。

下面是一个硬件CRC32的实现示例(以STM32为例):

// 硬件CRC32初始化
void CRC_Init(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_CRCEN;  // 使能CRC时钟
    CRC->CR = CRC_CR_RESET;              // 复位CRC计算器
}

// 计算CRC32
uint32_t CRC_Calculate(uint8_t *data, uint32_t len) {
    uint32_t i;
    
    CRC->CR = CRC_CR_RESET;  // 每次计算前复位
    
    for (i = 0; i < len; i++) {
        CRC->DR = data[i];   // 逐字节写入
    }
    
    return CRC->DR;  // 读取结果
}

如果MCU没有硬件CRC,可以用查表法实现软件CRC32:

// 软件CRC32查表法
static const uint32_t crc32_table[256] = {
    0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA,
    // ... 省略中间数据,实际使用时需要完整256项
};

uint32_t CRC32_Software(uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    uint32_t i;
    
    for (i = 0; i < len; i++) {
        crc = crc32_table[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
    }
    
    return crc ^ 0xFFFFFFFF;
}
避坑指南:我曾经在一个项目里用了软件CRC32,结果发现计算速度跟不上串口接收速度,导致丢包。后来换成硬件CRC,问题迎刃而解。所以,能用硬件就别用软件。

4.6 协议状态机:让通信有条不紊

协议实现的核心是一个有限状态机。我画了一张图,帮你理清整个流程:

Ymodem协议状态机 空闲 (IDLE) 等待 'C' 字符 接收数据帧 校验CRC 发送ACK 发送NAK 传输完成 说明:CRC校验失败时发送NAK,发送端收到NAK后重发当前帧;CRC校验成功时发送ACK,发送端收到ACK后发下一帧 最大重发次数建议设为3次,超过则判定为通信失败

状态机的实现代码:

// 协议状态机
typedef enum {
    STATE_IDLE,
    STATE_WAIT_C,
    STATE_RECEIVE,
    STATE_CHECK_CRC,
    STATE_SEND_ACK,
    STATE_SEND_NAK,
    STATE_COMPLETE,
    STATE_ERROR
} ProtocolState_t;

ProtocolState_t state = STATE_IDLE;

void Protocol_Process(uint8_t byte) {
    switch (state) {
        case STATE_IDLE:
            if (byte == 'C') {
                state = STATE_WAIT_C;
            }
            break;
            
        case STATE_WAIT_C:
            if (byte == SOH) {
                // 开始接收数据帧
                state = STATE_RECEIVE;
            }
            break;
            
        case STATE_RECEIVE:
            // 接收完整帧后,进入CRC校验
            state = STATE_CHECK_CRC;
            break;
            
        case STATE_CHECK_CRC:
            if (CRC_Check(frame) == SUCCESS) {
                state = STATE_SEND_ACK;
            } else {
                state = STATE_SEND_NAK;
            }
            break;
            
        case STATE_SEND_ACK:
            UART_SendByte(ACK);
            state = STATE_WAIT_C;
            break;
            
        case STATE_SEND_NAK:
            UART_SendByte(NAK);
            state = STATE_WAIT_C;
            break;
            
        case STATE_COMPLETE:
            // 传输完成,跳转到固件
            JumpToApplication();
            break;
            
        case STATE_ERROR:
            // 错误处理
            Error_Handler();
            break;
    }
}

4.7 超时处理:别让协议卡死

协议最怕的就是卡死。发送端发了数据,接收端没回复,两边都在等,然后就死锁了。所以,超时机制必不可少。

我一般会设置两个超时:

  • 帧间超时:等待下一个字节的时间,通常设为100ms
  • 帧超时:等待完整一帧的时间,通常设为1秒
注意:超时时间要根据波特率来调整。波特率9600时,128字节的帧大约需要130ms,所以帧超时至少要设200ms。我习惯留50%的余量,设到300ms。

4.8 实战建议:先仿真,再烧录

协议开发最容易出问题的地方是边界条件。比如:

  • 包序号从0xFF跳转到0x00时,接收端能不能正确处理?
  • 连续收到两个NAK时,发送端会不会死循环?
  • 最后一帧数据不足128字节时,填充的0xFF会不会被误认为有效数据?

我的建议是:先用PC仿真工具测试协议,确认所有边界条件都覆盖到了,再烧录到目标板。我常用的工具是串口调试助手+Python脚本,模拟发送端和接收端的行为。

总结一下:协议设计要简单、可靠、可测试。别想着一步到位,先跑通基本流程,再逐步优化。记住,Bootloader的使命是「把固件安全地写进Flash」,不是「展示你的协议设计有多牛」。