背景
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.c 和 app_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_req 把 net_buf 投递到 MCUmgr 处理线程,后续 CBOR 解析、命令分发、镜像写入都在那完成,传输层不阻塞。
COBS 编解码与最坏膨胀
COBS 把数据中的 0x00 替换成距离指针,使编码后不含 0x00,0x00 就能安全做帧分隔符。每 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拉低
- 拉高后才能发送:GPIO 写和 UART 首字节间有几条指令间隔,足够 RS485 收发器建立。
- 必须等 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-COBS | 5ms | 二进制流连续到达,5ms 静默即帧结束 |
| JSON | 200ms | DTU 经 MQTT 透传可能分包,大帧(base64 分片 ~900B)在 115200bps 下需 ~78ms |
| Modbus | 5ms | 3.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_uart | Shell | USART1 | 本地调试,mcumgr CLI 直连 |
smp_mqtt_transport | 自定义 COBS | USART3 | 远程 OTA,经 RS485 + DTU + MQTT |
两通道注册到同一 MCUmgr 实例,命令处理逻辑完全复用。镜像分片走哪条取决于请求来源,响应原路返回。本地调试不用拔 DTU,接 USART1 即可用 mcumgr CLI 查镜像状态、上传固件。
小结
整套实现约 900 行 C,核心设计点:
smp_transport可插拔——提供output回调 +smp_rx_req入口,任意管道接 MCUmgr- COBS +
0x00分隔实现三协议复用——和 Modbus 地址、JSON 起始符不冲突 - RS485 DE 时序靠 TC 标志——
uart_poll_out只保证 TDR 空,必须等 TC 才切方向 usart3_tx_mutex串行化三发送者——SMP 响应、遥测、Modbus 从站互斥- JSON 解析推迟到工作队列——ISR 里不能
malloc,双槽缓冲防工作项合并覆盖 - 超时按协议特性设——SMP 5ms,JSON 200ms 容忍 DTU 分包
- MCUmgr 状态钩子比逐包日志干净——四事件覆盖 OTA 全生命周期
和前一篇《SMP 升级协议》对照:.NET 侧实现协议栈(Header + CBOR + COBS + 状态机读帧),Zephyr 侧实现传输层(注册 smp_transport + 路由状态机 + RS485 时序),两侧 COBS 编解码和帧格式逐字节对齐。