为什么自己实现 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 / OpReadRsp0 / 1读请求 / 读响应
OpWrite / OpWriteRsp2 / 3写请求 / 写响应
GroupOs0OS 管理(RESET)
GroupImage1镜像管理(ERASE/UPLOAD/STATE)
OsMgmtIdReset5系统重启
ImgMgmtIdState0镜像状态查询/确认
ImgMgmtIdUpload1镜像上传
ImgMgmtIdErase5镜像擦除

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

关键细节:

  1. _pendingBuffer 暂存:一次 Feed 喂入的字节可能跨多帧,返回第一帧后剩余字节存进 _pendingBuffer,下次 Feed 拼到新数据前面继续处理,避免丢帧。
  2. 噪声容忍WaitDelimiter 态下非 0x00 字节直接丢弃,这栽数据流里混入 Modbus 回包或 JSON 日志时不会污染 SMP 帧边界。
  3. 溢出保护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_lennh_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=512NETBUF_SIZE=640。单片数据过大超过 MTU 会被丢弃,过小则升级慢。

算一下一帧的总开销:

部分字节数
SMP Header8
CBOR map 头 + off + data 头~11
data payloadX
COBS 膨胀~X/254
前后 0x00 分隔2

UploadChunkSize = 384,总帧约 8 + 11 + 384 + 2 + 2 ≈ 407 字节,留出余量,确保 < 512。这个值和后端 stm32-mill-apiMAX_CHUNK_SIZE_SMP 保持一致,保证串口直连和 MQTT 远程走相同的分片逻辑。

256KB 固件按 384 字节分片约 683 片,115200 波特率下约 30 秒完成传输,可接受。

小结

整套 SMP 协议栈约 600 行 C#,覆盖了 Header 编解码、CBOR 编解码、COBS 帧分隔、串口状态机、MQTT 包装、镜像升级四命令。核心经验:

  1. 大端是小端的反义词——Header 两个 16 位字段必须大端,这是 mcumgr 协议规范,不是建议
  2. CBOR 解析用字节扫描——避开定长/不定长 map 的差异,和设备端实现对齐
  3. slot 不要硬编码——让设备自己选对侧 slot,避免运行槽位假设错误
  4. 末帧靠自动 finalize——off + data_len == total_len 即触发,不用单独发空 data 帧
  5. RESET 响应丢了别慌——设备重启正常现象,不强制校验