From d1e6bb4ba73d05064119453f05b9a8bd1754e34a Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 18:50:24 +0800 Subject: [PATCH] =?UTF-8?q?W5=E6=B3=A2+N-19=E6=94=B6=E5=8F=A3=EF=BC=9AFTP?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E8=90=BD=E5=9C=B0=E3=80=81Trap25=E4=B8=89?= =?UTF-8?q?=E8=BF=9E=E5=AE=9A=E6=A1=88=E4=BF=AE=E5=A4=8D(N-30)=E3=80=81N-2?= =?UTF-8?q?0/N-23/N-26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N-19 FTP 鉴权: - lftpd_auth(USER/PASS 状态机 + 连续5次失败锁60s)+ SHA-256 (user||pwd) 口令哈希;config.ini [ftp] 段 user/pwd_hash/write, 出厂 admin/admin 在用有启动告警;lftpd_log.c 删除并入 lftpd_auth - ftp_cfg 配置读写;台架 ftp_auth_bench.py 13/13 N-30 / Trap 25(CH395F_Trap_Records.md 四次定案全记录): - 修复1 closed-with-data accept:客户端发完正常 FIN 后芯片按 FUN_PARA(0x09) §5.52② 保留 Socket+RX 缓冲,旧代码把 CLOSED 一律 relisten 自毁缓冲 → STOR 必挂 425;改为接受"已关但可读"连接排空 - 修复2 丢 CONNECT 中断探针 net_lost_connect_probe + last_peer_port 幽灵甄别(拆链窗口握手完成但中断丢失,双信源去抖补 accept) - 修复3 send_list/send_nlst 批量单发:背靠背 ~60B 小段毒化芯片 TX (芯片报 SEND_OK 但对端全丢),整份目录一次性写入 - 证伪存档:固定沉降/Socket 轮换冷却/listen 门控(台架 v7 矩阵) - 台架工具 test/board/ch395f_reuse_bench.{c,h} + host 脚本存档 (不注册编译;实验分支 n30-chip-bench 保留) N-20:Error_Handler 死循环降级(记录+复位路径) N-23:scripts/check_registration.py 双端注册反向校验(pre-commit) N-26:GD5F NAND 直 DMA 调用方缓冲窗口断言 验证:BUILD_OK 0/0;ftp_regression 连跑 4 轮 11/11(修复前 LIST#4/#7 100% 挂、STOR#2 曾 100% 425);ftp_auth_bench 13/13; @test 启动标记 PASS;ci_local L0+L0.5 全过;doc-links 通过。 登记簿 N-30 已销项。 --- .cubemx_survival_baseline.json | 8 +- .dsh/skills/lvgl-simulator/SKILL.md | 2 +- .pre-commit-config.yaml | 44 ++- AGENTS.md | 11 +- App/util/sha256.c | 253 ++++++++++++++ App/util/sha256.h | 79 +++++ Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c | 20 +- Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h | 41 +++ Drivers/BSP/GD5F2GQ5UE/nand_ftl.c | 18 + Drivers/BSP/NET/lftpd/lftpd.c | 141 +++++++- Drivers/BSP/NET/lftpd/lftpd.h | 5 + Drivers/BSP/NET/lftpd/lftpd_auth.c | 100 ++++++ Drivers/BSP/NET/lftpd/lftpd_auth.h | 73 ++++ Drivers/BSP/NET/lftpd/lftpd_log.c | 27 -- Drivers/BSP/NET/net_socket.c | 154 ++++++++- Drivers/BSP/NET/net_types.h | 10 + Drivers/BSP/NETCFG/ftp_cfg.c | 116 +++++++ Drivers/BSP/NETCFG/ftp_cfg.h | 68 ++++ Drivers/BSP/NETCFG/net_cfg.c | 4 + MDK-ARM/STM32F407-Demo.uvprojx | 15 + Src/main.c | 30 +- docs/AGENTS编写指南.md | 109 ------ docs/README.md | 9 +- docs/UI设计/UI_界面制作流程.md | 22 +- docs/产品需求/产品需求设计文档.md | 27 +- docs/代码规范/pre-commit使用说明.md | 41 ++- docs/内存管理/RAM使用分析与优化方案.md | 10 +- docs/内存管理/内存内容分配策略.md | 6 +- docs/内存管理/存储栈RAM优化调研.md | 4 +- docs/子系统设计/ADC采样节拍设计说明.md | 4 +- docs/子系统设计/看门狗设计说明.md | 2 +- docs/子系统设计/网络配置说明.md | 46 ++- docs/待处理内容.md | 111 +++--- docs/文档规范.md | 71 +++- docs/架构设计/STM32F4-Base存储架构说明.md | 17 +- docs/架构设计/项目架构总览.md | 4 +- docs/测试说明/CH395F_Test_Guide.md | 4 +- docs/测试说明/GD5F2GQ5UE_Test_Guide.md | 4 +- docs/测试说明/ST-LINK_CLI_复位使用说明.md | 12 +- docs/测试说明/m2_data/README.md | 4 +- docs/测试说明/串口助手使用指南.md | 2 +- docs/测试说明/实机测试实施指南.md | 6 +- docs/测试说明/模拟器差距清单.md | 4 +- docs/测试说明/测试开发标准流程.md | 2 +- docs/测试说明/看门狗单元测试说明.md | 2 +- docs/测试说明/自动化测试流程.md | 2 +- docs/测试说明/链路延迟测试说明.md | 24 +- docs/环境与部署/CI配置指南.md | 2 +- docs/硬件平台/ADC通道映射说明.md | 2 +- docs/硬件平台/V0.0_网表详细分析报告.md | 2 +- docs/硬件平台/V0.1_网表详细分析报告.md | 24 +- docs/算法/继电保护功能复现规划.md | 6 +- docs/网络/BSD_Socket_API_函数调用关系.md | 6 +- docs/问题记录/CH395F_Trap_Records.md | 104 +++++- docs/问题记录/错误码传播审计.md | 4 +- docs/问题记录/项目审查与优化建议报告.md | 5 +- scripts/README.md | 13 +- scripts/check_cubemx_survival.py | 16 + scripts/check_doc_links.py | 383 ++++++++++++++++++++- scripts/check_registration.py | 301 +++++++++++++++++ scripts/ram_report.py | 16 +- scripts/rename_ref.py | 395 ++++++++++++++++++++++ scripts/section_aliases.json | 6 + simulator/CMakeLists.txt | 1 + simulator/README.md | 2 +- test/board/ch395f_reuse_bench.c | 373 ++++++++++++++++++++ test/board/ch395f_reuse_bench.h | 13 + test/host/ch395f_reuse_host.py | 92 +++++ test/host/ftp_auth/ftp_auth_test.c | 203 +++++++++++ test/host/ftp_auth/run_unit_test.py | 50 +++ test/host/ftp_auth_bench.py | 173 ++++++++++ test/host/ftp_regression.py | 6 +- test/host/n30_repro.py | 74 ++++ test/host/sha256/run_unit_test.py | 48 +++ test/host/sha256/sha256_test.c | 192 +++++++++++ 75 files changed, 3928 insertions(+), 352 deletions(-) create mode 100644 App/util/sha256.c create mode 100644 App/util/sha256.h create mode 100644 Drivers/BSP/NET/lftpd/lftpd_auth.c create mode 100644 Drivers/BSP/NET/lftpd/lftpd_auth.h delete mode 100644 Drivers/BSP/NET/lftpd/lftpd_log.c create mode 100644 Drivers/BSP/NETCFG/ftp_cfg.c create mode 100644 Drivers/BSP/NETCFG/ftp_cfg.h delete mode 100644 docs/AGENTS编写指南.md create mode 100644 scripts/check_registration.py create mode 100644 scripts/rename_ref.py create mode 100644 scripts/section_aliases.json create mode 100644 test/board/ch395f_reuse_bench.c create mode 100644 test/board/ch395f_reuse_bench.h create mode 100644 test/host/ch395f_reuse_host.py create mode 100644 test/host/ftp_auth/ftp_auth_test.c create mode 100644 test/host/ftp_auth/run_unit_test.py create mode 100644 test/host/ftp_auth_bench.py create mode 100644 test/host/n30_repro.py create mode 100644 test/host/sha256/run_unit_test.py create mode 100644 test/host/sha256/sha256_test.c diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index d5bb76cf..993e1839 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -5,6 +5,7 @@ "fault-handlers-3": "f3afab8d2be18794", "fault-handler-enable": "be87fcd3a7926138", "hardfault-report-impl": "f3afab8d2be18794", + "error-handler-degrade": "d02a92afda945c4b", "stack-overflow-check": "d4735e3a265e16ee", "malloc-failed-hook": "6b86b273ff34fce1", "assert-not-deadloop": "d4a00718ab2bd071", @@ -21,7 +22,12 @@ "ioc-task-stacks": "3262f737d92dcbee", "adc1-hal-enabled": "1a8ed6cff34f7ba6", "adc1-calibration-call": "8d5f4b40fb218913", - "adc1-calibration-impl": "d33bc4323e243248" + "adc1-calibration-impl": "d33bc4323e243248", + "adc-tick-tim3-branch": "39c3548c37f6c4ae", + "ioc-adc-trig-t3trgo": "a9939de260ee46f8", + "ioc-adc-dma-continuous": "189126670d85d80a", + "adc1-dma-link": "397fce92dca3b728", + "adc1-dma-irq-priority": "beff08e91333229b" }, "_note": "CubeMX 存活审计基线;改动用 --write 重建" } diff --git a/.dsh/skills/lvgl-simulator/SKILL.md b/.dsh/skills/lvgl-simulator/SKILL.md index 08dfd725..f4cb2e9b 100644 --- a/.dsh/skills/lvgl-simulator/SKILL.md +++ b/.dsh/skills/lvgl-simulator/SKILL.md @@ -40,7 +40,7 @@ RemoDispBus 推流回归单独跑:`.venv\Scripts\python.exe simulator/tests/ch - **改 `simulator/lv_conf.h` 触发 `lv-conf-sync` 门禁**:它与 MCU 端 `Lib/lv_conf.h` 必须保持同一特性子集(按**生效值**比对,未定义取 `lv_conf_internal.h` 兜底);白名单与四类判定 → `docs/代码规范/pre-commit使用说明.md` §2.15 / §5.5 - **模拟器代码与固件同标准受检**(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` 全覆盖 `simulator/`);**改动触及 `sim/` 任何后端,必须同步在 `docs/测试说明/模拟器差距清单.md` 登记一行**——模拟器跑过 ≠ 真机跑过 -## 构建/运行报错速查(详解 simulator/README.md §4) +## 构建/运行报错速查(详解 `simulator/README.md`「四、已知坑(构建/运行)」) | 现象 | 根因 | |---|---| diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index faa9cf8e..eb050c72 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -154,9 +154,10 @@ repos: # 5) 文档一致性门禁(D-7) # 存在性:md 互链 / 反引号路径是否指向真实文件(防迁移/删除后残留死链) + # 章节:`§N.M` 必须能在目标文档的编号标题里找到(--sections-strict:自引用失效也拦) - id: doc-links name: 文档链接存在性 (D-3/D-7) - entry: python scripts/check_doc_links.py + entry: python scripts/check_doc_links.py --sections-strict language: system files: \.md$ # 外部资料(芯片手册转录)链接形态不受控;第三方目录整体排除 @@ -354,6 +355,29 @@ repos: files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7k) SHA-256 主机侧单元测试(2026-10-11 加,N-19 FTP 鉴权) + # sha256 是 FTP pwd_hash 校验的地基——哈希算错是"正确口令被拒 / + # 错误口令放行"的静默语义错误,板端只见 530/230 看不出根因; + # FIPS 180-4 标准向量(空串/abc/56B 补位跨界/1MB)+ hex_eq 非法输入 + # 在主机侧毫秒级钉死。零平台依赖,无桩。 + - id: sha256-unit-test + name: SHA-256 单测(主机侧) + entry: python test/host/sha256/run_unit_test.py + language: system + files: ^(App/util/sha256\.(c|h)|test/host/sha256/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 7l) FTP 鉴权状态机主机侧单元测试(2026-10-11 加,N-19) + # 锁定 60s/解锁/成功清零/tick 32 位回绕是"防口令爆破"契约核心, + # 板端每验一条要真等 60s、回绕要跑满 49.7 天;lftpd_auth 纯函数 + # 设计(tick 参数注入)就是为了让这些分支主机侧毫秒级穷举。 + - id: ftp-auth-unit-test + name: FTP 鉴权状态机单测(主机侧) + entry: python test/host/ftp_auth/run_unit_test.py + language: system + files: ^(Drivers/BSP/NET/lftpd/lftpd_auth\.(c|h)|App/util/sha256\.(c|h)|test/host/ftp_auth/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 @@ -367,3 +391,21 @@ repos: language: system files: ^test/board/test_config\.h$ pass_filenames: false + + # 9) 双端注册反向校验(2026-10-12 加,登记簿 N-23 / 审查报告 N-06 残余) + # 背景:新 .c 必须手动注册两处(`MDK-ARM/*.uvprojx` + `simulator/CMakeLists.txt`) + # 才会被编译;历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c` + # **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。 + # 本钩子反向核对两份注册表:①存在但未进 uvprojx ②存在但未进 CMakeLists + # (MCU-only 豁免见脚本 SIM_EXEMPT,显式清单+每行理由,不整目录一刀切) + # ③注册了但文件不存在(幽灵注册)。 + # 存量缺口(rs485_task/lftpd_log)已进脚本 KNOWN_GAPS 白名单 → WARN 不拦, + # 只对新缺口 FAIL,故钩子可立即上线不拦现有提交(`--strict` 供审计关白名单)。 + # ⚠ pre-commit 4.x 已移除 `always: true` → files 必须覆盖所有触发面: + # 任意 .c + uvprojx + CMakeLists.txt + 脚本自身(脚本自身改动也值得复跑)。 + - id: registration-check + name: 双端注册反向校验 (uvprojx + CMakeLists) + entry: python scripts/check_registration.py + language: system + files: (\.c$|\.uvprojx$|CMakeLists\.txt$|^scripts/check_registration\.py$) + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 849086e3..77c3b406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,8 @@ ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **26 个钩子**(逐个详解 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + 主机侧单测 10 个(`-unit-test` 后缀)+ `test-suite-switch` -- **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见使用说明 §5.5) +- 任一失败即拦截,共 **29 个钩子**(逐个详解 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + 主机侧单测 12 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23) +- **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见 `docs/代码规范/pre-commit使用说明.md` §5.5) - ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/` - 提交信息:标题 ≤100 字符、**正文每行 ≤80**(`.gitlint` + gitlint 默认)→ 长说明一律用**短标题 + `git commit -F ` 多行正文**(中文长标题极易超限,且 `-m` 会被终端代码页转码破坏) @@ -140,7 +140,7 @@ - 所有 `net_*` socket 操作经 `netMsgQueue` 在 netTask 串行执行;非 netTask 任务直接调 `net_*` API,勿直接碰 CH395F 驱动 - **`net_*` 公开 API 的错误码经"任务通知值"回传**:`net_msg_t` 是**按值拷入队列**的,netTask 改的是自己那份副本,调用方永远看不到 → 错误码搭 `xTaskNotify()` 通知值回来。**勿给 `net_msg_t` 加"回传用"字段**(调用方只会读到栈垃圾) - **单连接 socket 的断开语义**:EOF 时 net 层已 `auto_relisten`,应用层**不得**在 EOF 时 `net_close` 该 fd(会连监听一起毁掉 → 端口"连接被拒");仅当数据 fd ≠ 监听 fd(模拟器多连接后端)时才关 -- 网口配置:`/config.ini` [network] 段(工厂默认 192.168.1.100/24、gw .1;MAC 全零 = 芯片内置;UI 保存立即生效;**旧档 `/cfg/netcfg.bin` 迁移已于 2026-10-10 删除**,旧档存在则被忽略)→ `docs/子系统设计/网络配置说明.md` +- 网口配置:`/config.ini` [network] 段(工厂默认 192.168.1.100/24、gw .1;MAC 全零 = 芯片内置;UI 保存立即生效;**旧档 `/cfg/netcfg.bin` 迁移已于 2026-10-10 删除**,旧档存在则被忽略);同文件 **[ftp] 段 = FTP 鉴权**(`user` / `pwd_hash` = SHA-256(user‖password) / `write`,出厂默认 admin/admin,默认口令在用有启动告警;连续 5 次失败锁 60 s)→ `docs/子系统设计/网络配置说明.md` - **新 .c 文件必须手动注册到两处**才能双端编译:`MDK-ARM/STM32F407-Demo.uvprojx` + `simulator/CMakeLists.txt`(用 `new-source-file` skill);⚠ **新文件落在还没有的新目录,还要把该目录加进 `uvprojx` 的 ``**——否则报错落在**消费者 TU** 上(`cannot open source input file`),极易误判 - 测试构建:`test/board/test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` **互斥**(一次只能一个,`#error`);全部注释 = 产品构建,**受 `test-suite-switch` 钩子门禁**;`ENABLE_DESTRUCTIVE_TESTS` **默认关闭**(关闭时套件只读模式,可随时在在用设备上跑)——⚠ 跑一轮破坏性 GD5F 即让 dhara 多记一个坏块且该计数持久化,普通恢复清不掉 → `docs/测试说明/存储维护与容量恢复.md`;板级套件运行用 **`board-test-suite` skill** - **改驱动前先读对应 `docs/问题记录/*_Trap_Records.md`**(CH395F / GD5F2GQ5UE / SD2506);各模块测试指南见 `docs/测试说明/` @@ -150,7 +150,8 @@ - **登记簿类文档「完成即销项」**:`docs/待处理内容.md` 这类待办登记簿,事项修复/关闭后**直接从表中删除该行**(不保留"已消化"行)——结论与证据应已沉淀进对应模块文档/问题记录。销项时必须:① 先核对目标文档确已含完整记录 ② 同步清理其他文档对该编号的引用 ③ 改完跑 `check_doc_links.py --all` + pre-commit - 图形走 **`draw-diagram` skill**:精确布局图用 `tools/arch_diagram/gen_*.py` 生成器(产物放 `docs/images/`,脚本与 .svg 一并提交);拓扑图用 Mermaid;**勿手绘 ASCII 框图**。文件引用关系图走 `tools/refgraph`(`& 'tools\refgraph\gen_refgraph.bat'`) -- 文档写作规范(编码/BOM/链接/表格/一致性钩子)→ `docs/文档规范.md`;**改本文件前先读** `docs/AGENTS编写指南.md`(入口文件的取舍与维护纪律);全量索引与推荐阅读顺序 → `docs/README.md` +- **改名 / 挪目录一律走 `scripts/rename_ref.py`(先 dry-run,确认后 `--apply`)**:`git mv` + 自动改写全库引用(反引号引用 / Markdown 链接 / 图片 `../` 层级换算 / `#include` / `uvprojx` `` / `CMakeLists.txt`),落盘后自动跑 `check_doc_links --all` 复核——手工扫引用既慢又漏(一次改名历史上波及 10~30 文件)。引用章节用「`路径.md` §N.M」**紧邻写法**(`doc-links` 校验编号是否还在:紧邻/裸名/前缀/别名/自引用**全部拦提交**(钩子带 `--sections-strict`);每次跑打印「可判定比例」,`--section-todo` 出待改清单,昵称歧义就登记 `scripts/section_aliases.json` 或写清楚)→ `docs/文档规范.md` §3.5 / §3.6 +- 文档写作规范(编码/BOM/链接/表格/一致性钩子)→ `docs/文档规范.md`;全量索引与推荐阅读顺序 → `docs/README.md` ## 边界(Ask first / Never) @@ -169,7 +170,7 @@ | ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` | | 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` | | RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` | -| pre-commit 26 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | +| pre-commit 29 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | | 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` | | 自动化测试 / 实机测试 / 板级套件 | `docs/测试说明/自动化测试流程.md`、`实机测试实施指南.md`、`board-test-suite` skill | | 烧录与复位语义 | `docs/测试说明/ST-LINK_CLI_复位使用说明.md` | diff --git a/App/util/sha256.c b/App/util/sha256.c new file mode 100644 index 00000000..72c0c86d --- /dev/null +++ b/App/util/sha256.c @@ -0,0 +1,253 @@ +/* + * 模块名称:sha256 + * 模块功能:SHA-256 安全散列(FIPS 180-4)实现——纯标准库、零堆分配 + * 适用平台:STM32F407ZGTx / PC 主机侧单测(双端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;标准向量由 + * test/host/sha256 主机单测钉死) + */ + +/* 头文件包含区 */ +#include + +#include "sha256.h" + +/* 私有宏定义区 */ +#define SHA256_ROTR32(x, n) (((x) >> (n)) | ((x) << (32u - (n)))) +#define SHA256_CH(x, y, z) (((x) & (y)) ^ ((~(x)) & (z))) +#define SHA256_MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) +#define SHA256_EP0(x) (SHA256_ROTR32(x, 2u) ^ SHA256_ROTR32(x, 13u) ^ SHA256_ROTR32(x, 22u)) +#define SHA256_EP1(x) (SHA256_ROTR32(x, 6u) ^ SHA256_ROTR32(x, 11u) ^ SHA256_ROTR32(x, 25u)) +#define SHA256_SIG0(x) (SHA256_ROTR32(x, 7u) ^ SHA256_ROTR32(x, 18u) ^ ((x) >> 3u)) +#define SHA256_SIG1(x) (SHA256_ROTR32(x, 17u) ^ SHA256_ROTR32(x, 19u) ^ ((x) >> 10u)) + +/* 私有变量定义区 */ +/* 轮常量(FIPS 180-4 §4.2.2.2:前 64 个素数立方根小数部分前 32 位) */ +static const uint32_t s_k[64] = { + 0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, + 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u, + 0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u, + 0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u, + 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, + 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau, + 0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, + 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u, + 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u, + 0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u, + 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, + 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u, + 0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, + 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u, + 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u, + 0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u, +}; + +/* 函数定义区 */ + +/* + * 函数功能:处理一个 64 B 分组(消息扩展 + 64 轮压缩) + * 入口参数:p_ctx - 上下文 p_block - 64 B 分组(大端字序) + * 返回值:无 + * 限定条件:p_block 恰为 64 B 有效数据 + * 函数说明:w[64] 为栈上消息扩展数组(256 B,ftpTask 4 KB 栈可容纳) + */ +static void s_transform(sha256_ctx_t *p_ctx, const uint8_t *p_block) { + uint32_t w[64]; + uint32_t a; + uint32_t b; + uint32_t c; + uint32_t d; + uint32_t e; + uint32_t f; + uint32_t g; + uint32_t h; + + for (uint32_t i = 0u; i < 16u; i++) { + w[i] = ((uint32_t)p_block[4u * i] << 24) | + ((uint32_t)p_block[4u * i + 1u] << 16) | + ((uint32_t)p_block[4u * i + 2u] << 8) | + (uint32_t)p_block[4u * i + 3u]; + } + for (uint32_t i = 16u; i < 64u; i++) { + w[i] = SHA256_SIG1(w[i - 2u]) + w[i - 7u] + + SHA256_SIG0(w[i - 15u]) + w[i - 16u]; + } + + a = p_ctx->state[0]; + b = p_ctx->state[1]; + c = p_ctx->state[2]; + d = p_ctx->state[3]; + e = p_ctx->state[4]; + f = p_ctx->state[5]; + g = p_ctx->state[6]; + h = p_ctx->state[7]; + + for (uint32_t i = 0u; i < 64u; i++) { + uint32_t t1 = h + SHA256_EP1(e) + SHA256_CH(e, f, g) + s_k[i] + w[i]; + uint32_t t2 = SHA256_EP0(a) + SHA256_MAJ(a, b, c); + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + + p_ctx->state[0] += a; + p_ctx->state[1] += b; + p_ctx->state[2] += c; + p_ctx->state[3] += d; + p_ctx->state[4] += e; + p_ctx->state[5] += f; + p_ctx->state[6] += g; + p_ctx->state[7] += h; +} + +/* + * 函数功能:初始化上下文(清零 + 置初始链接变量,FIPS 180-4 §5.3.3) + * 入口参数:p_ctx - 上下文 + * 返回值:无 + * 限定条件:p_ctx 为 NULL 时静默返回(fail-safe) + */ +void sha256_init(sha256_ctx_t *p_ctx) { + if (p_ctx == NULL) { + return; + } + (void)memset(p_ctx, 0, sizeof(*p_ctx)); + /* 初始链接变量(FIPS 180-4 §5.3.3:前 8 个素数平方根小数部分前 32 位) */ + p_ctx->state[0] = 0x6a09e667u; + p_ctx->state[1] = 0xbb67ae85u; + p_ctx->state[2] = 0x3c6ef372u; + p_ctx->state[3] = 0xa54ff53au; + p_ctx->state[4] = 0x510e527fu; + p_ctx->state[5] = 0x9b05688cu; + p_ctx->state[6] = 0x1f83d9abu; + p_ctx->state[7] = 0x5be0cd19u; +} + +/* + * 函数功能:流式喂入数据(任意长度,内部按 64 B 分组压缩) + * 入口参数:p_ctx - 上下文 p_data - 数据 len - 字节数 + * 返回值:无 + * 限定条件:p_ctx/p_data 为 NULL 时静默返回 + */ +void sha256_update(sha256_ctx_t *p_ctx, const void *p_data, size_t len) { + const uint8_t *p = (const uint8_t *)p_data; + + if ((p_ctx == NULL) || (p == NULL)) { + return; + } + for (size_t i = 0u; i < len; i++) { + p_ctx->buf[p_ctx->buf_len] = p[i]; + p_ctx->buf_len++; + if (p_ctx->buf_len >= SHA256_BLOCK_SIZE) { + s_transform(p_ctx, p_ctx->buf); + p_ctx->bitlen += (uint64_t)SHA256_BLOCK_SIZE * 8u; + p_ctx->buf_len = 0u; + } + } +} + +/* + * 函数功能:收尾输出 32 B 摘要(补位 + 长度字段 + 末次压缩) + * 入口参数:p_ctx - 上下文 p_out - 32 B 摘要输出 + * 返回值:无 + * 限定条件:p_ctx/p_out 为 NULL 时静默返回;收尾后上下文清零(防敏感残留) + */ +void sha256_final(sha256_ctx_t *p_ctx, uint8_t p_out[SHA256_DIGEST_SIZE]) { + uint64_t bitlen; + uint8_t pad = 0x80u; + uint8_t zero = 0x00u; + uint8_t len_be[8]; + + if ((p_ctx == NULL) || (p_out == NULL)) { + return; + } + /* 总长 = 完整分组累计 + 暂存区(必须在补位前定稿) */ + bitlen = p_ctx->bitlen + (uint64_t)p_ctx->buf_len * 8u; + + (void)sha256_update(p_ctx, &pad, 1u); + while (p_ctx->buf_len != 56u) { + (void)sha256_update(p_ctx, &zero, 1u); + } + for (uint32_t i = 0u; i < 8u; i++) { + len_be[i] = (uint8_t)((bitlen >> (56u - 8u * i)) & 0xFFu); + } + (void)sha256_update(p_ctx, len_be, 8u); + + for (uint32_t i = 0u; i < 8u; i++) { + p_out[4u * i] = (uint8_t)(p_ctx->state[i] >> 24); + p_out[4u * i + 1u] = (uint8_t)(p_ctx->state[i] >> 16); + p_out[4u * i + 2u] = (uint8_t)(p_ctx->state[i] >> 8); + p_out[4u * i + 3u] = (uint8_t)(p_ctx->state[i]); + } + (void)memset(p_ctx, 0, sizeof(*p_ctx)); +} + +/* + * 函数功能:一次性计算 32 B 摘要(init+update+final 便捷封装) + * 入口参数:p_data - 数据 len - 字节数 p_out - 摘要输出 + * 返回值:无 + * 限定条件:栈上上下文 ≈108 B;NULL 参数由子函数兜底 + */ +void sha256_calc(const void *p_data, size_t len, uint8_t p_out[SHA256_DIGEST_SIZE]) { + sha256_ctx_t ctx; + + sha256_init(&ctx); + sha256_update(&ctx, p_data, len); + sha256_final(&ctx, p_out); +} + +/* + * 函数功能:hex 字符 → 半字节值 + * 入口参数:c - 字符 p_out - 输出 0~15 + * 返回值:0 - 合法;-1 - 非法 int + * 限定条件:p_out 非空 + */ +static int s_hex_nibble(char c, uint8_t *p_out) { + uint8_t v; + + if ((c >= '0') && (c <= '9')) { + v = (uint8_t)(c - '0'); + } else if ((c >= 'a') && (c <= 'f')) { + v = (uint8_t)(c - 'a') + 10u; + } else if ((c >= 'A') && (c <= 'F')) { + v = (uint8_t)(c - 'A') + 10u; + } else { + return -1; + } + *p_out = v; + return 0; +} + +/* + * 函数功能:摘要与 64 位 hex 串恒定时间比较(大小写不敏感) + * 入口参数:p_digest - 32 B 摘要 p_hex - 64 字符 hex 串 + * 返回值:1 - 相等;0 - 不等 / 长度非 64 / 非法 hex / NULL + * 限定条件:比较段不短路(XOR 累加器);hex 非法属配置错误,早退不构成口令侧信道 + */ +int sha256_hex_eq(const uint8_t p_digest[SHA256_DIGEST_SIZE], const char *p_hex) { + uint8_t acc = 0u; + uint8_t hi; + uint8_t lo; + + if ((p_digest == NULL) || (p_hex == NULL)) { + return 0; + } + if (strlen(p_hex) != (SHA256_DIGEST_SIZE * 2u)) { + return 0; + } + for (uint32_t i = 0u; i < SHA256_DIGEST_SIZE; i++) { + if ((s_hex_nibble(p_hex[2u * i], &hi) != 0) || + (s_hex_nibble(p_hex[2u * i + 1u], &lo) != 0)) { + /* hex 非法 = 配置错误(可信来源),早退不构成口令侧信道 */ + return 0; + } + acc |= (uint8_t)(p_digest[i] ^ (uint8_t)((hi << 4) | lo)); + } + return (acc == 0u) ? 1 : 0; +} diff --git a/App/util/sha256.h b/App/util/sha256.h new file mode 100644 index 00000000..dacc7025 --- /dev/null +++ b/App/util/sha256.h @@ -0,0 +1,79 @@ +/* + * 模块名称:sha256 + * 模块功能:SHA-256 安全散列(FIPS 180-4)——流式 + 一次性接口,外加摘要与 + * 64 位 hex 串的恒定时间比较;纯标准库、零堆分配,MCU 与主机侧 + * 单测共用(无平台依赖) + * 适用平台:STM32F407ZGTx / PC 主机侧单测(双端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权:[ftp] pwd_hash 校验) + */ + +#ifndef SHA256_H +#define SHA256_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#define SHA256_DIGEST_SIZE 32u /* 摘要字节数 */ +#define SHA256_BLOCK_SIZE 64u /* 分组字节数 */ + +/* 散列上下文(≈108 B,调用方栈上或静态均可) */ +typedef struct { + uint32_t state[8]; /* 链接变量 */ + uint64_t bitlen; /* 已处理的完整分组累计比特数 */ + uint8_t buf[SHA256_BLOCK_SIZE];/* 未满分组的暂存区 */ + uint32_t buf_len; /* 暂存区已用字节数 */ +} sha256_ctx_t; + +/* + * 函数功能:初始化散列上下文 + * 入口参数:p_ctx - 上下文(不可为 NULL) sha256_ctx_t * + * 返回值:无 + * 限定条件:无 + */ +void sha256_init(sha256_ctx_t *p_ctx); + +/* + * 函数功能:喂入一段消息(可多次调用,任意切分) + * 入口参数:p_ctx - 上下文 p_data - 数据 len - 字节数 + * 返回值:无 + * 限定条件:p_ctx 已 init;p_data 为 NULL 或 len=0 时空操作 + */ +void sha256_update(sha256_ctx_t *p_ctx, const void *p_data, size_t len); + +/* + * 函数功能:补位收尾并输出 32 B 摘要(调用后上下文被清零,不可续用) + * 入口参数:p_ctx - 上下文 p_out - 32 B 输出缓冲(不可为 NULL) + * 返回值:无 + * 限定条件:p_ctx 已 init + */ +void sha256_final(sha256_ctx_t *p_ctx, uint8_t p_out[SHA256_DIGEST_SIZE]); + +/* + * 函数功能:一次性计算整段消息的 SHA-256 + * 入口参数:p_data - 数据 len - 字节数 p_out - 32 B 输出缓冲 + * 返回值:无 + * 限定条件:p_data/p_out 非空 + */ +void sha256_calc(const void *p_data, size_t len, uint8_t p_out[SHA256_DIGEST_SIZE]); + +/* + * 函数功能:恒定时间比较 32 B 摘要与 64 字符 hex 串(大小写不敏感) + * 入口参数:p_digest - 32 B 摘要 p_hex - 64 字符 hex 串(可 NULL) + * 返回值:1 - 相等;0 - 不等 / hex 非法 / 参数为空 int + * 函数说明:hex 来自配置文件(可信来源),格式非法早退不构成侧信道; + * 摘要与 hex 内容的比较逐字节累积异或,耗时只随长度(固定 64)变化 + */ +int sha256_hex_eq(const uint8_t p_digest[SHA256_DIGEST_SIZE], const char *p_hex); + +#ifdef __cplusplus +} +#endif + +#endif /* SHA256_H */ diff --git a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c index 5cc50fdd..cc2752b7 100644 --- a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c +++ b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c @@ -56,14 +56,13 @@ volatile uint8_t g_spi1_dma_done; * 否则静默失败(CCM 首次扩容实验实证的根因)。页级大块传输(2048)仍走 DMA, * 源/目的全部为文件作用域静态缓冲。 */ -/* DMA 可达内存区间:必须与分散加载文件 RW_IRAM1(0x20000000,128KB, - * SRAM1+SRAM2)一致。CCM(0x10000000,64KB)DMA1/DMA2 物理不可达, - * RW_CCM 区任何缓冲都不得进 DMA。阈值只能挡住"当前已知的小栈缓冲", - * 挡不住"将来新加的 >1024B 栈缓冲";gd5f_dma_buf_ok() 把这条约定 - * 升级为运行时强制检查(2026-09-21 加固)。若将来新增其它 DMA 可达 - * 内存(如外部 SDRAM),此处区间必须同步扩展。 */ -#define GD5F_DMA_RAM_BASE 0x20000000UL -#define GD5F_DMA_RAM_END 0x20020000UL +/* DMA 可达内存窗口 GD5F_DMA_RAM_BASE/END 与 CCM 窗口常量已上移到 gd5f2gq5ue.h + * (N-26,2026-10-12:nand_ftl.c 的 init 期自检也要用),并带编译期守卫 + * (白名单窗口与 CCM 永不相交、阈值契约)。此处只留语义提醒: + * 窗口必须与分散加载 RW_IRAM1(0x20000000,128KB,SRAM1+SRAM2)一致; + * CCM(0x10000000,64KB)DMA1/DMA2 物理不可达,RW_CCM 区任何缓冲都不得进 DMA。 + * 阈值只能挡住"当前已知的小栈缓冲",挡不住"将来新加的 >1024B 栈缓冲"; + * gd5f_dma_buf_ok() 把这条约定升级为运行时强制检查(2026-09-21 加固)。 */ /* * 函数功能:SPI1 TX DMA 完成回调(PROGRAM_LOAD 数据发送用) @@ -104,8 +103,7 @@ static int gd5f_private_bbt_find_best(void); static int gd5f_bbt_load(void); static int gd5f_bbt_save(void); -/* DMA 可达性守卫(见下方实现) */ -static int gd5f_dma_buf_ok(const void *p_buf, size_t size); +/* DMA 可达性守卫:实现见下方(N-26 起为公开 API,声明在 gd5f2gq5ue.h) */ /* ======================== DMA 可达性守卫 ======================== */ @@ -120,7 +118,7 @@ static int gd5f_dma_buf_ok(const void *p_buf, size_t size); * 本函数把"栈/堆缓冲禁入 DMA"的约定升级为运行时强制约束: * 起止地址任一越出 SRAM 区间即判不可达。 */ -static int gd5f_dma_buf_ok(const void *p_buf, size_t size) { +int gd5f_dma_buf_ok(const void *p_buf, size_t size) { uint32_t start = (uint32_t)(uintptr_t)p_buf; uint32_t end; diff --git a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h index b3749346..828566c4 100644 --- a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h +++ b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h @@ -77,6 +77,35 @@ extern "C" { */ #define GD5F_DMA_THRESHOLD 1024 +/* ==================== DMA 可达内存窗口(N-26,2026-10-12) ==================== + * DMA1/DMA2 物理不可达 CCM(0x10000000~0x1000FFFF):以 CCM 缓冲做 DMA 源/目的 + * 会静默不落地(2026-09-20 首轮 CCM 扩容实验实证根因;口径见 + * docs/内存管理/内存内容分配策略.md §2 与 §4 规则 R2/R3)。 + * 白名单窗口必须与分散加载 RW_IRAM1(0x20000000,128KB,SRAM1+SRAM2)一致 + * (MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct);将来新增其它 DMA 可达内存 + * (如外部 SDRAM)须同步扩展本窗口。 + * ⚠ 缓冲**自身地址**无法编译期断言:.bss 地址链接期才确定,armcc 的常量表达式 + * 取不到(__builtin 系在 MicroLIB 下亦不可用,见 Src/main.c Error_Handler 注释)。 + * 静态缓冲的落位由 scatter(RW_CCM 只列 heap_4.o)+ nand_ftl.c 的 init 期自检 + * (gd5f_dma_buf_ok)双层保证;动态传入缓冲由每次传输的守卫强制。 */ +#define GD5F_DMA_RAM_BASE 0x20000000UL +#define GD5F_DMA_RAM_END 0x20020000UL +#define GD5F_CCM_RAM_BASE 0x10000000UL +#define GD5F_CCM_RAM_END 0x10010000UL + +/* 编译期守卫(N-26):AC5 无 _Static_assert(C11 不支持),用项目惯用的 + * 负数组 typedef(同 App/cfg/cfg_file.c、Drivers/BSP/UI/ui_menu_tab.h)—— + * 条件不成立则数组尺寸 -1 → 编译错。 + * ① DMA 白名单窗口与 CCM 窗口**永不得重叠**(有人改上面常量时立即爆炸); + * ② 阈值契约(见 GD5F_DMA_THRESHOLD 处注释):≥ 存储路径最大栈缓冲 + * (dhara meta 132 B = DHARA_META_SIZE 上限)且 < 页大小(页级传输保留 DMA)。 */ +typedef char gd5f_dma_window_no_ccm_check_t[ + ((GD5F_DMA_RAM_END <= GD5F_CCM_RAM_BASE) || (GD5F_DMA_RAM_BASE >= GD5F_CCM_RAM_END)) + ? 1 : -1]; +typedef char gd5f_dma_threshold_contract_check_t[ + ((GD5F_DMA_THRESHOLD >= 132U) && (GD5F_DMA_THRESHOLD < GD5F_PAGE_SIZE)) + ? 1 : -1]; + /* 坏块表持久化:保留块池数量(从 NAND 顶部向下挑选出厂好块存储 BBT) */ #define GD5F_BBT_POOL_COUNT 4 @@ -253,6 +282,18 @@ int32_t gd5f_program_exec(uint32_t page_addr); int32_t gd5f_block_erase(uint32_t block_addr); int32_t gd5f_check_ecc(void); +/* + * 函数功能:判定缓冲整段是否位于 DMA 可达内存(GD5F_DMA_RAM_* 白名单窗口) + * 入口参数:p_buf - 传输缓冲首地址 const void* + * size - 传输字节数 size_t + * 返回值:1 - 整段 DMA 可达;0 - 不可达(含 size=0 / 地址回绕 / 越窗) + * 限定条件:无 + * 函数说明:N-26(2026-10-12)由 gd5f2gq5ue.c 私有守卫升级为公开 API—— + * 驱动内部每次 >阈值 传输前用它(不可达即回落 CPU 轮询), + * FTL 层(nand_ftl.c)在 init 期用它自检静态页缓冲的落位。 + */ +int gd5f_dma_buf_ok(const void *p_buf, size_t size); + /* * 函数功能:NAND 长耗时操作看门狗心跳弱钩子 * 入口参数:无 diff --git a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c index 3fd4850b..a217052a 100644 --- a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c +++ b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c @@ -228,6 +228,24 @@ DSTATUS disk_initialize(BYTE pdrv) { DBG_INFO("Initializing NAND FTL..."); + /* N-26 启动期地址自检(2026-10-12):本文件三个页缓冲是 SPI1 DMA 路径上 + * 唯一的**静态**缓冲(dhara_map_read/write → dhara_nand_read/prog/copy, + * 2048 B > GD5F_DMA_THRESHOLD;s_page_buf 现仅装 dhara meta 132 B 走轮询, + * 但它同为页尺寸且交给 dhara 内部使用,一并守住防将来越窗)。 + * DMA1/DMA2 不可达 CCM——若 scatter 改动/段放置把它们挪进 CCM,DMA 会 + * **静默不落地**(数据错而传输"成功")。缓冲地址链接期才确定,armcc + * 无法编译期断言(见 gd5f2gq5ue.h 的窗口常量注释),故在 init 期自检: + * 任一缓冲越出 DMA 白名单窗口 → 上报并拒绝挂载(存储降级、不挂死, + * 与 E-31 的"保留卷、上报自检"形态一致)。每次传输的运行期守卫 + * (gd5f_read_from_cache/gd5f_program_load 内)保留作第二层兜底。 */ + if ((gd5f_dma_buf_ok(s_cache_buf, FTL_PAGE_SIZE) == 0) || + (gd5f_dma_buf_ok(s_copy_buf, FTL_PAGE_SIZE) == 0) || + (gd5f_dma_buf_ok(s_page_buf, FTL_PAGE_SIZE) == 0)) { + DBG_ERROR("FTL page buffer not DMA-reachable (cache=0x%08lX), " + "storage refused", (unsigned long)(uintptr_t)s_cache_buf); + return STA_NOINIT; + } + s_nand.log2_page_size = 11; s_nand.log2_ppb = 6; /* dhara 可见块数 = 总块数 - 保留块池(BBT 存储区不交给 dhara,避免被擦写) */ diff --git a/Drivers/BSP/NET/lftpd/lftpd.c b/Drivers/BSP/NET/lftpd/lftpd.c index d2f404d2..25765ddc 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.c +++ b/Drivers/BSP/NET/lftpd/lftpd.c @@ -16,6 +16,8 @@ #include "private/lftpd_io.h" #include "net_socket.h" #include "net_config.h" +#include "lftpd_auth.h" +#include "ftp_cfg.h" #include "ff.h" typedef struct { @@ -62,6 +64,10 @@ static command_t commands[] = { { NULL, NULL }, }; +/* N-19 FTP 鉴权:全局失败计数/锁定状态(跨会话持久——单连接服务器,全局 + * 计数即可;成功清零、锁定期满自动放行,见 lftpd_auth.c) */ +static ftp_auth_state_t s_auth_state; + static int send_response(int socket, int code, bool include_code, bool multiline_start, const char *format, ...) { @@ -100,8 +106,8 @@ static int send_response(int socket, int code, bool include_code, static int send_list(int socket, const char *path) { - static const char *directory_format = "drw-rw-rw- 1 owner group %13lu Jan 01 1970 %s"; - static const char *file_format = "-rw-rw-rw- 1 owner group %13lu Jan 01 1970 %s"; + static const char *directory_format = "drw-rw-rw- 1 owner group %13lu Jan 01 1970 %s\r\n"; + static const char *file_format = "-rw-rw-rw- 1 owner group %13lu Jan 01 1970 %s\r\n"; void *dp = lftpd_io_opendir(path); if (dp == NULL) { @@ -112,17 +118,39 @@ static int send_list(int socket, const char *path) uint32_t size; int is_dir; + /* N30-PROBE(临时实验):整份目录一次性发送(验证"背靠背多小段 + * 毒化芯片 TX"假设——每行一个 net_send 时 LIST#4 必挂) */ + static char batch[1536]; /* static: off-stack */ + int blen = 0; + int trunc = 0; + while (lftpd_io_readdir(dp, name, sizeof(name), &size, &is_dir) == 0) { + int n; if (is_dir) { - send_multiline_response_line(socket, directory_format, - (unsigned long)size, name); + n = snprintf(batch + blen, sizeof(batch) - blen, directory_format, + (unsigned long)size, name); } else { - send_multiline_response_line(socket, file_format, - (unsigned long)size, name); + n = snprintf(batch + blen, sizeof(batch) - blen, file_format, + (unsigned long)size, name); } + if (n < 0 || (size_t)n >= sizeof(batch) - blen) { + trunc = 1; + break; + } + blen += n; } lftpd_io_closedir(dp); + + /* lftpd_inet_write 成功返回字节数、失败返回 -1(非 0=成功语义) */ + if (blen > 0) { + if (lftpd_inet_write(socket, batch, blen) < 0) { + return -1; + } + } + if (trunc) { + return -1; + } return 0; } @@ -137,13 +165,33 @@ static int send_nlst(int socket, const char *path) uint32_t size; int is_dir; + /* N30-PROBE(临时实验):同 send_list,一次性批量发送 */ + static char batch[1536]; /* static: off-stack */ + int blen = 0; + int trunc = 0; + while (lftpd_io_readdir(dp, name, sizeof(name), &size, &is_dir) == 0) { if (!is_dir) { - send_multiline_response_line(socket, name); + int n = snprintf(batch + blen, sizeof(batch) - blen, "%s\r\n", name); + if (n < 0 || (size_t)n >= sizeof(batch) - blen) { + trunc = 1; + break; + } + blen += n; } } lftpd_io_closedir(dp); + + /* lftpd_inet_write 成功返回字节数、失败返回 -1(非 0=成功语义) */ + if (blen > 0) { + if (lftpd_inet_write(socket, batch, blen) < 0) { + return -1; + } + } + if (trunc) { + return -1; + } return 0; } @@ -241,6 +289,14 @@ static int cmd_cwd(lftpd_client_t *client, const char *arg) static int cmd_dele(lftpd_client_t *client, const char *arg) { + /* N-19:写命令门——[ftp] write=0 时拒绝(鉴权门已保证此处必为已登录态) */ + const ftp_cfg_t *cfg = ftp_cfg_get(); + + if ((cfg == NULL) || (cfg->write_en == 0u)) { + send_simple_response(client->socket, 550, STATUS_550); + return 0; + } + if (arg == NULL || strlen(arg) == 0) { send_simple_response(client->socket, 550, STATUS_550); return -1; @@ -374,7 +430,33 @@ static int cmd_noop(lftpd_client_t *client, const char *arg) static int cmd_pass(lftpd_client_t *client, const char *arg) { - send_simple_response(client->socket, 230, STATUS_230); + const ftp_cfg_t *cfg = ftp_cfg_get(); + ftp_auth_creds_t creds; + ftp_auth_result_t res; + + if (cfg == NULL) { + /* fail-closed:配置不可用即拒绝(s_cfg 静态常驻,正常不会走到) */ + send_simple_response(client->socket, 530, STATUS_530); + return 0; + } + creds.p_user = cfg->user; + creds.p_pwd_hash = cfg->pwd_hash; + creds.write_en = cfg->write_en; + res = ftp_auth_check(&creds, client->auth_user_ok, + (arg != NULL) ? arg : "", + &s_auth_state, (uint32_t)xTaskGetTickCount()); + if (res == FTP_AUTH_OK) { + client->auth_logged_in = 1; + send_simple_response(client->socket, 230, STATUS_230); + return 0; + } + /* 预期事件(爆破/配错口令):用 INFO 而非 ERROR——[ERR] 是 @test Forbidden 标记 */ + if (res == FTP_AUTH_LOCKED) { + lftpd_log_info("auth locked after %u failures", (unsigned)FTP_AUTH_FAIL_MAX); + } else { + lftpd_log_info("auth failed for user '%s'", client->auth_user); + } + send_simple_response(client->socket, 530, STATUS_530); return 0; } @@ -500,6 +582,14 @@ static int cmd_size(lftpd_client_t *client, const char *arg) static int cmd_stor(lftpd_client_t *client, const char *arg) { + /* N-19:写命令门——[ftp] write=0 时拒绝(鉴权门已保证此处必为已登录态) */ + const ftp_cfg_t *cfg = ftp_cfg_get(); + + if ((cfg == NULL) || (cfg->write_en == 0u)) { + send_simple_response(client->socket, 550, STATUS_550); + return 0; + } + if (client->data_socket == -1) { send_simple_response(client->socket, 425, STATUS_425); return -1; @@ -552,7 +642,20 @@ static int cmd_type(lftpd_client_t *client, const char *arg) static int cmd_user(lftpd_client_t *client, const char *arg) { - send_simple_response(client->socket, 230, STATUS_230); + const ftp_cfg_t *cfg = ftp_cfg_get(); + + client->auth_logged_in = 0; + client->auth_user[0] = '\0'; + client->auth_user_ok = 0; + if ((arg != NULL) && (cfg != NULL)) { + strncpy(client->auth_user, arg, sizeof(client->auth_user) - 1); + client->auth_user[sizeof(client->auth_user) - 1] = '\0'; + client->auth_user_ok = (uint8_t)ftp_auth_user_match(cfg->user, + client->auth_user); + } + /* 不论用户名对错一律 331:不泄露"用户是否存在";错用户名在 cmd_pass + * 仍走一次同耗时哈希比较(时间拉平,见 lftpd_auth.h 说明 2) */ + send_simple_response(client->socket, 331, STATUS_331); return 0; } @@ -595,6 +698,21 @@ static int handle_control_channel(lftpd_client_t *client) command_tmp[i] = (char)toupper((int)command_tmp[i]); } + /* N-19:未鉴权命令门——仅 QUIT/FEAT/SYST/NOOP 放行(客户端兼容需要, + * 且无信息量);其余一律 530。USER/PASS 本身也在放行名单外, + * 但它们在 gate 之前不可能通过 → 单独放行见下(USER/PASS 例外) */ + if (client->auth_logged_in == 0) { + if ((strcmp(command_tmp, "USER") != 0) && + (strcmp(command_tmp, "PASS") != 0) && + (strcmp(command_tmp, "QUIT") != 0) && + (strcmp(command_tmp, "FEAT") != 0) && + (strcmp(command_tmp, "SYST") != 0) && + (strcmp(command_tmp, "NOOP") != 0)) { + send_simple_response(client->socket, 530, STATUS_530); + continue; + } + } + bool matched = false; for (int i = 0; commands[i].command; i++) { if (strcmp(commands[i].command, command_tmp) == 0) { @@ -640,6 +758,11 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd) lftpd->port = port; lftpd->server_socket = -1; /* memset 清零后显式置"无监听器"(0 是合法 fd) */ + /* N-19:出厂默认口令在用告警(台架锚点;改了 [ftp] pwd_hash 即消失) */ + if (ftp_cfg_is_default_pwd() != 0) { + lftpd_log_info("using factory default password"); + } + while (true) { lftpd_task_wd_beat(); /* 看门狗:事件循环每轮喂心跳(ftpTask 主循环节拍点) */ if (lftpd->server_socket < 0) { diff --git a/Drivers/BSP/NET/lftpd/lftpd.h b/Drivers/BSP/NET/lftpd/lftpd.h index 6dbc54c6..92c5d671 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.h +++ b/Drivers/BSP/NET/lftpd/lftpd.h @@ -6,6 +6,11 @@ typedef struct { char directory[256]; int socket; int data_socket; + /* N-19 FTP 鉴权会话态(每会话 memset 0 = 未登录;跨会话锁定计数在 + * lftpd.c 的 s_auth_state,不在此处) */ + char auth_user[32]; /* USER 阶段暂存的用户名 */ + uint8_t auth_user_ok; /* 1 = 用户名与 [ftp] user 匹配 */ + uint8_t auth_logged_in; /* 1 = PASS 校验通过 */ } lftpd_client_t; typedef struct { diff --git a/Drivers/BSP/NET/lftpd/lftpd_auth.c b/Drivers/BSP/NET/lftpd/lftpd_auth.c new file mode 100644 index 00000000..7755e7ae --- /dev/null +++ b/Drivers/BSP/NET/lftpd/lftpd_auth.c @@ -0,0 +1,100 @@ +/* + * 模块名称:lftpd_auth + * 模块功能:FTP 鉴权状态机实现(纯函数,仅依赖 stdint/string 与 sha256) + * 适用平台:STM32F407ZGTx / PC 主机侧单测(双端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;契约由 + * test/host/ftp_auth 主机单测钉死) + */ + +/* 头文件包含区 */ +#include +#include + +#include "lftpd_auth.h" + +#include "sha256.h" + +/* 函数定义区 */ + +/* + * 函数功能:用户名匹配(大小写不敏感) + * 入口参数:p_expect - 配置用户名 p_user - 客户端 USER 参数 + * 返回值:1 - 匹配;0 - 不匹配或任一为 NULL int + * 限定条件:无 + */ +int ftp_auth_user_match(const char *p_expect, const char *p_user) { + if ((p_expect == NULL) || (p_user == NULL)) { + return 0; + } + for (size_t i = 0u; ; i++) { + char a = p_expect[i]; + char b = p_user[i]; + + if ((a >= 'A') && (a <= 'Z')) { + a = (char)(a + 32); + } + if ((b >= 'A') && (b <= 'Z')) { + b = (char)(b + 32); + } + if (a != b) { + return 0; + } + if (a == '\0') { + return 1; + } + } +} + +/* + * 函数功能:口令校验 + 失败计数 + 限时锁定(鉴权契约核心) + * 入口参数:p_creds - 凭据 user_ok - USER 阶段匹配结果 p_pass - 口令 + * p_state - 会话外共享状态 now_tick - 当前 tick(ms) + * 返回值:FTP_AUTH_OK / FAIL / LOCKED(枚举) + * 限定条件:锁定判定先于哈希(锁定期内不做哈希);用户名错也走哈希(耗时拉平); + * tick 有符号差抗 32 位回绕;NULL 入参 fail-closed 返回 FAIL + */ +ftp_auth_result_t ftp_auth_check(const ftp_auth_creds_t *p_creds, + uint8_t user_ok, const char *p_pass, + ftp_auth_state_t *p_state, uint32_t now_tick) { + sha256_ctx_t ctx; + uint8_t digest[SHA256_DIGEST_SIZE]; + int ok; + + if ((p_creds == NULL) || (p_state == NULL)) { + return FTP_AUTH_FAIL; /* fail-closed */ + } + + if (p_state->fail_count >= FTP_AUTH_FAIL_MAX) { + /* 有符号差:now 在 lock_until 之前(含回绕)= 仍在锁定窗内 */ + if ((int32_t)(now_tick - p_state->lock_until_tick) < 0) { + return FTP_AUTH_LOCKED; + } + p_state->fail_count = 0u; /* 锁定期满,重新起算 */ + } + + /* 盐 = 期望用户名;用户名错也走同一哈希路径(耗时拉平,见头文件说明 2) */ + sha256_init(&ctx); + if (p_creds->p_user != NULL) { + sha256_update(&ctx, p_creds->p_user, strlen(p_creds->p_user)); + } + if (p_pass != NULL) { + sha256_update(&ctx, p_pass, strlen(p_pass)); + } + sha256_final(&ctx, digest); + + ok = ((user_ok != 0u) && + (sha256_hex_eq(digest, p_creds->p_pwd_hash) != 0)) ? 1 : 0; + if (ok != 0) { + p_state->fail_count = 0u; + return FTP_AUTH_OK; + } + + p_state->fail_count++; + if (p_state->fail_count >= FTP_AUTH_FAIL_MAX) { + p_state->lock_until_tick = now_tick + FTP_AUTH_LOCK_TICKS; + } + return FTP_AUTH_FAIL; +} diff --git a/Drivers/BSP/NET/lftpd/lftpd_auth.h b/Drivers/BSP/NET/lftpd/lftpd_auth.h new file mode 100644 index 00000000..ed43ceea --- /dev/null +++ b/Drivers/BSP/NET/lftpd/lftpd_auth.h @@ -0,0 +1,73 @@ +/* + * 模块名称:lftpd_auth + * 模块功能:FTP 鉴权状态机(纯函数、零平台依赖,主机侧可测)——用户名匹配、 + * SHA-256(user‖password) 恒定时间校验、连续失败计数与限时锁定; + * tick 由调用方注入(MCU = xTaskGetTickCount,单测 = 合成值) + * 适用平台:STM32F407ZGTx / PC 主机侧单测(双端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;lftpd.c 为唯一消费者) + */ + +#ifndef LFTPD_AUTH_H +#define LFTPD_AUTH_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#define FTP_AUTH_FAIL_MAX 5u /* 连续失败 N 次后锁定 */ +#define FTP_AUTH_LOCK_TICKS 60000u /* 锁定时长(tick = 1 ms → 60 s) */ + +/* 一次校验所需的配置快照(由消费者从 ftp_cfg_t 组装,本模块不认配置引擎) */ +typedef struct { + const char *p_user; /* 期望用户名 */ + const char *p_pwd_hash; /* 64 字符 hex 摘要 */ + uint8_t write_en; /* 透传位:写命令门由 lftpd 侧执行,本模块不判 */ +} ftp_auth_creds_t; + +/* 鉴权状态(跨会话持久;单连接服务器,全局一份即可) */ +typedef struct { + uint8_t fail_count; /* 连续失败计数(成功即清零) */ + uint32_t lock_until_tick; /* 锁定截止 tick(fail_count ≥ FAIL_MAX 时有效) */ +} ftp_auth_state_t; + +typedef enum { + FTP_AUTH_OK = 0, /* 口令正确 */ + FTP_AUTH_FAIL, /* 口令/用户名错误(已计入失败计数) */ + FTP_AUTH_LOCKED /* 锁定中(本次不校验、不计数) */ +} ftp_auth_result_t; + +/* + * 函数功能:用户名匹配(大小写不敏感,与配置引擎键名口径一致) + * 入口参数:p_expect - 期望用户名 p_user - 来客输入 + * 返回值:1 - 匹配;0 - 不匹配或任一为 NULL int + * 限定条件:无 + */ +int ftp_auth_user_match(const char *p_expect, const char *p_user); + +/* + * 函数功能:口令校验 + 失败计数 + 限时锁定(一次 PASS 的完整判定) + * 入口参数:p_creds - 配置快照 user_ok - USER 阶段的用户名匹配结果 + * p_pass - 来客口令(NULL 按空串) p_state - 鉴权状态 + * now_tick - 当前 tick(ms) + * 返回值:ftp_auth_result_t + * 限定条件:p_creds/p_state 非空(NULL 一律 FAIL,fail-closed) + * 函数说明:1. 锁定中直接 LOCKED(不算哈希——锁定状态非机密,无侧信道); + * tick 差用 (int32_t) 有符号比较,天然抗 32 位回绕 + * 2. 无论用户名对错都走同一条哈希路径(盐 = 期望用户名), + * 耗时一致 → 不泄露"用户是否存在" + * 3. 成功清零计数;失败累计到 FTP_AUTH_FAIL_MAX 上锁 + */ +ftp_auth_result_t ftp_auth_check(const ftp_auth_creds_t *p_creds, + uint8_t user_ok, const char *p_pass, + ftp_auth_state_t *p_state, uint32_t now_tick); + +#ifdef __cplusplus +} +#endif + +#endif /* LFTPD_AUTH_H */ diff --git a/Drivers/BSP/NET/lftpd/lftpd_log.c b/Drivers/BSP/NET/lftpd/lftpd_log.c deleted file mode 100644 index 95c8785d..00000000 --- a/Drivers/BSP/NET/lftpd/lftpd_log.c +++ /dev/null @@ -1,27 +0,0 @@ -#include "private/lftpd_log.h" - -#include -#include -#include -#include "dbg_log.h" - - -void lftpd_log_internal(const char* level, const char* format, ...) { - static char buffer[256]; /* static: called at depth in ftpTask, keep off-stack */ - static char line[288]; /* static: 组装含前缀的整行,交给控制台单写者 */ - va_list args; - va_start(args, format); - int err = vsnprintf(buffer, sizeof(buffer), format, args); - va_end(args); - if (err >= sizeof(buffer)) { - return; - } - /* 走控制台单写者(App/log/console_log.c):整行一次入缓冲,避免逐字符 fputc 交错 */ - int n = snprintf(line, sizeof(line), "%s %s\r\n", level, buffer); - if (n > 0) { - if (n > (int)sizeof(line) - 1) { - n = (int)sizeof(line) - 1; - } - (void)dbg_console_write_line(line, (unsigned)n); - } -} diff --git a/Drivers/BSP/NET/net_socket.c b/Drivers/BSP/NET/net_socket.c index ed09e87f..30ec7414 100644 --- a/Drivers/BSP/NET/net_socket.c +++ b/Drivers/BSP/NET/net_socket.c @@ -581,8 +581,18 @@ static void net_reconcile_tick(void) { bad = 1; } else if ((p_sock->state == NET_SOCK_STATE_LISTENING) && (st[1] == CH395F_TCP_ESTABLISHED)) { - /* ② 本层 LISTENING 而芯片已完成握手(丢 CONNECT 中断)→ 补做 accept */ - bad = 2; + /* ② 本层 LISTENING 而芯片已完成握手(丢 CONNECT 中断)→ 补做 accept + * N-30(Trap 25)幽灵态甄别:拆链收尾窗口内芯片假报 ESTABLISHED, + * 回读对端端口 = 上一条连接端口 → 不补 accept(补了会把数据发给 + * 已死的旧连接 = 生产 LIST#4 数据被吞的直接成因),等拆净或新 SYN */ + uint8_t remote[6] = {0, 0, 0, 0, 0, 0}; + ch395f_get_remot_ipp((uint8_t)i, remote); + uint16_t peer = (uint16_t)(remote[4] | (remote[5] << 8)); + /* 仅"端口非零且 = 上一条连接端口"判幽灵拦截;peer==0 不拦截 + * (对端端口提交滞后时不得挡掉丢中断救援,见 net_lost_connect_probe 注释) */ + if (!((peer != 0U) && (peer == p_sock->last_peer_port))) { + bad = 2; + } } else if ((p_sock->state == NET_SOCK_STATE_LISTENING) && ((st[0] == CH395F_SOCKET_CLOSED) || (st[1] != CH395F_TCP_LISTEN))) { /* ③ 本层 LISTENING 而芯片监听已消失 → 重建监听 */ @@ -768,6 +778,9 @@ void net_process_messages(void) { * close/重监听命令(会摧毁芯片侧仍健康的监听器 → 端口永拒, * run8 实证 170s 拒绝);新连接的 CONNECT 中断会把两侧重新同步 */ p_sock->state = NET_SOCK_STATE_LISTENING; + /* N-30(Trap 25):死连接对端端口转登幽灵甄别基准 + 去抖清零 */ + p_sock->last_peer_port = p_sock->remote_port; + p_sock->lost_conn_seen = 0U; /* 取消本 fd 在死连接上的挂起 recv(必通知,防调用方挂死) */ net_pending_cancel(msg.sockfd, NET_ERR_CONNRESET); /* 仅修正 MCU 侧状态,不发芯片命令,直接返回成功 */ @@ -1090,6 +1103,8 @@ int net_listen_locked(int sockfd) { p_sock->state = NET_SOCK_STATE_LISTENING; /* 单连接模式:连接断开后由 net 层自动回到 LISTEN(模拟 BSD 监听常驻) */ p_sock->auto_relisten = 1; + /* N-30(Trap 25):新监听会话,丢中断去抖计数清零 */ + p_sock->lost_conn_seen = 0U; /* 单连接:CONNECT 中断直接作用于本 Socket 自身;启动监听前清空各 * Socket 及全局中断状态寄存器(读即清除),避免历史残留/伪中断在 * 监听刚开始时误触发连接或断开事件 */ @@ -1164,6 +1179,84 @@ int net_listen(int sockfd) { * 限定条件:Socket 处于 LISTENING 状态 * 函数说明:单连接:监听 Socket 自身在 CONNECT 后已转为 ESTABLISHED */ +/* ===== N-30 accept 路径"丢 CONNECT 中断"补 accept(2026-10-11 裸机芯片台架实证)===== + * 台架结论(剥离 OS/net/FTP 直接对芯片做受控 close→open→listen→connect 矩阵): + * 芯片关闭连接后的拆链收尾窗口**变长且不可见**(状态寄存器显示 CLOSED 也不代表 + * 拆完,实测 >2s 仍可能未拆完);该窗口内重新 listen 后,新连接握手照常完成 + * (芯片状态寄存器 → ESTABLISHED)但 **CONNECT 中断被丢**——纯事件驱动的 accept + * 永远等不到 → 数据被吞 / 425。固定沉降延时、Socket 轮换、listen 后 close 重试、 + * 缓冲重分配均已被实验证伪;唯一有效 = "中断 + 状态轮询"双信源(修复组 15/15 + * 全过,机制触发 5 次全部救回)。→ CH395F_Trap_Records.md Trap 25 + * 幽灵态甄别:拆链窗口内芯片也会直接假报 ESTABLISHED(旧连接未拆净),此时回读 + * 对端端口 = 上一条连接端口;新连接临时端口几乎必然不同 → 端口相同不 accept, + * 等旧连接拆净 / 新 SYN 到达(新连接若也丢中断,后续轮询以新端口命中)。 */ +#define NET_LOST_CONN_STABLE_N 2U /* 连续确认轮次(accept 轮询 100ms/轮,≈200ms 去抖) */ + +/* + * 函数功能:监听等待期的芯片状态轮询探针——芯片已 ESTABLISHED 而 CONNECT 中断 + * 未到(丢中断)时,去抖满后补做 accept(复用 CONNECT 事件簿记路径)。 + * 入口参数:p_sock - 处于 LISTENING 的监听控制块 net_sock_t * + * 出口参数:无(命中时 p_sock->state 经 handle_connect_event 转 ESTABLISHED) + * 返回值:无 + * 限定条件:netTask 上下文(net_accept_locked 内调用,SPI 访问安全) + * 函数说明:1. 每轮 accept 轮询(100ms)读一次芯片状态,兼作状态第二信源 + * 2. 幽灵态(对端端口 = last_peer_port)不动手,去抖清零 + * 3. 连续 NET_LOST_CONN_STABLE_N 轮确认后才补 accept(防"刚 listen + * 尚未生效"的瞬态误判——2026-09-23 实测教训) + */ +static void net_lost_connect_probe(net_sock_t *p_sock) { + /* 芯片侧状态两字节:[0]=socket 状态,[1]=TCP 状态 */ + uint8_t st[2] = {0, 0}; + /* 对端 IP+端口回读缓冲(4B IP + 2B 端口小端) */ + uint8_t remote[6] = {0, 0, 0, 0, 0, 0}; + + /* 读芯片状态 */ + ch395f_get_socket_status(p_sock->ch395_sock, st); + /* 芯片尚未完成握手:去抖计数清零,本轮无动作 */ + if (st[1] != CH395F_TCP_ESTABLISHED) { + p_sock->lost_conn_seen = 0U; + return; + } + /* 已 ESTABLISHED:回读对端端口做幽灵甄别 */ + ch395f_get_remot_ipp(p_sock->ch395_sock, remote); + /* 对端端口(小端 2 字节) */ + uint16_t peer = (uint16_t)(remote[4] | (remote[5] << 8)); + /* 幽灵甄别:仅"端口非零且 = 上一条连接端口"才判幽灵拦截; + * peer==0(芯片对端端口提交滞后,Trap 15 同类)不拦截——宁可补 accept + * 也不退回丢中断死等(2026-10-11 验证轮实测:peer==0 拦截把首连接 + * 丢中断的原有对账救援也挡死,LIST#1 卡满 20s,比修复前更糟) */ + if ((peer != 0U) && (peer == p_sock->last_peer_port)) { + p_sock->lost_conn_seen = 0U; + return; + } + /* 去抖:连续确认满门槛才补 accept(封顶防溢出) */ + if (p_sock->lost_conn_seen < NET_LOST_CONN_STABLE_N) { + p_sock->lost_conn_seen++; + } + if (p_sock->lost_conn_seen < NET_LOST_CONN_STABLE_N) { + return; + } + /* 动手前清去抖计数 */ + p_sock->lost_conn_seen = 0U; + /* 留痕:丢中断自愈命中 */ + DBG_INFO("sock%d lost-CONNECT self-accept (peer=%u)", + (int)p_sock->ch395_sock, peer); + /* 复用 CONNECT 事件的簿记路径(转 ESTABLISHED + 记对端 + 事件回调) */ + handle_connect_event(p_sock); +} + +/* + * 函数功能:TCP Server 接受新连接(内部版本,必须在 netTask 上下文调用) + * 入口参数:sockfd - 监听 Socket 描述符 int + * 出口参数:addr - 输出:客户端地址结构体指针 struct net_sockaddr *(可为 NULL) + * addrlen - 输入输出:地址结构体长度 int *(可为 NULL) + * 返回值:新连接的 Socket 描述符,失败返回 -1 int + * 限定条件:Socket 处于 LISTENING 状态 + * 函数说明:1. 单连接:监听 Socket 自身在 CONNECT 后已转为 ESTABLISHED + * 2. 等待期叠加芯片状态轮询探针(丢 CONNECT 中断自愈,Trap 25) + * 3. CLOSED + 芯片 RX 缓冲有残留数据:接受"已关但可读"连接排空 + * (FUN_PARA §5.52 ② 保留现场契约,Trap 25 三次定案) + */ static int net_accept_locked(int sockfd, struct net_sockaddr *addr, int *addrlen) { /* 监听 Socket 控制块 */ net_sock_t *p_listen_sock; @@ -1178,18 +1271,43 @@ static int net_accept_locked(int sockfd, struct net_sockaddr *addr, int *addrlen /* 先轮询一次:把状态机刷新到芯片侧最新状态 */ net_poll(); + /* N-30(Trap 25):监听等待期兼做芯片状态第二信源——丢 CONNECT 中断时 + * 去抖补 accept(裸机台架实证:拆链窗口丢中断是数据被吞/425 的主根因, + * 纯事件等待无自愈可能;详见 net_lost_connect_probe 区块注释) */ + if (p_listen_sock->state == NET_SOCK_STATE_LISTENING) { + net_lost_connect_probe(p_listen_sock); + } + /* 单连接:本 Socket 自身在 CONNECT 后处于 ESTABLISHED 才表示有可接受的连接 - * 芯片丢中断导致的失同步不在此处自愈:单次状态读会误判"刚 listen 尚未生效" - * (2026-09-23 实测风暴:监听被反复重建直至放弃),统一由 net_reconcile_tick() 周期对账(带去抖)处理 */ + * (含上一步探针补 accept 的命中);其余失同步由 net_reconcile_tick() + * 周期对账(带去抖)兜底 */ if (p_listen_sock->state != NET_SOCK_STATE_ESTABLISHED) { - /* 连接已断开(CLOSED)且启用自动重监听:先把 Socket 重新置回 LISTEN, - * 下一次 accept 即可等待新连接(BSD 风格监听常驻) */ + /* N-30(2026-10-11 手册 §5.23⑤ + 生产时间线定案): + * "closed-but-readable" 接受——STOR 类传输客户端**发完数据立即关数据连接** + * (正常 FTP 行为,Python ftplib sendall 后即 close),芯片按 + * FUN_PARA(SOCKET_CLOSE=0) 规格保留 Socket 与 RX 缓冲供 MCU 排空 + * (实测 DISCONNECT 后 st=0x05/0x00、recv_len=1027=完整载荷)。 + * 若 accept 轮询晚于对端 FIN,盲目 relisten(close+open 清缓冲)会把 + * 已到达的数据销毁 → 425(此前 N-30"425/被吞"的主根因,并非芯片杀连接)。 + * 处置:芯片 RX 缓冲有数据 → 接受这条"已关但可读"连接,应用经 + * net_recv_locked 快路径(无状态门槛)排空,排空后 EOF 自然触发 relisten。 */ + int drain_pending = 0; if (p_listen_sock->state == NET_SOCK_STATE_CLOSED && p_listen_sock->auto_relisten) { - /* 非阻塞:失败由 D10 重试轮询接管 */ - (void)net_relisten_try(p_listen_sock); + if (ch395f_get_recv_len((uint8_t)sockfd) > 0) { + drain_pending = 1; + DBG_INFO("sock%d closed-with-data accept (drain pending)", sockfd); + } else { + /* 连接已断开(CLOSED)且无残留数据:先把 Socket 重新置回 LISTEN, + * 下一次 accept 即可等待新连接(BSD 风格监听常驻) */ + /* 非阻塞:失败由 D10 重试轮询接管 */ + (void)net_relisten_try(p_listen_sock); + } } - /* 当前无连接 → WOULDBLOCK */ - return NET_ERR_RETURN_1(NET_ERR_WOULDBLOCK); + if (drain_pending == 0) { + /* 当前无连接 → WOULDBLOCK */ + return NET_ERR_RETURN_1(NET_ERR_WOULDBLOCK); + } + /* drain_pending:落入下方 accept 返回(对端簿记仍在控制块,排空后 EOF 了结) */ } /* 填充对端地址(若调用方需要) */ @@ -1895,6 +2013,10 @@ static int net_relisten_locked(net_sock_t *p_sock) { p_sock->state = NET_SOCK_STATE_LISTENING; /* 新监听不得继承上一条连接的释放原因(Trap 23 待办 #3) */ p_sock->released_abnormal = 0U; + /* N-30(Trap 25):清零前把对端端口转登为幽灵甄别基准 */ + p_sock->last_peer_port = p_sock->remote_port; + /* N-30:新监听会话,丢中断去抖计数清零 */ + p_sock->lost_conn_seen = 0U; /* 清对端端口(新连接待用) */ p_sock->remote_port = 0; /* 清对端 IP */ @@ -2432,7 +2554,11 @@ static int net_close_locked(net_sock_t *p_sock) { } while (1); /* 清零控制块(memset 0 = 释放本 fd 全部状态) */ + /* N-30(Trap 25):幽灵甄别基准跨 fd 释放保留(幽灵属芯片 Socket 级状态, + * 与 fd 复用无关——close 后新 listen 落在同一芯片 Socket 上仍可能撞幽灵) */ + uint16_t keep_peer = p_sock->remote_port; memset(p_sock, 0, sizeof(net_sock_t)); + p_sock->last_peer_port = keep_peer; /* 关闭即取消该 fd 的待重试(D10 重试队列) */ net_relisten_clear(sockfd); /* 关闭即清除该 fd 的挂起状态(L3;幂等) */ @@ -3478,7 +3604,8 @@ static void handle_disconnect_event(net_sock_t *p_sock) { } /* 单连接:先不关 CH395F Socket, * 留给 net_recv_locked 把剩余数据读出来再关,置 CLOSED 等待应用重新监听 */ - DBG_INFO("sock%d DISCONNECTED, releasing (chip tcp=0x%02X)", p_sock->ch395_sock, st[1]); + DBG_INFO("sock%d DISCONNECTED, releasing (chip st=0x%02X/0x%02X)", + p_sock->ch395_sock, st[0], st[1]); /* Trap 23 推广(2026-09-24 台架实证):正常 DISCONNECT 释放留下的芯片收尾状态 * 会在复用同一 fd 的下一条连接上以"伪 DISCONNECT"形式投递(实测 accept 后约 10~20ms)→ 该连接被提前释放,应用把已到的 2048B 当整份文件并回报 226; * 仅**本连接收过数据**时登记沉降(实测触发源 = 收过数据的数据通道被关闭),纯连接即关/只发不收的连接免沉降,保住 Trap 22 的快速复连时序(:21 @0ms ×50 全通过) */ @@ -3494,6 +3621,9 @@ static void handle_disconnect_event(net_sock_t *p_sock) { } /* 正常释放(对端 FIN):断连了结回 EOF(0) */ p_sock->released_abnormal = 0U; + /* N-30(Trap 25):登记本连接对端端口(幽灵态甄别基准,须在状态 + * 迁移前记录——remote_port 此刻仍有效,relisten 清零) */ + p_sock->last_peer_port = p_sock->remote_port; /* 状态 → CLOSED */ p_sock->state = NET_SOCK_STATE_CLOSED; /* 触发断连事件回调 */ @@ -3581,6 +3711,8 @@ static void handle_timeout_event(net_sock_t *p_sock) { * NET_ERR_CONNRESET 了结(而非 EOF),应用(lftpd STOR)回报 5xx * 而非误报 226(见 net_pending_tick 断连分支) */ p_sock->released_abnormal = 1U; + /* N-30(Trap 25):异常释放同样登记对端端口(幽灵甄别基准) */ + p_sock->last_peer_port = p_sock->remote_port; /* 状态 → CLOSED */ p_sock->state = NET_SOCK_STATE_CLOSED; } else if (p_sock->state == NET_SOCK_STATE_LISTENING) { diff --git a/Drivers/BSP/NET/net_types.h b/Drivers/BSP/NET/net_types.h index c3a99328..4cf2f7a9 100644 --- a/Drivers/BSP/NET/net_types.h +++ b/Drivers/BSP/NET/net_types.h @@ -179,6 +179,16 @@ typedef struct { * 连接的释放原因。 */ uint8_t released_abnormal; + /* N-30(2026-10-11 裸机芯片台架实证):本 Socket 上一条被释放连接的**对端端口**。 + * 芯片拆链收尾窗口内 Socket 会假报 ESTABLISHED(幽灵态),此时回读的对端端口 + * = 旧连接端口;新连接的临时端口几乎必然不同。accept 路径补 accept 与对账② + * 均以此甄别幽灵态(见 net_socket.c Trap 25)。跨 fd 释放保留(幽灵属芯片 + * Socket 级,与 fd 复用无关),新连接断开时刷新。 */ + uint16_t last_peer_port; + /* N-30:accept 路径"丢 CONNECT 中断补 accept"的去抖计数(连续确认次数, + * 节拍 = accept 轮询 100ms;新监听/补 accept 后清零) */ + uint8_t lost_conn_seen; + /* 统计信息 */ uint32_t recv_bytes; /* 累计接收字节数 */ uint32_t send_bytes; /* 累计发送字节数 */ diff --git a/Drivers/BSP/NETCFG/ftp_cfg.c b/Drivers/BSP/NETCFG/ftp_cfg.c new file mode 100644 index 00000000..8301b711 --- /dev/null +++ b/Drivers/BSP/NETCFG/ftp_cfg.c @@ -0,0 +1,116 @@ +/* + * 模块名称:FTP 配置([ftp] 段) + * 模块功能:RAM 副本 + 工厂默认 + cfg_file 引擎注册(键表 user/pwd_hash/write) + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权) + */ + +/* 头文件包含区 */ +#include +#include + +#include "ftp_cfg.h" + +#include "cfg_file.h" + +/* 私有变量定义区 */ + +/* 工厂默认(单一来源宏见 ftp_cfg.h;s_cfg 初值与其一致 = load 前也 fail-closed 可用) */ +static const ftp_cfg_t s_defaults = { + FTP_CFG_DEF_USER, + FTP_CFG_DEF_HASH, + FTP_CFG_DEF_WRITE, +}; + +/* RAM 副本(引擎原地更新;注册于 net_cfg_load 内 cfg_file_load 之前) */ +static ftp_cfg_t s_cfg = { + FTP_CFG_DEF_USER, + FTP_CFG_DEF_HASH, + FTP_CFG_DEF_WRITE, +}; + +/* [ftp] 段键表(ftp_cfg_t 布局内偏移) */ +static const cfg_key_def_t s_ftp_keys[] = { + { "user", CFG_VAL_STR, offsetof(ftp_cfg_t, user), FTP_CFG_USER_MAX }, + { "pwd_hash", CFG_VAL_STR, offsetof(ftp_cfg_t, pwd_hash), FTP_CFG_HASH_MAX }, + { "write", CFG_VAL_UINT, offsetof(ftp_cfg_t, write_en), 1u }, +}; + +/* 函数定义区 */ + +/* + * 函数功能:大小写不敏感字符串相等判断 + * 入口参数:p_a - 串 A p_b - 串 B(均可 NULL) + * 返回值:1 - 相等;0 - 不等或任一为 NULL int + * 限定条件:无 + */ +static int s_str_ci_eq(const char *p_a, const char *p_b) { + if ((p_a == NULL) || (p_b == NULL)) { + return 0; + } + for (size_t i = 0u; ; i++) { + char a = p_a[i]; + char b = p_b[i]; + + if ((a >= 'A') && (a <= 'Z')) { + a = (char)(a + 32); + } + if ((b >= 'A') && (b <= 'Z')) { + b = (char)(b + 32); + } + if (a != b) { + return 0; + } + if (a == '\0') { + return 1; + } + } +} + +/* + * 函数功能:注册 [ftp] 段到 cfg_file 引擎(须在 cfg_file_load 之前调用) + * 入口参数:无 + * 返回值:无 + * 限定条件:幂等(重复调用只注册一次);由 net_cfg_load 统一触发 + */ +void ftp_cfg_register(void) { + static uint8_t s_done; + cfg_section_reg_t reg; + + if (s_done != 0u) { + return; + } + (void)memset(®, 0, sizeof(reg)); + reg.name = "ftp"; + reg.p_data = &s_cfg; + reg.p_defaults = &s_defaults; + reg.data_size = sizeof(ftp_cfg_t); + reg.p_keys = s_ftp_keys; + reg.key_count = 3u; + reg.p_cb = NULL; /* 无"立即应用"需求:口令在下次 PASS 时生效 */ + (void)cfg_file_register(®); + s_done = 1u; +} + +/* + * 函数功能:取 [ftp] 段 RAM 副本(load 前后均有效,load 前 = 工厂默认) + * 入口参数:无 + * 返回值:配置指针(恒非 NULL,指向静态存储) + * 限定条件:调用方只读 + */ +const ftp_cfg_t *ftp_cfg_get(void) { + return &s_cfg; +} + +/* + * 函数功能:判断当前口令哈希是否仍为出厂默认(启动告警依据) + * 入口参数:无 + * 返回值:1 - 仍是默认;0 - 已改 int + * 限定条件:大小写不敏感比较 + */ +int ftp_cfg_is_default_pwd(void) { + return s_str_ci_eq(s_cfg.pwd_hash, FTP_CFG_DEF_HASH); +} diff --git a/Drivers/BSP/NETCFG/ftp_cfg.h b/Drivers/BSP/NETCFG/ftp_cfg.h new file mode 100644 index 00000000..04d63037 --- /dev/null +++ b/Drivers/BSP/NETCFG/ftp_cfg.h @@ -0,0 +1,68 @@ +/* + * 模块名称:FTP 配置([ftp] 段) + * 模块功能:FTP 服务鉴权配置——RAM 副本 + 工厂默认 + cfg_file 引擎注册 + * ([ftp] 段:user / pwd_hash / write);消费者(lftpd/lftpd_auth) + * 经 ftp_cfg_get() 只读,配置引擎属主仍为 defaultTask + * 适用平台:STM32F407ZGTx / PC 模拟器(两端共用) + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权) + */ + +#ifndef FTP_CFG_H +#define FTP_CFG_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#define FTP_CFG_USER_MAX 16u /* 用户名缓冲长度(含 NUL) */ +#define FTP_CFG_HASH_MAX 65u /* SHA-256 hex 摘要 64 字符 + NUL */ + +/* 工厂默认:user=admin、口令=admin(pwd_hash = SHA256("adminadmin"))、write=1。 + * write=1 保持既有维护工作流(FTP 推 config.ini);生产配置应改口令并按需 write=0。 + * 出厂默认口令在用期间,lftpd_start 每次启动打 INFO 告警锚点(N-19) */ +#define FTP_CFG_DEF_USER "admin" +#define FTP_CFG_DEF_HASH "d82494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892" +#define FTP_CFG_DEF_WRITE 1u + +typedef struct { + char user[FTP_CFG_USER_MAX]; /* 期望用户名(大小写不敏感) */ + char pwd_hash[FTP_CFG_HASH_MAX]; /* SHA-256(user‖password) hex(大小写不敏感) */ + uint8_t write_en; /* 1 = 允许 STOR/DELE;0 = 只读 */ +} ftp_cfg_t; + +/* + * 函数功能:向 cfg_file 引擎注册 [ftp] 段(幂等) + * 入口参数:无 + * 返回值:无 + * 限定条件:cfg_file_load 之前调用(net_cfg_load 内统一编排) + */ +void ftp_cfg_register(void); + +/* + * 函数功能:取 [ftp] 段 RAM 副本(只读) + * 入口参数:无 + * 返回值:恒非 NULL(静态常驻,load 前初值 = 工厂默认) const ftp_cfg_t * + * 限定条件:无 + * 函数说明:defaultTask poll 重载瞬间可能读到撕裂值 → 该次登录失败 + * (fail-closed,重试即恢复;配置变更本就罕见) + */ +const ftp_cfg_t *ftp_cfg_get(void); + +/* + * 函数功能:判断当前 pwd_hash 是否仍为出厂默认口令哈希 + * 入口参数:无 + * 返回值:1 - 默认口令在用;0 - 已改 int + * 限定条件:无 + */ +int ftp_cfg_is_default_pwd(void); + +#ifdef __cplusplus +} +#endif + +#endif /* FTP_CFG_H */ diff --git a/Drivers/BSP/NETCFG/net_cfg.c b/Drivers/BSP/NETCFG/net_cfg.c index d3b893cb..b65197c7 100644 --- a/Drivers/BSP/NETCFG/net_cfg.c +++ b/Drivers/BSP/NETCFG/net_cfg.c @@ -30,6 +30,8 @@ #include "cfg_file.h" +#include "ftp_cfg.h" + /* 深调用链模块(net_cfg_load/save 在 defaultTask 启动链与保存链上): * 日志行缓冲收到 128 B(默认 192),本模块最长行渲染后 < 128 → 不截断 */ #define DBG_LINE_BUF_MAX 128U @@ -145,6 +147,8 @@ static bool s_mac_is_zero(const uint8_t *p_mac) { */ int net_cfg_load(void) { s_net_register(); + /* N-19:[ftp] 段一并注册(所有受管段必须先注册、后 load,引擎按注册表解析) */ + ftp_cfg_register(); /* 旧档 NCFG 迁移已于 2026-10-10 删除(net_cfg_legacy 移除;理由见文件头 v1.3)。 * 若卷上仍残留 /cfg/netcfg.bin:不读、不删、不迁移 → 配置回落工厂默认 */ diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 44f0a034..8982afef 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -912,6 +912,11 @@ 1 ..\App\util\task_prof.c + + sha256.c + 1 + ..\App\util\sha256.c + vol_lock.c 1 @@ -1553,6 +1558,11 @@ 1 ..\Drivers\BSP\NETCFG\net_cfg.c + + ftp_cfg.c + 1 + ..\Drivers\BSP\NETCFG\ftp_cfg.c + nand_ftl.c 1 @@ -1563,6 +1573,11 @@ 1 ..\Drivers\BSP\NET\lftpd\lftpd.c + + lftpd_auth.c + 1 + ..\Drivers\BSP\NET\lftpd\lftpd_auth.c + lftpd_inet.c 1 diff --git a/Src/main.c b/Src/main.c index f7b95d54..3829c6a7 100644 --- a/Src/main.c +++ b/Src/main.c @@ -57,6 +57,11 @@ /* Private macro -------------------------------------------------------------*/ /* USER CODE BEGIN PM */ +/* N-20(2026-10-12):本文件唯一的 DBG_* 调用点是 Error_Handler;其帧经 + * MX_*_Init 调用链叠在 MSP 上,而 MSP 仅 1KB(startup Stack_Size 0x400) + * → 行缓冲收紧到 128 B(本文件最长行 = Error_Handler 行 ≈78 B < 128,不截断)。 + * 日志成本口径见 dbg_log.h 的 DBG_LINE_BUF_MAX 注释。 */ +#define DBG_LINE_BUF_MAX 128U #define DBG_TAG "[MAIN]" #include "dbg_log.h" /* USER CODE END PM */ @@ -274,11 +279,26 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) void Error_Handler(void) { /* USER CODE BEGIN Error_Handler_Debug */ - /* User can add his own implementation to report the HAL error return state */ - __disable_irq(); - while (1) - { - } + /* N-20 统一降级(2026-10-12):旧形态 __disable_irq()+while(1) = 单处外设初始化 + * 失败即整机挂死,只剩 IWDG 兜底且无任何自检日志。现:打 1 条自检日志 + + * NVIC_SystemReset()(与 4 个 Fault 处理同款自恢复;IWDG 是最后兜底而非唯一 + * 出路;复位归因 SFTRSTF → 开机日志 "software (NVIC_SystemReset)")。 + * ⚠ 不得 __disable_irq():日志必须能走串口。 + * 调用方识别(为何不打地址):本函数无参数、调用点全在 CubeMX 生成区(不可加标签); + * ⚠ __builtin_return_address(0) **不可用**——AC5 前端接受但编成库函数调用, + * MicroLIB 无该符号 → 链接错 L6218E(2026-10-12 台架实证,勿再尝试); + * ARMCC __asm 只有整函数形态且取不到 C 符号、带不动 C 日志(看门狗设计说明 §10.4)。 + * → 子系统定位靠启动日志锚点:失败点在"最后一条成功日志"与 main() 的 + * MX_*_Init 顺序(GPIO→DMA→USART1→SPI2→SPI1→I2C1→UART4→UART5→USART2→ + * USART3→IWDG→ADC1→TIM3→app_main_init)之间。 + * 日志路径内核前安全:调用点全部在 osKernelStart 之前,控制台写者未注册 + * → con_append 走 dbg_console_write_raw(DWT/计数有界轮询,huart1 未就绪 + * 即返回),sys_clock_get_str 未初始化时打占位串——全程无内核 API。 + * 日志成本:仅这 1 条(≈90 B Flash;行缓冲已在本文件 PM 区收到 128 B)。 + * 重生成存活:函数体在 USER CODE Error_Handler_Debug 区(L2,KeepUserCode + * 逐字保留);另登记 check_cubemx_survival.py 审计项兜底(L3)。 */ + DBG_ERROR("boot init failure, resetting (locate MX_*_Init via boot order)"); + NVIC_SystemReset(); /* USER CODE END Error_Handler_Debug */ } #ifdef USE_FULL_ASSERT diff --git a/docs/AGENTS编写指南.md b/docs/AGENTS编写指南.md deleted file mode 100644 index 4fb65be5..00000000 --- a/docs/AGENTS编写指南.md +++ /dev/null @@ -1,109 +0,0 @@ -# AGENTS 编写指南——一份好的 AGENTS.md 应该怎么写 - -> **定位**:回答"AGENTS.md 该写什么、不该写什么、怎么维护"。适用于本仓库 `AGENTS.md` 与任何采用 [AGENTS.md 开放格式](https://agents.md/) 的项目。 -> **依据**:官方规范 + 两篇实践指南 + 一个标杆范例 + 本仓库实测(51 KB → 25 KB 瘦身与重构,见 §8)。 -> **关联**:文档体系通用约定 → [`文档规范.md`](文档规范.md);人类索引 → [`README.md`](README.md)。 - ---- - -## 1. 定位:它是谁的什么 - -| 文件 | 读者 | 内容 | -|---|---|---| -| `README.md` | 人类贡献者 | 项目介绍、快速上手、贡献指南 | -| `AGENTS.md` | **AI 编码代理** | 构建/测试命令、约定、约束、坑——那些"放 README 会碍事、但 agent 每次都需要"的上下文 | - -[官方定义](https://agents.md/):*"Think of it as a README for agents"*、*"anything you'd tell a new teammate belongs here"*。它没有必填字段、纯 Markdown,但**位置即命运**——它坐在每次请求的上下文最前面,写什么就得为什么付费(§3 P3)。 - -## 2. 一句话定位(写之前先回答) - -文件第一行必须让 agent 回答"**这是什么东西、给谁用、跑在哪**"。[aihero 指南](https://www.aihero.dev/a-complete-guide-to-agents-md)的说法:这句话是**锚定后续每个决策的角色提示**——"嵌入式固件"与"Next.js 网站"会让 agent 对同一个"加个日志"请求做出完全不同的权衡。没有这句话,后面所有规则都失去解释框架。 - -反例:直接以"编码规范"开头——agent 知道规矩,不知道为什么有这些规矩。 - -## 3. 六条核心原则 - -| # | 原则 | 为什么 | 反例信号 | -|---|---|---|---| -| P1 | **一句话定位打头**(§2) | 所有规则的锚点 | 文件从规范讲起,产品是什么要翻三节才拼出来 | -| P2 | **宏观先行**:概况 → 目录地图 → 架构边界 → 再是操作规则 | [airflow 范例](https://github.com/apache/airflow/blob/main/AGENTS.md)把 Repository Structure 与 Architecture Boundaries 放在编码规范之前;agent 先知道"代码怎么摆、边界在哪"才不会把算法塞进任务文件 | 规则堆完才出现"架构要点",或根本没有宏观视图 | -| P3 | **指令预算**:模型只能稳定遵循约 150~200 条指令,且**每个 token 每次请求都加载**([Humanlayer](https://www.humanlayer.dev/blog/writing-a-good-claude-md) / aihero) | 膨胀不是"浪费",是**稀释**——不相关的指令会实际降低执行质量 | 文件 >30 KB;同一主题超过 5 条规则 | -| P4 | **渐进披露**:入口只留面包屑,细节进 `docs/` 与 skills;大仓用嵌套 AGENTS.md(就近生效,[官方推荐](https://agents.md/)) | 用到才加载 = 零常驻成本。本仓库把串口助手、模拟器整节移进 skill 后入口各减数 KB 而能力无损 | 入口里出现完整命令表/参数表/FAQ | -| P5 | **描述能力,不写易漂移路径**(aihero):目录地图只到**顶层稳定边界**,细节让 agent 自己 `ls` | 深层路径随重构漂移,agent 会**拒绝相信眼前的真实代码**去服从过期地图 | 入口罗列到三级以下的文件清单 | -| P6 | **每条 = 禁令/事实 + 一句为什么 + 指针**(本仓库实测) | "为什么"让 agent 能外推到未列举的场景;只有禁令会催生新坑的新禁令(回 P3 雪球) | 只有"禁止 X"没有半句原因;或大段叙事讲踩坑史 | - -## 4. 推荐结构(模板) - -综合[官方常用节](https://agents.md/)(Project overview / Build & test / Code style / Testing / Security)与 airflow 实践: - -``` -# 项目名 -一句话定位(P1)+ 平台/工具链一行 + 最高频命令一行(如"改完必须 build") - -## 项目概况 产品现状一句话(含最大缺口)· 数据/调用主线(能力级,P5) - · 顶层目录地图(目录|职责|可改性 三列)· 分层边界 -## 架构要点 运行时视图(任务/线程/启动顺序),机器生成表用标记包裹(§5) -## 操作规则 编辑铁律 · 构建与测试命令(含退出码判读)· 提交门禁 -## 领域约束 平台/硬件/协议陷阱——每条带为什么(P6),按主题分组 -## 索引 按主题查哪份文档(表格;子域知识入口=skills) -``` - -要点:**构建/测试命令必须给全**(官方:agent 会自动执行文中列出的检查命令并在完成前修复失败);**边界用"必须/禁止"级措辞**——airflow 的 `Never` / `Ask first` 两栏清单是范例。 - -## 5. 机器可读契约(本仓库经验) - -入口文件里凡是被脚本/钩子消费的部分,用**显式标记**声明契约,人改脚本改都撞不上: - -- 生成表用锚点包裹:``,由 `--check` 钩子校验与代码一致(同类:`MEM_SNAPSHOT:BEGIN/END`)→ `代码规范/pre-commit使用说明.md` -- **段名是跨文档引用键**:其它文档以「AGENTS.md 平台约束」这类**段名**做引用,改名 = 静默断链。重构时段名保持稳定,删节可以、改名要全库 grep 引用 -- 入口自身纳入 `doc-links` 类门禁:文中路径全部可验证存在 - -## 6. 不该进入口的内容(反模式清单) - -| 反模式 | 为什么坏 | 去处 | -|---|---|---| -| **规则雪球**(每次 agent 犯错加一条) | [aihero](https://www.aihero.dev/a-complete-guide-to-agents-md):数月后成 ball of mud,互相矛盾、无人通读 | 同类规则合并成一条带"为什么"的总则 | -| **自动生成全量 dump**(`/init` 一把梭) | 生成器追求"面面俱到"而非克制,等于开局就把预算花光 | 生成后必须人工按 P3 裁剪 | -| **历史叙事**(日期、commit 号、踩坑过程) | 决策依据进 changelog/问题记录;入口只留结论 | `docs/问题记录/`、文档变更记录 | -| **从 docs 抄全表**(引脚表、命令表、FAQ) | 双写必漂移,违反单一事实源 | 一行指针(本仓库「完成即销项」约定) | -| **个人偏好**(某人的缩进口味) | 每次请求全员付费 | 个人级 agent 配置 / editorconfig | -| **过期路径与旧工具名** | 比没有更糟——agent 会服从它 | 定期瘦身(§7) | - -## 7. 维护纪律 - -1. **单一事实源**:同一事实只在一处详述,其余位置用链接指向(本仓库 `docs/文档规范.md` 同此要求)。 -2. **瘦身方法论**(本仓库实测,先审计后动刀):全量条目普查 → 逐条核对去处(docs/skills 是否已完整承接)→ 缺口先迁移 → 再删除 → 跑链接/一致性门禁。**禁止**没核对去处就删。 -3. **skills 是披露层**:子域工作流(构建工具、串口、模拟器…)整段进 skill,入口索引行留名——目录常驻、全文用到才加载。 -4. **改完跑门禁**:链接存在性、生成表一致性、表格格式、编码(本仓库 `check_doc_links.py --all` + `pre-commit`)。 -5. **体量红线**:入口超过 ~30 KB 就触发瘦身流程(P3);新增一节前先问"这条是否**每次**都需要在场"。 - -## 8. 本仓库案例:51 KB → 25 KB - -| 步骤 | 动作 | 结果 | -|---|---|---| -| 1 普查 | 脚本统计各节行数/字节数,49 个主题逐条核对 docs 承接情况 | 48/49 主题文档已有完整承接,9 条入口独有 → 先迁移 | -| 2 迁移 | 叙事史/日期/commit 号回写各设计文档;串口、模拟器两节移入 skills | 零信息损失(逐节 diff 验证) | -| 3 删除 | 入口重写:禁令 + 一句为什么 + 指针 | 51,067 → 21,795 B(−57%) | -| 4 重构 | 按 P1/P2 补一句话定位 + 项目概况(数据主线/目录地图/分层边界),平台约束 17 条分 5 组 | 25,306 B,段名与机器标记全保留,门禁全绿 | - -**教训**:瘦身最大的风险不是删错,而是**删了唯一副本**——所以"先核对去处"必须先于任何删除;第二大风险是**改段名断引用**——跨文档按段名引用在 Markdown 链接检查里不可见。 - -## 9. 质量自查清单 - -- [ ] 第一行能否让新 agent 说出"这是什么、跑在哪、用什么工具链"? -- [ ] 前五节之内能否回答"代码怎么摆、哪些目录不能碰"? -- [ ] 每条规则是否带"为什么"(或本身自明)? -- [ ] 有没有任何一条在 docs/skills 已有更全版本?(有 → 删,留指针) -- [ ] 构建/测试命令是否可直接复制执行、含退出码判读? -- [ ] 生成表/锚点标记是否完好?段名是否未改(或已全库同步引用)? -- [ ] 文件多大?>30 KB 触发瘦身。 -- [ ] 最近一次 agent 犯错,是"缺规则"还是"规则太多没执行"?(后者 → 删规则而不是加规则) - -## 10. 参考资料 - -| 来源 | 要点 | -|---|---| -| [agents.md 官方规范](https://agents.md/) | 定位/常用节/嵌套文件/60k+ 项目案例(openai/codex、apache/airflow 等) | -| [A Complete Guide To AGENTS.md](https://www.aihero.dev/a-complete-guide-to-agents-md)(Matt Pocock) | 指令预算、渐进披露、能力>路径、反雪球、skills 披露层 | -| [Writing a good CLAUDE.md](https://www.humanlayer.dev/blog/writing-a-good-claude-md)(Humanlayer) | "instruction budget"概念出处:前沿模型约 150~200 条指令 | -| [apache/airflow AGENTS.md](https://github.com/apache/airflow/blob/main/AGENTS.md) | 标杆结构:Repository Structure / Architecture Boundaries / Ask first–Never / References | diff --git a/docs/README.md b/docs/README.md index 9d136a60..57e189e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,6 @@ docs/ ├── 测试说明/ 测试流程、模块测试指南、工具说明 ├── 代码规范/ 编码规范与静态检查门禁 ├── 文档规范.md 本文档体系的规范(命名/链接/门禁/结构) -├── AGENTS编写指南.md 如何写好 AGENTS.md(入口文件的取舍、结构与维护纪律) ├── 内存管理/ RAM 预算、分配策略与优化调研 ├── 问题记录/ 模块陷阱台账、专项审计与项目评审报告 ├── 环境与部署/ 环境搭建与部署核对清单 @@ -169,7 +168,6 @@ docs/ | 文档 | 内容 | |---|---| | [`文档规范.md`](文档规范.md) | **文档体系强制约定**:目录/命名(无空格)、编码换行、链接形态(互链用根相对、图片用目录相对)、`[计划]` 标记、门禁三档、结构要素与常见错误速查 | -| [`AGENTS编写指南.md`](AGENTS编写指南.md) | **如何写好 AGENTS.md**(agent 入口文件):定位与一句话锚点、六条核心原则(指令预算/渐进披露/能力>路径)、推荐结构模板、机器可读契约、反模式清单、瘦身方法论 + 本仓库 51KB→25KB 实测案例与外部标杆调研(agents.md 官方 / airflow / aihero) | ### 7.2 内存管理(**新增任务/缓冲/栈前必读**) @@ -254,7 +252,7 @@ docs/ | 问题 | 位置 | 说明 | |---|---|---| -| 指向未实现工具 | `UI_界面制作流程.md`、`UI_自动化测试流程.md` → `tools/ui_mock/` | 该目录**始终不存在**;golden 回归实际落在 `simulator/tests/`(四脚本)。引用待改指,见报告 §6.1 | +| 指向未实现工具 | `UI_界面制作流程.md`、`UI_自动化测试流程.md` → `tools/ui_mock/` | 该目录**始终不存在**;golden 回归实际落在 `simulator/tests/`(四脚本)。引用待改指,见 `docs/问题记录/项目审查与优化建议报告.md` §6.1 | | 产物缺失 | `内存管理/RAM使用分析与优化方案.md` → `scripts/ram_baseline.tsv` | 方案写了、工具未建(引用已加 `[计划]` 标记) | | ~~目录名含空格~~ | ~~`UI 设计/`~~ | ✅ 已修复(2026-09-25,D-6):改名为 `UI设计/`,全仓库 67 处引用同步;命名约定见 [`文档规范.md`](文档规范.md) §1 | | ~~缺一致性门禁~~ | — | ✅ 已落地(2026-09-25/26,D-7 三档齐):`scripts/check_doc_links.py`(存在性)+ `scripts/gen_task_table.py`(数值)+ `scripts/check_mem_numbers.py`(体积),接入 pre-commit `doc-links` / `task-table` / `mem-numbers` 钩子 | @@ -268,10 +266,11 @@ docs/ | 日期 | 变更 | 说明 | |---|---|---| -| 2026-10-11 | 新增文档 | 新建 [`AGENTS编写指南.md`](AGENTS编写指南.md)(好的 AGENTS.md 怎么写:六原则/模板/反模式/维护纪律 + 本仓库瘦身重构案例与外部标杆调研);本索引 §1 目录树与 §7.1b 登记;同日 `AGENTS.md` 按该指南完成宏观重构(补项目概况/目录地图、平台约束分组) | +| 2026-10-11 | 功能 | N-19 FTP 鉴权:`/config.ini` 新增 `[ftp]` 段(`user` / `pwd_hash` = SHA-256(user‖password) / `write`)+ 恒定时间比较 + 连续 5 次失败锁 60 s + 未登录命令门;出厂默认 admin/admin,默认口令在用有启动告警。新增 `App/util/sha256.*`、`Drivers/BSP/NETCFG/ftp_cfg.*`、`Drivers/BSP/NET/lftpd/lftpd_auth.*`;主机侧单测 `test/host/sha256/`(13 例)+ `test/host/ftp_auth/`(36 例),pre-commit 钩子 26→28;台架脚本 `test/host/ftp_auth_bench.py`。文档同步:`docs/子系统设计/网络配置说明.md`、`docs/架构设计/STM32F4-Base存储架构说明.md`、`docs/代码规范/pre-commit使用说明.md`、`docs/产品需求/产品需求设计文档.md` | +| 2026-10-11 | 文档调整 | `AGENTS编写指南.md` 新建后**同日删除**(要点已沉淀入 `AGENTS.md` 宏观重构与本索引变更记录,指南类内容不单独成文);撤销 §1 目录树与 §7.1b 登记;同日 `AGENTS.md` 完成宏观重构(补项目概况/目录地图、平台约束分组、边界块) | | 2026-10-10 | 新增子系统设计 | 新建 `docs/子系统设计/ADC采样节拍设计说明.md`(ADC 采集链路 1 kHz 硬件定时器触发的 CubeMX 配置规格与代码改造清单,B1 遗留项规划);本索引 §5.1 补登记 | | 2026-10-10 | 新增算法专题 | 新建 `docs/算法/继电保护功能复现规划.md`(保护链路复现总规划);本索引 §1 目录树补 `算法/`、§5 拆为 5.1 功能子系统 / 5.2 算法专题并登记 `算法/` 两篇文档 | -| 2026-10-05 | 新增产品需求文档 | 新建 `docs/产品需求/产品需求设计文档.md`(面向产品的需求基线);本索引新增 §3 产品需求、§2 推荐阅读顺序置顶、§1 目录树补 `产品需求/`;后续章节号顺延(架构设计 §4 … 变更记录 §12) | +| 2026-10-05 | 新增产品需求文档 | 新建 `docs/产品需求/产品需求设计文档.md`(面向产品的需求基线);本索引新增 §3 产品需求、§2 推荐阅读顺序置顶、§1 目录树补 `产品需求/`;后续章节号顺延(本文 §4 … 本文 §12) | | 2026-09-25 | 目录重组 + 索引建立 | 顶层 16 篇散落 md 归入 5 个新目录(`架构设计/` `子系统设计/` `网络/` `审查记录/` `环境与部署/`);全仓库 90 处引用同步;新建本索引(D-5) | | 2026-09-25 | D-6 目录去空格 | `docs/UI 设计/` → `docs/UI设计/`;全仓库 67 处引用同步(含 URL 编码形态 `UI%20设计`);命名约定写入 §10 | | 2026-09-25 | D-7 文档-代码一致性门禁 | 新建 `scripts/check_doc_links.py`(链接存在性,含行内代码豁免/就近状态词/`[计划]` 标记)、`scripts/gen_task_table.py`(任务表由 `Src/freertos.c` 生成/校验,锚点 `TASK_TABLE:BEGIN/END`);接入 pre-commit `doc-links`/`task-table` 钩子;全库 66 文档 0 失效 | diff --git a/docs/UI设计/UI_界面制作流程.md b/docs/UI设计/UI_界面制作流程.md index 3f4bd9da..1d82867a 100644 --- a/docs/UI设计/UI_界面制作流程.md +++ b/docs/UI设计/UI_界面制作流程.md @@ -1,7 +1,7 @@ # UI 界面制作流程(参考图 → LVGL) > **用途**:规范"参考图 → 设计规格 → LVGL 代码 → 双端验证"的完整管线,规定每一步的**动作、产物、放行条件(gate)**,保证过程可重复、可交接。 -> **适用**:所有 UI 新界面 / 界面大改;小改动走 §10 快速通道。 +> **适用**:所有 UI 新界面 / 界面大改;小改动走「小改动快速通道」一节(见文末第十节)。 > **配套文档**:`UI_设计架构.md`(系统如何工作)、`菜单页面设计.md`(菜单/导航行为与数据流设计)、`LVGL模拟器部署与使用说明.md`(模拟器)、`UI_中文字体生成与配置.md`(字体生成)、`UI_自动化测试流程.md`(P2/P3 回归门禁的可执行版本:脚本 + 池水位常量 + 黄金参考更新)。 > **首个实例**:界面 01 主界面(根页),规格 `mockups/01_主界面_说明.md`(本文所有示例数字均取自该试点)。 @@ -24,7 +24,7 @@ flowchart TD P3 -- "Gate: @build 0 错 0 警 + @test PASS + 上机目检通过" --> P4(["P4 归档
→ 变更记录/台账 + 随代码提交(代码+字体+字集+规格+文档)"]) ``` -> 小改动(文案/间距/纯交互/字体重生成)走 §10 快速通道,无需全流程。 +> 小改动(文案/间距/纯交互/字体重生成)走「小改动快速通道」一节(见文末第十节),无需全流程。 ### 1.1 各阶段职责分工 @@ -160,7 +160,7 @@ flowchart TD | 黑/白背景 | `lv_style_set_bg_color` + `lv_style_set_bg_opa` | 1bpp 仅纯黑/纯白 | | 文本 | `lv_label_create` + `lv_label_set_text_static` | 静态字符串(C5) | | 文字对齐 | 标签定位 / `lv_label_set_long_mode` | 默认左对齐(见 §3.6 推论) | -| 选中反色 | `LV_STATE_CHECKED` 样式(bg/文字色互换) | 条目对象**自身**带 CHECKED(防白底白字,架构 §5.5) | +| 选中反色 | `LV_STATE_CHECKED` 样式(bg/文字色互换) | 条目对象**自身**带 CHECKED(防白底白字,`UI_设计架构.md` §5.5) | | 隐藏状态 | `LV_OBJ_FLAG_HIDDEN` | 页面/状态切换 | | 圆角/阴影/透明/渐变 | **禁止**(C8) | 规格里就不该有 | @@ -175,8 +175,8 @@ flowchart TD ### 4.3 翻译纪律 1. **逐参数翻译**:代码中每个 px 值必须能在规格"布局参数表"中找到出处;禁止"看着办" 2. 样式沿用现有 `s_style_flat` 模式(静态样式、一次性初始化、后加优先覆盖主题) -3. 焦点对象恒为**页面容器**;条目不入输入组(架构 §5.4) -4. 容器**永不删除**(indev 释放事件 use-after-free,架构 §5.2);只建/毁框体(行自绘、无对象) +3. 焦点对象恒为**页面容器**;条目不入输入组(`UI_设计架构.md` §5.4) +4. 容器**永不删除**(indev 释放事件 use-after-free,`UI_设计架构.md` §5.2);只建/毁框体(行自绘、无对象) 5. 编码纪律(C6):Edit 工具精确替换;文件保持 UTF-8 BOM 不变 6. 注释按代码规范 V1.0:文件头模块块 + 函数头块(功能/参数/返回/限定/说明) @@ -241,7 +241,7 @@ flowchart TD ### 5.4 Gate(进入 P3) - [ ] 模拟器出图 `shot.png` 与规格参数表逐项一致、与参考图结构一致(§5.2,忽略 5.3) -- [ ] 导航行为符合键位语义表(架构 §3.4) +- [ ] 导航行为符合键位语义表(`UI_设计架构.md` §3.4) - [ ] 自动化回归 PASS:`check_menu_rows.py` + `check_content_pages.py`(`UI_自动化测试流程.md`) --- @@ -307,17 +307,17 @@ flowchart TD | 用了字体子集外的字 | 该字显示空白 | P0 字符集核对 + P1 字体重生成 | | 字体缺字形(如宋体无 ▶) | 该字符显示成方框 □ | `make_font.py` 已内置 `merge_arrow_glyph()` 自动补回;其余缺字形同法(从有该字的字体提取位图合并,§4.4) | | 新 .c 只注册了 Keil(或只改了 CMake) | 单端编译缺函数/静默缺失 | 双端注册(C9) | -| 模拟器主循环调 `SDL_PollEvent` | 键盘全部无响应 | 事件只由 LVGL SDL 驱动内部轮询(架构 §5.7) | -| 选中行"白底白字" | 选中文字不可见 | 历史坑(2026-09-02):子 label 自身 card 样式总命中、继承不到父选中色;现行行自绘(绘制时给色、无样式继承)已根除(架构 §5.5) | -| 按键处理里删除焦点容器 | use-after-free 崩溃 | 容器持久不删(架构 §5.2) | +| 模拟器主循环调 `SDL_PollEvent` | 键盘全部无响应 | 事件只由 LVGL SDL 驱动内部轮询(`UI_设计架构.md` §5.7) | +| 选中行"白底白字" | 选中文字不可见 | 历史坑(2026-09-02):子 label 自身 card 样式总命中、继承不到父选中色;现行行自绘(绘制时给色、无样式继承)已根除(`UI_设计架构.md` §5.5) | +| 按键处理里删除焦点容器 | use-after-free 崩溃 | 容器持久不删(`UI_设计架构.md` §5.2) | | LVGL 池爆 40KB | 对象创建失败/画面残缺 | §4.5 预算 + 按需建毁 | | 极性配反 | 黑屏/白屏/不可读 | 模拟器 mono 主题 `dark_mode` 参数 + MCU 端背景色一致 | | 步进宽接近/超出格宽的标签居中 | 亚像素错位/不对称 | 左对齐 + 字体侧边距(§3.6) | -| 关闭了 `LV_DRAW_SW_COMPLEX`(省代码) | `lv_line` 斜线不绘制(如主界面顶部"浅 V"两腿消失,只剩水平线) | `LV_DRAW_SW_COMPLEX` 两端显式 =1;`LV_DRAW_SW_CIRCLE_CACHE_SIZE` 须非零(默认 4,否则零长数组 C2466)(架构 §关键约束 7) | +| 关闭了 `LV_DRAW_SW_COMPLEX`(省代码) | `lv_line` 斜线不绘制(如主界面顶部"浅 V"两腿消失,只剩水平线) | `LV_DRAW_SW_COMPLEX` 两端显式 =1;`LV_DRAW_SW_CIRCLE_CACHE_SIZE` 须非零(默认 4,否则零长数组 C2466)(`UI_设计架构.md` §关键约束 7) | | 斜线用细 `line_width`(如 2px) | 1bpp 阈值吃掉斜线边缘,腿偏细、比设计稿细 | 斜线加粗(腿 3px),或按段拆 `lv_line` 分粗细(如"浅 V"两腿 3 / 水平线 2) | | 规格引入翻译不出来的效果 | P1 翻译不出来 | 1bpp 禁区(C8),P0 阶段就拦下 | | UI 源文件(UTF-8 BOM)里 `snprintf` 格式串含中文 | 模拟器 cl.exe 报 C4819(代码页 936 误读 UTF-8 字节) | 格式化输出用 `lv_snprintf`(LVGL 自带,双端一致),勿用标准库 `snprintf` | -| 常驻样式用动态 `lv_style_t` + set_prop | LVGL 池永久占用:首次 set_prop 分配 `values_and_props` 且永不释放(x64 实测 88 B/个) | 常驻样式一律常量样式:`LV_STYLE_CONST_INIT(var, props[])` + `lv_style_const_prop_t[]`(属性存 Flash、零池分配,见架构 §5.6) | +| 常驻样式用动态 `lv_style_t` + set_prop | LVGL 池永久占用:首次 set_prop 分配 `values_and_props` 且永不释放(x64 实测 88 B/个) | 常驻样式一律常量样式:`LV_STYLE_CONST_INIT(var, props[])` + `lv_style_const_prop_t[]`(属性存 Flash、零池分配,见`UI_设计架构.md` §5.6) | --- diff --git a/docs/产品需求/产品需求设计文档.md b/docs/产品需求/产品需求设计文档.md index 35818f54..595f9ad3 100644 --- a/docs/产品需求/产品需求设计文档.md +++ b/docs/产品需求/产品需求设计文档.md @@ -465,7 +465,7 @@ sequenceDiagram | 服务 | 协议 / 端口 | 用途 | 核对方法 | |---|---|---|---| -| FTP | TCP **21** | 文件上传/下载(根目录 `/`,NAND 存储) | 任意 FTP 客户端列目录/读写 | +| FTP | TCP **21** | 文件上传/下载(根目录 `/`,NAND 存储) | 登录后任意 FTP 客户端列目录/读写(鉴权见 §7.6) | | 墙钟对时 | TCP **8888** | PC 主动推送时间到设备 | 对时后核对设备时间 / 日志时间戳 | | 远程显示(RemoDispBus) | TCP **7003** | 远端看图(160×160 1bpp)+ 远端导航按键 | DTU-RemoteLCD 或任意 TCP 客户端连 7003 收帧 | @@ -512,6 +512,7 @@ sequenceDiagram - **实现**:lftpd(轻量 FTP 服务器),root = `/`(FatFS 卷根),端口 21,**单客户端**。 - **支持命令**:登录 / TYPE I / PASV / STOR / RETR / LIST / DELE / QUIT。 +- **鉴权**(已鉴权 · 2026-10-11,N-19):口令登录(`[ftp]` 段 `user` / `pwd_hash` = SHA-256(user‖password) / `write`);未登录仅 USER/PASS/QUIT/FEAT/SYST/NOOP 放行,其余一律 530;`write=0` 可禁 STOR/DELE;连续 5 次失败锁 60 s;出厂默认 `admin`/`admin`,默认口令在用有启动告警。口径详见 `docs/子系统设计/网络配置说明.md` §3.4。 - **启动门**:`ftpTask` 必须等 `NET_READY` **且** `STORAGE_READY`(两位 `NoClear`)才 `lftpd_start`。 - **注意**:同一时刻只服务 1 个会话,旧会话异常断开后约 30 s 恢复监听;测试脚本须内置连接重试,**不要用裸 TCP 工具探测 21 端口**。 - **实测**:128 KB 上传下载逐字节一致(约 49/60 KB/s)。 @@ -678,7 +679,7 @@ cfg-file-unit-test、test-suite-switch、gitlint。 | 项 | 现状 | |---|---| -| FTP 鉴权 | **无**(维持现状,非本期目标) | +| FTP 鉴权 | **已鉴权**(2026-10-11,N-19):口令登录(`[ftp]` 段存 SHA-256 摘要,不存明文)+ 连续 5 次失败锁 60 s + `write=0` 可选只读;出厂默认 `admin`/`admin`,默认口令在用有启动告警。边界:FTP 明文传口令,挡未授权访问、不挡网络嗅探(详见 `docs/子系统设计/网络配置说明.md` §3.4) | | UI 菜单权限 | 受保护叶子(行尾 `#P`)先经密码页;密码**仅编译期写入**(查看 `000` / 修改 `123`),不落 Flash、无运行期设置页 | | 远程显示 | 无鉴权,单连接;现场同网段部署 | @@ -778,24 +779,10 @@ cfg-file-unit-test、test-suite-switch、gitlint。 ### 9.2 阶段路线图 -```text -【阶段 B 功能】 B0 产品定义确认 ✅ ─┬─ B3 E-59 门禁固化(并行) -(当前最高优先) └─ E-58 原子快照 → B1 ADC ✅ → B2 RS485 - ↓ -【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线 ─ C4 栈(S-1) ─ C5 收口 ─ C6 USART1 TX-only - ↓ -【阶段 D 常态】 D2 N-3 L5 基线重锁 / D5 N-16 负向台架 / D6 N-17 探针窗口 -``` - -| 阶段 | 关键任务 | 前置 | -|---|---|---| -| **B0** | 产品定义确认 ✅(2026-10-10):持续采集 / 1000 Hz·20 点周波 / 上行 IEC 60870-5-104(2404)/ RS-485 对下 ModBus RTU;详见 `docs/算法/继电保护功能复现规划.md` §1.5 | — | -| **B1** | ADC 采集链路实现(周期 CONVST + `adc_task` 消费;**先修原子快照 API**) | ✅ 已实现并 `@test` 闭环(2026-10-10) | -| **B2** | RS-485 采集链路实现(双缓冲 + 序号 + DE 收尾 + 协议状态机) | B0 + E-59 门禁 | -| **B3** | E-59 门禁固化入 `AGENTS.md`(接消费者前先修缺陷 + 栈预算) | — | -| **C1** | pending 二期(ACCEPT + SEND pending 化) | — | -| **C4** | `defaultTask` 栈余量(**当前 10%,需尽快处理**):4096→4608 或 GC 链局部削减 | — | -| **C6** | USART1 TX-only 结构根治(灭 ORE 僵尸机制) | 单开一轮 | +> **顺序唯一权威 → [`../待处理内容.md`](../待处理内容.md) §2(统一推进顺序,波次 W1~W8)**。 +> 本节自 2026-10-11 起不再维护路线图(原阶段图删除,避免与登记簿漂移;原 C4"defaultTask 栈余量 10%" +> 已于 2026-10-10 削链闭环销项,见 `../内存管理/RAM使用分析与优化方案.md`)。 +> 功能缺口口径见 §9.1;B0 产品定义结论见 `../算法/继电保护功能复现规划.md` §1.5。 ### 9.3 未接线开关说明 diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index ac0e2755..bdb274b5 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -24,7 +24,8 @@ git commit ├─ lv-conf-sync 两份 lv_conf.h 特性子集一致性(比对生效值) ├─ cubemx-survival CubeMX 重生成存活审计(易失定制清单 + 基线漂移) ├─ watchdog-unit-test 看门狗判定状态机主机侧单测(L0.5) - └─ test-suite-switch 测试套件开关=产品构建(防测试固件静默提交) + ├─ test-suite-switch 测试套件开关=产品构建(防测试固件静默提交) + └─ registration-check 双端注册反向校验(uvprojx + CMakeLists,N-23) └─ commit-msg(.git/hooks/commit-msg) └─ gitlint 校验 commit message 格式 ``` @@ -48,6 +49,7 @@ git commit | scripts/check_mem_numbers.py | 随仓库提交 | 内存/Flash 数字一致性(纯 Python,无依赖) | | scripts/check_cubemx_survival.py | 随仓库提交 | CubeMX 重生成存活审计(纯 Python,无依赖) | | scripts/check_test_suite.py | 随仓库提交 | 测试套件开关门禁(纯 Python,无依赖) | +| scripts/check_registration.py | 随仓库提交 | 双端注册反向校验(纯 Python,无依赖) | > **PATH 要求**:cppcheck 安装后需在 PATH 中可找到(winget 自动写入 User PATH,但当前终端不会立即生效,需**重开终端**);装在非 PATH 位置时可用 `DTU_CPPCHECK` 环境变量指向 `cppcheck.exe` 完整路径(显式设置但无效 → 硬报错,不静默回退)。 @@ -334,6 +336,41 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 | **触发范围** | `Lib/lv_conf.h`、**`simulator/lv_conf.h`** 或脚本自身变动 → 两端任一改动都会跑。⚠ 早期版本只能由 MCU 端触发,因为顶层 `exclude` 整目录挡掉了 `simulator/`(已删,见 §5);pre-commit 4.x 又移除了 `always: true`(实测对 `local` 钩子只告警并跳过),所以没有"总是跑"的兜底写法——**必须让文件模式覆盖两端** | | **首次落地实测** | 两端共清掉 **26 个 9.3 不存在的选项**(LVGL 8 / 9.4 模板残留)、3 处同文件重复定义,并把 10 项 MCU 端靠默认值隐式生效的开关显式固化为 0(含默认 =1 的 `LV_USE_OBSERVER` / `LV_USE_DRAW_PXP` / `LV_USE_IMAGEBUTTON`)。本次清理对镜像体积**中性**(那些 TU 本就不在 uvprojx,或编成 0 字节空对象),价值在防漂移与可读性 | +### 2.16 sha256-unit-test — SHA-256 主机侧单测(2026-10-11 加,N-19) + +| 项 | 说明 | +|---|---| +| **触发条件** | `App/util/sha256.*`、`test/host/sha256/**` 或 `test/host/common/unit_test_harness.py` 变动 | +| **处理方式** | **阻止提交**(单测失败即拦截) | +| **被测对象** | `App/util/sha256.c/h`(FIPS 180-4 实现,零堆分配)——FTP `pwd_hash` 校验的地基 | +| **用例** | 13 例:FIPS 180-4 标准向量(空串 / `abc` / 56 B 补位跨界 / 1 MB)+ `hex_eq` 非法输入 | +| **为什么需要** | 哈希算错是"正确口令被拒 / 错误口令放行"的**静默语义错误**,板端只见 `530`/`230` 看不出根因;主机侧毫秒级钉死。零平台依赖,无桩 | +| **运行器** | `test/host/sha256/run_unit_test.py`(MSVC `cl.exe`,L0.5) | + +### 2.17 ftp-auth-unit-test — FTP 鉴权状态机主机侧单测(2026-10-11 加,N-19) + +| 项 | 说明 | +|---|---| +| **触发条件** | `Drivers/BSP/NET/lftpd/lftpd_auth.*`、`App/util/sha256.*`、`test/host/ftp_auth/**` 或 `test/host/common/unit_test_harness.py` 变动 | +| **处理方式** | **阻止提交**(单测失败即拦截) | +| **被测对象** | `Drivers/BSP/NET/lftpd/lftpd_auth.c/h`(纯函数鉴权状态机,tick 由调用方参数注入,零平台依赖) | +| **用例** | 36 例:连续 5 败锁定 / 60 s 解锁 / 成功清零 / tick 32 位回绕 | +| **为什么需要** | 锁定 60 s / 解锁 / 清零 / 回绕是**防口令爆破契约核心**——板端每验一条要真等 60 s、回绕要跑满 49.7 天;纯函数设计(tick 注入)就是为让这些分支主机侧毫秒级穷举 | +| **运行器** | `test/host/ftp_auth/run_unit_test.py`(MSVC `cl.exe`,L0.5) | + +### 2.18 registration-check — 双端注册反向校验(2026-10-12 加,N-23) + +| 项 | 说明 | +|---|---| +| **触发条件** | 任意 `.c` 文件 + `*.uvprojx` + `CMakeLists.txt` + 脚本自身变动(pre-commit 4.x 无 `always: true`,`files:` 必须覆盖全部触发面) | +| **处理方式** | **阻止提交**(存在**新**缺口或幽灵注册即拦截);存量缺口走白名单只 `[WARN]` 不拦 | +| **检查对象** | 扫描 `App/`、`Drivers/BSP/`、`Src/` 下全部 `.c`(排除第三方),对照两份注册表:`MDK-ARM/STM32F407-Demo.uvprojx` 的 `` + `simulator/CMakeLists.txt` 的显式源列表(先展开 `${NETCFG_DIR}` 等变量) | +| **三类缺口** | ① 存在但未进 uvprojx(固件不编译)② 存在但未进 CMakeLists(模拟器不编译,**仅当本应进模拟器时**才算)③ 注册了但文件不存在(**幽灵注册**,改名/删除后残留) | +| **豁免机制** | MCU-only 文件不进模拟器是**正确**的(模拟器另有 `*_sim.c` 替身或无对应)。豁免用**显式清单 + 每行理由**(命名模式 `*_mcu.c` + 个别点名),**不整目录一刀切**(N-23 教训正是目录级盲区);唯一目录级豁免 `Src/`(CubeMX 生成,MCU 专属)→ 见脚本 `SIM_EXEMPT` | +| **存量白名单** | `KNOWN_GAPS` 登记 N-23 存量缺口(`App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`,Lead 处置中)→ 命中打 `[WARN]` 不拦,只对新缺口 FAIL,故钩子可立即上线不拦现有提交;`--strict` 关白名单供审计 | +| **为什么需要** | 新 `.c` 必须手动注册两处才会被编译;历史上 `rs485_task.c`/`lftpd_log.c` **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效(出处审查报告 N-06 残余) | +| **运行器** | `scripts/check_registration.py`(纯标准库;退出码 0=一致 / 1=有缺口 / 2=环境错误) | + --- ## 3. 日常工作流 @@ -461,7 +498,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**26 个钩子**,2026-10-11 清点;本文 §2 详解非单测类,主机侧单测 10 个见 AGENTS.md 门禁条) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**29 个钩子**,2026-10-12 清点;本文 §2 详解非单测类——N-19 新增的两个单测钩子见 §2.16/§2.17,N-23 双端注册反向校验见 §2.18,其余主机侧单测见 AGENTS.md 门禁条;主机侧单测共 12 个) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 5f8f31b0..c4d04668 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 210,148 B (205.22 KB) | 1,048,576 B (1024 KB) | 20.0% | -| RW_IRAM1 | 91,536 B (89.39 KB) | 131,072 B (128 KB) | 69.8% | +| ER_IROM1 | 213,256 B (208.26 KB) | 1,048,576 B (1024 KB) | 20.3% | +| RW_IRAM1 | 94,768 B (92.55 KB) | 131,072 B (128 KB) | 72.3% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **211,024 B (206.1 KB)** | 1,048,576 B (1024 KB) | **20.1%** | +| **Flash (ROM 合计)** | **214,224 B (209.2 KB)** | 1,048,576 B (1024 KB) | **20.4%** | 各区的容量与常量来源: @@ -203,7 +203,7 @@ | 堆测量 | `xPortGetFreeHeapSize()` / `xPortGetTotalHeapSize()` 随时可查;heap_4 支持 free 但有碎片化,需对比"理论余量 vs 实测 free"观察碎片 | FreeRTOS 内核源码 `heap_4.c`(工程内);[FreeRTOS 文档](https://docs.freertos.org/) | | 运行时统计 | `vTaskGetRunTimeStats` 需 `configGENERATE_RUN_TIME_STATS=1` + `portCONFIGURE_TIMERS_FOR_RUN_TIME_STATS`(占用一个硬件定时器),给出每任务 CPU 占比 | 本工程内核 `task.h:1694-1756` 注释 | | LVGL 缓冲 | 官方文档:PARTIAL 渲染模式"buffers can be smaller than the display to save RAM,**至少 1/10 屏**";单色 I1 格式缓冲 = `(W×H/8)+8`;双缓冲可并行渲染/搬运 | LVGL v9.2 文档 [Display interface](https://github.com/lvgl/lvgl/blob/release/v9.2/docs/porting/display.rst)(lvgl.io/docs/porting/display 的源文件) | -| STM32F4 CCM | F407 含 64KB CCM @0x10000000;**只能由 CPU 访问,DMA 不可达**,无 cache/ECC;适合纯 CPU 读写的大缓冲 | ST RM0090(STM32F405/407/415/417/42x 参考手册)§3.1.1 | +| STM32F4 CCM | F407 含 64KB CCM @0x10000000;**只能由 CPU 访问,DMA 不可达**,无 cache/ECC;适合纯 CPU 读写的大缓冲 | `docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md` §3.1(RM0090 原文该小节为 §3.1.1 CCM,本仓摘录只到 §3.1) | | FatFs | `FF_FS_TINY=1`:FIL 结构体去掉 `buf[FF_MAX_SS]` 私有扇区窗,改用卷内共享 9 字节窗口(FAT32),每打开文件省 512B | 本工程 `Lib/FatFs/ff.h` FIL 结构注释、`Lib/FatFs/ffconf.h:32` | | 静态占用测量 | Keil map 文件 "Image component sizes" 给出每个 .o 的 RO/RW/ZI 精确字节数;`fromelf --image_info xxx.axf` 可输出各 Region 用量 | 本工程 `STM32F407-Demo.map` "Image component sizes" 表(行号随每次重建变化,勿引用行号) | @@ -344,7 +344,7 @@ void vApplicationMallocFailedHook(void) - 帧宽榜直接点名宽度大户(自身帧):`s_append` 704 B(FIL 512 + 记录 64 + 时间戳)、`StartDefaultTask` 560 B(探针 `FIL __chk`)、`cfg_store_read`/`cfg_store_write_atomic` 576 B、LVGL 绘制 624/560/400 B——与 §1.4 的整改项对得上。**lftpd 三项已于 2026-09-21 静态化(§1.4),已退出帧宽榜前 15**(原 `handle_control_channel` 1104 B、`receive_file`/`send_file` 1088 B、`send_response` 800 B)。 - **静态分析的天然盲区**:递归/互递归(LVGL widget 树、`layout_update_core`、`apply_theme` 等)与间接调用只能按 "Cycle" 近似,armlink 明确标注 `+ Unknown(Cycles, Untraceable Function Pointers)`。因此 uiTask 的 912 B 是**下限**(实测 3824 B),必须靠运行时水位。 - testTask 在产品构建里只是 `osDelay` 空循环(168 B);2048 words 是给**存储套件构建**(`TEST_SUITE_STORAGE`)预留的,测套件时应重跑本脚本看该构建的静态值。 -- **测 ftpTask 的复现配方**:设备就绪后跑 `& '.venv\Scripts\python.exe' test/host/ftp_speed_test.py --size 128`(STOR 128KB + RETR + 逐字节比对),再按上面命令叠加串口日志出报告。**不要用裸 TCP 工具探测 21 端口**——CH395F 单客户端复用,探测会占掉唯一会话(见 FTP 指南 §8.9)。 +- **测 ftpTask 的复现配方**:设备就绪后跑 `& '.venv\Scripts\python.exe' test/host/ftp_speed_test.py --size 128`(STOR 128KB + RETR + 逐字节比对),再按上面命令叠加串口日志出报告。**不要用裸 TCP 工具探测 21 端口**——CH395F 单客户端复用,探测会占掉唯一会话(见 `docs/测试说明/FTP_Test_Guide.md` §8.9)。 #### 2026-10-10 复测:**不抬栈**把 defaultTask 从 FAIL 拉回 PASS(S-1 销项) diff --git a/docs/内存管理/内存内容分配策略.md b/docs/内存管理/内存内容分配策略.md index 42d7ae71..c7c3f5c1 100644 --- a/docs/内存管理/内存内容分配策略.md +++ b/docs/内存管理/内存内容分配策略.md @@ -24,7 +24,7 @@ 3. CCM 存在性已用 `ST-LINK_CLI -r32 0x10000000` 本机实测可读写;**克隆片可能缺 SRAM3,换料必须重测并回退布局**。 4. 工程不进入 Stop/Standby 低功耗模式,两区域掉电保留行为无差异,不构成分配判据。 -> 勘误(原《STM32F4-Base 内存使用分析报告》2026-09-11 快照,随主文档合并保留于此):该报告内存地图中 "SRAM3 64KB (0x2001_C000)" 为笔误——F407 的 SRAM3 即 CCM,位于 `0x10000000`(AXI);AHB2 侧只有 SRAM1(112KB)+SRAM2(16KB)。**以本文为准**(主文档 §8.4 与 `内存布局_RAM.svg` 均按此口径)。 +> 勘误(原《STM32F4-Base 内存使用分析报告》2026-09-11 快照,随主文档合并保留于此):该报告内存地图中 "SRAM3 64KB (0x2001_C000)" 为笔误——F407 的 SRAM3 即 CCM,位于 `0x10000000`(AXI);AHB2 侧只有 SRAM1(112KB)+SRAM2(16KB)。**以本文为准**(`RAM使用分析与优化方案.md` §8.4 与 `内存布局_RAM.svg` 均按此口径)。 ## 3. 全板 DMA 使用方清点(DMA 缓冲必须为 SRAM 静态) @@ -71,7 +71,7 @@ CCM 64KB 只能容纳 {FreeRTOS 堆 48KB} 与 {LVGL 集合 ~52.5KB} 之一(两 当时提出的两条判据经核实**均为事实但不具区分力**: - "UI 不用 DMA" ✓ 属实(§3 路径核实:DMA 只寻址驱动静态缓冲)→ 堆同样不用 DMA,两候选在 DMA 轴**等价** -- "图形不紧迫速度" ✗ 无效判据(§2.1:M4 无 cache,CCM 与主 SRAM 对 CPU 同速)→ 两候选在速度轴**等价** +- "图形不紧迫速度" ✗ 无效判据(§2:M4 无 cache,CCM 与主 SRAM 对 CPU 同速)→ 两候选在速度轴**等价** 真正可判别的轴是**"该内存损坏后的后果" × "DMA 不可达带来的隔离收益"**: @@ -148,4 +148,4 @@ LVGL 集合明细(均在 SRAM):LVGL 池 40,960B(`lv_mem_core_builtin.o` 已知未决风险: - `.ioc` 未与 `freertos.c` 的栈值直改同步——CubeMX 重新生成会覆盖栈值(已注释标记,未修) -- CCM 存在性依赖芯片批次(克隆片可能缺 SRAM3,见 §2.3) +- CCM 存在性依赖芯片批次(克隆片可能缺 SRAM3,见 §2) diff --git a/docs/内存管理/存储栈RAM优化调研.md b/docs/内存管理/存储栈RAM优化调研.md index d0eaf355..5d436889 100644 --- a/docs/内存管理/存储栈RAM优化调研.md +++ b/docs/内存管理/存储栈RAM优化调研.md @@ -2,7 +2,7 @@ > 版本 v1.1 日期 2026-09-21 作者 AI 辅助调研 > 数据来源:`data/ram_report_20260921.md`(.map 静态占用)+ 逐行代码核验(`nand_ftl.c` / `gd5f2gq5ue.c` / `Lib/dhara` / `Lib/FatFs/ffconf.h` / `lftpd*`) -> 定位:`RAM使用分析与优化方案.md` 的存储专项补充;优化项编号 P0/P1/P2,与总方案 §6 的 S 序号在"规划"一节对齐 +> 定位:`RAM使用分析与优化方案.md` 的存储专项补充;优化项编号 P0/P1/P2,与 `docs/内存管理/RAM使用分析与优化方案.md` §6 的 S 序号在"规划"一节对齐 > v1.1 清理(2026-09-21 同日):移除已落地的 P0-1 BBT 页缓冲收缩(−1,776B,commit `be1a30a`)与编程回读校验整体移除的实施记录(详见 GD5F Trap 05 / 错误码传播审计),以及过期逐模块基线表;仅保留未实施项与回归要求 ## 1. 现状约束(不变,是下述全部方案的依据) @@ -28,7 +28,7 @@ **明确不做**:移除 dhara(丢掉电一致性与坏块重映射,代价不对称);journal checkpoint 分片(违反 dhara 协议);调 `gc_ratio`(无 RAM 收益);`FF_FS_REENTRANT=0`(defaultTask+ftpTask 并发访问 FatFs 必需)。 -## 3. 分阶段规划(与总方案 §6 S 序号对齐) +## 3. 分阶段规划(与 `RAM使用分析与优化方案.md` §6 S 序号对齐) | 阶段 | 内容 | 收益 | 前置 | |---|---|---:|---| diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index 56031d2f..fb4c246d 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -223,7 +223,7 @@ t=1000 µs 下一个 TIM6 更新事件 - 本配置已为此预留:**1 µs 分辨率** + **ARR 预装载使能** → 运行期 `__HAL_TIM_SET_AUTORELOAD(&htim6, 周期µs - 1)` 即可无毛刺改频。 - 需要配套:① 测频源(过零/DFT 相位);② 把当前采样率同步给保护算法(相量/定值); - ③ **消费端改事件驱动**(§6.7)——否则固定周期轮询在非 1 kHz 采样率下会丢帧。 + ③ **消费端改事件驱动**(§10)——否则固定周期轮询在非 1 kHz 采样率下会丢帧。 ✅ **已落地(2026-10-10,§10)**:消费唤醒与 TIM6 同源 → 调频时只改 ARR,消费节拍自动跟随。 --- @@ -330,7 +330,7 @@ t=1000 µs 下一个 TIM6 更新事件 HAL **没有** `HAL_ADCEx_Calibration_Start` → 由 `Src/adc.c` 的 `adc1_calibrate()` 实现 (`USER CODE` 区 = L2,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底)。 实测校准前后同一拍 STM32 侧 6 路**同向**偏移 +10~+63 LSB、TPAFE 侧一字不变 → 此前 ADC 确实 - 带失调在跑。它**不消** 2 ms 忙等,但把"未校准"这条可疑路径从 §7.2 的候选根因里划掉。 + 带失调在跑。它**不消** 2 ms 忙等,但把"未校准"这条可疑路径从本节的候选根因里划掉。 - **启动 fail-safe(设计如此,已按此实现)**:TIM6 未启动 / 停摆 → `adcTask` 永久阻塞于 `osThreadFlagsWait(osWaitForever)` → `TASK_WD_ADC` 缺席 → IWDG 复位。属安全 fail-safe, 非缺陷(确保"节拍源缺失"不被静默放过)。 diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index d0949b4b..1fe6b8c6 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -627,7 +627,7 @@ enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的 | 来源 | 引用点 | |---|---| | `docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md` §21 | 12 位递减计数器、LSI 时钟源、启动后不可停、超时公式、`SR.PVU/RVU` 同步 | -| 同上 §6.3.22(`RCC_CSR`) | `PORRSTF/BORRSTF/PINRSTF/SFTRSTF/IWDGRSTF/WWDGRSTF/RMVF`;"复位标志仅由电源复位清除" | +| `docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md` §6.3.22(`RCC_CSR`) | `PORRSTF/BORRSTF/PINRSTF/SFTRSTF/IWDGRSTF/WWDGRSTF/RMVF`;"复位标志仅由电源复位清除" | | `Drivers/CMSIS/.../stm32f407xx.h` | `IWDG_TypeDef`(KR/PR/RLR/SR)、`IWDG_BASE`、`IWDG_SR_PVU/RVU`、`RCC_CSR_*` 位定义 | | `STM32F407-Demo.ioc` | `Mcu.IP4=IWDG`、`IWDG.Prescaler/Reload`、`RCC.LSI_VALUE=32000`、`FREERTOS.Tasks01`、两个加固宏 | | `Inc/stm32f4xx_hal_conf.h:58` | `HAL_IWDG_MODULE_ENABLED`(已启用) | diff --git a/docs/子系统设计/网络配置说明.md b/docs/子系统设计/网络配置说明.md index e3f123fe..c25ce587 100644 --- a/docs/子系统设计/网络配置说明.md +++ b/docs/子系统设计/网络配置说明.md @@ -1,6 +1,6 @@ # 网口设置页配置说明 -> 版本:v2.2 日期:2026-10-10 +> 版本:v2.3 日期:2026-10-11 > 适用工程:STM32F4-Base(STM32F407ZGTx + CH395F 以太网控制器) > 范围:UI「网口设置」页可配置的网口参数(IP 地址 / 子网掩码 / 默认网关 / MAC 地址)—— > 参数与默认值、持久化、生效链路、页面操作与权限。 @@ -77,6 +77,8 @@ gw=192.168.1.1 mac=00:00:00:00:00:00 ``` +> 出厂模板同时含 `[ftp]` 段(`user` / `pwd_hash` / `write`,见 §3.4)——本块只展示 `[network]` 部分。 + - **加载校验(逐键容错)**:单个键值非法(越界/格式错)不影响其他键——非法键回工厂默认并留痕(`parse: [network] <键> value invalid`);文件缺失 → 自动生成本模板;文件 > 4KB = 损坏 → 工厂默认 + 重建模板(坏配置不带崩网络)。 - **保存(外科手术式)**:注释 / 未知段 / 未知键逐行保留,受管键按 RAM 副本原位改写、缺失键补写;写盘 = 临时文件 `/config.TMP` + `f_rename` 原子替换(掉电不损坏目标文件)。 - **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。 @@ -107,6 +109,47 @@ mac=00:00:00:00:00:00 INI 格式两端一致,文件可互读;mtime 变更令牌:MCU = FAT 打包时间(`fdate<<16\|ftime`,分钟级粒度),模拟器 = 宿主文件 mtime(秒级)。 +### 3.4 `[ftp]` 段:FTP 鉴权(2026-10-11 新增,N-19) + +FTP 服务(lftpd,:21)自 N-19 起启用口令鉴权。配置与 `[network]` **同文件、同引擎**(`net_cfg_load()` 内统一注册,键表与工厂默认见 `Drivers/BSP/NETCFG/ftp_cfg.c/h`;鉴权状态机在 `Drivers/BSP/NET/lftpd/lftpd_auth.c/h`,纯函数、主机侧可测): + +```ini +[ftp] +user=admin +pwd_hash=d82494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892 +write=1 +``` + +| 键 | 类型 | 工厂默认 | 说明 | +|---|---|---|---| +| `user` | STR(≤15 字符) | `admin` | 期望用户名,**大小写不敏感** | +| `pwd_hash` | STR(64 位 hex) | `d82494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892`(即口令 `admin`) | **SHA-256(user‖password)** 摘要,hex 大小写不敏感 | +| `write` | UINT(0/1) | `1` | 1 = 允许 STOR/DELE;**0 = 写命令一律 `550` 拒**(只读卷) | + +**鉴权流程**: + +1. `USER`:不论用户名对错一律回 `331`(不泄露"用户是否存在";错用户名在 `PASS` 阶段仍走同耗时哈希路径拉平时间)。 +2. `PASS`:计算 SHA-256(期望用户名‖口令),与 `pwd_hash` **恒定时间比较** → 对回 `230`、错回 `530`。 +3. **未登录命令门**:登录成功前仅 `USER` / `PASS` / `QUIT` / `FEAT` / `SYST` / `NOOP` 放行,其余命令一律 `530`。 +4. **失败锁定**:连续 **5 次**失败 → **锁定 60 s**(窗内所有 `PASS` 直接 `530`、不再计数);登录成功清零计数;锁定状态**全局跨会话**、易失(重启清零)。 + +**改口令步骤**(无 UI 入口;下载 `/config.ini` 编辑后上传,或经串口/烧录工具改写): + +1. 生成新摘要 hex(对 `user + 新口令` 拼接串做 SHA-256;下例中 `user` / `pwd` 为占位,替换为字符串字面量,如 `('admin'+'新口令')`): + + ```powershell + python -c "import hashlib;print(hashlib.sha256((user+pwd).encode()).hexdigest())" + ``` + +2. 把结果写入 `[ftp]` 段的 `pwd_hash=`(hex 大小写随意); +3. 上传回设备——配置引擎 poll 沉降 **≈4 s** 后重载,**下次登录生效**(既有会话不受影响)。 + +**忘口令恢复路径**:口令**不可**从 `pwd_hash` 反推(这正是哈希的意义)——经**串口/烧录工具改写配置**,或**重烧固件**回落出厂默认,然后立即改口令。 + +**出厂默认口令告警**:`pwd_hash` 仍为出厂值时,每次启动打 INFO 锚点 `[FTP] using factory default password`(改了 `pwd_hash` 即消失)。 + +> **诚实边界**:FTP 协议**明文传输口令**——本方案挡的是**未授权访问**,不挡**网络嗅探**(CH395F 上实现 TLS 不现实)。`pwd_hash` 只防 `/config.ini` 泄露后的**离线口令还原**,盐 = 用户名(无每机随机盐,局域网威胁模型下够用)。 + --- ## 4. 生效链路 @@ -265,6 +308,7 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) | 日期 | 变更 | |---|---| +| 2026-10-11 | v2.3:**新增 §3.4 `[ftp]` 段(N-19 FTP 鉴权)**——`user` / `pwd_hash`(SHA-256(user‖password))/ `write` 字段表、鉴权流程(USER 恒 331 / 恒定时间比较 / 未登录命令门 / 5 败锁 60 s)、改口令步骤与忘口令恢复路径、出厂默认口令启动告警、明文嗅探边界 | | 2026-10-10 | v2.2:**删除旧档 NCFG 迁移**——`net_cfg_legacy.c/h` 整体移除、`cfg_store_legacy_path()` 撤销;旧档 `/cfg/netcfg.bin` 若存在则被忽略(不读、不删、不迁移),配置回落工厂默认。理由:该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏栈链 +600 B,且本仓库无携带旧档的设备 | | 2026-09-14 | v2.1:存储迁移至**通用配置文件** `/config.ini`(INI `[network]` 段,cfg_file 引擎:逐键校验 / 外科手术式保存 / 原子写 / 旧档首启迁移 / 沉降轮询);`net_cfg_store.*` 弃用(由 `cfg_file_store.*` 取代);对外 API 不变 | | 2026-09-10 | MAC 显示改为常识格式 **`A4:94:B6:12:34:56`**(冒号分隔,17 字符);页面布局左移(名 x=4 / 值 x=54)以容纳 102px 宽度,编辑光标段偏移随之为 3i | diff --git a/docs/待处理内容.md b/docs/待处理内容.md index 2e3588f0..0da7e442 100644 --- a/docs/待处理内容.md +++ b/docs/待处理内容.md @@ -12,7 +12,7 @@ > **不在本文件范围**:`问题记录/错误码传播审计.md`(E-xx,审计报告,非待办池)、 > `问题记录/项目审查与优化建议报告.md`(体系层评审报告)、`测试说明/实机测试实施指南.md`(执行流程)。 > -> **创建**:2026-09-22(L3 pending 台架收尾);**合并重写**:2026-10-04。 +> **创建**:2026-09-22(L3 pending 台架收尾);**合并重写**:2026-10-04;**统一排序 + 审查报告并轨**:2026-10-11。 --- @@ -27,6 +27,7 @@ | `S1~S14` / `A-1~A-7` | 测试充分性与架构改进专项 | `测试与架构改进建议.md`(2026-10-02 复审) | **编号不随条目删除而回收** | | `E-xx` | 错误码传播审计发现 | `错误码传播审计.md` | **不并入本文件**,交叉引用 | | 阶段 `A/B/C/D` + `B0~B3`/`C1~C6`/`D2~D6` | 任务排序分组 | `下一步任务规划.md` | 见 §2 | +| 报告 `N-x`/`R-x`/`F-x`/`S-x`/`U-x`/`D-x` | **审查报告自有编号** | `项目审查与优化建议报告.md` | ⚠ **与本登记簿 N-x/S-x 同名不同义**(报告 N-16 = 日志逐条开关,登记簿 N-16 = A2/A4 负向台架;报告 S-3 = check_registration.py,登记簿 S-3 = USART1 TX-only)→ **引用一律带文档名**。2026-10-11 其未闭环项已并入本登记簿,新编 `N-19`~`N-29`(出处列保留报告编号) | **严重度定义**(沿用审计口径):P0 = 确认功能 bug|P1 = 疑似 bug 或健壮性风险|P2 = 改进|P3 = 卫生。 **置信度**:high = 代码级确证|medium = 依赖外部条件(手册/硬件/产品定义)。 @@ -37,51 +38,58 @@ | 级别 | 数量 | 关键项 | |---|---|---| -| **P1** | 0 (+1 收尾) | P1-10 测试固件构建实测闭环(主体已闭环,见 §3.1) | -| 登记簿 | 9 | 网络:N-2(49s 日志静默,**低风险观察项**)、N-3(L5 基线重验)、N-4(pending 二期)、N-16(A2/A4 专项负向验证)、N-17(探针窗口心跳缺口);存储/日志:**S-3(USART1 TX-only 根治)**、S-4(dhara 重试自旋)、S-5(可选全序列卷锁);硬件:**N-18(8 路电气量分属两转换器 = V0.1 缺陷,V0.2 改版项)** | -| 测试/基础设施 | 5 | T-7(CI runner 未就绪)+ 测试架构专项 **S12**(断言宏)、**S1-c**(可选单测)、**A-4**(采集链路测试约束)、**A-5 残余**(CI 体积门禁盲区) | +| **安全** | 0 | — | +| **P1** | 0 (+1 收尾) | P1-10 测试固件构建实测闭环(主体已闭环,见 §3.1);~~N-30~~ 已销项(2026-10-11 三连定案修复,回归 4 轮 11/11,见 `问题记录/CH395F_Trap_Records.md` Trap 25) | +| 登记簿 | 20 | 网络:N-2(49s 日志静默,**低风险观察项**)、N-3(L5 基线重验)、N-4(pending 二期)、N-16(A2/A4 专项负向验证)、N-17(探针窗口心跳缺口);存储/日志:**S-3(USART1 TX-only 根治)**、S-4(dhara 重试自旋)、S-5(可选全序列卷锁)、N-22(写回 flush)、N-24(日志逐条开关);数据正确性:**N-21(dhara is_free/copy,报告 N-12/13)**;结构:**N-20(Error_Handler 死循环)、N-23(注册反向校验)、N-26(NAND DMA 断言)**;预算:**N-27(内存优化簇)、N-28(Flash 死代码簇)**;仓库:**N-29(.git 149 MB,需用户决策)**;硬件:**N-18(V0.2 改版项)** | +| 测试/基础设施 | 6 | T-7(CI runner 未就绪 + 推送后两 job 闭环 + 第二机交叉验证)+ **S12**(断言宏)、**S1-c**(可选单测)、**A-4**(采集链路测试约束)、**A-5 残余**(CI 体积门禁盲区)、**D7**(串口助手 A6/A7 待 CH340) | > **网络侧另有 E-xx 未闭环项**(E-12/15/16/23/24/25/26/27/28/30/47/48/49/50/58/59/61,共 17 项) > 与 1 项回归监督(E-62),详见 `问题记录/错误码传播审计.md` §3。本文件不重复登记。 +> **UI 标准化簇**(报告 U1~U5,27 页未落地)与**覆盖率缺口**(lftpd/dhara 移植层无主机测试) +> 为整簇排期项,见 §2 W7 / N-21,明细指报告 §6/§7。 ### 1.1 结构性判断(排序依据) -1. **网络层已是全项目最成熟子系统**(pending 机制 + 四份 Trap 记录 + 量化基线 + 探针族)。 - 剩余网络工作都是"二期扩展/收口",**没有紧急项**。 -2. **产品功能缺口正在收口:ADC 采集链路(B1)已闭环,RS-485(B2)仍为空桩**。 - `adc_task` 已由空桩转为真实消费者并 `@test` 实机闭环(2026-10-10:`frame_seq` 严格 1000 Hz - 递增、`idle=0`、`frst_err=0`);`rs485_task` 仍为空桩,且受 E-59 门禁挡住(E-50 / E-49 未修)。 - DTU 的核心职责(数据采集与转发)中,**采集侧还差 RS-485 一路**——即 E-59 那条"休眠缺陷闸门"的另一面。 -3. **验证债已基本还清,重心是"功能债"**:剩余验证债仅 **N-3(L5 基线重锁,待 PC peer)** - (原 **S-1 栈风险已于 2026-10-10 削链闭环并销项**:defaultTask 静态最坏链 5312 → 2776 B、 - 实测峰值 3392 → 2152 B,**未抬栈**,见 `docs/内存管理/RAM使用分析与优化方案.md`)。 - **当前最值得投入的是阶段 B(ADC/RS485 功能)**。 +1. **产品功能主线只剩"采集与转发"两环**:ADC 侧 ✅(B1 台架闭环);**RS-485 侧被 E-50/E-49 挡着(B2)**;**上行 IEC104(B4)与 protectTask 立项(B5)此前只写在产品需求/保护规划,未进本登记簿——2026-10-11 补登**。B4 数据源已具备,可与 B2 并行。 +2. **数据正确性最高风险 = N-21**(dhara `is_free` 只查页首 64 B / `copy` 不处理哨兵):低概率、后果重(数据丢失),且**至今只能靠人工读代码发现**——修复须配主机侧移植层测试(一并补报告"覆盖率"缺口)。 +3. **内存优化被预算卡死**:CCM 只剩 16,304 B,54 KB 的 LVGL 池+UI 缓冲迁不进去(R-2 后半段堵死)→ 必须先做 §5.5 台账三件套(`ram_baseline.tsv` + 预算台账 + 92% 门禁)再排期 R-9→R-2。 +4. **验证债仅剩窗口性事项**(D2/D5/D6/D7 + P1-10 收尾),不单独排期、逢窗口顺手做。 --- -## 2. 任务排序(阶段 A/B/C/D) +## 2. 统一推进顺序(全项目唯一权威) -> 排序原则:**先降风险(产品级故障)→ 再补功能(核心链路)→ 再清结构债 → 最后卫生**。 -> 本节只给「做什么 / 为什么现在 / 入口在哪 / 完成判据」,明细指向 §3 对应条目。 +> **本节是全项目所有工作流唯一的权威顺序**:`产品需求设计文档.md` §9.2、`算法/继电保护功能复现规划.md`、 +> `项目审查与优化建议报告.md` §0.3 中的路线图均为局部视图,**顺序变更只改本节,其它文档只留指针**。 +> 排序原则:**安全风险 > 产品功能主线 > 数据正确性 > 预算/结构债 > 验证常态 > UI 扩展 > 仓库决策**。 -### 2.1 阶段 B:核心功能补齐(产品价值最高,产品定义已确认) +### 2.0 波次总览 -> 目标:让 DTU 真正采到数——这是当前**最大的功能空洞**,**当前最高优先级**。 -> **B0 产品定义已于 2026-10-10 确认并销项**:上行 **IEC 60870-5-104**(以太网 TCP 2404)/ +| 波次 | 内容 | 为什么在这 | +|---|---|---| +| **W2 功能主线** | **修 E-50/E-49 → B2** RS-485(ModBus RTU)∥ **B4** IEC104 上行 → **B5** protectTask 立项 | DTU 核心职责"采集与转发"仅剩两环:RS-485 侧被 E-59 挡、上行侧未动工;B4 数据源(ADC ✅)已具备,**可与 B2 并行**。**已定(2026-10-11):B2 先行(先修 E-50/E-49 驱动契约)、B4 并行开工** | +| **W3 数据正确性** | **N-21** dhara `is_free`/`copy`(报告 N-12/13) | 低概率、后果重(数据丢失);修复须配主机侧移植层测试,一并补报告"覆盖率"缺口 | +| **W4 预算与内存** | **N-27**:`docs/问题记录/项目审查与优化建议报告.md` §5.5 台账三件套 → R-9 池 40→16 KB → R-2 后半(54 KB→CCM) | CCM 只剩 16,304 B,R-2 后半被堵死;三件套是排期前提且工作量小 | +| **W5 结构债** | C1 pending 二期 · C3 日志管线(N-2 + N-24)· C5 收口 · C6 USART1(S-3)· N-20 `Error_Handler` · N-23 注册反向校验 · N-22/N-25/N-26/N-28 | 二期/健壮性/评估类,无紧急;N-23 是唯一"1 小时工作量"的一条;~~N-30~~ 已销项(Trap 25 定案修复,2026-10-11 回归 4 轮 11/11) | +| **W6 验证常态** | P1-10 收尾 · T-7 CI · D2 N-3 · D5 N-16 · D6 N-17 · **D7 串口助手 A6/A7** · S12 · S1-c · A-5 | 窗口性,逢机顺带,不单独排期 | +| **W7 UI 扩展** | U4 残余(golden + 字符集差集挂门禁)→ U1 令牌层 → U2/U3 → **27 页**落地 | 产品驱动、体量大,需单独立项(顺序按 `docs/问题记录/项目审查与优化建议报告.md` §6.5) | +| **W8 仓库决策** | **N-29** `.git` 149 MB + 图片 36.3 MB | `git filter-repo` 历史重写影响未推送提交与协作者 → **需用户决策** | +| **硬件改版** | N-18 8 路电气量全接 TPAFE(V0.2) | 下一版硬件;固件侧已用语义映射隔离,改版只动映射表 | + +### 2.1 W2 功能主线详述 + +> **B0 产品定义已于 2026-10-10 确认并销项**:上行 **IEC 60870-5-104**(以太网 TCP 2404,遥测短浮点系数 1)/ > 持续采集 / **1000 Hz(20 点/周波)** / RS-485 **对下 ModBus RTU 采集**;结论详见 > `docs/算法/继电保护功能复现规划.md` §1.5,并已同步 `docs/产品需求/产品需求设计文档.md` §5.9/§5.10/§9。 -> **B3(纪律门禁)已于 2026-10-10 固化进 `AGENTS.md` 并销项**;同日 TPAFE 侧前置缺陷 -> **E-44 / E-45 / E-58 全部修复** → **B1(ADC 采集链路)已于 2026-10-10 实现并 `@test` -> 实机闭环销项**(`adc_task` 真实消费者 + 建任务 + 栈预算 + 台架 1000 Hz 确认); -> B2 仍欠 **E-50 / E-49**(RS485 驱动契约未修,门禁仍挡着)。 +> **B3(纪律门禁)已固化进 `AGENTS.md`**;**B1(ADC 采集链路)已 `@test` 实机闭环销项**(2026-10-10)。 | 序 | 任务 | 前置 | 说明 | |---|---|---|---| -| **B2** | **RS-485 采集链路实现**(消化 E-50、E-49) | B0 ✅ + **E-59 门禁**(**尚未满足**:E-50 / E-49 未修) | 双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ 协议状态机(`App/protocol/` 正是为此预留);**B0 定用途 = 对下 ModBus RTU 采集** | +| **B2** | **RS-485 采集链路实现**(消化 E-50、E-49) | **E-59 门禁尚未满足**(E-50 / E-49 未修,先修驱动契约) | 双缓冲 + 序号(防包撕裂)+ DE 收尾(超时路径等 TC)+ ModBus RTU 状态机(`App/protocol/` 正是为此预留);设计约束见 A-4 | +| **B4** | **IEC 60870-5-104 上行**(TCP 2404,遥测/遥信/遥控上送) | B0 ✅ + 数据源(ADC ✅,**可与 B2 并行**) | 协议解析与 I/O 分离(A-4 约束 1)→ 天然适合 L0.5 主机单测;注意 CH395F 单连接约束(104 客户端与 FTP/远显/对时抢同一 socket 池,接入前定抢占策略,关联报告 N-09) | +| **B5** | **protectTask 立项**(L3 板端接线) | B2/B4 推进中;E-59 同款门禁 | `prot_set`/`prot_elm` 软件侧已落地(链接器移除、零足迹);建任务前后各跑 `stack_report.py`;定值/回路接线按保护规划路线图走 | -**建议**:B1 已闭环;下一步是 **B2**——但 B2 之前必须先修 **E-50 / E-49**(RS485 驱动契约未修,E-59 门禁仍挡着)。 - -### 2.2 阶段 C:结构债清理(二期)+ 存储侧健壮性 +### 2.2 W5 结构债详述 | 序 | 任务 | 说明 | 出处 | |---|---|---|---| @@ -90,24 +98,30 @@ | **C5** | 一档剩余 | E-49(RS485 DE 收尾)、E-15(ringbuf 定去留——**已确认零调用者,倾向删除**)、E-61(SD2506 注释订正)、E-16(cfg_file 段名长度)、E-26(UDP 定案,倾向移除)、E-23、E-30 | 一档/二档 | | **C6** | **USART1 TX-only 结构根治**(S-3) | 灭 ORE 僵尸机制根源:改 `.ioc` 令 USART1 不使能 RX / PA10 不复用为 `USART1_RX` → CubeMX 重生成 → 跑 `check_cubemx_survival.py`。属结构性改动,需单开一轮 + 复跑台架闭环 | S-3 | -### 2.3 阶段 D:验证与卫生(常态化) +### 2.3 W6 验证常态详述 | 序 | 任务 | 说明 | |---|---|---| | **D2** | N-3 L5 基线重锁 | 下次 Phase 13 窗口顺带做(低风险:阈值余量 >8×;**需 PC peer**) | | **D5** | **N-16 专项负向台架窗口** | A2/A4 负向场景:PHY 拔线(需断 DTU 侧/关受管交换机端口)、A2 芯片级 `net_init` 失败(SPI 故障注入)、A4 短写压测 | | **D6** | **N-17 探针窗口心跳缺口** | 定位 `time_sync` 监听重建为何阻塞 NET/FTP/UI(~1s 缺口,余量仅 ~0.7s);评估缩短重建重试或让掉线路径不阻塞其它任务 | +| **D7** | **串口助手 A6/A7 正路径补验** | 需 CH340 硬件接入后补验(`串口助手设计说明.md` 状态行 A1–A5/A8 已过) | -### 2.4 建议执行顺序(一图) +### 2.4 顺序一图 ``` -【阶段 B 功能】 B0 产品定义确认 ✅ ─┬─ B3 E-59 门禁固化(并行) -(当前最高优先) └─ E-58 原子快照 → B1 ADC ✅ → B2 RS485 - ↓ -【阶段 C 结构】 C1 pending 二期 ─ C3 日志管线(N-2/S-3) - ─ C5 收口 ─ C6 USART1 TX-only - ↓ -【阶段 D 常态】 D2 N-3 / D5 N-16 负向 / D6 N-17 探针窗口 +W2 功能 E-50/E-49 → B2 RS485(ModBus) ∥ B4 IEC104 上行 → B5 protectTask + ↓ +W3 数据 N-21 dhara is_free/copy(配主机侧移植层测试) + ↓ +W4 预算 内存台账三件套 → R-9 池瘦身 → R-2 后半(54KB→CCM) + ↓ +W5 结构 C1 pending 二期 · C3 日志(N-2/N-24) · C5 · C6(S-3) · N-20 · N-23 · N-22/25/26/28 + ↓ +W6 验证 P1-10 · T-7 CI · D2/D5/D6/D7 · S12 · S1-c · A-5 + ↓ +W7 UI U4 残余 → U1 令牌层 → U2/U3 → 27 页(单独立项) +W8 仓库 N-29 .git 149MB(用户决策) 硬件:N-18 V0.2 ``` --- @@ -133,7 +147,7 @@ | N-3 | **L5 队列往返基线(1223/988µs)的 L3 后重验**——L5 测 DONTWAIT 队列全程;L3 对该路径逐位不变 → 机制上无影响,但基线数值属分片时代,严格重锁需跑一轮 Phase 13(需 PC peer) | 2026-09-22(L3 收尾留档) | 低风险:10ms/100ms 阈值余量 >8×;下次 Phase 13 窗口顺带重锁即可 | `链路延迟测试说明.md` §6.4 L5 注¹ | | N-4 | **pending 机制二期:ACCEPT + SEND 阻塞路径 pending 化**——E-06、E-20、E-21 剩余分量共用该机制扩展;届时 L3/L5 基线再重锁、探针族扩充、看门狗接 selftest 留痕、模拟器差距清单 +1 | — | 一期(RECV)已落地并入主线;架构现状见 `Net层阻塞模型架构设计.md`,二期设计基线见 §5.1 | `错误码传播审计.md` 第二档 #2 | | N-16 | **A2/A4 专项场景台架验证待办**(正常路径已实测零回归):① PHY 拔线负向测试——断链需断 **DTU 侧**线缆或关**受管交换机端口**;② A2 芯片级 `net_init` 失败分支(需 SPI 故障注入);③ A4 短写压测(需远显客户端 + UI 推流压力) | 2026-10-03(台架拓扑 = CH395↔交换机,拔线操作受限) | 待专项台架窗口 | `实机测试实施指南.md` §10 | -| N-17 | **`time_sync` 30s 挂起客户端掉线 + 监听重建期间,网络任务心跳缺口达 800ms~1s(声明 stall 但未复位)**——V5 长跑 44 次 `task stall: mask=0x00000001`,**100% 与 `e02_probe` 的 30s 挂起客户端触发 `time_sync: no TIME packet in 30s, drop conn` → 监听重建(约 4s)重合**;隔离实验(仅 FTP,120s)**0 次 stall** → 探针场景专属。缺口(~1s)< IWDG 最坏超时(1701ms),余量仅 ~0.7s。**待办**:定位监听重建为何阻塞 NET/FTP/UI;评估缩短重建重试(5×2s)或让掉线路径不阻塞其它任务 | 2026-10-03(V5 台架) | `实机测试实施指南.md` §4;`子系统设计/看门狗设计说明.md` | +| N-17 | **`time_sync` 30s 挂起客户端掉线 + 监听重建期间,网络任务心跳缺口达 800ms~1s(声明 stall 但未复位)。**待办**:定位监听重建为何阻塞 NET/FTP/UI;评估缩短重建重试(5×2s)或让掉线路径不阻塞其它任务 | 2026-10-03(V5 台架) | V5 长跑 44 次 `task stall: mask=0x00000001`,**100% 与 `e02_probe` 的 30s 挂起客户端触发 `time_sync: no TIME packet in 30s, drop conn` → 监听重建(约 4s)重合**;隔离实验(仅 FTP,120s)**0 次 stall** → 探针场景专属。缺口(~1s)< IWDG 最坏超时(1701ms),余量仅 ~0.7s。 | `实机测试实施指南.md` §4;`子系统设计/看门狗设计说明.md` | **存储 / 日志管线** @@ -153,7 +167,23 @@ | # | 事项 | 优先级 | 问题与证据 | 处置方向 | 出处 | |---|---|---|---|---|---| -| T-7 | **CI runner 未就绪(部署前置)** | 中 | `.gitea/workflows/ci.yml` 已落地,但 CI 仅在 runner 就绪后才真正生效:① Gitea 实例 `[actions] ENABLED = true`;② 注册 Windows runner(labels 含 `windows`),装 Python 3.x / cppcheck / pre-commit / MSVC | runner 未就绪前,**提交前仍须在本地跑 `scripts/ci_local.py` 或 `pre-commit run --all-files` 自查**(L0 唯一保障,不能因"已配 CI"而放宽) | `.gitea/workflows/ci.yml`;`测试说明/测试开发标准流程.md` §9 | +| T-7 | **CI runner 未就绪(部署前置)** | 中 | `.gitea/workflows/ci.yml` 已落地,但 CI 仅在 runner 就绪后才真正生效:① Gitea 实例 `[actions] ENABLED = true`;② 注册 Windows runner(labels 含 `windows`),装 Python 3.x / cppcheck / pre-commit / MSVC;③ runner 就绪后**推送远端跑一次两 job 闭环验证**(当前 21 个提交未推送,CI 从未真实执行);④ **第二机交叉验证**(不同工具安装路径,验 `DTU_*` 环境变量方案,→ `环境与部署/本地工具路径环境变量方案.md` 变更记录 TODO) | runner 未就绪前,**提交前仍须在本地跑 `scripts/ci_local.py` 或 `pre-commit run --all-files` 自查**(L0 唯一保障,不能因"已配 CI"而放宽) | `.gitea/workflows/ci.yml`;`测试说明/测试开发标准流程.md` §9 | + +**审查报告并轨(2026-10-11;⚠ 报告编号与本登记簿同名不同义,出处列保留报告编号,引用一律带文档名)** + +| # | 事项 | 影响 / 现状 | 处置方向 | 波次 | 出处 | +|---|---|---|---|---|---| +| N-20 | `Error_Handler` 仍 `__disable_irq(); while(1){}`(`Src/main.c:274-282`) | 一处初始化失败 = 整机挂死(只剩 IWDG 兜底),无自检日志 | 统一降级策略:记自检 + 禁用该子系统 + 其余照常 | W5 | 报告 N-03c | +| N-21 | **dhara 两处**:`dhara_nand_is_free()` 只查页首 64 B;`dhara_nand_copy()` 不处理 `DHARA_NO_PAGE` 哨兵 | 映射恢复误判 → **数据丢失风险**(低概率、后果重);哨兵页号被当真实页 → 未定义行为;至今只能靠人工读代码发现 | 整页(或首/中/尾)扫描 + 返回 `DHARA_E_UNSUPPORTED`;**必须配主机侧移植层单测**(一并补报告"覆盖率"缺口) | **W3** | 报告 N-12/N-13 + §7 | +| N-22 | 写回缓存无空闲/掉电强制 flush(整页写与换页已 flush) | 写回窗口内掉电丢最近日志/配置写 | defaultTask 空闲 N ms flush | W5 | 报告 N-14 | +| N-23 | **双端注册无反向校验**:`rs485_task.c`、`lftpd_log.c` 既不在 uvprojx 也不在 simulator | "写了不编译"= 死代码 + 误导 | `check_registration.py`(报告中唯一"1 小时工作量")+ 挂 pre-commit;`app_cfg.h` 未接线开关一并处置(C5) | W5 | 报告 N-06 残余 + 报告 S-3 | +| N-24 | 日志每条 `f_open+f_write+f_close`(`sys_log_mcu.c:123-140`) | 每条日志 2–4 次 NAND 页读写;UI 翻记录逐条 open/seek/close | 与 C3 日志管线合并评估(现仅 `FIL` static 化省 528 B 栈) | W5 | 报告 N-16(**≠ 本登记簿 N-16**) | +| N-25 | 性能/资源建议簇:PASV 每次新建监听;`NET_RECV_TIMEOUT_MS 30000`;PHY 强制 100M 无自协商回退;`NET_MAX_SOCKETS 8` 与单连接语义不符;`HAL_Delay(1)` 阻塞 DE | 次级性能/语义一致性 | 逐项见 `docs/问题记录/项目审查与优化建议报告.md` §4.2/§4.5/§4.7,随窗口评估 | W5 | 报告 N-09/P2-8/PHY/P3-x/rs485 | +| N-26 | NAND 直 DMA 调用方缓冲(已加运行时守卫,阈值 1024) | CCM 缓冲被 DMA = 静默传错(与 ADC DMA 不可达 CCM 同类坑) | 补编译期/启动期地址断言 + **新铁律:堆缓冲不可被 DMA 直接寻址** | W5 | 报告 N-11 | +| N-27 | **内存优化簇**:LVGL 池 + UI 两份缓冲 54,146 B 仍在 SRAM1,CCM 只剩 16,304 B;`ram_baseline.tsv` / `RAM预算台账.md` / 92% 阈值门禁**均未建** | R-2 后半段(迁 CCM)被堵死 → B2/B4/B5 的功能预算看不清 | **前置 §5.5 台账三件套** → R-9(`LV_MEM_SIZE` 40→16 KB,池水位门禁数据已具备)→ R-2 后半;R-3/R-4/R-5/R-6 逐项评估(R-6 前置已满足) | **W4** | 报告 R-2 剩余/R-3~R-6/R-9/§5.5 | +| N-28 | Flash 死代码评估簇:剩余 ≈10 KB(`box_shadow` 3,246 / `draw_sw_img` 2,960 / `bin_decoder` 1,972 / `image_decoder` 952 / `triangle` 930)+ 双精度 `dadd` 等 ≈1 KB;uvprojx 仍挂 201 个空 LVGL TU(F-1/F-2/F-3 已闭环,省的是编译时长) | 编译时长 + 文档噪音 | 逐项实测再关;⚠ `mask`/`mask_rect`/`grad` 是文字与矩形绘制必经路径,**不可盲关** | W5 | 报告 F-4/F-5/F-6 | +| N-29 | **仓库膨胀**:`.git` 149 MB + 图片 36.3 MB | clone/备份慢,且持续恶化 | 降采样 / 拆 submodule / LFS 三选一 **+ 一次 `git filter-repo` 历史重写**。**已决策(2026-10-11):暂不处理、登记观察**——推送/协作前重评(届时未推送提交更少、重写代价最低) | **W8** | 报告 D-1 | +| U 簇 | UI 标准化:无令牌层/组件库/页注册表(手写 `case` 仍在),**27 页**未落地;golden 未挂 pre-commit、字符集差集检查仍是约定 | 新增一页成本仍是 8 处手工改动 | 按 `docs/问题记录/项目审查与优化建议报告.md` §6.5:U4 残余(两脚本入门禁,可逢窗口顺带)→ U1 令牌层 → U2/U3 → 页面落地;U4 golden 可直接当 U1 验收工具。**已确认(2026-10-11):本轮不排期**,等功能主线出结果后按产品需求启动 | W7 | 报告 U1~U5 | ### 3.3 测试与架构改进专项(S1x / A-x) @@ -345,6 +375,9 @@ S14 已在 workflow 显式提示"体积门禁未执行"(`::notice::`)并在 **不留变更记录**,历史见 `git log` 与各模块文档。 - 已闭环条目的**结论与证据沉淀进模块文档**,本文件不再重复。 - 编号一律**沿用且不回收**(P-x / N-x / S-x / T-x / S1x / A-x);`错误码传播审计.md` 的 E-xx 独立维护。 +- **引用审查报告编号一律带文档名**(报告 N-16 ≠ 登记簿 N-16、报告 S-3 ≠ 登记簿 S-3); + 报告并轨项用本登记簿新编号(N-19~N-29 + U 簇),出处列保留报告编号。 +- **§2 是全项目唯一的推进顺序权威**:`产品需求设计文档.md` §9.2 等只留指针,顺序变更只改 §2。 - 阶段 B 完成后重写 §1(届时 ADC/RS485 应从未闭环消失)。 - 新增发现按既有惯例分流:能立即修的进 §2(阶段 C5);需排期的进 §3 对应编号空间;跨版本观察的进 §3.2(登记簿)。 - 上游引用关系:`问题记录/项目审查与优化建议报告.md`(体系层评审)、`问题记录/错误码传播审计.md`(E-xx)、 diff --git a/docs/文档规范.md b/docs/文档规范.md index 17ed2180..c874e887 100644 --- a/docs/文档规范.md +++ b/docs/文档规范.md @@ -102,6 +102,69 @@ `check_doc_links.py` 扫描前会剥离行内代码 span,故写法示例不会误报;但**反引号内的仓库根相对路径**(`docs/xxx.md`)仍会被校验——那是本工程的引用约定形态。 +### 3.5 引用章节:紧邻写法 + 别名表,让门禁能校验 + +引用某文档的某小节,写成 **`` `docs/xxx/yyy.md` §N.M ``**(文档名与 `§` 紧邻,中间只允许空白/反引号/括号)。 + +| 写法 | 门禁 | +|---|---| +| `` `docs/子系统设计/看门狗设计说明.md` §10.4 `` | ✅ 硬门禁(失效拦提交) | +| `` `看门狗设计说明.md` §10.4 ``(裸文件名,全库唯一) | ✅ 硬门禁 | +| `看门狗设计说明 §10.4`(省略 `.md`,文件名全库唯一) | ✅ 硬门禁 | +| `链路延迟 §8.3`(文档名**前缀**,全库唯一) | ✅ 硬门禁 | +| 「内存方案 §1.4」——已登记别名 | ✅ 硬门禁 | +| 「报告 §4.2」——昵称歧义、未登记 `[示例]` | ⚠️ 判不了 → 进 `--section-todo` 清单 | +| 本文档内「见 §3.1」 | ✅ 硬门禁(本文档必须有该编号;确实指别篇却省了文档名的,机器判不了 → 跳过并进待办) | + +**两档严重性**:目标明确(路径 / 裸名 / 前缀 / 别名 / 同句 `§A / §B` 链)→ **错误**;按自引用解析 → 默认**告警**,但 **pre-commit 钩子带 `--sections-strict`,两档都拦提交**(2026-10-11 起:全库告警已清零,清零之后就该守住)。手工跑想只看告警:去掉该 flag。 + +**别名表 `scripts/section_aliases.json`**:昵称 → 文档路径。登记一次,正文一个字不用改,引用就纳入硬门禁。**只登记无歧义的昵称**(「报告」命中多篇 → 不登记,逼着写清楚)。 + +**覆盖率必须看着**:每次跑 `doc-links` 都打印一行 + +``` +章节引用:共 1576 处,机器可判定 843 处(53%);跳过 → 昵称式 68 / 变更记录 419 / 外部手册 187 / … +``` + +比例只许涨不许跌。清 backlog: + +```powershell +& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all --section-todo +``` + +它列出全部机器判不了的 `§`(能唯一猜到的还给「建议改写:`docs/…` §N」),改一处涨一处;猜不到的就是**必须写清楚文档名**的那些。 + +> **口径怎么调出来的**:全库 1578 处 `§`。宽松版报警 173 处(几乎全是昵称误判)→ 加文档名解析 111 → 加紧邻 + `§` 链 + 前缀匹配 + 别名表 + 「父级编号不存在就不是自引用」判别 → 可判定比例 **7% → 58%(914 处)**,`--section-todo` 待办 **83 → 0**;清完 backlog 后**两档都拦提交**。变更记录小节(422 处)与外部手册编号(218 处)刻意不校验:前者是「当时那个编号」的历史指称,后者归厂商文档。 +> +> **把待办清零靠的三类动作**(按性价比排序;引号内是**改前**写法 `[示例]`):① **手册引用写全名**——「手册 §9.2.1」一律改成 `docs/芯片手册/CH395F.md` §9.2.1(CH395F / GD5F / SD2506 / RM0090 摘录里都有编号标题,改完立刻纳入校验);② **昵称写全或登记别名**——「架构 §5.5」→ `UI_设计架构.md` §5.5、「报告 §6.5」→ 全路径、「V0.0 报告 §4.1」→ `V0.0 §4.1`(别名表登记 `V0.0`);③ **路径补 `docs/` 前缀**——`子系统设计/xxx.md` 这种「从 docs/ 起算」的写法机器解析不到。 +> +> **两条写作铁律**(机器只看**同一行**):`§` 必须与文档名**紧邻**(换行/跨句就判不了);「见/按/该 + 昵称」这类含糊指代,宁可多打 30 个字符写全路径。 +> +> **首轮清账揪出的真断链(41 处,已修)**——`[示例]` 下列编号是**修复前的错值**,故意原样保留作反例:`链路延迟测试说明.md` 10 处引 §8.1,而该文 §8 下只有 8.2/8.3/8.4(§8.1 早被删);`GD5F2GQ5UE_Test_Guide.md` 引 §4.8(BBT 实为 §3);`自动化测试流程.md` 引 `ST-LINK_CLI_复位使用说明.md` §10(该文只到 §7);`模拟器差距清单.md` 引 §219(实为 §4.1);`实机测试实施指南.md` 引 §4.4 白名单(白名单在 `看门狗设计说明.md` §4.3);`V0.1_网表详细分析报告.md` 表格里 12 处裸 `§3.4`/`§5.3` 其实指 V0.0 报告(补 `V0.0` 前缀 + 登记别名)。**教训:编号会随重构漂移,只有机器校验能发现。** + +### 3.6 改名 / 挪目录:用 `scripts/rename_ref.py`,不要手工扫 + +**固定动作**(改名、挪目录、合并文档一律走这条,别靠 `git grep` 手改): + +```powershell +# 1) 预览:看它打算改哪些文件的哪些行(默认 dry-run,不落盘) +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md + +# 2) 确认无误后落盘:git mv + 改写全库引用 + 自动跑 check_doc_links --all 复核 +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md --apply + +# 整目录搬迁(末尾带 / = 挪进该目录并保留文件名;旧/新都是目录 = 前缀改写) +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧目录/ docs/新目录/ --apply +``` + +它覆盖的引用形态:反引号根相对引用、Markdown 链接(根相对与文档相对都按原风格改写)、**图片链接的 `../` 层级换算**(§3.2 那条实证坑由它兜住)、`#include "x.h"` 裸文件名、`MDK-ARM/*.uvprojx` 的 ``、`simulator/CMakeLists.txt` 的 `${PROJECT_ROOT}/…` 与 `${VAR}/…`、以及 skill / 脚本 / 配置里的路径串。 + +**它管不到的三件事**(脚本会在结尾提示): + +1. **昵称式引用**(「内存方案 §1.4」)与正文里的裸文件名散文 → 落盘后 `git grep -n '<旧文件名>'` 扫一遍; +2. **生成物**(`docs/images/引用关系图.*`)不改写 → 重跑 `& 'tools\refgraph\gen_refgraph.bat'`; +3. 改的是 `.c/.h` 且**新增了目录** → 还要把目录加进 `uvprojx` 的 ``(→ `new-source-file` skill),随后 `@build` 验证。 + --- ## 4. 门禁:文档-代码一致性(D-7) @@ -110,7 +173,7 @@ | 层次 | 钩子 | 检查内容 | 触发条件 | |---|---|---|---| -| **存在性** | `doc-links` | `docs/**/*.md` 里的链接、反引号路径是否指向真实文件 | 改动任意 `.md` | +| **存在性** | `doc-links` | `docs/**/*.md` 里的链接、反引号路径是否指向真实文件;`§N.M` 章节引用是否指向真实小节(§3.5) | 改动任意 `.md` | | **数值** | `task-table` | `README.md`、`AGENTS.md` 的 FreeRTOS 任务表是否与 `Src/freertos.c` 一致 | 改动 `README.md` / `AGENTS.md` / `Src/freertos.c` | | **体积** | `mem-numbers` | 内存/Flash 数字是否与最新 `.map` 一致(受管区 + 陈旧散句数字) | 改动内存文档 / `App`·`Src`·`Inc` 源码 / 脚本 | | **体积** | `check-added-large-files` | 阻止 >1MB 大文件入库 | 任意文件 | @@ -121,6 +184,10 @@ # 全库文档链接存在性 & '.venv\Scripts\python.exe' scripts/check_doc_links.py --all +# 改名 / 挪目录(先预览再落盘,见 §3.6) +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md --apply + # 任务表一致性(只检查) & '.venv\Scripts\python.exe' scripts/gen_task_table.py --check @@ -194,6 +261,8 @@ - 外部 URL(`http/https/mailto`)与纯锚点(`#...`) - `docs/芯片手册/` 等外部资料目录 +**章节引用(D 类)另有的跳过**:昵称式引用(「内存方案 §1.4」)、非紧邻写法、变更记录小节内的历史指称、外部手册编号(RM0090 等)、目标文档本身不用数字编号标题。 + > ⚠️ 状态词按**就近**(±40 字符)判定,不是整行——否则文件名本身含「不存在」「删除」等字样时会误豁免同行的其它真实引用(2026-09-25 实测修正)。 --- diff --git a/docs/架构设计/STM32F4-Base存储架构说明.md b/docs/架构设计/STM32F4-Base存储架构说明.md index b144ef35..8c0971ec 100644 --- a/docs/架构设计/STM32F4-Base存储架构说明.md +++ b/docs/架构设计/STM32F4-Base存储架构说明.md @@ -117,7 +117,7 @@ HAL_Init → SystemClock_Config → MX_GPIO_Init → MX_USART1_UART_Init 2. READ_ID (9Fh) └─ 校验 MID=0xC8, DID=0x52 3. BBT 扫描 - └─ 读每块第一页 (page 0) spare byte 0(列地址 0x800,手册 §12.4 初始坏块标记位) + └─ 读每块第一页 (page 0) spare byte 0(列地址 0x800,`docs/芯片手册/GD5F2GQ5UExxG.md` §12.4 初始坏块标记位) └─ 非 0xFF 即出厂坏块,写入 s_bbt[] 4. SET_FEATURE (B0h=10h) — 使能内部 8bit ECC 5. SET_FEATURE (A0h=00h) — 解除全部块保护 @@ -186,7 +186,7 @@ DMA 消除了轮询模式下 SPI 状态寄存器查检的逐字节 CPU 开销, - `gd5f_bbt_scan()` — 在 `gd5f2gq5ue_init()` 阶段、ECC 使能前扫描构建 BBT;运行时不再提供公开重扫接口(见 GD5F2GQ5UE_Trap_Records.md) - `gd5f2gq5ue_print_bbt()` — 打印 BBT 摘要(调试用) -> **注意**:`gd5f_private_bbt_scan()` 选择在 ECC 使能前扫描。按手册 §12.6,ECC 使能后整段 spare 仍可读取(仅 0x840~0x87F 的 ECC 校验区禁止编程),因此读取 0x800(spare byte 0)即便 ECC 开启也安全;但扫描放在 ECC 禁用阶段是更稳妥的默认做法。运行时坏块只来源于真实擦除/编程失败,不要主动重新扫描。 +> **注意**:`gd5f_private_bbt_scan()` 选择在 ECC 使能前扫描。按`docs/芯片手册/GD5F2GQ5UExxG.md` §12.6,ECC 使能后整段 spare 仍可读取(仅 0x840~0x87F 的 ECC 校验区禁止编程),因此读取 0x800(spare byte 0)即便 ECC 开启也安全;但扫描放在 ECC 禁用阶段是更稳妥的默认做法。运行时坏块只来源于真实擦除/编程失败,不要主动重新扫描。 ### 4.7 页内寻址模型(行地址 / 列地址) @@ -197,7 +197,7 @@ NAND 访问是**二维寻址**,两个地址含义不同: | 行地址(页地址 / page address) | 第几页 | `gd5f_page_read(page)` | 把哪一页从存储阵列搬进芯片内部 cache 寄存器 | | 列地址(column address) | 该页内第几个字节 | `gd5f_read_from_cache(column, ...)`(发送 `0Bh` + 2 字节列地址) | 从 cache 里偏移 `column` 处开始吐数据 | -**一页的内部布局**(手册 §12.6 Table 12-8):一页 = 2KB 主数据 + 64B 备用区(spare) = 2112 字节,页内连续编址为 column 0 ~ 2111: +**一页的内部布局**(`docs/芯片手册/GD5F2GQ5UExxG.md` §12.6 Table 12-8):一页 = 2KB 主数据 + 64B 备用区(spare) = 2112 字节,页内连续编址为 column 0 ~ 2111: ``` page (2112B) = main(2048B) + spare(64B) @@ -209,7 +209,7 @@ NAND 访问是**二维寻址**,两个地址含义不同: - `column 0 ~ 2047` → 主数据区(main data) - `column 2048 ~ 2111` → 备用区(spare),其中 **2048(0x800)就是 spare 的第 0 字节** -**为什么坏块标记的列地址是 2048**:main 区占据了前 2048 字节,spare 区从 2048 起算,故 spare byte 0 落在列地址 2048(0x800)。驱动把 `GD5F_PAGE_SIZE`(=2048)作为 `column` 传给 `gd5f_read_from_cache()`(`gd5f2gq5ue.c:307-308` 将其拼成 2 字节列地址),芯片即从 spare[0] 开始回数据——这正是出厂坏块标记位(手册 §12.4 / Table 12-6:First spare area location = Byte 2048)。 +**为什么坏块标记的列地址是 2048**:main 区占据了前 2048 字节,spare 区从 2048 起算,故 spare byte 0 落在列地址 2048(0x800)。驱动把 `GD5F_PAGE_SIZE`(=2048)作为 `column` 传给 `gd5f_read_from_cache()`(`gd5f2gq5ue.c:307-308` 将其拼成 2 字节列地址),芯片即从 spare[0] 开始回数据——这正是出厂坏块标记位(`docs/芯片手册/GD5F2GQ5UExxG.md` §12.4 / Table 12-6:First spare area location = Byte 2048)。 > 一句话:**page 选页,column 选页内字节偏移;main 占前 2048 字节,spare 从 0x800 起算。** @@ -569,3 +569,12 @@ if (nand_ftl_format() == 0) { 11. **FA_OPEN_APPEND 陷阱 / 短写** — `FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(这是当年 `/log` 写失败的真根因);追加写必须写 `FA_WRITE | FA_OPEN_APPEND`(2026-09-20 台架实测,`Lib/FatFs` 移植版)。该移植的 `f_write` 在簇分配失败时返回 `FR_OK` + 短写(**没有错误码**)→ 判失败必须校验写回的字节数。标志位为**旧式编号**:`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(`ff.c` 内部定义),与上游 FatFs R0.14 不同,**勿照抄上游代码示例**。 12. **崩溃可观测性(勿屏蔽)** — 已开启 `configCHECK_FOR_STACK_OVERFLOW=2` + `configUSE_MALLOC_FAILED_HOOK` + HardFault 现场报告(`Src/stm32f4xx_it.c`:裸 UART 打印 PC/LR/xPSR + CFSR/HFSR/BFAR/MMFAR 后复位,对照 `.map` 定位符号)。defaultTask **1024** words、testTask **2048** words 是**实测下限,勿降**。开机日志出现 `[ERR] STACK OVERFLOW task=` / `[ERR] HARDFAULT ...` / `MALLOC FAILED` 是精确定位线索,禁止屏蔽或降级。 13. **BBT 恢复模式** — 运行期坏块会随 TC-STO-03 注入累积;当介质被**误标脏**(如 DMA 误打 CCM 栈导致随机块被写坏并持久化)时,打开 `test/board/storage_test.h` 的 `ENABLE_STORAGE_RECOVERY_TESTS` 编译**恢复构建**,testTask 会执行 `gd5f2gq5ue_bbt_rebuild()`(出厂扫描清除运行期误标)+ `nand_ftl_format()` 清账后复位。恢复后容量回到基线(即扣除 BBT 保留池后的正常容量,非出厂裸容量)。 + +### 10.2 FTP 服务(lftpd)鉴权语义(2026-10-11,N-19) + +FTP 根目录 = 卷根,lftpd 是卷的网络文件入口(`Drivers/BSP/NET/lftpd/`)。N-19 起启用口令鉴权——`[ftp]` 配置段、鉴权流程与改口令方法的**权威口径**在 `docs/子系统设计/网络配置说明.md` §3.4,此处只登记存储侧语义: + +- **未登录命令门** — 登录成功前仅 `USER`/`PASS`/`QUIT`/`FEAT`/`SYST`/`NOOP` 放行,其余命令(含 RETR/STOR/LIST/DELE)一律 `530`。修复前 `cmd_user`/`cmd_pass` 对任意口令无条件回 `230` = **匿名全读写整卷**(N-19 根因)。 +- **STOR/DELE 写门** — `[ftp] write=0` 时写命令一律 `550` 拒,卷对 FTP 侧只读(`/log`、`/config.ini` 仍可下载不可改写)。 +- **全局失败锁定** — 连续 5 次鉴权失败 → 锁定 60 s(跨会话全局、易失、重启清零);纯 lftpd 层状态,不触存储。 +- **默认口令告警锚点** — 出厂 `pwd_hash` 在用期间每次启动打 INFO `[FTP] using factory default password`。 diff --git a/docs/架构设计/项目架构总览.md b/docs/架构设计/项目架构总览.md index 33a854f9..29819828 100644 --- a/docs/架构设计/项目架构总览.md +++ b/docs/架构设计/项目架构总览.md @@ -190,7 +190,7 @@ flowchart TB **单连接约束**:CH395F 配置为多 Socket + TCP Server 单连接(`set_fun_para(0x09)`),Socket 0 复用监听与数据通道;FTP / 对时 / 远程显示三个 TCP 服务**串行**占用连接,连接断开后自动回到 LISTEN。 > **FTP 启动门(2026-09-21 修复)**:`ftpTask` 必须等 `NET_READY` **且** `STORAGE_READY`(两位均 `NoClear`)才 `lftpd_start`——此前就绪位被其它等待者消费,FTP **静默不监听**(详见 §2.2 与 `docs/测试说明/FTP_Test_Guide.md` §8.6)。 -> **单客户端实测注意**:同一时刻只服务 1 个会话,旧会话异常断开后约 30s 才恢复监听。测试脚本必须内置连接重试(`test/host/ftp_up_dl_test.py`、`test/host/ftp_speed_test.py` 已内置),且**不要用裸 TCP 工具探测 21 端口**(探测会占掉唯一会话,见该指南 §8.7)。 +> **单客户端实测注意**:同一时刻只服务 1 个会话,旧会话异常断开后约 30s 才恢复监听。测试脚本必须内置连接重试(`test/host/ftp_up_dl_test.py`、`test/host/ftp_speed_test.py` 已内置),且**不要用裸 TCP 工具探测 21 端口**(探测会占掉唯一会话,见 `docs/测试说明/FTP_Test_Guide.md` §8.7)。 > 2026-09-21 实测:登录/TYPE I/PASV/STOR/RETR/LIST/DELE/QUIT 全通,128KB 上传下载逐字节一致(约 49/60 KB/s,低于阶段 5 裸 socket 基线,待查)。 --- @@ -314,7 +314,7 @@ flowchart LR | 控制台单写者日志(USART1 调试流) | `docs/子系统设计/控制台单写者日志设计说明.md` | | RAM 预算与优化 | `docs/内存管理/RAM使用分析与优化方案.md` | | 栈/内存观测工具 | `scripts/stack_report.py`(Keil 静态调用图 × 串口 `[STACK]` 水位,判定取两者更坏者)· `scripts/ram_report.py`(.map 静态占用)—— 用法与基线见内存方案 §0.6 | -| FTP 功能与陷阱 | `docs/测试说明/FTP_Test_Guide.md`(启动门 §8.6 / 单客户端 §8.7)· 脚本 `test/host/ftp_up_dl_test.py`、`test/host/ftp_speed_test.py` | +| FTP 功能与陷阱 | `docs/测试说明/FTP_Test_Guide.md` §8.6(启动门)/ §8.7(单客户端)· 脚本 `test/host/ftp_up_dl_test.py`、`test/host/ftp_speed_test.py` | | UI 设计架构 / 内容页 / 菜单 | `docs/UI设计/UI_设计架构.md` · `内容页架构.md` · `菜单页面设计.md` | | UI 自动化测试 | `docs/UI设计/UI_自动化测试流程.md` | | 驱动已知陷阱 | `docs/问题记录/{CH395F,GD5F2GQ5UE,SD2506}_Trap_Records.md` | diff --git a/docs/测试说明/CH395F_Test_Guide.md b/docs/测试说明/CH395F_Test_Guide.md index 894baa57..45100203 100644 --- a/docs/测试说明/CH395F_Test_Guide.md +++ b/docs/测试说明/CH395F_Test_Guide.md @@ -17,8 +17,8 @@ | 参数 | 值 | 说明 | |------|-----|------| -| CH395F 内部总缓冲 | **24KB(48 块 × 512B)** | 手册 §5.45;CH395F.md "内置 24K RAM"。**经 Phase 7 实测确认块 0~47 全部有效**(此前 32×1KB 记录有误) | -| 上电默认分配 | Socket 0~3 各 接收8块(4KB)+发送4块(2KB);Socket 4~7 未分配 | 见 §5.45,用到 >4 个 Socket 必须重分配 | +| CH395F 内部总缓冲 | **24KB(48 块 × 512B)** | `docs/芯片手册/CH395F.md` §5.45;CH395F.md "内置 24K RAM"。**经 Phase 7 实测确认块 0~47 全部有效**(此前 32×1KB 记录有误) | +| 上电默认分配 | Socket 0~3 各 接收8块(4KB)+发送4块(2KB);Socket 4~7 未分配 | 见 `docs/芯片手册/CH395F.md` §5.45,用到 >4 个 Socket 必须重分配 | | **本固件分配(net_init)** | 8 个 Socket 各 **接收 4 块(2KB) + 发送 2 块(1KB)** | 块数由 `CH395F_TCP_MSS` 推导(接收 `CH395F_RECV_BLOCKS`=2×MSS、发送 `CH395F_SEND_BLOCKS`=1×MSS,每块 512B);起始块 `recv=i*6`、`send=i*6+4`;用尽 0~47,互不重叠 | | **TCP MSS** | **1024** | 由接收缓冲(2KB) ≥ 2×MSS 决定,8 Socket 共享 24KB 时的最大值 | | **SPI DMA 缓冲** | **4100B** | `CH395F_SPI_DMA_BUF_SIZE` | diff --git a/docs/测试说明/GD5F2GQ5UE_Test_Guide.md b/docs/测试说明/GD5F2GQ5UE_Test_Guide.md index c55c3d8f..377e941b 100644 --- a/docs/测试说明/GD5F2GQ5UE_Test_Guide.md +++ b/docs/测试说明/GD5F2GQ5UE_Test_Guide.md @@ -20,7 +20,7 @@ 因此测试代码可在任意任务上下文调用,但要注意调用任务会被 SPI 传输阻塞数毫秒(擦除最久)。 - **先擦后写**:NAND 只能将 1 写为 0,**写操作前目标块必须已擦除**,否则数据不可预期(见边界条件 TC-GD5F-1003)。 - **ECC 默认开启**:`gd5f2gq5ue_init()` 会置 `Feature(B0h).ECC_EN=1`,读/写均走内部 ECC。 -- **坏块以 BBT 管理**:出厂坏块标记在每块的第一页(page 0)spare byte 0(列地址 0x800,见手册 §12.4 / Table 12-6 / Note);`init` 阶段扫描构建 RAM 中的 BBT。 +- **坏块以 BBT 管理**:出厂坏块标记在每块的第一页(page 0)spare byte 0(列地址 0x800,见 `docs/芯片手册/GD5F2GQ5UExxG.md` §12.4 / Table 12-6 / Note);`init` 阶段扫描构建 RAM 中的 BBT。 ### 芯片参数(关键参数) @@ -706,7 +706,7 @@ ECC 状态(`ECCS1:ECCS0`):`00`=无错;`01/10/11`=纠正 1/2/3 bit;`10` | **测试步骤** | 1. `gd5f2gq5ue_bbt_rebuild()`(关 ECC → 重新扫描出厂坏块 → 开 ECC → `gd5f_bbt_save` 持久化,覆盖注入坏块)
2. `nand_ftl_format()` 清空 dhara map
3. `gd5f_bbt_dump_flash` 校验持久化 BBT 版本 ≥1 | | **通过标准** | 重建返回 OK 且持久化 BBT 版本 ≥1;设备坏块回到出厂集合,注入坏块被清除 | | **注意事项** | 运行一次即恢复;恢复后建议断电重启让 init 重新加载干净 BBT。本 TC 默认关闭,避免常规测试每次重置设备 | -| **依赖** | `gd5f2gq5ue_bbt_rebuild`(详见 §4.8 持久化设计) | +| **依赖** | `gd5f2gq5ue_bbt_rebuild`(详见 §3 坏块管理与 BBT 持久化) | --- diff --git a/docs/测试说明/ST-LINK_CLI_复位使用说明.md b/docs/测试说明/ST-LINK_CLI_复位使用说明.md index 38fcc316..9a8f3fd8 100644 --- a/docs/测试说明/ST-LINK_CLI_复位使用说明.md +++ b/docs/测试说明/ST-LINK_CLI_复位使用说明.md @@ -3,8 +3,8 @@ > 依据:本机 ST-LINK Utility 4.5.0.0 自带 `ST-LINK_CLI.exe`(CLI 版本 v3.5.0.0,`-h` 全文已实测核对)+ ST 官方文档语义。 > 探针路径:经 `DTU_STLINK_CLI` 环境变量解析(本机设为标准安装位 `C:\Program Files (x86)\STMicroelectronics\STM32 ST-LINK Utility\ST-LINK Utility\ST-LINK_CLI.exe`;未设时回退 PATH,`stlink_flash.py` 均可用) > 2026-09-19 已上板实测(ST-LINK/V2,FW V2J37S7,SN `0B250D12A218303030303032`;STM32F407ZGTx 目标,SWD)。 -> 2026-09-20 复测(V0.1 板):烧写静默失败根因闭环、完整启动闭环(详见 §10)。 -> ⚠️ **本机接线:3 根线(SWDIO / SWCLK / GND),nRST 未接** → 能力边界见 §0,实测记录见 §9。 +> 2026-09-20 复测(V0.1 板):烧写静默失败根因闭环(见 §2 ⑤ 与 §7)、完整启动闭环(见 §5)。 +> ⚠️ **本机接线:3 根线(SWDIO / SWCLK / GND),nRST 未接** → 能力边界见 §0,实测记录见 §5。 > 文中标注 ⓞ 的为"官方帮助/文档说法"与"本机实测"不一致处的更正。 --- @@ -108,7 +108,7 @@ ST-LINK_CLI -c SWD -P "MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex" -Rst -Run - ⓞ **`.hex` 直接支持已实测**:1MB Intel HEX 编程耗时 **6.5s**,`Programming Complete`(远快于 UV4/CMSIS-DAP 路径);`.bin` 同理(`fromelf --bin` 可转,ARMCC 自带 `C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe`);`.axf` 直读未测,无需。 - 加 `-V` 编程后校验、`-Q` 静默。 - 与 `stlink_flash.py flash` 同源(都走 ST-LINK_CLI)。原 `download.bat`(`UV4 -f` 走 CMSIS-DAP)与 `reset.bat` **均已删除**(2026-10-10),烧录/复位/回读统一由 `stlink-flash` skill 承载。 -- ⓞ **静默失败模式(2026-09-20 实测,代价最大的坑)**:省略结尾 `-Rst -Run` 的裸 `-c SWD -P <文件>` 仍报 `Memory programmed in 1s 906ms / Programming Complete`,但芯片 flash **不变**(回读向量表 = 旧镜像)——目标核处于死循环/异常状态时,"Normal 连接、不复位"的会话写入不落地;同文件改用完整 `-P <文件> -Rst -Run` 即正常落地(6.5s / 1MB)。**铁律:任何编程命令必须以 `-Rst -Run` 收尾;任何烧录之后必须 `-CmpFile` 全片回读校验**(详见 §10)。 +- ⓞ **静默失败模式(2026-09-20 实测,代价最大的坑)**:省略结尾 `-Rst -Run` 的裸 `-c SWD -P <文件>` 仍报 `Memory programmed in 1s 906ms / Programming Complete`,但芯片 flash **不变**(回读向量表 = 旧镜像)——目标核处于死循环/异常状态时,"Normal 连接、不复位"的会话写入不落地;同文件改用完整 `-P <文件> -Rst -Run` 即正常落地(6.5s / 1MB)。**铁律:任何编程命令必须以 `-Rst -Run` 收尾;任何烧录之后必须 `-CmpFile` 全片回读校验**(详见 §7)。 ### ⑥ 不扰目标连接(HOTPLUG,运行中板子的只读检查)✅ 本机可用 @@ -207,7 +207,7 @@ ST-LINK_CLI -c SWD -P "MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex" -Rst -Run | `stlink_flash.py halt` | `-c SWD -Rst -Halt` | ✅ 复位停核(一次性会话,读状态须同命令行附加;断开后核自动恢复运行) | | `stlink_flash.py run` | `-c SWD -Run` | ✅ 恢复被停核的目标 | | `stlink_flash.py list` | `-List` | ✅ 枚举探针 | -| `stlink_flash.py flash [F]` | `-c SWD -P F -Rst -Run`(F 缺省 = 档案 `hex` = `MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex`) | ✅ 烧写 + 复位 + 运行(1MB ≈ 6.5s;2026-09-20 复测验证,见 §10) | +| `stlink_flash.py flash [F]` | `-c SWD -P F -Rst -Run`(F 缺省 = 档案 `hex` = `MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex`) | ✅ 烧写 + 复位 + 运行(1MB ≈ 6.5s;2026-09-20 复测验证,见 §2 ⑤) | | `stlink_flash.py cmp [F]` | `-c SWD -CmpFile F 0x08000000` | ✅ 全片回读比对(`No difference found` = 真写入) | 退出码:`0`=OK;`1`=探针/目标错误;`2`=CLI 未找到/无效;`3`=用法错误。 @@ -221,8 +221,8 @@ ST-LINK_CLI -c SWD -P "MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex" -Rst -Run | `[RM=xxx]: Unknown debug protocol or option!`(exit=1) | ⓞ v3.5.0.0 CLI 不支持 `RM=`(帮助与实现不符);**且本机 nRST 未接线**——直接用软件复位 `-Rst`(`stlink_flash.py reset`) | | 连接失败 | 目标供电;SWDIO/SWCLK 接线(本机 3 线,勿依赖 nRST 辅助);芯片休眠 → 加 `LPM`;看门狗反复复位 → 提高连接速度(本机无 `UR` 可用);RDP 被锁 → 只能读有限信息 | | 复位后程序没跑 | 读复位向量确认 Flash 有效:`-R32 0x08000000 1`(应指向 0x08xxxxxx,`0xFFFFFFFF`=空片);查 BOOT0 状态;看串口是否有输出 | -| 复位后卡死(串口日志中断在半路) | ⓞ 2026-09-19 实测案例:`-c SWD HOTPLUG -SCore -CoreReg` 读 PC/寄存器定位卡点(案例:V2 引脚固件卡在 0x08000FA0 启动循环,见 §9)——**先定位再下结论**,勿直接怀疑探针 | -| 烧录报成功但芯片行为不变 | ⓞ **先怀疑静默写入失败**(2026-09-20 实测):命令缺 `-Rst -Run`(裸 `-P` 在目标核死循环/异常时不落地)或选项字节异常(RDP 字节非法,见 §10.4)。处置:`-CmpFile` 全片比对/回读向量表首字 → 用 `stlink_flash.py flash` 重烧 → 再 `-CmpFile` 复核;勿直接怀疑探针/接线 | +| 复位后卡死(串口日志中断在半路) | ⓞ 2026-09-19 实测案例:`-c SWD HOTPLUG -SCore -CoreReg` 读 PC/寄存器定位卡点(案例:V2 引脚固件卡在 0x08000FA0 启动循环,见 §5)——**先定位再下结论**,勿直接怀疑探针 | +| 烧录报成功但芯片行为不变 | ⓞ **先怀疑静默写入失败**(2026-09-20 实测):命令缺 `-Rst -Run`(裸 `-P` 在目标核死循环/异常时不落地)或选项字节异常(RDP 字节非法,见 §3 与 §7)。处置:`-CmpFile` 全片比对/回读向量表首字 → 用 `stlink_flash.py flash` 重烧 → 再 `-CmpFile` 复核;勿直接怀疑探针/接线 | | `daemon unresponsive after auto-start`(serial_tool) | ⓞ 复位后几秒内 daemon 忙于排空日志突发;稍等后直接读 `state/serial.log` 文件验证,勿反复重试 | | `port not found: COMx (available: COMy)`(serial_tool) | ⓞ 端口重新枚举(板子重插常见):改 `.serialtest/config.json` → **kill 旧 daemon 进程** → 重新 `open`(daemon 启动时缓存配置,不重启不生效) | | `log --grep` 查不到明明存在的标记 | ⓞ 该子命令实测不可靠(0 命中);改用直接读/`Select-String` 查 `state/serial.log` | diff --git a/docs/测试说明/m2_data/README.md b/docs/测试说明/m2_data/README.md index 55e5bf59..7d8ed579 100644 --- a/docs/测试说明/m2_data/README.md +++ b/docs/测试说明/m2_data/README.md @@ -1,7 +1,7 @@ # M2 链路延迟实测数据(Phase 13,安静档) 本目录保存 M2(Phase 13:芯片 + 队列时延基线)的**原始实测产物**。判定阈值与解读见 -[`../链路延迟测试说明.md`](../链路延迟测试说明.md) §6.2;缺陷总表见 §8.1。 +[`../链路延迟测试说明.md`](../链路延迟测试说明.md) §6.2;缺陷总表见 `docs/测试说明/链路延迟测试说明.md` §8。 ## 1. 运行元数据 @@ -115,6 +115,6 @@ frame#10:first=16.522ms batches=[1024,1024,1024,137] tail_lag=+55.7ms ## 7. 未纳入本目录的数据 -- run1~run11 为缺陷定位过程(D11~D16 见测试说明 §6.1),其中 run10/run11 的 L7 失败、 +- run1~run11 为缺陷定位过程(D11~D16 见 `docs/测试说明/链路延迟测试说明.md` §6.1),其中 run10/run11 的 L7 失败、 run9 的 170s 连接拒绝均已定位并修复; - 压力档(`--ftp-stress`,E-02 复现)与 `--mode remo`(生产 RemoDispBus 客户端)属 M3。 diff --git a/docs/测试说明/串口助手使用指南.md b/docs/测试说明/串口助手使用指南.md index 1ad6e35d..f9c9a51b 100644 --- a/docs/测试说明/串口助手使用指南.md +++ b/docs/测试说明/串口助手使用指南.md @@ -344,7 +344,7 @@ Get-Content '.dsh\skills\serial-assistant\scripts\state\daemon.log' -Tail 30 # - **daemon 会一直占着内存吗?** 一个 Python 进程约 20MB;不需要时 `stop` 即释放。常驻与否都不影响正确性。 - **daemon 被强杀(taskkill)会怎样?** 端口由系统自动释放;下次任何命令自动拉起新 daemon。**串口不会自动重开**,需要时再 `open`(这是设计:避免在设备异常时盲目重连)。 -- **能同时开两个串口吗?** v1 单口(一个 daemon 管一个口)。多口需求见设计文档 §8 v2 候选。 +- **能同时开两个串口吗?** v1 单口(一个 daemon 管一个口)。多口需求见 `docs/测试说明/串口助手设计说明.md` §8 v2 候选。 - **能直接删 `serial.log` 吗?** 可以,但代际 `gen` 不会变、写句柄不变——建议用 `clear`(原子 + 代际 +1 + 标记行)。 - **为什么 TX 日志里是字面 `\r\n` 而不是真换行?** 日志按行存储;真换行会把一条 TX 劈成两行,转义后一条 TX 恒一行。 - **ST-Link 烧录会抢串口吗?** 不会(烧录走 SWD,不走 COM)——这正是"串口全程保持打开、烧录前后不重开"的设计基础,也彻底绕开了 CH340 释放/重开陷阱。 diff --git a/docs/测试说明/实机测试实施指南.md b/docs/测试说明/实机测试实施指南.md index 7ef87c33..27fcae45 100644 --- a/docs/测试说明/实机测试实施指南.md +++ b/docs/测试说明/实机测试实施指南.md @@ -113,7 +113,7 @@ |---|---|---|---| | **V2** | 任务饿死 | 临时在 `netTask` 循环插 `while(1);` | 800 ms 后串口 `[ERR] WATCHDOG: task stall mask=0x...`;≤2.5 s 复位;重启后 `[WD] reset cause=watchdog reset` | | **V4** | 关中断死循环 | 临时插 `taskDISABLE_INTERRUPTS(); while(1);` | ≤2.5 s 复位(检验 IWDG **独立于中断**);`cause=watchdog reset` | -| **V5** | 长期不误杀(**核心验收项**) | FTP 大文件传输 + UI 菜单遍历 + 配置保存 + 网络重配 **≥30 min** | **零复位**(重点验证 §4.4 白名单是否完备) | +| **V5** | 长期不误杀(**核心验收项**) | FTP 大文件传输 + UI 菜单遍历 + 配置保存 + 网络重配 **≥30 min** | **零复位**(重点验证 `docs/子系统设计/看门狗设计说明.md` §4.3 白名单是否完备) | > ⚠️ **V5 是核心验收项**:误杀比不杀更有害。必须在**真实业务负载**下跑足时长。 @@ -124,7 +124,7 @@ # 1) 基线:跑一轮产品 @test 确认无复位 & '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py -# 2) 注入:按设计说明 §10 在 netTask 循环插 while(1);(或 taskDISABLE_INTERRUPTS(); while(1);) +# 2) 注入:按 `docs/子系统设计/看门狗设计说明.md` §7 在 netTask 循环插 while(1);(或 taskDISABLE_INTERRUPTS(); while(1);) # —— 改完 @build,然后烧录 & '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py & '.venv\Scripts\python.exe' .dsh\skills\stlink-flash\scripts\stlink_flash.py flash MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex @@ -148,7 +148,7 @@ $cli = if ($env:DTU_STLINK_CLI) { $env:DTU_STLINK_CLI } else { (Get-Command ST-L **风险/回滚**: - 故障注入用例**会复位设备多次**——确认设备非在用/非已部署。 - 长跑涉及 NAND 写入(FTP 上传)**消耗 NAND 块**——若设备已接近容量上限,改用只读传输。 -- ⚠️ **R5 风险**(设计说明 §9):NAND 擦写偶发超 800 ms 可能误复位——若 V5 出现偶发复位, +- ⚠️ **R5 风险**(`docs/子系统设计/看门狗设计说明.md` §9):NAND 擦写偶发超 800 ms 可能误复位——若 V5 出现偶发复位, 先按设计说明增大容忍次数或把存储操作改分段置位,**不要盲目放宽全局超时**。 --- diff --git a/docs/测试说明/模拟器差距清单.md b/docs/测试说明/模拟器差距清单.md index 696a9541..05f77ce4 100644 --- a/docs/测试说明/模拟器差距清单.md +++ b/docs/测试说明/模拟器差距清单.md @@ -51,7 +51,7 @@ | # | 差距 | 模拟器行为 | 真机行为 | 影响 | 回归补测 | |---|---|---|---|---|---| -| L-1 | **控制台单写者无对应实现** | `simulator/dbg_log.h` 把 `DBG_*` 映射到 `printf`;**不编译** `console_log.c` | 单写者环形缓冲 + defaultTask 唯一发送 | `console_log` 的环形缓冲溢出/丢弃计数逻辑**模拟器测不到**;共享代码若误调 `console_log.h` 声明,模拟器链接失败(已被此约束捕获) | `@test` + 板端串口观测;`docs/子系统设计/控制台单写者日志设计说明.md` §219 | +| L-1 | **控制台单写者无对应实现** | `simulator/dbg_log.h` 把 `DBG_*` 映射到 `printf`;**不编译** `console_log.c` | 单写者环形缓冲 + defaultTask 唯一发送 | `console_log` 的环形缓冲溢出/丢弃计数逻辑**模拟器测不到**;共享代码若误调 `console_log.h` 声明,模拟器链接失败(已被此约束捕获) | `@test` + 板端串口观测;`docs/子系统设计/控制台单写者日志设计说明.md` §4.1 | | L-2 | **mtime 粒度不同** | 宿主文件 mtime(**秒级**) | FAT 打包时间(`fdate<<16\|ftime`,**分钟级**) | `cfg_file_poll` 的 `(size, mtime)` 变更令牌在两端触发时机不同;分钟级粒度下的"同分钟内两次改动"在真机可能被判为无变化 | 板端 STORAGE 套件;`配置文件设计说明.md` §变更检测 | | L-3 | **无应用回调(空操作)** | 模拟器不注册段回调,`cfg_file_poll` 重载后回调为空 | 各段回调真正执行(如网络重配置) | "配置变更 → 触发回调 → 副作用"链路在模拟器上**只验证到写盘**,回调逻辑不覆盖 | `@test`(配置保存 → UI 生效) | | L-4 | **堆后端不同** | `cfg_store_alloc` = 宿主 `malloc`(堆余量以 MB 计) | FreeRTOS 堆(`configTOTAL_HEAP_SIZE` 48 KB,实测最低水位 ~4.8 KB) | **分配失败路径在模拟器上永不触发**——P0-1 类"KB 级分配必然返回 NULL"缺陷模拟器照不出来(`AGENTS.md` 已列为铁律) | 内存预算门禁 + 真机 `[HEAP]` 水位告警(S11) | @@ -66,7 +66,7 @@ | H-4 | **SD2506 RTC** | 无 | I2C RTC:时间读写、闹钟、温度、电池电压 | 时间源相关(`sys_clock_init` 降级路径)在模拟器上不可触发 | 板端 RTC 套件(18 断言点)+ `sys_clock` L0.5(已覆盖算术面) | | H-5 | **TPAFE5160 ADC** | 无 | SPI ADC + BUSY EXTI 中断 | 中断驱动采集链路 | 待功能实现(上游 B1) | | H-6 | **RS-485 半双工** | 无 | UART4 + DE 方向控制,DWT 微秒延时 | DE 收发切换时序(特有陷阱) | 待功能实现(上游 B2) | -| H-7 | **启动段硬件初始化** | 只跑纯软件部分 | I2C/SPI 外设初始化、BBT 扫描耗时、FatFS 挂载 | 启动段死机(无看门狗保护窗口)**只能在真机复现** | 真机开机日志判读(`项目启动顺序管理.md` §6.8,S10 落地) | +| H-7 | **启动段硬件初始化** | 只跑纯软件部分 | I2C/SPI 外设初始化、BBT 扫描耗时、FatFS 挂载 | 启动段死机(无看门狗保护窗口)**只能在真机复现** | 真机开机日志判读(`项目启动顺序管理.md` §6,S10 落地) | ### 2.4 哪些差异是"有意的对齐"(非缺陷) diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index 8dcb9ada..9998c5a6 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -529,7 +529,7 @@ flowchart LR > 新增任务/栈/缓冲/代码导致的体积增长**不会在 CI 被拦**。故该跳过以 `::warning::` 明示 > (不是弱化的 notice)并写入 step-summary。**兜底必须靠本地**:改代码后 `@build`(0/0) > → `python scripts/check_mem_numbers.py --scan`。详见 -> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0.0。 +> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0。 ### 9.2 两个 job diff --git a/docs/测试说明/看门狗单元测试说明.md b/docs/测试说明/看门狗单元测试说明.md index 1111137e..897d41d6 100644 --- a/docs/测试说明/看门狗单元测试说明.md +++ b/docs/测试说明/看门狗单元测试说明.md @@ -93,7 +93,7 @@ test/host/watchdog/watchdog_test.c | **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑,比板端套件便宜、比静态检查更强 | | **pre-commit 钩子** | `watchdog-unit-test`(本地钩子,`.pre-commit-config.yaml` §7) | | **汇总行** | `=== Task Watchdog Tests: N/N PASSED (failed=0) ===` | -| **与板端测试的关系** | 互补:本单测保证**判定逻辑**正确;板端 `@test` 故障注入(V2/V4,见 `子系统设计/看门狗设计说明.md` §7 验证记录)保证**真机复位链路**正确 | +| **与板端测试的关系** | 互补:本单测保证**判定逻辑**正确;板端 `@test` 故障注入(V2/V4,见 `docs/子系统设计/看门狗设计说明.md` §7 验证记录)保证**真机复位链路**正确 | --- diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md index d1e99923..81ff98f8 100644 --- a/docs/测试说明/自动化测试流程.md +++ b/docs/测试说明/自动化测试流程.md @@ -82,7 +82,7 @@ | 抓到日志缺 `Network Init` | `-SettleMs` 太小(boot 任务间有 ~3-4s 自然静默间隙),用默认 8000ms;2026-10-10 起首字节到达前不启动静默计时(`--first-wait-ms` 默认 12000),首行晚到不再被提前收尾 | | 烧录后无数据(`no_data`) | 2026-10-10 起 `no_data` = **本次等待期间零字节**(旧实现比 daemon 累计字节,常驻 daemon 下永不触发,只能靠"日志里没有 [RX] 行"兜底);开串口 + clear 必须在烧录**前**完成(`run_boot_test.py` 已保证);核对 COM 号、板子供电,看 `state/serial.log` 有无 SYS 异常行 | | `[MISS] fw build: <戳>`(其余标记全中) | 芯片里跑的不是这份构建:烧录没落地 / 烧错文件 / hex 未重编译。处置:`stlink_flash.py flash ` 重烧并看 `READBACK: OK`;确认 `@build` 已重跑(戳取自编译时刻) | -| `READBACK: FAIL` / `flash readback mismatch` | 芯片内容与 hex 逐字节不一致(写入未落地、选项字节/RDP 异常、烧错目标)。处置:按 `docs/测试说明/ST-LINK_CLI_复位使用说明.md` §10 排查;`stlink_flash.py flash` 重烧后闭环会自动复核 | +| `READBACK: FAIL` / `flash readback mismatch` | 芯片内容与 hex 逐字节不一致(写入未落地、选项字节/RDP 异常、烧错目标)。处置:按 `docs/测试说明/ST-LINK_CLI_复位使用说明.md` §7 排查;`stlink_flash.py flash` 重烧后闭环会自动复核 | | `boot banner x2 > allowed 1` | 采集窗口内板子自己复位过(IWDG 咬狗 / Fault 后 `NVIC_SystemReset` / 掉电重启)。处置:看判定里逐条打印的 `reset cause:` 定位;跑**故意复位**的套件用 `--max-boot-count N` 放宽 | | `marker order violated` | 标记全中但**先后顺序颠倒** = 某步在另一步之后补做(就绪屏障失序 / 失败重试补跑)。处置:看判定打印的各标记首次行号定位;`--require-order` 只在需要强约束时启用 | | `boot time N ms > limit` | 能开机但慢(mount 重试 / PHY 重协商 / 复位重试)。实测基线:`net init: OK` 在 **+537ms**(2026-10-10 台架);默认只报告不判(`--max-boot-ms 0`),要设门禁再给预算 | diff --git a/docs/测试说明/链路延迟测试说明.md b/docs/测试说明/链路延迟测试说明.md index 0ffdedf5..a1a9a4ff 100644 --- a/docs/测试说明/链路延迟测试说明.md +++ b/docs/测试说明/链路延迟测试说明.md @@ -26,7 +26,7 @@ MCU 侧真实处理只占 6.7ms,任务拾取/调度约 2.3ms(2026-09-22 重 非 Windows 用"入站轻推"兜底(**29.4ms**,2.3×);协议与固件都不需要改(§7.4)。 - **设备侧无可行解**(帧长 3209 为质数、TX FIFO = MSS、CH395F 无 Nagle 开关)→ 不必再投入。 - **缺陷**:D10(断线重监听 588ms 冻结 netTask)、E-02(一条空闲 FTP 控制连接冻结全网 17.7s)、 - D17(INIT 应答帧字节数字段恒 0)等已量化并**修复 + 台架复验**(§8.1); + D17(INIT 应答帧字节数字段恒 0)等已量化并**修复 + 台架复验**(§8); D1(重传周期 500→200ms)/ D6(per-recv 日志)2026-09-22 已实施(§10.1);D20 已定位未修(§8.2)。 --- @@ -642,7 +642,7 @@ sock4 auto-relisten ok / relisten retry ok ← 监听器随后自行恢复 |---|---|---| | 每帧都**稳定**慢 ≈150~300ms | 结构性基线(§10.1 的 D2/D5/D6/D8) | 跑 §5 的 Phase 13/14 对照基线 | | **间歇**卡,且 ≈200ms 的整数倍(旧固件 500ms) | **D1** 丢包重传 | Wireshark `tcp.analysis.retransmission`;查 PHY/网线/Wi-Fi | -| **间歇**整屏冻结数秒~数十秒 | **E-02** 头阻塞(已修)/ D10 重连(已修)/ 回归 | 查 FTP/对时会话是否挂着;核对 §8.1 修复在位 | +| **间歇**整屏冻结数秒~数十秒 | **E-02** 头阻塞(已修)/ D10 重连(已修)/ 回归 | 查 FTP/对时会话是否挂着;核对 §8 修复在位 | | 只在开 FTP / 对时后卡 | E-02 + 单连接 socket 争用 | 临时停 ftpTask/timeSyncTask 对照 | | 换网口/换线/换 PC 就好 | D7 物理链路 | §10.2-S3 拓扑纪律 | @@ -652,20 +652,20 @@ sock4 auto-relisten ok / relisten retry ok ← 监听器随后自行恢复 |---|---|---| | L1/L2 超 crit | SPI 走线/PCB/SPI2 分频或 DMA 配置 | 对照 `docs/问题记录/CH395F_Trap_Records.md`;逻辑分析仪抓 SCK/CS | | L3 spike ≥200ms(连续台阶;旧固件 500ms) | **D1 重传 / D7 链路** | Wireshark `tcp.analysis.retransmission`;查 PHY 协商/网线/Wi-Fi | -| L3 基线(非 spike)抬高 | 芯片发送路径 / 中断响应 | 对照 CH395F 手册 §5.17/5.18(注意:状态寄存器**不被 SET 类命令更新**,`GET_CMD_STATUS` 恒读到 RESET 的 0x10 陈旧残留,2026-09-22 台架实证——不可用作 SET 值验收) | +| L3 基线(非 spike)抬高 | 芯片发送路径 / 中断响应 | 对照 `docs/芯片手册/CH395F.md` §5.17/5.18(注意:状态寄存器**不被 SET 类命令更新**,`GET_CMD_STATUS` 恒读到 RESET 的 0x10 陈旧残留,2026-09-22 台架实证——不可用作 SET 值验收) | | L5 压力档爆表 | **E-02 头阻塞(已修)→ 回归** | 定位触发源(阻塞 recv 的 FTP/对时);核对 L3 pending 在位(`net_recv_locked` 阻塞分支 = 登记 pending 而非等待;`net_pending_tick` 在主循环) | | L5/L6 静默档抬高 | D5 调度(任务抢占/节拍) | 查任务优先级与 osDelay;`stack_report.py` 核对栈水位 | | L6/L7 整体抬高且 L1~L5 正常 | 线路+PC 段(L4) | Wireshark 段间间隙;`Δ` 对照定位 MCU 侧净贡献 | | **L7/L8 尾批固定迟到 ~50ms** | 已定性(§7.3):次 MSS 尾段被 Nagle 扣住 + Windows 延迟 ACK | 客户端侧解法已实测(§7.4):`--ack-probe` / `--ack-fast` → tail 52→3.9ms;生产客户端已实装;**设备侧无解**,不必再投入 | -| **L5 出现重复投递**(收到 > 发送) | **D11**:芯片 TCP RX 饱和重复递交 | 降低灌入速率/加抽干(§8.1 D11) | -| **会话无征兆失步**(结果错位/挂死) | **D12**:net 层任务通知值语义 | 核对 `net_wait_result` 清除掩码全 1 + 按返回值判定(§8.1 D12/D12-a) | -| **L7 一直超时 / peer 报 `l7_crc.fail`** | **D15**:帧格式不一致 / peer 超读 | 对照 `ui_net_sink.c` 的 `remo_send`/`remo_handle`(§8.1 D15) | -| **会话跑完一段后无限循环、peer 空等 300s** | **D16**:Phase 13 状态机迁移目标错 | 核对顺序 L5→L3→L10→L6→L7→DONE(§8.1 D16) | -| **`[LAT]` 段行缺失/截断、wait-idle 判不出空闲** | **D12-b**:热路径日志洪水 | 删除 net 热路径逐调用日志(§8.1 D12-b) | +| **L5 出现重复投递**(收到 > 发送) | **D11**:芯片 TCP RX 饱和重复递交 | 降低灌入速率/加抽干(§8 D11) | +| **会话无征兆失步**(结果错位/挂死) | **D12**:net 层任务通知值语义 | 核对 `net_wait_result` 清除掩码全 1 + 按返回值判定(§8 D12/D12-a) | +| **L7 一直超时 / peer 报 `l7_crc.fail`** | **D15**:帧格式不一致 / peer 超读 | 对照 `ui_net_sink.c` 的 `remo_send`/`remo_handle`(§8 D15) | +| **会话跑完一段后无限循环、peer 空等 300s** | **D16**:Phase 13 状态机迁移目标错 | 核对顺序 L5→L3→L10→L6→L7→DONE(§8 D16) | +| **`[LAT]` 段行缺失/截断、wait-idle 判不出空闲** | **D12-b**:热路径日志洪水 | 删除 net 热路径逐调用日志(§8 D12-b) | | **开机日志连续丢行**(含必检标记 `net init: OK` 等)但系统健康 | **日志并发竞态**:多任务并发 `printf` → 非重入 `fputc`/`HAL_UART_Transmit` 丢整行、UART 状态短暂卡住(2026-09-22 台架实证:boot 时 testTask 启动行与 netTask 日志并发 → 连续 4 行丢失、~2s 后自恢复) | 必现证据行采用"静默采集 + 延迟到安静窗口打印"模式(范例:P0-a 验收行曾按此模式实装,验收完成后探针已移除);根治 = S8 日志非阻塞/串行化(§10.2) | -| **`relisten` 段 FAIL、断线固定 ~588ms** | **D10** 回归(退回 `osDelay(500)`) | 核对 `net_relisten_try`/`net_relisten_retry_tick` 非阻塞重试在位(§8.1 D10) | +| **`relisten` 段 FAIL、断线固定 ~588ms** | **D10** 回归(退回 `osDelay(500)`) | 核对 `net_relisten_try`/`net_relisten_retry_tick` 非阻塞重试在位(§8 D10) | | **远端显示整帧卡住十几秒后自恢复** | **E-02** 回归 | 核对 L3 pending 在位:`net_recv_locked` 阻塞分支登记 pending(哨兵 `NET_PENDING_REGISTERED`)+ `net_pending_tick` 在主循环 + 调用方剩余预算循环(lftpd/timeSync)(§8.3 要点 4) | -| **INIT 应答帧字节数字段恒为 0** | **D17** 回归 | 核对 `remo_send_init` 发满 8B(§8.1 D17) | +| **INIT 应答帧字节数字段恒为 0** | **D17** 回归 | 核对 `remo_send_init` 发满 8B(§8 D17) | | **开机首个 FTP 会话第 2 个数据连接 425** | **D20**(已定位未修,§8.2) | 复现:`test/host/ftp_regression.py`(复位开边界);现场规避:客户端重试 425(瞬态 4xx);根治方向:数据 Socket 轮换 | | **`[LAT]` 多段数值雷同 / 全 0 却 verdict=OK** | **D18/D19**(测试工装) | 核对 `lat_probe_add_seg` 段名过滤 + `lat_time_init` 调用 + `med==0` 判无效 | | **连接建立后 15~30s 无流量即被 RST** | **D13**:芯片空闲超时(芯片行为) | 客户端须容忍并重连(peer `--wait` 重试是正常路径) | @@ -677,13 +677,13 @@ sock4 auto-relisten ok / relisten retry ok ← 监听器随后自行恢复 ## 10. 结构性成本项与待办方向 -### 10.1 静态分析十项结构原因(D1~D10,独立于 §8.1 的台架缺陷编号)与当前状态 +### 10.1 静态分析十项结构原因(D1~D10,独立于 §8 的台架缺陷编号)与当前状态 | # | 原因 | 实测验证(2026-09-22 台架) | 当前状态 | |---|---|---|---| | D1 | CH395F TCP 重传固定周期(非 RTT 自适应) | 台架链路(有线 100M 全双工)多轮零重传尖峰 → 台架无丢包;**弱网(Wi-Fi/双工不匹配)仍是秒级卡顿首要嫌疑**,200ms 粒度台阶 = 重传指纹(旧固件 500ms) | **已修(P0-a,2026-09-22)**:`NET_RETRANS_PERIOD_MS` 500→**200**(窗口 8×200=1.6s,原 4s)。台架验收说明:`GET_CMD_STATUS` 对 SET 类命令无判别力(500/2000ms 超限对照均读 0x10 陈旧残留,§9.2)→ 接受性以手册范围(≤1000ms)+ 全套件零回归(§6.2/§6.3)+ 与 500ms 行为同构为准。验收探针(3×20ms 状态轮询 + 证据行)验收完成后已从代码移除,结论保留于本文档 | | D2 | 3209B × 1KB 发送 FIFO 停止等待(4 次串行分块握手,期间 netTask 全程占用) | `chunk_wait` 219µs/块 ≈ SPI 搬运成本的 1/7 → 握手本身**不是瓶颈**;每帧固定成本小(MCU 净 7.1ms) | 基线成本,可接受;根治 = 差量/RLE + 帧版本号(待办 S2) | -| D3 | netMsgQueue 头阻塞(= §8.1 E-02) | 已量化:一条空闲 FTP 控制连接冻结全网 17.7s | **两阶段闭环**:① 5ms 分片有界读 + 让出(f8d6e90/4a1605b,缓解)② **L3 pending 化**(`a45c0cb`,结构性——执行器零占用,缺陷类消灭,§8.3 要点 4) | +| D3 | netMsgQueue 头阻塞(= §8 E-02) | 已量化:一条空闲 FTP 控制连接冻结全网 17.7s | **两阶段闭环**:① 5ms 分片有界读 + 让出(f8d6e90/4a1605b,缓解)② **L3 pending 化**(`a45c0cb`,结构性——执行器零占用,缺陷类消灭,§8.3 要点 4) | | D4 | PC 端延迟 ACK 与"每段等 ACK"停止等待交互 | `--ack-fast` 带内实验证实:关掉延迟 ACK 尾批自行消失(52→3.9ms) | **已客户端侧解决**(生产客户端关延迟 ACK/轻推兜底,§7.4) | | D5 | uiTask 节拍:10ms 循环 + 每拍一次 64B recv + step 在 poll 前 | `ui_loop` 10986µs ≈ 设计值(渲染 ~1ms);拾取调度 ~2.3ms(P0-b 删 per-recv 日志后,原 ~6.8ms) | 基线行为;可选优化 S7(poll 前置 + while 排空),低优先 | | D6 | 阻塞式日志在关键路径(fputc 逐字节 @115200) | `logtax_line` 4402µs/行;per-recv 日志已删(2026-09-22):`queue_rt_recv` 5997→1223µs、帧周期 66.0→61.6ms、首字节 13.9→9.6ms | **部分已修(P0-b,2026-09-22)**;S8 非阻塞日志仍待办(同时是开机日志并发丢行 §9.2 的根治项) | diff --git a/docs/环境与部署/CI配置指南.md b/docs/环境与部署/CI配置指南.md index 0ef41c37..206d5bfb 100644 --- a/docs/环境与部署/CI配置指南.md +++ b/docs/环境与部署/CI配置指南.md @@ -341,7 +341,7 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: | `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | runner 没装或不在 PATH | 装完后**重启 runner 服务**(新进程才吃到新 PATH) | | `checkout` 步骤卡住/超时 | 拉不到 `actions/checkout@v4`(内网) | 见 §4.3 | | `l05` 日志出现 `::warning::runner 未安装 MSVC` | VS 装在非默认路径或未装 | 见 §3.2(默认路径 / 同步改两处根目录) | -| `mem-numbers` 显示 `Skipped` | 正常(CI 无 Keil,无 `.map`) | 无需处理;但注意 **CI 未守体积门禁**(见 §0.0),改代码后本地 `@build` + `check_mem_numbers.py --scan` 兜底 | +| `mem-numbers` 显示 `Skipped` | 正常(CI 无 Keil,无 `.map`) | 无需处理;但注意 **CI 未守体积门禁**(见 §0),改代码后本地 `@build` + `check_mem_numbers.py --scan` 兜底 | | L0.5 显示 `2 runner(s), X/Y ...` 但 X.log` | | L0.5 报 `produced no '=== ... N/M PASSED ===' summary line` | 该模块运行器未输出汇总行(改了 harness 或运行器崩溃) | 核对 `test/host/common/unit_test_harness.py` 的 `summary_marker` 与该模块 `TestBuild` 是否一致 | | `Tee-Object` 步骤报 `TerminatorExpectedAtEndOfString` / 字符串缺少终止符 | `run:` 块混入了非 ASCII(GBK 解码错位) | 见文件头 🔴🔴;`run:` 内必须纯 ASCII | diff --git a/docs/硬件平台/ADC通道映射说明.md b/docs/硬件平台/ADC通道映射说明.md index 147ead27..709e6277 100644 --- a/docs/硬件平台/ADC通道映射说明.md +++ b/docs/硬件平台/ADC通道映射说明.md @@ -133,7 +133,7 @@ STM32(U17)为 LQFP144 脚号。 | ADC1 上电自校准 | **已补(2026-10-10)**:`Src/adc.c` 的 `USER CODE BEGIN ADC1_Init 2` 调用 `adc1_calibrate()`(实现同文件 `USER CODE BEGIN 1`,按 RM0090 §13.3.6:清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)。⚠ **CubeMX 不生成这一步**,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头**没有** `ADC_CR2_CAL`(bit3,代码里按 RM 自建 `ADC1_CAL_BIT`)→ 属 L2 手工定制,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底 | 实测有效:校准前后同一拍 STM32 侧 6 路**同向**变化 `UB 898→961`、`UC 978→1038`、`IB 906→922`、`IC 1061→1071`、`IN 1017→1055`、`T 790→846`(+10~+63 LSB ≈ 0.2~1.5 % FS);TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。缺这一步不报错,只是**读数带失调** | | ADC 时钟 | CubeMX 的 F4 ADC 面板**有** Clock Prescaler 一项(此前"面板没有该项、固定 DIV2"的判断有误),已选 **PCLK2/4 → ADCCLK = 21 MHz** ✓。面板里 **DIV2 是灰的、选不到**:DB 里 `FadcConstraint Max=36000000`,APB2 = 84 MHz → DIV2 = 42 MHz 超限,该候选值被 `Action="DISABLE"` 禁掉,超规值进不来 | **不设额外防护(2026-10-09 决定)**:约束已由 CubeMX 把关;`.ioc` 虽不保存 `ADC1.ClockPrescaler` 键,重生成也只会回落到约束允许的值。按 144+12 cycles 算 ≈ 7.4 µs/通道、9 通道一轮 ≈ 67 µs | | `adc_task` | **已实现(B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费,2026-10-10)**:`App/task/adc_task.c` 每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 原子快照(8 通道)+ 消费一帧 STM32 ADC1 DMA 乒乓缓冲,**经 `adc_input_raw()` 按语义取数**(不再直接索引驱动缓冲);**消费唤醒由 TIM3 ISR 置位 `ADC_WAKE_FLAG`**(不再 1 ms 轮询);任务在 `Src/freertos.c` 的 `USER CODE` 区创建为 `adcTask`(512 words / **AboveNormal**) | 数据路径已通(台架 `@test`:`frames` 严格 1000 Hz 递增、**`idle=0`**、`adc1_rc=0` / `adc1_err=0`) | -| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM3 更新事件(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim3)` 启动节拍(在 `tpafe5160_init()` 之后)。同一 TIM3 更新事件还经 **TRGO** 硬件触发 ADC1 一轮 9 通道(DMA 搬运),并调 `adc_tick_irq_notify()` **唤醒消费者**(P1-5 方案 a,2026-10-10;内含"消费者就绪"门)→ 两片转换器由同一硬件事件同源对齐 | 连续波形(1 kHz);详见 `子系统设计/ADC采样节拍设计说明.md` §10 / §11 | +| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM3 更新事件(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim3)` 启动节拍(在 `tpafe5160_init()` 之后)。同一 TIM3 更新事件还经 **TRGO** 硬件触发 ADC1 一轮 9 通道(DMA 搬运),并调 `adc_tick_irq_notify()` **唤醒消费者**(P1-5 方案 a,2026-10-10;内含"消费者就绪"门)→ 两片转换器由同一硬件事件同源对齐 | 连续波形(1 kHz);详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10 / §11 | | 前端偏置 | 每路 1k 串联 + 1k 到 `*_GND` + 滤波 C,**无中点偏置** | STM32 侧只能测 **0~3.3 V 单极性**,交流量负半周被削顶;主板侧只做监视可接受,若要算 RMS/功率需硬件补偏置或明确算法口径 | | TPAFE 量程 | 默认 ±5 V(RANGE 接 GND),16 位二进制补码,LSB ≈ 152.59 µV | 用 `tpafe5160_to_voltage()` 换算 | | 取数契约(**2026-10-10 已修**,E-44 / E-45 / E-58 闭环) | 旧的 `get_buf` / `data_ready` / `clear_ready` **已删除**(删除前确认全仓库零调用者):`get_buf` 只给指针、任务再拷 → 被 BUSY EXTI 抢占就是**两帧混合**;`s_ready` 电平标志被 `start_conv_irq` 触发前清 0 → **重触发即丢未消费帧**;`read_all` 从不看 FRSTDATA(宏有定义、代码零引用)→ 芯片死亡时返回 OK + 常量垃圾。现口径:**`tpafe5160_snapshot(dst, count)`**(临界区原子拷贝,返回 1/0/-1,**仅任务上下文**)+ **`tpafe5160_frame_seq()`**(帧序号只增不清)+ **`tpafe5160_frstdata_err()`**(无跳变帧计数);`tpafe5160_init()` 增**首帧自检**——一帧读下来 FRSTDATA 无跳变(芯片不驱动并行总线;板上 R24 4.7k 上拉会把它钉死为高)→ 返回 `-3` + `[ERR]` | 消费者**只能**用 `snapshot()` 取数(`AGENTS.md` 已立为铁律);真机实测开机 `[ADC] Init OK` 现只在自检帧通过后才打印 → 判据在本板成立、非空转 | diff --git a/docs/硬件平台/V0.0_网表详细分析报告.md b/docs/硬件平台/V0.0_网表详细分析报告.md index 50b8489f..8e9e4771 100644 --- a/docs/硬件平台/V0.0_网表详细分析报告.md +++ b/docs/硬件平台/V0.0_网表详细分析报告.md @@ -277,7 +277,7 @@ flowchart LR - 器件为 **SD2506API 模块**(非裸片):内置 32.768k 晶振 + **内置充电电池 + 充电电路**(手册 L11:电池满充常温下掉电走时约 8 个月,电池寿命 5~8 年;自动电源切换:VDD<2.4V 且 VDD **误差来源与预算**(参考《ADC采集至数据使用全流程详解》§12):采样量化(TPAFE LSB ≈ 152.6 µV)、通道标定残差、窗长偏离整周波(频偏)、系数定点舍入。前三项决定可达精度,最后一项可忽略。 +> **误差来源与预算**(参考《ADC采集至数据使用全流程详解》[计划] §12):采样量化(TPAFE LSB ≈ 152.6 µV)、通道标定残差、窗长偏离整周波(频偏)、系数定点舍入。前三项决定可达精度,最后一项可忽略。 ### 4.3 算力与内存预算 @@ -603,6 +603,6 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **L1-3/L1-4 落地:STM32 ADC1 扫描 + 语义映射** | 新增 `App/algo/adc_input.c/h`(L1 采集层):① `k_adc_map[]` **语义通道表**(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_desc()`/`adc_input_raw()` **按语义取数**;② `stm32_scan()` STM32 ADC1 9 通道软件触发顺序扫描(`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`);③ `adc_input_poll()` 统一"TPAFE 快照 + STM32 扫描"。`adc_task.c` 取数改走本层(首帧日志补全 8 路 + 温度、心跳增 `adc1_rc`/`adc1_err`);`app_main.c` 在 `tpafe5160_init()` 后调 `adc_input_init()`(含**映射自检**:STM32 映射通道必落在扫描序列内,失败记 `SYSLOG_SELFTEST`);Keil 工程挂 `adc_input.c` 并加 `../App/algo` 包含路径。§1.2 采样软件行、§3.1 L1-3/L1-4 同步。`@build` 0 错 0 警;台架 `@test` PASS(`ADC input layer init: OK`、`adc1_rc=0`/`adc1_err=0`、`seq` 仍 1000.0 Hz、`adc` 栈峰值 528→584 B、回读一致、零 `[ERR]`/HardFault),证据 `测试说明/boot_log_data/20261010_prod_adc_l1_boot_log.txt` | | 2026-10-10 | **L2 相量层落地 + L2-2 实例数口径对齐** | 新增 `App/algo/sample_ring.c/h`(L2 输入:按语义通道保存 2 周波 = 40 点环缓冲,整帧写后统一推进写指针)与 `App/algo/phasor.c/h`(L2 核心:全周傅氏单频 DFT,N = 20;系数表**离线预乘 √2/N** → `fAmSQ` 直接为**有效值**;计算区/使用区**双区原子切换**;实例表 `k_inst[]` 数据驱动);主机侧单测 `test/host/phasor/` **57 用例全绿**(`phasor-unit-test` 钩子)。**§3.2 L2-2 行口径对齐**:参考项目为 **4 个实例**,本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),已加注说明 4 → 5 的来历;§4.3 相量区 RAM 行同步为 5 实例。⚠ §3.2 的 L2 各点状态**仍标 ⬜**——实现与主机单测已完成,但**台架 `@test` 闭环未做**,且 L2-3 的完成判据(标准源读数 = 设定有效值)在无信号源台架上不可达,故不置 ✅(口径见 §3.2 表下注) | | 2026-10-10 | **L1-1/L1-3 节拍源与搬运方式升级(v2.8)** | 节拍源 **TIM6 → TIM3**(更正硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时触发 TPAFE CONVST 与(经 TRGO)ADC1 一轮 9 通道;L1-3 由"软件触发 + 逐通道轮询"改为 **DMA2_Stream4 循环搬运 + 乒乓双缓冲**,`stm32_scan()` → `stm32_take()`,单拍 `adcp` **122 µs → 8 µs**。§1.4 采样软件行、§3.2 L1-1/L1-3/L1-4 同步。完整设计与实测见 `docs/子系统设计/ADC采样节拍设计说明.md` §11 | -| 2026-10-11 | **L2 台架闭环:P0 收尾** | `@build` 0 错 0 警 → `@test` 闭环把 `phasor(first)`/`phasor(hb)` 列为 Required 并校验顺序 `App init OK>phasor(first)>phasor(hb)`(⚠ `net init: OK` **不在**该序内——adcTask 与 netTask 并行,`phasor(first)` ≈+500 ms 早于 `net init: OK` ≈+574 ms 属正常)→ **PASS**:5 实例相量开机即产出、心跳 5 实例持续刷新、`frames` 每 10 s +10000(1000.0 Hz)、`idle=0`、`frst_err=0`、`adc1_err=0`、`adc1_hw=0`、`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`/HardFault;证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`。栈复测:adcTask 静态 832 B / 实测峰值 **928/2048(余 54%)**,7 任务全 PASS(RAM 方案 §2.3 已同步)。§3.1 L1-9 置 ✅(`C04-IA-F2`/`C05-IA-F5` 台架产出);§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留"待标准源"注),**L2-5 更正为"V0.1 不适用、随 V0.2"**(跨转换器合成被 N-18 禁止、零序走外部专用通道——原表下注"L2-1~L2-7 实现完成"对 L2-5 系误记);§7.1 P0 里程碑置 ✅(遗留 L1-5/L1-6/L1-7)。下一步 = **P1**(A 相过流/过负荷 + 定值模型)与 **L1-6 采样有效性校验** | -| 2026-10-11 | **L1-6 采样有效性校验落地** | 新增 `App/algo/adc_health.c/h`(**纯逻辑判定层**,零平台依赖免桩单测):① 静默——两侧帧序号连续 ≥100 ms 无前进(电平位,可自愈;`adc_health_flags()` 为 L5-4 闭锁预留);② 丢帧——TPAFE 序号一次前进 >1 计 `tgap`(与 STM32 侧 `adc1_gap` 对称,边沿事件不告警——消费者超拍是已知现象,告警会淹没真故障);③ 单调性——序号倒退锁存位;④ 错误增量——FRSTDATA / HAL 错误回调 / STM32 停摆计数增量(事件位与锁存位**编号对齐**,消费者按位图"每类每次开机留痕一次")。`adc_task.c` 只做告警编排:严重事件/上升沿 `DBG_ERROR` + `SYSLOG_SELFTEST`("adc sampling fault"),下降沿 `DBG_INFO`,心跳行增 `tgap`。主机单测 `test/host/adc_health/` **31 用例全绿**(100 ms 门限两侧、tick 回绕、边沿语义、NULL 守卫、init 幂等;`adc-health-unit-test` 钩子登记)。台架 `@test` PASS:零 `[ERR]`(健康不误报)、`tgap=1→2` 与 `adc1_gap` 同步可见、`frames` 1000.0 Hz;证据 `boot_log_data/20261011_prod_adc_health_boot_log.txt`。⚠ **抬栈**:`adc_health_report`→`syslog_post` 控制台回显尾使 adcTask 静态最坏链 832→**1080 B**(512 words 余 968 B < 1024 B 底线)→ **512→640 words**,复测余 **1480 B / 57%**、堆 min_ever 7152→6640 B(AGENTS 任务表 / RAM 方案 §2.3 已同步)。§3.1 L1-6 置 ✅ | +| 2026-10-11 | **L2 台架闭环:P0 收尾** | `@build` 0 错 0 警 → `@test` 闭环把 `phasor(first)`/`phasor(hb)` 列为 Required 并校验顺序 `App init OK>phasor(first)>phasor(hb)`(⚠ `net init: OK` **不在**该序内——adcTask 与 netTask 并行,`phasor(first)` ≈+500 ms 早于 `net init: OK` ≈+574 ms 属正常)→ **PASS**:5 实例相量开机即产出、心跳 5 实例持续刷新、`frames` 每 10 s +10000(1000.0 Hz)、`idle=0`、`frst_err=0`、`adc1_err=0`、`adc1_hw=0`、`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`/HardFault;证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`。栈复测:adcTask 静态 832 B / 实测峰值 **928/2048(余 54%)**,7 任务全 PASS(`docs/内存管理/RAM使用分析与优化方案.md` §2.3 已同步)。§3.1 L1-9 置 ✅(`C04-IA-F2`/`C05-IA-F5` 台架产出);§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留"待标准源"注),**L2-5 更正为"V0.1 不适用、随 V0.2"**(跨转换器合成被 N-18 禁止、零序走外部专用通道——原表下注"L2-1~L2-7 实现完成"对 L2-5 系误记);§7.1 P0 里程碑置 ✅(遗留 L1-5/L1-6/L1-7)。下一步 = **P1**(A 相过流/过负荷 + 定值模型)与 **L1-6 采样有效性校验** | +| 2026-10-11 | **L1-6 采样有效性校验落地** | 新增 `App/algo/adc_health.c/h`(**纯逻辑判定层**,零平台依赖免桩单测):① 静默——两侧帧序号连续 ≥100 ms 无前进(电平位,可自愈;`adc_health_flags()` 为 L5-4 闭锁预留);② 丢帧——TPAFE 序号一次前进 >1 计 `tgap`(与 STM32 侧 `adc1_gap` 对称,边沿事件不告警——消费者超拍是已知现象,告警会淹没真故障);③ 单调性——序号倒退锁存位;④ 错误增量——FRSTDATA / HAL 错误回调 / STM32 停摆计数增量(事件位与锁存位**编号对齐**,消费者按位图"每类每次开机留痕一次")。`adc_task.c` 只做告警编排:严重事件/上升沿 `DBG_ERROR` + `SYSLOG_SELFTEST`("adc sampling fault"),下降沿 `DBG_INFO`,心跳行增 `tgap`。主机单测 `test/host/adc_health/` **31 用例全绿**(100 ms 门限两侧、tick 回绕、边沿语义、NULL 守卫、init 幂等;`adc-health-unit-test` 钩子登记)。台架 `@test` PASS:零 `[ERR]`(健康不误报)、`tgap=1→2` 与 `adc1_gap` 同步可见、`frames` 1000.0 Hz;证据 `boot_log_data/20261011_prod_adc_health_boot_log.txt`。⚠ **抬栈**:`adc_health_report`→`syslog_post` 控制台回显尾使 adcTask 静态最坏链 832→**1080 B**(512 words 余 968 B < 1024 B 底线)→ **512→640 words**,复测余 **1480 B / 57%**、堆 min_ever 7152→6640 B(AGENTS 任务表 / `docs/内存管理/RAM使用分析与优化方案.md` §2.3 已同步)。§3.1 L1-6 置 ✅ | | 2026-10-11 | **P1/P2 软件侧:定值模型 + 保护元件框架** | 新增 `App/algo/prot_set.c/h`(**定值模型**:Q10 定点与参考 2^10 口径一致;31 项元数据表**通讯地址逐项对照参考 `Comm/Pub/Const_CommSetPara.c`**——过流 Ⅰ/Ⅱ/Ⅲ = 0x8856~0x8861、过负荷 = 0x8871~0x8874、零序过压 = 0x888E~0x8890、过压/欠压沿用告警段 0x8027~0x802A;范围校验 + 整表装载 all-or-nothing + **sum32 正/反码封存自检闭锁**(参考 `dwTagSum/dwTagSumN` 模式,L5-1);⚠ 默认值/范围为**拟定**,参考 EEPROM 出厂表不在本仓库,待对照修订)+ `App/algo/prot_elm.c/h`(**元件框架**:通用"越限 + 返回系数滞回 + 定时限"核,数据驱动实例表 7 行 = OC1/OC2/OC3/OL(IA)+ OV/UV(UA)+ 3U0(UN);`prot_elm_eval()` 纯判据可主机单测,`prot_elm_run()` 为 phasor→测量薄适配(相量未就绪整拍跳过);定值闭锁 → 全元件复位)。主机单测 `test/host/prot_set/`(30 用例)+ `test/host/prot_elm/`(27 用例:1.01× 启动 / 100 ms 到点 / 滞回保持与释放 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕)全绿,钩子 `prot-set-unit-test` / `prot-elm-unit-test` 登记。`@build` 0 错 0 警(**未接线 → 链接器整对象移除,零 Flash/RAM 足迹**);板端接线随 protectTask 立项(栈预算 + IWDG 门禁),标准源注入验收随其后。§3.3 L3-1/3/4/6 置 🔶、L3-2/5 注明框架就绪待通道/频率算法;§3.5 L5-1/2/4 置 🔶;§5.4 落实进度注 | diff --git a/docs/网络/BSD_Socket_API_函数调用关系.md b/docs/网络/BSD_Socket_API_函数调用关系.md index 938da2a5..9f92ec0a 100644 --- a/docs/网络/BSD_Socket_API_函数调用关系.md +++ b/docs/网络/BSD_Socket_API_函数调用关系.md @@ -4,7 +4,7 @@ > 使用指南回答"**上层该怎么调**"(公开语义 / 示例 / 常见问题);本文回答"**内部函数各自做什么、互相怎么调用、为什么这么设计**"(调用关系 / 设计意图 / 踩坑)。 > > **范围**:MCU 端 `Drivers/BSP/NET/net_socket.c`(约 2870 行,全部函数)。 -> 模拟器端后端 `simulator/sim/net_socket_sim.c`(Winsock)是另一套实现,见使用指南 §2.3;`net_select.c` 不在此展开,见使用指南 §7 / §10.5。 +> 模拟器端后端 `simulator/sim/net_socket_sim.c`(Winsock)是另一套实现,见 `docs/网络/BSD_Socket_API_使用指南.md` §2.3;`net_select.c` 不在此展开,见 `docs/网络/BSD_Socket_API_使用指南.md` §7 / §10.5。 > > **交叉引用约定**:机制标 Trap 号 → `docs/问题记录/CH395F_Trap_Records.md`;用法语义引用使用指南 §;关键参数值与 `net_config.h` / `net_socket.c` 内宏一致。 @@ -103,7 +103,7 @@ flowchart TB | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| -| `net_init(ip,mask,gw,mac)` | 芯片一次性配置:探测/复位 → 参数 → MAC → 协议栈初始化 → 8 Socket 缓冲布局 → 使能 PING | `net_pending_cancel_all` → `ch395f_check_exist` / `ch395f_reset` / `set_fun_para(0x09)` / `set_tcp_mss` / `set_retrans_count+period` / `set_keepalive_idle+intvl+cnt` → `set_mac_addr` → `parse_ip_string` + `set_ip_addr` / `set_gwip_addr` / `set_mask_addr`(或 `set_dhcp`)→ `ch395f_init` → `set_send_buf` / `set_recv_buf` ×8 → `ping_enable` | ① **重建前**先取消全部挂起 recv 并逐一通知(不通知 = 调用方永久挂死),且必须早于控制块清零与芯片复位;② 重传/KeepAlive/**MAC**/IP/GW/MASK 参数**必须**在 `ch395f_init` 之前下发(手册 §9.2.1:SET_MAC 为步骤①,INIT_CH395 加载 MAC——顺序反了本次上电不生效,P1-7);③ `fun_para=0x09`:多 Socket 模式 + TCP Server 单连接 + DISCONNECT/TIMEOUT 不清缓冲(软件排空后手动关);④ 缓冲布局每 Socket 6 块(收 4 + 发 2)×8 = 48 块恰好用尽,Socket 0~7 必须显式重分配(Trap 01/02);⑤ 捕获 `xTaskGetCurrentTaskHandle` 存入 `s_net_task_handle`(防自通知死锁的判据) | +| `net_init(ip,mask,gw,mac)` | 芯片一次性配置:探测/复位 → 参数 → MAC → 协议栈初始化 → 8 Socket 缓冲布局 → 使能 PING | `net_pending_cancel_all` → `ch395f_check_exist` / `ch395f_reset` / `set_fun_para(0x09)` / `set_tcp_mss` / `set_retrans_count+period` / `set_keepalive_idle+intvl+cnt` → `set_mac_addr` → `parse_ip_string` + `set_ip_addr` / `set_gwip_addr` / `set_mask_addr`(或 `set_dhcp`)→ `ch395f_init` → `set_send_buf` / `set_recv_buf` ×8 → `ping_enable` | ① **重建前**先取消全部挂起 recv 并逐一通知(不通知 = 调用方永久挂死),且必须早于控制块清零与芯片复位;② 重传/KeepAlive/**MAC**/IP/GW/MASK 参数**必须**在 `ch395f_init` 之前下发(`docs/芯片手册/CH395F.md` §9.2.1:SET_MAC 为步骤①,INIT_CH395 加载 MAC——顺序反了本次上电不生效,P1-7);③ `fun_para=0x09`:多 Socket 模式 + TCP Server 单连接 + DISCONNECT/TIMEOUT 不清缓冲(软件排空后手动关);④ 缓冲布局每 Socket 6 块(收 4 + 发 2)×8 = 48 块恰好用尽,Socket 0~7 必须显式重分配(Trap 01/02);⑤ 捕获 `xTaskGetCurrentTaskHandle` 存入 `s_net_task_handle`(防自通知死锁的判据) | | `net_get_generation()` | 读网络代次号(纯 RAM) | 无 | FTP/UI/对时服务据此感知"网络已重建"→ 放弃旧监听并重建 | | `net_generation_inc()` | 代次号 +1 | 无 | 唯一合法递增点 = `net_stack_bring_up()` 末尾(PHY/链路就绪后)——太早递增会让服务在网络不可用时抢跑重建;模拟器端恒为 0(无重建概念,上层自愈逻辑空转) | @@ -349,7 +349,7 @@ sequenceDiagram | `ch395f_check_exist` / `ch395f_reset` | `net_init` | 上电探测与复位 | | `ch395f_set_fun_para(0x09)` | `net_init` | 多 Socket + TCP 单连接 + 手动关缓冲 | | `ch395f_set_tcp_mss` / `set_retrans_count` / `set_retrans_period` / `set_keepalive_idle+intvl+cnt` | `net_init`(**均须在 `ch395f_init` 之前**) | 值见 `net_config.h` | -| `ch395f_set_mac_addr` | `net_init`(**须在 `ch395f_init` 之前**) | 写芯片 EEPROM;INIT_CH395 加载 MAC(手册 §9.2.1 步骤①,P1-7) | +| `ch395f_set_mac_addr` | `net_init`(**须在 `ch395f_init` 之前**) | 写芯片 EEPROM;INIT_CH395 加载 MAC(`docs/芯片手册/CH395F.md` §9.2.1 步骤①,P1-7) | | `ch395f_set_ip_addr` / `set_gwip_addr` / `set_mask_addr` / `set_dhcp` | `net_init`(**均须在 `ch395f_init` 之前**) | 固定 IP 或 DHCP | | `ch395f_init` / `ch395f_ping_enable` | `net_init` | 协议栈初始化(INIT_CH395)/ PING(须在 init 之后) | | `ch395f_set_send_buf` / `ch395f_set_recv_buf` | `net_init`(8 Socket 布局);`net_bind`(UDP)(Socket 4~7 兜底) | 每 Socket 收 4 块 + 发 2 块 | diff --git a/docs/问题记录/CH395F_Trap_Records.md b/docs/问题记录/CH395F_Trap_Records.md index c58ac459..74a55f2a 100644 --- a/docs/问题记录/CH395F_Trap_Records.md +++ b/docs/问题记录/CH395F_Trap_Records.md @@ -19,7 +19,7 @@ ### 注意事项 - 数据 Socket 不调 `OPEN_SOCKET`,由 CH395F 在连接到达时自动打开 - DISCONNECT 后 CH395F 自动关闭 Socket,但保留协议/端口/缓冲区配置,可被再次分配 -- 手册 §9.2.6 要求数据 Socket **先设源端口再设协议类型**(与监听 Socket 顺序相反) +- `docs/芯片手册/CH395F.md` §9.2.6 要求数据 Socket **先设源端口再设协议类型**(与监听 Socket 顺序相反) ### 对应文件 `Drivers/BSP/NET/net_socket.c` → `net_listen()` @@ -35,7 +35,7 @@ Phase 8 压力测试,2025-07 多连接模式下多个 Socket 数据互相覆盖,收到错误数据或客户端挂死。 ### 根因 -CH395F 默认只为 Socket 0~3 各分配独立收发缓冲(共 48 块 × 512B 用满,见手册 §5.45),Socket 4~7 为零块。`net_listen()` 未对 Socket 4~7 显式分配独立缓冲区块,后续代码误用相同块号(如 28~31)导致数据重叠。 +CH395F 默认只为 Socket 0~3 各分配独立收发缓冲(共 48 块 × 512B 用满,见`docs/芯片手册/CH395F.md` §5.45),Socket 4~7 为零块。`net_listen()` 未对 Socket 4~7 显式分配独立缓冲区块,后续代码误用相同块号(如 28~31)导致数据重叠。 ### 解决方案 每个数据 Socket 独占 4 个缓冲区块(2 发 + 2 收),按 `ds * 4` 基址分配,确保不重叠: @@ -496,14 +496,14 @@ NET 层 TCP Client(阶段 7)`net_connect()` 返回 0,但 `net_poll()` 报 ### 解决 1. `net_init`:`ch395f_set_fun_para(0x09)`(bit0=1, bit1=0, bit3=1)。 -2. `ch395f_init()` 之后,循环 0~7 用 `ch395f_set_send_buf(sock, i*6+4, 2)` / `ch395f_set_recv_buf(sock, i*6, 4)` 给每个 Socket 分配独立收发缓冲。按手册 §5.45(48 块 × 512B = 24KB)+ §9.2.8 最优分配:每 Socket 6 块 = 接收 4 块(2KB) + 发送 2 块(1KB),`ch395f_set_tcp_mss(1024)`,恰好用尽 0~47 块、互不重叠。 +2. `ch395f_init()` 之后,循环 0~7 用 `ch395f_set_send_buf(sock, i*6+4, 2)` / `ch395f_set_recv_buf(sock, i*6, 4)` 给每个 Socket 分配独立收发缓冲。按`docs/芯片手册/CH395F.md` §5.45(48 块 × 512B = 24KB)+ §9.2.8 最优分配:每 Socket 6 块 = 接收 4 块(2KB) + 发送 2 块(1KB),`ch395f_set_tcp_mss(1024)`,恰好用尽 0~47 块、互不重叠。 3. MSS 选取依据 §9.2.8:接收缓冲(2KB) ≥ 2×MSS(1024) 满足建议、≥ MSS 满足必须;发送缓冲(1KB) ≤ 8KB 满足必须。MSS 取 1024 为 8-Socket 共享 24KB 时的最大值。 4. `net_init` 缓冲分配顺序遵循 Trap 01:先配数据 Socket,再配监听 Socket 0(本 Net 层 Socket 按需 open,故统一在 init 一次性配好)。UDP 路径 `net_socket()` 中对 Socket 4~7 的兜底重设也同步为同一布局。 5. PC 端 `tcp_server` 改为每连接一线程的并发回显(`--max-conn` 为并发计数),否则 Phase 7 全量连接会触发累计计数上限而拒掉 4~7(Trap 19)。 ### 注意事项 - 4~7 的缓冲必须显式分配,否则其 CONNECT/RECV 中断不触发(Trap 01/02)。 -- 上述布局**以手册 §5.45 的 48×512B 缓冲几何为准**,并已由 Phase 7 TC704(40/40 PASS)实测确认块 0~47 全部有效(此前文档中"32×1KB"记录有误)。Socket 4~7 分配与 8 路并发回显均验证通过。 +- 上述布局**以`docs/芯片手册/CH395F.md` §5.45 的 48×512B 缓冲几何为准**,并已由 Phase 7 TC704(40/40 PASS)实测确认块 0~47 全部有效(此前文档中"32×1KB"记录有误)。Socket 4~7 分配与 8 路并发回显均验证通过。 - 多 Socket 模式下 TCP Server 单连接语义不变:Socket 0 监听且连接停留在本 Socket,Sockets 1~7 由应用当作独立 client 使用。 - `net_poll` 用 `GET_GLOB_INT_STATUS_ALL`(2 字节,支持 0~7)并遍历 0~7,Socket n 中断位即 `bit(n+4)`(Socket 0~3→bit4~7,Socket 4~7→bit8~11),原代码正确无需改动。 @@ -738,13 +738,13 @@ NET 层 TCP Client(阶段 7)`net_connect()` 返回 0,但 `net_poll()` 报 --- -## Trap 24:MAC 写入顺序违反手册 §9.2.1(SET_MAC 必须在 INIT_CH395 之前) +## Trap 24:MAC 写入顺序违反`docs/芯片手册/CH395F.md` §9.2.1(SET_MAC 必须在 INIT_CH395 之前) ### 现象(静态审查发现;手册确证) -`net_init()` 原顺序为 `ch395f_init()`(CMD_INIT_CH395)→ `ch395f_set_mac_addr()`,与手册 §9.2.1 规定的初始化顺序**相反**。后果:INIT_CH395 已按 EEPROM 旧值加载 MAC,其后写入 EEPROM 的新 MAC 本次上电**不生效**(回落芯片内置/上次值,实际"滞后一次上电")→ UI 配置的 MAC 与设备实际在线 MAC 不一致,设备在线身份错误、网口排查困难。本轮未做"UI 改 MAC + 重启"台架复现,结论由手册条文确证。 +`net_init()` 原顺序为 `ch395f_init()`(CMD_INIT_CH395)→ `ch395f_set_mac_addr()`,与`docs/芯片手册/CH395F.md` §9.2.1 规定的初始化顺序**相反**。后果:INIT_CH395 已按 EEPROM 旧值加载 MAC,其后写入 EEPROM 的新 MAC 本次上电**不生效**(回落芯片内置/上次值,实际"滞后一次上电")→ UI 配置的 MAC 与设备实际在线 MAC 不一致,设备在线身份错误、网口排查困难。本轮未做"UI 改 MAC + 重启"台架复现,结论由手册条文确证。 ### 根因 -手册 §9.2.1「初始化 CH395,必要操作」顺序为: +`docs/芯片手册/CH395F.md` §9.2.1「初始化 CH395,必要操作」顺序为: ① `SET_MAC_ADDR` → ② `SET_IP_ADDR` → ③ `SET_GWIP_ADDR` → ④ `SET_MASK_ADDR` → ⑤ `INIT_CH395`。 - §5.7:`SET_MAC_ADDR` 把 MAC **写入芯片内部 EEPROM**(约 30us); - §5.13:`INIT_CH395` **初始化 CH395 的 MAC、PHY 以及 TCP/IP 协议栈**(约 5ms)。 @@ -766,4 +766,92 @@ NET 层 TCP Client(阶段 7)`net_connect()` 返回 0,但 `net_poll()` 报 `Drivers/BSP/NET/net_socket.c`(`net_init` SET_MAC 顺序)、`Drivers/BSP/NET/net_socket.h`(`net_init` 注释)、`Drivers/BSP/CH395F/ch395f.c`(四个 SET 的限定条件注释);`docs/网络/BSD_Socket_API_函数调用关系.md`(§4.1 / §7 顺序)。 ### 发现时间 -2026-10-04(静态审查项 P1-7;依据手册 §9.2.1 / §5.7 / §5.13 定案)。 +2026-10-04(静态审查项 P1-7;依据`docs/芯片手册/CH395F.md` §9.2.1 / §5.7 / §5.13 定案)。 + +## Trap 25:FTP 数据连接三连故障——丢 CONNECT 中断 / accept 竞态自毁缓冲 / 背靠背小段毒化 TX(N-30,2026-10-11 定案并修复,回归 4 轮 11/11) + +### 现象(生产 FTP 回归,多轮复现) +同一 FTP 会话内**第 2 次及以后**的数据连接(LIST/STOR/RETR 各一次)出现两类失败,两种面孔同一根因: +- **吞数据(SWALLOW)**:新数据连接建立成功(芯片 0x05/0x04 ESTABLISHED)、服务端 `net_send` 返回成功,客户端 20 s 收不到任何字节(无 RST、无 DISCONNECT,直到客户端放弃); +- **425**:新数据连接被接受后 ~10-20 ms 内出现 DISCONNECT(`chip tcp=0x00`),服务端 accept 失败 → `425 Can't open data connection`。(⚠ 本条当时的解读"芯片自杀连接"**有误**,见文末"三次定案"——实为客户端发完数据后的**正常 FIN** + 我方 accept 竞态自毁缓冲。) + +失败概率随**上一条数据连接的负载大小与间隔**变化(NAND 目录文件越多 LIST 越大越易触发),与提交历史无关——在 D20 基线 e5733b34 上以快速连发同样复现(D20 时代"11/11 通过"是慢速台架 + 干净目录的运气)。 + +### 定案实验(裸机芯片台架,剥离 OS/net/FTP) +实验分支 `n30-chip-bench`(worktree),`test/board/ch395f_reuse_bench.c` 在**内核启动前**直接调 `ch395f_*` 做受控矩阵:`{socket, close 后复开延迟, TX 长度, 关闭顺序}` 组合循环,每 50 ms 打印芯片两字节状态 + 中断寄存器;主机侧 `test/host/ch395f_reuse_host.py` 同步做纯 TCP 客户端。**OS/net/FTP 全部剥离后 bug 依旧复现** → 纯芯片操作时序问题,非上层逻辑、非芯片硬件缺陷。 + +关键现场(芯片日志): +``` +post-listen st=0x05/0x04 ← listen 返回成功,但 TCP 态停在旧连接的 ESTABLISHED(幽灵态) +unexpected int=0x10 while wait ← 只等来旧连接滞后的 DISCONNECT +no CONNECT within 10s ← 新连接的 CONNECT 中断永远没来(芯片状态却已是 0x04) +``` + +**芯片级规律**:CH395F 关闭连接后的拆链收尾窗口**变长且不可观测**——状态寄存器显示 CLOSED 持续 1 s 以上也不代表拆完(实测窗口可 >2 s 且时长不定)。窗口内重新 `open+listen`: +1. 新连接握手照常完成(状态寄存器 → ESTABLISHED)但 **CONNECT 中断被丢**(Trap 22 丢中断机制的窗口期形态)→ 事件驱动的 accept 永远等待 → 吞数据; +2. 或 listen 后状态直接假报 0x04(幽灵态),此时回读对端端口 = **旧连接端口**; +3. 或旧连接滞后的 DISCONNECT 打到新监听 → 触发 relisten 毁掉刚到的新连接 → 425。 + +### 已证伪的修复(全部经台架受控实验) +| 尝试 | 结果 | +|---|---| +| close 后固定沉降 100/300/800/2000/3000 ms | 仍挂(2 s 也挂过)——窗口变长,固定等待无保证 | +| Socket 轮换(sock3↔sock4 交替) | 仍挂——上一轮拆链尾巴跨 socket 毒化下一轮 | +| listen 后 close+重试 / 耐心等幽灵消退 | 持平对照组,无效 | +| 收发缓冲重分配(clear_recv_buf + set_*_buf) | 无效 | +| close 前 200 ms TX 排空延时 | 无效 | +| 禁用对账 tick(验证因果) | 仍挂——对账不是元凶 | + +### 有效修复(台架修复组 15/15,机制触发 5 次全部救回) +**双信源原则:中断只是快路径,状态寄存器才是真相。** 等连接时不能只等中断: +1. **accept 路径内联探针**(`net_lost_connect_probe`,`net_accept_locked` 每 100 ms 轮询挂探针):芯片状态已 ESTABLISHED 而 CONNECT 中断未到 → 连续 2 轮去抖后补做 accept(复用 `handle_connect_event` 簿记)。 +2. **幽灵态甄别**:补 accept 前回读对端端口(`GET_REMOTE_IPP`),**端口 = 上一条连接端口(`last_peer_port`)→ 幽灵,不 accept**——否则会把数据发给已死的旧连接(这正是生产 LIST#4"accept 成功但数据被吞"的直接成因:对账②曾把幽灵连接补 accept 了)。 +3. **对账②同步加幽灵甄别**(`net_reconcile_tick` 类型②分支同样比对 `last_peer_port`)。 +4. `last_peer_port` 登记点:`handle_disconnect_event` / `handle_timeout_event` / `net_relisten_try`(清零 remote_port 前转登)/ `net_close_locked`(跨 memset 保留——幽灵属芯片 Socket 级,与 fd 复用无关)/ NET_MSG_FIX 陈旧 ESTABLISHED 修复路径。 + +### 与 Trap 22 的关系 +Trap 22 的周期对账(200ms 节拍 + 400ms 去抖 + 1s 冷却)设计目标是"自愈兜底",对常驻监听器有效;但 FTP 数据通道是"活 1 秒就死"的短命监听器,且对账②**无幽灵甄别**——它既救不全(冷却期内 accept 已超时)又反过来帮凶(把幽灵补 accept)。Trap 25 把"看状态补 accept"内联进 accept 热路径(短去抖、无冷却)并补上端口甄别,对账②保留为兜底网。 + +### 对应文件 +`Drivers/BSP/NET/net_socket.c`(`net_lost_connect_probe` / `net_accept_locked`(含 closed-with-data accept)/ `net_reconcile_tick`② / `last_peer_port` 各登记点)、`Drivers/BSP/NET/net_types.h`(`last_peer_port` / `lost_conn_seen` 字段)、`Drivers/BSP/NET/lftpd/lftpd.c`(`send_list`/`send_nlst` 批量发送);台架工具 `test/board/ch395f_reuse_bench.{c,h}` + `test/host/ch395f_reuse_host.py`(实验分支 `n30-chip-bench` 注册编译,主分支只存档不注册)。 + +### 验证(台架层) +`ch395f_reuse_bench` 修复组(丢中断捕获自 accept)15/15,机制触发 5 次全部救回(对照同表 4/15 挂)。⚠ 生产回归当时未达 11/11——生产主导模式与台架不同,见下两节。 + +### 三次定案:所谓"kill 模式"是寄存器状态误读(2026-10-11 当日终案,双向验证) +第二轮验证(仅双信源探针修复,4 轮:1/11、5/11、2/11、2/11)暴露 STOR #2 仍 100% 失败,当时解读为"芯片在 accept 后 10-20 ms 杀连接"。后经**手册条文 + 网络资料双向核对**推翻: + +**寄存器读数本身全部正确**(`GET_SOCKET_STATUS_SN` §5.22:`05H=SOCKET_OPEN/00H=SOCKET_CLOSED`;第二字节 TCP 态 `00H=CLOSED/04H=ESTABLISHED`,与 WCH 官方数据手册表 5-8/5-9 一致;`GET_RECV_LEN_SN` §5.33 低字节在前,驱动实现一致)。**错在对 DISCONNECT 事件语义的解读**: + +1. `net_init` 的 `ch395f_set_fun_para(0x09)` 置 **FUN_PARA_FLAG_SOCKET_CLOSE=1**(§5.52 ②):产生 DISCONNECT/TIMEOUT 后芯片**不关 Socket、不清缓冲**,就是留给单片机把残留数据读出后再关 Socket。 +2. 判别探针现场:`kill-check recv_len=1027 re-read st=0x05/0x00`——Socket 仍 OPEN、TCP 已 CLOSED、**RX 缓冲 1027 字节 = STOR 载荷一个不少全在**。这是芯片按 §5.52 规格**保留现场**,不是杀连接。 +3. 那个 DISCONNECT 是**客户端的正常 FIN**:Python ftplib `storbinary` 语义 = `sendall` 发完立即关数据连接,然后回控制通道等 226——标准 FTP 行为。局域网小文件下"连接→发完→关闭"在 ~5 ms 内完成。 + +**425 真根因(竞态 + 自毁缓冲)**:客户端 5 ms 内完成发送并关闭;`cmd_stor` 的 accept RPC 赶到 net 层时 MCU 态已是 CLOSED;旧 `net_accept_locked` 把 CLOSED 一律当"无连接" → `net_relisten_try`(close+open **清空芯片 RX 缓冲**)→ 1027 字节被**我方亲手销毁** → WOULDBLOCK → lftpd 判"数据套接字已关" → 425。芯片全程无辜(此模式)。 + +**有效修复(已落地,`net_accept_locked` CLOSED 分支)**:CLOSED + auto_relisten + **芯片 RX 缓冲有数据**(`GET_RECV_LEN_SN`>0)→ 接受这条"已关但可读"连接(日志 `closed-with-data accept (drain pending)`),应用经 `net_recv_locked` 快路径(无状态门槛)排空,排空后 EOF 自然触发 relisten——完全对齐 §5.52 ②"读出残留数据后再关闭"的芯片契约。**验证:修复后 4 轮回归 STOR #2 从 4/4 失败 → 4/4 通过**(7/11、6/11、9/11、5/11,失败项中已无 STOR)。 + +**连带修正**:台架 mode0(主机先关)观察到的"accept 后 10-20 ms DISCONNECT"大概率同为误读——那是主机收完数据自己关的。"拆链窗口杀连接"理论对生产 425 不成立;台架**丢 CONNECT 中断模式**(主机侧 got=0 + 芯片 0x04 + 无中断,主机端独立证据)仍然成立,双信源探针修复保留。 + +**当时的遗留**:LIST #4/#7 超时(服务端 `net_send` 目录、客户端 20 s 收 0 字节、无 DISCONNECT)——发送方向的真问题,与上述 425 机制不同——已由下节"四次定案"解决。 + +### 四次定案:LIST 吞包 = 背靠背小段写毒化芯片 TX(2026-10-11 终案,修复后 8/8 通过) +探针定位过程(全部生产固件 + 临时探针,一轮复现): +1. PASV→accept 间隔 2 s(`net_listen_locked` open 重试在跑)→ 曾疑"listen 撞拆链窗口"; +2. accept 正常、CONNECT 正常、发送后中断位 `0x03`(SENBUF_FREE+SEND_OK)——**芯片自认发送成功**; +3. `GET_REMOTE_IPP` 对端 IP:port 完全正确(排除对端混淆); +4. **Socket 轮换**(服务端先关后 4 s 冷却、数据连接轮转 sock3~7):**证伪**——LIST 换到全新 sock5 仍 100% 挂(芯片行为与失败前完全一致); +5. **决定性对照**:同一目录同一载荷,LIST #1(会话首连,FAT 冷、readdir 慢、行间天然间隔大)通过;LIST #4/#7(FAT 热、每行一个 `net_send` 背靠背小段)必挂。 + +**定案**:`send_list`/`send_nlst` 每行一次 `net_send`(~60 B 小段、间隔仅 ms 级)——CH395F 对同一连接的**背靠背多段小写入**会把 TX 整条连接毒化(芯片报 SEND_OK 但对端协议栈全部丢弃,无 RST 无 DISCONNECT)。`net_send_locked` 的逐块协议(等 SENBUF_FREE 再写下一块)本身合规(§5.45),但芯片对该模式的内部处理不可靠;RETR 的 1 KB 满块连发不受影响(实测通过)。 + +**修复(`lftpd.c`)**:`send_list`/`send_nlst` 整份目录**一次性批量发送**(static 1536 B 缓冲,snprintf 累积 + 单次 `lftpd_inet_write`;溢出截断判 -1→550)。注意 `lftpd_inet_write` 成功返回**字节数**非 0(首版误按 0=成功判 → 全 550,已纠)。 + +**验证**:修复后 ftp_regression 连跑 **4 轮 11/11**(终版:批量发送 + closed-with-data accept + 丢中断探针 + 幽灵甄别,无轮换无探针)+ `ftp_auth_bench.py` **13/13** + `@test` 启动标记 PASS;此前同条件 LIST #4/#7 为 100% 失败、STOR #2 曾 100% 425。轮换冷却机制随证伪**已撤**(不留未证明的复杂度;其 5 轮 11/11 一并存档备查)。 + +**教训(Trap 25 总纲)**:本轮先后误读三次——把客户端正常 FIN 读成"芯片杀连接"、把 open 重试读成"拆链窗口"、把芯片假 SEND_OK 读成"数据已上线"。铁律:**CH395F 的"成功"(中断位/状态码)只代表芯片本地动作完成,不代表对端收到;判定必须回到对端可见证据(主机侧收发)+ 手册语义(FUN_PARA §5.52、状态码 §5.22)**。 + +**顺带发现的疑点(存疑,暂不动)**:`set_fun_para(0x09)` 的 bit0——§5.52 表明确写 **bit0 保留、必须为 0**,而 `net_init` 注释声称"bit0=1 多 Socket 模式"(手册与网络资料均无此说法,CH395 的 8 Socket 是芯片原生能力,单/多 Socket 之分在手册中只关联 SCS0 引脚硬件形态)。现固件 bit0=1 下 sock1/2/3 多路并行正常 → 该位大概率被芯片忽略。**本轮不改**(若真为多 Socket 使能,改 0x08 会毁掉全部 1~7 号 Socket);后续单独台架验证"0x08 下多 Socket 是否仍正常"再定。 + +### 发现时间 +2026-10-11(登记簿 N-30;裸机芯片台架定案实验见本节,实验分支 `n30-chip-bench`)。 diff --git a/docs/问题记录/错误码传播审计.md b/docs/问题记录/错误码传播审计.md index e73a769d..ec32126a 100644 --- a/docs/问题记录/错误码传播审计.md +++ b/docs/问题记录/错误码传播审计.md @@ -60,7 +60,7 @@ > > **2026-10-03 变更**:**E-34 / E-04 已修复并通过真机验证**(CH395F 字节命令有限超时 + `remo_send` 返回值校验;见 §5 第一档)。 > **2026-10-03 变更②**:**GD5F 错误通道统一(E-10 / E-42 / E-53 / E-54 / E-56 / E-57)已修复并通过台架套件回归**(`=== GD5F2GQ5UE Tests: 16/16 PASSED (failed=0) ===`,只读模式;见 §5 第二档 #1)。 -> 真机全闭环 `@test` PASS 7/7;执行判据见 `测试说明/实机测试实施指南.md` §3 / §10。 +> 真机全闭环 `@test` PASS 7/7;执行判据见 `docs/测试说明/实机测试实施指南.md` §3 / §10。 --- @@ -455,7 +455,7 @@ E-57(`int` / `int32_t` 混用)、**E-10 / E-42 / E-56(GD5F init / BBT / ra | **E-23** | `test/` | 测试套件缺代次检查(accept 失败分支);TC704 并发度偏高 | **仍然存在** | 加代次断言;TC704 降并发或在测试构建抑制服务任务 | | **E-30** | `simulator/sim/net_socket_sim.c` | sim 后端 errno 语义与 MCU 不对齐(成功即清 errno,MCU 不清) | **仍然存在**(语义差异在 §6 表中仍成立) | 对齐语义;并在缺陷记录中标明"sim 复现盲区" | | **E-57** | `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h:119/140/189/222-253` | 公开 API 返回类型 `int` / `int32_t` 混用(码值均为小负数,无功能影响) | ✅ **已修复(2026-10-03)**:公开 API 全部统一为 `int32_t` | 见 §5 第二档 #1 | -| **E-60** | `Drivers/BSP/SD2506/sd2506.c/h` | `SD2506_CHARGE_5K` 宏位与手册 §4.8 不符的风险 | **已订正**(2026-09-21,`41e111b`):宏位已改为 `bit1`,18H 回读校验在位 | 无需改动,仅作为"手册总表 vs 专用表不一致"的案例保留 | +| **E-60** | `Drivers/BSP/SD2506/sd2506.c/h` | `SD2506_CHARGE_5K` 宏位与 `docs/芯片手册/SD2506API-G.md` §4.8 不符的风险 | **已订正**(2026-09-21,`41e111b`):宏位已改为 `bit1`,18H 回读校验在位 | 无需改动,仅作为"手册总表 vs 专用表不一致"的案例保留 | | **E-61** | `Drivers/BSP/SD2506/sd2506.c:482` | `sd2506_clear_int` 注释称"ARST=1 时自动清除 INTAF",实际 ARST 在 **CTR3**(11H)而代码从未写过 CTR3 | **仍然存在(纯注释错误)** | 修注释:明确"ARST 未启用,INTAF 靠读 CTR1 清除" | | **E-62** | `Lib/FatFs/ffconf.h` | `FF_USE_LFN=0` → 严格 8.3 短名;自愈逻辑若把 `FR_INVALID_NAME` 归入损坏触发集 → 不可逆重格式化(`.fschk` 无限重格式化事故根因) | **铁律已固化**(`AGENTS.md`);`ff.c` `create_short` 全部拒绝点已加 DEBUG 打印 | 无需改动,回归监督项(与 §3.2-E-31 后续同族) | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index c418b886..52c8334c 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -55,6 +55,9 @@ ## 0.4 未闭环清单(2026-10-11 复核) > 只列**未办 / 半办**项;已修项已按「修复即销项」删除(去处见变更记录)。图例:❌ 未办 / 🟡 半办。 +> **统一排序与推进**:本表各项已于 2026-10-11 并入 [`../待处理内容.md`](../待处理内容.md) §2/§3.2 +> (新编号 N-19~N-29 + U 簇,出处列保留本报告编号);**顺序权威在登记簿 §2,本表为评审快照**。 +> ⚠ 本报告编号与登记簿同名不同义(报告 N-16 = 日志逐条开关,登记簿 N-16 = A2/A4 负向台架)→ 引用带文档名。 | 类别 | 编号 | 未闭环内容 | 下一步 | |------|------|-----------|--------| @@ -710,7 +713,7 @@ UI 剩余页面批量落地(U5)、目录级层次迁移(S-1 的物理搬 | 已销项条目 | 去处 | |-----------|------| -| N-01(配置引擎 C 堆必失败) | `../子系统设计/配置文件设计说明.md`(v1.13,§6.2/§8) | +| N-01(配置引擎 C 堆必失败) | `../子系统设计/配置文件设计说明.md` §6.2 / §8(v1.13) | | N-03a/N-03b/N-07/N-08(IWDG、`configASSERT`、两个钩子、4 个 Fault 现场) | `../子系统设计/看门狗设计说明.md`(§4.4、§10.1/§10.2)+ AGENTS.md「平台约束」 | | N-10(UI accept 门控) | `../UI设计/RemoDispBus协议与接收设计说明.md` §3 + 看门狗设计说明 §11.4 | | P1-1(ADC 数据路径) | `../子系统设计/ADC采样节拍设计说明.md` §10/§11 + `App/algo/README.md` | diff --git a/scripts/README.md b/scripts/README.md index 6b7ee36f..37eb6f0e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,7 +11,8 @@ |---|---|---| | `check_coding_standard.py` | `C coding standard` | C 代码规范机械检查(命名前缀/常量右置/头文件保护宏等) | | `check_mojibake.py` | `Forbid U+FFFD mojibake` | 扫描 `U+FFFD` 乱码(`EF BF BD` 字节) | -| `check_doc_links.py` | `doc-links` | **文档链接存在性** + 命名规范(D-3/D-7) | +| `check_doc_links.py` | `doc-links` | **文档链接存在性** + 命名规范(D-3/D-7)+ **`§N.M` 章节引用**(口径见 `docs/文档规范.md` §3.5)。钩子已带 `--sections-strict`(自引用失效也拦);`--section-todo` 列出机器判不了目标的 `§`(带改写建议)。每次跑打印「可判定比例」行,防覆盖率回落 | +| `section_aliases.json` | (被 `check_doc_links.py` 读) | 文档**昵称别名表**:「内存方案」→ `docs/内存管理/RAM使用分析与优化方案.md`。只登记无歧义昵称;登记一次即把该昵称的引用纳入硬门禁 | | `gen_task_table.py` | `task-table` | FreeRTOS 任务表以 `Src/freertos.c` 为**唯一事实源**生成/校验(D-2/D-7) | | `check_mem_numbers.py` | `mem-numbers` | 内存/Flash 数字以 `.map` 为唯一事实源校验(D-7 体积档) | | `check_test_suite.py` | `test-suite-switch` | 测试套件开关=产品构建(防测试固件静默提交) | @@ -23,6 +24,14 @@ | `ram_report.py` | 从 `.map` 生成**静态 RAM/Flash 占用**报告(→ `docs/内存管理/data/`) | | `stack_report.py` | 从 Keil 静态调用图 + 串口 `[STACK]` 水位生成**每任务最坏链**报告 | +### 引用同步(改名 / 挪目录) + +| 脚本 | 作用 | +|---|---| +| `rename_ref.py` | **改名/挪目录 + 全库引用自动改写**:默认 dry-run 预览,`--apply` 才落盘(`git mv` → 改写引用 → 自动跑 `check_doc_links --all` 复核)。覆盖反引号引用、Markdown 链接、图片 `../` 层级换算、`#include`、`uvprojx`/`CMakeLists.txt` 源列表。固定动作与边界 → `docs/文档规范.md` §3.6 | + +> `check_doc_links.py` 除链接存在性外,还校验 **`§N.M` 章节引用**是否指向目标文档真实存在的编号标题(口径见 `docs/文档规范.md` §3.5)。 + ### CI 与本地等价(2026-10-01 T-6) | 脚本 / 文件 | 作用 | @@ -43,6 +52,8 @@ # 单个脚本 & '.venv\Scripts\python.exe' scripts/check_doc_links.py --all +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md # 预览 +& '.venv\Scripts\python.exe' scripts/rename_ref.py docs/旧/名.md docs/新/名.md --apply # 落盘 & '.venv\Scripts\python.exe' scripts/gen_task_table.py --check # 或 --write / --diff & '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --check & '.venv\Scripts\python.exe' scripts/check_test_suite.py # 门禁判定 diff --git a/scripts/check_cubemx_survival.py b/scripts/check_cubemx_survival.py index e613f1b3..ff39e25c 100644 --- a/scripts/check_cubemx_survival.py +++ b/scripts/check_cubemx_survival.py @@ -142,6 +142,22 @@ CHECKS = [ hint="崩溃现场报告实现(打印 PC/LR/xPSR/CFSR/HFSR/BFAR/MMFAR)" "须在 USER CODE BEGIN 0 区内(生成区内的标准保护位)。", ), + # N-20(2026-10-12):Error_Handler 统一降级。函数体本身在 CubeMX 的 + # `USER CODE BEGIN/END Error_Handler_Debug` 区内(L2 受保护),重生成不丢; + # 本项是 L3 兜底:被打回模板默认(__disable_irq+while(1) 静默挂死)**不报编译错**, + # 只在真出外设初始化故障时暴露——整机只剩 IWDG 兜底且无自检日志。 + dict( + id="error-handler-degrade", + kind="protected", + path="Src/main.c", + region="Error_Handler_Debug", + must_contain=["DBG_ERROR", "NVIC_SystemReset"], + must_not_contain=["__disable_irq", "__builtin_return_address"], + hint="Error_Handler 必须打 1 条自检日志后 NVIC_SystemReset(),不得回到 " + "__disable_irq()+while(1) 静默挂死(无日志、只剩 IWDG 兜底)。" + "⚠ 勿用 __builtin_return_address:AC5 编成库调用,MicroLIB 无符号 = L6218E(2026-10-12 实证)。" + "恢复位置 = `USER CODE BEGIN Error_Handler_Debug` 区。", + ), # ---------------- P1:FreeRTOS 加固 ---------------- # ⚠ 2026-10-09 升级:这两项原先靠 FreeRTOSConfig.h 里**自造**的 # `USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING` 区——模板不认这个区名, diff --git a/scripts/check_doc_links.py b/scripts/check_doc_links.py index b2da9d34..5f924e0b 100644 --- a/scripts/check_doc_links.py +++ b/scripts/check_doc_links.py @@ -9,6 +9,9 @@ B. 反引号路径引用 `docs/xxx/yyy.md` —— 相对仓库根解析(本工程约定:文档间引用 一律用「仓库根相对路径」) C. 文件名规范 目录名 / 文件名不得含空格与路径敏感字符(见 docs/文档规范.md §1) + D. 章节引用 `docs/xxx.md` §N.M —— 目标文档里必须真有该编号的标题。 + 刻意只校验**紧邻写法**(文档名与 § 之间只允许空白/反引号/括号, + 实测可把误报压到 0);昵称式引用如「内存方案 §1.4」不校验。 豁免(不算失效,不报错): * 以 `[计划]` 前缀标记的路径(尚未创建的约定产物,见《项目审查与优化建议报告》D-3 建议) @@ -30,6 +33,7 @@ import re import sys import pathlib import urllib.parse +from collections import Counter REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -61,6 +65,50 @@ STATUS_PROXIMITY = 40 PLACEHOLDER_TOKENS = ("NN_", "XX_", "界面名", "模块名", "日期", "示例", "{{", "}}", "${", "%s", "%d", "<", ">") +# --- D 类:章节引用 §N.M ------------------------------------------------- +# 引用写法:§ + 数字编号(§10 / §10.4 / §2.1.1) +SEC_RE = re.compile(r"§\s*(\d+(?:\.\d+)*)") +# 文档名 token:带目录的路径,或裸文件名(中文/下划线/短横均可)。 +# 非 .md 后缀也要认——`.pre-commit-config.yaml §7` 这类要识别成「非文档锚点」并跳过, +# 否则会误当自引用去校验本文档编号。 +PATH_EXT = r"(?:md|ya?ml|py|c|h|txt|json|ini|ioc|s|bat)" +DOC_NAME_RE = re.compile(r"((?:[\w$.\-一-鿿]+/)+[\w$.\-一-鿿]+\." + PATH_EXT + + r"|[\w$.\-一-鿿]+\." + PATH_EXT + r")") +MD_SUFFIX_RE = re.compile(r"\.md$") +# 裸文档名(不带 .md):紧邻 § 的词,用于「看门狗设计说明 §10」「V0.0 §4.7」这类写法 +# (允许点号,否则 `V0.0` 会被截成 `0`) +STEM_RE = re.compile(r"([A-Za-z0-9_一-鿿][\w.一-鿿]{1,30})[`\s))】\]]{0,4}$") +# 自引用线索:§ 前面是这些词/符号 → 指向本文档 +SELF_CUE_RE = re.compile(r"(见|参见|详见|如|按|第|本文|本节|下文|上文|下面|上方|$" + r"|[((、。::||\[\]])[`\s))】\]]{0,4}$") +# 文档昵称的常见结尾:命中说明 § 前面是「别的文档的名字」,不能当自引用 +DOC_NOUN_TAIL = ("方案", "报告", "规划", "说明", "指南", "流程", "清单", "架构", + "总览", "手册", "规范", "记录", "审计", "分析", "对照", "要求", + "部署", "使用", "设计", "文档", "台账", "总结", "综述") +# 标题与其编号:`## 3. 链接规范` / `### 2.1.1 xxx` +HEAD_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$") +HEAD_NUM_RE = re.compile(r"^(\d+(?:\.\d+)*)\s*[.、))]?\s") +# 文档名与 § 之间允许的最大间隔字符数,且间隔只能是空白/反引号/括号类 +SEC_MAX_GAP = 8 +SEC_GAP_CHARS = "` \t)(()【】[]「」《》、::" +SEC_GAP_RE = re.compile("[" + re.escape(SEC_GAP_CHARS) + "]{0,8}$") +# § 链:同一句内连续引用(`a.md` §3.2 L2 设计点 / §10.2 术语表)——后续 § 继承前一个的目标。 +# 中间允许描述性文字,但不允许句末标点,也不允许再出现文档名(那会换目标) +SEC_CHAIN_MAX = 40 +SEC_TERMINATORS = "。;!?" +# 变更记录类小节:里面的 § 是「当时那个编号」的历史指称,不参与校验 +CHANGELOG_WORDS = ("变更记录", "更新记录", "修订记录", "历史", "changelog") +# 外部资料编号(芯片手册等):不受本仓库标题编号约束 +EXTERNAL_HINTS = ("RM0090", "参考手册", "数据手册", "手册", "datasheet", + "FreeRTOS", "CMSIS", "FatFs", "dhara", "LVGL", "Graphviz", "RFC") +# D 类计数(覆盖率可见化:每次跑都打印,防止比例悄悄回落) +SEC_STATS = Counter() +# 机器判不了目标的 § 引用清单(--section-todo 打印,供逐步改成紧邻写法) +SEC_TODO = [] +# 昵称别名表:昵称 → 仓库根相对路径 +ALIAS_FILE = REPO_ROOT / "scripts" / "section_aliases.json" +_alias_map = None + def is_placeholder(text: str) -> bool: if "*" in text or "<" in text or ">" in text or "{" in text or "}" in text: @@ -149,6 +197,286 @@ def check_backtick_paths(path: pathlib.Path, lines, errors): errors.append((path, lineno, target, "路径引用")) +# --- D 类:章节引用 §N.M ------------------------------------------------- +_doc_index = None # 文件名 -> [Path] +_head_cache = {} # 文档绝对路径 -> 该文档的数字编号标题集合 + + +def build_doc_index(): + """全库 .md 索引(按文件名),用于解析裸文件名引用。""" + global _doc_index + if _doc_index is not None: + return _doc_index + idx = {} + for p in REPO_ROOT.rglob("*.md"): + if is_skipped(p): + continue + idx.setdefault(p.name, []).append(p) + _doc_index = idx + return idx + + +def numbered_headings(path: pathlib.Path): + """目标文档里出现的数字编号标题(`## 3. xxx` / `### 2.1.1 xxx`)。""" + key = str(path) + if key in _head_cache: + return _head_cache[key] + out = set() + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + _head_cache[key] = out + return out + for line in text.splitlines(): + m = HEAD_RE.match(line) + if not m: + continue + m2 = HEAD_NUM_RE.match(m.group(1).strip()) + if m2: + out.add(m2.group(1)) + _head_cache[key] = out + return out + + +def stem_candidates(token: str, by_stem): + """解析裸文档名:先全名相等,再按前缀(`V0.0` → `V0.0_网表详细分析报告.md`)。 + + 返回候选列表;调用方只在**唯一**时采信,所以「报告」「方案」这类会命中多篇而自动跳过。 + 前缀匹配要求 ≥3 字符,否则「文档」「手册」这类剥剩的通用词会乱认邻居(「主文档」→「文档」 + → 前缀命中「文档规范」)。 + """ + if token in by_stem: + return by_stem[token] + if len(token) < 3: + return [] + out = [] + for stem, paths in by_stem.items(): + if stem.startswith(token): + for p in paths: + if p not in out: + out.append(p) + return out + + +def load_aliases(): + """昵称别名表(`scripts/section_aliases.json`):「内存方案」→ 具体文档。 + + 存在的意义:昵称式引用(「内存方案 §1.4」)读起来顺、机器判不了;登记一次别名, + 正文不用改,引用就纳入校验。表里没有的昵称不猜(宁缺毋滥)。 + """ + global _alias_map + if _alias_map is not None: + return _alias_map + _alias_map = {} + if ALIAS_FILE.exists(): + try: + import json + data = json.loads(ALIAS_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict): + _alias_map = {k: v for k, v in data.items() if isinstance(v, str)} + except (ValueError, OSError, UnicodeDecodeError): + pass + return _alias_map + + +def resolve_name(name: str, by_stem): + """裸文档名 → 目标:先文件名(含前缀唯一),再别名表。判不了返回 (None, None)。""" + cand = stem_candidates(name, by_stem) + if len(cand) == 1: + return cand[0], "stem" + alias = load_aliases().get(name) + apath = (REPO_ROOT / alias) if alias else None + if apath is not None and apath.exists(): + return apath, "alias" + return None, None + + +def suggest_doc(token: str, by_stem): + """给「昵称式引用」猜一个目标文档,用于 --section-todo 的改写建议。 + + 只在**唯一命中**时给建议:昵称整体出现在文件名里,或「昵称前段出现在目录名 + + 昵称后两字出现在文件名」(「内存方案」→ docs/内存管理/RAM使用分析与优化方案.md)。 + """ + if not token or len(token) < 2: + return None + hits = [] + for stem, paths in by_stem.items(): + for p in paths: + rel = _rel(p) + if token in stem or stem in token: + hits.append(p) + elif len(token) > 3 and token[-2:] in stem and token[:-2] in rel: + hits.append(p) + uniq = [] + for p in hits: + if p not in uniq: + uniq.append(p) + return uniq[0] if len(uniq) == 1 else None + + +def sec_plausible(heads, ref: str) -> bool: + """编号存在,或是现有编号的祖先(§8 → 8.2 在位)/后代。""" + return ref in heads or any(h.startswith(ref + ".") for h in heads) + + +def sec_parent_exists(heads, ref: str) -> bool: + """ref 的父级编号在文档里存在——不存在说明这个数量级不属于本文档, + 该 § 其实是「省略了文档名的跨文档引用」,机器判不了(跳过而非误报)。""" + parts = ref.split(".") + if len(parts) == 1: + return any(h.split(".")[0] == parts[0] for h in heads) + parent = ".".join(parts[:-1]) + return parent in heads or any(h.startswith(parent + ".") for h in heads) + + +# 昵称前常挂的动词/介词:「见内存方案 §1.4」「顺序按报告 §6.5」——先剥掉再查别名/文件名 +CUE_STRIP_PREFIXES = ("参见", "详见", "并同步更新", "教训见", "用法与基线见", "反向依赖在", + "改动前先看", "多口需求见", "写入顺序违反", "宏位与", "顺序按", + "依据", "参照", "对照", "违反", "参考", "按", "见", "如", "该", "以", + "与", "和", "及", "或", "并", "在", "的", "第", "看", "主") +# 明确指「本文档」的昵称 → 按自引用处理 +SELF_TOKENS = ("本文档", "本文", "该文", "该文档", "本节", "上文", "下文", "下面", "上方") + + +def strip_cue(token: str) -> str: + prev = None + while prev != token: + prev = token + for c in CUE_STRIP_PREFIXES: + if token.startswith(c) and len(token) > len(c) >= 1: + token = token[len(c):] + break + return token + + +def chain_blocked(between: str, by_stem) -> bool: + """§ 链能否跨过这段文字:出现句末标点、或出现**与某文档文件名完全相同**的裸名就不许跨 + (`… §3 + 看门狗设计说明 §11.4` 里的 §11.4 属于后者,不能继承前一个目标)。 + 只认完全相等:`内容页 / §5.6` 里的「内容页」是描述词,不该断链。""" + if len(between) > SEC_CHAIN_MAX: + return True + if any(t in between for t in SEC_TERMINATORS): + return True + if DOC_NAME_RE.search(between): + return True + for m in re.finditer(r"[A-Za-z0-9_一-鿿][\w.一-鿿]{1,30}", between): + if m.group(0) in by_stem: + return True + return False + + +def check_section_refs(path: pathlib.Path, lines, errors, warns): + """D 类:§N.M 章节引用 —— 目标文档必须真有该编号标题。 + + 目标判定优先级(能判就判,判不了就跳过并计数,口径同 mem-numbers 的「窄而准」): + 1. 紧邻的文档名(`docs/x.md` / `x.md`,≤8 字符间隔)→ 唯一解析即采信 + 2. 同一句内的 § 链(`a.md` §3.2 … / §10.2)→ 继承前一个 § 的目标 + 3. 紧邻的裸文档名(`看门狗设计说明 §10`、`V0.0 §4.7`)→ 全名或前缀唯一匹配 + 4. 自引用线索(见/本文/本节/(/、/句首…)→ 目标 = 本文档 + 跳过:非 .md 锚点(`.pre-commit-config.yaml §7`)、变更记录小节、含状态词的行、 + 外部手册(RM0090 等)、目标文档不用数字编号标题、自引用但父级编号不存在。 + + 两档严重性:**目标明确**的失效 = 错误(拦提交);**自引用**的失效 = 告警 + (打印不拦,因为省略文档名的跨文档引用与真失效机器分不干净)。 + """ + idx = build_doc_index() + by_stem = {} + for name, paths in idx.items(): + for p in paths: + by_stem.setdefault(p.stem, []).append(p) + in_changelog = False + for lineno, line in enumerate(lines, 1): + hm = HEAD_RE.match(line) + if hm: + in_changelog = any(w in hm.group(1) for w in CHANGELOG_WORDS) + if EXAMPLE_MARKER in line or "[计划]" in line: + continue + prev_end = None + prev_tgt = None + prev_kind = None + for m in SEC_RE.finditer(line): + ref = m.group(1) + SEC_STATS["total"] += 1 + window = line[max(0, m.start() - 120):m.start()] + tgt = None + kind = None + names = list(DOC_NAME_RE.finditer(window)) + if names: + last = names[-1] + raw = urllib.parse.unquote(last.group(1)) + # 注意:last 的坐标是**窗口内**的,不能拿 m.start()(整行坐标)去减 + gap_txt = window[last.end():] + near = (len(gap_txt) <= SEC_MAX_GAP and SEC_GAP_RE.match(gap_txt)) + if near and MD_SUFFIX_RE.search(raw): + cand = ([b / raw for b in (path.parent, REPO_ROOT) + if (b / raw).exists()] + if "/" in raw else idx.get(raw, [])) + if len(cand) == 1: + tgt, kind = cand[0], "path" + elif near: + SEC_STATS["skip_nonmd_anchor"] += 1 + prev_end, prev_tgt, prev_kind = None, None, None + continue + if tgt is None and prev_tgt is not None: + between = line[prev_end:m.start()] + if not chain_blocked(between, by_stem): + # 继承前一个 § 的目标**与它的判定档位**(自引用链仍按自引用处理) + tgt, kind = prev_tgt, prev_kind + if tgt is None: + sm = STEM_RE.search(window) + if sm: + stem = sm.group(1) + tgt, kind = resolve_name(stem, by_stem) + if tgt is None: + short = strip_cue(stem) + if stem in SELF_TOKENS or short in SELF_TOKENS: + tgt, kind = path, "self" + elif short != stem: + tgt, kind = resolve_name(short, by_stem) + if tgt is None and short.endswith(DOC_NOUN_TAIL): + SEC_STATS["skip_nickname"] += 1 + SEC_TODO.append((path, lineno, ref, short, "昵称未解析")) + prev_end, prev_tgt, prev_kind = None, None, None + continue + if tgt is None and stem.endswith(DOC_NOUN_TAIL): + SEC_STATS["skip_nickname"] += 1 + SEC_TODO.append((path, lineno, ref, stem, "昵称未解析")) + prev_end, prev_tgt, prev_kind = None, None, None + continue + if tgt is None: + if SELF_CUE_RE.search(window): + tgt, kind = path, "self" + else: + SEC_STATS["skip_unknown"] += 1 + SEC_TODO.append((path, lineno, ref, "", "目标无法判定")) + prev_end, prev_tgt, prev_kind = None, None, None + continue + prev_end, prev_tgt, prev_kind = m.end(), tgt, kind + if any(h in line for h in EXTERNAL_HINTS) or "芯片手册" in window: + SEC_STATS["skip_external"] += 1 + continue + if in_changelog or has_status_hint(line, m.start(), m.end() - m.start()): + SEC_STATS["skip_changelog"] += 1 + continue + heads = numbered_headings(tgt) + if not heads: + SEC_STATS["skip_unnumbered"] += 1 + continue + if sec_plausible(heads, ref): + SEC_STATS["checked"] += 1 + continue + if kind == "self" and not sec_parent_exists(heads, ref): + SEC_STATS["skip_implicit_crossdoc"] += 1 + SEC_TODO.append((path, lineno, ref, "", "省略了文档名")) + continue + SEC_STATS["checked"] += 1 + where = f"§{ref}({tgt.relative_to(REPO_ROOT).as_posix()} 无此编号标题)" + if kind == "self": + warns.append((path, lineno, where)) + else: + errors.append((path, lineno, where, "章节引用")) + + def _rel(p: pathlib.Path) -> str: """仓库相对路径(正斜杠),用于与 SKIP_PARTS 前缀比较。""" try: @@ -210,9 +538,12 @@ def iter_docs(argv): def main(argv): + argv = argv[1:] if len(argv) > 1 else [] + strict = "--sections-strict" in argv errors = [] + warns = [] n_files = 0 - for doc in iter_docs(argv[1:] if len(argv) > 1 else []): + for doc in iter_docs(argv): if is_skipped(doc): continue try: @@ -223,8 +554,55 @@ def main(argv): lines = text.splitlines() check_md_links(doc, lines, errors) check_backtick_paths(doc, lines, errors) + check_section_refs(doc, lines, errors, warns) check_name(doc, errors) + # 覆盖率可见化:每次跑都打印,防止「可校验比例」悄悄回落 + total = SEC_STATS["total"] + checked = SEC_STATS["checked"] + if total: + print(f"章节引用:共 {total} 处,机器可判定 {checked} 处" + f"({100.0 * checked / total:.0f}%);跳过 → 昵称式 {SEC_STATS['skip_nickname']}" + f" / 非文档锚点 {SEC_STATS['skip_nonmd_anchor']}" + f" / 变更记录 {SEC_STATS['skip_changelog']}" + f" / 外部手册 {SEC_STATS['skip_external']}" + f" / 省略文档名的跨文档引用 {SEC_STATS['skip_implicit_crossdoc']}" + f" / 目标无编号体系 {SEC_STATS['skip_unnumbered']}" + f" / 无法判定 {SEC_STATS['skip_unknown']}") + if warns: + print(f"\n⚠️ 章节引用待确认({len(warns)} 处:按自引用解析后本文档没有该编号;" + "不拦提交):") + for path, lineno, where in warns[:40]: + try: + rel = path.relative_to(REPO_ROOT) + except ValueError: + rel = path + print(f" {rel}:{lineno} {where}") + if len(warns) > 40: + print(f" … 其余 {len(warns) - 40} 处略") + print(" 修法:确认它到底指哪篇 → 写成「`路径.md` §N.M」(此后自动纳入硬门禁)," + "或改指本文档现有编号。") + if strict: + for path, lineno, where in warns: + errors.append((path, lineno, where, "章节引用(自引用,--sections-strict)")) + + if "--section-todo" in argv and SEC_TODO: + by_stem = {} + for name, paths in build_doc_index().items(): + for p in paths: + by_stem.setdefault(p.stem, []).append(p) + print(f"\n📋 待改成紧邻写法的章节引用({len(SEC_TODO)} 处,机器判不了目标):") + for path, lineno, ref, token, why in SEC_TODO: + try: + rel = path.relative_to(REPO_ROOT) + except ValueError: + rel = path + sug = suggest_doc(token, by_stem) if token else None + tip = "" + if sug is not None: + tip = f" → 建议改写:`{sug.relative_to(REPO_ROOT).as_posix()}` §{ref}" + print(f" {rel}:{lineno} §{ref} [{why}]{(' ' + token) if token else ''}{tip}") + if errors: print(f"发现的失效文档引用 / 命名问题({len(errors)} 处):") for path, lineno, target, kind in errors: @@ -239,6 +617,9 @@ def main(argv): "已删除的文件请写明「已删除」状态词;" "引用报错样例/日志片段请在该行加 `[示例]` 标记;" "命名规范见 docs/文档规范.md §1。") + print("章节引用失效([章节引用]):目标文档已无该编号小节 → 改指现有编号或直接写小节名。" + "可校验的写法:`路径.md` §N.M / 裸文件名 §N.M / 文档名前缀(V0.0)/ 已登记别名;" + "判不了目标的跑 --section-todo 看清单与改写建议。") return 1 print(f"文档路径检查通过({n_files} 个文档)。") diff --git a/scripts/check_registration.py b/scripts/check_registration.py new file mode 100644 index 00000000..4ee0342d --- /dev/null +++ b/scripts/check_registration.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check_registration.py - 双端注册反向校验门禁(纯标准库)。 + +背景(登记簿 N-23,出处审查报告 N-06 残余): + 本工程 Keil 工程与 PC 模拟器都用**显式文件列表**(无通配扫描)。新 `.c` + 必须手动注册两处才会被编译: + ① `MDK-ARM/STM32F407-Demo.uvprojx` 的 `` 条目(MCU 固件构建) + ② `simulator/CMakeLists.txt` 的显式源列表(PC 模拟器构建) + 历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c` **两处都没注册** + = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。 + +本脚本做什么(三类缺口): + 扫描 `App/`、`Drivers/BSP/`、`Src/` 下全部 `.c`(排除第三方),对照两份注册表: + ① 存在但未进 uvprojx -> 固件不编译(死代码 / 漏注册) + ② 存在但未进 CMakeLists -> 模拟器不编译(**仅当该文件本应进模拟器时**才是缺口; + MCU-only 文件不进模拟器是**正确**的,见下方豁免) + ③ 注册了但文件不存在(反向)-> 幽灵注册(改名/删除后残留的注册条目) + +豁免机制(关键): + 模拟器只建模 **UI + 网络配置共享核心**;其余模块(MCU 驱动 / 固件任务 / 纯算法 / + CubeMX 生成层)**不进模拟器是设计事实**,模拟器另有 `_sim` 替身或根本无对应。 + 豁免用**显式清单 + 每行理由注释**(命名模式 `*_mcu.c` + 个别点名), + **不做整目录一刀切**(N-23 教训正是"目录级盲区")。唯一例外是 `Src/`: + CubeMX 生成的外设初始化/中断/main,按构造即 MCU 专属、不可能有模拟器替身, + 故整目录豁免(任务口径明确认可"Src/ 全部")。 + +已知缺口过渡(存量不拦、只对新缺口 FAIL): + `KNOWN_GAPS` 登记存量缺口(N-23 存量,Lead 处置中):命中 -> 打印 `[WARN]` 不拦; + 新出现的缺口 -> 打印 `[GAP]` 并 FAIL。`--strict` 关闭白名单(存量也 FAIL),供审计。 + +退出码: + 0 = 一致(无新缺口;存量缺口已白名单 WARN) + 1 = 有缺口(存在未白名单的新缺口或幽灵注册) + 2 = 脚本环境错误(注册表文件缺失 / 无法读取) + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' scripts/check_registration.py + & '.venv\\Scripts\\python.exe' scripts/check_registration.py --strict + +设计依据:docs/待处理内容.md N-23;.dsh/skills/new-source-file +创建日期:2026-10-12 +""" + +import argparse +import fnmatch +import os +import re +import sys + +# --- 仓库根(scripts/ 的上一级)--- +REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +UVPROJX = os.path.join(REPO_ROOT, "MDK-ARM", "STM32F407-Demo.uvprojx") +CMAKELISTS = os.path.join(REPO_ROOT, "simulator", "CMakeLists.txt") +SIM_DIR = os.path.join(REPO_ROOT, "simulator") + +# --- 扫描范围与第三方排除 --- +SCAN_ROOTS = ["App", "Drivers/BSP", "Src"] +# 第三方目录(即便落在扫描根下也跳过) +THIRD_PARTY = [ + "Drivers/STM32F4xx_HAL_Driver", + "Drivers/CMSIS", + "Middlewares", + "Lib", +] + +# --- 模拟器豁免清单(category ②:不进模拟器是**正确**的)--- +# 每项 = (fnmatch 模式(对仓库相对 posix 路径匹配), 理由)。 +# 原则:命名模式 + 个别点名,不整目录一刀切;唯一目录级豁免 Src/(CubeMX 生成,MCU 专属)。 +SIM_EXEMPT = [ + # CubeMX 生成的外设初始化 / 中断向量 / main / freertos:按构造即 MCU 专属, + # 模拟器无对应替身(任务口径明确认可"Src/ 全部")。唯一目录级豁免。 + ("Src/*", "CubeMX 生成的外设初始化/中断/main,MCU 专属,模拟器无替身"), + + # MCU 平台后端:命名约定 *_mcu.c,模拟器另有同名 *_sim.c 替身(在 simulator/sim/)。 + ("*_mcu.c", "MCU 平台后端,模拟器另有 *_sim.c 替身"), + + # --- App/:固件任务 / MCU 依赖 / 纯算法(模拟器无消费路径)--- + ("App/algo/adc_health.c", "L1 有效性校验纯算法,模拟器无消费路径"), + ("App/algo/adc_input.c", "依赖 HAL/TPAFE 取数,MCU 专属"), + ("App/algo/phasor.c", "L2 相量纯算法,模拟器无消费路径"), + ("App/algo/prot_elm.c", "L3 保护元件纯算法,模拟器无消费路径"), + ("App/algo/prot_set.c", "L5 保护定值纯算法,模拟器无消费路径"), + ("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"), + ("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"), + ("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"), + ("App/log/console_log.c", "MCU 控制台输出,模拟器无消费路径"), + ("App/sync/time_sync.c", "对时任务(net+RTOS),MCU 专属"), + ("App/task/adc_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), + ("App/task/default_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), + ("App/task/net_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), + ("App/task/rs485_task.c", "固件 RS-485 任务,MCU 专属(另:uvprojx 缺口见 KNOWN_GAPS)"), + ("App/task/ui_task.c", "固件 UI 任务,模拟器另有 main.c 驱动 UI"), + ("App/util/sha256.c", "纯工具(FTP 鉴权在 MCU 端),模拟器无消费路径"), + ("App/util/sys_clock.c", "RTC/SD2506 依赖墙钟,MCU 专属"), + ("App/util/task_prof.c", "任务性能探针,MCU 专属"), + ("App/util/task_watchdog.c", "看门狗判定(RTOS 依赖),MCU 专属"), + + # --- Drivers/BSP/:MCU 芯片/网络驱动与 FTP 服务(模拟器无对应)--- + ("Drivers/BSP/CH395F/ch395f.c", "MCU SPI 网卡驱动"), + ("Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c", "MCU SPI NAND 驱动"), + ("Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "dhara FTL 适配,MCU 存储栈"), + ("Drivers/BSP/NET/lftpd/lftpd.c", "MCU FTP 服务器"), + ("Drivers/BSP/NET/lftpd/lftpd_auth.c", "MCU FTP 鉴权"), + ("Drivers/BSP/NET/lftpd/lftpd_inet.c", "MCU FTP 网络层"), + ("Drivers/BSP/NET/lftpd/lftpd_io.c", "MCU FTP 文件 IO"), + ("Drivers/BSP/NET/lftpd/lftpd_string.c", "MCU FTP 字符串工具"), + ("Drivers/BSP/NET/net_select.c", "MCU socket 选择层"), + ("Drivers/BSP/NET/net_socket.c", "MCU socket 后端,模拟器另有 net_socket_sim.c"), + ("Drivers/BSP/RS485/rs485.c", "MCU RS-485 驱动"), + ("Drivers/BSP/SD2506/sd2506.c", "MCU RTC 驱动"), + ("Drivers/BSP/TPAFE5160/tpafe5160.c", "MCU 计量芯片驱动"), + ("Drivers/BSP/UI/ui_port.c", "MCU UI 端口层(显示/触摸硬件),模拟器另有 sim/sim_port.c"), +] + +# --- 已知缺口白名单(存量,N-23,Lead 已处置)--- +# 命中 -> WARN 不拦;新缺口 -> GAP FAIL。仅针对"存在但未注册"方向。 +KNOWN_GAPS = { + "App/task/rs485_task.c": "E-59 门禁刻意未接线(B2 消费者就绪时随任务立项注册)", +} + + +def to_repo_rel(path): + """把绝对/相对路径归一为仓库相对 posix 路径。""" + p = os.path.normpath(path) + rel = os.path.relpath(p, REPO_ROOT) + return rel.replace("\\", "/") + + +def is_scan_scope(repo_rel): + """是否在扫描范围内(App/、Drivers/BSP/、Src/ 下的 .c,排除第三方)。""" + if not repo_rel.endswith(".c"): + return False + for tp in THIRD_PARTY: + if repo_rel == tp or repo_rel.startswith(tp + "/"): + return False + for root in SCAN_ROOTS: + if repo_rel.startswith(root + "/"): + return True + return False + + +def scan_sources(): + """扫描扫描根下全部 .c,返回仓库相对 posix 路径集合。""" + found = set() + for root in SCAN_ROOTS: + abs_root = os.path.join(REPO_ROOT, root.replace("/", os.sep)) + if not os.path.isdir(abs_root): + continue + for dirpath, _dirnames, filenames in os.walk(abs_root): + for fn in filenames: + if fn.endswith(".c"): + abs_p = os.path.join(dirpath, fn) + rel = to_repo_rel(abs_p) + if is_scan_scope(rel): + found.add(rel) + return found + + +def parse_uvprojx(): + """解析 uvprojx 的 ,返回仓库相对 posix 路径集合(含 .c/.s)。""" + with open(UVPROJX, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + mdk_dir = os.path.dirname(UVPROJX) + entries = set() + for m in re.finditer(r"(.*?)", text): + raw = m.group(1).strip() + if not raw: + continue + # uvprojx 路径相对 MDK-ARM/ 目录,反斜杠或正斜杠混用 + joined = os.path.join(mdk_dir, raw.replace("\\", os.sep)) + entries.add(to_repo_rel(joined)) + return entries + + +def _expand_var(s, varmap, depth=0): + """递归展开 ${VAR}。""" + if depth > 12: + return s + + def repl(m): + name = m.group(1) + val = varmap.get(name, m.group(0)) + return _expand_var(val, varmap, depth + 1) + + return re.sub(r"\$\{(\w+)\}", repl, s) + + +def parse_cmake(): + """解析 CMakeLists 的 *_SOURCES 列表(先展开变量),返回仓库相对 posix 路径集合。""" + with open(CMAKELISTS, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + + # 1) 单值 set(NAME value):目录变量(不含 *_SOURCES) + varmap = {"CMAKE_CURRENT_SOURCE_DIR": SIM_DIR} + for m in re.finditer(r'set\s*\(\s*([A-Za-z_]\w*)\s+("?)([^"\r\n]*?)\2\s*\)', text): + name = m.group(1) + val = m.group(3).strip() + if name.endswith("_SOURCES"): + continue + varmap[name] = val + + # 2) 多值 set(NAME ... ) 中名字以 _SOURCES 结尾的块 + sources = set() + for m in re.finditer(r"set\s*\(\s*([A-Za-z_]\w*_SOURCES)\b(.*?)\)", text, re.DOTALL): + body = m.group(2) + # 逐行剥注释 + lines = [] + for line in body.splitlines(): + idx = line.find("#") + if idx >= 0: + line = line[:idx] + lines.append(line) + body = " ".join(lines) + for tok in body.split(): + expanded = _expand_var(tok, varmap) + if not expanded: + continue + # 展开后若仍是绝对/带盘符路径直接用,否则相对 simulator/ 目录 + if os.path.isabs(expanded) or re.match(r"^[A-Za-z]:[\\/]", expanded): + cand = expanded + else: + cand = os.path.join(SIM_DIR, expanded) + sources.add(to_repo_rel(cand)) + return sources + + +def main(): + ap = argparse.ArgumentParser(description="双端注册反向校验(uvprojx + CMakeLists)") + ap.add_argument("--strict", action="store_true", + help="关闭 KNOWN_GAPS 白名单:存量缺口也按 FAIL 处理(供审计)") + args = ap.parse_args() + + # 环境校验 + for path, label in ((UVPROJX, "uvprojx"), (CMAKELISTS, "CMakeLists.txt")): + if not os.path.isfile(path): + print("[ENV-ERROR] 注册表缺失: %s (%s)" % (label, path)) + return 2 + + try: + scanned = scan_sources() + uv = parse_uvprojx() + cmake = parse_cmake() + except OSError as e: + print("[ENV-ERROR] 读取失败: %s" % e) + return 2 + + uv_c = {p for p in uv if is_scan_scope(p)} + cmake_c = {p for p in cmake if is_scan_scope(p)} + + gaps = [] # 新缺口 -> FAIL + warns = [] # 存量白名单 -> WARN + + # ① 存在但未进 uvprojx(无豁免:扫描范围内每个 .c 都应编入固件) + for f in sorted(scanned): + if f not in uv: + if (not args.strict) and f in KNOWN_GAPS: + warns.append("[WARN] %s | 缺 uvprojx(存量:%s)" % (f, KNOWN_GAPS[f])) + else: + gaps.append("[GAP] %s | 缺 uvprojx" % f) + + # ② 存在但未进 CMakeLists(MCU-only 豁免) + for f in sorted(scanned): + if f in cmake_c: + continue + if any(fnmatch.fnmatch(f, pat) for pat, _ in SIM_EXEMPT): + continue + if (not args.strict) and f in KNOWN_GAPS: + warns.append("[WARN] %s | 缺 CMakeLists(存量:%s)" % (f, KNOWN_GAPS[f])) + else: + gaps.append("[GAP] %s | 缺 CMakeLists" % f) + + # ③ 幽灵注册:注册了但文件不存在(反向) + for f in sorted(uv_c): + if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))): + gaps.append("[GAP] %s | 幽灵注册(uvprojx)" % f) + for f in sorted(cmake): + if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))): + gaps.append("[GAP] %s | 幽灵注册(CMakeLists)" % f) + + # 输出 + print("== 双端注册反向校验 ==") + print("扫描 .c: %d | uvprojx(扫描范围): %d | CMakeLists(扫描范围): %d" + % (len(scanned), len(uv_c), len(cmake_c))) + for w in warns: + print(w) + for g in gaps: + print(g) + + if gaps: + print("结果: FAIL(%d 个新缺口/幽灵注册,%d 个存量 WARN)" % (len(gaps), len(warns))) + return 1 + if warns: + print("结果: PASS(无新缺口;%d 个存量缺口已白名单 WARN,N-23 Lead 处置中)" % len(warns)) + return 0 + print("结果: PASS(双端注册一致)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ram_report.py b/scripts/ram_report.py index 5634c337..73dde519 100644 --- a/scripts/ram_report.py +++ b/scripts/ram_report.py @@ -19,17 +19,23 @@ MAP_DEFAULT = os.path.join( ) # Rows in "Image component sizes" that re-aggregate objects (would double-count sums). -TOTALS_ROWS = {"Object Totals", "Library Totals", "User Totals", "mc_w.l", "mf_w.l"} +TOTALS_ROWS = {"Object Totals", "Library Totals", "User Totals"} + +# ARM library aggregate rows (e.g. mc_w.l / mf_w.l / m_wm.l): the linker prints one +# aggregate line per library in addition to the individual member .o lines, so any +# ".l" row is a re-aggregation and must be dropped from per-file sums. +# (2026-10-11: sha256's 64-bit shifts pulled in m_wm.l — the hardcoded name list +# missed it and the self-consistency check went +58; match the name pattern instead.) +_LIB_AGG_RE = re.compile(r'^[a-z]{1,2}_[a-z0-9]+\.l$') def _is_aggregate(name): """Aggregate rows to drop from per-file sums: library-name summaries and Totals lines. - '(incl. Padding)' / '(incl. Generated)' are linker metadata; they are excluded from - per-file tables but their contribution IS counted in Grand Totals, so the per-file - sums will differ from Grand Totals by the alignment-padding delta (~300B here). + '(incl. Padding)' / '(incl. Generated)' are linker metadata ledger rows; they ARE + kept so that per-file sums reconcile exactly with Grand Totals (self-check Δ=0). """ - return name in TOTALS_ROWS + return name in TOTALS_ROWS or bool(_LIB_AGG_RE.match(name)) def parse_execution_region_header(line): diff --git a/scripts/rename_ref.py b/scripts/rename_ref.py new file mode 100644 index 00000000..947ba7a5 --- /dev/null +++ b/scripts/rename_ref.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +改名 / 挪目录 + 全库引用自动改写(把"同步引用链接"从手工活变成一条命令)。 + +背景:本仓库 1800+ 处路径引用(`docs/xxx.md` 反引号引用 + Markdown 链接 + +`#include` + `uvprojx`/`CMakeLists.txt` 源文件列表),改名或挪目录后要靠人 +逐文件扫改(历史上一次改名波及 10~30 个文件)。本脚本把这件事机械化。 + +用法: + python scripts/rename_ref.py <旧路径> <新路径> # 默认 dry-run,只打印将要改什么 + python scripts/rename_ref.py <旧路径> <新路径> --apply # 真正落盘(git mv + 改写引用) + python scripts/rename_ref.py <旧路径> <新目录/ # 挪进目录(保留文件名) + python scripts/rename_ref.py docs/a/ docs/b/ --apply # 整目录搬迁(前缀改写) + +路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。 + +改写覆盖的引用形态: + 1. 反引号根相对引用 `docs/xxx/yyy.md` + 2. Markdown 链接 [文本](docs/xxx/yyy.md)(根相对) / [文本](../xxx/yyy.md)(文档相对) + 3. 图片链接 ![x](images/a.svg) —— 含"文档挪层级后 ../ 前缀要跟着变"这条实证坑 + 4. C 的 #include #include "yyy.h"(改名时连裸文件名引用一起改) + 5. 构建文件 MDK-ARM/*.uvprojx 的 、simulator/CMakeLists.txt 的 + ${PROJECT_ROOT}/… 与 ${VAR}/… 源文件列表 + 6. 任意文本文件里的路径串(.py/.bat/.yaml/.json/.txt/skill 等) + +保真约定(见 docs/文档规范.md §2、scripts/README.md「脚本编写约定」): + * 按原样回写 BOM 与换行符(LF/CRLF),无改动的文件不写 + * 只扫 git 跟踪的文本文件;第三方/生成物目录跳过 + * 默认 dry-run;--apply 才落盘,且落盘后自动跑 check_doc_links.py --all 复核 + +退出码:0 成功;1 有错误(旧路径不存在 / 新路径已存在 / 复核失败);2 用法错误。 +""" +import argparse +import os +import pathlib +import re +import subprocess +import sys +import urllib.parse + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +# 不扫描的路径片段(第三方 / 生成物 / 本地状态) +SKIP_PARTS = ("Lib/", "Middlewares/", "Drivers/STM32F4xx_HAL_Driver/", + "Drivers/CMSIS/", "MDK-ARM/objects/", "MDK-ARM/Listings/", + "MDK-ARM/RTE/", "MDK-ARM/STM32F407-Demo/", ".venv/", + "node_modules/", "__pycache__/", "build/", "obj/", + ".workbuddy-ai/", ".mnemon/", "simulator/SDL2/", + "docs/芯片手册/") +# 生成物:不改写,只在结尾提示重跑生成器(改了也是白改,下次生成会覆盖) +GENERATED = ("docs/images/引用关系图.",) + +# 不扫描的扩展名(二进制 / 生成物) +SKIP_EXT = {".o", ".d", ".map", ".axf", ".bin", ".hex", ".elf", ".htm", ".lst", + ".png", ".jpg", ".jpeg", ".gif", ".ttf", ".woff", ".dll", ".lib", + ".pyc", ".zip", ".gz", ".pdf", ".docx", ".xlsx", ".ico", ".cur"} + +# 路径 token:至少含一个分隔符;允许 ${VAR} 前缀、中文、点、下划线、短横 +CH = r"[\w$.\-{}一-鿿]" +TOKEN_RE = re.compile(CH + r"+(?:[/\\]" + CH + r"+)+") +VAR_PREFIX_RE = re.compile(r"^(\$\{[^}]+\}/)(.+)$") +INCLUDE_RE = re.compile(r'(#\s*include\s*)([<"])([^<>"\n]+)([">])') +FILEPATH_RE = re.compile(r"()([^<\n]+)()") + +BOM = b"\xef\xbb\xbf" + + +def rel(p: pathlib.Path) -> str: + """仓库根相对 POSIX 路径。""" + try: + return p.resolve().relative_to(REPO_ROOT).as_posix() + except ValueError: + return p.as_posix() + + +def is_skipped(relpath: str) -> bool: + for s in SKIP_PARTS: + if relpath == s.rstrip("/") or relpath.startswith(s): + return True + return False + + +def is_generated(relpath: str) -> bool: + """生成物(引用关系图等):跳过改写,只提示重跑生成器。""" + return any(relpath.startswith(g) for g in GENERATED) + + +def tracked_files(): + """git 跟踪 + 未跟踪但未忽略的文件(自动排除生成物与 .gitignore 内容)。""" + out = subprocess.run(["git", "ls-files", "-z", "--cached", "--others", + "--exclude-standard"], + cwd=REPO_ROOT, stdout=subprocess.PIPE, check=True).stdout + for name in out.decode("utf-8").split("\0"): + if not name: + continue + name = name.replace("\\", "/") + if is_skipped(name): + continue + if pathlib.Path(name).suffix.lower() in SKIP_EXT: + continue + p = REPO_ROOT / name + if p.is_file(): + yield p + + +def read_text(p: pathlib.Path): + """返回 (text, bom, ok)。二进制/非 UTF-8 → ok=False。""" + data = p.read_bytes() + bom = data.startswith(BOM) + if bom: + data = data[len(BOM):] + try: + return data.decode("utf-8"), bom, True + except UnicodeDecodeError: + return None, bom, False + + +def resolve_token(tok: str, file_dir: pathlib.Path, old_abs: pathlib.Path): + """把 token 解析成绝对路径;解析不到 old 就返回 (None, "", False)。 + + 返回 (解析到的绝对路径, ${VAR}/ 前缀, 是否按仓库根解析)。 + 依次尝试:${VAR}/ 前缀剥离 → 相对仓库根 → 相对引用文件目录 → 尾段后缀匹配。 + """ + var_prefix = "" + body = tok + m = VAR_PREFIX_RE.match(tok) + if m: + var_prefix, body = m.group(1), m.group(2) + + unquoted = urllib.parse.unquote(body) + roots = [] + if not unquoted.startswith("."): + roots.append((REPO_ROOT, True)) + roots.append((file_dir, False)) + if unquoted.startswith("."): + roots.insert(0, (REPO_ROOT, True)) + for base, via_root in roots: + for b in (unquoted, body): + b2 = b.rstrip(".") + try: + rc = pathlib.Path(os.path.normpath(base / b2)) + except (OSError, ValueError): + continue + if rc == old_abs or str(rc).startswith(str(old_abs) + os.sep): + return rc, var_prefix, via_root + + # ${VAR}/name.ext 之类:变量指向的目录未知,按「尾段后缀」匹配 + parts = re.split(r"[/\\]", body) + old_parts = list(old_abs.parts) + if var_prefix and len(parts) <= 2: + if parts[-1] == old_parts[-1] and (len(parts) == 1 or parts[-2] == old_parts[-2]): + return old_abs, var_prefix, True + return None, "", False + + +def resolve_relative(tok: str, file_dir: pathlib.Path): + """按「相对引用文件所在目录」解析,且目标必须真实存在(用于被移动文件的出链)。""" + if VAR_PREFIX_RE.match(tok): + return None + for b in (urllib.parse.unquote(tok), tok): + b2 = b.rstrip(".") + p = pathlib.Path(os.path.normpath(file_dir / b2)) + if p.exists(): + return p + return None + + +def rel_spelling(tok: str, target: pathlib.Path, base_dir: pathlib.Path) -> str: + """保持原分隔符风格,重算相对 base_dir 的写法。""" + body = os.path.relpath(target, base_dir).replace("\\", "/") + if "\\" in tok: + body = body.replace("/", "\\") + if "%" in tok: + body = urllib.parse.quote(body, safe="/") + return body + + +def new_spelling(tok: str, resolved: pathlib.Path, var_prefix: str, via_root: bool, + base_dir: pathlib.Path) -> str: + """按原写法风格算出新 token(根相对保持根相对,相对保持相对)。""" + old_abs = old_abs_global[0] + new_abs = new_abs_global[0] + if resolved == old_abs: + remainder = pathlib.Path(".") + else: + remainder = resolved.relative_to(old_abs) + target = new_abs if remainder == pathlib.Path(".") else new_abs / remainder + + if var_prefix or via_root: + new_body = os.path.relpath(target, REPO_ROOT) + else: + new_body = os.path.relpath(target, base_dir) + if "\\" in tok: + new_body = new_body.replace("/", "\\") + else: + new_body = new_body.replace("\\", "/") + if "%" in tok: + new_body = urllib.parse.quote(new_body, safe="/") + return var_prefix + new_body + + +old_abs_global = [None] +new_abs_global = [None] + + +def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=True): + """改写一个文件里的所有引用;changes 收集 (lineno, old, new)。""" + old_abs = old_abs_global[0] + new_abs = new_abs_global[0] + file_abs = file_path.resolve() + # 被移动文件(含整目录搬迁时目录内的文件)自身的相对链接(图片等): + # **解析**用旧目录(链接当前指向的位置),**改写**用新目录 + # ——docs/文档规范.md §3.2 记的实证坑(迁目录后 images/ 未跟着改 ../) + moved = (file_abs == old_abs + or str(file_abs).startswith(str(old_abs) + os.sep)) + resolve_dir = file_path.parent + emit_dir = new_abs.parent if moved else file_path.parent + + out = [] + pos = 0 + for m in TOKEN_RE.finditer(text): + tok = m.group(0) + resolved, var_prefix, via_root = resolve_token(tok, resolve_dir, old_abs) + if resolved is None and moved: + # 被移动文件自身的**出链**(图片等):目标没变但基准目录变了 → 重算写法 + own = resolve_relative(tok, resolve_dir) + if own is not None: + rep = rel_spelling(tok, own, emit_dir) + if rep != tok: + lineno = text.count("\n", 0, m.start()) + 1 + changes.append((lineno, tok, rep)) + out.append(text[pos:m.start()]) + out.append(rep) + pos = m.end() + continue + if resolved is None: + continue + rep = new_spelling(tok, resolved, var_prefix, via_root, emit_dir) + if rep == tok: + continue + lineno = text.count("\n", 0, m.start()) + 1 + changes.append((lineno, tok, rep)) + out.append(text[pos:m.start()]) + out.append(rep) + pos = m.end() + out.append(text[pos:]) + new_text = "".join(out) + + # 改名时:裸文件名引用(#include "x.h"、x.s) + old_name = old_abs.name + new_name = new_abs.name + if old_name != new_name and old_abs != new_abs: + def fix_include(m): + inc = m.group(3) + if pathlib.PurePosixPath(inc.replace("\\", "/")).name == old_name: + base = inc[: len(inc) - len(old_name)] + new_inc = base + new_name + changes.append((text.count("\n", 0, m.start()) + 1, inc, new_inc)) + return m.group(1) + m.group(2) + new_inc + m.group(4) + return m.group(0) + + def fix_filepath(m): + fp = m.group(2) + if pathlib.PurePosixPath(fp.replace("\\", "/")).name == old_name: + base = fp[: len(fp) - len(old_name)] + new_fp = base + new_name + changes.append((text.count("\n", 0, m.start()) + 1, fp, new_fp)) + return m.group(1) + new_fp + m.group(3) + return m.group(0) + + new_text = INCLUDE_RE.sub(fix_include, new_text) + if file_path.suffix.lower() in (".uvprojx", ".uvoptx"): + new_text = FILEPATH_RE.sub(fix_filepath, new_text) + + return new_text + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="改名/挪目录并自动改写全库引用(默认 dry-run)") + ap.add_argument("old", help="旧路径(仓库根相对)") + ap.add_argument("new", help="新路径(仓库根相对;以 / 结尾表示挪进该目录)") + ap.add_argument("--apply", action="store_true", help="真正落盘(默认只预览)") + ap.add_argument("--no-git", action="store_true", help="用 os.rename 代替 git mv") + ap.add_argument("--no-verify", action="store_true", help="跳过 check_doc_links 复核") + args = ap.parse_args(argv) + + old_rel = args.old.replace("\\", "/").rstrip("/") + new_rel = args.new.replace("\\", "/") + if new_rel.endswith("/"): + new_rel = new_rel + old_rel.rsplit("/", 1)[-1] + new_rel = new_rel.rstrip("/") + + if not new_rel or old_rel == new_rel: + print("错误:新旧路径相同或新路径为空") + return 2 + + old_abs = (REPO_ROOT / old_rel).resolve() + new_abs = (REPO_ROOT / new_rel).resolve() + if not old_abs.exists(): + print(f"错误:旧路径不存在:{old_rel}") + return 1 + if new_abs.exists(): + print(f"错误:新路径已存在:{new_rel}") + return 1 + if is_skipped(old_rel): + print(f"错误:{old_rel} 属于第三方/生成物范围,不应由本脚本改写") + return 1 + + old_abs_global[0] = old_abs + new_abs_global[0] = new_abs + + plan = [] + hit_generated = False + for f in tracked_files(): + if is_generated(rel(f)): + if resolve_token(old_rel, f.parent, old_abs)[0] is not None: + hit_generated = True + continue + text, bom, ok = read_text(f) + if not ok: + continue + changes = [] + new_text = rewrite_text(text, f, changes) + if changes: + plan.append((f, text, new_text, bom, changes)) + + n_refs = sum(len(c) for _, _, _, _, c in plan) + print(f"计划:{old_rel} → {new_rel}") + print(f"受影响文件 {len(plan)} 个,引用改写 {n_refs} 处\n") + for f, _, _, _, changes in plan: + print(f"── {rel(f)} ({len(changes)} 处)") + for lineno, o, n in changes[:12]: + print(f" :{lineno} {o} → {n}") + if len(changes) > 12: + print(f" … 其余 {len(changes) - 12} 处略") + print() + + if not args.apply: + print("(dry-run:未做任何修改。确认无误后加 --apply)") + return 0 + + # 1) 先移动文件本身 + new_abs.parent.mkdir(parents=True, exist_ok=True) + if args.no_git: + os.rename(old_abs, new_abs) + else: + r = subprocess.run(["git", "mv", "--", old_rel, new_rel], + cwd=REPO_ROOT, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + if r.returncode != 0: + os.rename(old_abs, new_abs) + print(f"已移动:{old_rel} → {new_rel}") + + # 2) 再改写引用(保 BOM / 换行) + # 被移动文件本身已换位置 → 内容必须写到**新**路径,否则会在旧路径复活一份 + def mapped(f: pathlib.Path) -> pathlib.Path: + fa = f.resolve() + if fa == old_abs: + return new_abs + if str(fa).startswith(str(old_abs) + os.sep): + return new_abs / fa.relative_to(old_abs) + return f + + for f, _, new_text, bom, _ in plan: + target = mapped(f) + target.parent.mkdir(parents=True, exist_ok=True) + data = new_text.encode("utf-8") + if bom: + data = BOM + data + target.write_bytes(data) + print(f"已改写 {len(plan)} 个文件、{n_refs} 处引用") + + # 2b) 收尾提示(脚本管不到的部分) + if hit_generated: + print("ℹ 引用关系图是生成物,未改写 → 重跑 & 'tools\\refgraph\\gen_refgraph.bat'") + if old_abs.suffix.lower() in (".c", ".h") or new_abs.suffix.lower() in (".c", ".h"): + print("ℹ 改了 .c/.h:已同步 uvprojx 与 CMakeLists;若**新增目录**还需把目录加进 " + "uvprojx 的 (见 new-source-file skill),随后跑 @build 验证") + print("ℹ 正文里的**裸文件名/昵称引用**(如「内存方案 §1.4」「app_main.c/h」)无法机械判定," + "已尽量改写,仍建议 grep 一遍旧名:git grep -n '<旧文件名>'") + + # 3) 复核 + if not args.no_verify: + r = subprocess.run([sys.executable, str(REPO_ROOT / "scripts" / "check_doc_links.py"), + "--all"], cwd=REPO_ROOT) + if r.returncode != 0: + print("\n⚠️ check_doc_links 未通过——上面列出的断链需人工确认(多为裸文件名引用)") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/section_aliases.json b/scripts/section_aliases.json new file mode 100644 index 00000000..d052b5ae --- /dev/null +++ b/scripts/section_aliases.json @@ -0,0 +1,6 @@ +{ + "内存方案": "docs/内存管理/RAM使用分析与优化方案.md", + "架构总览": "docs/架构设计/项目架构总览.md", + "规划": "docs/算法/继电保护功能复现规划.md", + "V0.0": "docs/硬件平台/V0.0_网表详细分析报告.md" +} diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index 724b2947..a4c1b642 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -104,6 +104,7 @@ set(SYSLOG_SOURCES # 网络配置(双端共享核心 + cfg_file 引擎 + 平台存储后端;MCU 端编 cfg_file_store_mcu.c,见 Keil 工程) set(NETCFG_SOURCES ${NETCFG_DIR}/net_cfg.c + ${NETCFG_DIR}/ftp_cfg.c ${PROJECT_ROOT}/App/cfg/cfg_file.c sim/cfg_file_store_sim.c ) diff --git a/simulator/README.md b/simulator/README.md index f1b278b3..01d9beb0 100644 --- a/simulator/README.md +++ b/simulator/README.md @@ -282,4 +282,4 @@ UI 改动的自动化门禁脚本(完整流程见 `docs/UI设计/UI_自动化 > **模拟器跑过 ≠ 真机跑过**:本目录与真机的**全部已知差异**(网络 pending 机制缺失、代次号恒 0、 > 日志/配置的 mtime 粒度与堆后端差异、IWDG/NAND/RTC 等硬件不可建模)统一登记在 > [`../docs/测试说明/模拟器差距清单.md`](../docs/测试说明/模拟器差距清单.md)—— -> 改动触及 `sim/` 任何后端时,**必须同步在那里登记一行**;改动前先看该文档 §3 判断是否必须回真机。 +> 改动触及 `sim/` 任何后端时,**必须同步在那里登记一行**;改动前先看 `docs/测试说明/模拟器差距清单.md` §3 判断是否必须回真机。 diff --git a/test/board/ch395f_reuse_bench.c b/test/board/ch395f_reuse_bench.c new file mode 100644 index 00000000..d13cfb4a --- /dev/null +++ b/test/board/ch395f_reuse_bench.c @@ -0,0 +1,373 @@ +/** + * @file ch395f_reuse_bench.c + * @brief N-30 芯片级 Socket 复用时序台架(裸机,内核前运行,OS/net/FTP 全剥离) + * @details 直接调用 ch395f_* 命令,循环执行受控时序矩阵(延迟/关闭次序/载荷/Socket 号), + * 每步打印芯片状态与中断寄存器,定位"关→开→监听→连接"复用时序中 + * 芯片行为的确切规律。配套主机脚本 test/host/ch395f_reuse_host.py。 + * 定案结论与实验矩阵演进 → CH395F_Trap_Records.md Trap 25。 + * + * 存档状态:本文件在主分支**不注册编译**(未加入 uvprojx),仅作实验工具存档; + * 运行方法:在实验分支(如 n30-chip-bench)把本 .c 注册进 uvprojx Test 组、 + * Src/main.c 的 USER CODE 2 区在 app_main_init() 之前调 ch395f_reuse_bench() + * (内核前裸机循环,永不返回,断电退出),烧录后跑主机脚本对照芯片日志。 + * 当前矩阵为定案轮(对照 vs "丢 CONNECT 捕获自 accept"修复组),见 s_vars[]。 + * + * close_mode:0=主机先关(芯片等 DISCONNECT);1=芯片先关;2=无数据连上即关 + * verify:0=无修复;1=listen 后确认 LISTEN 重试;2=缓冲重分配; + * 3=幽灵耐心等;4=等 CONNECT 期轮询状态+自 accept(定案修复) + */ + +#include "ch395f_reuse_bench.h" + +#if N30_CHIP_BENCH + +#include "ch395f.h" +#include "dbg_log.h" +#include "stm32f4xx_hal.h" + +/* 变体表:{sock, close 后复开延迟 ms, TX 字节数, close_mode, verify_listen} */ +typedef struct { + uint8_t sock; + uint16_t delay_ms; + uint16_t tx_len; + uint8_t close_mode; + uint8_t verify; /* 1 = listen 后确认 TCP==0x01,否则 close 重试(修复假设) */ +} bench_var_t; + +static const bench_var_t s_vars[] = { + { 3, 0, 100, 0, 0 }, /* 0ms 对照 */ + { 3, 300, 100, 0, 0 }, /* 300ms 对照 */ + { 3, 800, 2000, 0, 0 }, /* 2KB 对照 */ + { 3, 0, 100, 0, 4 }, /* 0ms + 丢CONNECT捕获自accept */ + { 3, 300, 100, 0, 4 }, /* 300ms + 丢CONNECT捕获 */ + { 3, 800, 2000, 0, 4 }, /* 2KB + 丢CONNECT捕获 */ +}; + +#define BENCH_N_VARS (sizeof(s_vars) / sizeof(s_vars[0])) +#define BENCH_PORT(i) (5000U + (uint16_t)(i)) + +/* TX 图案缓冲(静态,SRAM;2KB = 最大变体载荷) */ +static uint8_t s_tx_pat[2048]; +/* RX 排空缓冲 */ +static uint8_t s_rx_buf[2048]; + +/* + * 函数功能:轮询本 Socket 中断(读全局→读本 Socket,读即清) + * 入口参数:sock - Socket 号 uint8_t + * timeout - 超时 ms uint32_t + * out_bits - 输出最后一次读到的 SINT 位 uint8_t * + * 返回值:1=拿到中断 0=超时 int + */ +static int bench_wait_sock_int(uint8_t sock, uint32_t timeout, uint8_t *out_bits) { + uint32_t t0 = HAL_GetTick(); + while ((uint32_t)(HAL_GetTick() - t0) < timeout) { + uint16_t glob = ch395f_get_glob_int_status_all(); + if ((glob & (1U << (sock + 4U))) != 0U) { + uint8_t si = ch395f_get_sock_int_status(sock); + if (out_bits != 0) { + *out_bits = si; + } + return 1; + } + HAL_Delay(10); + } + return 0; +} + +/* 打印芯片两字节状态(Socket 态 / TCP 态) */ +static void bench_log_state(const char *tag, uint8_t sock) { + uint8_t st[2] = { 0, 0 }; + ch395f_get_socket_status(sock, st); + DBG_INFO("BENCH %s sock%u st=0x%02X/0x%02X t=%u", tag, (unsigned)sock, + st[0], st[1], (unsigned)HAL_GetTick()); +} + +/* 芯片级初始化(复刻 net_init 序列,剥离全部内核 API) */ +static int bench_chip_init(void) { + if (ch395f_check_exist() != CH395F_STATUS_OK) { + DBG_ERROR("BENCH chip not found"); + return -1; + } + ch395f_reset(); + /* 等 PHY link(Trap 09:link up 前 open 必失败) */ + for (int i = 0; i < 100; i++) { + if (ch395f_get_phy_status() != 0U) { + break; + } + HAL_Delay(100); + } + ch395f_set_fun_para(0x09); + ch395f_set_tcp_mss(CH395F_TCP_MSS); + ch395f_set_retrans_count(3); + ch395f_set_retrans_period(200); + ch395f_set_keepalive_idle(60000); + ch395f_set_keepalive_intvl(5000); + ch395f_set_keepalive_cnt(3); + /* 静态 IP(与生产一致) */ + { + uint8_t ip[4] = { 192, 168, 1, 100 }; + uint8_t gw[4] = { 192, 168, 1, 1 }; + uint8_t msk[4] = { 255, 255, 255, 0 }; + ch395f_set_ip_addr(ip); + ch395f_set_gwip_addr(gw); + ch395f_set_mask_addr(msk); + } + if (ch395f_init() != CH395F_STATUS_OK) { + DBG_ERROR("BENCH ch395f_init failed"); + return -1; + } + /* 8 Socket 缓冲布局(与生产一致) */ + for (int i = 7; i >= 0; i--) { + uint8_t recv_start = (uint8_t)(i * 6); + ch395f_set_send_buf((uint8_t)i, (uint8_t)(recv_start + 4), 2); + ch395f_set_recv_buf((uint8_t)i, recv_start, 4); + } + ch395f_ping_enable(1); + return 0; +} + +/* 单轮实验 */ +static void bench_round(unsigned idx) { + const bench_var_t *v = &s_vars[idx]; + uint8_t sock = v->sock; + uint16_t port = BENCH_PORT(idx); + uint8_t si = 0; + /* 幽灵等待期已见 CONNECT 的标志(跳过后续 CONNECT 等待) */ + int pre_connected = 0; + + DBG_INFO("BENCH === r%u sock%u port%u delay%u tx%u mode%u t=%u", + idx, (unsigned)sock, (unsigned)port, (unsigned)v->delay_ms, + (unsigned)v->tx_len, (unsigned)v->close_mode, (unsigned)HAL_GetTick()); + + /* 1. open + listen(Trap 09:close 后需延时重开,失败重试 ×3) */ + ch395f_set_proto_type(sock, CH395F_PROTO_TYPE_TCP); + ch395f_set_sour_port(sock, port); + uint8_t ost = CH395F_ERR_SUCCESS; + for (int open_try = 0; open_try < 3; open_try++) { + ost = ch395f_open_socket(sock); + if (ost == CH395F_ERR_SUCCESS) { + break; + } + DBG_INFO("BENCH open retry%d err=0x%02X t=%u", open_try, ost, (unsigned)HAL_GetTick()); + ch395f_close_socket(sock); + HAL_Delay(500); + } + if (ost != CH395F_ERR_SUCCESS) { + DBG_ERROR("BENCH open fail 0x%02X", ost); + return; + } + uint8_t lst = ch395f_tcp_listen(sock); + if (lst != CH395F_ERR_SUCCESS) { + DBG_ERROR("BENCH listen fail 0x%02X", lst); + ch395f_close_socket(sock); + return; + } + /* 缓冲重分配变体:listen 后重设收发缓冲(隔离"缓冲管理残留"假设) */ + if (v->verify == 2U) { + uint8_t recv_start = (uint8_t)(sock * 6); + ch395f_clear_recv_buf(sock); + ch395f_set_send_buf(sock, (uint8_t)(recv_start + 4), 2); + ch395f_set_recv_buf(sock, recv_start, 4); + DBG_INFO("BENCH realloc done sock%u t=%u", (unsigned)sock, (unsigned)HAL_GetTick()); + } + /* 清残留中断(与生产 listen 后一致) */ + for (int j = 0; j < 8; j++) { + (void)ch395f_get_sock_int_status((uint8_t)j); + } + (void)ch395f_get_glob_int_status_all(); + bench_log_state("post-listen", sock); + + /* 修复假设验证:listen 后 TCP 态必须真正 == 0x01(LISTEN); + * mode1:close 快速重试;mode3:耐心等幽灵消退(5s,每 100ms 复查), + * 期间幽灵→LISTEN 转变会补发 CONNECT 中断则直接放行 */ + if (v->verify == 1U || v->verify == 3U) { + uint32_t budget = (v->verify == 3U) ? 5000U : 1000U; + int att_max = (v->verify == 3U) ? 2 : 4; + for (int att = 0; att < att_max; att++) { + uint8_t st[2] = { 0, 0 }; + uint32_t tw = HAL_GetTick(); + int connect_fired = 0; + /* 等 TCP 进 LISTEN(幽灵消退)或 CONNECT 中断补发 */ + while ((uint32_t)(HAL_GetTick() - tw) < budget) { + uint16_t glob = ch395f_get_glob_int_status_all(); + if ((glob & (1U << (sock + 4U))) != 0U) { + uint8_t si = ch395f_get_sock_int_status(sock); + if ((si & CH395F_SINT_STAT_CONNECT) != 0U) { + connect_fired = 1; + DBG_INFO("BENCH verify CONNECT during ghost-wait att%d t=%u", + att, (unsigned)HAL_GetTick()); + break; + } + } + ch395f_get_socket_status(sock, st); + if (st[1] == 0x01U) { + break; + } + HAL_Delay(100); + } + DBG_INFO("BENCH verify att%d st=0x%02X/0x%02X cf=%d t=%u", + att, st[0], st[1], connect_fired, (unsigned)HAL_GetTick()); + if (connect_fired) { + /* 幽灵期主机已连上:跳过 CONNECT 等待直接进流程 */ + pre_connected = 1; + break; + } + if (st[1] == 0x01U) { + break; + } + /* 幽灵未消:close → 50ms → 重新 open+listen(mode3 只试一次) */ + ch395f_close_socket(sock); + HAL_Delay(50); + ch395f_open_socket(sock); + ch395f_tcp_listen(sock); + for (int j = 0; j < 8; j++) { + (void)ch395f_get_sock_int_status((uint8_t)j); + } + (void)ch395f_get_glob_int_status_all(); + } + } + + /* 2. 等 CONNECT(10s;幽灵等待期已见 CONNECT 则跳过) + * v6 关键探针:等待期每 500ms 读芯片状态;若已 ESTABLISHED(0x04) + * 说明握手完成但 CONNECT 中断丢失 → 补做 accept(对账②模拟) */ + int got = pre_connected; + uint32_t t0 = HAL_GetTick(); + while (!got && (uint32_t)(HAL_GetTick() - t0) < 10000U) { + if (bench_wait_sock_int(sock, 100, &si)) { + if ((si & CH395F_SINT_STAT_CONNECT) != 0U) { + got = 1; + break; + } + DBG_INFO("BENCH unexpected int=0x%02X while wait", si); + } + if (v->verify == 4U) { + uint8_t st[2] = { 0, 0 }; + ch395f_get_socket_status(sock, st); + if ((uint32_t)(HAL_GetTick() - t0) % 1000U < 110U) { + DBG_INFO("BENCH wait-state st=0x%02X/0x%02X t=%u", + st[0], st[1], (unsigned)HAL_GetTick()); + } + if (st[1] == 0x04U) { + DBG_INFO("BENCH LOST-CONNECT caught: state ESTABLISHED, self-accept t=%u", + (unsigned)HAL_GetTick()); + got = 1; + break; + } + } + } + if (!got) { + DBG_ERROR("BENCH no CONNECT within 10s (host not synced?)"); + ch395f_close_socket(sock); + HAL_Delay(v->delay_ms); + return; + } + bench_log_state("post-accept", sock); + /* 对端信息 + RX 残渣检查(旧连接残留的直接证据) */ + { + uint8_t rem[6] = { 0 }; + ch395f_get_remot_ipp(sock, rem); + uint16_t rl = ch395f_get_recv_len(sock); + DBG_INFO("BENCH remote port=%u recv_len=%u", + (unsigned)(rem[4] | (rem[5] << 8)), (unsigned)rl); + if (rl > 0U) { + uint16_t n = (rl > 2048U) ? 2048U : rl; + ch395f_read_recv_buf(sock, s_rx_buf, n); + DBG_INFO("BENCH drained %u residue bytes", (unsigned)n); + } + } + + /* 3. TX 探针(1KB 分块,Trap 20) */ + if (v->tx_len > 0U) { + uint16_t sent = 0; + while (sent < v->tx_len) { + uint16_t chunk = (uint16_t)(v->tx_len - sent); + if (chunk > 1024U) { + chunk = 1024U; + } + ch395f_write_send_buf(sock, &s_tx_pat[sent], chunk); + sent += chunk; + /* 等 SENBUF_FREE(500ms) */ + if (sent < v->tx_len) { + uint32_t tw = HAL_GetTick(); + while ((uint32_t)(HAL_GetTick() - tw) < 500U) { + if (bench_wait_sock_int(sock, 50, &si)) { + if ((si & CH395F_SINT_STAT_SENBUF_FREE) != 0U) { + break; + } + } + } + } + } + DBG_INFO("BENCH tx %u written t=%u", (unsigned)v->tx_len, (unsigned)HAL_GetTick()); + /* 连接存活观察窗:500ms 内每 50ms 记状态+中断(抓"秒杀") */ + for (int k = 0; k < 10; k++) { + uint16_t glob = ch395f_get_glob_int_status_all(); + if ((glob & (1U << (sock + 4U))) != 0U) { + si = ch395f_get_sock_int_status(sock); + DBG_INFO("BENCH int during window=0x%02X t=%u", si, (unsigned)HAL_GetTick()); + } + HAL_Delay(50); + bench_log_state("window", sock); + } + } + + /* 4. 关闭次序 */ + if (v->close_mode == 1U) { + /* 芯片先关 */ + HAL_Delay(300); + ch395f_close_socket(sock); + DBG_INFO("BENCH chip closed first t=%u", (unsigned)HAL_GetTick()); + } else { + /* 等主机关(DISCONNECT 事件,5s) */ + uint32_t tw = HAL_GetTick(); + int disc = 0; + while ((uint32_t)(HAL_GetTick() - tw) < 5000U) { + if (bench_wait_sock_int(sock, 100, &si)) { + if ((si & (CH395F_SINT_STAT_DISCONNECT | CH395F_SINT_STAT_SOCK_TIMEOUT)) != 0U) { + disc = 1; + break; + } + } + } + DBG_INFO("BENCH host-close seen=%d t=%u", disc, (unsigned)HAL_GetTick()); + ch395f_close_socket(sock); + } + + /* 5. 关闭后拆链观察:1s 内每 50ms 记状态 + 中断(Trap 14 滞后中断取证) */ + for (int k = 0; k < 20; k++) { + uint16_t glob = ch395f_get_glob_int_status_all(); + uint8_t late = 0; + if ((glob & (1U << (sock + 4U))) != 0U) { + late = ch395f_get_sock_int_status(sock); + } + if (late != 0U) { + DBG_INFO("BENCH late-int=0x%02X t=%u", late, (unsigned)HAL_GetTick()); + } + if ((k % 4) == 0) { + bench_log_state("post-close", sock); + } + HAL_Delay(50); + } + + /* 6. 受控延迟后进入下一轮 */ + HAL_Delay(v->delay_ms); +} + +void ch395f_reuse_bench(void) { + DBG_INFO("BENCH ===== N30 chip-level reuse bench START ====="); + if (bench_chip_init() != 0) { + DBG_ERROR("BENCH init failed, giving up (normal boot continues)"); + return; + } + DBG_INFO("BENCH chip ready, cycling %u variants forever", (unsigned)BENCH_N_VARS); + for (unsigned i = 0; i < 2048U; i++) { + s_tx_pat[i] = (uint8_t)(0xA5U + (i % 251U)); + } + unsigned idx = 0; + for (;;) { + bench_round(idx); + idx = (idx + 1U) % (unsigned)BENCH_N_VARS; + } +} + +#endif /* N30_CHIP_BENCH */ diff --git a/test/board/ch395f_reuse_bench.h b/test/board/ch395f_reuse_bench.h new file mode 100644 index 00000000..09694d65 --- /dev/null +++ b/test/board/ch395f_reuse_bench.h @@ -0,0 +1,13 @@ +/* + * ch395f_reuse_bench.h - N-30 芯片级 Socket 复用时序台架(裸机实验,临时分支专用) + */ + +#ifndef CH395F_REUSE_BENCH_H +#define CH395F_REUSE_BENCH_H + +/* 实验总开关:1 = 内核前进入裸机台架(OS 永不启动,断电退出) */ +#define N30_CHIP_BENCH 1 + +void ch395f_reuse_bench(void); + +#endif /* CH395F_REUSE_BENCH_H */ diff --git a/test/host/ch395f_reuse_host.py b/test/host/ch395f_reuse_host.py new file mode 100644 index 00000000..5dae2f31 --- /dev/null +++ b/test/host/ch395f_reuse_host.py @@ -0,0 +1,92 @@ +# N-30 芯片级复用时序台架:主机端(与 test/board/ch395f_reuse_bench.c 共用变体表) +# 用法: python ch395f_reuse_host.py [--host IP] [--cycles N] +# 纯 TCP,无 FTP。按 idx 轮转连接 5000+idx 端口,记录收发结果与时刻。 +import argparse +import socket +import sys +import time + +# {idx: (tx_len, close_mode)} 与固件表一致 +VARS = { + 0: (100, 0), # 0ms control + 1: (100, 0), # 300ms control + 2: (2000, 0), # 2KB control + 3: (100, 0), # 0ms + ghost-wait fix + 4: (100, 0), # 300ms + ghost-wait fix + 5: (2000, 0), # 2KB + ghost-wait fix +} + + +def ts(): + return time.strftime("%H:%M:%S") + ".%03d" % (int(time.time() * 1000) % 1000) + + +def run_round(host, idx, tx_len, close_mode): + port = 5000 + idx + # 等端口监听(芯片还没 open 时连接被拒,重试) + s = None + t0 = time.time() + while time.time() - t0 < 12.0: + try: + s = socket.create_connection((host, port), timeout=5) + break + except OSError: + time.sleep(0.2) + if s is None: + print("%s r%u CONNECT timeout" % (ts(), idx)) + return False + + ok = True + try: + s.settimeout(5) + got = 0 + while got < tx_len: + chunk = s.recv(min(1024, tx_len - got)) + if not chunk: + break + got += len(chunk) + note = "" + if got != tx_len: + note = " <<<< SWALLOWED (want %d)" % tx_len + ok = False + if close_mode == 1: + # 等芯片 FIN(mode1 芯片先关) + try: + fin = s.recv(1) + if fin: + note += " unexpected data after tx: %r" % fin + except socket.timeout: + note += " <<<< no FIN from chip (mode1)" + ok = False + print("%s r%u connected got=%d/%d%s" % (ts(), idx, got, tx_len, note)) + except socket.timeout: + print("%s r%u recv timeout <<<< STALL" % (ts(), idx)) + ok = False + except OSError as e: + print("%s r%u sock error %r" % (ts(), idx, e)) + ok = False + finally: + s.close() # 主机关闭(mode0/2 主机先关) + return ok + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="192.168.1.100") + ap.add_argument("--cycles", type=int, default=3) + args = ap.parse_args() + + fails = [] + for c in range(args.cycles): + for idx in sorted(VARS): + tx_len, mode = VARS[idx] + print("%s === cycle%u r%u (tx=%d mode=%d) ===" % (ts(), c, idx, tx_len, mode)) + if not run_round(args.host, idx, tx_len, mode): + fails.append((c, idx)) + time.sleep(0.3) + print("RESULT: %d failures %s" % (len(fails), fails)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/ftp_auth/ftp_auth_test.c b/test/host/ftp_auth/ftp_auth_test.c new file mode 100644 index 00000000..4938e2ac --- /dev/null +++ b/test/host/ftp_auth/ftp_auth_test.c @@ -0,0 +1,203 @@ +/* + * 模块名称:FTP 鉴权状态机(lftpd_auth)主机侧单元测试 + * 模块功能:在 PC 上对 Drivers/BSP/NET/lftpd/lftpd_auth.c 做离线验证 + * (用户名匹配 / 口令校验 / 失败计数 / 限时锁定 / tick 回绕)。 + * + * 被测对象:真实源文件 lftpd_auth.c + 其依赖 sha256.c(均以 #include 原样 + * 纳入,保证测的是生产代码而非副本;两模块零平台依赖,无需桩—— + * tick 由参数注入正是本模块的纯函数设计意图)。 + * + * 为什么要主机侧单测(而不是只用台架): + * 锁定 60 s / 解锁 / 成功清零 / tick 32 位回绕是"防口令爆破"契约的核心, + * 板端每验一条要真等 60 s 且回绕要跑满 49.7 天;主机侧注入合成 tick + * 毫秒级穷举。用户名侧信道路径(错用户名也走哈希)同属纯逻辑。 + * + * 覆盖点: + * TC-FA-001 user_match:相等 / 大小写不敏感 / 不等 / NULL + * TC-FA-002 正确用户名+正确口令 → OK,计数 0 + * TC-FA-003 错口令 → FAIL,计数 1 + * TC-FA-004 错用户名+对口令 → FAIL(哈希路径已走,时间拉平) + * TC-FA-005 连续 5 次失败 → 第 6 次(锁定期内)LOCKED,对口令也拒 + * TC-FA-006 锁定期满(+60000 tick)→ 对口令 OK,计数清零 + * TC-FA-007 成功清零计数:3 败 + 1 成 → 再 4 败不触发锁定(第 5 败才锁) + * TC-FA-008 tick 32 位回绕:锁定期内回绕点仍 LOCKED,期满回绕点放行 + * TC-FA-009 配置 pwd_hash 非法 hex → 恒 FAIL(fail-closed) + * TC-FA-010 空口令 / NULL 口令 → FAIL;NULL creds/state → FAIL + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-11 + */ + +#include +#include + +/* 生产源文件原样纳入(顺序敏感:先 sha256 后 lftpd_auth) */ +#include "sha256.c" +#include "lftpd_auth.c" + +/* ===================== 断言框架 ===================== */ + +static int s_total; +static int s_passed; +static int s_failed; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" PASS %s\n", name); } \ + else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* 与 ftp_cfg.h FTP_CFG_DEF_HASH 同源钉值(SHA256("adminadmin")) */ +#define DEF_HASH "d82494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892" + +static const ftp_auth_creds_t s_creds = { "admin", DEF_HASH, 1u }; + +/* ===================== TC-FA-001:user_match ===================== */ + +static void tc_user_match(void) +{ + CHECK(ftp_auth_user_match("admin", "admin") == 1, "TC-FA-001a 相等匹配"); + CHECK(ftp_auth_user_match("admin", "ADMIN") == 1, "TC-FA-001b 大小写不敏感"); + CHECK(ftp_auth_user_match("admin", "root") == 0, "TC-FA-001c 不等拒绝"); + CHECK(ftp_auth_user_match("admin", "admin2") == 0, "TC-FA-001d 前缀不算匹配"); + CHECK(ftp_auth_user_match("admin", NULL) == 0, "TC-FA-001e NULL 拒绝"); +} + +/* ===================== TC-FA-002..004:基本判定 ===================== */ + +static void tc_basic(void) +{ + ftp_auth_state_t st; + + (void)memset(&st, 0, sizeof(st)); + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, 1000u) == FTP_AUTH_OK, + "TC-FA-002 对用户名+对口令 → OK"); + CHECK(st.fail_count == 0u, "TC-FA-002b 成功后计数为 0"); + + (void)memset(&st, 0, sizeof(st)); + CHECK(ftp_auth_check(&s_creds, 1u, "wrong", &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-003 错口令 → FAIL"); + CHECK(st.fail_count == 1u, "TC-FA-003b 失败计数 1"); + + (void)memset(&st, 0, sizeof(st)); + CHECK(ftp_auth_check(&s_creds, 0u, "admin", &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-004 错用户名+对口令 → FAIL(哈希路径已走)"); +} + +/* ===================== TC-FA-005..006:锁定与解锁 ===================== */ + +static void tc_lock(void) +{ + ftp_auth_state_t st; + uint32_t t = 10000u; + + (void)memset(&st, 0, sizeof(st)); + for (int i = 0; i < 5; i++) { + CHECK(ftp_auth_check(&s_creds, 1u, "wrong", &st, t) == FTP_AUTH_FAIL, + "TC-FA-005 第 1..5 次失败 → FAIL"); + } + CHECK(st.fail_count == 5u, "TC-FA-005b 计数到达 FAIL_MAX"); + /* 锁定期内:对口令也 LOCKED,且不再计数 */ + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, t + 1u) == FTP_AUTH_LOCKED, + "TC-FA-005c 锁定期内对口令 → LOCKED"); + CHECK(st.fail_count == 5u, "TC-FA-005d LOCKED 不改变计数"); + /* 59999 tick:仍在窗内(差值 < 60000) */ + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, + st.lock_until_tick - 1u) == FTP_AUTH_LOCKED, + "TC-FA-005e 窗内最后 1 tick 仍 LOCKED"); + /* 期满:恰好到达 lock_until → 放行 */ + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, + st.lock_until_tick) == FTP_AUTH_OK, + "TC-FA-006 期满对口令 → OK"); + CHECK(st.fail_count == 0u, "TC-FA-006b 解锁成功后计数清零"); +} + +/* ===================== TC-FA-007:成功清零计数 ===================== */ + +static void tc_reset(void) +{ + ftp_auth_state_t st; + uint32_t t = 5000u; + + (void)memset(&st, 0, sizeof(st)); + for (int i = 0; i < 3; i++) { + (void)ftp_auth_check(&s_creds, 1u, "wrong", &st, t); + } + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, t) == FTP_AUTH_OK, + "TC-FA-007a 3 败后成功 → OK"); + CHECK(st.fail_count == 0u, "TC-FA-007b 成功清零"); + for (int i = 0; i < 4; i++) { + CHECK(ftp_auth_check(&s_creds, 1u, "wrong", &st, t) == FTP_AUTH_FAIL, + "TC-FA-007c 清零后再 4 败仍 FAIL(未锁)"); + } + CHECK(ftp_auth_check(&s_creds, 1u, "wrong", &st, t) == FTP_AUTH_FAIL, + "TC-FA-007d 第 5 败 FAIL 并上锁"); + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, t + 1u) == FTP_AUTH_LOCKED, + "TC-FA-007e 连续 5 败后 LOCKED"); +} + +/* ===================== TC-FA-008:tick 32 位回绕 ===================== */ + +static void tc_wraparound(void) +{ + ftp_auth_state_t st; + + /* 手工构造:锁定截止在 0xFFFFFFF0(距 32 位翻转仅 16 tick) */ + (void)memset(&st, 0, sizeof(st)); + st.fail_count = 5u; + st.lock_until_tick = 0xFFFFFFF0u; + /* 回绕前 240 tick:仍在窗内 */ + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, 0xFFFFFF00u) == FTP_AUTH_LOCKED, + "TC-FA-008a 回绕前窗内 → LOCKED"); + /* 回绕后 32 tick:now=0x10,(int32_t)(0x10-0xFFFFFFF0)=+32 → 期满 */ + CHECK(ftp_auth_check(&s_creds, 1u, "admin", &st, 0x00000010u) == FTP_AUTH_OK, + "TC-FA-008b 回绕后期满 → OK(有符号差抗回绕)"); +} + +/* ===================== TC-FA-009..010:fail-closed 边界 ===================== */ + +static void tc_fail_closed(void) +{ + ftp_auth_state_t st; + ftp_auth_creds_t bad_creds = { "admin", "zz2494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892", 1u }; + + (void)memset(&st, 0, sizeof(st)); + CHECK(ftp_auth_check(&bad_creds, 1u, "admin", &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-009 非法 hex 配置 → 恒 FAIL(无人能登录,fail-closed)"); + + (void)memset(&st, 0, sizeof(st)); + CHECK(ftp_auth_check(&s_creds, 1u, "", &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-010a 空口令 → FAIL"); + CHECK(ftp_auth_check(&s_creds, 1u, NULL, &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-010b NULL 口令 → FAIL"); + CHECK(ftp_auth_check(NULL, 1u, "admin", &st, 1000u) == FTP_AUTH_FAIL, + "TC-FA-010c NULL creds → FAIL"); + CHECK(ftp_auth_check(&s_creds, 1u, "admin", NULL, 1000u) == FTP_AUTH_FAIL, + "TC-FA-010d NULL state → FAIL"); +} + +/* ===================== main ===================== */ + +int main(void) +{ + printf("=== FTP Auth Tests ===\n"); + + printf("-- user match --\n"); + tc_user_match(); + printf("-- basic check --\n"); + tc_basic(); + printf("-- lock / unlock --\n"); + tc_lock(); + printf("-- success reset --\n"); + tc_reset(); + printf("-- tick wraparound --\n"); + tc_wraparound(); + printf("-- fail-closed --\n"); + tc_fail_closed(); + + printf("\n=== FTP Auth Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/ftp_auth/run_unit_test.py b/test/host/ftp_auth/run_unit_test.py new file mode 100644 index 00000000..7b3c703f --- /dev/null +++ b/test/host/ftp_auth/run_unit_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 鉴权状态机(lftpd_auth)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/ftp_auth/ftp_auth_test.c(它以 #include 纳入 + **生产源文件** Drivers/BSP/NET/lftpd/lftpd_auth.c 与 App/util/sha256.c), + 在 PC 上离线跑口令校验/失败计数/限时锁定/tick 回绕,失败即非 0 退出。 + 零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用台架): + 锁定 60s / 解锁 / 成功清零 / tick 32 位回绕是"防口令爆破"契约核心, + 板端每验一条要真等 60s、回绕要跑满 49.7 天;lftpd_auth 的纯函数设计 + (tick 参数注入)正是为了让这些分支在主机侧毫秒级穷举。 + 两被测模块零平台依赖,无需任何桩。 + +用法(仓库根目录): + python test/host/ftp_auth/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/ftp_auth/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:Drivers/BSP/NET/lftpd/lftpd_auth.h;创建日期:2026-10-11 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="FTP Auth Tests", + src=os.path.join(HERE, "ftp_auth_test.c"), + include_dirs=[ + os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"), + os.path.join(ROOT, "App", "util"), + HERE, + ], + summary_marker="=== FTP Auth Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/ftp_auth_bench.py b/test/host/ftp_auth_bench.py new file mode 100644 index 00000000..0ae78557 --- /dev/null +++ b/test/host/ftp_auth_bench.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 鉴权台架回归(N-19,需真机在线) + +验证固件侧 lftpd 鉴权集成的完整行为矩阵(主机单测 test/host/ftp_auth 覆盖 +不到的"协议接线"部分:命令门、530/331/230/550 响应码、跨会话锁定): + + T1 匿名登录(anonymous) → 期望 530 拒 + T2 未登录直接 NLST → 期望 530(命令门) + T3 对用户名 + 错口令 ×5 → 期望 530 ×5(并触发锁定) + T4 锁定窗内正确口令 → 期望 530(LOCKED,不算哈希) + T5 等待锁定期满后正确口令 → 期望 230(60s + 余量) + T6 登录后 NLST / STOR / RETR / DELE → 期望全通(出厂 write=1) + T7 会话结束重连、错口令一次 → 期望 530 且计数已清零(T5 成功清零) + +注意:CH395F 单连接——每步用完必须 quit() 释放 socket,否则下一步连不上; + 跑之前确保 UI 远程显示(7003)/对时(8888)客户端未占用 socket。 + +用法(设备默认 192.168.1.100,出厂口令 admin/admin): + & '.venv\\Scripts\\python.exe' test/host/ftp_auth_bench.py + 改过口令: ... --pwd <新口令> + 跳过 60s 锁定等待(只验锁定触发不验解锁): ... --skip-unlock + +退出码:0=全部通过 / 1=任一失败 / 2=环境错误(连不上设备) +创建日期:2026-10-11 +""" + +import argparse +import io +import socket +import sys +import time +from ftplib import FTP, error_perm + +LOCK_WAIT_S = 62 # 固件锁定 60 s + 余量 + + +def _try_login(host, port, timeout, user, pwd): + """尝试一次登录;返回 (ok, code_or_exc)。失败不断言连接状态。 + + 单连接 socket:上一次会话结束后 net 层 auto_relisten ~50 ms,但 accept + 失败会走"毁监听→重建"路径(3 s 重试窗)→ connect 被拒时重试 3 次。 + """ + f = FTP() + last_err = None + for attempt in range(3): + try: + time.sleep(0.5) + f.connect(host, port, timeout=timeout) + break + except (ConnectionRefusedError, socket.timeout, OSError) as e: + last_err = e + f = FTP() + else: + return None, repr(last_err), None + try: + try: + f.login(user, pwd) + return True, "230", f + except error_perm as e: + f.close() + return False, str(e), None + except (socket.timeout, OSError) as e: + return None, repr(e), None + + +def main(argv=None): + ap = argparse.ArgumentParser(description="FTP auth bench matrix (N-19)") + ap.add_argument("--host", default="192.168.1.100") + ap.add_argument("--port", type=int, default=21) + ap.add_argument("--timeout", type=int, default=15) + ap.add_argument("--user", default="admin") + ap.add_argument("--pwd", default="admin") + ap.add_argument("--skip-unlock", action="store_true", + help="不验 60s 解锁(省 1 分钟;锁定触发仍验)") + args = ap.parse_args(argv) + + fails = [] + + def expect(name, cond, detail=""): + tag = "PASS" if cond else "FAIL" + print(" [%s] %s %s" % (tag, name, detail)) + if not cond: + fails.append(name) + + # ---- T1 匿名登录被拒 ---- + ok, code, conn = _try_login(args.host, args.port, args.timeout, "anonymous", "x@x") + if ok is None: + print("[env] 连不上 %s:%d → %s" % (args.host, args.port, code)) + return 2 + expect("T1 匿名登录 → 530", (ok is False) and code.startswith("530"), code) + if conn: + conn.quit() + + # ---- T2 未登录直接 NLST → 530(命令门)---- + time.sleep(0.5) # 单连接 socket:等 auto_relisten 沉降(~50 ms + 余量) + f = FTP() + f.connect(args.host, args.port, timeout=args.timeout) + try: + f.sendcmd("NLST") + expect("T2 未登录 NLST → 530", False, "居然放行") + except error_perm as e: + expect("T2 未登录 NLST → 530", str(e).startswith("530"), str(e)) + except Exception as e: # noqa: BLE001 + expect("T2 未登录 NLST → 530", False, repr(e)) + # ⚠ 必须 close:单连接模型下不关 = 占住 socket,后续步骤全被拒 + # (2026-10-11 台架实测:QUIT 后不 close → 芯片视角旧连接仍 ESTABLISHED) + try: + f.quit() + except Exception: # noqa: BLE001 + try: + f.close() + except Exception: # noqa: BLE001 + pass + time.sleep(0.5) + + # ---- T3 错口令 ×5 → 530 ×5 ---- + for i in range(1, 6): + ok, code, conn = _try_login(args.host, args.port, args.timeout, + args.user, "wrong-pass-%d" % i) + expect("T3.%d 错口令 → 530" % i, (ok is False) and code.startswith("530"), code) + if conn: + conn.quit() + + # ---- T4 锁定窗内正确口令 → 530 ---- + ok, code, conn = _try_login(args.host, args.port, args.timeout, args.user, args.pwd) + expect("T4 锁定窗内对口令 → 530", (ok is False) and code.startswith("530"), code) + if conn: + conn.quit() + + if args.skip_unlock: + print("[skip] 未验 60s 解锁(--skip-unlock)") + else: + # ---- T5 期满正确口令 → 230 ---- + print(" [wait] 等锁定期满 %d s ..." % LOCK_WAIT_S) + time.sleep(LOCK_WAIT_S) + ok, code, conn = _try_login(args.host, args.port, args.timeout, + args.user, args.pwd) + expect("T5 期满对口令 → 230", ok is True, code) + if conn: + # ---- T6 登录后读写全通(出厂 write=1)---- + try: + names = conn.nlst() + expect("T6a NLST", len(names) >= 0, "%d 项" % len(names)) + payload = b"N19-auth-bench-" + b"X" * 500 + conn.storbinary("STOR n19t.tmp", io.BytesIO(payload)) + got = io.BytesIO() + conn.retrbinary("RETR n19t.tmp", got.write) + expect("T6b STOR/RETR 往返一致", got.getvalue() == payload) + conn.delete("n19t.tmp") + expect("T6c DELE", True) + except Exception as e: # noqa: BLE001 + expect("T6 登录后读写", False, repr(e)) + # ---- T7 成功已清零计数:本会话退出后错一次只算 1(不锁)---- + conn.quit() + ok, code, conn = _try_login(args.host, args.port, args.timeout, + args.user, "one-wrong") + expect("T7 成功后错 1 次 → 530 且未锁(下次对口令可登)", + (ok is False) and code.startswith("530"), code) + if conn: + conn.quit() + ok, code, conn = _try_login(args.host, args.port, args.timeout, + args.user, args.pwd) + expect("T7b 紧接对口令 → 230", ok is True, code) + if conn: + conn.quit() + + print("\nVERDICT: %s (%d fail)" % ("PASS" if not fails else "FAIL", len(fails))) + return 0 if not fails else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/ftp_regression.py b/test/host/ftp_regression.py index 84542c4a..4dd4657a 100644 --- a/test/host/ftp_regression.py +++ b/test/host/ftp_regression.py @@ -30,6 +30,10 @@ def main(argv=None): ap.add_argument("--host", default="192.168.1.100", help="device IP") ap.add_argument("--port", type=int, default=21, help="FTP control port") ap.add_argument("--timeout", type=int, default=20, help="socket timeout (s)") + # N-19(2026-10-11):FTP 已强制鉴权,匿名登录被 530 拒;默认口令 admin/admin + # (出厂值,见 ftp_cfg.h),改过 [ftp] pwd_hash 后用这两个参数覆盖 + ap.add_argument("--user", default="admin", help="FTP user ([ftp] user)") + ap.add_argument("--pwd", default="admin", help="FTP password") args = ap.parse_args(argv) results = [] @@ -52,7 +56,7 @@ def main(argv=None): f = FTP() print("[ftp] connecting %s:%d ..." % (args.host, args.port)) f.connect(args.host, args.port, timeout=args.timeout) - f.login("anonymous", "x@x") + f.login(args.user, args.pwd) print("[ftp] login ok: %s" % f.getwelcome()) # 同一会话内连续 8 次数据连接(含两次大文件)—— D20 的失败点是第 2 次 diff --git a/test/host/n30_repro.py b/test/host/n30_repro.py new file mode 100644 index 00000000..a82db06e --- /dev/null +++ b/test/host/n30_repro.py @@ -0,0 +1,74 @@ +# N-30 确定性复现:连续快速 STOR(无间隔),统计 425/超时失败次数 +# 用法: python n30_repro.py [--host IP] [--user U --pwd P] [--n 10] [--size 1024] +# 退出码: 0=全部成功 / 1=有失败 / 2=连接错误 +import argparse +import io +import sys +from ftplib import FTP, error_temp, error_perm + + +def main(): + 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=None, help="None=anonymous") + ap.add_argument("--pwd", default=None) + ap.add_argument("--n", type=int, default=10) + ap.add_argument("--size", type=int, default=1024) + args = ap.parse_args() + + data = b"X" * args.size + fails = [] + try: + f = FTP() + f.connect(args.host, args.port, timeout=15) + if args.user is None: + f.login("anonymous", "x@x") + else: + f.login(args.user, args.pwd) + f.voidcmd("TYPE I") + for i in range(args.n): + name = "n30_%02d.tmp" % i + try: + with io.BytesIO(data) as b: + f.storbinary("STOR " + name, b) + print("STOR #%02d OK" % (i + 1)) + except (error_temp, error_perm, OSError, EOFError) as e: + print("STOR #%02d FAIL %r" % (i + 1, e)) + fails.append(i + 1) + # 会话可能已重启:尝试重登继续计数 + try: + f.quit() + except Exception: + pass + try: + f = FTP() + f.connect(args.host, args.port, timeout=15) + if args.user is None: + f.login("anonymous", "x@x") + else: + f.login(args.user, args.pwd) + f.voidcmd("TYPE I") + except Exception as e2: + print("re-login failed: %r" % e2) + break + # 清理 + try: + for i in range(args.n): + try: + f.delete("n30_%02d.tmp" % i) + except Exception: + pass + f.quit() + except Exception: + pass + except Exception as e: + print("connect error: %r" % e) + return 2 + + print("RESULT: %d/%d failed %s" % (len(fails), args.n, fails)) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/sha256/run_unit_test.py b/test/host/sha256/run_unit_test.py new file mode 100644 index 00000000..1ccb432b --- /dev/null +++ b/test/host/sha256/run_unit_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""SHA-256(sha256)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/sha256/sha256_test.c(它以 #include 纳入 + **生产源文件** App/util/sha256.c),在 PC 上离线跑 FIPS 180-4 标准向量、 + 流式等价、hex_eq 边界,失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端): + sha256 是 FTP pwd_hash 校验(N-19)的地基——哈希算错是"正确口令被拒 / + 错误口令放行"的静默语义错误,板端只见 530/230 看不出根因;标准向量 + (空串/abc/56B 补位跨界/1MB 长度进第二分组)主机侧一次钉死。 + 本模块零平台依赖,无需任何桩。 + +用法(仓库根目录): + python test/host/sha256/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/sha256/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:App/util/sha256.h;创建日期:2026-10-11 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="SHA-256 Tests", + src=os.path.join(HERE, "sha256_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "util"), + HERE, + ], + summary_marker="=== SHA-256 Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/sha256/sha256_test.c b/test/host/sha256/sha256_test.c new file mode 100644 index 00000000..160d91c0 --- /dev/null +++ b/test/host/sha256/sha256_test.c @@ -0,0 +1,192 @@ +/* + * 模块名称:SHA-256 主机侧单元测试 + * 模块功能:在 PC 上对 App/util/sha256.c 做离线验证(FIPS 180-4 标准向量 + + * 流式等价 + sha256_hex_eq 边界)。 + * + * 被测对象:真实源文件 ../../../App/util/sha256.c(以 #include 原样纳入, + * 保证测的是生产代码而非副本;本模块零平台依赖,无需任何桩)。 + * + * 为什么要主机侧单测(而不是只靠台架): + * sha256 是 FTP pwd_hash 校验(N-19)的地基——哈希算错的后果是 + * "正确口令被拒 / 错误口令放行"这类静默语义错误,板端只看到 530/230, + * 看不出哪一步坏;标准向量在主机侧毫秒级钉死。 + * + * 覆盖点: + * TC-SH-001 FIPS 180-4 向量:空串 + * TC-SH-002 FIPS 180-4 向量:"abc" + * TC-SH-003 FIPS 180-4 向量:56 字节(补位跨分组边界) + * TC-SH-004 FIPS 180-4 向量:1,000,000 × 'a'(流式,长度字段进第二分组) + * TC-SH-005 流式任意切分与一次性计算等价(逐字节喂入) + * TC-SH-006 hex_eq:小写匹配 / 大写匹配 + * TC-SH-007 hex_eq:内容不等 / 非法字符 / 长度 63/65 / NULL → 全 0 + * TC-SH-008 默认口令哈希钉值:SHA256("adminadmin") = FTP_CFG_DEF_HASH 同源值 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-11 + */ + +#include +#include + +/* 生产源文件原样纳入(零平台依赖,直接编) */ +#include "sha256.c" + +/* ===================== 断言框架 ===================== */ + +static int s_total; +static int s_passed; +static int s_failed; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" PASS %s\n", name); } \ + else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* 摘要 → hex 小写串(仅测试用,允许栈上 sprintf) */ +static void digest_to_hex(const uint8_t d[SHA256_DIGEST_SIZE], char out[65]) +{ + static const char hexd[] = "0123456789abcdef"; + + for (int i = 0; i < 32; i++) { + out[2 * i] = hexd[(d[i] >> 4) & 0xF]; + out[2 * i + 1] = hexd[d[i] & 0xF]; + } + out[64] = '\0'; +} + +static int hex_eq_vector(const uint8_t d[SHA256_DIGEST_SIZE], const char *want) +{ + char got[65]; + + digest_to_hex(d, got); + return strcmp(got, want) == 0; +} + +/* ===================== TC-SH-001..004:标准向量 ===================== */ + +static void tc_vectors(void) +{ + uint8_t d[SHA256_DIGEST_SIZE]; + + sha256_calc("", 0u, d); + CHECK(hex_eq_vector(d, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + "TC-SH-001 空串向量"); + + sha256_calc("abc", 3u, d); + CHECK(hex_eq_vector(d, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + "TC-SH-002 \"abc\" 向量"); + + sha256_calc("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", 56u, d); + CHECK(hex_eq_vector(d, "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"), + "TC-SH-003 56 字节向量(补位跨分组)"); + + { + sha256_ctx_t ctx; + + sha256_init(&ctx); + for (int i = 0; i < 1000000; i++) { + sha256_update(&ctx, "a", 1u); + } + sha256_final(&ctx, d); + } + CHECK(hex_eq_vector(d, "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"), + "TC-SH-004 1e6 × 'a' 向量(流式)"); +} + +/* ===================== TC-SH-005:流式等价 ===================== */ + +static void tc_stream_equiv(void) +{ + static const char msg[] = "the quick brown fox jumps over the lazy dog"; + uint8_t d1[SHA256_DIGEST_SIZE]; + uint8_t d2[SHA256_DIGEST_SIZE]; + sha256_ctx_t ctx; + + sha256_calc(msg, strlen(msg), d1); + sha256_init(&ctx); + for (size_t i = 0; i < strlen(msg); i++) { + sha256_update(&ctx, &msg[i], 1u); + } + sha256_final(&ctx, d2); + CHECK(memcmp(d1, d2, sizeof(d1)) == 0, "TC-SH-005 逐字节喂入与一次性等价"); +} + +/* ===================== TC-SH-006..007:hex_eq ===================== */ + +static void tc_hex_eq(void) +{ + uint8_t d[SHA256_DIGEST_SIZE]; + char lower[65]; + char upper[65]; + char shortv[65]; + char longv[66]; + + sha256_calc("adminadmin", 10u, d); + digest_to_hex(d, lower); + for (int i = 0; i < 64; i++) { + upper[i] = (char)((lower[i] >= 'a') ? (lower[i] - 32) : lower[i]); + } + upper[64] = '\0'; + + CHECK(sha256_hex_eq(d, lower) == 1, "TC-SH-006a 小写 hex 匹配"); + CHECK(sha256_hex_eq(d, upper) == 1, "TC-SH-006b 大写 hex 匹配(大小写不敏感)"); + + lower[63] = (char)((lower[63] == '0') ? '1' : '0'); + CHECK(sha256_hex_eq(d, lower) == 0, "TC-SH-007a 内容不等 → 0"); + lower[63] = (char)((lower[63] == '0') ? '1' : '0'); /* 还原 */ + + { + char bad[65]; + + memcpy(bad, lower, 64); + bad[64] = '\0'; + bad[10] = 'g'; + CHECK(sha256_hex_eq(d, bad) == 0, "TC-SH-007b 非法 hex 字符 → 0"); + } + + memcpy(shortv, lower, 63); + shortv[63] = '\0'; + CHECK(sha256_hex_eq(d, shortv) == 0, "TC-SH-007c 长度 63 → 0"); + + memcpy(longv, lower, 64); + longv[64] = 'a'; + longv[65] = '\0'; + CHECK(sha256_hex_eq(d, longv) == 0, "TC-SH-007d 长度 65 → 0"); + + CHECK(sha256_hex_eq(d, NULL) == 0, "TC-SH-007e NULL → 0"); +} + +/* ===================== TC-SH-008:默认口令哈希钉值 ===================== */ + +static void tc_default_hash(void) +{ + uint8_t d[SHA256_DIGEST_SIZE]; + + /* 与 ftp_cfg.h FTP_CFG_DEF_HASH 同源钉值(两处若漂移本测试立刻红) */ + sha256_calc("adminadmin", 10u, d); + CHECK(sha256_hex_eq(d, "d82494f05d6917ba02f7aaa29689ccb444bb73f20380876cb05d1f37537b7892") == 1, + "TC-SH-008 SHA256(\"adminadmin\") = 出厂默认 pwd_hash"); +} + +/* ===================== main ===================== */ + +int main(void) +{ + printf("=== SHA-256 Tests ===\n"); + + printf("-- FIPS 180-4 vectors --\n"); + tc_vectors(); + printf("-- streaming equivalence --\n"); + tc_stream_equiv(); + printf("-- hex_eq --\n"); + tc_hex_eq(); + printf("-- default password hash --\n"); + tc_default_hash(); + + printf("\n=== SHA-256 Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +}