From d96113bcc46aa9c6762b6d4fcab65587d233e056 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Tue, 13 Oct 2026 09:10:17 +0800 Subject: [PATCH] =?UTF-8?q?docs(net):=20N34=20E11/E12=20=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8E=E5=8F=B0=E6=9E=B6=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=88KeepAlive=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=BE=A7?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E3=80=81TSL=20=E8=A7=84=E6=A0=BC=E5=81=8F?= =?UTF-8?q?=E7=A6=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 比对, 建议纳入常备回归集)。 --- docs/网络/N34-自愈审计/实验方案.md | 105 +++++++++++++++ docs/问题记录/CH395F_Trap_Records.md | 18 +++ test/host/e11_keepalive_probe.py | 184 +++++++++++++++++++++++++++ test/host/ftp_integrity_test.py | 111 ++++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 test/host/e11_keepalive_probe.py create mode 100644 test/host/ftp_integrity_test.py diff --git a/docs/网络/N34-自愈审计/实验方案.md b/docs/网络/N34-自愈审计/实验方案.md index c3e27a4a..b7449d0b 100644 --- a/docs/网络/N34-自愈审计/实验方案.md +++ b/docs/网络/N34-自愈审计/实验方案.md @@ -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 起就存在的这条口径,本轮再次被独立复现。 diff --git a/docs/问题记录/CH395F_Trap_Records.md b/docs/问题记录/CH395F_Trap_Records.md index 476d0853..01dd04ae 100644 --- a/docs/问题记录/CH395F_Trap_Records.md +++ b/docs/问题记录/CH395F_Trap_Records.md @@ -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 为零块 diff --git a/test/host/e11_keepalive_probe.py b/test/host/e11_keepalive_probe.py new file mode 100644 index 00000000..6f27e384 --- /dev/null +++ b/test/host/e11_keepalive_probe.py @@ -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()) diff --git a/test/host/ftp_integrity_test.py b/test/host/ftp_integrity_test.py new file mode 100644 index 00000000..8e64ae56 --- /dev/null +++ b/test/host/ftp_integrity_test.py @@ -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())