背景

Zephyr 的 MCUmgr 自带 smp_uart/smp_udp/smp_bt 三种传输,都假设设备直接和主机相连。Mill 项目拓扑不同——设备挂 RS485 总线,通过 DR154 DTU 透传到 MQTT broker,云端 .NET 服务再 publish 下发 SMP 帧:

云端 .NET → MQTT broker → DR154 DTU → RS485 → STM32 USART3

中间多了 DTU 和 MQTT 两层,标准 smp_uart 用不了。但 MCUmgr 传输层可插拔——注册自定义 smp_transport,提供 output 回调和 get_mtu 回调,收到数据时调 smp_rx_req,就能把任意管道接到 SMP 协议栈。

前一篇《SMP 升级协议》讲 .NET 侧协议栈,这篇聚焦设备侧传输层。

整体架构

发送和接收分别由 app_smp_mqtt.capp_usart3_router.c 承担:

发送路径(SMP 响应 → USART3):
  MCUmgr → smp_mqtt_tx_pkt(output 回调)
    → COBS 编码 → DE 拉高 → 发送 [0x00][编码数据][0x00]
    → 等待 TC → DE 拉低

接收路径(USART3 → SMP 协议栈):
  USART3 中断 → router_feed_byte 状态机
    → 首字节 0x00 → 收集 COBS 帧至尾部 0x00
    → app_smp_mqtt_submit_encoded → COBS 解码
    → smp_packet_alloc → net_buf_add_mem → smp_rx_req

帧格式与原 STM32 工程 G780s_ota 兼容:[0x00][COBS 编码数据][0x00]0x00 作分隔符,和 Modbus 地址(1-247)及 JSON 起始符 {(0x7B)不冲突,可在同一条 USART3 上时分复用三协议。

smp_transport 注册

注册分三步:填回调、初始化、注册 OTA 状态钩子。

static struct smp_transport smp_mqtt_transport;

int app_smp_mqtt_init(void)
{
    gpio_pin_configure_dt(&rs485_de, GPIO_OUTPUT_INACTIVE);  /* DE 初始低=接收 */
    smp_mqtt_transport.functions.output  = smp_mqtt_tx_pkt;
    smp_mqtt_transport.functions.get_mtu = smp_mqtt_get_mtu;
    smp_transport_init(&smp_mqtt_transport);
    mgmt_callback_register(&ota_status_mgmt_callback);
    return 0;
}

static uint16_t smp_mqtt_get_mtu(const struct net_buf *nb)
{
    return CONFIG_APP_SMP_MQTT_MTU;   /* 默认 512,和 smp_uart 一致 */
}

注册后和 smp_uart(USART1,Shell 通道)并存,MCUmgr 不区分请求来源,两个通道都能下发镜像命令。本地调试走 USART1 直连,远程 OTA 走 USART3 + DTU。

发送路径:output 回调

output 回调收到装好完整 SMP 响应的 net_buf,负责编码后送出。

static int smp_mqtt_tx_pkt(struct net_buf *nb)
{
    uint16_t encoded_len = cobs_encode(nb->data, nb->len,
                                       smp_tx_buf + 1, SMP_MQTT_MAX_ENCODED);
    smp_tx_buf[0] = 0x00;                  /* 前导分隔符 */
    smp_tx_buf[encoded_len + 1] = 0x00;   /* 尾部分隔符 */

    k_mutex_lock(&usart3_tx_mutex, K_MSEC(200));  /* 与遥测/Modbus 互斥 */
    gpio_pin_set_dt(&rs485_de, 1);                 /* DE 拉高,切发送 */
    for (uint16_t i = 0; i < encoded_len + 2; i++) {
        uart_poll_out(usart3_dev, smp_tx_buf[i]);
    }
    while (!uart_irq_tx_complete(usart3_dev)) {    /* 等 TC */
        /* 10ms 超时保护 */
    }
    gpio_pin_set_dt(&rs485_de, 0);                 /* DE 拉低,回接收 */
    k_mutex_unlock(&usart3_tx_mutex);

    smp_packet_free(nb);   /* output 回调拥有 net_buf 所有权,必须释放 */
    return 0;
}

关键点:COBS 编码从 smp_tx_buf[1] 开始写,[0] 留给前导 0x00,省一次 memcpy。uart_poll_out 返回时仅 TXE=1(TDR 空),末字节还在移位寄存器里,此时拉低 DE 会截断末字节——必须等 TC(Transmission Complete)标志。

接收路径:USART3 路由状态机

DTU 通过 MQTT 透传是字节流,一帧可能跨多次中断到达。app_usart3_router.c 实现逐字节状态机,按首字节判协议。

三协议时分复用

ROUTER_IDLE ──首字节──┬── 0x00 ──▶ ROUTER_SMP    (收集到下一个 0x00 结束)
                      ├── '{'  ──▶ ROUTER_JSON   (收集到 '\n' 结束)
                      └── 1-247 ─▶ ROUTER_MODBUS (静默超时结束)
enum router_state { ROUTER_IDLE, ROUTER_SMP, ROUTER_JSON, ROUTER_MODBUS };

switch (router_ctx.state) {
case ROUTER_IDLE:
    if (b == 0x00)        router_ctx.state = ROUTER_SMP;     /* COBS 前导 */
    else if (b == '{')    router_ctx.state = ROUTER_JSON;    /* JSON 配置帧 */
    else                  router_ctx.state = ROUTER_MODBUS;  /* Modbus 地址 */
    break;
case ROUTER_SMP:
    if (b == 0x00) router_handle_smp_complete();   /* 帧尾 */
    else           smp_enc_buf[router_ctx.index++] = b;
    break;
/* ... */
}

SMP 帧收齐后调 app_smp_mqtt_submit_encoded 上送:

int app_smp_mqtt_submit_encoded(const uint8_t *encoded, uint16_t len)
{
    uint16_t decoded_len = cobs_decode(encoded, len, decode_buf, sizeof(decode_buf));
    struct net_buf *nb = smp_packet_alloc();       /* 从 MCUmgr 缓冲池分配 */
    net_buf_add_mem(nb, decode_buf, decoded_len);   /* 填入解码后的 SMP 帧 */
    smp_rx_req(&smp_mqtt_transport, nb);            /* 上送 MCUmgr 工作队列 */
    return 0;
}

smp_rx_reqnet_buf 投递到 MCUmgr 处理线程,后续 CBOR 解析、命令分发、镜像写入都在那完成,传输层不阻塞。

COBS 编解码与最坏膨胀

COBS 把数据中的 0x00 替换成距离指针,使编码后不含 0x000x00 就能安全做帧分隔符。每 254 字节增加一个 code,再加首个 code:

#define SMP_MQTT_MAX_PAYLOAD  CONFIG_APP_SMP_MQTT_MTU     /* 512 */
#define SMP_MQTT_MAX_ENCODED  (SMP_MQTT_MAX_PAYLOAD +     /* 原始 */
                              (SMP_MQTT_MAX_PAYLOAD / 254u) + 1u)
#define SMP_MQTT_TX_BUF_SIZE  (SMP_MQTT_MAX_ENCODED + 2u) /* 前后 0x00 */

512 字节 payload 最坏膨胀到 512 + 2 + 1 = 515,加前后分隔符共 517 字节。编码核心:遇 0x00 回填当前 code 并开新块;code 满 0xFF(254)强制分块。

RS485 DE 控制时序

RS485 半双工,DE 引脚控制方向:高=发送,低=接收。时序错一字节就丢。

DE拉高 ──▶ 发送 [0x00][COBS][0x00] ──▶ 等待TC ──▶ DE拉低
  1. 拉高后才能发送:GPIO 写和 UART 首字节间有几条指令间隔,足够 RS485 收发器建立。
  2. 必须等 TC 才能拉低uart_poll_out 只保证 TDR 空,末字节还在移位寄存器。轮询 uart_irq_tx_complete(TC 标志)确认移位寄存器也空了,否则末字节被截断。10ms 超时保护防死锁。

usart3_tx_mutex:发送串行化

USART3 被 SMP 响应、DR154 遥测、Modbus 从站响应三发送者共享。半双工总线两个同时写会电平冲突。

usart3_tx_mutex 定义在 dr154_telemetry.c,所有 USART3 发送路径先拿锁。锁粒度是整帧——从 DE 拉高到拉低必须在临界区内。200ms 超时保护避免遥测卡死时 SMP 跟着卡。

app_smp_mqtt.c 没直接 include dr154_private.h,而是前向声明 extern struct k_mutex usart3_tx_mutex——那个头还 extern 了 usart3_dev/rs485_de,和本文件 static 定义冲突,只声明需要的 mutex 改动最小。

JSON 帧延迟处理与双槽缓冲

后端除裸 SMP-COBS 帧,还用 JSON 包裹 SMP 帧(ota_smp_control/ota_smp_chunk),COBS 帧 base64 编码塞进 data 字段。首字节 {,走 ROUTER_JSON 分支。

问题:JSON 解析用 cJSON_Parse,内部调 malloc/free。Zephyr 堆非异步信号安全,ISR 里调会堆损坏。

解决:ISR 里只拷帧到缓冲,k_work_submit 推迟到系统工作队列线程解析。但 k_work_submit 同一 work 项多次提交会合并,第二帧可能在第一帧处理完前到达覆盖 pending 缓冲。

双槽缓冲解决:

#define ROUTER_JSON_PENDING_COUNT 2u
static char     router_json_pending_buf[2][ROUTER_JSON_BUF_SIZE + 1];
static uint16_t router_json_pending_len[2];
static atomic_t router_json_buffer_used;
K_MSGQ_DEFINE(router_json_queue, sizeof(uint8_t), 2, 1);

static void router_queue_json_frame(uint16_t copy_len)
{
    uint8_t slot;
    for (slot = 0; slot < 2; slot++) {   /* 原子占一个空闲槽 */
        if (!atomic_test_and_set_bit(&router_json_buffer_used, slot)) break;
    }
    memcpy(router_json_pending_buf[slot], json_buf, copy_len);
    k_msgq_put(&router_json_queue, &slot, K_NO_WAIT);
    k_work_submit(&router_json_work);
}

工作队列线程从 msgq 取槽号,拷到栈上 local_buf 后立刻 atomic_clear_bit 释放槽,再 cJSON_Parse。两槽覆盖一帧处理期间到达的下一帧;连发三帧则第三帧丢弃告警,不阻塞 ISR。

帧超时策略

协议超时理由
SMP-COBS5ms二进制流连续到达,5ms 静默即帧结束
JSON200msDTU 经 MQTT 透传可能分包,大帧(base64 分片 ~900B)在 115200bps 下需 ~78ms
Modbus5ms3.5 字符间隔 @ 115200 ≈ 0.3ms,留余量

JSON 的 200ms 是踩坑后的值。最初 50ms,小帧够用,但 ota_smp_chunk 帧总 JSON 约 900 字节,DTU 分包间隔可能超 50ms,导致帧截断、cmd 字段丢失。改 200ms 容忍 DTU 分包延迟。

MCUmgr OTA 状态钩子

MCUmgr 官方状态回调比逐包日志干净。注册 mgmt_callback 后在 OTA 生命周期关键点触发:

static enum mgmt_cb_return ota_status_callback(uint32_t event, ...)
{
    switch (event) {
    case MGMT_EVT_OP_IMG_MGMT_DFU_STARTED:
        ota_upload_chunk_count = 0;
        LOG_INF("OTA upload started");
        break;
    case MGMT_EVT_OP_IMG_MGMT_DFU_CHUNK_WRITE_COMPLETE:
        if ((++ota_upload_chunk_count % 32u) == 0u)   /* 每 32 片报一次 */
            LOG_INF("OTA progress: chunks=%u", ota_upload_chunk_count);
        break;
    case MGMT_EVT_OP_IMG_MGMT_DFU_PENDING:
        ota_upload_completed = true;
        LOG_INF("OTA completed: chunks=%u", ota_upload_chunk_count);
        break;
    case MGMT_EVT_OP_IMG_MGMT_DFU_STOPPED:
        LOG_ERR("OTA stopped: chunks=%u", ota_upload_chunk_count);
        break;
    }
    return MGMT_CB_OK;
}

ota_upload_completed 标记用于后续收到 OS_MGMT_ID_RESET 时识别这是 OTA 完成后的重启,只记一次日志。分片写入每 32 片报进度,避免逐包日志淹没串口。

Kconfig 配置

应用级 Kconfig:

config APP_SMP_MQTT
    bool "SMP-over-MQTT transport (USART3 + COBS + RS485)"
    default y

config APP_SMP_MQTT_MTU
    int "SMP-over-MQTT transport MTU"
    default 512
    range 64 1024

prj.conf 关键项:

CONFIG_MCUMGR=y
CONFIG_MCUMGR_GRP_IMG=y
CONFIG_MCUMGR_GRP_OS=y
CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y     # 状态钩子
CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y
CONFIG_MCUMGR_TRANSPORT_SHELL=y              # USART1 Shell 通道
CONFIG_MCUMGR_TRANSPORT_SHELL_MTU=512
CONFIG_MCUMGR_TRANSPORT_NETBUF_SIZE=640      # 必须 >= MTU+2,默认 384 不够
CONFIG_APP_SMP_MQTT=y

NETBUF_SIZE=640 是踩坑值。默认 384 装不下 512 MTU 的完整 SMP 帧,smp_packet_alloc 返回的 net_buf 尾空间不足,net_buf_add_mem 失败。

与 smp_uart 并存

通道传输UART用途
smp_uartShellUSART1本地调试,mcumgr CLI 直连
smp_mqtt_transport自定义 COBSUSART3远程 OTA,经 RS485 + DTU + MQTT

两通道注册到同一 MCUmgr 实例,命令处理逻辑完全复用。镜像分片走哪条取决于请求来源,响应原路返回。本地调试不用拔 DTU,接 USART1 即可用 mcumgr CLI 查镜像状态、上传固件。

小结

整套实现约 900 行 C,核心设计点:

  1. smp_transport 可插拔——提供 output 回调 + smp_rx_req 入口,任意管道接 MCUmgr
  2. COBS + 0x00 分隔实现三协议复用——和 Modbus 地址、JSON 起始符不冲突
  3. RS485 DE 时序靠 TC 标志——uart_poll_out 只保证 TDR 空,必须等 TC 才切方向
  4. usart3_tx_mutex 串行化三发送者——SMP 响应、遥测、Modbus 从站互斥
  5. JSON 解析推迟到工作队列——ISR 里不能 malloc,双槽缓冲防工作项合并覆盖
  6. 超时按协议特性设——SMP 5ms,JSON 200ms 容忍 DTU 分包
  7. MCUmgr 状态钩子比逐包日志干净——四事件覆盖 OTA 全生命周期

和前一篇《SMP 升级协议》对照:.NET 侧实现协议栈(Header + CBOR + COBS + 状态机读帧),Zephyr 侧实现传输层(注册 smp_transport + 路由状态机 + RS485 时序),两侧 COBS 编解码和帧格式逐字节对齐。