目录
- AWS IoT Jobs OTA 联调实录:从“设备完全没反应”到端到端闭环
- 一、结论先行
- 二、先理解 Jobs:它不是“一条消息”
- 三、阶段一:功能写完,但从未真正联调
- 四、阶段二:第一次上线,订阅就被断 TCP
- 五、阶段三:`InvalidTopic`——主题多写了一段
- 六、阶段四:黑障——订阅成功、云端已发、设备零日志
- 七、阶段五:读协议栈源码,找到静默丢弃点
- 补记(2026-10-23):复现静默丢弃,并把最后一块日志补上
- 八、阶段六:修复与内存账本
- 九、阶段七:真实 bin 下载,又撞上 HTTP 512
- 十、阶段八:端到端成功
- 十一、同期的另一个坑:无声重启循环
- 十二、方法论:设备“完全没反应”时怎么查
- 十三、附:几个容易记错的结论
AWS IoT Jobs OTA 联调实录:从“设备完全没反应”到端到端闭环
平台:ESP32-C61(ESP-IDF 5.5)+ AWS IoT Core Jobs + 自有 OTA 管理平台
结果:1.0.1 → 1.0.2完整升级链路跑通,云端作业状态成功
一、结论先行
这次联调真正卡住人的不是 OTA 逻辑,而是一个完全静默的失败:
设备订阅成功、
start-next收到 PUBACK、云端作业状态已经在推进、第三方 MQTT 客户端也能收到云端推送——唯独设备日志一行不打,仿佛消息从未存在。
最终根因:coreMQTT 对超过 MQTT 网络缓冲区大小的入站报文执行discardStoredPacket,排空整包后返回MQTTNoDataAvailable,MQTT_ProcessLoop再把它转成MQTTSuccess——应用层回调根本不会被调用。而项目里的网络缓冲区只有 1024 字节,AWS Jobs 的accepted/notify-next报文因携带含 STS 临时凭证的预签名 URL,实际大小约 1.7 KB。
修复后同一条日志变成:
I (8416) AWS_DEAL: Incoming PUBLISH: topic=$aws/things/<thing>/jobs/start-next/accepted len=1701 qos=0. I (8416) AWS_DEAL: Jobs: start-next/accepted received (1701 bytes). I (8416) jobs_ota: Execution: job=ota-... ver=1.0.2 (running=1.0.1)根因总表
| # | 现象 | 真实原因 | 修复 |
|---|---|---|---|
| 1 | 订阅$aws/.../jobs/*后连接被断 | 证书 Policy 未放行 Jobs 保留主题 | 补iot:Subscribe/Publish/Receive |
| 2 | start-next被拒:InvalidTopic | 主题多写了$next段 | 改为jobs/start-next |
| 3 | 订阅成功、云端已发、设备零日志 | coreMQTT 静默丢弃超过 1024B 的入站包 | 网络缓冲 1024 → 4096;打开 coreMQTT error 日志 |
| 4 | 报文收到了但解析成 malformed | URL 缓冲仅 768B,装不下 1.3KB 预签名 URL | JOTA_URL_MAX→ 2048 |
| 5 | HTTP_CLIENT: Out of buffer | HTTP 请求行缓冲默认 512B | buffer_size_tx = 2048 |
| 6 | 埋下潜在崩溃 | AWS 任务栈在 PSRAM,却在该任务里写 NVS | NVS 操作移到内部栈一次性任务 |
| 7 | 上电无声重启循环 | menuconfig 在旧 IDF 环境下把控制台翻回 UART0,与 LoRa 抢串口 | 固定 IDF 5.5 环境 + USB 控制台 |
| 8 | 内部 SRAM 最低仅剩 8 字节 | BLE 初始化与 WiFi 连接峰值并发叠加 | 错峰初始化 + 证书缓冲迁 PSRAM |
| 9 | 丢弃发生后设备仍然零报错 | CONFIG_CORE_MQTT_LOG_ERROR未定义,LogError被编译成空宏 | 组件 CMakeLists 加CONFIG_CORE_MQTT_LOG_ERROR=1 |
二、先理解 Jobs:它不是“一条消息”
排查前必须建立正确的模型,否则会一直找错方向:
- Job 是云端的一条持久化任务工单(
QUEUED → IN_PROGRESS → SUCCEEDED/FAILED),不是一次性消息; - 设备与工单的交互只有四个主题:
订阅 $aws/things/{thing}/jobs/notify-next 云端推送“任务有变化” 发布 $aws/things/{thing}/jobs/start-next 设备主动拉取 订阅 $aws/things/{thing}/jobs/start-next/accepted 拉取回包(带 jobDocument) 发布 $aws/things/{thing}/jobs/{jobId}/update 设备上报终态notify-next只在任务变化时推一次、断线重连不回放,所以设备每次连接都必须主动发一次start-next;accepted/rejected是定向发给发起请求的那条连接的,不经过 Broker 订阅匹配——用别的客户端订阅也看不到。这一点在后续排查中非常关键。
三、阶段一:功能写完,但从未真正联调
代码里早就实现了 Jobs OTA 状态机,但编译开关一直关着:
/* jobs_ota.h */#defineJOBS_OTA_ENABLE0原因是当时正在联调绑定链路,而证书 Policy 还没放行$aws保留主题。开关关着,所有 Jobs 代码都不编译,所以后面第一次打开时,所有问题一次性爆发。
四、阶段二:第一次上线,订阅就被断 TCP
打开开关后,业务主题订阅正常,紧接着连接被服务端断开:
I (7344) AWS_DEAL: Subscribed to the topic /ertk/<thing>/cmd. with maximum QoS 1. E (8294) network_transport: Connection closed E (8294) AWS_DEAL: Jobs: SUBACK wait failed/timeout. E (8294) AWS_DEAL: Jobs: subscribe failed, OTA via Jobs unavailable this session. E (11574) esp-tls-mbedtls: read error :-0x004C判读要点
普通主题缺权限,Broker 会返回SUBACK 0x80,连接保持;但$aws保留主题缺权限,服务端直接断 TCP。两者指纹完全不同,可以据此快速区分是“业务权限问题”还是“保留主题权限问题”。
补上 Policy 的三条语句后恢复:
{"Action":"iot:Subscribe","Resource":"arn:aws:iot:<region>:<account>:topicfilter/$aws/things/${iot:Connection.Thing.ThingName}/jobs/*"},{"Action":"iot:Receive","Resource":"arn:aws:iot:<region>:<account>:topic/$aws/things/${iot:Connection.Thing.ThingName}/jobs/*"},{"Action":"iot:Publish","Resource":"arn:aws:iot:<region>:<account>:topic/$aws/things/${iot:Connection.Thing.ThingName}/jobs/*"}注意资源前缀不能混用:Subscribe用topicfilter/,Publish和Receive都用topic/。
五、阶段三:InvalidTopic——主题多写了一段
Policy 修好后,订阅成功、start-next也拿到了 PUBACK,但回包是拒绝:
E (9394) AWS_DEAL: Jobs: start-next REJECTED: {"timestamp":1792658719,"code":"InvalidTopic", "message":"$aws/things/<thing>/jobs/$next/start-next is not a recognized job topic"}根因:把$next当成了路径段。官方StartNextPendingJobExecution的主题是:
$aws/things/{thing}/jobs/start-next$next只是DescribeJobExecution里jobId 的占位符(jobs/$next/get),不能搬到start-next上。
教训:这类“主题拼错”在 Publish 侧是静默的——MQTT 层照样回 PUBACK,只有 Jobs 服务会通过rejected告诉你。所以必须订阅并打印start-next/rejected的 payload,否则永远看不到拒绝原因。
六、阶段四:黑障——订阅成功、云端已发、设备零日志
这是本次联调耗时最长的阶段。
现象
I (7564) AWS_DEAL: Jobs: topics subscribed. I (8284) AWS_DEAL: PUBACK received for packet id 3. I (8284) AWS_DEAL: Jobs: start-next published, awaiting accepted reply. (此后 30 分钟,AWS_DEAL 没有任何输出)与此同时:
- 云端作业状态确实在推进(从
QUEUED变IN_PROGRESS); - 用 AWS 控制台的 MQTT 测试客户端订阅
notify-next,能正常收到任务推送。
当时已经排除的
| 怀疑点 | 验证方式 | 结论 |
|---|---|---|
| 设备没发出去 | PUBACK 收到、云端状态推进 | 排除 |
| 订阅没登记 | SUBACK 逐码扫描,任一失败会打印告警 | 排除 |
| 云端没发 | 测试客户端能收到 notify-next | 排除 |
| 设备收到但解析失败 | 解析失败路径有 ERROR 日志 | 排除(完全没有日志) |
“订阅没登记”能排除,是因为 SUBACK 的每个状态码都会被逐个检查,任一主题被拒都会打印主题序号和告警。
矛盾出现了:三方证据都指向“消息应该到”,但设备就是没有任何反应。
走过的两条弯路
弯路一:iot:Receive的资源前缀
一开始怀疑 Receive 写成了topicfilter/。但核对设备实际证书的策略后确认写的是topic/,且策略版本为“活跃”,证书也确实挂载了这份策略(用证书 SHA256 指纹与策略目标列表逐字比对过)。排除。
弯路二:默认版本 / 证书绑定
控制台存在“编辑保存但不设为默认版本”的陷阱,也确认过。最终确认活跃版本就是最新版本,证书指纹与固件内置证书完全一致。排除。
决定性的对照
把两件事放在一起看:
- 早些时候,
InvalidTopic的rejected报文(payload 约100 字节)能被设备收到; - 现在的
accepted报文(带完整 jobDocument,含预签名 URL)收不到。
差异不在权限(同一个主题前缀、同一份策略),而在报文大小。
补观测点:把“没收到”和“收到没解析”分开
在 MQTT 收到任何 PUBLISH 的最底层、所有主题匹配之前,加一行无条件日志:
ESP_LOGI(AWS_TAG,"Incoming PUBLISH: topic=%.*s len=%u qos=%d.",(int)pPublishInfo->topicNameLength,(constchar*)pPublishInfo->pTopicName,(unsigned)pPublishInfo->payloadLength,(int)pPublishInfo->qos);这行位于 MQTT 发布回调的最前部、所有主题匹配之前。它的意义:它不出现,就说明问题在协议栈以下,而不是业务代码把消息吞了。
消息若真的到达业务层,回调里还会紧接着打印Jobs: notify-next received (...)或Jobs: start-next/accepted received (...)。黑障现场这两行同样没有出现,说明报文连回调入口都没进。
七、阶段五:读协议栈源码,找到静默丢弃点
直接读 coreMQTT 源码,找到三段关键逻辑。PUBLISH 等常规入站包走的是receiveSingleIteration(),不是receivePacket()(后者只用于等待 CONNACK)。
证据一:主循环判定超长后转入丢弃
/* receiveSingleIteration():入站包主循环 *//* If the MQTT Packet size is bigger than the buffer itself. */elseif(totalMQTTPacketLength>pContext->networkBuffer.size){/* Discard the packet from the receive buffer and drain the pending * data from the socket buffer. */status=discardStoredPacket(pContext,&incomingPacket);}证据二:排空整包并返回MQTTNoDataAvailable
staticMQTTStatus_tdiscardStoredPacket(MQTTContext_t*pContext,constMQTTPacketInfo_t*pPacketInfo){...if(totalBytesReceived==remainingLength){LogError(("Dumped packet. DumpedBytes=%lu.",(unsignedlong)totalBytesReceived));/* Packet dumped, so no data is available. */status=MQTTNoDataAvailable;}...returnstatus;}证据三:状态被“洗白”成成功
/* MQTT_ProcessLoop() 末尾 */if(status==MQTTNoDataAvailable){/* No data available is not an error. Reset to MQTTSuccess so the * return code will indicate success. */status=MQTTSuccess;}关键点:
- 超过缓冲区的报文整包丢弃,
discardStoredPacket()按剩余长度精确排空,保持字节流同步; - 丢弃完成后返回
MQTTNoDataAvailable,MQTT_ProcessLoop()将其转成MQTTSuccess,应用层看不到任何错误; - 丢弃发生在反序列化之前,应用层回调、业务日志、我们刚加的无条件回执都不会被执行;
discardStoredPacket()内部有LogError("Dumped packet. DumpedBytes=%lu."),但当时这个宏被编译成了空;LogError用的Dumped packet是这条路径上唯一的痕迹;Incoming packet will be dumped: PacketSize=...位于receivePacket(),只用于 CONNACK。
而项目里的缓冲配置是:
/* 修改前 */#defineAWS_MQTT_NETWORK_BUFFER_SIZE1024// 减小网络缓冲区,节省内存AWS Jobs 的accepted报文里含预签名 URL,而该 URL 使用 STS 临时凭证,仅X-Amz-Security-Token就有 700~900 字符,整条 URL 约 1.3 KB,整个报文约 1.7 KB ——超过 1024,被静默丢弃。
为什么排查时容易被误导?
- 小报文(拒绝原因约 100B)能收到,看起来“收包链路是好的”;
- Publish 侧一切正常(小报文);
- 云端、测试客户端都正常;
- 设备侧零日志,连“收到但解析失败”都没有。
“完全没有日志”本身就是最强的线索:说明失败发生在日志代码之前。
补记(2026-10-23):复现静默丢弃,并把最后一块日志补上
第七节的结论来自源码推断。为了真正闭环,2026-10-23 做了两件事:先打开 coreMQTT 的 error 日志,再把缓冲区临时改回 1024,主动复现一次。
1. 为什么之前连库自己的日志都没有
coreMQTT 的日志宏是编译期开关:
#ifCONFIG_CORE_MQTT_LOG_ERROR#defineLogError(message,...)ESP_LOGE(LIBRARY_LOG_NAME,REMOVE_PARENS(message),##__VA_ARGS__)#else#defineLogError(message,...)#endif这个 coreMQTT 组件没有 Kconfig,sdkconfig里也没有CONFIG_CORE_MQTT_LOG_ERROR,所以LogError被展开为空——discardStoredPacket()里的Dumped packet从未进入固件,串口自然一行都没有。
修复方式是给 coreMQTT 组件加一个编译定义:
# 只开 error 级,正常收发路径不会刷屏 target_compile_definitions(${COMPONENT_LIB} PRIVATE CONFIG_CORE_MQTT_LOG_ERROR=1)2. 复现步骤
- 临时把
AWS_MQTT_NETWORK_BUFFER_SIZE从 4096 改回1024; - 编译烧录;
- 设备连上后,在云端新建一个 job,触发
notify-next推送。
3. 真实日志(已脱敏,时间戳保持原样)
I (8820) AWS_DEAL: Jobs: publishing start-next -> $aws/things/<thing>/jobs/start-next (payload={}) I (8820) AWS_DEAL: PUBACK received for packet id 3. I (8820) AWS_DEAL: Jobs: start-next published, awaiting accepted reply. I (8820) AWS_DEAL: Incoming PUBLISH: topic=$aws/things/<thing>/jobs/start-next/accepted len=24 qos=0. I (8820) AWS_DEAL: Jobs: start-next/accepted received (24 bytes). E (289360) coreMQTT: Dumped packet. DumpedBytes=700.判读:
- 连接时的
accepted只有 24 字节,是“当前没有待办 job”的空 execution,正常处理并忽略; - 约 289 秒后,云端新建 job 触发
notify-next,整包约1.7 KB(1024 字节缓冲已读满,另排空 700 字节),超过 1024 缓冲; - 只出现
Dumped packet,没有Jobs: notify-next received、没有JobsOta Execution:、没有断线——与第六节“黑障”现象完全一致; - 设备保持在线,但 job 永远不会被取走;重连后主动
start-next拿到的 accepted 同样超长,会再次被丢弃。
4. 两个容易误读的细节
- PUBLISH 超长时不会打印
Incoming packet will be dumped: PacketSize=... NetworkBufferSize=...。那条位于receivePacket(),只用于 CONNACK 路径;PUBLISH 走discardStoredPacket(),只有Dumped packet。 DumpedBytes是“已经读入缓冲的部分”之外、从 socket 排空的字节数,不是整包大小。本例整包约 1.7 KB(1024 + 700 的近似,前提是recv读满缓冲)。
5. 闭环结论
- 容量侧:
AWS_MQTT_NETWORK_BUFFER_SIZE恢复为4096,当前 1.7 KB 报文可正常接收; - 可观测性侧:保留
CONFIG_CORE_MQTT_LOG_ERROR=1。以后报文再超过缓冲,串口一定留下E (...) coreMQTT: Dumped packet...,不会再出现“设备毫无反应、日志一行没有”的黑障。
缓冲区加大只是提高上限;真正的防线是“丢弃必须可见”。
八、阶段六:修复与内存账本
直接把缓冲改到 4096 会多占 3 KB 内部 SRAM,而当时设备的内部 SRAM 最低水位只剩8 字节,不能硬加。最终做的是一组“置换”:
| 改动 | 内部 SRAM |
|---|---|
| MQTT 网络缓冲 1024 →4096 | +3 KB |
JOTA_URL_MAX768 →2048(缓冲在 PSRAM) | 0 |
s_ota_url768 →2048 | +1.3 KB |
三个 4 KB 证书缓冲迁 PSRAM(EXT_RAM_BSS_ATTR) | −12 KB |
| Jobs NVS 写操作移到内部栈一次性任务 | 仅任务存在时 ~4 KB |
净效果:内部 SRAM 反而多出约7.7 KB余量。
用objdump -t确认落位:
s_root_cert .ext_ram.bss 00001000 s_client_cert .ext_ram.bss 00001000 s_client_key .ext_ram.bss 00001000 s_ota_url .dram0.bss 00000800 s_tAwsDev .dram0.bss 000012e0顺带修掉的潜在崩溃
AWS 任务的栈是分配在 PSRAM 的(xTaskCreateWithCaps(..., MALLOC_CAP_SPIRAM))。而 Jobs 状态机原本会在该任务里直接执行nvs_set_str/nvs_commit——flash 写会短暂关闭 cache,此时访问 PSRAM 栈会崩溃。
改成:AWS 任务只做决策,把“落盘 + 触发下载”和“清除在途记录”交给一次性内部栈任务(xTaskCreate,4096 B),由它先写 NVS、再发起下载。这与工程里evt_log的做法一致:它的 NVS flush 也是内部栈任务。
修复后的内存数据
(修复前) internal heap [got_ip_before_aws]: free=24691, largest=24576, min_free_ever=8 (修复后) MEM_SNAP [MQTT_Subscribe_QoS1_ACK_Done] 内部SRAM: free=35231 | largest=31744 | min_free_ever=320min_free_ever从8 字节提升到320 字节,稳态最大连续块约 31 KB。
九、阶段七:真实 bin 下载,又撞上 HTTP 512
缓冲修好后,第一次真正下载固件:
I (83977) ota: OTA start, downloading: https://<s3-bucket>/upgrade_ertk_v1.0.9.bin?... W (83987) jobs_ota: Upgrade armed: job=ota-... target_ver=1.0.9 W (61456) esp-tls-mbedtls: skip_common_name=true: hostname matching and SNI disabled... I (62636) esp-x509-crt-bundle: Certificate validated E (63016) HTTP_CLIENT: Out of buffer E (63016) esp_https_ota: Failed to open HTTP connection: ESP_FAIL E (63026) ota: OTA begin failed: ESP_FAIL (-1) E (63026) jobs_ota: Upgrade download failed (job=...), report FAILED链路本身是对的:证书校验通过、失败也正确上报了download_failed。问题在esp_http_client:
buffer_size_tx默认512 字节;- 拼请求行
GET <完整 URL> HTTP/1.1时,URL 的 path+query 约 1.3 KB; - 超过缓冲 →
Out of buffer。
esp_http_client拼请求行时用snprintf写入buffer_size_tx大小的缓冲,返回值一旦不小于缓冲大小就打印Out of buffer并返回;拼接 query 时还会再检查一次。所以 URL 超过 512B 就会在这里失败。
修复:
esp_http_client_config_thttp_cfg={.url=s_ota_url,.timeout_ms=30000,.crt_bundle_attach=esp_crt_bundle_attach,.buffer_size_tx=2048,// 请求行缓冲.skip_cert_common_name_check=false,// 开启 SNI/CN 校验};同时把skip_cert_common_name_check从true改为false——原配置会让 IDF 打出:
skip_common_name=true: hostname matching and SNI disabled. This disables ALL server-name authentication (CN/SAN/SNI), not just CN.即“证书链校验了,但服务器身份没校验”,生产环境存在中间人推固件的风险。
另一个输入侧脏数据
OTA 平台生成的 jobDocument 里,args[0]的 URL 被反引号包裹:
"args":["`https://<s3-bucket>/upgrade_ertk_v1.0.9.bin?...`"]设备侧做了防御性裁剪(首尾反引号/空白),但云端模板也应修正——否则预签名校验会失败。
十、阶段八:端到端成功
第一次完整升级(1.0.6 → 1.0.9)
I (83947) jobs_ota: Execution: job=ota-... ver=1.0.9 (running=1.0.6) url=https://... W (83987) jobs_ota: Upgrade armed: job=ota-... target_ver=1.0.9 I (86257) esp_https_ota: Writing to <ota_1> partition at offset 0x410000 I (90177) ota: Downloading: 9% (152/1684 KB) ... I (130037) ota: Downloading: 100% (1684/1684 KB) I (130497) ota: OTA success in 46s, rebooting in 2s to switch partition... --- 重启后 --- I (997) ota: Boot partition: ota_1 | firmware version: 1.0.9最终验证(1.0.1 → 1.0.2,全部使用当前源码)
I (66356) jobs_ota: Execution: job=ota-... ver=1.0.2 (running=1.0.1) url=https://... I (66376) ota: OTA start, downloading: https://... I (69546) esp_https_ota: Writing to <ota_1> partition at offset 0x410000 I (118596) ota: Downloading: 100% (1684/1684 KB) I (119096) ota: OTA success in 52s, rebooting in 2s to switch partition... --- 重启后 --- I (998) ota: Boot partition: ota_1 | firmware version: 1.0.2 W (1088) jobs_ota: Pending OTA job from last boot: id=ota-... ver=1.0.2 I (8918) jobs_ota: Execution: job=ota-... ver=1.0.2 (running=1.0.2) W (8928) jobs_ota: Target version already running, report SUCCEEDED W (9638) AWS_DEAL: Jobs: report published -> .../update : {"status":"SUCCEEDED"}至此,创建作业 → 拉取 → 解析 → 下载 → 写备用分区 → 重启切槽 → 幂等校验 → 上报 SUCCEEDED 的完整生命周期闭环。
十一、同期的另一个坑:无声重启循环
OTA 联调期间还遇到一个更吓人的问题:设备上电后连续重启,屏幕上没有任何 panic、没有任何app_main日志。
现象
I (1096) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations ESP-ROM:esp32c61-eco3-20250228 rst:0xc (SW_CPU),boot:0x1c (SPI_FAST_FLASH_BOOT) Core0 Saved PC:0x40800390 --- 0x40800390: esp_restart_noos_inner at .../system_internal.c:116同样的段落连续出现12 次。
排查路径
| 步骤 | 动作 | 结果 |
|---|---|---|
| 1 | 确认 panic 配置 | CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT=y,panic 一定会打印 → 排除 panic |
| 2 | 找显式重启调用 | 全工程仅ota_update.c一处esp_restart(),但它在 OTA 任务里,需app_main运行后才可能执行 |
| 3 | 看日志断点 | 停在Reserving pool之后,且没有main_task: Started/Calling app_main |
| 4 | 查控制台配置 | 控制台被配置成 UART0;而 LoRa 模块的LORA_UART也是UART_NUM_0,引脚 GPIO10/11 |
| 5 | 查为什么配置被改 | 终端默认环境是 IDF 5.3,~/.profile固定 source 5.3;而 5.3 里 C61 的SOC_USB_SERIAL_JTAG_SUPPORTED是注释状态(TODO),menuconfig 保存时把“USB 控制台”这一非法选项静默回退成 UART0 |
| 6 | 结论 | 控制台 REPL 与 LoRa 驱动抢同一个串口 → 崩溃;而报错也写在这个出不去的通道上 → 表现为“无声重启” |
正确的组合是:panic 打印开启、控制台固定为 USB Serial/JTAG,并且每次编译前先source env55.sh切到 IDF 5.5。
教训
- 工程有
env55.sh专门切 IDF 5.5,编译前必须 source,否则 menuconfig 会在旧 Kconfig 下静默改写sdkconfig; sdkconfig.defaults只在sdkconfig新建时生效,无法纠正已存在文件;- 日志“完全看不到应用层输出”时,先怀疑日志通道本身,再怀疑业务代码。
十二、方法论:设备“完全没反应”时怎么查
这次最大的收获不是某个具体修复,而是一套可复用的排查顺序:
- 先证明发送侧成功:Publish 是否拿到 PUBACK?云端状态是否变化?
- 再证明订阅已登记:SUBACK 是否逐码成功?不要只看“SUBSCRIBE sent”。
- 引入第三方观察点:另开一个 MQTT 客户端订阅同一主题,确认云端是否真的在发。
- 在最底层加无条件回执:在协议栈回调入口打印 topic + len,把“没收到”与“收到但没解析”分开。
- 区分定向响应与广播响应:Jobs 的
accepted是定向的,第三方客户端看不到,不能拿它当“云端没发”的证据。 - 当应用层日志完全为零时,去读协议栈源码:查找静默丢弃、静默降级、被吞掉的错误码。
- 每个假设都要能被一个实验证伪:例如“权限问题”用错误报文大小差异来反证,“包太大”用一条 1701B 的接收日志来证实。
一句话总结:没有日志不等于没有问题,往往意味着问题在日志之下。
十三、附:几个容易记错的结论
- 普通主题缺
iot:Subscribe:回 SUBACK0x80,连接保持; $aws保留主题缺权限:直接断 TCP,特征为mbedtls -0x004C CONN_EOF;iot:Receive被拒:订阅成功、消息静默不投递,没有任何错误日志;- coreMQTT 超长入站包:
discardStoredPacket()排空整包 →MQTTNoDataAvailable→MQTT_ProcessLoop()转成MQTTSuccess,应用层完全无感;必须打开CONFIG_CORE_MQTT_LOG_ERROR才能看到Dumped packet; - Jobs 的
accepted/rejected:定向发送,不经过 Broker,其他客户端订阅不到; notify-next:只在任务变化时推一次,不回放,设备必须每次连接主动start-next。