ESP8266 AT 模式下的 +IPD 分包陷阱 —— 一次异地 OTA 失败排查记录
现象:同一版固件,实验室升级 100% 成功,现场设备 100% 失败。
排查结论:不是网络不通,是 TCP 分包导致模组的+IPD前缀被拼进了固件正文。
关键词:ESP8266 / AT 指令 /+IPD/ TCP 分段 / MSS / 路径 MTU / OTA
一、问题现象
设备是 STM32 + ESP8266(AT 固件,普通模式 AT+CIPMODE=0),通过 WiFi 连后台做 OTA 升级。
| 环境 | 结果 |
|---|---|
| 实验室(本地) | 完全正常,约 1 分钟升完 |
| 现场(异地) | 必然失败,十几台设备同时中招 |
失败时的串口日志:
OTA_STATUS_INIT
OTA_STATUS_PACK_GET 0/55
OTA_STATUS_PACK_RECE 0/55 ← 响应收到了
[getHttpResponse:181] ← 解析函数返回"成功"(打印为空)
OTA_STATUS_PACK_FAIL 1/55 fail , retry=1
... retry=2 3 4 5 全部同样失败
→ 放弃,升级失败
几个很误导人的特征:
- 只有固件包失败。心跳、拉配置、查询版本这些请求全部正常
- 失败不是偶发 —— 第 0 包连续 5 次全败,重试完全无效
- 日志显示
PACK_RECE打印了,说明响应确实到了,但后面内容校验不过 - 换 AP、换网络环境,现象一模一样
二、排查过程
2.1 先排除掉一批方向
排查中最容易走弯路的是"先入为主"。我们依次排除:
| 方向 | 怎么排除的 |
|---|---|
| 服务器返回了错误内容 | 抓包对比,响应头 Content-Type / Content-Length 完全正常 |
| 数据在传输中损坏 | 抓包提取正文与本地直连获取的逐字节比对,MD5 相同 |
| 设备连到了不同的服务器节点 | 抓包里解析到的 IP 与实验室完全一致 |
| 模组硬件损坏 | 现场十几台同时失败,且换过 AP 现象不变 |
| 请求内容有问题 | 抓包里请求体与实验室逐字节一致 |
| 转发/代理链路有问题 | 见 2.3 —— 这个方向试过但被误导了很久 |
2.2 关键转折:抓包
因为设备在异地、无法直接接串口,最初只能靠后台状态码猜。后来让现场同事
在设备所连的热点侧用 Wireshark 抓包,才拿到决定性证据。
抓包里最有价值的三组数据:
① 响应大小与分段
HTTP 响应头 880 字节
HTTP 正文 512 字节
─────────────────────────
合计 1392 字节
实际被拆成 两个 TCP 段:[1320, 72]
② MSS 协商值(对比两个环境)
| 方向 | 实验室 | 现场 |
|---|---|---|
| 设备 → 服务器 | MSS 1460 | 1460(相同) |
| 服务器 → 设备 | MSS 1460 | MSS 1320 ← 唯一差异 |
③ 反复重试的分段点完全一致
5 次重试的 TCP 段尺寸都是 [1320, 72],切在正文的同一个位置
→ 说明这不是随机抖动,而是必然重现
2.3 一个值得记的弯路:代理转发测不出来
排查中我们一度用"经代理转发"的方式模拟现场网络,结果165 个包全部成功,
于是误以为"现场网络没问题"。
为什么代理转发测不出来?
因为MSS 是每条 TCP 连接单独协商的,只取决于这条连接两端之间的路径。
设备 ──[连接A]──> 转发入口 ──[连接B]──> 远端 ──> 后端
本地, MTU 1500 跨 WAN
↑ ↑
设备只看到这条 代理自己消化
代理把上游响应整个收下来,再通过连接 A 一次性发出。窄口在代理处就终止了,
根本传导不到设备。
教训:代理转发会改写连接拓扑,把跨 WAN 的路径特性(MTU、分段、重传)屏蔽掉。
“走代理测试正常"不能推出"现场网络没问题”。同类道理:“PC 测速正常"也不代表"设备正常” —— 路径和端点都不是同一个。
三、根因
3.1 TCP 分段是"发方"根据 MTU 决定的
先用一个比喻:
| 概念 | 通俗说法 |
|---|---|
| MTU | 一个「箱子」连箱子带运单最大能多大 |
| MSS | 一个「箱子」里实际能装多少货(≈ MTU − 40) |
MTU 1500 → MSS 1460
MTU 1360 → MSS 1320
两个环境要发的东西一模一样大(1392 字节),但:
实验室:1392 < 1460 → 一箱装得下,一次发完 ✓
现场: 1392 > 1320 → 一箱装不下,必须拆两箱 ✗
发送方确实在按 MSS 填满分段 —— 抓包里看到服务端发出的 IP 包总长正好 1360 = MTU。
3.2 关键:ESP8266 会给每个分段贴标签
这是问题的核心。ESP8266 在普通模式(AT+CIPMODE=0)下,
每收到一个 TCP 分段,就在串口输出一段,并在前面贴一个长度标签:
+IPD,1320: <第一段:1320 字节>
+IPD,72: <第二段:72 字节>
(+IPD,<len>: 读作"接下来 <len> 字节是数据")
整个串口流长这样:
[AT 回显] \r\nRecv 178 bytes\r\n\r\nSEND OK\r\n\r\n
[帧头] +IPD,1320:
[数据] <880 字节 HTTP 头><正文前 436 字节>
[帧头] +IPD,76: ← 这个 8 字节前缀插在了正文中间!
[数据] <正文后 76 字节>
[收尾] \r\nCLOSED\r\n
3.3 代码为什么解析失败
解析代码原本假设:「串口缓冲里就是一整段连续的 HTTP 响应」——
这在单段情况下成立,在多段情况下就崩了:
body_start = 找到 "\r\n\r\n" 后 +4; // 指向正文首字节 ✓
body_end = body_start + content_length; // +512
closed_pos = bufstr(body_end, ..., "CLOSED"); // 从 body_end 起找收尾标记
body_len = closed_pos - body_start;
因为 +IPD,76: 这 8 字节被当成了正文内容,CLOSED 的实际位置被推后了约 10 字节:
数出 body_len = 524(512 真实正文 + 8 字节帧头 + 2 字节分隔)
期望 content_length = 512
→ 不相等 → 判失败
5 次重试结果完全一样,因为分段点是固定的,每次必然重现。
3.4 为什么只有固件包中招
| 请求 | 响应大小 | 会不会被拆段 |
|---|---|---|
| 心跳 | ~150 B | 一箱 ✓ |
| 拉配置 | ~300 B | 一箱 ✓ |
| 查询版本 | ~194 B | 一箱 ✓ |
| 固件包 | 1392 B | 超了 1320,必拆两箱 ✗ |
1392 恰好落在 1320~1460 这个区间 —— 这是为什么只有固件包出问题、
而且只在路径 MTU 较小的那个环境出问题。
四、修复
4.1 正确做法:解析前先做 +IPD 分帧剥离
在 HTTP 解析之前,把各分片的 payload 原位收拢成连续字节流,
后续解析逻辑一行不用改:
static bool isIpMarker(const char *p, const char *end)
{
return ((end - p) >= 5) &&
(p[0] == '+') && (p[1] == 'I') && (p[2] == 'P') && (p[3] == 'D') && (p[4] == ',');
}
static u16 deframe(void)
{
char *buf = rx_buf;
char *end = buf + rx_len;
char *src = buf + s_src; // 增量进度:读到哪
char *dst = buf + s_dst; // 增量进度:结果写到哪
while (src < end)
{
char *marker = src;
if (!isIpMarker(src, end))
{ src++; continue; } // 非标记字节(分隔符 / AT 回显)丢弃
/* 解析 <len> */
p = src + 5;
while (p < end && *p >= '0' && *p <= '9') { need = need*10 + (*p-'0'); p++; }
if (p >= end || *p != ':') { src = marker; break; } // 帧头不全 → 回退等下一轮
p++;
if ((end - p) < need) { src = marker; break; } // 数据不全 → 回退等下一轮
memmove(dst, p, need); // 原位收拢
dst += need;
src = p + need; // 直接跳到本片末尾
}
s_src = src - buf; s_dst = dst - buf;
return s_dst;
}
几个设计要点:
| 点 | 说明 |
|---|---|
| 原位收拢 | dst <= src 恒成立(每消费一个标记至少跳过 6 字节),memmove 安全,不需要额外缓冲 |
| 增量处理 | 记录进度,只消费"整块到齐"的分片;配合"解析→等数据→再解析"的循环 |
| 不完整帧 | 帧头或数据未到齐时回退到标记起点,等下一轮再拼,不吞半截数据 |
| 兼容单段 | 没有帧标记时原样返回,老行为不变 |
4.2 ★ 一个容易忽略的陷阱:帧标记的自指问题
这是整个修复里最容易踩、也最值得记住的一点。
一旦代码里写了 memcmp(p, "+IPD,", 5) 或 bufstr(..., "+IPD,"),
编译器就会在固件里生成 "+IPD," 字符串常量。
而这个固件本身会被 OTA 下载 —— 于是"解析用的标记"就出现在了"被解析的数据"里。
实测确认(用工具在编译产物里搜):
固件二进制里 '+IPD,' 出现 1 次 @ 0x4b54 ← 就是代码里的字符串常量
这正是同一套代码里已经踩过的另一个坑:收尾标记 CLOSED 也是代码自己的字符串常量,
它会在下载中的固件流里提前命中,导致响应没收完就返回。
| 标记来源 | 后果 | |
|---|---|---|
CLOSED | 标记在自家固件里(自指) | 在下载的固件数据中误命中 |
+IPD,(用字符串常量时) | 同上 | 同上 |
同源于一个错误假设:「串口缓冲里的字节流 == 协议数据本身」。
在 AT 模式下这个假设不成立 —— 缓冲里还混着模组自己的帧和回显。
解法:改用逐字节字符比较
if (p[0] == '+' && p[1] == 'I' && p[2] == 'P' && p[3] == 'D' && p[4] == ',')
字符字面量进的是指令立即数,不产生 RO 字符串 —— 固件里不再有 +IPD,。
顺带说明:即使一时忘了这点,只要解析逻辑从不扫描 payload 内部,
也不会误解析(处理后src直接跳到本片末尾)。
但"payload 不被扫描"是个隐含不变量,一旦以后改了循环边界就可能被破坏 ——
从根上消除字符串常量更稳妥。
五、如何验证
5.1 用两条 ping 测路径 MTU
不用抓包也能测:对目标地址发带 DF 标志的包,二分找最大可通过尺寸
(ICMP 载荷 + 28 = 路径 MTU):
# Windows
ping -n 1 -f -l 1332 <目标IP> # 载荷 1332 → MTU 1360
ping -n 1 -f -l 1372 <目标IP> # 载荷 1372 → MTU 1400
- 返回「需要拆分数据包」→ 路径 MTU 小于该值
- 实验室基线:
-l 1472通过(MTU 1500)
5.2 本地复现:手工制造 TCP 分段
分段是"发方"的行为,不需要真实广域网就能复现 ——
在本地起一个 TCP 代理,把响应手工切开:
sendall(resp[:1320]) # 先发前 1320 字节
sleep(0.08) # ← 关键:超过 TCP 合并窗口
sendall(resp[1320:]) # 再发剩下的
为什么必须
sleep:即使分两次write(),只要间隔很短,
TCP 会自动合并成一个段发出(Nagle / 发送缓冲)。
要逼出两个真实分段,间隔必须超过合并窗口。
我们把代理的 SPLIT_AT 参数设为 0 做对照组,同一台设备、同一份固件:
| 配置 | 结果 |
|---|---|
| 不分段 | 55/55 全部成功 |
切成 [1320, 72] | 必然失败,5 次重试全败 |
唯一变量是"是否分段" —— 这排除了长链路、高时延、DNS 等所有其他解释。
5.3 如果帧标记真的出现在数据里,怎么确认安全
用真实固件字节构造测试输入,把含有该字符串的那一段当作 payload,
验证去帧后数据逐字节完整。同时构造对抗性用例(伪造一个看起来合法的帧头放进 payload)。
六、经验清单
排查类
- 异地问题优先抓包。只有后台状态码时排查效率极低;一次抓包能省几天
- 对比两个环境的差异,而不是猜。把两个环境的数据摆在一起,差异项往往只有一个
- 注意方向。本例只有「服务器 → 设备」一个方向的 MTU 变小;
反方向完全正常,只看单向会得出错误结论 - 代理转发有复现盲区 —— 它会改写连接拓扑,屏蔽路径特性
- PC 测速正常 ≠ 设备正常 —— 路径和端点都不是同一个
代码类
- 不要假设「一段缓冲 == 一条完整消息」。AT 模式下缓冲里混着模组自己的帧和回显
- 凡是"等某个标记出现"的写法,先问:这个标记会不会出现在数据里?
—— 自指(自家固件里的字符串)和外插(模组插进数据的前缀)是同一类错误的两个面 - 用字符比较代替标记字符串,避免把自己的标记编进固件
- 协议层能自己定长度就用长度(如 HTTP 的
Content-Length),
比靠标记字符串定位稳健得多
协议类
- TCP 是流,分段由发送方按路径 MTU 决定,应用层无法阻止
- MSS 是每条连接单独协商的,与后面的链路无关 —— 中间有代理时,
窄口会被代理挡住 - ESP8266 的
+IPD是"每段一个",不是"每响应一个"。
这是 AT 模式下最容易踩的坑之一
附:相关 AT 指令背景
| 指令 | 作用 |
|---|---|
AT+CIPMODE=0 | 普通模式,收到数据以 +IPD,<len>:<data> 输出 |
AT+CIPMODE=1 | 透传模式,数据原样吐出(无 +IPD),但 AT 指令失效、需 +++ 退出 |
AT+CIPMUX=0/1 | 单连接 / 多连接(多连接时 +IPD 会带 <link_id>) |
AT+CIPRECVMODE=1 | 被动接收(需 AT 固件 ≥ 1.7.0):只上报 +IPD,<len> 通知,用 AT+CIPRECVDATA 主动取数据 —— 取出的是模组内部重组后的连续数据,可从根上规避本问题 |
关于被动模式:它看起来是更优雅的解法,但依赖 AT 固件版本。
模组采购批次不同、固件版本可能不一致时,
在 MCU 侧做分帧解析是兼容性最好的方案。
418



被折叠的 条评论
为什么被折叠?



