TCP OTA 服务端手册
通用 OTA 服务 TCP 通道的服务端视角文档:实现契约(收发包、动作处理、错误码选择、 分片与会话参数)与运维要点。开发服务端、排查线上问题看这份; 设备端接入看 《TCP OTA 客户端接入手册》。
服务端要点速查(完整规则见后文各章)
必须守住的回显(多设备并发不串台的前提)
sid回包带本请求帧的会话号,逐请求记录,不能缓存在连接对象上svc_type同样逐请求回显(mux 按(type, sid)路由)- 链路:
tcp_mux↔ 本服务之间是一条长连接,同时承载多台设备
报文与帧上限
- 64 KB 应用消息上限(
TCP_OTA_MSG_MAX),超限回err=2 - 4 MiB 单帧负载上限(
TCP_FRAME_MAX_DATA),超出判为帧头损坏 - 14 B 帧头固定长度,多字节字段大端
关键阈值
600 s下载会话空闲回收64并发会话表上限(满 →err=13)16单次pull片数上限(硬顶 128)60 KBchangelog 上限(超 →err=11)512发送队列槽位(不足即回again)
TCP 通道参数([transfer.tcp])
CHUNK默认 8192,区间 128 ~ 65535BATCH16RETRY2IDLE300 sCONNS64
一、服务端在链路中的位置
1、链路上的角色
| 角色 | 职责 | 与协议的关系 |
|---|---|---|
| 设备端 | 发语义请求、按帧拆包、按 sid 匹配响应 | 协议的发起方,见 客户端手册 |
| FRP / 公网隧道 | 把 tcp.qftek.top:32200 映射到内网 mux | 纯转发,不解析内容 |
tcp_mux | 分配会话号 sid、应答心跳、按 (type, sid) 路由 | 完整帧透传:本服务收到的就是设备原始帧 |
| OTA 服务(本手册) | 帧编解、动作处理、会话管理、分片下发、版本 / 差分 / 灰度决策 | 监听 0.0.0.0:8011,被 mux 当作普通 TCP 上游 |
设计要点
业务逻辑与链路解耦:引擎只认 7 类语义动作(
hello / check / start / pull /
finish / abort / report / changelog 里的统一请求结构),适配层负责把帧 + JSON 翻译成
ota_req_t、把 ota_resp_t 序列化成报文。
换协议只换适配层,引擎不动。
2、多设备并发与 sid 回显
tcp_mux 与本服务之间只有一条 TCP 长连接,这条连接上同时承载多台设备
(各自不同 sid)。因此:
- 每处理一个请求帧,就记下这个帧的
sid与svc_type, 并在回包里原样带上; - 绝不能把 sid 缓存在连接对象上 —— 期间其它设备的请求会把它改掉。
/* ✅ 正确:逐请求记录 */
if (f->frame_type == DATA) { cur_sid = f->sid; cur_svc = f->svc_type; }
send_frame(DATA, cur_svc, cur_sid, payload, len);
/* ❌ 错误:多设备并发必然串台 */
conn->sid = f->sid; // 设备 A 的请求
... send_frame(..., conn->sid) // 期间设备 B 的请求可能已改掉它实测踩过的坑
6 台设备并发时数据互相串台(A 的响应发给了 B)。改成逐请求记录后,6 路并发 100% 正确。
本服务的
handle_frame() 就是按这个原则实现的:进入分发前先记录本请求的
sid / svc_type,后续所有回包都用这份记录。
二、帧收发契约
1、帧头与同步字
与客户端一致(详见 客户端手册 · 帧格式),服务端实现要点:
- 同步字固定
AA 55 5A A5 FF EC(6 字节),帧头 14 字节,多字节字段大端; - 帧长 =
14 + 数据长度,无填充、无帧级校验和; - 发 JSON 响应用
DATA(0x00)帧,一帧一条消息; 二进制分片的每个字节帧也是独立的DATA帧。
2、收帧容错
| 场景 | 处理要求 |
|---|---|
| 流里出现垃圾字节 | 用同步字重新对齐(memchr + memcmp 逐点匹配),不能让解析器卡死 |
| 长度字段不合理 | 超过 TCP_FRAME_MAX_DATA(4 MiB)判为帧头损坏:丢弃同步、重新找帧 |
| 读到的数据不足一帧 | 保留半帧状态(解析器状态机),下次读入继续拼 |
| 跨包同步字 | 同步字可能被 TCP 分段切开,需支持跨读边界匹配 |
3、发帧要求
- 回包的
frame_type/svc_type/sid取本请求的值(见 一、2); - JSON 响应与字节帧都在同一连接上按序发出(
pull的 JSON 元信息帧在前,cnt个字节帧紧随其后); - 发送队列满时不丢帧:
pull走again流控 (见 六、4),其它响应排队发送。
三、请求处理契约
1、报文校验与错误映射
| 场景 | 错误码 | 说明 |
|---|---|---|
| 负载为空 | 2 | empty payload |
负载 ≥ 64 KB(TCP_OTA_MSG_MAX) | 2 | message too large,防超大报文打爆单条消息缓冲 |
| JSON 解析失败 / 未知动作 | 1 | unknown act 'xxx';解析失败不触发固件库扫描 |
该动作必填字段缺失(如 product) | 2 | missing product |
pull / finish / abort 缺 sid | 8 | missing sid / session not found |
未知字段一律忽略
解析器只取它认识的键,多余的字段不报错 —— 这是协议向前演进的前提,客户端多加字段不会被打回。
2、动作分发表
| 动作 | 处理路径 | 成功响应 |
|---|---|---|
hello | 引擎统一处理(读本协议分片参数) | {"ok":1,"pver","chunk","min","max"} |
check | 引擎:写锁 → 产品 → 禁用 → 灰度 → 固件 → 版本 / 差分 | {"ok":1,"latest","upd","busy","force","hw","size","md5","delta","dsize"} |
start | 引擎:分配下载会话、决定全量 / 差分 | {"ok":1,"sid","trans","size","total","chunk","md5"} |
pull | 适配层专有:读会话、算片数、发 JSON + 字节帧 | {"ok":1,"cnt","next","eof","done",…} |
finish | 适配层专有:结束会话、记录产物 md5 | {"ok":1,"sid","size","md5"} |
abort | 释放会话(未带 sid → err=8) | {"ok":1} |
report | 仅记日志(event / msg) | {"ok":1} |
changelog | 读产品日志文件 | {"ok":1,"content":"…"} |
3、按需扫描固件库
- 只有
check/changelog/start三类动作会触发固件库扫描(它们才开始「一次新的判断 / 传输」); - 扫描有最小间隔(
SCAN_INTERVAL_SEC,默认 10 秒),间隔内重复请求零系统调用开销; - 写锁(
write_busy)每秒检查,秒级生效,不受扫描间隔影响; - 配置热加载走同一套节奏:只在收到设备请求时看一眼配置文件,
冷却 ≥10 秒(与
SCAN_INTERVAL_SEC取大者),冷却期内零 IO; 改完配置 → 下一个请求即生效(日志出现配置已热加载, 统计行有「配置重载 N」计数); - 设计意图:没有升级任务时系统完全静止,不空转、不浪费 CPU (闲时既不扫固件库,也不读配置文件)。
4、响应序列化规则
- 失败:
{"ok":0,"err":N,"msg":"…"}(msg供日志 / 排障, 客户端不必解析); - 成功:
{"ok":1, …}+ 该动作的字段;字段按「是否非零 / 非空」决定是否输出, 客户端只读它关心的; check的busy字段与latest一起判断是否输出; 无更新时附带"nonew":1,便于客户端直接分支。
维护中为什么故意不带
latest
写锁开启时的响应是 {"ok":1,"upd":0,"busy":1}:不暴露任何版本信息。
如果实现成「带 latest 但 upd=0」,客户端会把维护误判成「已是最新」—— 语义完全不同。
四、各动作实现要点
1、hello
- 返回本协议的
pver、允许区间min/max与生效分片chunk; - 客户端带了
chunk→ 按本协议区间钳制后返回; - 带了
product→ 顺带返回该产品配置里的推荐分片(同样钳制), 客户端可以据此少配一个参数。
2、check
判定顺序(前面的条件命中就直接返回,不读固件文件):
- 写锁 / 维护中 →
busy=1+upd=0(不暴露版本) - 产品不存在 →
err=3 - 产品停用 →
err=4 - 灰度未命中(按
did)→err=10 - 固件文件缺失 →
err=5 - 版本比对 → 相同则「无更新」:
upd=0+nonew=1(ok仍为 1) - 有更新时再选差分:补丁可用且客户端版本支持差分 →
delta=1+dsize
版本判定用「不相等」而不是「小于」
cv 与服务端目标版本一致才算已是最新;不一致(更高或更低)都返回有更新。
这样可以支持服务端降级发布,且与既有通道口径一致。
| 差分可用条件 | 说明 |
|---|---|
二进制分片开关打开([proto] ENABLE_BINARY_CHUNK) | 关闭时一律下发全量 |
客户端版本能解析且 ≥ 产品配置的 MIN_CLIENT_VERSION | 不满足则不下发补丁(客户端若强行要差分,start 会拿到 err=14) |
存在 cv → 目标版本 的补丁文件 | 缺失时 start 指定 trans:"D" 会拿到 err=6 |
3、start
- 分配下载会话并返回
sid;会话表满 →err=13; trans返回实际采用的方式:请求显式指定就按指定(无补丁 →err=6、 版本过低 →err=14),未指定则「有补丁优先差分」;size是本次传输对象的大小:全量 = 完整包;差分 = 补丁大小;md5恒为目标完整包的 MD5(差分路径下即「旧包 + 补丁」合成产物的校验基准);total= ⌈size/chunk⌉(按实际生效分片向上取整)。
4、pull
| 要点 | 实现规则 |
|---|---|
| 会话校验 | 会话不存在 / 已关闭 → err=8 |
| 片数钳制 | 请求 batch > 本协议上限 → 钳到上限;硬顶 128 |
二进制模式(bin=1) | 先发元信息 JSON(cnt/next/eof/done/chunks[]),再发 cnt 个字节帧 |
文本模式(bin=0) | 分片以 hex 内嵌 JSON、一帧返回;片数按 64 KB 消息上限反推(见 六、3) |
| 发送队列不足 | 全有或全无:不发部分数据,返回 {"ok":1,"cnt":0,"again":1,"next":0,"eof":0} |
off 越界(off ≥ size) | 返回 {"ok":1,"eof":1},不报错(客户端「最后一片后再问一次」是常见实现) |
| 片元信息 | chunks[i] 给出 {off, len, crc}:off 是目标文件里的偏移,crc 为 CRC32(小写 hex,可选校验) |
5、finish / abort / report
finish:结束下载会话,返回sid/size/ 目标完整包md5;请求里的result与客户端产物md5仅记日志,用于对账排障,不参与决策;abort:直接释放会话,回{"ok":1}(缺 sid →err=8);report:把event/msg写进日志后回{"ok":1},客户端上报失败原因靠它落盘。
6、changelog
- 返回更新日志全文;没有日志文件时回
{"ok":1,"content":""},不是错误; - 日志超过 60 KB(
OTA_CHANGELOG_MAX)→err=11(不截断成半截 JSON); cv可忽略:一律返回「最新版本的日志」;- 错误分支与
check对齐:缺product→err=2、 未知产品 →err=3、停用 →err=4、灰度未命中 →err=10、维护中 →err=7、读文件失败 →err=11。
五、错误码触发场景全表
服务端视角:什么情况回哪个码。客户端侧的建议动作见 客户端手册 · 错误码全表。
| err | 服务端触发场景 | 线上报文 |
|---|---|---|
| 0 | 成功 | {"ok":1,…} |
| 1 | 未知动作 / JSON 解析失败(含空动作名) | {"ok":0,"err":1,"msg":"unknown act 'x'"} |
| 2 | 参数缺失或非法:空负载、消息 ≥ 64 KB、缺 product、to 与发布版本不符 | {"ok":0,"err":2,"msg":"missing product"} |
| 3 | 产品不存在(目录名不匹配,含编码不一致) | {"ok":0,"err":3,"msg":"unknown product 'X'"} |
| 4 | 产品 OTA_ENABLE=0 | {"ok":0,"err":4,…} |
| 5 | 固件文件缺失 / 超大小护栏被拒 | {"ok":0,"err":5,"msg":"firmware file missing: …"} |
| 6 | trans:"D" 但 cv 对应不上任何补丁基线 | {"ok":0,"err":6,…} |
| 7 | 维护中(写锁)时收到非 check 的动作 | {"ok":0,"err":7,…} |
| 8 | 会话不存在 / 已过期 / 已关闭;缺 sid | {"ok":0,"err":8,"msg":"session not found"} |
| 9 | off 越界(保留码):当前实现把 off ≥ size 视为「已完成」,回 {"ok":1,"eof":1},该码暂未在 TCP 通道使用 | —(保留) |
| 10 | 灰度开启且 did 不在灰度池 | {"ok":0,"err":10,"msg":"device not in gray pool"} |
| 11 | 文件 IO 失败:读固件 / 日志失败,日志超 60 KB | {"ok":0,"err":11,…} |
| 12 | 已是最新(内部语义标记) | check 线上表现为 {"ok":1,"upd":0,"nonew":1} |
| 13 | 并发下载会话达上限(64) | {"ok":0,"err":13,"msg":"too many sessions (64)"} |
| 14 | 客户端版本低于产品 MIN_CLIENT_VERSION,不支持差分 | {"ok":0,"err":14,…} |
六、分片与传输参数
1、钳制规则与优先级
客户端传来的 chunk 一律钳制到本协议区间;生效值的优先级(高 → 低):
start请求里的chunkproduct.ini的CHUNK_SIZE[transfer.tcp] DEFAULT_CHUNK_SIZE[transfer] DEFAULT_CHUNK_SIZE(公共默认)
2、TCP 通道参数
| 配置项 | 默认 | 说明 |
|---|---|---|
DEFAULT_CHUNK_SIZE | 8192 | 客户端未指定时使用;8 KB 让 1 MB 固件约 128 次往返 |
MIN/MAX_CHUNK_SIZE | 128 / 65535 | 钳制区间(太小往返多,太大客户端缓冲吃紧) |
MAX_BATCH | 16 | 单次 pull 最多几片(16 × 8KB = 128 KB / 次) |
SEND_RETRY | 2 | 发送失败重试次数 |
IDLE_TIMEOUT_SEC | 300 | mux ↔ 服务连接的空闲回收(≠ 下载会话空闲 600 秒) |
MAX_CONNS | 64 | 本协议最大并发连接数 |
3、文本模式片数反推
bin=0 时每片以 hex 内嵌 JSON(体积 ×2 加包装),因此按消息上限反推安全片数:
/* 每片约占用 chunk*2 + 64 字节,留出 JSON 包装余量 */ int per = chunk * 2 + 64; int max_by_size = (TCP_OTA_MSG_MAX - 256) / per; /* TCP_OTA_MSG_MAX = 64 KB */ if (batch > max_by_size) batch = max_by_size;
建议
引导客户端用二进制模式(
bin=1):文本模式只适合小补丁或调试,
且要求客户端自己解 hex。
4、发送队列与 again
- 每连接一个发送队列(512 槽);二进制模式下每片占一个槽,元信息 JSON 还要占 1 格;
- 可用槽位不足时不裁剪
cnt偷发一部分,而是整体回again=1—— 保证「声明 N 片就一定发 N 片」; - 客户端按
off不变、退避 50 ms 重发即可(见 客户端手册 · 流控信号 again)。
不要用「少发几片」实现流控
实测踩过的坑:JSON 说
cnt=N 但实际只入队 M<N 片,客户端会把下一个
长度 0 的帧当成数据片,报「分片长度不符」整包失败。全有或全无是硬要求。
5、限速与传输节奏
- 下载限速在
[throttle]配置:分「空闲时段 / 拥挤时段」两档速率 (默认 00:00~17:00 = 8 Mbps,17:00~24:00 = 2 Mbps); PER_CONN=0表示所有连接共享一份速率(保服务器出口),=1表示每连接独立;- 限速只影响发送节奏,不影响正确性:表现为客户端更频繁地收到
again, 不会出现数据缺口。
七、会话与资源管理
1、会话生命周期
start创建下载会话(分配sid);pull/finish都会刷新活动时间;- 空闲超过 600 秒(
SESSION_IDLE_SEC)自动回收并记日志; finish/abort立即释放;服务重启后会话全部丢失 (客户端需重新start,check结果仍有效)。
2、上限与回收
| 项 | 值 | 说明 |
|---|---|---|
| 并发下载会话表 | 64 | 引擎级(OTA_SESSION_MAX),满 → err=13 |
| 本协议最大连接数 | 64 | [transfer.tcp] MAX_CONNS |
| 连接空闲回收 | 300 s | IDLE_TIMEOUT_SEC,回收的是 mux ↔ 服务的连接 |
| 文件描述符 | — | 单线程 epoll,连接上限即 fd 用量上限 |
3、内存与并发
- 固件用
mmap共享映射:N 台设备同时下载同一固件只占一份物理内存; - MD5 带缓存(按 mtime + size 失效,条目上限 256),避免每个
check都重算大文件; - 下载走
writev(mmap),不做编码与复制 —— 二进制模式的大流量路径零拷贝。
八、固件库与产品名(服务端视角)
1、产品名匹配
product= 固件库目录名,按字节精确比对(区分大小写);- 名字缓冲
FW_NAME_MAX = 128→ 上限 127 字节; - 允许中文;客户端发 GBK 字节时会自动归一化成 UTF-8 再比一次(老设备兼容), 两种编码指向同一份固件;
- 目录组织、补丁命名、发布流程见
ota/docs/固件管理.md。
2、版本与差分基线
- 版本号按
X.Y.Z解析(各段 1~4 位数字,可带v前缀); 解析失败的cv一律签名为「不支持差分」(不下发补丁); - 补丁按
cv → 当前发布版本查找;找到才给delta=1; 找不到就把客户端要的差分拒掉(err=6),客户端回退全量; - 校验基准始终是目标完整包 MD5:全量时它等于文件 MD5,差分时用于校验合成产物。
3、灰度与写锁
| 开关 | 位置 | 效果 |
|---|---|---|
OTA_ENABLE | product.ini | 0 → 该产品 err=4(客户端应静默跳过) |
GRAY_ENABLE / GRAY_ID_POOL | product.ini | 只对池内 did 开放,未命中 → err=10 |
write_busy | firmware/WRITE_LOCK.ini | 维护中:check → busy=1+upd=0;其它动作 → err=7 |
MAX_FILE_SIZE_MB | 服务端配置 | 超过护栏(默认 300 MB)的文件不参与 OTA,按「固件缺失」处理 |
为什么
did 必须传
灰度按设备唯一 ID 命中;客户端不传 did 时,灰度开启的产品一律视为未命中。
灰度池为空表示「不限制」还是「全不放行」由产品配置决定,发布前务必在面板上验证一次。
九、运维与排障
1、与协议相关的配置项
| 段 | 配置项 | 影响 |
|---|---|---|
[server] | LISTEN_PORT / LISTEN_HOST | 监听端口须与 mux 的转发目标一致(默认 8011);改端口要重启 |
[transfer.tcp] | 分片 / 批 / 重试 / 空闲 / 连接数 | 见 六、2;热更新生效 |
[proto] | PROTO_VER | hello 下发的 pver |
[proto] | ENABLE_BINARY_CHUNK | 关掉则一律文本模式且不下发差分 |
[firmware] | ROOT / LOCK_FILE / SCAN_INTERVAL_SEC / ENABLE_MMAP | 固件库位置、写锁、扫描冷却、共享映射 |
[throttle] | 时段与速率、PER_CONN | 下载限速,见 六、5 |
2、自检与日志
cd /home/user/install/ota ./ota_server config/ota_config.ini --check-config # 打印生效配置 + 固件库全貌(版本/大小/MD5/补丁) python3 scripts/test_ota_e2e.py --via-mux # 经真实链路端到端自测 python3 scripts/verify_real.py --via-mux # 真实大包 + 官方差分补丁验证
- 日志关键字:
check:(版本判定 / 差分选择)、start:(会话与传输方式)、pull相关(队列满 → 退避)、客户端上报(report 内容); - 响应里的
msg与日志同源,排障时先看err再看日志里的产品名与版本号。
3、常见故障对照表
| 现象 | 原因 / 处理 |
|---|---|
客户端拿到 err=3 | 目录名与请求的 product 是否完全一致(含大小写、编码) |
客户端拿到 err=5 / 自检显示「固件缺失」 | TARGET_FW_NAME 与实际文件名是否一致;文件是否超大小护栏 |
客户端拿到 err=6 | 补丁文件名基线版本与客户端当前版本不匹配(make_patch.py --list 查看) |
| 客户端校验产物 MD5 失败 | 日志里找「产物 MD5 与官方包不一致」:差分基线不匹配或旧包被改过 |
| 多设备并发时数据串台 | 回包是否逐请求回显本请求的 sid(见 一、2) |
| 改配置不生效 | 是否设了 write_busy = 1(会暂停热加载检查) |
大量 err=13 | 会话泄漏:客户端没 finish / abort;
或并发设备数确实超过 64(需扩容会话表) |
客户端频繁收到 again | 发送队列吃紧或限速生效:属正常流控,客户端退避重发即可 |
十、相关文档
| 文档 | 给谁看 / 内容 |
|---|---|
| TCP OTA 客户端接入手册 | 设备端接入:帧格式、动作字段、状态码、升级流程、Python 参考客户端 |
ota/docs/README.md | 服务端架构、完整配置说明、编译与 systemd 运维 |
ota/docs/固件管理.md | 面向运维:固件目录组织、发布流程、差分补丁规则、故障排查 |
ota/docs/TCP OTA 协议与接入开发文档.md | 协议 md 源文档(客户端手册的内容来源) |