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通信流程就清晰了。我画了一张图,方便你理解:
这个流程看起来简单,但实际实现时有很多细节。比如握手帧,我一般会包含一个随机数,防止误触发。还有超时重传,我习惯用500ms作为超时阈值,连续3次收不到ACK就退出升级模式,避免死锁。
4.5 实战中的几个坑
最后,分享几个我在实际项目中踩过的坑:
- 波特率偏差:UART通信时,如果主从双方的晶振精度不同,波特率偏差超过2%就会丢包。我建议在握手阶段做一次波特率自适应,或者直接用内部RC振荡器时选择9600这种低速。
- Flash写入时序:写入Flash时,单片机不能响应中断。如果你在Bootloader中开了中断,写入前一定要关掉,否则会导致写入失败甚至死机。
- CRC计算范围:很多人只对数据部分算CRC,忽略了类型和长度字段。我曾经因为这个疏忽,导致类型字段被篡改后无法检测,程序跳转到了错误地址。正确的做法是:CRC覆盖“长度+类型+数据”三个字段。
我的习惯:在正式量产前,我会用串口助手连续发送1000次随机数据帧,统计丢包率和误码率。如果丢包率超过0.1%,我就会检查硬件设计或调整通信参数。这个习惯帮我避免了好几次批量返工。