CoAP / LwM2M 轻量协议:受限设备为什么不用 MQTT

这篇是 IoT 专栏的第 5 篇。前面聊过 Wi-Fi + MQTT,也聊过 NB-IoT/LoRa 的远传。

这一篇解决一个我被问过好几次的问题:"NB-IoT 上跑 MQTT 到底行不行?不行的话用什么?"

适用人群:写过 ESP32/STM32 的 MQTT 上报、能看懂 AT 指令的同学。不需要提前懂 CoAP。

读完你能得到

  • 一张 MQTT vs CoAP 的对比表,能说清"什么情况下 MQTT 是错的";
  • CoAP 的报文长什么样(我带你按字节拆一包真实报文);
  • LwM2M 的对象模型 /3/0/1 到底在说什么,以及注册/上报/执行的完整流程;
  • 一份能跑的本地 CoAP 实验(Python,两台机器都不用买);
  • 一张"受限设备上跑 CoAP 必踩的坑"清单。

没看过前面的也能懂的核心:MQTT 跑在 TCP 上,要先握手建连接、要发心跳维持连接;CoAP 跑在 UDP 上,没有"连接"这个概念,发一包就是一包。对电池供电、几十 KB RAM、按流量计费的设备,这个差别是致命的。


一、场景:一个"穷设备"的账本

先说清楚我说的"穷设备"长什么样。不是你桌上那块 ESP32------那玩意儿有 520 KB RAM、有 Wi-Fi、旁边就是插座。我说的是这种:

text 复制代码
[场景] 一枚装在农田里的土壤传感器

  MCU:      STM32L0 系列,RAM 8 KB,Flash 64 KB
  通信:     NB-IoT 模组,通过 UART + AT 指令驱动
  供电:     一颗 ER14505 锂亚电池,目标是活 3 年
  数据:     每小时上报一次,一包数据 JSON 化后大概 20~40 字节
  数量:     500 个节点,分散在相邻三个村

我在《09_无线_03》里算过这类节点的电池账。这里换个角度算通信账

  • TCP 的代价:三次握手 3 个包、四次挥手 4 个包。哪怕你的业务数据只有 20 字节,光"建连接 + 拆连接"就要在空中传 7 个包。NB-IoT 上每个包都要经过射频发射,发射一次就是几百毫安级的电流冲一下。
  • 心跳的代价 :MQTT 的 keepalive 是为了让 broker 知道你还活着,也是为了维持 NAT 映射 。你设 60 秒心跳,设备就一分钟醒一次发一个包。PSM 直接白给------我在《09_无线_03》里踩过这个坑,模组根本没睡着,一天就把电池干掉 30%。
  • TLS 的代价 :握手要交换证书、协商密钥,几 KB 的交互量。你的业务数据 20 字节,握手 3000 字节,99% 的电量花在"打招呼"上
  • RAM 的代价:一个完整的 MQTT + TLS 客户端栈,RAM 占用常常要几十 KB。8 KB RAM 的 MCU 根本装不下。

所以结论不是"MQTT 不好",而是 MQTT 是为"不穷"的设备设计的 ------有市电、有 Wi-Fi/4G、RAM 够、流量不心疼。穷设备需要一套为它量身定做的协议:

  • 报文头要小(最好几个字节);
  • 不要"连接"这个概念(发完就睡);
  • 但要能确认对方收到了(UDP 会丢包);
  • 最好顺便把"设备管理"也标准化(升级、改参数、看状态)。

这就是 CoAP (RFC 7252)和 LwM2M(OMA SpecWorks)要干的事。

⚠️ 先打个预防针:我没有量产经验,下面所有内容是我读 RFC / OMA 规范 / 各家文档,加上自己在 PC 上做的实验整理出来的。凡是涉及具体模组的指令,我都会标注"以你手上的手册为准"。


二、MQTT vs CoAP:一张表看清

维度 MQTT CoAP
传输层 TCP(面向连接) UDP(无连接),安全模式用 DTLS
默认端口 1883 / 8883(TLS) 5683 / 5684(DTLS)coap://coaps://
交互模型 发布/订阅(Broker 中转) 请求/响应(类似 HTTP 的 REST)+ 可选的 Observe 订阅
固定头部 2 字节 4 字节(另有 0~8 字节 Token + 可选 Options)
方法 CONNECT / PUBLISH / SUBSCRIBE / PINGREQ ... GET / POST / PUT / DELETE
可靠性 靠 TCP 保证 消息类型:CON 需确认并重传,NON 不确认
QoS 0 / 1 / 2(协议层保证) 没有 QoS 级别,只有"确认"或"不确认"两档
长连接 必须维持(心跳) 不需要,发完就睡
下行推送 天然支持(订阅 topic 随时推) Observe,或设备定期醒来问
数据格式 任意二进制 任意二进制(常用 CBOR / SenML / 纯文本 / link-format)
安全 TLS DTLS,支持 PSK / RawPublicKey / X.509 三种模式
典型 RAM 占用 十几 KB 起(带 TLS 更高) 可以做到几 KB 级
适合的场景 有市电、数据量较大、需要频繁双向通信 LPWAN、电池供电、小包、低频上报

一句话总结:MQTT 像打电话------先拨号接通,聊完挂断,中间要一直占着线;CoAP 像发微信------你想说就说一句,对方回一句,各自该干嘛干嘛。

2.1 报文大小:手算一笔账

这一节的数字是我按 RFC 的报文结构手算的,你有 Wireshark 的话可以自己抓包核对(动手练第 3 步就要干这个)。

MQTT 3.1.1 的 CONNECT 报文(RFC 中 MQTT 规范的结构):

text 复制代码
固定头   : 0x10 + 剩余长度(1~4 字节)                     = 2 字节
可变头   : 协议名长度(2) + "MQTT"(4)                      = 6 字节
           协议级别(1) + 连接标志(1) + 心跳(2)            = 4 字节
载荷     : ClientID 长度前缀(2) + ClientID(N)             = 2+N 字节
         ─────────────────────────────────────────────────
合计     : 14 + N 字节        以 ClientID="dev001" 为例 → 20 字节

CoAP 的 CON POST(下面第四节会逐字节拆):

text 复制代码
固定头   : Ver/T/TKL(1) + Code(1) + Message ID(2)         = 4 字节
Token    : 假设 2 字节                                    = 2 字节
Options  : Uri-Path(11) 值 "rd"  → 1 + 2                  = 3 字节
           Uri-Query(15) 值 "ep=dev001" → 1 + 9           = 10 字节
载荷标记 : 0xFF                                           = 1 字节
         ─────────────────────────────────────────────────
不含载荷 : 20 字节

看起来差不多?差别在"握手"和"拆连接"上

环节 MQTT over TCP CoAP over UDP
建连接 TCP 三次握手,3 个包
应用层握手 CONNECT + CONNACK,一来一回 ,第一包就是业务
心跳 PINGREQ/PINGRESP,keepalive 周期内必须发 (靠业务包顺带刷新)
拆连接 TCP 四次挥手,4 个包
加密握手 TLS,量级 KB DTLS,量级 KB(PSK 模式能显著减小

⚠️ 别误读成"CoAP 一定省" 。如果你要频繁双向通信 (比如每秒一条指令),TCP 的长连接反而是优势------一次握手摊薄到几千个包里。CoAP 的省,省在"低频 + 小包 + 发完就睡"这个特定模式上。这个判断标准比记协议细节重要。


三、CoAP:把 HTTP 塞进 UDP

3.1 4 字节的报文头

RFC 7252 定义的 CoAP 报文头只有 4 字节,这是它"轻"的根本:

text 复制代码
[图 1] CoAP 报文头(RFC 7252 Figure 7)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Ver| T |  TKL  |      Code     |          Message ID           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   Token (TKL 字节, 0~8) ...                                    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   Options (TLV 格式, 可多个) ...           | 0xFF | Payload ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
字段 位数 说什么
Ver 2 版本号,RFC 7252 规定填 1。收到未知版本号的消息必须静默丢弃
T 2 消息类型:0=CON (要确认)1=NON (不要确认)2=ACK 3=RST
TKL 4 Token 长度,0~8 字节(9~15 是保留值,收到要当格式错误处理)
Code 8 写成 c.dd。class:0=请求、2=成功、4=客户端错、5=服务端错。0.00 表示空消息
Message ID 16 用于重复检测和把 ACK 对上 CON
Token 0~8 字节 用于把响应 对上请求(这是新手最容易和 Message ID 搞混的地方,见坑表)
Options 变长 Uri-Path(11)、Uri-Query(15)、Observe(6)、Content-Format(12)、Block1(27)、Block2(23)...
0xFF 8 载荷标记。看到 0xFF,后面全是 payload

两种 ID 的分工一句话讲清:Message ID 管"这包有没有丢"(传输层),Token 管"这个响应是给哪个请求的"(应用层)。一个 CON 消息重传时 Message ID 不变;一个请求的响应必须带上同样的 Token。

3.2 请求/响应:和 HTTP 一模一样的味道

CoAP 方法 Code 对应 HTTP 常用响应码
GET 0.01 GET 2.05 Content
POST 0.02 POST 2.01 Created2.04 Changed
PUT 0.03 PUT 2.04 Changed
DELETE 0.04 DELETE 2.02 Deleted

常见的响应码:2.01 Created2.02 Deleted2.04 Changed2.05 Content2.31 Continue(块传输用)、4.00 Bad Request4.04 Not Found4.05 Method Not Allowed4.08 Request Entity Incomplete

3.3 可靠还是不可靠:CON / NON

UDP 会丢包,所以 CoAP 自己实现了一套轻量可靠机制(不是照搬 TCP):

text 复制代码
[图 2] CON 与 NON

CON(要确认,重传直到收到 ACK 或放弃):
   客户端                          服务器
     |--- CON [MID=0x1234] -------->|      第 1 次发
     |      (2 s 没等到 ACK)         |
     |--- CON [MID=0x1234] -------->|      第 2 次(超时翻倍)
     |      (4 s)                   |
     |--- CON [MID=0x1234] -------->|      第 3 次
     |                              |
     |<-- ACK [MID=0x1234, 2.05] ---|      可以捎带响应(piggybacked)

NON(不确认,发了就不管):
     |--- NON [MID=0x1235] -------->|      发一次,不重传
     |                              |      适合"丢了也无所谓"的定期采样值

RFC 7252 §4.8 给的默认传输参数(这几个数字值得背一下,调参时全是它们):

参数 默认值 含义
ACK_TIMEOUT 2 s 首次重传前的等待
ACK_RANDOM_FACTOR 1.5 每次实际超时在 TIMEOUT ~ TIMEOUT×1.5 之间随机,避免多设备同时重传
MAX_RETRANSMIT 4 最多重传 4 次(超时按 2→4→8→16→32 s 翻倍)
NSTART 1 同一时刻只允许 1 个未确认的 CON 去往同一个对端
DEFAULT_LEISURE 5 s 组播 NON 后的响应等待
PROBING_RATE 1 B/s 无响应时的探测速率上限
MAX_TRANSMIT_SPAN 45 s 从首传到最后一次重传的最长时间
MAX_TRANSMIT_WAIT 93 s 从首传到彻底放弃的最长时间

⚠️ 93 秒这个数字很重要 :一包 CON 从发出去到判定失败,最坏情况要接近一分半钟 。你的应用层超时如果设成 10 秒就重发一个新请求,网络上会同时存在好几个"逻辑上是同一条"的数据------服务器必须做去重(靠 Message ID)。

3.4 两个必知扩展:Observe 和块传输

Observe(RFC 7641)------CoAP 版的订阅

客户端发一个 GET,带上 Observe 选项(值=0 表示订阅,值=1 表示取消订阅)。之后资源一变,服务器主动推通知过来,每条通知带一个递增的序号。这样"订阅"和"下一次请求"就合并了,省掉反复轮询。

text 复制代码
[图 3] Observe 订阅

  客户端                                      服务器
    |--- CON GET /3303/0/5700, Observe=0 ------>|   订阅温度
    |<-- ACK 2.05 Content 26.5, Observe=1  -----|   第一条(序号 1)
    |                                            |
    |       ... 温度变了 ...                      |
    |<-- CON 2.05 Content 27.1, Observe=2  -----|   通知(序号 2)
    |--- ACK ---------------------------------->|
    |                                            |
    |--- CON GET /3303/0/5700, Observe=1 ------>|   显式取消订阅(推荐做法)

💡 有个反直觉的点:观察关系的"寿命"靠 Token 维系 。如果服务器重启、或者中间隔太久没通知,客户端再收到旧关系的通知会回一个 RST ,服务器收到 RST 就取消这条观察。所以 Observe 不是"永久订阅",客户端必须能处理"观察悄悄失效了"这件事(动手练第 4 步就去踩这个坑)。

Block-wise Transfer(RFC 7959)------大块数据分着传

受限于 IPv6 最小 MTU 1280 字节和受限节点的缓冲区,CoAP 不建议发大包(RFC 7252 建议载荷控制在 MTU 级别,不建议依赖 IP 分片)。要传大块数据(比如下发固件),就用块传输:

选项 编号 作用于
Block1 27 请求的载荷(POST/PUT 上传时用)
Block2 23 响应的载荷(GET 下载时用)

块大小由 3 位的 SZX 编码,实际块大小 = 2^(SZX+4)

SZX 0 1 2 3 4 5 6 7
块大小(B) 16 32 64 128 256 512 1024 保留(禁止发送)
  • M 位 (more):1 表示后面还有块,0 表示这是最后一块。分块传输是否结束只看 M 位,不看预估长度。
  • 单次块最大 1024 字节 。这是块传输最容易被忽略的硬边界:你的 800 KB 固件要切 800 个块,每块一次 CON/ACK 往返。
  • SZX=7 是保留值,发了必须回 4.00 Bad Request
text 复制代码
[图 4] 用 Block1 上传固件包(示意)

  客户端                                       服务器
    |--- CON PUT /5/0/0, Block1=0/NUM=0/M=1, 1024B -->|
    |<-- ACK 2.31 Continue, Block1=0/NUM=0/M=1 -------|
    |--- CON PUT /5/0/0, Block1=0/NUM=1/M=1, 1024B -->|
    |<-- ACK 2.31 Continue, Block1=0/NUM=1/M=1 -------|
    |            ... 中间省略 797 个块 ...             |
    |--- CON PUT /5/0/0, Block1=0/NUM=799/M=0, ... -->|   M=0 = 最后一块
    |<-- ACK 2.04 Changed ----------------------------|   资源在这一刻才真正替换

⚠️ 注意 2.31 Continue2.04 Changed 的区别 :服务器在中间块回 2.31 Continue 时表示"我攒着,等最后一块再一起处理"(原子性);如果它回的是 2.04 Changed,表示"我每收到一块就立即处理"(非原子)。非原子模式下中途断电,资源会停在"更新了一半"的状态------这就是坑表里第 2 条。

3.5 安全:DTLS 和三种模式

CoAP 的 URI 是 coap://(5683)和 coaps://(5684,走 DTLS)。RFC 7252 §9 规定了安全模式,实际工程里常用三种认证方式:

模式 特点 什么时候用
PreSharedKey (PSK) 双方预置同一个对称密钥。最省 RAM、握手最快 大批量同型号设备、能安全预置密钥的产线
RawPublicKey (RPK) 只放公钥,不用完整的 X.509 证书链 想避免证书链开销、又能接受非对称密钥
X.509 证书 完整的证书体系,支持吊销/轮换 需要证书生命周期管理、对接已有 PKI

IoT 场景有专门的 profile:RFC 7925 (TLS/DTLS Profiles for the IoT),里面强制要求实现 TLS_PSK_WITH_AES_128_CCM_8TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8 这类适合小设备的密码套件。

⚠️ DTLS 不是"免费的轻" 。它照样要握手、要占 RAM 做加密上下文。"CoAP 轻"成立的前提是你接受 PSK 或者干脆在内网/专网里跑无加密 。NB-IoT 走运营商公网还上 DTLS,那 TLS 的那点开销你一分也省不掉------能省的是会话复用(DTLS 支持用 session ID/resumption,别每次重连都全量握手)。


四、LwM2M:把"设备管理"也标准化了

4.1 LwM2M 和 CoAP 的关系

一句话:CoAP 是传输和交互的方式,LwM2M 是"管设备"的一套标准数据模型 + 流程。

打个比方:CoAP 规定了"我说 GET,你回 2.05"这套说话规矩;LwM2M 规定了"设备必须有一个"型号"字段,路径是 /3/0/1;服务器想看你的电量,就去 GET /3/0/9"。没有 LwM2M,各家的温度传感器路径五花八门;有了它,任何符合规范的服务器都能读懂任何符合规范的设备。

LwM2M 由 OMA SpecWorks 维护,版本号有 1.0.x / 1.1.x / 1.2.x。选版本的时候注意生态支持度------比如 EMQX 的 LwM2M 网关官方文档就明确写了仅支持 v1.0.2,暂不支持 1.1.x/1.2.x ,也没有 Bootstrap 服务。这种"平台支持到哪一版"必须先看清楚。我手上没有实际接入经验,你选版本前一定先查目标平台的文档。

4.2 对象 / 实例 / 资源模型

这是 LwM2M 的灵魂,三层结构:

text 复制代码
[图 5] LwM2M 的三层模型

  Object(对象,一个功能类别,有标准 ID)
    └── Object Instance(实例,同一类别可以有多份,通常编号从 0 开始)
          └── Resource(资源,最小的可读/可写/可执行的数据项)

  路径写法:/{ObjectID}/{ObjectInstanceID}/{ResourceID}
            └──┬───┘ └────────┬───────┘ └────┬───┘
              3              0              1

  例:/3/0/1  =  Device 对象(3) → 第 0 个实例 → 第 1 号资源 = 型号(Model)
      /3/0/9  =  Device 对象(3) → 第 0 个实例 → 第 9 号资源 = 电量百分比
      /5/0/3  =  固件升级对象(5) → 第 0 个实例 → 第 3 号资源 = 升级状态(State)

常用的标准对象(对象 ID 是 OMA 统一分配的):

Object ID 名字 干什么用
0 LwM2M Security 存放服务器地址、安全模式、PSK 密钥等(最敏感的一个
1 LwM2M Server 服务器参数:生命周期、绑定模式、注册更新触发条件
2 Access Control 谁(哪个服务器)能访问哪个对象实例
3 Device 设备的"身份证 + 体检表":厂商、型号、序列号、固件版本、电量、内存
4 Connectivity Monitoring 网络质量:信号强度、小区 ID、IP 地址
5 Firmware Update 固件升级,和我们的 OTA 主题直接对应
6 Location 经纬度、海拔、半径
10241+ / 32769+ 厂商自定义 标准没覆盖的,厂商自己扩展(不同区间含义不同,查规范)

Device 对象(ID 3)的资源(我核对过 OMA 的对象注册表,Zephyr 文档里也有一份完整表格):

资源 ID 名字 操作
0 Manufacturer(厂商) R
1 Model(型号) R
2 Serial number(序列号) R
3 Firmware version(固件版本) R
4 Reboot(重启) E(可执行)
5 Factory Reset(恢复出厂) E
9 Battery Level(电量 %) R
13 Current Time(当前时间) R/W
16 Supported Binding(支持的绑定模式) R

Firmware Update 对象(ID 5)的关键资源:

资源 ID 名字 操作 说明
0 Package W 直接把固件包写进来(配合 Block1 块传输)
1 Package URI W 给一个下载地址,让设备自己去拉(常用,省得走 CoAP 慢慢传)
2 Update E 执行这个资源 = 触发"现在开始升级"
3 State R 0=空闲 1=下载中 2=已下载 3=升级中
4 Update Result R 0=初始值 1=成功 2=存储不足 3=下载时内存不足 4=下载中断连 5=CRC 校验失败 6=包类型不支持 7=URI 非法 8=升级失败 9=协议不支持

看到 /5/0/x 你应该立刻意识到:LwM2M 把 OTA 的"下载---校验---执行---上报结果"全流程都定义好了 。它和我在《07_固件升级与OTA》里讲的 A/B 分区、签名校验、差分是互补的------LwM2M 负责"怎么把这件事从服务器传到设备",A/B 和签名负责"设备端怎么安全地落地"。

4.3 四个接口:注册、上报、管理、引导

LwM2M 定义了 4 个逻辑接口,你要记住的是前 3 个:

text 复制代码
[图 6] LwM2M 的三个常用接口

  ┌──────────────┐                          ┌──────────────┐
  │ LwM2M Client │                          │ LwM2M Server │
  │  (设备端)     │                          │  (平台端)     │
  └──────┬───────┘                          └──────┬───────┘
         │                                          │
  ① 注册接口                                        │
         │--- POST /rd?ep=<名字>&lt=<生命周期> ----->│   "我叫 dev001,
         │    &lwm2m=1.0&b=U                        │    我能活 300 s,
         │    payload: </1/0>,</3/0>,</5/0>         │    我支持这些对象"
         │<-- 2.01 Created, Location-Path: /rd/xxx -│   "记住了,你的 ID 是 xxx"
         │                                          │
         │--- POST /rd/xxx (Update) --------------->│   定期续命 + 上报变化
         │<-- 2.04 Changed -------------------------│
         │                                          │
  ② 上报接口(Observe / Notify)                     │
         │<-- GET /3/0/9, Observe=0 ----------------│   "你电量变了告诉我"
         │--- 2.05 Content, Observe=1 ------------->│
         │--- 2.05 Content 85, Observe=2 ---------->│   电量变了,主动推
         │                                          │
  ③ 设备管理接口(Read / Write / Execute / Create)   │
         │<-- GET /3/0/1 ---------------------------│   读型号
         │--- 2.05 Content "TEMP-100" ------------->│
         │<-- POST /3/0/4 --------------------------│   执行 Reboot
         │--- 2.04 Changed ------------------------>│
         │<-- PUT /5/0/1 "http://.../fw.bin" ------>│   写下载地址
         │<-- POST /5/0/2 ------------------------->│   执行 Update
         │                                          │
  ④ 引导接口(Bootstrap):出厂后由引导服务器写入最初的服务器地址和安全密钥

几个关键参数(以 EMQX 官方文档里给的注册消息为例):

参数 含义 例子
ep Endpoint Name ,设备名。必须全局唯一,重名会让服务器把两台设备当一台 testlwm2mclient
lt Lifetime,注册有效期(秒)。到期不 Update,服务器就认为你下线了 300
lwm2m 协议版本 1.0
b Binding 绑定模式U=UDP,UQ=带队列模式的 UDP U / UQ
objectList 设备支持哪些对象实例,用 link-format 表示 ["/1/0","/3/0","/5/0"]

💡 UQ(Queue mode)是给 NB-IoT + PSM 量身定做的 。设备在 PSM 里睡着的时候,服务器根本叫不醒它。开了队列模式,服务器会把指令排队 ,等设备下一次主动联系(Update 或上报数据)时再顺带发下去。代价是下行时延不可控------可能要等一个完整的上报周期。这是 LPWAN 上唯一的现实选择。

4.4 客户端/服务端实现有哪些

类型 项目 说明
C 客户端(设备端) Wakaama(Eclipse 基金会) 我 EMQX 文档的"客户端库"一节里看到它就被列为参考实现
C 客户端(设备端) Anjay(AVSystem) 另一个成熟的 C 实现
Java 服务端 Leshan(Eclipse) 学习和做实验最方便,有现成的 Web UI
Java CoAP 库 Californium(Eclipse) 底下的 CoAP 引擎
Python aiocoap 写 PC 端测试客户端最省事,下面实战就用它
RTOS 集成 Zephyr 内置 LwM2M 库 默认实现 1.0.2 ,可通过 Kconfig 配成 1.1.1

五、什么时候该选 CoAP / LwM2M

照着下面这张决策表问自己:

你的情况 建议
有市电 / 有 Wi-Fi / 数据量较大 MQTT,别折腾
需要服务器随时主动下发指令、秒级响应 MQTT(CoAP 的 Observe 撑不住这种实时性)
电池供电 + NB-IoT/LoRa + 一天上报几次 CoAP(或干脆裸 UDP + 自定义协议)
上面那条,还要做远程升级/改参数/看状态 CoAP + LwM2M(别自己发明一套设备管理协议)
上面那条,还要对接第三方平台(运营商 AEP、OneNET、华为云) LwM2M(很多平台直接支持 LwM2M 接入,自定义协议要自己写解析插件)
设备会进 PSM 深睡 CoAP + Queue mode (UQ),接受下行时延
text 复制代码
[图 7] 一句话决策树

   设备是不是"穷"的?(电池 / RAM < 32KB / 按流量计费)
        │
        ├── 否 ──────────────────────────> MQTT(生态最好,别造轮子)
        │
        └── 是 ──> 需要标准化设备管理吗?
                       │
                       ├── 是(要 OTA / 改参数 / 对接平台)
                       │        └──> CoAP + LwM2M
                       │
                       └── 否(就上报一个温湿度,别的都不管)
                                └──> 裸 UDP / CoAP,自定义 20 字节的二进制包
                                     (比 JSON 再省一半流量)

⚠️ 最后补一条现实:很多时候你其实没有选择权 。设备用的 NB-IoT 模组固件里如果已经内置了 LwM2M 客户端(很多 NB 模组都有),那你用 AT 指令调它比自己移植 Wakaama 省事十倍。反过来,如果你的目标平台只支持 MQTT,别为了"技术先进"硬上 CoAP------网关转发(LwM2M/CoAP → MQTT)是更常见的工程做法(EMQX 的 LwM2M 网关干的就是这个)。


六、实战:跑通一次注册

分两条路:PC 上用 Python 跑通 CoAP (人人都能做,强烈建议先做这个),和NB 模组用 AT 指令跑 LwM2M(有硬件再做)。

6.1 路线 A:Python + aiocoap,10 分钟看到 CoAP 报文

安装:pip install aiocoap(Python 3.8+)。

服务端(模拟一台设备,暴露几个资源)

python 复制代码
# ============ coap_device.py:用 Python 模拟一台 CoAP 设备 ============
# 平台:PC(Linux/macOS/Windows 都行),Python 3.8+,aiocoap
# 参考 aiocoap 官方 examples(server.py)
import asyncio
import datetime

import aiocoap
import aiocoap.resource as resource
from aiocoap.numbers.contentformat import ContentFormat


class Temperature(resource.ObservableResource):
    """可观察的资源:温度。模拟《09_无线_03》里那个田间节点的 /3303/0/5700"""

    def __init__(self):
        super().__init__()
        self.temp = 26.5

    def tick(self):
        self.temp = round(self.temp + 0.3, 1)      # 每次调用"温度变了"
        self.updated_state()                        # 通知所有观察者
        asyncio.get_running_loop().call_later(5, self.tick)

    def update_observation_count(self, count):
        # count > 0:有人订阅了,开始产生数据;count == 0:没人看了,省点电
        if count > 0:
            print("[设备] 有人订阅了,开始上报")
            self.handle = asyncio.get_running_loop().call_later(5, self.tick)
        else:
            print("[设备] 没人订阅了,停")
            if getattr(self, "handle", None):
                self.handle.cancel()

    async def render_get(self, request):
        return aiocoap.Message(payload=f"{self.temp}".encode(),
                               content_format=ContentFormat.TEXT)


class Led(resource.Resource):
    """可写资源:模拟 POST 执行一个动作(相当于 LwM2M 的 Execute)"""

    def __init__(self):
        super().__init__()
        self.state = b"off"

    async def render_get(self, request):
        return aiocoap.Message(payload=self.state, content_format=ContentFormat.TEXT)

    async def render_post(self, request):
        if request.payload.strip() not in (b"on", b"off"):
            # CoAP 的 4.00 Bad Request
            return aiocoap.Message(code=aiocoap.BAD_REQUEST, payload=b"only on/off")
        self.state = request.payload.strip()
        print(f"[设备] LED -> {self.state}")
        return aiocoap.Message(code=aiocoap.CHANGED, payload=self.state)


async def main():
    root = resource.Site()
    root.add_resource([".well-known", "core"],
                      resource.WKCResource(root.get_resources_as_linkheader))
    root.add_resource(["3303", "0", "5700"], Temperature())   # 温度(可观察)
    root.add_resource(["led"], Led())                          # LED(可写)
    await aiocoap.Context.create_server_context(root)
    print("[设备] CoAP 服务已起,端口 5683")
    await asyncio.get_running_loop().create_future()


if __name__ == "__main__":
    asyncio.run(main())

客户端(模拟服务器,读温度 / 订阅温度 / 下发指令)

python 复制代码
# ============ coap_server.py:模拟平台侧 ============
import asyncio
import aiocoap


async def main():
    ctx = await aiocoap.Context.create_client_context()

    # 1) 发现设备有哪些资源(CoAP 的 .well-known/core,相当于能力自描述)
    req = aiocoap.Message(code=aiocoap.GET, uri="coap://127.0.0.1/.well-known/core")
    resp = await ctx.request(req).response
    print(f"[发现] {resp.code}\n{resp.payload.decode()}")

    # 2) 读一次温度
    req = aiocoap.Message(code=aiocoap.GET, uri="coap://127.0.0.1/3303/0/5700")
    resp = await ctx.request(req).response
    print(f"[读温度] {resp.code} -> {resp.payload.decode()}")

    # 3) 订阅温度(Observe=0),收 3 条通知后退出
    req = aiocoap.Message(code=aiocoap.GET,
                          uri="coap://127.0.0.1/3303/0/5700",
                          observe=0)
    req.remote = aiocoap.Message.remote_property  # 占位,实际由 uri 决定
    resp = await ctx.request(req).response
    print(f"[订阅] {resp.code} -> {resp.payload.decode()}")
    async for n in ctx.request(req).observation:
        print(f"[通知] observe={n.opt.observe} -> {n.payload.decode()}")

    # 4) 下发指令
    req = aiocoap.Message(code=aiocoap.POST, payload=b"on",
                          uri="coap://127.0.0.1/led")
    resp = await ctx.request(req).response
    print(f"[写LED] {resp.code} -> {resp.payload.decode()}")


if __name__ == "__main__":
    asyncio.run(main())

⚠️ 上面第 3 步那段订阅写法是我为了让你看清"Observe 是一个持续的异步流"而写的示意,第 4 步在真实代码里永远执行不到async for 不会结束)。跑的时候把第 3 步和第 4 步分开跑,或者给观察加个计数退出条件。aiocoap 的 Observe API 细节请以你装的那个版本的文档为准,我这是按官方 examples 改的。

6.2 路线 B:NB-IoT 模组用 AT 指令跑 LwM2M 注册

🔴 这一节是"功能流程示意",不是可以直接抄的指令表。 各家模组的 LwM2M AT 指令完全不通用 (Quectel BC26 用 AT+QLW* 系列,中移 M5310/OneNET 生态用 AT+MIPL* 系列),参数格式也各不相同。我手上没有这些模组、没有实测过,你拿到模组第一件事是去找它的《LwM2M AT Commands Manual》或《LwM2M 应用指导》,对照着改。

流程上,所有模组都绕不开这 6 步:

text 复制代码
[AT 序列] LwM2M 注册流程(示意,指令名以你模组的手册为准)

# ① 先保证入网(见《09_无线_03》的 AT 序列 1)
→ AT+CEREG?                      ← +CEREG: 0,1        # 1=已注册

# ② 配置 LwM2M 服务器地址(coap://ip:port 或 coaps://)
→ AT+<设置服务器地址>="coap://203.0.113.10",5683
                                 ← OK

# ③ 配置端点名(ep)------必须唯一!一般用 IMEI 或 IMSI 拼
→ AT+<配置端点名>="8664xxxxxxxxxxx"
                                 ← OK

# ④ "声明"我要上报哪些对象(对应 objectList)
→ AT+<添加对象>=3303,0            ← OK        # 温度对象实例 0
→ AT+<添加对象>=3,0               ← OK        # Device 对象

# ⑤ 发起注册(这一步模组会自己发 CON POST /rd?ep=...&lt=...&lwm2m=1.0&b=U)
→ AT+<发起注册>                   ← OK
                                 ← +<注册结果 URC>: 0   # 0=成功,非 0 查错误码表

# ⑥ 上报一个资源的值(模组内部会发 POST 到对应路径)
→ AT+<上报数据>=3303,0,5700,"26.5"
                                 ← OK

# ⑦ 生命周期内要 Update 续命;下线要注销
→ AT+<更新注册>                   ← OK
→ AT+<注销>                       ← OK

第 ⑤ 步模组实际发出去的 CoAP 报文,按 RFC 7252 拆开就是这样的(我手工算的,你可以抓包核对):

text 复制代码
[图 8] 一包 LwM2M 注册请求逐字节拆解

 42 02 12 34 AB CD B2 72 64 49 65 70 3D 64 65 76 30 30 31
 FF 3C 2F 33 2F 30 3E 2C 3C 2F 35 2F 30 3E

 ── 固定头(4 字节)──
 42    = 0100 0010b  → Ver=1, T=0(CON), TKL=2
 02    = Code 0.02   → POST
 12 34 = Message ID = 0x1234

 ── Token(2 字节,TKL=2)──
 AB CD

 ── Options ──
 B2 72 64
   B2 = 1011 0010b → Option Delta=11, Length=2  → 11 号选项 = Uri-Path
   72 64 = "rd"                                  (LwM2M 的注册路径就是 /rd)

 49 65 70 3D 64 65 76 30 30 31
   49 = 0100 1001b → Delta=4(11+4=15), Length=9  → 15 号选项 = Uri-Query
   65 70 3D 64 65 76 30 30 31 = "ep=dev001"

 ── 载荷标记 ──
 FF

 ── 载荷(link-format 的对象列表)──
 3C 2F 33 2F 30 3E 2C 3C 2F 35 2F 30 3E  =  "</3/0>,</5/0>"

 总计:19 + 1 + 11 = 31 字节(不含 IP/UDP 头)

服务器回 2.01 Created,并在 Location-Path 选项里给出 /rd/<注册ID>。之后所有的 Update / 注销都针对这个路径。

⚠️ 现实中的 Token 和 Message ID 都是随机/递增生成的,不会是我写的 ABCD / 0x1234。我固定写死是为了让你能对照着数。

6.3 设备端 C 代码长什么样(伪代码)

如果你不用模组内置的 LwM2M,而是自己移植 Wakaama/Anjay,骨架大概是这样(明确标注:伪代码,函数名是我按概念写的,真实 API 查库的文档):

c 复制代码
/* ================= lwm2m_app.c(伪代码,非可直接编译) =================
 * 平台:RAM ≥ 32 KB 的 MCU(如 STM32F4/F7/L4+、ESP32),跑 Wakaama/Anjay
 * 目的:展示"定义对象 → 注册 → 被读取 → 上报"这个骨架在想什么
 */

typedef enum {
    FW_STATE_IDLE = 0,
    FW_STATE_DOWNLOADING = 1,
    FW_STATE_DOWNLOADED  = 2,
    FW_STATE_UPDATING    = 3
} fw_state_t;

static fw_state_t g_fw_state = FW_STATE_IDLE;
static int        g_battery  = 100;

/* ① 资源的读回调:服务器 GET /3/0/9(电量)时被调用 */
static int read_battery(uint8_t *out, int *out_len)
{
    g_battery = read_battery_percent();     /* 需自行实现 */
    out[0] = (uint8_t)g_battery;
    *out_len = 1;
    return LWM2M_OK;                        /* 失败要返回对应错误码,别硬返回 OK */
}

/* ② 资源执行回调:服务器 POST /5/0/2(触发升级)时被调用 */
static int exec_update(void)
{
    /* 这里绝对不能直接开始刷 Flash!
       正确顺序:校验 State==DOWNLOADED → 验签(OTA_04) → 写非活跃分区 → 置 boot 标志 → 重启
       见《07_固件升级与OTA》 */
    if (g_fw_state != FW_STATE_DOWNLOADED) {
        return LWM2M_ERR_BAD_REQUEST;       /* 4.00 */
    }
    if (fw_verify_signature() != 0) {       /* 需自行实现 */
        return LWM2M_ERR_FORBIDDEN;
    }
    fw_state_set(FW_STATE_UPDATING);
    request_reboot_from_bootloader();
    return LWM2M_OK;
}

/* ③ 资源写回调:服务器 PUT /5/0/1(下载地址)时被调用 */
static int write_package_uri(const char *uri, int len)
{
    /* 关键:URI 只是"一张纸条",真正的下载要在这里启动一个独立任务,
       不能在这个回调里阻塞下载几 MB ------ CoAP 栈会超时。*/
    if (len <= 0 || len > MAX_URI_LEN) {
        return LWM2M_ERR_BAD_REQUEST;       /* 历史上真有 CVE 是 URI 缓冲区溢出,别学 */
    }
    start_download_task(uri, len);          /* 需自行实现:HTTP(S) Range 断点续传下载 */
    fw_state_set(FW_STATE_DOWNLOADING);
    return LWM2M_OK;
}

/* ④ 主循环:注册 + 定期 Update */
void lwm2m_task(void *arg)
{
    lwm2m_init();                                   /* 建对象树 /3/0、/5/0 */
    lwm2m_register("ep=<IMEI>", LIFETIME_SEC);      /* ① 注册,拿回 location */

    for (;;) {
        lwm2m_step(lwm2m_poll_timeout_ms());        /* 驱动协议栈(处理重传、Observe) */

        if (time_to_report()) {
            lwm2m_notify_changed("/3303/0/5700");   /* 值变了 → 通知观察者 */
        }
        if (elapsed_since_last_update() > LIFETIME_SEC * 0.8f) {
            lwm2m_update();                         /* ② 生命周期 80% 时续命 */
        }
        sleep_or_deepsleep(REPORT_INTERVAL_MS);     /* 其余时间睡觉 */
    }
}

三个关键点:

  1. lwm2m_step() 必须被周期性调用 。CoAP 的重传定时器、Observe 的序号维护全靠它。放在一个只会 sleep(1000) 的循环里,你的 CON 永远等不到超时重传。
  2. Update 的时机要留余量 。生命周期 lt=300 秒,别卡着 300 秒才发 Update------网络抖动一次你就"被下线"了。我上面写的是 0.8 × lt
  3. 执行回调里不能干重活。刷 Flash、下载固件都是秒级甚至分钟级的操作,回调必须立刻返回,把活丢给另一个任务,用 State/UpdateResult 资源汇报进度。

七、新手必踩的 8 个坑

# 后果 正确做法
1 把 CoAP 的"确认"当成 TCP 的"可靠" 数据丢了还以为发出去了 CON 只是"尽力重传 4 次",最坏 93 秒 后彻底放弃。应用层要自己判业务超时、自己补发;服务器靠 Message ID 去重
2 块传输以为有原子性 固件传一半断电,资源停在"半新半旧" 确认服务器用 2.31 Continue 攒块(原子的);否则设备端必须有"下载完整包 + 校验通过才落盘"的机制(呼应《存储_05》掉电保护)
3 忘了 NAT 超时 设备一直在线,服务器就是推不下来 UDP 没有连接,NAT 上的映射会老化。lt(生命周期)必须小于 NAT 老化时间,靠 Update 定期刷新;有条件的让设备主动上报而不是等服务器推
4 以为运营商不管 UDP 某些网络丢包率高、甚至限制/封禁 UDP 端口 换卡/换运营商前先用一个最小 UDP 程序实测:连续发 100 包统计丢包率、测非 5683 端口通不通
5 Token 和 Message ID 混为一谈 并发请求时响应对不上号,张冠李戴 Token 匹配请求↔响应,Message ID 匹配 CON↔ACK。重传时 Message ID 不变,Token 也不变
6 NSTART=1 没考虑 同时发多个 CON,只能排队,吞吐被卡死 同一对端同时只能有 1 个未确认 CON。要么串行发,要么用 NON,要么调大 NSTART(但要确认对端也支持)
7 Observe 当成永久订阅 服务器重启后静悄悄不再有通知,设备端毫无察觉 客户端收到"不属于当前观察关系"的通知会回 RST。业务层要有"N 分钟没收到通知就重新订阅"的兜底
8 端点名 ep 撞车 两台设备被服务器当成一台,数据串了 IMEI/IMSI/芯片 UID 生成,并在产线烧录时保证唯一。别用 "device001" 这种手写名字

⚠️ 额外提醒一条生态上的坑:LwM2M 版本支持度参差不齐 。前面说过 EMQX 的 LwM2M 网关只支持 v1.0.2 且没有 Bootstrap 服务;Zephyr 默认也是 1.0.2。你照着 1.1/1.2 的规范做出来的设备,很可能接不上平台------动手之前先确认两端版本。


八、动手练一练

练习 1:把本地 CoAP 跑起来(30 分钟)

pip install aiocoap,起第六节那个 coap_device.py,用 aiocoap-client(装完 aiocoap 就有这个命令行工具)打几下:

bash 复制代码
aiocoap-client -m get  coap://127.0.0.1/.well-known/core
aiocoap-client -m get  coap://127.0.0.1/3303/0/5700
aiocoap-client -m post -p "on" coap://127.0.0.1/led

验收标准 :你能说出每个响应码(2.05 / 2.04 / 4.00)分别对应哪个操作。

练习 2:Wireshark 逐字节核对报文

打开 Wireshark,过滤器填 coap,重做练习 1。找一个 CON 包,把 Ver / T / TKL / Code / Message ID / Token / Uri-Path 逐字段点开,和图 8 的手算结果对照

这一步看着笨,但它是"我看过 CoAP 报文"和"我听说过 CoAP"的分界线。面试时你能说"我按字节拆过 CoAP 头",比背十个概念管用。

练习 3(核心):对比 MQTT 与 CoAP 的报文大小

装一个本地 MQTT Broker(Mosquitto 或 EMQX 都行),用 paho-mqttaiocoap 分别上报同一条数据 {"t":26.5},用 Wireshark 各抓一次完整流程:

你要数的东西 MQTT CoAP
建连接阶段的包数 / 总字节 0
应用层握手字节数(CONNECT+CONNACK) 0
业务数据这一包的字节数
拆连接的包数 / 字节 0

把表填完,你就能用自己的数据回答"CoAP 到底省在哪"。注意别只数业务包------省下的大头在握手和挥手上。

练习 4(故意搞破坏):让 Observe 悄悄失效

订阅温度资源,收到几条通知后,直接 Ctrl+C 杀掉服务端再重启。观察客户端:

  • 现象:要么永远卡住再也收不到通知,要么收到一条然后报 observe 相关的错误(取决于实现和时序)。
  • 你要补的东西:一个 last_notify_time 时间戳,超过 3 × 上报周期 没收到通知就重新发一次 Observe

练习 5(有硬件再做):NB 模组注册 + 拔天线

按你模组手册的 AT 序列注册到 Leshan 的公开演示服务器(或一个本地起的 Leshan),确认 +<注册结果>: 0。然后:

  • 故意试错 1 :注册成功后拔掉天线 ,等到 lt 超时,看平台什么时候把设备标成离线;
  • 故意试错 2 :把 ep 改成和另一台设备一样,看平台怎么处理(多半是后者顶掉前者);
  • 故意试错 3 :把 lt 设成 86400(一天),第二天再看------大概率设备早就被判定离线了,因为 NAT 映射早老化了。这就是坑表第 3 条的现场版。

小结

  • CoAP 不是"更好的 MQTT",是"给穷设备的 MQTT 替代品"。它的省,省在"无连接 + 4 字节头 + 没有心跳",全部服务于"电池供电、小包、低频"这个特定模式。
  • 4 字节报文头 + Token/Message ID 双 ID + CON/NON 两档可靠性 ,是 CoAP 的三个核心设计。记住 ACK_TIMEOUT=2 sMAX_RETRANSMIT=4MAX_TRANSMIT_WAIT=93 s 这三个数,你就知道应用层超时该怎么设。
  • Observe(RFC 7641)和块传输(RFC 7959)是两个必知扩展 :前者省掉轮询,后者让"固件下发"有了标准化的路子(注意单次块上限 1024 字节 ,且要盯住 2.31 Continue 的原子性语义)。
  • LwM2M = CoAP + 一套标准对象模型 。路径 /{ObjectID}/{InstanceID}/{ResourceID}/3/0/1 是型号、/3/0/9 是电量、/5/0/2 是"执行升级"、/5/0/3 是升级状态。它和 A/B 分区、签名校验是互补关系,不是替代关系
  • LPWAN 上一定要开 Queue mode(绑定 UQ,并且接受"下行时延不可控"这个代价------设备在 PSM 里睡着的时候,谁也叫不醒它。
  • UDP 的三个天敌:丢包、NAT 老化、运营商策略。 CoAP 帮你兜住了第一个的一半,后两个完全要靠你的 lt 配置和实测。

参考:CoAP 核心规范 RFC 7252(含 §4.8 传输参数、§9 DTLS 安全);块传输 RFC 7959(SZX 与 M 位语义);Observe RFC 7641;IoT 的 TLS/DTLS 规范 RFC 7925;OMA SpecWorks 的 LwM2M 对象与资源注册表(对象 ID 3/5 的资源定义);Zephyr 与 EMQX 官方文档(版本支持度)。文中 aiocoap 代码改自其官方 examples。

下一篇:物联网设备管理实战 ------ 批量 OTA、远程配置与设备生命周期

相关推荐
jonyleek3 小时前
工业物联网边缘计算架构设计:从数据采集到本地决策
人工智能·物联网·边缘计算·工业物联网·边缘网关·jvs物联网平台·iot架构
wuyk5553 小时前
107.FreeRTOS 链表深度解析:从原理到面试满分答案
c语言·开发语言·数据结构·stm32·单片机·链表·面试
物联网IoT小易3 小时前
MQTT消息为什么会重复、丢失或延迟?QoS、离线消息与重连机制详解
物联网·mqtt·消息队列·边缘计算·mqtt协议·物联网平台
hongmai6668883 小时前
ESP32-WROVER-IE-N4R8:外接天线+8MB内存,专治信号焦虑
笔记·单片机·嵌入式硬件·microsoft·risc-v
雾削木3 小时前
GPIO基本概念
单片机·嵌入式硬件
物联通信量讯说4 小时前
5G会取代4G吗?物联网设备出海正在走向多网络融合
网络·物联网·5g
LCG元4 小时前
STM32 USB CDC 虚拟串口开发实战:从 CubeMX 配置到 printf 重定向与稳定收发
stm32·单片机·嵌入式硬件
wtblszn5 小时前
智慧水利:排水泵站物联网解决方案
物联网
zcmodeltech5 小时前
源网荷储一体化沙盘模型多场景控制系统设计——基于STM32与Modbus RTU的源网荷储、多能互补、冷热电三联供全场景联动方案,服务范围覆盖全国
数据库·人工智能·stm32·单片机·嵌入式硬件