为什么自己实现 SMP
Zephyr 官方提供了 mcumgr CLI 工具和 mcumgr/smp_udp 等传输实现,但桌面 OTA 工具需要在同一条 RS-485 总线上时分复用 Modbus、JSON 日志、SMP 三种协议,官方 CLI 没法嵌入到 .NET 应用里,也不支持 MQTT 旁路。所以干脆在 OTA.Protocols.Smp 里从零实现一份 SMP 协议栈,和设备端 app_smp_mqtt.c、后端 smp-cbor-encoder.js 逐字节对齐。
SMP(Simple Management Protocol)是 Zephyr mcumgr 框架的传输层协议,承载 CBOR 编码的命令体,通过 UART/UDP/BLE/MQTT 下发到设备,完成镜像管理、OS 管理、文件系统管理等操作。本文只涉及镜像升级相关的四个命令。
8 字节 Header 结构
SMP 帧由定长 8 字节 Header + 变长 CBOR payload 组成。Header 字段布局参考 Zephyr smp_internal.h:
byte 0: _res1(3 bit) | version(2 bit) | op(3 bit)
byte 1: flags
byte 2-3: len (大端) —— CBOR payload 长度
byte 4-5: group (大端)
byte 6: seq
byte 7: id
关键常量与 mgmt_defines.h 对齐:
| 字段 | 值 | 含义 |
|---|---|---|
OpRead / OpReadRsp | 0 / 1 | 读请求 / 读响应 |
OpWrite / OpWriteRsp | 2 / 3 | 写请求 / 写响应 |
GroupOs | 0 | OS 管理(RESET) |
GroupImage | 1 | 镜像管理(ERASE/UPLOAD/STATE) |
OsMgmtIdReset | 5 | 系统重启 |
ImgMgmtIdState | 0 | 镜像状态查询/确认 |
ImgMgmtIdUpload | 1 | 镜像上传 |
ImgMgmtIdErase | 5 | 镜像擦除 |
C# 侧用 readonly record struct 表示 Header,Write 方法手动拼字节:
public void Write(Span<byte> buffer)
{
buffer[0] = (byte)((Op & 0x07) | ((SmpConstants.Version & 0x03) << 3));
buffer[1] = Flags;
BinaryPrimitives.WriteUInt16BigEndian(buffer[2..4], Len);
BinaryPrimitives.WriteUInt16BigEndian(buffer[4..6], Group);
buffer[6] = Seq;
buffer[7] = Id;
}
SmpFrameBuilder.Build 负责拼接 header + CBOR payload,返回完整 SMP 帧。
CBOR Payload 编解码
payload 用 CBOR(Concise Binary Object Representation,RFC 8949)编码。.NET 8 自带 System.Formats.Cbor,省去第三方依赖。
编码:IMG_UPLOAD 首帧需要四字段 map,末帧只有两字段,map 大小动态计算:
int mapSize = 2 + (totalLen.HasValue ? 1 : 0) + (sha is not null ? 1 : 0);
writer.WriteStartMap(mapSize);
writer.WriteTextString("off");
writer.WriteUInt64(off);
writer.WriteTextString("data");
writer.WriteByteString(data);
// 首帧额外写 len / sha
解码:响应里只关心 rc(返回码)和 off(下一期望偏移)。这里没用 CborReader,而是写了一个字节扫描器 TryParseUintField,直接在 payload 里找 0x60|len + "rc" 这个 CBOR text 键,再读紧跟的 uint。
为什么不用 CborReader?因为 mcumgr 设备端 CBOR 编码可能是定长 map(A1/A2),也可能是不定长 map(BF...FF),CborReader 需要先知道 map 长度才能正确推进状态。扫描器对两种格式都兼容,和后端 JS 实现保持一致。
支持的 CBOR uint 编码:0x00-0x17(内联)、0x18(1 字节)、0x19(2 字节大端)、0x1A(4 字节大端)、0x1B(8 字节大端)。
COBS 帧分隔(串口传输)
SMP 帧是变长的,串口是字节流,需要帧分隔。SMP-over-UART 用 COBS(Consistent Overhead Byte Stuffing)+ 0x00 分隔符:
[0x00][COBS 编码数据][0x00]
0x00 作为帧分隔符的好处:和 Modbus 从站地址(1-247)以及 JSON 起始字符 {(0x7B)都不冲突,可以在同一条 USART3 上时分复用三种协议。
COBS 编码原理:把数据中的 0x00 替换成”距离上一个 0 的距离”指针,使编码后的数据不含 0x00。每 254 字节强制分块(code 满值 0xFF)。编码开销最坏 1/254,约 0.4%。
// CobsEncode 核心循环
if (input[readIndex] == 0) {
output[codeIndex] = code; // 回填当前 code
code = 1;
codeIndex = writeIndex++; // 预留下一个 code 位置
} else {
output[writeIndex++] = input[readIndex];
code++;
if (code == 0xFF) { // 满 254 强制分块
output[codeIndex] = code;
code = 1;
codeIndex = writeIndex++;
}
}
状态机帧读取器
串口接收端不能假设一次 Read 就能拿到完整帧。SmpSerialFrameReader 是一个两状态的状态机:
WaitDelimiter ──收到 0x00──▶ ReadCobs ──再收到 0x00──▶ 返回解码帧,回 WaitDelimiter
关键细节:
_pendingBuffer暂存:一次Feed喂入的字节可能跨多帧,返回第一帧后剩余字节存进_pendingBuffer,下次Feed拼到新数据前面继续处理,避免丢帧。- 噪声容忍:
WaitDelimiter态下非0x00字节直接丢弃,这栽数据流里混入 Modbus 回包或 JSON 日志时不会污染 SMP 帧边界。 - 溢出保护:
MaxCobsFrameSize = 1024,超过则丢弃整帧回到WaitDelimiter,防止异常数据撑爆缓冲。
SmpSerialClient.SendCommand 用这个读取器循环读串口,直到拿到完整帧或超时,超时重试最多 2 次。响应匹配只校验 group/id,seq 因设备实现差异不做严格匹配。
三种传输路径
同一份 SMP 帧(header + CBOR)可以走三条传输路径:
| 路径 | 包装方式 | 用途 |
|---|---|---|
| 裸帧 | 直接用 SmpFrameBuilder.Build 输出 | 内部传递、单元测试 |
| 串口 COBS | [0x00][COBS][0x00] | 本地 RS-485 直连升级 |
| MQTT base64 | [0x00][COBS][0x00] → base64 → JSON data 字段 | 远程通过 MQTT broker 升级 |
MQTT 路径多一层 base64 是因为 MQTT JSON payload 必须是文本。SmpMqttWrapping.WrapForMqtt 负责完成 COBS 编码 + 0x00 包裹 + base64 三步:
public static string WrapForMqtt(byte[] smpFrame)
{
var encoded = SmpCobs.Encode(smpFrame);
var framed = new byte[encoded.Length + 2];
framed[0] = 0x00;
encoded.CopyTo(framed, 1);
framed[^1] = 0x00;
return Convert.ToBase64String(framed);
}
解包时 UnwrapFromDevice 先找前导 0x00,再找尾部 0x00(COBS 编码数据内部不含 0),中间部分 COBS 解码还原 SMP 帧。
完整升级流程
LocalSmpUpgradeService.Execute 编排完整流程:
IMG_ERASE → IMG_UPLOAD ×N → IMAGE_STATE_READ → RESET_WRITE
1. IMG_ERASE 擦除升级槽
SmpImageUpload.BuildErase(seq: 1) 发空 map,不指定 slot。这里踩过坑:早期硬编码 slot=1,但当设备实际运行在 slot1 时,对侧 slot0 不可用,设备返回 NO_FREE_SLOT。发空 map 让设备自己用 opposite_slot(active_slot) 选目标,避免 PC 端假设错误。
2. IMG_UPLOAD 分片上传
首帧含 off=0 / data / len / sha 四字段,后续帧只含 off / data 两字段。设备收到 off + data_len == total_len 的帧时自动 finalize 上传会话,不需要单独发 off=total_len, data=空 的末帧——多发反而会被部分 Zephyr 版本拒绝。
每片响应里带 off 字段表示下一期望偏移,可以用来做断点续传校验:
if (response.Off.HasValue && response.Off.Value != (long)(off + (ulong)chunkLen))
{
logSink.Write($"警告:设备期望偏移 {response.Off.Value},实际发送 {off + chunkLen}");
}
3. IMAGE_STATE_READ 查询状态
SmpImageState.BuildRead 发空 map 读镜像状态,确认 slot1 已标记为 pending。
4. RESET_WRITE 重启
SmpReset.BuildResetWrite 触发设备重启,MCUboot 在重启时完成镜像 swap。RESET 响应可能因设备已经重启而丢失,所以 maxRetries=0,不强制校验响应。
踩坑:大小端导致 MGMT_ERR_ECORRUPT(9)
Header 的 nh_len 和 nh_group 字段必须用大端写入,对应 Zephyr smp.c 里的 sys_cpu_to_be16()。
最初图省事用了 BinaryPrimitives.WriteUInt16LittleEndian,结果设备把 len=0x0040(64 字节)解析成 0x4000(16384 字节),认为 payload 长度远超实际,直接返回 MGMT_ERR_ECORRUPT(9)(帧损坏)。
Header byte 0 的位拼接也容易出错:op 占低 3 bit,version 占中间 2 bit,_res1 占高 3 bit。正确写法是 (Op & 0x07) | ((Version & 0x03) << 3)。
UploadChunkSize=384 与 Zephyr MTU=512 的适配
Zephyr 端配置 CONFIG_MCUMGR_TRANSPORT_UART_MTU=512,NETBUF_SIZE=640。单片数据过大超过 MTU 会被丢弃,过小则升级慢。
算一下一帧的总开销:
| 部分 | 字节数 |
|---|---|
| SMP Header | 8 |
| CBOR map 头 + off + data 头 | ~11 |
data payload | X |
| COBS 膨胀 | ~X/254 |
| 前后 0x00 分隔 | 2 |
取 UploadChunkSize = 384,总帧约 8 + 11 + 384 + 2 + 2 ≈ 407 字节,留出余量,确保 < 512。这个值和后端 stm32-mill-api 的 MAX_CHUNK_SIZE_SMP 保持一致,保证串口直连和 MQTT 远程走相同的分片逻辑。
256KB 固件按 384 字节分片约 683 片,115200 波特率下约 30 秒完成传输,可接受。
小结
整套 SMP 协议栈约 600 行 C#,覆盖了 Header 编解码、CBOR 编解码、COBS 帧分隔、串口状态机、MQTT 包装、镜像升级四命令。核心经验:
- 大端是小端的反义词——Header 两个 16 位字段必须大端,这是 mcumgr 协议规范,不是建议
- CBOR 解析用字节扫描——避开定长/不定长 map 的差异,和设备端实现对齐
- slot 不要硬编码——让设备自己选对侧 slot,避免运行槽位假设错误
- 末帧靠自动 finalize——
off + data_len == total_len即触发,不用单独发空 data 帧 - RESET 响应丢了别慌——设备重启正常现象,不强制校验