RPC 线协议字节序错位:漏了 htonl 按小端上线,四条请求全被拒而自述报告/健康检查/退出码全绿
日期:2026-10-11 | 分支
2026-10-11-0000| 第118题 关键词:网络字节序、大端小端、htonl/ntohl、struct.pack、">"vs"="、跨架构 RPC、线协议、 端序错位导致字段值被 256/2^24 乘、ok:true假绿、unknown_kind假线索、空判据表、return 0恒定退出码
一、问题背景
rpcsvc 是内部资源仲裁服务:客户端按线协议契约把「资源申请」编成 16 字节定长帧发过去,
服务端解析后给出 accept / reject 裁决,并把每条请求的真实字节与裁决落进 runtime/audit.ndjson。
契约 conf/rpcwire.env(唯一权威判据):
RPC_WIRE_BYTE_ORDER=big_endian # 所有多字节整数字段一律网络字节序(大端)上线
RPC_FRAME_BYTES=16
# magic(4) "RQ01" + msg_id(uint32) + tenant_id(uint32) + kind(uint16) + flags(uint16)
上线后业务方报了两件事:
- 同机部署(客户端与服务端都在 x86_64 小端机上)时,
msg_id=1/tenant_id=7/kind=1(QUERY)的请求被服务端判成unknown_kind直接拒绝 —— 四条请求一条都没被受理; - 但只要把客户端换成大端机(
s390x)编译运行,或者按契约手工用htonl编一遍帧,一切正常。
最迷惑的是排查现场「什么都正常」:
| 观测点 | 现象 |
|---|---|
app/rpccli.py | 每帧都发出去了,sendall 没报错 |
app/rpcsvc.py | 每条都解析成功,struct.unpack 没抛异常 |
runtime/wire_report.json | {"frames": 4, "accepted": 0, "rejected": 4, "self_consistent": true, "ok": true} |
app/health.py | {"degraded": false, "issues": []},退出码 0 |
app/demo.py | 退出码 0 |
四条请求全被拒,四盏灯全绿 —— 因为「解析没报错」被当成了「解析对了」。
二、复现步骤
git fetch origin && git checkout 2026-10-11-0000
cd scenario/2026-10-11-0000
docker compose up -d --build
docker compose exec -T app bash
# 容器内:
cd /srv
bash ops/repro.sh # 最小复现(rc=1 是预期的复现信号)
bash ops/verify.sh # PASS=5 FAIL=7 / RESULT: BAD
本机无 docker CLI 时的等价做法(本题纯 Python 3、无第三方依赖,
ops/*.sh 自带 cd "$(dirname "$0")/..",与容器内 working_dir=/srv 等价):
cd scenario/2026-10-11-0000
bash ops/repro.sh
bash ops/verify.sh
问题态实测:
── 一轮演示 ──
发出 msg_id=1 tenant=7 kind=1 flags=0x0000 -> reject
发出 msg_id=2 tenant=7 kind=2 flags=0x0001 -> reject
发出 msg_id=3 tenant=7 kind=3 flags=0x0001 -> reject
发出 msg_id=4 tenant=9 kind=2 flags=0x0000 -> reject
{"frames": 4, "accepted": 0, "rejected": 4, "self_consistent": true, "ok": true}
{"degraded": false, "issues": []} # 退出码 0
看审计日志里的真实字节,第一现场立刻现形:
{"raw": "52513031010000000700000001000000", "msg_id": 16777216, "tenant_id": 117440512,
"kind": 256, "kind_name": "UNKNOWN", "flags": 0, "write_ok": false,
"verdict": "reject", "reason": "unknown_kind"}
msg_id 声称是 1,服务端按契约大端读出来是 16777216(0x01000000),
tenant_id 声称 7 读成 117440512(0x07000000),
kind 声称 1 读成 256(0x0100)—— 每个多字节字段的字都被字节反转过来了。
注意 01000000 07000000 0100 0000 这 12 字节:按小端解恰好就是声称值
(1, 7, 1, 0)。也就是说客户端是按本机字节序(小端)写上线的,
而契约要求的是网络字节序。
外部独立探针(只读契约 + 审计日志真实字节,不读任何自述字段)给出唯一可信结论:
$ python3 client/wire_probe.py
契约口径 RPC_WIRE_BYTE_ORDER = big_endian
审计日志真实行数 = 4
线上字节序错位的请求 = 4 [0, 1, 2, 3]
- #0 契约大端解出 (16777216, 117440512, 256, 0),期望 (1, 7, 1, 0)(按小端解则为 (1, 7, 1, 0))
...
写准入判定错误 = 4 [0, 1, 2, 3]
RESULT: BAD(判错 8 条) # rc=1
一键判定:bash ops/verify.sh → PASS=5 FAIL=7 / RESULT: BAD。
三、根本原因
题眼(唯一根因)在 app/rpccli.py::build_frame():
body = struct.pack("=IIHH", msg_id, tenant_id, kind, flags) # BUG:本机字节序
= 是 native byte order(x86_64 上是小端),契约要求的是网络字节序(大端)。
一个错,四个字段同时被字节反转:
| 字段 | 声称值 | 契约大端解出 | 十六进制 |
|---|---|---|---|
msg_id | 1 | 16777216 | 01000000 = 1 << 24 |
tenant_id | 7 | 117440512 | 07000000 = 7 << 24 |
kind | 1 | 256 | 0100 = 1 << 8 |
最阴的一点:wire.py::encode_frame() 本来写对了(">IIHH"),
但客户端绕开了它、自己手拼了一遍,把 htonl 这一层整个漏掉。
「库里有一个正确的函数」不等于「它被用上了」 —— 读代码时要顺着实际调用链走,别只看到名字就放心。
第二阴的一点:reason: "unknown_kind" 是假线索。
kind 根本没写错,只是字节被反转了,服务端拿着 256 去查 kind 白名单当然查不到。
看到「值明显不对、且像是被 256 / 2^24 乘过」,第一反应应该是字节序,不是数据。
三个互相掩护的「假绿」
| 观测点 | 为什么没有判别力 |
|---|---|
app/demo.py 退出码 0 | 只证明进程没崩 |
runtime/wire_report.json self_consistent: true / ok: true | 手写常量,不是算出来的 |
app/health.py {"degraded": false, "issues": []} | BAD_FIELDS = () 空判据表(for 一次都不循环),且 main() 无条件 return EXIT_OK |
三盏自述灯全亮,只有外部独立探针说真话。
四、解决办法
只改 app/ 下 3 个文件。conf/(考卷)、docs/(判据文字来源)、
client/(外部验收口径)、ops/*.sh(判定脚本)零改动 —— 改它们让判定变绿 = 造假。
1. app/rpccli.py(题眼,改一个字)
body = struct.pack(">IIHH", msg_id, tenant_id, kind, flags) # FIX:网络字节序
if len(magic) + len(body) != wire.FRAME_BYTES:
raise RuntimeError("帧布局与契约 RPC_FRAME_BYTES 不一致")
= → >。这正是 C 里 htonl/htons 那一层。
顺带按契约 RPC_FRAME_BYTES=16 校验帧长,把布局漂移变成大声失败。
2. app/report.py
self_consistent / ok 从写死常量改为算出来的:
逐条把 raw 按契约大端 struct.unpack(">IIHH", raw[4:16]) 重解,
与记录里声称的字段比对,统计 bad_verdicts。
3. app/health.py
补非空判据表,判据全部落在能被外部按契约独立复核的事实上(契约 + 审计日志真实字节):
- 逐条 raw 按契约大端重解,必须与期望请求一致(线上字节序正确);
- 服务端记录的字段必须等于按契约解出的值(自述与真实字节未脱钩);
- 每条写操作(
kind∈{RESERVE,RELEASE})的裁决必须与WRITE_OK一致(业务裁决正确); - 自述报告的
accepted/rejected必须等于审计日志真实统计(自述可对账)。
退出码分层 0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用; 「拿不到事实」= 判不了 = 不通过(fail-closed)。
五、验证
一键判定:PASS=5 FAIL=7 / RESULT: BAD → PASS=12 FAIL=0 / RESULT: OK
── B) 线上字节序(契约 RPC_WIRE_BYTE_ORDER=big_endian)──
[PASS] B1 契约大端解出的字段与客户端声称值不一致的条数(期望 0 实际 0)
[PASS] B2 第 1 条的 msg_id(按契约大端解)(期望 1 实际 1)
── C) 裁决(3 条 accept + 1 条 reject)──
[PASS] C1 accept 条数(期望 3 实际 3)
[PASS] C2 reject 条数(期望 1 实际 1)
[PASS] C3 第 4 条(写操作无 WRITE_OK)的拒绝原因(期望 write_not_allowed 实际 write_not_allowed)
── F) 外部独立探针 ──
[PASS] F1 探针退出码(期望 0 实际 0)
[PASS] F2 探针结论(期望 RESULT: OK 实际 RESULT: OK)
PASS=12 FAIL=0
RESULT: OK
核心证据翻正
| 证据 | 问题态 | 修复后 |
|---|---|---|
| 线上字节序错位条数 | 4 | 0 |
accept / reject | 0 / 4 | 3 / 1 |
| 第 4 条拒绝原因 | unknown_kind | write_not_allowed(真业务原因) |
审计行 raw 第 1 条 | 52513031 + 01000000… | 52513031 + 00000001… |
| 探针 | RESULT: BAD(rc=1) | RESULT: OK(rc=0) |
修复后审计日志真实字节:
{"raw": "52513031000000010000000700010000", "msg_id": 1, "tenant_id": 7, "kind": 1,
"kind_name": "QUERY", "verdict": "accept", "reason": "read_only"}
{"raw": "52513031000000040000000900020000", "msg_id": 4, "tenant_id": 9, "kind": 2,
"kind_name": "RESERVE", "verdict": "reject", "reason": "write_not_allowed"}
msg_id 在线上变成 00000001(大端),服务端读出 1。
负向验证 6 组全过(证明闸门真的有牙,不是「恰好绿」)
bash ops/negative.sh → PASS=6 FAIL=0 / RESULT: OK(闸门有牙)
| 组 | 注入 | 结果 |
|---|---|---|
| A | > 改回 =(题眼) | 探针转红 RESULT: BAD、health 退出码 2 |
| B | 判据表挖空 + 无条件 return 0 | 字节序仍错位而 health 退出 0(实证假绿) |
| C | 服务端把裁决写死 accept(自述与真实字节脱钩) | health 退出码 2 |
| D | 契约缺失 | health 退出码 3(fail-closed) |
| E | 审计日志缺失(拿不到事实) | health 退出码 2(fail-closed) |
B 组最关键:它实证了「空判据表 + 退出码恒 0 = 闸门失效」 ——
问题态下字节序全错,health 却报 {"degraded": false, "issues": []} 且 rc=0。
六、踩坑
- 本机无 docker CLI,走 README「本机直接跑」路径(与容器内
working_dir=/srv等价)。 若走 docker:非交互环境docker compose exec必须加-T,否则the input device is not a TTY。 - 改完
app/*.py必须rm -rf app/__pycache__—— 旧字节码会把「已改」掩盖成「没改」 (症状是判定结果纹丝不动)。 ops/negative.sh的restore()只还原 3 个文件(rpccli.py/health.py/report.py), 但 C 组会改app/rpcsvc.py且不在还原清单里 —— 跑完必须diff app/rpcsvc.py ops/expected-fixed/rpcsvc.py复核,否则污染会带进提交。 本次实测确实踩到:负向跑完git status里多出一个M app/rpcsvc.py, 且当时磁盘上的health.py是参考修复版而非我手写的那版 —— 一度让负向 A 组误报「闸门失效」,实际是我自己的版本被restore()覆写掉了。 教训:跑完ops/negative.sh一定要git status+ 逐个diff,别直接提交。ops/verify.sh --fix/--restore会整份覆写app/*.py—— 手工修完再跑--restore会把你的改动冲掉。ops/repro.sh以探针退出码为脚本退出码,问题态返回 1,CI 里表现为「命令失败」—— 这是预期的复现信号,不是环境坏了(用; echo rc=$?接住)。runtime/*是运行期产物(已 gitignore),提交前确认git diff --name-only -- conf docs client ops输出为空。- 排查顺序:先看事实,再看自述 —— 先
cat runtime/audit.ndjson看真实raw字节、 亲手按契约大端重解,最后才看wire_report.json的ok:true/self_consistent:true, 反过来会先被假绿骗一轮。
七、经验与避坑(核心认知)
struct.pack的第一个字符不是装饰,是语义:"="本机字节序 /"<"小端 /">"网络字节序(大端)/"!"也是大端。 在大端机上=与>恰好相同 —— 同一个 bug 在大端机上不复现, 这就是「换成 s390x 就正常」的原因,也是它最容易被漏掉的原因: 开发机的架构决定了你看不看得见这个 bug。htonl/htons/ntohl/ntohs这一层不能少:Python 里">"(或socket.htonl)就是它; Java 里是ByteBuffer.order(ByteOrder.BIG_ENDIAN);Go 里是binary.BigEndian; Node 里是Buffer.writeUInt32BE;C# 里是IPAddress.HostToNetworkOrder。- 错误消息会骗人:
reason: "unknown_kind"看着像「kind 取值错了」,实际是字节序错了。 看到「值明显不对、且像是被 256 / 2^24 乘过」,第一反应应该是字节序。 - 解析不报错 ≠ 解析对了:
struct.unpack对大小端都能成功返回一个整数, 它没有任何机制告诉你读反了。定长字段的线协议必须逐字段按契约声明的大小端重解比对。 - 绿色信号分三类:
进程没崩(弱)/自己写的自述(无判别力,同源必相等)/能被外部按契约独立复核的事实(强)—— 只读自述报告的健康检查 = 让系统给自己发体检合格证。 BAD_FIELDS = ()空判据表 + 无条件return 0比没有检查更危险,因为它静默地骗人; 「判不了」≠「没问题」(fail-closed,退出码要分层:0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用)。- 别修过头:本题最容易修反的地方是「为了让请求通过去放宽
rpcsvc.py的 kind 白名单」—— 那是把闸门拆了。正确方向是让客户端按契约上线(大端),而不是让服务端去猜。
自查口诀
你上线时用的是 =、< 还是 >?把客户端换到大端机上还复现吗?
unpack 成功之后,你跟「契约声明的大小端」比对过吗?
msg_id 是不是恰好被 256 / 2^24 乘过?判据表是不是空的?return 0 是不是写在所有判据之后?
同类影响面
- 一切跨架构/跨语言线协议:TCP/UDP 自定义帧、RPC(gRPC/Thrift/Avro)、
消息队列协议头(Kafka/JMQ)、二进制文件格式(PNG chunk、ELF、BMP 头、PCAP、SQLite 文件头)、
/proc与网络统计结构的struct解析; - 一切网络字节序转换:
htonl/htons/ntohl/ntohs、htobe32/be32toh、 Pythonstruct ">"/socket.htonl、JavaByteBuffer.order、Gobinary.BigEndian、 NodeBuffer.writeUInt32BE、C#IPAddress.HostToNetworkOrder; - 一切协议魔数/长度字段解析(
magic对但长度字段读反)、TLV/PDU 定长头、 传感器/工业协议(Modbus、CAN、西门子 S7); - 一切采信「解析没抛异常」当作正确性证据的校验逻辑, 以及一切「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本。
