4. 通信协议设计:UART/I2C/SPI协议对比、自定义通信帧格式设计、CRC校验实现

做Bootloader,说白了就是让单片机在启动时能通过某种通信接口,把新的固件“灌”进去。通信协议选不对,后面全白搭。我见过不少项目,Bootloader写得很漂亮,结果选了个不适合的通信方式,升级时各种丢包、超时,最后只能返工。

这一章,咱们就把UART、I2C、SPI这三个最常用的协议掰开揉碎,看看它们在Bootloader场景下到底该怎么选、怎么用。然后我会给出一个我自己用了多年的自定义通信帧格式,最后把CRC校验的实现也一并讲透。

4.1 三大协议对比:谁更适合做Bootloader?

先看一张对比表,心里有个底:

特性 UART I2C SPI
物理层 2线(TX/RX) 2线(SDA/SCL) 4线(MOSI/MISO/SCLK/CS)
通信方式 异步,需双方约定波特率 同步,主从模式 同步,主从模式
最大速率 通常115200~921600 bps 标准100k/400k,快速1M 可达10M~50M bps
抗干扰能力 中等,无时钟同步 较好,有ACK/NACK机制 较好,差分或单端均可
硬件资源占用 少,大多数MCU标配 少,需开漏上拉 较多,需4个IO
Bootloader适用性 ★★★★★ 最常用 ★★★☆☆ 适合多设备 ★★★★☆ 适合高速升级

我的建议:如果项目没有特殊要求,优先选UART。原因很简单——几乎所有单片机都有UART外设,调试器、串口工具随手可得,开发效率最高。我在一个量产项目中用过I2C做Bootloader,结果因为从机地址冲突,折腾了两天才定位到问题。从那以后,除非是多设备共线升级,否则我坚决不用I2C。

4.2 自定义通信帧格式设计

协议选好了,接下来就是设计帧格式。很多人喜欢直接用现成的协议,比如XMODEM、YMODEM。但说实话,这些协议在资源受限的单片机上实现起来并不轻量。我个人习惯自己设计一个精简的帧格式,既灵活又可控。

下面是我在多个Bootloader项目中验证过的帧结构:

| 帧头(2B) | 长度(1B) | 类型(1B) | 数据(NB) | CRC16(2B) | 帧尾(1B) |
| 0xAA 0x55 |  N+4     |  0x01~0x05 | 有效载荷 |  校验值   |  0x0D    |

各字段说明:

  • 帧头(0xAA 0x55):用于同步,检测到这两个字节才认为一帧开始。我遇到过一些干扰强的环境,帧头用单字节经常误触发,改成双字节后问题就解决了。
  • 长度(1字节):从类型字段到CRC字段的总字节数。注意不包括帧头和帧尾。
  • 类型(1字节):定义命令类型,比如0x01表示擦除、0x02表示写入、0x03表示校验、0x04表示跳转、0x05表示查询版本。
  • 数据(N字节):有效载荷,比如固件数据块。
  • CRC16(2字节):对“长度+类型+数据”进行校验,低位在前,高位在后。
  • 帧尾(0x0D):结束标志,用于辅助接收端判断帧结束。

避坑指南:我曾经在帧尾只用了0x0D,结果数据里如果出现0x0D就会提前截断。后来我改成帧尾用0x0D 0x0A两个字节,并在数据中做转义处理(比如0x0D转成0x1D 0x0E),彻底解决了这个问题。如果你的数据量不大,也可以不做转义,但帧尾一定要选一个数据中极少出现的值。

4.3 CRC校验实现

通信过程中,数据出错是常态。尤其是在工业现场,电机启停、继电器动作都会产生强烈的电磁干扰。CRC(循环冗余校验)就是用来检测这些错误的。

我推荐使用CRC-16-CCITT(多项式0x1021),因为它实现简单、检错能力强,而且很多MCU硬件直接支持。下面给出一个纯软件实现的查表法版本:

// CRC-16-CCITT 查表法实现
// 多项式: 0x1021 (x^16 + x^12 + x^5 + 1)
// 初始值: 0xFFFF

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, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF];
    }
    return crc;
}

注意:查表法虽然快,但需要占用512字节的ROM空间。如果你的单片机Flash非常紧张(比如只有1KB),可以考虑用逐位计算法,速度慢一些,但几乎不占额外空间。我曾在STM8S003上做过Bootloader,Flash总共才8KB,查表法占用的512字节实在舍不得,最后用了逐位法。

逐位计算法的实现如下:

uint16_t crc16_bitwise(const uint8_t *data, uint16_t len) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < len; i++) {
        crc ^= (uint16_t)data[i] << 8;
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x8000) {
                crc = (crc << 1) ^ 0x1021;
            } else {
                crc <<= 1;
            }
        }
    }
    return crc;
}

4.4 通信协议整体流程

把上面这些串起来,一个完整的Bootloader通信流程就清晰了。我画了一张图,方便你理解:

Bootloader 通信协议整体流程 主机(上位机) 发送握手帧 发送擦除命令 发送数据帧 发送跳转命令 从机(单片机Bootloader) 解析握手帧,回复ACK 擦除Flash扇区,回复ACK 校验CRC,写入Flash,回复ACK 跳转到应用程序 关键点 • 每帧必须校验CRC • 超时重传机制(500ms) • 连续3次失败则退出升级

这个流程看起来简单,但实际实现时有很多细节。比如握手帧,我一般会包含一个随机数,防止误触发。还有超时重传,我习惯用500ms作为超时阈值,连续3次收不到ACK就退出升级模式,避免死锁。

4.5 实战中的几个坑

最后,分享几个我在实际项目中踩过的坑:

  • 波特率偏差:UART通信时,如果主从双方的晶振精度不同,波特率偏差超过2%就会丢包。我建议在握手阶段做一次波特率自适应,或者直接用内部RC振荡器时选择9600这种低速。
  • Flash写入时序:写入Flash时,单片机不能响应中断。如果你在Bootloader中开了中断,写入前一定要关掉,否则会导致写入失败甚至死机。
  • CRC计算范围:很多人只对数据部分算CRC,忽略了类型和长度字段。我曾经因为这个疏忽,导致类型字段被篡改后无法检测,程序跳转到了错误地址。正确的做法是:CRC覆盖“长度+类型+数据”三个字段。

我的习惯:在正式量产前,我会用串口助手连续发送1000次随机数据帧,统计丢包率和误码率。如果丢包率超过0.1%,我就会检查硬件设计或调整通信参数。这个习惯帮我避免了好几次批量返工。