docs(net): N34 E11/E12 实验记录与台架脚本(KeepAlive 服务器侧缺口、TSL 规格偏离)

E11:服务器侧 KeepAlive 未开启(静态证据链 + 注入手段两次修正 + 实测
生产参数 30.0s 判死、空闲存活会话不误杀),给出采纳建议;防火墙规则
对已建立连接无效、有效注入 = 禁用 PC 网卡,两条方法学留痕。
E12:缓存区读写路径长度→数据零间隔属规格偏离(读 0.4us/写 0.2us),
但 460 轮/约 58MB 字节级校验未测得可复现危害;依 Trap 25 登记偏离、
B 变体(两段+TSL,+28B,吞吐无回退)留作待用补丁。
残渣风暴态跨烧录存活污染 A/B 连接级对比,教训写入 E12:实验前后必须
冷启动(断电重上电),MCU 覆写烧录/RESET_ALL 不足以清除。
新增 test/host/e11_keepalive_probe.py(idle/block 两模式 + 注入正对照)
与 test/host/ftp_integrity_test.py(STOR→RETR 全量 SHA-256 比对,
建议纳入常备回归集)。
This commit is contained in:
2026-10-13 09:10:17 +08:00
parent eac36526e7
commit d96113bcc4
4 changed files with 418 additions and 0 deletions
@@ -388,3 +388,108 @@ Phase13 宏全量重编 **0 错 0 警**(Code=216632)后还原宏再重编产
227,824→228,172 B、Flash 合计 228,928→229,276 B;**RAM 两区未变**
(RW_IRAM1 97,864 B/74.7%、RW_CCM 57,376 B/87.9%),印证阶段 0/1 未动 RAM。
此后本分支常规提交不再需要 `--no-verify`。
### E11 结果(2026-10-13 07:01–07:18,CH395F 服务器侧 KeepAlive,临时实验固件)
**问题**:`CMD_SET_KEEPALIVE_SN`(手册 §5.56)——芯片在"IDLE 无收发 → 探测
CNT 次无 ACK"后产生 SINT_STAT_TIM_OUT / DISCONNECT。怀疑本仓没有利用它。
**静态证据(怀疑成立)**:
| 事实 | 证据 |
|---|---|
| 参数配了:idle 60s / intvl 5s / cnt 3(均 500 倍数、IDLE>INTVL) | `net_config.h:61-70`,`net_socket.c:298-302`(INIT_CH395 之前) |
| 开关**全仓仅一处**,且在 TCP **客户端**分支 `CONNECTING` | `net_socket.c:3257` |
| `App/` 目录**零个 `net_connect()`** → 生产无任何 socket 会走到该行 | grep 全仓 |
| 手册明确:TCP **服务器**应在 `SINT_STAT_CONNECT` 后开启 | `docs/芯片手册/CH395F.md` §5.56(:815-821) |
| 服务器侧调用 2026-10-12 曾加后**撤回**,理由仅覆盖"物理链路失效";注释自认"链路在、对端静默"未覆盖且**无法注入** | `net_socket.c:3223-3229` |
| 唯一能发现该缺口的 Phase 11 测试**默认关闭** | `test/board/ch395f_test.h`(`//#define ENABLE_PHASE11_TESTS`) |
**注入手段的两次修正(方法学留痕)**:
1. **Windows 防火墙入站 DROP **无效****:连接建立后再加规则,对该已建立连接
不生效(ALE 层在连接建立时判定)。正对照实测:装规则后 KEEPLIVE 回显**照常
收到** → `VERDICT: injection INEFFECTIVE`(`e11_inject_check.py`)。这也解释了
首轮 235s 窗口无 TIMEOUT(对端一直在 ACK,并非 keepalive 失效)。
2. **有效注入 = 禁用 PC 网卡**:中间有交换机(判别依据:ESTABLISHED 会话下
禁用 PC 网卡 12s,设备**零网络事件** → 设备侧 PHY 链路保持)。此时对端不可达、
设备链路在、且不产生 FIN/RST —— 正是"链路在、对端静默"。
**实测(实验固件:`handle_connect_event` LISTENING 分支加
`ch395f_set_keepalive_enable(sock,1)` + 调用点 DBG 留痕;TCP :7003)**:
| 试验 | 配置/注入 | 结果 |
|---|---|---|
| T2 正常空闲存活 | 生产参数 60/5/3,对端存活但静默 90s | KEEPLIVE 回显正常、**无 TIMEOUT** → 不误杀空闲会话 ✓ |
| T4 定标(短参数) | 6s/2s/2,禁用 PC 网卡 40s | 留痕 `keepalive enabled (idle=6000 intvl=2000 cnt=2)`;**T+5.1s** 报 `[ERR] sock3 TIMEOUT` → 参数按配置缩放,芯片默认值(20/15/9)假设**被否** ✓ |
| T5 生产参数 | 60/5/3,禁用 PC 网卡 90s | 留痕 `idle=60000 intvl=5000 cnt=3`;**T+30.0s** 报 `[ERR] sock3 TIMEOUT` → QUARANTINE → relisten → **新连接成功恢复** ✓ |
**结论**:
- KeepAlive 能力**真实可用**,且本层**已有现成消费者**(`net_poll` 派发
TIM_OUT → `handle_timeout_event` QUARANTINE → pump 重建)——打开开关即可闭环。
- 生产参数下实测判死 **≈30.0s**(早于手册公式 75s;芯片定时语义与手册公式
不一致,**以实测值为准**)。短参数实测 ≈5.1s,证明参数生效且可按需调优。
- 静默但**存活**的空闲会话不被误杀(T2)——因为对端会 ACK 探测包。
- 2026-10-12 撤回的盲区(链路在、对端静默)**正是本机制覆盖的场景**;当年
"+2s 即报 TIM_OUT"只覆盖了物理断链。
- **建议采纳**:在 `handle_connect_event` 的 LISTENING 分支加回一行
`ch395f_set_keepalive_enable(sock, 1)`(手册规定位置),并重新打开 Phase 11
测试纳入回归;参数是否调优(如缩短 IDLE)由实测判死窗口需求决定。
- ⚠ 台架副作用:本轮反复实验后设备落入 E8 残渣风暴态(详见 E12),需**冷启动
(断电重上电)**恢复,MCU 复位/RESET_ALL 不足以清除。
### E12 结果(2026-10-13 07:18–08:05,SPI 缓存区 TSL 时序 A/B,临时实验固件)
**问题**:手册 §7.4——SPI/并口模式下读写收发缓存区命令**只需**在"长度与数据
之间"留 TSL(读 0.4us / 写 0.2us),其余字节间无需间隔;**非缓存区命令**另有
TSC=0.6us(命令→数据)、TSD=0.3us(数据字节间)。本仓是否满足?
**静态证据(没考虑过,且缓存区路径零间隔)**:
| 事实 | 证据 |
|---|---|
| 全仓无任何 TSL 相关代码/注释(grep 只命中手册) | — |
| 两个缓存区函数把 `[cmd, sock, lenL, lenH, data...]` 打包进**同一 DMA 缓冲、一次连续突发**(CS 全程拉低)→ 长度→数据**间隔 = 0** | `ch395f.c` `write_send_buf` / `read_recv_buf`(原实现) |
| 其余命令走逐字节 `HAL_SPI_TransmitReceive(...,1,...)` → TSC/TSD 有 µs 级余量 ✓ | 同上 |
| 量化:SPI2 分频 8 @APB1 42MHz = **5.25MHz**(单字节 1.52us);TSL 0.4us ≈ 2.1 个 SCK 周期 | `Src/spi.c:84` |
**A/B 设计**:A = 现状(零间隔单突发);B = 头/数据两段传输 + 段间显式忙等
(`CH395F_TSL_LOOPS=40` ≈1.0–1.4us,另叠加两段间 HAL/DMA 重配开销;CS 全程拉低,
帧形与手册图一致)。B 代价:代码 +28B(Code 209580→209608)、写路径多一次
memcpy;**吞吐无回退**(A 40×128KB 51.8s vs B 47.9s)。
**字节级校验(`test/host/ftp_integrity_test.py`:STOR→RETR 全量 SHA-256 比对,
差异自动二次回读归因)**:
| 臂 | 样本 | 结果 |
|---|---|---|
| A(零间隔) | 260 轮(20×64KB + 240×128KB ≈32MB/方向) | **1 次不一致**(A2 轮,细节未留档,A3/A4/A5 未复现) |
| B(两段+TSL) | 200 轮(40+100+60 ×128KB ≈25.6MB/方向) | **0 次不一致** |
→ **未观察到 TSL 造成的可复现数据错位**;两臂均接近"零错位"。
**连接级 n30 ×50(每次运行前重烧录 = MCU 复位 + RESET_ALL)**:
| 臂 | 各轮失败数 | 有失败的轮次 |
|---|---|---|
| A | 8, 22, 0, 0, 12, 0, 0, 39 | 4/8 |
| B | 0, 0, 0, 0, 0, 47 | 1/6 |
**污染控制与关键反证**:序列末恢复产品镜像后再跑 n30 ×20 = **16/20 失败**,
日志签名 = PASV 数据连接被芯片接受后立即关闭(`sock4 st=0x00/0x00`)+ 控制连接
TIMEOUT → 即 E8 记录的**残渣风暴态**,且**跨烧录存活**(重烧 + @test PASS 后依旧)
→ 与 E8"普通复位后仍 12%"一致。A 39/50 与 B 47/50 **两臂都出现**灾难轮
→ 该差异**不能归因于 TSL**,E12 的连接级对比**被此状态噪声吞没,无结论**。
**结论(诚实口径)**:
1. 现状驱动**确属规格偏离**(零 TSL 间隔,按"间隔"语义解读),但**未测得实际危害**:
460 轮/≈58MB 双向字节级校验仅 1 次未复现异常,且两臂无差异。
2. 依 Trap 25 铁律「不留未证明的复杂度」:**当前不建议改产品路径**,而是
**登记该偏离 + 本轮证据**;B 变体作为**待用补丁**保留(若将来出现芯片批次
差异、提高 SCK、或不明数据错位,可直接采用——它已证明无吞吐/正确性回退)。
3. **方法学教训(后续 SPI 时序实验必读)**:本台架的**芯片残渣风暴态**必须在
实验前后受控(冷启动断电重上电,或专用的"残渣清理协议");**MCU 覆写烧录
(复位 + RESET_ALL)不足以清除**——实测同一镜像在重烧后仍可 0/50..47/50 摆动,
跨臂污染使 A/B 失效。E8 起就存在的这条口径,本轮再次被独立复现。
+18
View File
@@ -214,12 +214,30 @@ CH395F 的 RECV 中断是电平触发的——只要接收缓冲区有数据就
### SPI 通信
- 每次 SPI 事务需调用 `ch395f_spi_begin()` / `ch395f_spi_end()` 包裹
- **大数据量收发使用 SPI2 DMA**:`ch395f_write_send_buf()` 和 `ch395f_read_recv_buf()` 已改造为 DMA 批量传输(DMA1_Stream3 RX, DMA1_Stream4 TX),命令和配置操作仍使用逐字节轮询
- ⚠ **TSL 规格偏离(N34 E12,2026-10-13 登记)**:手册 §7.4 要求缓存区命令在
"长度与数据之间"留 TSL(读 0.4us / 写 0.2us;其余字节间无需间隔;非缓存区命令
另有 TSC=0.6us / TSD=0.3us)。本仓两个缓存区函数把命令头与数据打包进**同一
DMA 突发**(CS 全程拉低)→ 该间隔为 **0**,属规格偏离;其余命令逐字节传输,
TSC/TSD 有大余量。SPI2 = 5.25MHz(分频 8 @42MHz,单字节 1.52us)。
**实测未发现危害**:460 轮 / ≈58MB 双向 SHA-256 全量比对仅 1 次未复现异常,
且"两段+TSL"变体(+28B,吞吐无回退)与现状无差异 → 依 Trap 25 铁律暂不改产品
路径,偏离与证据登记于此、变体留作待用补丁。完整 A/B 与方法学见
`docs/网络/N34-自愈审计/实验方案.md` E12。
- DMA 缓冲区:`s_spi2_dma_tx_buf/rx_buf[CH395F_SPI_DMA_BUF_SIZE]` = 接收缓冲 2048 + 4 = **2052**(`ch395f.h:148`),4 字节对齐;单次有效载荷上限 2048(历史值 1500 / 4100 均已作废,见 Trap 13 复核注)
### TCP 参数配置(必须在 INIT_CH395 之前)
- **重传参数**:`SET_RETRAN_COUNT`(默认12次,最大20)和 `SET_RETRAN_PERIOD`(默认500ms,最大1000ms),总重传时间 = 次数 × 周期
- **KeepAlive 参数必须为 500ms 的倍数**:`IDLE`(默认20000ms)、`INTVL`(默认15000ms),且 IDLE > INTVL;传入非 500 倍数的值导致 CH395F 内部定时器异常
- **KeepAlive 默认关闭**:需在 `SINT_STAT_CONNECT` 后调用 `ch395f_set_keepalive_enable(sock, 1)` 启用
- ⚠ **本仓现状与 E11 实测(2026-10-13)**:`ch395f_set_keepalive_enable` 全仓仅
客户端分支(`CONNECTING`)调用,而 `App/` 无任何 `net_connect()` → **产品上
KeepAlive 实际全程关闭**;服务器侧调用 2026-10-12 曾加后撤回(理由仅覆盖
物理链路失效)。E11 用"禁用 PC 网卡"注入"链路在、对端静默"(防火墙规则对
已建立连接无效,见实验方案 E11 方法学),实测服务器侧开启后:生产参数
60s/5s/3 → **判死 ≈30.0s**(早于手册公式 75s,以实测为准);短参数 6s/2s/2
→ ≈5.1s(证明参数生效);静默但存活的对端**不被误杀**(90s 空闲 KEEPLIVE
回显正常)。消费链路现成:TIM_OUT → `handle_timeout_event` QUARANTINE →
pump relisten → 端口恢复。完整记录见 `docs/网络/N34-自愈审计/实验方案.md` E11。
### Socket 4~7 缓冲区
- CH395F 默认只给 Socket 0~3 分配独立收发缓冲(共 48 块 × 512B 用满),Socket 4~7 为零块
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""E11:CH395F 服务器侧 KeepAlive —— "链路在、对端静默"判死验证(PC 侧注入)。
背景(N34 E11):手册 §5.56 规定 TCP 服务器应在 SINT_STAT_CONNECT 后开启
KeepAlive;本仓该调用 2026-10-12 被撤回,全仓仅剩客户端分支启用,而
App/ 无任何 net_connect() → 产品上 keepalive 实际关闭。本脚本用于验证
"开启后能否在有界时间内判死静默对端,且不误杀正常空闲会话"。
用法:
# T2 正常空闲存活:连接后静默 hold 秒,末尾发 KEEPLIVE 验证会话仍在
python e11_keepalive_probe.py --mode idle --hold-s 90
# T1 静默对端:连接后立即装 Windows 防火墙入站 DROP(仅本连接本地端口 +
# 设备 IP)→ 芯片 keepalive 探测得不到 ACK → 期望设备在
# IDLE + INTVL×CNT 内判死(本仓 60 + 5x3 = 75s)
python e11_keepalive_probe.py --mode block --hold-s 120
判据(设备侧事件由串口日志核对,本脚本打印 PC 侧时间线):
T1 期望:串口出现 `[ERR][NET] sockN TIMEOUT`(耗时≈75s),随后
`sockN pump/relisten` 恢复;本脚本移除规则后新连接必须成功。
T2 期望:hold 期间**无** TIMEOUT;末尾 KEEPLIVE 必须收到 0xBB 回显。
需要管理员权限(仅 block 模式装/卸防火墙规则;脚本自行清理)。
"""
import argparse
import socket
import subprocess
import sys
import time
TAG_C, TAG_D = 0xAA, 0xBB
CMD_KEEPLIVE = 0
RULE_NAME = "N34-E11-DROP"
def _crc(data: bytes) -> int:
c = 0
for b in data:
c ^= b
return c
def build_frame(cmd: int, data: bytes = b"") -> bytes:
n = len(data)
return bytes([TAG_C, cmd, (n >> 8) & 0xFF, n & 0xFF]) + data + bytes([_crc(data)])
def recv_frame(sock: socket.socket, timeout: float = 3.0):
sock.settimeout(timeout)
hdr = b""
while len(hdr) < 4:
chunk = sock.recv(4 - len(hdr))
if not chunk:
return None
hdr += chunk
tag, cmd, n = hdr[0], hdr[1], (hdr[2] << 8) | hdr[3]
body = b""
while len(body) < n + 1:
chunk = sock.recv(n + 1 - len(body))
if not chunk:
return None
body += chunk
data, crcb = body[:n], body[n]
return cmd, data, (tag == TAG_D) and (crcb == _crc(data))
def firewall(action: str, local_port: int, remote_ip: str) -> bool:
verb = "add" if action == "add" else "delete"
cmd = ["netsh", "advfirewall", "firewall", verb, "rule",
f"name={RULE_NAME}", "dir=in", "action=block", "protocol=TCP",
f"localport={local_port}", f"remoteip={remote_ip}"]
if verb == "delete":
cmd = ["netsh", "advfirewall", "firewall", "delete", "rule",
f"name={RULE_NAME}"]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f"[WARN] 防火墙 {verb} 失败:{r.stdout.strip()} {r.stderr.strip()}")
return False
return True
def connect(host: str, port: int, timeout: float = 5.0):
s = socket.create_connection((host, port), timeout)
return s
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="192.168.1.100")
ap.add_argument("--port", type=int, default=7003)
ap.add_argument("--mode", choices=["idle", "block"], required=True)
ap.add_argument("--hold-s", type=float, default=90.0)
ap.add_argument("--expect-detect-s", type=float, default=75.0,
help="判死上界(IDLE + INTVL*CNT),仅用于提示")
args = ap.parse_args()
t0 = time.time()
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')} +{time.time() - t0:6.1f}s] {msg}",
flush=True)
log(f"模式={args.mode} 目标={args.host}:{args.port} hold={args.hold_s}s")
log(f"(设备侧判死上界参考 = {args.expect_detect_s}s)")
try:
s = connect(args.host, args.port)
except OSError as e:
print(f"[FAIL] 连接失败:{e}")
return 2
s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 0) # PC 侧不参与
lport = s.getsockname()[1]
log(f"已连接(本地端口 {lport})→ 进入静默")
rc = 0
if args.mode == "block":
# 入站 DROP:设备发来的 keepalive 探测包在 PC 侧被丢弃 → 永不回 ACK
if not firewall("add", lport, args.host):
s.close()
return 2
log(f"已装防火墙入站 DROP(本地端口 {lport} <- {args.host})→ 对端静默开始")
try:
time.sleep(args.hold_s)
except KeyboardInterrupt:
pass
if args.mode == "idle":
# 存活探针:KEEPLIVE 必须被回显
log("静默结束 → 发 KEEPLIVE 验证会话存活")
try:
s.sendall(build_frame(CMD_KEEPLIVE))
r = recv_frame(s, 3.0)
except OSError as e:
r = None
log(f"KEEPLIVE 发送/接收异常:{e}")
if r and r[2]:
log("PASS:收到 0xBB KEEPLIVE 回显 → 正常空闲会话未被误杀")
else:
log(f"FAIL:未收到有效回显({r})→ 空闲置的会话被断开(误杀)")
rc = 1
s.close()
return rc
# block 模式:卸规则 → 验证旧连接已死 + 监听器已恢复
log("静默结束 → 卸载防火墙规则")
firewall("delete", lport, args.host)
time.sleep(1.0)
# 旧连接是否已被设备判死(应读到 EOF/RST;读不到也算进程侧正常)
try:
s.settimeout(2.0)
d = s.recv(16)
log(f"旧连接 recv → {d!r}(b'' = 已收到 FIN,即设备已判死并释放)")
except OSError as e:
log(f"旧连接 recv 异常(设备已判死/连接被拆):{e}")
s.close()
# 监听器恢复验证:新连接 + KEEPLIVE 回显
log("验证监听器恢复:新建连接 + KEEPLIVE")
ok = False
for attempt in range(1, 6):
try:
s2 = connect(args.host, args.port)
s2.sendall(build_frame(CMD_KEEPLIVE))
r = recv_frame(s2, 3.0)
s2.close()
if r and r[2]:
log(f"PASS:第 {attempt} 次新连接建立并收到回显 → 监听器已恢复")
ok = True
break
except OSError as e:
log(f"第 {attempt} 次新连接失败:{e}")
time.sleep(2.0)
if not ok:
log("FAIL:监听器未恢复(端口仍不接客)")
rc = 1
return rc
if __name__ == "__main__":
sys.exit(main())
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""E12:CH395F SPI 缓存区 TSL 时序 A/B 的字节级校验工作负载。
背景(N34 E12):手册 §7.4 规定读写收发缓存区命令需在"长度与数据之间"留
TSL(读 0.4us / 写 0.2us)。本仓 ch395f_write_send_buf / ch395f_read_recv_buf
把命令头与数据打包进**同一 DMA 突发**(零间隔)。本脚本用 SHA-256 逐轮校验
"上传→回读"是否字节一致,为 A/B 提供灵敏判据:
- STOR(PC→设备)走设备 read_recv_buf(TSL 读 0.4us)
- RETR(设备→PC)走设备 write_send_buf(TSL 写 0.2us)
任一方向出现比特错位都会让 SHA 不匹配。
用法:
python ftp_integrity_test.py --rounds 20 --size 65536
python ftp_integrity_test.py --rounds 20 --size 65536 --tag A # 结果标记
判据:全部轮次 SHA-256 一致 = PASS;出现不一致则打印首个差异偏移,
并自动二次回读以区分"存储副本错(写路径/上传)"与"回读瞬时错(读路径)"。
"""
import argparse
import hashlib
import io
import random
import sys
import time
from ftplib import FTP, error_perm
NAME = "e12a.bin" # 8.3 短名(FatFs FF_USE_LFN=0)
def sha(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def first_diff(a: bytes, b: bytes):
n = min(len(a), len(b))
for i in range(n):
if a[i] != b[i]:
return i, a[i], b[i]
if len(a) != len(b):
return n, None, None
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="192.168.1.100")
ap.add_argument("--port", type=int, default=21)
ap.add_argument("--user", default="admin")
ap.add_argument("--pwd", default="admin")
ap.add_argument("--rounds", type=int, default=20)
ap.add_argument("--size", type=int, default=65536, help="每轮载荷字节数")
ap.add_argument("--seed", type=int, default=20261013)
ap.add_argument("--tag", default="", help="结果标记(A/B 分组)")
args = ap.parse_args()
rng = random.Random(args.seed)
ftp = FTP()
ftp.connect(args.host, args.port, timeout=20)
ftp.login(args.user, args.pwd)
ftp.set_pasv(True)
print(f"[E12{args.tag}] 已登录 {args.host}:{args.port} "
f"rounds={args.rounds} size={args.size}B", flush=True)
bad = 0
t_all = time.time()
for i in range(1, args.rounds + 1):
payload = rng.randbytes(args.size)
want = sha(payload)
t0 = time.time()
ftp.storbinary(f"STOR {NAME}", io.BytesIO(payload))
got = bytearray()
ftp.retrbinary(f"RETR {NAME}", got.extend)
got = bytes(got)
dt = time.time() - t0
if got == payload:
print(f" round {i:2d}/{args.rounds} OK "
f"size={len(got)}B sha={want[:16]} {dt:.2f}s "
f"({args.size / 1024 / max(dt, 1e-3):.0f} KB/s)", flush=True)
continue
bad += 1
fd = first_diff(got, payload)
print(f" round {i:2d}/{args.rounds} MISMATCH len={len(got)} want={len(payload)} "
f"first_diff={fd}", flush=True)
# 归因:二次回读
got2 = bytearray()
try:
ftp.retrbinary(f"RETR {NAME}", got2.extend)
got2 = bytes(got2)
if got2 == payload:
print(" → 二次回读一致:判为回读瞬时错(read 路径)", flush=True)
else:
print(f" → 二次回读仍不一致(sha2={sha(got2)[:16]}):"
f"判为存储副本错(write/上传路径)", flush=True)
except error_perm as e:
print(f" → 二次回读失败:{e}", flush=True)
ftp.quit() if False else ftp.close()
total = time.time() - t_all
print(f"[E12{args.tag}] 汇总:{args.rounds - bad}/{args.rounds} 轮字节一致"
f"({bad} 轮不一致),总耗时 {total:.1f}s", flush=True)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())