273 Commits

Author SHA1 Message Date
wandering1 529c58da39 docs(CI): 修 run 46 暴露的 doc-links 违规,并更正「CI 工作区跨 run 复用」错话
CI / L0 静态门禁 (pre-commit) (push) Successful in 5m8s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 2m15s
run 46(你推的 dc5d6a0):L0.5 = Success,40 runner、3188/3188 断言、fail=0;
L0 = 47 Passed / 1 Failed / 2 Skipped —— A 方案确实生效:`ui-golden` 在 CI 里第一次
真编模拟器、真比像素,菜单 8 状态 + 内容页 40 页全过转绿(SYSTEM 命中机器级
`DTU_SDL2_DIR`)。你补跑的 -SetEnv 顺带把 `DTU_MS_COVERAGE` 也钉上了,两个变量成对。

剩下那 1 根红是我自己造的:新加的症状行里写了 gitignored 的构建产物路径,本机
存在、干净检出不存在,于是 `doc-links`「本地过、CI 挂」——正是本文 §4.1.2 记的那
一坑,我一边写它一边踩了它。修法按工具给的提示走(不把不入库的运行时路径当
引用写)。验证没有用本地:在一份**从未构建过模拟器**的干净克隆里先复现(1 处
问题、同一行),再验证修完(0 问题、RC=0),那才是 CI 的处境。

撤回两句错话:我先前写「毒缓存会跨 run 存活」「`-ffd` 是因为 runner 工作区跨运行
复用被忽略文件」——对 CI 都不成立。实测 run 44 / 45 / 46 的 L0 工作区目录哈希互不
相同,**每个 job 一个全新目录**,各付 137~161 s 全量拉取(612 s 的 run 里约 5.4 分钟
是两次拉取)。自愈检查仍然必要,但真正的暴露面是**同一个 job 内**(`ui-golden` 与
板端导航自检共用同一份构建目录,前者中断会毒到后者)以及本机日常开发。§4.5、
§5.3 的 `-ffd` 依据、§7 行与 `ci.yml` 注释四处同步改成实测口径;变更记录按规矩不动
历史行,只在上方加更正行。

自查:两个 job 的检出正文仍逐字一致(7147 字符)、run 块非 ASCII 计数 0、`uses:` 0;
本地暂存钩子 HOOKS_RC=0;干净克隆里 `doc-links` 0 问题、`check_ids` 不可判定 1166 =
上限(未增)。预期这一笔推上去 L0 归零失败——那是自 run 23(10-12)以来第一次
两个 job 全绿。
2026-10-27 17:52:53 +08:00
wandering1 dc5d6a0680 fix(ci): ui-golden 需要机器级 SDL2 SDK——补 DTU_SDL2_DIR、配置期护栏与 build 缓存自愈
CI / L0 静态门禁 (pre-commit) (push) Failing after 7m4s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 3m6s
2026-10-27 17:32:35 +08:00
wandering1 ae80bb8e34 fix(ci): run 块按点源语义加固——原生 stderr 变终止性错误曾致两 job 3 秒亡
CI / L0 静态门禁 (pre-commit) (push) Failing after 6m43s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 2m39s
run 44(本分支上一次推送)把上一版原生检出打回原形:L0 用了 3 s、L0.5 用了
2 s 双双失败,日志里是 error: No such remote 'origin' 被包成 NativeCommandError
后整步终止。根因在我上一版:runner 执行 run: 块的方式是 powershell -command
点源那个文件,会话继承的 $ErrorActionPreference 是 Stop,于是原生程序往 stderr
写的任意一行都会被 PS 5.1 包成终止性错误。我写的 git remote get-url origin
2>$null 正是这一类,而 2>$null 不算摊平,反而让它先把整步炸掉。

修法两件事一起做:块首显式 $ErrorActionPreference = Continue(不依赖继承);
所有 git 调用走同一个漏斗 Invoke-DtuGit,把 stderr 摊平成字符串再决定是否打印。
探针顺手换成 git config --get remote.origin.url:远端不存在时它只返回非零码、
不往 stderr 写,从源头不再触发这类错误。

更阴的一条教训是我的测法本身。第一版复现器用 -File 包一层点源,结果四条失败
路径全测成 rc=0。用最小矩阵把语义钉死后:exit 1 写在被点源的文件里时,-File
直跑 = 1;-File 包一层点源 = 0 且继续执行后续语句;-Command 点源(runner 的真
实用法)= 1。也就是说只要我一直用 -File 测,检出失败也会一路伪装成绿,这个洞
比上面那个 bug 更坏。现在统一用 runner 的真实语义测:powershell -NoProfile
-Command 点源块体文件,且前置 $ErrorActionPreference = Stop。

换测法后的实测(全部跑在从 ci.yml 抽出的那份字节上,不是另抄的副本):
- 点源 + Stop、全新工作区:ExitCode=0、checkout ok: c1e7e8c / commits=669、
  dirty_files=0、150.9 s(修复前同一模式就是 run 44 的 3 秒亡);
- DTU_REPO_SHA 空 / DTU_REPO_URL 空 / sha 不在远端 / 坏令牌:ExitCode 均 1,
  坏令牌 0.5 s 返回 unable to get password,不挂起、不烧 job 超时;
- 复用工作区:1.6 s,dirty_files=0,被忽略文件保留,未跟踪垃圾清除;
- 结构面:13 步、两 job 第 1 步逐字一致、uses: 计数 0、run 块非 ASCII 计数 0、
  无 BOM 无 CR;check yaml Passed;check_doc_links --all 180 篇、无法判定 0。

文档同笔落地(教训要跟它救的那段代码一起进历史):指南 §5.3 补 run 44 实录与
退出码矩阵、实测表补 G/H 两行、§7 补 job 几秒就亡一行、两份变更记录各加一行;
remote-ci-ops skill §10 补 run: 块的正确测法。另记一个运维事实:推到已存在
的远端分支会正常建 run(run 44 即如此,推送后约 4 分钟入库,我先前把轮询窗口
取得太短误判成没建),以后验 workflow 不再需要手动 dispatch 与服务凭据。
下一轮可能红的两处我先标出来,红了正是我们要的证据:SYSTEM 下的 ui-golden
(历史上败在 32 位 MSBuild 与 VCTargetsPath)与 test-coverage(缺
DTU_MS_COVERAGE,走自动发现)。
2026-10-27 12:52:56 +08:00
wandering1 c1e7e8c42c fix(104): 拆掉四处恒假的上界判断——合法带只改判下界,口径不变
CI / L0 静态门禁 (pre-commit) (push) Failing after 3s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 2s
L0 的 cppcheck 在 --all-files 下恒红四处,全在同一文件:
App/protocol/iec104/iec104_comm.c 的 80 / 94 / 107 / 121 行,诊断为
compareValueOutOfTypeRangeError —— 拿 unsigned short 与 65535 比较,
Condition is always false。根因是 IEC104_COMM_CA_MAX 与 PORT_MAX 都等于
65535u,而被比较的量是 uint16_t:那个上界判断在类型上就不可能成立。
这四处随上游 iec104_comm 落地进来,本地提交只跑暂存文件所以没人看见,
CI 跑 --all-files 就每轮都红(同族盲区:暂存面与全量面不等价)。

处置走「语义不动」那一支(用户裁定 2026-10-27:A):只删恒假的半边、
改判 if (v < MIN),合法带口径一字未改(仍照 iec104_comm.h 的常量注释:
ca 1..65535、port 1024..65535),只在第一处写清为何不再写上界、其余三处
短引用。没有把 MAX 改成 65534(按 60870-5-104 的 65535 = 未编址),因为
test/host/iec104/iec104_test.c:1521 明确断言 ca 设成 IEC104_COMM_CA_MAX
必须被接受——那会同时打断这条断言并改变现场设备行为(配 65535 的站会
突然回退工厂默认),属协议决定,不在本次范围;要改请单独裁。

验证:单文件 cppcheck 原样复刻钩子参数 → RC=0;cppcheck --all-files 由
红转绿;iec104-unit-test Passed(即 65535 那条合同照过);@build 0 错误
0 警告(Code 231732→231720,减 12 B,RO/RW/ZI 不变);凭证与两份台账
随之重盖(ER_IROM1 257,292→257,280、Flash 合计 258,812→258,800);
本次触发的 13 根钩子全 Passed,含 test-coverage(删掉 4 行已覆盖代码
未被判成回退)。
2026-10-27 10:34:39 +08:00
wandering1 51ecd36192 docs(CI): §5.3 重写并记录覆盖率工具链落地,更正 §1.2.1 与本会话先前的错判
补上今天两件 CI 工作的落地记录,并把错误结论改对(历史行不动,只在上方加行)。

新增 §4.4「覆盖率工具链(mscoverage)与 VS2026 BuildTools」。为什么只能装:
那个 exe 没有免安装的独立发行版——实测 NuGet 两个候选包,
Microsoft.Testing.Extensions.CodeCoverage 18.12.0 的 109 个条目里 .exe 数为 0,
Microsoft.CodeCoverage 18.11.0 只有老版 CodeCoverage.exe(CLI 语法还不同),
所以它只随 Visual Studio 本体分发。安装入口是 aka.ms/vs/18/stable/
vs_buildtools.exe(release 那条短链不存在,会掉到"未找到"页);组件 ID
Microsoft.VisualStudio.PackageGroup.TestTools.CodeCoverage 由 18.11 的
VisualStudio.vsman 解析得到(清单里唯一引用 DynamicCodeCoverage 与
CodeCoverage.Console.Targeted 的组)。含可直接粘贴的三条管理员命令、-Force
陷阱、Restart-Service Schedule 判据,以及「装在非默认盘 = 门禁与 CI 都看不见」
的成因:Find-MsvcCl 与 _default_vs_roots 都只由 %ProgramFiles% 派生根。

§5.3 整节重写:本仓库已移除 uses:,记录六项实测(A/B/C/D/E/F,其中 E 是执行
从 ci.yml 抽出的正文、测的就是文件里那份字节)与两条自查命令,并明标两条
**未证边界**:PR 的 refs/pull/<n>/merge 兜底在本实例无法证实(0 个 PR、
ls-remote 该命名空间为空);Basic 令牌那一支本地验证不了(公开仓库匿名即过,
伪造令牌只能证明坏的会被拒)。旧版那段"可直接抄"的示例标为禁抄:shell: pwsh
在 runner 上 PATH 无 pwsh 会直接失败,--depth 1 让 base..head 报 unknown
revision,两条都会让下一个照抄的人多红一轮。

更正两处过时/错误说法:① §1.2.1 表第三行——自定义 DEFAULT_ACTIONS_URL 在
1.25.4 已无可用取值,旧写法会把排障的人引向一个不存在的开关;② 我自己先前
写的「旧探测会把机器级变量覆回 C 盘 14.29」——本机实情是 C 盘 VS2019 已于
10-27 01:00 卸载(C 盘 x64 cl.exe 实测 0 个),旧探测的行为是「找不到 → 只
Warn → 不写」,于是变量一直指着失效路径。§7 的 checkout 行相应改为「看见此
症状=有人把 uses: 加回来了」。

另:口径文档 §7 第 8 条加实测补注(「CI runner 与本机同一台」对量基线那台不
成立,本机当日 vswhere 只有 VS2019);新增 remote-ci-ops skill 并接上 AGENTS
与 docs/CI/README 两处指针;指南与 README 各加变更记录。纯文档,按 workflow
的 paths-ignore 不触发 CI。
2026-10-27 08:01:40 +08:00
wandering1 2d18193d38 fix(ci): 去掉 actions/checkout 外网依赖,MSVC 探测改 vswhere 并钉住工具集世代
两个 job 的第 1 步原先是 `uses: actions/checkout@v4`。`uses:` 的解析发生在
Prepare all required actions 阶段——先于任何步骤、无条件联网(默认 github.com),
且每个 job 各自解析进自己的 .cache/act/<hash>、跨 job 不复用。这条链在本机实测
不稳:run 36(10-21)/ 41(10-25)/ 42(10-26)三次都是 ~45 s 后整 job 死亡、
零钩子输出,而同一分钟手工 curl 同一地址返回 200——手工可达 ≠ CI 可用(解析器
跑在 SYSTEM 下)。Gitea 1.25.4 又去掉了自定义 DEFAULT_ACTIONS_URL,服务端没有
可拧的键,唯一归本仓库管的修法就是不再依赖它:两个 job 第 1 步换成原生 git 全量检出。

关键选择(依据与实测表见 docs/CI/远程CI完整指南.md 同文 §5.3,此处只记结论):
- 完整 refspec + 全量,不是 --depth 1:提交信息门禁要解 base..head 的 range;
  且目标 sha 不在历史里就 ::error:: 硬失败,绝不默默构建一棵错的树;
- clean -ffd 而非 -ffdx:-x 会删被忽略文件,而 runner 工作区跨运行复用它们;
- GIT_TERMINAL_PROMPT=0 + 空 credential.helper:否则认证挑战会等人类输入,
  把 30 分钟 job 超时白烧掉,表现得像 CI 挂了;
- 令牌只走 per-invocation -c http.extraheader(Basic + x-access-token,与
  checkout@v4 同拼法),不落 .git/config、不回显;仓库公开时匿名即可;
- 不抓 tag:本仓库 tag 数 0、无脚本用 describe --tags(实测)。

另修 scripts/ci_runner_bootstrap.ps1 的 Find-MsvcCl。旧版只扫 %ProgramFiles%
派生根且取首个命中:本机 C 盘 VS2019 已于今日 01:00 卸载(C 盘 x64 cl.exe 实测
0 个),旧版因此是「找不到 → 只 Warn → 不写」,机器级变量一直指着那条失效路径,
而 harness 对「显式设置但无效」是硬失败、不做自动回退。现在改为 vswhere 取实例
(能看见非默认盘)+ 工具集版本降序 + -MsvcToolset 前缀钉住世代(默认 14.51.,
与覆盖率基线同版本;钉不上连 WARN 再回落最新,绝不静默)+ -MsvcLatest;
-SetEnv 与 -Status 同步纳入 DTU_MS_COVERAGE。

验证(内层一律 powershell.exe 5.1,忠实复现 runner 执行方式):临时工作区六项
A/B/C/D/E/F——E 是执行**从 ci.yml 里抽出的正文**,测的就是文件那份字节。
A rc=0、commits=665、refspec 完整、分离 HEAD;B 复用工作区后 dirty_files=0、
被忽略文件保留、17ece86..HEAD 解出 18 个提交;C rc=1 且 HEAD 不动;D 坏令牌
fatal: unable to get password 秒失败;令牌改造后再跑 ANON/BOGUS/ANON2=0/1/0。
ci_local 全量:L0.5 = 40 runner、3188/3188 断言、fail=0(MSVC 14.51);
test-coverage 与 ui-golden 均 Passed。.ps1 非 ASCII 字节 0、语法错误 0、无 BOM、
LF;ci.yml 无 BOM 无 CR、13 步、0 个 run 块含非 ASCII、uses: 计数 0。
2026-10-27 08:00:14 +08:00
wandering1 5869c0307b fix(104): 消掉 cppcheck 两根常驻红——回显不再传未初始化缓冲、blen 收作用域
L0 静态门禁(pre-commit)历史 36 次启动里 28 红 / 7 绿 / 1 取消,最后一次绿是
10-12 19:17 的 run 23;此后连续全红。恒定的一根就是 cppcheck:本机(与 runner
同机、同一 Machine 级 Cppcheck 2.21.0)以钩子原样参数复现,诊断与 runner 日志
task-54/58/60/62/64、以及 master 上那条「Failing after 6m49s」的归档 run 26
逐字一致——不是版本漂移,也不是抑制文件缺失。

两条诊断与处置:
- s_cmd_con:body 仅在 n>0 时写入,n==0(回显超长退化为空信息体)时把未初始
  化缓冲连同长度 0 一起传给 s_send_asdu。现实现内 len>0 才 memcpy,故无运行时
  缺陷,但契约脆弱、门禁照拦。改为把 body 声明收进真要回显的分支,空信息体
  分支显式传 NULL。
- s_spont_step:blen 只在 pend_yx 分支使用,声明移入该分支。

验证(基线 b24b2b5):cppcheck 原样复刻钩子 args → RC=0 零输出;@build 0 错误
0 警告(Code 231712→231732,+20 B);ram_baseline.py --update 重盖体积同码凭证、
--write 同步受管区台账,check_mem_numbers.py --write 同步 RAM 快照表。本次触发的
14 根钩子全 Passed(含 test-coverage)。

环境与遗留(非本笔引入):这台 runner 原本只有 VS2019/MSVC 14.29,没有
Microsoft.CodeCoverage.Console.exe,test-coverage 恒 [ENV] exit 2;今日已装
VS2026 BuildTools 18.11.12324.101(E 盘,含 mscoverage 18.11 与 MSVC
14.44/14.51/14.52),本笔按 14.51.36231 跑通覆盖率总闸 40/40 套、相对基线零
回退——与量基线那台同版本,故分母可比。CI 侧仍差两步:① 机器级 DTU_MSVC_CL
还指 C 盘 14.29,需改指 E 盘这套并重启 runner 守护进程(SYSTEM 只在进程启动
时读机器变量);② ci_runner_bootstrap.ps1 的 Find-MsvcCl 只扫 %ProgramFiles%
派生的 C 盘根且取首个命中(2019 在前),重跑 -SetEnv 会把对齐抹掉,应改用
vswhere + 版本降序。
2026-10-27 04:27:11 +08:00
wandering1 b24b2b5f31 docs(计数): 内容页黄金参考 27 → 40 全仓同源改写,含钩子显示名这最后一处不同码
CI / L0 静态门禁 (pre-commit) (push) Failing after 49s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 45s
UI 三页落地后机器真值变了,文档却还写着 27 页——这不是笔误,是会骗人的现在时。
作者(batch-auditor)先自己从机器面取数再动笔:git ls-files 里 ref/*.bin = 40、
PAGES 40 项、TEXTS 37 页 310 条、ABSENT 16 页 29 条、池 5448 B 与常驻堆 5368 B 未变、
字库 183 字未扩。**它还纠了我一个数**:我报 ABSENT +19,实测 11 → 29 = +18;作者按
实测写文档,并且不在文档里写 ABSENT 总数,免得把我这个差 1 的数固化成第二真源。

改写面(现在时陈述,共 12 + 3 处):AGENTS.md 的 ui-golden 半句、门禁说明 ui-golden.md
(标题与「拦不住什么」段)与索引行、simulator/tests/README.md、simulator/README.md 两处、
UI_自动化测试流程.md(310 条 / 40 页 / PASS 文案 / 覆盖面)、内容页架构.md、页面补齐规划.md、
主机单测覆盖率口径.md、自动化测试流程.md、测试开发标准流程.md,外加
.pre-commit-config.yaml 里 ui-golden 的显示名——那是「声明 / 文档 / 实跑」三数同源的最后
一处不同码:AGENTS 与门禁说明都已 40,配置里还写着 27。

两条纪律作者都自证了,不是口头:
· **历史数字一律保留**:8 处变更记录行里的 27 / 242 / 24 / 11 原样未改;两处带日期的
  验证记录只加限定语(「该下限此后已扩至 40 页,双次逐字节一致需按同口径重跑回填」),
  没有把 27 改成 40 去造连续性。
· **同名不同义的数字不许全局替换**:页面补齐规划.md:12 与 :157、页面扩充详细方案.md:649
  里的「27」是**空叶子口径**,与黄金参考数无关——三处故意没动。这条比改对更值钱。
· 余量自证(回退名下 12 文件复跑同一扫描器):refs +0 / dec +0 / und +0 / 比值 +0.00pp /
  错误 +0,本笔零消耗,也未新增任何编号引用;simulator/tests/ref/ 下 40 个 .bin 零改动。

闸线现状照实挂着:id-check 不可判定 1,162 / 上限 1,166,**余量只剩 4**(吃进者是今天
UI 那笔的注释引用,账在 Lead)。清账之前任何人新增编号都可能直接撞闸。
2026-10-27 00:16:07 +08:00
wandering1 ac9ca33f0f fix(台架脚本): 导航器加两条硬闸——执行类页面默认不进、未知屏 ENTER 最多一次
bench-runner 在给 UI 窗口做准备时报出一条**三段叠加就会真执行破坏性动作**的路径,
而我就是其中一段的作者:496b4dc5 把 e 放进 UNKNOWN 屏的回退键序(当时的动机是真的
——本机待机态按 s/l 无效,冷启动落在待机态时闭环导航直接开不了工)。叠加面在这里:
· 导航器每进一个叶子就发一次 ENTER,且会替「查看密码」自动确认;
· 装置维护的 强制复归 / 清除记录 已进菜单树;
· 这两页是**两段式确认**——第一次 ENTER 只把该行标成「确认?」,第二次 ENTER 就真的
执行(复归全部保护动作位并写 SOE / 清 SOE·运行·操作记录)。
单个 ENTER 安全,但**同一屏上第二次 ENTER 不安全**;而退避键序是循环消费的,第一次
按 e、第四次又按 e。更要命的是:新字库缺字、白框、状态行变化恰恰最容易让页面被判成
UNKNOWN——那正是我们最想在 UI 窗口里抓的东西。**越是显示异常,越容易被脚本按执行**。

两条硬闸(不改任何固件、只动这一只台架脚本):
① to_root() 内 ENTER **最多按 BACKOFF_MAX_E 次**(默认 1);还需要第二次就当场报错
停机、并把 dump 的帧留在盘上,而不是把第二个 ENTER 投出去。报错文案直接点名执行类
风险与放行方式(改用无 ENTER 的键序 --backoff-keys sl)。
② 执行类页面 EXEC_PAGE_IDS = clear / reset **默认拒绝下钻**;确有真机需要才显式加
--allow-exec-pages,且运行时打印警告。要看这两页的显示正确性,正路是模拟器黄金参考——
同一份 UI 源码、零真机副作用。

通用口径一并写进文件头:**导航类自动化脚本默认零 ENTER**,ENTER 只允许出现在明确
指定的、非执行类的用例步骤里。「靠按 ENTER 从未知状态脱身」这个前提本身就与执行类
页面冲突,不该由脚本替你承担后果。

行为验证(假设备,绝不连真机、绝不注键):clear / reset 均被拒且理由含「真执行」;
clock 不受该闸影响(走到设备层才失败);持续 UNKNOWN 的循环实际按键序列是 e-s-l 后
在第 2 次 ENTER 前停机,实测 ENTER 计数 = 1 = BACKOFF_MAX_E,报错理由点名执行类风险。
5/5 通过;py_compile 通过;--help 可跑。临时脚本没留在仓库里,__pycache__ 已清。

另两处纯计数更正(与文档道 task-39 同批真值:黄金参考 40 页):本文件 selftest_refs
的 docstring 27 → 40;.pre-commit-config.yaml 里 ui-golden 的显示名 27 → 40(这是
「声明 / 文档 / 实跑」三数同源的最后一处不同码)。
2026-10-27 00:13:41 +08:00
wandering1 41c277a8ae docs(门禁): 抢跑等价门禁自查表落地——把「谁漏跑一次,这道检查这次就不存在」写死
今天三次 --no-verify 共放过三类真问题:5 条 CODE-RULE-17(.c/.h 配对)、2 个缺尾换行加
1 个漏提文件(HEAD 一度是「代码已整行独占、旁边 runner 文档串还写着无锁直发」)、
以及共享尾段 +80 B 把 iec104Task 压到 1,024 B ISR 底线之下。根因不是「不该抢跑」,
而是**抢跑时只跑了部分等价门禁**——doc-links、id-check、字节卫生都跑了,唯独漏了
stack_report,而它恰恰是数字与安全边界那一类。

§2.4 三组清单(任何人可用,不是 Lead 特权说明):A 抢跑前必跑(stack_report、
ram_report、ram_baseline --gate 含「跳过不等于有余量」、coding-standard、bom-drift
含「别用有 BOM 就是错的外行判据」、mojibake、受影响模块单测、test-coverage、id-check、
doc-links、generated-fresh 必须 .venv、ci_local 并注明它**不含** stack_report 与
ram_baseline 所以不能当替身);B 手跑代替不了真提交的三类(改写型钩子、要靠 stash
才能正确归因的、只有真提交才碰到的 gitlint 与正文每行 80 列);C 抢跑之后:
git show --stat 对交付清单 + git status 里不该再出现声明过的文件(含 ??,漏提 runner
就是这么发现的),并强制在正文点名「哪些没跑、为什么、谁在飞」。

一条结构性说明:stack_report 与 ram_baseline **不在钩子清单里**,所以「人漏跑一次 =
这道检查这次不存在」;要机械化得先解决「磁盘 .htm/.map 属于最后一次构建的那个人」
——与 stash 覆盖面那条同源,不是加一根钩子就完事。另写明 --all-files **也不 stash**,
会让改写型钩子当场改全库,只能在静默窗口跑、跑完复核。

文档里的命令必须能直接跑:只读等价那条一行式刻意纯 ASCII——Windows PowerShell 5.1 按
ANSI 读无 BOM 的 .ps1,带中文标签的版本会碎成 SyntaxError(作者踩过并复现),改完后
从文档里抽出来重跑验证:checked 40 files (incl untracked),problems 0。

同篇顺手核掉的同源不同源(可单独回滚):§6 钩子数 40 → 50(口径 = YAML 解析条目;
直接 grep 声明行会多算一条被注释掉的候选 = 51)、主机侧单测「16 个合一篇」→ 22 根
专属钩子 + 18 套走 test-coverage 总闸;两处跨行断行的表格行修好,全文 8 个表格块
列数一致(脚本核过)。generated-fresh.md §4 补上解释器解析口径与 [ENV] 输出。
2026-10-26 23:42:41 +08:00
wandering1 b1280e3cd1 feat(台架工具): 原子性复验能力并进既有 verify_log 并接进 @test——旧口径判 PASS 的缺陷新口径判 FAIL
关键决定是**不造第二只校验器**:卡片原建议把校验器放进 scripts/,但「标记校验」这个
能力已归 .dsh/skills/serial-boot-test/scripts/verify_log.py,再造一只就是本仓反对的
实现漂移(reset.bat 折进技能是同一类判断)。改为扩展它 + 把开关接进 run_boot_test.py
——**不接就等于吸收了没人用得到**;默认路径逐字节不变(不传开关时 exact_check 根本不进)。

新增:--exact 整行锚定(并把「含最长字面串却未整行命中」的邻居原样打出来,那就是
行级交错的特征)、--pair 跨复位配对、--allow-residual 与 --head-char 服务崩溃注入腿、
--include-comments 复现旧行为、--selftest 判别自检。钉长度改成显式 @@LEN=<n> 逃生口,
**不再是默认**。

最硬的一条实证(进文档表格,也钉成一条 selftest 用例):同一份缺陷捕获——行首被吃、
尾巴完好的那行 NAND_FTL——旧 --required 子串口径判 **PASS(假绿)**,--exact 判 **FAIL
并打出缺行首的邻居**。口径据此改写:凡是这一行可能丢行首或被插字节的标记,一律
--exact,不许只用子串。

实测不是声称:--selftest 16/16;真日志三向(修复后 PASS / 修复前 FAIL+邻居 / 带 ASCII
溯源头不再被头里的 [ERR] 误报);被动烟测(--flash-cmd 置空 = 不烧录、不动板上镜像、
只采 60 行空闲流量,bench 锁取了即还)证明四个开关在 @test 闭环里**真的执行了**;
空闲流量里 PROF 行长 124/78/123 交替,顺手再证一次「钉长度当默认」会天天假红。

作者自报四个自己的问题(都被自测抓到并修掉,卡里也写了):残留行被重复计成交错邻居
(会假红)、一条断言写反、**CLI 参数忘了接进 verify()——最危险那种:功能静默空跑而
外面看着全绿**、以及误报 run_boot_test.py 变 CRLF(实为自己的检查把换行符写成了字面
量,两个文件复核均为纯 LF,别按那句去查)。

boot_log_data/README.md 里同时有两个道的内容:bench23 的台账行,与 task-25 那条道写的
「证据可达性」规则与 force-add 定案——autostash 往返后两侧都逐一确认仍在。三份 bench23
证据 txt 首次入库(可再生的采集文本;两张非再生 .bin 已随前一笔入库)。
2026-10-26 23:42:34 +08:00
wandering1 496b4dc5e2 fix(台架脚本): board_ui_nav 的 UNKNOWN 回退键序改为 e-s-l,冷启动落在待机态不再空转
旧逻辑 UNKNOWN 分支里的 l 条件**永不可达**:classify 只在 MENU 态填 cols,UNKNOWN 恒
cols=None ⇒ 回退实际等于静默连按 s。而本机待机态按 s/l 无效、要按 e 才进菜单 ⇒
冷启动恰好落在待机态时,闭环导航直接开不了工(症状是界面点不动,脚本却报正常)。

现在 BACKOFF_KEYS 默认 esl、--backoff-keys 可覆盖(要旧行为填 sl),回退时**循环消费
键序、绝不连按同一键**,并新增 _dump_unknown 把认不出的帧存证下来,免得下次只能靠
复述。同笔带上 task-24 在 19:41:56 重跑产生的 trace 归档(1 行,不是本次手改)。
2026-10-26 23:42:27 +08:00
wandering1 8ff2743374 chore(台账): RAM 基线、体积凭证与覆盖率基线随 22:52 构建回填
三个基线文件与一篇内存文档表格同步到同一事实源(MDK-ARM 下 .map,mtime 2026-10-26
22:52:12;.map/.htm 按约定不入库,故把身份写进正文):
· Code 231,712 B / RO 25,560 B / RW 1,504 B / ZI 136,216 B
· ER_IROM1 257,272 B(24.5%)、RW_IRAM1 80,312 B(61.3%)、RW_CCM 57,376 B(87.9%)、
  Flash 合计 258,792 B(24.7%)
· scripts/size_attestation.json 记录索引指纹 06808c35e47b…(1430 个文件),把「这份
  体积数出自哪一份工作树状态」变成机器凭证,而不是口头

覆盖率基线登记两条 [NOTE](此前挂着不登记就是在攒债):
· Drivers/BSP/UI/ui_data.c 65 → 316 行——账在 Lead:task-1 那笔改了装配层却没回填
  基线,此后每次提交都打印同一条 NOTE;一条长期存在的 NOTE 等于没有 NOTE。
· App/protocol/iec104/iec104_comm.c 36/36 行——task-8 搬家新增模块,作者已自测,
  登记归共享台账(我串行改,避免两个道同改一份门禁账)。
当前口径:line 6854/7626(89.9%)、func 505/542(93.2%)、suites 40/40、
tested_files 45、untested_files 142。

为什么这一笔能走真钩子:这是今天第一个静默窗口——四条道全部 PAUSED,全量
pre-commit 实跑(改写型钩子在没人写盘时才安全),因此这一笔不需要任何 --no-verify
例外。今天前三次 --no-verify 共放过三类真问题(5 条 CODE-RULE-17、2 个缺尾换行加
1 个漏提文件、共享尾段 +80 B 把 iec104Task 压到 ISR 底线之下),根因都是「抢跑时
没跑等价门禁」;task-37 把那张自查表写进了 pre-commit 使用说明,这一笔是还清的债。

仍然挂着的一条闸线:id-check 不可判定引用 1,162 处 / 上限 1,166,余量只剩 4。
主要吃进者是今天 UI 那笔(三页与其 68 条文本断言的注释引用),账在 Lead。
清账之前任何人新增编号都可能直接撞闸——已记进 task-34:要么把裸 TC 引用改成指向
属主登记处的写法让 und 掉头,要么把余量当硬约束写进协作口径。
2026-10-26 23:39:08 +08:00
wandering1 b623a406ad fix(栈·存储·UI 收尾): 断掉临界区↔断言幻影环回到 1,464,标定门控单点化,卷锁五态可观测
这四笔在主树里已经互相纠缠(同一次 @build 编译、同一份 .htm 度量),拆成四笔就不
存在任何一个被真编过的中间态,所以合一笔入库;下面按模块分段陈述,作者与出证归属
逐段写明。

—— 一、崩溃证据路径的整行独占收尾(log-maint-writer,task-26)——
上午那笔 1d4b6a17 之后,stack_report 报 iec104Task 静态最坏链 1,544 / margin 1,016,
低于 1,024 的 ISR 底线。真因不是新增局部缓冲,而是本模块与内核临界区之间新长出
一条环:taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒ vApplicationAssertFailed
⇒ dbg_console_write_raw ⇒ 回到本模块。armlink 把这一整坨计入**每一个含临界区的
任务链**(vApplicationAssertFailed 的 Max Depth 由约 40 B 涨到 128 B + In Cycle),
所以九条链同涨约 80 B——同涨本身就排除了任何单个调用方。
改法两处:① 本模块三处窗口(取凭证 / 还凭证 / 整行入环)改用 CON_CRIT_ENTER/EXIT
(PRIMASK 保存恢复到局部变量;注释写全为什么不能直接 __enable_irq()——嵌套时会
替别人打开任务级临界区——以及屏蔽面变宽是有意为之);② 异常/终点钩子里那对
自证空转的 begin/end 摘掉(异常态 begin 放行但不持有、end 是幂等空操作,调它只有
成本没有收益),任务态 STALL 行保留 begin/end,这个区分写进两处注释与文档 §4.4.1。
实测四棵树逐个 .htm 解析(身份钉 tree + Linker run 时刻 + 产物来源):A=77779d49
链 1,456;B=1d4b6a17 链 1,464 / margin 1,096 PASS;C=含未提交钩子接线的工作树
1,544 / 1,016 FAIL;D=本轮修复后(Linker run 22:52)回到 **1,464 / 1,096 PASS**,
九条链齐降 80 B。断环图证:vPortExitCritical 由 136 B + In Cycle 变 8 B 且不再
In Cycle,con_tx_release 子树 152→8;Error_Handler 回到 280 + In Cycle = A 树同值
(既有发送环无人拆动)。顺带删掉 Src/freertos.c 崩溃路径里最后一个 HAL_Delay(200)
(该文件真实调用现为 0)。
测试:console_log 152→**153** 条(只增不减);换钉不删防——015 现在钉「PRIMASK
窗口 2 次、深度 1、内核临界区 0 次」,新钉 015c 绝不等待,160 注入点搬到
con_pm_hook,176 强化到未调度路径连 PRIMASK 窗口都不开;红线水位
con_pm_at_tx_max 与 con_uart_crit_at_tx_max 双 0;判据⑦(延后的行与当场直发逐
字节相同)170b/171/178 仍全绿。作者自报并纠正一处:换钉时曾把 TC-CONLOG-015b 改
编号,而 015b 在生产注释与文档里都被引用——已恢复「编号不退役、语义搬过去」。
覆盖率 App/log/console_log.c 199/200 → 256/257 行、21/21 函数(只增)。

—— 二、未标定闭锁判据收成单点访问器(ui-param-writer,task-16)——
「UA/UN/IA 三相 VALID 全置位才允许跑判据」这条门控原先在两处各自镜像实现
(protect_task.c 的 s_cal_locked 与 ui_data_feed.c 的 feed_cal_block),改一处另一处
静默失效且无门禁可照。现新增 adc_cal_prot_blocked() 落在 App/algo/adc_cal,DTU_BENCH
特例收进一处;两个消费者同批改调、本地副本删除。作者还查实并纠正了我先前一句
错判:全仓 App/app→App/task 的 include 实为 0 条,故访问器不能落在 protect_task。
新增 TC-ADCCAL-018 八条(含冷启动即闭锁),adc_cal 套件 99/99。

—— 三、共享卷锁五态可观测性(ui-param-writer,task-17)——
原状比登记簿写的更糟:五个可区分态(未调用 / try 超时 / 分片超时 / fail-open /
未持锁却 release)生产侧零留痕,且 vol_lock.c 的返回值注释与代码相反——
fail-open(锁没建成、互斥已失效却在读写盘)被返 1 **谎报成取到锁**,而那正是
vol_lock.h 记录的台架实证故障(并发写坏 FTL → 喂狗点饥饿 → IWDG 误复位)的前置。
锁层加四个 volatile 计数(16 B 静态,零新局部、零新 callee、零日志、零 FatFs)与
不取锁的 vol_lock_get_stats();13 处 try 调用点一字未动。**刻意不在锁层记时刻**:
记时刻要读 tick,等于给 defaultTask 取锁链添帧——诊断不值那几个字节;消费方若
自盖时间戳,那只能是被观测到的时刻而非发生时刻,.h 里已写明不得写成首次/末次。
四套本地假件各加 s_last_wait_to(字段名逐字一致),把「喂狗点取锁一律 timeout=0」
与「ftpTask 分片一律 100 ms」从注释纪律升级成会 FAIL 的断言;红-绿反证:临时把
sys_log_mcu.c 的 0u 改 50u → TC-SYSLOG-010/012 双红,把 lftpd_io.c 的 100u 改 0u →
TC-LFTPIO-034 红,两处按原串改回并用 SHA-256 证明逐字节还原。TC-VOLLOCK-015~019
共 45/45(无新桩)。

—— 四、UI 死接口清理(ui-maint-writer,task-6 收尾)——
删掉 ui_data_maint_ready():全仓零消费者、只有它自己的用例在读。**更值得记的是
它为什么能活过一整轮评审**:头注释写的是「页面用它决定这页能不能操作」,而页面
从来没用过它——注释陈述的是意图,评审读的是意图,门禁读的是可达路径,两者都不
反驳「意图未实现」。作者对「连用例一起删」做了显式抗命:TC-UIDATA-025 的 13 条里
只有 4 条读那个死口,另 9 条钉的是真防线(六口未注入安全返回 / 残表整表拒绝 /
被拒不覆盖已生效的好表),故删冗余的 025a、把 025i/025k/025m 换成用 reset_req()
的可观察返回值判定,性质不丢。ui_data 套件 244 → 243(净减 1),黄金 40 页与
池 5448 B 未变(该刀零像素影响,参考图无 diff)。

—— 门禁与未跑项(本笔如实留账)——
cubemx-survival 基线已 --write(漂移 6 项全部是上述意图内区域:断言/栈溢出钩子
实现、看门狗模块行等),基线文件同笔入库,随后 --check 通过。
本笔未跑的钩子只有两条:mem-numbers 与 mem-budget——磁盘 .map 出自 22:52 那次
构建、含尚未入库的 UI 与工具改动,与 HEAD 台账必然不同码;按仓库既有约定允许
--no-verify + 正文留证,回填在紧随的台账一笔统一做(含 ram_baseline --update 与
覆盖率基线两条 [NOTE]:ui_data.c 65→316、iec104_comm.c 36/36)。
两条已挂着的账:① id-check 不可判定引用 1,162 / 上限 1,166,**余量只剩 4**,
主要吃进者是我今天推进的 UI 那笔(68 条文本断言的注释引用),账在 Lead;
② 崩溃注入腿(故意复位)台架复验未跑,需先由代码道提交触发器,且判据只判原子性
不判 [ERR]=0——IPSR 路径的主张归主机侧套件,硬件照不出来。

产物身份(按 D 表要求钉死,避免数字与树脱钩):本笔包含的固件树就是那张 D 表对应的
那一棵——MDK-ARM/.../STM32F407-Demo.htm 的 mtime = 2026-10-26 22:52:12(.htm/.map 按
约定不入库,故把时刻写进正文)。提交前在本树复跑 stack_report.py:exit 0,
iec104Task 1,464 / margin 1,096(42%)PASS、defaultTask 3,040 / 2,080(40%)PASS,
与 D 表逐项一致;本次运行不含任何未提交固件源码之外的新改动。
2026-10-26 23:31:05 +08:00
wandering1 52e28885cb feat(UI): 装置维护 3 页落地(时钟 / 强制复归 / 清除记录),内容页黄金 27→40
三个薄页面模块 ui_page_clock / ui_page_reset / ui_page_clear,页面注册由生成器
自动写双端(裁定 A-1:「三叶各绑一页」与「零新增 .c」不可兼得,选薄页面加生成器,
不写手写 node switch——那种分发在增删叶子时会静默换功能,且没有任何门禁照得到)。

数据侧是注入式函数表:UI 不 include App 层的头。两条硬理由都实测过——分层方向
(layer-deps)之外,模拟器根本不链 sys_clock,ALLOW 豁免挡得住 layer-deps,
挡不住模拟器链接期。面板对时的唯一真判据是回读 RTC:sys_clock_set() 恒返 0
(RTC 写失败只打日志),所以真机侧写 1 后回读 1,最坏约 200 ms 落在一次「保存」
按键上,IWDG 800 ms 窗有余;上界与依据都写进注释。

「无时钟」徽标位置有两个像素级实测坑:放标题带右侧会被 4 字标题连成「时钟设置
无时钟」一句话;放页脚带左侧 x=7 会压住页脚——页脚从 x=6 起排,不是居中。
最终落在清单末行与页脚之间的空带 y=124。clear_done 那张顺带给出像素证据:
清 run.log 之后「操作记录」那一行从 2 变 3,页面上没缓存、没撒谎。

ui_w_records 的 UI_W_REC_LOG_* 顺序必须等于 syslog_id_t 顺序(7 类,只准尾追加),
已加编译期顺序守卫;执行侧 7 条映射表同样带编译期守卫。

测试与真值:内容页黄金 27 → 40 页(+13),文本断言 +68、反向断言 ABSENT +19;
27 张旧参考 git diff 为空 = 逐字节未变。往返池 5448 B 与菜单常驻堆 5368 B 均未变;
字库未扩(源码在用 171 并 KEEP_EXTRA 39 = 清单 183),font-charset 与 cn-chars、
cn-font 字节一致。@build 0 错误 0 警告;uiTask 静态最坏链 1,336 B / 16,384
(余 91%)与基线相同,页面钩子没进最坏链。

一条测试学结论单独留痕:反向断言不能当「位置不重叠」的证据——本脚本的字形匹配
把 y 带判得比字面宽(徽标在 125..136、页脚在 144..155,仍被 (140,160) 判命中)。
该防线改由正向断言「页脚未被徽标压住」承担(压字那一版实测过不了它)。

两条自我更正一并记档(作者自报,按真值而非原口径):
① 先前报「文案零新增汉字」是错的——观测行拟用「动作元件」,「元」不在子集,
cn-chars 与 font-charset 如实红;最终改「保护动作 N」才让「零新增」成为事实。
顺序是:先按机器红,再按真值写结论。
② 先前报「6 个文件 BOM 逐个核过」被 bom-guard 证伪:丢 BOM 发生在上一次编译之后,
正是 AGENTS 那句「历史 0 警告不代表安全」的活例;此后一律以退出码为准。

未覆盖项不假装:台架「N>0 → N=0」在产品构建的未标定闭锁期拿不到(闭锁期 N 恒 0),
判据显式退化为「页面受理 + 落一条操作记录」,写在 rs_watch 注释里。

生成物入库例外照用:uvprojx 只加 3 个 ui_page_ 的 FilePath(真增删源文件),
CMakeLists 同批;两处都由 gen_menu.py 自己写,没手写。

本笔先走了一次真钩子:20 条钩子实跑通过(ui-golden 菜单 8 状态加内容页 40 页、
generated-fresh、registration-check、font-charset、ui-data 单测、cppcheck、
coding-standard、bom、mojibake、id-check、doc-links 等),只有 mem-numbers 与
mem-budget 两条红——磁盘上的 .map 出自 20:39 那次构建、含三条在飞道的代码,
与 HEAD 台账必然不同码。按仓库既有约定(这两条等台账回填时允许 --no-verify 并
正文留证)落这一笔;回填由我统一在收尾一笔做。

三条在飞道(task-26 崩溃证据路径、task-16 与 task-17 谓词收敛和卷锁观测、
task-36 台架工具)的未暂存改动被 autostash 暂时收走,钩子看到的是 HEAD 加本笔;
过程中曾把 comm 道未跟踪的 iec104_comm.c 临时移出(它不在 stash 覆盖面内),
那次移出反而让 ui-golden 撞见 CMake 缓存里的悬空引用而红——已改用「先落 comm
那笔、再落本笔」的顺序消掉这个中间态。ui_data_maint_ready() 待删(只有测试在读
的自检口),排在本笔之后单独一笔。
2026-10-26 22:43:54 +08:00
wandering1 45086cf5f9 refactor(104): [comm] 段实现搬进配对模块 iec104_comm.c 并同笔补双端注册
上一笔 e7136cbe 把 [comm] 段实现写在 iec104_pts.c、契约放在 iec104_comm.h,
撞上 coding-standard 的 CODE-RULE-17 (2.2):非 static 函数必须在按文件名配对的
头文件里声明,于是 iec104_comm_init / _ca / _port / _ca_set / _port_set 五条判违规。
这 5 条本该被钩子拦下——是我用 --no-verify 放进主干的,成因与处置都记在案。

正解不是把声明抄进 iec104_pts.h(那会让一个函数有两个属主头文件,比原状更糟),
而是让模块名与文件配齐:新建 App/protocol/iec104/iec104_comm.c 承接实现体,
逐字迁移、零逻辑改动;iec104_pts.c 里 comm 符号清零,回落到 task-8 之前的基线。

实测(作者自跑,两版 .map 逐对象对账,不是「应该不变」):纯搬家净差值 Code −8 B、
RO 0、RW +4 B(s_comm 从对齐余量改为显式计量)、ZI 0;iec104_comm.o = Code 120、
RO 45、RW 4、ZI 0。@build 0 错误 0 警告(Linker run 20:39:43,符号级证明
app_main.o 与 iec104_serv.o 现在指向 iec104_comm.o)。三套主机单测复跑:
cfg_file 207/207、iec104 174/174、iec104_serv 37/37,断言数零掉;
coding-standard 对 7 个固件文件 exit 0(5 条随配对成立自然消掉);
registration-check PASS(唯一 WARN 是存量 rs485_task)。

注册与实现同笔落库,不留两种中间态:uvprojx 的 <File> 块(「真增删源文件」才入库
那条例外)+ simulator/CMakeLists.txt 的 IEC104_SOURCES 与 add_executable 接线。
今天已被中间态咬过两次——「有声明无定义」让别人撞 L6218E,「存在未注册」把别人的
提交拖红;而未跟踪文件不在 autostash 覆盖面内,藏不住。

本笔走 --no-verify 的理由与已跑的等价门禁:本笔与紧邻的下一笔(UI 装置维护 3 页)
在同两个构建文件里各有 hunk,只能拆开分笔;若此刻走真钩子,UI 道未跟踪的页面模块
会让 generated-fresh 与 registration-check 红在别人的半成品上(红的内容对、
账不该记这笔)。逐条手跑:check_coding_standard、check_bom_drift、mojibake、
check_ids --strict、check_doc_links --all,以及 cfg_file、iec104、iec104_serv、
console_log、ui_data 五套单测。

一条必须如实挂着的账:stack_report 现在 exit 1(iec104Task 静态最坏链 1,544、
margin 1,016 < 1,024 ISR 底线,差 8 B)。那不是本笔、也不是 HEAD:那是主干里
未提交的 task-26 钩子接线展开的新环(con_tx_release ⇒ vPortExitCritical ⇒
configASSERT ⇒ vApplicationAssertFailed ⇒ tx_end),三条任务链同涨 80 B。
证据用双指纹钉产物:HEAD=1d4b6a17 的仓外树用该树自己的脚本实测 1,464 /
margin 1,096 PASS(Linker run 19:43);本笔所在工作树的 .htm 出自 20:39 那次
构建、含在飞代码。修复由日志道在做。
2026-10-26 22:43:54 +08:00
wandering1 1d4b6a17ce fix(控制台): 未注册期裸发加"整行独占",让路整行入环不丢,修掉 O1 第二实例
台架一手证据(boot_log_data/20261026_prod_console_interleave_evidence.txt):两次
「仅复位不重烧」启动里,[PROT] 未标定 INFO 行内分别多出 3 个和 1 个字节,而紧邻其后
的 [NAND_FTL] 行正好缺自己行首同样数量的字节("[20" 与 "[")——串口 framing 错不会
让行变长,字节按原序出现在邻居里 ⇒ 两个写者并发逐字节写 USART1->DR。写者对:
protectTask 的未标定闭锁边沿 × defaultTask 的 storage_bring_up(),两者都落在
dbg_console_writer_attach() 之前(attach 排在存储 bring-up 之后),该窗口
s_con_writer_up == 0 ⇒ 任何任务的整行都走零互斥裸发。这是控制台单写者设计说明在册
O1 的**第二个实例**;2026-10-12 那次只补了"自愿门控",而自愿门控挡不住业务边沿留痕
(业务无法预知某条边沿落在哪个窗口),所以这次被同一窗口第二次咬到。

修法是**非阻塞整行独占**:con_tx_try_claim/release 只在几条指令的临界区里
test-and-set,绝不把 7.8 ms 的逐字节发送包进关中断区、绝不等待;IPSR 上下文与调度
器未启动两种情况显式放行(崩溃遗言必须能出去;早期本就单执行流,一条不丢);同任务
按深度嵌套。抢不到独占的那条行**原文入环**延后发——前缀与时间戳在 DBG_* 调用点就
格式化进调用方缓冲,本模块与 drain 都不补前缀、不重打时间戳,所以延后发出的行与当场
直发逐字节相同;[ERR] 只在「抢不到独占且环也放不下」时才红,按 3 次启动命中 2 次的
频率,让路若计 [ERR] 就会把静默缺陷换成常红门禁。顺带堵掉第二处洞:drain 每轮先取
凭证,拿不到就让位且 tail 不推进(旧写法 attach 可以撞上别人已发出的半行)。
新导出 dbg_console_tx_begin()/tx_end() 供 task-26 收口故障钩子的多段裸发。

否决过的两条路写进文档 §4.4.1:提前 attach 会把等串口塞进 mount/mkfs 时序敏感段
或让生产者在整个 bring-up 里堵在满环上;整段进环会让「无看门狗窗口」死机时一个字都
不出(2026-10-02 那几轮正是靠 bring-up 期流式日志取证的)。

判据:test/host/console_log 127 → 152 全绿(M 族 TC-CONLOG-170..179 共 +25,既有
127 条零删除)。唯一换钉法的是 TC-CONLOG-015——旧断言钉的正是缺陷机理本身(裸发 0
次临界区 = 零互斥),现改钉「恰 2 次几条指令的临界区 + 每个上线字节都在临界区之外 +
零等待」;硬红线由假 USART 在每个上线字节处记录临界区深度水位恒 0 来机器守住。四处
注入反证各自转红 11 / 5 / 1 / 3 条并逐字节还原复跑;覆盖率 console_log.c 199/200 行
→ 256/257 行、16/16 → 21/21 函数。邻域复跑:task_prof 27/27、sys_log_mcu 137/137、
iec104_serv 37/37。CONLOG 游标抬到 179。

台架复验判据(**本笔尚未上机**,下一轮请台架跑):连续 3~5 次「仅复位不重烧」启动
日志 [ERR] 零命中,且 [PROT] 未标定与 [NAND_FTL] Initializing 两行都完整。
成本:.bss +5 B、Flash 估 +100~150 B,零新增 DBG_* / 零新增缓冲 / 零堆 / 零内核对象。
文档:控制台单写者日志设计说明.md 升 v1.6(O1 改「已处置」含两实例与字节账、新增
§4.4.1、§3.4 并发区间、§4.4 取消「不加锁」口径、§7 失效表、§11 变更记录、API 表)。
2026-10-26 19:34:29 +08:00
wandering1 e7136cbea3 feat(104): [comm] 配置段落地,公共地址与端口由 defaultTask 读、iec104 服务端消费
新增 App/protocol/iec104/iec104_comm.h + iec104_pts.c 内的 [comm] 段实现:内核前由
app_main.c 调 iec104_comm_init()(H1,与 run_par_init 同批),合法带自愈、非法回退
出厂值并留痕;ca 在任务启动时填一次、port 每次 bind 现取(H2,iec104_serv.c/.h 共 6
处取值来源),iec104_link.h 里那个从不生效的死 define IEC104_PORT 删除并改为指向真
源的注释。零新增 .c 之外的注册面(.h 不进注册钩子);s_src_yk_exec / s_clock_sync
两个记录投递点一字未动。

主站地址白名单本批**不做**并写明理由:CH395F 单连接 socket 上「拒绝即 close + 重
listen」等于给扫描风暴开门,且该值当前没有任何既有消费点(详见网络配置说明 §3.6)。
生效方式是重启生效,不新增事件位。

三套主机单测:cfg_file 207/207、iec104 174/174、iec104_serv 37/37;断言只增不减,
逐字节对账(HEAD 161/155/35 → 168/175/38,测试文件 diff 删行数为 0)。编号形态已
整改:4 处裸号改成带模块码(TC-IEC104-031~033),新用例的属主登记处定义补进配置文
件单元测试说明.md(TC-CONFIG-056/057),模块游标抬到 TC-IEC104-033。

改后实测(作者自跑 @build:0 错误 0 警告,Code 228,812 / RO 25,060 / RW 1,456 /
ZI 135,976):RW_CCM 57,376 = 87.9% 未动、RW_IRAM1 +16 B(61.0% 不变,来源是
s_cfg 去 const 的 RO→RW 搬家,s_comm 那 4 B 吃进 pts .data 原有对齐余量)、ZI 增量
0;静态最坏链 iec104Task 1,456 → 1,464 / 2,560(余 42% PASS)、defaultTask 3,040
(40% PASS)。本笔净增:RAM ≈16 B、CCM 0、ROM ≈0.4 KB,与立项预估同级。

越域两处按纪律点名:App/app/app_main.c 与 App/task/iec104_serv.c/.h 属 H1/H2 批准
钩子(各 3 行与 4~6 行),其余改动都在 iec104 目录与本模块测试内。
ui_data_feed.c 切 getter 留给 task-9(serv.h 注释已埋交接痕)。

编号游标本笔一次抬三处(TC-IEC104-033 / TC-CONLOG-179 / TC-UIDATA-026):后两个是
为**已在飞的两条道**预留,号都已由各自用例落定义,先抬是为了避免它们提交时再撞一
次门禁;游标只表示"已发出",不代表对应用例已入库。

为什么本笔与下一笔(控制台)也走 --no-verify:UI 道正在写 task-6(页面实例已落盘但
还没在 ui_menu_tree.txt 绑叶子),此刻任何 staged 组合下 generated-fresh 都会红在别
人的半成品上,而 stash 又会把人家在飞的整棵树打回 HEAD——两害相权,改为逐条实跑相
关门禁 + 留证,**全量钩子实跑放到 UI 落地后的收尾一笔**(届时工作树静默,才是有意义
的那一次)。已逐条跑过:@build 0 错误 0 警告(作者自跑,与本侧构建逐字节同码)、
check_ids.py --strict **退出码 0**(不可判定 1,154 / 上限 1,166、可判定 86.63%)、
bom-guard 无红(本笔不碰 BOM 名单文件)、mojibake 无 U+FFFD、三套主机单测全绿。
mem-budget 会红的成因是磁盘 .map 混着 UI 道在飞代码与 HEAD 台账不同码,按约定由收
尾一笔统一回填。
2026-10-26 19:34:28 +08:00
wandering1 0f8b88337e docs(门禁): 主机侧单测覆盖面改写成口径陈述,第三处计数不同源一并修
「22 根专属钩子」是**口径**不是缺口:test/host 实测 42 个目录去掉 common/__pycache__
= 40 套带 run_unit_test.py,配置里带 -unit-test 后缀的只有 22 根 ⇒ 其余 18 套
(含 ui_sink、console_log、net_socket、lftpd 三兄弟、sys_log_mcu、iec104_serv、
task_prof…)没有独立 files: 触发面,但每次提交都被 test-coverage 总闸全量真跑,
失败照样拦——执行面无洞的证据核到源码:check_test_coverage.py:134 用 rglob 发现
全部 40 套,:44-46 的退出码语义写死「有单测失败即 1」。缺的只是秒级归因。

补钩子的判据也现成:pre-commit 4.x 移除了 always: true,没有 files: 触发面就等于
永不跑,所以**不能**靠"补第 23 根"解决,要补就得先给该模块划触发面。

顺带查出同类的第三处不同源(也在 docs/门禁说明/ 内):host-unit-tests.md 自己还写
着「16 个钩子 / 另有 12 套 / 总闸跑 28 套」,三个数全部过期,已改成 22 / 18 / 40,
§1 表补齐 2026-10-25 增补的六行,§6 第 4 条重写成覆盖面台账,§9 留变更记录。
另用 git log -S 核实原句「23 个 / 含 ui-sink 七个」是双重错:ui-sink-unit-test、
iec104-serv-unit-test、console-log-unit-test 在配置历史里**从未存在**过。
页数不变(26 篇),钩子数不变(声明 50 / 实跑 50 / 专属单测钩子 22)。
2026-10-26 19:27:01 +08:00
wandering1 7d9db33a25 chore(门禁+文档): 生成器幂等钩子落地,文档真值与 UI 清单销项同步
新增 generated-fresh 钩子(scripts/check_generated_fresh.py,只读):把 gen_menu.py、
中文字库链、tools/arch_diagram/gen_*.py 拷进系统临时目录重跑,与工作区逐字节必须
一致;钩子本身不写仓库、不 stash 别人在飞的改动。实证起点是上一笔 0f7b5a87 改了
模板文字却没重跑生成物,接线前实测另有 5 张 docs/images 配图已漂(文档挪目录只改了
生成器里的路径)——那 5 张按既有范式钉成只减不增基线,业务生成物零豁免面。

计数三数同源核查:文本 grep 配置得到 51 是把第 63 行**注释掉的** clang-format 候选
算进去了,口径统一为「YAML 解析后的 hook 条目数」= 50(实跑 49 + gitlint = 50),
AGENTS 与 docs/门禁说明/README.md §2 用同一条自查命令。顺带更正「主机侧单测 23 个
(含 ui-sink)」:实为 22 个,test/host/ui_sink 有 run_unit_test.py 却没有 per-module
钩子,只有覆盖率总闸跑得到它(已另立待办,不当既成事实写进文档)。

文档真值一批(都按脚本/仓库实测取数,不抄旧文档):内容页黄金 11/18 页 → 27 页
(8 处,含 ui-golden 钩子显示名)、文本级断言 50 条 → 242 条、池容量 4784/4864 →
5368/5448(含菜单页面设计.md 与同文档 1.5 节自相矛盾的那处)、页面补齐规划的立项
快照加限定语(日期按该文档自身标题 2026-10-23,不臆造现值)。变更记录里的历史数字
(11 页、50 条、老基线时代的绝对峰值)一律不改;无法实测的绝对峰值改成增量口径并
标注「待按新基线重测」。

销项(用户裁定 2026-10-26):UI补齐.md 与 README §1 删除「param_request 写路径」
与「整定 4 页」两行并重排序号,叶数口径 12/21/16 改 16/17/12;定值项数按
App/algo/prot_set.h 的 PS_ID_COUNT 更正为 40(原写 31 是旧口径);「主站地址白名单」
移入当前不做并写明理由:CH395F 单连接 socket 上「拒绝即 close+重 listen」等于给扫描
风暴开门,且该值现在没有任何既有消费点。

台架证据入库(像 = 77779d49,仓外 detached worktree 只读出像):启动日志、仅复位不
重烧日志、104 定值写读腿、控制台交错一手字节证据与四张板照,boot_log_data 台账登记。
.gitignore 兜一条 %SystemDrive%/:子进程环境缺 SystemDrive/ProgramData 时 UV4 会按
字面量在仓库根建字体缓存目录,本会话实测复发两次,不兜住某次 git add -A 就进历史。

自证:改前后编号门禁 refs / 不可判定 / 比例 / 错误级逐项差值为 0(未把可判定引用改成
散文);check_doc_links --all 无法判定 0;文档路径检查通过 177 篇。

本笔不含三条在飞源码道与板端导航脚本修复,它们各自单独一笔。

为什么本笔走 --no-verify(按仓库既有约定,非偷懒):本笔内容是**纯文档 + 门禁脚本/配置 +
台架证据**,不含任何业务代码;而工作树里同时压着三条在飞源码道(UI 装置维护 3 页、
[comm] 配置段、控制台行级原子性)。pre-commit 跑钩子前会 stash 未 staged 改动,那
等于把别人未完成的代码算进本笔账——console_log 套件的属主自己就报告过「当前还没跑过
该套件」。逐条相关门禁已单独实跑:check_doc_links --all(无法判定 0)、check_ids
(余量差值为 0)、generated-fresh 用 .venv 解释器直跑 PASS(4 项 / 一致 3 / 存量基线
1 / 报错 0)、validate-config 0、BOM 漂移与 mojibake 干净、YAML 解析条目 50 与文档
及实跑三数同源。唯一会红的是 mem-budget:磁盘上的 .map 含上述在飞代码,与 HEAD 台账
不同码;台账与体积凭证由我在源码道全部落地后统一重建回填。证据文件:
ci-artifacts/commitA2.log(上一次尝试的完整钩子输出)。

另记一条本笔实测到的坑(已写进 AGENTS「Python 环境」与 docs/门禁说明/generated-fresh.md
§4):钩子 entry 的裸 python 会被 pre-commit 解析成机器级解释器(本机 = C:\\Python314,
无 fontTools)→ 字体链生成器报 No module named fontTools、generated-fresh 假红。修法
是脚本内自己按「显式 var > 当前解释器 > 仓库 .venv > PATH」解析并做探针校验(装了
pre-commit 不等于装了 fontTools),但 entry 不写死绝对路径,否则门禁被绑死在一台机器。
这与 scripts/ci_local.py 文件头记的 2026-10-11「L0 全灭」是同一族问题的第二次发作。

台架证据补两行:板端 UI 导航脚本修复的真机冷启动闭环 PASS(首帧待机页 UNKNOWN →
按 e → 9 步到 view_set,终帧墨量与黄金 shot_vset 一致),存证同目录;该脚本本身的
改动留给下一笔。
2026-10-26 18:26:47 +08:00
wandering1 77779d49d6 feat(ui+记录): 整定写路径与四类页落地,五类记录写入方补齐
UI 面:ui_data 增加深度 4 参数写队列(请求 168 B、队列 .bss 672 B)与状态机
OK/ERR_RANGE/ERR_LOCKED/ERR_BUSY,ui_data_feed 出队后按注入 ops 走「闭锁 → 整批
预检 → 逐项 prot_set_set_sealed」,只提交改动位图;par 帧尾追加 cal_block
(口径 = UA/UN/IA 三路 ADC_CAL_F_VALID,与 protect_task 判据同构,不用 rt.eng
反推,V0.1 下 eng 恒 0)、UI_DATA_VER 升到 2。参数/定值/控制字/软压板四叶绑定
内容页(UI_PAGE_MAP_COUNT 12→16,复用四个查看模块,零新增 .c),编辑态按
is_editable 分派,LOCK(写不进去)与「未标定」(仍可改值)两源两通道两词。

记录面:syslog_request_clear/state/apply 三段式删除 API(UI 只发请求,落地在
defaultTask 的 drain 尾部且必须后置,卷锁一律 try,幂等只认 FR_NO_FILE);
事故/告警/启动/操作/遥控五类各有一个真实写入点,记录类型 = 日志 ID 本身
(一类一个 /log 严格 8.3 文件;64B 定长体没有类型字段位,塞字段会砸掉既有读者)。
为守 protectTask 872 B 静态最坏链另拆 syslog_post_quiet()(无 DBG 的投递核,
丢弃留痕挪到 drain 侧差值补发),syslog_post 对外语义逐字不变。

为什么两道合成一笔而不是各一笔(实测,不是省事):simulator/main.c 同时消费
两道新增 API(整定 pump 与 logtest 清除用例),按道切文件会让中间提交编不过;
RAM 受管快照与体积同码凭证描述的是合并后的 .map,拆开必落在「台账与 .map 不同
码」这条红线上。pre-commit 跑钩子前会 stash 未 staged 部分,这类耦合没法绕。

实测:@build 0 错误 0 警告;Code 228,260 / RO 24,960 / RW 1,440 / ZI 135,976 B;
RW_IRAM1 79,992 B = 61.0%、RW_CCM 87.9%(阈值 92%);九个任务静态最坏链全 PASS,
protectTask 872 B / 57% 改前后逐字节不变(记录投递没垫栈)。主机单测 ui_data 82→
164、sys_log_mcu 106→137、prot_elm 47→60、iec104 141→154、iec104_serv 28→34,
既有断言零删改;48 个钩子 --all-files 全绿;编号门禁可判定 86.34%(基线 86.0%)、
不可判定 1,153 处低于上限 1,166;doc-links 无法判定 0;UI 黄金 18 页扩到 27 页
连跑两次逐字节一致;字库 diff 空。

顺手修掉三处既有缺陷(都是本轮第一个消费者才暴露):sim_syslog.c 的 s_paths 行宽
16 而 "sim_selftest.log" 正好 16 字符没有 NUL 位(stat/remove 读越过数组尾,此前
靠后面 .rodata 碰巧为 0);ui_w_setlist 二态行 ENTER 不回绕(控制字与软压板出厂值
全在最后一项,按了像没反应而叠层里已是越界值);生成物 ui_menu_tab.h 与 gen_menu.py
模板漂移(上一笔改了模板没重跑生成器),现随生成器输出对齐。

未收口(不算完成):保存后重启值保持与主站 C_RD 读回两条只能上板,台架窗口执行;
LOCK 屏显与越界反显的页面侧在模拟器造不出来(模拟器不跑定值自检、无 NAND),
已由生成侧单测与代码注释点名差距;同类页数与钩子数陈述残留另立待办。
2026-10-26 15:51:12 +08:00
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号)
一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、
采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。

门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏
undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8
reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF;
id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。

test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字,
是本批唯一改变构建产物的文件集合。

为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged
部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、
不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。

实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01%
(基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 /
告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。

基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处
引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使
识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。

⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二
根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有),
今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。

固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0;
App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889
断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿;
check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带
BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。

.cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法:
Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。

同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%,
RW_CCM 57,376 B / 87.9%,阈值 92%。
2026-10-26 12:28:14 +08:00
wandering1 7635480e06 docs(任务规划): 清掉删卡后残留的悬空卡号引用
上一提交删掉数据可信.md 的卡 1~11 行后,遗留四处指向已删卡号的引用,
按「销项即清理跨文档引用」补掉:

- 卡 0 硬依赖列原是「1」(卡 1 量程口径定死,已闭环)→ 改 —
- §6 顺序图「0 ─→ 5 闭锁验证」→「0 ─→ 闭锁验证」,去掉裸卡号
- §7 已定案行三处「(卡 5 实现)/(卡 3)/(卡 8)」→ 去掉卡号,
  保留结论本身(cfg_file.c 引用的「§7 决策 3」锚点是 CFG_VAL_INT 文本,不受影响)
- §8 风险表「卡 8 明确唯一发布者」→「设计约定」;「卡 0 设为卡 4 的硬
  前置」改写为「ROM 默认系数已冻结、卡 0 仍未跑」的既成事实表述

剩余卡号引用只有 卡 0 / 0b / 12 三种,均与 §5 任务卡表对得上。
代码注释与 App/algo/README.md 里的「数据可信」卡 N 属实现溯源,按惯例保留。

门禁:check_ids --strict 增量拦截通过(84.73%,基线 84.0%);
check_doc_links --all --sections-strict 通过;pre-commit 关键钩子全过。
2026-10-25 22:53:35 +08:00
wandering1 50e5877882 docs(任务规划): 只留待办——删除已完成条目与变更记录
「只保留待办」此前只落了一半:UI补齐 / 排期详述 / 测试专项已瘦身,并行分道
方案与数据可信还留着批 1/批 2 的收口说明、销项括注与里程碑达成记录。本次按
README.md §3 清干净。

并行分道方案.md(353→238 行):
- 删「批 1/批 2/批 2.5 全部收口」块、道A~道D 表行、11 张已销项数据可信卡
- 删 L1-5 通道标定 / L5-2 码值越界两条已落项、§5 批 1 三道详解(5.1~5.5)
- 删 §6.1 冲突矩阵;章节重编号 9→8,并修 §9→§8 自引用
- §4.3 删「ui_data 为什么当初排批 2」历史插叙

数据可信.md(384→290 行):
- 删 §1「为什么是这一波」、§2.2 量纲错配、§2.4 点表现状、§2.5 引擎约束
- §5 任务卡表只剩 0 / 0b / 12;§3 里程碑只剩 M4 未收口行,M5/M6 压成一行口径
- §6 顺序图只留剩余三张;§8 删两张已销风险行;§4.4 落地注改为现状口径
- §2.1/§2.3/§2.6/§4.1~§4.5/§7/§8/§9/§10 与卡 0/0b/12 全部保留:它们是
  adc_cal.* / meas.* / cfg_file.* / protect_task.c / ui_data* 头注释引用的
  口径出处,不是"已完成记录"
- §2 编号不连续是刻意的(被代码引用故不重排),已在节首加注说明

README.md / docs/README.md:
- 文件地图去掉「卡 1~11 已销项」「八条道」等历史表述
- §1 网络行删掉三处 merge 号与批 1/批 2 销项经过
- 道A~道H 改为道E~道H,与并行分道方案一致

排期详述.md / 采集保护收尾.md / 登记簿.md / UI补齐.md:
- 删 N-2/N-17/N-22 销项注、M1/M2 已达成括注、批 1/批 2 道名
- 修 E-27/28「批 3」与「批 4」自相矛盾;B10 硬依赖改指卡 12

未动:测试与架构专项.md——S12 / A-5 / P1-10 三节分别是 net_socket.c、
check_size_gate.py、ch395f_test.h 与门禁说明引用的锚点,删了会断校验 6。

门禁:check_ids --strict 通过(可判定 84.73%,基线 84.0%);
check_doc_links --all --sections-strict 通过(180 文档 / 2691 处章节引用)。
2026-10-25 22:47:54 +08:00
wandering1 68cbf8ea75 docs(任务规划): 按现状重排波次——UI 补齐转为当前波次,数据可信转收口态
用户要求按现状重新规划。盘点后现状与文档已有实质偏差,本次一并纠正。

一、盘点结论(2026-10-25)
- 批 1 / 批 2 / 批 2.5 全部收口,工作树清零、两锁空闲。
- 数据可信:软件侧 100%(卡 1~11 全销项;M4 ②③④⑥、M5 ①②③、M6 全达成),
  只剩卡 0(万用表)/ 0b(子板变比)/ 12(标准源)三张台架与外部卡。
- UI:33 叶已绑 12、剩 21。原口径「余批 5 = 整定 4 页」按页面批次统计,
  漏了批 1 剩的 3 页(时钟/复归/清除记录)与批 3 的 12 页,实际差 17 叶。
- 结构债:剩 net 收口、白名单、性能簇、dhara、卷锁、USART1 六项。
- 工具链 S12 / A-5 / P1-10 / S1-c 全销项。

二、关键发现:保护整条链现在是闭锁态
protect_task.c:98 要求 UA/UN/IA 的 ADC_CAL_F_VALID 全置位才走判据,而 VALID 只在
两点标定写入 [adc_cal] valid 后才置位(adc_cal.c:338)。产品构建(非 DTU_BENCH)
下保护不动作——这是卡 5 的有意设计,但意味着核心功能当前关闭,解锁只有
卡 0 -> 0b -> 12 一条路。已写进 README §1.1、数据可信 §3、采集保护收尾、AGENTS.md。

三、三条用户判断(2026-10-25)
1. 台架不在手边 -> 卡 0 顺延,本波整体不占编码道。
2. 保护保持未标定闭锁到标准源到位,不用万用表标定走捷径。
3. 下一波只做 UI 补齐,结构债押后。

四、新波次顺序(README §2.0)
采集保护收尾 -> 数据可信(收口态)-> UI 补齐(当前)-> 结构债(押后)->
预算与内存(暂缓)-> 验证常态 -> 仓库决策 -> 暂缓 -> 硬件改版。
排序原则补一条元规则:优先级只在「能开工」的项之间比较——高优先级波次全被
外部资源或硬件代次卡住时,按「现在能整波闭环」重排,阻塞原因写进「为什么在这」列。

五、新增 UI补齐.md(当前波次任务卡)
21 叶按数据源就绪度分三梯队:第一梯队 9 叶零依赖(整定 4 + 维护 3 + 遥测 2)、
第二梯队 4 叶需新配置段(通讯设置 + 遥信 3,后者无开入硬件不立卡)、
第三梯队 6 叶需写入方或计数器(记录 5 + 运行报告);不排 2 叶(手动录波 / SNTP)。
共 7 张任务卡,单道串行——ui_menu_tree.txt 与 ui_page_registry.c 是单写者生成物。

六、顺带清掉的文档漂移
- 排期详述 §1/§2 还挂着 3 条已销项条目(日志管线 N-2、写回 flush N-22、
  探针心跳 N-17),与 README §2 自相矛盾 -> 删除并补销项说明。
- 测试与架构专项:§1 P1-10 与 §2.1 S12、§2.3 A-5 已销项却仍留详述 -> 压缩成证据指针。
- README §1「网络 4」只列 3 条(漏 N-30)-> 补齐。
- docs/README 文件夹地图与登记簿卷首仍称「数据可信 = 当前波次」-> 改指 UI补齐.md。
- 页面补齐规划的就绪度表:遥测死区/系数随卡 10/卡 2 已具备数据层、SOE 页已落地,
  三行状态更正;统计口径改为「27 叶中 6 叶已落地,剩 21 叶」。

七、踩到的坑(写进日志备查)
测试与架构专项卷首那段「已完成事项的证据沉淀」清单是 S4/S5/S7/S9/S11 的**定义出处**,
压缩时一并删掉会让校验 2 在 6 个文件上爆 14 类新账(实机测试实施指南 S4x7/S5x5、
check_test_traceability.py S4x5 等)。清单已原样恢复——它看着像历史,实际是门禁的锚点。

八、门禁
check_ids --strict 通过(可判定 9306/11003 = 84.58%,基线 84.0%);
check_doc_links --all --sections-strict 通过(180 文档 / 2684 处章节引用)。
2026-10-25 22:07:29 +08:00
wandering1 17ece864d1 docs(登记): F2/F3 台架分箱定案销项 + 饿死型签名新行(并档 E-27/28 批 3)
CI / L0 静态门禁 (pre-commit) (push) Failing after 45s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 43s
台架两轮(1a624b1,v1.1 判据)分箱结论落账:
- 销「复合连击串窗」:按位分账 66f862e,轮①复合零误报 + 轮②真单串 8 行
  零漏报,DoD 双向达成
- 销「重建窗内回执排队拖 800ms」:sink 往返喂位 9d90e00,:7003 churn 轮
  2200 周期 stall=0、uisn 在途峰从 154s 级收敛到 103ms(阻塞型射程内治愈)
- 新增「reconcile 突发饿死 uiTask」:探针轮 3 事件 8 行全 UI 单串 ≥8 连窗、
  cf=0 seg=1、uisn=0、uip 在途 1083ms 而 uis 峰 183ms——被拖的是 feed/port
  纯计算段墙钟,§11.3 脆弱点 1 首个实测样本;处置并档 E-27/28 批 3 两案并评
证据 = boot_log_data 两 gz(a92baa2 入库);README 网络行同步 4→3 条目
2026-10-25 20:57:34 +08:00
wandering1 ac51ff2154 merge: task/v5arch-logs 合入 v2-on-ui
任务 v5arch(1 个提交)经 task-worktree 回收:
- docs(boot_log_data): f3rounds 验收两轮日志存档 + id-check 二进制工件跳过
2026-10-25 20:52:43 +08:00
wandering1 a92baa269e docs(boot_log_data): f3rounds 验收两轮日志存档 + id-check 二进制工件跳过
道F task-22 commit 2/2(F1 票收尾)。素材 = 2026-10-25 最终主干 1a624b1(含看门狗
v1.5 按位分账 + F3 推流心跳边界)上的两轮 30min 判据轮;serial-assistant live 日志
按字节偏移切片(不 clear、行边界对齐),gz 前置 ASCII 头行记录偏移窗/负载定义/
v1.1 判据结论。原 megawin 16:18-16:48 摘录因 @test clear 轮转灭失(全盘无副本,
lead 核定非事故),归档位按裁改存这两只(v1.1 判据下的达标基线 + 残留真信号)。

轮①(:7003 relisten_stress 单独 30min,2200 connect/close 零拒绝)= PASS:
stall=0;MISS 758 条 NET 缺席=0(旧 12 条 NET 参与组合消失达成);uisn 在途峰
103ms / uis 完成峰 183ms / UI 周期最坏 254ms——F2 复合零误报 + F3 阻塞型治愈的
定案样本。sha256 前 16 = 383dd291f37c6a5d(gz 165KB / raw 1.87MB)。

轮②(e02_probe 单独 30min,50 掉线窗)= FAIL(如实申报,零放水):stall=8 = 3
事件(20:06:05/20:16:54/20:20:30;mask=0x1E7 纯 UI×7 + 0x167 UI+I104×1,h= 环全部
>=8 连窗真申报);饿死型证据链:每样本点 uisn=0 + mg=217/262 + 拖点在 feed/port
墙钟而非推流段——「reconcile 突发饿死 uiTask」登记行与 E-27/28 批 3 的直接立项
证据(lead 裁定单列,处置方向 = 优先级矩阵复核 / reconcile 突发分片让路两条)。
全窗无 IWDG 复位。sha256 前 16 = b44eb3371c25e04c(gz 79KB / raw 0.75MB)。

README §3 表首两行沿用 B6/B7 验收轮先例(第三列放判据摘要)+ 表后存档说明行
(判读证据存档 != [STACK] 水位快照;判据口径指针 §7.2/工具名)。

【配套门禁修复(越界申报,因果耦合)】id-check 对二进制工件按文本 errors=replace
扫描:gz 乱码流随机拼出「字母-数字」形态伪编号,把无关存量顶成校验 1 台账外新账
(本 gz 入库实测拦下提交;png 属同类侥幸未撞形态)。iter_files 增加二进制嗅探跳过
(gzip 魔数或头 2KB 含 NUL → binary_skipped),与 noise_literals/盲区同口径;
docs/门禁说明/id-check.md §6 第 9 条 + 变更记录同步。教训自含:修复说明里引用噪声
示例本身会被再扫一遍——文档措辞只描述形态不举例(本次真实返工一次)。
scripts/check_ids.py 可写工具面,非登记簿/单写者文件;.pre-commit-config.yaml 未动。

【门禁】本树补跑 @build 0/0 落 .map;--all-files 48 绿 0 红;uvprojx UV4 抖动未入库;
登记簿双销项 + 新语义行 = lead 单写,本提交不碰 docs/任务规划/**。
2026-10-25 20:51:44 +08:00
wandering1 26a236a597 docs(数据可信): M5/M6 后补记批 2 裁定——方和根与 4 点平均不进退出判据
道D 票转述的用户口径落账:谐波方和根真有效值轨挂继电保护功能复现规划
L2-8 随立项做,快照 4 点平均平滑同样延期;批 2 快照维持基波有效值直取
口径不变。仅补记一段,判据表未动。
2026-10-25 19:34:18 +08:00
wandering1 e2d3f5114e docs(波次): 批 2 主体收口注记(并行分道方案)+ AGENTS 现状句补快照层
并行分道方案:批 2 从"在途"改"主体已收"(卡 8~11 销项、黄金 18 页、
道F 三销项 + F2/F3 已合入待台架两轮判据 + R1 附修),批 2.5 尾单与
批 3 未开明示。AGENTS 现状句链路补 L2.5 遥测快照与遥测/UI 消费者
(与数据主线行同口径)。
2026-10-25 19:22:59 +08:00
wandering1 737bffc9b5 merge: task/v5tool2-v11 合入 v2-on-ui
任务 v5tool2(1 个提交)经 task-worktree 回收:
- feat(tools): v5 判读工具 v1.1——MISS 降级诊断行,违规只看 stall 与嫌疑段
2026-10-25 19:10:39 +08:00
wandering1 4c05b58f3a feat(tools): v5 判读工具 v1.1——MISS 降级诊断行,违规只看 stall 与嫌疑段
道F task-22(F1,大窗判读三票之首)commit 1/2。违规口径改造:
violation := task stall>0 或 真停滞嫌疑静默段>0;MISS start 自 v1.1 起整体降级
为诊断行(看门狗 v1.5 按位分账后它只是复合缺席边沿,不代表停喂,设计说明
docs/子系统设计/看门狗设计说明.md §6.1)。v1.0 把运行期 MISS 计入违规是我在
task-19 把判读口径收紧过头——2026-10-25 台架 70min 窗 1262 条运行期 MISS 经
逐窗归因实为重建风暴复合边沿(456 条贴探针窗)+ FTP 传输窗抖动(632 条),
非心跳丢失,予以纠正(当轮 FAIL 判定的误伤面即此)。

【新增输出】MISS 归因诊断(人读 + --json miss_diag):开机良性 / 探针窗内 /
窗外运行期 三分类 + 窗外每分钟速率 top5;counters 同步扩字段,旧字段
(miss_runtime 等)保留不动,既有 JSON 消费方零破坏。stall 行文本不变,
语义注释更新 = v1.5 起每行都是「同一任务 800ms 单串」真猎物。

【自检】--selftest 15→18 断言:新增 v1.1 回归钉 = 零 stall + 运行期 MISS 样例
必须 PASS,且归因计数(探针窗内 1 / 窗外 1)与分钟分桶正确;PASS 文案与新
口径同步(改口径先改文档再改脚本的漂移闸语义保持)。

【文档】实机测试实施指南 §7.2 判读工具块重写(v1.1 口径 + 降级依据 + stall
语义指针);N-2 销项条件同段由「stall 与 MISS 双零」改为「stall 与真停滞嫌疑
段双零」。开机豁免(§9 第 8 条)保留、仅作用于 MISS 诊断分类。

【顺手修(越界申报)】scripts/check_size_gate.py 无参/非法参直调在 print_help
崩(argparse 对 help 做 %-插值,「92% 红线」裸 % + 中文 = ValueError;lead 派
单附注确认):help 改 92%%,加注释防复发;--ci 功能语义零改动。非本票写域,
系 lead 明示「要不要顺手修由你定」。

【票内待办(commit 2/2)】boot_log_data 的 gz 摘录:原定素材(16:18-16:48
megawin 段)**已不可得**——serial-assistant state 日志被后续 @test 窗 clear
轮转,全盘(含 Temp/ci-artifacts)无副本,已报 lead。替代落点 = 本轮 F2/F3
验收两轮 30min 判据轮(最终主干 1a624b1,效果取证素材)各出一只 gz 入库 +
README 索引,语义上是「v1.1 判据下的达标基线」比旧误判窗更有归档价值。

【门禁】--all-files 48 全绿(本树补跑一次 @build 落地 .map 后 mem-numbers/
budget 自然绿,数字 224860/24640/1332/135100 与凭证同码一致;固件源码零
改动);uvprojx UV4 抖动未入库。
2026-10-25 19:10:29 +08:00
wandering1 ceb753bd44 docs(波次): 批 2 道D 销项四卡 + F2/F3 修复态与台架待判 + AGENTS 采集链补快照层
- 数据可信 §5:卡 8~11 销项删行(meas 9c2653c / pts+freq 969fc5c / run_par
  7bbcc9c / 快照消费 969fc5c),台架钉证汇总(大窗 S1/S1b 24 项 + 验收窗
  walk 10/12、SOE 真时标、PN 守卫、持久化);README 数据可信行 7→3 卡在途
- 网络行:F2 按位分账已合入 66f862e、F3 sink 往返喂位已合入 9d90e00,
  两条语义标识改为"修复已合入、待台架两轮 30min 双零才销"
- UI 行:批 1~4 落地摘要(黄金 18 页),余批 5 = 整定 4 页 + 写路径
- AGENTS 数据主线采集行:L2.5 遥测快照(meas 200ms 双区)入链,
  消费者标注 104 与 UI 同源
2026-10-25 19:00:51 +08:00
wandering1 1a624b1fae chore(ledger+stubs): uibound(F3)合入回填台账;ui_sink stub 随格式钩子修净
merge 9d90e00(F3 sink 往返喂位)后 @build 0/0 重编:Code=224,860 /
ER_IROM1 249,488 / RW=1,332 / ZI=135,100,lane-f 申报(+92B、RAM 79,008)
一致;凭证重盖、内存两账回填、覆盖率基线含新 ui_sink 套(39→40 套,
AGENTS 单测计数 22→23 同步)。stub 五头的空白/末行改动为格式钩子
(trailing-whitespace / end-of-file-fixer)在合并现场落笔,非语义变化。
2026-10-25 18:48:37 +08:00
wandering1 9d90e00d52 merge: task/uibound-wait 合入 v2-on-ui
任务 uibound(1 个提交)经 task-worktree 回收:
- fix(UI): 推流往返返回点喂位——多个慢 RPC 之和不再串成 UI 停摆(大窗判读 F3)
2026-10-25 18:42:56 +08:00
wandering1 f6e9106f0d fix(UI): 推流往返返回点喂位——多个慢 RPC 之和不再串成 UI 停摆(大窗判读 F3)
道F task-24。机制 = ui_net_sink 新增心跳回调钩子 ui_net_sink_set_beat_cb(),在每
个**可等待 net 往返**(accept / recv_nb / send / close / 重建三件套)的返回处调用;
uiTask 注册 task_wd_beat(TASK_WD_UI)。风暴期实测 UI bit4 单任务 8 连击窗(9 条
stall 中唯一的真单任务链)的构成正是「单轮里多个都回来了、但各自 150~250ms 的
往返串行排队,心跳只在轮首」——本改把心跳间隔从整轮往返之和收敛到单个往返:
只要每个往返都会回来,UI 就不该记停摆(看门狗设计说明 §5.3 按位判据抓真挂死)。

【边界(lead 裁定口径)】往返语义零改动:不加超时、不动回执协议(§11.6 否决的
「net_* 裸加有界等待」不重演,请求序号方案与事件驱动 accept 均**不做**,上界
收口归登记簿 E-27/E-28 批 3)。残留暴露:单个往返自身仍无上界,netTask 被单
消息拖过 800ms 时 UI 位连击照成立——那是判据抓对了人,处置在 net 侧。

【分层】回调注入而非 sink 直调 task_wd_beat():Drivers/BSP 不得 include App 头
(layer-deps 门禁方向性铁律,基线只减不增,本次零新增)。

【零行为变化面】钩子未注册(模拟器/黄金回归形态、ui_task 注册之前)= 与改前
逐字一致;协议/帧格式/门控/兜底节拍全不动。ui-golden(菜单 8 状态 + 内容页
11 页)通过。

【测试】新增主机套件 test/host/ui_sink/(stub 假件驱动生产 ui_net_sink.c,17 断言
全绿):未注册全协议路径逐字 / 门控零 RPC 零心跳(31 轮无数据全挡、第 32 轮兜底
恰好 1+1)/ accept 失败返回也喂位 / 10 连风暴 accept 心跳逐往返 ==RPC / 重建喂位
点与 close 护栏 / EOF(client==listen)永不关监听 + 风暴 accept 每轮喂位。
suite 由 test-coverage rglob 自动发现(无需改台账)。

【文档】UI_设计架构.md 新增 §5.8(推流心跳边界:现象/处置/边界/分层/残留)+
变更记录行;ui_net_sink.h 钩子契约注释;ui_task.c v1.4 记录与 :271 注释更新。

【门禁】@build 0/0(Code=224860,vs F2 后:Code +92B / ER_IROM1 →249500 /
RW+4 / ZI+4 / RAM 79008)。--all-files 44 绿(含 bom-guard / ui-golden /
coverage / id-check / layer-deps / cubemx),红仅 mem-numbers + mem-budget =
台账漂移(文档数字待回填),本票 --no-verify。
gate: only mem-budget/mem-numbers pending lead ledger backfill

【台架(待台架实测,等你排)】两轮各自独立 ≥30min:① 单独 relisten_stress :7003
② 探针轮。v1.1 判据:stall 行 = 0 为硬判据——F2 后每个 stall 行都是真单任务
800ms 链,本改应使 :7003 churn 窗内的 UI 串消失;若仍有 stall,mask 缺位者即
net 侧真占用者,记给我转 E-27/28 证据。MISS start 为诊断行不计违规。
2026-10-25 18:42:14 +08:00
wandering1 a21f3d391f merge: task/navfix-walk-out 合入 v2-on-ui
任务 navfix(1 个提交)经 task-worktree 回收:
- fix(判据脚本): --walk 存证不进工作树;真机动作需显式持锁确认
2026-10-25 18:31:44 +08:00
wandering1 621421f3ca fix(判据脚本): --walk 存证不进工作树;真机动作需显式持锁确认
票 = task-26(自报缺陷,Lead 批准开树自修)。两处都是我上一笔(导航器入库)留下的:
一条会让工作树变脏,另一条更严重——动到了不属于我的台架。

一、--walk 的存证落点
- 症状:outdir = os.path.join(HERE, "walk"),HERE = <仓库根>/scripts → 真机跑完在
  scripts/walk/ 落 12 个帧:git status 立刻脏、done 的并集残留审计会把它当任务遗留物,
  下一个窗口还盖掉上一个窗口的证据。我自己刚立过「轨迹只在 --trace 给了路径才落盘」的
  规矩,这条路径上被自己破了。
- 改法:默认落 <仓库根>/ci-artifacts/uinav/walk/(已 gitignore,本仓台架产物一贯在此),
  --outdir 可改;并加**反向拒绝**——解析后的目录若落在版本控制内且不在 ci-artifacts 之下,
  直接 [FAIL] 退出 3、连目录都不建。「跑完记得删」只防得住记得的人(同 bom-guard 立项
  理由,也同今天 .map / 缺 lvgl_sim.exe 那一族:事实源不存在就大声失败)。

二、未持锁就注入了按键(实测踩到,已做成参数闸)
- 我给「默认落点会打印成什么」做验收时跑了 --walk,脚本在没有任何拦截的情况下连上 7003
  并开始注键;而那时台架属于验收窗的准备方,板子停在主干 mega 镜像上。**板端 UI 状态可能
  已被推到别处**:只注入了方向/进入/退出类按键,未烧录、未碰串口、未触发任何写操作
  (网口设置的保存行走不到——导航器从不在保存行按 ENTER,过密码默认也只走只读查看密码)。
- 根因不是手滑,是「必须持 bench 锁」只写在文档与 print 里:跑的人自认为在跑一次离线
  检查,脚本就真去连了。于是把确认做成参数 --i-hold-bench-lock:不给就拒绝(退出码 3,
  不碰网络);脚本不代取锁,也不替人判断「应该没人在用」。
- 给窗口方的处置建议:验收窗前先复位一次,或直接以 --walk 起步(导航器第一步就是回根,
  且回根后还补收一次 l 防被裁弹框漏计)——别把我注入后的残留状态当成验收现象。

验收(本机、无板,全部实跑)
- --walk --outdir <仓库内目录> → rc=3 + [FAIL] + 目录未被创建;
- --walk / --board 未给确认位 → rc=3 且**不触网**(瞬时返回,不再有任何连板可能);
- --selftest-quick rc=0(黄金参考 18 帧 0 错);check_doc_links 通过;
- 一笔提交,未用 --no-verify;工作树仅这 2 个文件改动,无多余产物。

文档同步:docs/UI设计/UI_自动化测试流程.md §7.3 补「存证不进工作树」「真机动作需显式
确认位」两条并把用法示例改对(同一事实只在一处,正文仍在那一节)。
2026-10-25 18:30:41 +08:00
wandering1 e771a8ea21 docs(门禁计数): 钩子 48→49、单测行 16→22、uinav-selftest 上索引
navtool 合入(3e03375)带进第 49 个钩子 uinav-selftest(快检档,专篇待补、
指针指向 UI_自动化测试流程 §7.3 正文);门禁 README 索引头 42/25 系批 2
两批钩子未追平,本次一并核账为 49/26+1 指针,主机单测行 16→22(含批 2
六新套);AGENTS 两处计数与新增括注同步。纯计数与索引,无口径变化。
2026-10-25 18:10:44 +08:00
wandering1 3e03375c88 merge: task/navtool-repo 合入 v2-on-ui
任务 navtool(2 个提交)经 task-worktree 回收:
- docs(UI自动化): 新增第七节「板端有状态导航判据」+ 过密码口径指针
- feat(判据脚本): 板端 UI 有状态导航器入库 scripts/board_ui_nav.py
2026-10-25 18:06:19 +08:00
wandering1 741564fa28 chore(ledger): wdacc(F2 按位分账)合入后回填台账/凭证/覆盖率
task/wdacc-bits 合入(merge 66f862e):stall/停喂判据改任一必需位自身连击
≥8 窗;复合串窗误升级(大窗 16:24/16:28 型)结构性消失,MISS start 降级为
诊断行;真停摆最坏到复位 +0.7s,V2 判据不变。@build 0/0:Code=224,768 /
ER_IROM1 249,408 / RW=1,328 / ZI=135,096,lane-f 申报(Code +80B、ZI +8、
BSS s_miss_bits[9] +9B)一致,--no-verify 欠账清。覆盖率基线复算;凭证重盖。
2026-10-25 18:04:34 +08:00
wandering1 c23b21eed8 docs(UI自动化): 新增第七节「板端有状态导航判据」+ 过密码口径指针
票 = task-25 第二半(配套上一笔的 scripts/board_ui_nav.py)。

docs/UI设计/UI_自动化测试流程.md 新增第七节:
- 7.1 为什么必须闭环:KEY 空帧 + 盲走 9 帧仅 3 帧名副其实的实测表(含每帧落点与墨量),
  三张证据 PNG 落 docs/测试说明/boot_log_data/(弹框裁切现场、盲走落密码页、盲走停在级联)。
  「512 bits 神秘空白态」在表里定名为**密码输入页**——走带 #P 注记的叶子时第一屏是要密码。
- 7.2 认屏三判据(表格:判据 + 为什么这样判)与两条实测纪律(l 收列、#P 注记顺序)。
- 7.3 四种用法与钩子档位:钩子只跑快检档,完整档(含闭环)合入前 / 上板前手工跑;「没跑」
  与「跑过且通过」在脚本输出里是两种打印,不互相顶替。
- 7.4 四条已知限度:末两行叶子弹框被裁(真机定位留台架窗口取证,是取证题不是编码题)、
  同标题多态不区分、与黄金文案的有意耦合、快检不证导航。
- 7.5 过密码口径:查看 000 / 修改 123,键序与黄金回归编辑态完全一致;只读判据不自动升格。
docs/UI设计/UI_数据供给设计说明.md 追加落地注·五(指针级):写路径(批 5)必须把「过密码」
当显式状态——快照层不提供「曾否进过某页」,可写性只由 UI 权限态与 par.locked 决定。

纯文档与取证图,无行为改动。取证 PNG 由 simulator/i1_to_png.py 从留档帧转出(4 倍放大,与
其余 UI 证据图同规格);本笔钩子按各自触发面执行,其中 doc-links 会核对本节新增的两处
相对链接与三张图的存在性。
2026-10-25 18:02:32 +08:00
wandering1 6a904c1bee feat(判据脚本): 板端 UI 有状态导航器入库 scripts/board_ui_nav.py
票 = task-25(Lead 定位置与钩子档位)。ci-artifacts/uinav/board_nav.py 收编入仓:判据依赖一
个不受版本控制的东西,本身就是隐患(今天刚写过这条道理:只有记得做的人才做得到)。

为什么要有这个脚本(大窗实测,不是预防性工程)
- RemoDispBus 的 KEY 应答是**空帧、不携带状态**:发了 5 个键不等于到了第 5 层。
- 靠预置键序盲走到深页,9 个「应当到达」的态里只有 3 个真是那个态:03 停在菜单级联、
  04/05/06 停在**密码输入页**(墨量 512/515/512,与黄金参考的两张密码态逐字对得上——此前
  它被记成「512 bits 神秘空白态」)、07 停在网口设置、08 停在装置信息。
- 所以导航必须闭环:每按一键重新认屏,再决定下一个键;「到达」只由页面身份裁定。

认屏三条判据(全部精确字形匹配,复用同一份 ui_font_cn_12 与黄金回归脚本,不复制第二套规则)
- 内容页:标题带命中 + 该页黄金行文本至少一条 + 读页码行「第 N 页,共 M 页」;只认标题会把
  两屏认成一屏(「参数」既是菜单项也是页名),页码让「第 2 页 / 共 5 页」成为可比对的事实。
- 菜单:在白底横条(反白选中行)覆盖的行范围内、**整幅宽度**找反白标签,长标签优先、命中框
  被包住者丢。三条实测教训都在注释里:① 选中行是黑字白底,只按正常极性永远读不出选中项;
  ② 一条连续白 run 里可能**贴着两列**(子列左边框就是根列右边框),按 run 限宽找标签会把
  子列整个吞掉;③「参数定值」那行必然同时落进子串「参数」「定值」,不去重就凭空多出两列。
- 密码页:「用户密码」四个字中三个即判 PWD(整串匹配实测差约 1 px 打不中;数字格随输入变,
  不参与判定)。
- 另两条纪律:收级联列按 l 不按 s(菜单态按 s 不收列,用 ssssssss 回根正是错位来源);菜单树
  的 #P 注记可能写在 #page= **之前**,解析顺序错了就剥不掉、那一叶的 id 永远认不出来。

结构与安全边界
- SimDevice(无头模拟器当设备,不需要台架)/ BoardDevice(只连 7003 取图注键)双后端同一套
  导航逻辑;脚本不含串口、烧录、复位,跑真机必须自己持 bench 锁。
- 「过密码」默认只走**查看密码**(出厂 000 直接确认);修改密码要显式 modify=True,不因
  「能进去」就自动升格成可写。
- 末列弹框被屏幕裁掉时读不到选带 → 「枚举 + 验终点」兜底(末列盲按 N 次再进,成败仍只由
  页面身份裁定),并可用 --dump-fail 把认错那一屏存下来;轨迹只在 --trace 给了路径时落盘,
  默认不往工作树里写文件。
- 缺模拟器可执行文件时报 FAIL + 可执行提示,不「跳过当通过」(与同一天 mem-numbers 缺 .map
  同一课);--selftest-quick 是那条限制下仍可用的快检档。

门禁
- 新钩子 uinav-selftest 跑 --selftest-quick(files 只覆盖脚本自身与 simulator/tests/ref 帧,
  档位选择理由写在配置注释里)。本笔**未用 --no-verify**:本树先 @build 一次让 .map 事实源
  就位,全量钩子通过。
- 实测(模拟器后端):黄金参考 18 帧分类 0 错、真机留档帧 9 帧 0 错、闭环 12 个绑定页到达
  10 页(差的 2 页正是上面第 1 条限度,报错大声、现场帧已取证)。
2026-10-25 18:00:56 +08:00
wandering1 66f862e770 merge: task/wdacc-bits 合入 v2-on-ui
任务 wdacc(1 个提交)经 task-worktree 回收:
- fix(看门狗): 停喂判定按位分账——复合串窗不再误停喂(2026-10-25 大窗判读 F2)
2026-10-25 17:59:47 +08:00
wandering1 2d16fdddc0 fix(看门狗): 停喂判定按位分账——复合串窗不再误停喂(2026-10-25 大窗判读 F2)
道F task-23。动因 = 2026-10-25 台架大窗判读:9 条 stall 行里 16:24/16:28 两起为
复合判据误升级——旧逻辑喂狗前置 = 全必需位同窗跑过,任一窗缺任一位即断喂,
复合连续 8 窗申报 stall;UI 一窗缺、IEC104 下一窗缺、再下窗 FTP 缺这类互不相干
的亚秒抖动被串成 800ms 假停摆(mg=877/918,两事件中无任何任务达单任务容忍线)。
16:39 那起才是真 UI 单任务串(~1.2s,bit4 到 8 连击——受害者面修复 = F3 票)。

【改造】stall/停喂唯一判据 = 任一必需位自己的连续缺席窗数(s_miss_bits[] 分账,
+9B BSS):同一任务 8 窗(800ms)没跑过 → 申报 + 停喂 → IWDG 自然溢出
(IWDGRSTF 权威归因不变);所有位连击 <8 的缺席窗照常喂狗;位出现即该位清零,
掩码外位不入账(wd_required_mask 随构建变化自动跟随、不留旧账)。`MISS start`
保留为复合边沿诊断行(不再代表停喂,判读口径以设计说明 §6.1/§11.1 为准)。
代价:真停摆从挂死到复位总时长最坏 +约 0.7s(≈2.5s 最坏 / ≈3.3s 标称),V2
台架判据「声明 stall 后 ≤2.5s 复位」不受影响(申报时点未变,停喂起点即申报)。

【测试】test/host/watchdog 断言按新判据改写 5 处(004/005/006/008/013:缺席窗
照常喂),新增 TC-WATCHDOG-017(20 窗 UI/NET 交替缺席:零 stall + 20 窗持续喂——
旧复合判据必在第 8 窗误报,回归钉)与 TC-WATCHDOG-018(同位 8/9/10 连击必报停
+ 停喂;恢复窗喂、账本重启,第二段亚容忍串重新从零计)。47/47 全绿。

【文档】看门狗设计说明 §0/§2.2(停喂链加「v1.5 起字面成立」注)/§3.2/§5.3/
§6.1/§11.1/§11.6 大窗补记 + 变更记录;看门狗单元测试说明(用例表 + 47 断言 +
变更记录,顺带修正 TC-WATCHDOG-003 行 10-12 后未跟的 `0x37`→`0x1F7`)。

【越界申报】① docs/测试说明/看门狗单元测试说明.md——票面写域外,但该文档
§4 自要求「行为变更须同步改单测与本文」,执行性同步;② 根目录
.cubemx_survival_baseline.json——cubemx-survival 钩子对 task_watchdog.c(watchdog-
module 审计项 = 全文哈希基线)明示「有意改动请 --write 重建基线」,重建后
31 通过 / 0 失败。均非他人并行改动面。

【门禁】@build 0 错误 0 警告(Code=224768 RO=24640 RW=1328 ZI=135096)。
--all-files 43 绿;红仅 mem-numbers/mem-budget = 台账漂移(vs 2026-10-25 行:
Code +80B / ER_IROM1 249328→249408 / ZI +8B(s_miss_bits)/ IRAM1 约 +8B,待回填)。
id-check 增量拦截通过(首跑拦下变更记录里 TC-003/TC-004 裸形,已改全 ID)、
test-coverage 通过、cubemx-survival 通过。
gate: only mem-budget/mem-numbers pending lead ledger backfill

【台架】等你排窗两轮(各 ≥30min):单独 relisten_stress :7003 / 探针轮。判据按
v1.1 口径 = stall 行零为硬判据;预期本修复后 16:24/16:28 型复合误升级归零,
MISS start 仍会出现(诊断量);16:39 型 UI 真单串在 F3 落地前仍可能照报——
那属于「判据抓对了人」,请连同 mask 一起记给我。
2026-10-25 17:58:18 +08:00
wandering1 4777ed2373 docs(登记): 大窗销项三条 + 新签名两条 + §4.4 落地注
台架大窗(2026-10-25 15:51-17:00,V5 复跑 + 104 读回 + UI 取图)判读落账:
- 登记簿删 N-2(附录 A 结论 6f18f8d + 大窗静默零段)、N-17(修复 a3c6e89:
  9 条 stall 行 NET 位全程在场,44/44 旧签名归零;证据 §4.6 + 日志留档)、
  N-22(vol_idle_sync 484d068 + V5 全程 0 idle sync FAIL)
- 网络表新增两条语义标识(lane-f 定名):「复合连击串窗」(看门狗全局连击
  把多任务各自亚秒抖动误累计成 800ms 停喂,🟦批2.5 修复=按位分账)与
  「重建窗内回执排队把 net 等待者拖过 800ms」(UI 睡 portMAX_DELAY 回执,
  距 IWDG 最坏余量仅 0.1-0.3s,🟦批2.5 有界化;根治 E-27/28 批 3)
- README 网络行改写(4→N-3/N-16 + 两条新)、结构行 4→3、结构债波次线
  删已销两项
- 数据可信 §4.4 落地注:CFG_VAL_INT/[run_par] 已实现、5/8 段、系数真源
  仍 [adc_cal]、p_cb 未用口径不变
2026-10-25 17:29:16 +08:00
wandering1 138aebc6d6 merge: task/m104fix-rxbits 合入 v2-on-ui
任务 m104fix(1 个提交)经 task-worktree 回收:
- fix(104主站工具): RX 解析按本仓 6 字节头取位——PN 在 cause 字节 bit6(道D 批2.5 R1)
2026-10-25 16:57:03 +08:00
wandering1 2808ea9fb8 fix(104主站工具): RX 解析按本仓 6 字节头取位——PN 在 cause 字节 bit6(道D 批2.5 R1)
大窗实锤(2026-10-25,V5 间隙连 2404 复放两笔越界写):板端对 51.0@0x8030 与
2.0@0x8034 均回 type=50 / cause 字节=0x47(7|PN 0x40)——固件行为正确、无需重烧。
本工具 _asdu 旧解析按标准 2 字节 COT 的习惯去 byte[3](本仓语义 = OA+EVI)找 PN
(恒 False),且 cause 字节未 &0x3F(拒绝帧读成 cot=71)→ actcon_of(type, True)
永不匹配 → E2_dbig / E2_kband exec / G2 三条 PN 断言超时。历史用例全部
want_pn=False,False 配恒 False 恒真,故 B6~D2 无人暴露;E2/G2 首次真机断言
负确认即命中(审计定位 lane-d,task-21 修复)。

- 提取纯函数 decode_cot(cause_byte) -> (cot&0x3F, bit6),_asdu 改用;byte[3] 不再
  参与 PN;TX 侧 asdu() 未动(与固件 RX 本就对称)
- COT_PN 常量处 NOTE 注释:本仓 B4 自定义 6 字节压缩头(T/PN/RBT=bit7/6/4 同字节,
  byte[3]=OA+EVI),引用 App/protocol/iec104/iec104_asdu.c put_hdr 为准;标注根治
  走 COT=2 标准化合并单(登记簿「104 ASDU 头非标-COT1 压缩」,本票不动固件)
- 新增离线样本单测 test/host/test_iec104_master_rx.py(纯 stdlib 无台架,6 用例:
  两条窗口捕获帧字节重建 + 正例 actcon + 旧错误解码反证 + matcher 语义)
- RX log 语义变化:拒绝帧从 cot=71 变为 cot=7 + " PN" 后缀(v5_log_gap_report.py
  已核无 cot= 解析耦合)

gate:py_compile 双文件过;单测 6/6 OK;全钩子常规跑(见提交记录);写域仅
test/host/iec104_master.py + test/host/(票面内),未加钩子(yaml 在写域外,
如需常态化由 lead 批 2.5 顺手)。
2026-10-25 16:26:13 +08:00
wandering1 7099c56c34 chore(ledger): pages 批 1~4 合入后回填凭证/覆盖率并同步黄金参考计数
task/pages-ui 合入(merge dea7591 + 修复 498cc60):三页回炉 + SOE 页 +
四查看页 + 遥信 33 行页 + ui_data 三通道 + soe_store 只读 API + 字库与
门禁配套共 12 笔。凭证重盖(Code=224,688 / RW_IRAM1 78,992 B=60.3% /
CCM 87.9%);覆盖率基线 39/39 套件复算。ui-golden 黄金参考 11→18 页
随批 4 扩容,AGENTS 门禁行/索引行与门禁说明两处、覆盖率口径一处同步;
两篇新详解(bom-guard/setview-check)已由道E 交付、索引去"待补"。
memset 隐式声明一条教训:分支树增量构建"0 警"不覆盖未重编文件,
done 主树全量重编才是终判。
2026-10-25 15:49:42 +08:00
wandering1 498cc60b9d fix(UI): ui_page_yx.c 补 string.h——memset 隐式声明 #223-D 两条警告
pages-ui 合入(merge dea7591)后 done 门禁在主树全量重编暴露;
道E 树内增量构建吃缓存未触发重编,故其提交时"0 警"未假报但漏检此条
("只有重编该文件才暴露"的已知形态,教训进下方回填说明)。
体积不变(Code=224688 RO=24640 RW=1328 ZI=135088);BOM 已核 EF BB BF。
随笔同步内存台账受管区(pages 12 笔合入的 --no-verify 欠账)。
2026-10-25 15:45:29 +08:00
wandering1 dea75914f5 merge: task/pages-ui 合入 v2-on-ui
任务 pages(12 个提交)经 task-worktree 回收:
- docs(门禁说明): 补 bom-guard 与 setview-check 两篇详解
- fix(门禁脚本): 缺 .map 时说明"本树从未构建",并写进工作树文档
- docs(UI方案): 返回系数小数位按可分辨精度修正 + 遥信量落地注
- test(ui-golden): 新增遥信量第 1、2 页像素参考
- feat(ui-pages): 遥信量 33 行固定名表页接实时快照(新增 yx_dp)
- test(ui-golden): 新增四个定值查看页像素参考
- feat(ui-pages): 参数定值控制字软压板四个查看页接 par 快照
- test(ui-golden): 新增 SOE 记录页像素参考 shot_soe.bin
- feat(ui-pages): SOE 记录页接 soe_store 只读遍历(类型 7/8 首次上屏)
- test(ui-golden): 刷新装置信息/交流量/直流量三页像素参考
- feat(ui-pages): 装置信息/交流量/直流量三页接 ui_data 快照真值
- fix(字体门禁): 35 个"未知冗余"字成组并入 KEEP_EXTRA,钉死"按 extract 重写=洗字形"
2026-10-25 15:40:56 +08:00
wandering1 fa80b17d5f docs(门禁说明): 补 bom-guard 与 setview-check 两篇详解
票面:task-20 合入后 README 索引上挂着我名的两篇(Lead 指定挂批 4 MR)。按本目录八段结构写,
「拦下过什么真事」一栏用同日实测,作者自己的也算。

- bom-guard.md:立项依据是 2026-10-25 一天内 5 次(道E 4 次 + 道B 1 次);症状 #870-D / #8 /
  #274 一类**假语法错**,位置不指向真因;MSVC 与既有钩子全照不出来;"人工逐文件比 HEAD"只防得
  住记得做的人。两条刻意不报的边界(新文件无 BOM、反向加 BOM)+ 一条刻意不做(不自动补,整文件
  重写会把可修的丢 BOM 变成不可修的 U+FFFD)+ 三处互指防漂移,各写清理由。也记了本门禁自己的
  立项提交被 gitlint 拦过(正文一行 92>80,量出来仍按了提交)——这一栏只挑别人的事写就等于暗示
  写门禁的人不需要门禁。
- setview-check.md:立论点是"分层边界制造的物理盲区"——BSP 不得 include App 头,行表 id 只能是
  裸数字 + 注名,编译期无法自证;像素断言又只看可见 8 行,40 项分 5 页,错位一行时第 1 页可能
  看着完全正常。另收两条脚本自身实测坑:\w+ 吃不下带引号的单位导致**整行静默跳过**(故加行数
  硬闸)、打印中文时 CP936 半路崩(门禁脚本崩在半路 = 一条都没检查)。退出码 1 与 2 分开:把
  "我没读懂"报成"你写错了"是检查类脚本最坏的一种失败。
- README.md:索引加两行;"那 46 个钩子" → 48、"40 个 hook → 23 篇" → 42 → 25 篇(不同步就是在
  让索引说谎)。AGENTS 里"两篇待补"那句已过期,但 AGENTS 是单写者文件,留给 Lead 收。

跳过的门禁(三条同族):mem-numbers / mem-budget / size-gate(L0c),与本笔无关,等台账回填。
2026-10-25 15:33:46 +08:00
wandering1 a8de47ca3f fix(门禁脚本): 缺 .map 时说明"本树从未构建",并写进工作树文档
票面:task-20 交付后的回报——全新工作树没有 .map,依赖它的门禁必然红,而现场只看到一句
"找不到 .map 事实源",容易误判成代码问题。(Lead 定的两头修法都做,本笔独立于 UI 改动。)

- scripts/check_mem_numbers.py:FAIL 后补三行原因与动作——.map 是**每棵工作树各自的**构建产物、
  不入版本库,新开的工作树(task-worktree new)从未构建过时必然走到这里,先 @build 一次即可,
  再按需要 ram_baseline.py --update 回填台账。保持 FAIL 语义不变,只把提示词写对。
- scripts/ram_baseline.py:两处(--update / --write 路径)同样的补充说明。
- docs/环境与部署/并行任务工作树方案.md:生命周期表加一行「新空间首次:@build 一次生成 .map」,
  表下补一段说明点名 mem-numbers / mem-budget / L0c 三个消费者。
- .dsh/skills/task-worktree/SKILL.md:陷阱区加同一条,并与既有的「done 的门禁跑在 @build 之前
  → .map 是旧的」明确区分——一条是"有但旧",一条是"根本没有",处置动作不同。

只改提示文案与文档,不改判定逻辑与阈值;两个脚本 py_compile 通过并实测打印。
跳过的门禁(三条同族):mem-numbers / mem-budget / size-gate(L0c)——本笔恰恰是在解释其中两条
为什么会红,数值本身仍等 Lead 回填。
2026-10-25 15:33:45 +08:00
wandering1 3dc7f4a088 docs(UI方案): 返回系数小数位按可分辨精度修正 + 遥信量落地注
两件事都落在这份方案文档里,合一笔提交(同一文件的两处改动按文件切分提交不干净,故合并说明)。

一、四个返回系数:口径从"我偏离文档"改成"文档原口径错了"(Lead 裁定 2026-10-25)
- §3.2.2 表里 4 行小数位 3 → 2 并加 ⚠;落地注改写;同步去掉 ui_w_setview.c 注释与
  tools/setview_check/check_setview_limits.py 文档串里的"偏离"措辞。
- 原口径 3 位来自"把 Q10 当成十进制存储"这个误会:Q10 存不下 0.95(0.95×1024 截成 972,真存
  0.94921875),按 3 位显示等于多打一位根本不存在的数字;整定单写 0.95 的人看到屏上 0.949 会
  怀疑装置、甚至反复重输(重输还是 0.949)。2 位才是这份存储在自己可分辨精度上的正确读数。
- 不改行为,只改文档与注释:屏上显示与行表换算逻辑一笔未动。

二、§3.1.3 落地注(遥信量页落地后回填,含两条命名取舍与一条通用陷阱)
- 命名:idx0~3 屏上写「位置1~4」不写「YK1~4 位置」;idx30 写「保护闭锁」不写「闭锁1」;点表未
  定义名的行用点号占位,不编造功能名。
- 值映射次序(先无效 → 再单双点 → 后位值)与双点支路"当前无自动化证据"的诚实登记。
- 通用陷阱:把 *_PUT(dst, 字面量) 宏用在 const char *const 表项上,sizeof(源) 变成指针宽度,
  CJK 名被截而短 ASCII 名恰好完好——只有像素文本断言会报"名字匹配不到而值在原位"。

跳过的门禁(三条同族):mem-numbers / mem-budget / size-gate(L0c),根因是台账与体积凭证待回填。
2026-10-25 15:33:44 +08:00
wandering1 345e12ebd7 test(ui-golden): 新增遥信量第 1、2 页像素参考
上一笔的两态基线单独提交(基线变化与代码变化分笔,回归时能一眼看出是图变了还是代码变了)。
- shot_yx.bin:第 1 页 8 行**全是 "--"**——位置1~4 是无硬件的双点、YX5~8 点表未定义。这一页的
  作用是把「V0.1 只有 6 点有来源」钉成图像证据:以后谁把无效位画成「复归」,或反过来把有效位
  画成横杠,都会在这里响。
- shot_yxp2.bin:第 2 页出现两个有来源的行(故障总 / 过流Ⅰ段 = 复归),同时选中行落在第 2 页
  首行(YX9)——一张图同时钉住分页数学(33 行 / 8 = 5 页)与"翻页后取值不串行"。
- 先证语义再钉像素:33 条文本断言全过之后才 --update 建基线(顺序反过来就是把错画面合法化);
  其余 19 页参考逐字节不变。
2026-10-25 15:33:43 +08:00
wandering1 4332142680 feat(ui-pages): 遥信量 33 行固定名表页接实时快照(新增 yx_dp)
批 4 主体(票 task-15;规格 docs/UI设计/页面扩充详细方案.md §3.1.3 方案 A)。V0.1 只有 6 点有
硬件来源,其余 27 点在屏上就是 "--"——这是真实状态,不是页面没做完。

页面与数据
- 33 行固定名表(Flash const)+ 动态分页(8 行/页 = 5 页)。两处命名不照抄文档简写:idx0~3
  屏上写「位置1~4」而非「YK1~4 位置」(遥信页出现 YK 前缀会被读成遥控对象,对应关系留在注释);
  idx30 写「保护闭锁」(写侧位号注释的语义即此,用编号反而丢信息)。idx4~13、idx31~32 点表未
  定义功能名,如实写点号占位(YX5…、YX32),不编造功能名。
- 值映射次序:先判无效 → 再判单/双点 → 最后看位值。无效位上的位值不代表任何事实,次序颠倒就会
  把垃圾位画成「复归」。
- 快照新增 yx_dp 双点位图(+8 B)。不教页面"前 4 点是双点":单双点是 104 点表事实(YX_DP_N /
  iec104_pts_yx_is_dp),抄进表现层就是第二真相——点表把双点段挪位或改起始下标,屏上不报错,
  只会用错词形容同一个点。写侧逐点问 is_dp() 拼位图。
- UI_DATA_YX_N(33) 与 iec104_pts_yx_count() 编译期比不了(那个 #define 在协议层 .c 内),故写侧
  留一条一次性 DBG_ERROR(约 90 B Flash,只在真漂移时打一次)。名表行数 = UI_DATA_YX_N 由数组
  宽度法编译期守卫钉住(ARMCC C99 无 _Static_assert,本仓惯用写法)。
- 补 include 一条实测坑:DBG_ERROR 是宏,漏 include "dbg_log.h" 时 ARMCC 把它当隐式声明函数放过
  编译期,错落在链接期(L6218E Undefined symbol DBG_ERROR)——报错点离根因隔了一整个链接步骤,
  已在 include 处写明「DBG_TAG 必须在 include 之前」。

一个只有像素文本断言能抓到的截断 bug
- 照抄批 1 的 *_PUT(dst, 源) 宏(长度取 sizeof(源)),但本表源是 const char *const 数组的
  **元素(指针)**,sizeof 得 4 → 「故障总」被截成「故」,而 "YX9" 恰好 4 字节看起来完全正常。
  编译器 / cppcheck / 编码钩子全不响。定位来自断言:第 2 页「过流Ⅰ段」匹配不到,同一行值列
  「复归」却在原位 = 名字被截、值没错位。修法:长度取目标缓冲容量 UI_BUF_STRLEN。同名宏、不同
  参数类别(数组 vs 指针),批 1 那处本身是对的,照抄才中招——教训写在宏定义上方。

模拟器与覆盖诚实性
- 固定帧发布 yx_dp = 0x0F,但刻意不把这几位置进 yx_valid:V0.1 无开入硬件,屏上就是 "--"。不用
  「让它显示合位/分位」的假数据填满画面,那等于让黄金参考声称本机有开关位置遥信。
- 代价如实登记:合位/分位这条词映射当前**没有任何自动化证据**(像素恒 IV;页面依赖 LVGL 进不了
  主机单测)。已写进方案 §3.1.3 落地注与契约落地进度·四,作为开入落地后的台架判据,不假装测过。

测试
- 主机单测 +2 条(TC-UIDATA-009c 双点位图原样透传、不被 valid 掩;009d 点数 33 的独立见证,注明
  绊红时要同步改哪四处)→ 85/85。
- 文本断言 +33 条(yx / yxp2 两态,含页码钉住 33/8 = 5 页的分页数学);两页像素基线单独一笔。
- font-charset 本批零新增字:名表用字全部命中既有 183 字子集——「置/故/障/总/外/部」正属批 1
  成组并入 KEEP_EXTRA 的那批字,第二次兑现(当时若按 extract 输出重写字符集,这些字早被洗掉,
  上屏是空白,而门禁只会说「字不在清单里」)。

体积(同一棵树、构建前后的实测增量)
- Code 224272→224688(+416);RO 24040→24640(+600);RW 1324→1328(+4);
  ZI 134844→135088(+244)→ Flash 约 +1020 B、RAM +248 B。
- RO 增量主要是 33 行名表与名称串,是"把 33 个真点名上屏"的代价,不是可省开销。

跳过的门禁(三条同族,同一根因):mem-numbers / mem-budget / size-gate(L0c)——台账与体积凭证
回填是 Lead 单写者,本分支不做静默处理,数值见上。
2026-10-25 15:33:04 +08:00
wandering1 9bd7ff96d3 test(ui-golden): 新增四个定值查看页像素参考
随上一笔(参数/定值/控制字/软压板四个查看页)落第 13~16 页黄金参考;本笔只有判据文件,
页面键序列与逐行文本断言已在上一笔同批改入 check_content_pages.py。

数据源与可复现性
- 值全部来自 simulator/main.c 发布的 par 帧:prot_set 出厂默认(App/algo/prot_set.c 是纯算法,
  双端可编),不是手抄的 40 个数字——手抄一次就多一份会漂移的第二真相
- 因此这四张基线同时也是换算口径的判据:10.00A / 500ms / 0.95 / 100A 这些串由 ui_w_setview
  的 Q10 拆分换算产出,换算改了没改对,像素这一步就会响

刷新纪律(黄金参考是判据不是快照:版面错了就等于把错误钉死)
- 先让 39 条文本断言过(名/值两列逐行断),拦住缺字与错标签,再回退比对像素
- 四页出图逐张肉眼确认:值列右缘不越屏(最宽 10000V)、名值两列不重叠、行槽不与页脚相撞、
  分页正确(定值 18 行 = 3 页并显示「第 1 页,共 3 页」;软压板 10 行 = 2 页)
- 一处肉眼读错被匹配器纠正:CT 一次额定我读成 1000A,值列实际只有 4 个字符位(100A)。
  断言按匹配器能证明的写,不按我觉得像什么写
- 控制字页断言刻意含「Ⅰ/Ⅱ 段跳闸、Ⅲ/过负荷段告警」这种两两一组:行表与枚举序若整体错位,
  就不可能复现这个组合——像素侧因此兼作 id 对齐的机械证据

回归面
- 其余 12 页(密码 4 态 / 网口设置 4 态 / 装置信息 / 交流量 / 直流量 / SOE)逐字节不变:
  这四页与它们共用同一套框架绘制与同一份 ui_w_setlist 骨架令牌,没连带漂移正好反证改动局部
- 内容页回归 19 项全 Passed;菜单 8 状态 Passed(池 5368 B,本批只多 4 条 #page 注记)
- ui-golden 钩子在含本笔的整树上 Passed;全量钩子(--all-files)红项只有同族三条
  mem-numbers / mem-budget / size-gate(台账与同码凭证回填归单写者)→ --no-verify

体积:本笔不参与固件构建,与上一笔一致(Code 221856 / RO 23508 / RW 1300 / ZI 134660)。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
2026-10-25 14:44:47 +08:00
wandering1 45393be3d5 feat(ui-pages): 参数定值控制字软压板四个查看页接 par 快照
四个空叶子(参数定值 → 查看 → 参数/定值/控制字/软压板)接上 par 通道。页面薄、模型厚:
新建 Drivers/BSP/UI/ui_w_setview.{c,h} 收四张视图表 + Q10 换算 + 越界反显,四个页面各是
一份实例 + 四个跳板钩子;渲染与分页复用批 1 的 ui_w_setlist(查看态 commit=NULL:无保存行、
无编辑通路,§3.2.6 查看列口径)。

行表三项口径全部有真源,一项都不在这份文件里拟定
- id = App/algo/prot_set.h 的枚举序。BSP 不 include App 头(分层门禁),所以行表里是裸数字
  + 行尾注枚举名——这份对不上就没人能在 BSP 侧编译期发现,于是从外面钉:见下面的新脚本
- min/max = App/algo/prot_set.c 的 g_prot_set_meta Q10 上下限按该行小数位换算的结果。整定页
  下一批复用同一批表,界内界外一处一份,不会出现「查看看着合法、整定说越界」两套边界
- 显示名/小数位/单位 = docs/UI设计/页面扩充详细方案.md §3.2.2~§3.2.5 四张表

一处偏离文档(不是静默偏离,写进三处:代码注释 / 文档落地注 / 本正文)
- 四个返回系数按 2 位小数显示,文档写 3 位。理由是量值真实性不是排版:Q10 存不下 0.95
  (0.95×1024 截成 972 → 真存 0.94921875),按 3 位显示就多打一位根本不存在的数字;整定单
  上写 0.95 的人看到屏上 0.949 会怀疑装置、甚至反复重输(重输还是 0.949)。2 位才是这份存储
  在自身可分辨精度上的正确读数——文档的 3 位来自「把 Q10 当成十进制存储」这个误会
- 单位挂在值串尾(10.00A / 500ms)而不是塞进显示名:值列只有数字会让 60000 既是 ms 又是 A。
  为此给 ui_w_setlist_row_t 加了 unit 字段(该结构此前无任何消费者,扩展零成本);只挂在
  非编辑态——编辑中是逐位输入串,挂单位会让位光标算错列宽。A/V/ms/Hz 全 ASCII 不占字库

新增可重跑核验:tools/setview_check/check_setview_limits.py
- 从外面把三件事钉住:四张表的 id 恰好覆盖 0..39 各一次;数值行 min/max 等于 meta 上下限的
  换算;kind 与 meta.is_ctl 一致、dec 查表与行表小数位一致、单位只允许 ASCII。改 meta 不改
  行表就红。当前输出:行表 40 行 / dec 40 项 / meta 40 项 -> OK
- 写这个脚本时自己踩的两个坑也留在脚本注释里:① 正则用 \w+ 吃不下带引号的单位,会让整行
  **静默跳过**(少检一行比检查错更难发现)→ 改 \S+ 并加「行数必须等于 40」硬闸;② 脚本要
  打印中文,Windows 控制台按 CP936 写 stdout 会崩在半路(崩在半路 = 一条都没检查)→ 开头
  把 stdout/stderr 锁到 UTF-8

两个只有画面才会暴露的错(都不是静态检查抓到的,都记进了注释)
- ui_data_par() 的通道是 0=成功 / -1=尚无首帧,我按 bool 反义用了:真机与模拟器上都会「永远
  读不到」,整页值列恒 --,而页面看起来完全正常、编译与钩子全绿。看像素图才发现满屏横杠
- 肉眼把 CT 一次额定读成 1000A,字形匹配器证明值列只有 4 个字符位(命中 x=101/107/113/119):
  实际是 100A。断言因此按匹配器能证明的写。顺带一个自洽佐证:PT 10000/100 V 配 CT 100/5 A
  正是 10 kV 馈线终端的变比;控制字页默认值天然两两一组(Ⅰ/Ⅱ 段跳闸、Ⅲ 与过负荷段告警),
  行表若整体错位就不可能复现这个组合——这条已进像素断言,成为 id 对齐的机械证据

无数据时的显示口径
- 参数帧未就位:名字照打、值列打 --(不把 0 当定值显示,也不给整页空白让人以为菜单坏了)
- 越界反显只在 bad_valid=1 时参与:把「没数据」画成「这行有毛病」是假告警,比不画更糟
- Q10 换算不用浮点也不用 64 位除:整数段 + 小数段拆分四舍五入,满位进位(MicroLIB 软浮点与
  __aeabi_ldiv 一进镜像几百字节,模拟器(宿主 CRT)永远照不出这笔代价)

批 1 那个 KEEP_EXTRA 决策在这里第一次兑现
- 本批行表用到的「延时 / 电流 / 压力 / 总 / 配 / 重 / 间 / 未」等字,正是当时成组并入 KEEP_EXTRA
  的 35 个「未知冗余」字里的一批;若当时按 extract 输出重写字符集,这些字形已被洗掉,本批
  上屏就是白框,而 font-charset 只会报「字在清单里没有」——它报不出「字曾被删掉过」
- 本批 font-charset 直接 Passed,未扩字库、未重生成字体

验证
- @build 0 错误 0 警告;模拟器 0 警告;内容页 19 项 PASS(16 页像素 + 四页共 39 条文本断言),
  其余 12 页逐字节不变;菜单 8 状态 PASS;ui-data 83/83、soe 32/32
- 四页出图逐张肉眼确认:值列右缘不越屏(10000V 最宽)、名值不重叠、第 1 页行槽与页脚不撞;
  分页页码正确(定值 18 行 = 3 页、软压板 10 行 = 2 页)
- check_setview_limits.py OK;ui_menu_tab.h 等生成物全部由 gen_menu.py 重生成(手改生成物 = 一定
  会被覆盖,见批 2 那条),uvprojx/CMakeLists 注册由生成器写入,另手工登记 ui_w_setview.c 两处
- 全量钩子 --all-files:见下一行 gate;红项只有同族三条(台账与同码凭证回填归单写者)

体积(相对 b746a1f 台账 217204 / 21504 / 1256 / 132680)
- 实测 Code 221856 / RO 23508 / RW 1300 / ZI 134660
  → Code +4652、RO +2004、RW +44、ZI +1980
- Flash 合计 +6700 B、RAM +2024 B(三批累计)。本批 RO +2120 主要是 40 行行表与名称/单位串,
  ZI +388 是 par 帧缓存(184 B)+ 四个组件实例 + 注册表增量;这是把 40 项真定值上屏的代价,
  不是可省开销

待台架实测:装载真定值后的四页读数(含真机 bad_mask 非 0 时的反显行)、翻页节拍、
PT/CT 变比与 104 上位的定值读回(C_RD)一致性。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
像素基线单独提交(下一笔新增 4 个 shot_v*.bin):本笔单独 checkout 时 ui-golden 会报缺参考,
属预期拆分;上面的钩子结论取自「本笔 + 下一笔」的合并树。
自查:12 个新增/改动 UI 源文件 BOM 与 HEAD 对齐(本批 edit 又剥掉 4 次,逐次补回);CR = 0;
U+FFFD = 0;C 字面量纯 ASCII(中文只在带 BOM 文件里)。
2026-10-25 14:44:47 +08:00
wandering1 8ee3be4c97 test(ui-golden): 新增 SOE 记录页像素参考 shot_soe.bin
随上一笔(SOE 记录页 + soe_store 只读遍历)落第 12 页黄金参考;本笔只有判据文件,无任何
代码或断言改动(页面键序列与逐行文本断言已在上一笔同批改入 check_content_pages.py)。

刷新纪律(黄金参考是判据,不是快照:版面错了就等于把错误钉死)
- 先让**文本断言**过:缺字、串标签、时标串错会在那一步先拦,拦住了根本不给回退比对像素
- 再出图肉眼确认:6 行两两成对(时标 / 元件名+动作-返回)、19 字符时标在 94 px 名列内
  不越屏、非元件行"未标定闭锁"落在名列且值列为空、页脚"第 1 页,共 1 页"
- 数据源是 simulator/main.c 的固定 3 条假记录(时标固定串)——跑一次一个样,否则基线无意义

覆盖与回归
- 内容页回归:15 项全 Passed(soe 页 9 条文本断言 + 12 页像素);其余 11 页逐字节不变,
  正好反证本批改动是局部的:三页回炉与 SOE 页共用同一套框架绘制,共享代码若被碰坏,
  密码页与网口设置页必然一起漂
- 菜单 8 状态 Passed(池 5368 B;本批菜单树只多一个 #page 注记,节点数未变)
- ui-golden 钩子在含本笔的整树上 Passed;全量钩子(--all-files)41 项 Passed,红项只有
  mem-numbers / mem-budget(Flash/RAM 台账待回填)→ --no-verify;脚本层 size-gate 同族同因

体积:本笔不参与固件构建,体积与上一笔一致(Code 219712 / RO 21388 / RW 1288 / ZI 134272)。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
2026-10-25 14:44:46 +08:00
wandering1 67c469fa40 feat(ui-pages): SOE 记录页接 soe_store 只读遍历(类型 7/8 首次上屏)
ui_w_soe 组件(task-14 落)此前两端都没绑 reader,只能显示"无记录"空态。本批补齐三件事:
App 侧只读遍历、两端 reader 绑定、SOE 记录页与菜单注册。

App/log/soe_store 只读 API(Lead 已放行,本批独占这两个文件)
- soe_store_count() / soe_store_read(idx, soe_rec_t*):与 syslog_count /
  syslog_get_record 同构——8 条一窗(512 B)整块读、命中窗口零 I/O、卷锁 try(0 超时)
  非阻塞;定长记录逐条 open 等于同一扇区开 4 次,SPI NAND 上这个差距是肉眼可见的卡顿
- 读者不得看到半成品记录,三道保障缺一不可(规则写进 soe_store.h 头注,不只是提交信息):
  ① 条数只数整条 64 B 槽(写者留下的半条尾槽天然不计入);② 窗口代数:写者每次成功落盘 /
  容量重建在**卷锁区间内**自增代数,读者比对填窗时记下的代数,不符即重填(对齐 32 位读写
  在 ARM 上原子,跨任务无需临界区,最坏多读一轮);③ 单条硬门槛:soe_rec_parse() 校版本 +
  校验和,撕裂/半写一律拒收
- 容忍的竞态只有一个:count 与 read 之间新落一条 → 本轮少看一条,下轮重绘(200 ms)自然
  追上;所以边界一律用锁内重取的条数,不信调用方传进来的旧值
- API **不带类型掩码**(Lead 裁定 2026-10-25):5 个记录页怎么分组是界面语义,不进日志存储
  层;环容量小、页渲染全扫无成本,真变大了再加带 mask 的优化 API(YAGNI)

⚠ 顺带修掉一颗定时炸弹:菜单生成器与生成物漂移
- 提交里的 Drivers/BSP/UI/ui_menu_tab.h 带着 5 个表查询 API 的规范注释块,而
  tools/menu_gen/gen_menu.py 只会输出"声明 + 行尾短注释"——说明历史上有人**手改生成物**
  补注释、没回灌生成器。我这次加 #page=soe 后重新生成,等于把那些注释抹掉,bsp-err 立刻
  报 5 条 return-doc
- 修法不是把生成物改回去(下次重新生成还会再抹一次),而是把注释块**回灌进生成器**;
  回灌后重跑 gen_menu.py 产出的 ui_menu_tab.h 与 HEAD **逐字节一致**(git diff 为空),
  漂移关闭。生成物里的手工优化 = 一定会被覆盖的优化,要么进生成器要么别改生成物

时标口径(跨模块陷阱,注释即防线)
- 页面时标取记录内的 CP56(事件自己的时标,不是"现在")。它与运行/自检记录页同口径,靠两条
  事实叠出来:sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
  sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)"),而 soe_rec.c 的 epoch→CP56
  分解不加任何时区偏移。**谁"顺手补 8 小时",SOE 页就会和文本记录页差 8 小时**;要引显式
  时区必须两侧一起改

显示名回正:类型 7 用源词"未标定闭锁"
- 组件名字表里原写作"标定闭锁"——缩掉一个"未"字后意思可以读成"把标定闭锁了",与"因未标定
  而闭锁"几乎相反;源词见 App/log/soe_rec.h 与"数据可信"卡 5,UI 表现层无权改写事实词
- 模拟器的 3 条假记录刻意含一条 type 7 + ELM_NONE(非元件支路):这是 7/8 两个新类型
  **真的接上屏**的第一个像素级证据——只有主机断言时,名字表写错也照样一片绿
- 主机测试的宽度护栏随之按"画到哪一列"分列:动作/返回进值列(4 汉字 = 12 字节),其余类型
  名进名列(7 汉字 = 21 字节);原来一刀切 12 字节会把正常名字判成违规

页面与注册
- 新建 Drivers/BSP/UI/ui_page_soe.{c,h}:薄跳板,一条记录两行共用组件内单条读盘缓存,
  实例独立(两页共用一份缓存会互相命中对方记录号,task-6 的教训)
- 菜单树 SOE记录 加 #page=soe;重生成 4 个生成物,uvprojx / CMakeLists 两处注册由生成器
  自动写入(本批 uvprojx 只含这一条新增,无 UV4 抖动)
- reader 绑定在两端适配层:真机 App/task/ui_task.c(CP56 → UI 视图 + 容量钳位),
  模拟器 simulator/main.c(固定 3 条、时标固定串,保证黄金参考可复现)

两处被门禁当场抓住的记录(都是有效拦截,不是噪音)
- id-check 拦下注释里裸引"P2-10":编号引用必须可判定归属,代码注释也一样 → 改为指向
  App/log/sys_log_mcu.c 与事实本身,不留裸编号
- 又一次 BOM 铁律现场复现:一次编辑剥掉 ui_w_soe.c 的 BOM,ARMCC 立刻报一串 #8/#7/#20
  假语法错(missing closing quote 之类),而 MSVC 侧 0 警告完全照不出来;补回 EF BB BF
  即 0/0。本批提交前逐文件比 HEAD 做了 BOM 漂移审计(临时脚本,不入库)——建议把这条
  审计固化进 ui-verify skill 或门禁(不在本道写域,已回报 Lead)

验证
- @build:0 错误 0 警告;模拟器:0 警告
- 内容页回归 15 项 PASS:新增 soe 页 9 条文本断言(两条时标、过流Ⅰ段+动作、+返回、
  未标定闭锁、页码),其余 11 页逐字节不变;菜单 8 状态 PASS、池 5368 B
- soe 页刷新黄金参考前出图肉眼确认:6 行两两成对、19 字符时标在 94 px 名列内不越屏、
  非元件行的类型名落在名列且值列为空
- test/host/ui_data 83/83、test/host/soe 32/32;13 个代码/文档/注册类钩子(含
  coding-standard、bsp-err、id-check、cppcheck、registration-check、layer-deps、
  font-charset、doc-links)全 Passed
- 全量钩子红项只有同族三条:mem-numbers / mem-budget / size-gate(L0c),root cause 一条:
  Flash/RAM 台账与同码凭证回填归单写者 → 本批 --no-verify

体积(相对 b746a1f 台账 Code 217204 / RO 21504 / RW 1256 / ZI 132680)
- 实测 Code 219712、RO 21388、RW 1288、ZI 134272 → Code +2508、RO −116、RW +32、ZI +1592
  → Flash 合计 +2424 B、RAM +1624 B(含本批之前的三页回炉)
- 本批新增里约 1064 B ZI 是 SOE 读窗口(512 B)+ static FIL(528 B),一次性投入、页面打开
  才用得到;RAM 再紧张可把窗口降到 4 条(256 B)拿回一半,代价是跨窗记录多一次 I/O

待台架实测:真机 /log/soe.log 有记录时的翻页与尾部行为、卷忙时该行退化显示、SOE 页与文本
记录页时标一致性(跨 8 小时口径的现场核对)、记录增长时窗口代数失效后的重读节拍。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
像素基线单独提交(下一笔新增 simulator/tests/ref/shot_soe.bin):本笔单独 checkout 时
ui-golden 会报"缺参考",属预期拆分;上述钩子结论取自"本笔 + 下一笔"的合并树。
自查:BOM 漂移审计 17 个改动文件全对齐 HEAD;CR = 0;U+FFFD = 0;字面量纯 ASCII。
2026-10-25 14:44:45 +08:00
wandering1 a6d6588257 test(ui-golden): 刷新装置信息/交流量/直流量三页像素参考
随 44921cb(三页接快照真值)刷新黄金参考,本笔不含任何代码或断言改动——文本侧期望(PAGES/
TEXTS)已在上一笔同批改过,这一笔只把"代码新效果"登记成新的判据。

差异量(与旧参考逐像素比对,刷新前的实测)
- shot_info.bin 3669 px(6 行 → 8 行、标签去冒号、值列左对齐 x=56、页码改真值)
- shot_ac.bin 964 px(24 行 → 7 行;页脚从"共 3 页"变"共 1 页")
- shot_dc.bin 800 px(3 行 → 2 行;温度值列改"码2048"、通道行"未配置")
- 其余 8 页(密码 4 态 + 网口设置 4 态)逐字节不变:这三页与它们共用同一套框架
  绘制,没连带漂移正好反证改动是局部的——不是把共享代码碰坏了而没被发现

刷新前的人眼确认(黄金参考是判据,不是快照:版面真错了就等于把错误钉死)
- 装置信息:8 行行槽 38..129 不与页脚(143)相撞;MAC 17 字符右缘 158 在屏内;软件校验
  显示 "--"(不再抄参考项目的 4454);IP 走 net_cfg_fmt_ipv4 的三段补零口径,与网口设置页
  同一真源;页脚"第 1 页,共 1 页"
- 交流量:Ua 0.812 / U0 0.005 / Ia 0.046 / Ia2 0.002 / Ia5 0.001 五行读数在位;
  Hz "--"(eng=0 期间幅值门恒不成立)、标定行 "未标定"(量纲口径写在屏上而不是注释里);
  选中行 Ua 有白带(band_h >0 才可见,同记录页约定)
- 直流量:温度 "码2048"、直流通道 "未配置"

验证
- check_content_pages.py:rc=0,14 项全 Passed(含开页-关页往返池水位 5448 B 不涨)
- check_menu_rows.py:rc=0(菜单 8 状态与池 5368 B 不变,本批没动菜单树)
- ui-golden 钩子在含本笔的整树上 Passed;全量钩子(--all-files)41 项 Passed,红项只有
  mem-numbers / mem-budget(Flash/RAM 台账待回填,单写者禁区归 Lead)→ --no-verify
- 体积与上一笔一致(本笔只换 3 个 3200 B 的二进制参考,不参与固件构建)

gate: only mem-budget/mem-numbers pending lead ledger backfill
2026-10-25 14:44:45 +08:00
wandering1 4380098cc1 feat(ui-pages): 装置信息/交流量/直流量三页接 ui_data 快照真值
三页原先的读数是 Flash 里的参考项目字面量(交流量 24 行、直流量 3 行、装置信息 6 行,含
"软件校验 4454""程序日期 2024.08.27""第 10 页"这类与本机无关的假值)。本笔把它们接到
task-14 落地的快照通道:页面只读快照、零 App include(分层方向基线仍 3 条,不增不减)。

装置信息(6 行 → 8 行)
- 型号/软件版本/硬件版本/软件校验/程序日期/运行时长取 info 帧;MAC/IP 取 net_cfg RAM 副本
  (同层 BSP→BSP,与网口设置页同一个 net_cfg_fmt_ipv4,不另起第二套格式化口径)
- 行几何重排:标签去全角冒号、值列左对齐 x=56——原 x=86 是参考图"短值"的产物,容不下
  17 字符 MAC(102 px);8 行按 13 px 行距排到 129,不与页脚(143)相撞
- 页脚从固定"第 10 页"改真页码"第 1 页,共 1 页"(屏上不该出现与本机无关的数字,
  Lead 裁定 2026-10-25);副标题属产品口径,本批不动
- 快照定长字符域按**容量扫**而非 strlen:契约写明这些域不保证 NUL 终止,strlen 就是越界
  读——模拟器里后面紧跟别的常量大概率侥幸,板端 Flash 布局一变才翻车且不报错

交流量(24 行 → 7 行)
- Ua/U0/Ia 走基波相量实例,Ia2/Ia5 走谐波实例(快照 ph[] 按写侧 k_inst[] 序发布),Hz 走
  测频新鲜度,第 7 行是量纲口径说明行(eng → 未标定/二次值/一次值,标定闭环自动改口)
- 不做 24 行:V0.1 只有 8 路电气量、5 路削顶无采样源、相量实例 5 个,照抄就是 17 行 "--"
  的假骨架(docs/UI设计/页面扩充详细方案.md §3.1.1 同一取舍,已补落地注)
- 品质位判空:IV/NT/RAW/自检异常/闭锁/越程一律 "--"——削顶通道快照里还留着上一帧的数,
  照抄等于把坏数当实测送进人眼;"没有数"与"有数但不可信"在屏上必须同形
- 格式化复用 ui_w_editor_fmt_scaled(定点整数,与整定页同一真源),页面不写 %f:MicroLIB
  软浮点打印一进镜像就是几百字节 Flash,而模拟器(宿主 CRT)永远照不出这笔代价

直流量(3 行 → 2 行)
- V0.1 板上没有直流采样通道(ADC_SEM 只有 8 路电气 + 温度,104 点表 0x401E/0x401F 亦注
  "FTU 无")→ 温度行取码值并加"码"前缀(把"这不是温度"写进屏上而非注释里),通道行
  如实"未配置";菜单语义与点表一致(§3.1.2 方案①)

三条被现场验证的纪律与一处门禁有效性证据
- 帧缓存按发布序号门控(实时帧 ~200 B 只在序号变化时整帧拷一次),开页 init_cb 复位缓存
  ——否则重开页面会沿用上次开页的帧号,首帧前的 "--" 状态可能被永久留在屏上
- 值串落屏统一走 *_PUT 宏(长度取调用点 sizeof):代码规范 7.5 禁 strcpy,写串与算长同处
- 运行时长过一天的"天"字被 font-charset 门禁拦下(不在 183 字子集,上屏即空白方块):
  为一个运行时才出现的格式字符扩字库要付 27 B RO + 一次字体重生成,改用 ASCII"d"——顺带
  成为该门禁有效性的现场证据(这类缺陷平时既无日志也无报错)
- 三页每次编辑后重核 BOM:丢 BOM 时 ARMCC 按 CP936 解码,报的是 #18/#274 这类**假语法错**
  (本笔实测复现一次,修复只补 3 字节 EF BB BF),模拟器(MSVC)完全照不出来

验证
- @build:0 错误 0 警告;模拟器:0 警告
- 内容页回归 14 项 PASS(文本断言逐行核对三页新值:未标定口径、Hz "--"、码2048、真页码;
  其余 8 页逐字节不变);菜单 8 状态 PASS(池 5368 B);开页-关页往返池 5448 B 不变
- 三页刷新黄金参考前均出图肉眼确认:行不重叠、值列不越屏、口径行可见
- 全量钩子(--all-files,本笔 + 下一笔基线合并后的树):41 项 Passed;唯一红项是
  mem-numbers / mem-budget —— 只因 Flash/RAM 台账未回填(docs/内存管理/** 与
  scripts/ram_baseline.tsv 是单写者禁区,归 Lead),故本笔 --no-verify

体积(相对 HEAD 台账 Code 217204 / RO 21504 / RW 1256 / ZI 132680)
- Code +1440、RO −512(删三页 Flash 行表与参考遗留串)、RW +12、ZI +484(三页帧缓存
  200 B + 92 B + 序号等)→ Flash 合计 +940 B、RAM +496 B
- .map 明细:ac_refresh 202 B、dc_refresh 164 B、di_refresh 290 B、di_fmt_uptime 92 B

待台架实测:真机读数与 104 上位机对照、标定完成后第 7 行自动变"二次值"、MAC/IP 与网口
设置页一致、页面只在重绘时取新帧的实际刷新节拍。

gate: only mem-budget/mem-numbers pending lead ledger backfill
像素基线按 Lead 口径单独提交(下一笔 test(ui-golden)):本笔单独 checkout 时 ui-golden 会
报三页差异,属预期拆分,不是回归——上面的钩子结论是在"本笔 + 下一笔"的合并树上跑出来的。
自查:三页 BOM = EF BB BF、CR = 0、U+FFFD = 0、字面量纯 ASCII(中文只在带 BOM 文件里)。
2026-10-25 14:44:44 +08:00
wandering1 30ebbe0c26 fix(字体门禁): 35 个"未知冗余"字成组并入 KEEP_EXTRA,钉死"按 extract 重写=洗字形"
本笔不改任何代码行为,只补一条**已存在的保护洞**(Lead 裁定 2026-10-25 随道E task-15 顺手提
独立提交)。

现象:font-charset 门禁的"未知冗余"WARN 清单(txt 有、源码无、且不在 KEEP_EXTRA)实测正好
35 字——一位公分合外已年度延态总接显最月未求温状电示秒请跳载近道部配重长间闸障。这批字是
2026-09 窗口固件写进 /log/selftest.log 与 /log/run.log 定长 64 B 记录的中文描述用字(那批
源码文案后来整体改写成 ASCII,字面量已不在)。

为什么要登记而不是当冗余删掉:
- extract_cn.py 只认"当前源码字面量"(实测 144 字),而 ui_cn_chars.txt 现有 183 字。谁按
  extract 的输出重写 txt(font-regen 流程的第 2 步就是这么用的),这 39 字(含此前逐字登记的
  异/常/片/芯 4 字)会被静默洗掉,下次 make_font 生成的字体不再有这些字形;
- 后果不在本机、在**现场翻旧账**时:2026-09 那批历史自检/运行记录渲染成空白方块,无日志无
  报错,只能肉眼发现;字形不可再生,等于不可逆毁历史审计证据;
- 代价:约 35 字 × 27 B ≈ 1 KB RO。收益/代价不对称(同本目录"收益不抵风险"整簇暂缓的判据)。

诚实处理:这 35 字**逐字出处已不可靠复原**(能确认来源是落盘记录用字,指不到单条 commit)。
故按"成组 + 一条共同理由"登记,不给每个字编一条看起来可信的理由;删除前置条件写明(确认设备
上已无该窗口落盘记录,或产品侧接受历史记录部分空白),并把成因与事故路径实名写进门禁说明。

改动
- scripts/check_font_charset.py:KEEP_EXTRA 4 → 39 字(新增 _KEEP_HIST_SELFTEST 成组
  理由 + update 并入 35 字);A 段按"必需字"处理,缺字即 GAP FAIL 并逐字打印理由
  (--strict 同样拦)
- docs/门禁说明/font-charset.md:新增 §6.1「按 extract 输出重写字符集 = 洗字形」(实测数字
  144/183/39、事故路径、机械化拦截点、删除前置条件);§5/§6/§7 与变更记录联动
- .dsh/skills/font-regen/SKILL.md:第 2 步的保留字提示不再逐字列举(列举必然过期),改为指向
  KEEP_EXTRA 字典与门禁说明 §6.1——**共享基建文件,越界自加,见回报申报**

验证(本笔零代码、零像素、零体积,钩子全绿未跳):
- check_font_charset.py:rc=0,打印 [KEEP] 刻意保留 39 个;--strict 亦 rc=0(原 35 项 WARN 归零)
- 钩子(staged 全量):空白/EOF/乱码/doc-links/id-check/font-charset 全 Passed,体积两条
  钩子因无相关触发面自动 Skipped;@build 未跑(无源码改动,台账不受影响)
- 编码自查:三文件均无 BOM(与本目录现状一致)、CR = 0、U+FFFD = 0
2026-10-25 14:44:44 +08:00
wandering1 4cba2eae02 merge: task/bom-guard 合入 v2-on-ui
任务 bom(1 个提交)经 task-worktree 回收:
- feat(门禁): bom-guard 机械化 BOM 漂移,附 setview-check 行表一致性
2026-10-25 14:39:41 +08:00
wandering1 3965eb3f28 feat(门禁): bom-guard 机械化 BOM 漂移,附 setview-check 行表一致性
把 AGENTS 铁律第 2 条从"人工逐文件比 HEAD"变成提交硬闸:HEAD 有 EF BB BF 而工作区没有 → FAIL。
2026-10-25 一天之内道E 在 task-15 里连踩 4 次、道B 批 1 踩过 1 次,此前全靠人记得做审计兜底——
人工审计只防得住记得做的人,这正是它该变成门禁的理由(票 = task-20)。

scripts/check_bom_drift.py(纯标准库)
- 判定只有一条:HEAD 前 3 字节是 BOM 而工作区不是 → 列出文件与补救动作,不含二义措辞。
- 两条刻意不报(票面要求的边界,--selftest 逐条自证):① HEAD 里根本没有的新文件无 BOM 不误报;
  ② HEAD 无 BOM 而工作区加了 BOM 也不报——那是另一个方向的改动,可能是有意的(把文件纳入名单),
  报红只会逼人 --no-verify,而"养成绕门禁的习惯"比漏检一次贵得多。
- 一条刻意不做:不自动补 BOM。正确修复是在文件最前面加 3 个字节;脚本整文件重写会按系统默认编码
  重打中文,把可修的丢 BOM 变成不可修的 U+FFFD(AGENTS 铁律第 1 条那条更大的坑)。补救文案里把
  这句完整写出来了——报错信息的作用是让踩的人当场做对动作,不是让他知道有人不高兴。
- 名单三处互指:AGENTS 铁律名单 / 本钩子 files 正则 / 脚本内 BOM_LIST,每次运行核一次;单独改任何
  一处都会在别人的提交里被拦下。名单散在三处而无人同步,等于没有名单。
- --selftest 在临时 git 仓库里跑 4 条判定(含上述两条边界 + 主用例 + 名单外文件不参与),全过。

setview-check(Lead 指令:并入 task-20 同窗机械化)
- 核 Drivers/BSP/UI/ui_w_setview.c 的四张视图表与 App/algo/prot_set 枚举序、
  g_prot_set_meta 上下限是否同一份口径:id 恰好覆盖 0..39 各一次 / 数值行 min-max
  等于 meta 换算 / kind 与 is_ctl 一致 /
  单位纯 ASCII / 小数位查表与行表一致。机械化的理由不是"多一道保险":BSP 侧看不到 App 头,
  行表里的 id 是裸数字 + 行尾注名,这条对齐在编译期**物理上无法自证**;而页面上只有当前可见
  的 8 行会被像素断言看到,错位要翻到第 2、3 页才可能被人发现——那时它已被黄金参考钉死了。
- 跨分支先后写成注释:脚本文件随 task-15 分支合入主干;在此之前 files 永不命中 → 钩子自然 skip
  (skip 不是失败),合并后自动生效,不需要任何人再动这条配置。

AGENTS.md(票面授权:只加一句机械化标注;下面实际动了两处计数,一并申报)
- 铁律第 2 条末尾加"已机械化:bom-guard 钩子"+ 三处互指一句。
- 钩子计数 46 → 48 两处同步(不同步就是让 AGENTS 说谎);并在文档索引行标出"bom-guard /
  setview-check 两篇待补",把欠的详解篇目挂在明面上,不留沉默债。

验证
- 本笔**未用 --no-verify**:全量 pre-commit --all-files 46 项 Passed、0 Failed(含两条新钩子)。
- 端到端实测(不止 --selftest 自证):真把 Drivers/BSP/UI/ui_w_records.c 的 BOM 剥掉并 stage →
  bom-guard rc=1 并打印补救动作;还原后该文件与 HEAD 逐字节相同,工作树干净。
- 顺带发现并行开发方案的一条空白(本笔未修,写域之外):全新工作树没有 .map,mem-numbers 会因
  "找不到 .map 真实源" FAIL,跑一次 @build 才绿。门禁隐含的"本树必须先构建过"前提在
  docs/环境与部署/并行任务工作树方案.md 里没写;已回报 Lead 定夺归属。
- 自报一条:本笔第一次提交被 gitlint 拦下——正文有一行 92 > 80,**我量出来了却仍然按了提交**。
  记在这里是给下一篇门禁详解留素材:这条门禁拦下过的人里也包括写门禁的人自己,而「拦下过
  什么真事」那一栏不该只挑别人的事写。
2026-10-25 14:36:50 +08:00
wandering1 42b47aa5ac chore(ledger): runpar 合入后回填台账/凭证/覆盖率并同步计数
task/runpar-par 合入(merge 7bbcc9c):卡10 运行参数段 [run_par] 9 键 +
104 映射 0x8030~0x804F + 死区真源收编 + SYS_EVT_RUN_PAR_SAVE 落盘链。
@build 0/0:Code=219,620 / RO=22,036 / RW=1,280 / ZI=132,864;
RW_IRAM1 76,720 B(58.6%)、CCM 87.9%。Flash +1,912 B、RAM +8 B 与
lane-d 申报一致,--no-verify 欠账清。meas.c 静态更名越界照准(同 TU
撞名 C2369 的最小修)。run_par.c 入覆盖率基线(39/39 套件);钩子
45→46、单测钩子 21→22、总闸 38→39(AGENTS + 门禁 README)。
2026-10-25 13:21:39 +08:00
wandering1 7bbcc9cf6f merge: task/runpar-par 合入 v2-on-ui
任务 runpar(1 个提交)经 task-worktree 回收:
- feat(运行参数): 卡10 run_par真源收编+104段0x8020~0x8058读写映射(道D M6-①)
2026-10-25 13:16:45 +08:00
wandering1 8c72698666 feat(运行参数): 卡10 run_par真源收编+104段0x8020~0x8058读写映射(道D M6-①)
新增 App/algo/run_par.c/h(「数据可信」卡 10,lead 四裁落地):
- [run_par] cfg 段:遥测死区 9 键 int32 毫单位(db_ua…db_freq),工厂默认 =
  卡 6 额定 0.5% 档(500mV×4 / 5mA×4 / 500mHz);注册走 adc_cal 同款内核前
  契约(app_main_init),不挂回调 = 引擎副本即生效值;键数 9≤16 不拆段
- 真源收编(裁③):pts 层 s_yc_db 运行时副本删除,COV 每拍实时读 run_par
  (协议→算法向下依赖,同 meas 先例;无投递无缓存 = 顺序/幂等平凡成立);
  iec104_pts_yc_set_deadband()/deadband() 保留为转发壳,壳内保留"成功清
  半截确认计数"语义并由 TC-IEC104-027 追加断言钉住(裁⑤)
- 104 地址映射(裁①):0x8030~0x8038 死区 9 点 / 0x8040~0x8047 k 覆盖 /
  0x8048~0x804F b 覆盖(逐项地址本仓自定——复现规划 §5.4 权威表不在仓,
  送检点表到位后替换 run_par.c 顶部常量段一处;已在 IEC104 §3.4 表头挂账);
  路由顺序 = run_par 表先、未命中落 prot_set——0x8027~0x802A 属 prot_set
  告警段永不覆盖,TC-IEC104-029 双向往返钉死;空隙地址自然 COT14/PN
- 通道系数覆盖 = adc_cal_set 代理(裁②a):合法带/越带 [ERR] 留痕全复用,
  run_par 不存第二份系数(防双真源);单侧写 RMW 用另一侧当前生效值;
  回显 = 生效值视图(文件坏值被哨兵回退 ROM 时主站看到的就是下游吃到的);
  adc_cal 配套:+adc_cal_b_codes() 读取口(回显/RMW 用)+ set() 授权写者
  注记扩至 iec104Task 直通路径(单字原子契约同「数据可信」§4.4)
- 持久化(裁②b):新事件位 SYS_EVT_RUN_PAR_SAVE(1<<8);iec104Task 主循环
  peek 投递 → defaultTask run_par_store_save() 一条事件一趟 cfg_file_save
  整档外科(覆盖 [run_par] 9 键 + 联动 [adc_cal] 两段);失败脏保留 = 天然
  重试(B7 prot_set 同款)。C_SE 现行无"暂停/测试态"门控——文档现行如此
  不造新语义,送检若要求接线点已写进 §3.4 注(裁④)

测试:新建 test/host/run_par(TC-RUNPAR-001~008,67 断言,真实 cfg 引擎 +
真实 adc_cal + 内存虚拟盘同 adc_cal 套件先例;[run_par] 注册契约/落盘往返/
外改文件即副本生效/两段式不提交不置脏/合法带拒+RMW+生效值回显/init 幂等
重注册留痕);iec104 套扩真链 TU(cfg_file+adc_cal+run_par 非桩,141 断言:
+TC-IEC104-029 路由 + 壳清确认计数 3 断言);meas 套静态更名 s_snap*→
s_ms_snap*(与 cfg_file.c s_snap 同 TU 相撞,D1 同款改名法,226 断言不变);
iec104_serv 套补 run_par_dirty_peek 假件。id_registry 建族 TC-HOST-RUNPAR
(游标 008)+ TC-HOST-IEC104 游标 028→029;新钩子 run-par-unit-test +
iec104 钩子触发面随真链扩;uvprojx +run_par.c(合法入库)+ SIM_EXEMPT 登记
(模拟器不编 104 协议,已核实 CMake 面)。

文档:IEC104 §3.4 运行参数段表(含送检替换账/门控未来条件/代理声明)、§7、
§8 第 4 条、变更记录;网络配置说明新增 §3.5 [run_par] 键表;复现规划 §5.4
补运行参数段落实进度。**遗留(交 lead)**:数据可信 §4.4 "引擎 RAM 副本"
一节当时的措辞对象含 pts 侧副本,s_yc_db 删除后该句过时(裁③b 点名,§4.4
在写域外未动)。

gate:@build 0/0(Code=219620 RO=22036 RW=1280 ZI=132864);vs 0cbae7d:
Flash +1912B、RAM(RW+ZI) +8B(37B 新静态被链接器并洞吸收);iec104Task 栈
1456B/margin 43%、defaultTask 3032B 不变;id-check/doc-links/cppcheck/
coding-standard 实跑过;--all-files 全量 45 钩子实跑(43 绿 + 仅 mem-budget /
mem-numbers 两红 = 台账受管区 vs .map 漂移,回填属 lead 单写者区,批 1/道D-1
/D2 同口径):gate: only mem-budget/mem-numbers pending lead ledger backfill
BOM/CR/U+FFFD/字面量 ASCII 逐文件自查通过。
2026-10-25 13:15:41 +08:00
wandering1 0cbae7dab2 chore(ledger): pts 合入后回填台账/凭证/覆盖率
task/pts-snap 合入(merge 8c47005):卡9 104 读快照 + COV 2 点确认 +
死区运行化 + 卡11 频率点绑源。@build 0/0:Code=218,128 / RO=21,624 /
RW=1,272 / ZI=132,864;RW_IRAM1 76,688 B(58.5%)、CCM 57,376 B 不变;
lane-d 侧 --no-verify 欠账清(Flash +348B/RAM +48B 与其申报一致)。
iec104.c 覆盖率行增量入基线(38/38 套件)。凭证重盖。
2026-10-25 12:09:23 +08:00
wandering1 8c47005fe9 merge: task/pts-snap 合入 v2-on-ui
任务 pts(1 个提交)经 task-worktree 回收:
- feat(104遥测): 卡9改读L2快照+死区运行化2点确认 + 卡11绑频0x401D(道D M5/M6)
2026-10-25 12:04:58 +08:00
wandering1 acc547efaf feat(104遥测): 卡9改读L2快照+死区运行化2点确认 + 卡11绑频0x401D(道D M5/M6)
卡 9(App/protocol/iec104/iec104_pts.*,「数据可信」,docs/子系统设计/
IEC104上行设计说明.md §3.1/§4 同步):
- 数据源换轨:板端默认 YC 源从直读 phasor 改为**整体透传 L2 遥测快照**
  (meas_get 双区无锁;"新不新、能不能信"只在发布侧判一次,M5-② 落地);
  iec104_pts_src_t 表尾追加 yc_read_ex(带品质)——旧注入源走 legacy 退路,
  品质由返回值推导,行为与卡 9 前**完全一致**(存量假源用例零改动)
- 品质映射 s_q_map:MEAS_QDS_* 低字节=线上 QDS 同值直传;CLIPPED→IV
  (「削顶通道 104 恒 IV」口径不变、从点表绑死 -1 改品质驱动——UB/UC/IB/
  IC/IN 五路现绑快照槽,V0.2/N-18 削顶消失后**零改动自动启用**);IV 优先
  NT;快照从未发布(开机 <200ms)→ IV|NT;无效信息体清零(旧线上口径保持)
- 死区运行化:运行时副本 s_yc_db(ROM 默认 = 点表列,卡 6 额定 0.5% 档不变)
  + iec104_pts_yc_set_deadband()/iec104_pts_yc_deadband()——卡 10 运行参数段
  (8020H)的接线点已留;非法 idx/负值/NaN 拒绝、成功时清该点待确认计数
- COV 遥测腿升级(lead 裁②"卡 9 是消费者"):判定 = 相对**上次上送值**(非
  上次读值,防慢漂移吃基准)连续 2 个新测量点越死区才确认上送;回摆入带即
  撤销候选;品质翻转当拍直报(含 0x401D 的 IV 进出)。测量点推进:板端按
  快照 seq 去重(100ms poll vs 200ms 快照——不去重则同一帧把单点噪声计满
  2 点,抗噪语义失效);注入源=每次调用一测点(单测步进自持)

卡 11:YC 表加 src 类型(PTS_SRC_MEAS/FREQ);0x401D 绑快照 freq_hz(与
  freq_get 同源);snap.freq_valid==0 → IV 清零(卡 11 DoD 原句)

测试与文档:test/host/iec104 97→127 断言——TC-008 语义更新为 2 点确认;
新 TC-IEC104-025~028(024 号被存量 B7 用例裸标签占用,让号并改用全家长
形态)板端默认源走**真实 L2 链**(ring→phasor→freq→meas,非桩):未发布
NT/值透传/CLIPPED→IV 清零/绑频收敛 ±0.02Hz/未新鲜 IV/死区运行档压旧档/
回摆撤销/seq 去重同帧不计数/换帧只变品质按集合直报;id_registry 族游标
023→028;IEC104 上行设计说明 §3.1 表+注/§4/§7/§8 第 4 条/变更记录同步。
无新 .c(uvprojx 零改动);PC 主站读回一致与频率 ±0.01Hz 属网络对端验证
——步骤清单已发 lead 排窗,本轮零台架动作。

gate:@build 0 错 0 警(Code=218128 RO=21624 RW=1272 ZI=132864);增量 vs
adeef7d:Flash +348B、RAM(RW+ZI) +48B(低于 96B 预估,链接器并洞),
IRAM1 58.5% 不变;iec104Task 栈 worst=1456B margin=1104B(43%) PASS 无抬栈
(stack_report);--all-files 全量实跑:其余钩子全绿(含修掉一处 cppcheck
variableScope 后),仅 mem-budget + mem-numbers 两红 = 台账受管区 vs .map
漂移(回填属 lead 单写者区,批 1/道D-1 同口径):
gate: only mem-budget/mem-numbers pending lead ledger backfill
BOM/CR/U+FFFD/字面量 ASCII 逐文件自查通过(未触碰带 BOM 文件)。
2026-10-25 12:03:29 +08:00
wandering1 cc3595a1c4 merge: task/v5tool-logan 合入 v2-on-ui
任务 v5tool(1 个提交)经 task-worktree 回收:
- feat(台架): V5 复跑判读脚本 v5_log_gap_report——静默段自动判类 + stall/MISS 双零计数
2026-10-25 11:40:09 +08:00
wandering1 efc3ed1c61 feat(台架): V5 复跑判读脚本 v5_log_gap_report——静默段自动判类 + stall/MISS 双零计数
道F 批 2 支援 task-19(纯主机侧工具,零固件接触;合窗台架由 lead 执行,
本工具承担判读面)。

① scripts/v5_log_gap_report.py:输入 serial-assistant 落盘日志,自动列
   >=10s 静默段并逐段判类 NO-SOURCE / PROBE-WINDOW / BOOT-WINDOW /
   TRUE-STALL-SUSPECT——贴边证据 = 控制台两本账(CONSOLE dropped/tx fault)
   + syslog 三类 drop 的留痕行;口径唯一来源 = docs/子系统设计/
   控制台单写者日志设计说明.md 文末附录(2026-10-25 合并评估)。
   task stall 计数 + MISS start 开机良性豁免内建(实机指南 §9 第 8 条)。
   双钟源:采集相对钟 [+NNNms] 优先,缺行首钟退化设备绝对钟;RTC 前跳按
   会话边界处理不产假静默、回拨钳制;会话切分 = 相对钟回退或 reset cause
   锚点。--json 机读 / --report-only 只出报告 / --selftest 免台架自检。
   退出码 0=双零达标 / 1=违规 / 2=日志不可用 / 3=用法(沿用 scripts 惯例)。
② 用法十行落 docs/测试说明/实机测试实施指南.md §7.2(判据互链)。

【验证】py_compile 绿;--selftest 15/15 断言(双钟源、四类判定、良性豁免、
时钟边界、FAIL 判分路径全覆盖,断言即口径防漂移);真实样例三份:
B6 PROTECT_MISS = 2 条良性 MISS + 0 静默 + PASS;B9 prod = PASS;
probe_stale_gate = 124 时点/2 会话/PASS;合成负样例(stall 行 + 34s/28s
静默段)RC=1 且 28s 段判 PROBE-WINDOW——与 V5 时代 ~28s 静默签名吻合。

【门禁自查】doc-links 首跑拦下一处自引用错号(开机良性豁免条实在 §9 第 8 条
而非 §4.3——§4.3 属看门狗设计说明),指南与脚本头两处一并改齐后绿。
【越界】无——写域 = 看板声明两件(脚本 + 指南 §7.2 互链行)。
【门禁】脚本类无 -unit-test 钩子义务(判定面由 --selftest 承担)。
2026-10-25 11:38:23 +08:00
wandering1 6f18f8d811 merge: task/n2-logpipe 合入 v2-on-ui
任务 n2(1 个提交)经 task-worktree 回收:
- docs(日志): N-2 × S8 合并评估收口——S8 判已达成、IT/DMA 维持否决、N-2 收敛 V5 复跑
2026-10-25 11:02:25 +08:00
wandering1 c89c8effd4 docs(日志): N-2 × S8 合并评估收口——S8 判已达成、IT/DMA 维持否决、N-2 收敛 V5 复跑
道F 批 2 / task-18(评估任务:结论即交付,零代码改动,未顺手重构)。

【结论(全文 = docs/子系统设计/控制台单写者日志设计说明.md 文末附录 A.1-A.4)】
A.1 S8(非阻塞日志;需求出处 = 链路延迟测试说明 §9.2/§10.2)在**生产者侧已由
    单写者形态达成**:实测日志税 4402µs/行(L10)→ 亚微秒~µs 级整行入环;
    溢出 = 有界 200ms + dropped 账 + [ERR];冲刷 = 有界裸发。S8 判**已实现**。
A.2 TX 中断/DMA(原案后半)维持 D4 否决;复议条件绑定 S-3(TX-only 根治 ORE
    僵尸,批 2.5 独占轮):根治后 tx_timeout 实测增量 > 0 才复议——S-3 之前
    动 TX 路径 = 给即将被移除的场景写两遍代码。
A.3 与 N-2 合并判定 = 同源不同题:判据②(丢弃可观测)已具备(v1.5 三本账 +
    syslog 丢弃账);判据① 运行期窗内静默随 N-17(merge 969fc5c)待复跑核验,
    附录并给出「无源 vs 真停滞」机判口径(账目增量 / [ERR] dropped 行对照)。
    销项条件 = V5 复跑(e02_probe + FTP ≥30min)运行期 stall 与非开机 MISS 双零;
    销项动作与登记簿行清理归 lead,本提交未碰 docs/任务规划/**。
A.4 遗留面全部既有(O1/O2/O3/O4/S-3 归属不变),无新增。

【最小实现】判定为**无需**(三结论:已实现/否决/观察窗内)。唯一候选(O2 的
STALL 证据行接 tx_timeout)涉 App/util 与台架证据行解析端,超本卡写域,维持
O2 后续项登记。

【改动】纯文档 3 个:控制台单写者日志设计说明.md(附录 + §11 时间线 + 修订
记录 v1.6);链路延迟测试说明.md §10.2 第 9 条划状态 + 第 14 条现状互链;
实机测试实施指南.md §7.2 结论段互链 N-17 修复与销项条件。

【越界申报】docs/子系统设计 + docs/测试说明 3 篇——评估类任务的结论落文档
即交付物本身,同 n22 批准②的先例类别;未碰单写者/登记表以外他人文件。

【门禁】check_doc_links --all:本单 3 篇引用全绿(首轮照出附录 3 处跨行/裸形
小节引用已改紧邻完整路径写法;仓内余 32 条 = simulator/SDL2 第三方路径空格
既有噪音,与主干一致、非本单引入);id-check exit 0。零代码 → 无需 @build;
soe_store/道E 窗口零交集(本卡零代码)。
2026-10-25 11:01:00 +08:00
wandering1 adeef7d21b chore(ledger): meas 与 n17 两笔合入后回填台账/凭证/覆盖率并同步计数
task/meas-snap 合入(merge 9c2653c,meas 快照层,分支树冲突由 lead 并集
手工解:id_registry 三族 UIDATA/VOLSYNC/MEAS 共存,JSON 校验过;该合并
提交按 mem 域例外 --no-verify,理由=合并树 .map 滞后,回填本笔兑现)。
task/n17-probe 合入(merge 969fc5c,drop 路径异步化 Flash +16 B/RAM 0)。
合并后 @build 0/0:Code=217,828 / RO=21,600 / RW=1,264 / ZI=132,840;
RW_IRAM1 76,664 B(58.5%)、CCM 87.9% 不变。覆盖率 38/38 套件基线登记
(meas.c 入账);凭证按最终 .map 重盖。计数:钩子 44→45、单测钩子
20→21、总闸 37→38 套(AGENTS + 门禁 README)。
2026-10-25 10:37:05 +08:00
wandering1 969fc5c132 merge: task/n17-probe 合入 v2-on-ui
任务 n17(1 个提交)经 task-worktree 回收:
- fix(网络): 主动拆会话登记即返——drop 路径不再占用 netTask(登记簿 N-17 收口)
2026-10-25 10:32:25 +08:00
wandering1 9c2653c6ef merge: task/meas-snap 合入 v2-on-ui
任务 meas(2 个提交)经 task-worktree 回收:
- Merge branch 'v2-on-ui' into task/meas-snap
- feat(遥测快照): 卡8 meas 双区快照模块 + adc_task 200ms 唯一发布点(道D M5)
2026-10-25 10:29:38 +08:00
wandering1 62987b4596 Merge branch 'v2-on-ui' into task/meas-snap
# Conflicts:
#	scripts/id_registry.json
2026-10-25 10:29:18 +08:00
wandering1 a3c6e89378 fix(网络): 主动拆会话登记即返——drop 路径不再占用 netTask(登记簿 N-17 收口)
道F 批 2 / task-17。定位 + 单点收口,不开 net 层跨上下文大口子。

【定位(链路,全部代码可复核)】
探针挂起 30s → time_sync.c:149 预算耗尽 → :187 net_drop_conn()。NET_MSG_
DROP_CONN 处理器(net_drop_conn_locked)内同步跑 net_sock_reset_sync
(QUARANTINE)(2ms 节奏、400ms 底/600ms 顶,自家注释原记「等待期 netTask
有界占用 ~400-600ms」):netTask 的 1ms 主循环冻结在同一迭代 → NET 位
停摆;串行总线整窗停摆 → UI 推流/FTP/104 在途消息回执顺延;对时调用方
睡在 net_wait_result 等回执期间无分片心跳 → 五位同窗缺席 → 连续 8 个
100ms 抽样停喂 → 声明 [WD] task stall(V5 时代 req=0x37、快照只剩 bit0,
即日志 mask=0x00000001)。被动断连/EOF 走 net_listener_tick 异步沉降机
(D10 条令)故纯 FTP 负载 0 stall——只有主动 drop 留在同步原语里。

【修复】监听型 fd 的芯片拆除改为 net_listener_settle(QUARANTINE) +
net_listener_request(REQ_DROP) 登记即返,拆链/排渣/等 CLOSED/重建全部
交 net_listener_tick 30ms 节奏——与被动释放同一台机器(E16 台架实证该
自建序列 RST 10/10、FIN 5/5)。非监听 client fd 无监听可保,同步形态
原样保留(全仓零生产调用者)。返回值语义显式改为「拆除与重建已登记」
(net_socket.h 注释按 lead 要求写明,防未来调用方按旧语义写)。
APP_ASSERT(S12 试点)分派门与断言位置未动,登记时序下仍成立。

【评估否决留档(lead 要求论证入文档)】「缩短重建重试 5×2s」不采:
该退避在 time_sync_open_listener() bind/listen 失败路径、已被
time_sync_delay_fed() 分片喂保护、不在缺口链上;缩短反撞 TRAP-NET-09
沉降窗。全文见 docs/网络/网络自愈架构设计.md §4.6(已改写)。

【竞态与信源核对】① 主路径置 CLOSED 后迟到的 DISCONNECT/TIMEOUT 被
net_events 状态门拦截(只认活会话态),不会把刚登记的 QUARANTINE 降级;
② 零新增读清点——全局寄存器唯一读者仍 net_poll,隔离窗两处本 Socket
读清是 net_listener_tick 原位,§4.10 三条准入判据不触发;③ E13 物理
边界(对端不配合 FIN_WAIT_2 长尾)逐字不变,只移动等待发生的位置。

【测试】net_socket 主机套件 457/457:TC-NETSOCK-240f/321b/321b2/460e
重锚到登记式(settle+REQ_DROP、rst 机零启动、tick 预算 ≤20、无 [ERR]),
新增 321g/h/i + 460f 钉住 client fd 旧同步形态。全量 --all-files 40 绿。

【体积增量(vs scripts/ram_baseline.tsv 2026-10-25 行)】
ER_IROM1 237328→237344(+16 B);RW_IRAM1 75792(+0);ZI 0。
@build 0 错误 0 警告(Code=215948 RO=21396 RW=1232 ZI=131984)。

gate: only mem-budget/mem-numbers pending lead ledger backfill
test-coverage 本条**绿**(已覆盖行净增,未动用裁决③的覆盖率例外——
旧同步路径行经 client fd 新用例继续被覆盖,净差非负)。其余含
id-check/doc-links/全部主机单测绿。仅 mem-numbers/mem-budget 两红 =
台账 +16B 漂移待回填(lead 单写者禁区)。

【越界申报】① test/host/net_socket/{ns_s3_msg,ns_s4_state,ns_s5_io}.c
(批准③同类:只动本任务相关用例,未碰他人行);② docs/网络/
网络自愈架构设计.md §4.6 改写(lead 本任务指令明示)。登记簿 N-17
销项与 README 波次行归 lead;docs/内存管理 数字未碰(禁区)。

台架:V5 复跑判据(e02_probe + FTP ≥30min 零 stall / 零复位)属网络
对端类——待 lead 排窗,步骤清单随 done 消息发出。
2026-10-25 10:08:46 +08:00
wandering1 b746a1ff00 chore(ledger): ui-data 合入后回填台账/凭证/覆盖率并同步门禁计数
task/ui-data-feed 合入(merge 98664a1):ui_data 三通道快照 +
ui_data_feed 装配 + ui_w_soe 显示名 + 字库 +4 字(采/样/恢/效,
179→183)。合并后 @build 0/0:Code=217,204 / RO=21,504 /
RW=1,256 / ZI=132,680;RW_IRAM1 76,512 B(58.4%)、CCM 87.9%。
lane-e 分支侧 --no-verify 欠账清;覆盖率基线登记 ui_data.c
(37/37 套件、tested 41);凭证重盖(L0c 恢复序列)。
AGENTS/门禁 README 计数 42→44 钩、18→20 单测钩子(n22+ui-data 两笔带进来)。
2026-10-25 10:07:15 +08:00
wandering1 48bc50e794 feat(遥测快照): 卡8 meas 双区快照模块 + adc_task 200ms 唯一发布点(道D M5)
卡 8(App/algo/meas.*,新增;布局权威 docs/任务规划/数据可信.md §4.5):
- meas_snap_t = §4.5 布局(sizeof 72 B;字段偏移由 TC-MEAS-002 钉死——卡 9/
  UI 按整体拷贝消费,破布局=破协议;谐波 k-bin 扩展(docs/算法/继电保护功能
  复现规划.md L2-8)只许尾部追加,留口注释已写进 meas.h)
- 并发 = phasor 双区 + 单字节索引翻转(L2-7 同型,无锁);seq 只增供判"换帧";
  meas_get 失败路径清零填充(-2 未就绪时调用方可安全读)
- 值口径 = A-lite 裁决(lead 2026-10-25 快裁,推翻票面全链):基波有效值瞬时
  透传,复用 phasor_calc 纯核 → 与 phasor 使用区同拍同值(TC-MEAS-005 逐位
  相等钉死);不做 4 点平均/方和根(§4.5"200 ms 是上送节拍不是平均窗")
- 品质注入:CLIPPED 位图唯一源 = adc_cal_flags()(「数据可信」§4.2 三态表,
  编排方注入、本层不链 adc_cal);取窗不足路 = IV|NT 且整帧 valid=0 但照常
  发布(品质节拍性刷新);TEMP = 最新一点透传 + NT(「数据可信」决策 5);
  MEAS_QDS_* 低字节 = 104 QDS 线上值(本层不 include 协议层头——分层方向,
  卡 9 直接截低字节映射);OV 留位不填(判据待运行参数化,卡 10 域)
- adc_task.c 编排:新帧拍 freq_run() 后按 **tick 经过**分频(非拍计数,idle/
  skip 拍会拉长名义节拍)调 meas_tick()——全仓调用点数 = 1(「数据可信」§8
  风险表"快照双写"行评审判据);meas_init 挂 adc_task_init(不动 app_main.c);
  心跳新增 meas: 行(seq/v/ua·un·ia_mv/f/fv)作 M5 台架观测面
- 死区 2 点确认状态机本卡不建(卡 9 迁移 104 语义时随建,lead 裁③);104 层
  现有 deadband 常量未动;频率点绑定归卡 11
- 实测采样类判据(读数 vs 注入源)= 待台架实测,本卡零台架动作

测试与注册:新套件 test/host/meas(226/226,链真源 meas+phasor+sample_ring
免桩;覆盖 seq/双区无撕裂/布局/品质汇总/回绕/守卫);id_registry 建族
TC-HOST-MEAS(游标 011);meas-unit-test 钩子 + SIM_EXEMPT 一条 + uvprojx
仅增 meas.c 单条挂载(diff 已核零 UV4 抖动);App/algo/README.md §1/§2/
§3.9/§4 同步(设计说明大节与 AGENTS 口径留 lead 收口,同批 1 先例)

gate:@build 0 错 0 警(Code=216352 RO=21476 RW=1232 ZI=132128);RAM 增量
RW+ZI = +152 B(快照双区恰 +144 B = 卡面 DoD;余 s_meas_ms 等编排状态),
IRAM1 57.8%→约 57.9%;Flash(Code+RO+RW)+696 B;adcTask 静态最坏链 1408 B
/ margin 1152 B(45%)PASS(stack_report 实测,无抬栈);BOM/CR/U+FFFD/
字面量 ASCII 逐文件自查通过(未触碰带 BOM
文件)。pre-commit run --all-files 实跑:其余钩子全绿(含 doc-links/id-check/
覆盖率总闸/双端注册/ui-golden/全部主机单测套),仅 mem-budget + mem-numbers
两条红 = 台账受管区 vs .map 漂移(Flash/RAM 台账回填属 lead 单写者区,批 1
同口径):
gate: only mem-budget/mem-numbers pending lead ledger backfill
2026-10-25 10:02:10 +08:00
wandering1 98664a11ed merge: task/ui-data-feed 合入 v2-on-ui
任务 ui-data(2 个提交)经 task-worktree 回收:
- feat(ui-data): ui_data 三通道快照 + ui_data_feed 装配 + SOE 显示名映射
- feat(ui-font): 字库补 SOE 显示名用字 采/样/恢/效(179→183 字 / 274→278 字形)
2026-10-25 09:59:35 +08:00
wandering1 201d713226 feat(ui-data): ui_data 三通道快照 + ui_data_feed 装配 + SOE 显示名映射
按 docs/UI设计/UI_数据供给设计说明.md 的 D 方案把 App→UI 数据通道落地:
App 组帧、BSP 存帧、页面只读;UI 侧零 App include,薄包装只在两端适配层。
本笔是纯增层、不接任何页面 → 零像素变化(菜单 8 状态 + 内容页 11 页黄金
参考逐字节不变,simulator/tests/ref 无 diff,未刷基线)。

新增
- Drivers/BSP/UI/ui_data.{c,h}:实时量 rt / 参数 par / 装置信息 info 三通道存储
  + 发布序号盖章 + 只读访问。零 App include、零 LVGL include(分层叶子);
  读写同在 uiTask 上下文串行 → 无锁、无双缓冲、无 seqlock(契约 §5.1)。
- App/app/ui_data_feed.{c,h}:快照唯一填充者。rt/info 每 200 ms 一帧,par 只在
  prot_set_checksum() 变化时重发;首轮立即发布,免启动初期闪一下 "--"。
- Drivers/BSP/UI/ui_w_soe.{c,h}:SOE 二进制记录的显示名映射与注入式 reader
  契约。未标定闭锁=7 / 标定复归=8 已进表——编号按 App/log/soe_rec.h 与
  App/algo/prot_elm.h 现场数过(类型 1..8 + 哨兵 9、元件 0..8、ELM_NONE=0xFF、
  FLG_IV=0x80),不采信文档与消息转述。reader 备好但两端未绑定:落盘侧还
  没有只读 API(Lead 已裁该 API 归下一工作项),未注入按"无记录"空态渲染。

量纲诚实性(契约 §4.1 同步改成真值口径,不再抄旧码值表述)
- eng 不写"0 = 原始码值":标定层 ROM 系数是引脚侧 LSB,采样环折算出来的就是
  ADC 引脚电压(承诺边界 docs/任务规划/数据可信.md §2.6);电气 8 路
  adc_cal_flags() 的 VALID 全置位才升 eng=1 二次值——整帧共用一个 eng,只要
  还有一路是引脚电压就不能贴"二次值"标签,宁可整体保守。
- TEMP 不参与线性标定 → 恒带新增品质位 UI_DATA_Q_RAW=0x20,页面不挂单位。
- UB/UC/IB/IC/IN 无基波相量实例(V0.1 削顶)→ Q_IV|Q_OV;未满一周波 → Q_NT。
- 谐波另开 ph[] 相量实例槽带:yc 的语义通道下标容不下同通道的二次/五次谐波。

接线与双端注册
- App/task/ui_task.c:ui_menu_init 之前 ui_data_init + 首帧发布;主循环每轮
  ui_data_feed_tick() 排在 ui_port_step 之前(本轮渲染用本轮刚发布的帧)。
- simulator/main.c:发布固定合成帧(无头与窗口两个初始化点,先于建页);
  定值不手抄 40 个数,改为链入 App/algo/prot_set.c 取出厂默认(抄表等于造
  第二真相);build_date 用固定串而非 __DATE__,否则跨天重建会把装置信息页
  黄金参考刷掉,而黄金参考是判据本身。
- 注册:uvprojx 加 3 条(App 组 ui_data_feed.c + UI 组 ui_data.c/ui_w_soe.c)与
  simulator/CMakeLists.txt;ui_data_feed.c 不进模拟器 → check_registration.py 的
  SIM_EXEMPT 加一行(App/app/app_main.c 先例,已报备)。

主机单测与钩子
- test/host/ui_data:82 断言全绿。钉三类事:快照语义(首帧前读 -1、序号由本层
  盖章、三通道互不串台、NULL 防御、init 幂等复位);跨层不透明下标现场一致
  (yc 序 = adc_semantic_t、q10 序 = prot_set_id_t,同一 TU 里 include 两侧头);
  SOE 编号与名字表现场一致(八类事件都有名、采样/标定四个词互不相同、
  名长不超列宽、越界回占位)。
- .pre-commit-config.yaml 加 ui-data-unit-test 钩子(第 43 个,Lead 已批自加)。
- scripts/id_registry.json 建族 TC-HOST-UIDATA(属主登记处 = 单测目录,同
  TC-HOST-ADCCAL 先例;不建族则新号被编号门禁判"未登记前缀")。

体积(相对 HEAD 台账 Code 215744 / RO 21396 / RW 1224 / ZI 131984)
- Code +1272 B、RO +108 B(字库 4 字,见上一笔)、RW +24 B、ZI +696 B
  → ER_IROM1 238520 B(22.7%)、RW_IRAM1 76504 B(58.4%,台账 57.8%)。
- .map 逐项:ui_data.o 488 B(Code 184/RW 12/ZI 476)、ui_data_feed.o 221 B
  (1034/12/209);LVGL 池 0 增量;ui_w_soe 未接线不进镜像(.map 无其符号)。
- 台架未跑(本笔零像素、零接线):首帧时刻、200 ms 刷新、eng 升档与遥信
  位图真值全部留"待台架实测",接线完成后请 Lead 排窗。

遗留与风险(随回报进台账)
- 写路径(契约 §6 的 param_request/state)不落:无消费者就是永不执行的死码,
  随整定 4 页一起落;届时须先定"UI 整定 vs iec104Task 写 prot_set"的并发口径
  (prot_set_set_sealed 的影子区非重入)。
- 公共地址 CA 未下发:固件里 CA 逐帧从 ASDU 头解析,无集中常量可取。
- 遥信位图与 App/protocol/iec104/iec104_pts.c 板端源是两处镜像(该文件位号
  写在函数体内未导出,UI 不得 include 协议头),开入采集落地时应并成单源。
- 版本类字段是本文件内的宏(仓库无产品级版本登记表,值沿用页面既有显示
  值);软件校验位在拿到构建期 CRC 步骤前一律显示 "--",不抄参考遗留值。

gate: 全量钩子(--all-files)40 项通过;cppcheck/test-cppcheck/coding-standard/
layer-deps(基线仍 3 条)/registration/bsp-err(存量 38 条不变)/font-charset/
id-check(增量无新账)/doc-links/ui-golden/主机单测与覆盖率全绿;唯一红项是
mem-numbers 与 mem-budget——两者只因 Flash/RAM 台账未回填(禁区,归 Lead)。

gate: only mem-budget/mem-numbers pending lead ledger backfill

自查:BOM(Drivers/BSP/UI/* 与 App/task/ui_task.c 保持 EF BB BF,新建 UI 两件
同规范;App/app 与 test/host 无 BOM 与本目录一致);CR = 0 全 LF;U+FFFD = 0;
字符串字面量纯 ASCII(中文只进注释)。
2026-10-25 09:56:22 +08:00
wandering1 8cbc7bc6b6 feat(ui-font): 字库补 SOE 显示名用字 采/样/恢/效(179→183 字 / 274→278 字形)
单独提交:按字库独占规矩,缺字改动自成一笔,不与功能改动混笔。

用字来源(下一笔提交的 ui_w_soe 显示名映射):事件类型"采样闭锁/
采样恢复/标定复归"与时标行的"时钟无效"。缺字上屏是空白方块、无日志
无报错,只能靠像素回归与门禁照出来。

流程(font-regen 四步,未跳步):
1. extract_cn 扫出 144 字 < 现清单 179 字:35 字源码已不用但不属 KEEP_EXTRA
   那 4 字(异/常/片/芯)——它们是 2026-09 窗口固件写进 /log/selftest.log
   定长记录的历史用字,跨固件持久落盘,静态扫描照不出来。故按并集回填,
   本笔只加 4 字、不做瘦身:瘦身等于让历史审计记录永久渲染空白且不可逆,
   收益(约 35 字 × 27 B RO)与代价完全不对称(同门禁头注"刻意保留 ≠
   未知冗余"的同一判定逻辑)。这 35 字要不要入 KEEP_EXTRA 已回报 Lead
   裁决,不在本道写域。
2. make_font.py 重生成 ui_font_cn_12.c:coverage OK,183 字全覆盖;▶/Ⅰ/Ⅱ/Ⅲ
   补回字形的单码位 cmap 条目未被宽稀疏 range 吞掉(首命中守卫通过)。
3. 像素回归:菜单 8 状态 + 内容页 11 页黄金参考逐字节不变,
   simulator/tests/ref 无 diff → 本笔不产生基线变化,未用 --update 刷参考。
4. 构建:@build 0 错误 0 警告(RO-data 21396→21504,+108 B,1bpp 12px 每字
   约 27 B 含字形描述符);模拟器 0 警告。

计数字段同步(同一事实只改数字,不重复抄流程):
- docs/UI设计/UI_中文字体生成与配置.md:产物说明 + 体积档(含 4 字实测增量)
- docs/UI设计/UI_界面制作流程.md C3 行
- docs/UI设计/UI_设计架构.md 默认字体行
- simulator/README.md 已知坑第 4 条

自查:BOM/CR/U+FFFD/字面量 ASCII 全树核对通过;全量钩子 40 项通过,
仅台账两条待回填(数字随下一笔的 ui-data 提交一起由 Lead 回填)。

gate: only mem-budget/mem-numbers pending lead ledger backfill
2026-10-25 09:56:10 +08:00
wandering1 042e85820c chore(ledger): n22 合入后回填台账/凭证/覆盖率基线
task/n22-flush 合入(merge 6f99dea):vol_idle_sync 空闲 flush 兜底。
Code 215,744→215,932(+188 B)、RW 1,224→1,232(+8 B)、ZI 不变;
RW_IRAM1 75,792 B(57.8%)、CCM 57,376 B(87.9%)不变——lane-f 分支侧
--no-verify 欠账清。覆盖率基线登记 vol_idle_sync.c(36/36 套件)。
凭证重盖(L0c 恢复序列)。
2026-10-25 09:29:55 +08:00
wandering1 6f99dea3a9 merge: task/n22-flush 合入 v2-on-ui
任务 n22(1 个提交)经 task-worktree 回收:
- feat(存储): 写回缓存空闲 flush 兜底——卷空闲周期 CTRL_SYNC(登记簿 N-22 收口)
2026-10-25 09:25:14 +08:00
wandering1 484d068250 feat(存储): 写回缓存空闲 flush 兜底——卷空闲周期 CTRL_SYNC(登记簿 N-22 收口)
道F 批 2 / task-16。FTL 单页写回缓存(nand_ftl.c s_cache_dirty)与 dhara
journal 只在换页/整页写/f_close 时刷出,漏网脏页的掉电驻留窗无上限。本
提交以最小面收口:不碰 GD5F/dhara 驱动,全部走既有 FatFs CTRL_SYNC 路径。

实现 App/fs/vol_idle_sync.[ch](新增,仅 MCU 端,uvprojx App 组注册):
- 四级门:STORAGE_READY 就绪门 → 5 s 节流(无符号差回绕安全判据)→
  vol_lock_wait(0) try(锁被持有=文件序列在做,跳过,不改 STOR 持有期
  语义)→ disk_ioctl(0, CTRL_SYNC);
- clean 态脏标志+journal 双短路、零 NAND 触点;脏态最坏 = 一次 f_close
  的同步段同型成本,不引入新磨损来源;
- 失败按边沿 latch 一条 DBG_ERROR、成功重新武装;失败路径也成对放锁;
- TEST_SUITE_STORAGE 构建整体 no-op(卷由 testTask 独占)。
接线:defaultTask 主循环 syslog_drain 之后(default_task.c v1.3)。

文档:docs/架构设计/STM32F4-Base存储架构说明.md §5.5 补空闲兜底与边界;
本板无掉电检测硬件、不做掉电前强制 flush、勿当掉电保护——口径同步落
注释与文档(sys_log_mcu.c 两处旧「暂不做」注释随实现改口)。

测试:新主机单测 test/host/vol_idle_sync/(TC-VOLSYNC-001..012,19 断言
全过);scripts/id_registry.json 建 TC-HOST-VOLSYNC 族(仿 ADCCAL 先例);
.pre-commit-config.yaml 加 vol-idle-sync-unit-test hook(只加自己条目)。

越界申报(①②③ 均 lead 批准,④ 为门禁连带必要):
① App/task/default_task.c 单点接线(include + 1 调用 + 头修改记录);
② docs/架构设计/STM32F4-Base存储架构说明.md §5.5;
③ .pre-commit-config.yaml 单 hook + scripts/id_registry.json 单族;
④ scripts/check_registration.py SIM_EXEMPT 加一行(vol_idle_sync.c 模拟器
   侧豁免,registration-check GAP 所需;与批准③同类别但未列名,特此申报)。

体积增量(vs scripts/ram_baseline.tsv 2026-10-25 行):
Flash(Code+RO) 237140→237328(+188 B)、RW 1224→1232(+8 B)、ZI 0;
增量 = 3 个文件级 static + 一处 DBG_ERROR 调用点;栈/堆无变化。

gate: only mem-budget/mem-numbers pending lead ledger backfill
全量 pre-commit --all-files:40 项 Passed,仅 mem-numbers(D-7 体积档)与
mem-budget 两条红 = 台账数字待回填(增量如上,属 lead 单写者禁区);
其余含 doc-links/id-check/registration/test-coverage/全部主机单测皆绿。
@build 0 错误 0 警告(Code=215932 RO=21396 RW=1232 ZI=131984)。

台架:空闲 sync 实机节奏(clean 零触点、脏态时长、无心跳扰动)属实际
测量采样类——本批跳过,留「待台架实测」。
2026-10-25 09:23:20 +08:00
wandering1 13ef0715bb docs(销项): 批 1 登记簿收口 + A-5 口径联动到三处门禁文档
销项(登记簿删行 + 结论沉淀指针):
- 数据可信.md:卡 1~7 行删除,表下补销项记录(merge 231dc81/d39e5c9/
  59da40b);§2.1 口径冲突注改 ✅ 已定死;§6 顺序图与失效预警改写为
  "批1 已销项 + 批2 在途"现状;卡 0/0b/12 与 M4①⑤台架面保留待跑
- 测试与架构专项.md:未闭环一览删 S12 / A-5 两行;§1 标题 1 项→0 项,
  P1-10 剩余两条改已销项记录(5 档构建 + f3 PASS,数字正文在
  test/board/ch395f_test.h);§2.1 S12、§2.3 A-5 标题 ⬜→✅ 带落点
- README.md:一览删 P1 收步行;网络/结构/UI 行加 🟦批2 在途标记;
  测试基础设施 5→3;数据可信行改"在途 7 卡";验证常态两处删
  P1-10/S12/A-5
口径联动(A-5 已落地,文档追平):
- CI 远程CI完整指南.md §0.0 整节重写(盲区→同码凭证强制 + 三项仍
  守不住 + 变更记录行);CI README 索引行同步
- 门禁说明/mem-numbers.md §1/§6、mem-budget.md §6 第 4 条:远端缺口
  已由 check_size_gate.py 关闭,唯一正文 = 其 docstring
2026-10-25 08:36:44 +08:00
wandering1 db9f256fd0 docs(收口): 批 1 台架记录与门禁/产物分家口径落档(道C 文案兑现)
- test/board/ch395f_test.h:f3 轮台架记录追加——VERDICT PASS,
  task stall 468→14(net 类 431→0,掩码复活实锤),余 13 条为
  netTask 自挂起前喂一次 vs 精确匹配判据的语义打架 + 1 条 uiTask
  瞬态 + 1 条设计内 P2 留痕;lead 裁决不放宽超集判定
- 主机单测覆盖率口径.md:中间产物路径 L80 与坑 12 重写为
  %TEMP%/dtu_host/<工作树哈希>/ 分家 + runs 独占(adabb6a 落地,
  旧固定共享名作废;判据正文指向 harness 顶部)
- 并行任务工作树方案.md §5 表:单测产物一行——不需要锁但必须
  按树分家(对照 build/bench 真独占)
- 门禁说明/cubemx-survival.md §6:新增第 7 条「基线漏项=静默不检」
  (net-msgq-adopt 实证,--write 后抽查新增条目)
- 门禁说明/id-check.md §5:拦下过真事追加——P1-10 注释裸编号被
  校验 6 拦(紧邻写法在 C 注释同样生效)
2026-10-25 06:12:02 +08:00
wandering1 bd7f15fc3e chore(size): P1-10 合入后重盖体积凭证(v2 索引指纹)
task/P1-10-test-fw 合入(merge 73f9e10,含 A5 指纹跨树稳定修复 /
WD 掩码死分支复活 / %TEMP 分家 / 四 Fault 显式判据 / P1-10 四档实测)。
产品态体积与 383b450 逐项相同:Code=215,744 / RO=21,396 /
RW=1,224 / ZI=131,984(零增量——掩码修复在产品构建走原分支)。
台架 f3 轮 VERDICT PASS(boot log 20261025_055725,bench PASS 已入台账)。
2026-10-25 06:08:25 +08:00
wandering1 73f9e10492 merge: task/P1-10-test-fw 合入 v2-on-ui
任务 P1-10(8 个提交)经 task-worktree 回收:
- chore(size): sync 追平后重盖体积同码凭证(v2 索引指纹,1396 文件)
- docs(P1-10): sync 追平后补记四档复测数字(产品基线已前进,旧注保留)
- merge: v2-on-ui 追平至 task/P1-10-test-fw(含 A-3 卡5~7 与 B-3 记录 reader)
- test(P1-10): 套件回归 forbidden 换成显式四 Fault 标签 + 台架记录 21/21
- fix(WD): HW 测试模式掩码死分支复活——include 缺失自 7feeea6
- fix(test): 主机单测/覆盖率产物按工作树分家 + 每次跑独占目录(task-10)
- test(P1-10): CH395F 套件三档构建实测 + 修阶段开关行尾跨行注释的切换陷阱
- fix(A5): 体积凭证指纹改取 git 索引 blob——跨 checkout 稳定,并拦未 staged 改动
2026-10-25 06:03:55 +08:00
wandering1 8c28f3bfdd docs(收口同步): 批 1 落地后 AGENTS/104/算法层口径更新 + SOE 7/8 消费面补齐
- AGENTS.md:钩子 40→42、主机单测钩子 16→18(adc-cal/prot-dim)、
  覆盖率总闸 28→35 套、layer-deps 基线 6→3(记录 reader 注入兑现)
- IEC104上行设计说明.md:遥测读数改工程量口径 + 死区额定 0.5% 重定
  已落地(卡 6);§8 第 4 条改写、变更记录追加行
- 采集与算法层设计说明.md:状态行/红线③/幅值门三处由「标定未实现」
  更新为「折算已落地、精度复核待卡 12 注入」
- App/log/soe_rec.h 类型注释表补 7/8 两行(lane-a 口误 6/7 已勘误,
  真值按枚举链 SAMPLE_RESUME=6 连续计数)
- test/host/soe/decode_soe.py:TYPE_NAMES + docstring 补 7/8——
  否则台架 FTP 下载校验会把标定闭锁记录判 bad type 误红(隐藏消费边);
  soe 套件 32/32 复跑绿
2026-10-25 05:55:00 +08:00
wandering1 54dd019950 chore(size): sync 追平后重盖体积同码凭证(v2 索引指纹,1396 文件)
sync 合并(a281231)之后按 lead 的六步做重盖。合并带进来的主干凭证是 v1 格式
(383b450 用主干那版脚本盖的,schema=1),而本分支的 check_size_gate.py 已是
schema=2(7c4628b:身份取 git 索引 blob,跨 checkout / 跨工作树稳定)→ 不重盖则
L0c 与 CI 的体积门禁都会按设计报"schema 不符"。

盖戳前置(脚本自己会验,这里是实跑结果):
- 产品态 @build 0 错误 0 警告:Code=215744 RO=21396 RW=1224 ZI=131984,与 lead 信号
  里的主干数字逐项相同;.map 晚于指纹范围内全部源码 mtime;
- 受管区:RW_IRAM1 75,784 B / 57.82%、RW_CCM 57,376 B / 87.89%,都在线内(阈值 92%);
- 指纹范围内无未 staged 改动/未跟踪文件(第四道前置)→ 允许盖章。

凭证:索引指纹 abfd54b71de9…,1396 个文件(比追平前多 8 个:新增 adc_cal /
prot_dim 等受管源码),测量时刻 2026-10-25T05:51:46+08:00,git_head=a281231 侧。
复核:--local rc=0、--ci rc=0(CI 侧按凭证数字复核红线,无需 Keil)。

为什么用 --stamp 而不是 --update:--update 会顺手往 scripts/ram_baseline.tsv 追加一行
当日趋势数据,而台账行归 lead 在 done 时统一回填(今天 383b450 刚回填过一笔,
再追加就是同日重复行)。这里只需要凭证,所以单独盖戳,tsv 一个字节没动。

RAM 增量:0 B(凭证是 JSON,不进固件;本次产品构建数字与主干逐项相同)。
gate:见本笔提交输出;未用 --no-verify。
2026-10-25 05:53:05 +08:00
wandering1 d07a9a4d67 docs(P1-10): sync 追平后补记四档复测数字(产品基线已前进,旧注保留)
sync 到 59da40b / 891ca81 之后重测四档 CH395F 配置(全部 0 错误 0 警告、链接通过):
  默认 11/14   Code=219,112  RW_IRAM1 78,072 B 59.6%   距 92% 线 42,514 B
  NET 全开     Code=220,636  RW_IRAM1 85,688 B 65.4%   距线 34,898 B
  HW 全开      Code=230,900  RW_IRAM1 91,088 B 69.5%   距线 29,498 B(最坏态)
  HW 无 PC     Code=216,888  RW_IRAM1 78,808 B 60.1%   距线 41,778 B
产品基线同期前进到 Code=215,744 / RO=21,396 / RW=1,224 / ZI=131,984,RW_IRAM1
75,784 B / 57.82%(与 lead 信号里给的主干数字逐项相同)。04 点那组旧记录按"历史
不追改"原则留着,只标注基线已前进;两组差值都在 +136~+152 B,来自新增 adc_cal 的
数据段,所以**结论不变**:最坏态仍余约 29 KB,不需要把 scatter 扩到 192 KB。
PHASE5 三块静态缓冲依旧在链上(hw_full 的 ch395f_test_task.o 数据段仍 12,285 B)。

四档复测都走**朴素行注释切换**(改宏 = 行首加/去 //),顺带二次确认 16917bf 修
的开关行注释陷阱没有复发。跑完 test_config.h 与 ch395f_test.h 均按字节还原,
产品固件重建后回到基线数字。

RAM 增量:0 B(纯注释)。gate:test-cppcheck / id-check / no-mojibake /
trailing-whitespace / end-of-file-fixer / doc-links / mem-budget 全 0;
未用 --no-verify。
2026-10-25 05:50:45 +08:00
wandering1 a28123115d merge: v2-on-ui 追平至 task/P1-10-test-fw(含 A-3 卡5~7 与 B-3 记录 reader)
按 lead 信号 sync 追平:主干 59da40b(cal-lockout-dim)+ 891ca81(UI-rec-reader)+
383b450(台账与凭证回填)合入本任务分支,为下一轮台架做准备(台架镜像必须建立在
追平后的代码上,否则烧进去的 hex 与主干不同码,体积门禁的凭证也对不上)。

唯一冲突:scripts/size_attestation.json —— **取主干侧**(383b450 刚按合并后的固件重盖
过,数字更新;本分支侧是 7c4628b 的 v2 格式戳,覆盖的是追平前的树)。取完即
`git add` 解决,没有手工拼接。**已知副作用**:主干那份是 v1 格式(schema=1),而本
分支的 check_size_gate.py 已升到 schema=2 → 在下一步 `ram_baseline.py --update` 重盖
之前,ci_local 的 L0c 会按设计报"凭证 schema 不符"。这不是回归,是合并两侧脚本版本
差一步,下面第 4 步就把它治好(也正是要请 lead 合这笔的原因:合完主干两边同版)。

合并后产品构建(实跑,非推演):@build 0 错误 0 警告,
Code=215744 RO=21396 RW=1224 ZI=131984 —— 与 lead 在信号里给的主干数字**逐项相同**;
RW_IRAM1=75,784 B / 57.82%(追平前 75,632 B / 57.70%,+152 B 与"两笔合计 +152B"吻合)、
RW_CCM=57,376 B / 87.89% 未动、ER_IROM1 22.62%。说明本树固件内容已与主干同码。

本分支带来的(前四笔已在分支上,这里只登记合并面):
- 7c4628b 体积凭证指纹改索引 blob;16917bf CH395F 三档实测 + 阶段开关注释陷阱;
- adabb6a 主机单测/覆盖率产物按工作树分家 + runs 独占(task-10);
- a9ba8ec 看门狗 HW 层掩码死分支复活(App/util,lead 背书写域)+ 存活基线 --write;
- 5f1057a 套件 forbidden 换显式四 Fault 标签(GD5F 旧豁免的漏口一并堵上)。
与主干侧的文件交集只有 size_attestation.json 一处,已按上解决;.pre-commit-config.yaml
本轮是主干侧改的(道A 加了钩子相关条目),本分支没碰它,无并集冲突。

gate:本笔合并提交走完整 pre-commit(含 16 根单测钩子与 test-coverage 全量覆盖率
基线比对);未用 --no-verify。
2026-10-25 05:41:49 +08:00
wandering1 5f1057ac74 test(P1-10): 套件回归 forbidden 换成显式四 Fault 标签 + 台架记录 21/21
台架第一轮(boot log 20261025_050539)判 VERDICT FAIL,命中 469 条 [ERR],两类:
  1) [ERR][CH395] ch395f P2 boundary fault @get_state sn —— **设计内**:Phase 15 的
     sn 越界负向用例故意命中驱动守卫(Drivers/BSP/CH395F/ch395f.c 的
     ch395f_p2_fault_note():首次 DBG_ERROR、之后只计数),用例本身 PASS;
  2) [ERR][WD] task stall ×468 —— **设计外**,已在 a9ba8ec 单独修(掩码死分支),
     不用豁免去掩盖它。
本笔只管判据:套件回归这条路要豁免的是"设计内守卫 [ERR]",不是崩溃。

为什么不能只是把 [ERR] 从 forbidden 里删掉(核过 verify_log 源码与固件崩溃行):
四种 Fault 的崩溃行是 [ERR] HARDFAULT / MEMFAULT / BUSFAULT / USAGEFAULT
(Src/stm32f4xx_it.c 的 hard_fault_report 先打 "[ERR] " 再打标签),而 verify_log 的
forbidden 是**大小写不敏感的子串匹配**(fl in ln.lower())→ 默认集合
HardFault;assert;[ERR] 里的 HardFault 只兜得住硬Fault,**MEM/BUS/USAGE 三种平时
完全靠 [ERR] 兜**。据此:
- 新增 FORBIDDEN_SUITE =
  HardFault;MEMFAULT;BUSFAULT;USAGEFAULT;assert;bring-up FAIL
- CH395F 用它(原来吃默认值,含 [ERR]);
- GD5F 也用它——旧值 "HardFault;assert" 正是上面那个漏法(当初只为豁免
  TC-GD5F-704 的设计内 [ERR] 就把整条 [ERR] 摘了,等于顺手放走了三种 Fault),
  现在既保留豁免意图,又把崩溃判定补齐;
- STORAGE / RTC 不动(它们没有设计内 [ERR],继续吃含 [ERR] 的默认判据)。
边界写死在注释里:产品 @test 的判据(.serialtest/config.json 的 forbidden 含 [ERR])
**一个字节没动**,豁免只活在"套件回归"这条路上,渗不回产品路径。

test/board/ch395f_test.h:
- Phase 15 汇总实测 21/21(旧注释写"台架 20/20 PASS"是历史记录,已按本轮改正);
- 记一条台架档案:镜像 ch395f_hw_nopc_v2.hex 单次开机跑完 1/12/15,汇总行
  9/9、1/1、21/21,failed=0,全程无 Fault;并记下同轮暴露的掩码死分支结论与它的
  证据位置,后来人切阶段时不必再踩一遍。

验证:
- py_compile 通过;test/run_all_suites.py --check-build --suites CH395F 走**官方编排**
  实跑 PASS(BUILD_OK 0 错 0 警,Code=217468 RO=21452 RW=1268 ZI=134076),脚本自己
  还原 test_config.h 并重建产品固件;这个 Code 与上一笔实测表里"默认阶段 11/14"一行
  逐项相同 → 两个独立工具互证同一组数字。
- gate:id-check / no-mojibake / trailing-whitespace / end-of-file-fixer /
  doc-links / cppcheck(test/) / test-suite-switch(产品态 Skipped)全 0;
  未用 --no-verify。
- RAM/Flash 增量:0 B(Python 与注释,不进固件;test/board/*.h 的注释不改代码)。

下一轮台架等 lead:f2 镜像我会在 sync 到 A-3/B-3 之后重编(现在这份
ci-artifacts/p110_fw/ch395f_hw_nopc_f2.hex 戳 05:23:03,若中途主干动过固件就要重编),
判据用上面的 FORBIDDEN_SUITE;请顺手带回两个数:[ERR][WD] 总条数、mask 含 bit4(ui)
缺位的条数。
2026-10-25 05:36:25 +08:00
wandering1 383b4500d0 chore(ledger): UI-rec 与 cal-lockout 两笔合入后回填台账与凭证
task/UI-rec-reader(merge 891ca81):reader 注入 Code +104/RO +8/
RW +4/ZI +4;task/cal-lockout-dim(merge 59da40b):卡5 闭锁 +
卡6 三常数 + 卡7 prot_dim 套件,Code +200/RO +80(vs 含 UI-rec 的
前一构建)。合并后 @build 0/0:Code=215,744 / RO=21,396 /
RW=1,224 / ZI=131,984;RW_IRAM1 75,784 B (57.8%)、CCM 57,376 B
(87.9%)——两笔合计 RAM +152 B,远低于 92% 红线。
覆盖率 35/35 套件基线同步;凭证按 .map 重盖(L0c 恢复序列)。
道A M4 代码面与道B 批 1 三卡至此全部落地。
2026-10-25 05:32:46 +08:00
wandering1 a9ba8ec745 fix(WD): HW 测试模式掩码死分支复活——include 缺失自 7feeea6
结论先说:wd_required_mask() 里那条"硬件层测试构建剔除 netTask"的分支,自它被写进
仓库那天起**从未生效过**——不是逻辑错,是本编译单元根本看不见判据宏。台架一轮
(2026-10-25,boot log 20261025_050539)把它照出来了:
  - 468 条 [ERR][WD] task stall 里,**431 条是 mask=0x1F5 / req=0x1F7**;
  - 0x1F5 恰好等于那条死分支本该要求的集合(default|ftp|ui|timesync|adc|iec104|
    protect,NET 位不在内)——该动的任务全动了,只是"要求方"看不见自己的例外,
    于是按产品掩码 0x1F7 追 netTask 的心跳位;而 netTask 在 ENABLE_HW_LAYER_TESTS
    下按设计入口自挂起(CH395F 归 testTask 独占),永远追不到。
根因:ENABLE_HW_LAYER_TESTS 定义在 test/board/ch395f_test.h(经 test_config.h 拉入),
而本文件没有 include 任何一条通往它的路径 → #if defined(...) 在本 TU 恒为假 → 死代码。
同一个需求早有既有做法:App/task/net_task.c:39 就为这个宏 include 了 test_config.h,
注释还写着"产品构建全未定义,无作用"——本文件只是漏了这一步。

修法:补 #include "test_config.h",并把根因与 req/mask 证据写在包含点上方(同一事实
不另开文档;销项时 docs 引这里)。写域扩到 App/util 由 lead 背书(看板 task-9)。

零产品影响(实测,不是推演):
- 产品态(TEST_SUITE_* 全注释、宏未定义 → 走原分支)@build 0 错误 0 警告,
  Code/RO/RW/ZI = 214104 / 20388 / 1204 / 131852,与主干 2026-10-25 台账逐项相同。
- 测试态 hw_nopc 尺寸也与修复前逐项相同:改的只是"选哪个常量",没有新代码路径。
- 修复后净效果(下一轮台架复核):431 条 net 类 stall 消失,剩下的是 uiTask 间歇
  缺位的 37 条(0x1E7 / 0x1E5)——那是另一件事,不在本提交里掩盖。

顺手记两条**没在本提交改**的发现(判据语义,归 lead 裁):
1) 抽样判定是**精确匹配**(注释自述:"掩码必须恰等于会置位的任务集合")。而 netTask
   自挂起前会喂一次位 → 那一瞬间 mask=0x1F7 又不等于剔除后的 0x1F5 → 照样报 stall。
   即"从掩码里剔一位"与"精确匹配"天然打架;要不要放宽成"观测 ⊇ 要求即可"值得单定。
2) 本轮 miss 一路涨到 1971 而**没有停喂、没有复位**:说明"连续未达标即停喂"的阈值
   路径在 HW 层测试构建里未被触发(或 IWDG 尚未启动)。这条对产品侧更重要,值得单查。

随附 .cubemx_survival_baseline.json:本文件在存活审计里是 kind="generated" 的整文件
哈希项 watchdog-module("非 CubeMX 管辖,但须确认未被误删"),有意改动按脚本自述用
--write 重建基线。**先证明漂移 100% 来自本笔**:把我的 include 临时撤掉再跑
--check → rc=0(基线与 HEAD 逐项相符),因此不存在"别人早就改了没登记"的情况。
--write 顺带补出一条**基线里原本缺失**的条目 net-msgq-adopt(Src/freertos.c 的
RTOS_QUEUES 区,须含 net_msg_queue_adopt):它此前不在基线里 → --check 一直在静默
跳过这一项。本笔把它一并登记,属于意外收获,不是本笔的目的。

gate:cppcheck / coding-standard / bsp-err / id-check / no-mojibake /
trailing-whitespace / end-of-file-fixer / mem-numbers / mem-budget /
cubemx-survival(已 --write 重建基线)全 0;
未用 --no-verify。
2026-10-25 05:32:24 +08:00
wandering1 59da40bb4a merge: task/cal-lockout-dim 合入 v2-on-ui
任务 cal-lockout(1 个提交)经 task-worktree 回收:
- feat(保护标定): 卡5 未标定闭锁 + 卡6 下游三常数重定 + 卡7 量纲回归单测
2026-10-25 05:28:05 +08:00
wandering1 891ca81995 merge: task/UI-rec-reader 合入 v2-on-ui
任务 UI-rec(1 个提交)经 task-worktree 回收:
- refactor(UI): 记录页改注入式 reader,layer-deps 基线 6->3(道B task-6)
2026-10-25 05:22:31 +08:00
wandering1 ffd462ef3c feat(保护标定): 卡5 未标定闭锁 + 卡6 下游三常数重定 + 卡7 量纲回归单测
卡 5(protect_task.c 第⑤闭锁源,「数据可信」§4.3 决策 1=闭锁):
- UA/UN/IA 的 adc_cal VALID 未全置位 → prot_elm_init + 本拍不 eval;每拍现查
  flags、无自有状态(卡 12 标定写入后自动解锁);判据链优先级 = 采样可信在前、
  标定可用在后(数据都是旧的谈不上标定)
- 边沿日志用 DBG_INFO 而非 [ERR]:未标定是出厂常态、非故障,且 @test 禁用
  [ERR](「默认构建保护不动作 = 期望」的台架判据同步项已在 MR 报 lead);
  SOE 分型 SOE_T_CAL_BLOCK/RESUME(soe_rec.h 枚举尾追加,值 6/7——reader 侧
  显示标签映射归道B reader,MR 已报)
- 台架旁路 = 编译期宏 DTU_BENCH(§4.3:不进 cfg 不进 104;本仓所有提交目标
  都不定义它 = 产品恒强制闭锁)

卡 6(下游三常数按工程量重定,依据同源 docs/任务规划/数据可信.md §2.3):
- FREQ_AMP_MIN(freq.h):语义从拟定码值域重定为二次值 V——50 V = 0.5×Un
  (标称 100 V),断保险/PT 空载/浮空必低于之;数值沿用 50.0f 属巧合并在
  注释明写;eng=0 过渡期本门恒失效 → OF/UF 按必不启动填充,与卡 5 同向安全
- k_yc[].deadband(iec104_pts.c):9 点按「额定 0.5 %」规则重定:电压类
  0.5 %×100 V=0.5、电流类 0.5 %×1 A=0.005、频率维持 0.5 Hz 绝对值(非额定
  比,卡 10 运行参数化);标称基准与 prot_set 头部同源,防两处真相
- g_prot_set_meta[](prot_set.c):数值/范围复核结论=不动(0~4×标称自洽),
  文件头补量纲口径块:A/V = 二次值、标称 100 V/1 A、OV120=1.2Un/UV20=.2Un/
  3U0 15=.15Un、OC1/2/3=10/5/2×Ir、OL=1.0×Ir;PS_ID_COUNT/blob VER 未动
- iec104 套件 TC-008 死区用例同步重定标(100.4 带内 / 101.25 越带),97/97

卡 7(test/host/prot_dim 新套件,真实链 adc_cal→ring→phasor→prot_elm):
- 用例从 ADC 码值出发、显式写出 code↔二次值互逆换算(dim_code vs apply),
  折算点被旁路或量纲再错位断言立刻翻转(M4 判据⑥「不该动作时不动作」)
- 合成 ROM 覆写(#undef K_Q16 宏)模拟卡 12 标定后板级口径,走 adc_cal_set
  正规路径置 VALID;b=-30 码全链对消进 rms 断言
- 38/38:基线静默(含 OL 低定值陷阱)、反错配对照(raw 码值灌环复现历史事故
  必假动作 vs 经折算必静默)、OC1/2/3+OL+OV+UV+3U0 七类逐一:越限定值→
  受控启动拍→dly-2 不动作/+5 动作→返回系数带内保持→跌破释放、双件聚合位图
  独立性(撤 IA 只回 OC1)

测试与注册:prot-dim-unit-test 钩子 + TC-HOST-PROTDIM 族(游标 010,B 层口径
同 ADCHEALTH/ADCCAL 先例);无新 MCU 源文件(uvprojx/SIM_EXEMPT 零改动)

gate:@build 0 错 0 警(Code=215640 RO=21388 RW=1220 ZI=131980 vs 基线
215440/19780/1220/131980 → Flash +1808 B、RAM 零增量);protectTask 静态最坏
链 872 B/margin 1176 B(57%)PASS,adcTask 不变;staged 门禁除 mem-numbers/
mem-budget(台账漂移,回填归 lead)外全绿:
gate: only mem-budget/mem-numbers pending lead ledger backfill
BOM/CR/U+FFFD/字面量 ASCII 已逐文件自查(本轮未触碰带 BOM 文件)
2026-10-25 05:22:17 +08:00
wandering1 c4edf352fe refactor(UI): 记录页改注入式 reader,layer-deps 基线 6->3(道B task-6)
B-6 分层收紧(UI_数据供给设计说明.md §7.2 契约原样落地):
- ui_w_records:删 #include "sys_log.h",改 ui_rec_reader_t 只读函数表 +
  ui_w_records_bind() 模块级注入(sys_log 读 API 本为全局,函数表共享、
  实例读盘缓存仍各自独享——切页陈旧行防御不变);log_id 换不透明编号
  UI_W_REC_LOG_RUN/SELFTEST;缓冲容量宏本地化 UI_W_REC_TS/DESC_LEN
  (20/29,与 sys_log 契约下限同宽;读失败退化空行防御保留)
- ui_page_runlog / ui_page_selftest:去 sys_log include,实例改不透明编号
- 两端薄包装适配层(编号→syslog_id_t 显式映射,错误码 0/-1/-2 直通不改写):
  App/task/ui_task.c 与 simulator/main.c(无头+窗口两个 init 点),均在
  ui_menu_init 之前 ui_w_records_bind;未绑定窗口组件按空态防御不读野
- scripts/check_layer_deps.py:BASELINE 6→3(--prune 实测恰 3 条 UI 记录
  边消失后写回;剩 NETCFG×2 + lftpd_io 与 UI 无关)——道B 独占收紧口径
- 文档同步:门禁说明 layer-deps.md §1/§6/§9 与 README 计数 6→3;
  UI_数据供给设计说明.md §7.2 加落地注记、§14"计划削减"改"已兑现"
- 首提被 id-check 拦下:注释/文档里自造"批卡编号"(B-6/道B-3)命中
  "未登记前缀"增量拦截——已全数改回 task-6/道B 普通指称(编号不回收、
  禁自造,教训入本提交);id-check 复跑 RC=0(S10 告警为存量非本改动)

验证:
- 模拟器 0 警告;菜单 8 状态 + 内容页 11 页黄金参考逐字节 PASS
  (记录页数据流经 reader 后像素与基线一致,无基线更新提交)
- cppcheck/coding-standard/bsp-err/font-charset/no-mojibake 单跑全绿;
  layer-deps 复跑:6 处反向 include = 豁免 3 / 基线内 3 / 新增 0,
  --prune 显示"基线无需收紧(3 条存量全部仍成立)"
- doc-links --all 的 35 处告警全为本仓检出路径含空格误扫 SDL2 自带 md
  (环境问题、非本改动引入;提交门禁按暂存文件名过滤不受影响)
- @build 0 Error 0 Warning(终树复跑 BUILD_OK);uvprojx 无 UV4 抖动、
  无新文件不涉注册;其余 38 钩全绿(ui-golden/font-charset/coding-standard/
  bsp-err/id-check/cppcheck…单跑复验 RC=0)

增量(A/B 同树 .map,基线 = 合入 c1b425d 后 HEAD):
- Flash:Code +104 B、RO +8 B(两端薄包装代码 + reader 函数表 const)
- RAM:RW +4 B(s_reader 指针)/ ZI +4 B(两页实例 struct 枚举改 uint8
  本应 -8 B,链接段对齐后净 +4 B);RW+ZI 合计 +8 B

gate: only mem-budget/mem-numbers pending lead ledger backfill
- pre-commit run --all-files 实测仅这两条失败(台账/RAM 文档 vs .map 漂移,
  根因即上列 Code +104/RO +8/RW +4/ZI +4),其余 38 钩全绿;按 Lead 裁决
  (task-4 口径"以后照此办理",含 Flash/RAM 漂移同例外)--no-verify 提交,
  合并后请主树回填台账(scripts/ram_baseline.py --write 由 Lead 侧执行)
- 手工自查通过:5 个 BOM 文件 BOM=EF BB BF 齐全、docs/py/main.c 保持无 BOM,
  全部 10 文件 CR=0、U+FFFD=0
- 台账增量供回填:Flash Code +104 B、RO +8 B;RAM RW +4 B、ZI +4 B
2026-10-25 05:21:05 +08:00
wandering1 adabb6af12 fix(test): 主机单测/覆盖率产物按工作树分家 + 每次跑独占目录(task-10)
现象(2026-10-25 三条道并行开发实测,看板 task-10):全量 ci_local 的 L0 里
test-cppcheck / test-coverage 偶发 "files were modified by this hook",单独重跑
即绿、工作树零改动。根因在 unit_test_harness:obj/exe 直接落在**全机共用**的
%TEMP%,名字由套件名派生(%TEMP%\net_socket_tests.exe、%TEMP%\net_socket_obj),
多棵工作树并行跑同名套件必然互踩。

真正要命的不是编不过,而是**结论张冠李戴**:A 树编译完,B 树把同名 exe 又盖一
遍,A 树随后 subprocess 执行的是 B 树编出来的二进制——两棵树的 summary_marker
一模一样,harness 分辨不出来,于是拿别人的运行结果给自己判 PASS。这种失败不会
变红,只会静默给出错误结论,比红难查一个量级。

修法(不是加锁):
- 产物根按**工作树**分家:%TEMP%\dtu_host\<仓库根路径 sha1 前 10 位>\。树身份取
  harness 文件自身位置(test/host/common 往上三级),不依赖 cwd/环境变量;工作树
  之间只联接 .venv 与模拟器 SDK,test/host 每棵树各有一份,所以这个身份可靠。
- **每次运行再独占一个子目录** runs\<pid>-<纳秒>\,obj 与 exe 都放进去,跑完
  finally 删(含 Ctrl-C 路径)。这一步顺带把"同树并发同名套件"也消灭了:两个
  进程既拿不到同一个 exe,也拿不到同一个 .obj(实测共享根时并发会因 obj 撞名
  直接编不过,返回 rc=2)。没有牺牲缓存:每个测试单元就一个 .c,cl 每次整体重编,
  "共享 obj 省编译"本来就是错觉。**不加锁**:这里没有需要互斥的独占资源,只是
  名字撞了;加锁等于把 L0.5 变成 build/bench 那样的全局串行。
- 独占目录里留 <exe>.owner.json(suite / repo_root / pid / started):需要证明"这
  份产物属于哪棵树哪次跑"时有的可查,不必靠时间戳猜。
- 编译返回 0 但产物不存在 → 明确报环境错误,绝不跑来路不明的二进制。
- 两个开关:DTU_HOST_BUILD 改产物根(CI/容器固定路径),DTU_HOST_BUILD_KEEP=1
  保留本次目录(要单步调试 exe 时用)。判据单一事实源 = host_build_root()。
- scripts/check_test_coverage.py 同源改造:work_root 由固定共享名 dtu_test_cov 改为
  复用 harness 的 host_build_root()/coverage,并同步 --work-dir 帮助文案与 docstring
  (docs 里"并行期每人只跑 --module 自己模块"那条规避就此作废,它是为绕开旧共享名
  而写的)。顺带删掉因此不再使用的 import tempfile。

附带修一处同族不诚实(做本任务时从自己的输出里发现):ci_local 的收尾那行以前
无条件打印"L0 + L0b + L0c + L0.5 全部通过",--l05-only 时等于把没跑过的段宣布
为通过——正是 A-5 那个"SKIP 冒充 PASS"的镜像版。改为只列本次真正跑过的段。

自证(都是实跑,不是推演):
- 两棵树身份并发跑同一套件:把修好的 harness 复制成 A/B 两个树身份(repo_root 不
  同 → 两个不同产物根),套件 BUILD 从真仓库加载,各跑 4 轮并发 → 8/8 rc=0,
  两侧 roots 分别是 …\55deb98e3b 与 …\902100bc41,互不可见。
- 敌意场景:把两边 DTU_HOST_BUILD 强行指到同一目录(旧写法必撞 exe/obj)→ 8/8
  rc=0,共享根下只剩一个空的 runs/。同树并发同理被这一条覆盖。
- 单套:test/host/sha256 rc=0(13/13),产物确实落在 …\4b9166f602\runs\<pid>-…;
  覆盖率 --module sha256 rc=0(line=111/114 func=7/7),落在 …\coverage\sha256。
- 全量:ci_local --l05-only → 33 runner(s),2276/2276 assertions passed,fail=0。
- 临时目录与 scratch 已清理(脚本在仓库外,不入库;ci-artifacts/ 之外无残留)。

文档:test/host/common/README.md 新增"产物目录归属"一节(落点结构、为什么按树、
为什么每次独占、两个开关、一行命令查当前树的产物根);harness 顶部注释是判据正文。
docs 侧两处按分工归 lead 在 done 时统一改,合并请求里已给可直接粘贴的替换文本:
docs/测试说明/主机单测覆盖率口径.md 的产物目录条与"坑 12"、以及并行任务工作树
方案.md 的产物归属条。AGENTS.md 与 docs/** 本次一个字节没动。

RAM/Flash 增量:0 B(纯测试工具链,不进固件)。gate:见提交钩子输出,未用 --no-verify。
遗留:tools/font_gen/make_font.py 同样用固定共享名(%TEMP%\simsun_regular.ttf、
font_extra_<cp>.c)——同一类跨树竞态,但属道B 字体域,我没动,建议另开一张卡。
2026-10-25 05:13:14 +08:00
wandering1 16917bf165 test(P1-10): CH395F 套件三档构建实测 + 修阶段开关行尾跨行注释的切换陷阱
P1-10 剩余①「CH395F 套件实际构建确认链接通过」今天才真正有数:产品基线 + 三档
测试配置(ARMCC,全部 0 错误 0 警告;.sct 保持 128 KB 未动,符合 P1-10 写的
"不建议扩到 192KB"):

  配置(TEST_SUITE_CH395F + 阶段)    Code      ZI      RW_IRAM1      距 92% 余量
  产品基线(阶段全关)                214,104   131,852   75,632 57.7%   44,954 B
  默认阶段 11/14(只切套件宏)        217,468   134,076   77,920 59.5%   42,666 B
  NET 层全开 6~11/13/14              218,992   141,688   85,552 65.3%   35,034 B
  HW  层全开 1~5/12/15(最坏态)      229,256   147,176   90,952 69.4%   29,634 B
  HW  层无需 PC 1/12/15(冒烟用)     215,244   134,896   78,672 60.0%   41,914 B

P1-10  specifically 关心的 PHASE5 三块静态缓冲(3 x PHASE5_CHUNK_SIZE = 3 x 4093)
确实进了链接,不是"理论上会在":hw_full 的 ch395f_test_task.o 数据段 = 12,285 B
(12,279 + 该 TU 其余 6 B)。于是"理论余量足够但未实测"改成了实测:最坏态仍余
29,634 B(RW_IRAM1 上限 131,072 B,92% 线 120,586 B)。RW_CCM 四档恒为 57,376 B /
87.9%——测试代码不进 CCM,但该区已经贴着红线,将来往 CCM 加东西要单独核算。

顺手修掉一个把实测挡了三年的坑(就是它让第一轮 hw 配置炸掉的):
ENABLE_PHASE11/14 两行的说明是**挂在行尾的跨行块注释**。而"关掉一个阶段"的标准动
作是在行首加 //,// 只注释到行尾 → 块注释从第二行起变成裸代码,实测 188 条
error #7: unrecognized token,报点落在注释续行上,看着像头文件坏了、其实只是注释
被腰斩。run_all_suites.py 的 _rewrite_config 只改 TEST_SUITE_* 行(那些行没有行尾
跨行注释)所以一直没暴露;ch395f_test.h 恰是"人手工切阶段"的入口,必须自身安全。
修法:把两段说明挪到 #define 行的上一段,行尾只留同行闭合的短注释;并在开关区后面
写清"开关行注释规范"+ 上面这张实测表(同一事实只在这一处,销项时文档引它)。

验证(_wt/P1-10-test-fw,每步都实跑 @build):
- V1 产品态(改注释后):0/0,Code/RO/RW/ZI = 214104/20388/1204/131852,与主干
  2026-10-25 台账行逐项相同 → 本改动零体积增量。
- V2 只切套件宏(默认阶段 11/14):0/0,RW_IRAM1 77,920 B / 59.5%。
- V3 **朴素行注释切换**(正是炸掉第一轮的那个动作)改到 HW 1/12/15:0/0,且尺寸与
  用 #undef/#define 覆盖法得到的 hw_nopc 逐字节相同(215244/23656/1200/134896)
  → 陷阱确认已消除,两条改法等价。
- V4 还原后产品重建:0/0,数字回到基线;test_config.h 与 ch395f_test.h 均按字节还原
  (git status 只剩本文件这一处改动,测试宏没有留在树里,test-suite-switch 无需放行)。
- 产物身份戳实测:hw_nopc = "Oct 25 2026 04:32:16 suite=CH395F dstr=off"、产品态 =
  "Oct 25 2026 04:32:25 suite=NONE dstr=off" → dstr=off 三档一致,破坏性开关全程关。

RAM 增量:0 B。gate:改动只命中 no-mojibake / trailing-whitespace / end-of-file-fixer /
check-added-large-files / id-check / mem-budget,全部 0;未用 --no-verify。

P1-10 剩余②(@test 烧录抓日志)需要 bench 锁,已把镜像与锚点清单交给 lead 执行;
真机跑 Phase 2~5 / 8 / 11 / 13 / 14 需 PC 端 tcp_server / peer 配合(Phase 11 还要
制造死对端,E11),属"实际测量采样"类,按用户指示本轮跳过,不在本提交声称做过。
2026-10-25 04:49:19 +08:00
wandering1 317e04219d chore(ledger): UI-widgets 合入后重盖凭证并登记覆盖率分母
task/UI-widgets 合入(merge c1b425d,3 对组件文件 + 双端注册)。
组件暂无引用方 → 链接器剥离整 TU,Code/RW/ZI 与上一步相同
(215,440 / 1,220 / 131,980,RAM 零增量);覆盖率分母 +7 文件
(untested 109→115,接线后转实测)。凭证按 @build 0/0 后 .map 重盖。
2026-10-25 04:42:33 +08:00
wandering1 d89c07c40c chore(ledger): UI-widgets 合入后重盖凭证并登记覆盖率分母
task/UI-widgets 合入(merge c1b425d,3 对组件文件 + 双端注册)。
组件暂无引用方 → 链接器剥离整 TU,Code/RW/ZI 与上一步相同
(215,440 / 1,220 / 131,980,RAM 零增量);覆盖率分母 +7 文件
(untested 109→115,接线后转实测)。凭证按 @build 0/0 后 .map 重盖。
2026-10-25 04:40:53 +08:00
wandering1 c1b425dc53 merge: task/UI-widgets 合入 v2-on-ui
任务 UI(1 个提交)经 task-worktree 回收:
- feat(UI组件): 新增数值编辑器/定值表/动作确认页三组件(道B task-5)
2026-10-25 04:36:52 +08:00
wandering1 f711130b4c feat(UI组件): 新增数值编辑器/定值表/动作确认页三组件(道B task-5)
域5 新增 3 对文件(双端注册 uvprojx + CMakeLists),只做渲染与输入抽象、
不接真实数据源(数据绑定 = 批 2 道E ui_data/prot_set 经回调注入):
- ui_w_editor:定点整数(10^decimals)逐位编辑——上下步进(0..9 环绕无进位)、
  左右移数字光标(首末环绕)、ENTER 提交经 min/max 范围校验,越界拒绝并
  显示"越界"提示留在编辑态;光标反显盒与 ui_page_net_cfg 字节光标同画法
  (含"只画单字符防折行"实测坑注释);共享格式化 ui_w_editor_fmt_scaled()
  单一规则源(值列显示与编辑光标恒同串)
- ui_w_setlist:定值四视图(参数/定值/控制字/软压板)共用列表渲染 +
  整定输入——行表(名称<=7字/id/类型/小数位/范围/二态串表) + 回调注入
  (get/set/commit/state/bad);查看视图(commit=NULL)与整定视图只差绑定:
  整定视图末行固定"保存"行,值随交接态 ENTER/WAIT/OK/ERR(net_cfg 先例),
  整表一次提交(一次封存一次校验和,页面补齐规划 §3.2);二态行 ENTER
  循环切换;保存失败越界行经 bad 回调反显(页面扩充详细方案 §3.2.7)
- ui_page_action:可复用操作确认链——一次确认 -> 二次确认 -> ok_cb
  只投递不阻塞 -> done_text 回报(或成功即关页),拒收显示 ERR 可重试;
  ESC/LEFT 关页取消(实测取消后菜单与基线逐字节一致);单例描述符由
  开页装载(内容页框架全局单页前提),不定义 ui_page_action_desc
  (registration-check 页面模块判据不误拦,批 2 跳板各自出口)

组件文案用字全部落在上一分支预置字集内(font-charset WARN 60->52 印证
确/取/消/越/二/次/返/回 已转源码在用),无需再动字库。

BSP 公开 API 约定合规(首提被 cppcheck/coding-standard/bsp-err 三钩拦下,
按门禁口径修净后复验全绿):
- 键分发返回值取 uint8_t 1/0 不用 bool(bsp-err/err-type:bool 只许
  is_/has_ 谓词);ui_w_editor_value 直返 int32_t(出参形状无错误通道)
- ui_page_action_open 返回 bsp_err_t:BSP_ERR_PARAM 参数无效 /
  BSP_ERR_STATE 已有页打开 / BSP_ERR_NO_SPACE 框架开页失败(可判分支)
- get/state/bad 回调 typedef 各补"返回值:"注释块(bsp-err/return-doc)
- cppcheck variableScope 变量收scope 7 处;CODE-RULE-18 Yoda 1 处
  (sel+1u==total -> sel==count);提交内已不含 --wdemo/--wact harness

验证:
- 模拟器零警告;组件行为经临时 sim-only harness(--wdemo/--wact) 截图
  目检 7 态:基线视图/bad 反显/进编辑步进/越界拒绝/二态切换/保存整表
  提交 OK/确认链三段/取消回菜单;harness(main.c 4 处 + CMake 1 行 +
  ui_w_demo.c)已完整还原删除,提交内不含(最终树 main.c 无改动,
  CMakeLists diff 仅 3 行注册)
- 回归:菜单 8 状态 + 内容页 11 页黄金参考逐字节 PASS(ref 无变化,
  无基线更新提交);池基线 5368/往返 5448 不变
- 门禁:coding-standard PASS / font-charset PASS / layer-deps PASS
  (新文件零 App include,基线 6 不变——减基线归 task-6)
- @build 0 Error 0 Warning;Code +324 B、RO/RW/ZI 0(组件尚无引用方,
  ARM 链接器未剥离仅因新 TU;批 2 页面接线后实例 RAM 每页约 +40~60 B)

gate: 全钩子正常执行通过,无 --no-verify
- pre-commit run --all-files 全绿(含 mem-budget/mem-numbers:本分支基于
  回填后台账 d9ab955,RAM 0 变化、RO 不变,仅 Code +324 B 不涉受管区)
- 手工自查:6 新文件 BOM=EF BB BF 齐全、全 LF、U+FFFD=0、字面量仅
  UTF-8 中文(UI 带 BOM 先例口径:保存/越界/二次确认/告警/跳闸等,
  全部已在字体子集);ASCII 串仅 ENTER/WAIT/OK/ERR 状态字
2026-10-25 04:34:43 +08:00
wandering1 a5f7385cad chore(ledger): cal-adc 合入后回填 Flash/RAM/覆盖率台账并重盖凭证
task/cal-adc-cal 合入(merge d39e5c9):adc_cal 折算模块 + 系数落盘
+ L5-2 rail_watch。Code 214,104→215,440(+1,336 B)、RO 20,388→21,308
(+920 B)、RW 1,204→1,220(+16 B)、ZI 131,852→131,980(+128 B)。
RAM 增量 +144 B(IRAM1 57.8%、CCM 87.9%,远低于红线);adcTask 栈
worst 1344 B / margin 47%。清 lane-a 分支侧 --no-verify 欠账。
覆盖率基线登记 adc_cal.c(118/119 行 99.2%);体积凭证按 @build
0/0 后的 .map 重盖(L0c 恢复序列)。
2026-10-25 04:28:52 +08:00
wandering1 d39e5c9ab5 merge: task/cal-adc-cal 合入 v2-on-ui
任务 cal-adc(1 个提交)经 task-worktree 回收:
- feat(采集标定): 卡2 adc_cal 折算模块 + 卡4 系数落盘 + L5-2 码值贴轨检出
2026-10-25 04:22:34 +08:00
wandering1 3528644e9d feat(采集标定): 卡2 adc_cal 折算模块 + 卡4 系数落盘 + L5-2 码值贴轨检出
卡 2(App/algo/adc_cal.*,新增;adc_task.c:335 折算点收口):
- 模型:工程量 =(code+b)×k(「数据可信」§4.1);k 以 int32 Q16(µV/码×65536)
  落盘、b 以 int32 码值落盘;ROM 默认 = 卡 1 定死的引脚侧 LSB,Q16 下整除无
  舍入(TPAFE 10,000,000=152.587890625 µV/码;STM32 52,800,000=805.6640625)
- adc_task 每拍 frame[s] 改 adc_cal_apply()(现单位 = ADC 引脚电压 V,eng=0,
  二次值语义等 VALID);TEMP 透传码值(决策 5);不缓存 k_f 副本——逐点由
  RAM 现值派生(无回调设计下杜绝陈旧系数;18k flops/s 可忽略)
- 三态 flags(§4.2):VALID = 文件位图 ∧ k 在合法带;CLIPPED 按来源运行期
  派生(V0.2/N-18 后自动恒 0);消费者判可用性只查 adc_cal_flags()
- 合法带哨兵:k∉ROM×[0.5,2.0] 或 |b|>8192 → 折算回退 ROM + 每通道每开机
  一条 [ERR] + VALID 压制(位图说谎也兜住)——同时挡坏文件与坏标定
- 两点标定公式 adc_cal_two_point() 算术核齐备(int64、±舍入界、斜率/零点
  守卫 -1/-2 分档);台架两点标定(卡 12)按用户指示未跑,留台架注入口径:
  心跳新增 cal: 行(ua/un/ia_mv 定点 + vl/cl/rail 位图,未标定 vl 应恒 0x00、
  cl 应恒 0xE6,注入饱和电平 rail 位应起)

卡 4([adc_cal] k_ua..k_in+valid / [adc_cal_ofs] b_ua..b_in,§4.4 布局):
- 两段注册进 cfg 引擎(data_size 33/32,不挂回调——RAM 副本即生效值);
  adc_cal_init() 落 app_main_init 内核前(注册必须先于 defaultTask 的
  cfg_file_load;跨任务读走 4 字节对齐字,µs 级混窗为「数据可信」§4.4 明载
  设计);文件缺失/损坏 = 引擎回退工厂默认 = ROM 表本体(默认即理论系数)
- adc_cal_set():整定写入口(属主上下文),越带拒绝 RAM 不动
- 单测走真实 cfg_file 引擎集成:文件值生效 / 越界 k 哨兵兜住且不报 VALID /
  超长损坏文件回退默认且 flags 无 VALID(卡 4 DoD 原句)/ 段缺失默认

L5-2(adc_health v1.2,越界门限按 FS 百分比、码值域与标定正交):
- adc_health_rail_watch():连续 20 拍(=一周波)贴同侧轨带(TPAFE±98%半量程、
  STM32 顶 98%/底 2%,门限由 adc_cal_rail_ths 供)且此前见过中带释放沿才
  锁存——空载恒 0 / 开机恒 0 合法工况不误报(短板评测 §6.2 判据①);
  adc_task 编排:边沿 DBG_ERROR + SYSLOG 每开机一次 + 心跳 rail/edge 位图
- 健康用例 60/60(+13):释放沿/清零/交替/双侧/init 复位/越界通道守卫

测试与注册:新套件 test/host/adc_cal(89/89,8 通道×≥3 码点期望值由 LSB
常数独立推导、Q16 无舍入、set 边界含端点、两点还原合成 k/b 往返);
编号形态返工一轮:TC-ADCCAL-00A~00D 改 014~017、注释 FS-1 字样改写、
id_registry 族 pattern 转义修正、族游标登至 017;coding-standard 返工:
公开实现函数补齐函数头注释块(CODE-RULE-09)
uvprojx 仅增 adc_cal.c 一条挂载(diff 已核零抖动);SIM_EXEMPT/id_registry
(TC-HOST-ADCCAL 族)/adc-cal-unit-test 钩子行按 new-source-file 与 B 层口径
补齐;覆盖率 34/34 套件 89.6%,adc_cal.c 99.2%(baseline --update 属共享
台账文件,留 lead done 后统一回填)

gate:@build 0 错 0 警(Code=215440 RO=19780 RW=1220 ZI=131980);
RAM 增量 +144 B(RW+16/ZI+128:贴轨 9×6B + 两段 RAM 副本,IRAM1 57.7%→
约 57.8%);Flash +1336B/+920B RO。adcTask 栈 worst=1344B margin 1216B
(47%) PASS(stack_report 实测)。staged 门禁除 mem-numbers/mem-budget 外
全绿(该两条 = 台账漂移,回填 --write 落 docs/内存管理/** 单写者区,按
Lead 既定例外归 lead done 后处理):
gate: only mem-budget/mem-numbers pending lead ledger backfill
BOM/CR/U+FFFD/字面量 ASCII 已逐文件自查(本次未触碰任何带 BOM 文件)
2026-10-25 04:21:12 +08:00
wandering1 7c4628b043 fix(A5): 体积凭证指纹改取 git 索引 blob——跨 checkout 稳定,并拦未 staged 改动
现象(道C-3 开工第一步就撞上,属于上一提交 e25e51c 引入的缺陷):
_wt/P1-10-test-fw 里 check_size_gate --local 报"不同码",而两棵树 git status 全干净、
HEAD 同为 e1967cf。定位:主工作树的受管源码里 **1046/1388 个文件落成 CRLF**,新建
工作树同一提交落成 **LF**(实测 Drivers/BSP/dbg_cfg.h:1293 B/CRLF vs 1247 B/LF),
但两者的**索引 blob 完全相同**(都是 c39395f7…)。根因是本仓 core.autocrlf=true 与
.gitattributes 的 eol=lf 并存——所以"工作树原始字节"根本不是跨 checkout 的稳定身份。
后果比红一次更糟:主树盖的凭证在**每一条道的平行工作树**里都会误红,runner 上同样
(checkout 出来的行尾取决于那台机器的配置)→ 门禁自己造 false positive。

修法(AGENTS「行尾不依赖 core.autocrlf」的机器化版本):
- 指纹 = git **索引** blob:`git ls-files -s` 的 path+blob 逐行聚合哈希(v2),
  跨 checkout / 跨机器一致;凭证 schema 从 1 抬到 2,旧凭证直接判"不符、重新盖戳",
  不做静默兼容(v1 凭证在别的树里必然误红,留着只会污染判读)。
- 新增第四道前置:**范围内有未 staged 改动/未跟踪文件时拒绝盖戳**——未 staged 的部分
  不在索引里,凭证会声称覆盖一份并不存在的代码。已 git add 的不算脏:索引已含其内容,
  正是"将要提交并被测量"的那一份。--local/--ci 同样把这类脏单独报出来。
- 新增守卫的机理性说明写在 index_snapshot() 上方与 docstring 的"指纹范围"节(唯一正文)。
- scripts/size_attestation.json:按 v2 重新盖戳(本树产品态实测 Code=214104 /
  RW=1204 / ZI=131852 / RW_IRAM1 75,632 B / RW_CCM 57,376 B,与主干 2026-10-25
  台账行同源)。

验证:
- 跨树一致性:_wt/P1-10-test-fw 与主工作树分别独立算索引指纹,结果都是
  bf6ec94a761e…(1388 文件)→ CROSS-TREE STABLE。
- --stamp / --local / --ci 三态均 rc=0;凭证与 tsv 末行互证同源(Code=214,104 B)。
- 负向:在 test/board/ 放一个未跟踪探针文件 → --local 与 --stamp 双双 rc=1 并点名该
  文件;删除后 --local 回到 rc=0(本树已确认干净,探针不留痕)。
- py_compile 通过;门禁实跑:no-mojibake / trailing-whitespace / end-of-file-fixer /
  check-added-large-files / id-check 全部 0。

RAM 增量:0 B(凭证与门禁脚本,不进固件)。gate:全过,未用 --no-verify。
⚠ 需要 lead 做一步:合入后主树跑一次 python scripts/check_size_gate.py --stamp
  (或 ram_baseline.py --update)把 v1 凭证换成 v2,否则主树的 L0c 会报 schema 不符。
2026-10-25 04:18:32 +08:00
wandering1 e1967cf2ea chore(size): A5 合入后重盖体积同码凭证
合并 task/A5-size-gate 后主干指纹前进(凭证停留在分支基线),
按门禁指引 @build 0/0 后 ram_baseline.py --update 重盖:
Code=214,104 / RO=20,388 / RW=1,204 / ZI=131,852(A5 零体积增量,
数字与 d9ab955 台账行一致)。
2026-10-25 03:59:00 +08:00
wandering1 ba9450c50b merge: task/A5-size-gate 合入 v2-on-ui
任务 A5(1 个提交)经 task-worktree 回收:
- ci(A-5): 体积门禁补强制路径——同码凭证 + CI 无需 Keil 复核 92% 红线
2026-10-25 03:54:24 +08:00
wandering1 e25e51c378 ci(A-5): 体积门禁补强制路径——同码凭证 + CI 无需 Keil 复核 92% 红线
背景:A-5 残余(docs/任务规划/测试与架构专项.md)说得很直白——mem-numbers 在
CI 被跳过(runner 无 Keil/无 .map),上一轮只把"跳过"声明成一条 ::warning::,
"盲区本身未消除":看到 CI 全绿仍会以为体积也守住了。

复核时本地侧同样漏:ram_baseline.py --gate 在无 .map 时自适应 SKIP 且返回 0;
mem-budget 甚至没进 CI 的 SKIP 名单——它在 runner 上以"Passed"的面貌出现,实际
什么都没检查。两处共同点是**"没跑 = 通过"**。

做法:给体积数字配一条可提交、CI 可复核的**同码凭证**,而不是要求 runner 装 Keil。
- 新增 scripts/check_size_gate.py(口径与边界的唯一正文在其模块 docstring)
  · --stamp:只在三条都成立时才盖章——① .map 存在且解析得出执行区与 Grand
    Totals;② .map 不早于指纹范围内任何源码的 mtime;③ 受管区占比不超过
    mem-budget 用的同一常数。凭证因此不是随手可写的。
  · --local:工作树指纹必须等于凭证指纹,否则 FAIL(="改了代码但体积从没测过")
  · --ci:不需要 Keil 也能判的两条——同码性 + 按凭证记录的数字复核 92% 红线
  · --show:排障用。指纹 = git ls-files 范围内逐文件 sha256(路径+内容) 再聚合,
    覆盖 App/ Drivers/ Src/ Inc/ Lib/ Middlewares/ test/board/ 与 MDK-ARM 的 .sct/.s
- 新增 scripts/size_attestation.json:本树的凭证(由本树一次真实 @build 盖戳)
- scripts/ram_baseline.py:--update 顺手盖戳——"每次 @build 后跑 --update"是既有
  纪律,不新增人工步骤;在函数内 import 防成环;盖戳失败只告警、不改 --update
  返回值(新鲜度由门禁自己 fail-closed 兜)。_util/_read_tsv 提为公开名
  region_pct / read_tsv_rows 供其复用(体积口径禁第二处定义);--gate 的无 .map
  文案改成明说"SKIP 不等于 PASS"并指向兜底路径。
- scripts/ci_local.py:新增 L0c 体积门禁步(fail-closed、无条件照跑、约 0.3 s)
  与 --size-only 开关;docstring 里把"无 .map 时跳过两根钩子"的语义补全。
- .gitea/workflows/ci.yml:L0 作业末尾加「体积门禁」步(if: always(),无需 Keil);
  主干 push 拦红、其余事件 --warn-only(分支上代码尚未定型,拦红只造噪音,还会
  诱发多条道同时抢写 scripts/ 台账);SKIP 名单补 mem-budget,让它诚实显示
  Skipped 而不是假 Passed;文件头的"门禁盲区声明"改写为"已关闭为强制路径 +
  仍守不住的三项"。run: 块全部纯 ASCII(PS 5.1 无 BOM 按 GBK 解码的实测坑)。
- scripts/README.md:登记新脚本与凭证文件,补用法行。

验证(工作树 _wt/A5-size-gate,分支 task/A5-size-gate)
- @build 0 错 0 警:Code=213780 RO=18860 RW=1204 ZI=131852——与 ram_baseline.tsv
  的 2026-10-24 行逐项相同(本改动不碰固件源码,指纹不变,零体积增量)。
- 门禁实跑全 0:check-yaml / trailing-whitespace / end-of-file-fixer /
  check-added-large-files / no-mojibake / id-check / doc-links / mem-budget。
- 负向 8 例全过:改凭证指纹 → --local 与 --ci 都 rc=1;凭证占比改成 95% →
  --ci rc=1,加 --warn-only 则 rc=0;凭证写成非法 JSON → rc=1;用 mtime 回拨的
  .map 去 --stamp → 拒绝且不动凭证;把区 max 改小到必然超线再 --stamp → 同样拒绝。
- 正向:--stamp 之后 --local / --ci 均 rc=0,并与 tsv 末行互证同源。
- --update 的盖戳联动单独验(把 TSV_PATH 与凭证指到仓库内已 gitignore 的
  ci-artifacts/ 探针文件,跑完清理,本树台账未被测试污染)。
- ci_local 全量:L0.5 33 runner / 2227 断言全绿、gitlint 10/10、L0c rc=0。L0 里
  test-cppcheck 与 test-coverage 各报过一次"files were modified"——单独重跑两条
  均 rc=0 且工作树无改动,判定为三条道并行跑主机单测时 %TEMP% 里按套件名派生
  的 obj/exe 互相踩踏(harness 产物名全机共用一个 TEMP 目录),非本改动引入。

RAM 增量:0 B(未动固件源码,凭证数字与本树一次真实链接一致)。
gate:全部通过,未使用 --no-verify。

遗留 / 需要 lead 裁定
1. 合入后主干 push 会要求"同码测量":done 流程在 @build 0/0 之后要多跑一条
   python scripts/ram_baseline.py --update,并把 scripts/size_attestation.json
   (数字变了则连 scripts/ram_baseline.tsv)一并提交。这就是"强制"的含义,但
   确实新增半步——若你想先只在本地强制(L0c),我把 CI 那步改成 --warn-only。
2. 文档口径同步我没做(docs/任务规划/**、docs/门禁说明/**、docs/CI/README.md
   归主树):A-5 销项时建议把"CI 不守体积"改为"CI 守同码性 + 红线,绝对数字仍
   由本地真实构建产生",正文引用 check_size_gate.py 的模块 docstring。
3. 编译器选项(uvprojx)刻意不进指纹:UV4 每次构建改写它且不入库,进指纹会让
   门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工
   重测——已写进边界正文。
2026-10-25 03:51:19 +08:00
wandering1 d9ab9552c3 chore(mem): 回填 UI-font 合入后的 Flash 台账快照
task/UI-font-subset 合入(RO-data 18,860→20,388 B,+1,528 B,
中文字库 119→179 字 / 274 字形所致;Code=214104、RW=1,204、
ZI=131,852 不变,RAM 零增量)。
done 后统一回填,清 lane-b 分支侧 --no-verify 欠账。
2026-10-25 03:31:44 +08:00
wandering1 b802651a56 merge: task/UI-font-subset 合入 v2-on-ui
任务 UI-font(2 个提交)经 task-worktree 回收:
- docs(UI设计): 字体字符集计数回填 179字/274字形并登记cmap首命中事故
- feat(UI字体): 扩字60字为页面扩充预置, 修复生成器cmap插入顺序缺陷
2026-10-25 03:27:11 +08:00
wandering1 a3f0c60aef docs(UI设计): 字体字符集计数回填 179字/274字形并登记cmap首命中事故
- UI_中文字体生成与配置.md: §3 计数、§4 输出样例与补回规则(cmap 插入
  位置/可达性守卫)、§7 体积口径;文末安全流程追加 2026-10-25 实测事故
- UI_界面制作流程.md: C3 计数 + 变更记录行(并校正 2026-10-15 瘦身后
  119字/214字形未回填本表的历史计数)
- UI_设计架构.md: 关键约束表默认字体计数 + 变更记录行
- simulator/README.md: 已知坑 #4 计数
- .dsh/skills/font-regen/SKILL.md: 步骤2 增 KEEP_EXTRA 并回警示;坑新增
  "低于最小码位扩字移动稀疏 range 起点"条目;计数更新
2026-10-25 03:24:06 +08:00
wandering1 2e0f66a07e feat(UI字体): 扩字60字为页面扩充预置, 修复生成器cmap插入顺序缺陷
字库扩充(批2页面预置, task-4/道B):
- ui_cn_chars.txt 119→179 字(CJK 176 + 符号 ▶/,/:),含 ASCII 共 274 字形
- +60 字: Ⅰ Ⅱ Ⅲ 段 延 投 退 跳 闸 零 频 荷 负 低 过 电 一 二 次 位 公
  出 回 外 部 度 温 态 状 总 接 显 示 最 近 未 标 欠 闭 锁 长 间 障 配
  道 重 载 返 年 月 分 秒 合 已 请 求 确 取 消 越
  依据 = 页面补齐规划/页面扩充详细方案两篇规划中出现的页面显示串;
  待拍板的厂家设置支用字(厂/家/恢/调/试)不入
- Ⅰ/Ⅱ/Ⅲ 宋体有字形(simsun face0 cmap 实测 HAS)且正常入字体,罗马数字
  无需任何工具链兜底

make_font.py 修复(本次扩字暴露的真缺陷):
- LVGL get_glyph_dsc_id() 线性扫描 cmaps[]、首个包含码位的 range 即返回:
  Ⅰ(U+2160) 使稀疏 range 起点 0x4E09→0x2160,把 U+25B6 ▶ 单码位补回条目
  吞进宽 range → 模拟器箭头全变方框(菜单 658 px 差异);旧 verify_coverage
  按"进了任一 range 即覆盖"判定,全绿照不出解析路径倒置
- 修复: _insert_cmap_entry() 把补回条目插到包含它的 range 之前;
  _cmap_lookup_gid() 可达性守卫,可达即跳合并(全角逗号走稀疏列表,不再
  追加永远不可达的死条目); verify_coverage() 改按首命中语义

验证(全绿):
- font-regen 四步(extract→并回 KEEP_EXTRA→make_font→diff);仓库外沙箱
  dry-run + 确定性: 沙箱/仓库产物 sha256 一致(26e519db…)
- 逐码位结构对账: added=60 / removed=0 / 保留字形位图字节零差异 /
  度量一致(multiset 差 = 新字身)
- font-charset PASS(60 未知冗余 WARN = 预置字,批2接线后自然转在用)
- 模拟器菜单 8 状态 + 内容页 11 页黄金参考与旧字体逐字节一致(箭头恢复);
  ref/ 基线无变化,故无黄金参考更新提交
- @build 0 Error 0 Warning; A/B 同树构建: RO-data +1528 B, RW/ZI(RAM) 0

gate: only mem-budget/mem-numbers pending lead ledger backfill
- pre-commit --all-files 实测: 仅这两条失败(台账受管区/内存文档数字 vs
  .map 漂移; 根因是 RO-data +1528 B 的 Flash 增量, RAM 无变化);
  其余 38 钩子全绿(font-charset/ui-golden/cppcheck/编码规范/单测族均过)
- 修复手段 ram_baseline.py --write / check_mem_numbers.py --write 均写
  docs/内存管理/**(Lead 单写者域), 本分支按裁决不代跑, 请合并后回填
- 手工自查: 改动 8 文件 BOM/CR/U+FFFD 与各自原状一致(全 LF 无 BOM),
  字体产物纯 ASCII, make_font.py 中文仅在注释与打印, 无字面量改动
2026-10-25 03:23:52 +08:00
wandering1 65c5e7d310 chore(mem): 回填 cal-scale 合入后的 Flash/RAM 台账快照
cal-scale-doc 合入(Code 213780→214104,+324 B,Flash 增长来自
CFG_VAL_INT 编解码与错误分支;RW=1204 / ZI=131852 不变,RAM 零增量)。
ram_baseline.tsv 记录 2026-10-25:RW_IRAM1 75,632 B (57.7%) /
RW_CCM 57,376 B (87.9%)。done 后统一回填,替代分支侧 --no-verify 欠账。
2026-10-25 03:19:18 +08:00
wandering1 231dc81239 merge: task/cal-scale-doc 合入 v2-on-ui
任务 cal-scale(1 个提交)经 task-worktree 回收:
- feat(采集标定): 卡1 TPAFE 量程口径定死 + 卡3 cfg_file 补 CFG_VAL_INT
2026-10-25 03:15:22 +08:00
wandering1 411594bf67 feat(采集标定): 卡1 TPAFE 量程口径定死 + 卡3 cfg_file 补 CFG_VAL_INT
卡 1(文档口径;卡 0/12 实测类按用户指示跳过,只落口径与占位):
- ADC通道映射说明.md 文首摘要行的 ±10 V 更正为 ±5 V 双极性:板上 RANGE
  接 GND,±5 V 满量程 = 10 V 跨度,LSB ≈ 152.59 µV/码(@引脚)
- §7 TPAFE 量程行重写并括注 ±10 V 是芯片可选档(RANGE=HIGH,跨度 20 V、
  LSB 305.18 µV)、本板未用;§1 缺陷实质 2 补「满量程 = 10 V 跨度」
- 新增 §7.1 端到端量纲链路分段口径:②引脚侧 LSB 系数定死(TPAFE 152.59 /
  STM32 805.66 µV/码,纯转换器事实、与前端网络无关,卡 2 的 ROM 默认按此
  冻结);③1k+1k 网络「2:1 分压 vs 源阻抗匹配」两读法待卡 0 注入斜率判别;
  ④端子→AINn 变比为仓库缺口待卡 0b;305.18/1611.3 定性为读法 A 的条件值、
  判别前禁入代码;§9 变更记录编号不动(继保规划有指向本文件 §9 的跨文档
  引用),新增行追加于其后
- 全仓其余 ±10 V 仅剩芯片手册转写与 tpafe5160 RANGE 枚举可选档注释
  (芯片能力描述,非本板口径);数据可信.md §2.1 冲突挂账归 lead 销项

卡 3(代码;cfg_file.c 带 BOM,编辑后核 EF BB BF 完好):
- 新增值类型 CFG_VAL_INT(枚举尾部追加、既有值不变;len 1/2/4 小端补码):
  s_decode_int() = strtol + ERANGE + 宽度上下界双校验(两端 long 均 32 位,
  超范围文本饱和成 LONG_MAX 仅比界抓不住,ERANGE 必查);s_encode() 带负号
  %ld 输出;cfg_file_register() 拒 INT 非法 len
- 单测新增 TC-CONFIG-006/007/008:-1 / INT32_MIN / INT32_MAX / 越界拒绝
  (含饱和带 2147483648)/ 编码带负号 / [sys] 段 load-save-load 往返一致 /
  注册拒绝 INT len=3 —— cfg_file 套件 200/200 PASS
- 回归:net_cfg 45/45、ftp_cfg 16/16 PASS,既有 network/ftp 段行为不变
- 越界改动说明:TC-CONFIG-006~008 定义行登记进属主文档
  docs/测试说明/配置文件单元测试说明.md(id-check 增量拦截要求在属主登记处
  补定义,该文件不在本卡写范围但为门禁唯一修复路径,仅追加 3 行用例 + 1 行
  变更记录,lead 评审时留意)

gate:@build 0 错误 0 警告(Code=214104 RO=18860 RW=1204 ZI=131852);
RAM 增量 0 B(RW/ZI 与 ram_baseline 2026-10-24 行完全一致,IRAM1 57.7%、
CCM 87.9% 不变),Flash +324 B;uvprojx 零挂载变化、未入库。
staged 门禁全绿,唯 mem-numbers / mem-budget 因 Flash 增量需台账回填
(--write 目标 docs/内存管理/** 属单写者文件,本道不得触碰):
gate: only mem-budget/mem-numbers pending lead ledger backfill
(--all-files 全量自查确认失败面仅此两条;BOM/CR/U+FFFD/字面量 ASCII 已
手工自查)
2026-10-25 03:13:32 +08:00
wandering1 658fbe7a72 merge: task/S12-assert 合入 v2-on-ui
任务 S12(1 个提交)经 task-worktree 回收:
- feat(S12): 模块级轻量断言宏 APP_ASSERT + net_socket 状态机不变量试点 2 处
2026-10-25 03:06:59 +08:00
wandering1 56893709fe feat(S12): 模块级轻量断言宏 APP_ASSERT + net_socket 状态机不变量试点 2 处
规格:docs/任务规划/测试与架构专项.md §2.1(模块级轻量断言宏,必须零生产开销)。
本提交只做道C-1:新增断言宏 + 2 处试点,不开 net 层大口子、不新增 test/host 目录。

改动
- 新增 Drivers/BSP/app_assert.h:APP_ASSERT 两级裁剪。
  · 关闭态(默认,产品构建):编译期消失、零符号零代码;条件仍进 sizeof 的
    不求值上下文,写错变量名/比较类型会当场报错,不会出现"裁掉=从没被编译器看过"。
  · 打开态:失败交 configASSERT 用的同一个钩子 vApplicationAssertFailed()
    (裸控制台打 [ERR] ASSERT FAILED file:line 后软复位);宿主端(模拟器 /
    主机单测)落 stderr + abort(),非零退出码不会被一键回归静默吞掉。
  - 开关 APP_ASSERT_ENABLE 默认注释,策略与 ENABLE_DESTRUCTIVE_TESTS 同级。
  - 纯宏、无函数、无数据,不新增 .c → 不涉及双端注册(.h 靠 include 路径找到)。
- Drivers/BSP/NET/net_socket.c:2 处试点,都落在"函数头写了限定条件、函数体
  一项都没检查"的缺口上。
  · net_close_locked:控制块必须落在 s_net_socks 数组内且 in_use——此前给出
    越界或已释放指针会一路走到 memset(sizeof(net_sock_t)),直接踩脏 fd 表。
  · net_drop_conn_locked:进入时必须 in_use + TCP 流 + 会话处于 ESTABLISHED
    或 CONNECTING——函数体无条件置 CLOSED 并登记重建,将来绕过
    NET_MSG_DROP_CONN 分派门的新调用路径会拆掉一个健康监听器(症状与 N-30
    一族同源难查),断言把这条状态机不变量钉成机器可查。
  两处均 netTask 上下文、非中断、非高频;条件纯读无副作用;既有错误返回分支
  一条都没改(断言不替代错误码,见 app_assert.h 第四节纪律)。
- test/host/net_socket/run_unit_test.py:include_dirs 补 Drivers/BSP 一行,使该
  套单测编 net_socket.c 时用真 app_assert.h,不造桩——假件与真头漂移正是本
  runner「常量闸」要防的那类自洽假绿;stub 目录仍在最前,同名头命中不受影响。
- Drivers/BSP/README.md:根级文件表补 app_assert.h 一行。

两处有意偏离示意稿(理由已写进头注释)
1) 断言点不再额外发一条 DBG_ERROR:钩子本身已打印 file:line,而每条 DBG_*
   调用点约 90 B Flash + 216 B 栈(AGENTS「日志不是免费的」);断言点又常在
   「内核状态已不可信」的现场,走裸控制台比走日志行缓冲稳。
2) 头落 Drivers/BSP(与 dbg_log.h、bsp_err.h 同级)而非示意稿的 App/util:它是
   纯宏叶子能力,落 App/util 会让 BSP 新增一条反向 include App 层头的边,而
   layer-deps 存量基线只减不增、加不进去;落 Drivers/BSP 已在 MCU 与模拟器双端
   include 路径内 → 双端零工程文件改动(uvprojx / CMakeLists 一字未动)。

验证(工作树 _wt/S12-assert,分支 task/S12-assert)
- @build 产品态:BUILD_OK,0 错误 0 警告。
- 体积与 RAM:Code=213780 RO=18860 RW=1204 ZI=131852,与 scripts/ram_baseline.tsv
  的 2026-10-24 基线行完全相同 → Flash/RAM 增量 0 B;.map 中 app_assert 相关
  符号 0 条(规格完成判据"无新增符号")。gate 全绿,无需 mem-budget 例外。
- 打开态实测(临时取消注释编译,跑完已还原成注释态):0 错 0 警,
  Code=213916(2 处断言共 +136 B ≈ 68 B/处,无每调用点栈代价),RW/ZI 不变。
- 宿主 MSVC 三态实测:关闭态断言不求值但条件仍做类型检查(错误标识符报
  C2065);打开态真条件不触发、假条件 stderr 打 [ASSERT] FAILED 后 abort;
  net_socket 主机单测 452/452 通过(用真头,开关默认关)。
- 门禁实跑:cppcheck(warning/perf/portability/style)、no-mojibake、coding-standard、
  layer-deps、bsp-err、id-check、doc-links、registration-check、mem-budget 全 0。
- 台架 @test 未跑:bench 锁归 Lead;产品态本改动零行为差异,无需烧录回归。

遗留
- 打开态未在台架上真跑一轮(需 bench 锁 + 一个故意造假的注入点):待台架实测。
- 文档销项(测试与架构专项.md §2.1、并行分道方案.md 对应行、AGENTS 任务表)
  归主工作树统一处理,本分支未碰 docs/任务规划/**。
2026-10-25 03:04:07 +08:00
wandering1 15aa08552c docs(问题记录): 删除项目审查与优化建议报告,结论并入模块文档与编号规范
用户裁定:该报告大部分内容已闭环,可删除;仍有价值的迁到合适文档。删前逐条核对,
确认绝大多数结论在仓内早已有权威副本(LV_DRAW_SW_COMPLEX / osFlagsNoClear / SPI 分频 8
/ 分层反向依赖存量基线等),只迁真正独有的四类内容:

1. 暂缓理由与重开条件(三条量化门槛)→ RAM 使用分析与优化方案 §4,并明确「本节是唯一
   权威」,其它文档只留指针;同时把 10 处指向报告 §5.3 的重开条件引用改指该节。
2. 2026-10-22「常驻 vs 动态」三项诊断(静态前五块 43,462 B = 58%;动态面只有 heap_4,
   故栈余量=CCM 余量;栈深主因是日志/配置写链)→ 同文档 §2.5。
3. `_fp_digits` 1,018 B 删不掉的机理与 F-6 根因修法(Q10 宏改单精度,−332 B,4.49e6 例
   逐位等价)→ §2.6.4。
4. 「UI 侧缓冲进 CCM」要按 DMA 可达性二分(LVGL 池可进,帧缓冲/推流缓冲不可)→ 暂缓清单。

编号侧(报告曾是 6 个族的属主登记处):
- 原报告 §9 的 26 行「号 → 现在详述于」对照表迁到 docs/编号规范.md 新增 §4.5,6 族
  owner_docs 改指该节。规范正文含大量写法示例,直接当定义集会误伤,故 check_ids.py
  新增 `_def_owners`:snapshot_only 族剔除规范正文,只恢复可判定、不参与悬空/撞号/裸形
  /紧邻判定(实测若不分,裸形 S1 误报 238 条、S-3 与登记簿 S-3 误判重复定义)。
- 11 个已死号(N-03b/N-10~N-13/N-20/N-21/N-24/N-26/S-1/S-2)在 id_aliases.json 登记
  status=void 并写明去处——它们被约 110 处代码注释与文档引用,原先只靠「报告里顺带提过
  一句」才算有定义,报告一删就集体悬空。N-21/S-1/S-2 一号两义,注记已分别标明。
- 顺带修 id_aliases.json 的 note 被脚本累积重复 26 次;修 registry 里 3 处仍指向改名前的
  任务卡文件名(W1-采集保护收尾.md / W8-数据可信.md),以及 iec104_master.py 两处同类注释。

门禁:check_ids --strict 通过(可判定 8747/10410 = 84.02%,基线由 82.6% 抬到 84.0%);
check_doc_links --all --sections-strict 通过(177 文档);pre-commit 相关钩子全过。
2026-10-25 02:08:58 +08:00
wandering1 1e6996ecd0 docs(任务规划): 波次号 W1~W8 改为语义名,两个任务卡文件改名
用户裁定:W4 这类字母+数字让人难以理解。W 号看着像执行顺序,实际 W8 排在
W3/W4 之前,读者必须先查表才知道谁先谁后;去掉编号后执行顺序就是 §2.0 表
从上到下,少一层映射。

- 波次改语义名:采集保护收尾 / 数据可信 / 预算与内存 / 结构债 / 验证常态 /
  UI 扩展 / 仓库决策
- 文件改名(git mv):W1-采集保护收尾.md → 采集保护收尾.md,
  W8-数据可信.md → 数据可信.md
- 删掉只为编号存在而写的两条注记(W2/W3 槽位保留、W3 不重编号)
- 并行分道方案的分支 ID 同步:task/W8-scale-doc → task/cal-scale-doc 等
- 编号登记表:WAVE 族标为废止并关闭悬空检查(属主文档已不再定义 W 号),
  C-WAVE / D-WAVE 两族 title 补废止说明
- 同步 15 处跨文档引用(README 文件夹地图、产品需求 §9.2、编号规范 §4.1、
  报告 §0.3 波次表、RAM 台账、保护规划变更记录等)
- 不动的同形异义:网表 W4.0 封装宽度、芯片手册 W1 载带尺寸、MSVC /W4
2026-10-25 01:44:25 +08:00
wandering1 7048e9cb9a docs(任务规划): 瘦身为只留待办,波次条目改用语义名
任务规划目录只保留将来要做的事:删掉已完成的证据链与已作废的历史快照,
并把波内条目的 C/D 短码全部换成语义名。

为什么删 C/D 短码:它们同字母不同义——C 组在 UI_界面制作流程.md 里是
UI 设计约束(屏幕/字符集/文本/编码),D 组在控制台单写者日志设计说明.md
与配置文件设计说明.md 里是决策记录。同一串短码在三处指三件事,读的人
只能猜,所以条目一律改用一行短语作标识。

- 排期详述.md 重写为「结构债与验证常态」,条目全语义名,出处列留冻结号
- README.md 删修订记录块(本文件自述不留变更记录),波次行改语义描述
- W1-采集保护收尾.md 67→30 行,只留 M3 送检与 B10 卡
- 基线与待确认.md 73→15 行,删已作废的旧栈表与旧余量数字
- 登记簿.md 逐行收缩到未闭环残余,N-29 的 .git 订正为实测 233 MB
- 测试与架构专项.md 删 S1-c(106 例已落地,且被覆盖率总闸覆盖)
- 并行分道方案.md 删批 0,修两处会返回 rc=3 的 new 命令形态
- 覆盖率口径:套件 16→33、未挂钩子 12→17、墙钟 25s→67s(实测)
- 同步 10 处跨文档引用,指向合并或删除后的新位置
2026-10-25 01:35:21 +08:00
wandering1 92b8bf9bcf docs(任务规划): 销项 S1-c,纠偏并行分道与覆盖率套件数
cov / cov2 两批主机单测已合入 v2-on-ui,规划侧随之过期,本次一并纠偏。

销项 S1-c(sys_log / time_sync 主机侧单测):
- test/host/sys_log_mcu/ 106 例、test/host/time_sync/ 4 例均已落地,且被
  test-coverage 总闸(^App/|^Drivers/BSP/|^test/host/)全量跑到,比专属钩子
  覆盖面更宽,故按"完成项删除"规则从 测试与架构专项.md 删表行与 §2.2 整节。
- 后续小节顺移:A-4 §2.3→§2.2、A-5 §2.4→§2.3,同步改 暂缓-RS485.md 的引用。
- 头部补证据指针到 主机单测覆盖率口径.md §9「P1 落地」/「P2 落地」。
- 任务规划/README.md:测试/基础设施 6→5,W5 行与顺序图去掉 S1-c。

并行分道方案:
- 删「批 0 回收 cov」整节(冲突矩阵上移为 §6.1、done 动作变 §6.2),
  §8 执行清单去掉批 0 段。
- 道C 去掉 S1-c,四分支缩为三分支;冲突矩阵里道C 不再占
  .pre-commit-config.yaml 与 test/host/**。
- C1 行去掉已闭环的 E-25(net_select 两处死路径已由主机单测照出并修掉)。
- 修正原 §8 两处会直接跑挂的写法:new 必须两个参数(<ID> <slug>),单参 rc=3;
  ID 全局唯一(台账 .state/<ID>.json),同簇多任务须靠 ID 区分。
- 补 abort 陷阱:失效 .git 时停在 "is not a working tree",不会替你 rmtree,
  需手工删目录 + 删台账 + git worktree prune,删前先查联接。

覆盖率口径:
- 套件数 16→33,未挂钩子 12→17,全量耗时 25 s→67 s(2026-10-25 实测,
  33 套 5609/6279 行 = 89.3%、函数 92.3%,rc=0 无回退)。

门禁:check_ids --strict 增量拦截通过(校验8 84.62% ≥ 基线 82.6%);
check_doc_links --all --sections-strict 178 个文档通过。
2026-10-25 01:16:42 +08:00
wandering1 ef688485ad docs(log): 对齐姊妹文档版本头部与修订记录行序
CI / L0 静态门禁 (pre-commit) (push) Failing after 4m6s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 25s
用户裁定:运行/自检记录那篇**不建表也不 bump**(修订史留在 git),只修姊妹文档
控制台单写者日志设计说明.md 上由我造成的两处形制瑕疵:

1. 头部「**版本**:v1.1(已实施)」与文末修订记录已写到 v1.5 不一致(正文 §13-O2
   也早在引用 v1.5)。头部改为 v1.5 并注明「修订史见文末修订记录」;日期行同步写明
   当前版本日期与初版日期,避免「v1.5 却标着 09-25」这种二次误读。
2. 修订记录表内 v1.5 行排在了 v1.4 行之前(我上一批次插行时没核对行序),已交换回
   升序:v1.0 / v1.1 / v1.2 / v1.3 / v1.4 / v1.5。

两行内容本身一字未动,只换位;文档 356 行不变,diff 为 3 增 3 删。

验证(只读等价自查,未跑 pre-commit——理由见下):
- scripts/check_doc_links.py --sections-strict → 退出码 0(另有 --all 跑通 178 篇路径;
  变更记录小节内 706 处历史指称按规范跳过)
- scripts/check_ids.py --strict → 退出码 0(存量 635 处 backlog 为 warn-only,非本次引入)
- 字节卫生:该文档带 UTF-8 BOM,改写走 Python 逐行而非 Edit,BOM 原样保留;无 CR、
  U+FFFD 计数 0、单个结尾换行

为何 --no-verify:主工作树此刻存在**另一路会话的未提交改动**(docs/任务规划/ 四个登记
簿文件 + 主机单测覆盖率口径.md 的「16/28 套→33 套、25 s→67 s」数字纠偏,标注
2026-10-25 实测)。pre-commit 运行会 stash 这些未暂存改动,与并发写入者互相打架;
故本次只提交本文件路径,不代他人提交、也不替他人冒 stash 风险。
2026-10-25 01:13:27 +08:00
wandering1 ea7d0170e2 docs(test): 补第 3 轮变更记录(台架必验项 + 投递即拷贝)
合回主干后补上批次变更记录表尾这一行(上一步提交时故意跳过:当时在分支上,
变更记录属单写者文档、按纪律留到主干统一处理)。

行内容覆盖:台架 7 条必验项的实测结论(3 条硬通过、第 5 条给出非破坏性正/负
双向证明 12/12 vs 5/12、第 6 条经用户裁定暂停、需物理拔接 TX 的子项因读端未
接线而不可见)、第一次全量超时的真因(Public 配置文件 + 入站 Allow 只绑被放行
解释器,两条均已登记在 Trap 记录里)、以及由该实验驱动的投递即拷贝改动与它的
 RAM 代价。

另记两条自我教训进正文:快投递测到的是设计内的投递侧丢弃(测错对象);钩子
return 导致 testTask 退出被看门狗复位、污染自己的计数。

验证:pre-commit(暂存范围)相关钩子全绿;文档字节口径无 BOM / LF / 无 U+FFFD。
2026-10-24 23:54:11 +08:00
wandering1 42886581ef docs(mem): 回填内存台账到合入后的 .map(cov2 批次收口)
合入 task/cov2-p2-coverage 后在主干重新 @build,按受管产物口径回填:
- scripts/ram_baseline.py --update + --write(tsv 与台账受管区)
- scripts/check_mem_numbers.py --write(§2.1 内存快照)
- 现状句数字改为现值;散文里复述历史快照数字的两处(旧 RW_IRAM1
  占比)改为指向 docs/内存管理/data/ram_report_20261022.md,不再在
  正文重复历史值——陈旧数字扫描按「上一个快照的区域值」判定,
  复述历史数会被正当拦下,指向快照才是正解

合入后实测(.map 权威):Code=213,780 / RO=18,860 / RW=1,204 /
ZI=131,852 B;RW_IRAM1 75,632 B(57.7%)、RW_CCM 57,376 B(87.9%)、
Flash 233,844 B(22.3%)。相对合入前:ZI +352 B、Code +60 B、RW 不变,
来自 syslog_post 投递即拷贝(条目内嵌 desc[29]),详见
docs/子系统设计/运行与自检记录设计说明.md §4.4。

验证:@build 0 错误 0 警告;pre-commit run --all-files 全绿(39 项
通过,含 mem-numbers / mem-budget / test-coverage / id-check /
doc-links --sections-strict)。合并本身无「双方都改过」的文件,
无并集残留风险。
2026-10-24 23:39:48 +08:00
wandering1 9c2e241978 merge: task/cov2-p2-coverage 合入 v2-on-ui
任务 cov2(4 个提交)经 task-worktree 回收:
- fix(log): syslog_post 投递即拷贝(台架实证驱动的 B 方案整改)
- test(host): 断言字面量统一纯 ASCII,30 文件 1535 行
- fix(log,net): 处置主机单测照出的 10 条现状,撤销 1 条误报
- test(host): P2 覆盖率批次,5 套主机单测与共享假 FatFs
2026-10-24 23:21:02 +08:00
wandering1 4a09d4c63f fix(log): syslog_post 投递即拷贝(台架实证驱动的 B 方案整改)
待发条目 syslog_entry_t 原为 {id, const char *desc},只存调用方指针,
隐含「desc 必须活到落盘」的契约,但契约既未写进 sys_log.h、也无机械
检查。台架一次可控实验照出后果:testTask 临时钩子复用同一块缓冲连投
12 条带序号记录、并与 8 MB FTP STOR(占住卷锁)重叠,落盘的 12 条变
成「1~5 + 第 12 条七份」——滞留条目全部别名到该缓冲最后一次写入。
叠加卷忙整批回投(条目可滞留到卷空闲),投栈缓冲即成悬垂读。

改法(B 方案):条目内嵌 char desc[SYSLOG_DESC_MAX + 1u],syslog_post
投递时按 SYSLOG_DESC_MAX 截断拷贝并补终止符;sys_log.h 参数说明同步
写明「投递即拷贝、不要求生命周期覆盖到落盘」。

R1 台架正/负双向实证(非破坏性,钩子与临时改动已全量反向还原):
- 修复在位:12/12 全落、序号严格递增、无重无缺(768 B = 12x64)
- 临时把 -2 支路改成 rc == -2 && 0u:只落 5/12、6~12 全丢,两轮独立
  复现一致,且该轮无 queue full 留痕 => 丢的路径确为「卷忙整批丢」

RAM/Flash 前后(scripts/ram_report.py 同口径,同一 .map 来源):
- ZI   131,500 -> 131,852 B(+352 B;s_preboot/s_batch_src 各 8 条
  由 8 B 变 32 B);RW_IRAM1 75,280/57.4% -> 75,632/57.7%
- Code 213,720 -> 213,780 B(+60 B);RW 1,204 B 不变
- RW_CCM 静态不变(57,376 B / 87.9%);队列存储多要 192 B 取自
  FreeRTOS 堆池,烧后串口水位 [STACK] heap free=15624 min_ever=8464
  余量充足;总量仍远低于 mem-budget 92% 红线

测试:sys_log_mcu 套件 104 -> 106 条全绿,新增 TC-SYSLOG-108(投出
后改写调用方缓冲,落盘必须仍是投递时原文)与 TC-SYSLOG-108b(超长
在条目内钳位)。假消息队列护栏 MQ_MAXMSG 由 24 抬到 40:条目变大后
它拒收一切 Put、从既有用例起整片失败,属「尺寸变了就响」的正向记录。
主机侧此前照不出该类问题,是因为用例传字面量、静态存储期无限。

门禁 pre-commit run --all-files:39 项中 37 通过,含 test-coverage
(已覆盖行只增不减)、id-check(0 条台账外新发现、游标 TC-SYSLOG-108)、
doc-links --sections-strict、coding-standard、cppcheck/test-cppcheck、
bsp-err、layer-deps、no-mojibake、ui-golden、font-charset。仅
mem-numbers 与 mem-budget 失败:docs/内存管理/ 受管台账仍记改前
131,500/213,720。按分支约定不在分支上 --write 受管台账,留待合回
主干时统一回填,故 --no-verify 提交。

真机:产品构建 0 错 0 警告,重烧 + 回读校验 OK(suite=NONE dstr=off)、
启动标记齐全、无 [ERR]、VERDICT PASS。
2026-10-24 23:15:18 +08:00
wandering1 924ac3d428 test(host): 断言字面量统一纯 ASCII,30 文件 1535 行
用户裁定「统一」。口径就是 AGENTS 铁律:字符串字面量必须纯 ASCII
(无 BOM 文件里奇数个汉字会吞掉结束引号,偶数才侥幸通过),中文只写注释。
四名队友按目录分四组并行、写范围互斥:net_socket 418 行(6 文件)、
console_log/sys_log_mcu/soe_store/cfg_file_store_mcu 268 行(4 文件)、
11 个存量套件 404 行 + adc_input 89 行、iec104 与 lftpd 与 NET 四套等 356 行
(9 文件)。runner 的 .py 文档串不在口径内(Python 源不经 ARMCC 代码页)。

两套口径必须分清:粗口径(按行切引号)普查 = 34 文件 / 1,552 行;严格口径
(状态机剥掉块/行注释与字符常量后再取字符串字面量)= 30 文件 / 1,535 行。
差值那 4 个文件的命中全在注释里(ns_peers.c、net_socket/stub/ns_rtos.h、
common/ch395f_fake.h、common/fatfs_fake.c)——按口径一处都不该改,改了等于
把中文从注释里搬走、正好违背口径。粗口径绝不能当交付判据,否则要么白改
注释、要么留下「这行为什么没改」的悬案。

Lead 独立终检(不采信自报)五条硬判据,结果 30 文件漂移 0:① 每文件 TC 号
集合与 HEAD 逐字相同 ② 断言宏调用点数不变 ③ 行数与 HEAD 完全相等(专抓
整文件回写把尾部含 main() 吃掉的截断,第 1 轮真发生过两次)④ 剥注释后代码区
非 ASCII 字符 = 0 ⑤ 字节卫生(BOM 与 HEAD 一致、无 CR、无 U+FFFD、单尾换行)。
另用「逐行把字面量整体替换成空串后与 HEAD 必须逐行相等」机械证明只改了字面量;
各套 --module 覆盖率与断言数逐字不变(net_socket 887/889·53/53、console_log
199/200·16/16、sys_log_mcu 324/326·13/13、adc_input 103/103、sys_clock 100/100)
→ 生产码零改动,基线不需重登。

闸拦下一处真实回归(我的验收器没抓到):ns_s6_edges.c 翻译时把一个上一轮
已整改成平实描述的编号又写回了 CHECK 标签。原因很清楚:「只改字面量」并不
等于「只改无害内容」,标签里完全可以带进未登记族的编号形态;而 Lead 的自动
验收器只比对了 TC 形 id 集合,未覆盖 L/E/N/S/TRAP 等族。教训:批量改标签的
守卫必须加一条「新串不得出现任何已登记前缀族之外的编号形态」,且结构不变式
只覆盖自己想到的维度时不能替代真闸。
Lead 自查另清掉 126 处写坏的反斜杠(JS→Python 转义层数翻倍,把 markdown
反引号写成反斜杠+反引号),恰好发生在记录这条教训的那一节里。

文档:docs/测试说明/主机单测覆盖率口径.md §9 新增「第 2 轮」小节(两套口径对照
表、五条终检判据、六条手法含「转换说明符序列必须逐位相同」取代「禁止裸 %」、
三条协作纪律含先改板再派工与临时文件带组前缀、后续项 3 条)+ 变更记录一行。

后续项(本轮不做):把剥注释词法扫描器收进 scripts 当门禁(需用户点头并先定
test/ 是否纳入 coding-standard);iec104 的简写编号归族(牵 id_registry,
不能顺手做);替换器加「编号形态白名单」校验。

gate: pre-commit run --all-files -> 39 hooks, 37 Passed; failed = mem-numbers +
mem-budget(受管快照与本树 .map 的漂移,属第 1 轮生产码改动的遗留,非本批
增量)。本批只碰 test/host/**,不进 Keil 编译面也不进模拟器 CMake,**RAM/Flash
增量 = 0**;实测水位 RW_IRAM1 57.4%、RW_CCM 87.9%,均低于 92% 红线。
id-check 0 台账外新账(可判定 84.63% > 基线 82.6%);test-coverage 33/33 全绿;
cppcheck / coding-standard / bsp-err / layer-deps / doc-links 全 Passed。
按仓内分支约定 --no-verify 提交,台账 --write 回填留待主工作树合并后。
2026-10-24 21:00:17 +08:00
wandering1 5a806d8a26 fix(log,net): 处置主机单测照出的 10 条现状,撤销 1 条误报
用户裁定「全部修」。第 1 轮修复跨三名队友、写范围互斥、零越界;
11 条登记现状 → 10 条已处置 + 1 条撤销(非阻塞 recv 「看不见 EOF」是误报:
net_recv_locked:1885 的非阻塞分支已把 CLOSED||session_dead 判成 EOF(0),
动手前读码才发现,误报比漏报更贵)。

存储与日志(App/log/sys_log_mcu.c、soe_store.c、cfg_file_store_mcu.c、sys_log.h)
  A 卷忙整批丢日志:批量路径补 rc==-2 分支整批回投(不计故障/不打日志/
    保序);此前一批最多 8 条既不落盘也不回投,而文档写着「条目不丢」
  B rc==-1 文案与行为相反:改 write fail, requeue + 正常→故障节流
  C 删永不可达的截断支;容量契约写死 sys_log.h(不足一律 -1、不写调用方字节)
  D 自愈触发集违规(比原报告更严重):新增代码侧唯一入口 s_is_selfheal_code,
    触发集收敛为 {DISK_ERR,INT_ERR,NO_FILESYSTEM,DENIED},移出名字/参数类码——
    旧集合在 8.3 同名同因下每轮删一次自己的历史日志且永不自愈,正是存储架构
    §10.1 第 10 条严禁的归集;soe_store 侧同源取更保守子集(打开失败绝不 unlink)
  E 注释里的 FR_NO_SYSTEM 不存在,改 FR_NOT_READY / FR_NO_FILESYSTEM
  F syslog_post 满态留痕节流(A 修好后「队列常驻满」成必然场景,不修等于用
    新刷屏换掉静默丢);丢弃语义一字未改

NET(net_socket.c/.h、net_types.h、net_socket_sim.c、lftpd.c)
  IP 字节序统一到 POSIX 网络序:改 net_inet_addr(&s_addr[0]==第一段)+ 出向
    IP 数组按内存字节顺位拆装(进与出同时换族→芯片侧数组净效果不变,550h 钉住)
    + 模拟器逐字同步;ntoa 函数体未动,只改对它注释里取了废弃族值的例子——
    那正是两套约定长期共存不被发现的原因。lftpd 两处算术核对确认无需改
  UDP 绕过 netTask:两入口加执行任务上下文守卫,新码 NET_ERR_NOT_IN_NETTASK
    (-12,重试永远无意义);结论口径=危险面已结构性关掉、串行化改造仍未做。
    附带关掉第二条更隐蔽的路:非 netTask 自旋 recvfrom 期间控制块被 close 并
    把同一 sockfd 分给新会话→旧自旋者消费新会话数据报

控制台(console_log.c/.h):冷却改独立 s_con_cooling 标志(旧写法在 tick==0 那
  拍冷却等于没记);con_append 的判空位+推进 head+整行拷贝收进同一临界区(旧
  为区外 check-then-act,环 >95% 满时可静默覆盖且既不打 [ERR] 也不计 dropped);
  Instance==NULL 与僵尸通道分诊(未初始化绝不 reinit);三本账分离 + 新读口
  dbg_console_tx_timeout();[ERR] 行改为发成功才销账

测试:net_socket 452、console_log 127、sys_log_mcu 104、soe_store 27、
cfg_file_store_mcu 34;回归 net_listener 34 / net_events 34 / net_pending 43 /
net_select 22 / lftpd 35 / lftpd_inet 28 / lftpd_io 33 / iec104_serv 28 /
nand_port 149 / vol_lock 33 / soe 32 / cfg_file 151 全绿。原「按现状钉住」的
用例全部翻正向;console_log 用「逐条退回旧实现」做变异自证,失败集合单调增长
6→11→18→22(把一条本来空转的断言逼了出来)。
游标抬升:TC-NETSOCK-545→560、TC-SYSLOG-100→107、TC-CONLOG-135→167。

文档:控制台设计说明 §4.1 口径与 §13-O2 销项 + v1.5;运行与自检记录 §4.4/§4.5
四条;存储架构 §10.1 第 10 条推论(指向 s_is_selfheal_code);TRAP-NET-21 后续
条目;BSD 指南新增 §9.0 s_addr 唯一约定;覆盖率口径 §9「第 1 轮修复处置」表 +
后续项 5 条 + 台架复验 7 条 + 新坑 8 条 + 变更记录。

后续项(本轮刻意不做,理由见 §9):UDP 改走 netMsgQueue(需 per-task 结果槽,
net_msg_t 按值拷贝、禁止加回传字段,且要真机回归);sim_syslog.c 同源不可达
支(本轮编译验证不到,不交没编译过的改动);syslog_post 丢弃计数;
dbg_console_tx_timeout() 读端接线(STALL 行是被解析的证据行,加字段要连解析端
与台架判据一起改);104 来源地址日志(现网无输出点)。

gate: @build 0 errors 0 warnings(Code=213720 RO=18860 RW=1204 ZI=131500)。
pre-commit run --all-files -> 39 hooks, 36 Passed; failed = mem-numbers +
mem-budget(受管快照与本树首次构建产物的漂移,非本批增量)+ id-check(已修,
现 0 台账外新账,可判定 84.63% > 基线 82.6%)。实测水位 RW_IRAM1 57.4%、
RW_CCM 87.9%,均低于 92% 红线;RAM/Flash 本批增量为数个 static 变量。
test-coverage / cppcheck / coding-standard / bsp-err / layer-deps / doc-links
全 Passed;全量覆盖率 33/33 套件、line 89.3%、func 92.3%、逐文件下降 0。
按仓内分支约定 --no-verify 提交,台账 --write 回填留待主工作树合并后。
2026-10-24 19:43:56 +08:00
wandering1 794906cdc8 test(host): P2 覆盖率批次,5 套主机单测与共享假 FatFs
P2 五个文件全部进分母(三名队友并行,写范围互斥、零越界):
  net_socket 420 条 / 881-883=99.8%、52-52 函数(S1~S6 分片)
  sys_log_mcu 75 条 / 310-313=99.0%、12-12
  console_log 104 条 / 188-189=99.5%、15-15
  cfg_file_store_mcu 34 条 / 97-97=100%、10-10
  soe_store 26 条 / 78-79=98.7%、4-4
合计 659 条断言,P2 本体 1554-1561=99.6%、93-93 函数。

账:套件 28→33 全绿;files_tested 32→37;未触及 112→107(.c 34→29,
恒等式 29 = 0(P1剩) + 0(P2) + 29(P3) 咬合,§9 终点判据达成);分母
4684→6245 插桩行;line 85.8%→89.3%;func 90.1%→92.2%(这批 5 个文件
全都以 ≥98.7% 进分母,故未摊薄)。--update-baseline 前后逐文件比对:
下降 0 个文件。

基建:新建 test/host/common/fatfs_fake.{h,c}(以真实 ff.h 为声明源、只
定义三文件实际调用的 9 个 f_*,缺哪个链接期响;卷忙/短写/FR_DENIED
可注入;短名违规分档);扩展 ch395f_fake 为纯新增(g_fx/g_fxchip 另开
一份世界 + ext_reset 挂进既有复位,既有三套面一字不动),并按真头修正
两个错值常量(SOCKET_OPEN 0x01→0x05、TCP_ESTABLISHED 0x05→0x04),
另在 net_socket runner 加真头/假件同名常量文本核对闸(负例验证过)。
存量回归:net_listener 34、net_events 34、net_pending 43、net_select 22、
nand_port 149、lftpd_io 33、vol_lock 33、soe 32、cfg_file 151 全绿。

编号:补 TC-HOST-{NETSOCK,SYSLOG,CFGFST,SOESTORE,CONLOG} 五族(22→27,
先补族再引号);net_socket 模块码由自取的两字母 NS 正名为 NETSOCK;
id-check 增量拦截无台账外新账,可判定比例 83.27%(基线 82.6%)。

文档:覆盖率口径 §9 新增「P2 落地」(含两套分母口径差、10 条现状表、
12 条可复用手法与并发红线),§8 第 1 条改「清单已吃穿」,补变更记录。
本提交未改任何生产代码(App/Drivers/Src/Lib/MDK-ARM 零触碰)。

10 条现状已逐条登记在 §9(含 sys_log_mcu.c:509~537 卷忙整批丢日志、
net_socket.c 同文件两套 IP 字节序、UDP 两 API 绕过 netTask),已获指示
全部修复,另开一轮(含测试字面量统一纯 ASCII)。

gate: pre-commit run --all-files → 39 hooks, 38 Passed; only mem-numbers
Failed(本工作树从未 @build,无 .map 事实源,属分支期已知陷阱);
mem-budget Passed;test-coverage 与 id-check Passed。RAM/Flash 增量 = 0
(纯测试批次)。按仓内分支约定走 --no-verify,台账回填留待主树合并后。
2026-10-24 17:54:02 +08:00
wandering1 4ac869058d ci(gates): 自触发已生效;纠正我自己误读提交输出得到的两条假结论
先说结论:**触发面含门禁自身是生效的**。只改 .pre-commit-config.yaml 的两个提交
(e327636、c4ca292)里 test-coverage 都实跑并 Passed(约 45 s、28 套),bsp-err 同样
实跑。上一提交写下的"改 files: 那次由旧配置管辖"是**我误读输出得到的假结论**,
已按事实改写;那条猜测当时被当成"实测语义"写进配置注释,正是本仓反复防的问题,
所以这一批先降级为"待证"、再判定、再落事实。

误读的机制(值得留档,因为它会让人系统性看错门禁状态):钩子没声明 stages: 就
默认属于**所有阶段**,于是同一次提交的输出里每根钩子出现两次——pre-commit 阶段
那一遍才可能实跑;commit-msg 阶段那一遍拿到的"文件"是提交信息文件,跟这些钩子的
files: 都不匹配,几乎必然 (no files to check)Skipped。我前几次都只 tail 了输出结尾,
看到的就是第二遍,于是连着误判了两次(第一次判成"自触发没生效",第二次为了自洽
又编出"旧配置管辖"这条规则)。同一批里 bsp-err 的表现也是同一成因。

判据写成一句话:**看某根钩子跑没跑,认它第一次出现的那行**,别读尾巴。已同时补到
pre-commit使用说明 §2.2 的通用机制表里(新增一行),配置注释里也留了实跑证据的提交号。

自查:本提交只改配置与一份机制文档(无代码改动,未跑 @build);提交自身会触发
test-coverage 实跑一遍作为回归;doc-links 与 id-check 见下条。未 push。
2026-10-24 08:18:45 +08:00
wandering1 c4ca292102 ci(gates): 把"改触发面由旧配置管辖"降级为待证,并用这次只改配置的提交判定
上一条提交我把一个**猜测写成了实测语义**——这正是本仓一直在防的事(门禁文档骗人
比没有文档更糟),所以先降级为"待证",再用这次提交判定。判定依据是一根现成的对照
钩子:bsp-err 的 files: 从很早就含 ^\.pre-commit-config\.yaml$,它跑不跑直接反映
"本次提交的文件列表里有没有这个配置文件"。

预期读法(结果回来后写进上一条注释,或删掉待证标记):
  - bsp-err 与 test-coverage 都实跑 → 配置文件确实进了列表,那么 2d7c4fd 里两个都
    Skipped 只能用"新加的模式当次不生效"解释,猜测方向成立;
  - bsp-err 跑、test-coverage 不跑 → 说明 test-coverage 的自触发没生效,得查正则;
  - 两个都 Skipped → 说明"改配置那次不看配置文件本身",猜测成立但要改写措辞。

另外两次我误当证据的运行其实是 nothing to commit(暂存集为空 → 所有钩子 Skipped),
不能作为任何结论的依据——这也是我这轮反复的地方,记在这里免得下次重踩。
2026-10-24 08:05:23 +08:00
wandering1 e3276368b2 ci(gates): 记下 test-coverage 自触发的实测语义(改触发面由旧配置管辖)
接线时遇到一个说不通的现象:把自触发写进 files: 之后,那次提交里 test-coverage 仍然
Skipped;而我用 --files 与 staged 两种方式手工跑都会实跑。今天补做对照才定位:
同一批输出里 bsp-err 也 Skipped,而它的 files: 从很早就含 ^\.pre-commit-config\.yaml$。
两次现象一致指向同一条规则——**修改钩子配置的那次提交,按"修改前"的配置求值**。

这条对门禁的自洽性是好事:
- 想**拆掉**自触发(把 files: 里的本文件删掉):这次提交仍受旧模式管辖,钩子照跑,拆不掉;
- 想**加**触发面:要等下一次提交才生效,也就是加固会迟到一步、拆除不会漏网。

顺带说明:上一轮我"还原诊断探针"时命令顺序写错(先 git reset 再 git checkout --,
后者又从暂存区把那版恢复回来),弄出一条 # probe 残留在工作区;把它删掉后文件与
HEAD 完全一致,所以刚才那次 git commit 其实是 nothing to commit(不是被钩子拦的)。
现工作区已核实与 HEAD 一致、无残留。
2026-10-24 08:03:03 +08:00
wandering1 2d7c4fd40b ci(gates): test-coverage 的触发面补上门禁自身,否则改坏 files: 就没人跑这根闸
上一提交落地后立刻暴露这个口子:那次提交里新钩子显示 Skipped (no files to check),
因为它自己的 files: 不含 .pre-commit-config.yaml —— 也就是说,任何人改坏这一行
正则(哪怕只是打错一个字符),这根闸就永久静默停用,而提交照样全绿。这正是
本仓反对的"会静默失效的门禁",且已有先例处置:lv-conf-sync 与 bsp-err 都把
"门禁自身"写进触发面。

验证方式(不是只测绿的一半):
- 改前:pre-commit run test-coverage --files .pre-commit-config.yaml → Skipped
- 改后:同一命令 → 实跑并 Passed(约 45 s,28 套)
代价:以后改门禁配置要多等 45 秒——这种提交本来就是低频操作,值。

顺带修两处上一批留下的表述:
- 口径 §8 的 YAML 块补同一个 files: 模式(保持与落库那份一致)。
- 该节抬头那句我上次写坏了:JS 里把续行写成独立表达式,导致"…README.md 里只挂
  一行索引指向本文"这半句被丢掉,句子断在文档名上。已补全。
- 门禁说明 host-unit-tests.md 的触发面描述同步为"含门禁自身",并写明理由。

自查:doc-links --all --sections-strict 通过;改动文件 U+FFFD = 0、无 CRLF;
零代码改动故未跑 @build。未 push。
2026-10-24 07:53:40 +08:00
wandering1 744a1cb086 ci(gates): 接线 test-coverage 覆盖率总闸(FAIL 语义),并修三处已成假话的门禁文档
用户裁定 2026-10-24:缺 mscoverage 组件时**失败**,不做 skip。于是 P1 之后那个
"钩子只挂 16/28 套"的缺口关闭了。

为什么挂这一根而不是补 12 根 per-module 钩子:本闸跑**全部 28 套**,并且守
"已覆盖行只增不减"的基线台账。这是 per-module 钩子结构性做不到的——删掉一套
测试,它那根钩子只会因为"没有文件要检查"而静默跳过(Skipped 不等于失败)。
实测接线代价:45 s(28 套,含 28 次 MSVC 编译与插桩运行)。

FAIL 语义已按本仓"合成自证"的纪律验证(不是只测绿的那一半):把
DTU_MS_COVERAGE 指到一个不存在的文件 → 钩子 Failed、脚本退出码 2、并打印可
执行的 [ENV] 提示(说明该 exe 装在哪、怎么覆盖)。脚本对显式环境变量刻意
"不做自动探测回退",所以指错会被点名,而不是悄悄换一份工具跑出另一本账。

顺手修掉三条**现在已经不成立**的文档说法(门禁文档最贵的就是骗人):
- 口径 §7 第 8 条:原写"只有 BuildTools 且没勾代码覆盖率工具的机器会退出码 2"。
  实测本机 Community 18.10 与 BuildTools 18.9 **两份安装都自带**该 exe(mtime
  2026-09-15 02:30 / 2026-09-05 16:17,都是各自安装当日,不是后来补勾)→ VS 18
  的 BuildTools 默认就带它。真实风险面只剩:裸 MSVC 无 VS 的机器、环境变量指错、
  以及靠 PATH 兜底的环境。
- 门禁说明 host-unit-tests.md 盲区第 4 条:原写"没有对应钩子的模块 = 零回归"——
  如今 12 套新模块由总闸兜住,这句话已经不成立,改成两种口径并写清触发面。
- 同篇第 6 条:原写"无覆盖率统计"。行/函数覆盖**已有**度量(本闸出 Cobertura 并
  守基线),仍无的只是分支覆盖——那是用户裁定 2026-10-23 记下的**已接受缺口**,
  不是待办,改口时把这个区分写清楚。

钩子计数同步(新增 1 根 → 声明 40 根 / pre-commit 阶段 39 根 + commit-msg 的
gitlint,已用脚本核对,不是手数):AGENTS.md(总数、枚举、索引表)、
pre-commit使用说明.md(66 / 91 / 234 三处,含清点日期)、门禁说明 README
(正文与索引标题,另加一行索引指向口径文档——23 篇不变,同一事实不在两处维护)、
门禁说明 gitlint.md(--no-verify 跳过的"其它 38 个"→39)、并行任务工作树方案。

自查:end-of-file-fixer 与 trailing-whitespace 复跑 Passed(首轮 --all-files 只
因末行空行红过一次,已按钩子自身规范化);doc-links --all --sections-strict 通过
(178 个文档);id-check --strict 零台账外新账;8 个改动文件 U+FFFD = 0、无 CRLF。
零代码改动,故未跑 @build。未 push。
2026-10-24 07:50:53 +08:00
wandering1 2c3939494d docs(测试): 更正覆盖率口径 §8 的接线理由——现在缺口换了方向
P1 合入后本仓主机单测从 16 套涨到 28 套,而 pre-commit 只挂 16 个 -unit-test
钩子。§8 理由 1 写的是"再加一份全量插桩等于同一件事做两遍"——这句话在只有
16 套时成立,现在**反了**:P1 新增的 12 套(net_cfg / ftp_cfg / prot_set_store /
task_prof / lftpd_io / net_select / net_listener / net_pending / net_events /
lftpd_inet / lftpd / iec104_serv)目前只被 ci_local 的 L0.5(自动发现)与
check_test_coverage.py 跑到,**不在提交钩子里**。

仍然不擅自接线,理由只剩 §8 第 2 条那一个,而且它是要用户裁定的:挂之前必须
先定"缺 VS 可选组件(mscoverage)时 skip 还是 FAIL"——选 skip 就是造一根会
静默失效的门禁(与 AGENTS 里"pre-commit 4.x 移除了 always: true、必须靠 files:
覆盖"是同一类坑);选 FAIL 则任何没装该组件的机器都提交不了。与其悄悄挂一根
可能假绿的钩子,不如把缺口写在文档里。

同时更新 §8 下一步第 1 条的过期数字:P1 已吃完(files_tested 19 → 32),
下一步是 P2 的 5 个文件 / 2,226 行;"把已有 19 个文件从 88% 推到 95%"改成按
当前实测口径表述。核对:该文档 U+FFFD=0、无 CRLF,doc-links 钩子 Passed。
2026-10-24 00:11:20 +08:00
wandering1 ec2dbc9e01 chore(merge): 回填内存台账与钩子规范化,收口 task/cov-host-line-coverage
done cov 已把 15 个提交合入主干(合并提交 c8de948),但它的门禁按已知顺序跑在
@build 之前(skill 实测陷阱那条),比对用的是合并前的旧 .map,台账必然失配。
这里按 skill 的官方处置在主工作树回填,使主干自身全绿。

内存台账(@build 已在 done 里跑过:0 错 0 警,Code=213652 RO=18860 RW=1192
ZI=131504;主工作树的 .map 是权威值):
- scripts/ram_baseline.py --update(趋势行 2026-10-24:RW_IRAM1 75,272 B 57.4% /
  RW_CCM 57,376 B 87.9%)+ --write(受管区);check_mem_numbers.py --write。
- RAM使用分析与优化方案.md 两处正文数字:「当前状态」那句刷成合并后的真值
  (75,264 → 75,272,日期改 2026-10-24);2026-10-23 那条变更行里的裸 B 值
  232,536 改为 KB + 快照文件指针——**历史结论一字未改**,只是不再在正文里留
  会过期的裸字节值(--scan 的判据正是"曾是区域值、现已不是",无内联豁免)。
- 补一行本次合并的变更:RAM +8 B(net_cfg.c 为 WAIT 契约修复新增 s_save_busy)、
  Flash -24 B(lftpd_string 改名删死循环 + net_select 去冗余守卫)、CCM 零增量。

覆盖率口径文档:修我自己留下的占位符泄漏——§9 有一段写的时候用 @@ 当反引号哨兵
(run_code 里反引号与模板串冲突),漏做还原,共 52 处。后果不只是难看:
- `docs/网络/网络自愈架构设计.md` §4.10 因失去反引号而不再被识别为"紧邻写法",
  被 doc-links(钩子带 --sections-strict)按**自引用**判成失效章节——这条真红了。
- "test/host/common/ch395f_fake.h + .c" 被整体当路径引用检查,报文件不存在。
- 另有一处未配对的 @@ 与一个 ``` 残留,以及过期计数("套件 21+"、"族数 16"),
  改为落地实测值:TC-HOST 族 16 → 22、套件 16 → 28,并指向下方落地进度表。
  核对:该文档 @@ 残留 0、U+FFFD 0、无 CRLF。

12 个 run_unit_test.py:end-of-file-fixer 去掉末尾多余空行。分支期我用 --no-verify
提交(只为跳过 .map 台账那两条已知误报),把这两个改写型钩子的规范化机会一起
跳掉了——本批由钩子自己改正,**内容零改动**,也正是"提交门禁不该长期绕过"的账单。

自查:pre-commit run --all-files 在本工作树 **38 Passed / 0 Failed**(含 16 个主机
单测钩子实跑);doc-links --all --sections-strict 0 问题;mem-numbers --scan 绿。
未 push(推送需用户明确指示)。
2026-10-24 00:07:05 +08:00
wandering1 c8de948089 merge: task/cov-host-line-coverage 合入 v2-on-ui
任务 cov(15 个提交)经 task-worktree 回收:
- test(APP/104): iec104_serv 模块(28 条,84.2%)——P1 十三块全部进分母
- test(NET/lftpd): lftpd 控制通道模块(35 条),FTP 一套四块全部进分母
- test(NET/lftpd): lftpd_inet 模块(28 条,127/127 = 100%)
- test(NET): net_events 模块(34 条,覆盖 97.8%),NET 三块收官
- test(NET): net_pending 模块(43 条,覆盖 87.1%),钉住通知值回传契约
- test(NET): 假 CH395F 芯片落地 + net_listener 模块(34 条,覆盖 99.2%)
- docs(覆盖率): §9 新增"假 CH395F 芯片"实施规格,并更正一项被我高估的前置成本
- docs(任务规划): 删除查无正文的 E-25 交叉引用,net_select 处置改用平实语言留档
- fix(NET,lftpd): 处置覆盖率照出的三处死路径(改名 + 修 timeout=0 + 激活异常集)
- test(NET): net_select 模块(21 条)与 NET 域首份 ch395f 桩,登记两处死路径
- test(NET/lftpd): P1 增补 lftpd_io 模块(33 条),登记 FTP 域一处潜伏缺陷
- test(NETCFG,CFG): P1①四个主机单测模块,并修 net_cfg 的 WAIT 契约与定长错记
- docs(覆盖率): 新增 §9 未触及面建桩优先级清单(P1/P2/P3 逐个画像)
- feat(门禁): coding-standard 加 CODE-RULE-20 禁 C 库堆分配(销 §4.8 空白 ②)
- feat(门禁): 主机单测覆盖率脚本与口径落地(销报告 §4.8 空白 ①)

备注:W5 验证常态:主机单测覆盖率脚本与口径(补 项目审查与优化建议报告 4.8 空白一)
2026-10-23 23:46:57 +08:00
wandering1 957ec64645 test(APP/104): iec104_serv 模块(28 条,84.2%)——P1 十三块全部进分母
P1 收官批次。新增 test/host/iec104_serv/,iec104_serv.c 覆盖 117/139 = 84.2%。
做法:把生产源整个 #include 进 TU,直接驱动它的 7 个 static 辅助函数——**不跑
iec104_serv_task 的无限主循环**(那是 never-returning 的任务体,不适合单测;事件组
相关支路用 sys_evtHandle=NULL 走"不可用"分支)。链路层与 pts 层给假件:链路协议本身
有 test/host/iec104 的 97 条专测,本模块只管服务怎么活、怎么死、怎么重生。

钉住的契约:
- 建监听:2404 端口过 htons、绑 NET_INADDR_ANY;socket/bind/listen 任一失败都要把
  已经拿到的号关掉(漏关就是每次重试漏一个 socket 号,CH395F 只有 8 个)。
- **网络代次一变就拆掉旧连接与旧监听并重建**:现场改 IP 后端口必须在新地址上继续
  服务,这条不重建就是"设备换了 IP 之后 104 主站再也连不上"。
- accept 门控:监听未就绪不 accept;表项已失效时把监听号丢掉、下一轮重建。
- 单连接 socket 的断线语义:对端 FIN 时只关数据口,**监听必须留着**;数据号与监听
  号同一个时绝不关(关了就端口失联)。recv 返 -1 且 errno 是 WOULDBLOCK 是正常空转,
  不能当成错误丢连接;真错误则丢弃并**当场重建监听**,不留空窗。
- 判死用**连续**短写计数:一次成功即复位。写成累计就会把偶发拥塞误判成断线,主站
  看到的就是莫名掉线(连到 3 次才判死,I104_SEND_FAIL_MAX)。
- 32 分频探活:无可读数据时不是每轮都去收(省 CPU 预算),到点才收一次;对端 FIN 后
  复位计数器。协议状态掉回 IDLE 也要丢并重生(否则服务挂着但永不服务)。
- 时钟桥接两个溢出边界:epoch 秒超过 32 位一律拒绝且不碰时钟(截断写进 RTC 就是乱值);
  未对时(sys_clock_get 返回 UINT32_MAX)回 UINT64_MAX 哨兵而不是 0(否则对端会以为
  设备时间是 1970-01-01)。

主机侧兼容处理(写进桩注释):真实 App/util/sys_clock.h 会 include sd2506.h(RTC 芯片
驱动)一路拉到 HAL,PC 上编不动,故桩掉 sys_clock.h 但**两个签名与真实头逐字一致**;
104 的头散在 App 与 Drivers/BSP 的多个子目录(iec104_serv.h/iec104_link.h/iec104_pts.h/
task_watchdog.h/sys_events.h/prot_set.h/sd2506.h…),逐个手写 include 目录必漏且报错会
落在被测源上、极易误读成生产代码坏了——运行器改为**按目录实际是否含 .h 自动收集**。

自查:初跑红 2 条,都是我的场景问题(分频到点那轮 recv 返回 0 会走对端 FIN 支路,
应给 -1 + WOULDBLOCK;"重建失败丢监听号"必须同时让表项失效),改正后 28/28。

P1 收官合计:12 个新模块、**308 条断言**;套件 16 → **28** 全绿;files_tested 19 → **32**;
未触及 .c 47 → **34**;恒等式 34 = 0(P1 剩) + 5(P2) + 29(P3);分母 2,893 → **4,684** 行;
line=4020/4684(85.8%)、func=300/333(90.1%)。百分比"降"是摊薄不是回退(lftpd.c 64.3%、
lftpd_io.c 53.5% 这类只测主体的文件也进了分母),按 §5 口径该盯 files_tested/files_untested。
亮点:lftpd_inet.c **127/127 = 100%**、net_listener.c 99.2%、net_events.c 97.8%。
编号补建 TC-HOST-I104SRV(游标 041),TC-HOST 族数 20 → 22。§9 的落地进度表补齐并
统一为 NET 拆出的四块 ①②③④。

自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量);
10 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。
--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)
与 ui-golden(ref/*.bin 不入库)三条失败,与本分支改动无关。
2026-10-23 23:44:53 +08:00
wandering1 a0c261b5ee test(NET/lftpd): lftpd 控制通道模块(35 条),FTP 一套四块全部进分母
新增 test/host/lftpd/,lftpd.c 覆盖 304/473 = 64.3%。做法是把生产源整个 #include 进
TU,于是命令表 commands[]、17 个 static cmd_* 与 handle_control_channel 都能直接驱动:
假 inet 层给"脚本化命令行队列 + 响应记录",假 io 层给"极简内存视图 + 可注入失败",
认证与配置当可注入黑盒(认证本身有 test/host/ftp_auth 的 10 条专测,不重复验)。

钉住的契约(都是真机上贵、却难按需复现的那几类):
- STOR 传到一半写失败必须 **unlink 半成品**:否则 Flash 上永久留个坏文件,现场表现
  是"升级包传坏了再也传不上去"。真机要卡准"传一半掐线"的时机,这里注一个短写就行。
- 数据通道是单连接 socket:LIST/RETR/STOR 用完必须关监听并把 data_socket 复位成 -1;
  accept 失败要报 425 不留残口,会话退出(QUIT 或对端断线)时 cleanup 也要关掉还没
  用的被动监听。漏一次就是下一条命令拿着上一个号继续 accept,偶发连不上。
- 命令名缓冲只有 command_tmp[4+1],前置判断 if (index >= 5) 就是防 memcpy 越界;
  恰好 5 个字符必须被拒(改一行这条就响)。
- 认证前置白名单:未登录只放行 USER/PASS/QUIT/FEAT/SYST/NOOP,其余一律 530;口令错
  必须清 auth_user_ok 强制重走 USER;被锁与口令错都只回 530,不给攻击者区分提示;
  配置读不到时 USER 无法匹配、PASS 直接 530(fail-closed)。
- 写开关([ftp] write_en=0)必须在**碰文件之前**挡住 STOR/DELE,且不去 accept 数据
  通道;DELE 不许删目录。
- 227/229 编码:IP 经 net_htonl 后按高字节到低字节排(就是点分序),端口高/低两字节;
  拿不到本机 IP 时要把刚建的监听关掉。FEAT 多行:首行 211-、四条能力无状态码、末行
  211 结束(共 6 条响应)。
- 220 欢迎没送达(对端已走)就直接收工,不空转读命令;该留痕按真实分级算 INFO 而不是
  ERROR(真实 private/lftpd_log.h 把 lftpd_log_peer 归到 INFO:任何人连 21 端口就走
  就能无限制造固件级 ERROR,还会撞掉 @test 的 Forbidden 标记 [ERR])。
- 传输循环每轮喂狗(lftpd_task_wd_beat 计数可断言)。

主机侧两个必要的兼容处理(都写在桩注释里,不是随手改):
1. lftpd.c 会 include ff.h,而这份 FatFs 在 Windows 支路上把 winsock.h 拉进来,它带
   "#define s_addr S_un.S_addr" 的 BSD 兼容宏,会把真实 net_types.h 里的字段声明
   uint32_t s_addr; 展开成 uint32_t S_un.S_addr; 直接炸(实测 C2143 在 "." 前缺 ";")。
   桩里只 #undef 这个污染宏,让固件那份结构体定义原样参与编译,被测代码读的还是真字段。
2. 引号 include 先从 lftpd.c 自己目录找 private/lftpd_log.h,桩目录赢不过它——于是
   顺着用真实那份(它只是把 lftpd_log_* 映射成 DBG_*),只桩最底下的 dbg_log.h。这样
   真实日志分级也一起被编译验证,比我自己重写一套更对。

自查(35 条里前两轮红了 4 条,全是**我的期望/前置态写错**,实现均正确):未登录时
FOO 被白名单以 530 拦掉测不到 502 分派;227 的 IP 顺序我把 htonl 前后搞反;FEAT 是
6 条响应我按 5 条索引;PASV 之后走完整会话会被 cleanup 关掉监听(那正是 006 要验的
行为),观察 PASV 留下的状态必须直调 cmd_pasv。

覆盖率与台账:套件 26 → 27 全绿;files_tested 30 → 31;未触及 .c 36 → 35;分母
4,072 → 4,545 行。**总体 line_rate 88.4% → 85.9% 是摊薄不是回退**(64.3% 的文件进
了分母),按 §5 口径要盯 files_tested/files_untested——它从 19 一路涨到 31。
恒等式 35 = 1(P1 剩 iec104_serv.c) + 5(P2) + 29(P3)。编号补建 TC-HOST-LFTPD
(游标 034),族数 20 → 21。

自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量);
9 个文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。--all-files 仍只有
mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)与 ui-golden
(ref/*.bin 不入库)三条失败,与本分支改动无关。
2026-10-23 23:44:52 +08:00
wandering1 8aaf9bbb0e test(NET/lftpd): lftpd_inet 模块(28 条,127/127 = 100%)
FTP 的 socket 适配层吃满——全仓第一个 **100% 行覆盖**的文件(127/127)。
新增 test/host/lftpd_inet/,28 条断言全部 MSVC 编译通过并 PASS。

钉住的契约:
- 端口字节序:bind 前必须过 net_htons(sin_port 的注释就写着"网络字节序"),问名字
  回来要 ntohs 还原;问不到名字报 -1,不拿脏端口当结果。
- 失败路径一律关 socket:建口失败不 bind;bind 失败关口不 listen;listen 失败关口
  并各留一条 ERROR(漏关就是白占一个号,CH395F 只有 8 个)。
- accept 每轮失败都让出 CPU(同一段里也喂狗),直到成功;**网络代次一变立刻撤掉
  accept**——否则改了 IP 之后监听还挂在旧代次上,端口在新地址上根本不通。这是现场
  最常见的自愈路径,台架上要凑"对面正连着我顺手改 IP"极难。
- 带超时的 accept:监听门撤防时**立即放弃**,不对着死监听耗满窗口;对端一直不开
  数据通道就按 100ms 步进等满再 INFO 收场。
- read_line:CRLF 就地截成结束符(FTP 命令行的边界);跨包分片要能拼出整行;读到 0
  (对端关闭)立刻失败;-2 表示这一片超时继续等;其它负值**原样返回**(错误码不许被
  吞成 -1);等待按 200ms 分片、30 s 总预算到点退出(分片写错就饿死看门狗);缓冲
  填满仍无行尾要失败且**不越界写**(最后一格留给结束符)。
- 短写续传:net_send 可以只写一部分,续传必须从正确偏移接着写(少半条响应就是协议
  错乱);写 0 字节按失败处理;二进制写成功时回请求长度。

桩的口径:private/lftpd_inet.h 与 net_types.h 用真实头;net_socket.h 是桩但函数原型
与真实头逐字一致,并**照做真实头对 FreeRTOS.h / task.h 的 include**(lftpd_inet.c 的
TickType_t / vTaskDelay / pdMS_TO_TICKS 就是从那条链上拿的,桩少这一行就是 C2065)。
lftpd_task_wd_beat 是 __weak 空实现、主机侧替换不掉,因此用同段代码里的"睡 100ms"
次数当喂狗轮数的代理(写进桩注释,不假装能直接数到喂狗)。accept 循环带 100 轮跑飞
保险(net_select 批次被挂死教训换来的)。

覆盖率与台账:套件 25 → 26 全绿;files_tested 29 → 30;未触及 .c 37 → 36;分母
3,945 → 4,072 行;line=3599/4072(**88.4%**,连涨三批)、func=272/299(91.0%)。
恒等式 36 = 2(P1 剩:lftpd.c 与 iec104_serv.c) + 5(P2) + 29(P3) 仍咬合。编号补建
TC-HOST-LFTINET(游标 070),族数 19 → 20。

自查:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量);
9 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。
--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)
与 ui-golden(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。
2026-10-23 23:44:52 +08:00
wandering1 59e6d77224 test(NET): net_events 模块(34 条,覆盖 97.8%),NET 三块收官
假 CH395F 的第三批、也是 NET 域最后一块。net_events.c 覆盖 175/179 = 97.8%。共享
假芯片本批补齐**对端六元组**(get_remot_ipp 的 6 字节)与**链路变更计数**(
link_change_count / link_up_cached 改为可预置,原来硬编码 0/1)。

钉住的契约(都是真机上会发生、却不能按需复现的那类):
- 对端六元组解析:remote_ip 是**低字节在前**拼的(192.168.1.50 -> 0x3201A8C0,与
  网络序相反)。钉住它是防止有人"顺手改成大端",让所有按 IP 比对/白名单静默错位。
- 陈旧中断防误判:芯片说 TCP 还 ESTABLISHED 时,迟到的 DISCONNECT 一律忽略并报
  ERROR(拆活会话的代价是 104 主站看到莫名掉线);一条数据都没收就报断且芯片接收区
  还有字节,判为疑似误报、只置 lost_conn_seen 延后交给对账器。
- 正常断开:优雅级沉降 + session_dead + last_peer_port 记旧端口 + 转 CLOSED + 发事件。
- 超时:ESTABLISHED 走隔离级沉降并置 released_abnormal —— 这正是 net_pending 收尾时
  把挂起 recv 报成 CONNRESET 而不是 0 的那条输入(TC-NETPEND-014),**跨文件因果**
  只有两边都能离线跑才可测。
- 丢中断自愈:芯片已连上而软件还写着 LISTENING 时补一次 accept;若软件已 CLOSED 则
  只留痕不凭空造会话(TC-NETEV-042b 记现状)。
- 对账器:100ms 周期闸门(同一时刻不重复查芯片);链路计数**基线那趟不打日志**
  (0xFFFF 哨兵,否则每次上电都冒一条假"link up")、变化即留痕;五种不一致形态的
  稳定计数分三档(会话死亡要连观 4 次、芯片偷重开监听 2 次、丢 CONNECT 自接 1 次);
  动作后 1000ms 冷却内不重复折腾;U11 重同步(软件 CLOSED 而芯片在 LISTEN -> 直接跟
  芯片,不做多余重建);不在用的槽位直接跳过。

三条初判失败的复盘(都是**我的期望值/场景写错**,实现是对的):① remote_ip 字节序
我按网络序写了期望;② 自愈场景我用了 CLOSED 态,而 accept 分支只认 LISTENING/
CONNECTING,改场景并补一条现状断言;③ 链路"基线不打日志"我测在非基线场景(s_
link_last 是函数内 static,跨场景本就延续)。对账器的 s_last/s_bad[]/s_cool[] 都是
函数内 static(外部够不着),因此每个形态**各占一个 fd**、时基单调前进,互不串扰。

覆盖率与台账:套件 24 → 25 全绿;files_tested 28 → 29;未触及 .c 38 → 37;分母
3,766 → 3,945 行;line=3472/3945(**88.0%**,连涨两批)、func=263/290(90.7%)。
恒等式 37 = 3(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-NETEV(游标 058),
族数 18 → 19。

自查:test-cppcheck Passed(含补齐后的假芯片);check_ids --strict 通过;doc-links
35 处(= 存量);16 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑
@build。--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后
统一 --write 回填)与 ui-golden(simulator/tests/ref/*.bin 不入库,新 worktree 没有
黄金参考)三条失败,与本分支改动无关;提交按并行分支约定走 --no-verify。
2026-10-23 23:44:51 +08:00
wandering1 b4441f0e1e test(NET): net_pending 模块(43 条,覆盖 87.1%),钉住通知值回传契约
假 CH395F 的第二批消费者。新增 test/host/net_pending/,net_pending.c 覆盖
169/194 = 87.1%。共享假芯片补了**缓冲尺寸宏链的同构转抄**(RAM_BLOCK 512 /
SEND_BLOCKS 2 → SEND_BUF 1024;RECV_BLOCKS 4 → RECV_BUF 2048 → SPI_DMA_BUF 2052
→ SPI_DMA_MAX_PAYLOAD 2048),于是 net_pending.h 里的 NET_SEND_CHUNK_MAX 是**真值
1024**,分块断言测的是芯片上界而不是假件自己发明的数字。

本模块的核心契约是跨上下文回传:AGENTS 模块约定写着 net_* 的错误码经**任务通知值**
回传(net_msg_t 按值拷入队列,netTask 改的是自己那份副本,调用方永远看不到;也勿给
net_msg_t 加回传字段,否则调用方读到栈垃圾)。台架读不到通知值,所以把 xTaskNotify
做成可观测假件(记目标句柄与通知值)之后,这条契约才第一次被真正验证。

钉住的契约:
- 注册闸门:越界 fd 报 BADF;同 fd 已有挂起报 BUSY;caller 为空报 BUSY;**属主任务
  自己发起被拒**(自己等自己等于死锁 netTask);to_ms=0 记为无限等(不置 deadline)。
- 到达与截断:读量钳在调用方缓冲内(绝不越界写别人的 buf);readable 但芯片没数据时
  只清标志继续等,不误交 0 字节。
- 收尾四条分支:还有残留先把最后一包读走再收尾(**不丢尾包**);干净结束报 0 并按
  auto_relisten 请求重开监听(不自动重开的不被偷偷要求重建);released_abnormal 报
  CONNRESET 而不是 0(调用方要能分辨好坏收尾);session_dead 与 CLOSED 同路。
- settle_close:没挂起时直接返回不打扰芯片;有残留交还字节数;无残留报 CONNRESET。
- 挂起发送:参数校验(buf 空 / len<=0 / sent<0 / sent>=len 报 INVAL);单次写入钳在
  NET_SEND_CHUNK_MAX;**deadline 每写一轮就续期**(还在推进就不该被 1000ms 掐死,
  FTP 上传大文件最容易撞这条);停滞不前进才报 TIMEDOUT;槽位消失或会话塌掉都**交还
  已发字节数**(不是错误码,调用方据此算进度);cancel 留痕且幂等;**clear 不发通知**
  (与 recv 侧同一条纪律,不给调用方假答复)。

自查:42/43 首跑,唯一红的是**我的测试构造缺陷**——无限等那条没先把槽位置成在用,
被 in_use==0 兜底分支提前收口,测不到时间轴;补 mk_sock 后 43/43。reset_all 从上一
批的教训直接带过来(连被测模块的 static 挂起表与属主句柄一起复位),这次没再红。

覆盖率与台账:套件 23 → 24 全绿;files_tested 27 → 28;未触及 .c 39 → 38;分母
3,572 → 3,766 行;line=3297/3766(87.5%)、func=255/282(90.4%)。恒等式
38 = 4(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-NETPEND(游标 040),
族数 17 → 18;注册表仍只做增量插入。

自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量);
12 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。
--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)
与 ui-golden(ref/*.bin 不入库)三条失败,与本分支改动无关。
2026-10-23 23:44:51 +08:00
wandering1 0a3b932395 test(NET): 假 CH395F 芯片落地 + net_listener 模块(34 条,覆盖 99.2%)
按 §9 规格做假芯片的第一批消费者。新增 test/host/common/ch395f_fake.{h,c}:15 个
函数的**真实原型逐字照抄**(含 ch395f_sock_state_t{open,tcp_state} 与 ERR_SUCCESS/
PROTO_TYPE_TCP/SOCKET_CLOSED/TCP_CLOSED/TCP_LISTEN 的真实取值),脚本化模型沿用本仓
既有假件风格:读函数按调用序号取返回队列(耗尽回落最后一个值)、写函数记次数与
最近入参、外加一条**调用顺序日志**让时序契约可断言。两条芯片怪癖必须复刻:
① get_sock_int_status **整体读即清**、无按位清除手段(AGENTS 模块约定 B3 实证坑,
不复刻就照不出"顺手全量清一遍把别的在用 Socket 门焊死");② *_cached 是**缓存值**
而非现场值,"监听还活着"的判据是 (open != CLOSED && tcp_state == LISTEN)。
各模块 stub/ch395f.h **只做转发** include 共享件(规格里拍板的方案):三份假芯片各自
漂移会让"假件与被测代码同时错"再也报不出来,签名与常量只能有一份定义。

新增 test/host/net_listener/,34 条断言,net_listener.c 覆盖 125/126 = **99.2%**。
钉住的契约(真机上一闪而过、台架凑不齐的组合):
- 四道互相牵制的时间闸门:30ms 退避(20ms 不重试、30ms 重试)、隔离级 400ms 与
  优雅级 100ms(399ms/99ms 不放行、400ms/100ms 放行)、**800ms 上限只留痕且不放弃
  请求**(留痕只打一次,到 2000ms 预算耗尽才报 ERROR 交对账器)。排期登记里那句
  "沉降窗 800ms 上限只留痕"至此第一次有了可执行的验证。
- 该不该重建:芯片仍报 LISTEN 就不重建且清空请求;有挂起 recv/send 时让路但**保留
  请求**(挂起结束自动补做);五种条件静默放弃(非 LISTENING/CLOSED 态、
  auto_relisten=0、非 STREAM、local_port=0、in_use=0)。
- 失败不留半成品:open 失败关回去并回 BUSY;listen 失败也必须 close_socket。
- 重建后簿记:转 LISTENING、清 send_ready/recv_bytes/remote_ip(_arr)/remote_port、旧
  remote_port 记进 last_peer_port、清 lost_conn_seen/released_abnormal;源端口与协议
  取自表项本身(监听端口不能漂)。
- 芯片时序:清接收 → 关断 → 读即清 → 读状态(顺序不可换);清理接收区单次读钳到
  256B、最多 16 轮、读到空即停。越界 fd 全程静默且不打扰芯片;请求去重保留首个 reason。

测试自查修了两处(都不是生产问题但值得记):
① reset_all 只复位假世界、**没复位被测模块自己的 static**(s_relisten_req/_next/_dead/
_quarantine/_settle_*/_capped),一上来红了 9 条;改用模块自带的 net_listener_clear()
清,顺带验证了 clear 的完整性。
② 沉降窗边界必须在**独立场景**测:放行判定与 30ms 退避共用 s_relisten_next,同一条
链里"恰好 400ms"那轮 tick 会被上一轮设的退避槽挡掉。这本身是个该知道的事实——
**hold 到点后最坏还要再等一个退避周期(实测 ≤30ms)才真正放行**,已写进用例注释。
另留痕一处死分支:net_relisten_locked 的 NET_ERR_BADF 从 tick **不可达**(tick 在调用
前就因表项为空放弃),TC-NETLIS-026 记着,新增调用点时要重新审视。

桩的口径:net_listener.h/net_types.h/net_config.h/net_pending.h 用**真实头**(net_sock_t
字段与各枚举一改即编译期报错);net_socket.h 是桩,但 net_set_errno 与 NET_ERR_RETURN_1
照抄真实头同名宏(net_socket.h:528/532);真实 net_pending.h 原型带 TaskHandle_t,故
宿主 FreeRTOS.h 补一个 void* typedef(只出现在签名里,被测代码从不传它)。

覆盖率与台账:套件 22 → 23 全绿;files_tested 26 → 27;未触及 .c 40 → 39;分母
3,446 → 3,572 行;line=3128/3572(87.6%,本批净涨因为新进来的文件几乎全绿)、
func=243/266(91.3%)。恒等式 39 = 5(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建
TC-HOST-NETLIS(游标 026),族数 16 → 17;注册表仍只做增量插入。

自查:test-cppcheck Passed(连假芯片一起过);check_ids --strict 通过;doc-links 35 处
(= 存量);新增文件 BOM/CR/U+FFFD/行尾逐项净。**本增量零生产代码改动**,未跑 @build。
--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)
与 ui-golden(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。
2026-10-23 23:44:50 +08:00
wandering1 fa4cde36f6 docs(覆盖率): §9 新增"假 CH395F 芯片"实施规格,并更正一项被我高估的前置成本
立项假 CH395F:NET 域剩下三块(net_pending 287 / net_events 263 / net_listener 191 =
741 行)都把芯片当真理读,这些语义真机上只在总线上一闪而过、台架不可复现;做完它
连 P2 的 net_socket.c(1,251 行,全仓最大逻辑块)一起解锁。

**一处更正**:我上一轮(提交 8bf4273 的正文与对话汇报)说这三块"还调 net_socket.c 的
内部函数(net_drain_chip_rx / net_relisten_done / net_relisten_locked),要一并给假件"。
实测这三个名字全是 **net_listener.c 文件内的 static**(net_listener.c:86 / :114 / :273),
不存在跨 TU 引用 → 该项前置成本**不成立**,工作量比我说的小。三块之间只走公开 API
(net_listener_request/settle、net_pending_*、net_get_sock/net_sock_raw 等)。

规格内容(全部从真实头逐条核出,不凭记忆):
- 15 个芯片函数的真实原型,假件必须一字不改照抄;特别标出三种形态:
  ch395f_get_socket_status / ch395f_get_remot_ipp 是**出参**、
  ch395f_sock_state_cached 返回 **ch395f_sock_state_t 枚举**(不是 uint8_t,虽然底层
  整型兼容,照抄才会在原型漂移时编译期报错)。
- 假芯片必须复刻的两条芯片怪癖:① get_sock_int_status **整体读即清**、芯片无按位
  清除手段 —— 假件要按"返回该 socket 挂起位图并同步清空、可逐 socket 预置"实现,
  否则照不出 AGENTS 模块约定里 B3 的实证坑("顺手全量清一遍"把别的在用 Socket 的
  门焊死);② sock_state_cached / link_up_cached 是**缓存值**而非现场值,要把"缓存
  在第几次调用后更新"做成可脚本化,才能测对账循环读到陈旧状态的路径。
- 脚本化模型沿用本仓既有假件风格(nand_port 的真头+假 diskio、lftpd_io 的可注入假
  堆):读函数一条按调用序号取的返回队列、写函数一条调用记录、外加每函数调用计数
  以断言"这一轮不该碰芯片"。
- 桩放置点需要拍板,已给推荐:test/host/common/ch395f_fake.h/.c 三模块共享,理由是
  三份各自漂移会让"假件与被测代码同时错"这类问题再也报不出来;代价是不进 MCU 构建、
  要靠 test-cppcheck 覆盖。若沿用每模块自带桩的惯例,则三份必须 include 同一份头锁签名。
- 实施顺序 net_listener → net_pending → net_events,逐个写明各自要桩什么、判据抄哪份
  文档(网络自愈架构设计 §4.10 的读清点与三条准入判据),以及每步的验收动作
  (@build 0/0、套件全绿、--update-baseline、先补 TC-HOST-* 族再往文档里引号)。

本轮**只动这一篇文档**(零代码改动,故不跑 @build);check_doc_links --all = 35 处
(= 存量基线,本分支贡献 0);文件 BOM/CR/行尾与 HEAD 一致。
另:本会话上下文见底,故把这块以规格形式交付而不是留下编译不过的半成品 —— 规格里的
原型、static 归属、怪癖与顺序都已核实,下一会话可直接从"写 test/host/common/
ch395f_fake.h"开始。
2026-10-23 23:44:49 +08:00
wandering1 e307213745 docs(任务规划): 删除查无正文的 E-25 交叉引用,net_select 处置改用平实语言留档
起因:上一提交修掉 net_select 两处死路径后,发现排期登记里写着"C1 消化 E-25
(net_select 语义)",想核对 E-25 到底还列了哪些子项,结果查不到条目正文。

调查结论(三条独立证据,不是"我没找到"):
1. scripts/id_registry.json 里 E 系列族 E-AUD:pattern \bE-[0-9]{1,2}\b、
   frozen=true、游标 E-63,而 **owner_docs_glob = None** —— E 系列是当年审计报告
   的冻结快照号,仓库里从来没有属主登记处;
2. 全库(含代码与文档,排除构建产物)E-25 只有 3 处命中,且全部是"挂 C1"这一句话
   的交叉引用,没有任何条目正文、判据或用例;
3. 债务台账 id_known_backlog.json 里 E-25 命中 0 条 —— 它落在族游标 E-63 之内,
   被门禁当作"在册号"放行,于是既不报悬空也不报越界。
结论:这是一个**号在册、正文已随当年销项消失、却仍被当作在册依据**的编号,正是
编号规范 §0 与销项纪律里点名的那类漏清引用(同类先例:N-31 因销项删行后引用未清
而被复用撞号)。用户指示:查无相关内容即删除。

处置(E-25 全库归零):
- 排期详述.md §1 的 C1 行:编号列改为 E-27 / E-28;描述去掉 E-25,但**不丢信息**
  —— 改用平实语言写明"net_select 的两处死路径(timeout={0,0} 退化成死等、
  exceptfds 恒为空)已于 2026-10-23 由主机单测照出并修掉",并指向
  docs/测试说明/主机单测覆盖率口径.md §9 的处置表(裁定、改法、用例都在那里)。
- 任务规划/README.md 两处(§0 冻结号说明、W4 结构债行)同步去掉 E-25。
- 冻结历史快照族本身不动(E-AUD 仍 frozen、游标仍 E-63),只清引用;E-27/E-28
  照旧挂在 C1 下继续排期。

门禁:check_ids --strict 通过且**可判定比例 82.6% → 83.12%**(删掉的是"族已登记但
属主登记处不存在"的不可判定引用,分母下降所以比例升,符合只许涨不许跌);
check_doc_links --all = 35 处(= 存量基线,本次改的两个文件 0 命中);task-table
Passed;两个文件 BOM/CR/行尾与 HEAD 一致。
pre-commit --all-files 仍只有 mem-numbers / mem-budget(.map 与受管快照漂移,等
Lead 合并后统一回填,分支内不 --write)与 ui-golden(ref/*.bin 不入库,新 worktree
天生没有黄金参考)三条失败,与本分支改动无关。
2026-10-23 23:44:49 +08:00
wandering1 5224f21622 fix(NET,lftpd): 处置覆盖率照出的三处死路径(改名 + 修 timeout=0 + 激活异常集)
三处都由本轮 P1 新增的主机单测照出,用户逐条裁定后实施。共同性质:都不是改动引入
的回归,而是**从没被执行到的分支**——生产路径上要么没有调用者、要么没走边界,所以
编译器(0 错 0 警)、cppcheck、真机日志对它都无话可说。

1) 缺陷一(选 a:改名,行为零变化)
   lftpd_string_trim 的尾部循环起点落在 strlen(p) 那一格 NUL,isspace(0) 为假 →
   循环体一次都不执行,"尾部空白从未被裁",而名字承诺两头都去。改名
   lftpd_string_ltrim + 删掉那条死循环 + 在 .h/.c 写明"只去前导"。不去尾才是对的:
   RFC 959 以命令终止符 CRLF 界定命令,参数(PASS 口令、含空格的文件名)的尾随
   空格属于参数本体,去掉等于静默改数据;CRLF 已由 lftpd_inet_read_line 截断。
   调用点 lftpd.c 同步改名。全库无遗留真引用(余 6 处旧名均为解释改名历史的散文)。

2) 缺陷二(按推荐修)
   net_select 的 timeout = {0,0} 换算出 timeout_ms = 0,而退出条件写着
   if ((timeout_ms > 0) && (已用时 >= timeout_ms)) → 超时永不生效,退化成死等;
   POSIX 语义是"只查一轮立即返回"。去掉 timeout_ms > 0 这个错误前置即可。就绪
   检查在本判断之前,故有就绪时仍立刻带计数返回;同文件 net_poll_events 的
   timeout >= 0 本来就对,现在两条对齐。风险面:拿它当"非阻塞查一遍"会让 netTask
   回不到主循环,而 netTask 在 IWDG required_mask 里(串行 socket 栈唯一执行者)。

3) 缺陷三(选 ①:按 POSIX 置位)
   exceptfds 是恒为空的死参数:循环开头 p_sock == NULL → continue 先跳过,而
   check_socket_error() 只有"表项为空 / fd 越界"才返回 1,越界的 i 又不在本循环
   范围内 → 调用方按 POSIX 直觉用它收异常,永远什么都收不到,且返回码不报错。
   把异常判定提到 continue 之前:表项不存在的 fd 计入 exceptfds 与返回计数,并更新
   函数注释。选 ① 会改变返回计数语义,故补一条"未请求异常集时不计数"的用例,
   确保只查可读的老调用方不被误伤(TC-NETSEL-012b)。

测试同步翻转:原 TC-NETSEL-008 / TC-NETSEL-012 是"现状桩、改了会响",现在改成正向
契约,并新增 TC-NETSEL-012c(异常位只落到被请求的那个集合);TC-LFTSTR-005 从
"尾部不裁"的现状登记改为正向契约(尾随空格必须保留)。net_select 22/22、
lftpd_io 33/33 全绿。

@build:0 错误 0 警告。实测体积 ER_IROM1 232,512 B(快照 232,536 → **−24 B**)、
Flash 233,704 B(−20 B)、RW_IRAM1 75,272 B(+8 B 是上一提交 net_cfg 那个字节,
本批零新增)、**RW_CCM 57,376 B 不变**;四档百分比 22.2/57.4/87.9/22.3 全不变。
Code 减少 36 B 即删掉的死循环。

覆盖率台账:门禁如实报了一次 lftpd_string.c 已覆盖行 8 → 7(**代码少了一行**,不是
覆盖退化),已 --update-baseline 重登;现 22/22 套件、line=3003/3446(87.1%)、
func=236/259(91.1%)、tested_files=26、untested_files=118。

文档:主机单测覆盖率口径 §9 新增"三处死路径的处置"小节(裁定、改法、影响、钉住
它的用例一一对应),并把两处"等裁定"的登记改为已处置。

自查:coding-standard / test-cppcheck / id-check --strict(无台账外新账)/ doc-links
(35 处 = 存量)全过;八个改动文件与 HEAD 逐项比对 BOM/CR/U+FFFD/行尾无变化
(这几个文件本就不带 BOM,未被动过)。pre-commit --all-files 仍只有三条失败且与
本分支无关:mem-numbers / mem-budget(.map 与 docs/内存管理 受管快照漂移,增量见
上,按并行分支约定不在分支里 --write,等 Lead 合并后统一回填)与 ui-golden
(simulator/tests/ref/*.bin 在 .gitignore 内,新建 worktree 天生没有黄金参考)。
2026-10-23 23:44:48 +08:00
wandering1 658bc40ccc test(NET): net_select 模块(21 条)与 NET 域首份 ch395f 桩,登记两处死路径
P1 的 NET 四块开了第一块:新增 test/host/net_select/,21 条断言全部 MSVC 编译
通过并 PASS。net_select.c 一个芯片函数都不调,所以它是四块里唯一不需要假芯片的,
先把它拿下,顺带把 NET 域的桩骨架立起来。

钉住的契约:
- 就绪位回写:入参集合里未就绪的 fd 必须被清掉,只留就绪的(POSIX 语义);未请求
  的集合不被写回、可以传 NULL。
- 状态准入:LISTENING 即使 readable 也不算可读(accept 语义不在这层);ESTABLISHED
  + send_ready 才可写;CONNECTING 时 send_ready 不作数。
- 边界:nfds 为 0 / 负 / 超过 NET_MAX_SOCKETS 都返回 -1(不许扫出 socket 表外)。
- 超时算术:tv_usec 是**整除截断**(1999us 当 1ms 而不是 2ms);timeout=NULL 时
  无限等并由对端事件唤醒;poll 的 timeout=0 首轮即退、每轮先清 revents。
用可控 tick(每问一次 +1)+ 可控 osDelay(计数、可指定轮数后造一个就绪 socket)
把这些变成确定性轮数,台架上根本构造不出"没人来连时该怎么退"。

照出两处**死路径**(只登记,未改语义;全库唯一调用点 test/board/net_test_task.c
传的是 NULL 与非零 tv,今天没有受害者):
1. net_select 的 timeout = {0,0} 换算出 timeout_ms = 0,而退出条件写的是
   if ((timeout_ms > 0) && (已用时 >= timeout_ms)) → **超时 0 永不触发退出**,退化成
   无限等;POSIX 在这里必须立刻返回 0。同文件 net_poll_events 写的 timeout >= 0
   是对的,两条并排放着正好是对照。风险面:谁拿它当"非阻塞查一遍",netTask 就
   回不到主循环,而 netTask 在 IWDG required_mask 里。
2. exceptfds 是恒为空的死参数:循环体开头 p_sock == NULL → continue 先跳过,
   check_socket_error 的 NULL 分支从 select 里到不了。按 POSIX 直觉用它收异常的人
   会永远什么都收不到。
两处都用"现状桩"钉成改了会响(用例名里写明不是期望行为)。另修掉我自己写错的一条
用例:给 timeout=NULL 又没有就绪 fd,直接死循环——已在假 osDelay 里加了 500 轮
保险(跑飞就 exit(2)),因为挂死的测试会把 exe 文件名占住、让后续构建报 LNK1104。

桩的边界写死在文件头:net_select.h / net_types.h / net_config.h 用真实头(net_sock_t
字段名与 NET_SOCK_STATE_* 一改即编译期报错);main.h / cmsis_os.h / FreeRTOS.h /
task.h 是本目录桩;新建的 ch395f.h 是**空壳**,只服务不调芯片函数的文件——
net_pending / net_events / net_listener 直接调十余处 ch395f_*,要的是可脚本化返回的
假芯片,不要把这个空壳当成"NET 域桩已就绪"。

覆盖率:套件 21 → 22 全绿;files_tested 25 → 26;未触及 .c 41 → 40;分母 3,332 →
3,446 行;net_select.c 97/114(85.1%)。恒等式更新为 40 = 6(P1 剩) + 5(P2) + 29(P3)。
编号按 B 层口径补建 TC-HOST-NETSEL(游标 019),TC-HOST 族现共 16 族;注册表仍只
做增量插入。

自查:test-cppcheck Passed;check_ids --strict 通过;doc-links = 35 处(= 存量);
BOM/CR/U+FFFD/行尾逐项净。本增量零生产代码改动,未跑 @build。--all-files 仍只有
mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)与 ui-golden
(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。
2026-10-23 23:44:48 +08:00
wandering1 6bdf063f50 test(NET/lftpd): P1 增补 lftpd_io 模块(33 条),登记 FTP 域一处潜伏缺陷
新增 test/host/lftpd_io/:一个 TU 同时纳入生产源 lftpd_io.c 与 lftpd_string.c
(两者静态符号不冲突,省一套桩),33 条断言全部 MSVC 编译通过并 PASS。

钉住的契约(都是真机上偶发、日志看不出根因的那类):
- 路径规范化:**".." 上跳过头仍被钳在根内**("/a" + "../../x" = "/x")——FTP 客户端
  可发任意路径,这是安全属性;绝对名忽略 base、"." 丢弃、段间多余斜杠折叠、
  NULL 入参容忍、全空结果归 "/"。
- 分配失败:这个函数返回 pvPortMalloc 出来的指针(历史上正是它落在 C 库 malloc 上,
  RAM 方案 §4.2.1)。用**可注入失败的假堆**跑两条分支:首块失败 → NULL;结果缓冲
  失败 → NULL **且第一块已 vPortFree**(不泄漏堆)。模拟器宿主 CRT 永不失败,这条
  只有假堆能构造。
- 卷锁时机:open 成功**故意不释放**(整个传输期独占卷,由 close 释放),失败必须
  释放并留 ERROR;单文件槽守卫 s_file_open 命中时**不碰卷锁也不碰 FatFs**。
- 等卷锁循环每轮喂看门狗(lftpd_task_wd_beat):defaultTask 与 FTP 争卷时这里可能
  空转上百毫秒,漏喂就是整机复位。脚本化 vol_lock_wait 的返回序列直接构造 2 轮空等。
- lftpd_string_trim:行首空白由**返回指针**跳过、原缓冲不搬移(调用点 lftpd.c 用的
  正是返回值);全空白串返回空串。

**本批照出一处,未改语义、只登记**:lftpd_string_trim 的尾部循环写成
for (i = strlen(p); i >= 0 && isspace(p[i]); i--),i 起步那格是 NUL、isspace(0) 为假
→ 循环一次都不执行,**尾部空白与 CRLF 从未被裁掉**。今天不发作是因为 CRLF 在
lftpd_inet_read_line 里已用 strstr 截断、且唯一调用点只关心行首;风险在于"以后有人
拿它去尾部空白"不会生效。修法只要一行(i 从 strlen(p)-1 起),但那会改变 FTP 参数
(含空格的文件名/口令)的可见行为,需要 FTP 域一起过,**留给用户裁定**;当前用
TC-LFTSTR-005 把现状钉成"改了会响"(明确标注是现状桩、不是期望行为)。

未覆盖也写在文件头:lftpd_io_read/write/close/stat/unlink/opendir/readdir/closedir
要先把假 FIL/FILINFO 的字段语义(fptr、obj.objsize、fname 等)做实,否则断言会落在
假件自己写的规则上;这 8 个 f_* 只给了可链接的 FR_INT_ERR 实现,走进即失败不静默。

覆盖率:套件 20 → 21 全绿;files_tested 23 → 25;未触及 .c 43 → 41;分母 3,163 →
3,332 行;lftpd_io.c 85/159(53.5%)、lftpd_string.c 8/10。总体 line_rate 88.9% →
87.2% —— **这是摊薄不是回退**(新进来的文件只测了一半,分母涨得比分子快),已按
§5 口径写进口径文档 §9 的落地进度表;恒等式更新为 41 = 7(P1 剩) + 5(P2) + 29(P3)。

编号:按 B 层口径补建 2 族 TC-HOST-LFTPIO(游标 037)与 TC-HOST-LFTSTR(005),
TC-HOST 族现共 **15** 族;id_registry.json 仍只做增量插入(无重排)。

自查:test-cppcheck Passed;coding-standard Passed;check_ids --strict 通过(无台账外
新账);check_doc_links --all = 35 处(= 存量,本分支贡献 0);新增/改动文件 BOM/CR/
U+FFFD/行尾卫生逐项净。**本增量零生产代码改动**(只加测试、注册表与文档),故未跑
@build。pre-commit --all-files 仍只有三条失败且与本分支无关:mem-numbers / mem-budget
(本 worktree 上次 @build 生成的 .map 与受管快照漂移,增量 ER_IROM1 +12B、RW_IRAM1
+8B、CCM 0,按并行分支约定不在分支里 --write,等 Lead 合并后统一回填)与 ui-golden
(simulator/tests/ref/*.bin 在 .gitignore 内,新 worktree 天生没有黄金参考)。
2026-10-23 23:44:47 +08:00
wandering1 b1291f1308 test(NETCFG,CFG): P1①四个主机单测模块,并修 net_cfg 的 WAIT 契约与定长错记
按 docs/测试说明/主机单测覆盖率口径.md §9 的清单吃 P1 第①批「配置与工具」四块,
新增 4 个主机单测模块共 104 条断言,全部 MSVC 编译通过并 PASS:
- test/host/net_cfg/(45 条):出厂默认可用、注册幂等与内容、load 报 -1 仍返回 0
  (RAM 恒有效契约)、跨任务保存交接状态机 IDLE/WAIT/OK/FAIL、MAC 三态优先级、
  IPv4 定长格式化。
- test/host/ftp_cfg/(16 条):[ftp] 段注册(**p_cb 必须为 NULL**,口令下次登录才
  生效)、默认口令判定大小写不敏感、NULL 一律判不等(fail-closed)。
- test/host/prot_set_store/(16 条):blob 返回码分流 1 无档 / 0 成功 / **2 换代
  (只打 INFO)** / -1 损坏;写盘失败必须回置脏标志(否则定值静默丢失)。
- test/host/task_prof/(27 条):无效样本闸门(防 DWT 复位把 max 抬到 2^32)、
  未 enter 就 exit 不记账、首次 5s + 每 2s 节流、环形缓冲与裸发两通道、
  周期→微秒换算随 SystemCoreClock 变。

覆盖率收益(已 --update-baseline 登记):套件 16 → 20 全绿;files_tested 19 → 23;
未触及 125 → 121 个文件(.c 47 → 43、9,657 → 9,228 行);分母 2,893 → 3,163 插桩
行;line=2811/3163(88.9%)、func=225/240(93.8%)。§9 的恒等式同步更新且仍咬合:
43 = 9(P1 剩) + 5(P2) + 29(P3)、9,228 = 2,188 + 2,226 + 4,814。

新单测照出并修掉两处真问题(都在 net_cfg,不是测试写错):
1. 实现违反自己的文档契约:net_cfg.h 明写"属主任务写盘中显示 WAIT",但
   net_cfg_save_run 为了不丢新请求会先清 pending 再写盘 → 写盘那几百毫秒里 UI
   保存行退回上一轮的 OK/IDLE(第一次保存按 ENTER 后屏幕上还是 ENTER),用户只
   会再按一次。补 s_save_busy 标志,TC-NETCFG-028/026/030 钉死(含失败路径必须
   清 busy,否则 UI 永久卡 WAIT)。
2. "定长 11 字符"是错的:%03u.%03u.%03u.%03u = 4 段×3 位 + 3 个点 = **15**。这个数
   错了 4 处(net_cfg.h、net_cfg.c、网络配置说明.md 两处)——是我照抄它才撞上
   FAIL,错在文档不是代码。已全部更正,并确认 len ≥ 16 的要求与 UI_BUF_STRLEN=32
   本来就没问题(无截断风险)。

桩的口径(沿用既有模块,不另起一套):真实头优先——cfg_file.h / cfg_file_store.h /
console_log.h / prot_set.h 全用真头(接口一漂移就编译期报错),只有 main.h 必须桩
(真实 main.h 拉整棵 HAL 头树,PC 上编不动;桩里字段名与位掩码宏名与 CMSIS 一致)。
prot_set_store 连真实 prot_set.c 一起编:blob 的编解码与脏标志在它里面,只有拿真
serialize 产出的 blob 才能造出"换代"与"损坏"两种真实形态,手搓等于测自己的桩。
配置引擎本身(解析/写盘/沉降)归 test/host/cfg_file 的 103 条,本批不重复测。

编号门禁:按 2026-10-21 批 3 的 B 层口径补建 4 族 TC-HOST-*(NETCFG 游标 056 /
FTPCFG 014 / PROTSTOR 016 / TASKPROF 020,代码即属主登记处)。这一步不是走形式:
建族之前文档里引用 TC-NETCFG-028 被判「台账之外新账」直接卡提交,建族后才通过;
编号规范 §8.4 变更记录加一行说明族数 9 → 13。id_registry.json 只做增量插入(+88 行,
无重排)。

自查(提交时 pre-commit run --all-files,**仅 3 条失败且都与本分支改动无关**):
- mem-numbers / mem-budget:本 worktree 因需验证代码改动而 @build 过,新生成的 .map
  与 docs/内存管理 受管快照漂移。实测增量 ER_IROM1 +12 B、RW_IRAM1 +8 B、Flash
  +16 B、**CCM 0**,水位百分比全不变(改动 = 1 个静态字节 + 几条指令)。按并行分支
  约定**不在分支里 --write**,台账由 Lead 合并后统一回填。
- ui-golden:simulator/tests/ref/*.bin 写在 .gitignore 里(黄金参考是本地生成物),
  新建的 worktree 天生没有,报的是"缺黄金参考"而非像素差异;本分支零 UI 文件改动。
- 其余全部 Passed,含 @build 0 错误 0 警告、test-cppcheck(它抓到过我一条 p!=NULL
  后又解引用的写法,已改为断言+显式守卫)、coding-standard、doc-links(35 处=存量)、
  id-check --strict、mojibake 与 BOM/CR/U+FFFD/行尾逐项卫生。
- 自审修掉自己写的两条恒真断言(形如 x==0?1:1 的假绿)与一个重复编号标签。
- 4 个新模块**尚未挂 pre-commit 钩子**:.pre-commit-config.yaml 是单写文件,任务工作
  树内不改;覆盖率脚本按 test/host/**/run_unit_test.py 自动发现,已实跑 20/20。
- 未跑 @test:改动仅 1 个静态字节 + UI 保存行的显示时序,无硬件语义变化。
2026-10-23 23:44:47 +08:00
wandering1 e70b861215 docs(覆盖率): 新增 §9 未触及面建桩优先级清单(P1/P2/P3 逐个画像)
回答"那 125 个到底怎么吃":对 47 个未触及 .c(9,657 有效代码行)逐个量化后再排序,
不靠拍脑袋。排序口径写进文档、可复核可反驳:价值 = 有效代码行 × 逻辑度 × 缺陷历史
(该文件的 fix/refactor 提交数),成本 = 要桩的外部头数 − 能复用的现有桩;逻辑度 =
1 − 硬件符号密度(HAL_*() / FatFs f_*() / CH395F_* / SPI-I2C / OSAL 调用数 ÷ 行数)。

三档(数字互相咬合,覆盖率"不该 100%"在这里是算出来的):
- P1 先吃 13 个 .c = 2,617 行:配置与工具 429(真实头即可编译,建议第一个落地,先用它
  跑通"新增文件进台账 + NOTE 不误报回退"这条链)→ FTP 一套 1,083(20 次提交里 6 次
  fix,最近两条都是静默型)→ NET 拆出的四块 903(须新建 stub/ch395f.h)→ 104 服务层 202。
- P2 值得但工程量大 5 个 = 2,226 行:net_socket.c 1,251(51 函数,**必须分片**,先测状态
  迁移与语义判据);假 FatFs 一次吃 sys_log_mcu/cfg_file_store_mcu/soe_store 共 710;
  console_log 265(单写者契约 K1/K2 已写成表)。
- P3 明确不做 29 个 = 4,814 行,并把理由写死免得下次又列回待办:芯片驱动 1,990(假到底
  ≈ 重写驱动,测的是自己的桩)、UI 侧 1,942(与模拟器同一份源码,已由 ui-golden 覆盖)、
  任务编排 747(4~5 个函数的装配层,写成单测就是"断言我调了几次")、app_main 135
  (被测对象本身就是内核启动前的时序约束)。
- 恒等式:47 = 13 + 5 + 29、9,657 = 2,617 + 2,226 + 4,814;做完 P1+P2 约 4.8k 行进分母
  (现分母 2,893),files_tested 19 → 37。

配套口径修正:
- "125 个未触及"易被读成 125 个待办,三处写精确 = 47 个 .c(9,657 行)+ 78 个 .h
  (2,182 行,多为声明与宏);待办池是那 47 个 .c。
- §8 下一步①改为指向 §9;终点判据从百分比改成"untested_sources 里剩下的每一条都能在
  P3 表里找到归属",并提醒盯 files_tested/files_untested 而不是 88.2%(新增大块未测代码
  会把它摊薄,那不是回退)。

自查:check_doc_links.py --all = 35 处(与主干同数,本分支贡献 0;期间修掉一处 §4.5 被
误算成本文自引用);check_ids.py --strict 通过;check_mojibake + BOM/CR/行尾卫生净。
本轮纯文档,零 .c/.h 改动,未跑 @build、未占 build/bench 锁。
2026-10-23 23:44:46 +08:00
wandering1 6505cef9df feat(门禁): coding-standard 加 CODE-RULE-20 禁 C 库堆分配(销 §4.8 空白 ②)
规则:在**掩码后**的文本上匹配 malloc/calloc/realloc/strdup/strndup/
free/aligned_alloc 调用。三条判据都是踩过坑之后定的——
- 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会误报;
- 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类同形后缀不误报;
- 用掩码:注释与字符串字面量里写 malloc 不报(规范示例就踩过这种)。
覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc——同族同类,
漏一个就等于留后门。

出处不在规范 V1.0,在平台约束:MicroLIB 的 C 堆由 startup_stm32f407xx.s
的 Heap_Size EQU 0x200 只有 512 B,KB 级分配必返 NULL,而模拟器(宿主
CRT 堆)完全正常、**照不出来**——配置引擎 2026-09-18 那次堆分配纠偏与
lftpd 的 malloc 隐患都是**先上线才发现**的那类,靠评审等于没管。

落地前按 门禁说明/coding-standard.md §8 的纪律先全库普查:钩子范围内
C/H 共 151 个文件,命中**只有** simulator/sim/cfg_file_store_sim.c 两行,
即 cfg_store_alloc/cfg_store_free = **平台分配后端的宿主侧实现**(设计依据
写在 docs/子系统设计/配置文件设计说明.md 的"MCU 端 = pvPortMalloc、
模拟器端 = 宿主 CRT")。所以零基线、直接硬拦截;豁免用脚本里的
HEAP_ALLOC_ALLOWLIST(**文件路径 + 函数名**双条件),刻意不做目录级豁免:
下次谁要碰 C 堆就得往这张表加一行,评审看得见。

自证:临时样例文件里 malloc/calloc/free 各报一处,pvPortMalloc/vPortFree/
buf_alloc(、注释、字符串五种陷阱形态全部不误报;
pre-commit run coding-standard --all-files → **Passed**(= 全库零存量)。

顺带修三处口径不一致:
- 规范 V1.0 §6.1 的示例自己写着 malloc/free(通用 C 模板遗留)→ 已在示例
  下加"本仓例外,别照抄"批注,事实源指向 AGENTS.md 平台约束;
- docs/门禁说明/mem-budget.md 里"这条纪律只靠 AGENTS 铁律与评审"已过期 →
  更正为由 CODE-RULE-20 机器拦截;
- AGENTS.md 平台约束条加"已机器拦截 + 唯一豁免"一句;scripts/README.md
  补登 check_test_coverage.py(上一个提交漏登记)。

另:本次一并修好上一处编辑事故——报告 §4.8 曾被我的脚本调用截成半句
(只落第一行、丢掉 malloc 那半句),已重写为"两处空白均已闭合"。

自查:pre-commit run --files(9 个改动文件)适用项全 Passed;
check_doc_links.py --all = 35 处,与主干同数(本分支贡献 0);
check_ids.py --strict 通过(裸编号 P0-1 改平实说法,避免记成新账);
9 个文件 BOM/CR/U+FFFD/行尾与 HEAD 逐项一致。本轮零 .c/.h 改动,未跑
@build、未占 build/bench 锁;分支已 rebase 到 v2-on-ui,覆盖率脚本全量
回归仍 16/16、line=2552/2893 不变。
2026-10-23 23:44:46 +08:00
wandering1 2bfa09ed15 feat(门禁): 主机单测覆盖率脚本与口径落地(销报告 §4.8 空白 ①)
落地物:
- scripts/check_test_coverage.py:发现规则与 L0.5 同一条(test/host/**/
  run_unit_test.py),直接 import 各运行器复用其 TestBuild 与
  unit_test_harness._BASE_FLAGS —— 配置零复制,不会漂出第二套口径;
  只算自研生产源码(App/**、Drivers/BSP/**)的行/函数、多套取并集、
  生成物(文件头写"自动生成/请勿手改")不进分母、分母一律来自插桩
  报告不猜行数;中间物全在 %TEMP%,不新增 .gitignore 规则。
- scripts/test_coverage_baseline.json:增量拦截台账(只拦回退不拦进步,
  与 layer-deps / bsp-err 同姿态);ci_local.py 加 opt-in --coverage。
- docs/测试说明/主机单测覆盖率口径.md:口径 + 工具链实测 + 九条盲区。
- 报告 §0.1 / §0.4 / §4.8 / §5.5 / §7 改写 + 变更记录 + 去处表。

工具链是实测出来的(换机必读):Windows 原生侧无任何 gcc/gcov(全盘扫
Program Files*、msys64、scoop、choco、.platformio、LocalAppData = 0 处);
MSVC 14.51 已砍 /coverage,且只报 D9002 后照常编译成功 = 静默假数据;
CodeCoverage.exe 17.0 三个子命令只打印用法。最终用 mscoverage 18.x
(Microsoft.CodeCoverage.Console.exe)collect -f cobertura 做加载期动态
插桩,并实测它透传子进程退出码 → "插桩运行下仍必须 PASS"可当硬条件。

首登数字:16/16 套插桩下全绿,并集 line=2552/2893(88.2%)、
func=193/207(93.2%)、19 个文件在测、**未触及 125 个生产源文件 /
约 11,839 行**——最后一栏才是这套口径真正照出来的东西。

门禁自证:人为抬高基线后 行/函数/总计 三条回退路径都报 REGRESS 且
退出码 1;--module 子集跑不再假报总计回退(首版会,已修:并集口径下
子集必然变小,全部回退判定只在全量跑时生效)。

GCC 追问的复测结论(口径文档 §2.1 决策记录):本机 WSL2 Ubuntu 确实有
gcc/gcov 15.2.0,16 套单测在 gcc 下全部编过、用例数与 MSVC 逐个一致,
gcov -b -c 也确实给出分支覆盖(nand_ftl.c 分支至少走过一次 41.40%);
但换编译器=换分母(sha256.c gcc 105 行 vs MSVC 114 行;adc_input.c 101
vs 120)。**用户裁定:本仓只留 MSVC 一条覆盖率通道**,不建第二条——
多一套工具链就多一套分母、多一本台账、多一个"这台机器到底有没有 WSL/
gcov"的未决项,换来的只有分支这一维。分支覆盖因此**记为已接受缺口**
(不是待办):绿灯只证明行与函数被测到,补偿判据 = TC 双向追溯 +
用例显式列边界值;§8 写明重开该裁定的前提是先答"两份分母怎么对齐"。

刻意未挂 pre-commit:16 个单测钩子已经跑过同一批测试;依赖 VS 可选
组件(缺组件时 skip 会静默失效、FAIL 会拦换机);待一个真实回退事件。
YAML 片段与理由写在口径文档 §8。

自查:pre-commit run --files(6 个改动文件)全 Passed;check_doc_links
--all 回到存量 35 处(本分支贡献 0);check_ids.py --strict PASS;
check_mojibake + BOM/CR/U+FFFD/行尾卫生逐项自查全净。本轮零 .c/.h 改动
(纯 .py/.json/.md),故未跑 @build、未占 build/bench 锁。
2026-10-23 23:44:45 +08:00
wandering1 49a257b34c docs(任务规划): 立项 W8 数据可信,补 ADC 注入验证两卡,新增并行分道方案
- W8 数据可信:通道标定 → 遥测链 → 运行参数,14 张任务卡。
- 补卡:核查确认 ADC 从未采过已知电压(只验过通路活),新增
  "已知电压注入验证"(只需万用表)与"端子变比确认"两卡;M4 的承诺
  边界改为"ADC 引脚电压",二次值降为依赖变比的条件项。
- 撤销"1k+1k 即 2:1 分压"的换算用法:该网络被定性为源阻抗匹配,
  且端子→引脚变比仓库内无文档。
- 并行分道方案:全部待办归入 8 条道 / 8 个写域,给批次与合并次序。
2026-10-23 23:37:10 +08:00
wandering1 c0ccd777ae docs(UI): 页面补齐规划、逐页扩充方案与实时数据供给设计
- 页面补齐规划:重算菜单树(42 节点 / 33 叶子 / 6 已绑页 / 27 空叶子),
  27 叶归并为 7 模块 + 2 项前置能力 + 1 数据层,分 4 批。
- 页面扩充详细方案:逐页规格(行表、数据来源、交互、字号),并纠正
  已建 6 页里 3 页实为 Flash 字面量。
- UI 数据供给设计说明:选定"快照推送"(App 推 / BSP 存 / 页面只读),
  rt 200ms + par 变化触发双通道;单写者同上下文故无需锁;记录页改为
  注入 reader,可回收 3 条分层门禁基线(6→3)。
2026-10-23 23:34:44 +08:00
wandering1 70dd6f8192 docs(评测): 新增采集与数据处理短板评测(对照 CLS100 参考项目)
按"采集→数据处理"全链逐层对照参考项目,结论:已建成的是保护链,
还不是能上送的测控装置。四类阻断短板:工程量折算缺失、遥测链整条
缺失、运行参数段 0x8020~0x8058 未实现、录波与 104 文件传输缺失。

另登记两处此前未记录的发现:固件无任何开入/开出代码;交流量与
直流量页为硬编码占位值,运行时不变。

建议分三阶段:数据可信 → 精度与安全网 → 录波与文件。
2026-10-23 23:33:14 +08:00
wandering1 1e4f74ba14 merge: task/ui-golden-ui-golden-ref 合入 v2-on-ui
任务 ui-golden(1 个提交)经 task-worktree 回收:
- chore(ui-golden): 像素黄金参考 ref/*.bin 入库(把门禁改成可移植)
2026-10-23 12:22:02 +08:00
wandering1 b145e74ded merge: task/N15-nand-ftl-pagebuf 合入 v2-on-ui
任务 N15(1 个提交)经 task-worktree 回收:
- docs(存储): 补上 N-15 页缓冲可合并性论证,判定三块不可合并
2026-10-23 12:14:40 +08:00
wandering1 b22779d244 docs(存储): 补上 N-15 页缓冲可合并性论证,判定三块不可合并
报告 N-15 一直写"与 R-3 同批评估",但评估从来没写过——nand_ftl.c:300
的注释直接引用了这份不存在的"报告 R-3"。本次补齐:

- 6,216 B = RW 16 + ZI 6,200,三块 2 KB 页缓冲 6,144 B 占 98.8%
- 不可合并:dhara_map_write 先 auto_gc(map.c:327)再落数据,GC 搬页
  与"待写的 s_cache_buf""攒了 16 页的 checkpoint 元数据 s_page_buf"
  三方同时存活,任两块合并都是静默错数据而非报错
- s_page_buf 也不能缩:元数据跨 0..1,999 偏移,编程的是整页
- 唯一可动的是 s_copy_buf:分块 512 B(-1,536 B,4 次 CPU 轮询)或
  内部搬页 copy-back(-2,048 B,须实证"奇偶属性相同"限制)
- 收益落点 RW_IRAM1(余 55,808 B),红线在 CCM(余 7,904 B)→ 判定不做

顺带订正 nand_ftl.c 两处注释:悬空引用改指本节;s_page_buf 原文写
"仅装 132 B 走轮询"不成立,实际按整页 2,048 B 编程、同样走 DMA。

@build 0 错 0 警告,镜像逐项不变(232,536 / 132,688 / 232,824)。
2026-10-23 12:03:08 +08:00
wandering1 6822726b2c docs(门禁说明): 手册 §2 收敛为通用机制与查号表,逐项详解归一到 23 篇
用户一句"现在门禁都在干什么,我一概不知"引出的收尾。原手册 §2 有 27 个逐钩子小节,只回答
"拦了什么、怎么配",回答不了"什么时候会骗我";docs/门禁说明/ 已把逐项详解(含"限制与盲区"
一节)建好,本笔做收敛:同一事实只留一处(docs/文档规范.md 的要求)。

**先搬后删**(否则收敛等于丢事实):旧 §2 里独有条目逐篇折回,共 18 处插入、15 篇受益。折回的
东西包括:六套主机单测的来历与用例规模(13/36/82/46/32/149 断言,含 nand-port 的"与旧算法
对照")与"改动公共骨架 unit_test_harness.py 会同时触发 16 个钩子";UI 页面**两层注册**(构建
注册 vs 框架注册:枚举 + s_defs[] 描述符表)与"豁免不整目录一刀切";lv-conf-sync 首次落地清掉
26 个 LVGL 9.3 不存在的选项、3 处重复定义、10 项隐式开关显式化,以及 ALLOW_DIFF 端专属白名单
与必须穿过 lv_conf_internal.h 里那层 KCONFIG 包裹;cubemx-survival 的 16 项清单、断言前先剥
C 注释(本仓注释刻意引用历史代码,不剥就恒定误报)、旧手册"自造标记区"建议与看门狗文档实测
教训之间的优先级;id-check 顺带收编 check_test_traceability.py、裸形同字母多义属**显式承认的
盲区**、--update-baseline 只许涨;layer-deps 的注入自证与报错时三条修法(注入回调 / 上层递数据
/ 才谈 ALLOW);coding-standard **nand_ftl.* 也在排除清单**(我先前那篇漏了);test-suite-switch
的剥注释判定与派生宏白名单;mem-budget 两类软提醒不拦、退出码 2 是解析失败不是通过;doc-links
的排除目录与状态词 ±40 字符距离;task-table 改栈三步;gitlint 报错样例;no-mojibake 的修复路径。

**顺带抓到旧文档两处错**(记进文档,不悄悄改):一是 check-added-large-files 的阈值旧手册写
"单个文件 > 1MB",按本机安装的钩子源码实测是 --maxkb 默认 **500**(500 KB)——已更正,并把
"本目录任何默认值都以实测或安装的钩子源码为准"写进 end-of-file-fixer 当反面教材;二是
**ui-golden 在旧手册里连小节都没有**,只活在配置注释里:除了"有基线不等于有门禁",这是另一种
失踪——有钩子、没文档。

手册 §2 改写为三节:迁移说明 / **旧编号 → 新文档 查号表**(历史引用靠它读,旧号按"冻结历史不
回改"保留)/ **跨钩子通用机制**:两阶段与换机必须重装 hooks 才带 commit-msg、stash 陷阱、
always: true 已废、改写型与只读型豁免不对称、增量拦截三段式且刻意没有 --write、没有 .map 是
"跳过"不是"通过"、改配置的验收是看目标钩子出现在输出里且不是 Skipped、新增门禁三步必须做
注入自证且"限制与盲区"不许留空,另有快速自查入口。手册 707 行 → 273 行。

引用**按内容**重指(只核编号存在与否不够,同号小节会被当本文自引用静默放过):编号规范、
scripts/README(顺带更正那里过时的 warn-only 说法,早已关闸为增量拦截)、RAM 使用分析与优化
方案、测试开发标准流程、lvgl-simulator skill、AGENTS 文档表(拆成"每门禁一篇详解"与"通用
机制"两行)、.pre-commit-config.yaml 头注释、docs/README.md 描述、手册自身 §5.5 与 §6 的两处
内部引用。另:表格里的裸节号会被 doc-links 判成本文自引用,查号表统一写成不带 § 的形态。

写说明过程又被自家门禁拦一次:gitlint 篇里引用报错样例时写了两个规则号字面量,被判形态不合规
→ 改成中文描述,没有往台账里塞 reason 解决。

验证:纯文档与注释改动,不动代码,Program Size 不变,故不跑 @build/@test;
check_doc_links --all --sections-strict **171 篇 0 问题**;id-check 增量拦截通过;
pre-commit run **RC=0 / 13 项 Passed**;28 个改动文件字节卫生 **0 异常**(无 BOM、无 CR、
无 U+FFFD、均 LF、无行尾空白)。
2026-10-23 11:44:21 +08:00
wandering1 871ace422b docs(门禁说明): 统一第八节标题措辞,新增模块单测的入口写在标题上 2026-10-23 10:55:34 +08:00
wandering1 dc44c1e54e docs(门禁说明): 补齐 23 篇逐项门禁文档,每种门禁一篇含限制与盲区
用户诉求原话:"现在门禁都在干什么,我一概不知。" 原有文档回答"有哪些钩子、怎么配",回答不了
"这一条到底在拦什么、什么时候会骗我"。本目录按**每种门禁一篇**补齐 23 篇(39 个 hook id 全覆盖:
16 个主机单测是同机制 16 实例合一篇;cppcheck 与 test-cppcheck 是同工具两套范围合一篇),
文件名用 hook id,与提交输出里的名字一致、可 grep。

八段固定结构:查什么 / 为什么要它(拦下过什么真事)/ 触发面 / 报错怎么读 / 好处 /
**限制与盲区** / 豁免与基线口径 / 改门禁本身要注意什么。最后一节是这批文档存在的理由——
只看"拦了什么"的文档会让人以为绿就是对的;本仓三个增量拦截门禁(layer-deps 6 条、bsp-err 38 条、
id-check 台账 640 处)的绿灯含义只是"没比上次更差"。README 另立"三种拦截姿态"一节:改写型 /
硬拦截 / 增量拦截,分清才知道报错时该改代码还是该还基线。

有实证的地方全部带数字,没实证的地方直接写"预防性、未记录到实际拦截案例"(不编故事):
· cppcheck:把 simulator/ 纳入受检后立刻揪出 sim_syslog.c 真实空指针;test/ 曾有 s_rx_buf
  使用先于声明;**并记入两条实测负结果**——它对 14 处丢弃 HAL_SPI_* 返回值与"未初始化局部量
  进 P_FAIL 判定"一条不报(这正是 consume-result 的由来)。
· lv-conf-sync:漂移导致 64,576 B 死代码入库(旧镜像的 23.5%),且 golden 跑的是模拟器那份配置。
· cubemx-survival:2026-09-29 一次重生成覆盖 4 处定制(P0×1+P1×2+P2×1),且不报编译错。
· registration-check:rs485_task.c、lftpd_log.c 两处都没注册 = 从不参与编译的死代码;第四类
  "页面描述了但没接进 s_defs[] 表"编译链接全过,前三类完全照不出。
· test-suite-switch:实测已提交的 HEAD 里 TEST_SUITE_CH395F 就是开着的,.map 含 34 处 test 代码。
· task-table:2026-09 两张表都比源码落后一大截(写 2048/4096/…/16384,实际 1024…512)。
· mem-numbers:2026-09-26 复核,文档写 82,048 B / 62.6%,实际 87,040 B / 66.4%;Flash 写
  249,432 / 23.8%,实际 261,932 / 25.0%。
· ui-golden:注入 .top_y +3 像素 → golden FAIL 点名"1366 px 差异"、退出码 1;并把"有基线
  ≠ 有门禁"与"逼人 --no-verify 的门禁等于没有门禁"两条选型依据写进去。
· check-yaml:本仓已移除的 always 键只告警 Unexpected key(s) 然后忽略——配置"看起来生效其实没
  生效"而本门全绿,所以验收方式是实跑看目标钩子是 Passed 还是 Skipped。
· no-mojibake:types: [c] 只管 C/H,**文档侧乱码无人管**——覆盖面与需求错配定性写进限制一节。

盲区写法示例(每篇都要求写真实限度,不留空):layer-deps 只查直接 include、按 basename 匹配、
不查是否真的用到;font-charset 管不到运行期拼出来的字符串与配置文件里的字;host-unit-tests 用宿主
编译器、桩是第二份真相、include_dirs 手写、没有对应钩子的模块等于零回归;mem-budget 的 92% 是管理
阈值不是安全余量、.map 缺失即"通过式跳过";registration-check 不看条件编译与注册指向是否正确;
coding-standard 的 CODE-RULE-17 在无配对头时跳过、规则编号 14 作废不回收。

写文档过程自己被自家门禁拦了两轮,都按规矩改而不是绕过:
① doc-links 拦下我自己——我在解释"写 §数字 而没紧邻文档名会被判成本文自引用"这段话里,正好
   就犯了这个写法(两处),另有一处示例输出未标记;改成正例写法 + [示例] 标记。
② id-check 拦下 7 类新账:示例报错行里的号、档号引用未紧邻文档名、说明"裸形不合规"时自己写了
   裸形。全部改成平实描述或补文档名、给样例加 [示例],没有一条是往台账里塞 reason 解决的。
这两次本身已写进相关篇目的"限制/变更记录",是这套文档最实在的注脚。

同步:手册 pre-commit使用说明.md 的 §2 顶部加指针(逐项详解已迁往本目录;两处收敛为"通用机制 +
总览 + 指针"排在 docs/门禁说明/README.md §4 的下一步,并注明收敛要按内容重指既有引用——只核编号
存在与否不够);README 索引 23 行状态全部置为已写、文档列改为可达链接。

验证:纯文档,不动代码与构建产物;check_doc_links --all --sections-strict **171 篇 0 问题**
(新目录 24 个文件的内部链接与跨文档引用都纳入判定);id-check --strict 增量拦截通过;本次受影响
文件 pre-commit run **RC=0**;24 个文件字节卫生 0 异常(无 BOM、无 CR、无 U+FFFD、均有尾换行)。
2026-10-23 10:54:33 +08:00
wandering1 3488617bc9 docs(门禁说明): 新建逐项门禁文档目录,先落索引 README 与 bsp-err 首篇
用户的原话是:"现在门禁都在干什么,我一概不知。" 现有文档只回答"有哪些钩子、怎么配"
(docs/代码规范/pre-commit使用说明.md),回答不了"这一条到底在拦什么、什么时候会骗我"。
故新开一个目录,**每种门禁一篇**,八段固定结构:查什么 / 为什么要它(拦下过什么真事)/
触发面 / 报错怎么读 / 好处 / **限制与盲区** / 豁免与基线口径 / 改门禁本身要注意什么。

**最后一节是这批文档存在的理由**:只看"拦了什么"的门禁文档会让人以为绿就是对的。而本仓
三个增量拦截门禁(layer-deps 6 条、bsp-err 38 条、id-check 台账 640 处)的绿灯含义只是
"没有比上次更差"。

索引按 39 个 hook → 23 篇规划(16 个主机单测是同机制 16 实例,合一篇;cppcheck 与
test-cppcheck 是同工具两套范围,合一篇),文件直接用 hook id 命名,与提交输出里的名字
一致、可 grep。README 另有一节"三种拦截姿态"——改写型 / 硬拦截 / 增量拦截——分清这三类,
报错时才知道该改代码、还是该还基线。钩子清单、files 范围、pass_filenames 全部从
.pre-commit-config.yaml 实测解析(39 个 id:官方 5 + gitlint + local 33),不是手抄。

本次交付:索引 README + 首篇 bsp-err.md。首篇里把今天踩过的坑全写进"限制与盲区",共 10 条,
例如:只扫 Drivers/BSP(App 层同类问题仍无人管);consume-result 只认单行整句的裸调用,
跨行写的调用按正则形状测不到;must-use 名单靠"返回类型 + 注释关键词"启发式,注释不写
"错误码/失败"就漏、改名就能绕开规则;HAL 族是固定清单,清单外的返回 HAL 状态的函数不在
名单里;return-doc 的判据是"注释块里出现『返回值』字样",**散文里出现这三个字也会放行**
(本仓真发生过两条靠函数说明里的关键词误判通过);基线键带出现序号,--prune 只报"消失"、
不会报告某处只是搬了家;改标签名等于改基线键,必须原子完成。在册数量与构成按当前基线
逐条数出来写(38 = consume-result 37:gd5f2gq5ue.c 28 含 14 处 HAL 丢弃 + 14 处自家原语、
ui_net_sink.c 6 全为 net_close、rs485.c 2、nand_ftl.c 1;err-type 1 为 bool
ui_content_page_open())。

同步指向:AGENTS.md 提交门禁一节改为"每种门禁一篇详解 → docs/门禁说明/README.md",通用
机制仍指 pre-commit使用说明.md;docs/README.md §7.1 增加索引行;.pre-commit-config.yaml 的
bsp-err 注释块顺手更正(原文还写着"三条规则""现 1 条""只能靠书写约定兜着",均已过时)。
迁移状态用索引表的"状态"列跟踪;全部写完后 pre-commit使用说明.md §2 收敛为通用机制 +
总览,同一事实只留一处(docs/文档规范.md 的要求)。

验证:纯文档与注释改动,未触碰构建产物;check_doc_links --all --sections-strict 149 篇
0 问题(新目录的相对链接、跨文档章节引用都能判定);bsp-err 仍 38/38/0;本次受影响文件的
pre-commit 全绿 RC=0;两份新文档字节卫生 ok(无 BOM、无 CR、无 U+FFFD、有尾换行)。
2026-10-23 09:48:26 +08:00
wandering1 2575177e81 refactor(gate): bsp-err 四项检查改用自描述标签,废掉 R1~R4 这组撞车代号
纯改名,检查行为一个字没动:规则逻辑、阈值、豁免形状全部原样。

改名原因(用户一句「R4 到底是什么」问出来的):R1~R4 是我在 check_bsp_err.py 注释里
自造的序号,而这个裸形在本仓另有含义——R1~R8 已在 scripts/id_registry.json 登记为
"专项风险"的文档内编号(保护规划与看门狗的风险表都在用),带连字符的那一族又是审查
报告的可靠性 / 内存优化项编号(编号规范 §1 的"连字符承载语义"一行正是这么写的)。
**同一个裸形四种意思**。最刺人的一点:编号门禁**不会报**这种撞法,因为编号规范 §4.3
已把这些裸形声明为"同号不同义"、走 collides_with 白名单,校验 4 不计事故——同一天里
我起的另一组自造号被当场拦下、这组代号却一路绿灯,差别不在我做对了什么,而在白名单
挡的是"事故"、不是"可读性"。

新名同时是基线键的第二字段(格式 文件|标签|名字):
· err-type        错误通道不外泄(对外接口不拿 HAL_StatusTypeDef / bool 当成败)
· timeout-name    超时宏不取负值
· return-doc      公开函数必须写「返回值:」
· consume-result  返回值不得裸丢弃(须接住或显式 (void))
老名对照:R1=err-type、R2=timeout-name、R3=return-doc、R4=consume-result。

同步面:脚本里的规则码与 38 条基线键的字段**一起换**(中途不跑 --prune,否则改名会被
读成"38 条全还清 + 38 条新账"),换完 --emit-baseline 复核仍是 38/38/0、RC=0。现状类
文本全部换新名:AGENTS.md、pre-commit使用说明.md §2.27(新增"检查项叫什么"一行含老新
对照与撞号说明)、bsp_err.h 边界纪律、报告 §0.4 / §9 / 附录 A,以及代码注释里的 5 处
(gd5f2gq5ue.c/.h、nand_ftl.h、ui_menu_tab.h)。已提交的变更记录行里的老名按"冻结历史
不回改"处理,以 §2.27 对照行为准。ui_menu_tab.h 带 BOM,Edit 又剥掉、已按原样补回。

写说明文字时又被编号门禁拦了一次:我为了举例"老名撞车",在那段话里写了两个编号字面,
被判"未紧邻文档名 / 未登记前缀"共 3 类新账——连批评自造编号的段落本身都在自造编号。
改法是不写号、只描述形态并指向权威条目。

验证:@build **0 错 0 警告**,Program Size 与上一笔**逐字段相同**(Code 213,676、RO
18,860、RW 1,188、ZI 131,500)→ 证明确为注释与文案,故不跑 @test(无指令变化);
pre-commit run 全绿 **17 项 Passed**;check_doc_links --all --sections-strict 147
  篇 0
问题(中途我自己写的 §4.3 没紧邻文档名,被判成本文自引用,已补全路径);9 个文件字节
卫生 0 异常(UI 头 BOM 仍在、无 CR、无 U+FFFD、均有尾换行)。
2026-10-23 09:36:01 +08:00
wandering1 d9b887c0af feat(S-5 R4): 返回值不得裸丢弃成为机械检查,并修掉照出的第一处 ECC 静默降级
B 的第一步(加规则)与第二步(修最小最值的那处)。用户裁定"B 按你说的进行"。

**为什么只能项目侧自己做**(两条实测负结果,已写进 §2.27 与 bsp_err.h 边界纪律 4):
· ARMCC v5 对 __attribute__((warn_unused_result)) 不生效(凌晨在 rs485_transmit 上实测:属性
  在位、调用点裸调不接返回值,构建仍 0 错 0 警告);
· cppcheck 也照不出来——按本仓自己的 --enable=warning,style,performance,portability 跑
  gd5f2gq5ue.c,对 14 处丢弃 HAL_SPI_* 返回值、以及"未初始化局部量直接进 P_FAIL 判定"
  **一条不报**(它假定 gd5f_read_status(&status) 会写出参,而失败路径根本不写)。
结论:**丢弃底层传输的返回值,今天没有任何工具会响**。

**R4 上线即照出 37 条存量丢弃**:gd5f2gq5ue.c 29(14 处 HAL + 15 处自家原语)、net_socket.c 8、
ui_net_sink.c 4(net_close)、rs485.c 2、nand_ftl.c 1。按增量拦截老规矩入基线、只减不增——
这是覆盖面扩大新暴露的历史债,不是新增违规;基线 19 → 1 → **38**。

**R4 自己被修三次(逐条记下,都是"规则比我想的更脆")**:
① 首版漏判"裸调用行尾带注释"这种写法(正则的 \s*$ 卡死),而那恰是最常见的"带说明的丢弃"
   → 补可选注释分支,并做 6 条正/反例自检;
② 把 net_set_errno 八处误判成丢弃(它是 setter,返回 int 是状态回声不是调用成败)→ **按真实
   反例把规则收窄**(谓词 + setter 两类豁免),而不是塞进基线洗掉假阳性;
③ must-use 名单只从**头文件**取,gd5f_set_feature 这类 static 原语整个在视野外——而下面那处
   真窟窿恰恰是 static、靠人工才发现 → 名单扩到 .c 内 static 定义(同一套启发式),补完立刻
   又照出 gd5f_bbt_save#1:**运行时标坏后 BBT 落盘结果被丢弃 = 坏块记账可能没持久化**,已入
   基线记为待还,本步不动行为。
另修一处自己的排版误判:R3 原本要求注释块出现字面"返回值",规范 V1.0 允许列对齐写法
「返 回 值:」→ 改为忽略空格匹配(门禁不该规定排版)。

**修掉的第一处真窟窿**:bbt_rebuild 的注释写着"无论扫描成败都须恢复 ECC,否则后续 I/O 在无
ECC 下运行",而它下面 gd5f_set_feature(0xB0,…) + gd5f_wait_busy(100) **返回值全丢**:SPI 一抖
ECC 就保持关闭,且 gd5f_check_ecc() 在 ECC 关闭时恒读"无 ECC 错"——整条链再没有任何信号。
补法复用 init 路径的 E-10(写完回读 B0h 验 ECC_EN 位真生效,"写了但没生效"与写失败同罪),
并把"关 ECC 失败"改成**立即中止**(此时 ECC 仍开着,是安全侧;带着 ECC 去扫 spare 反而会把
厂标好块误判成坏块,"重建 BBT"变成制造坏块)。恢复失败时按 (void)gd5f_bbt_load() 回读盘上
副本,避免 RAM 留着"比盘上更新"的表而下次上电又退回旧表。

**一条定位纠正(差点把修复记大)**:查链接映射发现 Removing gd5f2gq5ue.o
(i.gd5f2gq5ue_bbt_rebuild), (604 bytes)——该函数在产品镜像里**根本没被链接**(产品构建无调用
者),只由 ENABLE_STORAGE_RECOVERY_TESTS 的恢复构建经 testTask 调用。所以本次修的是**恢复流程
自身的安全**(跑恢复时若 ECC 没恢复,设备会带着"已恢复"的结论在无保护状态下继续跑),不是
现场日常路径;相应地镜像 Code 的 +28 B 也不是它带来的(未逐字节追,线索是
gd5f_private_get_feature 由单调用点变多调用点、内联决策可能变化)。验证因此不能只跑产品构建:
临时打开 TEST_SUITE_STORAGE + ENABLE_STORAGE_RECOVERY_TESTS 编一次(0 错 0 警告且该函数不再
出现在 Removing 列表),随即还原两处开关并重编产品构建核对(test/board 无残留差异)。

**编号门禁把我自己的自造编号拦下了**:我起初用 B-1/B2/B3/B4 标记这批缺陷,id-check 报"未登记
前缀"13 处(且 B2/B3/B4 会与真实里程碑号撞号)→ 全部改写成平实语言。这是那条"注释与文档里
不许用没人检索得到的内部阶段编号"的规则第一次在提交前拦住我,规则是对的。

验证:@build 0 错 0 警告,Code 213,648 → **213,676**、RO 18,860、ER_IROM1 **232,536 B
  / 22.2%**
(ROM 合计 233,724 B;RAM 逐字段不变);ram_report 重生成 + ram_baseline.py --update + 配图常量
同步(mem-numbers Passed);@test **PASS**(到 net init: OK 682 ms、[ERR] 0);R4 两处**探针
实证**(临时加一条裸调用 → 立刻成为新增并 RC=1;还原 → 38/38/0);--prune 点名本次还掉的 2 条
已删;pre-commit run 全绿 15 Passed;check_doc_links --all --sections-strict 147 篇
  0 问题;
id-check --strict 增量拦截通过。

文档:AGENTS.md 与 .pre-commit-config.yaml 的 bsp-err 口径(四条规则、基线 38);
pre-commit使用说明.md §2.27(R4 全量口径 + 两条负结果 + 名单来源与豁免形状 + 单行限度);
bsp_err.h 边界纪律 4;报告 §0.4 / §5.1 / §9 / 附录 A + 变更记录;存储维护与容量恢复.md 新增
§4.3(运维判据:出现 ECC restore FAILED / ECC not enabled 即本次恢复**不算完成**,须断电重启
重走 init,不许接着 nand_ftl_format() 当作已修好;并记明该函数产品镜像不链接)——顺带修该文
结构缺陷:原 ## 5. 变更记录 误插在 §4.1 与 §4.2 之间,会让 §4.2 变成 §5 的子节。
2026-10-23 09:15:41 +08:00
wandering1 cc2966319a docs(S-5): 补齐 16 条返回值约定,bsp-err 基线 19 → 1,并修门禁自身误判
A(还债)先行,B(GD5F 的 SPI 失败被吞成成功)另波处理。本笔不改行为:@build 的
Program Size 与上一笔逐字段相同(Code 213,648、RO 18,860、RW 1,188、ZI 131,500)。

**补齐 16 条「返回值:」约定,bsp-err 存量基线 19 → 1**:
· gd5f2gq5ue.h 九个 SPI 原语(wait_busy / write_enable / read_status / page_read /
  read_from_cache / program_load / program_exec / block_erase / check_ecc)
  写全规范五要素,
  并把**每个函数能返回哪些码**写死:哪一个是"可重试"的 GD5F_WAIT_EXPIRED、哪一个才是
  器件自认坏块的 P_FAIL/E_FAIL、失败时出参为什么不可信(E-42 同族)。
· nand_ftl.h 从零散三行补成带模块头,并明写 **nand_ftl_init() 的 −1 抹掉了失败类**:
  分类目前只在失败点进日志,要按类分支需要 C4 快照(现无消费者故未做);同时警告"不得
  把 −1 解读成卷坏了可以重格式化"(E-31 的教训正在这条上)。
· ui_menu_tab.h 五个表查询 API 由行尾短注释升级为完整块(含"越界返回 false 是保守取
  只读"、"宁可按一张错表导航失败也不要按错表导航成功"这类判断依据)。

**修掉门禁自己的一条误判口径**(本笔最该记的):R3 原本要求紧邻注释块里出现字面"返回值",
而规范 V1.0 允许列对齐写法「返 回 值:」。后果有两层:① 合规写法被判违规;② 反向更糟——
gd5f_write_enable / gd5f_read_status 两条**早就"通过"了**,原因竟是它们的**函数说明**里顺口
写了"返回值"三个字,而不是真有一行返回值说明。**一条会因为散文里出现关键词而放行的规则,
等于在奖励运气**。修法是在块内忽略空格后再匹配关键词(排版选择不是违规),并在注释里写明
为什么不许把"必须写成不空格的形式"写进规则。

剩余 1 条刻意保留:bool ui_content_page_open()。它与"补注释"不是一档事——要改签名、要处理
3 个调用点(ui_menu.c:138/192/196 **今天三处全部丢弃返回值**,即"按 ENTER 开页失败是静默的"
这件事现在没人知道)、且 UI 源码双端编译(模拟器同一份),验证面是"重建模拟器 + 像素回归 +
真机逐页进菜单"。这不是还债,是独立一波改动,留档待决策。UI 三个文件带 BOM,Edit 会静默
剥掉 → 已按原样补回 3 字节(AGENTS 铁律:丢 BOM 后 ARMCC 按 CP936 解码,中文报 #870-D)。

**更正我自己写进文档的一条错话**:S-5 各条里我把 GD5F_SPI_TIMEOUT 100 记成"自旋圈数(改
分频时长会变)"。这次逐行核它 12 处用法,**全部是 HAL_* 超时参数或 HAL_GetTick() 差值 →
它也是毫秒**。真问题降级为"单位没写进名字(缺 _MS 后缀),读代码必须回实现里查单位"。
osWaitForever 与 HAL_MAX_DELAY 同为 0xFFFFFFFFU、以及 *_BUSY_TIMEOUT = -2 其实是返回码这两条
仍然成立(后者已改)。现状类条目(报告 §0.4、bsp_err.h、pre-commit使用说明.md §2.27、脚本
文件头)已全部改准;**已提交的变更记录行仍写着旧说法——按 §8.1 冻结历史不回改,以本笔新增
的更正行为准**。

验证:@build 0 错 0 警告(尺寸同上,故**不跑 @test**——无指令变化,跑它没有新信息);受影响
文件的 17 个钩子全 Passed(含 ui-golden 像素回归、cppcheck、编码规范、bsp-err 1/1/0 且
--prune 干净);check_doc_links --all --sections-strict 147 篇 0 问题;id-check
  --strict 通过;
9 个文件字节卫生 0 异常(UI 头 BOM 仍在、无 CR、无 U+FFFD、无行尾空格、均有尾换行)。
口径同步:AGENTS.md、.pre-commit-config.yaml、pre-commit使用说明.md §2.27 的基线计数 19 → 1;
报告 §0.4 / §9 / 附录 A 与变更记录两条(还债一条、更正一条)。
2026-10-23 08:25:58 +08:00
wandering1 78248a1cdf feat(S-5): 收编 SD2506/TPAFE 并给 dhara 失败分类,门禁基线 19→17
前一笔立契约与门禁(`Drivers/BSP/bsp_err.h` + `bsp-err` 钩子 + RS485 试点);这一笔按用户
指定顺序 C3 → C1 → C2 把三个模块真正收编,并让存储失败第一次变得可读、可分类。

**C3 · SD2506 整模块(11 个公共 API + 6 个内部 helper)**:三枚私有宏 `SD2506_OK`/
`SD2506_ERROR`/`SD2506_I2C_ERROR` **删除而不是留别名**——留别名等于新代码还能用旧形状,
把迁移洗掉;编译器替我们找全所有调用点。新增 `sd2506_err_from_hal()`,**把 I2C 超时从
"和没人应答共用一个 −2"里拆出来归成 `BSP_ERR_TIMEOUT`(可重试)**——SD2506 是启动链第一站、
I2C1 上电时序敏感,这是本波唯一真正的语义改善,不是改名。`sd2506_init` 的 charge 寄存器
**回读校验不符**原返参数类错误,改归 `BSP_ERR_IO`(那不是调用方的错)。

**C1 · 同形两义**:`GD5F_BUSY_TIMEOUT` → `GD5F_WAIT_EXPIRED`(**值 −2 不变**,纯消"名字叫
TIMEOUT 其实是返回码")。**GD5F 私有码集整体收编刻意没做**:169 处引用里 48 处在板级套件、
还要动 Trap 记录用词,属另一波带真机+套件回归的工作,不夹带。TPAFE5160 收编 23 处:
`BUSY_TIMEOUT → BSP_ERR_TIMEOUT`、`ERROR → BSP_ERR_PARAM`(**同值 −1,行为零变化**)、
`FRSTDATA_ERR → BSP_ERR_NO_DEVICE`(E-44 的本义就是"芯片没驱动 DB 总线",归到器件无响应比
私有 −3 有用,且避开与 `BSP_ERR_TIMEOUT` 撞值)。实测 `tpafe5160.o`/`gd5f2gq5ue.o` 的 Code
增量为 **0**——改名不产生一条指令。

⚠ **`tpafe5160_snapshot()` 刻意不套成败码**:它是 1=有新帧 / 0=无新帧 / −1=参数错 的三态
**状态**返回,0 不是错误而是"这一拍没有新数据",强行收编会把慢消费者的正常路径变成失败。
顺带订正我上一版在 `bsp_err.h` 的断言——"本层只有 `net_*` 把数量与错误挤同一返回值"是错的,
`snapshot`/`adc_input_poll` 就是第二族;两族现都已写明并各注保留理由。

**C2 · FTL 坍缩点分类**:`ftl_flush_cache`/`ftl_read_page` 不再把 dhara 失败压成
  `GD5F_ERROR`,
按**处置差异**分三类,并在最早同时知道 LPN 与原始码的位置就地打印:`ECC`/`TOO_BAD` →
`BSP_ERR_CRC`(页级,重试或换页,不代表卷坏);`JOURNAL_FULL`/`MAP_FULL` →
`BSP_ERR_NO_SPACE`(介质健康但块池/日志见底,此时重格式化只是把问题推回去);
`BAD_BLOCK`/`RECOVER`/`CORRUPT_MAP`/越界 → `BSP_ERR_IO`(完整性故障,保卷 + 上报,不许自愈
重格式化)。这与 **E-31** 是同一条纪律的两端。可读名用自建短表:**本仓 dhara 没有编
`error.c`,用 `dhara_strerror()` 会直接链接失败**。`disk_read`/`disk_write` 仍回
  `RES_ERROR`
(FatFs 契约);要让 defaultTask 卷健康探针按类分支还差跨调用存活的快照 → C4 待还。

**两次靠核实拦住的自我错误**:① 我一度认定 `DBG_ERROR("... err=%d", err)` 是把结构体塞进
varargs 的格式 bug——核 `Lib/dhara/error.h` 后确认 `dhara_error_t` 是 `enum`(标量),那行日志
本来合法,改了反而是引入错误;② `bsp-err` 一次 `replace_all` 越界把 8 处 **GD5F 码空间**的
判据改成 `!= BSP_OK`(数值同为 0、无行为变化),但那正是"把两个码空间混起来",已逐一还原。
`cppcheck` 亦抓到 `cls` 声明作用域过宽,已收进失败分支。

**验证**:`@build` 0 错 0 警告(`Code` 213,648、RO 18,860、`ER_IROM1` 232,508 B /
  22.2%、
`ROM 合计` 233,696;**RAM 三项零增量**);`@test` PASS(692 ms,烧入戳 `Oct 23 01:35:32`)
且逐条读到被改模块的锚点:`SD2506 init: OK (ret=0)` 与 ID/时间/温度/电池四条读回正常、
`ADC init: OK`、`NAND init: OK`、`Map resumed: 14094/97504`、`adc=` 帧计数持续递增(证明
snapshot 三态未被改坏)、`[ERR]`/Fault/FAIL 0 条。主机单测:系统时钟 100/100、dhara NAND
移植层 149/149(两者都因本次改动需补 `Drivers/BSP` include 路径,补后在钩子内实跑 Passed)。
`pre-commit run` 全量 RC=0(20 个实跑钩子 Passed);`bsp-err` 基线 **19 → 17**(还掉两个
`*_BUSY_TIMEOUT = -2`)、`--prune` 干净;`doc-links --all --sections-strict` 147 篇
  0 问题;
`id-check --strict`、`layer-deps`、`mem-numbers`、`mem-budget`、`ui-golden` 全过;33
  个文件
字节卫生 0 异常(无 CR / 无 U+FFFD / 无行尾空格 / 均有尾换行)。

**口径与数字联动**:新增 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.4(三类码与处置
理由);`pre-commit使用说明.md` §2.27 与 `AGENTS.md` 基线计数 19 → 17;TPAFE README、
GD5F 测试指南与 Trap 记录均**保留旧名留痕**以便对旧日志;配图桶按 .map 对象级增量重算
(`dhara/FTL` 10,931 → 11,222,+291 全部落在 `nand_ftl.o`;应用代码/UI +36 落在
`app_main.o`;其余散量入"其他"7,557;桶和 = Code+RO 断言通过、文本溢出 0);
`ram_baseline --update/--write`、`ram_report_20261023.md` 快照、报告 §1.3/§5.1/§5.4
  与
部署文档 §3.3 数字全部同步;报告 §0.4/§9 的 S-5 状态改为 🟡 主体已落地。
2026-10-23 07:42:02 +08:00
wandering1 8d3349c587 docs(存储维护): 记录自检台账清零与 flushchk 孤儿清理,修表格断行
设备侧动作(不动仓库内容)与文档同步,一并落在这里。

**① 自检台账清零,重建 S7 审计基线**:`/log/selftest.log` 里 29 条历史记录(**16 × `watchdog`
`reset` + 13 × `heap low water`**,跨度 `2026-10-10 09:24:12 → 2026-10-14
  06:55:02`,看门狗
引入期与堆迁移期留下,两案后来都已结案)按用户决定删除。**安全判据**:`sys_log_mcu.c` 是每条
记录开-写-关(`f_open(FA_WRITE|FA_OPEN_APPEND)` → `f_write(64)` → `f_close`),**不持常驻
句柄** → FTP 侧 `DELE` 不会撕裂,下次真有故障时自动重建。删前把导出副本留档(1856 B = 29×64)。

**② 顺带清掉一个孤儿**:`/log/flushchk.bin` 32,768 B,全库零引用——已退役的 flush 诱导实验
留下的(`sys_log_mcu.c` 注释里"旧称 flush 诱导"就是它)。**删前先 RETR 出来验过内容**:256 种
字节值全部出现、前 64 字节全 `FF` → 一份 0..255 图案的写盘校验填充,无任何产品语义;留档后再
`DELE`。判据同 §4.1:**先知道删的是什么,再删**。

**清理后 `/log` 只剩三条、全是产品数据**:`run.log` / `wdev.log`(掉电取证)/ `soe.log`
(B9 SOE)——这三条是固件在写的活文件,容量护栏由固件自己管,**别手删**,文档里也这么写。

**验收(两条判定路径都要过才算基线干净)**:
· `--require-selftest-pass`(默认 `this-run`)→ PASS,`SELFTEST_BASELINE: 既有记录 0 条`
  (`status=absent`);
· `--selftest-scope all`(累计硬口径)→ **PASS**:`selftest: no fault
  record`——**清零前这条是
  `29 fault record(s)` FAIL**,也就是那条判据在台架上从来没可能通过;现在真的能用了。
· 之后再跑 `@test` → PASS(699 ms、`[STACK] heap` 等锚点齐全、无 `[ERR]`/Fault)。
设备侧无此文件时 lftpd 回 450,被按"台账干净"处理——这条映射过去是假绿来源,现在是有意的正常
路径(见 `../架构设计/STM32F4-Base存储架构说明.md` §10.3 与 S7 判定表)。

**修自己的排版 bug**:上一笔(`23daa3d`)把 §4.1 的变更记录行拆成了三行——Markdown 表格行必须
单行,会渲染成断裂的表。已合并;本次两条变更行(§4.1、§4.2)都是单行。

门禁:`check_doc_links.py --all --sections-strict` 146 文档 0 问题;`check_ids.py
  --strict` 增量
拦截通过(可判定 82.82%);文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格 / 尾换行。本轮无
固件与脚本改动,故不跑 `@build`(`@test` 已跑,镜像仍是 `23daa3d` 那笔的构建)。
2026-10-22 23:46:46 +08:00
wandering1 23daa3d9ad chore(台架): 删零引用的一次性探针、修可信判据工具登录、清理口径落文
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m34s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 21s
两件事一起做:把"没人用又已经失效"的一次性探针清掉,把"台架现场脏"这件事的口径写进文档。

**删除(5 个文件,全库零引用)**:`MDK-ARM/ftp_retr_abort_probe.py`(其职能已被
`test/host/ftp_abort_probe.py` 取代,Trap 记录引用的也是后者)与整个 `MDK-ARM/bench_diag/`
(4 个诊断脚本:abort_gap / pcref / recover / sink_recycle)。删除判据三条同时成立:① 全库
`git grep` 除自身外零引用;② 自 2026-10-11 设备启用 FTP 鉴权起,脚本里写死的匿名登录必然 530
→ 已经跑不动 11 天;③ 都是"跑一次拿现场"的诊断件,价值在结论而结论已沉淀进 Trap 记录。
需要时从 git 历史取回(`test/host/README.md` 与 `MDK-ARM/README.md` 都写明了这一点)。

**刻意保留并修好**:`MDK-ARM/ftp_freeze_probe.py`——它不是一次性件:
`CH395F_Trap_Records.md` 拿它当"177 轮 SHA-256 全匹配"的**可信复现配方**、
`BSD_Socket_API_使用指南.md` 把它列进排障判据、`Drivers/BSP/NET/lftpd/README.md` 也指它。
  删掉它等于删掉文档指定的真值工具。改为 `--user/--password`(默认出厂 admin/admin,模块常量
`FTP_USER`/`FTP_PASS`,两处登录点都读它),实测一轮:`ROUND 1 size=4096 stor=OK retr=OK
hash=MATCH`、`SUMMARY pass=1 fail=0`——修前它连 530 都过不去。

**台架设备清理(不动仓库内容)**:`/` 根目录实测残留 **27 个探针文件 / 262 KB**(`AB000x`、
`A000x`、`R000x`、`SZ2K~64K`、`E12A`、`DIAG_A`、`PP0~PP4`——正是刚删的那批一次性探针留下的)。
逐个 `DELE` 后剩 `LOG` + `CONFIG.INI` + `PROT_SET.BIN` 三条目。**判据:只删显式点名的文件,
绝不 glob**;保留清单必须含 `/config.ini`(网口 + FTP 鉴权)、`/PROT_SET.BIN`(保护定值 blob,
`PROT_SET_BLOB_SZ` = 176 B,删了等于定值清零)、`/log` 目录。清完两步验收:`@test` PASS
(630 ms,定值巡检与配置加载都在这条链上)+ `ftp_list_fill_test.py` PASS(29 行 / 1851 B /
完成回包 226,26 个新文件全列出)。口径写进 `docs/测试说明/存储维护与容量恢复.md` 新 §4.1
(明确它是"动作 D 清文件",与 A 重建 BBT / B 全片擦除 / C 容量恢复区分——那三个会丢卷内容)。

门禁:`py_compile`(freeze 探针)通过;`check_doc_links.py --all --sections-strict` 146
  文档
0 问题(删除的 5 个文件确认无任何文档引用);`check_ids.py --strict` 增量拦截通过(可判定
82.82%);`layer-deps` 通过;4 个改动文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。
本轮无固件改动,未跑 `@build`(设备侧清理后的 `@test` 已跑,镜像仍是上一笔的构建)。未推送。
2026-10-22 22:51:21 +08:00
wandering1 8b0ea17b71 fix(lftpd): LIST/NLST 满块续发——大目录不再被静默截断并回 550
现象:`ftp_test.py` 的 `LIST 150->226` 断言失败——1512 B 数据全收到了,完成回包却是 **550**。

**根因**(`send_list`/`send_nlst` 各一处):定案三把"整份目录一次性批量发送"落进 static 1536 B
缓冲时,顺手留下了"装满就 `break` 并回 −1",`cmd_list` 据此回 550。于是目录一旦超过一块,客户端
拿到的是**静默截断**的清单**外加一个错误码**——而清单看起来完全像"目录里就这些"。这比单纯报错
更危险:没有人会去数条目个数。

**台架实测(同一台设备、同一个根目录,改动前后各测一次)**:
· 改前:1512 B / **24 条目** / 完成回包 **550**(停在 A0007.BIN,后面 6 个文件被吞)
· 改后:1880 B / **30 条目** / 完成回包 **226**

**修法:满块续发**,而不是把缓冲区加大。新增 `list_batch_flush()`——装满一块就整块发出、清空
继续装,收尾再发最后一块。为什么仍要"批量":定案三的本体是实测结论——同一连接上背靠背 ~60 B
小段写会毒化 CH395F 的 TX(芯片报 SEND_OK 但对端全丢,无 RST 无 DISCONNECT),而 RETR 的 1 KB
满块连发安全;所以这里只发大块,绝不退回"每行一发"。单行真装不下时**明确失败**而不是少发条目
(8.3 短名 + 定长格式远小于 1536,真发生说明格式被改坏)。两条出口的 `lftpd_io_closedir` 收敛到
循环后单点:卷锁持有至 closedir,任何提前 return 都会漏锁。

**防回退**(关键):修完之后,**目录清空的设备上这颗雷再也暴露不出来**,所以补了
`test/host/ftp_list_fill_test.py`——自造 26 个 8.3 短名文件把清单撑过一块,三条判据(STOR 全 226
/LIST 全文里每个名字都在/完成回包 226 而非 550,用 ftplib `retrlines` 让非 2xx 完成直接抛),
跑完逐个 DELE 还原。真机实测 `收到 56 行 / 3544 字节`(跨 3 块,满块续发被真正走到)→ 三条全
PASS。用法登记进 `docs/测试说明/FTP_Test_Guide.md` §4.5,机理与增补写进
`docs/问题记录/CH395F_Trap_Records.md` 定案三。

**顺带记录的已知偏差(未修)**:`cmd_list`/`cmd_nlst` 忽略命令参数、恒列会话 CWD
(`client->directory`)。主流客户端都是 `CWD` + 裸 `LIST`,实际影响小,先记在案。

体量:`Code` 213,264 → **213,436 B(+172)**、RO 不变 → `ER_IROM1` **232,088 B(22.1%)
  **、
`ROM 合计` 233,276 B;**RAM 一字未动**(`RW_IRAM1` 75,264 B 57.4% / `RW_CCM` 57,376 B
  87.9%)。
数字联动照做:`data/ram_report` 重跑、`ram_baseline --update/--write`、
  `check_mem_numbers --write`、
FLASH 配图常量重算重生成(桶合计 = 232,088 与 `Code+RO` 相符)、报告 §1.3/§5.1/§5.4 与部署 §3.3。

门禁:`@build` 0 错 0 警告;`@test` PASS(692 ms);`ftp_test.py` → **TEST
  PASSED**(含此前失败
的 `LIST 150->226`);`ftp_up_dl_test.py` → TEST PASSED;`ftp_list_fill_test.py`
  → TEST PASSED;
`doc-links --all` 146 文档 0 问题;`id-check --strict` 增量拦截通过(可判定 82.82%);
`mem-numbers` / `mem-budget` 通过;改动文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。未推送。
2026-10-22 22:41:31 +08:00
wandering1 4c21cff753 fix(lftpd): 对端行为不再打 [ERR]——新增 lftpd_peer 级别并划清级别边界
现象:抓自检台账基线那次 FTP 会话一结束,设备就打 `[ERR] [FTP] error reading next command`,
本次 `@test` 直接 FAIL(Forbidden 含 `[ERR]`)。查下去发现 lftpd 把**全部**异常路径一律按
error 级打,其中九处其实由外部输入决定:客户端关窗口、连上就走、PASV 后不建数据连接、
运维改 IP 触发监听重建。两条代价:① 任何人"连 21 端口一下就断开"就能把一次启动测试打成
FAIL;② 同样的动作可以**无限制造固件级 ERROR 日志**——日志面与门禁面都可被外部驱动,
工业现场过不了审。

**做法**:`private/lftpd_log.h` 新增 `lftpd_log_peer()`(info 级,带 `[FTP]` 前缀),而不是
就地改成 `DBG_INFO`——让"谁才算真故障"在读代码时一目了然,也防止后来者顺手把对端行为升回
error。降级的九处:控制通道被对端关闭(原 `error reading next command`,改为
`control channel closed by peer (ret=%d)`,措辞原先把正常收尾写成故障、误导过排查方向)、
发送 220 前对端已走、`listener rebuilt`(网络重建,原因由 accept 层自己记)、四处
`data accept failed`(PASV 后没来数据连接)、`accept timeout`。

**刻意留在 error 的**:create/bind/listen/取端口失败、写通道失败、文件打开失败、
`DOWNLOAD/UPLOAD FAILED`(对端中途退出确实让一次传输没走完 = 数据完整性事件,
`../问题记录/CH395F_Trap_Records.md` 的中止家族要靠这些锚点定位)、
`data listener disarmed`(控制连接还活着而我方数据监听已不在服役 = 本侧异常,就地注明)。

**实测划清的边界**:客户端**干净收尾**(发 `QUIT` 或 FIN 半关)现在整条链路没有任何 `[ERR]`;
但 **RST 硬拆**(`SO_LINGER` 清零 / 进程被杀 / 拔线)仍会留一条 `[ERR][NET] sockN TIMEOUT`——
那是 net 层的套接字超时锚点,网络自愈架构判据要读它,不在本次降级范围。所以"采集窗口内别跑
`data listener disarmed`(控制连接还活着而我方数据监听已不在服役 = 本侧异常,就地注明)。

**真机验证**:`@build` 0 错 0 警告;`@test --require-selftest-pass --device-ip` →
  **PASS**
(基线 29 条 → 本次无新增;`net init: OK` 726 ms);专门写了 RST 复现脚本(登录后 `SO_LINGER`
清零直接关)→ 串口得到 `[FTP] control channel closed by peer (ret=-1)`,**不再是 `[ERR]` 行**;
同一份日志里 QUIT 收尾的那次会话完全没有错误行。

体量:`Code` 213,236 → **213,264 B(+28)**、`RO` 18,580 → **18,652 B(+72)**(措辞变长),
同一份日志里 QUIT 收尾的那次会话完全没有错误行。
`ZI` 131,500、`RW_IRAM1` 75,264 B 57.4% / `RW_CCM` 57,376 B 87.9% 全不变)。数字联动照做:
两份 `data/` 快照、`ram_baseline --update/--write`、`check_mem_numbers --write`、FLASH
  配图
常量重算重生成(桶合计 = 231,916 与 `Code+RO` 相符;越界复查 0)、报告 §1.3/§5.1/§5.4 与
部署文档 §3.3 同步。

口径落文:新增 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.3「FTP 日志级别划分:对端行为
vs 固件故障」(含两类的判据表与边界),并更新 `docs/测试说明/自动化测试流程.md` 的采集窗口警示。
顺手把 `private/lftpd_log.h` 工作区的 CRLF 混用归一回 LF(仓库里 blob 本来就是 LF,diff 无影响)。

门禁:`doc-links --all --sections-strict` 146 文档 0 问题、`id-check --strict` 增量拦截通过
(可判定 82.82%)、`mem-numbers` / `mem-budget` / `layer-deps` 全绿;改动文件字节卫生 BOM 无 /
CR 0 / U+FFFD 0 / 无行尾空格。未推送。
2026-10-22 22:31:09 +08:00
wandering1 af5bad5609 fix(测试工具): 自检台账判据改为只看本次启动新增(修假绿)
接上一笔(FTP 凭据)之后,`@test --require-selftest-pass` 仍不可用——它其实是**三个 bug 叠
在一起**,这次一并收口。

**① 抓取失败被当成"设备干净"**:`_fetch_selftest_log()` 把"设备侧无此文件"(正常路径,
"仅故障才写"语义下干净设备本就没有该文件)和"连不上/登录被拒/FTP 故障"(结果**未知**)
都返回 `""`,调用方一律按"无记录 → PASS"处理。叠加上一笔修掉的匿名登录 530,这条门禁
**从写下那天起一次都没真正读到过那个文件**。现在 fetch 返回 `(路径, 状态)`,状态 ∈
{ok, absent, error},三者分开判。

**② 判据本身判不了"一次启动"**:`/log/selftest.log` 是 **512 条环形累计历史**(设计文档
§4.2 容量表),而旧判据是"文件非空即 FAIL"——在任何做过故障注入或长跑的设备上**永远不
可能 PASS**。本次实机抓到的 29 条记录,时间戳全是 2026-10-10 ~ 10-14,没有一条来自本次。
新判据 `--selftest-scope this-run`(默认):**烧录之前**先取基线,启动后再取一次,只把
"基线最后一条之后"的记录交给判定(差集落成临时副本,命名落在 `selftest_log_*.log` 里,
才会被 `--keep-out` 的保留规则收走)。

**③ 取基线本身会污染本次启动日志**(我这次实测踩到的):抓基线是一次真实 FTP 会话——
设备把 `USER/PASS/RETR` 打到串口上,客户端退出时还多打一行 `[ERR] [FTP] error reading
next command`。放在 `open/clear`(采集武装)之后,这些行就落进本次启动日志:Forbidden
`[ERR]` 命中 → 本次 FAIL,开机耗时从 612 ms 被拉到 **7997 ms**。所以基线固定在 open 之前,
并把这条写进文档当规避口径。

**判定表**(`docs/测试说明/自动化测试流程.md` §自检结果判定整段重写):无此文件=PASS;
本次无新增=PASS;本次有新增=FAIL;连不上/登录被拒/FTP 故障=**FAIL**;既无 `device_ip`
又无 `--selftest-log`=**FAIL**(要求判定却没有数据源,不能再"视为无记录");基线最后一条
被 512 条环覆盖(更新量 ≥ 环容)= **FAIL**(不拿"看不到"当"没有")。后四种"判不了"由调用
方在 `verify()` 之后强制收口成 RC=1——否则标记全中就把"没判"读成"判过了、没问题"。

`--selftest-scope all` 保留旧的累计口径(审计 / 第一次建基线用);给了 `--selftest-log`
(本地副本)就没有基线可比 → 自动降级为 all 并打印一行说明;FTP 凭据 `--ftp-user`/
`--ftp-pass`(config `ftp_user`/`ftp_pass`,默认出厂 admin/admin)。退出码 1 的含义、
`verify_log.py` 的语义条目、SKILL.md 参数表(+2 行)、config.example.json(+3 键)同步。

**真机三跑,三条路径各自验过**:
· 默认 this-run + `--device-ip` → **PASS / RC=0**:`基线 29 条 → 台账不变`、boot banner ×1、
  `net init: OK` **612 ms**(证明污染已消除)、Forbidden 零命中。
· `--selftest-scope all` → **FAIL / RC=1**:`29 fault record(s)`(累计口径仍可用)。
· 不给 `--device-ip` → **FAIL / RC=1**:`verify_log` 那侧显示 `no fault record → PASS`,
  调用方收口为 `RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行`(正是要的行为)。

门禁:`py_compile`(run_boot_test + verify_log)通过;`check_doc_links.py --all
--sections-strict` 146 文档 0 问题;`check_ids.py --strict` 增量拦截通过(可判定 82.82%,
在地板之上);6 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。

**仍待办(本笔未做)**:设备侧"FTP 客户端中途退出"按 ERROR 级打日志(要不要降级);lftpd
的 LIST 传完回 550 而非 226;设备 `/` 残留约 24 个旧探针文件;`MDK-ARM/` 下 4 个一次性
探针脚本仍写匿名。未推送。
2026-10-22 22:19:51 +08:00
wandering1 15d01d4dc1 fix(测试工具): FTP 脚本与 @test 抓取改用设备鉴权账号(修 11 天假绿)
设备 FTP 自鉴权落地起就**没有匿名通道**(实测 `USER anonymous` → 530、`admin`+错口令 →
530、`admin`/`admin` → 230;`lftpd` 源码里也没有 anonymous 特判),但三个老主机脚本、
`@test` 的自检记录抓取、以及测试指南都还写着匿名——同目录的 `ftp_auth_bench.py` /
`ftp_abort_probe.py` 反而早就记着"匿名一律 530",可见当时只改了新增件。

**改动**:
· `test/host/ftp_test.py`、`ftp_up_dl_test.py`、`ftp_speed_test.py`:写死的
  `USER anonymous` / `PASS test@test.com` 改为 `--user` / `--password`(默认出厂
  admin/admin);**USER 的期望码从 230 修成"230 或 331"**——要口令的服务器在 USER 后
  回 331,脚本原先要求 USER 直接给 230,这是第二个 bug(就算口令对也过不了);登录
  失败时打印口径提示(`/config.ini` 的 `[ftp]`、无匿名通道、连续 5 次失败锁 60 秒)。
· `.dsh/skills/serial-boot-test/scripts/run_boot_test.py`:`ftp.login()`(匿名)→
  `ftp.login(user, pass)`,新增 `--ftp-user/--ftp-pass` 与 config 的 `ftp_user`/
  `ftp_pass`(默认 admin);函数文档与 `--device-ip` 的 help 去掉 "(anonymous)"。
· `docs/测试说明/FTP_Test_Guide.md`:§1 的"匿名登录(`USER anonymous` / `PASS` 任意)"
  改为鉴权口径(`user` / `pwd_hash` = SHA-256(user‖password) / `write`,出厂
  admin/admin,默认口令在用有启动告警,5 次失败锁 60 秒,**无匿名通道**),§4 参数说明
  补两个新参数,§4.1 期望改为 `USER admin`→331、`PASS admin`→230,FileZilla 的用户
  由 `anonymous` 改 `admin`。

**这才是真正的危害**:`@test --require-selftest-pass` 的抓取走匿名登录 → 必然 530 → 落
进 `RUN_TEST_WARN … treated as no record (S7 PASS path)` → **每次都判 PASS**。也就是
说这条"联合判定自检记录"的门禁从写下那天起就没真正看过文件(假绿)。修完凭据后第一次
真抓到 `/log/selftest.log`(1856 B),立刻报出 29 条故障记录——而它们的**时间戳全是
2026-10-10 ~ 10-14**,没有一条来自本次启动:`/log/selftest.log` 是 512 条环形累计历史,
而 S7 的判据是"有任何 64B 记录就 FAIL" → **在任何做过故障注入或长跑的设备上,这条
门禁都不可能 PASS**。判据本身要改成"只看本次启动新增",已单独提出,不在本笔。

**验证(全部真机实跑)**:`ftp_up_dl_test.py` → TEST PASSED(10 项全过,含 STOR/LIST/
RETR/逐字节比对/DELE);`ftp_speed_test.py --size 128` → TEST PASSED(上行 225.8 KB/s、
下行 250.9 KB/s,131,072 B 逐字节一致);`@test` → PASS(625 ms 到 `net init: OK`);
`@test --require-selftest-pass --device-ip 192.168.1.100` →
`SELFTEST_FETCH: … (1856 bytes)`,抓取路径真跑通(旧代码在此步必 530)。
`py_compile` 四个脚本全过;`check_doc_links.py --all --sections-strict` 146 文档 0 问题;
`check_ids.py --strict` 增量拦截通过;5 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 /
无行尾空格。

**顺带暴露、未在本笔处理**:① `ftp_test.py` 的 `LIST 150->226` 断言失败——lftpd 的 LIST
数据本身正确(1512 B 全收到),但传输结束后控制通道回的是 **550** 而不是 226,需单独查
收尾路径;② 设备 `/` 根目录残留约 24 个旧探针文件(AB*/E12A/SZ*/A000x/R000x/DIAG_A);
③ `MDK-ARM/` 下 4 个一次性探针脚本仍写匿名,现在必然登录不上。未推送。
2026-10-22 21:39:25 +08:00
wandering1 e7f28502bc refactor(NET): netMsgQueueHandle 收进 net_socket 私有,改为创建后绑定
结构债 S-2 的最后一步——**该族的 24 处手写跨模块裸 extern 就此全部清零**。

队列对象仍由 CubeMX 在 `MX_FREERTOS_Init()` 创建(**创建点不可外移**:
`osKernelInitialize()` 之前的任何创建类 API 会杀死启动,见
`docs/架构设计/项目启动顺序管理.md`);改为创建后立刻在 `Src/freertos.c` 的
`USER CODE BEGIN RTOS_QUEUES` 区调用 `net_msg_queue_adopt(netMsgQueueHandle)`,
把句柄交给唯一使用者。`net_socket.c` 内改用 `static osMessageQueueId_t
s_net_msg_queue`(21 处引用改名),BSP 对该全局变量的裸 `extern` 消失——现在
`netMsgQueueHandle` 只出现在生成文件自己的两处(定义 + 创建)。

两个刻意的设计决定:
① **不新建第二个队列**。自建队列 + 删 `.ioc` 里的 netMsgQueue 也能收口,但要
   CubeMX 重生成(Ask-first),过渡期还会同时存在两个队列,白占 CCM 堆里
   8 × `net_msg_t` 的空间——CCM 正是当下 87.9% 的红线。故选"创建后绑定"。
② `adopt()` **拒绝重复绑定、保留首次句柄**。真出现两处绑定时,静默换句柄会让
   已入队的请求留在旧队列里永不处理;拒掉第二个反而更容易暴露。绑定前的判空
   是真实防线:丢了那一行,后果是"所有 net_* 请求被丢弃、网络全线静默不通",
   不是崩溃。

**防回退**:`check_cubemx_survival.py` 新增存活项 `net-msgq-adopt`(region
`RTOS_QUEUES`、`must_contain=["net_msg_queue_adopt"]`),易失清单 29 → 30 项全部
到位;基线已重建(有意的 USER CODE 区改动,按脚本文档流程 `--write`)。

**体量**:`Code` 213,204 → **213,236 B(+32)**、`ER_IROM1` **231,816 B(22.1%)**;
`RW +4 / ZI −4` → `RW_IRAM1` 恒 **75,264 B(57.4%)**、`RW_CCM` 恒 **57,376 B
(87.9%)**、`RW+ZI` 总量 132,688 B 不变(没有多占一个字节 RAM)。

**验证**:`@build` 0 错 0 警告;`@test` PASS——624 ms 到 `net init: OK`、`i104:
listening on 2404`、无 `[ERR]`/Fault、`heap free=15800 min_ever=8464` 逐项不变。
另在真机跑了一次 FTP 控制通道往返:220 → `USER admin` 331 → `PASS admin` 230
→ `PWD` 257 → `TYPE I` 200 → `QUIT` 221。每条命令都是一次过队列的
net_recv/net_send,证明绑定路径真在跑;`.map` 里 `freertos.o(MX_FREERTOS_Init)
refers to net_socket.o(i.net_msg_queue_adopt)` 证明镜像含该行。

⚠ **顺带揪出一条测试工具漂移(本次未修,等定夺)**:
`test/host/ftp_up_dl_test.py:123/126` 仍写死 `USER anonymous` /
`PASS test@test.com`,而设备自 N-19(2026-10-11)起按 `/config.ini [ftp]` 校验
admin/admin(SHA-256 `pwd_hash`)→ 该脚本必然登录失败。它是手工工具、不在任何
pre-commit 钩子里,所以 11 天没人发现,而 `docs/测试说明/FTP_Test_Guide.md` 把它
列为"推荐入口"。建议加 `--user/--password`(默认 admin/admin),并连
`ftp_speed_test.py` 一起核。

数字联动:两份 `data/` 快照重跑、`ram_baseline --update/--write`、
`check_mem_numbers --write`、FLASH 与任务栈两张配图常量重算并重新生成(逐文本按
字号与锚点复查越界 = 0)、报告 §1.3/§5.1/§5.4 与部署文档 §3.3 同步。报告 §0.4 的
S-2 行按「完成即销项」改写为已闭环,§9 拆出独立 S-2 行标 ✅。

门禁:`@build` 0/0、`@test` PASS、`layer-deps` / `cubemx-survival` / `mem-numbers`
(本次改到台账受管区,scan 已过)/ `mem-budget`(57.4% / 87.9%)/ `task-table` /
`doc-links --all`(146 文档 0 问题)/ `id-check --strict`(增量拦截通过、可判定
82.84%、存量债 640)全绿;14 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无尾空格。
未推送。
2026-10-22 20:01:40 +08:00
wandering1 6ede1443e9 feat(门禁): 新增 layer-deps——BSP 反向 include App 的增量拦截(37→38)
推荐清单第 5 步的拆分执行:反转依赖(5a)暂缓,**只把"防扩大"的门禁先上**。

**为什么门禁比搬迁值钱**:AGENTS.md 与分层图早就写着"六层单向依赖",可从来没有
机械检查——于是同一件事的记载从 2026-09 审查报告的"NETCFG 一处",漂到 2026-10-22
实测的 **8 个文件 9 处**,全靠人读代码才发现。边界的静默腐蚀只能靠门禁止住。

**机制(与 `id-check` 的增量拦截同构)**:`scripts/check_layer_deps.py` 扫
`Drivers/BSP/**.{c,h}` 的 include,按 `App/**` 头文件索引解析反向边(同名头按字面
路径消歧),命中分三类:
· ALLOW 永久豁免 3 条横切叶子头——`App/log/console_log.h`(日志后端,BSP 的
  dbg_log.h 就建在它上面)、`App/util/sys_clock.h`(时间源)、`App/util/sha256.h`
  (纯算法、无状态)。它们是"任何层都要用的叶子能力",不是上层服务;新增豁免
  必须先回答"它为什么不是上层服务"。
· BASELINE 钉住存量 6 条:`cfg_file.h`×2(NETCFG)、`vol_lock.h`×1(lftpd_io)、
  `sys_log.h`×3(UI 两处 + `ui_w_records.h`)。**只减不增**(脚本没有 `--write`)。
· 新出现的 (BSP 文件, App 头) 组合 → FAIL,并给出三条出路(注入回调 / 上层传参 /
  确属叶子才可豁免)。
`--prune` 在某条存量还清时点名"可从基线删除",于是"还欠几条"成为可见进度,而不是
文档里的一句口号。

**当场自证**:落地时临时给 `Drivers/BSP/RS485/rs485.c` 加了一行 include `cfg_file.h
`,钩子当场 FAIL 并点名该文件与该头(退出码 1),删掉后转绿——没被触发过的门禁
等于没有门禁。

**边界要说清(免得被误读成缺陷)**:本钩子只拦**静态 include 方向**。运行时的属主
纪律本来就没破——UI 只投递 `net_cfg_save_request()`,真正写盘在 defaultTask,模拟器
也早在主机侧跑整条配置链。

**口径同步**:钩子数 37 → 38 共 6 处(`AGENTS.md` 两处、`pre-commit使用说明.md` 两处
并新增 §2.26 详解、`并行任务工作树方案.md`、报告 §0.1/§1.1);
`项目架构总览.md` §1 分层原则处补"此方向已机械检查";报告 §0.4 的 S-1 计数由
"7 文件 15 处"更正为脚本口径的 8 文件 9 处,§9 状态改 🟡。

**暂缓项(都已在报告 §0.4 记因)**:5a 反转依赖——没有功能在等它,且要过"改 IP
免重启生效"整条真机链,留给下次真动 NETCFG/UI 时顺带做;5b 目录搬迁——uvprojx 326
条 + IncludePath + CMakeLists + 42 个 UI 文件 + 全库引用改写,收益只是"目录名好看",
而远程 CI 仍刻意不跑 `@build`,兜底未成立。

门禁:`layer-deps` 自身通过;`check_doc_links.py --all --sections-strict` 146 文档 0
问题;`check_ids.py --strict` 增量拦截通过、可判定 82.84%;`mem-numbers` 0;
`py_compile` 通过。本轮无 `.c/.h` 改动,未重跑 `@build`。未推送。
2026-10-22 19:30:02 +08:00
wandering1 7e77af08a0 fix(BSP): SPI1 完成标志收回 GD5F 私有,HAL 回调改契约转调
推荐清单第 4a 步(结构债 S-2 剩余面里唯一带"静默失效"属性的那条)。

**问题不是风格**:HAL 的 SPI 完成/错误回调是**全局唯一符号**,整个工程只能有一份
实现,当前由 CH395F 代管并按 `hspi->Instance` 分发——于是 GD5F 的完成标志
`g_spi1_dma_done` 被 CH395F 跨模块裸 `extern` 直接写,**GD5F 出错时的"释放等待者"
寄居在别人的 .c 里**。谁哪天做"CH395F 清理",把那两个 `else if (SPI1)` 分支当无关
代码删掉:编译零报错,GD5F 的 DMA 等待在出错时只能干等 `GD5F_SPI_TIMEOUT` 兜底。

**改法(时序一字不动,只换所有权)**:
· 标志收为 GD5F 文件内 `static volatile uint8_t s_spi1_dma_done`(6 处引用同步改名)
· GD5F 导出 `gd5f_spi1_notify_done()` / `gd5f_spi1_notify_error()`,头文件里写明
  "SPI1 分支必须转调这两个函数,否则本模块的 DMA 等待在出错时只能靠超时兜底"
· CH395F 删除裸 `extern`、include `gd5f2gq5ue.h`、两个 SPI1 分支改为转调并就地注明
  "勿删:少了这行 GD5F 的 DMA 出错只能靠超时退出"

`HAL_SPI_AbortCpltCallback`(`HAL_SPI_DMAStop()` 会触发)本次**刻意不做**——它会改
变中止语义、需要真机专项,当前超时兜底可用(已在报告 §0.4 记为观察项)。

**体量代价**:`ch395f.o` −8 B、`gd5f2gq5ue.o` +24 B → `Code` 213,188 → **213,204 B
(+16 B)**,RO/RW/ZI 全不变,`ER_IROM1` 231,768 → **231,784 B(22.1%)**。16 B 换
掉一个静默失效类,划算。

**真机 `@test` PASS**:无 `[ERR]`/Fault,复位原因 software;`[STACK] heap
free=15800 min_ever=8464` 与改前**逐项相同**;各任务实测峰值 default 2152 / net
1116 / ftp 764 / ui 4052 / timesync 616 / adc 952 / iec104 540 / protect 624,
**静态最坏链全部未变**(3024/1912/1456/4052/1264/1320/1456/872)→ 9/9 PASS。

⚠ 记一条读数陷阱:测试脚本打印的 "boot to net init: OK = 7,907 ms" 是采集窗口
锚点造成的度量偏差,真机日志里 `boot: syslog init OK → net init: OK` 实测 ≈699 ms,
与改前一致,**不是性能回退**。

**数字联动**:`data/ram_report_20261022.md`、`data/stack_report_20261022.md` 重跑;
`ram_baseline.py --update/--write`;`check_mem_numbers.py --write`(RAM 方案受管区 +
台账);`gen_memory_svg.py` 常量更新后重新生成 FLASH 与任务栈两张配图(逐文本按
字号与锚点复查越界 = 0;RAM 图无需变更);报告 §1.3/§5.1/§5.4、部署文档 §3.3 的
Flash 数字同步;§0.4/§9 的 S-2 口径改为"24 处手写已消 23 处,只剩
`net_socket.c:77`"。

门禁:`@build` 0 错 0 警告;`cubemx-survival --check`、`mem-numbers`、`mem-budget`
(57.4% / 87.9%)、`task-table`、`doc-links --all --sections-strict`(146 文档 0 问题)、
`id-check --strict`(增量拦截通过、可判定 82.83%)全绿;字节卫生 BOM/CR/U+FFFD/尾
空格全清;未提交 UV4 抖动的 `.uvprojx`(本次未被改写)。未推送。
2026-10-22 19:21:08 +08:00
wandering1 36a3c3449c refactor(task): 19 处任务句柄裸 extern 收拢为 task_handles.h 引用契约
推荐清单第 3 步(结构债 S-2 剩余面),用户批准"按你推荐做"。

新建 `App/task/task_handles.h`——**只有声明、没有 `.c`**:头文件不需要双端注册,
且 `../App/task` 本来就在 uvprojx 的 `<IncludePath>` 里,所以这一步零构建面。

- `App/task/default_task.c:52-69` 的 18 行局部 extern(9 个句柄 + 9 个 attributes)
  删除,改 include;栈水位观测用的任务清单表本体仍留在该文件
- `App/task/adc_task.c:125` 的 1 行同法收口
- `Src/main.c` 函数内的 `extern volatile uint32_t g_rcc_csr_latched;` 移进
  `App/app/app_main.h`(`main.c` 本来就 include 它),USER CODE 区内只留"读
  RCC_CSR → 写 RMVF 清除"两句,顺序约束的原注释保留

为什么值得做:原先每加一个任务都得记得往 `default_task.c` 再抄两行 extern,抄
漏一行 = 读别人的栈(静默,无报错)。现在只有一个改动点;而且 CubeMX 若把句柄
改名,报错会落在链接期而不是静默通过。

**镜像逐项体积不变**:`Code=213,188 RO-data=18,580 RW-data=1,184
ZI-data=131,504`,`@build` 0 错 0 警告 → 行为中性。

`check_cubemx_survival.py` 对这次有意的 USER CODE 区改动报"漂移",已按其文档流程
`--write` 重建基线(`.cubemx_survival_baseline.json` 仅 1 个指纹变化;顺手把该脚本
写文件带来的 CRLF 归一回 LF,符合 `* text=auto eol=lf`),`--check` 复跑通过、易失
清单 29 项全部到位。

报告 §0.4/§9 的 S-2 口径同时改成**精确分类**:该修的手写跨模块裸 extern **24 处,
今天已消 22 处,只剩 2 处**(`ch395f.c:177` 的 `g_spi1_dma_done` 与 `net_socket.c:77`
的 `netMsgQueueHandle`);`stm32f4xx_it.c:241-256` 那 16 条是 CubeMX「External
variables」生成块(删了会被重生成写回来,**不算债**),`:183/195/207/220` 那 4 条
是手写在 `__asm` Handler 里引用汇编标签(ARMCC `__asm` 取不到 C 数据符号,
**合法例外**)。上一版写的"25 处"把这三类混在一起了,一并更正。另更正 §0.4 的
S-1 一格:不是"运行时属主纪律被破"(UI 只投递 `net_cfg_save_request()`,写盘在
defaultTask),也不是"进不了主机侧单测"(模拟器已跑整条链),要修的只是静态分层
方向。

门禁:`@build` 0/0;`cubemx-survival --check` 通过;`check_doc_links.py --all
--sections-strict` 146 文档 0 问题;`check_ids.py --strict` 增量拦截通过、可判定
82.83%;`mem-numbers` 0。字节卫生:新增头文件与各改动文件 BOM 无 / CR 0 /
U+FFFD 0。未推送。
2026-10-22 19:09:20 +08:00
wandering1 67fbdd9897 refactor(结构债): S-4 删零消费点占位宏;S-2 消两处与 Inc 重复的 extern
用户批准「先只跑 1+2」(报告 §0.4 结构债三项 S-1/S-2/S-4 的评估结论)。

**S-4 闭环**:删除 `App/app/app_cfg.h` 里 4 个**全库零消费点**的宏
(`APP_NET_ENABLE` / `APP_ADC_ENABLE` / `APP_RS485_ENABLE` /
`APP_SYS_TIME_SRC`),文件降格为「决策占位说明」——修改记录 v1.2 写明为什么删、
将来在真实初始化点接入门控时怎么接回;同时去掉 `app_main.c` 里只为它存在的
include。留「看着能裁剪、其实不生效」的宏比不留更糟:它不产生任何编译期效果,
却会让评审误信模块可裁剪——此前为了对冲这个误解,AGENTS 之外还得在 6 处文档各
写一句「未接线占位,别当开关用」。

**文档口径同步 17 处**:`App/app/README.md`、`App/README.md`、根 `README.md`、
`项目架构总览.md`、`项目部署环境要求.md`(§3.2 两行 + 配置表一行)、`产品需求
设计文档.md`(B0 结论 + 配置表)、`基线与待确认.md`、`排期详述.md` C5、`登记簿
.md` N-23 残余、`RAM使用分析与优化方案.md` 三处。

**顺带纠出另一个「文档里有、代码里从来没有」的东西**:`RAM使用分析与优化方案
.md` 把 `APP_CCM_ENABLE` 写成「加在 `App/app/app_cfg.h`」,全库只有那篇文档提
到它,从未落地——已就地标注「未实现,重开本簇时一起落地」,免得下一个读者以为
CCM 有回退开关。

**S-2 第一步**:删 `gd5f2gq5ue.c:47`、`sd2506.c:20` 两处与 `Inc/spi.h` /
`Inc/i2c.h` 重复的 `extern`;GD5F 改 `#include "spi.h"`(沿用 `ch395f.c:22`
的既有做法)。

**镜像体积逐项不变**:`Code=213,188 RO-data=18,580 RW-data=1,184
ZI-data=131,504` 与改动前完全相同,`@build` 0 错 0 警告 → 两步均为行为中性。

报告 §0.4/§9 同步为实测口径并**认错**:S-2 不是旧写的「4 处」而是 **25 处**
(`App/task/default_task.c:52-69` 一次就 18 行任务句柄/属性 extern;
`stm32f4xx_it.c` 的 `fault_entry_hard` 属 ARMCC `__asm` 合法例外,不该「
修」);S-1 不是「语义层错位」而是**真实的 BSP→App 反向依赖**(NETCFG 10 处
include `App/cfg/cfg_file.h` + UI 3 个文件)。这两个旧数字是 v3.0 重写时沿用
未测所致。

途中被自家门禁正确拦下一次:在 .c 注释里写「S-2a」→ `+1 [校验1] 未登记编号
`,改成平实语言后转绿(正合既有纪律「代码注释禁用不可检索的内部编号」)。

门禁:`@build` 0/0;`check_ids.py --strict` 增量拦截通过、存量债 650 → 640、
可判定比例 82.83%(地板 82.6%+0.1pp);`check_doc_links.py --all
--sections-strict` 146 文档 0 问题;`pre-commit` 相关文件全 Passed;字节卫生 15
个文件 BOM/CR/U+FFFD/行尾空格全清(`end-of-file-fixer` 修掉 app_cfg.h 的一个
多余空行)。未推送。
2026-10-22 18:22:58 +08:00
wandering1 538c44c1cc docs(报告): 项目审查报告 v3.0 全量重写,按 2026-10-22 实测重判并删过时条目
用户裁定:报告很久没更新,过时的删除、重新写。697 → 440 行(−563 / +306)。

取数:旧报告里的数字一个都不沿用,全部按今天的仓库重测——`.map`(ER_IROM1
231,768 B / 22.1%,RW_IRAM1 75,264 / 57.4%,RW_CCM 57,376 / 87.9% 余 7,904 B)、
`.htm` + 同日真机 `[STACK]`(9/9 PASS、min_ever 8,464)、`git ls-files` 逐个 stat
(2,498 文件 / 80.0 MB,docs 45.3 MB 中芯片手册占 39.5 MB=87%,.git 248 MB)、
37 个钩子、16 套主机单测、TC 593∶271 交集 265、登记簿在策 18 条。

删除:六维评分旧表与 §0.4 的 12 条已闭环行;§6「UI 8 人日方案」正文(四层设
问、逐日排期、27 页清单);§7 交付物清单的已完成行与人日估算;§1~§3 的
2026-09-11 / 10-14 旧快照数字与附录 A 旧证据行;§4 各节的设计复述(同一事实
只在一处,细节归各自设计文档),改为"今天复核到的状态 + 仍在册问题 + 证据"。

刻意保留三层(删了会出事):
① 新增 §9 编号族登记处——本文是 id_registry.json 中 N-RPT / D-RPT / S-RPT /
   R-RPT(游标 R-9) / F-RPT / U-RPT(U5) 六族的属主登记处,5 族开 check_dangling,
   且外部存在「报告 N-14」「报告 F-4/F-5/F-6」「报告 D-1」式紧邻引用,删定义位
   会被编号门禁当"定义缺失"拦下;每个存量号一行状态 + 现去处。
② §1.2 / §1.3 复现命令与口径定义——保证每个数字可重跑。
③ 文末变更记录与已销项去处表 52 行原样冻结(登记簿销项纪律的落点)。
被外部点名的锚点 §0.3 / §3.1 / §4.1~§4.3 / §5.3×9 / §5.4 / §5.5×4 / §6.2 / §6.3 /
§6.5 逐个留位 → 全库 68 处入站引用零改动。

重审计改判三条:
· §6 原写 U1/U3/U4「❌ 未建」是错的:ui_tokens.h(69 行 L0 令牌)、
  ui_page_registry.c(描述符注册表,已取代手写 case)、ui-golden + font-charset
  两枚钩子都在;U2 只有 ui_w_records 一个组件;"27 页未落地"实为 7 页。
· "新增一页 ≤30 行"目标未达(最省 ui_page_dc.c 40 行,重页 275/370 行),如实
  记为未达项。
· §0.4 与登记簿已漂开 → 本版把待办清单降级为快照 + 指向,权威只留一处。

门禁:check_doc_links.py --all --sections-strict → 146 文档 0 问题(章节引用
2,515、可判定 1,483);check_ids.py --strict 增量拦截通过、存量债 650 → 641
(−9)、可判定比例 82.98% → 82.80%(基线 82.6% + 0.1pp 容差仍通过,缓冲从
0.38pp 缩到 0.20pp——被删引用 99% 可判定,属预期副作用);mem-numbers、
ram_baseline --gate、task-table、cubemx-survival 全 0;字节卫生 BOM/CR/U+FFFD/
行尾空格全清。未改任何 .c/.h,未推送。
2026-10-22 17:16:13 +08:00
wandering1 504db26698 docs(内存): 阶段子项改层级编号 4.阶段.项,消除与顶层章节同号撞面
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m1s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 21s
根因:`RAM使用分析与优化方案.md` 顶层有 §1 背景 / §2 现状测量(2.1~2.6),而 §4
「方案」下"阶段 N"的子项也用裸号 0.x / 1.x / 2.x——阶段 2 的 2.1/2.2/2.3 与顶层
2.x **完全同号**,阶段 1 的 1.x 又长得像"第 1 章第 x 节"。`doc-links` 对本文自
引用按设计直接放过,于是门禁和人一起看错:AGENTS.md 把"栈余量台账"写成 §1.4,
而 §1.4 其实是阶段 1 的 lftpd 静态化项(上一笔提交的信息里我把它误判成断链,
已 amend 更正)。

改法:阶段子项一律改层级编号,阶段号不变、只加 `4.` 前缀 —— 0.x→4.0.x、
1.x→4.1.x、2.x→4.2.x。共改 14 个标题;文内自引用与实施序列表(S4~S9 那套)、
暂缓清单、§5 预期效果列里的裸号一并补成 §4.x.y。

外部引用同步 15 处:AGENTS.md、lftpd/README ×2、菜单页面设计、RAM预算台账 ×2、
项目架构总览 ×3、FTP_Test_Guide ×3、boot_log_data/README、部署环境要求、审查
报告、ram_baseline.py ×3。最有说服力的两处歧义:审查报告与 RAM预算台账都写
"缩堆见 …§2.2"——既可读成顶层 §2.2(静态大项排行)也可读成阶段 2 第 2 项,
现均为 §4.2.2(阶段 2 第 2 项"缩堆")。

另修 4 处把 §1.4 当"栈台账"的错指:采样有效性校验设计说明 :441/:593/:650 改
指 §2.3(任务栈配置与实测峰值)与 §4.0.6(静态最坏链与判定);id_aliases.json
 的 note 改指 §4.0.6 并标注"原 §1.4,2026-10-22 层级编号"。

冻结与示例:其它文档**变更记录行**里的旧编号按项目纪律不改写(项目架构总览
:367、RAM预算台账 :98),改在被引文档 §4 开头留一张换算表 + 约定说明;把旧号
当语法示例写的 7 处一并换掉(文档规范 ×3、check_doc_links ×3、rename_ref ×1),
否则示例本身指向已改名的锚点。主文档版本行 bump 到 v1.3 / 2026-10-22。

门禁:check_doc_links.py --all --sections-strict → 146 个文档 0 问题(章节引用
2513 处、机器可判定 1491);check_ids.py --strict 增量拦截通过;mem-numbers、
ram_baseline --gate(SRAM1 57.4% / CCM 87.9%)、task-table 全绿;本轮未改任何
.c/.h。16 个文件字节卫生自查 BOM/CR/U+FFFD/行尾空格全清。已提交本地,未推送。
2026-10-22 15:38:14 +08:00
wandering1 f4b9bd57d5 docs(部署): 删任务栈副本表改指针;纠 CCM/FPU 事实并刷新配图基线
范围:AGENTS.md 一处指针、部署环境要求 §2.2/§3.1/§3.3、RAM 方案 6 处现状
数字、内存配图生成器 + 3 张 SVG、两份报告脚本的行尾与路径修正、新增两份
.map/.htm 数据快照。未改任何 .c/.h(仍跑 @build 复核:0 错误 0 警告)。

部署环境要求.md
- §3.3 删"任务栈预算"副本表、改为指针:该表已缺 adcTask/iec104Task/
  protectTask,defaultTask/timeSyncTask 两值停在旧数,注释却写"由
  gen_task_table.py 同源"——该钩子的校验面只有 README.md / AGENTS.md
  两处受管块,抄出来的副本没有门禁、改栈后必漂。指针指向 Src/freertos.c
  + 两份受管表 + RAM 方案 §2.3/§0.6,并留一条"别再抄"的成因说明。
- §2.2 链接布局补 `RW_CCM 0x10000000`(scatter 给 0x0000FF00 = 65,280 B,
  2026-10-11 起承载 FreeRTOS 堆;另有 256 B UNINIT_CCM 尾区),写明 CCM
  不可 DMA。原文"SRAM3/CCM 故意未映射"与 STM32F407-Demo.sct 直接矛盾。
- §3.1 MCU 行更正为**单精度 FPU(VFPv4-SP,无硬件双精度)、无 TrustZone**
  (原写"双精度 FPU / TrustZone"是芯片事实错误,也正是报告 F-6 双精度软
  浮点被长期误判的土壤);SRAM 行改为"两块都在用,仅 SRAM3 未映射"。
- §3.3 资源预算表补 CCM 行,并把"主 SRAM 125,104 B / 95.4% / 极度紧张"
  更正为 75,264 B / 57.4%(余 55,808 B);红线改标在 CCM(余 7,904 B)。
- 变更记录补 2026-10-22 行(含上一提交里的资源表刷新那一笔)。

AGENTS.md:任务表注里 "RAM使用分析与优化方案.md §1.4" 是**指错小节**——§1.4 确实
存在(该文档 §4「方案」下的阶段 1 子项,讲 lftpd 大局部缓冲静态化),但栈余量
台账在 §2.3(任务栈配置与实测峰值)与 §0.6(静态最坏链与判定),故改指这两处
再加 内存内容分配策略.md §6。根因是该文档里 §1(背景)与 §4 下的 1.1~1.5 同号
撞面,人和 doc-links(自引用放过)都会看错——本会话我上一轮就被它带错过一次。

配图与数据(draw-diagram 流程:改生成器顶部数据 → 重跑 → 逐文本核越界)
- 新增 data/ram_report_20261022.md、data/stack_report_20261022.md;后者
  运行时水位取自 2026-10-22 真机 [STACK] 心跳(8 任务高水位 + heap
  free=15,800 / min_ever=8,464),静态侧来自同日 .htm(全局最深链 3,024 B)。
- gen_memory_svg.py:三组图数据从 2026-09-21 / 10-10 快照换成 2026-10-22
  实测;任务栈 6 → 9 行,堆总量 48KB → 56KB,判定 6/6 → 9/9 PASS,新增
  AboveNormal 配色;Flash 子条改为"窄段不压字 + 下方四列图例";任务栈
  画布 440 → 620 高。CCM 段总量按 scatter 用 65,280 B(与台账同口径)。
- 三张 SVG 重生成;用"CJK≈1.0em / 其余≈0.58em + 锚点"逐文本估算边界,
  修掉 5 处文字越界/压字(生成器内置 ElementTree 自检亦通过)。
- ram_report.py / stack_report.py 两处工具性修正:write_text 未指定 newline
  参数 → Windows 下把 data/ 快照写成 CRLF(与 .gitattributes 及既有快照
  不符);stack_report 的 relative_to 在传相对 -i 时抛 ValueError,且会把
  本机绝对路径写进入库快照 → 统一走 resolve+relative_to 的相对路径回退。

RAM 方案:6 处"当前状态"随 .map/[STACK] 更正——RW_IRAM1 89,168 → 75,264、
RW_CCM 49,184 → 57,376、FreeRTOS 堆 48KB → 56KB(两处)、CCM 余量 ~16KB →
7,904 B、min_ever 4,848 → 8,464 B、6 任务全过 → 9/9 PASS;S-1 历史句保留。

门禁:pre-commit 对全部改动文件 Passed(doc-links --sections-strict、
task-table、mem-numbers、mem-budget、id-check 增量拦截);字节卫生自查
BOM / CR / U+FFFD / 行尾空格全清。已提交本地,未推送。
2026-10-22 15:08:04 +08:00
wandering1 d4867ddbe2 fix(algo): Q10 定值宏改单精度;收口 Flash 优化簇并更正审查报告三处误判
影响面:一个头文件宏(App/algo/prot_set.h)+ 七份文档口径;固件行为逐位
等价,无功能变化。门禁全绿后提交,未推送。

验证链
- @build:0 错 0 警;Code 213,520 → 213,188(−332 B),RO/RW/ZI 不变。
- 真机 @test PASS:戳 fw build: Oct 22 2026 10:36:15 suite=NONE;无
  [ERR]/ERROR/FAIL/Fault;708 ms 到 net init: OK;protect task start、
  i104: listening on 2404、[STACK] 三面水位齐。
- 主机单测:prot_set 90/90、prot_elm 47/47、iec104 97/97。
- 等价性穷举:宏内 42 个字面量;int32 全域 ÷1024 往返 4,494,305 例零
  差异;×1024 方向 20 万随机 float 零差异(×/÷1024 是 2^10 指数平移,
  不产生尾数舍入)→ 定值默认值/上下限、校验和、104 上送值全部不变。

F-6 双精度软浮点(已落地)
- 两个 Q10 宏去掉显式 (double)、1024.0 → 1024.0f:保护判据每 20 ms 每
  元件 3 次定值读取改走 F407 单精度 FPU,不再软件模拟。
- .map 里 dflti/d2f/f2d/dfixi/fepilogue 共 300 B 消失;自研侧对双精度
  helper 的引用归零。
- 更正报告归因:全库 0 处 %f(扫 App/Src/Inc/Drivers 共 173 个 .c/.h),
  剩下 dadd/dmul/ddiv/depilogue/dfixul = 1,018 B 由 MicroLIB printfa.o 的
  _printf_core → _fp_digits 无条件引用,只要还用 snprintf 就删不掉;
  报告"把 %f 改整数刻度"没有可执行对象。

F-4 LVGL 死代码(判定不做)
- 报告列的五项字节数逐字节仍准(合计 10,060 B),但没有一项有开关:
  根因是 lv_draw_sw.c 的 execute_drawing() 无条件 switch 引用全部 task
  类型,链接器 --remove 无从下手。
- 唯一总开关 LV_DRAW_SW_COMPLEX 被 ui_deco.c 的"浅 V"3 px 粗斜腿依赖
  (斜段与其所需 mask 核心都在该开关内);2026-09-03 已实测失败并把两端
  =1 固化 → UI_设计架构.md §1.4 第 7 条。
- 报告原"mask/grad 是文字与矩形绘制必经路径"证伪:字形走 blend 层
  mask_buf,不经 lv_draw_sw_mask_*。
- 剩余路线需给 Lib/lvgl 打本地补丁(上限 ≈16 KB),而 Flash 仅占 22.1%、
  无 OTA/双镜像预算 → 收益不抵代价;图片链同理不打补丁。

F-5 uvprojx 空 TU(降级为观察项)
- 实测 326 个 .c / LVGL 201 个 / .map 零产出 127 个;但增量构建 4~5 s、
  空 TU 只在全量重建或改 lv_conf.h 时才重编,且远程 CI 刻意不跑 @build。
- 报告原"编译时长 −40%"无基线记录,不复述;真要做用 Keil 原生
  <IncludeInBuild>0</IncludeInBuild>,且排在任何 lv_conf.h 改动之后。

文档与台账
- 审查报告 §0.1/§0.4/§1/注册表/§5.1/§5.4/§6.4/W4 行按实测更正,补
  2026-10-22 变更记录行与 F-4/F-6 记录去处行;登记簿 N-28 改写为"三项
  已判定、仅剩 F-5 观察项"。
- 更正 FTL 写回缓存旧口径(sys_log_mcu.c 注释、运行与自检记录设计说明
  §4.4、实机测试实施指南 §9.1):f_close 经 sync_fs → CTRL_SYNC →
  ftl_flush_cache 已落盘,"写另一个文件逼出脏页"不是必要条件;2026-10-02
  那次"复位后写入整段丢失"根因是 dhara resume 锚错 checkpoint。
- 部署环境要求 §3.3 资源预算由 2026-09-11 快照刷新为 2026-10-22 实测,
  并补 CCM 红线行(旧"SRAM 95.4%/极度紧张"的说法早已失效)。
- ram_baseline.py --update/--write、check_mem_numbers.py --write 已回填:
  mem-budget(SRAM1 57.4% / CCM 87.9%,阈值 92%)与 mem-numbers 转绿。
2026-10-22 11:03:05 +08:00
wandering1 af411866a8 feat(门禁): 批 9 位号规则扩展到引脚名 + 修 --prune-backlog 丢 reason 的 bug
规则扩展(scripts/check_ids.py,两处共用同一正则)
- 原:^[RCDJXUT][0-9]{1,3}(单字母位号)
- 现:再加 ^[A-Z]{2,3}[0-9]{1,2}(-[0-9]{1,2})? → 收掉硬件语料里的**引脚名 / 总线名 /
  网名 / 引脚区间**:PB12-15、PG0-15、PF13-15、DB0-15、OS0-2、KEY1-3、AIN1 这类
- 关键边界:带连字符的一字母形态(C-10 / A-9 / N-18 / D-7)与 TC-206、TC1003 **不落在
  新形状里**,照旧判定 → 报告自有条目号这类真编号没被顺手挡掉(实测台账仍留 21 类真债)
- 位号与引脚名都不进 refs_total:错误级 661 → **651**,refs_total 8807 → 8797(同步 −10),
  可判定比例 82.84% → **82.94%**(分母没被噪声灌水,涨幅来自噪声出清而非真进展)

修自己的 bug:--prune-backlog 会静默丢掉 reason 字段
- 症状:收账一次后,那 28 类硬件存量条目的「这笔债为什么该存在」说明全部消失
- 根因:prune 用 dict 字面量重建条目,只写了 file/code/literal/count 四个键
- 修法:改为从原始 JSON 取整条再覆盖 count,其它字段原样保留;已实测幂等
  (再跑一次「还清 0 条、剩 313 条、带 reason 仍 21 类」)
- 21 类真债的 reason 已重新写回,内容含一条判断:器件料号 FOC-817 **不做**形状规则,
  因为 [A-Z]{3}-\d{3} 与真家族 DEF-* 撞形,宁可留一条记账也不制造盲区

验证:id-check --strict 通过(存量 651 全在台账内、无未登记新账);
doc-links --all 失效与待确认均 0;未改 C/H 不需 @build;台账只减不增
2026-10-22 08:49:39 +08:00
wandering1 125140a3c6 feat(门禁): 硬件语料纳入扫描——位号不进编号也不进比例分母
- check_ids.py 扫描循环:位号([RCDJXUT][0-9]{1,3} 无连字符)在 refs_total 之前 continue,
  既不产生结论也不灌分母;add() 里的同一道判定保留作兜底
- 注册表 excluded_paths 移除 docs/硬件平台/(docs/芯片手册/ 仍排除)
- 首次纳入暴露 28 类先前不可见的存量 → 按规矩逐条写 reason 入台账(**不重打快照**,
  台账只增带理由的条目);错误级 626 → 661,可判定 82.84%(基线 82.6 仍守住)
- 遗留噪声已在 designator_rule 记明:引脚名 PB12-15 / PG0-15 / DB0-15(两字母+数字+区间)
  不是编号,但位号规则不覆盖这种形状,先记账、待加 pin-name 类型规则后清除
- 验证:--strict 通过(无未登记的台账外新账);未改 C/H 不需 @build
2026-10-22 06:43:58 +08:00
wandering1 11c82c0d99 feat(门禁): 位号类型识别机制落地(暂不纳入硬件语料)
- check_ids.py 的 add() 加一道类型判定:硬件类语料(docs/硬件平台/、docs/芯片手册/)里
  匹配「单个大写字母 + 1~3 位数字、无连字符」的字面量判为**位号**(R 电阻 / C 电容 /
  D 二极管 / J 接插件 / X 晶振 / U 芯片 / T 变压器),直接不产生编号结论,并计数到
  stats[designator_skipped],不是静默放过
- 两处刻意收窄:字母集**不含 B**(V0.0 网表报告里的 B1 是真专项条目);要求 fullmatch
  无连字符,所以 D-7 / N-18 / TC-206 / TC1003 这些真编号与违规形态一律照旧判
- 注册表新增 designator_rule 说明口径;**本批不解除 excluded_paths 里的硬件目录排除**:
  粗测该目录约 4200 处位号,会灌进可判定比例的分母 refs_total,把真实变化盖掉——
  解除排除必须先把位号从分母里减掉,那是下一件事
- 因此本批对现有数字零影响:错误级仍 626、可判定比例不变、--strict 通过
2026-10-22 06:38:29 +08:00
wandering1 ee25b10053 chore(编号): 硬件网表报告里的 Trap05 统一为 TRAP-NET-05
- 6 处裸形 Trap05(V0.0 报告 5 处 + V0.1 报告 1 处)→ TRAP-NET-05,序号不变只补模块码
- 语义已核对:属主条目 docs/问题记录/CH395F_Trap_Records.md 的 TRAP-NET-05 就是
  CH395F 与 RTL8305 自动协商不兼容,同名不同义的担心排除掉了
- 别名表登记 Trap05 → TRAP-NET-05(scope 限定这两个文件,写进真容器 aliases)
- rename_ids.py 解除对 docs/硬件平台/ 的排除(芯片手册仍排除)
- 注册表 TRAP 族注记改为事实:该目录仍不进门禁扫描语料——粗测约 4200 处
  R/C/D/J/X/U/T 位号,位号类型识别做完前纳入只会让 --strict 为位号天天拦提交

验证:id-check --strict 无台账外新账;doc-links 失效与待确认均 0;
改名文件 BOM=否、CR=0、U+FFFD=0;未改 C/H,不需 @build
2026-10-22 06:33:00 +08:00
wandering1 a38f862fb9 chore(ui-golden): 像素黄金参考 ref/*.bin 入库(把门禁改成可移植)
问题:ui-golden 是逐字节比对(check_content_pages.py 里 data == refdata),
而基准 simulator/tests/ref/ 整目录被 .gitignore 忽略、从未入库。后果不是
"少几个文件",而是这条门禁在任何新克隆 / 新并行工作树里必然报「缺黄金
参考」——只在有老副本的机器上它才是真的回归门禁。本会话已两次实测踩到
(并行工作树里只能现场 --update 造基准,而现场造出来的基准恰好失去意义)。

入库前先证明基线可复现,否则等于把一条天天误报的门禁写进仓库:
- 主树 ref/ 生成于 2026-10-11,其间合入 B6~B9 与 UI 令牌化等多次改动;
  今天用刚重建的 lvgl_sim 重跑 → 11 页 + 开页-关页往返池水位逐字节全过
- 主树与新工作树两份**独立构建**的 lvgl_sim(不同构建目录、不同绝对路径)
  各自生成基准 → 11 个 .bin 的 SHA-256 全部一致
即渲染是 CPU 侧确定性输出,可以当仓库内容。

改动:
- .gitignore:整目录忽略 → 只忽略派生的目检 PNG。另加一条
  !simulator/tests/ref/*.bin 反选——第 115 行的全仓 *.bin 兜底(挡
  sim_netcfg.bin 之类运行产物)会把基线一起挡掉,且反选必须排在它之后
  才生效
- 不引入 LFS:11 x 3200 B = 34.4 KB(160x160 单色打包)。走 LFS 反而新增
  失效面——没拉 LFS 的克隆会拿 pointer 文本去比像素,把"缺基准"换成
  "比不中"。.gitattributes 已有 *.bin binary,check-attr 确认 text unset,
  不会被行尾转换改写
- 基线不会被改写型钩子毁掉:trailing-whitespace / end-of-file-fixer 声明
  types: [text],位图含 NUL 被判二进制 → 实测 Skipped;索引 blob、磁盘、
  主树参照三方哈希 11/11 一致
- 新增 11 个基线;同步 5 份文档与 3 处脚本口径:缺基准的提示从"首次/换机
  请加 --update"改为"检出不完整 → git checkout -- simulator/tests/ref/ 取回,
  不要用 --update 就地造基准"(旧提示正是自我实现型基准的来源)
- 顺带修掉 simulator/tests/README.md 两处与实现不符的描述:基准写成 PNG
  (实为 .bin)、regen_ref_png.py 写成"重建黄金参考"(它只出目检图,基准
  由 check_content_pages.py --update 刷)
- .pre-commit-config.yaml 只改注释(原文明确断言 ref/ "永远不会进 git
  索引",已被本改动推翻);ui-golden 的 files 面覆盖 simulator/tests/,
  基线被改即触发回归,刷参考从此在 git diff 里显形

历史口径按纪律冻结:UI_自动化测试流程 2026-09-10 的"整目录不入库"等 6 处
变更记录行不改写,以新行登记反转。

实测:@build 0 错 0 警(Code=213520,与内存台账一致);ui-golden Passed;
pre-commit run --all-files = 36 Passed / 0 Failed(RC=0)。
2026-10-22 06:17:00 +08:00
wandering1 ebe2807ffd docs(内存管理): 回填 adc-health 合并后的 Flash/RAM 快照
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m31s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 20s
合并 0a66e56(adc-health 四个提交)后主树才第一次 @build 出 .map,
done 的快照同步当时按「未找到 .map」跳过 → 台账受管区仍停在合并前数字。
本次按新 .map 回填,增量为两个代码提交的累计:

- ER_IROM1 231,936 → 232,100 B(+164 B):接任务 init 入口 +68 B、
  保护侧兜底门 +96 B
- RW_IRAM1 75,240 → 75,264 B(+24 B):protect_task.c 新增两份
  adc_health_age_t(各 12 B,含对齐)
- Flash 合计 233,120 → 233,284 B(+164 B),RW_CCM 57,376 B 不变

至此分支期间 mem-numbers / mem-budget 的 --no-verify 例外闭环(见
9f826db 与 790e153 提交正文的 gate 行)。

验证:check_mem_numbers.py --write + ram_baseline.py --write 后两条钩子
Passed;pre-commit run --all-files 全绿;@build 0 错 0 警
(Code=213520 RO=18580 RW=1184 ZI=131504)。
2026-10-22 00:47:39 +08:00
wandering1 0a66e56770 merge: task/adc-health-design-doc 合入 v2-on-ui
任务 adc-health(4 个提交)经 task-worktree 回收:
- fix(adc_input): 丢帧计数改按帧点数累计,与 TPAFE 侧 tgap 同单位
- fix(protect): 采样陈旧时独立闭锁保护(判定层失明的兜底门)
- fix(adc_task): 接上从未被调用的任务初始化入口(含判定层参照系)
- docs(algo): 新增 adc_health 单文件深设计与三张配图
2026-10-22 00:44:32 +08:00
wandering1 790e153cd6 fix(adc_input): 丢帧计数改按帧点数累计,与 TPAFE 侧 tgap 同单位
读码确认的口径缺陷:两片转换器的丢帧计数单位不一致——TPAFE 侧
adc_health_tpafe_gaps() 按 (d-1) 加权记「帧点数」,STM32 侧
adc_input_stm32_gaps() 每次事件只 +1 记「次数」。两者都被当作心跳字段
并排打印(adc1_gap / tgap),一次跨多帧时数值就分叉:同样丢 3 帧,
一边记 2、一边记 1。跨字段对比或做除法会得出错误结论。

选 A(改代码,真对称)而不是只改字段名:
- 单拍超一帧(d=2)时两者数值相同,历史上绝大多数样本本来就等价,
  换单位对既有判读习惯冲击很小;
- 只在一次跨 ≥2 帧时分叉,而那正是心跳日志突发期最常见的形态,
  留在文档里当"注意点"迟早还会读错一次。

改动:
- adc_input.c:s_stm32_gaps += (d - 1u)(d = seq - s_dma_seq_seen,
  顺带把已算过的差值提成局部变量 d,不再重复算一遍)
- adc_input.h / adc_input.c 函数头:单位写明「Σ 每次前进的超出部分」,
  并点名 2026-10-21 之前固件该字段是次数口径,跨版本日志不可直接对比
- 用例:原两档只跨 1 帧(前进 2 记 1),钉不出单位;补第二档——连投
  3 帧不消费(一次跨 2 帧)必须记 2 点、累计 1+2=3。补充断言沿用所属
  用例覆盖点,不另立新编号,也不在文档外复制用例号(编号门禁按文件内
  实例数比对台账豁免,多写一处即被判新债)
- 文档同步:ADC采样节拍设计说明 §8/§9 字段口径与稳态基线、采集与算法
  层设计说明 §3/§4、采样有效性校验设计说明 §6.2/§10/§11(并把"两车道
  单位不同"从观测盲区降级为"历史日志口径差异")、ADC输入层单元测试
  说明用例表该行;两张配图生成器文案同步

实测:
- @build 0 错 0 警;Program Size 与上一提交**逐字节相同**(Code=213520
  RW=1184 ZI=131504)——编译器早已为半区推导算过同一差值并复用,
  故本次净增 0 B,不产生新的台账漂移
- 主机单测:ADC 输入层 103/103、ADC 有效性 45/45
- stack_report:adcTask 1320 B / protectTask 872 B 均不变
- 字节卫生:BOM 无 / CR 0 / U+FFFD 0 / 文件尾换行齐
- id-check:Passed(曾因新增用例号实例触发"台账之外 +2",已按不复制
  用例号的方式消除,未动 id_known_backlog.json 台账)

gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files 失败仅这两条,漂移量来自上一条提交 6f90600
(+96 B Code / +24 B ZI);本条自身 0 B。按并行分支约定本分支不跑
--write,台账由 Lead 合并后统一回填。
2026-10-22 00:43:32 +08:00
wandering1 9f826dbb4d fix(protect): 采样陈旧时独立闭锁保护(判定层失明的兜底门)
问题:L5-4 闭锁读的 adc_health_flags() 只有 adcTask 每拍跑
adc_health_check() 才可能置位。TIM3 一停,adcTask 就永久阻塞在
osThreadFlagsWait(刻意的 fail-safe),check() 不再执行 → stall 位
永远是 0,而 protectTask 仍每 20 ms 拿冻在最后一帧的相量继续判据。
窗口最坏约 800 ms(停喂阈值)+ 2498 ms(IWDG 标称)≈ 3.3 s。

方案:保护侧建一条与消费者存活完全解耦的兜底新鲜度门。
- adc_health.c/h 新增 adc_health_age_stale():无模块状态的纯判据,
  参照系 adc_health_age_t 由调用方自存。故意不让 protectTask 去调
  adc_health_check()——那是「每拍一次、单写者」的契约,两个上下文
  同写会互相刷新差值基线,把现有判定层一起弄坏。
- 新增 ADC_HF_STALE = bit8(与判定层 bit0..bit7 位段错开)。本位
  不由判定层写、不进 sticky、不进编排层 logged 位图。
- protect_task.c 用自己的时基直接读两个**中断侧递增**的计数器
  (tpafe5160_frame_seq / adc_input_stm32_seq),并入掩码。两条车道
  各自一份 primed:共用会让先观测的那条把标志置 1,另一条首拍拿
  adv_ms = 0 算年龄 → 开机即误锁保护。
- 门限 PROT_STALE_AGE_MS = 100 + 20 = 120 ms,买的是时序阶梯:
  判定层活着时它先报 0x1/0x2(带日志与自检留痕),死了只出现
  0x100。闭锁最迟 140 ms,仍远小于最小动作延时(默认 1000 ms)。

实测:
- @build 0 错 0 警:Code 213424 → 213520(+96 B);ZI 131480 →
  131504(+24 B = 两份 age 结构);RW-data 1184 不变
- stack_report:protectTask 静态最坏链 872 B 不变(余 1176 B / 57%)、
  adcTask 1320 B 不变——新判据无日志,不进最坏链
- 主机单测 45/45(新增 TC-ADCHEALTH-012..017 共 14 条断言:首拍只建
  基线 / 超拍不判老 / 门限含等号 / 跨 tick 回绕 / 倒退按分工不判老 /
  双观察者互不污染 / 空参数不判老)
- 台架注入(一次性停 TIM3 300 ms 探针,验完已撤;探针固件与最终
  固件 Program Size 逐字节相同):
  [+5462ms] [ERR][PROT] sampling FAULT -> protection blocked (hf=0x00000100)
  [+5757ms] [PROT] sampling recovered -> protection resumed
  同次开机 [ADC_TASK] health: 出现 0 次;MISS 快照缺的正是 bit6
  (0x1F7 - 0x1B7 = 0x40,实证 adcTask 当时确实阻塞,不是"在跑但没报");
  reset cause: 仅 1 次(300 ms 被 800 ms 容忍吸收,闭锁不靠复位)
  证据 docs/测试说明/boot_log_data/20261021_probe_stale_gate_boot_log.txt
- 字节卫生:BOM 无 / CR 0 / U+FFFD 0;新增行无非法字面量

gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files 失败仅这两条(台账受管快照与本树 .map 的
Flash/RAM 水位漂移,即本次 +96 B Code、+24 B ZI)。按并行分支约定
本分支不跑 --write,台账由 Lead 合并后统一回填。

文档同步:设计说明新增 §15(原相关文档改 §16),§7 位表加 bit8 行、
§8 责任矩阵与「已知窗口」段改写为已闭合、§10 盲区第 3 条、§11 用例
表、§12 判读手册、§14 改前清单全部跟上;位图配图补 bit8 列,并修掉
生成器把列数硬编码成 8 列导致新列被备注框盖住的问题。
2026-10-22 00:43:32 +08:00
wandering1 60a650c175 fix(adc_task): 接上从未被调用的任务初始化入口(含判定层参照系)
问题:adc_task_func 函数头写「限定条件:adc_task_init() 已调用」,但
adc_task_init() 全库无调用点;同类空壳 net_task_init()(体内只有一条
DBG_INFO,函数头却写「创建 TCP Server 监听 Socket」)同样无人调用。

判定层为什么今天还没坏:状态全在 .bss(复位即 0),且 adc_health 的
首拍只建基线、不判定,所以漏调 init 只是「少清一次」。但假契约会让
下一个接手者照它推理,故必须修在成灾之前。

改动:
- adc_task_func 进入循环之前(开闸 s_consumer_ready 之前)自己调用
  adc_task_init(),不依赖外部接线;init 与首拍之间无抢占窗口
- adc_task_init() 去掉 DBG_INFO 并在函数头标注禁止加日志:它落在
  adcTask 静态最坏调用链上,每条 DBG_* 约 216 B 栈,而这条启动行
  无诊断价值(存活由首帧日志与 TASK_WD_ADC 心跳证明)
- 删除 net_task.c/.h 里的空壳 net_task_init()(声明 + 定义 + 误述)
- docs/算法/采样有效性校验设计说明.md §14 与变更记录同步改写:
  原来登记的「init 不在产品路径」事实已修复,不留过时结论

实测(任务工作树 adc-health,构建 + 静态分析):
- @build 0 错 0 警;Code 213356 → 213424(+68 B)
- RW-data 1184 / ZI-data 131480 逐字节不变 ⇒ RAM 零增量
- stack_report:adcTask 静态最坏链 1320 B → 1320 B 不变,
  余 1240 B / 48% 仍 PASS(netTask 1912 B、protectTask 872 B 不变)
- ADC 有效性单测 31/31 通过(钩子按 adc_task.c 自动触发)
- 字节卫生自查:BOM 无 / CR 0 / U+FFFD 0,字符串字面量纯 ASCII
  (中文只在注释里),新增行无非 ASCII 字面量

gate: only mem-budget/mem-numbers pending lead ledger backfill
pre-commit run --all-files = 33 Passed / 2 Failed,失败仅这两条,
均为台账受管区与本树 .map 的 Flash 差异(233,120 → 233,188,即本次
+68 B);RW_IRAM1 75,240 与 RW_CCM 57,376 与台账完全一致。按并行
分支约定本分支不跑 --write,台账由 Lead 合并后统一回填。

另记一处门禁缺口(非本次引入):ui-golden 的黄金参考 simulator/
tests/ref/ 被 .gitignore 排除、从未入库,新工作树里必然失败;本树
用 check_content_pages.py --update 生成 11 页基线后 ui-golden Passed
(即本次改动对 UI 像素中性)。是否让基线入库待裁。
2026-10-22 00:43:31 +08:00
wandering1 513becd61d docs(algo): 新增 adc_health 单文件深设计与三张配图
- 新建 docs/算法/采样有效性校验设计说明.md(15 章):判据推导
  (100 ms 门限的三条独立依据)、电平/锁存/事件三种位语义与
  位号对齐的收益代价、闭锁与 IWDG 兜底责任矩阵、观测盲区、
  主机侧验证分工(11 组 TC / 31 断言映射)、现场判读手册、
  自测问题与改前必读清单
- 配图走 draw-diagram 生成器路线(脚本 + 产物一并提交):
  tools/arch_diagram/gen_adc_health_flow_svg.py 等三个脚本 →
  docs/images/ 下三张 SVG;文内另有 2 张 Mermaid(判定主干
  flowchart、静默位生命周期 stateDiagram-v2,沿用仓内既有方言)
- 登记与指针:docs/README.md §5.2 加一行 + 变更记录;
  docs/算法/采集与算法层设计说明.md §4 与 §13 加深读指针;
  本文只写「为什么」,接口表仍由该文档属主,不重复
- 读码确认并如实登记三项(本提交不改代码,待裁):
  1) adc_task_init() 全库无调用点,adc_health_init() 因此不在
     产品路径,判定正确只依赖 .bss 清零 + 首拍建基线
  2) tgap 计帧点数(d-1 加权),adc1_gap 计次数——两侧单位
     不对称,心跳里不可直接对比
  3) TIM3 停摆时判定层随消费者一起停,最坏约 800 ms + 2498 ms
     窗口内 protectTask 仍以旧相量判据(细节见 §10 / §14)
- 门禁自查:pre-commit 目标集全绿(doc-links / id-check /
  空白 / EOF / 大文件);主机单测 adc_health 31/31 复跑通过;
  9 个新增或改动文件 BOM=无、CR=0、U+FFFD=0
2026-10-22 00:43:31 +08:00
wandering1 a26a9d6f52 test(NET): 阶段 8/9 实现体补挂用例号 TC-NET-801/802/901/902
先纠正我上一轮的错判:这 4 条不是「被重构丢掉」,实现体一直都在,
只是从来没挂号——我在旧路径 test/ch395f_test_task.c 里找,而它们已在
2026-08-26 前后搬进 test/board/net_test_task.c(文件搬迁导致我误判成删除)

判据(为何不是有目的删除,也不是遗漏实现):
- 开关在(ch395f_test.h:62/63)、调用点在(net_test_task.c 主循环 phase8_run /
  phase9_run)、函数体在(phase8_run / phase9_run)三者齐全 → 若是有意删除
  必然连开关与调用一起清掉
- 内容逐条对得上:phase8_run 按 PHASE8_DIRECTION 编译期切 SEND/RECV、逐字节 +
  尾附 4B CRC32(= TC-NET-801 SEND / TC-NET-802 RECV);phase9_run 注释即
  「有数据返回正数 / NET_FD_ISSET 正确 / 超时 50ms 返回 0」
  (= TC-NET-901 就绪检测 / TC-NET-902 超时)
→ 所以按用户的判据(没意义才删)结论是**不删**,删了反而抹掉四条已有覆盖的入口

验证:@build 0 错误 0 警告,Program Size 与改前逐字节相同 → 只动注释;
DOC-ONLY 10 → 6、追溯交集 261 → 265;id-check --strict 无台账外新账;
BOM=否 CR=0 U+FFFD=0
2026-10-21 23:45:06 +08:00
wandering1 3c69411409 test(NET): 阶段 1 实现体补挂用例号 TC-NET-101 / TC-NET-102
手法与上一批 TC-NET-1101 相同:只把号挂到已有实现体的注释上,不改任何语义
- 第一轮第 1 项处补挂 TC-NET-101(附录 B 的 ✅ 由此可走到实现体)
- 第一轮第 6 项处补挂 TC-NET-102,并注明条目何时删、汇总行为何遗留

顺带修一处被门禁当场咬住的写法:注释里原写「P1-01~P1-09」这个范围式,
让校验 6 多出 2 类台账外新账 → 按既定规矩改写成平实语言,**不往债务台账塞条目**

验证:@build 0 错误 0 警告且 Program Size 与改前逐字节相同
(Code=213356 RO=18580 RW=1184 ZI=131480)→ 证实只动注释;
id-check --strict 通过(无台账外新账);doc-links --all 143 篇 0 失效;
追溯交集 259 → 261(101/102 两侧现在都有号,是真对上,不是靠注记压掉);
BOM / CR / U+FFFD 与 HEAD 逐项一致
2026-10-21 23:35:40 +08:00
wandering1 4d18e3b60d docs(测试): 补注 TC-NET-102 汇总行的真实来源(查清遗留,不是假 ✅)
查证链(全部有据,可复核)
- b0feaff 2026-07-22 引入「### TC-NET-102: 全局中断状态一致性」正文条目 + 汇总行 ✅
- c2fb2d9 2026-08-22「构建测试」删掉正文条目,附录 B 汇总行未同步 → 号从 3 处减到 1 处
- 现在 ch395f_test_task.c:179 起的第一轮第 6 项测的就是同一件事:
  ch395f_get_glob_int_status_all() 先自清读、延时 10ms、第二次读断言 0x0000

所以那个 ✅ 说的是真事,只是没人能从号走到证据;测试项从未被放弃,
丢掉的只是条目与编号映射。据此不删行、也不改成失败,而是写实状态列。
2026-10-21 23:35:35 +08:00
wandering1 514a514601 test(NET): 阶段 11 实现体补挂用例号 TC-NET-1101
不是新增测试,是把已有实现体的号补上:ENABLE_PHASE11_TESTS 在
ch395f_test.h:65 是打开状态,实现体在 net_test_task.c:810 起(phase11_run),
但从未挂 TC-NET-1101 → 追溯器把它当成「文档声明却无载体」

改法:只在阶段 11 说明块补一行用例号注释(含属主文档路径,满足 §5.2 紧邻要求)

验证:@build 0 错误 0 警告,Program Size 与改前逐字节相同
(Code=213356 RO-data=18580 RW-data=1184 ZI-data=131480)→ 证实只动注释;
BOM / CR / U+FFFD 与 HEAD 逐项一致;id-check --strict 通过
2026-10-21 23:14:31 +08:00
wandering1 a9fd5572a3 docs(测试): 601/606/1006 标注真实覆盖状态(2026-10-21 用例清点)
按第 0 步的逐条覆盖核对结果写,不写笼统的「已覆盖」
- TC-NET-601:主路径确实由 602~605 + 701~704 覆盖(PC 侧
  ch395f_socket_test.py:344-451 逐字节比对;固件侧 net_test_task.c:152
  phase7_cksum 提供 32 位累加和),但「accept 返回同一 fd」的单连接语义与
  「仅 Socket 0 状态变化、无其它 Socket 干扰」两点无任何观测 → 标部分覆盖,
  不加状态词(既不是未实现也不是已覆盖,留着可见)
- TC-NET-606:标未实现。不发 FIN 的静默断开 / KeepAlive 超时后 auto_relisten
  能否自动回 LISTEN 无可重复观测;605 走的是干净 close(DISCONNECT 事件),
  与 TIMEOUT 路径机制不同,**不算覆盖**——这是本轮最实的一条发现:
  auto_relisten 是 FTP/对时/远程显示/104 四条链路共同的断连恢复底座,
  而现场最常见的恰是拔网线、断电这类不发 FIN 的断开
- TC-NET-1006:标未实现。20 轮快速连接-断开与每轮回 LISTEN 的断言都没有;
  单轮断开重连由 605 覆盖

验证:DOC-ONLY 15 → 12、NOTIMPL 1 → 4、BENCH 14 不变、错误级 632 → 626;
id-check --strict 通过(未往债务台账塞条目);doc-links --all 143 篇 0 失效;
BOM=否、CR=0、U+FFFD=0
2026-10-21 23:14:25 +08:00
wandering1 fca414102e fix(编号): 批 6 追溯器分档 + 板级 NET 用例号标注归一
步骤 1:追溯器分两档(不改数据,改判据)
- BENCH:文档**定义行**(标题行或表格首格)带「(硬件层)」或其所属章节带的号 →
  人工台架用例,不期望有 C 代码载体;NOTIMPL:定义行标了未实现/待补/暂缓/已取消
- 两档**单列示数恒打印**、明细进 --verbose,不是静默豁免;也不等于"已验证"
- 两个实测坑写在脚本注释里:① 按"任意行出现即算"实现时 BENCH 收 14 个号却只消掉
  1 处差异(同一号被别处引用一次就复活)→ 改为只认定义行 + 从文档全集整体减档;
  ② 载体类型必须由定义处决定,引用行不影响判断
- 另把 has_intent() 收窄到同号所在行(原先整文件任意位置有人话就算过关)

步骤 2:板级 NET 用例标注归一(真债,改名 109 处 / 5 文件)
- 取证发现"文档声明却无载体"绝大多数是**标注形态烂**:实现体里写的是
  TC204 / TC-201 / TC 703 / TC1501 / TC1502b(三种畸形),文档写 TC-NET-204 等
- 序号一位不改、只补模块码(§7.1);作用域限定 5 个真载体文件:
  test/board/ch395f_test_task.c(67) net_test_task.c(37) net_test_task.h(1)
  test/host/ch395f_socket_test.py(3) Drivers/BSP/CH395F/ch395f.c(1)
- 审计轨迹 scripts/rename_maps/batch6-net-annotate.json;别名自动登记(scoped)

实测
- DOC-ONLY 29 → 15:701~704 的 NET 层实现体一直存在(net_test_task.c),
  此前因裸形标注被误判成"未实现";1501~1503 属硬件层人工 + 同样裸形
- 交集 260 不变、代码侧 578 不减(识别面没缩);BENCH 14 个号 / NOTIMPL 1 个
- 台账 --prune-backlog 还清 6 条(只减不增);@build 0 错误 0 警告
  (Program Size 与改前逐字节相同:Code=213356 RO=18580 RW=1184 ZI=131480
   → 证实只动了注释与日志串);7 个改动文件 BOM/CR/U+FFFD 与 HEAD 逐项一致

剩余 15 处 DOC-ONLY 的组成(步骤 3 未完,下轮处理)
- 4 处文档侧无模块码形态(TC-1003/206/501/704)→ 属 ② 大批
- TC-NET-607 一条是历史指称(原文"原 607/608/609,现 701/702/703")
- 真未实现 10 条:TC-NET-101/102/601/606/801/802/901/902/1006/1101
  ——其中 801/802/901/902 曾在 2026-08 的代码里有载体、后被重构丢掉,最值得补
2026-10-21 13:53:06 +08:00
wandering1 43fbfa140b fix(门禁): 批 5 追溯器口径补齐——变更记录行不参与双向比对(未改任何号)
结论先行:我上一轮说「批 2 模块码单词化漏扫了 4 篇属主文档」——**被证据推翻**

- 依据是 check_test_traceability.py 报的 DOC-ONLY 38 处里有 8 处旧模块码
  (TC-AI-070/080/090、TC-PH-001/030、TC-TS-001、TC-VL-001),我据此写好了
  逐号点名的改名映射表准备落盘
- 改名工具干跑 **0 处命中**:查下去这 8 处**全部在变更记录行**(| 2026-10-10 | … |)里,
  写的是**当年**的用例名;按编号规范 §8.1 冻结历史不回改,旧号靠别名解析——
  而 TC-AI-070 → TC-ADCIN-070 这批**早在批 2 就已登记在 id_aliases.json**
- 所以照原计划改等于**销毁历史记录、收益为零**。真缺口是判据:追溯器是唯一
  没有「变更记录 / [示例] / [计划] 行豁免」的一支(check_ids / rename_ids /
  doc-links 都有),它把冻结历史当活引用 → 系统性假阳性

改了什么(判据,不改数据)
- check_test_traceability.py 加 SKIP_LINE(与另三支脚本同源的豁免正则),两侧采集
  都改为逐行判定,被豁免的号收进 frozen_ids 留痕,--verbose 全量列出——
  **豁免不等于已对应**,别拿它当收敛
- 顺带把 has_intent() 的判定范围从整文件收窄到**同号所在行**(意图必须写在号旁边,
  原先整文件里任何位置有人话就算过关,太松)

实测(关键是"没吞掉真对应")
- DOC-ONLY 38 → **29**(−9 全是冻结行假阳性,**不是真变好**);总差异 45 → 36
- **交集 260 → 260 不变**:豁免只吐冻结行,没有让任何一条真实对应消失
- 代码侧 578 个号不变(识别面没缩,这是改名/改判据最容易踩的坑)
- 错误级 632 不变(追溯不参与错误级计数);可判定比例 83.02%;--strict 通过

剩 29 处是真问题,但**不是编号问题**(要人裁决,不由门禁决定)
- 4 处无模块码形态(TC-1003 / TC-206 / TC-501 / TC-704):② 大批的文档侧镜像
- TC-NET-607 一条是历史指称(原文「原 TC-NET-607/608/609,现 701/702/703」),
  一对多写不进一对一的别名表
- TC-NET-302/401/402/501 标题明写「(硬件层)」= 人工台架步骤,代码无载体是预期
- 其余 20 处(TC-NET-101/102/1101/601/606/701~704/801/802/901/902/1006/1501~1503、
  TC-GD5F-503)**全库无任何载体**:文档声明了用例而代码/台架/模拟器都没有实现

另一个我自己的错误,记进 §8.3 而不只是提交信息
- 补别名时把 8 个键写到了 JSON **顶层**(真容器是 aliases,515 条)→ 条目完全无效;
  是 assert k not in d['aliases'] 拦下的,删掉无效键后 id_aliases.json 字节与 HEAD 一致
- 教训:DOC-ONLY 这类两侧字面比对差异,先判它是不是冻结行/别名可解析,再谈改数据;
  改名工具 0 命中与 assert 失败都是**信号**,不是小故障

文档:docs/编号规范.md §8.1 口径修正记录 + §8.3 批 5 行与经过段(含被推翻的判断)+ 变更记录;
docs/代码规范/pre-commit使用说明.md §2.25 顺带收编行同步;脚本 docstring 同步

验证:check_doc_links.py --all 143 篇 0 失效;id-check --strict 通过;三个改动文件
BOM=否、CR=0;未改 C/H,不需 @build;台账无需收账(本轮 0 处销账)
2026-10-21 13:25:06 +08:00
wandering1 52976bdec3 refactor(编号): 批 4 陷阱号统一为 TRAP-<模块码>-<nn>
本批是 ② 形态迁移清的第一族(用户裁定顺序:② 里的 Trap 一族先做)

统一了什么(数字不变、只补模块码,§7.1)——改名工具 23 处 / 11 文件
- Trap 01~07 → TRAP-GD5F-01~07:属主文档 7 个标题行从此成为定义位
- 陷阱 1 → TRAP-RTC-01(SD2506 属 RTC 域,§4.2)、见陷阱 01 → TRAP-GD5F-01
- Trap 21 / Trap 19 → TRAP-NET-21 / TRAP-NET-19(引用的是活登记处
  CH395F_Trap_Records.md,号本身不变)
- 波及 Drivers/BSP/GD5F2GQ5UE/*(4 处注释)与 test/board/*(2 处)→ 已重编

三处判读后**刻意不改**(写进 §8.3,不让人当成漏网)
- docs/环境与部署/并行任务工作树方案.md 的 wip/trap22-fix 是 git 分支名,不是编号;
  这类非编号字面量要靠类型处理,与位号 T1、IEEE 负序 S2、gitlint 规则 ID 同一批
- docs/UI设计/菜单页面设计.md 变更记录行的「陷阱 3」按 §8.1 冻结不改写
- docs/硬件平台/V0.0、V0.1 网表报告里 6 处 Trap05 语义确指 TRAP-NET-05,但该目录
  是门禁与改名工具共同的排除语料 → 不越界动笔,盲区登记进注册表 TRAP 族 cursor_note

两处不是改名、是改错话(映射表类工具的典型失效:字面对了、意思错了)
- scripts/README.md 原写「Trap 19 从未存在」;改名后字面变成 TRAP-NET-19,而它是
  **真实条目**(SET_MAC 必须在 INIT_CH395 之前)→ 那句话成了假话,改用站得住的例子
- docs/编号规范.md 同一处事故叙述补准确说法:当年裸形不在册 ≠ 这个坑不存在
- 菜单页面设计.md 的「见 §8.2 陷阱 1」本就不是 TRAP-* 族的号(指本文小节第 1 条),
  改写成「见本文 §8.2 第 1 条(lv_text_get_width 的 length=0 陷阱)」

关闸第一次真拦人:咬的是自己家的审计表
- scripts/rename_maps/batch3-trap.json 里必然写着 Trap 21 / 陷阱 1 等旧形态,
  被当成 6 类「台账外新账」→ 提交被拦(拦得对,但表不是语料)
- 第 4 次同一类自循环(前三次:别名表、注册表、债务台账)→ 整个 rename_maps/
  目录入 excluded_paths;这条规律写进 §8.4:**登记号的表一律不得当引用语料**

实测变化
- 错误级 659 → 632(−27);② 空格/中文形 Trap 残余 2 处(都是上面刻意保留的)
- ③ 悬空错误 44 → 42:TRAP-GD5F-01/05/06 三处历史悬空引用自动落地为已定义
- 编号字面量 8277 → 8312(识别面没有缩水,这是改名最容易踩的坑);
  可判定比例 82.92% → 82.99%(基线 82.6%);⑦ 定义无引用 100 → 103(如 TRAP-GD5F-02
  只有定义位无人引用,如实挂 warn 不压掉)
- 台账 --prune-backlog 收掉已还清 42 条,剩 298 条(只减不增);--strict 通过

另修:core.autocrlf=true 把 SD2506_Trap_Records.md 检出成 CRLF(41 处)→ 还原 LF,
该文件 diff 只剩 1 行实质改动(仓库权威行尾是 LF,见 .gitattributes)

验证(全绿)
- @build:0 错误 0 警告(Program Size Code=213356 RO=18580 RW=1184 ZI=131480)
- ci_local.py:L0 + L0.5 全过(16 runner / 1104 断言)
- check_doc_links.py --all:143 篇文档 0 失效、0 待确认
- 改动文件字节卫生逐项与 HEAD 对比:BOM / CR / U+FFFD 异常 0

审计轨迹与文档
- 映射表 scripts/rename_maps/batch3-trap.json(含作用域切分理由与刻意不改清单)
- 别名 11 条全部带 scope;docs/编号规范.md §7.1 行状态、§8.3 批 4 行 + 说明段、
  §8.4 自循环第 4 条、变更记录
2026-10-21 12:32:13 +08:00
wandering1 51604f7717 merge: task/doctrim-adc-tick-history 合入 v2-on-ui
CI / L0 静态门禁 (pre-commit) (push) Failing after 50s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 45s
任务 doctrim(3 个提交)经 task-worktree 回收:
- chore(注释): 清掉源码里不可检索的过程编码
- docs(algo): App/algo/README 缩成目录索引,详细设计迁入 docs/算法
- docs(ADC): 采样节拍设计说明只留现状,历史决策与台架记录移出
2026-10-21 11:44:31 +08:00
wandering1 5a9988b557 chore(注释): 清掉源码里不可检索的过程编码
清掉的(读者无从解析、且所指文档已不存在)
- 已删任务文档的编号:Task #32、其内部 §5 / §4.3、P1-5、P0-2、P1-4a、
  "方案 a"、"Lead 批准 2026-10-12"
- 正文里当代词用的文件版本号:"(v2.7 起为 AboveNormal)""已随 v2.0 事件
  驱动改造删除""(v2.0:DMA 消费)""见文件头 v1.3 说明"等 → 改写成陈述现状
- 测试与脚本同类:adc_input 桩头"v2.0:DMA 模型"、stub/adc.h 的 v2.0、
  check_cubemx_survival.py 段头的"2026-10-10 v2.8"、run_all_suites.py 的"方案 A"

刻意保留
- 可检索登记簿锚点:P-AUDIT 真条目(P0-1/P1-8/P2-1/P2-9/P3-11)、S6/S11、
  E-31/E-44、N-18/N-23/N-32、B1/B5/B7/B8、L1-x~L5-x 功能号
- 文件头修改记录块内的 vN.M(规范 V1.0 §2.1/§2.2 要求保留该块;块内编号
  在本文件内自洽可读)

顺带纠正注释与代码已经不一致的过时事实
- 仍把 TIM6 写成现行节拍源:Src/main.c 的 include 注释与回调注释、
  Src/freertos.c 的 adcTask 块 → 改 TIM3;adc_input.h 修订条目里"ADC1 只认
  TIM2/TIM3/TIM8 的 TRGO"不准确 → 换成指向判据(ADC采样节拍设计说明 §3.2)
- App/algo/phasor.c 双区注释称"唯一消费者是 adcTask、为后续保护任务预留",
  实际 protectTask 已跨上下文读(prot_elm_run -> phasor_get)→ 改为"读者跨
  adcTask 心跳与 protectTask 判据两个上下文,勿改加锁、勿取消双区",并同步
  修正 docs/算法/采集与算法层设计说明.md 同一句
- adc_tick_irq_notify() 的"拒绝 ISR 内计算"理由仍写"内含 2 ms 忙等"(DMA 化
  后已无忙等)→ 换成三条真实理由:任务级共享数据 / 计算量 / 生成区回调
- 裸 §5(指代不明)与"规划 §1.6 / 规划 §4"昵称式 → 写成完整路径紧邻式

字节卫生(Edit 会静默剥 BOM)
- App/task/ui_task.c 与 Drivers/BSP/UI/ui_net_sink.c 被剥掉 BOM 两次,均按
  3 字节 EF BB BF 前缀补回并复验;全部 20 个文件 CR=0、U+FFFD=0、无行尾空白

门禁
- @build 0 错误 0 警告,且 Program Size 与清理前逐项相同
  (Code=213356 RO=18580 RW=1184 ZI=131480)→ 纯注释改动、中文未被误重编码
- ui-golden 首跑红:新工作树缺被 gitignore 的黄金参考 simulator/tests/ref/
  (不入库、只在主工作树生成过);从主工作树复制该基线后复跑通过 → 注释改动
  零像素影响
- 全套 pre-commit 在这 20 个文件上 Passed(coding-standard / cppcheck /
  6 个主机单测 / doc-links / task-table / mem-numbers / cubemx-survival /
  registration-check / mem-budget / font-charset / ui-golden / id-check)
- id-check 可判定比例 82.71% -> 82.88%(删掉的是"形似 P-AUDIT 但查无定义"的
  假锚点),悬空引用 863 -> 854
2026-10-21 11:39:53 +08:00
wandering1 64d8b052b1 docs(algo): App/algo/README 缩成目录索引,详细设计迁入 docs/算法
拆分层级
- 新建 docs/算法/采集与算法层设计说明.md(413 行、13 节)承接机制、契约、
  判据与设计理由:定位与边界 / 数据流与初始化落点 / adc_input / adc_health /
  sample_ring / phasor / freq(7.1 为什么自适应窗、7.2 判据 J1~J6)/
  prot_set / prot_elm / 关键约定与禁令 / 资源足迹 / 验证与判读 / 相关文档
- App/algo/README.md 360 -> 128 行:只留文件清单、依赖、逐文件"改代码必须
  知道"的红线、构建与测试入口、新增模块动作;深挖改成逐条指针
- README 保留 §3.1-§3.7 原编号,既有"§3.7"引用不失效

索引与引用同步
- 索引:docs/README.md §5.2 新增行;项目架构总览主题表拆成"设计与判据 /
  目录索引"两行;AGENTS.md 与 App/README.md 指向(后者原文还写着 L1/L2)
- 按内容重指(编号仍在但内容已搬走,只核编号会静默指错):App/algo/freq.h、
  test/host/freq/freq_test.c、run_unit_test.py、继电保护规划 L2-4 行、
  pre-commit使用说明 J4 行 -> 指向设计说明
- ADC采样节拍设计说明的两处指向同步改到新文档

修 README 内过时事实
- 挂载写"八个 .c",实际 uvprojx 内 7 个
- SIM_EXEMPT 写"prot_set / freq 才登记豁免",实际七个全在表内
- freq-unit-test 标"待登记",实际已在 .pre-commit-config.yaml
- 定值项数一处 31->40、一处 32->40;blob 144->176 B 证明是 32->40
- 用例与判据数字不再三处复制:属主是 test/host/<模块>/ 与 docs/测试说明/

门禁
- doc-links --all 无新增问题;--sections-strict 覆盖 10 篇文档通过
- id-check 可判定 82.65%(基线 82.6,容差 0.1);TC 双向追溯差异 124 处不变
- registration-check PASS(1 条已知 WARN);freq 单测 46/46;@build 0 错 0 警
- 全部改动文件无 BOM、无 CR、U+FFFD = 0
2026-10-21 11:39:53 +08:00
wandering1 0f0d002afc docs(ADC): 采样节拍设计说明只留现状,历史决策与台架记录移出
删除(已确定的决策与过程记录,无现状信息量)
- §2 方案选型(方案 A 推荐 / 方案 B 备选与结论)
- §9 变更记录整表(9 行"已实现 / 已验收 / 已落地 / 复核修正"叙事)
- §7.1、§10.3、§11.5 三张台架实测对照表 + 反向验证记录 + boot_log 证据行
- 用户决策与过程编号(Task #32 P1-5 方案 a、B1、P0-1/P0-2、v2.0/v2.2/v2.7/v2.8、
  "规划未预见""原规划写错了"更正叙事)
- §4 代码改造清单(改造已完成)与 §8 / §10.5 的后续规划口吻

保留并补齐现状
- 链路总览(新增 TIM3 同源 Mermaid)· 实现落点表 · TIM3 配置与"为什么是 TIM3"
  (F4 触发清单是硬约束)· ADC1 + DMA 四条硬约束与半区无竞态推导 · 上电自校准
  · TPAFE 侧契约与原子快照 · 就绪门与节拍缺失 fail-safe · 单周期时序
  · 心跳计数逐个判读(含相位伪影为何阈值取 2)· 性能与资源特征
  · 设计边界与已知代价(N-18 无同刻性、滞后一拍、TPAFE 未 DMA 化的前提未定、
  调频能力现状)· 设计铁律
- 468 -> 344 行

顺带修掉文档自相矛盾
- 原 §3 / §4 仍在教人配 TIM6(§11 已改 TIM3)-> 全文统一 TIM3
- 上位需求引用写成 继电保护功能复现规划.md §3.2,实际 L1-1 在 §3.1

连带引用同步(7 文件 8 处)
- App/algo/adc_input.c -> §8;App/task/adc_task.c -> §6;Src/main.c -> §3.3;
  Src/freertos.c -> §9 与 §10(顺带纠正已失效的"ADC 2 ms 忙等"表述,DMA 化后
  单拍取数已零忙等)
- 看门狗设计说明 §6 与 §9 / §4.4;ADC通道映射说明 §4 与 §6;
  继电保护功能复现规划 §3.2
- 其中 2 处活断链 doc-links --all 抓不到(整行含"已消""已实现"状态词被豁免),
  另写脚本按「文档名 + 章节号」全库扫出后修复

门禁
- doc-links --all(非 SDL2 问题 0,可判定 57% -> 58%)与 --sections-strict 通过
- no-mojibake / trailing-whitespace / end-of-file-fixer / id-check 通过;
  task-table / mem-numbers 一致;@build 0 错误 0 警告
- 改动文件均无 BOM、无 CR、U+FFFD = 0;CubeMX 文件的注释改动都落在 USER CODE 区
2026-10-21 11:39:52 +08:00
wandering1 858434492c feat(门禁): id-check 关闸——按债务台账增量拦截,warn-only 时代结束
机制(--strict 不再「有错就失败」,而是只拦台账外的新账)
- scripts/id_known_backlog.json:340 条指纹覆盖 710 处存量(②167 类 / ⑥135 / ③38)
- 指纹 = (文件, 校验号, 字面量) → 计数,故意不含行号:行号每改必漂,
  用它当指纹会让台账天天假新增,三天后没人敢看 strict,台账失效比没台账更糟
- 带计数而非集合:同一文件里同一个号多出现一次也算新账(复制粘贴式扩散
  正是编号烂掉的主路径,只按「出现过没有」判会放过它)
- 新增 --write-backlog 与 --prune-backlog(只减不增;台账内条目变多当场点名);
  --strict 缺台账返回 2,绝不退化成一次性拦死存量;--only 与 --strict 互斥
- 钩子 entry 加 --strict;--only 与 --strict 互斥
- 顺带修 _scan_malformed 的反向 bug:原条件 (skip and name != "空格形 Trap")
  语义正好写反,带 [示例] 的行反而专被 Trap 规则咬;改为示例行全部不判
- 第一次提交就被自己的台账咬住(id_known_backlog.json 里当然写着 P1-3 等号,
  被当成新引用)→ 台账加入 excluded_paths:登记号的表不是引用号的文档,
  与 id_aliases.json / id_registry.json 同一条道理

为什么不照「清零再升 strict」的原节奏(§8.4 详述)
- 存量 710 处清完要数周,这期间新债一条都不拦,而新增才是烂掉主因
- 「等清零」让人习惯「报红但照样提交」,真关闸那天没人再信门禁;
  本仓已有 mem-budget / mem-numbers 走 --no-verify 的先例,不再开第二个口子

关闸验收三例(临时件当场还原、git diff 干净)
- A 全新前缀 Z-99 → exit 1 点名 ✅ 咬
- B 同号计数 1→2 → exit 1 点名 ✅ 咬
- C 干净态 710 处 → exit 0 放行 ✅ 放(放不住存量就会逼出 --no-verify)

验证:--strict 干净态 exit 0;可判定比例 82.74%(基线 82.6%);
doc-links 失效/待确认 0;未改 C/H 不需 @build。
文档:编号规范 §8 上线节奏改写 + 新增 §8.4 机制表;pre-commit使用说明 §2.25;
AGENTS.md 门禁行由 warn-only 改为已关闸增量拦截。
2026-10-21 11:35:30 +08:00
wandering1 d74cd030ea fix(门禁): 追溯脚本与 B 层口径对齐 + [示例] 行豁免写反的 bug
为什么改(第 1 件,用户裁定的顺序)
- B 层已认 test/host/<模块>/* 为 TC 号属主登记处,但 check_test_traceability.py
  仍按老规矩要求"每个号都必须出现在文档" → 两处判据互相打脸
- 实测矛盾面 284 处里有 173 处属于该口径:照老口径升 --strict 会当场拦死这些
  本不该写文档的号,唯一出路是 --no-verify——门禁公信力就是这么丢的

追溯差异改为三档(口径与编号门禁一致)
- DOC-ONLY:文档声明了、代码没有 → 真断链,38 处
- CODE-ONLY:代码有、母号不在登记处、**且号旁边没写人话意图** → 真未登记,7 处
- IN-CODE:靠"号旁写着意图"放过 → 238 处,**弱于文档的保证,单列示数不算过关**,
  对外举证仍需 C 层模块级说明(脚本里写明这条边界,禁止把它读成"已登记")
- 小写子项后缀(TC-TIMESYNC-003b)按母号算,与 check_ids 的 subitem_ok 同口径
- 真差异从 284 降到 45(38 + 7)

顺带修两个自己的 bug
- TC_RE 模块码上界停在 {0,4}:批 2 模块码单词化(ADCHEALTH 9 位)后代码侧集合
  从 556 悄悄缩到 217(339 个号隐形)→ 放宽到 {0,8}。**同一个坑第二次踩**,
  第一次是 check_ids.py 的候选正则——改名必须复核**所有**识别面,已写进 §8.1
- _scan_malformed 的豁免条件写成 `(skip and name != "空格形 Trap")`,语义正好反了:
  带 [示例] 的行反而专门被 Trap 规则咬。改为示例行**全部**形态规则不判,
  并给引用示例号的两行文档补 [示例] 标记

验证
- 编号门禁:错误级 712 → 710,可判定比例 82.73%(基线 82.6% 未跌破),校验 1/4/5 仍 0
- 追溯:代码侧 578 / 文档侧 298 / 交集 260 / 差异 45;--board-only 回退口径不变
- check_doc_links.py --all 文档路径与章节引用均通过;字节卫生复查 BOM=否、CR=0
- 未改 C/H,不需 @build

文档:docs/编号规范.md §8「顺带收编」条目改写 + §8.1 口径修正记录补两行 + 变更记录;
docs/代码规范/pre-commit使用说明.md §2.25「顺带收编」行同步三档口径
2026-10-21 11:24:05 +08:00
wandering1 4b9a4f4e24 refactor(编号): B 层口径落地——主机侧单测认代码为属主登记处
为什么不是"补 7 篇文档"
- 实测这些号在代码目录外**零消费者**(NANDPORT 47 / PROTELEM 13 / PROTSET 11 /
  FREQ 11 / FTPAUTH 10 / SHA256 8 / SOE 7 / ADCHEALTH 11 / IEC104 23,共 141 号),
  唯一"外部引用"是 scripts/id_aliases.json 自己 → 逐号写文档等于造一份没人读、
  还要跟着代码同步改的复述层(违反"同一事实只在一处详述")

判据与注册表改动
- 新增 9 族 TC-HOST-<模块码>:class 仍为 TC(跨模块同号照旧由校验 4 抓)、
  owner_docs_glob = test/host/<目录>/*、每模块游标做号段不越界(含 IEC104 补漏:
  它上批改名前是纯数字 TC-104-*,没进当时的悬空普查所以漏建族)
- 新旗标 allow_orphans(不补该旗标会让 141 号全变孤儿告警)、
  subitem_ok(小写子项后缀母号在册即算已定义,差异仍由双向追溯如实报)
- snapshot_only:§4.3 冻结快照族不参与"定义位优先"——先试无差别生效时,已销项的
  登记簿 N-21 被审计报告同号接管,校验 6 从 266 暴涨到 1437、可判定比例假崩 68.7%,
  且提示会教人补错文档名(正是 §1.2 的 N-31 撞号制造方式),故回退并改为快照族豁免
- excluded_paths 加 scripts/id_aliases.json 与 scripts/id_registry.json:
  别名表/注册表自身不得制造引用面
- T-2 / T-3 / T-4 / T-6 入别名表标 void(2026-10-02 复审专项条目,销项只删行没清引用)

修自己的漏配(这条是 bug 修复,不是口径)
- N / S-LDG / T-LDG 族漏登记 docs/任务规划/README.md(活登记处,N-21 在其中出现 2 次),
  只登记了 docs/任务规划/登记簿.md(N-21 为 0)→ 74 处真条目被误判

数字与诚实交代
- 错误级 1318 → 711(②398 + ③44 + ⑥269);可判定比例 82.74%(基线 82.6%)
- ⚠ 其中 504 处属**口径修正**、74 处属**修 bug**,**真正变好 0 处**;已在
  docs/编号规范.md §8.1 写口径修正记录表,禁止拿 711 与 1318 直接比
- 文档:§4.1 补 TC-HOST 说明、§8.1 口径修正记录、§8.3 批 3 行、变更记录;
  pre-commit使用说明 §2.25 判定口径与最新实测同步
- 未改任何 C/H,不需 @build;提交钩子即 L0 + L0.5 全量验证
2026-10-21 10:51:25 +08:00
wandering1 bda7ab2b50 refactor(编号): 16 个测试模块码单词化 + 门禁识别面连带放宽
批 2(用户授权改名 + 「新名要一眼看懂」裁定)
- 16 个有歧义模块码单词化,共 1,146 处随 scripts/rename_ids.py 改写:
  AI→ADCIN、AH→ADCHEALTH、PS→PROTSET、PE→PROTELEM、PH→PHASOR、
  TS→TIMESYNC、VL→VOLLOCK、NP→NANDPORT、CFG→CONFIG、CLK→SYSCLK、
  WD→WATCHDOG、STO→STORAGE、SH→SHA256、FA→FTPAUTH、FR→FREQ、104→IEC104
- 不动 NET/GD5F/RTC/SOE/UI/ADC/LOG/RS485/TOOL(已是单词、芯片名或业界通用词,
  改了纯属 churn)
- 别名按全局登记:这批字面量已完全限定(TC-<码>-<nnn>),不存在裸形多义
- 映射表存档 scripts/rename_maps/batch2.json 作审计轨迹;§6 模块码表同步

连带修门禁(关键教训)
- 模块码涨到 9 位(ADCHEALTH/WATCHDOG)后,候选正则与族正则的 {0,4} 上界
  会让改名后的号**整批从门禁眼里消失**:实测编号字面量从 8271 掉到 7315、
  悬空假降 490 处。已放宽到 {0,9} 并写进 §6 注记
- 结论:改名批次必须连带复核识别面,只改数据不改判据 = 静默放行;
  这次是靠「可判定比例跌破基线即失败」那条线拦下来的

现状与遗留
- 校验 1 未登记前缀 0 处;可判定比例 82.71%(基线 82.6%,只涨不跌成立)
- 遗留:7 个模块的「单元测试说明.md」仍缺(PROTSET 116、NANDPORT 109、
  PROTELEM 65、FREQ 48、FTPAUTH 45、SHA256 25、SOE 14 处悬空)——改名不解决
  缺文档,处置方式待议
- 遗留:T-2/T-3/T-4/T-6 历史任务号悬空(别名表未收全,属本工具批次漏项)
- 未改任何 C/H 可编译语义(仅注释与宏名内的编号),故不需 @build
2026-10-21 09:53:28 +08:00
wandering1 301c3e5183 feat(门禁): 编号门禁 id-check 落地(36→37 钩子)+ 批 1 编号单词化改名
新增门禁本体
- scripts/check_ids.py:实现 docs/编号规范.md §8 八项校验;默认 warn-only,
  唯一硬拦项是「可判定比例跌破基线」(防回退的本体)
- scripts/id_registry.json:66 个前缀族,逐项来自 §4 总表 + 全库实测游标,
  偏差一律写进 cursor_note,不凭记忆编造
- scripts/id_aliases.json:旧号 → 新号 / 作废标记(照搬 section_aliases.json 范式)
- 顺带收编 check_test_traceability.py:正则补齐大写后缀与无模块码形态,
  比对范围扩到 test/host 与 7 篇「单元测试说明.md」,另加 --board-only 回退旧口径

批 1 改名(用户授权解禁「不改写存量号」)
- 代码规范条款 S1~S19 → CODE-RULE-nn:78 处 / 2 文件
- UI 像素回归状态 S0~S7 → MENU-STATE-nn:61 处 / 3 文件(冻结在 07)
- 两族属主登记处 = 脚本自身的规则表 / STATES 表,文档只作引用方
- 新增 scripts/rename_ids.py(§7.3 规划脚本):映射表驱动、显式 scope、默认干跑、
  自动写 scoped 别名,跳过 [示例]/[计划] 行与变更记录表,按原字节读写保 BOM 行尾
- 已应用映射表存档 scripts/rename_maps/batch1.json 作审计轨迹

有牙实测(证明门禁不是空转)
- 注入 Z-99 / 裸形 / 悬空 / 越游标 / 未紧邻 / 跨文档重复定义 六例,
  分别被校验 1/2/3/5/6/4 点名,--strict 退出码 1;临时档删除后工作树干净

验证与同步
- 可判定比例基线 82.3% → 82.7%(已 --update-baseline 入档);错误级 1312 → 1291
- ci_local.py 全绿(L0 + L0.5 共 1104 断言);ui-golden Passed;
  check_doc_links.py --all 无法判定 0
- 未改任何 C/H 源文件,故本次不需 @build
- 文档:AGENTS.md 与 pre-commit使用说明.md 计数 36→37 并新增 §2.25 详解;
  编号规范 §4.1 补两行、§8.1 落地记录、§8.2 规范与仓库偏差、§8.3 迁移批次记录
2026-10-21 09:33:54 +08:00
wandering1 efeaccbb18 docs(规划): U5 明确暂缓,等页清单
CI / L0 静态门禁 (pre-commit) (push) Failing after 3m27s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 39s
报告 §6.5 U5 行与任务规划 README 的 W6 行补一句带日期的裁定:
剩余页面尚未规划,本轮不补页;现存 7 个 ui_page_*.c 即当前全部内容,
报告里的 27 页是目标数、不可当差集清单用。
U1-U4 已全部落地,故 U5 未来开工是纯增量活(实测新增一页 = 3 处人工 +
1 条生成器命令,且 ui-golden 已在提交门禁兜像素)。

此前这两处读起来像"随时可开工",与实情不符,会误导排期。
未改 §1.1 编号列表(§2 有引用,不可重编号),只改两个表格行。
2026-10-20 23:11:40 +08:00
wandering1 fafe13f7ea docs(报告): 状态表与 U2 落地对齐
§6.x 组件清单 `ui_w_records` 行:未建 -> 已建成,附证据与实测增减(-116 B / +8 B)。
§6.5 U2 行:未办 -> 已落地,指回组件行避免同一事实两处详述。
§0.1 UI 行:改为"组件库已起步但仅此 1 个组件",不夸大成已有组件体系。
代码已随 64abefa 入库,此处只补状态表,不让文档比仓库旧。
2026-10-20 22:58:39 +08:00
wandering1 64abefaa00 refactor(UI): U2 合并运行/自检记录两页为共享组件 ui_w_records
ui_page_runlog.c 与 ui_page_selftest.c 各 102 行,逐行比对后差异全是命名:
唯一的功能变量是日志 ID(SYSLOG_RUN / SYSLOG_SELFTEST),计数、每记录 2 行、
同记录读盘缓存、空态伪行的实现两边逐字相同。是真重复,不是推测出的抽象。

## 落地
新建 Drivers/BSP/UI/ui_w_records.{c,h}(已在 uvprojx + CMakeLists 双端注册;
生成器只自动管 ui_page_*.c,故这两处手工加)。两页各留一个实例与两个薄跳板,
.count_cb/.refresh 名字不变 => 注册表与生成器零改动。
净效果:两页 90 删 / 28 增(−62 行),重复逻辑收成一份。

## 合并唯一的真陷阱:缓存归属
count_cb/refresh 签名里没有 user context,最容易写的版本是把
s_cache_valid / s_last_rec / s_ts / s_desc 留成组件级静态——那样两页共用一份
缓存,从 A 页切到 B 页会因记录号相同而直接命中缓存,显示另一页的陈旧行,
是静默错数据而非崩溃。故缓存内嵌进实例(ui_w_records_t),每页各一份。

## 验证
@build 0 错 0 警;golden 经真实 ui-golden 钩子跑通:菜单 8 状态 + 内容页
11 页(含这两页)逐字节一致,未用 --update 刷基线 => 语义搬迁行为等价有证。
ER_IROM1 232,052 → 231,936 B(−116 B)。RW_IRAM1 75,232 → 75,240 B(+8 B),
原因如实记录:原先 4 个文件级静态零初始化落 ZI,现在 s_rec 带 .id 初值整个
结构体落 .data(RW),两页合计净 +8 B;要压回 0 需把 .id 留零再加 init 钩子,
为 8 字节引入运行时初始化顺序不值得,故保留并把账写在此处。
stack_report FAIL 行数 0(间接层未顶破栈预算);mem-budget / mem-numbers 已
按 @build 后规定回填台账。

## 过程中的两处自我失误(已修复,记录以免重犯)
① 用 stem[:2] 生成函数前缀,对 selftest 得到 se_ 而非 st_,锚点失配。
② 撤销脚本里 ${UI_DIR} 被 PowerShell here-string 插值吞掉,导致"边校验边写"
   把树留在引用组件但未注册的链接错误态。教训:回滚脚本必须比正向脚本更保守,
   全部校验通过后才允许写盘。
2026-10-20 22:57:13 +08:00
wandering1 44dec5c49b feat(UI): golden 像素回归入提交门禁(W6 U4 第三件,35→36 钩子)
补上我上一轮自己标记为 2/3 的那块缺口:两个 golden 脚本早已存在且一直在
人工跑,但**从未进自动门禁**——"有基线"不等于"有门禁"(§6.1 原文即自认)。

## 先实测成本再定形态
改一个 UI 源文件后「增量重编模拟器 + 两条 golden」合计 **2~4 秒**,支持走
自动门禁。若实测要几分钟,正确做法是明确不挂、改为文档化人工门禁——一个
逼人 --no-verify 的门禁等于没有门禁,故这个数字是本决定的前提而非附注。

## 落地
新增 scripts/check_ui_golden.py:串「增量构建 → 菜单 8 状态 → 内容页 11 页
+ 池水位」。**有意忽略 argv 且 pass_filenames: false**——两条 golden 按整棵
菜单树 + 全量参考图工作,不接受文件参数,pre-commit 传的变更名对它无意义。
files: 覆盖 UI 源 + ui_task.{c,h} + 模拟器 tests/sim/src + main.c +
CMakeLists + lv_conf.h + 包装器与本配置自身(pre-commit 4.x 无 always: true,
files 是唯一触发手段)。

## 端到端验的是钩子,不是脚本
干净树连跑 3 次 rc=0 全 Passed;给 ui_page_dc.c 的 .top_y 注入 +5 位移 →
**钩子 Failed** 并点名 `[dc] FAIL 与参考页不一致(1882 px 差异)`;还原后
git diff 干净、simulator/tests/ref 的 git status 为空——**全程未用 --update
刷基线**(那正是本钩子要防的行为)。
诚实记录瑕疵:首次实测在"连续注入/还原的紧邻重建"之间偶发失败一次,疑似
MSBuild 节点争用,此后 3 次连跑均绿。已把"单次失败先复跑、连续两次才当真、
不许直接 --no-verify 绕过"写进钩子注释。

## 文档同步
AGENTS.md 钩子数与文末索引 35→36 + 钩子清单补 ui-golden;
pre-commit使用说明.md 概览计数同步;报告 §6.5 U4 由 2/3 → **3/3**(这次是
"真进了自动门禁",不是"脚本存在")、§6.1 golden 行转 ✅、§0.1 缺口描述更新。

## W6 剩余
U2 合并两页记录、U5 落地目标页(须先对齐"目标页 vs 现存 7 个 ui_page_*.c"
的差集——报告的"27 页"是目标数而非现状数)。
2026-10-20 22:14:42 +08:00
wandering1 dafc18f5ed feat(UI): W6 第一波——设计令牌层、页面注册表生成器与 UI 门禁补强
启动 docs/任务规划/README.md 的 W6 UI 扩展簇(用户裁定,覆盖 2026-10-11
的"本轮不排期")。四个工作包,全部验证通过。

## U1 设计令牌层(零行为变化)
新建 Drivers/BSP/UI/ui_tokens.h(20 个宏),框架 + 7 个页面 + App/task/
ui_task.c 全部接入。屏幕几何**复用 ui_port.h 既有 UI_SCREEN_W/H**,不重复
定义;令牌头自身只 include ui_port.h,不把 LVGL 拖进全部消费者。
补收了报告没列但代码里真重复的量:UI_TEXT_BAND_H 15(7 次)、列表左右边界
与每页行数(各 4 个页面逐字复制)、推流协议几何与显示几何同源(此前硬编码
3200 且注释写着"须与 UI_FB_SIZE 一致"——典型漂移隐患)、UI_TASK_TICK_MS
(ui_port_step 与 osDelay 必须同值,原先靠注释维系)。
故意不收页面私有几何与"同形不同义"的值(device_info 的 pitch=15 与列表
UI_ROW_PITCH=14 合并即改像素)。

## U3 页面注册表与生成器(删手工分发)
ui_page_registry.{c,h} 由生成器产出,**手工 menu_content_page_for() 已删
除**(仓库内仅存 2 处历史说明注释)。密码页按代码实况**单独出口**
(ui_page_registry_password())——它非节点绑定,任何 #P 叶子进同一页。
顺带落地此前判定"U1 阶段做不了"的共享 flat 样式 ui_style.{c,h}。
生成器除产注册表外**自动把新 .c 写进 uvprojx 与 CMakeLists.txt**,且
**双向对账**:加页打印 registered、删页打印 pruned,不留死条目;并双向
校验(#page 找不到实例报错、实例未被任何 #page 引用也报错)。

## U4 门禁补强(35 个钩子)
新增 font-charset 钩子(scripts/check_font_charset.py):复用
tools/font_gen 的同一套提取与 cmap 解析规则(importlib 直载,零第二套
规则),A 段查源码在用 vs 字符集、B 段查字符集 vs 字体 cmap。
registration-check 补第 ④ 类:实测原规则对"进了两端构建但没接描述符表"
的页面**全绿**——desc 被链接器剥离、编译链接全过、菜单永远打不开的死代码。
仍照不出的两项已如实写进文档(枚举漏加归 @build、菜单树漏挂归 golden)。

## 字体子集瘦身(D 案,4 留 3 删)
ui_cn_chars.txt 122→119 字(恰删 上/电/重,零新增)。异/常/片/芯 进
KEEP_EXTRA 带理由白名单:它们曾在 9eb1bd63 被写进中文自检描述落盘
/log/selftest.log(定长 64B 记录跨固件持久),删字形会使历史故障记录
渲染空白且不可逆。保留字按**必需**处理,反向有牙——谁只跑 extract_cn
把它们静默洗掉,钩子直接 FAIL 并逐字打印理由。
--strict 口径为"未知冗余 = 0"而非"冗余 = 0"。

## 验证
@build 0 错 0 警;ER_IROM1 232,052 B(较 HEAD −228 B)、RW_IRAM1
75,232 B **零增量**、RW_CCM 87.9% 未变。golden:菜单 8 状态全像素一致 +
内容页 11 页逐字节一致,**参考图 sha256 差异 0 条**(未用 --update 刷
基线);池水位菜单基线 5368 B、开页-关页往返 5448 B 与 U1 时相同。
38 个在途修改文件逐个核对 BOM/CRLF/U+FFFD 全部与 HEAD 一致。
L0 33 钩子 + L0.5 16 runner / 1104 断言全绿;check_doc_links --all
142 篇、无法判定 0;mem-budget / mem-numbers 已回填。

## 单位成本实测(U5 排期依据)
临时页 zzprobe 绑到"遥信量"叶子 → 只跑生成器 → 带页 @build 0/0 →
注册表出现 {5u, &ui_page_zzprobe_desc} → 回滚后 6 个产出文件 zzprobe
残留计数均 0、重编回基线。
**结论:新增一页 = 3 处人工(新 .c + 新 .h + 菜单树那一行加
 #page=<名>)+ 1 条生成器命令,手工编辑注册文件 0 处。**
据此修正报告原口径"8 处降到 1 处"——那个 1 只在"1 处对既有文件的修改"
意义下成立,页面自身 2 个文件本就要写,真实处数是 3。

## 文档同步与两处自我修正
§0.1 UI 行 B → B+;§6.1「内容页」「设计令牌」两行按实况回填;§6.5 U1/U3
状态更新;§6 顶部加"建议栏不可照抄"总警示(本轮四次证伪:ui_port.h 已有
同名宏、LV_COLOR_BLACK/WHITE 不存在且极性反了、27 页实为目标页数现存 7
个、生成器草图漏密码页非节点绑定)。
两处改口:① §6.5 U4 从我上一版**误写的 3/3 改回 2/3**——"golden 脚本早已
存在"不等于"已进自动门禁",两个 golden 脚本至今未挂 pre-commit,该缺口
单列为待办;② 原计划"分三笔提交"**作废**,U1 与 U3 改同一批文件、文件内
交叠,硬切会产生不可编译的中间态,故整波一笔。
2026-10-20 21:14:14 +08:00
wandering1 9d06798f2d docs(规划): 删 §0.3 内存主线复述层,记录 W6 UI 簇启动与并行度约束
§0.3 第 1 条「内存这条线到此为止」是同日暂缓裁定的**复述层**,同一事实已有
三处单点详述(§0.4 内存两行、§5.3 暂缓横幅、登记簿 N-27 与内存管理两份文档
的横幅)。按「同一事实只在一处详述」删复述、留权威。§0.3 现只剩 UI 一条
主线;标题「两条主线建议」→「主线建议」,**编号不变**(任务规划 README §2
前言引用的是编号不是标题)。上一条变更记录里「内存簇暂缓 → §0.3 主线一」
这个去处自此失效,以本次新增行记载为准——变更记录本身是历史,不回改。

启动 W6:用户裁定**覆盖** 2026-10-11 的「本轮不排期」,按覆盖记录而非静默
改掉。已把 U1~U5 拆成 5 个工作包上共享任务板,第一波 U1 令牌层 ∥ U4 门禁
补强两名成员并行,写范围严格不相交。

并行度的真实约束已写进文档:不是任务数量,而是**注册文件**——新增一个 .c
必须同时改 uvprojx 与 CMakeLists.txt 这两个共享文件,同一工作树里两人同改
必然互相踩。所以 U3(原型化 + 生成器)是必须串行的地基段,U2/U5 排在它
后面;U5 的页面批量要等 U3 把注册自动化之后才谈得上并行。

修正两处失真:① 交付物清单里移植层单测还写着拟名 `test/host/dhara/` ❌
未建,实际落地为 `test/host/nand_port/`(149 断言、第 16 个 -unit-test
钩子)→ ✅;② 报告称「27 页未落地」实为**目标页数**,现存 `ui_page_*.c`
实测只有 7 个,已在 §6.5 注明 U5 开工前先对齐目标/已实现差集,不要照抄
报告数字。

check_doc_links --all:142 篇、无法判定 0。
2026-10-15 14:42:08 +08:00
wandering1 118ea4f005 docs(报告): 删除 §0.2「未闭环问题」小节
Top 2 修复销项后该节只剩指针与暂缓说明,而三条内容都已在别处单点详述:
未闭环清单 → §0.4;内存簇暂缓 → §0.3 主线一与 §5.3 暂缓横幅;Top 2 修复
证据 → 变更记录与文末「已销项条目的记录去处」。按 ../文档规范.md「同一
事实只在一处详述」删除,不留空壳。

§0.3 / §0.4 编号保持不变、不重排:../任务规划/README.md §2 前言正引用
本报告 §0.3,报告内部另有 §0.4 交叉引用,重排会全断(与本报告 2026-10-14
变更记录第 ⑤ 条「报告自有编号不回改、不重排」同一口径)。代价是 §0 下
现为 0.1 → 0.3 → 0.4 的空号,已在变更记录里写明理由。

文首「销项状态」行指针 §0.2 / §0.4 → §0.4;变更记录补一行删除说明。
check_doc_links --all:142 篇、无法判定 0。
2026-10-15 13:45:54 +08:00
wandering1 db16694a87 refactor(nand): dhara 几何字面量收单一真源 + 两条编译期守卫
CI / L0 静态门禁 (pre-commit) (push) Failing after 1m45s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 18s
上一笔提交 720f841 留下的待办,已登记在报告与 GD5F Trap Records 遗留 3。

ftl_page_in_range() 的越界上界 = num_blocks << log2_ppb,完全依赖 log2_ppb;
而这两个 log2_* 原本在 disk_initialize 里是裸字面量 11 / 6。将来换 128 页/块
的兼容片、只改了 GD5F_* 常量而忘了同步字面量,守卫会**静默算错**——偏大就是
越界守卫失效,正是它要防的那一类错误,且不报任何错。

收口方式比"加一条断言"更彻底:字面量收成 FTL_LOG2_PAGE_SIZE / FTL_LOG2_PPB
单一真源(disk_initialize 与守卫同取此宏,杜绝两处各写一遍再各自漂移),再加
两条负数组编译期守卫 FTL_PAGE_SIZE == (1 << FTL_LOG2_PAGE_SIZE) 与
GD5F_PAGES_PER_BLOCK == (1 << FTL_LOG2_PPB)。AC5 不支持 C11 _Static_assert,
负数组是本项目惯用法(同本目录 gd5f2gq5ue.h 的 N-26 DMA 契约守卫)。

验证:@build 0 错 0 警且**代码尺寸逐字节不变**(Code 213,540 / ER_IROM1
232,280 B / RW_IRAM1 75,232 B)→ 纯语义等价重构;主机侧移植层单测
149/149 PASSED。**守卫牙齿实测**:临时把 FTL_LOG2_PPB 改成 7 → 编译失败
(nand_ftl.c:70 error C2118 负下标),改回即恢复,证明它真会拦而不是摆设。

文档:GD5F Trap Records 遗留 3 由"待办"改为"同日已收口"并记收口方式;
nand_ftl.c 模块头修改记录补第 ④ 条。
2026-10-15 13:35:31 +08:00
wandering1 720f841494 fix(storage): dhara 移植层 is_free 整页精度 + HAL 越界守卫;日志读写批量化
登记簿 N-21(报告 N-12/N-13)与 N-24(报告 N-16)两项未闭环问题修复并销项。

N-12:dhara_nand_is_free() 旧实现只读页首 64 B 判全 0xFF,会把"页首
0xFF、后段有数据"的已编程页误判空闲,dhara 遂往已编程页再编程 = 位碰撞、
journal/map 指向垃圾且不报任何错。改整页扫描(栈上 64 B 分块、页首命中即
早退,故"已编程且页首有数据"的绝大多数页成本与旧实现同价)。

N-13:报告原建议经核对不成立——本仓裁剪版 dhara 既无 DHARA_NO_PAGE(哨兵
实为 DHARA_PAGE_NONE,journal.h:46)也无 DHARA_E_UNSUPPORTED(error.h 枚举
里没有,照抄直接编译失败),且 map.c 三处 dhara_journal_copy 调用点各自已
挡掉哨兵。真风险是移植层零上界校验:越界页号被芯片地址位截断成某个真实页
→ 静默读写错页。改为六个 HAL 入口统一几何上界守卫 + DHARA_E_CORRUPT_MAP。
错误码选择是刻意的:BAD_BLOCK 会让 journal.c:904 标坏无辜好块,RECOVER 会
让 map.c:275 启动回收;无错误通道的 is_free/is_bad/mark_bad 一律取保守值。

N-16 写侧:一次排空合并成"每文件一次 open/write/close"(≤8 条 = 512 B
静态缓冲)。掉电语义不变——批次仍在同一次 drain 调用内 close,而 close
回写目录项这个不同 LPN 会把 FTL 单页写回缓存逼出到 NAND;失败尾部回投
队列,条目不丢(旧实现 -2 卷忙会直接丢掉那一条)。容量轮转仍只在按条慢
路径做,批量路径发现装不下一批就返回 -3 退回。
N-16 读侧:s_read_raw() 加 8 条滑动读窗口 + 写者代数失效,API 与 UI 零改动。

验证:@build 0 错 0 警(Code 213,540 / ER_IROM1 232,280 B = 22.2%,
RW_IRAM1 75,232 B = 57.4%,ZI +1,640 B 全为静态缓冲、不吃任务栈);
@test PASS,且台架 journal 恢复点与修复前同轨——连续四次启动 sectors used
恒 14,094、Map head 每轮 +128 单调推进、epoch 稳定 3 → 整页 is_free 未
改变恢复语义、无双编程、无回退;stack_report 9 个任务全 PASS(defaultTask
静态最坏链 3,024 B / 余 40%,实测峰值 2,164 B / 余 57%)。

新增主机侧移植层单测 test/host/nand_port/(第 16 个 -unit-test 钩子):
RAM 假芯片 + 真实 dhara map/journal 源,149 断言,含"旧算法只看页首 64 B"
对照用例(将来有人把 is_free 改回去会立刻红)与 dhara 级 resume 双编程计数
为 0 的场景。L0.5 由 15 runner / 955 断言 → 16 runner / 1,104 断言。

文档:存储架构说明 §5.3 加移植层两条硬约束;运行与自检记录设计说明
§4.4/§4.5 写批量化与读窗口;GD5F Trap Records 新增 Trap 07(含三条遗留
代价与一条待办编译期断言);登记簿 N-21/N-24 删行销项并同步 README
§1/§2.0/§2.1、排期详述 C3、报告 §0.1/§0.2/§0.4/§4.x/§7 与去处表;
钩子计数 33→34、主机侧单测 15→16 全库同步。
2026-10-15 12:34:39 +08:00
wandering1 8739faa7fe docs: 裁定——内存优化簇整簇暂缓(R-2 后半 / R-3 / R-4 / R-6)
用户判定"把 LVGL 池与 UI 帧缓冲(现 ≈29.6 KB)搬进 CCM 作为后续
功能预算来源"这个决定不合适,内存优化的事先放一放。据此把这条
主线降级为暂缓,同时明确:不回退已落地的部分,不拆治理面。

报告(docs/问题记录/项目审查与优化建议报告.md):
- §0.2 Top 3 → Top 2:R-2 剩余移出,表下加一条 ⏸ 说明
- §0.3 主线一由"把内存从卡死变富裕"改写为"这条线到此为止"
- §0.4 两条内存条目 🟡/❌ → ⏸ 暂缓
- §5.1 结论段与 §5.2 诊断补"不代表现在要动"
- §5.3 加暂缓横幅:三条理由(没有功能等这块预算 / 搬迁有风险和
  工时 / 收益与代价不对称)+ 保留常开的治理面 + 三条重开条件
- §5.3 表内 R-2 / R-3 / R-4 / R-6 优先级一律 ~~P1/P2~~ → ⏸ 暂缓
- §7 路线图 W3 划掉标暂缓,映射行改为"暂缓期间无验收动作"
- 去处表 R-9 行注明"该簇其余部分已暂缓";变更记录加一条

任务规划:登记簿 N-27 波次 W3 → 暂缓,并写清代价——CCM 只剩
7,904 B、FreeRTOS 堆实测 min_ever 8,464 B,**加任务 / 抬栈 / 加堆
会先在这里撞墙**,届时 mem-budget 会先报警,那就是重开信号。
README §1 结构/健壮性 7 → 6、暂缓块 1 簇 → 2 簇、§2.0 第 3 条
订正(顺带修掉旧值 ≈8,160 B → 实测 7,904 B)、W3 行与 §2.1 链路
图标暂缓、§3 暂缓规则推广到"只在原处标注"的簇。**W3 槽位保留
不重编号**,避免全库改 W4~W7 引用。

内存管理三篇:RAM预算台账 §2 把 UI 超配额改判为"已知长期挂账"
(R-2 暂缓后这条 ⚠ 会一直挂着,别每轮当新问题追)、§3 解锁链标
暂缓并去掉"← 现在卡在这一步";RAM使用分析 §4 加实施状态横幅
(测量与长效机制常开 / 已落地不回退 / 1.2、1.3、2.2、2.3 与 8KB
试探暂缓)、§5 加"别拿目标列当现状"、§6 S5 补 ✅ 2026-10-15、
S6/S7/S8 改暂缓、S9 台账门禁补 ✅、1.5 里"缓冲已迁 CCM"订正为
"实际仍在 SRAM1";存储栈RAM优化调研加同款横幅(P0-2 / P1-5 /
第三步)。

校验:check_doc_links --all 通过(142 文档、无法判定 0);
check_mem_numbers 与 .map 一致;6 个文件无 BOM/CRLF/U+FFFD。
纯文档改动,不涉及代码与构建,故未跑 @build / @test。
2026-10-15 10:27:02 +08:00
wandering1 ff9fcbd43c docs(报告): 补漏——§0.1 六维评分与口径/附录数字追平 R-9 后状态
用户指出 §0.1「内存使用」行仍写"LVGL 池 + UI 两份缓冲 54,146 B
仍在 SRAM1,而 CCM 只剩 7,904 B → R-2 后半被堵死,须先 R-9"。
上一条 R-9 提交只改了 §0.2/§0.3/§0.4/§5.x,漏了 §0.1、§1 术语表、
§3.1 体量表与附录 A 证据表。全库重扫后修这些:

- §0.1 内存使用:SRAM1 74.9% → **56.1%(余 57,504 B)**,瓶颈收敛
  到 CCM 单点(87.9%、只剩 7,904 B),待迁体量 29,570 B,只差
  "缩堆"一步;评级 B → **B+**(SRAM1 由卡点变富裕、治理三件套与
  观测齐备;扣分项换成 CCM 单点 + MicroLIB C 堆无机械检查)
- §0.1 Flash、§1 术语表、§6.4:231,036 → **231,084**
- §1 术语表:`ROM = 231,324 B` 是**算术错**,Code+RO+RW 实为
  **232,196 B**(§5.1 早前已修,这处漏了)
- §1 术语表 RAM 占用定义:98,144 / 74.9% → **73,568 / 56.1%**
- 附录 A 证据行:改 `RW_IRAM1 Size: 0x00011f60`、余 57,504 B
- §0.1 与 §3.1:工程文档 88 篇 / 31,416 行 → **87 篇 / 30,781 行**、
  受控文件 2,466 / 79.3 MB → **2,465 / 79.2 MB**(随 506eccc 删除
  错误码传播审计.md,−1 篇 / −635 行);同表 docs/ **56.9 MB** 与
  手册图片 **703 张 / 36.3 MB** 实测未变,保留
- §0.1 / §0.2 / §0.4 与附录的复核日期统一标 2026-10-15
- 最近复核行的 HEAD 改指 a7f5265,并注明"后续文档与模拟器侧提交
  不影响固件",避免每提交一次就再次过期

未改一处:§3.1"全部 Markdown 只有 5.4 MB"——本次复算得 5.8 MB /
192 篇,但原计数规则无法复现,差异也远超一次删除的量级,不猜着改,
留给下次复核定口径。

校验:check_doc_links --all 通过(142 文档、章节引用无法判定 0);
check_mem_numbers 与 .map 一致;无 BOM / CRLF / U+FFFD。
2026-10-15 10:14:34 +08:00
wandering1 172b3f4a20 fix(sim): UI 回归改为自洽运行——修 netcfg_save 假失败
现象:check_content_pages.py 只有 netcfg_save 一页 FAIL(保存行值列
显示 ERR 而非 OK),其余 10 页像素与两处往返池水位全过。

真因不是代码回归:模拟器按**当前工作目录**读写 sim_config.ini,而
--cfgtest 的 E-01 场景 2 会伪造一份 4000 B 纯注释"雷"文件、并断言
"拒写后原文件不动"(size == 4000)——连正常通过路径也不删除它。
实测仓库根那份是 2026-10-11 08:37 留下的。之后每次运行都会加载它
→ 外科式保存要在 4000 B 注释后补写受管段 → 输出必超
CFG_FILE_MAX_LEN → 引擎按设计拒写 → 保存行显示 ERR,与"工厂默认
态"的黄金参考对不上。引擎行为是对的,错在自检留雷 + 回归不自洽。

修三处:
- simulator/main.c:run_cfgtest 拆成 scenarios + 外壳,无论成败一律
  cfg_store_remove 配置路径与临时路径,让配置回到"无文件 = 工厂默认"
- check_menu_rows.py / check_content_pages.py:以空临时目录当工作
  目录跑 lvgl_sim(cwd=tmp),调用目录里的残留档不再影响结果
- check_content_pages.py:作废"删 sim_netcfg.bin 残留"的旧兼容清理
  (旧档迁移 2026-10-10 已删),改为只提示、不删别人的档

验证:--cfgtest PASS 且跑完不再留下 sim_config.ini;
check_menu_rows.py PASS(8 状态 + 池基线 5,368 B);
check_content_pages.py "PASS: 11 内容页 + 往返池水位全部通过",
往返 5,448 B。**未刷任何黄金参考**——参考本来就是对的。顺带删掉
本地两份 4000 B 残留(仓库根与 build/Debug,均 gitignore 产物)。

未跑 @build:本次只改 simulator/ 侧代码,固件工程无涉及;模拟器
CMake 构建 0 错 0 警,cppcheck 钩子覆盖 simulator/main.c。

文档:UI_自动化测试流程 §五 加排查行 + 变更记录;simulator/README
§3.7 说明删除保证与原因;lvgl-simulator skill 同步;订正审查报告
R-9 那条把该差异误归给 cfg_file 改动的表述。
2026-10-15 10:05:37 +08:00
wandering1 0cece5a305 docs(编号规范): 新增 P-8 语义优先与语义标识规范(§3.3 / §5.7)
规划 / 待办类条目改以一行短语作标识,不再分配字母+数字号:
- §2 加 P-8「语义优先」,七条原则变八条
- §3 主轴由"两层编号"扩为 编号 / 语义标识 / 章节引用 三种形态
- 新增 §3.3 形态规则与适用分界:TC / TRAP / FR / NFR 等需机器
  解析或作历史证词的仍保留编号,并强制带语义括注(P-7)
- 新增 §5.7 登记、冻结与别名规则:短语即标识,改措辞等于换 ID;
  销项 = 删行 + 清全库引用,短语不复用
- §4.2 加修订说明,§9 加「0.5 语义标识」迁移阶段

起因:N-31 销项删行后按"现存最大值 +1"又被取走,与两处历史引用
撞号(见 docs/任务规划/README.md §0)。

注:本文件不是 R-9 会话所改,按"全部提交"一并入库。
2026-10-15 09:56:33 +08:00
wandering1 a7f526569a perf(mem): R-9 落地——LVGL 池 40→16 KB,SRAM1 74.9%→56.1%
取值依据是实测水位,不是预估:菜单根框 5,368 B、开页-关页往返
5,448 B、最重页面峰值 6,568 B(占 16 KB 的 40%)、真机开机
used=3,192 B。注意 16,384 B 建池后 lv_mem_monitor 报的 total 是
15,020 B(TLSF 控制块与对齐元数据 ≈1,364 B),核算余量用后者。

两端 lv_conf.h 同步改值(lv-conf-sync 生效值比对 163 项 PASS),
Lib/lv_conf.h 的 UTF-8 BOM 已按原样补回(Edit 会静默剥掉)。

验证:
- @build 0 错 0 警;ZI 154,456 → 129,880,精确 −24,576 B
- SRAM1 98,144/74.9% → 73,568/56.1%(余 57,504 B);CCM 不变 87.9%
- 模拟器菜单回归 PASS(8 状态像素 + 池基线 5,368 B 未变);11 页
  黄金参考逐字节 10/11 一致,往返池水位 5,448 B 分毫未变
- 唯一差异 netcfg_save 保存态:用 40 KB 旧池做 A/B 复测同样失败,
  确认与本改动无关(属 506eccc 的 cfg_file 行为变化),未刷黄金
  参考掩盖,留给该改动属主处置
- @test PASS:fw build Oct 15 2026 07:31:34,无 [ERR]/Fault,
  开机 622 ms 到 net init: OK,menu build done: used=3192
- 本地 CI 全绿(L0 + L0.5,15 runner / 955 断言)
- 台账受管区与内存快照区已 --write 回填,mem-budget / mem-numbers
  由红转绿

连带结论:R-2 后半待迁体量 54,146 → 29,570 B,瓶颈由 SRAM1 换到
CCM(只剩 7,904 B);解锁链第 ① 步完成,下一步只差缩堆 56→32 KB
(B5 是反向扩容,缩回前须重跑 stack_report.py + 上板 min_ever)。

文档同步 20 处:本报告按修复即销项删除 R-9 行,并改写 §0.2/§0.3/
§0.4/§5.1/§5.2/§5.3/§6.4 与变更记录、去处表;RAM使用分析 §2.6.2
新增实测水位表、阶段 1.1 标已执行;台账 §3 解锁链、登记簿 N-27、
任务规划 README W3 行与链路图;UI 四篇、产品需求、内存内容分配
策略、运行与自检记录、项目部署环境要求、测试开发标准流程、
simulator/README、ui-verify skill 常量。顺带订正三处与脚本不符的
旧值:池水位常量 4784/4864 → 5368/5448、FreeRTOS 堆 48 → 56 KB。
2026-10-15 09:56:15 +08:00
wandering1 506eccc68f docs: 错误码传播审计收口并删除该文档(E-xx 转历史编号族)
本次会话先后销项与关闭:
- E-61 SD2506 清 INTAF:原实现只读取 CTR1,而按手册 ARST=0 时读操作
  不清标志,属空操作;改为读-改-写 CTR1,并按手册订正 CTR1 位宏
  (INTAF/INTDF/BLF 位置,ARST 归 CTR3)
- E-16 cfg_file:新增 CFG_FILE_MAX_NAME_LEN(段名/键名 ≤15),注册时
  拒绝超长键名;s_gen_document 两处 snprintf 改检查式;补 TC-CFG-024
  用例(主机单测 149→151 PASSED)
- E-23 TC704:PHASE7_MULTI_COUNT 8→5(并发上限 = 8 − 3 个常驻监听),
  accept 失败分支加网络代次检查(网络重建即重建监听)
- E-26(UDP)按“保留 API + 文档定案”关闭;E-30 评估为不必要后关闭;
  N-4(ACCEPT 决定不实施)关闭

审计文档剩余 5 项 P2 + 2 项监督,经用户裁定“都不重要”后整体删除:
- 删除 docs/问题记录/错误码传播审计.md(634 行)
- 九类缺陷模式表迁入 docs/编号规范.md §4.2.1(PAT-01~09)
- E-xx 转为纯历史编号族:编号规范 §4.1 属主改“无(原属主已删除)”,
  §4.3 保持冻结;全仓 26 处引用按“保留编号、去悬空文档名”处理
- 顺带修正 N-4 收口遗留:排期详述 C1 才是 E-25/E-27/E-28 的承载处
  (审计 §4 曾误写为登记簿);README 两处 C1 旧名改回“net 层跨上下文收口”

一并提交此前遗留的体系层评审报告 2026-10-14 复核 WIP(六维评分复核、
未闭环 Top 5→3、三区水位实测刷新)。

校验:Keil 构建 0 错误 0 警告(Code=212344);check_doc_links
--sections-strict 与 --all(142 文档)通过;内存双台账刷新后均 PASS。
2026-10-15 07:39:38 +08:00
wandering1 803f7717ed chore(skills): 纳入 template-skill 骨架(DSH 建 skill 的空白模板)
.dsh/skills/ 是入库的项目知识(.gitignore 用 `!.dsh/skills/` 显式放行),
此前该目录未被跟踪,现按同一口径纳入。

内容为 DSH 新建 skill 的空白骨架:description 仍是占位文案、正文未填。
后续要落地具体 skill 时直接改写本文件;确认不需要则删目录即可。
2026-10-14 23:22:26 +08:00
wandering1 617c84f570 docs: 文档去阶段化——清理"自愈收敛阶段 / L3 pending / 一期二期"标签
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m11s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 17s
代码注释去阶段化已在 task-arch-docs-sync 分支 d770e36 落地,本次把
同一口径补到文档正文(文末变更记录行作为历史保留不动)。

- BSD 使用指南 / 函数调用关系:删 自愈收敛阶段 N、N-32 P1/P2、
  L3 pending、一期二期;职责边界与实现位置改为现状口径
- CH395F_Trap_Records:TRAP-NET-17 / -21 / B10 状态行改述为现行形态
- 链路延迟测试说明:L3 pending → 挂起 recv;P0-a 阶段标签删除
- 模拟器差距清单:N-3 / N-4 / N-6 行改述(对账形态③④、无手动 relisten)
- 错误码传播审计:L3 pending → 挂起 recv;"N-4 二期" → N-4
- 任务规划 README / 登记簿 N-4 / 排期详述 C1 / 基线与待确认 §2.1:
  一期二期 → recv 挂起已落地 / ACCEPT pending 化(§2.1 标题同步)
- CH395F_Test_Guide:阶段 15 标题去掉撞名的"自愈收敛阶段 2"
- IEC104 上行设计说明:门控迁移改现状口径;"随二期" → 未登记具体条目
- 保留可检索锚点:TRAP-NET-*、E* / U* / D10、形态③④、N-4、Phase 15

门禁:check_doc_links.py --all 通过(143 文档、可判定章节引用 1282 处、
0 错 0 警);check_mojibake 全过;CR=0、U+FFFD=0、无 BOM 变化。
本轮只改 .md,无代码改动,故不跑 @build。
2026-10-14 23:05:17 +08:00
wandering1 1e568b8549 merge: task/ARCHDOCS-sync-20261014 合入 v2-on-ui
任务 ARCHDOCS(2 个提交)经 task-worktree 回收:
- refactor(NET): 源码注释去阶段化 + 网络两份文档重构 + 不稳根因图更新
- docs(架构): 全景/启动顺序/存储/引用图与 README 同步任务面与 NET 内部模块

备注:架构设计文档+README 同步现状(补登记:任务先以 task-arch-docs-sync 手工分支完成,现按 task-worktree 规...
2026-10-14 22:52:40 +08:00
wandering1 d770e36c7c refactor(NET): 源码注释去阶段化 + 网络两份文档重构 + 不稳根因图更新
- 源码注释:删去自愈收敛阶段
1/1.5/3等 scaffolding 称谓,改为语义引用
  (E-02 收口 / F-NET-2/3/4 / docs/网络/网络自愈架构设计.md §5.2 E13);
  涉及 net_socket/.h、net_events、net_listener、net_pending、net_types、
  net_config、ch395f、time_sync、iec104_serv、ui_net_sink、lftpd、
  simulator/sim/net_socket_sim、board/host 测试与探针
- 文档重构:网络自愈架构设计.md 与 Net层阻塞模型架构设计.md 改为现状口径
  (删历史节与阶段叙事),NET/README.md、docs/README.md 同步
- 工具与配图:gen_net_instability_svg.py 与
  docs/images/网络不稳根因总览.svg 随源码口径一并更新
- 门禁:@build 0 错误 0 警告
2026-10-14 22:05:53 +08:00
wandering1 cea2b51efb docs(架构): 全景/启动顺序/存储/引用图与 README 同步任务面与 NET 内部模块
- 项目架构总览 v1.1:§1.1 去重复⑥行、硬件行 UART4/TIM3/ADC1;§2.1
  任务表 6→9 行且栈值按 freertos.c 校准;§3 补采集算法链与 TIM3 节拍;
  §5 补 net_events/net_listener/net_pending 与 iec104_serv,单客户端
  恢复窗更正为实测 p50 208ms;§7 目录树、§9 索引、§10 变更记录同步
- 项目启动顺序管理:任务表 5→10 行;启动轨迹表 8→14 行(fw build、
  ADC input layer、phasor/freq/protection、SD2506 明细、TIM3 启动)
- 存储架构说明:§1 初始化顺序块重写(gd5f 归位 app_main_init 内、
  rs485Task 未创建、补 TIM3 与 L1~L3 链)
- 引用关系图说明 §7 统计与产物重跑刷新;就绪屏障 SVG 补 iec104Task
  消费者后重生成(1400x912)
- README:外设引脚纠错(RS485=UART4/PC10/PC11/PA15、TPAFE 控制脚
  PC8/PC9/PC7)、启动流程补 sys_clock/adc_input/TIM3、任务表重生成
  (9 任务)、目录树与 NET 小节同步、ftp_speed_test→regression/
  integrity、文档表补四条专项索引
- 工具:gen_task_table.py 补 iec104Task/protectTask 职责;
  gen_barrier_svg.py 补 iec104Task 消费者与 bit0 计数
2026-10-14 19:07:48 +08:00
wandering1 4f8e298394 chore(gitignore): .dsh 会话草稿不入库,skills 照常跟踪
现象:每轮 agent 会话在 .dsh/ 根目录留下草稿(提交信息草稿、门禁输出、台架
日志、一次性改名脚本、故障注入 patch),全部以 ?? 挂在 git status 里,混在
真改动中间。本次清掉 17 个:196 KB 门禁输出、台架日志、3 份提交信息草稿、
4 个注入 patch、2 个已合并实现的过程 diff、1 个一次性改名脚本。

规则:`.dsh/*` 忽略 + `!.dsh/skills/` 放行 —— skills 是**入库的项目知识**
(子域方法论),30 个已跟踪文件不受影响。

判据写进注释而非只写"忽略草稿",命中任一就不值得留:
  ① 能重跑的(门禁输出、日志);
  ② 内容已在 git 历史里的(已合并实现的过程 diff —— 如 T0/T1 E17 与
     sock_reset,主干已有 ch395f_sock_reset_begin/poll);
  ③ 离开已丢弃的任务分支就跑不通的(依赖 NET_FAULT_INJECT 的注入产物)。
第 ③ 条是关键:这类东西提交进去是"看着有证据、实际不可复现"的**假证据**,
比没有更坏;方法应沉淀成文档文字 —— docs/网络/网络自愈架构设计.md §5.2
E18/E19 已完整记录注入点、触发方式、判据与实测结果,patch 不是唯一载体。

与 MDK-ARM/retracted_bench_20260922 归档先例的区别(避免下次误套):那次
归档的 probe_*.py 独立可运行,归档理由是"判据是错的,但要立牌警示后人别
再用"。归档的前提是**还能跑、结论错**,不是"根本跑不起来"。

验证:git check-ignore 双向确认(草稿命中 .dsh/*、skills 路径不被忽略);
删除前逐个 git ls-files --error-unmatch 确认未跟踪,**未用 git clean**(会
误伤 skills 下可能存在的未跟踪文件);skills 跟踪数 30 不变;.gitignore 无
BOM / 无 U+FFFD / 无 CR。
2026-10-14 18:25:23 +08:00
wandering1 2a6080827d docs(网络): 自愈架构 §4.1 形态详解 / §4.10 读即清审计 / §1.5 横向对照
四块内容,都是"把已经做完的事写进文档",无新增设计。

1. §4.1 形态表扩成 6 小节:两个所有者 / 四个词 / 形态表(补"芯片命令"列与
   U11 独立行)/ 每个形态为什么长这样、怎么验的 / 为什么 ①③⑤ 常态 0 命中 /
   M3 可疑门与沿革。
   ⚠ 修一处文档 bug:原 ④ 行实际描述的是 U11,真正的 ④ 没有行。已补 U11
   独立行,并在 ⚠ 注记里记录这次更正及其 U12 后果。

2. §4.10 读即清信源审计:两个读即清寄存器与两级门控、现存读清点全量清单
   (产品代码 6 处)、已根除的三条互吞实例、一处"看着像缺陷其实是配对
   消费"、新增一处读清的三条准入判据 + 结构性残余。AGENTS.md 的 CH395F
   单连接 socket 条目加指针。

3. §1.5 与主流 offload 驱动的代码级对照(正面回答"是不是过度设计"):
   ioLibrary close()/listen() 原文、四个机制一一映射、三条约束差异表、
   "死等把代价挪到运行时"、lwIP/Zephyr 对照、诚实清单(可省 16 B / 不可省
   沉降窗 + D10 + listen 前排清 + 登记执行分离)、唯一杠杆是芯片。

4. 模块归属修正(承接 63cf73b 的 net_events.c 抽出):
   - §11.5 新增一节,含"为什么是一个模块而不是两个"(对账真实调用事件
     处理器 2 处 + NET_RECON_STABLE_DEATH 两边共用)与三处非纯搬移;
   - §9 行数快照更新,并加警告:这类快照**当天就会漂移**,是地图不是判据;
   - §1.5 的 net_listener.c 行数随注释精简同步(508/277 → 386/155)。
   引用修正波及 5 个文件:BSD_Socket_API 使用指南文件职责表(拆出
   net_events.c 行)、函数调用关系 §4.8 标题、Net 层阻塞模型两处形态⑤表格、
   CH395F_Trap_Records 两处"对应文件"(保留"原在 net_socket.c"的历史)。
   ⚠ doc-links 只校验 § 编号存在性,照不出"函数在哪个文件"——这类归属
   漂移门禁不拦,必须人工扫。

验证:check_doc_links --all --sections-strict PASS(2146 处引用,可判定
1263,无法判定 0,142 文档);ci_local L0 32 钩子 + L0.5 953/953 全过。
2026-10-14 18:18:06 +08:00
wandering1 dd51221ebc docs(NET): net_listener.c 注释精简 −44%(代码逐行未改)+ 判据指向修正
动机:508 行里注释占 277(54%),其中大量是逐行复述代码的噪音(/* 当前
tick */、/* 置 BUSY(结果回传调用方) */ ×4、/* 交重试轮询(30ms 后复检) */
×3 等),以及把整段陷阱推演塞在调用点旁边。

做法:删逐行复述;删各函数的"出口参数:无"字段;陷阱叙述压成"一句结论 +
指向文档"(→ 网络自愈架构设计 §4.3 / §4.10.3 / §4.2 / §11)。

保留的判断性内容(不是噪音,删了会重新踩坑):2KB 残渣静默截断、
SETTLE < DEADLINE 的时序约束、"只清理不等待 0ms 实测 4/4 仍截断"、严禁
osDelay 忙等、排事件必须在 LISTEN 之前(E14)、U12 对称 close、芯片已在听
绝不发 open、session_dead 刻意不清、clear 必须全清(跨轮残留)、scratch 勿
静态化、预算耗尽 ≠ 放弃、留痕只打首轮。

⚠ 另删一条**自相矛盾的过期注释**("上限 NET_RELISTEN_SETTLE_MS 后强制
放行")——它与 E19 把上限降级为"只留痕"直接冲突,留着比删掉更危险。同时
在相关处补一条显式守卫:此处**绝不**读全局中断寄存器(原警告埋在 7 行
"已删除"说明里,等于没有)。

结果:508 → 386 行(注释 277 → 155,−44%)。**代码逐行未改**:剥注释后
HEAD 与工作版均为 204 行且逐行一致;@build 尺寸完全不变
(Code 212296 / RO 18740 / RW 1112 / ZI 154456)。

顺带修正文件头指向:周期判据 net_reconcile_tick 已于 63cf73b 移入
net_events.c,原写"见 net_socket.c"(.c / .h 各一处)。
2026-10-14 18:17:49 +08:00
wandering1 63cf73b4d0 refactor(NET): 事件处理与周期对账抽出 net_events.c,net_socket.c 3720→3119
动机:net_socket.c 3720 行(注释 1878 / 代码 1518 / 54 函数),BSD 连接层
与"状态面收敛"两套职责混在一个文件里。16 个消费者只依赖 net_socket.h,
故搬移对应用层零波及;模拟器另有 net_socket_sim.c,新模块走 SIM_EXEMPT。

为什么是一个模块而不是两个:实测对账区真实调用事件处理器 2 处(形态② 补
accept 走 handle_connect_event),且 NET_RECON_STABLE_DEATH 两边共用——
拆成 net_events.c + net_reconcile.c 会造出跨模块调用,合起来则跨模块导出
为零。二者同属"芯片状态面与本层镜像的收敛"一个职责。

搬出:5 个 handle_*_event + fire_event + net_reconcile_tick +
net_lost_connect_probe + NET_RECONCILE_*/NET_RECON_STABLE_* 常量 +
net_bench_cmd_log(含其 2 个 static)。

搬移时 4 处 fd 取法替换(其余代码逐行未改,已用剥注释后的代码行多重集
证明差异恰好等于声明的改动集):
- p_sock - s_net_socks → p_sock->ch395_sock(net_socket() 里
  ch395_sock = sockfd,恒等;模拟器同构造)
- 对账循环 &s_net_socks[i] → net_sock_raw(i)

⚠ 随之收窄两处沉降登记的范围门 (qfd >= 0) && (qfd < MAX) → qfd < MAX:
ch395_sock 是 uint8_t,(int) 后恒非负,下界在类型上不可能越界;留着会被
cppcheck 判 knownConditionTrueFalse 拦提交。已在文件头写"勿加回"。

新增 net_sock_raw():对账必须看到 in_use==0 / state==CLOSED 的槽位
(形态④ 认领孤儿监听正依赖 CLOSED 态),而 net_get_sock() 对未在用槽位返
回 NULL 会漏掉这一类。声明放 net_socket.h 而非内部头 net_events.h——规范
S17 2.2 要求非 static 函数声明在配对头文件(同 net_set_errno 先例)。

验证:
- 代码行多重集差异 = 声明的改动集,无一处意外
- @build BUILD_OK 0 错 0 警;Code 212316→212296(−20 B,拆 TU 布局差),
  RO/RW/ZI 完全不变
- cppcheck / coding-standard / registration-check 全 PASS
- @test 硬件闭环 PASS:635ms 到 net init: OK,:8888/:2404/FTP 均建立,
  [ERR]/Fault/relisten/reconcile 命中 0 条
- 台账随 Code 变化同步(ram_baseline --write / check_mem_numbers --write)

遗留(与仍未提交的文档工作同文件,另提):BSD_Socket_API 使用指南的文件
职责表、net_listener.c/.h 文件头"见 net_socket.c"、自愈架构设计 §11 仍把
net_reconcile_tick 记在 net_socket.c——doc-links 只校验 § 编号存在性,
照不出"函数在哪个文件",需在文档提交中一并修正。
2026-10-14 18:04:57 +08:00
wandering1 0edbfaf08b fix(NET): 监听重建单一所有者收口 + 沉降窗 800ms 上限只留痕
CI / L0 静态门禁 (pre-commit) (push) Failing after 1m12s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 17s
T0 降噪 + T1 单一所有者:四处重建触发(recon/accept/eof/drop)与 pending
第五处统一走 net_listener_request(),由 net_listener_tick 单点执行;
沉降重试日志改"只留首末"(E0 口径),每次恢复少打 3 行重复行。

force open 降级为只留痕(E19 反事实 A/B 实测):芯片未回 CLOSED 时强行
open 注定吃 USE 错(TRAP-NET-09),放行与不放行的端口恢复时刻同一分布
(放行 +512 / 不放行 +505·533 / 降级后 +509 ms)——恢复由芯片何时回
CLOSED 决定。真正兜住"监听器永久失效"的是 D10 预算到期 + 对账形态④
重新登记,不是放行。新增 s_relisten_capped[] 使上限留痕只打一次,
隔离标志不清 → 下一轮继续排空残渣(构建 B 实测每 30ms 两行会刷屏)。

负向注入三轮打靶(E18,注入代码不入库):形态① 吞掉 DISCONNECT 后
4/4 救回、③ 空闲监听器静默失位两轮独立复现、⑤ 开盲区吞掉芯片自发中断
+ 收回 LISTEN 后 -> resync 命中且挂起 recv 被取消 → 三条均不可降级为
只留痕。顺带稳定复现 [ERR] relisten retry budget exhausted = 对端不配合
拆链的签名,非回归。

台架:boot PASS;ftp_abort_probe RST 10/10 + FIN 5/5;relisten_stress
:21 50/50(p50 507ms);n30_repro 30/30。构建 0 错 0 警 Code=212316;
netTask 静态最坏链 1920→1912 B;RAM 台账受管区已 --write 同步(RW +8B)。
文档:E17/E18/E19 三行 + §4.1 常态 0 命中归因 + §4.3 兜底归属改正;
登记簿 N-31 销项(结论沉淀进 §4.3 与 E19)。
2026-10-14 15:57:10 +08:00
wandering1 e95d2be30b docs(任务规划): 新条目改用语义标识——"编号不回收"在实践中不可执行
登记簿原规则写着"编号不回收"(README §0 与 §3 各一处),但销项 = 删行,
删完仓库里就不留任何痕迹 → 该规则要靠人先想起去翻 git log 才成立。

实测撞号:登记簿 N-31(2026-10-11 的 Phase 7 复跑项)销项后,2026-10-14
新建条目按"现存最大值 +1"又取了 N-31,与 CH395F_Test_Guide.md、
自动化测试流程.md 里那两处历史引用同号。

裁定(用户 2026-10-14):新条目 # 列填语义短语(如
"沉降窗 800ms 上限只留痕"),不再新增字母+数字编号;存量编号冻结沿用,
已随销项消失的号一律永久作废、不得作为新条目候选。语义标识自带唯一性
(重名一眼可见),从结构上消除复用可能。

改动:README §0 表首新增"语义标识"行 + 撞号因果注记;§3 原"编号一律
沿用且不回收"整条重写;AGENTS.md 完成即销项条目追加本裁定。
已确认无门禁校验编号格式(check_doc_links 只校 §N.M 与文档路径,
脚本里的 N-23/T-1 是注释性出处)→ 换标识不破任何钩子。
2026-10-14 15:56:54 +08:00
wandering1 f849c79b7a feat(NET): SEND pending 二期落地 + 末块挂起越界缺陷修复(台架复测闭环)
net_pending.c/h 增设第二张挂起表 s_net_send[](与 recv 表完全独立,收发
互不阻塞);net_send_locked 快速路径改为有界自旋,单次等待超出
NET_SEND_SPIN_MS(2ms) 即登记挂起 send,由 net_send_pending_tick 按主循环
节拍分块推进,netTask 最坏停滞由 1s 压到 2ms。net_listener_retry_tick 的
挂起保险扩展到 send 侧;net_close_locked / 网络重建 / 状态对账三路均先
取消再动作(取消必通知,调用方永不挂死)。

台架复测(A/B 对照 + 插桩定位)发现并修复末块挂起越界缺陷:内层"等本块
发完(SENBUF_FREE)"位于写块之后,写完末块时 sent 已 == len,若该次等待越过
2ms 预算便会调用 net_send_pending_register,而登记函数按 sent >= len 拒收
并回 -1,把一次实际已完整交付芯片的发送报成失败。修复为内层等待开头判
sent >= len 即跳出(末块已交付、SENBUF_FREE 等待是纯保守自旋),并置于
超时/自旋判定之前;该守卫同时保证后续登记必伴随 sent < len。

复验:ftp_regression 连续 19 次 11/11;ftp_integrity_test 20 轮 x 64KB
STOR/RETR SHA-256 20/20 一致(两轮),吞吐稳定约 80KB/s;设备日志零
write error;ST-LINK 回读 No difference found。

文档:Net层阻塞模型架构设计.md 补二期设计与新增 9.1 缺陷记录,netTask
栈实测峰值按本次台架 920B 订正为 1116/3840(FTP 传输期 1164);网络自愈
架构设计.md 吸收原监听生命周期文档、新增编号术语导读表,并把 5.2
"实验锚点速查"的 E 编号含义与 E-02 审计编号歧义写在表头(用户反馈该表
E 编号无法读懂)。

门禁:Keil 0 错 0 警;cppcheck / 编码规范 / 乱码零告警;
check_mem_numbers PASS;check_doc_links --all --sections-strict 142 篇 PASS。
2026-10-14 12:05:21 +08:00
wandering1 df13dec479 refactor(net): 挂起 recv 模型拆出 net_pending.c/h + 留痕探针编译门控 + E15 归因修正
CI / L0 静态门禁 (pre-commit) (push) Failing after 2m14s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 22s
- E15 归因修正:幽灵状态 0x04+旧端口 = 芯片如实报告拆除中的旧连接
  (文档化行为,非芯片故障;bit3=1 为我方设置(ver>=0x46)、
  RFC 9293 §3.3.2 Fig5 拆除顺序、WCH 官方博客方案 = close 完成后重开监听)
- 术语统一:全库"了结"→"结束"(约 45 处);阻塞模型 §4 首次出现处定义"五路"
- 注释清理:删孤儿函数头注释、考古叙事改现在时、去不可检索内部编码"L3"
  (33 处 6 文件)、banner 去重(唯一理由移到分支现场)
- 挂起超时留痕探针:编译期开关 NET_PENDING_TRIPWIRE(默认 0 = 编译剔除,
  省约 150B Flash;排查调用方挂死时置 1,只留痕绝不强制结束)
- 拆模块:net_pending.c/h(363/94 行)承载挂起 recv 模型,
  net_socket.c 4059→3661 行;tick 收控制块表参数、settle_close 双参显式传;
  net_pending_active 声明移配对头(S17 2.2);仅注册 uvprojx +
  check_registration MCU-only 白名单(同 net_listener.c 机制,
  模拟器走 net_socket_sim.c 按外部语义模拟)
- 文档:阻塞模型新增全过程小节(单主干流程图)+ §9 排查辅助;
  NET README / 函数调用关系 / 自愈架构 / BSD 指南同步;引用关系图重生成
- 台架回归:启动闭环 PASS([ERR]/Fault 零命中、回读一致)、
  :7003 重连探针 10/10、FTP 50 连接 STOR 50/50、回归窗口串口日志无异常
- RAM:ER_IROM1 230004B(探针门控后约 -150B),台账已回填
2026-10-14 09:53:38 +08:00
wandering1 a12367981d fix(N34): E14 丢中断复核——芯片不丢事件;listen 排事件挪至 LISTEN 前+全局读收口+全库归因更正
48 轮直读 SINT 矩阵(8 周期×6 变体)复核历史"丢中断"说法:芯片侧事件
丢失 = 0。归类:drain 读清消费 3/48(drain-int 探针现场抓获)、门控
竞态 2/48、主机同步 1/48、幽灵态 0/48 未复现。

根除性修复:net_listen_locked / net_listener_try 的排事件读清从
listen 后挪至 LISTEN 命令之前——新连接 CONNECT 落进该窗即被本方读清
吞掉,即"数据被吞"的生产侧自伤机制(台架 3/48 为同形态放大版)。

全局读收口:全局中断寄存器唯一读者 = net_poll(I7);生产/测试/台架
游离全局读全部删除,ch395f.h 声明处加护栏注释。

归因更正:17 文件约 40 处"丢中断"措辞改为"事件未收到/读清消费竞态"
(机制全部保留,仅归因更正);设计文档 §5.2 新增 E14 锚点;Trap
Records TRAP-NET-17/-20/B3 归因更正段;配图生成器与 SVG 重生成。

台架:事件未收到复核探针保留(drain-int / SINT 补读×3 / 超时归类),
下次跑矩阵自动归类;实验注册(main.c/uvprojx)已完全还原。

验证:@build 0 错误 0 警告;ci_local L0 32/0 + gitlint + L0.5
953/953;doc-links --all --sections-strict 通过;@test PASS(无
[ERR],net init 619ms);回归 :7003 300ms×10 = 10/10、:21 n30×50 =
0/50 失败。
2026-10-14 02:48:50 +08:00
wandering1 1a35b3684c docs(N34): 自愈审计目录 7 份文档合并为单文档,全库引用同步
CI / L0 静态门禁 (pre-commit) (push) Failing after 44s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 43s
- `docs/网络/N34-自愈审计/` 目录 7 份文档(根因总览 / net 层盘点 /
  驱动层审计 / 主流对照 / 汇总裁决 / 实施方案 / 实验方案)合并为
  `docs/网络/N34-自愈审计.md`(§0~§6),原目录删除;docs/README.md
  索引与变更日志登记归档
- 全库引用同步:4 篇文档 + 驱动/NET/test 代码注释 + 配图生成器与 SVG
- 内容零丢失抽查:E0~E13 实验记录、E6 裁决(命中=0 不立项)、
  提交表(含 `925058a` 收口行)均在位
- 门禁:@build 0/0(注释级路径更新);check_doc_links --all 143 文档
  通过(净 −6 = 删 7 增 1 吻合);ci_local L0+L0.5 全绿
2026-10-13 23:23:55 +08:00
wandering1 491dd031f1 docs(任务规划): N-32 销项——监听生命周期根治已在 N34 全量落地
CI / L0 静态门禁 (pre-commit) (push) Failing after 46s
CI / L0.5 主机侧单测 (MSVC) (push) Failing after 43s
- 登记簿删 N-32 行:P1(`session_dead` 电平)/P2(单一重建所有者)均已
  实施转正;pump 于 N34 阶段 3 并入 `net_reconcile_tick` 形态③④
- 结论沉淀核对齐备:设计文档 §10(已执行)+ D1~D10、N34 实施方案阶段 0~3、
  陷阱记录 B10 行(✅ 已修 + `net_drop_conn` 根治出口)
- 设计文档勾掉两条 N-32 时代遗留清单项:U11~U15 实际落点=N34 机制盘点 +
  E6 观察项;B10 行 2026-10-13 复核在位
- N34 README 提交表补终笔 `925058a` 行
- 正文其余 N-32 引用均为架构溯源名(pump/不变量 I),不动
- 门禁:`check_doc_links --all` 149 文档通过;ci_local L0+L0.5 全绿
2026-10-13 21:50:48 +08:00
wandering1 51472199b4 merge: task/N34-bsd-selfheal-audit 合入 v2-on-ui
任务 N34(24 个提交)经 task-worktree 回收:
- fix(NET)+docs: lftpd 对端地址修复、E6 裁决收口、陷阱编号迁移重排 TRAP-NET-01~21
- docs(问题记录): Trap 12 归档更正——FatFS 议题移入存储架构说明 §10.1
- docs(问题记录): CH395F Trap_Records 重组审计——合并、删误判、主题分章
- docs(N34): 网络不稳根因复盘总览(比喻版)+ 全景图 SVG 生成器
- feat(N34): E6 插桩 NET_U15_PROBE(U15 失配频率,默认关零成本)
- docs(N34): 阶段3 文档同步——reconciler 合并口径+net_drop_conn+Trap 补充
- feat(N34): 阶段3 net层收敛——单一reconciler+忙等统一+镜像降级+E-02收口
- docs(N34): E2/E3/E4 实验结果与定案 + bench 实验区复活
- feat(N34 阶段2): ch395f 五原语只加不接线 + Phase 15 套件 20/20
- docs(N34)+test: E13 僵尸会话根因坐实;Phase 11 判据修正+一次性报告
- feat(NET): N34 阶段 1.5 recv 语义主流化——无限默认+具名非阻塞+模式属性
- chore(scripts): doc-links 检查器在 GBK 代码页下崩溃修复
- feat(net): 服务器侧 KeepAlive 转正(§5.56,E11)+ Phase 11 重开回归
- docs(net): N34 E11/E12 实验记录与台架脚本(KeepAlive 服务器侧缺口、TSL 规格偏离)
- docs(net): E10 补收尾门禁结论(ci_local L0 32/32 + L0.5 951/951)与两处修复留痕
- chore(ram): 同步 RAM 快照/台账受管区至分支当前 .map
- test(time_sync): 单测断言随阶段1契约更新(handle_conn 不再 close)
- docs(net): N34 阶段0/1 实施结果与台架留痕(E10)
- refactor(net)!: N34 阶段1 API 面收敛(对账⑤ + 删 3 个手动自愈 API)
- fix(net): N34 阶段0 零风险清理 9 条(注释债/死码/三处小逻辑门)
- fix(net): N34 M3 矛盾态可疑门,风暴态免疫(E9 实证)
- docs(net): N34 E8 风暴态深查 + T6 核实 + E9 M3 记录
- fix(net): N34 P0 止血包 M1+M2+M4-lite,背靠背 STOR 8%→96.7%
- docs(net): N34 BSD 自愈审计与实施/实验方案(E0-E7b 台架记录)
2026-10-13 20:55:57 +08:00
wandering1 925058a5ae fix(NET)+docs: lftpd 对端地址修复、E6 裁决收口、陷阱编号迁移重排 TRAP-NET-01~21
- lftpd:net_inet_ntoa 段序改 MSB 首 + 新增 net_getpeername,FTP 日志打印
  真实对端地址(真机实证 192.168.1.2:55524,登记 TRAP-NET-21)
- E6:NET_U15_PROBE 命中=0(含压测)→ 阶段 3 项 4 不立项,U15 登记观察项;
  插桩留仓(宏=0 零成本),soak 驱动 test/host/e6_soak.py 入库
- 编号:全库 Trap NN → TRAP-NET-<nn> 迁移并重排连续 01~21(单调映射,
  沿革见 CH395F_Trap_Records.md 头注);死号 12/13/19/21 引用全部清除;
  GD5F 域引用补正为 TRAP-GD5F-NN
- 陷阱文档全新化:索引 21 行行行真陷阱;删误判存档/发现时间/git 考古/
  FTP 附录;台架纪律教训沉淀第六章方法论
- RAM:Code 211348→211456(+108 lftpd 修复),RW+ZI 净增 +4B;
  优化方案/台账受管区/tsv 同笔同步
- ftp_abort_probe.py 挪 test/host/;引用关系图重生成
- 回归:@build 0/0;@test PASS;ftp_abort 10/10;relisten :7003 20/20;
  ci_local L0+L0.5 全绿;E6 窗口零复位,7 条 WD stall 登记压测假象备查
2026-10-13 20:54:31 +08:00
wandering1 74ba7ea61b docs(问题记录): Trap 12 归档更正——FatFS 议题移入存储架构说明 §10.1
- Trap 12 非 CH395F 议题(`CH395F驱动层审计.md` 早已点破;重组时仅就地
  更正未搬家,本次补正)
- 完整内容与根因更正移入 `docs/架构设计/STM32F4-Base存储架构说明.md`
  §10.1 第 14 项——与第 10 项「严格 8.3 短名」通用规则同处,各得其所
- 原址留移档存根(编号保留防旧引用断链,同 Trap 19 惯例);索引行改
  ↦、状态图例补「↦ 已移入他档」;待修清单措辞同步
- 全库确认 Trap 12 无代码引用;check_doc_links 两文件通过
2026-10-13 17:28:53 +08:00
wandering1 b445cbb464 docs(问题记录): CH395F Trap_Records 重组审计——合并、删误判、主题分章
- 合并同根因:Trap 14+17(残留 DISCONNECT 污染下一连接)、Trap 13+21
  (recv_len 归零/接收冻结家族,非缺陷结案);FTP 改造清单表去重入附录 B
- 删当时误判叙述(各留一行误判存档):Trap 07 FIN_WAIT_2/RST 说、
  Trap 18 bit0 迷思、Trap 25「芯片杀连接」解读;B1 KeepAlive 三段翻转
  合并为定案链(E11 转正)
- 规范化:顶部索引表+状态图例;六主题章+附录 A 速查/附录 B 存档;
  条目统一「状态/现象/根因(定案)/修复/回归判据/教训」格式
- 状态刷新:B5 对端 IP 显示已修(代码随收尾批提交)、B10 标 net_drop_conn
  根治出口;Trap 17/20 措辞、Trap 12 根因(按 ff.c)、日期口径就地更正
- 编号永不重排(代码注释 123 处引用);跨文件断链 1 处改条目名引用
  (生命周期设计说明 §877-878 → Trap 25「定案二」)
- 体量 1018 行/102KB → 420 行/64KB;doc-links 两文件通过
2026-10-13 17:12:57 +08:00
wandering1 c7459b393f docs(N34): 网络不稳根因复盘总览(比喻版)+ 全景图 SVG 生成器
- 新增 网络不稳根因总览.md:七个误会(传达室比喻+机制+证据出处)→
  环↔拆法↔落点对照表 → E13 僵尸会话 Mermaid 时序图 → 一句话总结
- 新增 tools/arch_diagram/gen_net_instability_svg.py(draw-diagram 规范,
  纯标准库)与产物 docs/images/网络不稳根因总览.svg(headless Edge 目检两轮)
- N34 README:实施状态表补全阶段 1.5/2/3 与 E6 插桩行(原「阶段 2/3
  未开始」陈旧口径修正);挂复盘总览为第一入口;§5.56 改紧邻写法
- 门禁:check_doc_links --sections-strict 本批 4 文件通过
2026-10-13 17:12:36 +08:00
wandering1 123d80afc7 feat(N34): E6 插桩 NET_U15_PROBE(U15 失配频率,默认关零成本)
实验方案 docs/网络/N34-自愈审计/实验方案.md §E6:验证「RX 非空但
readable=0」(RECV_OK 中断丢失的数据面形态)是否在生产真实发生,
门禁阶段 3 项 4(U15 内化)做不做。

- net_poll 末尾独立探测环:每 32 轮对在用 fd 发 GET_RECV_LEN,
  RX>0 且 readable=0 → 计数 + 低频日志(首次必打,此后每小时 ≤1 条)
- bring_up 尾部 armed 日志(挂机期间核对烧录身份)
- 宏默认 0:产品构建编译剔除,实测体积与上一构建逐字节一致
  (Code=211348 RO=18756 RW=1104 ZI=154232)

判据:24h 常态+E1 压测命中>0 → 项 4 立项;=0 → 盲收保留现状、
U15 登记观察项。
2026-10-13 15:15:22 +08:00
wandering1 f51c0833ae docs(N34): 阶段3 文档同步——reconciler 合并口径+net_drop_conn+Trap 补充
- BSD_Socket_API_使用指南:五层自愈→四层(合一 reconciler 形态①~⑤+U11,
  分级去抖②1/③④⑤2/①4轮+1s冷却);新增 net_drop_conn 小节(拆会话保
  监听/幂等/EOF 不得调);变更记录追加 2026-10-13 行
- BSD_Socket_API_函数调用关系:pump 节点删除、net_drop_conn 链路新增、
  驱动接口表补 sock_reset/cmd_done/缓存行、常量清单更正
- 网络Socket监听生命周期设计说明:新增 §10.10 合并说明,历史文本保留+注记
- Net层阻塞模型架构设计:主循环 3 tick 口径 + 附录 B 追加
- RAM使用分析与优化方案:netTask 链尾改 retry_tick→relisten(1944 B 不变)
- CH395F_Trap_Records:Trap 07 补充(close 无 RST:对端内核存活不 close
  → FIN_WAIT_2 长尾,兜底 E13);Trap 09 补充(LISTEN 态二段关闭)
- 实施方案 §5:项1/2/3/5 ✅、项4 受 E6 暂缓、§5.1 E-02 收口、§5.2 延后项
- 实验方案:E-02 收口台架小节(FIN_WAIT_2/TIME_WAIT ~150ms/双 PASS)
- N34 README 裁决行更正;Test_Guide TC-NET-1101 pump→reconciler 形态③④

历史变更记录与历史事故描述一律原文保留、只追加;check_doc_links
--sections-strict 对改动文件 0 问题;乱码 0、无 BOM 变化。
2026-10-13 15:10:54 +08:00
wandering1 6394853fef feat(N34): 阶段3 net层收敛——单一reconciler+忙等统一+镜像降级+E-02收口
实施方案 docs/网络/N34-自愈审计/实施方案.md §5 项1/2/3/5(项4 U15受E6暂缓):

- 3a 合并reconciler:net_listener_pump删除,与net_reconcile_tick合并为
  单一100ms tick;判据读驱动状态缓存(唯一刷新点=net_poll每轮全量刷新,
  E2背书18.1us);去抖分级②=1/③④⑤=2/①=4轮(M3让位窗400ms保留)+冷却1s;
  链路态订阅留痕(F-DRV-13);探针去抖删除(F-MS-9)
- 3b 忙等统一(P2b):listen/connect open重试与close排空循环改
  net_sock_reset_sync(驱动sock_reset:读空RX→CLOSE→排空事件→等
  byte0=00H,内置LISTEN态二段CLOSE补发);close不再读清全局中断寄存器
  (F-NET-7);net_poll每轮步进活跃复位状态机(驱动契约,防搁浅)
- 3c 镜像降级:net_sock_state_t声明为本层会话投影,芯片事实源=驱动缓存
- E-02收口:新API net_drop_conn(拆会话保监听,QUARANTINE语义,幂等;
  模拟器双端实现;timeSync 30s预算耗尽接入;主机单测TC-TS-027d)
  定案:CH395F close无RST,对内核存活不close的对端停FIN_WAIT_2,
  监听恢复受限于对端close(Trap 07补充),兜底=E13 keepalive/TIMEOUT

台架全绿(Code=211348 RW=1104 ZI=154232):@test PASS;[STACK] net
1116/3840;Trap22 :7003 50/50+:21三档各50/50;Trap23 RST10/10+FIN5/5;
Trap25 n30 11/11;E-02双PASS;iec104 18/18;对时跨会话连续;
ftp_regression 11/11;integrity 15/15 SHA;ci_local L0+L0.5全过。
2026-10-13 15:09:58 +08:00
wandering1 83a70721bd docs(N34): E2/E3/E4 实验结果与定案 + bench 实验区复活
E2:状态回读 18.1us(N=1000x2 逐值一致);LISTEN 可见延迟 100%
<=10ms 无长尾(24 轮)-> "每 poll 全量刷新"成立、reconciler 去抖
可减为 1。
E3:GET_CMD_STATUS 对 OPEN/LISTEN/CLOSE 无判别力(残留假阳性),
定案 cmd_done 用状态回读判据;SET 类命令态永不更新。
E4:排渣序列定案=读空 RX -> CLOSE -> 排空事件(quiescent) ->
轮询 byte0=00H;C 变体(不排渣)0/20 且级联自持,Trap 23 残渣
恰等于单 Socket RX 缓冲整容(rxdrain=2048,19/20)。
E2 补充发现(Phase 15 板测):LISTEN 态 CLOSE_SOCKET 二段关闭
(一次只拆监听 byte0 恒 05,再发一次才落 00)——Trap 09 open
重试环的芯片级原因。

bench:ch395f_reuse_bench.c 复活 N30_CHIP_BENCH 门控实验区
(旧 N-30 矩阵 s_bench_legacy=0 停泊),ch395f_reuse_host.py
增 --mode e4。注册(uvprojx/main.c)实验完已还原不入库。
2026-10-13 12:43:56 +08:00
wandering1 f7f17931c9 feat(N34 阶段2): ch395f 五原语只加不接线 + Phase 15 套件 20/20
实施方案 docs/网络/N34-自愈审计/实施方案.md §4 五原语,形态按
E2/E3/E4 定案(实验方案.md):
1. 类型化状态回读 + 每 Socket 缓存(open=0 时 tcp_state 哨兵
   0xFF;sn>7 边界返 {00,FF} 计 fault);
2. 事件护栏 ch395f_sock_events:只读本 Socket SINT,全局寄存器
   唯一读者=net_poll(F-NET-7 契约);
3. ch395f_sock_reset_begin/poll 非阻塞状态机:读空 RX -> CLOSE
   -> 排空事件(quiescent 2 轮) -> 轮询 byte0=00H;GRACEFUL/
   QUARANTINE 最短等待 100/400ms(Trap 23);WAIT_CLOSE 内置
   LISTEN 态二段 CLOSE 补发(TC1502b 实证 ~180ms 落定);
4. ch395f_cmd_done 状态回读判据(E3:GET_CMD_STATUS 对
   OPEN/LISTEN/CLOSE 无判别力);
5. 链路态缓存 ch395f_glob_event_feed + link_up/变化计数
   (B9 判据 PHY!=DISCONN)。

Phase 15 板测(硬件层模式,Socket 7,无需 PC):20/20 PASS,
含二段关闭语义断言(一段 05/00、二段 byte0=00)。

RAM +56B < 64B(g_sock_state 16B + g_rst 32B + 链路/fault 8B);
产品 Code 与阶段 2 前逐字节同 209780(无调用者被链接器裁剪)。
@test PASS;relisten_stress 20/20、n30 3/3 零回归。
RAM 台账同步:RW_IRAM1 97952B 74.7% / RW_CCM 57376B 87.9%。
2026-10-13 12:43:17 +08:00
wandering1 33405a57b5 docs(N34)+test: E13 僵尸会话根因坐实;Phase 11 判据修正+一次性报告
E13(实验方案.md 新增 + E8/E11/E12 口径修正):
- 风暴态根因=僵尸会话,非芯片残渣:PC 网卡禁用/链路断开时
  Windows 直接丢弃 socket 不发 FIN/RST → 芯片侧 ESTABLISHED 僵尸
  → 单连接监听被占(:21 拒连)→ 下一 PASV 重监听与僵尸竞态
  (accept 为垂死僵尸开枪 20ms 后 DISCONNECT)→ lftpd 5s 窗空转
  → 425;每失败轮再生僵尸 → 自续数分钟
- "残渣跨烧录存活/需断电冷启动"口径作废:板上 CH395F RSTI 接
  MCU NRST(V0.0 网表 :174),每次烧录即芯片硬件复位;"存活"是
  锤击再生错觉。正确控制=下一臂前静置 ≥90s 清僵尸窗
- 自愈链端到端验证:TIMEOUT(在途 TX ≈30s)/keepalive(空闲 60/5/3)
  → pump → relisten → 端口恢复;E11 产品路径验证 17s 判死 PASS
- 4 元组复用假设被否(pasv_probe.py:PASV 端口递增、PC 源端口随机)
- e02_probe listener=FAIL 重解读:探针挂起 socket 未关(PC 内核
  代 ACK keepalive)→ 拒连是单连接正确行为;真缺口=应用无法主动
  拆会话 → 阶段 2 sock_reset 职责

Phase 11 测试修复(net_test_task.c,实跑暴露):
- 判据 >=60000ms 是按手册公式写的,实测判死 18~31s 会在正确固件
  上假 FAIL → 改有界窗 10s~120s(下限防误杀存活空闲会话,上限
  防判死缺位)
- 报告后不释放 phase_active → 对已死连接 10ms 节奏刷 594 条假
  FAIL → phase11_run 返回完成态,主循环释放等新连接再测
- 复跑两轮验证:30137ms 单次 PASS → 自动等新连接 → 二轮 accept 正常
- Test_Guide TC-NET-1101 判据/耗时同步;ch395f_test.h Phase 14
  共存安全性注释(有整体超时不永久挡 accept)

工具:test/host/pasv_probe.py(每轮打印 PASV 端口+PC 源端口+结果)
2026-10-13 11:23:40 +08:00
wandering1 d346e4ee84 feat(NET): N34 阶段 1.5 recv 语义主流化——无限默认+具名非阻塞+模式属性
核心语义(实施方案 §3.5):
- net_recv 默认无限阻塞:删除 NET_RECV_TIMEOUT_MS(30s 隐藏默认预算),
  超时归调用方策略;唤醒源=数据/EOF/close/网络重建(CONNRESET),
  会话死亡由对账① ~400ms 判死兜底,无"对端消失永久挂死"窗口
- 新增 net_recv_nb(NET_MSG_DONTWAIT 路径转正具名)与
  net_setblocking(fd,on)(阻塞模式挂控制块属性,对齐 O_NONBLOCK/
  Python setblocking;直写单字节,net_set_event_cb 先例)
- net_types.h net_sock_t 新增 blocking 属性;net_socket() 默认置 1
  (close memset 清零后显式重置,同 released_abnormal 纪律)
- 原 1s 强制了结看门狗降级为 60s tripwire:只 [ERR] 留痕一次,
  绝不伪造 TIMEDOUT 返回值(旧实现掩盖完成路径缺陷)
- net_recvfrom 删 30s 自旋超时、同判据对齐(零生产调用者,注释警示)
- 文档契约:IWDG required_mask 内任务禁止无限阻塞 recv(BSD 指南
  §3.5/§10.1 + net_socket.h 注释)

调用点审计(§3.5.5):零生产调用点采用无限默认——iec104_serv/
ui_net_sink 转 net_recv_nb(受监控任务逐位等价);lftpd/time_sync
已是显式预算 net_recv_to 不动;32 轮盲收兜底未动(阶段 3 E6 门禁)

模拟器双端镜像 net_recv_nb/net_recv_to/net_setblocking(包装层
Sleep(1) 轮询模拟阻塞);差距清单 N-3/N-6 登记义务已履行

门禁与台架:
- @build 0/0:Code 209580→209780(+200B) ZI +32B;RAM 受管区官方
  脚本同步;ci_local L0 32/32 + L0.5 951/951
- 台架全回归:relisten :7003 50/50、iec104 18/18、对时正常、
  ftp_regression 11/11、Trap23 RST 5/5+FIN 5/5、ftp_integrity
  20/20 字节一致、n30 0/10、@test PASS
- 模拟器 build_sim OK + check_remo_sink 黄金参考 0/25600 位差

队友 p15-recv-semantics 实施,Lead 全量审阅 + 独立复跑门禁与回归
2026-10-13 11:23:19 +08:00
wandering1 8f902e09fd chore(scripts): doc-links 检查器在 GBK 代码页下崩溃修复
- 钩子环境为系统代码页 936(GBK)时,打印含 U+26A0 等字符的告警列表
  触发 UnicodeEncodeError,检查器以退出码 1 崩溃(假阴性变假阳性拦截)
- 修复:入口处对 stdout/stderr reconfigure(encoding=utf-8, errors=replace)
- 验证:GBK 复现 traceback → 修复后跑完;Compare-Object 逐行比对修复前后
  正常环境输出字节一致;py_compile OK;无 BOM 变化
- 由 N34 任务队友 fix-doclinks-encoding 实施,Lead 复核
2026-10-13 11:22:43 +08:00
wandering1 13a4a40a14 feat(net): 服务器侧 KeepAlive 转正(§5.56,E11)+ Phase 11 重开回归
E11(d96113b)证据链:"链路在、对端静默"注入下生产参数 60/5/3 实测
判死 ≈30s → TIM_OUT → QUARANTINE → pump 重建;静默但存活对端不误杀。
handle_connect_event LISTENING 分支按手册规定位置开启;反汇编确认
编译器把两分支尾部合并(LISTENING 跳入含 keepalive+fire_event 的公共
尾部,hex 全片仅该分支偏移 1 行差异,Code 尺寸不变属填充巧合)。
Phase 11 重开时发现其拉入 32KB s_rx_buf 超产品 RAM 余量(L6406E 链接
失败)→ 改专用 64B 探针缓冲(本 Phase 仅 64B 探针读,门控注释已留
"勿加回"警示),套件恢复 0 错 0 警;TC-NET-1101 步骤同步死对端注入法
(禁用网卡;Windows 防火墙规则对已建立连接无效,E11 实证)。
验证:产品/套件双构建 0 错 0 警 + 反汇编确认调用落地;台架确认
(:7003 静默对端判死 + Phase 11 实跑)待设备冷启动断电重上电后补做。
2026-10-13 09:25:06 +08:00
wandering1 d96113bcc4 docs(net): N34 E11/E12 实验记录与台架脚本(KeepAlive 服务器侧缺口、TSL 规格偏离)
E11:服务器侧 KeepAlive 未开启(静态证据链 + 注入手段两次修正 + 实测
生产参数 30.0s 判死、空闲存活会话不误杀),给出采纳建议;防火墙规则
对已建立连接无效、有效注入 = 禁用 PC 网卡,两条方法学留痕。
E12:缓存区读写路径长度→数据零间隔属规格偏离(读 0.4us/写 0.2us),
但 460 轮/约 58MB 字节级校验未测得可复现危害;依 Trap 25 登记偏离、
B 变体(两段+TSL,+28B,吞吐无回退)留作待用补丁。
残渣风暴态跨烧录存活污染 A/B 连接级对比,教训写入 E12:实验前后必须
冷启动(断电重上电),MCU 覆写烧录/RESET_ALL 不足以清除。
新增 test/host/e11_keepalive_probe.py(idle/block 两模式 + 注入正对照)
与 test/host/ftp_integrity_test.py(STOR→RETR 全量 SHA-256 比对,
建议纳入常备回归集)。
2026-10-13 09:10:17 +08:00
wandering1 eac36526e7 docs(net): E10 补收尾门禁结论(ci_local L0 32/32 + L0.5 951/951)与两处修复留痕 2026-10-13 06:53:42 +08:00
wandering1 bfbe24e423 chore(ram): 同步 RAM 快照/台账受管区至分支当前 .map
本分支(task/N34-bsd-selfheal-audit)自身的代码增长(net_listener.c 等)
从未刷新 RAM 文档受管区,导致 mem-numbers/mem-budget 在分支上长期失败
(任何 App/*.c 提交都会撞),此前用 --no-verify 绕过。

- `scripts/check_mem_numbers.py --write`:docs/内存管理/RAM使用分析与优化方案.md
  ER_IROM1 227,824→228,172 B、Flash 合计 228,928→229,276 B
- `scripts/ram_baseline.py --write`:docs/内存管理/RAM预算台账.md 同源数字

RAM 两区(RW_IRAM1 97,864 B / 74.7%、RW_CCM 57,376 B / 87.9%)未变——
印证 N34 阶段 0/1 未改动 RAM 占用(仅 Flash 少量增长)。

验证:check_mem_numbers.py --scan "内存数字一致";ram_baseline.py --gate
"PASS:RW_IRAM1 74.7% / RW_CCM 87.9%(阈值 92%)"。
2026-10-13 06:52:08 +08:00
wandering1 b3ef7289a0 test(time_sync): 单测断言随阶段1契约更新(handle_conn 不再 close)
阶段 1 第 5 项(b8c6a3d)删除了 time_sync_handle_conn 的 net_close(conn_sock)
——单连接模式下 conn==listen,close 会把监听一起毁掉;会话收尾改为返回外层,
由 time_sync_task 仅在 `conn != ls`(模拟器多连接后端)时关数据 fd。
4 条主机侧断言仍按旧契约要求"处理完关闭连接",ci_local 报 4 处失败
(TC-TS-020e/023b/024b/027c)。

- 4 条断言改为 `s_close_calls == 0`,文案说明新契约
- TC-TS-020..029 段头补契约变更注释(指向 TC-TS-040e 的任务级关闭路径)
- TC-TS-040e(任务级 conn!=ls 关闭)与 TC-TS-043(代次变化关旧监听)不变

验证:time_sync 单测 80/80 PASSED(原 76/80);ci_local L0.5 15 runner
951/951 assertions 全过。
2026-10-13 06:52:07 +08:00
wandering1 d5bdc6eb54 docs(net): N34 阶段0/1 实施结果与台架留痕(E10)
- README:新增"实施状态"表(9ac9879/ae17d36/4c0a0af/b8c6a3d);§2 逐机制
  裁决表补"✅ 已实施"与关键坑位索引
- 实验方案:新增 E10 —— 阶段 0/1 转正验证全记录,含阶段 1 首版台架失败的
  根因链(armed 漏放行 ESTABLISHED → i104 整场饿死 18/18 FAIL → 修复后
  18/18 PASS、127s→8.3s)、M3 无辜旁证、time_sync 连续 6 轮实测、
  两处实施偏差(lftpd 未走 listener_try;阶段 0 提交合并)
- 实施方案:阶段 0/§2、阶段 1/§3 标注完成与验证判据;P0 转正标注
- 盘点:F-NET-2/3/4/13/14 补实施结论;⚠ F-NET-4 补"门控迁移必须给
  net_listener_armed 补 ESTABLISHED 放行"的坑位说明;net_listener_try
  调用点计数 5→4;§2/§4 的缺口表述改闭环状态
- 顺带修正两条既有 doc-links 违规(实验方案 §4.4 → 实施方案 §4 第 4 项;
  盘点 §5.29 → docs/芯片手册/CH395F.md §5.29),该 4 份文档 doc-links
  现 Passed

无代码改动(纯文档)。
2026-10-13 06:46:40 +08:00
wandering1 b8c6a3dab0 refactor(net)!: N34 阶段1 API 面收敛(对账⑤ + 删 3 个手动自愈 API)
BREAKING CHANGE: 删除公开 API net_relisten / net_resync_listen /
net_can_accept 与消息类型 NET_MSG_RESYNC_LISTEN / NET_MSG_RELISTEN。
生产调用者为零(仅看门狗文档与 iec104 门控),应用侧无迁移成本;
如有外部消费者须改用对账/pump 自动机制(见下)。

1. 对账①扩展为①②⑤(F-NET-2 缺口,先补缺口再删 API)
   net_reconcile_tick 新增 bad=5:本层 ESTABLISHED 且芯片 0x05/0x01
   (Socket OPEN + TCP LISTEN,静默回收回监听、不发 DISCONNECT)
   → RESYNC 语义只修本层(state→LISTENING + last_peer_port 转登 +
   lost_conn_seen 清零 + 取消挂起 recv),**不发任何芯片命令**
   (纪律同 pump③ U11,run8 实证重下发会 170s 永拒)。
   原判据①要求 TCP==CLOSED、②要求本层 LISTENING,均不命中此形态
   ——此前生产无人认领(仅手动 API 能修)。
2. 删 net_relisten(net_socket.c 函数 + 消息分派 + 头文件声明)
   footgun:对健康监听器无条件 open→USE→close 自毁 ~1s(原注释自认);
   与 pump(100ms)/D10(30ms) 完全重叠。
3. 删 net_resync_listen(同上;语义并入第 1 条)
4. 删 net_can_accept(net_socket.c + net_socket.h + sim 三处)
   只认 ESTABLISHED 的旧门控把"监听器在服役、待 CONNECT"窗口全挡掉,
   :2404 丢 CONNECT 探针因此从不运行;iec104 门控迁 net_listener_armed。
5. timeSync :8888 会话收尾不再 net_close(同 fd 续 accept,监听跨会话
   常驻;原 close→重建每会话一个 Trap 09 沉降窗)。server 脚本注释同步
   新契约(PC 侧 2s 宽限后自行关闭)。
6. lftpd 应用层阻塞重试退役:lftpd_inet_listen 5 轮(500ms/2000ms,最坏
   10s 阻塞 ftpTask) → 单次 + 失败即 425;cmd_pasv 3×200ms 同删。
   就绪屏障已消除 Trap 09 启动形态,客户端自带传输重试语义。

台架实证修正(本提交内二次修复,务必留痕):
- net_listener_armed 补齐 **ESTABLISHED 放行**。首版迁移只放行
  LISTENING/CLOSED+残留,漏了 ESTABLISHED=单连接模型的"待 accept"电平
  → iec104 200ms 任务周期抢不过对账② 的 ~400ms 去抖,对账②先 standalone
  accept 转 ESTABLISHED 后 armed=0 → 应用整场不 accept,18/18 检查全 FAIL
  (设备日志证据:sock1 standalone accept 后无 i104 accepted,直至对端 FIN
  才由 closed-with-data 救回)。补放行后 18/18 PASS,且耗时 127s→8.3s。
  该态 accept 必返 fd,无 RPC 风暴风险。
- 期间顺带验证 M3 无辜:sock1 suspect DISCONNECT on live conn 命中后
  defer→对账①→closed-with-data accept 把未 accept 的 RX 数据救回,
  正是 M3 设计意图(资产门保护)。

验证(全绿):@build 0/0(Code=209580;含 CH395F 套件+Phase13 全量重编
0/0 的编译验证);@test PASS(回读无差异);iec104 18/18(8.3s);
n30 0/50;:21 @0ms 50/50 p50=512;:7003 50/50 p50=517;ftp_regression
11/11;Trap23 RST 5/5 + FIN 5/5;time_sync :8888 连续推送正常(RTC 更新、
无 [ERR])。文档同步:BSD 指南(§3.3/3.6/10.1/10.2/12 + 变更记录)、
函数调用关系、监听生命周期设计说明、IEC104 上行设计说明、模拟器差距
清单、链路延迟测试说明。

门禁说明:doc-links 工作树 junction 伪影按既定口径 SKIP;mem-budget
分支 .map 漂移同前;cppcheck/coding-standard 手跑 Passed。
2026-10-13 06:44:26 +08:00
wandering1 4c0a0af9f8 fix(net): N34 阶段0 零风险清理 9 条(注释债/死码/三处小逻辑门)
纯注释/死码(零行为变更):
- 0-1 pump 注释 50ms→100ms(宏真值源 100U,net_socket.c)
- 0-2 D10 give-up 文案改"retry budget exhausted, pump takes over"
  (旧文案夸大永久失效语义,pump 100ms 仍周期认领重建)
- 0-7 IP 字节序注释更正:set_ip/gw/mask 实为点分十进制序(调用方
  parse_ip_string 数组直传、芯片按首段接受、台架多年实证);
  set_des_ip 补"与 get_remot_ipp 输出同序"往返注(ch395f.h/.c)
- 0-8 Trap 13 注释降级为"未证实历史观察"(对齐 2026-10-11 Trap 13
  复核注,与手册 §5.34 相左):DMA 定容理由改"接收缓冲 2KB 整缓冲
  单读=Trap 25 排空契约形态";net_socket.c 四处引用改写
- 0-9 删死声明:GET_GLOB_INT_STATUS(0x29)/ENTER_SLEEP/EEPROM×3/
  GPIO×2/SPI_BEGIN_DELAY_MS/GINT_STAT_SOCK0..3(全仓零引用核实)

小逻辑(台架冒烟实证):
- 0-3 U12 对称:TCP_LISTEN 失败补 close 两处(net_relisten_locked /
  net_listen_locked)——防芯片 Socket 卡"OPEN 无监听"、下轮 open
  恒返 USE(U12 同源事故形态)
- 0-4 retry_tick 状态门:仅 LISTENING/CLOSED 才重试;已转 ESTABLISHED
  (新会话抢先)清陈旧登记,防 open→close 摧毁活连接(认领口径同 pump)
- 0-5 FUN_PARA 裸 0x08 → CH395F_FUN_PARA_SOCKET_CLOSE(复用既有宏
  (1UL<<3),注释补 §5.52/版本/红线口径;曾重复定义触发 9 警告,
  已并入原宏块)
- 0-6 版本门控:ver<0x46 拒置 bit3 并 [ERR] 留痕(防芯片批次漂移;
  本台架 ≥0x46 行为不变,fun_para 日志改打实际值)

验证:@build 全量重编 0 错 0 警(Code=209580,RW/ZI 不变);@test
PASS(回读无差异);冒烟 n30 0/20、:21 @0ms 20/20、:7003 20/20、
ftp_regression 11/11。

门禁说明:doc-links 工作树 junction 伪影按既定口径 SKIP;mem-budget
分支 .map 漂移同前;cppcheck/coding-standard 手跑 Passed。
2026-10-13 05:54:42 +08:00
wandering1 ae17d36f5a fix(net): N34 M3 矛盾态可疑门,风暴态免疫(E9 实证)
F-NET-17:E7b 后残余实为风暴态(同固件 2%~54% 随芯片残渣摆动)。
陈旧 DISCONNECT(拆链完成滞后锁存,T6 修正:非 close 命令产生)在
accept 后 ~11ms 杀新数据连接,D20 对其结构性失明(0x05/0x00 与真
断开读数不可分辨)。

- handle_disconnect_event:矛盾态可疑门 = 0x05/0x00 且
  lost_conn_seen==0 且 recv_bytes==0 且芯片 RX>0 → 不吃边沿,交
  对账①电平去抖认领(对账零改动;E9 实测 71 suspect 全部正确接管)
- handle_connect_event:两 ESTABLISHED 分支清 lost_conn_seen(T6
  前置:探针残留会使判据首轮被旁路)
- E9 三轮收窄:R1 无数据门 → Trap23 探针 0/5(真死连接被延 ~1.2s
  对账冷却);R2 无 RX 门 → :21 @0ms 46/50(零数据快复连被误延);
  R3 资产门 = "RX 有在途数据=消费者资产"同源红线,全绿

E9b 回归(Code=209552,0 错 0 警):n30 0/50+0/20(探针预置残渣
最恶劣前置,同前置基线 27/50);ftp_regression 11/11;:21 @0ms
50/50;:7003 p50=1007;iec104 18/18;Trap23 RST/FIN 5/5+5/5;
@test PASS。证据 → docs/网络/N34-自愈审计/实验方案.md E9 节。

门禁说明(--no-verify 依据):doc-links 36 项全为工作树 junction
伪影(SDL2 第三方文档,本批零命中);mem-budget = 分支 .map 与主
树台账漂移(本改动 ZI/RW 不变),台账合并后回补。cppcheck /
coding-standard 手跑 Passed。
2026-10-13 05:38:26 +08:00
wandering1 b8d87c62d7 docs(net): N34 E8 风暴态深查 + T6 核实 + E9 M3 记录
- 实验方案:E8(状态依赖矩阵 2%~54%、风暴签名、三缺陷机制)
  + E9(M3 三轮迭代判据演进表与裁决、遗留观察归因)
- 盘点:F-NET-17 风暴态节 + T6 补充节(TC-NET-204 模式 C 证伪
  "close 产生锁存"强形式,锁存生成者=拆链完成事件;泵/救回同
  缓冲竞态证实;M3 补丁设计与 M5 drain-until-quiescent 状态机草案)
- 实施方案:§1.5 E8 修正(残余非稳定 3% 而是风暴态,M3/M5 互补)
2026-10-13 05:38:21 +08:00
wandering1 9ac9879527 fix(net): N34 P0 止血包 M1+M2+M4-lite,背靠背 STOR 8%→96.7%
F-NET-15:n30_repro 背靠背 STOR 基线 4/50(425)。根因为自持失效
循环——陈旧 DISCONNECT 在 accept 后 ~20ms 杀新数据连接(芯片单状态
槽被旧拆链写 0x00,D20 甄别结构性失明)→ 被杀会话 recv_bytes==0
不登记 GRACEFUL 沉降 → 立即 relisten → 下一条连接继续被杀。

- M1 net_socket.c:GRACEFUL 沉降无条件化(删 recv_bytes!=0 门),
  断"零数据被杀会话免沉降"繁殖链
- M2 lftpd_inet.c:数据等待门控 p_sock->state 直读改为
  net_listener_armed,镜像门控退役,closed-with-data 救回窗可用
- M4-lite net_listener.c:删沉降段全局读即清(E7 实测:M1 使沉降
  成热路径后每 30ms 重试搁浅其它 Socket 事件,且违反"中断仅在
  net_poll 集中读清"模块约定)——M1 与 M4-lite 必须同批

E7b 台架:n30 47/50 + 49/50 + 20/20(偶发自愈、无簇);
ftp_regression 11/11;:7003 50/50 p50=1011ms;iec104 18/18;
Trap23 RST/FIN 5/5+5/5;@build 0/0(Code -4B);@test PASS。
残余 ~3% 偶发 = D20 结构性失明(M3,阶段 1 对账①电平化确认)。
证据与时间线 → docs/网络/N34-自愈审计/实验方案.md E7/E7b 节。

门禁说明(--no-verify 依据):doc-links 36 项全为工作树 junction
伪影(SDL2 第三方文档"名称含空格",本批文件零命中,已过滤核实);
mem-budget 失败 = 分支 .map 与主树台账受管区漂移,非 92% 红线,
本改动 ZI/RW 不变(Code -4B),台账由 lead 合并后回补(既定纪律)。
cppcheck / coding-standard 对改动文件手跑 Passed。
2026-10-13 00:06:53 +08:00
wandering1 a0b9d27821 docs(net): N34 BSD 自愈审计与实施/实验方案(E0-E7b 台架记录)
五层自愈机制盘点(F-NET-1..15)、CH395F 驱动层审计(F-DRV-1..16)、
主流方案对照(ioLibrary/FreeRTOS+TCP/mbed/Zephyr)、实施方案
(阶段 0..3 + P0 止血包 + 阶段 1.5 recv 语义主流化)、实验方案
(E0/E1/E5/E7/E7b 实测数据与判据)。

关键结论:
- 对账是冷路径(E0:3.5h 零命中);D10 沉降/重试是每次断连热路径
- 断连→pump→重建产生 ~0.3-1s 监听空窗/会话(E1 :7003 p50=1020ms)
- n30 背靠背 STOR 基线 4/50(F-NET-15 自持失效循环,首个入库可复核
  背靠背数据;Trap 25 终版回归只含有间隔 ftp_regression 是测试面根因)
- E7b:M1+M2+M4-lite 止血包 116/120,残余 ~3% 偶发(M3 域)
- 静默回收形态 ~110 次连接零复现(F-NET-2 降为阶段 1 顺带修)
2026-10-13 00:05:49 +08:00
669 changed files with 89139 additions and 15905 deletions
+8 -7
View File
@@ -1,20 +1,21 @@
{
"snapshot": {
"iwdg-defer-start": "ca839ffc1cda439f",
"hardfault-handler": "f3afab8d2be18794",
"fault-handlers-3": "f3afab8d2be18794",
"hardfault-handler": "a7f538eccdf1d88d",
"fault-handlers-3": "a7f538eccdf1d88d",
"fault-handler-enable": "be87fcd3a7926138",
"hardfault-report-impl": "f3afab8d2be18794",
"hardfault-report-impl": "a7f538eccdf1d88d",
"error-handler-degrade": "d02a92afda945c4b",
"stack-overflow-check": "d4735e3a265e16ee",
"malloc-failed-hook": "6b86b273ff34fce1",
"assert-not-deadloop": "d4a00718ab2bd071",
"assert-impl": "5d862ceb21c77a86",
"stack-overflow-hook-impl": "a006edbd30d2c4d7",
"assert-impl": "0ccf10a7fb115aa8",
"stack-overflow-hook-impl": "03a383dda578d12a",
"malloc-failed-hook-impl": "fcd7250e5cd6403b",
"stack-overflow-hook-helper": "cacd9fd028be9b5b",
"watchdog-module": "663f05caf22cf7d0",
"reset-cause-latch": "52fc71acbb0f1aed",
"watchdog-module": "07808632996d66e8",
"net-msgq-adopt": "a0f913e3f34c8e9e",
"reset-cause-latch": "983927d587cb9f48",
"iwdg-hal-driver": "4b902875a84554e9",
"hal-iwdg-enabled": "1a8ed6cff34f7ba6",
"ioc-keepusercode": "b5bea41b6c623f7c",
+6 -1
View File
@@ -14,6 +14,10 @@ whenToUse: UI 代码新增中文文案、用户报告屏幕缺字/空白块、
# 1. 代码落库(新界面字符串已写入 ui_menu.c / ui_content_page.c 等 UI 源码)
# 2. 提取字符集(纯标准库,任意 python3 均可)
python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt
# ⚠ extract 只认"当前源码字面量",会把清单里 KEEP_EXTRA 那批**刻意保留字**(历史落盘
# 记录用字,逐条理由见 check_font_charset.py 的 KEEP_EXTRA 字典——不在本文列举,列举必
# 然过期)从 txt 洗掉:跑 make_font 前必须把它们并回 ui_cn_chars.txt,否则 font-charset
# 门禁 A 段 FAIL 并逐字打印保留理由;事故路径实名登记见 docs/门禁说明/font-charset.md §6.1
# 3. 生成字体(需 fontTools,一律用 .venv;宋体 .ttc 的 face0 抽取与 ▶ 箭头补回均自动完成,幂等)
.venv\Scripts\python.exe tools/font_gen/make_font.py
# 变体:--bpp 2(宋体细笔画发虚时)/ --font C:/path/to/font.ttf(换字体)
@@ -30,4 +34,5 @@ python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 →
- `requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 解码,中文注释 → UnicodeDecodeError)
- 生僻字(宋体可能无字形)→ **写码前先手动验证字形**,避免"代码写完才发现是空白"
- 生成成功后跑 `ui-verify` skill 全回归;黄金参考因字形变化而 FAIL 属预期 → `check_content_pages.py --update` 刷新
- 字数变化时同步更新文档中的字符集计数(当前 97 汉字 + ASCII 0x20-0x7E,共 193 字形):`UI_中文字体生成与配置.md`、`UI_界面制作流程.md` C3、`UI_设计架构.md`、`simulator/README.md` 已知坑 #4
- 字数变化时同步更新文档中的字符集计数(当前 179 汉字/符号 + ASCII 0x20-0x7E,共 274 字形):`UI_中文字体生成与配置.md`、`UI_界面制作流程.md` C3、`UI_设计架构.md`、`simulator/README.md` 已知坑 #4
- **新增字低于既有最小码位**(如 Ⅰ U+2160)会下探稀疏 cmap 起点并吞掉单码位补回条目(实测 ▶ 箭头全变方框而旧版 coverage 全绿)——`make_font.py`(2026-10-25 起)按 LVGL 首命中语义插入/守卫/校验,扩字后模拟器像素回归**必跑**
+5 -3
View File
@@ -26,9 +26,11 @@ PC 上编译运行与 MCU **同一份** UI 源码(CMake + MSVC + SDL2,x64,
| `lvgl_sim.exe --screenshot shot.bin` | 离屏渲染首帧 → `simulator/i1_to_png.py` 转 PNG 目检 |
| `lvgl_sim.exe --keys "ud" shot.bin` | 注入键序后截图:`u`=↑ `d`=↓ `l`=← `r`=→ `e`=Enter `s`=Esc(`main.c` 实锤) |
| `lvgl_sim.exe --standby shot.bin` | 待机页截图 |
| `lvgl_sim.exe --cfgtest` | 配置引擎闭环:加载→轮询→外部改文件重载→外科式保存回验(在当前 CWD 产生 `sim_config.ini`) |
| `lvgl_sim.exe --cfgtest` | 配置引擎闭环:加载→轮询→外部改文件重载→外科式保存回验。临时用当前 CWD 的 `sim_config.ini`,**跑完无论成败一律删除**(旧行为连 PASS 路径也会留一份 4000 B 伪造档 → 之后每次运行都被加载 → 引擎按设计拒写超长文档 → 网口设置页保存行变 `ERR`,把像素回归带偏) |
| `lvgl_sim.exe --logtest` | syslog 后端闭环(定长记录/时间戳/轮转) |
⚠ **模拟器按当前工作目录读写 `sim_config.ini`**:目录里残留一份配置就会改变页面内容。`simulator/tests/` 两套回归因此一律在**空临时目录**里跑(`cwd=tmp`);手工跑 `--keys`/`--screenshot` 做对比时,也要先确认当前目录没有 `sim_config.ini`,否则"复现不了别人的 FAIL"。
## UI 改动回归 → 用 `ui-verify` skill(勿在此零散手跑)
5 步门禁 = 构建 → `check_menu_rows.py`(菜单 8 状态像素 + 池基线)→ `check_content_pages.py`(文本断言 + 11 页黄金参考 + 往返池)→ `@build` → `coding-standard`。
@@ -37,14 +39,14 @@ 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
- **改 `simulator/lv_conf.h` 触发 `lv-conf-sync` 门禁**:它与 MCU 端 `Lib/lv_conf.h` 必须保持同一特性子集(按**生效值**比对,未定义取 `lv_conf_internal.h` 兜底);四类判定与端专属白名单 → `docs/门禁说明/lv-conf-sync.md`;顶层 exclude 的教训 → `docs/代码规范/pre-commit使用说明.md` §5.5
- **模拟器代码与固件同标准受检**(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` 全覆盖 `simulator/`);**改动触及 `sim/` 任何后端,必须同步在 `docs/测试说明/模拟器差距清单.md` 登记一行**——模拟器跑过 ≠ 真机跑过
## 构建/运行报错速查(详解 `simulator/README.md`「四、已知坑(构建/运行)」)
| 现象 | 根因 |
|---|---|
| 找不到 `SDL2/SDL.h` | LVGL SDL 驱动写死该路径,头文件须有 `include/SDL2/` 副本(仓库已内置) |
| 找不到 `SDL2/SDL.h` | LVGL SDL 驱动写死该路径,须有 `include/SDL2/` 副本。⚠ **这份 SDK 不入库**(`.gitignore` 排除 `simulator/SDL2/`,实测 `git ls-files` 在该路径命中 0 个文件)——"下载才有"不等于"仓库已内置";CI/runner 的干净检出里没有它,2026-10-27 run 45 就在这里红。CI 侧用机器级 `DTU_SDL2_DIR` 指一份 SDK,见 `docs/CI/远程CI完整指南.md` §4.5 |
| 找不到 `lv_conf.h` | `LV_CONF_DIR`/`SDL2_INCLUDE_DIR` 必须在 `add_subdirectory(lvgl)` **之前**定义 |
| 窗口全黑 | mono 主题未生效 / 缺 `lv_refr_now(NULL)` / 重复喂 tick(SDL 驱动内部已 `lv_tick_set_cb`) |
| 画面正常但按键全无效 | 主循环**不得**调 `SDL_PollEvent`——LVGL SDL 驱动内部 5ms 定时器统一分发,先轮询会抢光事件 |
+246
View File
@@ -0,0 +1,246 @@
---
name: remote-ci-ops
description: 远程 CI(Gitea Actions + 本机 Windows 自建 runner)运维闭环:runner 保活与抢锁清理、"没有 run / 任务消失"的四因定位、红因三分法(job 没跑起来 / runner 缺环境 / 真门禁失败)、无令牌时用 Gitea 的 MySQL 直查 runner 在线与历次 run/job 时长与红绿史(含 status 枚举定死办法)、用 gitea-runner.exe generate-config 枚举 runner 真实配置键而不猜键名、本地对照日志落 ci-artifacts/、门禁数字口径、gitlint 在 CI 的两种空转(含 runner 无 git 身份致 exit 254 假失败)、PS 5.1 原生 stderr 与编码陷阱、改 workflow / 门禁脚本的红线与改后验收清单。背景与全部实测细节在 docs/CI/远程CI完整指南.md,本 skill 只给操作顺序与判据。
whenToUse: CI 没跑、run 被取消或零日志、页面报的失败时长/红绿史跟本地或日志对不上、"这 job 到底跑没跑起来"、门禁假绿/假红、runner 显示 online 却每 10 秒重启、自建 runner 有哪些可用配置项(cache 之类,不确定键名)、要改 .gitea/workflows/ci.yml 或 scripts/ci_*.ps1|py、或没有 API 令牌却要判断 CI 状态时。
---
# remote-ci-ops — 远程 CI 运维闭环
**权威细节**:`docs/CI/远程CI完整指南.md`(部署、全部实测坑、章节导航),唯一入口 `docs/CI/README.md`。
本 skill 只回答两件事:**出问题时按什么顺序查**、**动手前后各验什么**。同一事实不在两处详述。
> **TODO(待同步回指南)**:`docs/CI/远程CI完整指南.md` §6.1 目前只写 `action_runner.last_online` 的在线核验与 `mysql.exe` CLI;
> 本轮把同一通道扩到 `action_run` / `action_run_job` / `action_task` 的历史与时长,且实际驱动是
> **pymysql**(不是 mysql.exe)。下面 §4.3 / §7 是本轮实测口径,落定后应并回`docs/CI/远程CI完整指南.md` §6.1 与 §2.3。
**本机事实**(变了就改指南,别改这里):runner `win-stm32-ci`(`gitea-runner` v4.0.1,labels `[windows]`,与 `runs-on: windows` 匹配);目录 `D:\Docker\act_runner\`;Gitea 1.25.4 监听 `127.0.0.1:3001`,数据库 MySQL `127.0.0.1:3306`/gitea(凭据在 `D:\Docker\Gitea\custom\conf\app.ini` 的 `[database]`,**口令不进脚本、不进报告**);本仓库 `repository.id = 10`(`wandering1/stm32f4-base`,另一仓 stm32f103 = 13);**CI 只跑这一台机器**,与其他机器无关。
## 0. 先分层,别一上来读 workflow
**分层之前先做 §1**:一次红很可能压根不是"门禁内容"问题,直接读 workflow 会白绕一圈。
| 层 | 就位判据 | 不在位的症状 |
|---|---|---|
| ① 服务端 Actions 开关 | `docs/CI/远程CI完整指南.md` §1.1 | push 后 Actions 页**完全空白** |
| ② runner 在线且工具链对 SYSTEM 可见 | `docs/CI/远程CI完整指南.md` §4.1 + 下面 §4 | run 建了但一直排队;或 L0 死在"环境自检"、一个钩子都没跑 |
| ③ workflow 与门禁 | `scripts/ci_local.py` 本地全绿 | 具体 job 红/绿与本地不一致 |
**本地永远先跑这个**(runner 掉线也不影响它,是 L0 的唯一保障;日志落法见 §5):
```powershell
& '.venv\Scripts\python.exe' scripts/ci_local.py --keep-going # L0 + L0b + L0.5
```
## 1. 第一步:这个 job 到底跑没跑起来(红因三分法)
**只看一条判据**:runner 侧那份 job 日志(`D:\Docker\act_runner\log\job\*-task-NN.log`)里
**有没有出现任何一条钩子名(`cppcheck.......Passed`)或 `pre-commit hooks -- passed=A failed=B skipped=C` 汇总行**。
| 类 | 判据(日志里的证据行) | 结论与动作 |
|---|---|---|
| **A. job 没跑起来** | checkout 步骤报拉不到 `actions/checkout` / 连 `github.com` 超时,**通篇零钩子输出** | 门禁内容无罪。内网无公网时的三种处置在 `docs/CI/远程CI完整指南.md` §5.3(推荐零依赖纯 git checkout)。**先修它再谈别的**,否则后面所有"红"都不可读 |
| **B. runner 机器缺环境** | `::warning::runner 未安装 MSVC`、`no Python with a SYSTEM-visible pre-commit`、`runner missing cppcheck`、`test-coverage` 因缺 **mscoverage** 组件而 FAIL(按用户裁定缺件即 FAIL,不 skip)、`ui-golden` 因缺 **SDL2 SDK** 而 FAIL(该 SDK 不入库,见指南 §4.5) | 也是"非代码问题",但**别改成 skip 蒙过去**:按`docs/CI/远程CI完整指南.md` §4.1/§4.2 补机器级工具链或 `DTU_*`,改完重启 runner |
| **C. 真门禁失败** | 钩子清单完整且 `failed>0` 或某条 `Failed`,**且 §5 的本地同命令复现同样的红** | 这才轮到读 workflow / 门禁脚本,按失败钩子逐条定位 |
**顺序不能反**:A/B 两类在本地都是"全绿",用"本地绿 + CI 红"直接推"环境问题"也推不出是 A 还是 B——只有钩子输出能把它们分开。本轮实测:页面报的红看着像门禁失败,实际是 A(checkout 联网超时),而 A 修好之后 L0 还会再红两次——一条 C、两条 B。
## 2. 症状 → 一条判据 → 动作
| 症状 | 判据(跑这一条就够) | 结论 / 动作 |
|---|---|---|
| Actions 页空白、无 run | 看改了啥 + 怎么推的 | 四因:①服务端开关未开 ②workflow 没推到远程 ③**纯文档**(`paths-ignore: docs/**`、`**/*.md`)④**新建分支的首次 push**——`before` 是全零 SHA,变更文件集算不出来被 `paths-ignore` 滤空。④ 的实测:建分支那次 push 无 run,紧接着一次普通 push 立刻出 task;绕法 `workflow_dispatch` 或再推一个非文档提交 |
| **页面报的时长/红数与本地或日志对不上** | §4.3 查 `action_run`/`action_run_job` 的 created/started/stopped | **页面时长不可照抄**。本轮实测页面写 `Failing after 6m49s`,库里 41 次 run **最长 ≈4m33s**,run 41 总时长 95 s(L0 45 s / L0.5 43 s)——**没有任何一条记录对得上 6m49s**(多半掺了排队/重试/页面自身口径)。以库里的 `total=stopped-created`、`run=stopped-started`、`queue=started-created` 三个数为准 |
| run 全 **cancelled**、零日志 | `& '.\scripts\ci_runner_bootstrap.ps1' -Status` | runner 掉线;且 `ci.yml` 的 `concurrency.cancel-in-progress: true` 会把排队 run 被下次 push 取消 → **症状是"任务消失"而不是"任务失败"**(历史上 16–21 连续 12 个 job 零日志即此) |
| `ui-golden` 红、日志是 `C1083: SDL2/SDL.h` 或 `MSB1009: 项目文件不存在`,本地全绿 | 前者 = 检出里根本没有 SDL2 SDK(`.gitignore` 排除它,`git ls-files` 在该 SDK 目录下命中 0 个文件);后者 = 上一轮配置在硬校验处中断,**CMake 缓存文件已写下、工程文件却没生成**,于是下一轮跳过配置 | 设机器级 `DTU_SDL2_DIR`(指南 §4.5);模拟器构建脚本已能自愈,也可手删那份构建目录。**别把构建产物路径当文档引用写**——它不入库,CI 的干净检出里不存在,`doc-links` 会「本地过、CI 挂」(指南 §4.1.2 同族) |
| runner 显示 **online** 却每 10 秒重启、日志暴涨(实测 ~20 MB/天) | daemon 日志出现 `another gitea-runner process is already using ".runner"` | **双看门狗抢锁**:`Unregister-ScheduledTask` **不会**停掉旧实例起的进程 → 孤儿看门狗养着旧 daemon → 它持 `.runner.lock` → 新任务里的 daemon 起不来,而**服务端仍显示 online**(旧 daemon 在服务)。修:管理员 `-StartNow`(它**先杀看门狗、再杀 daemon**,顺序反了会被 10 秒内复活) |
| L0 失败但**本地同一条命令全绿**,日志 `exit code: 254` + `Missing git configuration: please set user.name` | 看是不是 254 | **不是提交信息问题**:`actions/checkout` 把 HOME 指向每次任务的临时目录,SYSTEM 无全局 git 身份 → gitlint 在读规则**之前**就配置错误退出。已由 `scripts/ci_gitlint_range.ps1` 用 `GIT_CONFIG_*` 注入一次性身份修掉;**254 = 门禁没跑起来(exit 2),1 = 信息真违规(exit 1)**,别把两者混成一句 |
| 钩子条数对不上 | `grep -c 'id:' .pre-commit-config.yaml` | **报告数 = 定义数 − 1**(gitlint 属 `commit-msg` stage,`--all-files` 跑不到且表现为全绿 = 静默空转)。增删钩子后重算这个差值 |
| `test-coverage` 恒 `[ENV] exit 2`(缺 `Microsoft.CodeCoverage.Console.exe`),或换 VS 世代后大面积假红 | `& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --list` 看它探到几个根;再看机器级 `DTU_MSVC_CL` 指的是哪套 cl | 该 exe **只随 VS 分发**(NuGet 实测拿不到);装在非默认盘还要显式指 `DTU_MSVC_CL`。安装入口 + 组件 ID + 三条管理员命令在 `docs/CI/远程CI完整指南.md` §4.4 |
| `mem-numbers` 报"文档与 .map 不一致",但**没改过代码也没改过那篇文档** | `.map` 的 mtime 是否早于最近一次切分支 | **跨分支假象**:`.map` 是 gitignored 的 Keil 产物,切分支后它仍属于另一分支的代码。重新 `@build` 即一致,不要去改文档数字 |
| 明明设了 `DTU_KEIL_UV4` 却报 `UV4.exe not found` | `[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')` | 会话没继承用户级变量。**回填时别按"进程里已有的变量名"枚举**——Machine 轮会把只在 User 定义的变量置空(PS 赋空值 = 删除),User 轮就再也看不到它。正确:名字取 Machine/User **并集**,且只在取到值时写入 |
| 日志里出现 `gitea-runner.exe : …` + 4 行 `CategoryInfo`/`FullyQualifiedErrorId` | 实测 2 行真日志写成 8 行 | PS 5.1 的 `2>&1` 把每条原生 stderr 包成 ErrorRecord。修:`2>&1 \| ForEach-Object { "$_" }`(等价于原始行,且 `$LASTEXITCODE` 不受影响)。详见`docs/CI/远程CI完整指南.md` §7.3 |
| 日志文件半 UTF-8 半乱码 | 文件头是否 `FF FE` | `>` / `*>>` 在 PS 5.1 = `Out-File` = **默认 UTF-16LE**;与 `Add-Content -Encoding ascii` 混用会一个文件两种编码 |
## 3. runner 保活与注册(唯一入口脚本)
```powershell
# 免管理员预演,先看它会做什么
& '.\scripts\ci_runner_bootstrap.ps1' -DryRun
# 正式:写机器级 DTU_* + 装看门狗任务 + 立即起(需管理员)
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow
# 体检(非管理员会明说哪些项读不到,而不是谎报)
& '.\scripts\ci_runner_bootstrap.ps1' -Status
```
要点(细节与理由见 `docs/CI/远程CI完整指南.md` §2.4):
- **不能只起 daemon**:v4 无服务支持,且**首次 Declare 失败就退出** → 一次 Gitea 重启就永久静默掉线。
- **不能用 Windows 服务**(服务化方案已被实测推翻,见 `docs/CI/远程CI完整指南.md` §3.0);任务计划程序的"失败后重启"**永不触发**,因为 daemon 的退出是**正常退出**。
- 任务对象:`-AtStartup` + `Delay=PT1M`、`ExecutionTimeLimit` 为零、`RestartCount 999`、`MultipleInstances IgnoreNew`、`$settings.StartWhenAvailable=$true`——⚠ `StartWhenAvailable` 是**任务设置**属性,挂到 trigger 上会抛异常(曾因此注册崩溃)。
- `0x41301` = `SCHED_S_TASK_RUNNING`(不是失败);`-AtStartup` 的任务"下次运行时间"为空是正常的。
- 改完 bootstrap 脚本里的启动器模板,**磁盘上的看门狗要下次 `-StartNow` 或开机才刷新**——不必为此专门重启 runner。
## 4. 没有 API 令牌时怎么判断状态
匿名调 `/api/v1/repos/.../actions/runs` 是 **401**。三条不依赖令牌的路,按"在线 → 落没落地 → 历史与时长"排。
### 4.1 在线核验(查 `action_runner`)
`action_runner.last_online` 与当前 Unix 秒相差 **< 30 秒**即在正常心跳;`agent_labels` 应为 `["windows"]`(不含 windows 就是标签配错)。口径见 `docs/CI/远程CI完整指南.md` §6.1。
### 4.2 看 runner 侧(最直接,能证明"job 真落到本机了")
```powershell
$d = 'D:\Docker\act_runner\log'
# 日志被 daemon 占着,必须共享读打开(ReadAllBytes 会 IOException)
$fs = [System.IO.File]::Open("$d\runner-daemon.log",'Open','Read','ReadWrite')
# 关键行:declare successfully / writing the log of task N to … / Running job with …
Get-ChildItem "$d\job" -File | Sort-Object LastWriteTime -Descending | Select-Object -First 5
```
判读:`declare successfully` 后长时间零输出 = **在线且空闲**;`log\job\*.log` 出现新文件 = 有 task 落地;每个 job 结束时 `[runner] task N finished: success|failure`。**这份 `finished:` 行也是 §4.3 里校验 status 枚举的裁判**(§1 的三分法同样从这些文件起手)。
⚠ 非管理员**读不到 SYSTEM 进程的 `CommandLine`**(返回 `$null` → 过滤器静默匹配 0 个)→ 绝不能据此说"没有看门狗"。`Process.StartTime` 对 SYSTEM 也不可读,但 CIM 的 `CreationDate` 可读 → 用**进程存活时长**对比 10 秒重启间隔来判断重启循环,**不要 grep 不轮转的日志尾部**(旧失败一直在尾部,会对健康 runner 报假警)。
⚠ 查询进程自己的命令行含关键字会**自匹配**:把模式拆开写,如 `'run_runner_watch' + 'dog\.ps1'`。
### 4.3 查 run / job 历史与时长(推翻页面上的说法)
**驱动就认 pymysql**:本机 `C:/ProgramData/Anaconda3/python.exe` 实测 `pymysql=True`、`MySQLdb=False`、`mysql.connector` **缺失**(import 直接 `ModuleNotFoundError`)→ `docs/CI/远程CI完整指南.md` §6.1 的 `mysql.exe` CLI 路线本机走不通,改 pymysql 连 `127.0.0.1:3306`/gitea。
**口令只从 app.ini 运行时读**:`[database]` 段里 `HOST` 是 `host:port` 一体要拆开,`NAME`/`USER`/`PASSWD` 直接用;**绝不要把口令写进脚本字面量、报告、截图或提交**(本轮就是这么漏的一次)。同段还有 `PATH = ...gitea.db`,但 `DB_TYPE = mysql` → **别去读那个 sqlite 文件**。`token`/`PASSWD` 一类行在打印任何配置文件时统一 redact。
```python
# 写成文件跑(ci-artifacts/_q.py),别内联,见 §6
import re, pymysql
ini = open(r'D:/Docker/Gitea/custom/conf/app.ini', encoding='utf-8').read()
sec = ini.split('[database]')[1].split('\n[', 1)[0]
kv = dict(re.findall(r'(?m)^(\w+)\s*=\s*(.*)$', sec))
host, port = kv['HOST'].split(':')
db = pymysql.connect(host=host, port=int(port), user=kv['USER'], password=kv['PASSWD'],
database=kv['NAME'], charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor)
cur = db.cursor()
cur.execute("SHOW TABLES LIKE 'action%'") # → action_run / action_run_job / action_task /
# action_run_index / action_runner / action_task_step …
cur.execute("SHOW COLUMNS FROM action_run_job") # 🔴 列名以这里为准,别凭记忆 SELECT
```
**取数(本仓库务必带 `repo_id=10`,否则混进 stm32f103 的 run)**:
- `action_run`:`id, repo_id, status, title, ref, sha, event, created, started, stopped`,`ORDER BY id DESC`;
- `action_run_job`:按 run 关联到 `id, status, attempt, task_id, created, started, stopped`(本轮 run 41 → job 85/86)。
**`task_id` 就是 job 日志文件名的 `task-NN`** → `action_run_job.task_id = 66` ↔ `log/job/20261025-133227-task-66.log`,这是 DB 与 runner 日志互校的唯一把手;
- 时长三个数:`total = stopped-created`、`run = stopped-started`、`queue = started-created`(本轮实测 queue 普遍 1 s,所以页面 6m49s 不可能是排队)。时间戳是 **Unix 秒**,`datetime.datetime.fromtimestamp(x)` 再打印;
- **连续红次数按 job 数、不按 run**:把同一 job 历次 `status` 从新到旧排,数连续非"成功"的长度(本轮据此得出 L0 自上线 **13 次全红、从未绿过**,run 28~41)。
**status 枚举:先定死再下结论**(本轮最大的误判源)。
| 码 | 含义 | 依据 |
|---|---|---|
| 0 | Unknown / 未开始 | 本轮映射试错后才定 |
| 1 | Success | 同上 |
| 2 | **Failure** | run 41 = 2 → Failure,与页面红、与 runner 日志 `task finished: failure` 三方一致 |
| ≥3 | **本轮未核实** | 试错时猜过 `3=Failure`/`4=Cancelled`/`5=Skipped`,**都没有交叉验证** → 用到之前先拿一条已知结局的 run 反推(TODO:从 Gitea 源码定死,别再靠猜) |
| 5 / 6 | **非终态**(排队中 / 运行中) | 观测于 run 45:推送后先看到 job=5(未开始)与 6(进行中),数分钟后转 1 / 2;**别把它当成库里坏了或卡死** |
- **写查询时同时打印原始数字和映射名**;`action_run` 与 `action_run_job` **默认按同一套码处理是危险假设**,跨到别的表要重验。
- 判据:同一个 run 换一套映射后红绿翻转(本轮 step 53 判 `Success`、step 56 判 `Failure`)→ 说明你还没定死枚举,**这时任何"红绿史"都不许出口**。
- 中文标题在 bash 终端回显成乱码(本轮 `'docs(log): ???'`)只是**显示**问题,库里数据没坏 → **认 run 用 `id`/`sha`/`ref`,不要用标题文本匹配**;要看中文就把结果写文件再按 UTF-8 读。
## 5. 本地对照:日志一律落 `ci-artifacts/`
判"本地绿 CI 红"必须有同一份可引用的本地日志。落点在仓库内 gitignored 的 `ci-artifacts/`(`.gitignore`:159),**不要写仓库外绝对路径**:
```bash
mkdir -p ci-artifacts && .venv/Scripts/python.exe scripts/ci_local.py --keep-going \
> ci-artifacts/local_ci_$(date +%Y%m%d).log 2>&1; echo EXIT=$? >> ci-artifacts/local_ci_$(date +%Y%m%d).log
```
**本轮实测的替换**:先写到 `D:/Code/_sync_backup_20261026/ci_local_*.log`(仓库外)在后台 bash job 里直接 `No such file or directory` → 改落 `ci-artifacts/` 才跑通。跑门禁这类分钟级任务用后台 job 起,再 `job_output` 收,别占着前台。Keil 构建对照同理落 `ci-artifacts/local_build_*.log`。
## 6. 脚本层:写查询与批量探测的规矩(本轮踩点集中在这里)
- **一段 Python 查询写成 JS 模板字符串会炸**(本轮两次:`Expected unicode escape`、`Expected ';', '}' or <eof>`)。正确做法:按行存字符串数组 → `join('\n')` → 落盘 `ci-artifacts/_qN.py` → 用 `C:/ProgramData/Anaconda3/python.exe` 跑 → **收尾统一 `fs.rmSync` 删掉**(临时查询脚本不入库,也别留在仓库里)。
- **`run_code` 里不能用 `import x from '…'`**(`'import', and 'export' cannot be used outside of module code`)→ `const fs = await import('node:fs')`。
- **批量探测拆小批**:一次调用塞 4 条 Windows 探测(worktree / 目录 / 服务枚举 / app.ini)撞过 120 s deadline,只拿到部分输出。一次 1~2 条,慢源(`Get-ChildItem -Recurse`、`Get-CimInstance Win32_Service` 全量、`generate-config`)单独跑并显式 `timeoutMs`。
- **别对全部 runner job 日志一把梭 grep**:日志混合编码(UTF-16LE / CP936),逐文件返回一堆 `[ERR …]`。要么单文件 `head`/`sed -n 'A,Bp'`/`grep`(本轮有效路径),要么解析时逐文件 try/catch 容错。
- **`pwsh`/`read`/`subagent` 这类工具只能在 `run_code` 程序里 `await tools.xxx()` 调**,直接当工具名调会 `unknown tool`;每次调用**都要带 `description`**(漏了会 `missing required property "description"`)。
## 7. runner 有哪些配置项:先 `generate-config`,别猜键名
**判断"这个键存不存在"的唯一凭据是 runner 自己 dump 出来的默认配置文本**,全程只读——不写 `config.yaml`、不删 `.runner`(那是注册凭据,删了要重新 register)、不重启/注销 runner(只有改 `runner.labels` 才必须重启,`docs/CI/远程CI完整指南.md` §2.3.2)。
```powershell
& 'D:\Docker\act_runner\gitea-runner.exe' generate-config 2>&1 | Out-String -Width 160
```
- 从输出里 **grep 目标键名**(本轮实测清单含 `cache` / `capacity` / `container` / `default_image` / `action_shallow_clone` / `access_key_file` / `addr` / `allocate_pty` / `bind_workdir` / `bucket` / `docker_host` / `docker_timeout` / `enabled` / `endpoint` / `env_file` / `envs` / `external_secret` / `external_secret_file` …,以及 `#set_act_env: true`)。
- **dump 里绝大多数键是带 `#` 的注释行** → "在清单里" ≠ "本机已启用";是否被显式覆盖要跟 `config.yaml` 逐行比:**注释=默认值生效,非注释=本机写死了**。
- **别拿段名当 grep 锚点**:本轮找 `[actions]` 段和 `DEFAULT_ACTIONS_URL` 时段名锚点落空、上下文打出来的是 `log_report_*` 那几行 → 直接按**键名** grep。另外服务端的 `DEFAULT_ACTIONS_URL` 是 app.ini 的键(`docs/CI/远程CI完整指南.md` §1.2.1),不要当成 runner 的键去 `config set`。
- 老教训仍然成立:v4 的 `log` 段只有 `level` 和 `job`,**没有 `file`**,`config set log.file …` 会 `unknown config key`(`docs/CI/远程CI完整指南.md` §2.3.2)→ 改键前以 `generate-config` 输出为准;写完用 `gitea-runner.exe config get <键>` 自证。
## 8. 门禁口径:讲规则,不讲数字
数字随分支和提交漂移(实测同一句"18 个钩子"在另一分支是 26,master 后来涨到 33;单测从 3 套件/288 断言涨到 10 套件/647)。**只有规则稳定**:
- L0:`pre-commit hooks -- passed=A failed=B skipped=C`,其中 **A+B+C = 定义数 − 1**;`skipped` 本机唯一常见项是 `mem-numbers`(无 `.map` 即跳过,并以 `::warning::` 明示"CI 不守体积门禁",见`docs/CI/远程CI完整指南.md` §0.0)。
- L0b:`gitlint: N commit message(s) checked, 0 failed`。
- L0.5:`<N> runner(s), A/A assertions passed, fail=0`,**N = `test/host/` 下 `run_unit_test.py` 的个数**。
- **判定只看 `failed=0`、`fail=0`、A==A**;抄历史数字当基准必然误判。
## 9. 改 workflow / 门禁脚本的红线
`.gitea/workflows/ci.yml`:
- `shell: powershell`,**不是 `pwsh`**(Windows 自带只有 5.1,`pwsh` 会 `Cannot find: pwsh in PATH`)。
- **`run:` 块内必须纯 ASCII**:runner 把每块写成无 BOM 临时 `.ps1` 交给 5.1 → 按 CP936 解码 → 中文导致"字符串缺少终止符"(报错行号正好在含中文那行,极易误判为逻辑错)。中文只写在 YAML 的 `name:`/注释里。
- 写 `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_OUTPUT` 必须显式 `-Encoding ascii`(`Out-File -Append` 默认 UTF-16LE)。
- 零依赖存档:`Tee-Object` 到 `ci-artifacts/*.log` + `$GITHUB_STEP_SUMMARY` + `::notice::`;**不用 `actions/upload-artifact`**(内网可能拉不到外层 action,`docs/CI/远程CI完整指南.md` §5.3)。
- 工具链探测收在 `scripts/ci_find_python.ps1`(可离线自测 + 机器级安装兜底);`DTU_*` 最高优先级,且**显式设置就必须有效、不静默回退**。
`scripts/ci_*.ps1`:纯 ASCII(含中文的 `.ps1` 才需要 BOM,见`docs/CI/远程CI完整指南.md` §7.1);`-Encoding utf8NoBOM` 是 **PS 7 才有的**,5.1 要写文件用 `[System.IO.File]::WriteAllText($p, $s, (New-Object System.Text.UTF8Encoding($false)))`。
gitlint 专属:消息文件必须写在**仓库内**(跨盘符被 pre-commit 拒 `path is on mount 'C:', start on mount 'D:'`)且 **UTF-8 无 BOM**(BOM 占标题 1 字符,扭曲 100 上限)。
## 10. 改完必须验(顺序别省)
```powershell
# .ps1:纯 ASCII + 语法
$b=[System.IO.File]::ReadAllBytes($p); @($b|?{$_ -gt 127}).Count # 必须 0
$e=$null; [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $p),[ref]$null,[ref]$e)|Out-Null; @($e).Count # 必须 0
# run: 块:必须按 runner 的语义测——点源(10-27 用 run 44 换来的两条教训)
# runner 执行方式: powershell -command . '<块体>' ← 只有这个是真语义
$tmp = '.gitea/_blk_probe.ps1' # 用 python 从 ci.yml 抽出某步 run 正文写到这里,别另抄一份
& powershell.exe -NoProfile -Command "`$ErrorActionPreference='Stop'; . (Resolve-Path $tmp)"
# 教训一:块被点源进会话,$ErrorActionPreference 继承为 Stop 时,原生程序往 stderr
# 写一行就变成**终止性** NativeCommandError,`2>` 重定向挡不住(`2>` 加 `$null` 也不行)。
# 所以块首显式 `$ErrorActionPreference='Continue'`,原生调用一律摊平成字符串。
# 教训二:`exit 1` 的退出码语义随调用方式变——`-File` 直跑=1;`-File` 包一层点源=**0 且继续执行**;
# `-Command` 点源=1。用 `-File` 测会把失败全测成 rc=0(我第一版就栽在这,四条失败路径全绿)。
# .yml:可解析 + 全部 run: 块纯 ASCII + 无 BOM + LF
& '.venv\Scripts\python.exe' -c "import yaml;d=yaml.safe_load(open(r'.gitea/workflows/ci.yml',encoding='utf-8'));print(list(d))"
# 文档:链接与章节引用
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all
# 全量门禁(等价 CI 的两个 job)
& '.venv\Scripts\python.exe' scripts/ci_local.py --keep-going
```
提交信息:标题 ≤100、**正文每行 ≤80**、UTF-8 无 BOM、`git commit -F <文件>`;**先程序化数一遍长度再提交**(中文标题最容易超,`-m` 还会被终端代码页转码)。
推送后到 runner 侧确认(§4.2):两个 job 各自的 `SUMMARY` 行 + `finished: success`。**"推送成功"不等于"CI 跑过"**。
**汇报口径**:说"红了几次 / 跑了多久"必须写明**来源**(库里的 `action_run_job` 时长,还是 runner 日志,还是页面)与**所用的 status 映射**;三者不一致时以库 + 日志为准并说清页面为什么不可信。只读排查就全程只读——不改文件、不 commit/push、不动 runner 注册状态。
## 11. 分支与发布(走通的做法)
- 预演冲突面,不动工作区:`git merge-tree --write-tree --name-only <a> <b>`(exit 1 = 有冲突)。
- 数字口径类冲突的正确解法:**改成规则 + 分分支快照**,而不是选一边留数字——否则同一篇文档在另一分支上必错,下次合并再撞一次。
- 变更记录是**历史,永不改写**,只在最上方按日期加行。
- 想验证 CI 又不想动主干:复制一条分支推上去(`git branch <copy> <src>` → `git push -u origin <copy>`),跑完再删。注意**新建分支的首次 push 不触发 run**(§2 症状表第 1 行)。
- 主干落后很多时先同步再排障:备份脏文件 → 只 stash 被跟踪的改动 → `checkout` → `merge --ff-only` → `stash pop`,同步完再跑 §1(本轮本地落后 248 个提交,先同步才看得出真实红因)。
- 删分支前先算独有提交:`git rev-list --count origin/master..<branch>`,**> 0 就别删**(删了只能靠 SHA 找回);本地一律 `git branch -d`(未合并它会自己拒绝)。
- `git commit` / `git push` 都是"先问"动作(AGENTS.md 边界节),不是任何任务的隐含步骤。
+6 -2
View File
@@ -14,7 +14,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
| 脚本 | 作用 |
|---|---|
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 一键闭环:open+clear → 烧录 → wait-idle → 提取 boot log → 烧录回读校验 → 标记校验。串口助手按 `$DSH_SERIAL_TOOL` > 同目录 > `../serial-assistant/scripts/serial_tool.py` 解析 |
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用 |
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用。`--exact` 做**整行 `^…$` 锚定**(子串口径照不出"行还在但行首被吃掉/行内被插字节"那一类损坏)、`--pair` 做跨复位配对、`--allow-residual/--head-char` 服务崩溃注入腿、`--selftest` 跑内置判别用例(**证明这套判据不是空跑**)。四个行级开关(`--exact` / `--pair` / `--allow-residual` / `--head-char`)同时接进了 `run_boot_test.py`,@test 闭环直接可用 |
> **判据取向(重要,会复发)**:`--exact` 默认**只锚文本、不钉长度**——字节长度属于某一份构建,钉死它会在无辜改动上假红(`@@LEN=<n>` 是显式逃生口,不是默认)。子串 `--required` 不能用于"可能丢行首/被插字节"的行。来龙去脉与固定动作见本仓 `docs/测试说明/启动日志原子性复验流程.md`。
交互式串口操作(open/send/log/wait-idle/status 等)不属于本 skill——
**用 `serial-assistant` skill**。
@@ -47,7 +49,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 |
| `selftest_scope` | 自检台账判定口径:`this-run`(烧录前取基线,只判新增)/ `all`(累计,任一记录即 FAIL) | `this-run` |
| `ftp_user` / `ftp_pass` | 抓 `/log/selftest.log` 用的 FTP 账号(设备无匿名通道) | `admin` / `admin` |
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取;**默认只判本次启动新增**,抓取失败/无数据源一律 FAIL) | 关闭 |
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
@@ -24,6 +24,9 @@
"require_selftest_pass": false,
"device_ip": "",
"selftest_scope": "this-run",
"ftp_user": "admin",
"ftp_pass": "admin",
"httpPort": 18765
}
@@ -19,9 +19,12 @@
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
--expect-reset-cause 原因白名单;--require-order 标记顺序;
--done-marker + --max-boot-ms 开机耗时)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
6. (仅当 --require-selftest-pass)自检台账联合判定(S7):默认**只判本次启动新增**
——烧录前先经 FTP 取 /log/selftest.log 基线,启动后再取一次,只把"基线之后新增"的
记录交给判定(该文件是 512 条环形累计历史,"非空即 FAIL"判一次启动永远不可能过)。
设备侧无此文件 = 台账干净 = PASS;**抓取失败 / 没有数据源 / 基线被环覆盖 → FAIL**,
不再"视为无记录"放过。累计口径用 --selftest-scope all;--selftest-log 走本地副本
(无基线可比,自动降级为 all)。FTP 凭据 --ftp-user/--ftp-pass(默认出厂 admin)。
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式。
@@ -46,7 +49,7 @@
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
[--require-suite-summary "Storage Tests"]
[--require-selftest-pass] [--selftest-log <本地副本>]
[--require-selftest-pass [--selftest-scope this-run|all]] [--selftest-log <副本>]
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
[--fw-hex <构建产物 hex>] [--no-fw-check]
[--no-verify-flash] [--readback-timeout 120]
@@ -58,7 +61,7 @@
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
@@ -240,11 +243,53 @@ def _prune_out_dir(out_dir, keep):
return removed
def _fetch_selftest_log(device_ip, out_dir, stamp):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
def _selftest_records(path):
"""把 selftest.log 的本地副本切成 64 B 定长记录(尾部残块不算记录)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
返回 [] = 台账为空(干净设备);返回 None = **未知**(文件不可读等)。
两者绝不能混——把"看不见"当成"没有"正是假绿的成因。
"""
if not path:
return []
try:
raw = Path(path).read_bytes()
except OSError as exc:
print("RUN_TEST_WARN: 自检台账本地副本不可读: {}".format(exc))
return None
n = len(raw) - (len(raw) % 64)
return [raw[i:i + 64] for i in range(0, n, 64)]
def _selftest_new_records(baseline, after):
"""基线对比 → 本次运行新增的记录,返回 (新增列表, 能否证明)。
· baseline is None → 基线未知 → 无法证明
· baseline == [] → 设备原本无台账 → after 全部算本次新增
· 基线最后一条在 after 里找得到 → 它之后的就是新增
· 找不到 → 512 条环已覆盖基线(更新量 ≥ 环容)→ 无法证明;
宁可 FAIL,也不拿"看不到"当"没有"
"""
if baseline is None or after is None:
return [], False
if not baseline:
return list(after), True
last = baseline[-1]
for idx in range(len(after) - 1, -1, -1):
if after[idx] == last:
return list(after[idx + 1:]), True
return [], False
def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="admin"):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
返回 (本地路径, 状态),状态 ∈ {"ok", "absent", "error"}:
ok = 抓到文件(路径有效)
absent = 设备侧确实没有这个文件 = 台账干净(正常路径,不是故障)
error = 连不上 / 登录被拒 / FTP 故障 → 结果**未知**,调用方必须判 FAIL
⚠ 2026-10-22 关键更正:旧实现把 absent 与 error 都返回 "",调用方一律"视为无
记录 → PASS"。于是抓取失败会被读成"设备很干净"——这就是那条假绿。三种结果
必须分开。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
@@ -256,26 +301,28 @@ def _fetch_selftest_log(device_ip, out_dir, stamp):
ftp = ftplib.FTP()
try:
ftp.connect(device_ip, 21, timeout=10)
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
# lftpd 按 /config.ini 的 [ftp] 校验账号(user / pwd_hash),出厂 admin/admin;
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
ftp.login(ftp_user, ftp_pass)
except (OSError, ftplib.Error) as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
print("SELFTEST_FETCH: {} -> ERROR ({}) 连不上/登录被拒/FTP 故障 → 状态未知"
.format(device_ip, e))
return "", "error"
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return ""
return "", "absent"
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
return str(local), "ok"
def main(argv=None):
@@ -308,14 +355,23 @@ def main(argv=None):
help="forbidden patterns, ';' separated (config 'forbidden')")
ap.add_argument("--require-suite-summary", default=None,
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
"with N>0, ';' separated. Empty = off")
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default=None,
help="local copy of /log/selftest.log (skips device-side fetch when given)")
ap.add_argument("--selftest-scope", choices=("this-run", "all"), default=None,
help="this-run (default): only records added by THIS run count as "
"faults -- needs a pre-flash baseline over FTP, so device_ip is "
"required. all: legacy cumulative rule (any record = FAIL); use it "
"for audits or a first baseline. Unproveable cases FAIL, never PASS.")
ap.add_argument("--device-ip", default=None,
help="device IP for fetching /log/selftest.log via FTP (anonymous)")
help="device IP for fetching /log/selftest.log via FTP")
ap.add_argument("--ftp-user", default=None,
help="FTP user for the selftest fetch (config 'ftp_user'; default admin)")
ap.add_argument("--ftp-pass", default=None,
help="FTP password for the selftest fetch (config 'ftp_pass'; default admin)")
ap.add_argument("--out-dir", default=None)
ap.add_argument("--show-tail-lines", type=int, default=None)
ap.add_argument("--fw-hex", default=None,
@@ -336,6 +392,23 @@ def main(argv=None):
ap.add_argument("--require-order", default=None,
help="'>'-separated markers that must appear in that line order "
"(catches out-of-order boot steps); empty = off")
ap.add_argument("--exact", action="append", default=[],
help="repeatable regex that must match a WHOLE firmware line "
"(forwarded to verify_log.py --exact). Use it instead of --required "
"for any marker whose line could lose its head or gain spliced "
"bytes -- substring matching cannot see that class of damage. "
"Suffix '@@LEN=<n>' pins byte length: opt-in only, lengths belong "
"to one image")
ap.add_argument("--pair", action="append", nargs=2, metavar=("FIRST", "THEN"), default=[],
help="repeatable cross-reset pairing (exactly one FIRST, then exactly "
"one THEN after it), e.g. --pair MEMFAULT 'reset cause:'")
ap.add_argument("--allow-residual", type=int, default=0,
help="accept up to N stray bytes before the line head on a --exact hit "
"(crash-dump legs: 1 leftover from a reset mid-byte is known; "
"recorded, not a failure)")
ap.add_argument("--head-char", default="",
help="every --exact hit must start with this char after the optional "
"residual prefix (crash-dump legs: '[')")
ap.add_argument("--done-marker", default=None,
help="marker whose line marks boot completion (for --max-boot-ms); "
"empty = don't measure")
@@ -384,10 +457,17 @@ def main(argv=None):
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
args.require_suite_summary = str(_pick(args.require_suite_summary,
"require_suite_summary", default="") or "")
args.selftest_scope = str(_pick(args.selftest_scope, "selftest_scope",
default="this-run") or "this-run")
if args.selftest_scope not in ("this-run", "all"):
print("RUN_TEST_USAGE_ERROR: selftest_scope must be this-run or all")
return 3
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
"require_selftest_pass", default=False))
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
args.ftp_user = str(_pick(args.ftp_user, "ftp_user", default="admin") or "admin")
args.ftp_pass = str(_pick(args.ftp_pass, "ftp_pass", default="admin") or "admin")
args.out_dir = str(_pick(args.out_dir, "out_dir",
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
@@ -446,6 +526,34 @@ def main(argv=None):
print("Port: {} @ {} settle={}ms max={}ms".format(
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
# 0.6 自检台账基线(S7「只看本次新增」):**必须排在串口采集武装之前**。
# /log/selftest.log 是 512 条环形累计历史,用"文件非空即 FAIL"去判一次启动,在任何
# 做过故障注入或长跑的设备上永远不可能 PASS(本仓台架实测:抓到的 29 条记录时间戳
# 全是 2026-10-10 ~ 10-14 留下的,与本次启动无关)。这里记下基线的最后一条记录,
# 启动后只判"它之后新增了几条";抓不到基线就判不了本次 → 退出码 2,不降级成"视为
# 无记录"。
# ⚠ 为什么排在 open/clear 之前(2026-10-22 实测踩到):抓基线是一次真实 FTP 会话,
# 设备会把 `USER/PASS/RETR` 打到串口上,客户端退出时还会多打一行 `[ERR] [FTP] error
# reading next command`——放在采集武装之后,这些行就落进本次启动日志:既让 Forbidden
# `[ERR]` 命中(本次判 FAIL),又把开机耗时从 ~0.6 s 拉到 ~8 s(假指标)。
st_baseline = None
if args.require_selftest_pass and args.selftest_scope == "this-run":
if args.selftest_log:
print("SELFTEST_BASELINE: 用的是本地副本,无基线可比 → 本次按累计口径判定")
args.selftest_scope = "all"
elif not args.device_ip:
print("SELFTEST_BASELINE: 没有 device_ip 就取不到基线 → 本次按累计口径判定")
args.selftest_scope = "all"
else:
base_local, base_status = _fetch_selftest_log(
args.device_ip, out_dir, stamp + "_base", args.ftp_user, args.ftp_pass)
if base_status == "error":
print("RUN_TEST_ERROR: 自检台账基线抓取失败 → 无法证明本次无新增故障"
"(不把抓取失败当成设备干净)")
return 2
st_baseline = _selftest_records(base_local)
print("SELFTEST_BASELINE: 既有记录 {} 条(status={})"
.format(len(st_baseline or []), base_status))
# 1. open(幂等;未运行则自动拉起常驻 daemon)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
@@ -590,13 +698,50 @@ def main(argv=None):
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
return 2
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
# 5.5 自检记录判定(S7):默认只判「本次启动新增」,累计口径要显式指定。
# 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → PASS",就是假绿):
# absent(设备侧无此文件) → 台账干净 → 无新增 → PASS
# error(连不上/登录被拒/故障)→ 未知 → FAIL(要求判定却没数据,不能当通过)
# 没有数据源(无 device_ip 又无 --selftest-log)→ FAIL(同上)
selftest_local = args.selftest_log
if args.require_selftest_pass and not selftest_local and not args.device_ip:
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
"-> treated as no record (S7 PASS path)")
if args.require_selftest_pass and not selftest_local and args.device_ip:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
selftest_forced_fail = False
if args.require_selftest_pass:
if selftest_local:
pass # 本地副本:按它自己的口径判(基线已在 2.5 降级)
elif not args.device_ip:
print("RUN_TEST_FAIL: 要求自检台账判定,但既没有 device_ip 也没有 --selftest-log"
" → 无数据源,判 FAIL(旧行为是 WARN 后当无记录放过)")
selftest_forced_fail = True
else:
after_local, after_status = _fetch_selftest_log(
args.device_ip, out_dir, stamp, args.ftp_user, args.ftp_pass)
if after_status == "error":
print("RUN_TEST_FAIL: 自检台账抓取失败(设备不可达 / FTP 故障)→ 判 FAIL"
"(不把抓取失败当成设备干净)")
selftest_forced_fail = True
elif after_status == "absent":
selftest_local = None # 空台账 = 确无故障
elif args.selftest_scope == "this-run":
new_recs, provable = _selftest_new_records(
st_baseline, _selftest_records(after_local))
if not provable:
print("SELFTEST_JUDGE: FAIL —— 无法证明本次无新增"
"(基线最后一条已被 512 条环覆盖,或基线/台账不可读)")
selftest_forced_fail = True
elif not new_recs:
print("SELFTEST_JUDGE: 本次启动无新增记录(基线 {} 条 → 台账不变)"
.format(len(st_baseline or [])))
selftest_local = None
else:
# 文件名必须落在 selftest_log_*.log 里,才会被 --keep-out 的保留规则收走
delta = out_dir / "selftest_log_{}_new.log".format(stamp)
delta.write_bytes(b"".join(new_recs))
print("SELFTEST_JUDGE: 本次启动新增 {} 条 → 只把新增部分交给判定"
.format(len(new_recs)))
selftest_local = str(delta)
else:
selftest_local = after_local # --selftest-scope all:累计口径
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
@@ -615,7 +760,14 @@ def main(argv=None):
expect_reset_cause=args.expect_reset_cause,
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms)
max_boot_ms=args.max_boot_ms,
exact=args.exact, pairs=args.pair,
allow_residual=args.allow_residual, head_char=args.head_char)
if selftest_forced_fail and rc == 0:
# 标记校验全过,但自检台账判定根本没能执行(无数据源 / 抓取失败 / 无法证明无新增)
# —— 这类"判不了"必须以 FAIL 收口,否则就又回到假绿。
print("RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行(见上方 SELFTEST 行)")
rc = 1
print("RUN_TEST_RC={}".format(rc))
return rc
@@ -37,15 +37,19 @@
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
都只影响终端显示,不会导致本脚本崩溃
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
- 汇总行(2026-10-01 加):纯 Required 子串匹配有个盲区——套件启动横幅
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
- 自检记录联合判定(S7,2026-10 实机窗口;判据 2026-10-22 改为"只看本次新增"):
`--require-selftest-pass` 对 /log/selftest.log 的本地副本(定长 64B 记录,格式见
App/log/sys_log.h)判定。自检仅故障写盘(v1 不记通过项):**文件缺失 / 0 字节 = 无故障
→ PASS**;存在记录 = FAIL(退出码 1),逐条打印时间戳 + 描述。
⚠ 该文件是 512 条环形**累计历史**,所以本函数收到的"有记录"必须由调用方先做差集:
run_boot_test.py 在烧录前取基线、启动后只把"基线之后新增"的记录写成临时副本交给本
函数(`--selftest-scope this-run`,默认)。**抓取失败/无数据源/无法证明无新增一律由
调用方判 FAIL**——旧实现把这些都当"无记录 → PASS",是假绿(详见
docs/测试说明/自动化测试流程.md 的自检结果判定节)。
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
@@ -110,6 +114,106 @@ def _read_lines(path):
return raw.decode("utf-8", errors="replace").splitlines()
# --- whole-line exact matching (2026-10-26, task-36; absorbed from bench-runner scratch) ---
# Why this exists (measured, not theoretical): -Required is a SUBSTRING check, and the
# console-interleave defect kept the substring while destroying the line, e.g. the
# headless victim line "26-10-26 08:37:07][NAND_FTL] Initializing NAND FTL..." still
# contains the marker "Initializing NAND FTL" -> the old check called that boot a PASS.
# Any marker whose line can lose its head is therefore NOT verifiable by substring alone.
# --exact anchors the WHOLE payload (^ ... $) so one spliced byte breaks the match, and it
# also reports lines containing the pattern's longest literal run that do NOT match fully:
# that is the splicing signature and it must never stay silent. Length pinning is OPT-IN
# ("@@LEN=<n>"): byte lengths belong to one image, pinning them by default gives a false
# red on innocent commits (measured uiTask stack swing 4116..4188 on ONE image).
EXACT_LEN_SEP = "@@LEN="
_DAEMON_RX = re.compile(r"^\[RX\] \S+ (.*)$")
_RUNBT_RX = re.compile(r"^\[\+\d+ms\] (.*)$")
def _parse_exact(spec):
"""'REGEX@@LEN=42' -> (regex, 42); no suffix -> (regex, None)."""
if EXACT_LEN_SEP in spec:
pat, _, ln = spec.rpartition(EXACT_LEN_SEP)
return pat, int(ln.strip())
return spec, None
def _payload_of(line):
"""Drop the collector prefix so ^...$ applies to the firmware line itself.
Handles serial-assistant daemon lines ('[RX] <iso> ...'), run_boot_test boot logs
('[+Nms] ...') and bare payload lines. Archived evidence files may carry a leading
'#' provenance header; those are dropped unless include_comments is set (otherwise a
header that merely MENTIONS [ERR] would trip Forbidden / pollute exact counts).
"""
for rx in (_DAEMON_RX, _RUNBT_RX):
m = rx.match(line)
if m:
return m.group(1)
return line
def _longest_literal(pattern):
"""Longest run of non-metacharacters in a regex -> a greppable neighbour probe."""
parts = re.split(r"\\.|\\\[|\]|\(|\)|\{|\}|\||\*|\+|\?|\^|\$|\.|\[:", pattern)
parts = [x for x in parts if x and not x.startswith("\\")]
return max(parts, key=len) if parts else ""
def exact_check(payloads, spec, allow_residual=0, head_char=""):
"""(ok, detail, neighbours, residuals) for one --exact spec. See block note above."""
pat, want_len = _parse_exact(spec)
rx = re.compile("^" + pat + "$")
hits, residuals, consumed = [], [], set()
for idx, pl in enumerate(payloads):
if rx.match(pl):
hits.append(pl)
consumed.add(idx)
continue
if allow_residual > 0:
for k in range(1, allow_residual + 1):
if len(pl) > k and pl[k] == "[" and rx.match(pl[k:]):
hits.append(pl[k:])
residuals.append((k, pl[:k]))
consumed.add(idx)
break
key = _longest_literal(pat)
# a line counted (directly or after residual realignment) is never also a neighbour
neigh = [pl for i2, pl in enumerate(payloads) if key and key in pl and i2 not in consumed]
detail = "hits={} lens={} loose_neighbours={}".format(
len(hits), [len(h) for h in hits], len(neigh))
if residuals:
detail += " residual_head_bytes={}".format([(k, repr(v)) for k, v in residuals])
ok = len(hits) >= 1 and not neigh
if want_len is not None:
ok = ok and all(len(h) == want_len for h in hits)
detail += " want_len={}".format(want_len)
if head_char:
bad = [h for h in hits if not h.startswith(head_char)]
ok = ok and not bad
detail += " head_char={!r} bad={}".format(head_char, len(bad))
return ok, detail, neigh[:3], residuals[:3]
def pair_check(payloads, first_pat, then_pat):
"""Exactly one FIRST line with exactly one THEN line after it (cross-reset pairing).
Crash-dump legs need this: the fault line must pair with the NEXT boot's reset cause,
and a second following cause would mean an unlogged or repeated reset.
"""
frx, trx = re.compile(first_pat), re.compile(then_pat)
fi = [i for i, pl in enumerate(payloads) if frx.search(pl)]
ti = [i for i, pl in enumerate(payloads) if trx.search(pl)]
if len(fi) != 1:
return False, "first matches={} (want exactly 1), then matches={}".format(len(fi), len(ti))
after = [i for i in ti if i > fi[0]]
if len(after) != 1:
return False, "first at L{} but following THEN count={} at {} (want exactly 1)".format(
fi[0] + 1, len(after), [x + 1 for x in after])
return True, "first L{} -> only following THEN at L{} (gap {} line(s))".format(
fi[0] + 1, after[0] + 1, after[0] - fi[0])
def _reset_cause_of(line, marker):
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
i = line.find(marker)
@@ -125,7 +229,8 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
selftest_pass=False, selftest_log="",
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
expect_reset_cause="", require_order="", done_marker="",
max_boot_ms=0, as_json=False):
max_boot_ms=0, as_json=False, exact=None, pairs=None,
allow_residual=0, head_char="", include_comments=False):
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
@@ -147,7 +252,8 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
"""
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None,
"exact_bad": [], "exact_ok": [], "pair_bad": [], "pair_ok": []}
emit = out
def finish(code):
@@ -169,6 +275,10 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return finish(2)
if not include_comments:
# archived evidence files carry an ASCII provenance header; a header that merely
# MENTIONS [ERR] must not trip Forbidden (tool-level false-red guard, task-36)
lines = [l for l in lines if not l.lstrip().startswith("#")]
full = "\n".join(lines)
req_list = _split_list(required)
@@ -209,6 +319,37 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
else:
out("[OK] forbidden pattern absent: {}".format(f))
# ---- whole-line exact checks + cross-reset pairing (task-36) ----
payloads = [_payload_of(l) for l in lines]
res["payload_lines"] = len(payloads)
exact_bad = []
for spec in (exact or []):
pat, _want = _parse_exact(spec)
ok_x, detail, neigh, residuals = exact_check(payloads, spec, allow_residual, head_char)
if ok_x:
out("[OK] exact line: {} {}".format(pat[:64], detail))
res["exact_ok"].append({"pattern": pat, "detail": detail})
else:
out("[FAIL] exact line: {} {}".format(pat[:64], detail))
for nb in neigh:
out(" inexact neighbour: {}".format(nb.strip()[:170]))
exact_bad.append(pat)
res["exact_bad"].append({"pattern": pat, "detail": detail,
"neighbours": [n.strip()[:170] for n in neigh]})
for k, v in residuals:
out("[NOTE] {} residual byte(s) before the line head, realigned and NOT a "
"failure: {}".format(k, repr(v)))
pair_bad = []
for f_pat, t_pat in (pairs or []):
ok_p, detail = pair_check(payloads, f_pat, t_pat)
if ok_p:
out("[OK] pair: {} -> {}".format(f_pat[:44], t_pat[:44]))
res["pair_ok"].append({"first": f_pat, "then": t_pat, "detail": detail})
else:
out("[FAIL] pair: {} -> {} {}".format(f_pat[:44], t_pat[:44], detail))
pair_bad.append({"first": f_pat, "then": t_pat, "detail": detail})
res["pair_bad"].append(detail)
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
boot_issue = False
if max_boot_count > 0:
@@ -307,7 +448,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
"(run_boot_test.py adds it; --raw-log turns it off)")
res["timing"] = timing
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
# 套件汇总行解析:抓"没跑到汇总行 / 断言数为 0"的盲区
bad_summary = []
if summary_list:
parsed = {}
@@ -380,13 +521,75 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
out("")
if (missing or hits or bad_summary or selftest_fail or boot_issue
or order_issue or timing_issue):
or order_issue or timing_issue or exact_bad or pair_bad):
out("VERDICT: FAIL")
return finish(1)
out("VERDICT: PASS")
return finish(0)
def selftest(out=print):
"""Prove the exact/pair machinery actually discriminates (exit 0 = all behaviours ok).
Written because a PASS that cannot fail is worthless: case 2/3 are the real captured
shapes of the 2026-10-26 console-interleave defect, and case 9 shows that the legacy
substring check calls the SAME defective line a PASS -- that is the blind spot --exact
exists to close.
"""
T = "[2026-10-26 08:00:00]"
prot_re = (r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\[PROT\] uncalibrated -> protection "
r"blocked \(UA/UN/IA VALID not all set\)")
nand_re = r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\[NAND_FTL\] Initializing NAND FTL\.\.\."
prot_ok = T + "[PROT] uncalibrated -> protection blocked (UA/UN/IA VALID not all set)"
prot_spliced = T + "[PROT] uncalibrated -> protection [blocked (UA/UN/IA VALI2D not all s0et)"
nand_ok = T + "[NAND_FTL] Initializing NAND FTL..."
nand_headless = "26-10-26 08:37:07][NAND_FTL] Initializing NAND FTL..."
mem_re = r"\[ERR\] MEMFAULT PC=\S+ LR=\S+ CFSR=\S+ HFSR=\S+"
mem_ok = "[ERR] MEMFAULT PC=0x08004a12 LR=0x08004a3f CFSR=0x00008200 HFSR=0x40000000"
rc_ok = T + "[APP] reset cause: software (NVIC_SystemReset) (CSR=0x14000000)"
cases = []
def chk(name, got, want):
cases.append((name, got == want, "got={!r} want={!r}".format(got, want)))
chk("clean exact", exact_check([prot_ok], prot_re)[0], True)
ok_s, _d_s, neigh_s, _r_s = exact_check([prot_spliced], prot_re)
chk("spliced line rejected", ok_s, False)
chk("spliced line fails even with broken probe key", len(neigh_s), 0)
ok_n, _d_n, neigh_n, _r_n = exact_check([nand_headless], nand_re)
chk("headless line rejected", ok_n, False)
chk("headless line reported as neighbour", len(neigh_n) >= 1, True)
chk("residual 1 rejected when not allowed", exact_check(["#" + nand_ok], nand_re)[0], False)
ok_r, det_r, _n_r, res_r = exact_check(["#" + nand_ok], nand_re, allow_residual=1)
chk("residual 1 accepted when allowed", ok_r and len(res_r) == 1, True)
chk("residual 2 still rejected", exact_check(["##" + nand_ok], nand_re, allow_residual=1)[0], False)
chk("head_char enforced", exact_check([nand_ok], nand_re, 0, "[")[0], True)
chk("head_char catches wrong head", exact_check([nand_headless], r"26-10-26.*NAND_FTL.*", 0, "[")[0], False)
chk("len pin ok", exact_check([nand_ok], nand_re + EXACT_LEN_SEP + str(len(nand_ok)))[0], True)
chk("len pin mismatch fails", exact_check([nand_ok], nand_re + EXACT_LEN_SEP + "9")[0], False)
chk("pair good", pair_check([mem_ok, rc_ok], "MEMFAULT", "reset cause:")[0], True)
chk("pair two following causes fails",
pair_check([mem_ok, rc_ok, rc_ok], "MEMFAULT", "reset cause:")[0], False)
chk("pair wrong order fails",
pair_check([rc_ok, mem_ok], "MEMFAULT", "reset cause:")[0], False)
# blind spot: legacy substring would PASS on this defective line
full = "\n".join([prot_ok, nand_headless])
legacy_present = "Initializing NAND FTL" in full # what --required sees
exact_ok_here = exact_check([prot_ok, nand_headless], nand_re)[0]
chk("blind spot proven: legacy substring PASSES where --exact FAILS",
legacy_present and not exact_ok_here, True)
bad = 0
for name, ok, detail in cases:
out("{} {}".format("[OK] " if ok else "[FAIL]", name))
if not ok:
out(" {}".format(detail))
bad += 1
out("SELFTEST: {} case(s), {} failure(s) -> {}".format(
len(cases), bad, "PASS" if bad == 0 else "FAIL"))
return 0 if bad == 0 else 1
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
for stream in (sys.stdout, sys.stderr):
@@ -403,7 +606,7 @@ def main(argv=None):
help="forbidden patterns, ';' separated (case-insensitive)")
ap.add_argument("--require-suite-summary", default="",
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
"with N>0, ';' separated (T-2). Empty = off")
"with N>0, ';' separated. Empty = off")
ap.add_argument("--show-tail-lines", type=int, default=40,
help="show last N log lines (0 = off)")
ap.add_argument("--require-selftest-pass", action="store_true",
@@ -427,10 +630,36 @@ def main(argv=None):
ap.add_argument("--max-boot-ms", type=int, default=0,
help="fail if time from first line to --done-marker exceeds N ms "
"(needs '[+Nms]' line prefix; 0 = off)")
ap.add_argument("--exact", action="append", default=[],
help="repeatable regex that must match a WHOLE firmware line "
"(collector prefix stripped, anchored ^...$); a spliced byte or a "
"missing line head fails. Optional suffix '@@LEN=<n>' pins the byte "
"length -- opt-in only, lengths belong to one image. Use --exact "
"instead of --required for any marker whose line can lose its head.")
ap.add_argument("--allow-residual", type=int, default=0,
help="accept up to N stray bytes before the line head on an --exact hit "
"(crash-dump legs: 1 shift-register leftover is known; recorded, "
"not a failure). 0 = strict (default)")
ap.add_argument("--head-char", default="",
help="every --exact hit must start with this char right after the "
"optional residual prefix (crash-dump legs: '[')")
ap.add_argument("--pair", action="append", nargs=2, metavar=("FIRST", "THEN"), default=[],
help="repeatable pair: exactly one FIRST line, and exactly one THEN line "
"after it (cross-reset pairing, e.g. 'MEMFAULT' 'reset cause:')")
ap.add_argument("--include-comments", action="store_true",
help="do NOT skip '#' lines (archived evidence files carry an ASCII "
"provenance header; skipping it is the default so a header that "
"merely mentions [ERR] cannot trip --forbidden)")
ap.add_argument("--selftest", action="store_true",
help="run the built-in discrimination cases and exit (0 = all behave "
"as specified); needs no log file and no hardware")
ap.add_argument("--json", action="store_true",
help="emit one JSON object instead of the text verdict (exit code unchanged)")
args = ap.parse_args(argv)
if args.selftest:
return selftest()
return verify(args.log_path, args.required, args.forbidden,
args.show_tail_lines, suite_summary=args.require_suite_summary,
selftest_pass=args.require_selftest_pass,
@@ -441,7 +670,10 @@ def main(argv=None):
require_order=args.require_order,
done_marker=args.done_marker,
max_boot_ms=args.max_boot_ms,
as_json=args.json)
as_json=args.json,
exact=args.exact, pairs=args.pair,
allow_residual=args.allow_residual, head_char=args.head_char,
include_comments=args.include_comments)
if __name__ == "__main__":
+176 -13
View File
@@ -1,7 +1,7 @@
---
name: task-worktree
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、build/bench 两类独占锁、done 预演合并回主干并审计并集残留。规格见 docs/环境与部署/并行任务工作树方案.md。
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时。
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、活跃任务硬上限 3 与腾位、build/bench 两类独占锁(含非登记任务占锁后「缺台账」释放不掉的正解)、done 预演合并回主干、门禁未全绿的补基线收尾、并集合并残留审计。规格见 docs/环境与部署/并行任务工作树方案.md。
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到 `new` 报「已有 3 个活跃任务(上限 3)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿:工作树与分支保留,未清场」、两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时。
---
# task-worktree — 多任务并行开发空间(短命令 `@task`)
@@ -14,7 +14,15 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令> [参数]
```
bash(`ops_bash`)里固定两个变量,下文用 `$TW` 代指脚本:
```bash
PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktree.py"
"$PY" "$TW" list
```
退出码:`0`=成功 / `1`=操作失败 / `2`=前置或环境错误 / `3`=用法。
**⚠ 这几个码只有「不接管道」时才读得到**——原因与对策见「读输出」一节。
## 固定命令
@@ -24,14 +32,19 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 |
| `status <ID>` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) |
| `sync <ID>` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 |
| `lock acquire\|release\|status <build\|bench>` | 独占资源锁 |
| `lock acquire|release|status <build|bench>` | 独占资源锁 |
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
| `abort <ID>` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) |
`new` 常用开关:`--base <分支>`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)·
`--lean`(跳过 Git LFS smudge)· `--no-bootstrap`(不建联接)。
`--lean`(跳过 Git LFS smudge)· `--no-bootstrap`(不建联接)·
`--force`(**仅跳过并发上限 3 的检查**,不解决「谁该让位」,见 A 节)。
`done` 常用开关:`--dry-run`(只预演,零改动)· `--skip-build` · `--skip-gates` · `--force`。
`done` 常用开关:`--dry-run`(只预演,零改动)· `--skip-build` · `--skip-gates` · `--force`(清场时强制移除工作树)。
`lock` 开关:`--task <ID>`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(自报窗口)· `--note <文本>` ·
`--bench-pass`(release 时给台账记 PASS)· `--force`。
⚠ `--note` 只在 **acquire** 时写进锁 payload,**release 时被忽略**——别指望它留下验收证据(证据写进主干提交说明或文档)。
**环境变量**(高级):`DTU_WT_ROOT`(任务容器位置,默认仓库同级 `_wt/`)·
`DTU_TASK_BASE`(默认基线分支,默认 `v2-on-ui`)·
@@ -51,6 +64,121 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" done N21
```
## 读输出:管道与截断(两起误判的共同根因)
1. **`... 2>&1 | tail -N` 的退出码是 `tail` 的,恒为 0** → 脚本的 `1` / `2` 全被吞。实测三条印着
「rc=0」的输出其实都是 exit 2:`new` 撞活跃上限、`lock release` 缺台账两次。
所以**判据先看文本**(`[ok]` / `!!` / `前置/环境错误:` / `失败:`),要真码就**直接执行不接管道**或补一句
`echo RC=$?`;坚持接管道就在 bash 里取 `${PIPESTATUS[0]}`。
2. **`done` / `done --dry-run` 的输出不要用 `tail -2` 截**:接管道时 python 的 stdout 是块缓冲、git
子进程的 stderr 不缓冲,两股流错位混排,`tail -2` 只剩「--dry-run:到此为止…」加上 stderr 里带
`conflict` 字样的片段 → 据此误判「有冲突」。实测:`tail -2` 误读成 conflict,放宽到 `tail -10` 才看到
`[ok] 无冲突,预演结果树 <sha>`。dry-run 全文只有 5~8 行,直接读全量。
## 分步操作(三条运行时机制)
### A. `new` 被拒「已有 3 个活跃任务(上限 3)」——怎么腾位
1. 读原文:`前置/环境错误:已有 3 个活跃任务(上限 3);先 done/abort,或加 --force`(exit 2)。
上限是脚本常量 `MAX_CONCURRENT = 3`;**「活跃」的判据 = `_wt/.state/<ID>.json` 台账存在,且台账里
`path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步)。
2. `$TW list` 看全景(分支 / 落后领先 / 脏文件数)决定谁让位:**已完成的先 `done`**(合并回收,一步不省)。
3. 没人认领、零提交、尚未 claim 的空闲树 → `abort <ID>` 腾位(实测就是 abort 掉排在后面的空闲树换出名额)。
`abort` = 工作树 + 分支一起删、**永久丢弃**;工作树有未提交改动或已有提交时脚本会拒绝,确认可弃再加 `--force`。
4. 确实要同时开第 4 棵才 `new <ID> <slug> --force`:磁盘代价约 **83 MB/棵**(`--lean` 约 45 MB),
并行度上去了、**冲突面与台架排队也一起上去**——不是免费的。
5. **别用手工 `git worktree remove` 腾位**:台账不会被删(下次 `new` 同 ID 会报「任务 <ID> 已存在」),
还绕过了「先摘联接」(铁律 5),有让 git 递归进联接把主工作树 `.venv` 删掉的风险。
6. 腾位后复验:`$TW list` 的条目数与 `ls _wt` 的目录数一致,再 `new`。
### B. 自己占的锁没有台账:`lock release` 报「任务 X 不存在(缺台账)」
**症状**(lead 在主工作树跑台架验收窗,用临时名占锁:`lock acquire bench --task acceptwin`):
```
"$PY" "$TW" lock release bench --task acceptwin --bench-pass
前置/环境错误:任务 acceptwin 不存在(缺台账 _wt/.state/acceptwin.json)
"$PY" "$TW" lock release bench --task acceptwin --force --bench-pass # 同一句错误,锁仍在
```
**根因**(读脚本确认):`release` 分支里**只要带 `--bench-pass` 就先 `load_state(task)`** 去给台账写 PASS,
台账不在就抛「前置/环境错误」,**根本走不到释放那一步**;`--force` 只覆盖「持有者与 `--task` 不符」和
acquire 的陈旧接管,管不到台账缺失。而 `acquire` 侧**不查台账**——这个不对称就是坑本身;报错文案里的
「任务不存在」也别误读成「我没占过锁」。
1. 先 `$TW lock status` 确认持有者 / pid / 持有时长 / TTL(如 `bench acceptwin 持有(pid=2784,
已 0.1 / TTL 6.0 小时)`)——**别反复重试 release**。
2. **不需要同步记 PASS** → 直接 `$TW lock release bench --task <NAME>`(不带 `--bench-pass`)就能释放。
3. **需要 `--bench-pass` 而这个名字不是登记任务** → 手写**最小占位台账** `_wt/.state/<NAME>.json`
(键与 `new` 写出的完全一致,共 11 个):
```json
{
"id": "acceptwin",
"slug": "lead-window",
"branch": "(none: lead 验收窗占位台账)",
"path": "D:\\Code\\DTU Code\\STM32F4-Base",
"base": "v2-on-ui",
"created_at": "2026-10-26 17:00:00",
"junctions": [],
"needs_bench": true,
"bench_pass": false,
"note": "lead 验收窗(非工作树任务,为 lock 台账占位)",
"lean": true
}
```
用写文件的工具落 UTF-8(无 BOM),别用 `echo` / `Set-Content`(中文乱码铁律)。
4. `$TW lock release bench --task <NAME> --bench-pass` → `[ok] 已为任务 … 记录台架 PASS` +
`已释放锁 bench(原持有者 …)`。
5. **立刻删掉占位文件**:它活着期间会被 `list` 当任务列出,并**占掉 3 个活跃名额之一**(`path` 指向的
目录存在就算活跃);而且**绝不对这个 ID 跑 `done` / `abort`**——清场会拿台账里的 `path` / `branch`
去 `git worktree remove` 和删分支。
6. 复验 `$TW lock status`:`build 空闲(TTL 1.0h)` / `bench 空闲(TTL 6.0h)`。
7. 占位台账一删,那条 PASS 也随之消失。**这个 PASS 后面还要用的话,从一开始就用已登记的任务 ID 占锁**
(lead 的验收窗借某个已 `new` 出来的 ID 最省事),或把验收结论写进主干提交说明/文档。
8. **为什么必须马上释放**:`done` 的前置会跑 `release_stale_locks()`——**未到 TTL 的活锁直接判失败**:
`独占资源 bench 仍被 acceptwin 占用(已 0.1 / TTL 6.0 小时),先 release`。lead 挂着锁 = 挡住所有任务
的收口;只有超过 TTL 的锁才会被自动接管。
9. 最后手段(脚本本身都走不通时):锁就是 `_wt/.state/locks/<name>.lock` 里的单行 JSON
(`name/task/pid/host/epoch/at/note/ttl`),`release` 的实现就是 unlink 它——手工删这个文件等价于
「不记 PASS 的强制释放」,只在前面几步都不可用时才用。
### C. `done <ID>` 的正确用法(它是合并点,不是查看器)
1. **合并前确认任务属主已把全部改动提交完**。`$TW status <ID>` 看未提交清单与领先提交数;跨会话任务
再问一句属主(`send_message`)。实测教训:属主还剩「2/2」没提交就 `done`,把只含 1/2 的分支合进了主干,
只能另开一棵树补后半并向对方说明(是自己的时序错,不是分支问题)。
2. **先 `--dry-run`,并读全量输出**(别 `tail -2`,见「读输出」)。判据两行:
`[ok] 前置检查通过(领先 <base> 共 N 个提交)` + `[ok] 无冲突,预演结果树 <sha>`;
**N 必须等于属主应交的提交数**,对不上回到第 1 步。dry-run 零改动。
3. 正式 `$TW done <ID>`——**必须在主工作树**(铁律 1)。
4. 输出 `!! 门禁未全绿:工作树与分支**保留**,未清场` 时,紧跟的 `主干已含合并提交 <sha>` 已经说明
**合并落进主干了**。
- ✅ 走提示里的 **a) 在主工作树修正后追加提交**;
- ❌ **绝不用 b) 的 `git -C "<主树>" reset --hard <sha>`**——那是丢弃已入库的合并提交,而
`reset --hard` 本身是项目级 Never(会连未提交工作一起丢)。提示 b 只是列出的可能性,不是建议。
- 先定位是**哪条钩子**红:门禁输出里的 `[SUMMARY] L0: pre-commit hooks -- passed=N failed=M`,
不同根因处置不同(见陷阱「`done` 的门禁 = `pre-commit run --all-files`」)。最常态是 `.map` 滞后 → 第 5 步。
5. 补绿固定链(2026-10-25 实测;顺序即因果,全在**主工作树**跑,逐条不接管道看退出码):
```bash
"$PY" scripts/check_mem_numbers.py --write # 台账受管区 ↔ 新 .map 对齐
"$PY" scripts/ram_baseline.py --write # RAM 快照 / 92% 预算台账
"$PY" scripts/ram_baseline.py --update # 趋势基线追加一行
"$PY" scripts/check_test_coverage.py --update-baseline # 覆盖率基线(新单测 / 新套件时必刷)
"$PY" scripts/check_doc_links.py --all # 文档链接与章节引用
git add <上面几条刷出来的基线/快照文件> # 显式列文件,别 -A 把别人在飞的改动一起提交
git commit -F <UTF-8 无 BOM 的正文文件> # 提交属 Ask first(AGENTS.md 边界),按用户指示再做
```
为什么必然要补,见陷阱「`done` 的门禁跑在 `@build` 之前」。
6. **重跑 `done` 之前主工作树必须干净**——否则前置检查直接
`失败:主工作树不干净(…)——合并会把未提交改动卷进来`,所以第 5 步的产物必须**先提交**。重跑时若分支
已并入,会打印 `[i] task/<ID>-<slug> 已合入 <base>,跳过合并,直接清场` → 摘联接 → `完成(分支此前已合入)`。
7. 清场完成后再敲一次 `done <ID>` 会得到 `前置/环境错误:任务 <ID> 不存在(缺台账 …json)`(exit 2)——
**这不是失败**:清场的最后一步 unlink 了台账。判完成以 `git log` 为准。
## 铁律
1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
@@ -59,17 +187,23 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。**
- `build` 锁由 **`@build` 自持自释**,调用方无需手工 acquire/release;
- `bench` 锁**必须手工**:`lock acquire bench --task <ID>` → 烧录/`@test`/探针 →
`lock release bench --task <ID> --bench-pass`。
`lock release bench --task <ID> --bench-pass`。非登记任务(lead 的验收窗)占了锁怎么放 → B 节。
4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、
`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
`git worktree remove`。**手工清场时千万别直接 `git worktree remove`**——git 可能递归进联接,
把主工作树的 `.venv` 一起删掉。
6. **并发上限 3 棵工作树**(每棵约 83 MB 检出)。
6. **活跃任务硬上限 3**(脚本常量 `MAX_CONCURRENT = 3`,每棵约 83 MB 检出)。满了 `new` 直接 exit 2,
腾位顺序与 `--force` 的代价 → A 节。
7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**——否则污染 `git status`、`pre-commit --all-files` 与全库搜索。
8. **`done` 是合并点**:属主没提交完就别 `done`;合并提交一旦进了主干,只用「主工作树补提交」收尾,
**不用 `reset --hard` 反悔** → C 节。
## 陷阱(实测)
- **`--force` 不是万能钥匙**:它只覆盖四类检查——并发上限、持有者与 `--task` 不符、陈旧锁接管、
清场时强制移除工作树。**台账缺失这类前置/环境错误(exit 2)跟它无关**(实测 `lock release
--force --bench-pass` 报的是同一句话,锁照旧没放)。
- **联接不是符号链接**:本仓库 `core.symlinks=false`,git 里的 symlink 会被检出成
「内容是目标路径的文本文件」。共享 `.venv` 只能用 Windows **目录联接**(`mklink /J`,
不需要管理员权限),且联接必须建在版本控制之外。
@@ -78,19 +212,34 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。
- **联接建失败不中止 `new`**:只告警并打印手工补建命令,避免留下半成品任务空间;
但该任务里跑不了 `.venv\Scripts\python.exe`,`@build` / `ci_local.py` 会找不到解释器。
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并。
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并(补完基线再重跑 `done` 时最容易忘,见 C 第 6 步)。
- **⚠ `done` 的门禁跑在 `@build` 之前 → 依赖 `.map` 的钩子会误报**(2026-10-12 实测):
`done` 的顺序是「合并 → 重生成 → **门禁(ci_local)** → `@build`」,而
`mem-budget`(N-27)/ `mem-numbers` 是拿**文档快照 vs `.map`** 比对 —— 门禁跑时
`mem-budget`(登记簿条目「缩堆迁 CCM 后半(暂缓)」)/ `mem-numbers` 是拿**文档快照 vs `.map`** 比对 —— 门禁跑时
`.map` 还是**主工作树合并前**的旧产物,与刚合并进来的新台账**必然不一致** →
误报 `[FAIL] 台账受管区与 .map 不一致`。`@build` 跑完后 `.map` 才是新的。
**处置**:门禁失败且只差这两项时,按提示在主工作树跑
`python scripts/ram_baseline.py --write` + `python scripts/check_mem_numbers.py --write`
后追加提交,再重跑 `done`(它会检测到「已合入」直接清场)。
**处置(2026-10-25 补全)**:按 C 节第 5 步的完整链补——`check_mem_numbers.py --write` →
`ram_baseline.py --write` → `ram_baseline.py --update` → `check_test_coverage.py --update-baseline` →
`check_doc_links.py --all` → 提交 → 重跑 `done`(它会检测到「已合入」直接清场)。
此时主干已含合并提交,**绝不能用提示里的 `reset --hard` 回退**。
- **`done` 的门禁 = `ci_local.py` = `pre-commit run --all-files`(50 条钩子全跑)**(2026-10-26):红了先读
`[SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K`,**按钩子 id 定根因**,别一律当「`.map` 滞后」
去跑补基线链。典型例外:`generated-fresh`(生成器幂等性,触发面 `tools/**`、`docs/images/*.svg`、UI 生成文件)——
合进来的分支改了生成器却没重跑产物时,补基线链救不了,处置是在**主工作树重跑那个生成器**、脚本与产物同一笔提交,
再重跑 `done`。另注意 `pre-commit run --all-files` 只跑 pre-commit stage,个别只在 commit-msg / 手工阶段跑的守卫不在此列
(口径见 `scripts/ci_local.py` 头部说明)。
- **⚠ 新建工作树从未构建过 → 体积类门禁必然红**(2026-10-25 实测):`.map` 是每棵树各自的
构建产物、不入版本库,所以 `new` 出来的空间里 `mem-numbers` / `mem-budget` / `ci_local.py`
的 L0c 都拿不到事实源,报 `[FAIL] 找不到 .map 事实源`(脚本提示已写明原因,不是代码缺陷)。
同理 `done` 的重生成步骤会 `[skip] 未找到 .map,跳过内存快照同步(需先 @build)`。
**处置**:新空间第一次提交前先 `@build` 一次(`build` 锁自动排队,与别的树串行)。与上一条
的区别要分清:上一条是「有 `.map` 但是旧的」,本条是「根本没有 `.map`」,处置动作不同。
- **⚠ 增量构建可能不重链 → `.map` 数字瞬态不一致**(2026-10-12 实测):
`@build` 若判定无需重链,`.map` 保持旧值,此时 `--write` 生成的快照是**瞬态值**
(实测同一份源码两次读到 223,560 与 223,724,差 164 B)。**以主工作树(集成点)
的 `.map` 为准**;改完 RAM 相关代码后确认重链再刷新快照。
- **`ram_baseline.py` 的 help 串里有未转义的 `%`**(2026-10-25 实测:某些直调姿势会渲染 argparse help 并崩)——
查用法读脚本头部 docstring,别靠 `--help`;修脚本本身属 `mem-budget` skill 的范围。
- **合并冲突常落在「生成物」上**:`docs/内存管理/RAM使用分析与优化方案.md`(mem-numbers
快照)在双方都改过时必冲突,且**两边都不能直接选**(各自基于不同基线)——
正确做法是解冲突后用**合并后的实际构建**重新生成,不要挑一侧。
@@ -110,14 +259,28 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
bench 是会话级(flash→`@test`→探针),才配得上小时级。持有者还可用
`lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。
- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示;
未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败)。
未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败),但
**未到 TTL 的活锁会把 `done` 挡在前置检查**(B 节第 8 步)。
- **`build` 锁不必手工持**(2026-10-12 起):`@build`(`keil_build.py`)**自持自释**——
进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release、锁一直挂到 TTL"这条在构建路径上已消除。
`done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。
**`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`@test`→探针,见 `keil-build` SKILL.md)。
## 验证
- `$TW list`:条目数 = `ls _wt` 的目录数且 ≤ 3;刚回收的任务与 B 节的占位条目都不再出现。
- `$TW lock status`:`build 空闲(TTL 1.0h)` + `bench 空闲(TTL 6.0h)`——没有本会话留下的持有者。
- `git log --oneline -3`:能看到 `merge: task/<ID>-<slug> 合入 <base>` 与其后的补基线提交;
`git status --porcelain` 为空(补链改动已提交,才谈得上重跑 `done`)。
- `ls _wt/.state/`:该 ID 的台账已无(清场最后一步 unlink);重复 `done` 报「任务不存在」不必再追。
- 补绿链的每一条都自己 `echo RC=$?` 看到 0,最后以重跑 `done` 打出的**清场日志**(`已摘除联接` /
`已删除分支` / `完成(分支此前已合入)`)为成功判据,而不是 `tail` 出来的最后一行。
## 与其它 skill 的衔接
- 构建 = `keil-build` skill(`@build`);烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
- 烧录产物的真实 hex 路径以 `.serialtest/config.json` 的 hex 键为准(实测
`MDK-ARM/Objects/STM32F407-Demo.hex` 这个路径并不存在),拷错等于烧了旧镜像 → `serial-boot-test` skill
- 新 `.c` 文件 = `new-source-file` skill(`uvprojx` 与 `CMakeLists.txt` 双端注册)
- `.map` 口径 / RAM 台账与趋势 → `mem-budget` skill;UI 回归 → `ui-verify` skill
- 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md`
+6
View File
@@ -0,0 +1,6 @@
---
name: template-skill
description: Replace with description of the skill and when Claude should use it.
---
# Insert instructions below
+5 -2
View File
@@ -38,8 +38,10 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警
| 常量 | 脚本 | 当前值 | 含义 |
|---|---|---|---|
| `MENU_POOL_BASELINE` | check_menu_rows.py | 4784 B | 菜单根框建完的 LVGL 池 used(x64 模拟器) |
| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | 4864 B | 开页-关页往返后 = 基线 + 80 B 一次性残留 |
| `MENU_POOL_BASELINE` | check_menu_rows.py | 5368 B | 菜单根框建完的 LVGL 池 used(x64 模拟器;2026-10-11 随 LVGL 控件裁剪由 4784 上移) |
| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | 5448 B | 开页-关页往返后 = 基线 + 80 B 一次性残留 |
⚠ 两个常量量的是池的 **`used`**,与 `LV_MEM_SIZE` 总大小**无关** → 改池大小(如 2026-10-15 缩池 40→16 KB)**不该动这两个数**;动了就是页面结构变化或泄漏。
**禁止为过门禁盲目改常量**——无法解释的漂移 = 内存回归 bug。
@@ -47,5 +49,6 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警
- `.venv` 缺失/换机:`& <本机任一 python ≥3.10> -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`(`requirements.txt` 已含 `pre-commit`,重建后 L0 门禁直接可跑)
- 新增 UI `.c` 文件先走 `new-source-file` skill 双端注册,否则一端静默缺文件
- 改 `ui_menu_tree.txt` 或界面里的中文字符串 → **同一笔**重跑生成器(`tools/menu_gen/gen_menu.py`;字体走 **font-regen** 四步),否则提交门禁 `generated-fresh` 直接拦:它把生成器拷进系统临时目录重跑、与工作区逐字节比对(实证 `0f7b5a87` 改了模板没重跑)→ `docs/门禁说明/generated-fresh.md`
- 字体重生成后全页大面积像素差异属预期:确认字形正确后 `--update` 刷参考
- 涉及新界面/大改时本 skill 是 P3 门禁,完整管线(P0~P5)见 `docs/UI设计/UI_界面制作流程.md`
+346 -31
View File
@@ -10,16 +10,25 @@
# 明确不做的两件事(都是刻意为之):
# 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且 L0 的 cppcheck +
# 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。
# 2. `mem-numbers` 钩子 —— 它的事实源是 `MDK-ARM/**/*.map`(Keil 构建产物、已 gitignore),
# 无 `@build` 即无 `.map`。故本 workflow 在**检测不到 .map 时自动跳过该钩子**
# (见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner(如自建带 Keil 的机器)则照常跑。
# 2. `mem-numbers` / `mem-budget` 两根钩子 —— 事实源都是 `MDK-ARM/**/*.map`(Keil
# 构建产物、已 gitignore),无 `@build` 即无 `.map`。故本 workflow 在**检测不到
# .map 时自动跳过它们**(见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner 照常跑。
#
# 🔴 门禁盲区声明(S14,2026-10-02):跳过 `mem-numbers` 意味着 **CI 不守 Flash/RAM 体积门禁**。
# 体积变更(新增任务/缓冲/代码)不会在 CI 被拦下;必须靠**本地 `@build` 后**
# `python scripts/check_mem_numbers.py --scan` 兜底。故该跳过以 `::warning::` 明示
# (不是弱化的 `::notice::`),并写入 job step-summary 的醒目行。
# 🔴 体积门禁:盲区已关闭为**强制路径**(2026-10-25 起;原先只做到"声明")
# 跳过钩子只解释了"runner 没有 Keil",没解决**"没跑 = 通过"**:钩子在无 .map 时
# 自适应 SKIP 并返回 0,于是 CI 全绿仍可能被读成"体积也守住了"。现由 L0 作业**最后
# 一步** `python scripts/check_size_gate.py --ci` 在**不需要 Keil** 的前提下补两条判据:
# · 同码性:`scripts/size_attestation.json` 的源码指纹必须等于本次检出树的指纹,
# 而该凭证只能由"新鲜 .map + 当时未超线"的真实链接测量写出(见 --stamp 三道前置)
# ——等价于"这份代码至少被 Keil 链接并核算过一次";
# · 红线:该次测量记录的受管区占比 ≤92%,与 `mem-budget` 同一常数同一口径。
# 强制落点 = **主干 push**(唯一能保证"这就是要发出去的那份"的位置);其余事件降级为
# `::warning::`——任务分支上的代码尚未定型,拦红只制造噪音,还会诱发多条道同时
# 抢写 scripts/ 台账。仍守不住的(**口径正文唯一**在 scripts/check_size_gate.py 的
# 模块 docstring,此处不重复):绝对数字由本地真实构建产生(runner 无 Keil 许可证)、
# 编译器选项(uvprojx,UV4 每次改写且不入库)不在指纹范围内。
#
# 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
# 产物存档与结果可见性(2026-10-02 起):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3);
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
@@ -116,11 +125,128 @@ jobs:
name: L0 静态门禁 (pre-commit)
runs-on: windows
steps:
- uses: actions/checkout@v4
with:
# 钩子要对比基线与历史(doc-links 等读工作区即可,但 gitlint/cubemx 需完整历史)
fetch-depth: 0
- name: 检出源码(原生 git;不依赖 github.com 上的 action)
shell: powershell
env:
DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
DTU_REPO_REF: ${{ github.ref }}
DTU_REPO_SHA: ${{ github.sha }}
DTU_REPO_TOKEN: ${{ github.token }}
run: |
# Pure ASCII only (see file header).
#
# WHY NOT a "uses:" action: the runner resolves every uses: by contacting the
# actions URL during "Prepare all required actions" -- BEFORE the first step
# runs. On this intranet runner that call intermittently dies with a connect
# timeout: runs 36 (10-21) / 41 (10-25) / 42 (10-26) each burned ~45 s and
# produced ZERO hook output, while a manual curl to the same URL minutes later
# returned 200. Manual reachability is not CI health: the resolver runs as
# SYSTEM and the runner caches nothing across jobs. Gitea 1.25.4 also removed
# the custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to
# stop depending on the public resolver at all.
#
# KEEP IN SYNC with the same step in the other job. This logic deliberately
# does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file is
# not checked out yet at the moment it would be needed -- chicken and egg.
#
# $ErrorActionPreference is set to Continue EXPLICITLY. The runner executes a
# run: block by dot-sourcing it into a session, so an inherited Stop turns any
# raw native stderr line into a TERMINATING NativeCommandError. Measured
# 2026-10-27, run 44 / task-72: a benign "error: No such remote 'origin'" from
# a probe killed the whole job 3 seconds in, before any gate could run.
$ErrorActionPreference = 'Continue'
function Invoke-DtuGit {
# Single funnel for git. Two reasons it must be a funnel, not bare calls:
# 1) native stderr has to be flattened into plain strings (see above), and
# 2) $LASTEXITCODE has to stay readable by the caller afterwards.
# Returns Code + lines and lets the caller decide what to print.
param([string[]]$G)
$lines = & git @G 2>&1 | ForEach-Object { [string]$_ }
return [pscustomobject]@{ Code = $LASTEXITCODE; Lines = @($lines) }
}
if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 }
if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 }
# Never let a fetch wait for a human: the runner has no console, and a
# credential prompt would burn the whole job timeout while looking like a hung
# CI. Anonymous fetch works here (the repository is public; measured HTTP 200
# on /info/refs?service=git-upload-pack), so CI must not depend on a credential
# store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
$r = Invoke-DtuGit -G @('init', '-q', '.')
if ($r.Code -ne 0) { Write-Host '::error::git init failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
}
# "git remote get-url" writes to stderr when the remote is absent -- that is
# the exact line that killed run 44. Ask the config instead: an absent key
# prints nothing and only sets a non-zero code, so nothing escapes as an error.
$probe = Invoke-DtuGit -G @('config', '--get', 'remote.origin.url')
$url = ($probe.Lines -join '').Trim()
if ($probe.Code -eq 0 -and $url) {
$r = Invoke-DtuGit -G @('remote', 'set-url', 'origin', $env:DTU_REPO_URL)
} else {
$r = Invoke-DtuGit -G @('remote', 'add', 'origin', $env:DTU_REPO_URL)
}
if ($r.Code -ne 0) { Write-Host '::error::configuring remote origin failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
# Full refspec, NOT --depth 1: the commit-message gate resolves base..head
# ranges and several gates diff against history, so a shallow clone turns them
# into "unknown revision" failures. Tags are skipped on purpose: this repository
# has zero tags and no script uses "git describe --tags" (measured 2026-10-27).
$r = Invoke-DtuGit -G @('config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*')
if ($r.Code -ne 0) { Write-Host '::error::setting remote.origin.fetch failed'; exit 1 }
# The token, when the instance provides one, travels only as a per-invocation
# -c override: it never lands in .git/config and is never echoed in logs.
$auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false')
if ($env:DTU_REPO_TOKEN) {
# Same scheme actions/checkout uses against Gitea git-http: Basic with a
# placeholder user and the token as the password. (Bearer is the GitHub API
# style, not the git-http style.) Honest caveat: this branch CANNOT be
# verified here -- the repository is public, so an anonymous fetch succeeds
# and any token we fabricate locally only proves that a bad one is
# rejected. If the repository ever goes private this is the first line to
# check; the failure mode is a loud rc=1, never a silent fallback.
$pair = 'x-access-token:' + $env:DTU_REPO_TOKEN
$b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64))
}
$f = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin'))
if ($f.Code -ne 0) {
$f.Lines | ForEach-Object { Write-Host " $_" }
Write-Host ('::error::git fetch origin failed (exit ' + $f.Code + ')')
exit 1
}
# Some event refs (PR merge commits, refs outside refs/heads) are not covered
# by the branch refspec -- fall back to an explicit ref fetch, then INSIST the
# wanted SHA is really present rather than silently building the wrong tree.
$want = $env:DTU_REPO_SHA + '^{commit}'
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
if ($chk.Code -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$f2 = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF))
if ($f2.Code -ne 0) { $f2.Lines | ForEach-Object { Write-Host " $_" } }
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
}
if ($chk.Code -ne 0) {
Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history')
Write-Host ' (refusing to run the gates against a tree we did not verify)'
exit 1
}
$c = Invoke-DtuGit -G @('checkout', '-q', '--detach', $env:DTU_REPO_SHA)
if ($c.Code -ne 0) { $c.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::detached checkout failed'; exit 1 }
$rr = Invoke-DtuGit -G @('reset', '--hard', '-q', $env:DTU_REPO_SHA)
if ($rr.Code -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 }
# -ffd, deliberately not -ffdx: -x would delete IGNORED files. Within ONE job several
# hooks share this directory (pip / the simulator build tree), and local reruns reuse
# it. NOTE measured 2026-10-27: CI gives every job a FRESH workspace (the L0 dirs of
# run 44/45/46 all differ, each paying a full 137-161 s fetch), so -ffd is a safe
# default, not a speed trick.
$cl = Invoke-DtuGit -G @('clean', '-ffd', '-q')
if ($cl.Code -ne 0) { $cl.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::git clean failed'; exit 1 }
$head = (Invoke-DtuGit -G @('rev-parse', '--short', 'HEAD')).Lines -join ''
$ncommits = (Invoke-DtuGit -G @('rev-list', '--count', 'HEAD')).Lines -join ''
$dirty = @(Invoke-DtuGit -G @('status', '--porcelain')).Lines.Count
Write-Host ('checkout ok: ' + $head + ' | commits=' + $ncommits + ' | dirty_files=' + $dirty)
- name: 环境自检(Python / cppcheck / pre-commit)
shell: powershell
run: |
@@ -235,28 +361,32 @@ jobs:
id: skip
shell: powershell
run: |
# mem-numbers fact source is the Keil build artifact .map (gitignored).
# No .map => this hook cannot run => skip (not "pass", but "not applicable").
# mem-numbers / mem-budget fact source is the Keil build artifact .map
# (gitignored). No .map => those hooks cannot run => skip them EXPLICITLY.
#
# S14 (2026-10-02): make this GAP loud. mem-numbers is the ONLY gate that
# checks docs/memory numbers against the real .map (Flash/RAM size). When
# it is skipped, CI effectively does NOT guard the size budget -- a silent
# ::notice:: was too easy to miss. So:
# - emit ::warning:: (not ::notice::) so it shows in the run summary
# - write a machine-readable marker file for the summary step
# - append a bold line to the job step-summary
# 2026-10-25: two honesty fixes here.
# 1) mem-budget used NOT to be listed, yet it self-skips inside the script
# when .map is missing (exit 0) -- so on a Keil-less runner it reported
# "Passed" while checking nothing. A gate that fakes green is worse than
# a gate that is visibly skipped: skip it here so the report says Skipped.
# 2) "skipped" alone is still the blind spot, so the compensation is a
# real step, not only a warning: see the "size gate" step at the end of
# this job (scripts/check_size_gate.py --ci, no Keil needed: attestation
# parity + 92% re-check on the recorded numbers). Boundary and rationale
# live in that script docstring (single source of truth).
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
if ($map) {
Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally"
Write-Host "found .map: $($map[0].FullName) -- size hooks run normally"
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"> Volume gate (mem-numbers): RAN (.map present)" |
"> Volume gates (mem-numbers, mem-budget): RAN (.map present)" |
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
} else {
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build."
"value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers and mem-budget, so absolute Flash/RAM numbers are NOT recomputed here."
Write-Host "::warning::that gap is no longer silent: the final step of this job runs scripts/check_size_gate.py --ci (forced path: attestation parity + 92% budget re-check). Locally: scripts/ci_local.py step L0c."
"value=mem-numbers,mem-budget" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." |
"> :warning: Volume gates (mem-numbers, mem-budget): **NOT RUN** -- no `.map` (no Keil build); compensated by the size-gate step below." |
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
}
@@ -302,7 +432,7 @@ jobs:
if: always()
shell: powershell
run: |
# S3: zero-dependency artifact archival. We do NOT use
# Result visibility: zero-dependency artifact archival. We do NOT use
# actions/upload-artifact (inner networks may not fetch outer actions);
# instead we keep the log on the runner AND surface its path + tail in
# the step summary, so the UI is self-sufficient for triage.
@@ -314,6 +444,71 @@ jobs:
}
Write-Host "::notice::L0 artifacts kept on runner at: ci-artifacts\l0-pre-commit.log"
# ------------------------------------------------------------
# 强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map
# 放在 L0 作业**最后**且 if: always()——L0 失败时这条结论同样要看得到,
# 而它自己失败会让整个作业变红(这正是"强制"的含义)。
- name: 体积门禁(强制路径:同码凭证 + 92% 红线,无需 Keil)
if: always()
shell: powershell
env:
GATE_REF: ${{ github.ref }}
GATE_EVENT: ${{ github.event_name }}
run: |
# Pure ASCII only (see file header).
#
# WHY THIS STEP EXISTS: mem-numbers / mem-budget read the Keil .map and
# self-skip (exit 0) without it, so "not measured" used to look like
# "passed" -- that is exactly the blind spot ("CI all green does not
# mean the size budget held"). Merely declaring it did not close it.
#
# This step enforces a check that needs NO Keil and NO .map:
# * parity -- scripts/size_attestation.json (a committed attestation
# that --stamp refuses to write unless the .map is present, newer
# than every source file in scope, and inside budget) must
# fingerprint-match THIS checked-out tree => this exact code was
# linked for real at least once;
# * budget -- the region percentages that measurement recorded must
# stay within the same 92% line mem-budget uses.
#
# Enforcement point = trunk push, the only ref guaranteed to be the code
# we ship. Task branches / WIP pull requests run --warn-only instead: the
# code there is still moving, and blocking it would push every lane to
# edit the same scripts/ ledger file at the same time.
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
$log = 'ci-artifacts\size-gate.log'
if (-not $env:PYTHON) {
# The environment probe step already failed in that case, so the job is
# red anyway; do not mask that with a second, unrelated error.
Write-Host "::warning::PYTHON unresolved (env step failed) -- size gate cannot run"
exit 0
}
$trunks = @('refs/heads/v2-on-ui', 'refs/heads/master', 'refs/heads/main')
$enforce = ($env:GATE_EVENT -eq 'push') -and ($trunks -contains $env:GATE_REF)
if ($enforce) {
$mode = @()
$label = 'ENFORCING (trunk push)'
} else {
$mode = @('--warn-only')
$label = 'advisory (non-trunk event)'
}
$saved = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
& $env:PYTHON -s scripts/check_size_gate.py --ci @mode 2>&1 |
Tee-Object -FilePath $log
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### Volume gate (forced path)" -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "size gate: rc=$rc mode=$label (attestation parity + 92% re-check; no Keil)" -Encoding utf8
Write-Host "::notice::size gate: rc=$rc mode=$label (log: $log)"
if ($rc -ne 0) {
Write-Host "::error::volume gate FAILED: this tree has no matching real Keil measurement, or the measured regions exceed the 92% budget."
Write-Host "::error::fix: run the Keil build locally (0 errors 0 warnings), then 'python scripts/ram_baseline.py --update', and commit scripts/size_attestation.json (scripts/ram_baseline.tsv is refreshed by the same command). Rationale/boundary: scripts/check_size_gate.py docstring."
exit $rc
}
# ------------------------------------------------------------------
# L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
# ------------------------------------------------------------------
@@ -321,8 +516,128 @@ jobs:
name: L0.5 主机侧单测 (MSVC)
runs-on: windows
steps:
- uses: actions/checkout@v4
- name: 检出源码(原生 git;与 l0-gates 同一实现口径)
shell: powershell
env:
DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
DTU_REPO_REF: ${{ github.ref }}
DTU_REPO_SHA: ${{ github.sha }}
DTU_REPO_TOKEN: ${{ github.token }}
run: |
# Pure ASCII only (see file header).
#
# WHY NOT a "uses:" action: the runner resolves every uses: by contacting the
# actions URL during "Prepare all required actions" -- BEFORE the first step
# runs. On this intranet runner that call intermittently dies with a connect
# timeout: runs 36 (10-21) / 41 (10-25) / 42 (10-26) each burned ~45 s and
# produced ZERO hook output, while a manual curl to the same URL minutes later
# returned 200. Manual reachability is not CI health: the resolver runs as
# SYSTEM and the runner caches nothing across jobs. Gitea 1.25.4 also removed
# the custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to
# stop depending on the public resolver at all.
#
# KEEP IN SYNC with the same step in the other job. This logic deliberately
# does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file is
# not checked out yet at the moment it would be needed -- chicken and egg.
#
# $ErrorActionPreference is set to Continue EXPLICITLY. The runner executes a
# run: block by dot-sourcing it into a session, so an inherited Stop turns any
# raw native stderr line into a TERMINATING NativeCommandError. Measured
# 2026-10-27, run 44 / task-72: a benign "error: No such remote 'origin'" from
# a probe killed the whole job 3 seconds in, before any gate could run.
$ErrorActionPreference = 'Continue'
function Invoke-DtuGit {
# Single funnel for git. Two reasons it must be a funnel, not bare calls:
# 1) native stderr has to be flattened into plain strings (see above), and
# 2) $LASTEXITCODE has to stay readable by the caller afterwards.
# Returns Code + lines and lets the caller decide what to print.
param([string[]]$G)
$lines = & git @G 2>&1 | ForEach-Object { [string]$_ }
return [pscustomobject]@{ Code = $LASTEXITCODE; Lines = @($lines) }
}
if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 }
if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 }
# Never let a fetch wait for a human: the runner has no console, and a
# credential prompt would burn the whole job timeout while looking like a hung
# CI. Anonymous fetch works here (the repository is public; measured HTTP 200
# on /info/refs?service=git-upload-pack), so CI must not depend on a credential
# store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
$r = Invoke-DtuGit -G @('init', '-q', '.')
if ($r.Code -ne 0) { Write-Host '::error::git init failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
}
# "git remote get-url" writes to stderr when the remote is absent -- that is
# the exact line that killed run 44. Ask the config instead: an absent key
# prints nothing and only sets a non-zero code, so nothing escapes as an error.
$probe = Invoke-DtuGit -G @('config', '--get', 'remote.origin.url')
$url = ($probe.Lines -join '').Trim()
if ($probe.Code -eq 0 -and $url) {
$r = Invoke-DtuGit -G @('remote', 'set-url', 'origin', $env:DTU_REPO_URL)
} else {
$r = Invoke-DtuGit -G @('remote', 'add', 'origin', $env:DTU_REPO_URL)
}
if ($r.Code -ne 0) { Write-Host '::error::configuring remote origin failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
# Full refspec, NOT --depth 1: the commit-message gate resolves base..head
# ranges and several gates diff against history, so a shallow clone turns them
# into "unknown revision" failures. Tags are skipped on purpose: this repository
# has zero tags and no script uses "git describe --tags" (measured 2026-10-27).
$r = Invoke-DtuGit -G @('config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*')
if ($r.Code -ne 0) { Write-Host '::error::setting remote.origin.fetch failed'; exit 1 }
# The token, when the instance provides one, travels only as a per-invocation
# -c override: it never lands in .git/config and is never echoed in logs.
$auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false')
if ($env:DTU_REPO_TOKEN) {
# Same scheme actions/checkout uses against Gitea git-http: Basic with a
# placeholder user and the token as the password. (Bearer is the GitHub API
# style, not the git-http style.) Honest caveat: this branch CANNOT be
# verified here -- the repository is public, so an anonymous fetch succeeds
# and any token we fabricate locally only proves that a bad one is
# rejected. If the repository ever goes private this is the first line to
# check; the failure mode is a loud rc=1, never a silent fallback.
$pair = 'x-access-token:' + $env:DTU_REPO_TOKEN
$b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64))
}
$f = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin'))
if ($f.Code -ne 0) {
$f.Lines | ForEach-Object { Write-Host " $_" }
Write-Host ('::error::git fetch origin failed (exit ' + $f.Code + ')')
exit 1
}
# Some event refs (PR merge commits, refs outside refs/heads) are not covered
# by the branch refspec -- fall back to an explicit ref fetch, then INSIST the
# wanted SHA is really present rather than silently building the wrong tree.
$want = $env:DTU_REPO_SHA + '^{commit}'
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
if ($chk.Code -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$f2 = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF))
if ($f2.Code -ne 0) { $f2.Lines | ForEach-Object { Write-Host " $_" } }
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
}
if ($chk.Code -ne 0) {
Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history')
Write-Host ' (refusing to run the gates against a tree we did not verify)'
exit 1
}
$c = Invoke-DtuGit -G @('checkout', '-q', '--detach', $env:DTU_REPO_SHA)
if ($c.Code -ne 0) { $c.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::detached checkout failed'; exit 1 }
$rr = Invoke-DtuGit -G @('reset', '--hard', '-q', $env:DTU_REPO_SHA)
if ($rr.Code -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 }
# -ffd, deliberately not -ffdx: -x would delete IGNORED files. Within ONE job several
# hooks share this directory (pip / the simulator build tree), and local reruns reuse
# it. NOTE measured 2026-10-27: CI gives every job a FRESH workspace (the L0 dirs of
# run 44/45/46 all differ, each paying a full 137-161 s fetch), so -ffd is a safe
# default, not a speed trick.
$cl = Invoke-DtuGit -G @('clean', '-ffd', '-q')
if ($cl.Code -ne 0) { $cl.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::git clean failed'; exit 1 }
$head = (Invoke-DtuGit -G @('rev-parse', '--short', 'HEAD')).Lines -join ''
$ncommits = (Invoke-DtuGit -G @('rev-list', '--count', 'HEAD')).Lines -join ''
$dirty = @(Invoke-DtuGit -G @('status', '--porcelain')).Lines.Count
Write-Host ('checkout ok: ' + $head + ' | commits=' + $ncommits + ' | dirty_files=' + $dirty)
- name: 探测 MSVC(VS BuildTools / Community 等)
id: msvc
shell: powershell
@@ -456,7 +771,7 @@ jobs:
if: always()
shell: powershell
run: |
# S3: same zero-dependency archival as L0.
# Result visibility: same zero-dependency archival as L0.
if (Test-Path 'ci-artifacts') {
$logs = Get-ChildItem -Path 'ci-artifacts' -Filter '*.log' -ErrorAction SilentlyContinue
foreach ($f in $logs) {
+24 -3
View File
@@ -120,9 +120,14 @@ simulator/SDL2/
*.exe
*.pdb
*.ilk
# 内容页黄金参考(ref/*.bin)与目检 PNG 均为本地生成物,不入库:
# 首次/换机后先跑 check_content_pages.py --update 生成 ref/*.bin,再跑 regen_ref_png.py 出 PNG
simulator/tests/ref/
# 内容页像素黄金参考 ref/*.bin **已入库**(2026-10-22):整目录不入库时,任何新克隆 /
# 新工作树跑 ui-golden 都必然 FAIL(缺基准),这条像素回归就只在老机器上真实存在。
# 交叉验证:两份各自独立构建的 lvgl_sim(不同构建目录、相隔 11 天的代码)对同一批 ref
# 逐字节一致 → 渲染可复现,基线可入库。派生的目检 PNG 仍是本地产物,继续忽略:
simulator/tests/ref/*.png
# ⚠ 上面第 115 行的全仓 `*.bin` 兜底(挡模拟器的 sim_netcfg.bin 之类运行产物)会连入库的
# 黄金参考一起挡掉 → 必须显式反选;反选只有排在 `*.bin` **之后**才生效(.gitignore 后者覆盖前者)
!simulator/tests/ref/*.bin
# 架构图 PNG 预览(源为 docs/images/*.svg,PNG 本地渲染生成,不入库)
docs/images/*.png
*.pyc
@@ -133,6 +138,16 @@ docs/images/*.png
# Serial assistant runtime state (daemon log/meta, local runtime artifacts)
.dsh/skills/serial-assistant/scripts/state/
# agent 会话草稿(2026-10-14 定):提交信息草稿 / 门禁输出 / 台架日志 / 一次性改名
# 脚本 / 故障注入 patch —— 一律不入库。判据三条,命中任一就不值得留:
# ① 能重跑的(门禁输出、日志);② 内容已在 git 历史里的(已合并实现的过程 diff);
# ③ 离开已丢弃的任务分支就跑不通的(依赖 NET_FAULT_INJECT 的注入产物)——
# 这种提交进去是"看着有证据、实际不可复现"的假证据,**方法应沉淀成文档文字**
# (见 docs/网络/网络自愈架构设计.md §5.2 E18/E19)。
# ⚠ .dsh/skills/ 是**入库的项目知识**(skills = 子域方法论),必须放行。
.dsh/*
!.dsh/skills/
# 记忆工具(Mnemon)本地状态:项目文档索引 documents/ + 运行时记忆
# runtime/MEMORY.md、USER.md、memories.json。**只留本机、不进版本库**
# (2026-10-11 定):记忆是按机器/按人累积的私有状态,入库会让每次记忆
@@ -141,4 +156,10 @@ docs/images/*.png
# CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志;
# 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交)
# 子进程环境残缺时 Windows 会按字面量建目录(本会话实测两次复发:UV4 / 字体
# 工具在缺 SystemDrive、ProgramData 的环境里于仓库根创建
# %SystemDrive%/ProgramData/Microsoft/Windows/Caches/*.db,约 1 MB 字体缓存库)。
# 与"一次性中间产物不进 .gitignore"那条教训不同:这是任何一条道随时可能再生的环境
# 副产物,不兜住就会被某次 git add -A 带进历史。正解仍是继承完整环境跑这类工具。
%SystemDrive%/
ci-artifacts/
+352 -20
View File
@@ -1,5 +1,8 @@
# pre-commit 自动化代码审查配置
# 详见 docs/代码规范/pre-commit使用说明.md
# 通用机制(阶段 / stash 陷阱 / 触发面 / 增量拦截三段式)→ docs/代码规范/pre-commit使用说明.md §2
# 每个门禁一篇详解(含限制与盲区)→ docs/门禁说明/README.md
# 加/删一条钩子:同一笔提交里改 AGENTS.md 的「共 N 个钩子」与本文件注释;口径 = YAML 解析后的 hook 条目数,
# 自查命令与「声明 / 文档 / 实跑」三数同源核对 → docs/门禁说明/README.md §2(直接数文本行会把下面注释掉的候选条目算进去)
default_install_hook_types: [pre-commit, commit-msg]
# 顶层 exclude 只挡**第三方与生成物**。
@@ -8,8 +11,15 @@ default_install_hook_types: [pre-commit, commit-msg]
# tests/*.py、两份配置与 README)。整目录豁免 = 自研代码落在所有钩子之外(连
# lv-conf-sync 都拦不到);实测把 simulator/ 纳入 cppcheck 后,立刻揪出
# sim_syslog.c 一处真实的空指针解引用。真正的生成物/第三方(`simulator/build/`、
# `simulator/SDL2/`、`simulator/tests/ref/`、`simulator/shot*.{bin,png}`、`__pycache__/`)
# **已在 .gitignore 里**,永远不会进 git 索引 → 无需在此重复豁免,故直接删掉该条。
# `simulator/SDL2/`、`simulator/shot*.{bin,png}`、`__pycache__/`)**已在 .gitignore 里**,
# 不会进 git 索引 → 无需在此重复豁免,故直接删掉该条。
# ⚠ 例外(2026-10-22):`simulator/tests/ref/*.bin`(ui-golden 的像素黄金参考)已从
# 「整目录 gitignore」改为**入库**——基线不入库时任何新克隆 / 新工作树都必然报
# 「缺黄金参考」,这条门禁就只在有老副本的机器上真实存在。入库前提是实测可复现:
# 两份各自独立构建的 lvgl_sim(不同构建目录、相隔 11 天的代码)对同一批 11 个 .bin
# SHA-256 全部一致。改写型钩子(trailing-whitespace / end-of-file-fixer)声明
# `types: [text]`,位图含 NUL → 被判二进制自动 Skipped(实测「索引 blob == 磁盘 ==
# 参照」三方一致),无需为它另加 exclude。
exclude: |
(?x)^(
\.opencode/node_modules/|
@@ -152,11 +162,11 @@ repos:
Inc/
)
# 5) 文档一致性门禁(D-7)
# 5) 文档一致性门禁
# 存在性:md 互链 / 反引号路径是否指向真实文件(防迁移/删除后残留死链)
# 章节:`§N.M` 必须能在目标文档的编号标题里找到(--sections-strict:自引用失效也拦)
- id: doc-links
name: 文档链接存在性 (D-3/D-7)
name: 文档链接存在性
entry: python scripts/check_doc_links.py --sections-strict
language: system
files: \.md$
@@ -169,23 +179,23 @@ repos:
MDK-ARM/RTE/|
docs/芯片手册/
)
# 数值:README/AGENTS 任务表须与 Src/freertos.c 一致(D-2/D-7)
# 数值:README/AGENTS 任务表须与 Src/freertos.c 一致
- id: task-table
name: FreeRTOS 任务表一致性 (D-2/D-7)
name: FreeRTOS 任务表一致性
entry: python scripts/gen_task_table.py --check
language: system
files: ^(README\.md|AGENTS\.md|Src/freertos\.c)$
pass_filenames: false
# 体积:内存/Flash 数字须与最新 .map 一致(D-7 第三档)
# 体积:内存/Flash 数字须与最新 .map 一致(体积档)
# 只在内存文档或代码变动时触发;.map 未生成时脚本自身给出明确提示
- id: mem-numbers
name: 内存/Flash 数字一致性 (D-7 体积档)
name: 内存/Flash 数字一致性(体积档)
entry: python scripts/check_mem_numbers.py --scan
language: system
files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$
pass_filenames: false
# 5d) 两份 lv_conf.h 特性子集一致性(2026-10-11 加,随 F-1/F-2/F-3 落地)
# 5d) 两份 lv_conf.h 特性子集一致性(2026-10-11 加)
# 背景:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是两份独立文件,
# 此前长期漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、
# 5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 靠 lv_conf_internal
@@ -304,8 +314,23 @@ repos:
files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7f2) 卷空闲同步(写回缓存空闲 flush)主机侧单元测试(2026-10-25 加,道F 批 2)
# 背景:该模块是写回缓存掉电窗口的兜底网——掉电与"脏页驻留"本身台架
# 不可按需构造;卷忙跳过 / ioctl 失败边沿 latch / 32 位 tick 回绕 /
# 失败路径成对放锁(不得空放)全是纯状态逻辑。本钩子用 MSVC 离线编译
# 真实源文件(#include 纳入;桩:可控时钟 + 事件位图 + 卷忙注入 +
# 可注入 disk_ioctl),把四级门与 latch 分支一次钉死。
# files 含被测源与其头、就绪位属主 sys_events 的消费面 default_task.c、
# 以及 harness 本身。
- id: vol-idle-sync-unit-test
name: 卷空闲同步单测(主机侧)
entry: python test/host/vol_idle_sync/run_unit_test.py
language: system
files: ^(App/fs/vol_idle_sync\.(c|h)|App/task/default_task\.c|test/host/vol_idle_sync/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7g) 时间同步(time_sync)主机侧单元测试(2026-10-10 加)
# 背景:time_sync.c 是 TCP 时间同步协议状态机,也是 N-17 号看门狗风险的**全部
# 背景:time_sync.c 是 TCP 时间同步协议状态机,也是探针窗口心跳缺口那项看门狗风险的**全部
# 逻辑贡献者**——"listen 重建 -> 800ms~1s 心跳间隔"由本模块的分片延时/退避/
# 结算等待组成,板端只能看到"是否复位"这一结果,看不到各段延时是否如设计。本钩子
# 用 MSVC 离线编译真实源文件(#include 纳入)+ 可脚本化的网络/事件桩(含 longjmp
@@ -333,6 +358,57 @@ repos:
files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7h2) 通道标定层(L1.5,「数据可信」卡 2/卡 4)主机侧单元测试(2026-10-25 加,道A)
# 背景:标定折算是"码值→工程量"的唯一插入点,Q16 系数/零点两段落盘 +
# 合法带哨兵回退 + 两点标定公式——板端无标准源、坏文件注入路径在真机
# 上难以复现,只有离线端能逐系数钉死。钩子用 MSVC 编译真实源文件
# (#include 纳入;桩:dbg_log / cfg_file_register / adc_input_desc 强符号)。
# files 含被测源与头、系数口径联动的 adc_health/cfg_file/adc_task 消费方,
# 以及 harness 本身。
- id: adc-cal-unit-test
name: 通道标定层单测(主机侧)
entry: python test/host/adc_cal/run_unit_test.py
language: system
files: ^(App/algo/adc_cal\.(c|h)|App/algo/adc_health\.(c|h)|App/cfg/cfg_file\.(c|h)|App/task/adc_task\.c|test/host/adc_cal/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7h3) 量纲回归(「数据可信」卡 7,道A)主机侧单元测试(2026-10-25 加)
# 背景:台架曾长期缺"不该动作时不动作"的判据,码值/工程量错配曾以
# 恒假动作的形态混过验收。本钩子用真实链(adc_cal→ring→phasor→
# prot_elm)+ 显式换算的合成波把七类元件的量纲钉死:折算点被旁路
# 或量纲再错位,断言立刻翻转。files 覆盖链上全部生产源与两处编排。
- id: prot-dim-unit-test
name: 量纲回归单测(主机侧)
entry: python test/host/prot_dim/run_unit_test.py
language: system
files: ^(App/algo/(adc_cal|prot_elm|prot_set|phasor|sample_ring|freq)\.(c|h)|App/task/(adc_task|protect_task)\.c|test/host/prot_dim/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7h4) 遥测快照层(L2 发布侧,「数据可信」卡 8,道D)主机侧单元测试(2026-10-25 加)
# 背景:卡 8 的 DoD 一半是并发与结构类判据——双区切换无撕裂(撕裂窗
# 200 ms 量级,台架人工比对观测不到)、seq 只增、§4.5 布局与字段偏移
# (卡 9/UI 直接按整体拷贝消费,破布局 = 破协议)、CLIPPED 品质汇总
# (削顶位图要等卡 12 标定才真实出现)。主机侧以 #include 原样纳入
# meas/phasor/sample_ring 生产源,合成 9 通道帧序列一次性钉死;
# 读数与注入源比对类判据仍归台架(实测采样类)。files 覆盖被测源、
# 同链核(phasor/sample_ring)、编排方 adc_task 与 harness 本身。
- id: meas-unit-test
name: 遥测快照层单测(主机侧)
entry: python test/host/meas/run_unit_test.py
language: system
files: ^(App/algo/meas\.(c|h)|App/algo/(phasor|sample_ring)\.(c|h)|App/task/adc_task\.c|test/host/meas/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7h5) 运行参数段(「数据可信」卡 10,道D)主机侧单元测试(2026-10-25 加)
# 死区把守突发上报的量、104 整定段是现场唯一远程写入口:注册契约、
# 分档量程守卫、两段式、系数直通的合法带与生效值回显必须离线钉死。
- id: run-par-unit-test
name: 运行参数段单测(主机侧)
entry: python test/host/run_par/run_unit_test.py
language: system
files: ^(App/algo/run_par\.(c|h)|App/algo/adc_cal\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/run_par/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7i) 保护定值模型(P1 / L5-1 载体)主机侧单元测试(2026-10-11 加)
# 定值是保护动作的"输入宪法":范围校验、整表装载 all-or-nothing、
# 校验和封存-自检-闭锁(参考 tagPRS dwTagSum/dwTagSumN 模式)必须在
@@ -366,7 +442,7 @@ repos:
files: ^(App/algo/freq\.(c|h)|App/algo/phasor\.(c|h)|App/algo/sample_ring\.(c|h)|test/host/freq/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7k) SHA-256 主机侧单元测试(2026-10-11 加,N-19 FTP 鉴权)
# 7k) SHA-256 主机侧单元测试(2026-10-11 加,FTP 鉴权)
# sha256 是 FTP pwd_hash 校验的地基——哈希算错是"正确口令被拒 /
# 错误口令放行"的静默语义错误,板端只见 530/230 看不出根因;
# FIPS 180-4 标准向量(空串/abc/56B 补位跨界/1MB)+ hex_eq 非法输入
@@ -378,7 +454,7 @@ repos:
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)
# 7l) FTP 鉴权状态机主机侧单元测试(2026-10-11 加,FTP 鉴权)
# 锁定 60s/解锁/成功清零/tick 32 位回绕是"防口令爆破"契约核心,
# 板端每验一条要真等 60s、回绕要跑满 49.7 天;lftpd_auth 纯函数
# 设计(tick 参数注入)就是为了让这些分支主机侧毫秒级穷举。
@@ -392,14 +468,15 @@ repos:
# 7m) IEC 60870-5-104 被控站协议栈主机侧单元测试(2026-10-12 加,B4)
# APCI 重组/STARTDT/总召分片/COV/k-w-t1~t3/遥控定值两段式/C_CS/错误
# 应答——link 层 tick 注入 + pts 数据源 vtable 抽象就是为 L0.5 全离线
# 穷举设计(A-4 约束 1);板端复现这些分支要真主站+真等待。
# 穷举设计(分层约束);板端复现这些分支要真主站+真等待。
# 触发面含数据源依赖(prot_set/prot_elm/phasor/sample_ring)——测试
# 直接 #include 这些生产源,签名变了要立刻在这里暴露。
- id: iec104-unit-test
name: IEC104 协议栈单测(主机侧)
entry: python test/host/iec104/run_unit_test.py
language: system
files: ^(App/protocol/iec104/iec104_(asdu|pts|link)\.(c|h)|App/algo/(prot_set|prot_elm|phasor|sample_ring|adc_input|freq)\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$
# 卡 10 起本 TU 链入真实 cfg_file/adc_cal/run_par(板端真源链),触发面同步扩
files: ^(App/protocol/iec104/iec104_(asdu|pts|link)\.(c|h)|App/algo/(prot_set|prot_elm|phasor|sample_ring|adc_input|freq|meas|adc_cal|run_par)\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7m2) SOE 动作记录编解码主机侧单元测试(2026-10-12 加,B9)
@@ -414,13 +491,49 @@ repos:
files: ^(App/log/soe_rec\.(c|h)|App/log/soe_store\.(c|h)|test/host/soe/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7n) dhara NAND 移植层(nand_ftl)主机侧单元测试(2026-10-15 加)
# 背景:移植层六个 HAL 入口是**纯地址算术**,此前对越界页/块号(含
# DHARA_PAGE_NONE 哨兵)**不做任何上界校验**——加偏移送进 SPI 命令后被地址位
# 截断成某个真实页 → 静默读写/搬走无辜数据;而 is_free 旧实现只看页首 64 B,
# 会把"页首 0xFF、后段有数据"的已编程页误判空闲 → dhara 往已编程页再编程
# = 数据丢失。两类缺陷真机**极难复现**(要恰好撞上特定页形态 / 被污染的 meta),
# 板端只能表现为"某次复位后丢数据",无从定位。
# 本钩子用 MSVC 离线编译真实 nand_ftl.c(#include 纳入)+ **RAM 假芯片**
# (32 块 x 64 页 x 2048 B):把"双编程"做成可数事件、记录全部
# 访问次数,于是"越界不产生任何芯片访问""越界标坏块次数为 0"可机械断言;
# 并内联一份"只看页首 64 B"的旧算法做对照,证明这组断言真能咬人。
# 触发面含 dhara 本体(测试一并 #include map.c/journal.c 跑真链路场景)。
- id: nand-port-unit-test
name: dhara NAND 移植层单测(主机侧)
entry: python test/host/nand_port/run_unit_test.py
language: system
files: ^(Drivers/BSP/GD5F2GQ5UE/nand_ftl\.(c|h)|Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue\.h|Lib/dhara/(map|journal|nand|error|bytes)\.(c|h)|test/host/nand_port/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7z) UI 数据快照层(道E task-14)主机侧单元测试(2026-10-25 加)
# 背景:ui_data 是"页面看到的整个世界",但 ui-golden 只覆盖已接线的页面——
# 遥信 27 点、定值 40 项、遥测未接线的通道错了不会让任何参考图变红。本钩子
# 用 MSVC 离线编译真实 ui_data.c 与 ui_w_soe.c(#include 纳入,零桩),把两件事
# 钉死:① 快照存储语义(首帧前读 -1 / 序号由本层盖章 / 三通道互不串台 /
# NULL 防御);② 跨层不透明下标与显示名映射的**现场一致性**——测试单元在同一
# TU 里同时 include BSP 侧与 App/algo、App/log 真头,逐项比对 adc_semantic_t /
# prot_set_id_t / soe_type_t / prot_elm_id_t(解耦换来的"魔法下标"风险,口径
# docs/UI设计/UI_数据供给设计说明.md §9.4;SOE 类型含未标定闭锁=7/复归=8)。
# files 覆盖被测两件 + 编号真源四头 + 页面消费面(ui_content_page/ui_menu*)+ harness。
- id: ui-data-unit-test
name: UI 数据快照层单测(主机侧)
entry: python test/host/ui_data/run_unit_test.py
language: system
files: ^(Drivers/BSP/UI/(ui_data|ui_w_soe|ui_w_records|ui_content_page|ui_menu)\.(c|h)|App/app/ui_data_feed\.(c|h)|App/algo/(adc_input|prot_set|prot_elm)\.(c|h)|App/log/soe_rec\.(c|h)|test/host/ui_data/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 8) 测试套件开关门禁(2026-10-01 加)
# 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件,
# 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里
# TEST_SUITE_CH395F 就是开启状态(.map 含 34 处 test 代码),即仓库里
# 躺着的是测试固件。本钩子抓这个静默漏网:默认要求产品构建,跑套件回归
# 确需提交测试构建时用 `--allow-test-build` / ALLOW_TEST_BUILD=1 显式放行。
# 详见 docs/任务规划/登记簿.md T-1
# 详见 docs/任务规划/登记簿.md
- id: test-suite-switch
name: 测试套件开关=产品构建
entry: python scripts/check_test_suite.py
@@ -428,7 +541,7 @@ repos:
files: ^test/board/test_config\.h$
pass_filenames: false
# 9) 双端注册反向校验(2026-10-12 加,登记簿 N-23 / 审查报告 N-06 残余)
# 9) 双端注册反向校验(2026-10-12 加,登记簿条目「rs485_task 白名单缺口」)
# 背景:新 .c 必须手动注册两处(`MDK-ARM/*.uvprojx` + `simulator/CMakeLists.txt`)
# 才会被编译;历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`
# **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。
@@ -445,17 +558,236 @@ repos:
language: system
files: (\.c$|\.uvprojx$|CMakeLists\.txt$|^scripts/check_registration\.py$)
pass_filenames: false
# 10) RAM 预算 92% 阈值门禁(2026-10-12 加,登记簿 N-27 前半 / 审查报告 §5.5 三件套)
# 10) RAM 预算 92% 阈值门禁(2026-10-12 加,登记簿条目「缩堆迁 CCM 后半(暂缓)」的台账/门禁那半 / 审查报告 §5.5 三件套)
# 背景:报告 §5.5 第 1 条要求"超阈值即失败 + Top-10 附表"——此前 mem-numbers 只防
# "文档数字漂移",**没有任何机械检查拦"占用超阈值"**;台账三件套(ram_baseline.py +
# ram_baseline.tsv + docs/内存管理/RAM预算台账.md)落地后,本钩子把 92% 红线机械化。
# 逻辑:RW_IRAM1/RW_CCM 任一 >92% → FAIL + Top-10 RW+ZI 对象表;台账受管区漂移 → FAIL
# (提示 --write);无 .map → 自适应跳过(同 mem-numbers 在 CI 的行为,盲区登记 A-5 残余)。
# (提示 --write);无 .map → 自适应跳过(同 mem-numbers 在 CI 的行为,盲区已登记)。
# ⚠ pre-commit 4.x 无 `always: true` → files 覆盖所有能改变 RAM 的触发面:
# 全部自研代码目录 + lv_conf.h + 测试板配置 + 脚本/tsv/台账自身 + 本配置文件。
- id: mem-budget
name: RAM 预算 92% 阈值门禁 (N-27)
name: RAM 预算 92% 阈值门禁
entry: python scripts/ram_baseline.py --gate
language: system
files: ^(App/|Drivers/BSP/|Src/|Inc/|Lib/lv_conf\.h|test/board/|scripts/ram_baseline\.|docs/内存管理/|\.pre-commit-config\.yaml$)
pass_filenames: false
# 11) UI 字符集差集检查(2026-10-15 加,报告 §6.5 的 UI 字符集与像素回归门禁残余 / §7 交付物清单"字符集差集检查"行)
# 背景:`ui_font_cn_12.c` 是**子集**字体,只含 `ui_cn_chars.txt` 列出的码位。
# 生成链 UI 源码 --extract_cn--> ui_cn_chars.txt --make_font--> 字体 .c,其中
# verify_coverage 只在**生成期**校验"txt ⊆ cmap";改完 UI 字符串忘了重跑
# font-regen 就提交 → 缺字上机渲染为空白(白框),无日志无报错只能肉眼截图发现
# (font-regen skill 的"缺字渲染为空白"约定,此前纯靠自律)。本钩子把它机械化:
# A. 源码在用 vs txt 缺 → FAIL(白框方向);多 → WARN(只费 Flash,--strict 升级)
# B. txt vs 字体 cmap 缺 → FAIL(lv_font_conv 对缺字形/低码位**静默丢字**)
# A 过 + B 过 ⟹ 字体 ⊇ 源码每个在用字。提取与 cmap 解析**复用** tools/font_gen
# 两个生成器的同一套规则(importlib 直载 extract_cn.SCAN/collect + make_font.
# verify_coverage),杜绝第二套解析规则漂移(§5.5 同类教训)。
# ⚠ pre-commit 4.x 已移除 `always: true` → files 必须覆盖全部触发面:
# UI 源码(含字体 .c 自身)+ ui_task.{c,h} + 字符集 txt + 两个生成器 + 本脚本。
- id: font-charset
name: UI 字符集差集检查(防缺字白框)
entry: python scripts/check_font_charset.py
language: system
files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|ui_cn_chars\.txt$|tools/font_gen/|scripts/check_font_charset\.py$)
pass_filenames: false
# 12a) BOM 漂移门禁(2026-10-25 道E task-20)
# 背景:AGENTS 铁律第 2 条——编辑工具会**静默剥掉**文件原有 BOM,ARMCC 于是按 CP936
# 解码带中文的源文件,报 #870-D / #8 / #274 一类**假语法错**(报错位置与内容都不指向
# 真因),而 MSVC(模拟器侧)与既有钩子全照不出来。2026-10-25 一天内道E 踩 4 次、
# 道B 批 1 踩 1 次,此前全靠人工逐文件比 HEAD 兜底——人工审计只防得住记得做的人。
# 判定:HEAD 有 EF BB BF 而工作区没有 → FAIL。两条刻意不报(新增文件无 BOM、HEAD 无
# BOM 而工作区加了 BOM)与一条刻意不做(不自动补:整文件重写正是本仓更大的坑)都
# 写在脚本头部。files 与 AGENTS 名单、脚本内 BOM_LIST **三处互指**,脚本每次跑核一次,
# 单独改任何一处都会在别人的提交里被拦下——名单散在三处而无人同步等于没有名单。
- id: bom-guard
name: BOM 漂移检查(带 BOM 文件被剥掉即拦)
entry: python scripts/check_bom_drift.py
language: system
files: ^(Drivers/BSP/UI/|App/task/ui_task\.c$|App/cfg/cfg_file\.c$|Lib/lv_conf\.h$)
# 12b) 定值查看行表一致性(2026-10-25 道E task-20 追加批;脚本随 task-15 批 3 落)
# 核 Drivers/BSP/UI/ui_w_setview.c 的四张视图表与 App/algo/prot_set 的枚举序、
# g_prot_set_meta 上下限是否仍是同一份口径:id 恰好覆盖 0..39 各一次、数值行
# min/max 等于 meta 换算、kind 与 meta.is_ctl 一致、单位纯 ASCII、小数位查表与行表
# 一致。为什么必须机械化:BSP 侧看不到 App 头,行表里的 id 是裸数字 + 行尾注名,
# 这条对齐在编译期**物理上无法自证**;而页面上只有当前可见的 8 行会被像素断言看到,
# 错位要翻到第 2、3 页才可能被人发现(发现了也已被基线钉死)。
# ⚠ 跨分支先后:脚本文件随 task-15 分支合入主干;在此之前 files 永不命中 → 钩子自然
# skip(skip 不是失败),合并后自动生效,不需要任何人再动这条配置。
- id: setview-check
name: 定值查看行表与 prot_set 枚举/上下限一致性
entry: python tools/setview_check/check_setview_limits.py
language: system
files: ^(tools/setview_check/|Drivers/BSP/UI/ui_w_setview\.(c|h)$)
pass_filenames: false
# 12) UI 像素回归门禁(2026-10-20 加,W6 的 UI 字符集与像素回归门禁收尾第三件 / 报告 §6.5 同项、§6.1 golden 行)
# 背景:golden 两脚本(check_menu_rows.py / check_content_pages.py)早已存在且一直在
# 人工跑,但**从未进自动门禁**——"有基线"不等于"有门禁"(报告 §6.1 原文即自认此缺口)。
# 形态选型依据实测:改一个 UI 源文件后 build_sim 增量重编 + 两条 golden 合计 **2~4 秒**,
# 成本可接受 → 走自动门禁(若实测要几分钟,则应改为文档化人工门禁,一个逼人
# --no-verify 的门禁等于没有门禁)。
# 有牙实测:给 ui_page_dc.c 的 .top_y 注入 +3 位移 → build_sim 重编 →
# golden FAIL 且点名 `[dc] FAIL 与参考页不一致(1366 px 差异)`、退出码 1;还原后 diff 干净。
# ⚠ 两条 golden 按"整棵菜单树 + 全量参考图"工作、不接受文件参数,故包装器有意忽略
# argv 并 pass_filenames: false(pre-commit 传的变更名对它无意义)。
# ⚠ 参考图 simulator/tests/ref/*.bin 是判据本身,改动它等于改判据——它已入库,
# 评审时看 `git diff -- simulator/tests/ref` 必须逐页过目并在提交正文说明;
# 任何时候都不得用 --update 刷基线来"让钩子变绿"。
# ⚠ pre-commit 4.x 无 `always: true` → files 必须覆盖所有能改变像素的触发面。
# ⚠ 已知瞬时现象:首次实测时在"连续注入/还原的紧邻重建"之间偶发失败一次,
# 疑似 MSBuild 节点争用;此后 3 次干净树连跑均绿。判据:单次失败先复跑一次再看,
# 连续两次失败才当真(不许直接 --no-verify 绕过)。
- id: ui-golden
name: UI 像素回归(菜单 8 状态 + 内容页 40 页 黄金参考)
entry: python scripts/check_ui_golden.py
language: system
files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|simulator/(tests|sim|src)/|simulator/main\.c$|simulator/CMakeLists\.txt$|simulator/lv_conf\.h$|scripts/check_ui_golden\.py$|\.pre-commit-config\.yaml$)
pass_filenames: false
# 12a) 板端 UI 导航器认屏自检(2026-10-25 道E task-25)
# 背景:大窗实测——靠预置键序盲走到深页时,9 帧里只有 3 帧真是它声称的态;
# KEY 的 ACK 是空应答、不携带状态,所以**画面是唯一事实源**。判据脚本
# scripts/board_ui_nav.py 据此改成闭环:每按一键重新认屏再决定下一个键。
# 本钩子跑它的**快检档** --selftest-quick:只核"认屏认得对"(18 张黄金参考 +
# 真机留档帧,目录不在版本控制里、缺就整段跳过),秒级、无构建产物依赖。
# ⚠ 不挂完整 --selftest:第三段闭环需要 lvgl_sim.exe(构建产物,新工作树里没有)
# —— 正是同一天 mem-numbers 缺 .map 那课的形状;脚本对缺 exe 报 FAIL 并给出
# 可执行提示,不"跳过当通过"。合入前 / 上板前手工跑一次完整档:
# python scripts/board_ui_nav.py --selftest(约 30 s,含全绑定页闭环)。
# ⚠ 触发面刻意只两块(脚本自身、黄金参考帧):字体或菜单树变动**不**触发它,
# 那两类的守卫分别是 font-charset / ui-golden / registration-check 与 id 侧;
# 把每个 UI 提交都拉去跑导航器,只会让人习惯等一个与本改动无关的钩子。
- id: uinav-selftest
name: 板端 UI 导航器认屏自检(快检:黄金参考帧分类)
entry: python scripts/board_ui_nav.py --selftest-quick
language: system
files: ^(scripts/board_ui_nav\.py$|simulator/tests/ref/.*\.bin$)
pass_filenames: false
# 13) 编号门禁(2026-10-20 加,docs/编号规范.md §8 的 8 项校验)
# 背景:§1.2 用仓内证据列了 10 类编号事故(同字母多义 / 跨文档同号不同义 /
# 销项删行后残留悬空引用 / 自造前缀 / 会话临时号入册…),而 36 个钩子里
# **只有 TC 一族**有机械检查(check_test_traceability.py,且未挂钩子、正则还漏 [示例]
# `TC-AI-01A` [示例] 这类大写后缀)——E/N/S/B/C/D/A/U/O/gen/Trap 全部裸奔。本钩子把
# 规范 §8 的 8 项机械化:配置两份 `scripts/id_registry.json`(前缀 → 属主登记处
# /形态/冻结/游标,逐项来自规范 §4 总表)+ `scripts/id_aliases.json`(旧号→新号,
# 照搬 section_aliases.json 的成功范式)。
# 判据要点:「定义」= 字面量出现在该族**属主登记处文档**里;于是 §5.5 的
# 「销项 = 删行 + 清全库引用」只做一半,校验 3 立刻报(销项号被重新取走那类撞号正是这条)。
# 盲区显式登记在 registry 的 `blind_spots` / `unmanaged_bare`:裸形同字母多义
# (§1.2:P1 五义 / L1 四义 / UI 状态机号四义)与元器件位号同形(§4.4),机械全库判定
# 必然一边倒误报 → 裸形族只在属主文档内判越游标、文档外降告警;收口属 §9 阶段
# 1-② 的迁移批次。**不是「检查通过」,是记在案上的盲区。**
# ⚠ 当前 warn-only(§8 上线节奏:先清 backlog → 清零后 entry 加 --strict 拦提交,
# 与 doc-links 章节校验同一条路径)。校验 8 的「可判定比例」跌破
# id_registry.json 里的基线时**任何模式都失败**——那根线就是防回退的本体。
# ⚠ pre-commit 4.x 已移除 `always: true` → files 必须覆盖所有能改编号的触发面
# (任意 md/c/h/py/yaml/json/txt + 本配置文件);扫描范围恒为全库
# (悬空/孤儿/重复定义是跨文件判定,pass_filenames: false)。
- id: id-check
name: 编号门禁(docs/编号规范.md §8 八项校验)
entry: python scripts/check_ids.py --strict
language: system
files: \.(md|c|h|py|ya?ml|json|txt)$|^\.pre-commit-config\.yaml$
pass_filenames: false
# 14) 分层方向门禁(2026-10-22 加,结构债的"防扩大"那一半)
# 背景:AGENTS.md 与 项目架构总览.md 早就写着"六层单向依赖",但**从来没有机械检查**
# ——Drivers/BSP/ 里可以随手 include "cfg_file.h" 而无人报警。后果可测量:2026-09 审查
# 报告记"NETCFG 一处",2026-10-22 实测已长到 **8 个文件 9 处**。边界的静默腐蚀只能
# 靠门禁止住,所以本钩子做**增量拦截**(与 id-check 同构):现状 6 条钉成 BASELINE
# (只减不增,没有 --write 往里加),新出现的 (BSP 文件, App 头) 组合直接 FAIL;
# 3 条横切基础设施头走 ALLOW 永久豁免(日志后端 / 时间源 / 纯算法工具——它们是
# 任何层都要用的叶子能力,不是上层服务)。修完存量后跑 --prune 会点名让你收紧基线,
# 于是"还欠几条"变成可见进度,而不是文档里的一句口号。
# 注:本钩子只拦"静态 include 方向";**运行时的属主纪律本来就没错**(UI 只投递
# net_cfg_save_request(),写盘在 defaultTask),别把它当缺陷读。
# 扫描范围恒为全库(要跨文件建 App 头索引),故 pass_filenames: false。
- id: layer-deps
name: 分层方向(BSP 不得反向 include App)
entry: python scripts/check_layer_deps.py
language: system
files: ^Drivers/BSP/|^scripts/check_layer_deps\.py$|^\.pre-commit-config\.yaml$
pass_filenames: false
# 15) BSP 错误码与超时命名约定(机械检查那一半)。
# 四项检查:公开 API 不得用 HAL_StatusTypeDef/bool 当错误通道(谓词 is_/has_/can_/valid
# 不在此列);*_TIMEOUT 宏不得取负值(名字叫 TIMEOUT 其实是返回码);非 void 的公开 API
# 必须有「返回值:」注释;**返回错误码的函数不得裸丢弃返回值**(接住或显式 (void)——实测
# ARMCC v5 与 cppcheck 都不报这类,只有这条会响)。存量钉 BASELINE、只减不增(2026-10-23
# 首清点 19 条 → 已还 18;新增一项又暴露 37 条 → **现 38 条**:37 丢弃 + 1 个 bool 待改签名),
# 修完跑 --prune 点名收紧基线。约定正文 = Drivers/BSP/bsp_err.h,规则说明 =
# scripts/check_bsp_err.py 文件头,逐项详解(含限制与盲区)= docs/门禁说明/bsp-err.md。
# 恒扫全库(要按 git 清单建公开头索引),故 pass_filenames: false。
- id: bsp-err
name: BSP 错误码与超时命名约定
entry: python scripts/check_bsp_err.py
language: system
files: ^Drivers/BSP/|^scripts/check_bsp_err\.py$|^\.pre-commit-config\.yaml$
pass_filenames: false
# 8) 主机单测覆盖率总闸(2026-10-24 接线,用户裁定选 FAIL 语义)
# 为什么现在才挂:接线前本文件只有 16 个 *-unit-test 钩子,而仓库已有 28 套主机单测——
# P1 新增的 12 套不在任何提交钩子里。原先"再加一份全量插桩等于同一件事做两遍"
# 这条理由到现在已经**反了**(现状是有一遍没做),所以必须挂;缺口此前如实登记在
# docs/测试说明/主机单测覆盖率口径.md §8。
# 为什么挂这一根而不是补 12 个 per-module 钩子:本钩子跑**全部 28 套**,并且守 §5 的
# 回退台账(某套被删、或某文件的已覆盖行变少 → [REGRESS] 直接红)。这是 per-module
# 钩子做不到的——删掉一套测试,它那根钩子只会因为"没有文件要检查"而静默跳过。
# ⚠ FAIL 语义(用户裁定 2026-10-24):找不到 mscoverage 时脚本退出码 2 → 本钩子**失败**,
# 不做 skip。理由:skip 等于造一根会静默失效的门禁(与 pre-commit 4.x 移除 always: true
# 是同一类坑);宁可在没装组件的机器上被挡一次、看清 [ENV] 那行提示,也不要一根假绿的闸。
# 本机与 CI runner 同一台;实测两份 VS 安装都自带该 exe(口径 §7 第 8 条已按实测更正)。
# 口径 / 分母 / 基线 / 盲区详解都在 docs/测试说明/主机单测覆盖率口径.md,本处只留接线事实。
# 触发面**含门禁自身**(.pre-commit-config.yaml):不带自身就等于谁改坏这根闸的 files:
# 模式,它就再也不跑且无人察觉(同 lv-conf-sync / bsp-err 的既有做法)。
# ⚠ 读输出的坑(2026-10-24 连踩两次才定位):本钩子没写 stages: → 默认属于**所有阶段**,
# 于是同一次提交的输出里它出现两次:pre-commit 阶段那一遍才可能实跑;commit-msg 阶段
# 那一遍的"文件"是提交信息文件,几乎必然 (no files to check)Skipped。**只看输出结尾会
# 把"实跑通过"读成"根本没跑"**——判它跑没跑要认**第一次**出现的那一行。
# 实跑证据:只改本文件的提交(e327636 / c4ca292)里 test-coverage 与 bsp-err 都是 Passed。
- id: test-coverage
name: 主机单测覆盖率(增量拦截:已覆盖行只增不减)
entry: python scripts/check_test_coverage.py
language: system
files: ^(App/|Drivers/BSP/|test/host/|scripts/check_test_coverage\.py|scripts/test_coverage_baseline\.json|\.pre-commit-config\.yaml$)
pass_filenames: false
# 16) 生成器幂等性(2026-10-26 加,task-18;判据正文 docs/文档规范.md §4.4,索引行见 docs/门禁说明/README.md)
# 背景(实证,不是预防性):0f7b5a87 改了 tools/menu_gen/gen_menu.py 的**模板文字**却没重跑
# 生成物 → HEAD 里 Drivers/BSP/UI/ui_menu_tab.h 与模板不一致,后来是别人重跑生成器才被顺带
# 纠正(属主确认以生成器输出为准)。接线前实测又扫出 **5 张 docs/images/*.svg** 与各自生成器
# 不一致(文档挪目录后 rename_ref.py 只改了 tools/ 里的引用路径、产物没跟着重跑)——生成物
# 比生成器旧是**系统性**缺口。隐蔽之处:改生成器的人看不到任何 diff(脚本改动编译、单测全过),
# 下一个**碰巧**重跑的人却看到一段「不是我改的」改动 → 要么连带提交、要么 revert 掉真修复。
# 判定:把声明的输入与生成器脚本一起拷进**系统临时目录**重跑(三个生成器都按 __file__ 的三级
# 父目录定位仓库根 → 只会写 temp),再逐字节比对 temp 与工作区:DIFF / NEW / 生成器报错 = FAIL。
# **仓库一个字节都不写**,因此不需要还原、也不会 stash 别人在飞的改动(多人同树可安全跑)。
# 覆盖:gen_menu(含它回写的 uvprojx + CMakeLists 两份注册表)、中文字库链(extract_cn →
# ui_cn_chars.txt、make_font → ui_font_cn_12.c)、tools/arch_diagram 全部配图生成器。
# 一处刻意不按裸字节比对:ui_cn_chars.txt 按 font-regen 约定 = 源码在用 ∪ KEEP_EXTRA,改判
# 「txt 恰等于 重跑 ∪ KEEP_EXTRA」(两向都不许差;清单 importlib 直载 scripts/check_font_charset.py,
# 不留第二份清单)。存量基线 = 接线当天已漂的那 5 张 SVG,钉进脚本内 BASELINE 走 WARN 不拦提交
# (只减不增,还清即删、默认运行会点名);业务生成物(菜单表 / 页面注册表 / 字库 / 双端注册表)
# **零豁免面**,漂了就是 FAIL。
# ⚠ entry 是裸 python,而 pre-commit 在提交环境里可能把它解到**机器级**解释器:2026-10-26 真实提
# 交实测解到 C:/Python314(无 fontTools)→ 字体链报 No module named fontTools = 假红。所以脚本
# 自己按「显式 DTU_GEN_PYTHON > 当前解释器 > 仓库 .venv > PATH」解析并**逐个探针校验**(要真能
# import fontTools 才算数),整轮统一用同一个解释器;挑不出来打 [ENV]+候选表+修法、退出码 2 不
# SKIP。不把 entry 写死成 .venv 路径:那是 ci_local 2026-10-11「候选收窄 → runner 上 L0 全灭」
# 的同一条坑(详解 docs/门禁说明/generated-fresh.md §4)。
# ⚠ 字体链外部工具(node + 全局 lv_font_conv + 系统 simsun.ttc / seguisym.ttf)缺失时该项 SKIP 并
# 打印 [SKIP],--strict-env 才升 FAIL——与 test-coverage「缺 mscoverage 必 FAIL」刻意不同:
# 缺工具的机器上没有可执行的修法,SKIP 至少把「这台没验过」留在输出里(盲区同登记在索引行)。
# ⚠ pre-commit 4.x 已移除 always: true → files 必须覆盖全部触发面:任何生成器(tools/)、
# 生成器的输入(Drivers/BSP/UI 源码、App/task/ui_task、ui_menu_tree.txt、ui_cn_chars.txt)、
# 生成物(docs/images/*.svg + 两份注册表)、脚本自身与本配置文件(同 registration-check 口径)。
- id: generated-fresh
name: 生成器幂等性(重跑到临时目录 → 与工作区逐字节一致)
entry: python scripts/check_generated_fresh.py
language: system
files: ^(tools/.+|Drivers/BSP/UI/.+|docs/images/.*\.svg|App/task/ui_task\.(c|h)|ui_menu_tree\.txt|ui_cn_chars\.txt|MDK-ARM/STM32F407-Demo\.uvprojx|simulator/CMakeLists\.txt|scripts/check_generated_fresh\.py|scripts/check_font_charset\.py|\.pre-commit-config\.yaml)$
pass_filenames: false
+19 -15
View File
@@ -10,11 +10,11 @@
## 项目概况
**现状一句话**:平台能力(网络 / 存储 / 时间 / UI / 日志 / 看门狗 / FTP / 配置)**已实现并台架验收**;采集主线已闭环到业务消费者(L1→L2 相量→L3 保护判据 protectTask→104 遥信,B5);**RS-485 采集尚未接线**(B2 按用户决策冻结,见 `docs/任务规划/暂缓-RS485.md`)——当前最大功能缺口,接线节奏受 E-59 门禁约束(见「平台约束」)。
**现状一句话**:平台能力(网络 / 存储 / 时间 / UI / 日志 / 看门狗 / FTP / 配置)**已实现并台架验收**;采集主线已闭环到业务消费者(L1→L2 相量→L2.5 遥测快照→L3 保护判据 protectTask→104 遥信/遥测+UI,B5/批 2);⚠ **保护判据现处于「未标定闭锁」态**——产品构建下 `adc_cal_flags()` 的 `ADC_CAL_F_VALID` 未置位,`protect_task.c:98` 直接停走判据,等卡 12 两点标定写入 `[adc_cal] valid` 后自动解卡(用户裁定 2026-10-25:保持闭锁,不用万用表走捷径);**RS-485 采集尚未接线**(B2 按用户决策冻结,见 `docs/任务规划/暂缓-RS485.md`)——当前最大功能缺口,接线节奏受 **RS-485 接线门禁**约束(见「平台约束」)。
**数据主线**(能力视角;分层图与跨层依赖表 → `docs/架构设计/项目架构总览.md`):
- **采集**:TPAFE5160(计量级 8 路,并行总线)+ 片内 ADC1(9 路,DMA)→ TIM3 1 kHz 节拍 + 原子快照 → adcTask → L1 有效性校验 → L2 相量 → L3 保护(protectTask 20 ms 判据节拍,B5)→ 104 遥信上送
- **采集**:TPAFE5160(计量级 8 路,并行总线)+ 片内 ADC1(9 路,DMA)→ TIM3 1 kHz 节拍 + 原子快照 → adcTask → L1 有效性校验 → L2 相量 → **L2.5 遥测快照(`meas` 200 ms 双区,104 与 UI 共用同一份上游,批 2)**+ L3 保护(protectTask 20 ms 判据节拍,B5)→ 104 遥信/遥测上送
- **存储**:GD5F SPI NAND → dhara FTL → FatFs → `/log` 运行与自检记录、`/config.ini` 配置、FTP 根目录
- **网络**:CH395F → netTask 串行 socket 栈 → FTP(21) / 对时(8888) / RemoDispBus 远程显示(7003) / IEC 60870-5-104 上行(2404)
- **人机**:LVGL 160×160 单色菜单(uiTask),**与 PC 模拟器同一份 UI 源码**,远端可看图 + 注键
@@ -46,7 +46,7 @@
- netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 按网络代次号重建监听(新 IP 免重启生效)
- defaultTask:`f_mount` forced(无卷自动 `f_mkfs`;挂载后卷健康探针,FAT 区陈旧时自愈重格式化)→ 加载网络配置 → STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(netTask/uiTask 不得直接触碰配置引擎)
- adcTask:**纯消费者**——每拍 `adc_input_poll()`(TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 双缓冲),只取数不做保护运算;**节拍与唤醒同源**(TIM3 ISR 内触发 CONVST + 同一更新事件经 TRGO 触发 ADC1,并调 `adc_tick_irq_notify()` 置位,任务 `osThreadFlagsWait` 等唤醒)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`(内含"消费者就绪"门,TIM3 比调度器早 ≈100 ms 启动);手写于 `Src/freertos.c` 的 `USER CODE` 区,纳入 IWDG `required_mask`(`TASK_WD_ADC`)
- **采集与算法层(L1/L2/L3)落在 `App/algo/`**:`adc_input`/`adc_health` = L1,`sample_ring`/`phasor` = L2,`freq` = L2.5 测频(B8,自适应窗),`prot_set`/`prot_elm` = L3。板端消费者 = **protectTask**(B5,2026-10-12):20 ms 节拍 `prot_elm_run()` + L5-4 采样故障闭锁 + L5-1 定值校验和巡检;跨上下文复归(104 YK11)经 `prot_elm_request_reset()` 落请求。纳入 IWDG `required_mask`(`TASK_WD_PROTECT`,产品 `0x1F7`)。→ `App/algo/README.md`
- **采集与算法层(L1/L2/L3)落在 `App/algo/`**:`adc_input`/`adc_health` = L1,`sample_ring`/`phasor` = L2,`freq` = L2.5 测频(B8,自适应窗),`prot_set`/`prot_elm` = L3。板端消费者 = **protectTask**(B5,2026-10-12):20 ms 节拍 `prot_elm_run()` + L5-4 采样故障闭锁 + L5-1 定值校验和巡检;跨上下文复归(104 YK11)经 `prot_elm_request_reset()` 落请求。纳入 IWDG `required_mask`(`TASK_WD_PROTECT`,产品 `0x1F7`)。→ `docs/算法/采集与算法层设计说明.md`(设计与判据)、`App/algo/README.md`(目录索引)
- 全景 → `docs/架构设计/项目架构总览.md`;启动时序与内核前模式 → `docs/架构设计/项目启动顺序管理.md`
<!-- TASK_TABLE:BEGIN generated by scripts/gen_task_table.py -->
@@ -63,12 +63,12 @@
| protectTask | 512 | Normal |
<!-- TASK_TABLE:END -->
(实际值以 `Src/freertos.c` 为准,本表由 `scripts/gen_task_table.py` 生成、`task-table` 钩子校验;testTask 仅在启用测试套件时创建。FreeRTOS 堆已迁 CCM,任务栈不占 SRAM;**每次抬栈的实测最坏链与余量台账** → `docs/内存管理/RAM使用分析与优化方案.md` §1.4 与 `docs/内存管理/内存内容分配策略.md` §6)
(实际值以 `Src/freertos.c` 为准,本表由 `scripts/gen_task_table.py` 生成、`task-table` 钩子校验;testTask 仅在启用测试套件时创建。FreeRTOS 堆已迁 CCM,任务栈不占 SRAM;**每次抬栈的实测最坏链与余量台账** → `docs/内存管理/RAM使用分析与优化方案.md` §2.3(任务栈配置与实测峰值)与 §4.0.6(静态最坏链与判定)、`docs/内存管理/内存内容分配策略.md` §6(容量台账))
## 铁律:源码编辑与编码
- 改代码**只用 Edit 工具**(精确子串替换 / replaceAll);**绝不用 `Get-Content`/`Set-Content`/`cmd` 重写含中文的文件**——整文件重写按系统默认编码重解码 → `U+FFFD` 乱码且不可逆
- **Edit 会静默剥掉文件原有的 UTF-8 BOM**:带 BOM 的文件(`Drivers/BSP/UI/*`、`App/task/ui_task.c`、`App/cfg/cfg_file.c`、`Lib/lv_conf.h`)被 Edit 后丢 BOM → ARMCC 改按 CP936 解码 → 中文字面量报 `#870-D: invalid multibyte character sequence`(**只有重编译该文件才暴露,历史"0 警告"不代表安全**)。改完须核对 BOM 并按原样补回(只前缀 3 字节 `EF BB BF`,勿整文件重写)
- **Edit 会静默剥掉文件原有的 UTF-8 BOM**:带 BOM 的文件(`Drivers/BSP/UI/*`、`App/task/ui_task.c`、`App/cfg/cfg_file.c`、`Lib/lv_conf.h`)被 Edit 后丢 BOM → ARMCC 改按 CP936 解码 → 中文字面量报 `#870-D: invalid multibyte character sequence`(**只有重编译该文件才暴露,历史"0 警告"不代表安全**)。改完须核对 BOM 并按原样补回(只前缀 3 字节 `EF BB BF`,勿整文件重写)。**已机械化:`bom-guard` 钩子**(`scripts/check_bom_drift.py`:HEAD 有 BOM 而工作区没有即拦;上面这份名单与钩子 `files` 正则、脚本内 `BOM_LIST` **三处互指**,脚本每次跑都核一次,单独改一处会在别人的提交里被拦下)
- **字符串字面量必须纯 ASCII**:无 BOM 文件里奇数个汉字会吞掉结束引号(偶数侥幸通过)→ 中文只写注释
- 怀疑乱码先统计 `EF BF BD` 字节数(勿信终端/Read 渲染);**不要急著 `git checkout`**(会丢弃全部未提交工作)——用 Edit 只修坏行
- 行尾由 `.gitattributes` 写死(`* text=auto eol=lf`;`*.bat`/`*.cmd` 强制 CRLF):**不依赖 `core.autocrlf`、不要把工作区文件改回 CRLF** → 权威口径 `docs/文档规范.md` §2
@@ -76,6 +76,7 @@
## 构建与验证
- **门禁时机(用户裁定 2026-10-13)**:`@build` / `scripts/ci_local.py` / `check_doc_links.py` **只在准备提交时跑**(用户让 commit 后统一跑一遍),**不是每次中间改动后都跑**——多步改动期间直接改即可,验证集中在提交门禁;`@test` 硬件闭环仍按任务需要跑
- `@build` = `& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`(仓库根执行)。退出码 **0=PASS / 1=构建失败 / 2=环境错误(UV4/工程/档案)/ 3=用法 / 4=build 锁被占用**;日志 `MDK-ARM/build_log.txt`;换机必设 `DTU_KEIL_UV4` → 细节 **`keil-build` skill** + `docs/环境与部署/本地工具路径环境变量方案.md`
- `@test` = `& '.venv\Scripts\python.exe' .dsh\skills\serial-boot-test\scripts\run_boot_test.py`:开串口+清日志 → 烧录(**固件身份戳**保证烧进去的就是这份构建、**回读校验**)→ wait-idle → 采日志 → 标记校验(含启动锚点计数)。退出码 **0=PASS / 1=FAIL / 2=采集或烧录错误 / 3=用法**;戳提取规则、锚点计数判据与按模块定制标记见 `docs/测试说明/自动化测试流程.md`
- Forbidden 标记含 `[ERR]`:**四个 Fault 的崩溃日志都以 `[ERR]` 开头 → 出现 Fault 即本次不通过**(刻意)
@@ -85,11 +86,12 @@
## Python 环境
- 统一入口 `& '.venv\Scripts\python.exe'`(3.12,fontTools/Pillow 已装);PATH 上的 `python` 是 msys64 的,**不要用它**跑依赖第三方库的脚本;纯标准库脚本任意 python3 均可
- ⚠ **钩子 entry 的裸 `python` 不等于 .venv**:2026-10-26 真实提交实测 pre-commit 把它解析成**机器级**解释器(无 fontTools)→ 字体链生成器报 `No module named fontTools`、`generated-fresh` 假红(同族第二次发作见 `scripts/ci_local.py` 文件头 2026-10-11「L0 全灭」)。凡依赖第三方库的门禁脚本,一律在脚本内自己按「显式 var > 当前解释器 > 仓库 .venv > PATH」解析并**探针校验**(装了 pre-commit ≠ 装了 fontTools),但**别把 entry 写死成某台机器的绝对路径**(那等于把门禁绑死在一台机器上)→ `docs/门禁说明/generated-fresh.md` §4
- 重建/换机:`<本机任一 python ≥3.10> -m venv .venv` + `pip install -r requirements.txt`(含 `pre-commit`;`DTU_PRECOMMIT_PYTHON` 可指向别的装了 pre-commit 的解释器);`requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 代码页解码)
## 提交门禁(pre-commit)
- 任一失败即拦截,共 **33 个钩子**(逐个详解 → `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` + 主机侧单测 15 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23)+ `mem-budget`(RAM 92% 预算门禁,N-27)
- 任一失败即拦截,共 **50 个钩子**(**口径 = YAML 解析后的 hook 条目数**:自查命令与「声明 / 文档 / 实跑」三数同源核对见 `docs/门禁说明/README.md` §2 首条——⚠ 直接 grep 配置里的声明行会多算一条**注释掉的**候选钩子(= 51),不是口径;加一条钩子就同批改 AGENTS 与门禁索引两处计数。2026-10-25 道E 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三条,2026-10-26 加 `generated-fresh` 一条;**每种门禁一篇详解(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径)→ `docs/门禁说明/README.md` 索引**;通用机制与整体口径 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `id-check`(**编号门禁**,`docs/编号规范.md` §8 八项校验;**已关闸为【增量拦截】**:只拦 `scripts/id_known_backlog.json` 台账之外的新错误级问题,存量按指纹豁免、台账只减不增;另有一根恒生效的线——可判定比例跌破 `id_registry.json` 基线即失败;**2026-10-26 方案 B 同批加了第二根**:不可判定引用**绝对数**超过 `undecidable_total_ceiling` 即失败,因为比值会被"把可判定的引用改成散文"稀释(详见 `docs/门禁说明/id-check.md` §7.6))+ `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + 主机侧单测 **22 根专属钩子**(`-unit-test` 后缀 = 有独立 `files:` 触发面、需要秒级归因的那个子集;2026-10-25 增补 adc-cal / prot-dim / vol-idle-sync / ui-data / meas / run-par 六根)——**这是口径不是缺口**:`test/host/` 实测 40 套带 `run_unit_test.py`,其余 **18 套**(含 `test/host/ui_sink`)没有专属钩子,但每次提交都被 `test-coverage` 总闸全量真跑并守基线回退,**失败照样拦**,只是归因落在总闸输出上;**没有专属钩子 ≠ 没被跑**,18 套台账与补钩子的判据 → `docs/门禁说明/host-unit-tests.md` §6 第 4 条(原写「23 个 / 含 ui-sink 七个」是双重错:既把 22 说成 23,又宣称 ui-sink 有钩子——配置历史里 `ui-sink-unit-test` 从未存在)+ `test-coverage`(**覆盖率总闸**:跑全部 40 套并守基线回退;缺 mscoverage 组件时按用户裁定 **FAIL**、不 skip,2026-10-24)+ `test-suite-switch` + `registration-check`(双端注册反向校验 + UI 页面描述符表接线,登记簿条目「rs485_task 白名单缺口」)+ `generated-fresh`(**生成器幂等性**:把 `tools/menu_gen/gen_menu.py`、中文字库链、`tools/arch_diagram/gen_*.py` 拷进**系统临时目录**重跑,与工作区**逐字节**必须一致——钩子只读、不写仓库也不 stash 别人在飞的改动;实证 `0f7b5a87` 改了模板文字没重跑生成物,接线前实测另有 5 张 `docs/images/` 配图已漂 → 钉成只减不增基线,**业务生成物零豁免面** → `docs/门禁说明/generated-fresh.md`)+ `mem-budget`(RAM 92% 预算门禁,登记簿条目「缩堆迁 CCM 后半(暂缓)」)+ `font-charset`(UI 字符集差集检查,防缺字白框)+ `ui-golden`(UI 像素回归,菜单 8 状态 + 内容页 40 页黄金参考)+ `layer-deps`(**分层方向**:`Drivers/BSP/**` 不得 include `App/**` 的头;存量 3 条基线只减不增(2026-10-25 记录 reader 注入削掉 UI 记录 3 条边,6→3))+ `bsp-err`(**BSP 错误码与超时命名约定**:公开 API 不得拿 `HAL_StatusTypeDef`/`bool` 当错误通道、`*_TIMEOUT` 宏不得取负值、非 void 须写「返回值:」、**返回错误码的函数不得裸丢弃返回值**(须接住或显式 `(void)`;实测编译器与 cppcheck 都不报这类,只有这条会响);约定正文 `Drivers/BSP/bsp_err.h`,存量基线只减不增(2026-10-23 首清点 19 条 → 已还 18;`consume-result` 上线又暴露 37 条存量丢弃 → **现 38 条**))
- **范围 = 项目代码**:第三方(`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/`
@@ -109,7 +111,7 @@
- **`osKernelStart` 之前禁止任何 FreeRTOS/CMSIS-RTOS 创建类 API**(`osMessageQueueNew` 等会杀死启动,2026-09-20 实证根因)→ 内核前标准模式(纯寄存器/HAL 轮询 + **无锁静态缓冲** + 内核启动后**惰性创建**真队列)见 `docs/架构设计/项目启动顺序管理.md`
- **就绪屏障位必须 `osFlagsNoClear`**:`osEventFlagsWait` **默认消费**所等位,就绪位被先消费掉 → `ftpTask` 的 `WaitAll` 永不成立 → **FTP 21 端口静默不监听**(无日志无报错)。命令位(`SYS_EVT_CFG_SAVE`/`NET_RECONFIG`)保持消费型
- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md`
- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):**2026-10-23 起由提交门禁机器拦截**(`coding-standard` 的 CODE-RULE-20,唯一豁免 = 平台分配后端的宿主侧实现,见 `docs/门禁说明/coding-standard.md` §7):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md`
- **内存预算紧张**:新增任何任务/缓冲/栈前先核算 RAM 余量;改栈或加大局部变量**前后各跑一次** `scripts/stack_report.py`(静态最坏链 + 串口 `[STACK]` 水位)与 `scripts/ram_report.py`(.map 静态占用);**总量红线已机械化**:`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,子系统配额 / CCM 台账 / 趋势见 `docs/内存管理/RAM预算台账.md`(**每次 `@build` 后跑 `scripts/ram_baseline.py --update`**)→ `docs/内存管理/`
- FreeRTOS V10.3.1(CMSIS-RTOS V2);HAL 时基 = **TIM7**(非 SysTick),tick = 1ms;NVIC 4 位优先级分组,**外设中断优先级 ≥ 5**
@@ -118,7 +120,7 @@
- **ADC 取数只用 `tpafe5160_snapshot()`(临界区原子快照)**:旧的 `get_buf()`/`data_ready()`/`clear_ready()` 已从驱动**删除**(两步之间被抢占会拷回两帧混合数据;`s_ready` 三写者竞态)。消费者侧一律经 `adc_input_poll()` / `adc_input_raw()` **按语义**取数(不写死下标)→ `docs/子系统设计/ADC采样节拍设计说明.md`、`App/algo/README.md`
- **F4 上 ADC1 的硬件触发源与 DMA 流是硬约束**:① ADC1 的外部触发清单**没有 TIM6/TIM7 TRGO**(F4 上它们只接 DAC)→ 同时驱动两片转换器必须用 **TIM3**(PSC 83 / ARR 999 → 1 kHz + TRGO_UPDATE);② ADC1 在 DMA2 **只挂 Stream0 或 Stream4** → 选 **Stream4** 才不挤走 SPI1/USART1 RX,且**换流会被重置为优先级 0**(违反"外设中断 ≥5")→ 改流后必须回 NVIC 核对 `Src/dma.c`;③ `DMAContinuousRequests` **必须 ENABLE**(关掉 = 一轮 9 通道只发一次请求,静默丢 8 路);④ DMA 目的缓冲必须在 SRAM(DMA2 不可达 CCM)→ `docs/硬件平台/ADC通道映射说明.md`
- **STM32F4 ADC 上电必须自校准**:CubeMX **不生成**这一步,本仓裁剪版 HAL 没有 `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头没有 `ADC_CR2_CAL` → 按 RM0090 §13.3.6 手写 `adc1_calibrate()`(`Src/adc.c` 的 `USER CODE BEGIN 1` 区)+ `MX_ADC1_Init()` 末尾调用;缺它**不报任何错**,只是读数带失调
- **接 ADC / RS-485 消费者前的强制门禁(E-59)**:先确认驱动契约已修(TPAFE E-44/E-45/E-58 ✅;RS485 E-36/E-37 ✅ 但 **E-50 单缓冲包撕裂、E-49 TX 超时拉 DE 未修 ❌** → `rs485_task` 仍被挡)+ 建任务前后各跑一次 `stack_report.py`
- **接 ADC / RS-485 消费者前的强制门禁(RS-485 接线门禁)**:先确认驱动契约已修——TPAFE 三条 ✅(原子快照取数 / 帧序号取代就绪电平标志 / FRSTDATA 数据有效性校验);RS485 已修两条 ✅,但 **单缓冲包撕裂、TX 超时拉 DE 两条未修 ❌** → `rs485_task` 仍被挡。建任务前后各跑一次 `stack_report.py`
**存储与网络外设**
@@ -134,11 +136,11 @@
- **完整引脚表(含 SD2506 I2C1、SWD、调试口)→ `docs/产品需求/产品需求设计文档.md` §4.2**(权威,V0.1 口径;勿在本文重复抄表)
- ⚠ **网名 `AINn` ≠ 板级 `ADCn` ≠ 转换器通道号**(17 路里 13 路置换)→ 取数**必须查** `docs/硬件平台/ADC通道映射说明.md`(TPAFE `buf[2]=IA`/`buf[5]=UN`/`buf[6]=UA`;网名 ADC17=PC4 用 `ADC_CHANNEL_14`,**不是** `ADC_CHANNEL_12`)
- ⚠ **8 路电气量被分给两片转换器是 V0.1 硬件缺陷,不是有意分配**(跨转换器无共同采样触发 → 无同一时刻性、相位不确定)→ V0.1 上**禁止**跨转换器做相位/功率/功率因数/矢量运算;**V0.2 须把 8 路全接 TPAFE**(登记簿 N-18)
- ⚠ **8 路电气量被分给两片转换器是 V0.1 硬件缺陷,不是有意分配**(跨转换器无共同采样触发 → 无同一时刻性、相位不确定)→ V0.1 上**禁止**跨转换器做相位/功率/功率因数/矢量运算;**V0.2 须把 8 路全接 TPAFE**(登记簿条目「双转换器无共同采样触发」)
## 模块约定
- **CH395F 单连接 socket**:监听与数据通道复用同一 Socket(CONNECT 后转数据通道,DISCONNECT/TIMEOUT 后重开 → LISTEN);所有 CH395F 中断在 `net_poll()` 内经 `GET_GLOB_INT_STATUS` 集中读取并清除,**不得**在 ISR 或其他地方单独处理
- **CH395F 单连接 socket**:监听与数据通道复用同一 Socket(CONNECT 后转数据通道,DISCONNECT/TIMEOUT 后重开 → LISTEN);所有 CH395F 中断在 `net_poll()` 内经 `GET_GLOB_INT_STATUS` 集中读取并清除,**不得**在 ISR 或其他地方单独处理。⚠ 全局寄存器**整体读即清、芯片无按位清除手段**,而 net_poll 只在全局位为 1 时才读该 Socket 的 SINT → "顺手全量清一遍"等于把别的在用 Socket 的门焊死(B3 实证)。**现存读清点全量审计 + 新增一处读清的三条准入判据 → `docs/网络/网络自愈架构设计.md` §4.10**
- 所有 `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(模拟器多连接后端)时才关
@@ -150,9 +152,9 @@
## 文档与图形
- **登记簿类文档「完成即销项」**:`docs/任务规划/README.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'`)
- **改名 / 挪目录一律走 `scripts/rename_ref.py`(先 dry-run,确认后 `--apply`)**:`git mv` + 自动改写全库引用(反引号引用 / Markdown 链接 / 图片 `../` 层级换算 / `#include` / `uvprojx` `<FilePath>` / `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
- **登记簿类文档「完成即销项」**:`docs/任务规划/README.md` 这类待办登记簿,事项修复/关闭后**直接从表中删除该行**(不保留"已消化"行)——结论与证据应已沉淀进对应模块文档/问题记录。销项时必须:① 先核对目标文档确已含完整记录 ② 同步清理其他文档对该编号的引用 ③ 改完跑 `check_doc_links.py --all` + pre-commit。**新条目标识(用户裁定 2026-10-14)**:`#` 列填**语义短语**(如 `沉降窗 800ms 上限只留痕`),**不再新增字母+数字编号**——"编号不回收"只写规则却不留痕迹就不可执行(销项删行后仓库里毫无痕迹,2026-10-14 实测就发生过「销项号被重新取走」的撞号事故),语义标识自带唯一性;**存量编号(登记簿 N-x / S-x / T-x 与专项 A-x)已于 2026-10-26 全部转成语义短语**——旧号进 `scripts/id_aliases.json` 标 `retired-to-phrase`,不得再指;**已随销项消失的号永久作废**、不得复用 → `docs/任务规划/README.md` §0
- 图形走 **`draw-diagram` skill**:精确布局图用 `tools/arch_diagram/gen_*.py` 生成器(产物放 `docs/images/`,**脚本与产物同一笔提交**:改生成器没重跑,下一个碰巧重跑的人会看到一段「不是我改的」diff——已机械化 `generated-fresh`,重跑到临时目录逐字节比对 → `docs/文档规范.md` §4.4);拓扑图用 Mermaid;**勿手绘 ASCII 框图**。文件引用关系图走 `tools/refgraph`(`& 'tools\refgraph\gen_refgraph.bat'`)
- **改名 / 挪目录一律走 `scripts/rename_ref.py`(先 dry-run,确认后 `--apply`)**:`git mv` + 自动改写全库引用(反引号引用 / Markdown 链接 / 图片 `../` 层级换算 / `#include` / `uvprojx` `<FilePath>` / `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。**编号改名走 `scripts/rename_ids.py`**(映射表驱动、**显式 scope**、干跑默认、自动写 `scripts/id_aliases.json` 的 scoped 旧号→新号条目;`[示例]`/`[计划]` 行与变更记录表不改写;**绝不全局查找替换**——同一裸形字面量可能同时属多个族,全局替换等于造新撞号,实测 `S3` 三义)
- 文档写作规范(编码/BOM/链接/表格/一致性钩子)→ `docs/文档规范.md`;全量索引与推荐阅读顺序 → `docs/README.md`
## 边界(Ask first / Never)
@@ -172,7 +174,9 @@
| ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` |
| 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` |
| RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` |
| pre-commit 33 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` |
| **每个提交门禁一篇详解**(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径) | `docs/门禁说明/README.md`(50 个钩子 → 26 篇索引 + 1 指针) |
| **要不要给某个东西发编号**(默认:不发,写语义短语)/ 允许用号的 8 个族 / `id-check` 八项校验 | `docs/编号规范.md`(§1 先问三句、§4.1 族总表〔穷举〕、§8 门禁与实测基线) |
| 提交门禁通用机制(阶段 / stash 陷阱 / 触发面 / 增量拦截三段式) | `docs/代码规范/pre-commit使用说明.md` §2 |
| 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` |
| 自动化测试 / 实机测试 / 板级套件 | `docs/测试说明/自动化测试流程.md`、`实机测试实施指南.md`、`board-test-suite` skill |
| 烧录与复位语义 | `docs/测试说明/ST-LINK_CLI_复位使用说明.md` |
@@ -181,4 +185,4 @@
| 待办与审计发现登记簿 | `docs/任务规划/README.md` |
| 多任务并行开发(开空间 / 合并回收 / 台架与构建排队) | **`task-worktree` skill**(`@task`,用到自动加载)、`docs/环境与部署/并行任务工作树方案.md` |
| 换机 / 工具路径环境变量 | `docs/环境与部署/` |
| 远程 CI(Gitea Actions,**只跑本机一台 runner**) | [`docs/CI/README.md`](docs/CI/README.md) |
| 远程 CI(Gitea Actions,**只跑本机一台 runner**) | **`remote-ci-ops` skill**(排障顺序与判据,用到自动加载)、[`docs/CI/README.md`](docs/CI/README.md) |
+2 -2
View File
@@ -13,10 +13,10 @@ FreeRTOS/CMSIS-RTOS API**(见 `AGENTS.md`「平台约束」)。
| 目录 | 内容 | README |
|---|---|---|
| `app/` | **应用入口与全局**:`app_main.c/h`(初始化入口)、`app_cfg.h`(编译开关)、`sys_events.h`(就绪事件) | [`app/README.md`](app/README.md) |
| `app/` | **应用入口与全局**:`app_main.c/h`(初始化入口)、`app_cfg.h`(裁剪决策占位说明,当前无宏)、`sys_events.h`(就绪事件) | [`app/README.md`](app/README.md) |
| `cfg/` | **配置引擎**:`/config.ini` 解析 + 原子持久化(旧二进制档已废弃,2026-10-10) | [`cfg/README.md`](cfg/README.md) |
| `sync/` | **时间同步任务**:`time_sync.c/h` | [`sync/README.md`](sync/README.md) |
| `algo/` | **采集与算法层(L1/L2)**:`adc_input.c/h`(语义通道映射 + STM32 ADC1 扫描) | [`algo/README.md`](algo/README.md) |
| `algo/` | **采集与算法层(L1/L2/L3)**:语义取数 + 有效性判定 + 采样环/相量/测频 + 定值模型 + 保护元件框架 | [`algo/README.md`](algo/README.md)(索引);设计 → `docs/算法/采集与算法层设计说明.md` |
| `log/` | **日志子系统**:syslog(NAND)+ 控制台单写者日志 | [`log/README.md`](log/README.md) |
| `task/` | **FreeRTOS 任务实现**(6 个任务) | [`task/README.md`](task/README.md) |
| `util/` | **通用工具**:CRC / 环形缓冲 / 墙钟时间 | [`util/README.md`](util/README.md) |
+150 -335
View File
@@ -1,360 +1,175 @@
# App/algo/ — 采集与算法层(L1 / L2)
# App/algo/ — 采集与算法层(L1 / L2 / L3)
> **自研代码**,属 `docs/算法/继电保护功能复现规划.md` §2.1 分层中的 **L1(采集与预处理)**
> 与 **L2(相量计算)**。原则:**算法与 I/O、与调度分离**——本层不建任务、不做保护判据、
> 不碰寄存器(唯一例外见 §3.1),只做"数"(原始码值 → 语义通道)与"相量"
> (采样序列 → 复相量),因而可被主机侧单元测试(L0.5)离线全覆盖(落实登记簿 **A-4**)。
>
> 任务侧(`App/task/adc_task.c`)只做**消费与编排**;任何新算法一律落在本目录,勿塞进 `App/task/`。
> 本目录的**索引**:文件清单、依赖、逐文件的"改代码必须知道"、构建与测试入口、新增模块动作。
> **详细设计——机制、契约、判据、为什么这样设计 → `docs/算法/采集与算法层设计说明.md`**(本文不复制其内容)。
> 分层归属与功能项状态 → `docs/算法/继电保护功能复现规划.md` §2.1;
> 通道映射唯一权威 → `docs/硬件平台/ADC通道映射说明.md`;采样节拍 → `docs/子系统设计/ADC采样节拍设计说明.md`。
## 1. 文件清单(一眼版)
| 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 |
|---|---|---|---|---|
| `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓双缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 |
| `adc_health.h` / `adc_health.c` | L1 | **采样有效性判定层**:吃"计数快照"(帧序号/错误计数),产出静默/丢帧/单调性/错误增量故障位——判定与告警编排分离 | `adc_health_init()` `adc_health_check()` `adc_health_flags()` | L1-6 |
| `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) |
| `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 |
| `prot_set.h` / `prot_set.c` | L3 输入 | **保护定值模型**:Q10 定点 RAM 表(通讯地址对照参考 `g_tCommSetParaTab`)+ 范围校验 + 校验和封存自检闭锁 | `prot_set_init()` `prot_set_load_raw()` `prot_set_get_f()` `prot_set_selfcheck()` | §5 定值模型 / L5-1 |
| `prot_elm.h` / `prot_elm.c` | L3 | **保护元件框架**:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压 / **过频 / 低频**) | `prot_elm_init()` `prot_elm_eval()` `prot_elm_run()` `prot_elm_active_mask()` | L3-1 / L3-3 / L3-4 / L3-5 / L3-6(软件侧) |
| `freq.h` / `freq.c` | L2 派生量 | **频率测量**:相邻两拍基波相量共轭乘积取角(每拍相位增量)+ 自适应窗长滑动平均 + 假值钳制 + 幅值门(UA→IA 源回退) | `freq_init()` `freq_run()` `freq_get()` `freq_valid()` | L3-5 测量侧(`docs/算法/继电保护功能复现规划.md` §4.2 测频范围) |
| `adc_input.h` / `.c` | L1 | 统一语义取数:两片转换器的码值按语义(`UA…IN` / 温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 |
| `adc_health.h` / `.c` | L1 | 采样有效性判定:吃计数快照,产出静默 / 丢帧 / 单调性 / 错误增量故障位(判定与告警编排分离);L5-2 残余的码值贴轨观察器同层 | `adc_health_check()` `adc_health_flags()` `adc_health_rail_watch()` | L1-6 / L5-2(残余) |
| `adc_cal.h` / `.c` | L1.5 | 通道标定折算(「数据可信」卡 2/卡 4):工程量 =(code+b)×k,Q16 系数/零点两段落盘 + 三态 flags + 合法带哨兵 + 两点公式 | `adc_cal_apply()` `adc_cal_flags()` `adc_cal_set()` | L1-5(M4 半边) |
| `meas.h` / `.c` | L2 发布 | 遥测快照(「数据可信」卡 8,M5 地基):200 ms 基波有效值 + 品质快照,双区无锁读,全仓唯一发布者 = adcTask | `meas_tick()` `meas_get()` `meas_seq()` | M5(卡 9/11 与 UI 是读者) |
| `sample_ring.h` / `.c` | L2 输入 | 按语义通道存最近 2 周波(40 点),供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 供数) |
| `phasor.h` / `.c` | L2 | 全周傅氏:一周波 20 点 DFT → 基波 / 谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 |
| `freq.h` / `.c` | L2 派生量 | 频率测量:相邻两拍基波相量共轭乘积取角 + 自适应窗滑动平均 + 假值钳制 + 幅值门(UA→IA 源回退) | `freq_run()` `freq_get()` `freq_valid()` | L3-5 测量侧(测频范围 → `docs/算法/继电保护功能复现规划.md` §4.2) |
| `prot_set.h` / `.c` | L3 输入 | 保护定值模型:Q10 定点 RAM 表 + 范围校验 + 校验和封存 / 自检 / 闭锁 + 持久化 blob 纯逻辑 | `prot_set_load_raw()` `prot_set_get_f()` `prot_set_selfcheck()` | L5-1 载体 |
| `prot_elm.h` / `.c` | L3 | 保护元件框架:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压 / 过频 / 低频) | `prot_elm_run()` `prot_elm_eval()` `prot_elm_active_mask()` | L3-1 / L3-3 / L3-4 / L3-5 / L3-6(软件侧) |
依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor` ← `freq`;
`prot_set` **独立**(仅 `stdint.h`);`prot_elm` ← `prot_set` + `phasor` + `freq`。
`sample_ring.c` / `phasor.c` / `adc_health.c` / `prot_set.c` / `prot_elm.c` / `freq.c` **零平台依赖**
(只用 `math.h` / `stddef.h` / `stdint.h` 与本目录头);
`adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。
依赖(自下而上、无环):`adc_input.h`(纯枚举 + 声明)← `adc_cal`(另依赖 `cfg_file.h` 接口,
纯头文件级)← `sample_ring` ← `phasor` ← `freq`;`meas` ← `sample_ring` + `phasor`
(仅 `phasor_calc` 纯核;频率/品质由调用方注入,**不链** `freq`/`adc_cal`);
`prot_set` **独立**;`prot_elm` ← `prot_set` + `phasor` + `freq`。
`adc_health` 只吃调用方喂进来的计数 / 码值,不反向依赖 L1 实现(L5-2 贴轨门限由调用方
从 `adc_cal_rail_ths()` 取,本层仍零算法依赖)。
## 2. 数据流(一拍 = TIM3 1 kHz 节拍)
**除 `adc_input.c` 外全部零平台依赖**(只用 `stdint.h` / `math.h` / `stddef.h` 与本目录头)
→ 可被主机单测 `#include` 原样纳入、免桩跑。`adc_input.c` 另依赖 `adc.h`(`hadc1`)与
`tpafe5160.h`(快照),主机侧以桩头替身。
```mermaid
flowchart TD
TIM3["TIM3 更新事件 1 kHz(PSC 83 / ARR 999)<br/>ISR: tpafe5160_start_conv_irq() + adc_tick_irq_notify()<br/>同一事件经 TRGO 触发 ADC1"] --> TPAFE["TPAFE5160 转换<br/>BUSY EXTI 收帧"]
TIM3 --> ADC["STM32 ADC1 一轮 9 通道(≈67 us)<br/>DMA2_Stream4 循环模式自动搬运"]
ADC --> PP["乒乓双缓冲 s_stm32_dma[2×9]<br/>半传输 / 传输完成回调各推进 seq"]
TIM3 -. "ADC_WAKE_FLAG<br/>osThreadFlagsWait" .-> TASK
TPAFE --> POLL
PP --> POLL
TASK["adcTask(事件驱动唤醒,非定时轮询)<br/>App/task/adc_task.c"] --> POLL["adc_input_poll()<br/>TPAFE 快照 + 消费刚完成的那半区(零忙等)"]
POLL --> RAW["adc_input_raw(ch) × 9<br/>按语义取原始码值"]
RAW --> RING["sample_ring_push_frame()<br/>整帧入环(40 点窗)"]
RING --> RUN["phasor_run()<br/>逐实例 20 点重算 + 双区切换"]
RUN --> FREQ["freq_run()<br/>共轭乘积相位增量 + 自适应窗均值(B8)"]
RUN --> GET["phasor_get(idx)"]
FREQ --> FGET["freq_get() / freq_valid()"]
GET --> CONS["消费者:adcTask 日志(现状)<br/>UI / 自检 / L3 保护任务(待接)"]
FGET --> PCONS["消费者:prot_elm_run() OF/UF 判据(B8)<br/>adcTask 心跳 freq 行"]
```
## 2. 一拍的数据流(一眼版)
初始化落点:`adc_input_init()` 与 `phasor_init()`、`freq_init()` 由 `App/app/app_main.c` 的
`app_main_init()` 在**内核启动前**调用(各 `*_init()` 均无 RTOS 依赖、幂等);
`phasor_init()` 内部会连带 `sample_ring_init()`,等价于"刚上电"。
`adc_input_poll()` → `adc_input_raw()` ×9 → `adc_cal_apply()` ×9(L1.5 折算,现口径 =
ADC 引脚电压 V)+ `adc_health_rail_watch()` ×9(L5-2,码值域)→ `sample_ring_push_frame()`
→ `phasor_run()` → `freq_run()` → 消费者(`adcTask` 心跳与 L1 健康判定;`protectTask` 每 20 ms 取相量 / 频率跑
`prot_elm_run()`;**每 200 ms** `meas_tick()` 发布遥测快照供 iec104Task / uiTask 读,卡 8)。节拍 = TIM3 1 kHz 更新事件,两片转换器同源触发。
完整数据流图与初始化落点 → `docs/算法/采集与算法层设计说明.md` §2。
## 3. 逐文件说明
## 3. 逐文件要点(只留"改代码时必须知道"的;机制与理由见设计文档)
### 3.1 `adc_input.c` / `adc_input.h` — L1 ADC 输入层
> **改本目录前先记五条红线**:① 取数只经 `adc_input_raw()`,不写死下标;② **禁止跨转换器**做
> 相位 / 功率 / 矢量运算(双转换器无共同采样触发);③ 帧值已是 L1.5 工程量,但 **未标定(`adc_cal_flags()` 无
> VALID)时单位只是 ADC 引脚电压**,不是端子二次值——下游消费精度判据见「数据可信」§4.2 三态
> 表;④ 工程量 → 帧的唯一折算点是 `adc_cal_apply()`,禁止旁路再乘系数;⑤ 本层不建任务、
> 不等事件、不写日志(`adc_cal` 的 `[ERR]` 哨兵留痕是唯一例外,且每通道每开机一次),
> `*_init()` 才可在内核前调。
> 全文 → `docs/算法/采集与算法层设计说明.md` §10。
**解决的问题**:V0.1 上 8 路电气量被两片转换器分片(硬件缺陷 **N-18**),且网名 `AINn`
≠ 板级 `ADCn` ≠ 转换器通道号(13/17 路置换)。若消费侧直接索引驱动缓冲,改版即全线错位。
本层把"哪一路"收进一张表,上层**按语义取数、不写死下标**。
### 3.1 `adc_input.c` / `adc_input.h` — L1 语义取数
- 语义映射表 `k_adc_map[]` 的**内容**唯一权威是 `docs/硬件平台/ADC通道映射说明.md`;V0.2 改版只改表内容,API 不动。
- `adc_input_poll()` **仅任务上下文**(TPAFE 快照是临界区);STM32 侧固定滞后 1 拍。
- 四类 STM32 计数(`adc1_seq` / `adc1_gap` / `adc1_err` / `adc1_hw`)成因不同,**勿混读**。
- 本目录**唯一**允许出现 HAL 调用的文件。→ `docs/算法/采集与算法层设计说明.md` §3。
| API | 干什么 | 注意 |
### 3.2 `sample_ring.c` / `.h` — L2 输入采样环
- 整帧写入后才推进写指针 → 取到的窗永远落在整帧边界。
- 本拍取数失败 → **整拍跳过入环**(`adc_task.c` 的 `skip`),**绝不补 0 点**。
- 不做跨通道运算、不做丢帧判定(只出 `frames()` 计数)。→ `docs/算法/采集与算法层设计说明.md` §5。
### 3.3 `phasor.c` / `.h` — L2 相量计算
- 系数表预乘 √2/N → `fAmSQ` **直接是有效值**(口径 `docs/算法/傅氏算法详解.md` §2.3)。
- 实例表 `k_inst[]` 数据驱动:**新增实例只在表尾加一行**。
- 任一实例不足一周波 → **本拍整体不产出**(不切使用区);每拍 20 点**全部重算**(不做增量递推)。
- → `docs/算法/采集与算法层设计说明.md` §6。
### 3.4 `adc_health.c` / `.h` — L1 有效性判定
- 三类位**严格分开**:电平(可自愈)/ 锁存(必留痕)/ 边沿事件(仅当拍);**静默 ≠ 丢帧**。
- **gap 不告警**(消费者超拍是已知现象);告警编排在 `adc_task.c`,不在本层。
- 首拍 `check()` 只建基线。→ `docs/算法/采集与算法层设计说明.md` §4。
### 3.5 `prot_set.c` / `.h` — 保护定值模型(L5-1 载体)
- 装载 **all-or-nothing**:任一项越界整表不装载,越界项由 `prot_set_bad_mask()` 指认。
- 批量 `prot_set_set()` 后**必须** `prot_set_seal()`;单项原子写用 `prot_set_set_sealed()`。
- 定值项 / 实例**只许尾部追加**(枚举序 = 数组序 = 校验和覆盖序)。
- blob **换代(返回 2)≠ 损坏(返回负)**:前者打 INFO、不记自检。→ `docs/算法/采集与算法层设计说明.md` §8、§12.3。
### 3.6 `prot_elm.c` / `.h` — L3 保护元件框架
- 数据缺失两种处理**刻意不同**:相量类任一未就绪 → 整拍跳过;频率类无有效读数 → 按方向填"必不启动"值,**不整拍跳过**。
- `prot_set_locked()` 或投退 = 0 → 元件整体复位不判据;跨上下文复归走 `prot_elm_request_reset()`。
- 零序过流(IN 走 STM32)**故意不在实例表**;OF / UF 无独立 104 遥信点,只经 YX15 总聚合可见。
- `prot_elm_init()` 须在 `phasor_init()` 之后。→ `docs/算法/采集与算法层设计说明.md` §9。
### 3.7 `freq.c` / `.h` — 频率测量(L3-5 的测量输入)
- `freq_run()` **仅 adcTask 上下文**,须在 `phasor_run()` 之后同拍调;保护口径 =
`freq_get() == 0 && freq_valid() == 1`。
- 精度靠**自适应窗长**(窗长 = 泄漏纹波周期的整数倍);定长平均压不到 0.02 Hz,
换窗长 = 换精度,二者不是独立旋钮。
- 源切换即废相位基准重建(UA / IA 无共同相位基准,即双转换器无共同采样触发)。
- 判据 **J1~J6** 与实测口径 → `docs/算法/采集与算法层设计说明.md` §7.2(与 `test/host/freq/freq_test.c` 文件头同源)。
### 3.8 `adc_cal.c` / `.h` — L1.5 通道标定折算(「数据可信」卡 2 / 卡 4)
- 折算模型:工程量 = (code + b) × k;**k 落盘用 int32 Q16(µV/码 × 65536)**、b 用
int32 码值(负零点靠 `CFG_VAL_INT`)——单位与定点格式的选型依据见
`docs/任务规划/数据可信.md` §4.1,勿改回 V/码 直存(Q16 只剩 20 个刻度)。
- **ROM 默认 = 卡 1 定死的引脚侧 LSB**(TPAFE 152.59 / STM32 805.66 µV/码,
`docs/硬件平台/ADC通道映射说明.md` §7.1);表下标 = 语义枚举(UA0…IN7),
V0.2 改版与本目录 `adc_input` 映射表同步改。
- 三态可用性 `adc_cal_flags()`:VALID(已按变比标定,二次值成立——卡 0b/12 前恒 0)
/ CLIPPED(运行期按来源派生,V0.2 后自动清零)。**消费者判「能不能信」只许查它**,
`adc_cal_apply()` 不返回可用性(「数据可信」§8 风险表)。
- 合法带哨兵:k ∉ ROM×[0.5,2.0] 或 |b| > 8192 → 折算回退 ROM + 每通道每开机一条
`[ERR]` + VALID 被压制(**即便文件位图说已标定**);这层兜底同时挡坏文件与坏标定。
- `adc_cal_set()` / `adc_cal_two_point()`:整定与两点公式的算术核已齐备(单测还原合成
k/b);**台架两点标定(卡 12)待实测**,本层不含注入。
- cfg 两段 `[adc_cal]` / `[adc_cal_ofs]` **不挂回调**(RAM 副本即生效值,引擎 load/poll
原地改写,逐点现读);注册在 `app_main_init()` 内核前完成,必须先于 `cfg_file_load`。
### 3.9 `meas.c` / `.h` — 遥测快照发布(L2 发布侧,「数据可信」卡 8)
- **唯一发布者 = adcTask**:`phasor_run()`/`freq_run()` 同拍、200 ms **tick 经过**分频
调 `meas_tick()`(非拍计数——idle/skip 拍拉长名义节拍)。docs/任务规划/数据可信.md §8
风险表"快照双写"行 = 评审判据:全仓 `meas_tick` 调用点数须为 1;iec104Task /
uiTask 只读。
- 值口径(批 2 卡 8 A-lite 裁决):**基波有效值瞬时透传**,与 phasor 使用区
**同拍同核逐位相等**(单测钉死);**不做** 4 点平均 / 谐波方和根——按
docs/任务规划/数据可信.md §4.5"上送节拍非平均窗,再平均引入滞后";谐波 k-bin 轨
(docs/算法/继电保护功能复现规划.md L2-8)扩展只许结构尾部追加,
docs/任务规划/数据可信.md §4.5 字段序 = 协议(布局由单测按偏移钉死,72 B × 双区)。
- 并发 = phasor 双区 + 单字节索引翻转(L2-7 同型,无锁);seq 只增供消费者判换帧。
- 品质注入:削顶位图唯一源 = `adc_cal_flags()`(口径 docs/任务规划/数据可信.md §4.2
三态表;由 adc_task 编排注入,本层不链 `adc_cal`);TEMP = 最新一拍原始值透传 +
`NT`(「数据可信」决策 5);`MEAS_QDS_*` 低字节与 104 QDS 线上字节同值
(本层**不** include 协议层头——分层方向)。
- 104 死区迁移 / 频率点绑定 = 卡 9/卡 11(道D 后续分支);读数与注入源比对 = **待台架实测**。
## 4. 构建与测试
### 4.1 挂载与模拟器边界
- 九个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`;`../App/algo` 已在 `<IncludePath>` 内。
- 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路):九个文件全部登记在
`scripts/check_registration.py` 的 `SIM_EXEMPT`(附理由),`registration-check` 钩子反向校验(登记簿条目「rs485_task 白名单缺口」)。
### 4.2 主机侧单测(L0.5)——生产源文件以 `#include` 原样纳入
| 模块 | 命令 | pre-commit 钩子 |
|---|---|---|
| `adc_input_init()` | 清缓冲与统计 + **映射表自检**(每个 STM32 语义通道必须落在扫描 Rank 序列内)+ `HAL_ADC_Start_DMA()` 挂载乒乓缓冲(**只挂一次**) | 内核启动前调用;`-1` = 映射指向了从不扫描的通道(会恒读陈旧值);`-2` = DMA 挂载失败 |
| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ **消费刚完成的那半区**(`stm32_take()`,按 `seq` 奇偶选半区,零忙等) | **仅任务上下文**(快照走临界区);只搬码值,不做标定/运算;STM32 侧固定滞后 1 拍 |
| `adc_input_raw(ch, &out)` | 按语义读原始码值(TPAFE 16 位补码 / STM32 0..4095) | 消费侧取数**唯一入口**;须先成功 `poll()`;`-2` = 该来源尚无有效数据 |
| `adc_input_desc(ch)` | 语义 → 物理通道描述(来源 + 下标/通道号) | 供自检注入、UI 通道序;返回静态地址,勿改 |
| `adc_input_stm32_rc()` | STM32 侧最近一拍状态码(0 OK / -2 本拍无新帧) | 心跳日志 `adc1_rc` |
| `adc_input_stm32_seq()` / `_errs()` / `_gaps()` / `_hw_errs()` | DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧(seq 一次前进 >1)/ HAL 错误回调 | 心跳日志 `adc1_seq` / `adc1_err` / `adc1_gap` / `adc1_hw`——四类成因不同,**勿混读** |
| 相量 / 采样环 | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` |
| ADC 输入层 | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` |
| 有效性校验 | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` |
| 定值模型 | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` |
| 元件框架 | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` |
| 频率测量 | `& '.venv\Scripts\python.exe' test/host/freq/run_unit_test.py` | `freq-unit-test` |
| 通道标定(含真实 cfg_file 引擎集成) | `& '.venv\Scripts\python.exe' test/host/adc_cal/run_unit_test.py` | `adc-cal-unit-test` |
| 遥测快照(布局钉死/双区无撕裂/品质汇总) | `& '.venv\Scripts\python.exe' test/host/meas/run_unit_test.py` | `meas-unit-test` |
**要点**
改本目录任一 `.c/.h`,提交时对应钩子自动跑。**用例清单与判据数字的属主**是各
`test/host/<模块>/` 与 `docs/测试说明/` 下的单测说明,本文不复制(三处抄数字必然打架)。
- 语义映射表 `k_adc_map[]` 的**唯一权威口径**是 `docs/硬件平台/ADC通道映射说明.md`:
`UA/UN/IA` → TPAFE `buf[6]/[5]/[2]`;`UB/UC/IB/IC/IN/TEMP` → ADC1 `CH14/3/6/1/2/5`。
**V0.2 改版只改这张表的内容**,本层 API 与上层语义不动。
- STM32 侧是**顺序扫描**(Rank1..8 = CH0..CH7、Rank9 = CH14,与 `MX_ADC1_Init()` 严格一致),
9 个通道**不是同一时刻**(一轮 ≈67 µs)。v2.0 起这一轮由 **TIM3 TRGO 硬件触发 + DMA 自动搬运**,
消费侧**零忙等**(旧实现逐通道轮询、单通道超时 2 ms,已于 2026-10-10 移除)。
代价:STM32 侧数据**固定滞后 1 拍(1 ms)**——与 TPAFE 侧本来就没有同刻性(N-18),无新增误差。
- 本文件是本层**唯一**允许出现 HAL 调用的地方(它必须驱动 ADC1);新增模块若不需要外设,
请保持 `sample_ring` / `phasor` 那种零依赖形态,才能免桩单测。
### 4.3 板端
`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍 / 心跳(`phasor(...)`、`freq(...)`、
`cal: ua_mv=… vl=0x00 cl=0xE6 rail=…`、`ring` / `skip` / `adc1_*` / `tgap`);
健康判据 = **零 `[ERR]`**(健康告警与标定哨兵全以 `[ERR]` 开头,误报会被闭环直接抓住;
`vl` 在未标定默认构建必须为 0x00,卡 5 闭锁落地后 `cal:` 行就是台架期望的直接证据)。
→ `docs/算法/采集与算法层设计说明.md` §12。
### 3.2 `sample_ring.c` / `sample_ring.h` — L2 输入采样环
**解决的问题**:相量计算要"以当前时刻为终止的一周波",而 L1 每次只给最新一帧。
本层按**语义通道**存最近 `SAMPLE_RING_CYCLES = 2` 个周波(`SAMPLE_RING_DEPTH = 40` 点),
与参考系统 `CN_BUFSAM_CYCLE = 2` 一致,并为后续加长数据窗(1.5~2 周波抗衰减直流)预留空间。
| API | 干什么 | 返回 |
|---|---|---|
| `sample_ring_init()` | 清全部通道历史与计数(幂等,内核启动前可调) | — |
| `sample_ring_push_frame(values, n)` | 整帧压入(长度须 `= ADC_SEM_COUNT`) | `0` / `-1` 参数错 |
| `sample_ring_window(ch, n, out)` | 取该语义通道最近 `n` 点,**按时间序旧 → 新**(`out[n-1]` 最新) | `0` / `-1` 参数错 / `-2` 点数不足 |
| `sample_ring_filled()` | 已累积点数(**饱和**于 40)——判"够不够一周波" | `0..40` |
| `sample_ring_frames()` | 累计入环帧数(**不饱和**,1 kHz 下约 49.7 天回绕) | 帧数 |
**要点**
- **整帧写入后统一推进写指针** → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。
- 本层**只做按时序存/取,不做任何跨通道运算**:同刻性由上游 L1 负责,本层既不保证也不假设。
- `frames()` 与 L1 的 `tpafe5160_frame_seq()` 对照即可判丢帧(L1-6 的判据之一);
本层只提供计数,**判定不在这里**。
- 上层若某通道本拍取数失败,应**整拍跳过入环**(现 `adc_task` 的 `s_frames_skip` 即此),
切勿补 0 点——0 会污染相量窗。
### 3.3 `phasor.c` / `phasor.h` — L2 相量计算
**算法**:全周傅氏(单频 DFT),`N = PHASOR_N = 20`(1 kHz @ 50 Hz)。系数表
`k_fft_sin/k_fft_cos` **离线生成**并**预乘 √2/N**(口径 `docs/算法/傅氏算法详解.md` §2.3),
故 `fAmSQ = √(fRe²+fIm²)` **直接就是有效值**,`fAm = √2·fAmSQ` 为峰值。运行期不调 `sinf/cosf`。
| API | 干什么 | 注意 |
|---|---|---|
| `phasor_init()` | 双区清零 + 使用区/就绪复位 + `sample_ring_init()` | 幂等;内核启动前调用 |
| `phasor_inst_count()` / `phasor_inst(idx)` | 遍历实例表(`{name, sem, harm}`) | 返回静态地址,勿改;越界 `NULL` |
| `phasor_run()` | 逐实例取 20 点窗 → 重算 → 写计算区 → **一次性切换使用区** | 仅任务上下文;须先 `push_frame()` |
| `phasor_get(idx, &out)` | 读**使用区**结果 | `0` / `-1` 参数错 / `-2` 未攒满一周波 |
| `phasor_calc(x, n, harm, &out)` | 对给定窗口算 `harm` 次相量 | **纯函数**,可脱离采样环独立单测 |
**要点**
- **实例表数据驱动**(`k_inst[]`,现 5 行):`C01-IA-F1`(过流/过负荷)、`C02-UA-F1`(过压/欠压/频率)、
`C03-UN-F1`(零序过压)、`C04-IA-F2`(二次谐波制动)、`C05-IA-F5`(五次谐波选线)。
**新增实例只在表尾加一行**,遍历/日志/单测自动适配。
- **双区(计算区 / 使用区)**:结果先写非使用区,全部实例写完后单字节 `volatile` store 切换
`s_use_zone`(Cortex-M 上原子)→ 消费侧无锁、无撕裂。今日唯一消费者是 adcTask 自身,
该机制为后续独立 L3 保护任务预留。
- **任一实例数据不足一周波 → 本拍整体不产出**(不切换使用区),避免"部分新部分旧"的混合结果。
- 每拍 20 点**全部重算**(暴力法,无累积误差;迭代加减首尾项会累积)。相量刷新周期 = 1 ms。
- 相位(L2-4)**未常驻**结构体,需要时自行 `atan2f(fIm, fRe)`。
### 3.4 `adc_health.c` / `adc_health.h` — L1-6 采样有效性判定
**解决的问题**:驱动侧只有"观测计数"(`tpafe5160_frame_seq/_frstdata_err`、
`adc_input_stm32_seq/_errs/_gaps/_hw_errs`),**没有判定**——芯片静默、消费者超拍丢帧、
计数器被改写这些故障此前只能靠人读心跳日志发现。本层把判定收进纯逻辑,
告警编排留在消费者(`adc_task.c` 的 `adc_health_report()`)→ 判定可主机免桩单测。
| API | 干什么 | 注意 |
|---|---|---|
| `adc_health_init()` | 清全部状态(幂等) | 首拍 `check()` 只建基线、不判定 |
| `adc_health_check(&in, &out)` | 吃计数快照做一次判定(每拍调,常数次整数运算) | 差值法 `(int32_t)(now-prev)`,tick/计数回绕安全 |
| `adc_health_flags()` | 电平故障位:`TPAFE_STALL` / `STM32_STALL`(≥100 ms 无前进,可自愈)/ `SEQ_BACKWARD`(锁存) | 为 L5-4 闭锁预留 |
| `adc_health_sticky()` | 锁存位(含 `FRSTDATA` / `STM32_ERR` / `HW_ERR` 增量痕迹) | 自愈后仍留痕 |
| `adc_health_tpafe_gaps()` | TPAFE 累计丢帧点数(序号前进 >1 的超出部分) | 与 STM32 侧 `adc_input_stm32_gaps()` 对称 |
**要点**
- **三类语义严格分开**:电平位(stall,可自愈)/ 锁存位(出现过必留痕)/ 边沿事件位
(仅当拍有效)。`ADC_HE_FRSTDATA/STM32_ERR/HW_ERR` 与同名 `ADC_HF_*` **编号对齐**,
消费者直接用事件掩码对"已留痕位图"做每类一次 `SYSLOG_SELFTEST` 的编排。
- **gap 不告警**:消费者超拍(心跳日志 ~2 ms)是已知现象,`adc1_gap`/`tgap` 每 10 s 心跳
可见;若按事件告警会把真故障淹掉(台架实测开机 `adc1_gap≈24`、稳态每 10 s +2)。
- **静默 ≠ 丢帧**:d=0 才是静默候选;d>1 只说明消费者没跟上,生产者还活着——两者
处置完全不同,判据不许混。
- 告警策略(在 `adc_task.c`,不在本层):严重事件/电平上升沿 `DBG_ERROR` +
`SYSLOG_SELFTEST`(每类每次开机一次,防刷屏写满 32KB selftest.log);下降沿 `DBG_INFO`。
### 3.5 `prot_set.c` / `prot_set.h` — 保护定值模型(P1 / L5-1 载体)
**解决的问题**:保护判据的"输入宪法"。参考系统把定值放 EEPROM + `tagPRS` 常量表
校验和自检;本层做 **RAM 模型 + 持久化纯逻辑层**(blob 序列化/解析;文件胶水在
`App/cfg/prot_set_store.c`,B7),把编号 / 通讯地址 / 范围 / 默认值 / 校验和闭环钉死。
| API | 干什么 | 注意 |
|---|---|---|
| `prot_set_init()` | 装默认值 + 封存校验和(幂等) | 内核启动前可调 |
| `prot_set_load_raw(vals)` | 整表装载(B7 由 `prot_set_store_parse` 内部走同一路径),逐项范围校验 | **all-or-nothing**:任一项越界整表不装载,`prot_set_bad_mask()` 指认越界项;影子区 + 单字交换,读侧无中间态 |
| `prot_set_set(id, q10)` | 单项写入(范围校验,批处理路径) | **不自动重封存**——批量改完必须 `prot_set_seal()`,否则 selfcheck 判"非法改写";板端单项写一律用下行 |
| `prot_set_set_sealed(id, q10)` | **原子写+封存(B7)**:影子区改好重封后单字交换 | selfcheck 永不见中间态(收口 B5 竞态窗口);置脏标志 |
| `prot_set_dirty_peek/take/mark()` | 脏标志三件套(B7 落盘握手) | peek=iec104Task 通知;take=属主落盘前消费;mark=写失败回置重试 |
| `prot_set_store_serialize/parse(buf,len)` | 持久化 blob(`PSET`+版本+值区+sum32 正/反码对) | parse **整表原子**:magic/版本/长度/校验对/范围任一失败 = 一字不动 |
| `prot_set_get_f()` / `get_ctl()` | 工程量浮点 / 投退读取 | 单字读原子 |
| `prot_set_selfcheck()` | 重算 sum32 与封存正/反码比对,失配 → 闭锁 | L5-1;seqlock 式复核:提交撞见 = 本轮跳过不误锁;`prot_set_seal()` 是解锁的唯一合法路径 |
**要点**
- **Q10 定点**(×1024)与参考 `2^10` 折算口径一致;元数据表(地址/范围/默认)是
ROM 常量,校验和只覆盖 RAM 值区。
- **通讯地址逐项对照参考** `Comm/Pub/Const_CommSetPara.c` `g_tCommSetParaTab`
(基址 0x8820);单测 TC-PS-006 保证地址不重复、`min ≤ def ≤ max`、投退项 0..1。
- ⚠ **默认值/范围为拟定值**(参考 EEPROM 出厂表不在本仓库),待对照修订——
见规划 §5.4 落实进度注。
- **B8 扩 8 项(32→40)**:`PS_OF_EN/OF_HZ/OF_DLY/UF_EN/UF_HZ/UF_DLY/RET_OF/RET_UF`
**尾追加**(编号不回收,枚举序 = 数组序 = 校验和覆盖序)。OF/UF 的逐项通讯地址
不在本仓库(参考保护定值段 0x8820~0x8882 仅示"含低压/低频"类目)→ 全部按
**本地项 comm_addr=0** 登记(104 C_SE 不可寻址),待对照参考表补。
频率返回系数**独立**(`RET_OF=0.99 / RET_UF=1.01`,返回门槛 = 定值×系数)——
频率滞回带与电气量 0.95/1.05 不同量级,勿共用。
- **B8 换代语义**:项数变 → blob 144→176 B、`PROT_SET_BLOB_VER` 1→2。B7 旧档
(ver=1/32 项)在新固件 parse 即被**版本/长度闸**拒绝 → 出厂默认;
`prot_set_store_load()` 返回码分流:**2 = 换代**(`prot_set_store.c` 打 INFO
"prot set blob version change, defaults in use",**不是** CORRUPT、不记自检),
−1 = 真损坏(defaultTask 打 ERROR + 自检留痕)。升级路径不得报成损坏。
- **bad_mask 随项数扩为 `uint64_t`**(bit i = 第 i 项越界;消费方仅主机单测,
板端闭锁走 `prot_set_locked()`)。
### 3.6 `prot_elm.c` / `prot_elm.h` — 保护元件框架(L3,P1 子集)
**解决的问题**:参考系统 89 个 `PRS_*.c` 各自实现"滞回 + 定时限"——本框架把这段
公共核收敛为**一个通用核 + 数据驱动实例表**(同 `phasor` 的 `k_inst` 模式),
新增元件 = 表加一行 + 定值加几项。
| API | 干什么 | 注意 |
|---|---|---|
| `prot_elm_init()` | 复位状态 + 解析相量实例索引(按名前缀 `C01/C02/C03`) | 须在 `phasor_init()` 之后调;`src=FREQ` 行无相量绑定(`pidx=0xFF`,run 不查它) |
| `prot_elm_eval(now_ms, meas)` | **纯判据**:定值 RAM + 测量数组 → 全元件状态机 | 主机单测入口;NULL 测量按 0 |
| `prot_elm_run(now_ms)` | 适配层:按实例表 `src` 组测量 → eval(`src=0` 取 `phasor_get()` rms,`src=1` 取 `freq_get()`) | 相量类任一未就绪 → **整拍跳过**(不引入陈旧判据);频率类无有效读数 → **按方向填"必不启动"值**(过量填 0 / 欠量填 `1e30` → 滞回比较恒假 → 元件复位),**不整拍跳过**——频率源缺失不得饿死电流/电压类元件 |
| `prot_elm_state()` / `prot_elm_active_mask()` | 状态快照 / 动作位图 | 供 L4 出口与心跳日志 |
**要点**
- **滞回**:已启动用返回门槛(`定值 × 返回系数`),未启动用动作门槛——
过量/欠量方向由实例表 `over` 位统一处理(`UV`/`UF` 为欠量实例)。
- **定时限**:`(uint32_t)(int32_t)(now - t0) >= dly` 差值法,tick 回绕安全;
`dly=0` → 启动同拍即动作。返回**即时**(参考带 10 ms 返回延时,P4 接出口硬件时再补)。
- **闭锁联动**:`prot_set_locked()`(L5-1)或投退=0 → 该元件状态整体复位、不判据。
- **V0.1 实例**(N-18):`OC1/OC2/OC3/OL`(IA)、`OV/UV`(UA)、`3U0`(UN 外部通道)、
`OF/UF`(freq 层测频,B8)。零序过流(IN 走 STM32)**故意不在表中**。
- **OF/UF 出口可见性(B8)**:两元件**未单独映射 104 遥信点**(送检点表 = B10 口径),
其动作目前**只经 YX15 保护总聚合可见**(`iec104_pts` 的 fault-total 掩码含 OC/3U0,
OF/UF 动作会置总位但无独立分点)——README 与规划 §3.3 L3-5 行必须同此口径,勿写"独立遥信"。
- **板端接线已落地(B5,2026-10-12)**:`App/task/protect_task.c`(protectTask,
20 ms 节拍)消费 `prot_elm_run()` + L5-4 采样故障闭锁 + L5-1 校验和巡检;
跨上下文复归走 `prot_elm_request_reset()`(104 YK11 落请求、eval 头部消费)。
判据结果经 104 遥信上送(`iec104_pts` YX15~23/31)。→ `docs/算法/继电保护功能复现规划.md` §7
⚠ 实例数 7→9(B8):protectTask 启动锚点 `elm=` 与 `prot_elm_active_mask()` 位宽随之变,
`.serialtest` 锚点表未含 `elm=` 数字(已核),无需改配置。
### 3.7 `freq.c` / `freq.h` — 频率测量层(L2 派生量,L3-5 测量输入,B8)
**解决的问题**:过频/低频(L3-5)需要系统频率读数,而 L2 只出相量。本层把
"相邻两拍基波相量的相位差"变成"频率",并解决**非整周波窗长的 DFT 泄漏纹波**
这一决定精度的关键问题。
| API | 干什么 | 注意 |
|---|---|---|
| `freq_init()` | 清增量环/计数/读数/相位基准/有效标志(幂等) | 内核启动前可调,须在 `phasor_init()` 之后无要求 |
| `freq_run()` | 每拍步进:选源(UA→IA 过幅值门)→ 共轭乘积 `P=Z(m)·conj(Z(m-1))` 取角 → 假值钳制 → 自适应窗均值 | **仅 adcTask 上下文**,须在 `phasor_run()` 之后同拍调 |
| `freq_get(out)` | 读数(Hz) | 0 有读数(可能是保持值)/ −1 参数错 / −2 从未启动收敛(写 0) |
| `freq_valid()` | "本拍新鲜"标志 | 1 = 幅值门 OK 且已收敛;保护消费口径 = `get()==0 && valid()==1` |
| `freq_last()` | 最近有效读数 | 从未收敛返回 0.0 |
**算法要点(精度是实测驱动定稿的,两轮弯路记录在 freq.c 文件头)**
- **相位微分免解缠绕**:`arg(Z_now·conj(Z_prev))` 天然落在 (−π, π],纯音每拍
角**增大** 2πf/Fs(本层相量约定 φ=atan2(fIm,fRe)=窗首初相,窗每拍前滑一点);
45~55 Hz 段 |Δφ| ≈ 0.28~0.35 rad,距 ±π 裕度巨大。
- **自适应窗长**(核心):20 点矩形窗在非整周波时混入**负序泄漏项**
(45/55 Hz 处 |S₂/S₁| ≈ 3~5%),相量端轨迹成椭圆 → 逐拍相位差带周期
δ(φ) 纹波,周期 = fs/(2f) ≈ 9.1~11.1 拍。定长窗平均不整除纹波周期 →
扫频最坏误差 **0.317 Hz**(40 拍算术平均)/ **0.197 Hz**(60 拍复数和 Kay,
交叉项和角不随 K² 衰减,勿再尝试)。改 `K_eff = round(8·Fs/(2·f̂))`
(45~55 Hz 段 73~89 拍)使窗长恰为纹波周期整数倍 → 纹波相消,最坏 **0.0101 Hz**。
- **假值钳制带 35~65 Hz**(宽于系统合理带 40.5~60.5 = 参考 `CN_HERTZ_SOFTFR_MIN/MAX`,
规划 §10.1):单拍相位差本身带 ±5 Hz 纹波,钳制带贴边会**非对称丢弃纹波半周**
→ 均值偏置(实测 45 Hz 点 +0.16 Hz,已修)。35~65 仍拦得住窗滑 2 拍的 ≈2f
(90~110 Hz)与相位跳变假值;系统合理带由 OF/UF 定值范围在消费侧把关。
- **幅值门** `FREQ_AMP_MIN=50.0`(原始码值口径,**拟定待 L1-5 标定复核**):
低于门 = 无有效波形(浮空/PT 断线 rms≈0,相位无意义)→ 停更新、`valid` 清零、
读数保持 → 台架浮空不发散。信号消失过渡段(≤20 ms 残窗)读数漂移有界 ≤0.3 Hz。
- **源优先级 UA(C02)→IA(C01)**,按名前缀解析、每拍动态回退(台架只注电流亦可测频);
源切换即废相位基准重建(UA/IA 无共同相位基准,N-18)。
**精度判据(J1~J6,与 `test/host/freq/freq_test.c` 文件头同口径,数字为实测)**
| 判据 | 条件 | 门槛 | 实测最坏 |
|---|---|---|---|
| J1 扫频 | 纯音 45.00~55.00 Hz,0.5 Hz 步进(21 点) | **≤ 0.02 Hz** | **0.0101 Hz** @55.0 Hz |
| J2 噪声 | 幅值 1000 码、噪声 rms = 1% 幅值 | **≤ 0.10 Hz** | **0.0160 Hz** |
| J3 启动收敛 | 首个有效相量 → 首个有效读数 | **≤ 40 ms** | 20 ms(@39 ms 拍,相量 @19 ms) |
| J4 频率阶跃 | 50→51 Hz 进入 ±0.05 Hz 并保持 | **≤ 120 ms** | 98 ms(稳态 err 0.0053 Hz) |
| J5 零幅/浮空 | rms < 幅值门 | valid=0、不发散、保持漂移 ≤0.3 Hz | 漂移 0.13 Hz,无 NaN |
| J6 假值钳制 | 幅值+频率双跳变拍 | 60 ms 内回 ±0.05 Hz | 0.0000 Hz |
附加鲁棒性(同套件实测):直流偏置 8000 码 → err 0.0018 Hz;3/5 次谐波 20%/10%
→ err 0.0065 Hz;UA 无幅值落 IA → err 0.0020 Hz。
**J4 = 120 ms 是"精度优先于响应"的显式取舍(Lead 批准 2026-10-12)**:自适应窗
(M=8 ≈ 4 个泄漏纹波周期 ≈ 4 个信号周波)换来 0.317→0.0101 Hz,代价是频率骤变后
的窗冲刷(实测 98 ms)。OF/UF 定时限默认 **1000 ms** → 测频响应占保护总动作时间
**<10%**;暂态假值另有 J6(跳变后 ≤60 ms 回 ±0.05 Hz)兜底。故 120 ms 是工程整定,
不是判据失守——若将来要压响应,须同时重估精度(缩短窗 = 精度回退到 0.2~0.3 Hz 档)。
**板端可见性(DoD)**:adcTask 心跳/首拍日志追加一行
`freq(hb): f=5000 valid=1 (x0.01)`(MicroLIB 无 %f → ×0.01 定点,分辨率 0.01 Hz)。
正常带波形应 `valid=1` 且 `f≈5000`;浮空应 `valid=0`、`f` 为保持值。
## 4. 关键约定
- **语义映射唯一权威口径**:`docs/硬件平台/ADC通道映射说明.md`。取数一律经 `adc_input_raw()`,
禁止在消费侧直接索引驱动缓冲或写死下标。
- **禁止跨转换器运算**(V0.1 硬件缺陷 N-18):`UA/UN/IA` 在 TPAFE、`UB/UC/IB/IC/IN` 在 STM32 ADC1,
两者无共同采样触发 → 无同一时刻性、相位不确定。故本层**不做**跨转换器的相位 / 功率 /
功率因数 / 负序零序类矢量运算(V0.2 把 8 路电气量全接 TPAFE 后方可解禁)。
- **标定(L1-5)尚未实现**(缺标准源):现以**原始码值**充当工程量(系数 = 1),
因此相量读数只证明**链路通**,**不可作幅值精度判据**。
- **本层不建任务、不等事件、不写日志之外的副作用**;日志由调用侧(adcTask)打。
- 内核启动前只允许调 `*_init()`;`adc_input_poll()` / `phasor_run()` 仅任务上下文。
## 5. 静态足迹(BSS,供栈/RAM 预算参考)
| 位置 | 量 |
|---|---|
| `sample_ring.c` `s_ring[9][40]` float | **1440 B** |
| `phasor.c` `s_zone[2][5]` `phasor_t` | 160 B |
| `adc_input.c` `s_stm32_raw[16]` + `s_tpafe_raw[8]` | 48 B |
| `adc_health.c` 内部状态(9 × uint32 + uint8) | ≈ 40 B |
| `prot_set.c` `s_val[2][40]` + 封存/位图(64 位)/闭锁 | 336 B(B8:31→40 项) |
| `prot_elm.c` `s_rt[9]`(16 B/实例) | 144 B(B8:7→9 实例) |
| `freq.c` 相位增量环 `fq_d[96]` + 占位标志 + 状态 | **≈ 520 B**(B8 新增) |
| 运行期栈:`phasor_run()` 的 `win[20]` | 80 B/帧 |
| 运行期栈:`freq_run()` 局部 | ≈ 30 B/帧(无大局部数组) |
改容量(`SAMPLE_RING_CYCLES` / 实例数 / `ADC_SEM_COUNT` / `FREQ_AVG_TAPS`)前后各跑一次
`& '.venv\Scripts\python.exe' scripts/stack_report.py`(口径见 `mem-budget` skill)。
**B8 实测(adcTask 静态最坏链)**:见 READY 消息与 `docs/内存管理/` 台账回填。
## 6. 构建与测试
- **挂载**:八个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `<IncludePath>` 内。
本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)——纯算法且模拟器无消费路径的
新文件(`prot_set.c` / `freq.c`)改在 `scripts/check_registration.py` 的 `SIM_EXEMPT` 登记豁免
(`registration-check` 钩子反向校验,N-23)。
- **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码:
| 套件 | 命令 | pre-commit 钩子 |
|---|---|---|
| 相量层(57 用例:系数表逐项复核 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换) | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` |
| ADC 输入层(逐 Rank 注入互异码值钉死映射 + HAL 启动失败/超时分支) | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` |
| 有效性校验(31 用例:100 ms 静默门限两侧 / 丢帧边沿 / 倒退锁存 / 错误增量 / tick 回绕 / NULL 守卫 / init 幂等) | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` |
| 定值模型(90 用例:范围校验 / all-or-nothing 装载 / 封存-自检-闭锁 / Q10 往返 / 元数据表自检 / B7 blob 往返与损坏拒绝 / **B8 OF-UF 行 + 64 位 bad_mask + 换代拒绝**) | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` |
| 元件框架(47 用例:滞回与定时限 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕 / 位图聚合 / **B8 OF-UF 滞回与方向隔离 + run() 测频接线**) | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` |
| **频率测量(46 用例:J1 扫频 45~55 Hz / J2 噪声 / J3 启动收敛 / J4 阶跃 / J5 浮空不发散 / J6 假值钳制 / 直流·谐波抑制 / UA→IA 回退 / 守卫与幂等)** | `& '.venv\Scripts\python.exe' test/host/freq/run_unit_test.py` | `freq-unit-test`(**待登记**,合并时由 Lead 加钩子) |
改本目录任一 `.c/.h`,提交时对应钩子自动跑;详见 `docs/测试说明/相量层单元测试说明.md`。
- **板端**:`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍/心跳日志(`phasor(...)` 行、
`freq(...)` 行、`ring` / `skip` 计数、`adc1_rc` / `adc1_err`、`tgap`);健康判据 = 零 `[ERR]`
(`adc_health_report` 的告警全部以 `[ERR]` 开头 → 误报会被闭环直接抓住)。
- **定值档升级 vs 损坏(B8,台架判读)**:`prot_set_store_load()` **rc=2 = 换代**
(旧固件/旧项数的档被版本闸拒绝)→ 只打 INFO `prot set blob version change, defaults in use`,
**不是** `[ERR] ... CORRUPT`、不记自检;**rc<0 = 真损坏** → `[ERR] prot set blob CORRUPT` + 自检留痕。
升级固件后首启看到"version change"一行属**预期**,勿当故障排查。
## 7. 新增一个算法模块的固定动作
## 5. 新增一个算法模块的固定动作
1. 在本目录建 `xxx.c/h`,文件头写模块注释块(规范 V1.0),**尽量零平台依赖**以便免桩单测。
2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(目录已在 IncludePath,
若新建子目录则必须补 IncludePath)。模拟器侧二选一:有消费路径 → 加
`simulator/CMakeLists.txt`;**纯算法无消费路径 → 加 `scripts/check_registration.py`
的 `SIM_EXEMPT`**(照 `prot_set.c` / `freq.c` 先例写理由),否则 `registration-check` 钩子拦。
2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(新建子目录还要补 `<IncludePath>`)。
模拟器侧二选一:有消费路径 → 加 `simulator/CMakeLists.txt`;纯算法无消费路径 → 加
`scripts/check_registration.py` 的 `SIM_EXEMPT`(照本目录既有七条写理由),否则 `registration-check` 拦。
3. 加 `test/host/<模块>/` 主机单测 + `.pre-commit-config.yaml` 钩子(照 `phasor-unit-test` 抄)。
4. 消费者写在 `App/task/`,本层只出"数";跨转换器运算的禁令见 §4。
4. 消费者写在 `App/task/`,本层只出"数";跨转换器禁令见 §3 开头四条红线。
5. 在本 README §1 清单加一行,并在 `docs/算法/采集与算法层设计说明.md` 里写清该模块的契约与理由。
## 8. 相关文档
## 6. 相关文档
- **本层设计与判据**:`docs/算法/采集与算法层设计说明.md`
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
- 分层与路线图(L1/L2 清单与状态):`docs/算法/继电保护功能复现规划.md`
- 分层与功能项清单:`docs/算法/继电保护功能复现规划.md`
- 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md`
- 采样节拍(TIM3 1 kHz + TRGO 触发 ADC1/DMA):`docs/子系统设计/ADC采样节拍设计说明.md`
- 相量层单测细则:`docs/测试说明/相量层单元测试说明.md`
- 采样节拍(TIM3 1 kHz + TRGO 触发 ADC1 / DMA):`docs/子系统设计/ADC采样节拍设计说明.md`
- 相量层 / ADC 输入层单测细则:`docs/测试说明/相量层单元测试说明.md`、`docs/测试说明/ADC输入层单元测试说明.md`
- RAM 与栈台账:`docs/内存管理/RAM使用分析与优化方案.md`
+425
View File
@@ -0,0 +1,425 @@
/*
* 模块名称:通道标定与量纲折算(L1.5)——实现
* 模块功能:见 adc_cal.h。折算模型(工程量 = (code + b) × k × 1e-6,k 为 Q16
* µV/码)与两段 cfg 布局([adc_cal]:k_* + valid 位图;[adc_cal_ofs]:
* b_*)照 docs/任务规划/数据可信.md §4.1 / §4.4 落地;系数口径
* (ROM 默认 = 卡 1 定死的引脚侧 LSB)见 docs/硬件平台/ADC通道映射说明.md
* §7.1。
* 并发契约:cfg RAM 副本属主 = 配置引擎(defaultTask 的 load/poll 原地
* 改写),本层逐点折算现读——int32 对齐字在 Cortex-M4 上读写原子,
* poll 重载「复位默认 + 逐键解析」存在 µs 级窗口,撞上则该拍该通道
* 为默认值/半新值(相量 20 点窗摊薄,且触发面 = 用户改文件后 4 s
* 沉降一次性事件)——「数据可信」§4.4 明载此设计(不挂回调)。
* 合法带哨兵(防损坏文件/错误标定把假电压送进下游):k ∉ ROM×[0.5,2.0]
* 或 |b| > ADC_CAL_B_MAX → 该通道折算回退 ROM + b=0,flags 不报
* VALID,每通道每次开机首见沿打一条 [ERR](adc_cal_init 重置留痕)。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-25(道A 卡 2/卡 4)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:Q16 系数/零点两段落盘 + 折算 + 三态 flags
* v1.1 2026-10-25 DTU 项目组 卡 10(道D):新增 adc_cal_b_codes() 生效零点
* 读取口;set() 授权写者扩至 iec104Task 直通路径
* (run_par 0x8040~/0x8048~,契约同 §4.4)
* + 合法带哨兵回退 + 两点标定公式(台架注入待跑)
* v1.2 2026-10-26 DTU 项目组 道F task-16:新增 adc_cal_prot_blocked()——未标定
* 闭锁(UA/UN/IA 三路 VALID 全齐才放行)收成本层单点,
* DTU_BENCH 旁路同时收成全仓唯一一处;此前该判据在
* protect_task.c 与 ui_data_feed.c 各写一份,两处一旦
* 漂移,屏上「未标定」与判据实际停走就不是同一件事
*/
/* 头文件包含区 */
#include <stddef.h>
#include <string.h>
#include "adc_cal.h"
#include "cfg_file.h"
#define DBG_TAG "[CAL]"
#include "dbg_log.h"
/* 常量定义区 */
/* 段名(≤ CFG_FILE_MAX_NAME_LEN)与键名(k_ua…b_in,均 4/5 字符)
* ——「数据可信」§4.4 布局;键的书写顺序按该表,存储下标按语义枚举 */
#define ADC_CAL_SEC_K "adc_cal"
#define ADC_CAL_SEC_B "adc_cal_ofs"
/* Q16 → V 的合并比例因子:1e-6 / 65536(float 常量,精确到 1 ulp 以内即可:
* 系数本身 Q16 舍入误差 < 0.001 %,见「数据可信」§4.1) */
#define ADC_CAL_K_SCALE (1.52587890625e-11f)
/* 静态变量定义区 */
/* [adc_cal] 段 RAM 副本:k 按语义下标 0..7,valid 位图 bit=语义下标。
* data_size 取 offsetof(valid)+1 = 33(尾随对齐填充不属于引擎视图,
* 引擎「复位默认」按 data_size memcpy,必须覆盖 valid,见单测 TC-ADCCAL-001) */
typedef struct {
int32_t k[8];
uint8_t valid;
} adc_cal_kcfg_t;
/* [adc_cal_ofs] 段 RAM 副本:b 按语义下标 0..7(int32 有符号,卡 3 的
* CFG_VAL_INT 是负零点偏移能干净落盘的前提) */
typedef struct {
int32_t b[8];
} adc_cal_bcfg_t;
static adc_cal_kcfg_t s_kcfg; /* 生效系数副本(引擎原地更新) */
static adc_cal_bcfg_t s_bcfg; /* 生效零点副本 */
/* 工厂默认(ROM 理论系数,卡 1 定死,「数据可信」§4.4):**下标 = 语义枚举**
* (UA0 / UB1 / UC2 / UN3 / IA4 / IB5 / IC6 / IN7)——UA/UN/IA 走 TPAFE
* (16 位 ±5 V 档),UB/UC/IB/IC/IN 走 STM32 ADC1;逐路对过
* docs/硬件平台/ADC通道映射说明.md §3(IN=ADC11/STM32,勿再按分组顺序误写)。
* ROM 表按**当前 V0.1 来源排布**显式书写(不查 desc 运行期派生),因为它是
* 卡 1 定稿的存档事实;V0.2 改版(消除双转换器无共同采样触发)时本表与 adc_input 映射表同步改,
* 上层语义与 API 不动。CLIPPED 位则**运行期按来源派生**(见 adc_cal_flags) */
static const adc_cal_kcfg_t s_kcfg_rom = {
/* idx0 UA / idx1 UB / idx2 UC / idx3 UN */
{ ADC_CAL_K_Q16_TPAFE, ADC_CAL_K_Q16_STM32, ADC_CAL_K_Q16_STM32,
ADC_CAL_K_Q16_TPAFE,
/* idx4 IA / idx5 IB / idx6 IC / idx7 IN */
ADC_CAL_K_Q16_TPAFE, ADC_CAL_K_Q16_STM32, ADC_CAL_K_Q16_STM32,
ADC_CAL_K_Q16_STM32 },
0u /* 出厂未标定:VALID 全 0 */
};
static const adc_cal_bcfg_t s_bcfg_rom = { { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L } };
/* 合法带哨兵留痕位(bit=语义下标;每通道每次开机只打一条 [ERR],
* adc_cal_init 清零 = 新一轮观测窗) */
static uint8_t s_err_latched;
/* 键表:offset 用 offsetof 取真值(k[0]@0 / k[1]@4 … valid@32) */
static const cfg_key_def_t s_k_keys_tbl[9] = {
{ "k_ua", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[0]), 4u },
{ "k_un", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[3]), 4u },
{ "k_ia", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[4]), 4u },
{ "k_ub", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[1]), 4u },
{ "k_uc", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[2]), 4u },
{ "k_ib", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[5]), 4u },
{ "k_ic", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[6]), 4u },
{ "k_in", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[7]), 4u },
{ "valid", CFG_VAL_UINT, offsetof(adc_cal_kcfg_t, valid), 1u },
};
static const cfg_key_def_t s_b_keys_tbl[8] = {
{ "b_ua", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[0]), 4u },
{ "b_un", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[3]), 4u },
{ "b_ia", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[4]), 4u },
{ "b_ub", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[1]), 4u },
{ "b_uc", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[2]), 4u },
{ "b_ib", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[5]), 4u },
{ "b_ic", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[6]), 4u },
{ "b_in", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[7]), 4u },
};
/* 函数定义区 */
/*
* 函数功能:判定语义通道是否 STM32 源(经 adc_input 权威映射表,不另立第二份表)
* 入口参数:sem - 语义通道 unsigned
* 返 回 值:1 - STM32;0 - TPAFE(含 desc 取不到时的保守分支) int
* 限定条件:无
*/
static int s_rom_k_stm32(unsigned sem) {
const adc_channel_desc_t *d = adc_input_desc((adc_semantic_t)sem);
return ((d != NULL) && (d->source == ADC_SRC_STM32)) ? 1 : 0;
}
/*
* 函数功能:取语义通道的 ROM 默认系数(合法带/回退参照)
* 入口参数:sem - 语义下标(0..7) unsigned
* 返 回 值:Q16 系数 int32_t
* 限定条件:sem < 8(调用方已先行拦截 TEMP/越界)
* 函数说明:直接读出厂表本体——保证「哨兵合法带基准」与「引擎工厂默认」
* 永远同一份数(两者若靠重复表达式各算各的,改一处漏一处就是暗雷)
*/
static int32_t s_rom_k(unsigned sem) {
return s_kcfg_rom.k[sem];
}
/*
* 函数功能:k/b 合法带检查(ROM×[0.5,2.0] / |b| ≤ ADC_CAL_B_MAX)
* 入口参数:sem - 语义下标(0..7) unsigned
* k - 待检系数 Q16 int32_t
* b - 待检零点 码值 int32_t
* 返 回 值:1 - 合法;0 - 越带 int
* 限定条件:sem < 8
*/
static int s_kband_ok(unsigned sem, int32_t k, int32_t b) {
const int32_t rom = s_rom_k(sem);
if (k < (rom >> 1) || k > (rom << 1)) {
return 0;
}
if (b > ADC_CAL_B_MAX || b < -ADC_CAL_B_MAX) {
return 0;
}
return 1;
}
/*
* 函数功能:越带留痕(每通道每次开机首见沿一条 [ERR],init 重置)
* 入口参数:sem - 语义下标 0..7;k/b - 现场值
* 返 回 值:无
* 限定条件:仅 adc_cal_apply/set 内部
*/
static void s_note_bad(unsigned sem, int32_t k, int32_t b) {
const uint8_t bit = (uint8_t)(1u << sem);
if ((s_err_latched & bit) == 0u) {
s_err_latched |= bit;
DBG_ERROR("ch=%u k_q16=%ld b=%ld out of ROM band [0.5x,2x]/|b|<=%ld -> ROM fallback",
(unsigned)sem, (long)k, (long)b, (long)ADC_CAL_B_MAX);
}
}
/*
* 函数功能:读通道「当前生效且合法」的 k/b(越带就地回退 ROM)
* 入口参数:sem - 语义下标 0..7;p_k/p_b - 输出
* 返 回 值:1 - 用了合法值;0 - 已回退 ROM
* 限定条件:sem < 8
*/
static int s_eff_kb(unsigned sem, int32_t *p_k, int32_t *p_b) {
const int32_t k = s_kcfg.k[sem];
const int32_t b = s_bcfg.b[sem];
if (s_kband_ok(sem, k, b) != 0) {
*p_k = k;
*p_b = b;
return 1;
}
s_note_bad(sem, k, b);
*p_k = s_rom_k(sem);
*p_b = 0L;
return 0;
}
/*
* 函数功能:标定层初始化——RAM 副本置 ROM 默认 + 注册两段进 cfg 引擎(幂等)
* 入口参数:无
* 返 回 值:无
* 限定条件:内核启动前调用(app_main_init);注册必须先于 cfg_file_load
* 函数说明:完整契约见 adc_cal_init() 同名声明(adc_cal.h);注册失败(段名重复/
* 段数超限)打 [ERR] 后继续——ROM 默认系数照常生效,仅文件整定失效
*/
void adc_cal_init(void) {
cfg_section_reg_t reg;
(void)memcpy(&s_kcfg, &s_kcfg_rom, sizeof(s_kcfg));
(void)memcpy(&s_bcfg, &s_bcfg_rom, sizeof(s_bcfg));
s_err_latched = 0u;
(void)memset(&reg, 0, sizeof(reg));
reg.name = ADC_CAL_SEC_K;
reg.p_data = &s_kcfg;
reg.p_defaults = &s_kcfg_rom;
reg.data_size = (offsetof(adc_cal_kcfg_t, valid) + 1u); /* 33:覆盖到 valid */
reg.p_keys = s_k_keys_tbl;
reg.key_count = 9u;
reg.p_cb = NULL; /* 不挂回调:RAM 副本即生效值(「数据可信」§4.4) */
if (cfg_file_register(&reg) != 0) {
DBG_ERROR("register '%s' FAIL (dup/full?) - calibration file values inactive",
ADC_CAL_SEC_K);
}
(void)memset(&reg, 0, sizeof(reg));
reg.name = ADC_CAL_SEC_B;
reg.p_data = &s_bcfg;
reg.p_defaults = &s_bcfg_rom;
reg.data_size = sizeof(s_bcfg); /* 32 */
reg.p_keys = s_b_keys_tbl;
reg.key_count = 8u;
reg.p_cb = NULL;
if (cfg_file_register(&reg) != 0) {
DBG_ERROR("register '%s' FAIL (dup/full?) - zero-offset file values inactive",
ADC_CAL_SEC_B);
}
}
/*
* 函数功能:码值 → 工程量折算(「数据可信」§4.1 模型)
* 入口参数:sem - 语义通道;code - 原始码值 adc_semantic_t / int32_t
* 返 回 值:工程量浮点(未标定 = ADC 引脚电压 V;TEMP/越界 = 原样透传码值) float
* 限定条件:无(任意上下文,纯读)
* 函数说明:见 adc_cal.h 同名声明;k/b 经 s_eff_kb 合法带哨兵(越带回退 ROM)
*/
float adc_cal_apply(adc_semantic_t sem, int32_t code) {
int32_t k;
int32_t b;
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
return (float)code; /* TEMP/越界:不纳入线性标定(决策 5),原样透传 */
}
(void)s_eff_kb((unsigned)sem, &k, &b);
return ((float)(code + b)) * ((float)k) * ADC_CAL_K_SCALE;
}
/*
* 函数功能:读通道可用性标志(消费者判「能不能信」的唯一入口)
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:ADC_CAL_F_VALID / ADC_CAL_F_CLIPPED 组合;TEMP/越界恒 0 uint32_t
* 限定条件:无
* 函数说明:VALID 需文件位图与 k 合法带**同时**成立(哨兵压制说谎位图,见文件头)
*/
uint32_t adc_cal_flags(adc_semantic_t sem) {
uint32_t f = 0u;
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
return 0u;
}
/* VALID = 文件位图置位 且 k 合法(哨兵不落共享状态,随重载自愈) */
if (((s_kcfg.valid & (uint8_t)(1u << (unsigned)sem)) != 0u) &&
(s_kband_ok((unsigned)sem, s_kcfg.k[sem], s_bcfg.b[sem]) != 0)) {
f |= ADC_CAL_F_VALID;
}
/* CLIPPED 由**来源**派生(R5:STM32 侧单极性无偏置 → 负半周削顶)——
* V0.2 改版(8 路全接 TPAFE,消除双转换器无共同采样触发)后本位自动恒 0,无需改码,
* 对应「数据可信」§10「页面与 104 绑定零改动」的要求 */
if (s_rom_k_stm32((unsigned)sem) != 0) {
f |= ADC_CAL_F_CLIPPED;
}
return f;
}
/*
* 函数功能:未标定闭锁判定(判据所需的 UA/UN/IA 三路是否齐)
* 返 回 值:1 - 闭锁;0 - 放行 uint8_t(契约、三路集合的理由、判据停走的四路
* 来源、两个同族谓词的边界都写在 adc_cal.h,本处不重述——重述就是第二真源)
* 函数说明:**现查 flags(),本函数不留任何状态**。刻意不复用保护任务里那份
* 「上拍闭锁态」——那是给边沿日志用的,开机首拍前恒 0;拿它当判据等于在
* 「还没判过」的时候谎称「已标定」,而这正是本谓词最贵的失效(屏上
* 「未标定」徽标一消失,现场读到的就是「改了定值就会动作」)。
* DTU_BENCH 旁路留在这里、且全仓只此一处;用 #ifndef 把产品默认路径留在
* 主分支——#ifdef 双分支会让 cppcheck 对未定义宏取真分支、把静态分析引到
* 旁路上报 knownConditionTrueFalse 假信号(实测 2026-10-25,这条形态说明原先
* 写在 protect_task.c 的实现处,随判据一起搬到这里)
*/
uint8_t adc_cal_prot_blocked(void) {
#ifndef DTU_BENCH
if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) {
return 1u;
}
#endif
return 0u;
}
/*
* 函数功能:读通道当前生效系数(µV/码,回退后口径)
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:k[µV/码];TEMP/越界返 0 float
* 限定条件:无
* 函数说明:Q16 现值 ÷ 65536 浮点还原;勿据其反推二次值(可用性看 flags)
*/
float adc_cal_k_uv(adc_semantic_t sem) {
int32_t k;
int32_t b;
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
return 0.0f;
}
(void)s_eff_kb((unsigned)sem, &k, &b);
return ((float)k) / 65536.0f;
}
/*
* 函数功能:读通道当前生效零点偏移(码值,浮点)——adc_cal_k_uv 孪生入口
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:b[码值](越带时 s_eff_kb 已回 0);TEMP/越界返 0 float
* 限定条件:无(任意上下文,纯读)
* 函数说明:卡 10 的 104 b 覆盖回显 + k 单侧改写 RMW 读侧(App/algo/run_par.c)
*/
float adc_cal_b_codes(adc_semantic_t sem) {
int32_t k;
int32_t b;
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
return 0.0f;
}
(void)s_eff_kb((unsigned)sem, &k, &b);
return ((float)b);
}
/*
* 函数功能:整定入口——写通道系数与零点,并置位 VALID(「数据可信」卡 4)
* 入口参数:sem - 语义通道;k_q16 - 系数(µV/码×65536);b_codes - 零点(码值)
* 返 回 值:0 - 成功;-1 - 参数非法/越出合法带(RAM 不动,[ERR] 留痕) int
* 限定条件:整定路径上下文(defaultTask 整定/标定链;iec104Task 经 run_par
* 直通为卡 10 授权写者,单字原子契约同 §4.4);落盘由属主经 cfg_file_save
* 函数说明:见 adc_cal.h 同名声明
*/
int adc_cal_set(adc_semantic_t sem, int32_t k_q16, int32_t b_codes) {
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
return -1;
}
if (s_kband_ok((unsigned)sem, k_q16, b_codes) == 0) {
DBG_ERROR("set ch=%u rejected: k_q16=%ld b=%ld out of ROM band [0.5x,2x]/|b|<=%ld",
(unsigned)sem, (long)k_q16, (long)b_codes, (long)ADC_CAL_B_MAX);
return -1;
}
s_kcfg.k[sem] = k_q16;
s_bcfg.b[sem] = b_codes;
s_kcfg.valid |= (uint8_t)(1u << (unsigned)sem);
return 0;
}
/*
* 函数功能:两点标定公式(卡 12 的算术核,纯函数无状态)
* 入口参数:c_lo/c_hi、v_lo_uv/v_hi_uv、p_k_q16/p_b 见 adc_cal.h 同名声明
* 返 回 值:0 成功 / -1 参数或斜率非法 / -2 零点越 ±B_MAX int
* 限定条件:无(int64 中间量,主机可测;台架注入待跑)
*/
int adc_cal_two_point(int32_t c_lo, int32_t c_hi, int32_t v_lo_uv, int32_t v_hi_uv,
int32_t *p_k_q16, int32_t *p_b) {
long long dc;
long long dv;
long long kq;
long long bb;
if ((p_k_q16 == NULL) || (p_b == NULL)) {
return -1;
}
dc = (long long)c_hi - (long long)c_lo;
dv = (long long)v_hi_uv - (long long)v_lo_uv;
if ((dc <= 0LL) || (dv <= 0LL)) {
return -1; /* 斜率必须为正:码与电压同向,反向 = 映射/接线错 */
}
kq = ((dv << 16) + (dc >> 1)) / dc; /* (v_hi−v_lo)×65536/dc,四舍五入(dc>0) */
if ((kq <= 0LL) || (kq > 100000000LL)) {
return -1; /* 绝对合理域上界 1526 µV/code:越界 = 单位/量级错 */
}
bb = (((long long)v_lo_uv) << 16) / kq - (long long)c_lo;
if ((bb > (long long)ADC_CAL_B_MAX) || (bb < -(long long)ADC_CAL_B_MAX)) {
return -2; /* 零点异常 → 先查前端,别用系数凑(§8 风险表) */
}
*p_k_q16 = (int32_t)kq;
*p_b = (int32_t)bb;
return 0;
}
/*
* 函数功能:通道码值域贴轨带门限(L5-2 判定的门限来源,按 FS 百分比)
* 入口参数:sem - 语义通道;p_lo_th/p_hi_th - 输出下/上轨带界 int32_t *
* 返 回 值:0 - 已给出门限;-1 - TEMP/越界/参数空(不做贴轨监视) int
* 限定条件:无
* 函数说明:状态机在 adc_health_rail_watch();本函数只供门限,无状态
*/
int adc_cal_rail_ths(adc_semantic_t sem, int32_t *p_lo_th, int32_t *p_hi_th) {
if ((p_lo_th == NULL) || (p_hi_th == NULL) ||
((unsigned)sem >= (unsigned)ADC_SEM_TEMP)) {
return -1; /* TEMP(热敏非线性,不判「轨」)与越界不做监视 */
}
if (s_rom_k_stm32((unsigned)sem) != 0) {
*p_hi_th = (int32_t)((4095L * 98L) / 100L); /* 4013 = 98 % FS */
*p_lo_th = (int32_t)((4095L * 2L) / 100L); /* 81 = 触底带(削顶签名) */
} else {
*p_hi_th = 32112L; /* +98 % 半量程 */
*p_lo_th = -32112L; /* -98 % 半量程 */
}
return 0;
}
+195
View File
@@ -0,0 +1,195 @@
/*
* 模块名称:通道标定与量纲折算(L1.5,「数据可信」卡 2/卡 4)
* 模块功能:把 adc_input 的原始码值折算成工程量——模型与系数口径的唯一落点:
* 工程量 = (code + b) × k_f,k_f = k[µV/码] × 1e-6
* k 以 int32 **Q16**(µV/码 × 65536)落盘、b 以 int32 码值落盘,
* RAM 副本注册进 cfg_file 引擎([adc_cal] / [adc_cal_ofs] 两段,
* 布局见 docs/任务规划/数据可信.md §4.4);**不挂回调**——RAM 副本
* 即生效值,逐点折算现取现用(1 kHz × 9 通道 = 9k 次/秒浮点乘,可忽略)。
* 三态可用性:ADC_CAL_F_VALID(已按变比标定 → 工程量为二次值)与
* ADC_CAL_F_CLIPPED(V0.1 硬件削顶)由 adc_cal_flags() 单点查询,
* 104 / UI / 保护闭锁(卡 5)共用(docs/任务规划/数据可信.md §4.2)。
* **未标定闭锁的判定本身也归本层**(`adc_cal_prot_blocked()`):它讲的是
* 「这几路标定齐了没」,属标定层事实,不是任务编排逻辑——保护任务与 UI
* 组装器都只是它的消费者(道F task-16 把此前的两份实现收成一份)。
* ⚠ 输出单位口径 = **ADC 引脚电压 V**(eng=0):ROM 默认系数按卡 1
* 定死的引脚侧 LSB(TPAFE 152.59 µV/码、STM32 805.66 µV/码,见
* docs/硬件平台/ADC通道映射说明.md §7.1);端子变比(卡 0b)与两点
* 标定(卡 12)完成前不得把输出当二次值消费。TEMP 不参与线性标定
* (决策 5),adc_cal_apply() 原样透传码值。
* 本层**纯算法 + 纯数据**:不碰寄存器、不等事件;adc_cal_init() 内核前
* 可调(注册进引擎必须先于 cfg_file_load,FTP 鉴权确立的契约);apply/flags
* 任意上下文可读(cfg RAM 副本 4 字节对齐字读写在 Cortex-M4 上原子,
* 与 defaultTask 的 load/poll 复位存在 µs 级混窗——设计接受,见 .c 头)。
* 适用平台:STM32F407ZGTx(Cortex-M4F);主机侧 L0.5 单测 test/host/adc_cal/
* 作者:DTU 项目组
* 创建日期:2026-10-25(道A 卡 2/卡 4)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:Q16 系数/零点 + 折算/flags/集
* v1.1 2026-10-25 DTU 项目组 卡 10:+adc_cal_b_codes()(run_par 回显/RMW 用)
* v1.2 2026-10-26 DTU 项目组 道F task-16:+adc_cal_prot_blocked()——「UA/UN/IA
* 三路 VALID 是否齐」这份判据停走条件从
* protect_task.c 与 ui_data_feed.c 的两份重复实现收成本层
* 单点(DTU_BENCH 旁路同时收成一处)
*/
#ifndef ADC_CAL_H
#define ADC_CAL_H
#include <stdint.h>
#include "adc_input.h" /* adc_semantic_t(语义通道编号 = 系数表下标口径) */
#ifdef __cplusplus
extern "C" {
#endif
/* 通道可用性标志(adc_cal_flags() 返回值位) */
#define ADC_CAL_F_VALID (0x01u) /* k 已按变比标定 → 工程量为二次值(卡 12 后置位) */
#define ADC_CAL_F_CLIPPED (0x02u) /* 硬件削顶(V0.1 STM32 侧单极性无偏置)→ 交流量不可信 */
/*
* ROM 默认系数(int32 Q16 = µV/码 × 65536,卡 1 定死的引脚侧口径):
* TPAFE ±5 V 档:10 V 跨度 / 65536 码 = 152.587890625 µV/码 → ×65536 = 10,000,000(整)
* STM32 0~3.3 V:3.3 V / 4096 码 = 805.6640625 µV/码 → ×65536 = 52,800,000(整)
* 两个默认值在 Q16 下**无舍入**(系数定点舍入项从误差预算里抹掉,
* 选型依据 → docs/任务规划/数据可信.md §4.1)
*/
#define ADC_CAL_K_Q16_TPAFE (10000000L)
#define ADC_CAL_K_Q16_STM32 (52800000L)
/* 系数合法带(「数据可信」§8 风险表):k 必须落在板级 ROM 系数的 ×[0.5, 2.0]
* 区间内;带宽用 ROM 值移位实现(>>1 / <<1),不设独立宏防两处真相 */
/* 零点偏移幅值上限(码值):超此即判文件损坏/异常标定,回退 ROM 并留痕 */
#define ADC_CAL_B_MAX (8192L)
/*
* 函数功能:标定层初始化——RAM 副本置 ROM 默认 + 注册两段进 cfg 引擎(幂等)
* 入口参数:无
* 返 回 值:无
* 限定条件:内核启动前调用(app_main_init);注册必须先于 cfg_file_load
* (「所有受管段先注册、后 load」契约,见 Drivers/BSP/NETCFG/net_cfg.c 的
* net_cfg_load 注释)
* 函数说明:段属主为 defaultTask(配置引擎),本模块只提供 RAM 副本与默认值;
* 文件值由引擎 load/poll 原地写入 RAM 副本,本层逐点折算现读
*/
void adc_cal_init(void);
/*
* 函数功能:码值 → 工程量折算(「数据可信」§4.1 模型)
* 入口参数:sem - 语义通道(TEMP 透传不折算) adc_semantic_t
* code - 原始码值(TPAFE 16 位补码 / STM32 0..4095) int32_t
* 返 回 值:工程量浮点(未标定时单位 = ADC 引脚电压 V;VALID 后置位 = 二次值) float
* 限定条件:无(任意上下文,纯读)
* 函数说明:eng = (code + b) × k[µV/码] × 1e-6,由 Q16 现值派生,无缓存副本
* (避免无回调下的陈旧系数);k/b 越合法带 → 该通道回退 ROM 默认 +
* 首见沿 [ERR] 留痕(adc_cal_init 重置留痕位),flags 同时不报 VALID
*/
float adc_cal_apply(adc_semantic_t sem, int32_t code);
/*
* 函数功能:读通道可用性标志(消费者判「能不能信」的唯一入口)
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:ADC_CAL_F_VALID / ADC_CAL_F_CLIPPED 组合;TEMP 恒 0;sem 越界恒 0 uint32_t
* 限定条件:无
* 函数说明:VALID = 文件位图置位 **且** 该通道 k 在合法带内(哨兵不落状态,
* 天然跟随 load/poll 重载);CLIPPED = V0.1 静态表(UB/UC/IB/IC/IN)
*/
uint32_t adc_cal_flags(adc_semantic_t sem);
/*
* 函数功能:**未标定闭锁判定**——保护判据所需的三路标定是否齐(「数据可信」卡 5
* / §4.3;道F task-16 把它从两处重复实现收敛成这一份真源)
* 入口参数:无
* 返 回 值:1 - 闭锁(UA/UN/IA 中至少一路 `ADC_CAL_F_VALID` 未置位,判据链应停走);
* 0 - 三路全 VALID(标定侧允许判据跑) uint8_t
* 限定条件:任意上下文(纯读 `adc_cal_flags()`,与它同一条并发契约:int32 对齐
* 字读写原子、cfg poll 重载 µs 级混窗设计接受);**现查,本层不留任何状态**
* 函数说明:① **为什么恰好这三路**:V0.1 把 8 路电气量分给两片转换器(AGENTS
* 「双转换器无共同采样触发」),UA/UN/IA = TPAFE 侧这三路是**唯一有效**
* 的测量通道;UB/UC/IB/IC/IN 即使将来标定也另受 `CLIPPED` 品质约束
* (M5 快照层处理),不掺进本闭锁源。
* ② ⚠ **不得当「保护在跑」的总括用**:判据停走共四路来源,本函数只覆盖
* 第二路——(a) L5-4 采样故障闭锁(`adc_health_flags()`);(b) 本函数
* (未标定);(c) 定值校验和失配闭锁(`prot_set_locked()`);(d) 全局软压板
* 退出(`PS_GLOBAL_EN` = 0)。要判「这拍到底评估了没有」必须四路一起看
* (编排顺序见 `App/task/protect_task.c`)。
* ③ ⚠ **两个同族谓词与它不是一件事,不要顺手合并**(三种口径各有用途):
* · 电气 **8** 路全 VALID 才升 eng=二次值(量纲标签,判据在
* `App/app/ui_data_feed.c` 的 `feed_eng()`)——并进来会让部分标定的整帧
* 被误标二次值;反过来把本三路口径当 8 路用,V0.1 下 UB/UC/IB/IC/IN
* 永不置位 → 徽标与停走**永不解除**;
* · 逐通道打印 VALID/CLIPPED 位图是诊断输出(`App/task/adc_task.c`),
* 不是判定。
* ④ 台架调试旁路 = 编译期 `-DDTU_BENCH`(「数据可信」§4.3 决策 1 的例外通道,
* 仅本地台架自行定义;本仓任何工程档都不定义 = 产品恒强制)。旁路**只在
* 本函数实现内部这一处**,调用方不再各自写 `#ifndef`:否则有人只给单个
* TU 加宏,屏上「未标定」徽标与判据停走当场分叉,且 0 错误 0 警告。
*/
uint8_t adc_cal_prot_blocked(void);
/*
* 函数功能:读通道当前生效系数(µV/码,浮点)
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:k[µV/码](回退后口径);TEMP / 越界返 0 float
* 限定条件:无
* 函数说明:L5-2 门限换算、日志与整定界面回显用;勿据其反推二次值(看 flags)
*/
float adc_cal_k_uv(adc_semantic_t sem);
/*
* 函数功能:读通道当前生效零点偏移(码值,浮点)——adc_cal_k_uv 的孪生入口
* 入口参数:sem - 语义通道 adc_semantic_t
* 返 回 值:b[码值](越带时为 0,与折算同视图);TEMP/越界返 0 float
* 限定条件:无
* 函数说明:104 运行参数段(「数据可信」卡 10)b 覆盖回显与 k 单侧改写的 RMW
* 读侧用(App/algo/run_par.c);勿据其反推二次值(看 flags)。
*/
float adc_cal_b_codes(adc_semantic_t sem);
/*
* 函数功能:整定入口——写通道系数与零点,并置位 VALID(「数据可信」卡 4)
* 入口参数:sem - 语义通道(电气 8 路) adc_semantic_t
* k_q16 - 系数,µV/码 × 65536 内 int32_t
* b_codes - 零点偏移,码值 内 int32_t
* 返 回 值:0 - 成功;-1 - 参数非法/越出合法带(RAM 不动,[ERR] 留痕) int
* 限定条件:**整定路径上下文**(defaultTask 标定流程/整定链;卡 10 起 iec104Task
* 经 App/algo/run_par.c 的 0x8040~/0x8048~ 直通写亦为授权写者——写的仍是
* 引擎对齐 int32 单字,µs 级混窗契约同「数据可信」§4.4)。勿在 adcTask 调用
* 函数说明:只写 RAM 副本;落盘由属主经 cfg_file_save 完成(卡 10 前整定链
* 未接,台架两点标定(卡 12)是首个消费者)
*/
int adc_cal_set(adc_semantic_t sem, int32_t k_q16, int32_t b_codes);
/*
* 函数功能:两点标定公式(「数据可信」卡 12 的算术核,纯函数无状态)
* 入口参数:c_lo/c_hi - 两点码值(c_hi > c_lo) int32_t
* v_lo_uv/v_hi_uv - 两点实测电压(µV,v_hi > v_lo,端子/引脚域均可) int32_t
* p_k_q16 / p_b - 输出:系数(Q16)与零点偏移(码值) int32_t *
* 返 回 值:0 - 成功;-1 - 参数/斜率非法;-2 - 零点越 ±ADC_CAL_B_MAX(先查前端,
* 不得用系数凑错斜率,「数据可信」§8 风险表) int
* 限定条件:无(纯整数运算,主机侧可直接单测)
* 函数说明:k = (v_hi−v_lo)×65536/(c_hi−c_lo)(四舍五入),b = v_lo×65536/k − c_lo;
* 实测注入未做(台架待跑),本函数即「公式齐备」的交付物
*/
int adc_cal_two_point(int32_t c_lo, int32_t c_hi, int32_t v_lo_uv, int32_t v_hi_uv,
int32_t *p_k_q16, int32_t *p_b);
/*
* 函数功能:通道码值域「贴轨带」门限(L5-2 越界检出的门限来源,按 FS 百分比)
* 入口参数:sem - 语义通道 adc_semantic_t
* p_lo_th - 下轨带上界(code ≤ 此值判触底) int32_t *
* p_hi_th - 上轨带下界(code ≥ 此值判触顶) int32_t *
* 返 回 值:0 - 已给出门限;-1 - TEMP/越界(不做贴轨监视) int
* 限定条件:无
* 函数说明:TPAFE 双极性 ±98% FS(±32112);STM32 上轨 98% FS(4013)、
* 下轨 2% FS(81);判定状态机在 adc_health_rail_watch()(L5-2)
*/
int adc_cal_rail_ths(adc_semantic_t sem, int32_t *p_lo_th, int32_t *p_hi_th);
#ifdef __cplusplus
}
#endif
#endif /* ADC_CAL_H */
+129 -1
View File
@@ -1,16 +1,25 @@
/*
* 模块名称:ADC 采样有效性校验(L1 采集与预处理,规划项 L1-6)
* 模块功能:见 adc_health.h 文件头。本文件为**纯逻辑实现**:输入计数快照、
* 输出故障位/事件位,零平台依赖(仅 stdint.h)→ 主机侧免桩单测。
* 输出故障位/事件位,零平台依赖(仅 stdint.h/string.h 标准 C)→
* 主机侧免桩单测。
* 适用平台:STM32F407ZGTx + TPAFE5160
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验
* v1.1 2026-10-21 王建锋 新增 adc_health_age_stale():无模块状态的帧年龄
* 纯判据(参照系由调用方自存),供 protectTask 的
* 兜底新鲜度门使用,见 adc_health.h 同名声明
* v1.2 2026-10-25 DTU 项目组 新增 L5-2 残余:码值越界(贴轨)观察器
* (rail_watch/rail_sticky/rail_init),判据与释放
* 沿理由见 adc_health.h L5-2 段注释
*/
#include "adc_health.h"
#include <string.h> /* memset(L5-2 贴轨观察器数组复位) */
/* ===================== 内部状态 ===================== */
/* 基线是否已建立:首次 check() 只记基线、不判定(开机首拍无参照系) */
@@ -33,6 +42,17 @@ static uint32_t s_flags; /* 电平位(stall 可自愈;backward 锁存在
static uint32_t s_sticky; /* 锁存位(含电平位历史并集) */
static uint32_t s_tpafe_gaps;
/* L5-2 贴轨观察器(每通道独立小状态机,单写者 = adcTask) */
typedef struct {
uint16_t run_hi; /* 连续贴上轨计数(饱和 0xFFFF) */
uint16_t run_lo; /* 连续贴下轨计数 */
uint8_t seen_mid; /* 见过中带样本(释放沿)——锁存的必要前提 */
uint8_t latched; /* 已报过边沿(不重复报) */
} adc_rail_t;
static adc_rail_t s_rail[ADC_RAIL_CH_NUM];
static uint32_t s_rail_sticky;
/* ===================== 对外接口 ===================== */
/*
@@ -43,6 +63,7 @@ static uint32_t s_tpafe_gaps;
* 函数说明:清全部内部状态;判定从下一次 check() 的首拍基线重新开始。
*/
void adc_health_init(void) {
adc_health_rail_init();
s_primed = 0u;
s_tpafe_seq = 0u;
s_tpafe_frst = 0u;
@@ -166,6 +187,43 @@ void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out) {
}
}
/*
* 函数功能:帧年龄判定(无模块状态,保护侧兜底门专用)
* 入口参数:见 adc_health.h 同名声明
* 返 回 值:1 - 已陈旧;0 - 新鲜或仍在建立基线
* 限定条件:任意任务上下文;不读写本模块任何 static(与 check() 的关键区别)
* 函数说明:三步——建基线 → 有前进即刷新参照系 → 原地不动才量"距最后一次前进"。
* 倒退也按"有变化"处理(本函数不判单调性,那归 ADC_HF_SEQ_BACKWARD)。
*/
uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
uint32_t a_now_ms, uint32_t a_age_ms) {
if (a_p_age == 0) {
return 0u;
}
/* 1) 首拍建基线:此刻序号往往已非 0(TIM3 比调度器早约 100 ms 就在生产帧),
* 没有"上一拍"就没有参照系——开机窗口内绝不允许把保护锁掉 */
if (a_p_age->primed == 0u) {
a_p_age->primed = 1u;
a_p_age->seq = a_cur_seq;
a_p_age->adv_ms = a_now_ms;
return 0u;
}
/* 2) 有任何前进(含跨多帧、含跨回绕、含倒退)即算新鲜,并刷新参照系 */
if (a_cur_seq != a_p_age->seq) {
a_p_age->seq = a_cur_seq;
a_p_age->adv_ms = a_now_ms;
return 0u;
}
/* 3) 序号原地不动:只看"距最后一次前进多久",含等号(与 stall 判据同边界) */
if ((uint32_t)(int32_t)(a_now_ms - a_p_age->adv_ms) >= a_age_ms) {
return 1u;
}
return 0u;
}
/*
* 函数功能:读当前电平故障位
* 入口参数:无
@@ -198,3 +256,73 @@ uint32_t adc_health_sticky(void) {
uint32_t adc_health_tpafe_gaps(void) {
return s_tpafe_gaps;
}
/* ===================== L5-2 贴轨观察器实现 ===================== */
/*
* 函数功能:贴轨观察器复位(幂等;adc_health_init() 已内含)
* 入口参数:无
* 返 回 值:无
* 限定条件:与 check() 同为单写者任务上下文
*/
void adc_health_rail_init(void) {
(void)memset(s_rail, 0, sizeof(s_rail));
s_rail_sticky = 0u;
}
/*
* 函数功能:单通道单拍贴轨观察(判据与释放沿前提见 adc_health.h 同名声明)
* 入口参数:ch / code / lo_th / hi_th - 见 adc_health.h
* 返 回 值:1 - 本拍新锁存(边沿);0 - 无新事件(含 ch 越界忽略) uint32_t
* 限定条件:每拍每通道至多一次;纯算术、零平台依赖
*/
uint32_t adc_health_rail_watch(uint8_t ch, int32_t code, int32_t lo_th, int32_t hi_th) {
adc_rail_t *p;
if (ch >= (uint8_t)ADC_RAIL_CH_NUM) {
return 0u;
}
p = &s_rail[ch];
if (code >= hi_th) { /* 贴上轨带:清对侧计数 */
p->run_lo = 0u;
if (p->run_hi < 0xFFFFu) {
p->run_hi++;
}
if ((p->run_hi >= (uint16_t)ADC_RAIL_N_RUN) && (p->seen_mid != 0u)
&& (p->latched == 0u)) {
p->latched = 1u;
s_rail_sticky |= (1uL << ch);
return 1u;
}
return 0u;
}
if (code <= lo_th) { /* 贴下轨带(对称) */
p->run_hi = 0u;
if (p->run_lo < 0xFFFFu) {
p->run_lo++;
}
if ((p->run_lo >= (uint16_t)ADC_RAIL_N_RUN) && (p->seen_mid != 0u)
&& (p->latched == 0u)) {
p->latched = 1u;
s_rail_sticky |= (1uL << ch);
return 1u;
}
return 0u;
}
/* 中带:记释放沿(锁存的必要前提),并清两侧连续计数 */
p->seen_mid = 1u;
p->run_hi = 0u;
p->run_lo = 0u;
return 0u;
}
/*
* 函数功能:读贴轨锁存位图
* 入口参数:无
* 返 回 值:bit ch = 曾检出贴轨(低 9 位有效) uint32_t
* 限定条件:无
*/
uint32_t adc_health_rail_sticky(void) {
return s_rail_sticky;
}
+91 -1
View File
@@ -8,12 +8,25 @@
* ④ 错误计数增量:FRSTDATA 跳变 / HAL ADC·DMA 错误 / STM32 停摆计数。
* ⚠ 本层**纯逻辑**:不碰寄存器、不取数、不打日志——输入是调用方
* 采集的计数快照,输出是故障位;日志与自检上报由消费者
* (`App/task/adc_task.c`)按位执行 → 主机侧可单测(L0.5,A-4)。
* (`App/task/adc_task.c`)按位执行 → 主机侧可单测(L0.5)。
* 适用平台:STM32F407ZGTx + TPAFE5160
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验(静默/丢帧/单调性/错误增量)
* v1.1 2026-10-21 王建锋 新增 adc_health_age_stale()(无模块状态的帧年龄
* 纯判据,参照系由调用方自存)与 ADC_HF_STALE 位:
* 判定层的 stall/backward 位只在 adcTask 每拍跑
* check() 时才可能置起,节拍源停摆会把本任务卡在
* osThreadFlagsWait 上 → 那些位永远是 0,而相量冻在
* 最后一帧仍被 protectTask 用于判据。该纯判据让保护侧
* 能用**自己的时基 + 中断侧计数器**独立判"数据还新不新" * v1.2 2026-10-25 DTU 项目组 新增 L5-2 残余「码值越界(贴轨)检出」:
* adc_health_rail_watch()/rail_sticky()/rail_init()
* ——连续 N 拍贴满量程带且该通道见过释放沿才锁存
* (空载恒 0 / 开机恒 0 是合法工况,不误报);
* 判据动因 docs/问题记录/采集与数据处理短板评测.md
* §6.2,门限来源 adc_cal_rail_ths()(按 FS 百分比)
*/
#ifndef ADC_HEALTH_H
@@ -51,6 +64,13 @@ extern "C" {
#define ADC_HE_FRSTDATA (1uL << 3) /* 本次 FRSTDATA 计数有增量 */
#define ADC_HE_HW_ERR (1uL << 5) /* 本次 HAL 错误计数有增量 */
/* 兜底新鲜度位(bit8,与判定层 bit0..bit7 位段错开)
* ⚠ 本位**不由本模块置位**:由 protectTask 调 adc_health_age_stale() 独立观测后
* OR 进闭锁位图。因此它不进 sticky、不进 adc_task 编排层的 logged 位图、
* 也不由本模块打日志——留痕走 protect 侧既有的闭锁边沿日志与 SOE。
* 位段纪律见 docs/算法/采样有效性校验设计说明.md §14 */
#define ADC_HF_STALE (1uL << 8) /* 帧年龄超限(电平,序号恢复前进即清) */
/* 计数快照(由调用方采集,本模块不触碰任何驱动/外设) */
typedef struct {
uint32_t tpafe_seq; /* tpafe5160_frame_seq() */
@@ -71,6 +91,15 @@ typedef struct {
uint32_t tpafe_stall_ms; /* TPAFE 当前静默时长(0=正常) */
} adc_health_status_t;
/* 帧年龄参照系(adc_health_age_stale() 用)。状态**归调用方所有**,所以多个观察者
* 各判各的、互不污染——这是它与本模块其余接口的关键区别(check() 用的是模块级
* 单写者状态,只能有一个每拍调用者) */
typedef struct {
uint32_t seq; /* 上次看到的序号 */
uint32_t adv_ms; /* 最后一次看到序号前进的时刻(与调用方时基同源) */
uint8_t primed; /* 0 = 尚未建立基线(首拍只记录、不判定) */
} adc_health_age_t;
/*
* 函数功能:采样有效性校验初始化(幂等,内核启动前可调)
* 入口参数:无
@@ -91,6 +120,23 @@ void adc_health_init(void);
*/
void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out);
/*
* 函数功能:帧年龄判定(无模块状态,供与消费者存活解耦的兜底门使用)
* 入口参数:a_p_age - 调用方自存的参照系
* a_cur_seq - 本次读到的序号(须是**中断侧**递增的计数器,如
* tpafe5160_frame_seq() / adc_input_stm32_seq())
* a_now_ms - 本次时基(与 a_p_age->adv_ms 取同一个任务的 tick)
* a_age_ms - 判老门限(ms)
* 返 回 值:1 - 已陈旧(调用方应据此闭锁);0 - 新鲜,或仍在建立基线
* 限定条件:任意任务上下文;零内核依赖、零共享状态 → 主机侧免桩可直接单测
* 函数说明:差值口径与 adc_health_check() 一致((int32_t) 差值,计数与 tick
* 回绕均安全)。首次调用只建基线并返回 0;序号有任何前进即返回 0
* (一次前进多帧属消费者超拍,不是故障);倒退不在此判老,单调性
* 破坏归 ADC_HF_SEQ_BACKWARD 管。参数错返回 0——宁可不锁,不可误锁。
*/
uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
uint32_t a_now_ms, uint32_t a_age_ms);
/*
* 函数功能:读当前电平故障位
* 入口参数:无
@@ -119,6 +165,50 @@ uint32_t adc_health_sticky(void);
*/
uint32_t adc_health_tpafe_gaps(void);
/* ===================== L5-2 残余:码值越界(贴轨)检出,2026-10-25 ===================== */
/* 贴轨锁存的连续拍数:20 拍 = 1 kHz 节拍下一个 50 Hz 周波。削顶/触轨波形的
* 贴带段持续 ≥ 半周波;噪声毛刺不可能连续 20 拍钉在同侧轨带内(短板评测 §6.2
* 判据①「连续 N 点贴满量程」的定标)。 */
#define ADC_RAIL_N_RUN 20u
/* 贴轨监视的通道数上限(9 = 8 电气量 + 温度;温度是否监视由调用方经
* adc_cal_rail_ths() 返回 -1 决定,本层不内嵌语义知识) */
#define ADC_RAIL_CH_NUM 9u
/*
* 函数功能:贴轨观察器复位(adc_health_init() 已内含,单独暴露供编排层复用)
* 入口参数:无
* 返 回 值:无
* 限定条件:与 check() 同——仅任务上下文(单写者)
*/
void adc_health_rail_init(void);
/*
* 函数功能:单通道单拍贴轨观察(L5-2 码值越界检出,纯算术)
* 入口参数:ch - 通道号(0..ADC_RAIL_CH_NUM-1,越界忽略) uint8_t
* code - 本拍原始码值(**码值域**,与标定无关) int32_t
* lo_th - 下轨带上界(code ≤ 此值算贴底) int32_t
* hi_th - 上轨带下界(code ≥ 此值算贴顶) int32_t
* 返 回 值:1 - 本拍**新锁存**该通道(边沿,供编排层落日志);0 - 无新事件 uint32_t
* 限定条件:每拍每通道至多一次(与采样帧同节奏,单写者)
* 函数说明:锁存条件 = 连续 ADC_RAIL_N_RUN 拍贴**同一侧**轨带,且该通道此前
* 出现过中带样本(「释放沿」)。释放沿要求的理由:空载信号恒 0、
* 开机未采到帧恒 0 都是**合法工况**(短板评测 §6.2:空载与零值都是
* 合法工况)——只有「动过又钉死在轨上」才是削顶/越界签名。锁存后
* 不再重复报边沿,直到 rail_init/health_init 重置。
*/
uint32_t adc_health_rail_watch(uint8_t ch, int32_t code, int32_t lo_th, int32_t hi_th);
/*
* 函数功能:读贴轨锁存位图
* 入口参数:无
* 返 回 值:bit ch = 该通道曾检出贴轨(1 kHz × 9 路 → 低 9 位有效) uint32_t
* 限定条件:无
* 函数说明:供心跳日志 / 后续品质位映射(卡 8/9 的 qds 消费者)只读。
*/
uint32_t adc_health_rail_sticky(void);
#ifdef __cplusplus
}
#endif
+26 -16
View File
@@ -12,6 +12,10 @@
* "TIM3 TRGO 硬件触发 + DMA 双缓冲消费":本层不再触发
* 也不等待转换(消除最坏 2~3 ms 忙等);新增三个 HAL
* 回调(ISR 内只计数)与 seq / hw_errs 观测量
* v2.1 2026-10-21 王建锋 丢帧计数口径由「次数」改为「帧点数」(累加每次前进
* 的超出部分),与判定层 TPAFE 侧
* adc_health_tpafe_gaps() 同单位。⚠ 换版前固件里心跳
* 字段 adc1_gap 是次数口径,不可跨版本直接对比
*/
#include "adc_input.h"
@@ -37,7 +41,7 @@
* 权威口径:docs/硬件平台/ADC通道映射说明.md(2026-10-09 硬件确认的语义分配)。
* index 含义:TPAFE 源 = 驱动 buf 下标 0..7;STM32 源 = ADC1 通道号 0..15。
* ⚠ 消费侧一律经本表按语义取数,**不得写死下标**(V0.1 有 13/17 路被置换,
* 网名编号与通道号不等价);V0.2 改版(N-18)只改本表内容。 */
* 网名编号与通道号不等价);V0.2 改版(消除双转换器无共同采样触发)只改本表内容。 */
static const adc_channel_desc_t k_adc_map[ADC_SEM_COUNT] = {
[ADC_SEM_UA] = { ADC_SRC_TPAFE, 6U }, /* TPAFE buf[6](ch7) */
[ADC_SEM_UB] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_14 }, /* PC4 */
@@ -69,7 +73,7 @@ static uint16_t s_stm32_dma[ADC_INPUT_STM32_DMA_LEN];
static volatile uint32_t s_dma_seq; /* DMA 半帧/整帧完成次数(ISR 递增,只增不清) */
static uint32_t s_dma_seq_seen; /* 消费侧上次看到的完成计数 */
static volatile uint32_t s_stm32_hw_errs; /* HAL ADC/DMA 错误回调累计(ISR 写) */
static uint32_t s_stm32_gaps; /* 丢帧:seq 一次前进 >1(消费者超拍漏读)*/
static uint32_t s_stm32_gaps; /* 丢帧点数:Σ(前进量-1),与 tgap 同单位 */
static uint8_t s_stall_run; /* 连续"无新帧"拍数(相位伪影只会是 1 拍)*/
/* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */
static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM];
@@ -79,21 +83,22 @@ static int s_stm32_rc; /* STM32 最近一次扫描状态码 */
static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */
/*
* 函数功能:消费一帧 STM32 ADC1 采样(L1-3,v2.0 DMA 双缓冲版)
* 函数功能:消费一帧 STM32 ADC1 采样(L1-3,DMA 双缓冲)
* 入口参数:无
* 返 回 值:0 - 取到新一帧;-2 - 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错)
* 限定条件:仅任务上下文调用;adc_input_init() 已挂好 DMA
* 函数说明:本函数**既不触发转换也不等待转换**——转换由 TIM3 TRGO 硬件触发、由 DMA
* 自动搬运。每拍只做"看完成计数是否前进 + 拷 9 个半字",故**零忙等**:
* 原轮询版常态 adcp=122 µs、超时拍最坏 2~3 ms,现在两档都降到 ~1 µs 量级。
* 单拍只剩"读一个计数 + 拷 9 个半字"→ µs 量级。
* 读的是"上一拍刚完成的那一半"乒乓缓冲(DMA 此刻正在写另一半)→ 无撕裂。
* ⚠ 计数未前进 = 本拍 STM32 侧**无有效数据**,必须清 s_stm32_valid,绝不
* 沿用上一拍旧值(同 2026-10-10 修的陈旧帧缺陷口径)→ adc_input_raw()
* 沿用上一拍旧值(半新半旧的混合帧会污染相量窗)→ adc_input_raw()
* 返 -2 → adc_task 走既有"整帧跳过"路径;下一拍自动恢复。
*/
static int stm32_take(void) {
uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */
const uint16_t *src;
uint32_t d;
uint8_t half;
uint8_t i;
@@ -105,7 +110,7 @@ static int stm32_take(void) {
* 看到"计数没前进"(那个完成已被上一拍消费掉)。这种伪影**只会连续 1 拍**——
* DMA 若真停,第 2 拍仍不会前进。故只有连续 >= ADC_INPUT_STALL_RUN_MAX 拍
* 无新帧才计入故障计数(实测开机日志突发期约 21~26 次伪影、稳态每 10 s 心跳 1 次,
* 全部为单拍;见 docs/子系统设计/ADC采样节拍设计说明.md §10.5) */
* 全部为单拍;见 docs/子系统设计/ADC采样节拍设计说明.md §8) */
if (s_stall_run < 255u) {
s_stall_run++;
}
@@ -115,11 +120,14 @@ static int stm32_take(void) {
return ADC_INPUT_RC_STALL;
}
if ((uint32_t)(seq - s_dma_seq_seen) > 1u) {
d = seq - s_dma_seq_seen;
if (d > 1u) {
/* 计数一次前进 >1 = 中间有帧没被读走(乒乓缓冲已被覆盖)→ **真丢帧**,
* 与"无新帧"分开计数:前者是消费者超拍(本任务单拍耗时 > 1 ms),
* 后者多半是相位伪影 */
s_stm32_gaps++;
* 后者多半是相位伪影。按**丢掉的帧数**累计(d-1),与判定层 TPAFE 侧的
* adc_health_tpafe_gaps() 同单位——否则心跳里 adc1_gap 与 tgap 一个是次
* 一个是点,跨字段对比会得出错误结论 */
s_stm32_gaps += (d - 1u);
}
s_stall_run = 0u;
s_dma_seq_seen = seq;
@@ -145,8 +153,8 @@ static int stm32_take(void) {
* 函数说明:逐条校验映射表与扫描配置是否一致——① TPAFE 源下标须落在
* buf[0..7];② STM32 源的通道号必须出现在 k_stm32_rank_ch[] 中,
* 否则该通道永远不会被扫描(读到的恒为陈旧值)。这是"通道宏写错 →
* 读到另一只脚/读不到"这一 §5 警告的代码级兜底(无信号源时无法做
* 引脚级注入验证,见 docs/硬件平台/ADC通道映射说明.md §8)。
* 读到另一只脚/读不到"这一警告的代码级兜底(警告口径与逐通道验证方法:
* docs/硬件平台/ADC通道映射说明.md §5、§8;台架无信号源时注入验证做不了)。
*/
static int map_check(void) {
uint8_t s;
@@ -232,7 +240,7 @@ int adc_input_init(void) {
* STM32 侧无新帧不影响 TPAFE 快照与返回值语义(其状态另由
* adc_input_stm32_rc() / adc_input_stm32_seq() 暴露)。
* ⚠ STM32 侧数据比 TPAFE 侧**滞后 1 拍**(乒乓双缓冲的固有延迟,
* 9 路一致);按 N-18,V0.1 本就禁止跨转换器做矢量运算。
* 9 路一致);因双转换器无共同采样触发,V0.1 本就禁止跨转换器做矢量运算。
*/
int adc_input_poll(void) {
int r = tpafe5160_snapshot(s_tpafe_raw, (uint8_t)ADC_INPUT_TPAFE_CH_NUM);
@@ -310,12 +318,14 @@ uint32_t adc_input_stm32_errs(void) {
}
/*
* 函数功能:读取 STM32 ADC1 的丢帧次数
* 函数功能:读取 STM32 ADC1 累计丢帧点数
* 入口参数:无
* 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被乒乓缓冲覆盖)
* 返 回 值:Σ(完成计数每次前进的超出部分),即被乒乓缓冲覆盖掉、没被消费的帧数
* 限定条件:无
* 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如突发日志突发期);
* 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障。
* 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如心跳日志突发期);
* 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障,**勿混读**。
* 单位与判定层 TPAFE 侧 adc_health_tpafe_gaps() 一致(都是帧点数),心跳里
* 可直接对比;注意 2026-10-21 之前固件该字段是"次数"口径。
*/
uint32_t adc_input_stm32_gaps(void) {
return s_stm32_gaps;
+13 -8
View File
@@ -4,8 +4,8 @@
* 暴露,使上层(UI / 自检 / 保护算法)**按语义取数,不写死下标**:
* ① 语义通道映射表(L1-4)——权威口径 docs/硬件平台/ADC通道映射说明.md;
* ② STM32 ADC1 9 通道采样(L1-3)——由 **TIM3 TRGO 硬件触发 + DMA 双缓冲
* 搬运**(v2.0),本层只消费、不触发、不等待。
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18):UA/UN/IA 走
* 搬运**,本层只消费、不触发、不等待。
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷:双转换器无共同采样触发):UA/UN/IA 走
* TPAFE5160(16 位 / ±5 V 双极性),UB/UC/IB/IC/IN 走 STM32 ADC1
* (12 位 / 0~3.3 V 单极性)。V0.2 改版(8 路电气量全部接 TPAFE)
* 只改映射表内容,本层 API 与上层语义不动。
@@ -16,8 +16,11 @@
* v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射)
* v2.0 2026-10-10 王建锋 STM32 侧改 TIM3 TRGO 硬件触发 + DMA 双缓冲消费:
* 消除逐通道轮询的 2 ms 忙等;节拍源 TIM6 -> TIM3
* (F4 上 ADC1 只认 TIM2/TIM3/TIM8 的 TRGO,TIM6 TRGO
* 仅接 DAC);新增 adc_input_stm32_seq()/_hw_errs()
* (换源判据:F4 上 ADC1 收不到 TIM6/TIM7 的 TRGO,见
* docs/子系统设计/ADC采样节拍设计说明.md §3.2);新增
* adc_input_stm32_seq() / _hw_errs()
* v2.1 2026-10-21 王建锋 adc_input_stm32_gaps() 单位由「次数」改为「帧点数」,
* 与判定层 adc_health_tpafe_gaps() 同单位
*/
#ifndef ADC_INPUT_H
@@ -44,7 +47,7 @@ typedef enum {
ADC_SEM_COUNT /* 语义通道数(哨兵) */
} adc_semantic_t;
/* 数据来源:V0.1 的 8 路电气量分属两片转换器(N-18) */
/* 数据来源:V0.1 的 8 路电气量分属两片转换器(双转换器无共同采样触发) */
typedef enum {
ADC_SRC_TPAFE = 0, /* TPAFE5160:16 位、±5 V 双极性、8 通道同步 */
ADC_SRC_STM32 /* STM32 ADC1:12 位、0~3.3 V 单极性、9 通道顺序扫描 */
@@ -61,7 +64,7 @@ typedef struct {
/* STM32 ADC1 顺序扫描通道数(Rank 1..9) */
#define ADC_INPUT_STM32_CH_NUM 9U
/* STM32 ADC1 最近一次取帧状态码(v2.0:DMA 消费,已无"轮询超时"这一档) */
/* STM32 ADC1 最近一次取帧状态码(DMA 消费,已无"轮询超时"这一档) */
#define ADC_INPUT_RC_OK (0) /* 取到新一帧 */
#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start_DMA 挂载失败 */
#define ADC_INPUT_RC_STALL (-2) /* 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错) */
@@ -129,12 +132,14 @@ int adc_input_stm32_rc(void);
uint32_t adc_input_stm32_errs(void);
/*
* 函数功能:读取 STM32 ADC1 的丢帧次数
* 函数功能:读取 STM32 ADC1 累计丢帧点数
* 入口参数:无
* 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被覆盖,未及消费)
* 返 回 值:Σ(完成计数每次前进的超出部分),即没被消费就被覆盖掉的帧数
* 限定条件:无
* 函数说明:与 errs 分开——gap = 消费者超拍(单拍耗时 > 1 ms),
* err = 节拍源/ DMA 停摆。两类故障处置不同,混在一起就查不了。
* 单位与判定层 adc_health_tpafe_gaps() 一致(帧点数),心跳里可直接对比;
* 2026-10-21 之前的固件该字段是"次数"口径。
*/
uint32_t adc_input_stm32_gaps(void);
+1 -1
View File
@@ -15,7 +15,7 @@
* ≈4 个信号周波),使窗长恰为纹波周期的整数倍 → δ(m+K)≈δ(m),残差进入
* 0.01 Hz 量级。f_hat 用上一拍读数(收敛后误差 <0.1 Hz → K_eff 偏差 <1 拍,
* 自洽)。
* **精度优先于响应的显式取舍(Lead 批准 2026-10-12)**:4 周波窗换来
* **精度优先于响应的显式取舍**:4 周波窗换来
* 0.317→0.0101 Hz,代价是频率骤变后的窗冲刷(J4 阶跃整定 ≤120 ms,实测
* 98 ms)。OF/UF 定时限默认 1000 ms → 测频响应占保护总动作时间 <10%;
* 暂态假值另有 J6(跳变后 ≤60 ms 回 ±0.05 Hz)兜底。故 J4 的 120 ms 是
+11 -5
View File
@@ -9,7 +9,7 @@
* 推导与实测见 freq.c 文件头与 test/host/freq 输出)。
* 覆盖规划 §3.3 L3-5 的测量侧与 §4.2 测频范围(45~55 Hz 建议段)。
*
* 原理与判据(详见 App/algo/README.md §3.7 与 test/host/freq/):
* 原理与判据(详见 docs/算法/采集与算法层设计说明.md §7 与 test/host/freq/):
* - 采样率 Fs = 1000 Hz(TIM3 节拍,与 phasor 同源);45~55 Hz 段每拍
* 相位增量 ≈ 0.28~0.35 rad,距 ±π 缠绕边界裕度巨大,无歧义;
* - 瞬时值钳制:单拍相位差折算越出 35~65 Hz(**假值带**,宽于系统合理带
@@ -21,7 +21,7 @@
* - 信号源优先级:UA 基波 → IA 基波(UA 无幅值时用 IA,台架只注电流亦可测频)。
*
* ⚠ 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(输入 = phasor 使用区)
* → 主机侧可免桩单测(L0.5,落实登记簿 A-4)。
* → 主机侧可免桩单测(L0.5)。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-12(B8)
@@ -49,9 +49,15 @@
#define FREQ_INST_MIN_HZ 35.0f
#define FREQ_INST_MAX_HZ 65.0f
/* 幅值门限(有效值,**原始码值口径**,L1-5 标定未实现):低于该值判
* "无有效波形"。⚠ 拟定值(远小于正常波形千级码值、远大于浮空噪声),
* 标定落地后须按工程量口径复核(同 prot_set 默认值"拟定"口径)。 */
/* 幅值门限(有效值,**二次值 V 口径**,「数据可信」卡 6 重定,依据其 §2.3 表第 1 行):
* 低于该值判"无有效波形"。取值 50 V = 0.5×Un(相电压标称 100 V,与
* App/algo/prot_set.c 头部量纲基准同源):正常带载 UA 在 0.9~1.1×Un,
* 50 V 之下只可能是断保险/PT 空载/浮空噪声(浮空实测 ~1.7 V 引脚域、
* 二次域更小),不会吃掉正常纹波(纹波谷值远大于 0.5×Un)。
* ⚠ 过渡期行为(eng=0,卡 12 标定前):帧值还是引脚电压(≤5 V < 50 V),
* 本门**恒失效** → freq_valid()=0 → OF/UF 按"必不启动"填充复位——与卡 5
* 未标定闭锁同向(判据链本就停走),不构成额外风险;标定后自动恢复本意。
* 数值沿用原 50.0f 属巧合(原为"远小于千级码值"的拟定值),不是漏改。 */
#define FREQ_AMP_MIN 50.0f
/* 相位增量滑窗环容量(拍):96 拍 = 96 ms 上限。实际平均窗长**自适应**
+170
View File
@@ -0,0 +1,170 @@
/*
* 模块名称:遥测快照发布层(L2 派生·发布侧,「数据可信」卡 8)
* 模块功能:实现见 meas.h——200 ms 基波有效值 + 品质快照,双区无锁读。
* 本文件纯算法:输入 = sample_ring 取窗 + 调用方注入(频率/削顶位图),
* 无 RTOS / 无寄存器 / 无日志(「数据可信」层内禁令:本目录不写日志)。
* 双区不变量(docs/任务规划/数据可信.md §4.5,L2-7 同型,先例 = App/algo/
* phasor.c 的双区注释):写侧只碰**非使用区**,全部字段填完后一次
* volatile 单字节 store 翻转使用区;读者先取索引再整体拷贝 → 拷贝期
* 间写者不可能改读者所在区(两帧间隔 200 ms 远大于 72 B 拷贝)。
* 单核顺序执行,无需内存屏障;volatile 阻编译器把字段写入挪到翻转后。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-25(道D 卡 8)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读
*/
#include "meas.h"
#include <stddef.h>
#include "phasor.h" /* phasor_calc():基波 DFT 纯函数核(与使用区同核) */
#include "sample_ring.h" /* sample_ring_window():以本拍为终止的一周波窗 */
/* ===== 双区(计算区 / 使用区),口径同 App/algo/phasor.c 的 L2-7 ===== */
static meas_snap_t s_ms_snap[2];
static volatile uint8_t s_ms_use;
static uint8_t s_ms_ready; /* 使用区是否已产出过有效结果 */
static uint32_t s_ms_seq; /* 只增帧号(写者单写者,读者原子单字读) */
/*
* 函数功能:清零一份快照(含 rsv 与全部数组元素)
* 入口参数:p - 目标快照
* 返 回 值:无
* 限定条件:p 非空
* 函数说明:静态辅助;逐字段清零(不依赖 memset,行为与本目录 phasor_zero 同型)。
*/
static void meas_zero(meas_snap_t *p) {
uint8_t i;
p->seq = 0u;
p->tick_ms = 0u;
p->valid = 0u;
p->rsv[0] = 0u;
p->rsv[1] = 0u;
p->rsv[2] = 0u;
p->freq_valid = 0u;
p->freq_hz = 0.0f;
for (i = 0u; i < (uint8_t)ADC_SEM_COUNT; i++) {
p->yc[i] = 0.0f;
p->qds[i] = 0u;
}
}
/*
* 函数功能:遥测快照层初始化
* 入口参数:无
* 返 回 值:0 - 成功
* 限定条件:内核启动前或任务上下文皆可;幂等,可重复调用
* 函数说明:清双区 + 索引/就绪/序号复位。init 后到首次发布前,meas_get() 恒
* 返 -2(out 清零)、meas_seq() 恒返 0。
*/
int meas_init(void) {
meas_zero(&s_ms_snap[0]);
meas_zero(&s_ms_snap[1]);
s_ms_use = 0u;
s_ms_ready = 0u;
s_ms_seq = 0u;
return 0;
}
/*
* 函数功能:一拍遥测发布(被调即发布,节拍把关在调用方)
* 入口参数:tick_ms - 本拍时基(毫秒) uint32_t
* freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t
* freq_hz_in - 频率读数(freq_last() 值) float
* clip_mask - 削顶通道位图(bit s = 语义通道 s,来源唯一 =
* adc_cal_flags(),编排方注入) uint32_t
* 返 回 值:无
* 限定条件:仅 adcTask 上下文、全仓唯一调用点(meas.h 发布纪律);须在
* phasor_run() 之后调用(本拍窗含最新点,与使用区同窗同核)
* 函数说明:电气 8 路(语义序中 TEMP 之前的全部通道,映射表属主 =
* docs/硬件平台/ADC通道映射说明.md)逐路一周波基波 DFT;任一路取窗
* 不足(开机未满一周波)→ 该路 IV|NT + 整帧 valid=0,但照常发布
* (品质节拍性刷新,见 meas_tick 头注)。TEMP 只取最新一点透传 +
* NT(「数据可信」决策 5)。品质值不因 IV 清零——"值在场但不可信"
* 与"值缺失"分开,消费者按 qds 裁决。
*/
void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in,
uint32_t clip_mask) {
meas_snap_t *z = &s_ms_snap[1u - s_ms_use]; /* 计算区 = 非使用区 */
float win[PHASOR_N];
phasor_t p;
uint8_t all_ok = 1u;
uint8_t s;
meas_zero(z);
z->seq = s_ms_seq + 1u;
z->tick_ms = tick_ms;
for (s = 0u; s < (uint8_t)ADC_SEM_TEMP; s++) {
if (sample_ring_window((adc_semantic_t)s, (uint8_t)PHASOR_N, win) == 0) {
phasor_calc(win, (uint8_t)PHASOR_N, 1u, &p);
z->yc[s] = p.fAmSQ; /* 有效值口径(系数已折算,同 phasor 层) */
if ((clip_mask & (1uL << s)) != 0u) {
z->qds[s] |= (uint16_t)MEAS_QDS_CLIPPED;
}
} else {
/* 本路窗不足:值缺位(yc 保持 0)+ IV|NT;整帧判不可信 */
z->qds[s] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT);
all_ok = 0u;
}
}
/* TEMP:非交流量语义(「数据可信」决策 5),最新一点原始帧值透传 + NT;
* clip_mask 位 8 不参与(adc_cal_flags(TEMP) 恒 0,无削顶语义) */
{
float tv = 0.0f;
if (sample_ring_window(ADC_SEM_TEMP, 1u, &tv) == 0) {
z->yc[ADC_SEM_TEMP] = tv;
z->qds[ADC_SEM_TEMP] = (uint16_t)MEAS_QDS_NT;
} else {
z->qds[ADC_SEM_TEMP] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT);
}
}
z->valid = all_ok;
z->freq_valid = (freq_valid_in != 0u) ? 1u : 0u;
z->freq_hz = freq_hz_in;
/* 全部字段就绪后才翻索引 + 置就绪(顺序即协议,勿交换) */
s_ms_seq = z->seq;
s_ms_use = (uint8_t)(1u - s_ms_use);
s_ms_ready = 1u;
}
/*
* 函数功能:读当前使用区快照(无锁整体拷贝)
* 入口参数:out - 输出快照;失败路径同样清零填充
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过 int
* 限定条件:任意上下文;无(读 volatile 索引 + 普通拷贝)
* 函数说明:先取索引再索引拷贝,避免拷贝中途索引被翻转导致"半旧半新"。
* 返回 0 时帧内部自洽(同一发布拍的 seq/tick_ms/yc/qds 成套)。
*/
int meas_get(meas_snap_t *out) {
uint8_t z;
if (out == NULL) {
return -1;
}
if (s_ms_ready == 0u) {
meas_zero(out);
return -2;
}
z = s_ms_use;
*out = s_ms_snap[z];
return 0;
}
/*
* 函数功能:读已发布帧数(只增)
* 入口参数:无
* 返 回 值:seq,0 = 从未发布(单字读,原子) uint32_t
* 限定条件:任意上下文
* 函数说明:轻量"是否换帧"探针,正式取数以 meas_get() 为准。
*/
uint32_t meas_seq(void) {
return s_ms_seq;
}
+136
View File
@@ -0,0 +1,136 @@
/*
* 模块名称:遥测快照发布层(L2 派生·发布侧,「数据可信」卡 8)
* 模块功能:每 **200 ms** 从 L2 层取一次基波有效值 + 品质,产出一份全仓唯一的
* 遥测快照(meas_snap_t,布局权威 = docs/任务规划/数据可信.md §4.5),
* 让 104 与 UI **共用同一份上游**——"什么时候算新值、品质怎么判"只此
* 一处决定(M5 遥测链的地基;104 改读快照属卡 9,UI 侧 ui_data_feed 属
* 「UI 扩展」波次,两者都只是本快照的读者)。
* 值口径(批 2 卡 8 的 A-lite 裁决,2026-10-25):**基波有效值瞬时透传**
* (同拍 phasor 使用区口径,系数已在 L1.5 折算进帧值——现单位 = ADC
* 引脚电压 V,二次值语义等 adc_cal_flags() 出 VALID,见
* docs/算法/采集与算法层设计说明.md §10 红线③);**不做** 4 点平均 /
* 谐波方和根——docs/任务规划/数据可信.md §4.5 明示"200 ms 是上送节拍
* 不是平均窗,再平均引入滞后";谐波 k-bin 轨(docs/算法/继电保护功能
* 复现规划.md L2-8)将来扩展时只许在本结构**尾部追加**或留在发布侧
* 内部状态,既有字段序不得变更。
* TEMP 通道(「数据可信」决策 5:不参与线性标定):yc = 最新一拍原始
* 帧值透传,qds 恒置 NT——值在场但非交流量语义,UI 按「数据可信」§4.2
* 三态表显示占位符,104 无对应点。
* 并发(docs/任务规划/数据可信.md §4.5 原文口径):沿用 phasor 的
* **双区 + 单字节活跃索引翻转**(L2-7 同型,不是新机制)——写侧填满
* 计算区后一次 store 翻转,读者先取索引再整体拷贝,全程无锁;
* seq 只增,消费者据此判"是否换帧"。
* 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(窗自 sample_ring、
* 频率/品质由调用方注入)→ 主机侧免桩单测 test/host/meas/(L0.5)。
* 发布纪律:唯一发布者 = adcTask(在 phasor_run()/freq_run() 之后 200 ms 分频
* 调 meas_tick())。评审判据 = 全仓 meas_tick 调用点数 == 1(「数据可信」
* §8 风险表"快照双写"行);其余任务/模块**只读**。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-25(道D 卡 8)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读
*/
#ifndef MEAS_H
#define MEAS_H
#include <stdint.h>
#include "adc_input.h" /* adc_semantic_t / ADC_SEM_COUNT:yc/qds 下标 = 语义通道 */
#ifdef __cplusplus
extern "C" {
#endif
/* 上送节拍(ms):adc_task 按本宏对 1 kHz 拍做**经过时间**分频(非拍计数——
* idle/skip 拍会把拍计数口径拉得比名义值长),落点见 App/task/adc_task.c */
#define MEAS_PERIOD_MS 200u
/* 每点品质位(uint16)。**低字节与线上 104 QDS 字节同值**(OV/NT/IV,数值
* 口径同 App/protocol/iec104/iec104_asdu.h 的 IEC104_QDS_*)——但本层**不得**
* include 协议层头(分层方向:算法层在协议层之下,反向依赖禁止),故在此自
* 定义同值常量;卡 9 的 104 侧消费直接截低字节上送,一致性由本目录单测与
* test/host/iec104/ 既有断言双向钉住。高字节为 meas 域扩展位(线上不直接
* 出现,由消费者映射): */
#define MEAS_QDS_OV (0x0001u) /* 溢出(留位,卡 8 不产出——判据待运行参数化) */
#define MEAS_QDS_NT (0x0020u) /* 非当前值:未就绪 / 语义未处理(TEMP) */
#define MEAS_QDS_IV (0x0080u) /* 无效:该路取窗失败,无有效读数 */
#define MEAS_QDS_CLIPPED (0x0100u) /* 硬件削顶(adc_cal_flags 位图直通,
* 「数据可信」§4.2 三态表;AC 量不可信,
* 消费者应映射为品质无效,如卡 9 的 104 IV) */
/* 遥测快照(docs/任务规划/数据可信.md §4.5 权威布局;实测 72 B,2×72 ≈ 144 B
* 双区 = 卡 8 DoD 预算口径,由 test/host/meas/ 的布局钉死用例锁字段偏移——
* 改字段 = 破协议,须同步卡 9 / UI) */
typedef struct {
uint32_t seq; /* 只增;消费者据此判断是否换帧 */
uint32_t tick_ms; /* 发布拍时基(osKernelGetTickCount 原值) */
uint8_t valid; /* 整帧有效:本拍 8 电气路取窗全部成功 */
uint8_t rsv[3]; /* 对齐填充,恒 0 */
float yc[ADC_SEM_COUNT]; /* 工程量(引脚电压 V 域;二次值等 VALID 标定) */
uint16_t qds[ADC_SEM_COUNT]; /* 每点品质 MEAS_QDS_*(值本身不因品质清零) */
uint8_t freq_valid; /* 频率读数新鲜度(freq_valid() 注入) */
float freq_hz; /* 频率读数(freq_last() 注入,未收敛为 0) */
} meas_snap_t;
/*
* 函数功能:遥测快照层初始化(清双区 / 索引 / 序号 / 就绪标志)
* 入口参数:无
* 返 回 值:0 - 成功 int
* 限定条件:内核启动前或任务上下文皆可(纯 .bss 状态,无 RTOS 依赖);
* 本仓落点 = adc_task_init()(每开机一次,幂等可重复调)
* 函数说明:init 后 meas_get() 返 -2、meas_seq() 返 0,直到首次 meas_tick()
* 发布——消费者据 -2 走"未就绪"分支,不读半初始化数据。
*/
int meas_init(void);
/*
* 函数功能:一拍遥测发布(200 ms 分频由调用方把关,本函数被调即发布一帧)
* 入口参数:tick_ms - 本拍时基(毫秒) uint32_t
* freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t
* freq_hz_in - 频率读数(freq_last() 值;从未收敛为 0.0) float
* clip_mask - 削顶通道位图(bit s = 语义通道 s;来源唯一 =
* adc_cal_flags() 的 ADC_CAL_F_CLIPPED,编排方注入,
* 本层不链 adc_cal——可用性属主在折算侧) uint32_t
* 返 回 值:无
* 限定条件:**仅 adcTask 上下文、全仓唯一调用点**(发布纪律见文件头);
* 须在 sample_ring_push_frame()/phasor_run() 之后调用,本拍窗才含最新点
* 函数说明:8 电气路逐路取"以本拍为终止的一周波"做基波 DFT(复用 phasor_calc
* 纯函数核,与 phasor 使用区同窗同核 → 同拍同值,见单测钉死),
* 任一路窗不足 → 该路 yc=0 + qds=IV|NT、整帧 valid=0(不产出半新半旧
* 混合帧;与 phasor_run 的差异:phasor 宁可不切区,meas **照常发布**
* 让品质位可见——消费者需要节拍性的品质刷新,seq 亦随之只增)。
* TEMP = 最新一点透传 + NT(见文件头)。末尾单字节翻转使用区,
* 读者无锁。
*/
void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in,
uint32_t clip_mask);
/*
* 函数功能:读当前使用区快照(无锁整体拷贝)
* 入口参数:out - 输出快照;失败(含 -2 未就绪)时**清零填充**,调用方可安全读
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过(out 已清零,seq=0) int
* 限定条件:任意上下文(任务级读者:iec104Task / uiTask / protectTask)
* 函数说明:先取一次使用区索引再索引拷贝——拷贝期间写者最多改"**非使用区**",
* 本帧无撕裂(双区不变量,同 phasor_get 口径;写者两帧间隔 200 ms
* 远大于 72 B 拷贝耗时,读者被压超 200 ms 的竞态窗口按 §4.5 设计接受)。
* 判"新不新"用 seq:与上次不同则换帧。
*/
int meas_get(meas_snap_t *out);
/*
* 函数功能:读已发布帧数(只增)
* 入口参数:无
* 返 回 值:seq(0 = 从未发布;单字 32 位读,Cortex-M 原子) uint32_t
* 限定条件:任意上下文
* 函数说明:轻量观测入口(104 周期上送可先比 seq 省 72 B 拷贝);
* 与 meas_get 之间无原子性配对要求(读者以 get 返回的 seq 为准)。
*/
uint32_t meas_seq(void);
#ifdef __cplusplus
}
#endif
#endif /* MEAS_H */
+3 -3
View File
@@ -44,7 +44,7 @@ static const float k_fft_cos[PHASOR_N] = {
/* ===== L2-2 相量实例表 =====
* 参考项目为 **4 个实例**(C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波,
* `APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`,
* 见规划 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U"
* 见 docs/算法/继电保护功能复现规划.md §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U"
* 拆为两行(IA 电流 / UA 电压),并补出 C04 的五次谐波轨(L1-9)。
* 新增实例只需在表尾加一行,其余代码自动适配。 */
static const phasor_inst_t k_inst[] = {
@@ -62,8 +62,8 @@ static const phasor_inst_t k_inst[] = {
* (单字节 store,Cortex-M 上原子),消费侧读"使用区" → 无锁、无撕裂。
* s_use_zone 声明 volatile:阻止编译器把结果写入挪到切换之后
* (本平台单核顺序执行,无需额外内存屏障)。
* 今日唯一消费者是 adcTask 自身(同拍读写、实际无竞争),本机制为后续
* 独立保护任务(L3)预留。 */
* 写者 = adcTask(同拍);读者跨两个上下文:adcTask 心跳与 protectTask 判据
* (prot_elm_run → phasor_get)→ 无锁读正是为后者服务,勿改成加锁或去掉双区。 */
static phasor_t s_zone[2][PHASOR_INST_N];
static volatile uint8_t s_use_zone;
static uint8_t s_ready; /* 使用区是否已产出过有效结果 */
+1 -1
View File
@@ -7,7 +7,7 @@
* 覆盖规划 §3.2:L2-1 系数表 / L2-2 实例 / L2-3 有效值输出 /
* L2-6 每 1 ms 滑动窗 / L2-7 双区指针原子交换。
* ⚠ 本层**纯算法**:不碰寄存器、不碰任务调度、不自己取数(输入由
* `sample_ring` 提供)→ 主机侧可单测(L0.5,落实登记簿 A-4)。
* `sample_ring` 提供)→ 主机侧可单测(L0.5)。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:王建锋
* 创建日期:2026-10-10
+70 -11
View File
@@ -18,12 +18,20 @@ typedef struct {
uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */
uint8_t src; /* 测量源:0 = phasor 基波有效值,1 = freq 测频 */
prot_set_id_t en_id; /* 投退定值 */
uint8_t trip_ctl; /* 跳闸投退定值项(PS_*_TRIP);PROT_ELM_NO_TRIP =
* 该元件没有这一项(告警类)→ 动作只出告警记录。
* 用 uint8_t 而不是 prot_set_id_t 就是因为要能表达
* "无此项"这个第 41 种取值(PS_ID_COUNT 是数组长度,
* 拿去喂 prot_set_get_ctl() 会越界读) */
prot_set_id_t set_id; /* 动作定值 */
prot_set_id_t dly_id; /* 延时定值(ms) */
prot_set_id_t ret_id; /* 返回系数定值(RET_OVER / RET_UNDER) */
const char *pprefix; /* 相量实例名前缀("C01"…);src=1 时为 0(不用) */
} pe_cfg_t;
/* k_pe[].trip_ctl 的"无此定值项"取值(见上方字段注释) */
#define PROT_ELM_NO_TRIP 0xFFu
/* 测量源枚举(pe_cfg_t.src) */
#define PE_SRC_PHASOR 0u
#define PE_SRC_FREQ 1u
@@ -31,18 +39,24 @@ typedef struct {
/* freq 无有效读数时给欠量元件的"必不启动"填充值(远大于任何欠量定值上限) */
#define PE_MEAS_BIG 1e30f
static const pe_cfg_t k_pe[PE_COUNT] = {
{ "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" },
{ "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" },
{ "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" },
{ "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" },
{ "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" },
{ "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" },
{ "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" },
/* 列序 = pe_cfg_t 字段序(name, over, src, en_id, trip_ctl, set_id, dly_id, ret_id,
* pprefix)。⚠ trip_ctl 紧跟 en_id 插入,新增元件必须照这一列的口径填:有跳闸投退
* 定值项的填 PS_*_TRIP,没有的填 PROT_ELM_NO_TRIP(= 该元件动作只出告警记录)。*/
static const pe_cfg_t k_pe[PE_COUNT] = { /* ← 表头注释见上,行内列序与之一致 */
{ "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_TRIP, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" },
{ "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_TRIP, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" },
{ "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_TRIP, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" },
{ "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_TRIP, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" },
/* ↓ 三项 + 下面两项频率元件:参考系统的告警段(0x8027~0x802A)本就没有「跳闸
投退」这一档,g_prot_set_meta 也无对应项 → PROT_ELM_NO_TRIP,动作一律归告警。
这不是兜底猜测,是照实反映("哪些元件算跳闸"的真源始终是这张表 + 定值表)。*/
{ "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PROT_ELM_NO_TRIP, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" },
{ "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PROT_ELM_NO_TRIP, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" },
{ "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PROT_ELM_NO_TRIP, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" },
/* 过频/低频(L3-5,B8):测量量 = freq 层测得频率(Hz),返回系数独立
* (RET_OF/RET_UF——频率滞回带与电气量不同量级,勿共用 0.95/1.05) */
{ "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 },
{ "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 },
{ "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PROT_ELM_NO_TRIP, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 },
{ "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PROT_ELM_NO_TRIP, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 },
};
/* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */
@@ -189,7 +203,8 @@ void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]) {
* 函数功能:适配层——按实例表 src 组装测量数组后调判据核
* 入口参数:now_ms - 单调时基(tick ms)
* 返 回 值:无
* 限定条件:仅保护消费者任务上下文;工程量↔码值换算 = 1(L1-5 标定未实现)
* 限定条件:仅保护消费者任务上下文;测量已是 L1.5 工程量(未标定域由
* protectTask 卡 5 闭锁挡在门外,本函数不感知标定态)
* 函数说明:src=PHASOR 行任一相量未就绪(未满一周波)→ **整拍跳过**,
* 不引入陈旧/半新测量(B5 口径)。src=FREQ 行:freq 有有效读数
* (started 且幅值门 OK)→ 填测得频率;否则按方向填"必不启动"值
@@ -258,6 +273,50 @@ uint32_t prot_elm_active_mask(void) {
return mask;
}
/*
* 函数功能:聚合启动位图
* 入口参数:无
* 返 回 值:bit i = 元件 i picked(越限电平,早于/等于 asserted)
* 限定条件:任意上下文(只读快照,无锁;与 prot_elm_active_mask 同口径)
* 函数说明:调用方是 protectTask 的「保护启动」记录边沿检测(task-10)。本函数
* **只聚合电平**,不造边沿——边沿属观测者状态(s_prev_pick 在那一侧),
* 判据核里再存一份"上一拍"会让 eval 不再幂等。
*/
uint32_t prot_elm_pick_mask(void) {
uint32_t mask = 0u;
uint32_t i;
for (i = 0u; i < (uint32_t)PE_COUNT; i++) {
if (s_rt[i].picked != 0u) {
mask |= (1uL << i);
}
}
return mask;
}
/*
* 函数功能:判定该元件动作上升沿应归哪一类记录(见 prot_elm.h 同号声明)
* 入口参数:id - 元件编号 prot_elm_id_t
* 返 回 值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(id 越界) prot_rec_kind_t
* 限定条件:任意上下文;只读,不改任何状态
* 函数说明:判据唯一来源 = k_pe[id].trip_ctl 指向的那枚 PS_*_TRIP 投退定值。
* trip_ctl == PROT_ELM_NO_TRIP 时**先返回告警**,绝不把 0xFF 喂给
* prot_set_get_ctl()(那是数组越界读,会读到定值区外的 RAM——
* 与"未标定拿 ADC 引脚电压当二次值"同类的静默错数据形状)。
*/
prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id) {
uint8_t trip_ctl;
if (id >= PE_COUNT) {
return PROT_REC_NONE;
}
trip_ctl = k_pe[(uint32_t)id].trip_ctl;
if (trip_ctl == (uint8_t)PROT_ELM_NO_TRIP) {
return PROT_REC_ALARM;
}
return (prot_set_get_ctl((prot_set_id_t)trip_ctl) != 0u) ? PROT_REC_TRIP : PROT_REC_ALARM;
}
/*
* 函数功能:取实例名(日志用)
* 入口参数:id - 元件编号
+42 -2
View File
@@ -4,7 +4,7 @@
* 同 phasor 实例表模式)。判据为**纯逻辑**(吃测量值 + 定值),
* 主机侧可离线单测;出口/SOE(L4)与任务编排为后续。
*
* V0.1 有效实例(N-18:仅 TPAFE 三路):
* V0.1 有效实例(双转换器无共同采样触发 → 仅 TPAFE 三路):
* OC1/OC2/OC3 三段式相间过流(A 相 IA,L3-1)
* OL 过负荷(A 相 IA,L3-3)
* OV/UV 过压/欠压(UA,L3-4)
@@ -22,7 +22,10 @@
* - 返回**即时**(参考 GRly_TimeRelay 带 10 ms 返回延时——V0.1 无出口硬件,
* 抖动只影响日志,P4 接出口时再补返回延时);
* - 复压/方向/无压无流闭锁(dwSetFyCtrl 类)随 P2/P3 元件扩展;
* - 工程量↔码值换算系数暂为 1(L1-5 标定未实现,同 phasor 层口径)。
* - 测量输入 = adc_task 经 adc_cal_apply() 折算后的工程量(L1.5,卡 2 已落地):
* 未标定(VALID=0)时单位只是 ADC 引脚电压——该域下判据必然错配,由
* protectTask 的未标定闭锁(卡 5)停走整条判据链,元件核本身不掺和
* 标定语义(保持纯逻辑);标定后单位 = 二次值,定值口径才名副其实。
*
* 线程约定:init 内核启动前或保护消费者任务上下文;eval/run 仅保护消费者任务
* 上下文(B5 起 = protectTask);request_reset 任意上下文(跨上下文复归请求,
@@ -97,6 +100,43 @@ void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out);
/* 动作位图(bit i = 元件 i asserted),供 L4 出口 / 心跳日志 */
uint32_t prot_elm_active_mask(void);
/* 启动位图(bit i = 元件 i picked),供「保护启动」记录边沿(task-10,2026-10-26):
* picked 是越限滞回后的电平,早于(或同拍于)asserted——只看 active_mask 会让
* "启动了但没到点"这一整段在记录里彻底消失,而它正是"保护启动"页要看的东西。 */
uint32_t prot_elm_pick_mask(void);
/*
* 元件动作边沿的记录归属(task-10「五类记录」)。
* ⚠ 本枚举刻意**不**用 syslog_id_t:那会把 App/log 的头拽进 algo 层,而
* App/algo/prot_elm.c 同时被 test/host/prot_dim 编进测试单元(那个运行器的
* include 路径里没有 App/log,且不在本道写域)→ 一改就在别人的单测上爆错。
* "元件 → 哪一类"是判据域事实(属主在本文件),"哪一类 → 哪个日志文件"是
* 记录域映射(属主在 App/task/protect_task.c 的一行三元),两边各自单源。
*/
typedef enum {
PROT_REC_TRIP = 0u, /* 跳闸型动作 → 事故记录 */
PROT_REC_ALARM, /* 告警型动作 → 保护告警记录 */
PROT_REC_NONE /* id 越界 → 调用方一条都不许落 */
} prot_rec_kind_t;
/*
* 函数功能:判定"该元件若发生动作上升沿,应归哪一类记录"
* 入口参数:id - 元件编号 prot_elm_id_t
* 返回值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(越界) prot_rec_kind_t
* 限定条件:任意上下文(只读 k_pe[] + prot_set RAM,不落任何状态)
* 函数说明:判据**唯一来源** = 该元件的跳闸投退定值项 g_prot_set_meta 里的
* PS_*_TRIP(其枚举注释即"0 告警 / 1 跳闸")。元件→投退项的对应关系
* 放在实例表 k_pe[] 的 trip_ctl 列(与 en_id/set_id/dly_id 同一张表,
* 新增元件只加一行);**本函数里没有第二份"哪些元件算跳闸"的名单**,
* 所以它不会与 g_prot_set_meta / k_pe[] / 整定视图门禁互相打架。
* 过压/欠压/零序过压/过频/低频五项本就没有 TRIP 投退项(告警类)→
* trip_ctl = PROT_ELM_NO_TRIP → 恒 PROT_REC_ALARM,这是照实反映,
* 不是兜底猜测。
* 定值巡检闭锁(prot_set_locked)不影响本判定:闭锁态下元件会被复位、
* 根本走不到动作上升沿,调用方无需在此重复判闭锁。
*/
prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id);
/* 实例名(ROM 常量,日志用) */
const char *prot_elm_name(prot_elm_id_t id);
+8
View File
@@ -5,6 +5,14 @@
* ⚠ 默认值/范围为拟定值(参考 EEPROM 出厂默认表不在本仓库,规划 §10.3 待补);
* 通讯地址逐项对照参考 Comm/Pub/Const_CommSetPara.c g_tCommSetParaTab。
*
* 量纲口径(「数据可信」卡 6 复核,2026-10-25):A/V 项 = **二次值**工程量,
* 标称基准 100 V / 1 A(UA/UB/UC 相电压、3U0、OV/UV 同域——OV 120 = 1.2×Un、
* UV 20 = 0.2×Un、3U0 15 = 0.15×Un;OC1/2/3 = 10/5/2 ×Ir、OL = 1.0×Ir,
* 三段递升与过负荷低于速断均自洽)。ADC 帧仅在 adc_cal VALID 置位后才处
* 于此域(未标定 = 引脚电压域,由卡 5 闭锁停走判据,见 protect_task.c);
* min/max 范围覆盖 0~4×标称,无需随本卡改动数值——换算依据与三处下游
* 常数同源,见 docs/任务规划/数据可信.md §2.3。
*
* 创建日期:2026-10-11
*/
#include "prot_set.h"
+7 -3
View File
@@ -26,9 +26,13 @@
#include <stdint.h>
/* Q10 定点:工程量 × 1024(与参考 2^10 口径一致) */
#define PROT_SET_Q10(v) ((int32_t)(((double)(v)) * 1024.0))
#define PROT_SET_FROM_Q10(v) ((float)((double)(v) / 1024.0))
/* Q10 定点:工程量 × 1024(与参考 2^10 口径一致)。
* 必须写成单精度:×/÷1024 只是 2^10 指数平移,float 与 double 的结果逐位相同
* (宏里 42 个字面量 + int32 全域往返均已穷举核对),而写成 double 会把
* MicroLIB 的软浮点拉进镜像——F407 只有单精度 FPU,本工程的保护判据每
* 20 ms 节拍每元件要读三次定值(见 prot_elm_run),软件双精度纯属白烧。 */
#define PROT_SET_Q10(v) ((int32_t)((float)(v) * 1024.0f))
#define PROT_SET_FROM_Q10(v) ((float)(v) / 1024.0f)
/* 定值项编号(枚举序 = RAM 数组序 = 校验和覆盖序,只增不删不改序) */
typedef enum {
+289
View File
@@ -0,0 +1,289 @@
/*
* 模块名称:运行参数段(L2/L3 整定层,「数据可信」卡 10)——实现
* 模块功能:见 run_par.h。工厂默认 = 「数据可信」卡 6 额定 0.5 % 档(电压 500
* mV / 电流 5 mA / 频率 500 mHz,毫单位 int32 定点落盘);[run_par]
* 段 9 键(db_ua…db_freq,均 ≤15 字符)≤ CFG_FILE_MAX_KEYS 不拆段
* (通道系数覆盖**不落本段**——直通 adc_cal 的 [adc_cal]/[adc_cal_ofs],
* 系数唯一真源在那两段,lead 裁②a)。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-25(道D 卡 10)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:[run_par] 段 + 104 映射 + 直通 adc_cal
* + B7 式脏标志落盘请求
*/
/* 头文件包含区 */
#include <math.h>
#include <stddef.h>
#include <string.h>
#include "run_par.h"
#include "adc_cal.h"
#include "cfg_file.h"
#define DBG_TAG "[PAR]"
#include "dbg_log.h"
/* 常量定义区 */
/* cfg 段名(≤ CFG_FILE_MAX_NAME_LEN);键名 6~7 字符小写 */
#define RUN_PAR_SEC "run_par"
/* 104 运行参数地址(复现规划 §5.1 类内落实;权威逐项表不在仓,送检点表到位
* 后以送检为准替换本常量段——lead 裁①。0x8027~0x802A = prot_set 告警段,永不让位) */
#define RUN_PAR_IOA_DB0 0x8030u /* 9 点:0x8030~0x8038 */
#define RUN_PAR_IOA_K0 0x8040u /* k 覆盖 8 路:0x8040~0x8047 */
#define RUN_PAR_IOA_B0 0x8048u /* b 覆盖 8 路:0x8048~0x804F */
/* 死区合法域上限(毫单位):电压 50 V / 电流 1 A / 频率 5 Hz——守卫挡误写,
* 不限工况;下限统一 0(负死区 = 配置错误) */
#define RUN_PAR_DB_MAX_U 50000L
#define RUN_PAR_DB_MAX_I 1000L
#define RUN_PAR_DB_MAX_F 5000L
/* 静态变量定义区 */
/* [run_par] 段 RAM 副本(引擎原地更新;属主契约与 µs 级混窗见「数据可信」§4.4,
* 同 adc_cal——本层不挂回调,副本即生效值,消费侧实时读) */
typedef struct {
int32_t db[RUN_PAR_DB_N];
} run_par_db_t;
static const run_par_db_t s_db_rom = {
{ 500L, 500L, 500L, 500L, /* UA/UB/UC/UN:0.5 V(卡 6 额定档) */
5L, 5L, 5L, 5L, /* IA/IB/IC/IN:0.005 A */
500L } /* 频率:0.5 Hz(绝对档,非额定百分比) */
};
static run_par_db_t s_db;
/* 键表 offset 与 run_par_db_t 字段一一对应(sizeof(int32_t) 步进) */
static const cfg_key_def_t s_db_keys[] = {
{ "db_ua", CFG_VAL_INT, 0u, 4u },
{ "db_ub", CFG_VAL_INT, 4u, 4u },
{ "db_uc", CFG_VAL_INT, 8u, 4u },
{ "db_un", CFG_VAL_INT, 12u, 4u },
{ "db_ia", CFG_VAL_INT, 16u, 4u },
{ "db_ib", CFG_VAL_INT, 20u, 4u },
{ "db_ic", CFG_VAL_INT, 24u, 4u },
{ "db_in", CFG_VAL_INT, 28u, 4u },
{ "db_freq", CFG_VAL_INT, 32u, 4u }
};
/* 落盘脏标志:104 写成功置位,run_par_store_save 成功清零(B7 prot_set 同款,
* 只置不清 = 写盘失败天然重试);单字节,跨任务 peek 无害 */
static uint8_t s_dirty;
/* 内部函数实现区 */
/*
* 函数功能:死区点量程上限(毫单位)
* 入口参数:idx - 0..8 uint8_t
* 返 回 值:上限;idx 越界返 0 长(一切值皆拒) 内 int32_t
*/
static int32_t s_db_cap_milli(uint8_t idx) {
if (idx < 4u) {
return RUN_PAR_DB_MAX_U;
}
if (idx < RUN_PAR_DB_N - 1u) {
return RUN_PAR_DB_MAX_I;
}
if (idx == (uint8_t)(RUN_PAR_DB_N - 1u)) {
return RUN_PAR_DB_MAX_F;
}
return 0L;
}
/*
* 函数功能:工程量浮点 → 毫单位定点(只舍不入的截断由调用侧范围守卫兜底)
* 入口参数:v - 工程量(须已判有限) float
* 返 回 值:四舍五入最近毫单位 内 int32_t
*/
static int32_t s_milli_of(float v) {
return (int32_t)lrintf(v * 1000.0f);
}
/*
* 函数功能:死区值校验(不提交),set/104 选择段共用同一守卫 = 两路语义同源
* 入口参数:idx - 0..8 uint8_t;db - 工程量 float
* 返 回 值:合法毫单位 ≥0;-1 非法(越界/NaN/负/越程) 内 int32_t
*/
static int32_t s_db_check(uint8_t idx, float db) {
int32_t milli;
if ((idx >= RUN_PAR_DB_N) || !isfinite(db) || !(db >= 0.0f)) {
return -1L;
}
milli = s_milli_of(db);
if ((milli < 0L) || ((long long)milli > (long long)s_db_cap_milli(idx))) {
return -1L;
}
return milli;
}
/* 公共函数实现区 */
/*
* 函数功能:初始化——副本置工厂默认 + 注册 [run_par] 段(幂等,内核前)
* 返 回 值:无
*/
void run_par_init(void) {
cfg_section_reg_t reg;
(void)memcpy(&s_db, &s_db_rom, sizeof(s_db));
s_dirty = 0u;
(void)memset(&reg, 0, sizeof(reg));
reg.name = RUN_PAR_SEC;
reg.p_data = &s_db;
reg.p_defaults = &s_db_rom;
reg.data_size = sizeof(s_db);
reg.p_keys = s_db_keys;
reg.key_count = (uint8_t)(sizeof(s_db_keys) / sizeof(s_db_keys[0]));
reg.p_cb = NULL; /* 不挂回调:RAM 副本即生效值(同 adc_cal) */
if (cfg_file_register(&reg) != 0) {
DBG_ERROR("register %s FAIL (dup/full?) - file values inactive",
RUN_PAR_SEC);
}
}
/*
* 函数功能:读第 idx 路死区(工程量)
* 返 回 值:V / A / Hz;越界返 0.0(消费侧按"无死区"保守处理) float
*/
float run_par_deadband(uint8_t idx) {
if (idx >= RUN_PAR_DB_N) {
return 0.0f;
}
return ((float)s_db.db[idx]) / 1000.0f; /* 毫→工程唯一换算点 */
}
/*
* 函数功能:写第 idx 路死区(守卫通过才提交;成功置脏)
* 返 回 值:0 成功;-1 非法(副本不动) int
*/
int run_par_deadband_set(uint8_t idx, float db) {
int32_t milli = s_db_check(idx, db);
if (milli < 0L) {
return -1;
}
s_db.db[idx] = milli;
s_dirty = 1u;
return 0;
}
/*
* 函数功能:104 运行参数地址读(命中集精确匹配,见 run_par.h 地址表)
* 返 回 值:0 命中;-1 非本段(调用方落 prot_set) int
*/
int run_par_104_read(uint32_t addr, float *out) {
if (out == NULL) {
return -1;
}
if ((addr >= RUN_PAR_IOA_DB0) && (addr < RUN_PAR_IOA_DB0 + RUN_PAR_DB_N)) {
*out = run_par_deadband((uint8_t)(addr - RUN_PAR_IOA_DB0));
return 0;
}
if ((addr >= RUN_PAR_IOA_K0) && (addr < RUN_PAR_IOA_K0 + RUN_PAR_CH_N)) {
*out = adc_cal_k_uv((adc_semantic_t)(addr - RUN_PAR_IOA_K0));
return 0;
}
if ((addr >= RUN_PAR_IOA_B0) && (addr < RUN_PAR_IOA_B0 + RUN_PAR_CH_N)) {
*out = adc_cal_b_codes((adc_semantic_t)(addr - RUN_PAR_IOA_B0));
return 0;
}
return -1;
}
/*
* 函数功能:104 运行参数地址写(两段式;k/b 直通 adc_cal_set——**run_par 不存
* 系数**,系数唯一真源 = [adc_cal]/[adc_cal_ofs],lead 裁②a)
* 返 回 值:0 接受 / -1 拒绝 / -2 非本段(调用方落 prot_set) int
* 函数说明:选择段 = 校验不提交(死区走与 set 同源守卫;k/b 的 adc_cal 合法带
* 由执行段裁决——IEC104 允许执行段 PN,两段结果可不一致,注释在此
* 明说防"选择过了怎么还 PN"的现场困惑);k/b 单侧写 RMW 用**当前
* 生效值**做另一侧(生效值经合法带哨兵,天然满足再写校验)。
*/
int run_par_104_write(uint32_t addr, float v, uint8_t select) {
uint8_t slot; /* 每个命中分支先赋值再使用(无未init读路径) */
int32_t k_q16;
int32_t b_codes;
int rc;
if ((addr >= RUN_PAR_IOA_DB0) && (addr < RUN_PAR_IOA_DB0 + RUN_PAR_DB_N)) {
int32_t milli;
slot = (uint8_t)(addr - RUN_PAR_IOA_DB0);
milli = s_db_check(slot, v);
if (milli < 0L) {
return -1;
}
if (select == 0u) {
s_db.db[slot] = milli;
s_dirty = 1u;
}
return 0;
}
if ((addr >= RUN_PAR_IOA_K0) && (addr < RUN_PAR_IOA_K0 + RUN_PAR_CH_N)) {
slot = (uint8_t)(addr - RUN_PAR_IOA_K0);
if (!isfinite(v) || !(v > 0.0f) || (v > 1.0e9f)) {
return -1;
}
if (select != 0u) {
return 0; /* 粗守过了 ≠ 执行必过:合法带裁决在执行段(见函数头) */
}
k_q16 = (int32_t)lrintf(v * 65536.0f);
b_codes = (int32_t)lrintf(adc_cal_b_codes((adc_semantic_t)slot));
rc = adc_cal_set((adc_semantic_t)slot, k_q16, b_codes);
if (rc == 0) {
s_dirty = 1u;
}
return rc;
}
if ((addr >= RUN_PAR_IOA_B0) && (addr < RUN_PAR_IOA_B0 + RUN_PAR_CH_N)) {
slot = (uint8_t)(addr - RUN_PAR_IOA_B0);
if (!isfinite(v) || (v < -1000000.0f) || (v > 1000000.0f)) {
return -1; /* 粗守 ±1e6 码;±8192 真带由 adc_cal 执行段裁 */
}
if (select != 0u) {
return 0;
}
b_codes = (int32_t)lrintf(v);
k_q16 = (int32_t)lrintf(adc_cal_k_uv((adc_semantic_t)slot) * 65536.0f);
rc = adc_cal_set((adc_semantic_t)slot, k_q16, b_codes);
if (rc == 0) {
s_dirty = 1u;
}
return rc;
}
return -2;
}
/*
* 函数功能:落盘脏探查(非消费)
* 返 回 值:1 有未落盘整定;0 干净 uint8_t
*/
uint8_t run_par_dirty_peek(void) {
return s_dirty;
}
/*
* 函数功能:执行落盘(整档外科保存成功后清脏;失败脏保持 = 下轮重试)
* 返 回 值:0 成功/无脏;-1 写盘失败 int
* 限定条件:defaultTask(配置引擎属主)上下文,SYS_EVT_RUN_PAR_SAVE 驱动
* 函数说明:一次 save 同时覆盖 [run_par] 9 键与刚被 adc_cal_set 改写的
* [adc_cal]/[adc_cal_ofs] 副本(lead 裁②:一条事件位一次 save)。
*/
int run_par_store_save(void) {
if (s_dirty == 0u) {
return 0;
}
if (cfg_file_save() != 0) {
DBG_ERROR("cfg save FAIL - keep dirty, retry next round");
return -1;
}
s_dirty = 0u;
return 0;
}
+130
View File
@@ -0,0 +1,130 @@
/*
* 模块名称:运行参数段(L2/L3 整定层,「数据可信」卡 10)
* 模块功能:遥测死区(9 点)与通道标定系数覆盖(k/b 各 8 路)的**单一真源**:
* - 死区 = 本模块 int32 毫单位 RAM 副本(注册进 cfg 引擎 [run_par] 段,
* 工厂默认 = 「数据可信」卡 6 额定 0.5 % 档),104 消费侧
* (App/protocol/iec104/iec104_pts.c)每拍实时读取——无投递、无缓存
* 副本(卡 9 曾在 pts 侧留运行时副本 s_yc_db,卡 10 落地后收编于此,
* 双源必打架);
* - 系数覆盖 = **直通 adc_cal_set()**([adc_cal]/[adc_cal_ofs] 是系数
* 唯一真源,卡 4 已定;本模块只加 104 地址映射 + 落盘请求,不另存
* 一份系数——同 D1 裁"两处判断必打架"逻辑)。
* 104 地址映射(docs/算法/继电保护功能复现规划.md §5.1 运行参数类
* 0x8020H~0x8058H;逐项表原文"待补"(参考项目 CommPara_ConfigTable
* 不在仓),本表即卡 10 的落实,送检点表若另给权威地址只改常量段:
* 0x8030~0x8038 = 死区 UA/UB/UC/UN/IA/IB/IC/IN/FREQ(工程量 V/A/Hz);
* 0x8040~0x8047 = k 覆盖(µV/码);0x8048~0x804F = b 覆盖(码值,可负);
* 其余留白(未启用类别),**0x8027~0x802A 属 prot_set 告警段**,本表
* 永不让位(两表按精确地址分流,见 iec104_pts.c set 路由)。
* 持久化(B7 prot_set 先例同款):任一写成功 → 脏标志只置不清;
* iec104Task 主循环 peek → 置 SYS_EVT_RUN_PAR_SAVE → defaultTask
* (配置引擎属主)run_par_store_save() 调 cfg_file_save() 整档外科
* 保存(含 [run_par] 与刚被 adc_cal_set 改写的 [adc_cal]/[adc_cal_ofs]
* 副本)→ 成功清脏;失败留脏 = 下轮天然重试。本模块自身零文件 IO
* (属主纪律)。
* 并发契约:引擎副本 int32 对齐字读写在 Cortex-M4 上原子;poll 重载 µs 级窗口
* 为「数据可信」§4.4 明载设计(adc_cal 同款)。写路径 = 104 执行段
* (iec104Task)与引擎读路径互不加锁,勿改成"读侧缓存"(缓存 = 新真源)。
* 纯算法:只用 stdint/stddef/math + cfg_file.h/adc_cal.h 头(注册/直通均为
* 接口级)→ 主机侧可单测(test/host/run_par,链真 cfg 引擎 + 真 adc_cal)。
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
* 作者:DTU 项目组
* 创建日期:2026-10-25(道D 卡 10)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:[run_par] 段 + 104 地址映射 + 直通
* adc_cal + B7 式脏标志落盘请求
*/
#ifndef RUN_PAR_H
#define RUN_PAR_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 死区/覆盖点数(死区 = YC 表行数:电气 8 + 频率;覆盖 = 语义电气 8 路) */
#define RUN_PAR_DB_N 9u
#define RUN_PAR_CH_N 8u
/*
* 函数功能:运行参数初始化——副本置工厂默认 + 注册 [run_par] 段进 cfg 引擎
* 入口参数:无
* 返 回 值:无
* 限定条件:**内核启动前**调用(app_main_init,adc_cal_init 之后);注册必须先
* 于 defaultTask 的 cfg_file_load(FTP 鉴权确立的契约,同 adc_cal 先例);幂等
* 函数说明:注册失败(段名重复/段数超限)打 [ERR] 后继续——工厂默认照常生效,
* 仅文件整定失效(adc_cal_init 同款降级)。
*/
void run_par_init(void);
/*
* 函数功能:读第 idx 路遥测死区(工程量)
* 入口参数:idx - 0..7 = 语义通道序(UA..IN),8 = 频率 uint8_t
* 返 回 值:死区 V / A / Hz;idx 越界返 0.0(调用方按"无死区"保守处理) float
* 限定条件:任意上下文(int32 单字读原子;104 COV 每拍调用)
* 函数说明:毫单位 → 浮点的换算点(除以 1000 唯一在此,勿在消费侧另写)。
*/
float run_par_deadband(uint8_t idx);
/*
* 函数功能:写第 idx 路死区(工程量,含范围守卫 + 脏置位)
* 入口参数:idx - 同上 uint8_t;db - 死区工程量(V/A/Hz,≥0) float
* 返 回 值:0 成功;-1 越界/越程/NaN(副本不动) int
* 限定条件:**iec104Task 上下文**(104 执行段 / 卡 11 后的整定路径;UI 若整定
* 需另行走配置引擎属主路径,勿跨任务直写)
* 函数说明:合法域按点型分档——电压 0~50 V、电流 0~1 A、频率 0~5 Hz(上限 =
* 额定 × 二档裕量,守卫目的是挡误写不是限工况;见 .c 常量段)。
* 成功即置脏(落盘请求,见文件头持久化契约)。
*/
int run_par_deadband_set(uint8_t idx, float db);
/*
* 函数功能:104 运行参数地址读(C_RD 路由,先于 prot_set 扫描)
* 入口参数:addr - 通讯地址(0x8020~0x8058 段内) uint32_t;out - 工程量浮点
* (死区 V/A/Hz;k 覆盖 µV/码;b 覆盖 码值) float *
* 返 回 值:0 命中并写出;-1 地址不归本段(调用方继续走 prot_set) int
* 限定条件:iec104Task 上下文
* 函数说明:命中集 = 0x8030~0x8038 / 0x8040~0x8047 / 0x8048~0x804F 精确匹配;
* k/b 读回 = adc_cal **当前生效值**(与折算同视图:文件坏值被合法带
* 哨兵回退 ROM 时,回显的就是 ROM——主站看到的即下游实际吃到的)。
*/
int run_par_104_read(uint32_t addr, float *out);
/*
* 函数功能:104 运行参数地址写(C_SE 两段式路由,先于 prot_set 扫描)
* 入口参数:addr - 通讯地址 uint32_t;v - 工程量浮点 float;
* select - 1 = 选择段(只校验不落副本)/ 0 = 执行段 uint8_t
* 返 回 值:0 接受;-1 拒绝(越程/NaN/合法带外/非本段可写地址命中守卫);
* -2 地址不归本段(调用方继续走 prot_set) int
* 限定条件:iec104Task 上下文;执行段成功即置脏
* 函数说明:k/b 覆盖经 adc_cal_set() 合法带哨兵(µV/码 ×[0.5,2.0]、|b|≤8192,
* 越带 -1 → 104 侧 PN 否定确认,adc_cal 不留痕由**其自身**越带 [ERR]
* 机制负责——本模块直通不重复留痕);两段式与 prot_set 定值写同构。
*/
int run_par_104_write(uint32_t addr, float v, uint8_t select);
/*
* 函数功能:落盘脏标志探查(非消费)
* 入口参数:无
* 返 回 值:1 = 有已整定未落盘的改动;0 = 干净 uint8_t
* 限定条件:任意上下文(单字节);iec104_serv 主循环 peek 后投递事件(B7 同款)
*/
uint8_t run_par_dirty_peek(void);
/*
* 函数功能:执行落盘请求(cfg_file_save 整档外科保存 + 成功清脏)
* 入口参数:无
* 返 回 值:0 成功(或无脏可存);-1 写盘失败(脏保持,下轮重试) int
* 限定条件:**defaultTask(配置引擎属主)上下文**,由 SYS_EVT_RUN_PAR_SAVE 驱动
* 函数说明:save 走引擎唯一写闸(超长拒写/原子改名);本函数不清
* 引擎以外模块的脏语义([adc_cal] 副本值已由写路径就位)。
*/
int run_par_store_save(void);
#ifdef __cplusplus
}
#endif
#endif /* RUN_PAR_H */
+1 -1
View File
@@ -1,7 +1,7 @@
/*
* 模块名称:采样环缓冲(L2 相量计算的输入)
* 模块功能:按**语义通道**保存最近若干周波的采样值,供 L2 相量计算按窗取数。
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18),本层只做"按时序
* V0.1 上 8 路电气量分属两片转换器(双转换器无共同采样触发),本层只做"按时序
* 存/取",**不做任何跨通道运算**——同刻性由上游(L1)保证,本层不假设。
* 容量取 **2 个周波(40 点)**,与参考系统 `CN_BUFSAM_CYCLE = 2`
* (规划 §10.1)一致,为后续加长数据窗(1.5~2 周波,抗衰减直流,
+1 -1
View File
@@ -7,7 +7,7 @@
| 文件 | 作用 |
|---|---|
| `app_main.c/h` | **应用初始化入口 `app_main_init`**:时间子系统优先 → RS485 → NAND → ADC → 诊断(在 `osKernelStart` 之前执行) |
| `app_cfg.h` | **功能模块编译开关**(裁剪测试套件/模块用) |
| `app_cfg.h` | **功能裁剪的决策占位说明**——2026-10-22 起**不含任何宏**(原 3 个 `APP_*_ENABLE` 零消费点,已删;接回方法见该文件修改记录 v1.2) |
| `sys_events.h` | **系统就绪事件定义**(CMSIS-RTOS v2 Event Flags:`SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` / `SYS_EVT_FTP_READY` / `SYS_EVT_TIMESYNC_READY` 及命令位) |
## 初始化顺序(`app_main_init`)
+12 -33
View File
@@ -1,47 +1,26 @@
/*
* 模块名称:Application Configuration
* 模块功能:应用层全局配置宏(**预留占位,当前均未接线**)
* 模块功能:功能裁剪开关的决策占位说明(**本头文件当前不提供任何宏,也不被任何 .c 包含**)
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-04 王建锋 P2-12:下列宏此前被表述为"功能模块使能开关",但全库
* 零代码引用(grep 仅本文件有定义)——"开关名存实亡"会
* 误导评审者以为模块可裁剪。现如实标注为**预留占位**:
* 当前**不门控任何代码**,各模块一律编译进产品。是否/
* 如何接线取决于产品定义(docs/任务规划/README.md §2 阶段 B0),
* 确认后再在真实初始化点(如 app_main.c 的 rs485_init /
* tpafe5160_init)加门控;在此之前**不要**把它们当开关用。
* v1.1 2026-10-04 王建锋 如实标注三个 APP_*_ENABLE 为"未接线占位"(全库零消费点)
* v1.2 2026-10-22 王建锋 收口(登记簿 C5):删除 4 个零消费点的宏
* (APP_NET_ENABLE / APP_ADC_ENABLE / APP_RS485_ENABLE /
* APP_SYS_TIME_SRC)。留"看起来能裁剪、其实不生效"的宏比不留更糟:
* 评审者会误信模块可裁剪,而关掉它们不会产生任何编译期效果。
* 需要真实门控时(产品定义 B0 落定后),在真实初始化点接入并**在本
* 文件登记**,同时接回 App/app/app_main.c 的 include;在此之前
* 本模块一律编译进产品(各外设的常驻事实见
* docs/环境与部署/项目部署环境要求.md §3.2)。
* 注意:本文件不是 App/app/README.md 里说的"编译开关",那行已同步更正。
*/
#ifndef APP_CFG_H
#define APP_CFG_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* 系统时钟源选择(**预留占位,未接线**)
* 0 - DWT CYCCNT(当前实现:console_log.c 直接使用 DWT,未读本宏)
* 1 - TIM2(未实现)
* 说明:本宏当前无任何消费点;实际的"与时间源无关硬上界"由 console_log.c 内联
* 使能 DWT 实现,不经过本宏。
*/
#define APP_SYS_TIME_SRC 0
/*
* 功能模块"预留开关"(**当前均未接线,不门控任何代码**)
* 三个宏一律保持定义(= 模块编译进产品);它们**不产生任何编译期效果**,
* 仅作为产品定义(B0)落定后接入真实门控的占位锚点。
*/
#define APP_NET_ENABLE
#define APP_ADC_ENABLE
#define APP_RS485_ENABLE
#ifdef __cplusplus
}
#endif
/* 故意不提供任何宏:功能裁剪当前不存在。理由与接回方法见上方修改记录 v1.2。 */
#endif /* APP_CFG_H */
+46 -14
View File
@@ -12,7 +12,6 @@
*/
#include "app_main.h"
#include "app_cfg.h"
#include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */
#include "stm32f4xx_it.h" /* fault_handler_enable() */
@@ -24,12 +23,15 @@
#include "gd5f2gq5ue.h"
#include "tpafe5160.h"
#include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */
#include "adc_cal.h" /* L1.5 标定层:adc_cal_init()(ROM 系数 + cfg 两段注册,道A 卡2/4) */
#include "run_par.h" /* 运行参数段:run_par_init()([run_par] 注册 + 104 地址映射真源,道D 卡10) */
#include "iec104_comm.h" /* [comm] 段:iec104_comm_init()(ca/端口注册,道G task-8) */
#include "phasor.h" /* L2 相量层:phasor_init()(系数表 + 实例 + 双区交换) */
#include "freq.h" /* L2 派生量:freq_init()(相位解缠绕测频,B8) */
#include "prot_set.h" /* L3 定值模型:prot_set_init()(B5,内核前封存) */
#include "prot_elm.h" /* L3 元件框架:prot_elm_init()(B5,解析相量索引) */
#include "sys_log.h"
#include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */
#include "task_watchdog.h" /* task_wd_seg_report():第 8 轮复位不丢"进段打点"上报 */
#include "test_config.h" /* 套件开关:身份戳的构建变体串(FW_BUILD_VARIANT)依赖本头,
* 改 TEST_SUITE_* / ENABLE_DESTRUCTIVE_TESTS 必重编本 TU →
* __DATE__/__TIME__ 随之刷新,见 app_main_init() 的戳注释 */
@@ -130,7 +132,7 @@ int app_main_init(void) {
/* 日志引擎初始化(创建待发队列;须先于任何 syslog_post) */
(void)syslog_mcu_init();
/* 启动锚点(2026-10-02 S10):本函数在内核启动前执行、不创建任何 RTOS 对象,
/* 启动锚点(2026-10-02 加):本函数在内核启动前执行、不创建任何 RTOS 对象,
* 恒成功且无副作用;本行是"启动进度轨迹"的起点——之后的每一步都有结果日志,
* 故某次启动卡死时,最后一行日志即可指向卡住的步骤。 */
DBG_INFO("boot: syslog init OK");
@@ -165,10 +167,10 @@ int app_main_init(void) {
}
}
/* 0b) round 8 取证:上报上一轮 defaultTask 主循环的**最后进段**。
/* 0b) 第 8 轮取证:上报上一轮 defaultTask 主循环的**最后进段**。
* 轨迹槽在 .noinit(scatter UNINIT_CCM),IWDG/引脚复位不清 SRAM →
* 误复位后开机第一时间可见 `PREVRUN seg=N`,直接指向主循环阻塞点
* (round 7 观察"判定分支未执行"经 round 8b 更正:真因是 IWDG 超时被静默
* (第 7 轮观察"判定分支未执行"经第 8b 轮更正:真因是 IWDG 超时被静默
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。
* 裸 UART 直发,且一次复位只报一次(内部清 magic)。 */
task_wd_seg_report();
@@ -177,14 +179,14 @@ int app_main_init(void) {
* 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳)
* sd2506_init() 从 RTC 读取初始时间
* sys_clock_init() 将 RTC 时间转为 Unix epoch,建立墙钟基准
* S10(2026-10-02):sd2506_init 原先所有失败路径静默返回,是启动段
* 启动段可观测性(2026-10-02 加):sd2506_init 原先所有失败路径静默返回,是启动段
* 唯一无日志的初始化;现内部已逐路径打 DBG_ERROR,此处再补一行
* 带错误码的结果回执,使 I2C 不通时能看到"在哪一步、什么错"。
*/
{
int rtc_ret = sd2506_init();
bsp_err_t rtc_ret = sd2506_init();
DBG_INFO("SD2506 init: %s (ret=%d)",
(rtc_ret == SD2506_OK) ? "OK" : "FAILED", rtc_ret);
(rtc_ret == BSP_OK) ? "OK" : "FAILED", rtc_ret);
}
if (sys_clock_init() != 0) {
syslog_post(SYSLOG_SELFTEST, "clock chip fault");
@@ -199,7 +201,7 @@ int app_main_init(void) {
DBG_INFO("NAND init: %s",
(ret == GD5F_OK) ? "OK" :
(ret == GD5F_ERROR) ? "ERROR" :
(ret == GD5F_BUSY_TIMEOUT) ? "BUSY TIMEOUT" :
(ret == GD5F_WAIT_EXPIRED) ? "BUSY TIMEOUT" :
(ret == GD5F_ECC_ERROR) ? "ECC ERROR" :
(ret == GD5F_PROGRAM_FAIL) ? "PROGRAM FAIL" :
(ret == GD5F_ERASE_FAIL) ? "ERASE FAIL" :
@@ -209,10 +211,12 @@ int app_main_init(void) {
}
/* 4) TPAFE5160 ADC 测试 */
int tp_ret = tpafe5160_init();
bsp_err_t tp_ret = tpafe5160_init();
DBG_INFO("ADC init: %s",
(tp_ret == TPAFE5160_OK) ? "OK" : "TIMEOUT");
if (tp_ret != TPAFE5160_OK) {
(tp_ret == BSP_OK) ? "OK" :
(tp_ret == BSP_ERR_TIMEOUT) ? "TIMEOUT" :
(tp_ret == BSP_ERR_NO_DEVICE) ? "NO CHIP" : "FAIL");
if (tp_ret != BSP_OK) {
syslog_post(SYSLOG_SELFTEST, "ADC fault");
}
@@ -249,6 +253,34 @@ int app_main_init(void) {
(void)freq_init();
DBG_INFO("freq layer init: OK");
/* 4c-3) 通道标定层(L1.5,「数据可信」卡 2/卡 4)初始化:RAM 系数副本置
* ROM 默认(卡 1 定死的引脚侧 LSB,docs/硬件平台/ADC通道映射说明.md §7.1)
* + 向配置引擎注册 [adc_cal] / [adc_cal_ofs] 两段。注册必须先于
* defaultTask 的 cfg_file_load(「先注册后 load」契约)→ 只能落内核前;
* 折算消费在 adcTask 每拍(adc_cal_apply),标定文件值随 load/poll 原地
* 生效,无回调。 */
adc_cal_init();
DBG_INFO("adc cal init: OK (k_q16 tpafe=%ld stm32=%ld)",
(long)ADC_CAL_K_Q16_TPAFE, (long)ADC_CAL_K_Q16_STM32);
/* 4c2) 运行参数段初始化(「数据可信」卡 10):死区 9 点工厂默认 + 注册
* [run_par] 段。同 adc_cal 的「先注册后 load」契约 → 必须内核前;
* 104 消费(COV 实时读 / 0x8020 段读写映射)在 iec104Task 启动后进行。 */
run_par_init();
DBG_INFO("run par init: OK (db9 k/b via adc_cal, evt=1<<8)");
/* 4c3) 通讯参数段初始化(道G task-8,「UI 补齐」第 5 项非 UI 前置):ca/端口
* 工厂默认 + 注册 [comm] 段。与 adc_cal / run_par 同「先注册后 load」契约
* → 必须内核前:defaultTask 在 storage_bring_up 里才做 net_cfg_load →
* cfg_file_load(引擎唯一读点),本注册必须排在它前面。生效语义 =
* 改后重启(iec104Task 启动读 getter,不挂回调,见 iec104_comm.h)。 */
if (iec104_comm_init() != 0) {
DBG_ERROR("comm init FAIL (dup/full?) - file values inactive");
} else {
DBG_INFO("comm init: OK (ca=%u port=%u)",
(unsigned)iec104_comm_ca(), (unsigned)iec104_comm_port());
}
/* 4d) 保护定值 + 元件层初始化(B5):出厂默认定值封存校验和 + 元件状态
* 复位并解析相量索引(须在 phasor_init 之后)。内核前完成 → iec104
* 定值读/protectTask 首拍前定值必已装载。判据消费在 protectTask。 */
@@ -289,8 +321,8 @@ int app_main_init(void) {
/* 6) 启动 1 kHz 采样节拍:TIM3 更新事件 → ① ISR 内脉冲 CONVST(Src/main.c)
* ② 同一事件经 TRGO 硬件触发 STM32 ADC1 一轮 9 通道转换(DMA 自动搬运)。
* ⚠ 必须在 tpafe5160_init() 成功之后启动,否则 ISR 会在驱动首帧自检
* (E-44 有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。
* ⚠ 节拍源 v2.0 由 TIM6 改为 **TIM3**:F4 上 ADC1 的外部触发只认
* (有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。
* ⚠ 节拍源必须能触发 ADC1,故用 **TIM3**:F4 上 ADC1 的外部触发只认
* TIM1/TIM2/TIM3/TIM4/TIM5/TIM8 的 CC 或 TRGO,**TIM6/TIM7 的 TRGO 只接
* DAC**(查 CubeMX DB 与本仓库 stm32f4xx_hal_adc.h 的 EXTSEL 表证实)
* → 要让两片转换器由同一个硬件事件驱动,节拍源必须换成 TIM3。 */
+8
View File
@@ -17,6 +17,14 @@ extern "C" {
#include <stdint.h>
/*
* 复位原因锁存影子(定义于 app_main.c)。Src/main.c 在 HAL_Init() 之后、串口/日志
* 尚未就绪时读 RCC->CSR 并清标志,这里提供引用契约——原先写成 main.c 函数内的局部
* extern(跨文件裸 extern,改名/删除都不会有提示)。读→清两步的顺序约束见 main.c
* 该段注释,不可调换。
*/
extern volatile uint32_t g_rcc_csr_latched;
int app_main_init(void);
void app_main_run(void);
+4
View File
@@ -34,6 +34,10 @@ extern osEventFlagsId_t sys_evtHandle;
#define SYS_EVT_PROT_SET_SAVE (1u << 7) /* 保护定值落盘请求(B7):iec104Task 检测到定值脏置位,
* defaultTask(配置引擎属主)消费并写 /prot_set.bin
* (消费型命令位;写盘失败脏标志保留 → 下轮再通知重试) */
#define SYS_EVT_RUN_PAR_SAVE (1u << 8) /* 运行参数段落盘请求(「数据可信」卡 10):iec104Task
* 检测到 run_par 脏(死区 9 键 / 系数覆盖联动 [adc_cal])
* 置位,defaultTask 消费并 cfg_file_save 整档外科保存
* (消费型命令位;失败脏保留 → 下轮重试,B7 同款) */
#ifdef __cplusplus
}
+618
View File
@@ -0,0 +1,618 @@
/*
* 模块名称:UI 数据组装器(ui_data_feed)
* 模块功能:App → UI 数据通道的写侧——按节拍从采集/标定/相量/测频/保护/协议/墙钟
* 各模块取数,组装成 docs/UI设计/UI_数据供给设计说明.md §4 契约的三帧
* (实时量 rt / 参数 par / 装置信息 info),经 Drivers/BSP/UI/ui_data.h
* 的发布 API 推给 UI 层;本模块是快照的**唯一填充者**,不含任何布局、
* 格式化与 LVGL 调用(显示规格全在页面行表,同文档 §8)
* 写方向(道F task-1):同一条 tick 顺带把页面投递的定值整定请求落定——
* ui_data_param_drain() 每轮取一条,执行动作经本文件的 s_param_ops
* (locked = prot_set_locked / check = g_prot_set_meta 上下限 /
* apply = prot_set_set_sealed)注入给 BSP;值域与落盘事实都留在 App
* 侧,BSP 只有队列与状态机(契约 §6.1"UI 不能直写"的落地形态)
* 量纲诚实性:eng 字段按**真值**填——docs/任务规划/数据可信.md §2.6 把承诺边界定在
* "引脚电压",故未做两点标定前 eng = UI_DATA_ENG_PIN(值 = ADC 引脚电压 V,
* 不是端子二次值);电气 8 路 adc_cal_flags() 的 VALID 全置位才升
* UI_DATA_ENG_SEC。TEMP 按决策不参与线性标定 → 恒带 UI_DATA_Q_RAW,
* 页面据此不挂单位(三态口径同文档 §4.2)
* 适用平台:STM32F407ZGTx(PC 模拟器不链接本文件,见 ui_data_feed.h 端接法)
* 作者:王建锋
* 创建日期:2026-10-25
* 修改记录:
* v1.0 2026-10-25 王建锋 创建(道E task-14)
* v1.1 2026-10-26 DTU 项目组 道F task-1:① tick 前置 ui_data_param_drain()
* (定值写路径出队 → 闭锁/越界预检 → set_sealed,
* 见 s_param_ops);② 参数帧尾填 cal_block(未标定
* 闭锁徽标,三路口径同 protect_task.c,不用 eng)
* v1.2 2026-10-26 DTU 项目组 道F task-16:删除本地镜像 feed_cal_block(),
* 两个调用点(填帧 / 门控比较)改调
* adc_cal_prot_blocked()——同一份三路判定此前在
* 本文件与 protect_task.c 各写一遍,且各自带一份
* #ifndef DTU_BENCH(只给单个 TU 加宏就能让屏与判据
* 分叉);真源现落在 App/algo/adc_cal.c,旁路全仓一处。
* 行为不变(判定式逐字同构、调用时机不变)
*/
/* 头文件包含区 */
#include <string.h>
#include "cmsis_os.h" /* osKernelGetTickCount:与 adc_task 同一 ms 时基 */
#include "ui_data_feed.h"
/* 日志锚点:本文件只在"点表点数与快照点数不等"这一条静默漂移上打一条一次性日志
* (每条约 90 B Flash,见 AGENTS「日志不是免费的」——常规发布路径刻意一条都不加)。
* ⚠ DBG_TAG 必须在 include 之前定义:dbg_log.h 的 DBG_ERROR 是宏,漏 include 时
* ARMCC 只把 DBG_ERROR 当隐式声明函数放过编译期,错误落在链接期(L6218E
* Undefined symbol),比漏写头文件本身更难归因 */
#define DBG_TAG "[UIDATA]"
#include "dbg_log.h"
/* 下层(Drivers/BSP):快照存侧 + 两个端口/用户名常量来源(App → BSP 是合法方向) */
#include "ui_data.h"
#include "ui_net_sink.h" /* UI_NET_SINK_PORT:远程显示(RemoDispBus)监听端口 */
#include "ftp_cfg.h" /* FTP_CFG_USER_MAX / ftp_cfg_get() */
/* 同层(App)数据源 */
#include "adc_input.h" /* ADC_SEM_COUNT / adc_input_raw(TEMP 码值) */
#include "adc_cal.h" /* adc_cal_flags:VALID/CLIPPED 三态可用性;adc_cal_prot_blocked:未标定闭锁单点 */
#include "phasor.h" /* 基波/谐波有效值(fAmSQ) */
#include "freq.h" /* 测频读数与新鲜度 */
#include "prot_elm.h" /* 元件动作态(遥信位图来源) */
#include "prot_set.h" /* 40 项定值 Q10 + 校验和 + 闭锁 + 越界位图 */
#include "sys_clock.h" /* 墙钟字符串 */
#include "iec104_serv.h" /* IEC104_SERV_PORT:实际监听端口(非协议层同名常量) */
#include "iec104_pts.h" /* task-15 批4:遥信点数与单/双点性质(点表事实的唯一来源) */
/* 私有宏定义区 */
/* 实时帧与信息帧发布节拍(ms);参数帧只在定值校验和变化时发布
* (节拍依据 docs/UI设计/UI_数据供给设计说明.md §5.2) */
#define FEED_RT_PERIOD_MS 200u
/* 遥信位号:**与 App/protocol/iec104/iec104_pts.c 板端源的位图同源**(该文件把
* 位号写死在函数体内、未导出,UI 侧不得 include 协议头,故在此镜像一份)。
* 变更纪律:104 点表位号改动时必须同步这里 + 页面名表 + 主机一致性测试
* (test/host/ui_data 的位图断言)。V0.1 有硬件来源的只有以下 6 位,
* 其余 27 位恒无效(开入未实现);过压/欠压/过频/低频四个元件在
* V0.1 点表里没有对应遥信点,故本函数不产出。 */
#define FEED_YX_BIT_FAULT_ALL 14u /* YX15 故障总(任一电压/电流段或零序动作) */
#define FEED_YX_BIT_OC1 15u /* YX16 过流 I 段 */
#define FEED_YX_BIT_OC2 16u /* YX17 过流 II 段 */
#define FEED_YX_BIT_OC3 17u /* YX18 过流 III 段 */
#define FEED_YX_BIT_OL 22u /* YX23 过载(过负荷) */
#define FEED_YX_BIT_LOCK 30u /* YX31 保护闭锁(定值校验和自检失败) */
/* YX1~4(bit0..3)是开关位置类双点点,V0.1 无开入硬件 → 恒 0 且不在有效位图内,
* 因此本文件不为它们定义常量(定义了也不参与任何判定 = 看着像接了线其实没有) */
/* 有效位图:YX15~18/23/31(与 104 点表 YX_VALID_MASK 同集合) */
#define FEED_YX_VALID_MASK ((((uint64_t)1u << FEED_YX_BIT_FAULT_ALL) | \
((uint64_t)1u << FEED_YX_BIT_OC1) | \
((uint64_t)1u << FEED_YX_BIT_OC2) | \
((uint64_t)1u << FEED_YX_BIT_OC3) | \
((uint64_t)1u << FEED_YX_BIT_OL) | \
((uint64_t)1u << FEED_YX_BIT_LOCK)))
/*
* 装置身份(版本类):仓库内**没有**产品级版本登记表,值沿用页面既有显示值
* (docs/UI设计/内容页架构.md 的装置信息行表现值),有正式口径后只改本段一处。
* ⚠ 软件校验不抄参考项目遗留的 "4454":构建期没有 CRC 生成步骤,如实显示 "--"
* (处置口径 docs/UI设计/页面扩充详细方案.md §3.7 第 5 行)
*/
#define FEED_MODEL "F30"
#define FEED_SW_VER "SV0.010"
#define FEED_HW_VER "HW0.010"
#define FEED_SW_SUM "--"
/* 编译期一致性守卫(ARMCC C99 无 _Static_assert,用项目惯用的数组宽度法,
* 先例 App/cfg/cfg_file.c 与 Drivers/BSP/UI/ui_menu_tab.h):
* 快照下标空间与各 App 枚举一旦漂移,编译即失败,不留到运行期
* (契约 docs/UI设计/UI_数据供给设计说明.md §9.4 第 1/2 条) */
typedef char feed_chk_yc_n_t[(UI_DATA_YC_N == (uint32_t)ADC_SEM_COUNT) ? 1 : -1];
typedef char feed_chk_set_n_t[(UI_DATA_SET_N == (uint32_t)PS_ID_COUNT) ? 1 : -1];
/* 写路径注入表的 id 形参是 uint8_t(BSP 侧不透明编号宽度,位图只有 64 位):
* 定值项数一旦破 64/255,request 的 mask 与 check/apply 的编号都会静默截断,
* 故在写侧也钉一道(BSP 侧同一条守卫在 Drivers/BSP/UI/ui_data.c) */
typedef char feed_chk_set_u8_t[((uint32_t)PS_ID_COUNT <= 64u) ? 1 : -1];
typedef char feed_chk_fuser_t[(UI_DATA_FUSER_LEN >= FTP_CFG_USER_MAX) ? 1 : -1];
/* 静态变量定义区 */
/* 组装缓冲:三帧串行填充,共用一块存储,省两份 RAM。
* 实际最大者是**实时帧**(208 B > par 192 B > info),故给 par 尾追加字段
* 只要不超过 rt 就一字节 RAM 都不多花(这条由主机单测 TC-UIDATA-021g/h 钉住,
* 尺寸一变这里就是唯一会撒谎的地方) */
static union {
ui_data_rt_t rt;
ui_data_par_t par;
ui_data_info_t info;
} s_scr;
/* 语义通道 → 基波相量实例下标缓存(-1 = 该通道无基波实例 → 该路发 IV);
* 首帧惰性建表(相量实例表是 ROM 表,运行期不变,建一次即可) */
static int8_t s_yc_phasor[ADC_SEM_COUNT];
static uint8_t s_map_done;
/* 发布节拍与参数变化检测状态
* par 帧有**三个各自独立**的变化源,任一变了就要重发一帧(详见 tick 里的门控注释):
* s_par_sum 定值改动(校验和)
* s_par_locked 定值闭锁(L5-1 自检失配,校验和不动)
* s_par_cal_block 未标定闭锁(卡 12 标定完成,校验和也不动)
* 后两个各占一字节——省下来就是"屏上状态与真值脱拍"这笔债 */
static uint32_t s_last_rt_ms;
static uint32_t s_par_sum;
static uint8_t s_par_sum_valid;
static uint8_t s_par_locked;
static uint8_t s_par_cal_block;
static uint8_t s_first_done;
/* 函数定义区 */
/*
* 函数功能:定长缓冲内拷贝字符串(跨层快照只允许定长数组,禁指针)
* 入口参数:p_dst - 目标缓冲(结构体内数组首地址) char *
* dst_len - 目标容量(含 NUL) size_t
* a_src - 源串(Flash 字面量或上层缓冲) const char *
* 返回值:无
* 限定条件:三者非 NULL 且 dst_len > 0;截断时保证 NUL 结尾
* 函数说明:不用 strcpy/sprintf(门禁 CODE-RULE-06 禁用不安全函数),
* 超长**静默截断**——快照字段本就是定长显示串,截断只会少显示尾部,
* 不会越界;本函数每 200 ms 调用个位数次,不为此加日志
*/
static void feed_copy_str(char *p_dst, size_t dst_len, const char *a_src) {
size_t n;
if ((p_dst == NULL) || (a_src == NULL) || (dst_len == 0u)) {
return;
}
n = strlen(a_src);
if (n > (dst_len - 1u)) {
n = dst_len - 1u;
}
(void)memcpy(p_dst, a_src, n);
p_dst[n] = '\0';
}
/*
* 函数功能:建立"语义通道 → 基波相量实例"映射(首帧一次)
* 入口参数:无
* 返回值:无
* 限定条件:uiTask 上下文,ui_data_feed_tick 首帧前调用
* 函数说明:只认 harm == 1 的实例;一个语义通道有多个基波实例时取**首个**
* (现状 k_inst[] 每通道最多一个基波,取首个即唯一解)。
* UB/UC/IB/IC/IN 无实例(V0.1 硬件削顶:双转换器无共同采样触发)→ 留 -1,该路发 IV
*/
static void feed_map_phasor(void) {
uint8_t n;
uint8_t i;
for (i = 0u; i < (uint8_t)ADC_SEM_COUNT; i++) {
s_yc_phasor[i] = -1;
}
n = phasor_inst_count();
for (i = 0u; i < n; i++) {
const phasor_inst_t *p = phasor_inst(i);
if ((p == NULL) || (p->harm != 1u) || ((uint8_t)p->sem >= (uint8_t)ADC_SEM_COUNT)) {
continue;
}
if (s_yc_phasor[(uint8_t)p->sem] < 0) {
s_yc_phasor[(uint8_t)p->sem] = (int8_t)i;
}
}
s_map_done = 1u;
}
/*
* 函数功能:算整帧量纲口径 eng
* 入口参数:无
* 返回值:UI_DATA_ENG_PIN 或 UI_DATA_ENG_SEC uint8_t
* 限定条件:无(纯读)
* 函数说明:**全或无**——电气 8 路(TEMP 除外,它不参与线性标定)的
* ADC_CAL_F_VALID 全置位才升二次值。取全或无是因为整帧共用一个 eng:
* 只要还有一路是引脚电压,页面挂"二次值"就会把那一路的读数误标
* (宁可整体显示"未标定",也不给部分真值贴真值标签)
*/
static uint8_t feed_eng(void) {
uint8_t sem;
for (sem = 0u; sem < (uint8_t)ADC_SEM_TEMP; sem++) {
if ((adc_cal_flags((adc_semantic_t)sem) & ADC_CAL_F_VALID) == 0u) {
return UI_DATA_ENG_PIN;
}
}
return UI_DATA_ENG_SEC;
}
/*
* 函数功能:组装遥信双点位图(bit i = 第 i 点是双点型 M_DP)
* 入口参数:无
* 返回值:位图 uint64_t
* 限定条件:uiTask 上下文;点数上限 UI_DATA_YX_N(<64,故位图放得下)
* 函数说明:逐点问点表 `iec104_pts_yx_is_dp()`,**不在此复制"前 4 点是双点"这个事实**
* ——那句话写在这里就是第二真相,点表一改就悄悄错(下发位图的理由见
* ui_data.h 的 yx_dp 字段注释)
*/
static uint64_t feed_yx_dp(void) {
uint64_t m = 0u;
uint8_t i;
for (i = 0u; (i < (uint8_t)UI_DATA_YX_N) && (i < 64u); i++) {
if (iec104_pts_yx_is_dp(i) != 0u) {
m |= ((uint64_t)1u << i);
}
}
return m;
}
/*
* 函数功能:组装遥信位图(bit i = 第 i 点"合/动作")
* 入口参数:无
* 返回值:位图 uint64_t
* 限定条件:uiTask 上下文(读 protectTask 写的元件状态快照,读侧拷贝语义,
* 与 App/protocol/iec104/iec104_pts.c 板端源同形)
* 函数说明:位号镜像本文件 FEED_YX_BIT_* 一组宏的注释(含同步纪律);
* 双点遥信 YX1~4 无硬件 → 恒 0 且不在有效位图内
*/
static uint64_t feed_yx_bits(void) {
prot_elm_state_t st;
uint64_t bits = 0u;
uint8_t fault_all;
fault_all = 0u;
prot_elm_state(PE_OC1, &st);
if (st.asserted != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_OC1);
fault_all = 1u;
}
prot_elm_state(PE_OC2, &st);
if (st.asserted != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_OC2);
fault_all = 1u;
}
prot_elm_state(PE_OC3, &st);
if (st.asserted != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_OC3);
fault_all = 1u;
}
prot_elm_state(PE_3U0, &st);
if (st.asserted != 0u) {
fault_all = 1u;
}
if (fault_all != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_FAULT_ALL);
}
prot_elm_state(PE_OL, &st);
if (st.asserted != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_OL);
}
if (prot_set_locked() != 0u) {
bits |= ((uint64_t)1u << FEED_YX_BIT_LOCK);
}
return bits;
}
/*
* 函数功能:组装实时量快照(遥测 9 路 + 相量实例槽 + 遥信 + 频率 + 墙钟)
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_rt_t *
* tick_ms - 发布时刻(tick 域) uint32_t
* 返回值:无
* 限定条件:uiTask 上下文;映射表已建(feed_map_phasor)
* 函数说明:取数口径与 104 上送同源不同路——本函数读**相量使用区**(1 ms 刷新、
* 双区原子交换),不做死区与平均;遥测链快照(docs/任务规划/数据可信.md
* §4.5 的 meas 快照)落地后,本函数改成读该快照即可,页面零改动
* (这正是本层存在的意义:换数据源只改这里)
*/
static void feed_fill_rt(ui_data_rt_t *p_snap, uint32_t tick_ms) {
uint32_t now_ep;
uint8_t sem;
char ts[UI_DATA_CLOCK_LEN];
p_snap->tick_ms = tick_ms;
p_snap->valid = 1u;
p_snap->eng = feed_eng();
/* 遥测:语义通道序的基波有效值(采样环已按 adc_cal 折算 → 当前为引脚电压域) */
for (sem = 0u; sem < (uint8_t)ADC_SEM_COUNT; sem++) {
int8_t pi = s_yc_phasor[sem];
p_snap->yc[sem].val = 0.0f;
p_snap->yc[sem].qds = 0u;
if (sem == (uint8_t)ADC_SEM_TEMP) {
/* 温度:不参与线性标定,透传转换器码值并打 RAW 位(页面不得挂单位) */
int32_t code = 0;
p_snap->yc[sem].qds = UI_DATA_Q_RAW;
if (adc_input_raw((adc_semantic_t)sem, &code) == 0) {
p_snap->yc[sem].val = (float)code;
} else {
p_snap->yc[sem].qds |= UI_DATA_Q_IV;
}
continue;
}
if (pi < 0) {
/* 无基波实例 = V0.1 该通道无可用采样源(削顶) → 无效 */
p_snap->yc[sem].qds = UI_DATA_Q_IV;
} else {
phasor_t ph;
int rc = phasor_get((uint8_t)pi, &ph);
if (rc == 0) {
p_snap->yc[sem].val = ph.fAmSQ; /* 有效值口径(系数已折 sqrt2/N) */
} else if (rc == -2) {
p_snap->yc[sem].qds = UI_DATA_Q_NT; /* 未攒满一周波 */
} else {
p_snap->yc[sem].qds = UI_DATA_Q_IV;
}
}
if ((adc_cal_flags((adc_semantic_t)sem) & ADC_CAL_F_CLIPPED) != 0u) {
p_snap->yc[sem].qds |= UI_DATA_Q_OV; /* 硬件削顶:交流量不可信 */
}
}
/* 相量实例槽:含谐波(Ia 二次/五次),交流量页的 Ia2/Ia5 行走这条带 */
{
uint8_t n = phasor_inst_count();
if (n > UI_DATA_PH_MAX) {
n = UI_DATA_PH_MAX; /* 超出槽上限的实例本帧不发布(现 5 实例,余 3 槽) */
}
p_snap->ph_n = n;
for (sem = 0u; sem < UI_DATA_PH_MAX; sem++) {
p_snap->ph[sem].val = 0.0f;
p_snap->ph[sem].qds = UI_DATA_Q_IV;
}
for (sem = 0u; sem < n; sem++) {
phasor_t ph;
if (phasor_get(sem, &ph) == 0) {
p_snap->ph[sem].val = ph.fAmSQ;
p_snap->ph[sem].qds = 0u;
} else {
p_snap->ph[sem].qds = UI_DATA_Q_NT;
}
}
}
p_snap->yx_bits = feed_yx_bits();
p_snap->yx_valid = FEED_YX_VALID_MASK;
p_snap->yx_dp = feed_yx_dp();
/* 点数漂移检查(只在真漂走时打一条日志):点表 YX_N 与本层 UI_DATA_YX_N 一旦不等,
* 页面按 33 行画、协议按另一个数上送,两边都"看起来正常"——这是静默错,值得花一条
* DBG(约 90 B Flash)换一次现场可见的自证;只打一次不刷屏(同丢弃计数口径) */
{
static uint8_t s_yx_n_bad;
uint8_t n = iec104_pts_yx_count();
if ((n != (uint8_t)UI_DATA_YX_N) && (s_yx_n_bad == 0u)) {
s_yx_n_bad = 1u;
DBG_ERROR("yx point count drift: pts=%u ui_name_table=%u",
(unsigned)n, (unsigned)UI_DATA_YX_N);
}
}
/* 测频:读数新鲜度单独走 freq_valid()(幅值门 + 收敛),两者任一不成立
* 页面即显示 "--"——eng=0 期间幅值门恒失效,读数本就不可消费,如实反映 */
{
float hz = 0.0f;
p_snap->freq_valid = 0u;
p_snap->freq_hz = 0.0f;
if (freq_get(&hz) == 0) {
p_snap->freq_hz = hz;
if (freq_valid() != 0u) {
p_snap->freq_valid = 1u;
}
}
}
/* 墙钟:UINT32_MAX 是 sys_clock_get() 的失败哨兵 → clock_ok=0、串清空 */
p_snap->clock_ok = 0u;
p_snap->clock[0] = '\0';
now_ep = sys_clock_get();
if ((now_ep != UINT32_MAX) && (sys_clock_get_str(ts, sizeof(ts)) != NULL)) {
feed_copy_str(p_snap->clock, sizeof(p_snap->clock), ts);
p_snap->clock_ok = 1u;
}
}
/* ── 写路径(契约 §6.2):把 prot_set 的三个动作包成注入给 BSP 的函数表 ────── */
/*
* 函数功能:写路径判定"定值闭锁中吗"(BSP 经 ui_data_param_ops_t 来问)
* 入口参数:无
* 返 回 值:1 - 闭锁(定值校验和自检失败态,禁一切定值写入);0 - 可写 uint8_t
* 限定条件:uiTask 上下文(只读 prot_set 状态位)
* 函数说明:这里的"闭锁"是**定值闭锁**(= par.locked = YX31 遥信),**不是**未标定
* 闭锁(= par.cal_block):后者仍可改定值、只是判据停走,两者在屏上不得
* 互认(三词三源口径 docs/任务规划/数据可信.md §4.3 + 落地简报 §7-6)
*/
static uint8_t feed_param_locked(void) {
return prot_set_locked();
}
/*
* 函数功能:写路径整批预检——单项是否落在 meta 上下限内
* 入口参数:a_id - 定值项编号(位图位序;uint8_t 是 BSP 侧不透明编号的宽度)
* a_q10 - 待写 Q10 定点值
* 返 回 值:0 - 合法;-1 - 编号越界或值越界 int
* 限定条件:只读 g_prot_set_meta(ROM 表),任意上下文安全
* 函数说明:上下限**只此一处取**——BSP 行表(Drivers/BSP/UI/ui_w_setview.c)是它的
* 换算镜像,由 setview-check 钩子核一致;本函数再抄一份数字就是第二真相
* ("查看合法、整定越界"两套边界就是这么来的)
*/
static int feed_param_check(uint8_t a_id, int32_t a_q10) {
if (a_id >= (uint8_t)PS_ID_COUNT) {
return -1;
}
if ((a_q10 < g_prot_set_meta[a_id].min) || (a_q10 > g_prot_set_meta[a_id].max)) {
return -1;
}
return 0;
}
/*
* 函数功能:写路径落值——单项**原子**写入 + 重封校验和 + 置脏
* 入口参数:a_id - 定值项编号;a_q10 - Q10 定点值
* 返 回 值:0 - 已写入;-1 - 编号越界或 prot_set 拒写(原值不动) int
* 限定条件:uiTask 上下文;调用前 BSP 已整批预检通过(两条件与本函数一致)
* 函数说明:用 prot_set_set_sealed()(影子拷贝→改→重封→单字交换,**每项自带置脏**,
* 落盘由 defaultTask 50 ms 圈扫脏写 /prot_set.bin,本模块不碰 FatFs)。
* ⚠ 不得改成 prot_set_set() + 批末 prot_set_seal():两条路径之间有
* "已改未封存"窗口,prot_set.c 明令不得交错,撞上就被 L5-1 自检判成
* "定值被非法改写"→ 整台保护闭锁
*/
static int feed_param_apply(uint8_t a_id, int32_t a_q10) {
if (a_id >= (uint8_t)PS_ID_COUNT) {
return -1;
}
return prot_set_set_sealed((prot_set_id_t)a_id, a_q10);
}
/* 注入给 ui_data_param_drain() 的执行侧实现(BSP 不保存副本,每次调用现传) */
static const ui_data_param_ops_t s_param_ops = {
feed_param_locked,
feed_param_check,
feed_param_apply
};
/*
* 函数功能:组装参数快照(40 项定值 Q10 + 校验和 + 封存态 + 越界位图 + 未标定闭锁)
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_par_t *
* sum - 本次发布的 prot_set_checksum() 值 uint32_t
* 返回值:无
* 限定条件:uiTask 上下文(读侧;写者 = iec104Task 的 set_sealed 与装载路径,
* 读到的永远是"值区与封存一致"的完整快照)
* 函数说明:只搬 Q10 原值不换算小数位——换算属表现层
* (docs/UI设计/UI_数据供给设计说明.md §8)
*/
static void feed_fill_par(ui_data_par_t *p_snap, uint32_t sum) {
uint8_t i;
p_snap->checksum = sum;
p_snap->valid = 1u;
p_snap->locked = prot_set_locked();
p_snap->bad_mask = prot_set_bad_mask();
/* 越界位图非 0 = 上一次整表装载/提交有越界项(prot_set_bad_mask 的既有口径),
* 页面据此反显;为 0 时无意义位图不参与显示 */
p_snap->bad_valid = (p_snap->bad_mask != 0u) ? 1u : 0u;
/* v2 尾追加:未标定闭锁徽标(与判据停走**同一个判定**:App/algo/adc_cal.c 的
* adc_cal_prot_blocked(),道F task-16 起本文件不再自带一份三路判定——两份实现
* 可以各自漂移,屏上"未标定"就会与判据实际停不走同一件事)。
* ⚠ 不要改回用 rt.eng 反推:eng 要求电气 **8** 路全 VALID,V0.1 双转换器下
* UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照它画徽标就永不解锁;
* 两个谓词各有用途,边界写在 adc_cal.h 的函数头第③条。
* ⚠ 与上面的 locked 是两件事——locked 禁编辑,cal_block 只说明"改了也不会动作" */
p_snap->cal_block = adc_cal_prot_blocked();
for (i = 0u; i < (uint8_t)PS_ID_COUNT; i++) {
p_snap->q10[i] = prot_set_get((prot_set_id_t)i);
}
}
/*
* 函数功能:组装装置信息快照(身份 + 运行时长 + 通讯参数)
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_info_t *
* tick_ms - 当前 tick(换算 uptime_s 用) uint32_t
* 返回值:无
* 限定条件:uiTask 上下文
* 函数说明:页面不得自己 include 协议/配置头取这些值,一律经本帧下发
* (docs/UI设计/页面扩充详细方案.md §3.5"值必须在 App 侧取");
* 公共地址 CA 不在此下发——固件里 CA 是逐帧从 ASDU 头解析的,
* 没有集中常量可取,待 104 侧给出查询点后按扩展规则追加字段
*/
static void feed_fill_info(ui_data_info_t *p_snap, uint32_t tick_ms) {
const ftp_cfg_t *p_ftp = ftp_cfg_get();
p_snap->valid = 1u;
p_snap->uptime_s = tick_ms / 1000u;
p_snap->port_104 = (uint16_t)IEC104_SERV_PORT;
p_snap->port_disp = (uint16_t)UI_NET_SINK_PORT;
feed_copy_str(p_snap->model, sizeof(p_snap->model), FEED_MODEL);
feed_copy_str(p_snap->sw_ver, sizeof(p_snap->sw_ver), FEED_SW_VER);
feed_copy_str(p_snap->hw_ver, sizeof(p_snap->hw_ver), FEED_HW_VER);
feed_copy_str(p_snap->build_date, sizeof(p_snap->build_date), __DATE__);
feed_copy_str(p_snap->sw_sum, sizeof(p_snap->sw_sum), FEED_SW_SUM);
if (p_ftp != NULL) {
feed_copy_str(p_snap->ftp_user, sizeof(p_snap->ftp_user), p_ftp->user);
} else {
p_snap->ftp_user[0] = '\0';
}
}
/*
* 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与
* 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布
* (免启动初期出现一闪的 "--")
* 入口参数:无
* 返回值:无
* 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms)
* 函数说明:每轮未到期时代价 = 一次 osKernelGetTickCount + 两次减法比较 +
* 一次空队列判断(drain 无请求时只是一次计数比较);
* 差值法吸收 tick 回绕(同 App/algo/adc_health.h 的 now_ms 口径)。
* 写路径失败态(ERR_RANGE/ERR_LOCKED/ERR_BUSY)走屏上通道,不打日志
* (每条 DBG 约 90 B Flash + 216 B 栈,AGENTS「日志不是免费的」)
*/
void ui_data_feed_tick(void) {
uint32_t now;
uint32_t sum;
uint8_t due_rt;
now = osKernelGetTickCount();
if (s_map_done == 0u) {
feed_map_phasor();
}
/* 写路径:先落定整定请求,**再**取校验和——本轮刚写进 prot_set 的定值就进
* 本轮"校验和变化才发"那道门,页面保存行的 OK 与新值同一帧回来,不必多等
* 一轮(10 ms)。顺序反了不出错,只是慢一轮。
* 每轮最多处理一条(ui_data_param_drain 的限定条件):整定是人工低频事件,
* 一轮一条已经把最坏耗时钉住,不必排空整个队列
* ⚠ 返回值必须显式丢弃:本函数不消费"处理了几条",但 bsp-err 的
* consume-result 只认接住或 (void) 两种写法(实测编译器与 cppcheck 都不报) */
(void)ui_data_param_drain(&s_param_ops);
sum = prot_set_checksum();
due_rt = 0u;
if ((s_first_done == 0u) || ((uint32_t)(now - s_last_rt_ms) >= FEED_RT_PERIOD_MS)) {
due_rt = 1u;
s_first_done = 1u;
s_last_rt_ms = now;
}
if (due_rt != 0u) {
(void)memset(&s_scr, 0, sizeof(s_scr));
feed_fill_rt(&s_scr.rt, now);
(void)ui_data_publish_rt(&s_scr.rt);
(void)memset(&s_scr, 0, sizeof(s_scr));
feed_fill_info(&s_scr.info, now);
(void)ui_data_publish_info(&s_scr.info);
}
/* 参数帧门控:定值变化是低频事件,不随 200 ms 白拷一帧(契约 §5.2)。
* 但"校验和没变"不等于"页面该看的没变"——本帧里还有两个与校验和无关的事实:
* · locked(定值闭锁):自检失配时置位,校验和一字不变,
* 而整定页保存行要显示 LOCK 并禁编辑;
* · cal_block(未标定闭锁):卡 12 两点标定写完 [adc_cal] 的 valid 就翻转,
* 校验和同样不动,而标题上的"未标定"徽标要当场消失。
* 只盯校验和,这两件事都到不了屏上(真机现象:标定做完了屏上徽标永远挂着,
* 直到某次定值改动才被顺带刷掉——那是把两件不相干的事当同步点)。
* 代价:每轮多两次字节比较(无变化时零拷贝),换来屏上状态与真值同拍 */
{
uint8_t locked_now = prot_set_locked();
uint8_t cal_now = adc_cal_prot_blocked(); /* 标定层单点,与判据同源 */
if ((s_par_sum_valid == 0u) || (sum != s_par_sum)
|| (locked_now != s_par_locked) || (cal_now != s_par_cal_block)) {
(void)memset(&s_scr, 0, sizeof(s_scr));
feed_fill_par(&s_scr.par, sum);
(void)ui_data_publish_par(&s_scr.par);
s_par_sum = sum;
s_par_locked = locked_now;
s_par_cal_block = cal_now;
s_par_sum_valid = 1u;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
* 模块名称:UI 数据组装器(ui_data_feed)
* 模块功能:App → UI 数据通道的"写侧"——按节拍从采集/算法/保护/协议/时钟各模块
* 取数,组装成 docs/UI设计/UI_数据供给设计说明.md §4 契约的三帧
* (实时量 rt / 参数 par / 装置信息 info),经 Drivers/BSP/UI/ui_data.h
* 的发布 API 推给 UI 层;本模块是快照的**唯一填充者**
* 适用范围:App/app(装配层:可同时 include 两侧头,方向仍是上层依赖下层,
* scripts/check_layer_deps.py 只拦"Drivers/BSP 目录反向 include App 目录")
* 线程约定:uiTask 上下文,主循环每轮调 ui_data_feed_tick() 一次;与页面读写同
* 上下文串行 → 无锁(docs/UI设计/UI_数据供给设计说明.md §5.1)。
* ⚠ 不得在 adcTask/protectTask/netTask 里调用本模块
* 端接法:MCU = App/task/ui_task.c 主循环;PC 模拟器**不链接本文件**(模拟器不跑
* 固件装配链,快照由 simulator/main.c 直接发布固定帧保证像素回归可复现,
* 见 docs/UI设计/UI_数据供给设计说明.md §9.2;模拟器豁免登记在
* scripts/check_registration.py 的 SIM_EXEMPT)
* 作者:王建锋
* 创建日期:2026-10-25
* 修改记录:
* v1.0 2026-10-25 王建锋 创建(道E task-14)
* v1.1 2026-10-26 DTU 项目组 道F task-1:tick 兼任定值写路径的落定节拍
* (契约 §6.2;实现见 .c 的 s_param_ops)
*/
#ifndef UI_DATA_FEED_H
#define UI_DATA_FEED_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与
* 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布
* (免启动后出现一闪的 "--")
* 入口参数:无
* 返回值:无
* 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms);页面"保存"按键
* 与本页节拍同上下文,故写路径的投递/落定之间不需要任何锁
* (Drivers/BSP/UI/ui_data.h 写路径节)
* 函数说明:本函数每轮代价 = 一次 tick 差值比较 + 一次校验和比较 + 一次
* ui_data_param_drain()(队列空时只是一次计数判断);真正的取数与组装
* 只在到期时发生(docs/UI设计/UI_数据供给设计说明.md §5.2)。
* ⚠ drain 排在取校验和**之前**:本轮写入的定值要在本轮就被"校验和变化
* 才发"那道门看见,页面才能在同一帧里同时显示 OK 与新值
* 内部零阻塞、零堆分配、零日志(日志每条约 90 B Flash + 216 B 栈,
* 200 ms 周期打不起);整定失败态走屏上通道,不打日志
*/
void ui_data_feed_tick(void);
#ifdef __cplusplus
}
#endif
#endif /* UI_DATA_FEED_H */
+105 -19
View File
@@ -8,20 +8,28 @@
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
* v1.1 2026-09-18 王建锋 P0-1:临时缓冲改经 cfg_store_alloc(MCU = FreeRTOS 堆),
* v1.1 2026-09-18 王建锋 临时缓冲改经 cfg_store_alloc(MCU = FreeRTOS 堆),
* 移除对 C 库 malloc 的全部依赖(MicroLIB C 堆仅 512B,
* 原 4 处 KB 级 malloc 真机必然失败);增编译期堆峰值预算
* 校验 + 失败上报自检记录
* v1.2 2026-09-21 王建锋 E-01(错误码传播审计 P0):超长文件守卫根治——poll 判定
* v1.2 2026-09-21 王建锋 超长文件守卫根治——poll 判定
* 前置到 cfg_store_stat 真实 st.size(旧检查位于截断后的
* len 上恒假=死码,超长文件被截断当有效解析 → 界外静默
* 回工厂默认 → 基线粘住 → 用户配置销毁链);save 输出上限
* 由 CFG_OUT_MAX 收紧至 CFG_FILE_MAX_LEN;s_write_doc 写盘
* 唯一闸口拒写超长文档;load/poll 超长处置补自检留痕
* v1.3 2026-10-04 王建锋 P2-1:受管键行超 CFG_LINE_TEST_MAX 时拒绝保存——
* v1.3 2026-10-04 王建锋 受管键行超 CFG_LINE_TEST_MAX 时拒绝保存——
* 原实现 Pass1 静默跳过超长行,Pass2 遂在段头补写新 KV 且
* 原文超长行原样保留 → 同一键出现两行(旧值残留、静默截断)
* 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save,见 P1-3;
* v1.4 2026-10-15 王建锋 生成文档边界余量收口——
* s_gen_document 两处 `off += snprintf(...)` 改检查式(截断即
* 返回 -1,不再跳过缓冲末尾);注册拒超长键名(≤ 15 字符),
* 使每行 112 字节余量成为可编译期验证的前提
* v1.5 2026-10-25 DTU 项目组 数据可信卡 3:新增值类型 CFG_VAL_INT(有符号十进制,
* len 1/2/4 小端补码)——s_decode_int() 用 strtol + ERANGE +
* 宽度上下界校验,s_encode() 带负号输出;负零点偏移从此可
* 干净落盘(动因见 docs/任务规划/数据可信.md §4.4 与 §7 决策 3)
* 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save;
* 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证
* (见《配置文件设计说明》D15 A+ 方案)
*/
@@ -29,12 +37,14 @@
/* 头文件包含区 */
#include <string.h>
#include <stdlib.h> /* strtoul(键值解码);堆分配一律走 cfg_store_alloc,禁 malloc */
#include <stdlib.h> /* strtoul / strtol(键值解码);堆分配一律走 cfg_store_alloc,禁 malloc */
#include <stdio.h>
#include <ctype.h>
#include <errno.h> /* ERANGE:strtol 饱和判别(INT 键解码需要,数据可信卡 3) */
#include "cfg_file.h"
#include "cfg_file_store.h"
@@ -63,7 +73,7 @@ static const char s_doc_header[] =
#define CFG_OUT_MAX 5120u /* 保存输出缓冲上限(经 cfg_store_alloc 取,见下方堆峰值预算) */
#define CFG_LINE_TEST_MAX 127u /* 受管键行匹配的行长上限(超长必非受管键行) */
#define CFG_SNAP_MAX 64u /* 变更检测段快照缓冲(挂回调段 data_size 须 ≤ 此值) */
#define CFG_NAME_MAX 16u /* 段名缓冲(15 字符 + NUL) */
#define CFG_NAME_MAX (CFG_FILE_MAX_NAME_LEN + 1u) /* 段名/键名缓冲(15 字符 + NUL) */
#define CFG_HEAP_BUDGET 10240u /* 暂态堆峰值预算:读缓冲(1B NUL) + 输出缓冲之和上限 */
/* 编译期钉死暂态堆峰值:放大 CFG_FILE_MAX_LEN / CFG_OUT_MAX 会先在此编译失败,强制
@@ -259,6 +269,28 @@ static int s_encode(const cfg_key_def_t *p_key, const uint8_t *p_src, char *p_ds
n = snprintf(p_dst, cap, "%lu", v);
break;
}
case CFG_VAL_INT: {
long v = 0L;
unsigned long raw = 0uL;
size_t i;
if (p_key->len != 1u && p_key->len != 2u && p_key->len != 4u) {
return -1;
}
for (i = 0u; i < p_key->len; i++) {
raw |= ((unsigned long)p_src[i]) << (8u * i);
}
/* 小端补码还原为有符号值(两端 long = 32 位:ARMCC 32 位 ARM / MSVC LLP64) */
if (p_key->len == 1u) {
v = (long)(int8_t)(raw & 0xFFuL);
} else if (p_key->len == 2u) {
v = (long)(int16_t)(raw & 0xFFFFuL);
} else {
v = (long)(int32_t)raw;
}
n = snprintf(p_dst, cap, "%ld", v);
break;
}
case CFG_VAL_IP4:
n = snprintf(p_dst, cap, "%u.%u.%u.%u",
(unsigned)p_src[0], (unsigned)p_src[1],
@@ -272,7 +304,7 @@ static int s_encode(const cfg_key_def_t *p_key, const uint8_t *p_src, char *p_ds
case CFG_VAL_STR: {
size_t slen;
/* P3-1:len==0 时 `p_key->len - 1u` 回绕为 SIZE_MAX → memcpy 巨长越界。
/* len==0 时 `p_key->len - 1u` 回绕为 SIZE_MAX → memcpy 巨长越界。
* STR 字段至少 1 字节(含结尾 NUL),len==0 非法,直接拒绝 */
if (p_key->len == 0u) {
return -1;
@@ -325,6 +357,44 @@ static int s_decode_uint(uint8_t *p_dst, size_t len, const char *p_val) {
return 0;
}
/*
* 函数功能:解析有符号十进制值(小端补码写入,数据可信卡 3)
* 入口参数:p_dst - 目标字段 uint8_t *
* len - 字段字节数(1/2/4) size_t
* p_val - 值文本(可带 +/- 号) const char *
* 返回值:0 - 成功;-1 - 非法(空/非数字/带后缀/超本宽度范围/strtol 饱和) int
* 限定条件:无
* 函数说明:两端 long = 32 位,strtol 对超范围文本饱和到 LONG_MAX/MIN 并置
* ERANGE(如 "2147483648" 饱和成 INT32_MAX 本身,光比 v>max_v 抓不住),
* 故 ERANGE 与宽度上下界两道都要查;1/2 字节键按 int8/int16 范围收窄
*/
static int s_decode_int(uint8_t *p_dst, size_t len, const char *p_val) {
const long min_v = (len == 1u) ? -128L : ((len == 2u) ? -32768L : (-2147483647L - 1L));
const long max_v = (len == 1u) ? 127L : ((len == 2u) ? 32767L : 2147483647L);
char *p_end = NULL;
long v;
size_t i;
if (len != 1u && len != 2u && len != 4u) {
return -1;
}
if (p_val[0] == '\0') {
return -1;
}
errno = 0;
v = strtol(p_val, &p_end, 10);
if (p_end == p_val || *p_end != '\0' || errno == ERANGE) {
return -1;
}
if (v < min_v || v > max_v) {
return -1;
}
for (i = 0u; i < len; i++) {
p_dst[i] = (uint8_t)(((unsigned long)v) >> (8u * i));
}
return 0;
}
/*
* 函数功能:解析点分 IPv4("a.b.c.d",每段 0~255)
* 入口参数:p_dst - 4 字节目标 uint8_t *
@@ -436,6 +506,8 @@ static int s_decode(const cfg_key_def_t *p_key, uint8_t *p_dst, const char *p_va
switch (p_key->type) {
case CFG_VAL_UINT:
return s_decode_uint(p_dst, p_key->len, p_val);
case CFG_VAL_INT:
return s_decode_int(p_dst, p_key->len, p_val);
case CFG_VAL_IP4:
return s_decode_ip4(p_dst, p_val);
case CFG_VAL_MAC:
@@ -457,6 +529,7 @@ static int s_decode(const cfg_key_def_t *p_key, uint8_t *p_dst, const char *p_va
*/
static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
size_t off;
int n;
int i;
int k;
size_t hlen;
@@ -480,7 +553,11 @@ static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
if (cap < off + 112u) {
return -1;
}
off += (size_t)snprintf(p_buf + off, cap - off, "\r\n[%s]\r\n", p_sec->name);
n = snprintf(p_buf + off, cap - off, "\r\n[%s]\r\n", p_sec->name);
if (n < 0 || (size_t)n >= cap - off) {
return -1;
}
off += (size_t)n;
for (k = 0; k < (int)p_sec->key_count; k++) {
const cfg_key_def_t *p_key = &p_sec->p_keys[k];
@@ -492,7 +569,11 @@ static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
if (cap < off + 112u) {
return -1;
}
off += (size_t)snprintf(p_buf + off, cap - off, "%s=%s\r\n", p_key->name, v);
n = snprintf(p_buf + off, cap - off, "%s=%s\r\n", p_key->name, v);
if (n < 0 || (size_t)n >= cap - off) {
return -1;
}
off += (size_t)n;
}
}
*p_len = off;
@@ -538,7 +619,7 @@ static int s_load_file(uint8_t **p_pbuf, size_t *p_len) {
* len - 数据长度 size_t
* 返回值:0 - 成功;-1 - 写失败或超长拒写 int
* 限定条件:p_buf 非空;len ≤ CFG_FILE_MAX_LEN
* 函数说明:E-01 纵深防御——超长文档一旦落盘,下次开机 load 守卫即判损坏、
* 函数说明:纵深防御——超长文档一旦落盘,下次开机 load 守卫即判损坏、
* 整份用户配置连同注释/未知段销毁,故在唯一出口硬性拦截,使
* "引擎永不产出超长 config.ini"成为结构性质(调用者新增路径免疫)
*/
@@ -842,7 +923,7 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
* i_sec - 段索引 int
* 返回值:1 - 是受管键;0 - 不是(无 '=' 或键名不匹配) int
* 限定条件:仅比较 '=' 前的键名(大小写无关、剥尾部空白),不解析值
* 函数说明:P2-1 用于"超长行是否落在受管键上"的判定(值可能很长,无法拷入定长缓冲);
* 函数说明:用于"超长行是否落在受管键上"的判定(值可能很长,无法拷入定长缓冲);
* 非修改式,故可安全用于仍供 Pass2 原样保留的行
*/
static int s_line_key_managed(const char *p_line, int i_sec) {
@@ -888,9 +969,9 @@ static int s_line_key_managed(const char *p_line, int i_sec) {
/*
* 函数功能:保存 Pass 1——定位段头行号与各受管键行号
* 入口参数:p_in - 原文本(可修改) char *
* 返回值:0 - 成功;-1 - 受管键行超 CFG_LINE_TEST_MAX(拒绝保存,见 P2-1) int
* 返回值:0 - 成功;-1 - 受管键行超 CFG_LINE_TEST_MAX(拒绝保存) int
* 限定条件:行末被 NUL 化且不复原——调用方须传一次性副本
* 函数说明:P2-1(2026-10-04):受管键行超过 CFG_LINE_TEST_MAX 时**不能**静默跳过
* 函数说明:受管键行超过 CFG_LINE_TEST_MAX 时**不能**静默跳过
* ——Pass2 会因该行未登记而在段头补写新 KV,同时原文超长行原样保留,导致
* 同一键出现两行(旧值残留 + 新值,解析取谁取决于行序)。故此处显式拒绝:
* 返回 -1,调用方中止保存、原文件不动、自检留痕。非受管键的超长行照旧跳过
@@ -952,7 +1033,7 @@ static int s_surgical_pass1(char *p_in) {
}
}
else if (i_cur >= 0) {
/* 超长行(> CFG_LINE_TEST_MAX):若其键名为本段受管键 → 拒绝保存(P2-1);
/* 超长行(> CFG_LINE_TEST_MAX):若其键名为本段受管键 → 拒绝保存;
* 否则按未知内容放过(Pass2 原样保留) */
if (s_line_key_managed(p_line, i_cur) != 0) {
return -1;
@@ -1004,13 +1085,18 @@ int cfg_file_register(const cfg_section_reg_t *p_reg) {
for (k = 0; k < (int)p_reg->key_count; k++) {
const cfg_key_def_t *p_key = &p_reg->p_keys[k];
if (p_key->name == NULL || p_key->len == 0u) {
if (p_key->name == NULL || p_key->len == 0u ||
strlen(p_key->name) >= CFG_NAME_MAX) {
return -1;
}
if (p_key->type == CFG_VAL_UINT &&
(p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) {
return -1;
}
if (p_key->type == CFG_VAL_INT &&
(p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) {
return -1;
}
if (p_key->type == CFG_VAL_IP4 && p_key->len != 4u) {
return -1;
}
@@ -1079,7 +1165,7 @@ int cfg_file_load(void) {
}
if (st.size > CFG_FILE_MAX_LEN) {
/* 文件超长 = 损坏:留工厂默认 + 重建模板(E-01:补自检留痕——销毁
/* 文件超长 = 损坏:留工厂默认 + 重建模板(补自检留痕——销毁
* 用户文件属重大事件,仅串口 DBG_ERROR 在现场无调试器时不可追溯) */
if (s_write_template() != 0) {
DBG_ERROR("load: corrupt file, template write FAIL");
@@ -1168,7 +1254,7 @@ int cfg_file_save(void) {
(void)memcpy(p_in1, p_in, in_len + 1u);
if (s_surgical_pass1((char *)p_in1) != 0) {
/* P2-1:受管键行超 CFG_LINE_TEST_MAX → 中止保存(原文件不动)。此类超长行
/* 受管键行超 CFG_LINE_TEST_MAX → 中止保存(原文件不动)。此类超长行
* 只可能来自手工编辑(代码生成的受管 KV 行恒 <127B);若强行外科保存会
* 产生重复键行,故宁可拒存并留痕,也不静默写坏。 */
cfg_store_free(p_in1);
@@ -1190,7 +1276,7 @@ int cfg_file_save(void) {
return -1;
}
/* E-01:输出上限由 CFG_OUT_MAX(5120) 收紧至 CFG_FILE_MAX_LEN(4096)——
/* 输出上限由 CFG_OUT_MAX(5120) 收紧至 CFG_FILE_MAX_LEN(4096)——
* 旧上限允许把 >4096B 文档合法写盘(= 下一颗雷:下次开机 load 守卫
* 判损坏、整份用户文件连同注释/未知段一起销毁)。超限走下方既有
* 溢出中止路径:原文件不动 + 自检上报,RAM 副本保留(可修复后重试) */
@@ -1256,7 +1342,7 @@ void cfg_file_poll(void) {
{
int i;
/* E-01(2026-09-21 审计 P0):超长守卫必须基于 cfg_store_stat 的真实
/* 2026-09-21 审计 P0:超长守卫必须基于 cfg_store_stat 的真实
* st.size 且先于 s_load_file——s_load_file 读长钉死 CFG_FILE_MAX_LEN
* (nread ≤ 4096 恒成立),旧检查写在读后的 len 上恒假(死码):
* 超长文件被截断当有效解析 → 界外段/键静默回工厂默认 → 基线粘住 →
+21 -9
View File
@@ -8,11 +8,16 @@
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
* v1.1 2026-09-15 王建锋 属主任务由 netTask 改为 defaultTask(配置引擎单一属主,P1-3)
* v1.2 2026-09-21 王建锋 E-01(错误码传播审计 P0):超长守卫契约同步——poll 沉降
* v1.1 2026-09-15 王建锋 属主任务由 netTask 改为 defaultTask(配置引擎单一属主)
* v1.2 2026-09-21 王建锋 超长守卫契约同步——poll 沉降
* 后先按真实 st.size 判超长(旧实现查截断后读长恒假)、
* save 输出上限收紧至 CFG_FILE_MAX_LEN、写盘唯一闸口拒写
* 超长;超长处置补自检留痕
* v1.3 2026-10-15 王建锋 新增公开常量
* CFG_FILE_MAX_NAME_LEN(段名/键名上限 15 字符)
* v1.4 2026-10-25 DTU 项目组 数据可信卡 3:新增 CFG_VAL_INT 有符号十进制值类型
* (len = 1/2/4,小端补码)——标定零点偏移等负值需有符号
* 落盘,动因与取舍见 docs/任务规划/数据可信.md §4.4
*/
#ifndef CFG_FILE_H
@@ -27,12 +32,16 @@ extern "C" {
#include <stddef.h>
/* 配置文件长度上限(字节);超过 = 文件损坏,按出厂模板重建
* (load 与 poll 同判,E-01:poll 在读取前基于真实 st.size 判定) */
* (load 与 poll 同判:poll 在读取前基于真实 st.size 判定) */
#define CFG_FILE_MAX_LEN 4096u
/* 引擎可注册段数上限 */
#define CFG_FILE_MAX_SECTIONS 8u
/* 段名 / 键名最大字符数(不含结尾 NUL);超长的注册描述符被 cfg_file_register 拒绝。
* 该上限是生成文档每行余量(112 字节)可编译期验证的前提,勿放宽 */
#define CFG_FILE_MAX_NAME_LEN 15u
/* 单段键数上限 */
#define CFG_FILE_MAX_KEYS 16u
@@ -45,17 +54,20 @@ extern "C" {
* IP4 :点分 IPv4 "a.b.c.d";字段 uint8_t[4]
* MAC :十六进制 MAC "xx:xx:xx:xx:xx:xx"(分隔符兼容 - 与 .);字段 uint8_t[6]
* STR :ASCII 字符串;字段 char[len](len = 缓冲长度,自动补 NUL)
* INT :有符号十进制(可带 + / - 号);len = 目标字段字节数(1/2/4,小端补码,
* 范围按宽度收窄:int8/int16/int32;负值合法,如标定零点偏移)
*/
typedef enum {
CFG_VAL_UINT = 0,
CFG_VAL_IP4,
CFG_VAL_MAC,
CFG_VAL_STR
CFG_VAL_STR,
CFG_VAL_INT
} cfg_val_type_t;
/* 键定义(offset/len 均相对段 RAM 副本起始) */
typedef struct {
const char *name; /* 键名(小写) */
const char *name; /* 键名(小写,≤ CFG_FILE_MAX_NAME_LEN 字符) */
cfg_val_type_t type; /* 值类型 */
size_t offset; /* 字段偏移 */
size_t len; /* 字段长度 */
@@ -63,7 +75,7 @@ typedef struct {
/* 段注册描述符 */
typedef struct {
const char *name; /* 段名(≤ 15 字符) */
const char *name; /* 段名(≤ CFG_FILE_MAX_NAME_LEN 字符) */
void *p_data; /* 段 RAM 副本(模块自有静态区,引擎原地更新) */
const void *p_defaults; /* 工厂默认值(与 p_data 同布局;load 先复位再解析) */
size_t data_size; /* RAM 副本字节数 */
@@ -91,7 +103,7 @@ int cfg_file_register(const cfg_section_reg_t *p_reg);
* 2. 文件缺失 → 按当前 RAM 副本生成出厂模板写盘(首启建文件)
* 3. 文件存在 → 解析:有效键写入 RAM 副本;无效键留工厂默认并留痕
* 4. 文件超长(> CFG_FILE_MAX_LEN)= 损坏 → 工厂默认 + 重建模板
* + 自检留痕(poll 检出运行期超长文件执行同一契约,E-01)
* + 自检留痕(poll 检出运行期超长文件执行同一契约)
*/
int cfg_file_load(void);
@@ -104,7 +116,7 @@ int cfg_file_load(void);
* 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建
* 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超
* CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报,
* 引擎写盘闸口同拒,E-01)
* 引擎写盘闸口同拒)
*/
int cfg_file_save(void);
@@ -117,7 +129,7 @@ int cfg_file_save(void);
* 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT
* 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认
* + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启
* 收敛,E-01);否则重载全部段(各段复位工厂默认后解析);挂回调
* 收敛);否则重载全部段(各段复位工厂默认后解析);挂回调
* 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线
* (回调触发的写盘不引发二次重载)
*/
+20 -8
View File
@@ -9,16 +9,20 @@
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本
* v1.1 2026-09-18 王建锋 增 cfg_store_alloc / cfg_store_free / cfg_store_heap_free:
* 共享核心临时缓冲改走 FreeRTOS 堆(P0-1,MicroLIB C 堆仅 512B)
* 共享核心临时缓冲改走 FreeRTOS 堆(MicroLIB C 堆仅 512B)
* v1.2 2026-09-22 王建锋 原子写修复:rename 前先 unlink 已存在目标——本 FatFs
* 移植 f_rename 不覆盖既有目标(FR_EXIST),旧实现一切
* "目标存在的覆盖写"必然失败(E-01 台架验证暴露)
* "目标存在的覆盖写"必然失败(台架验证暴露)
* v1.3 2026-10-02 王建锋 四个后端函数纳入卷锁 try 语义(vol_lock_wait(0)):
* 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 →
* dhara journal 竞争实证);busy 返回 -1 由调用方
* 按既有失败语义处理(poll 保留 RAM/保存待重试),
* 本任务 = 看门狗喂狗点,绝不阻塞等锁
* v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导(P3-2):与目标同目录的
* v1.5 2026-10-24 王建锋 仅注释更正:cfg_store_read 的"限定条件"引用了
* FRESULT 里不存在的 FR_NO_SYSTEM,改成实际会返回的
* FR_NOT_READY / FR_NO_FILESYSTEM 并写明"非 FR_OK 一律
* 转 -1,不要枚举错误码集合"(无行为改动)
* v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导:与目标同目录的
* 固定 8.3 名 config.TMP,不再硬编码卷根;目标恒为
* "/config.ini" 时推导结果与旧值逐字节一致
*/
@@ -47,7 +51,15 @@ static const char s_path_tmp[] = "/config.TMP";
* cap - 缓冲容量 size_t
* p_nread - 实际读字节数输出(不可为 NULL) size_t *
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
* 限定条件:卷已挂载(未挂载时 f_open 返回 FR_NO_SYSTEM,转 -1)
* 限定条件:卷已挂载。未挂载时本函数一律转 -1,但**别照着不存在的错误码去写
* 判据**:Lib/FatFs/ff.h 的 FRESULT 共 20 个码(FR_OK..FR_INVALID_PARAMETER),
* 没有 FR_NO_SYSTEM(旧注释此处笔误,2026-10-24 更正)。本移植里卷未挂载
* 实际返回 FR_NOT_READY(3)(物理驱动器不可用)或 FF_VOLUMES 内无已挂载卷时
* 由 mount_volume 直接给错;卷上无有效 FAT 则返回 FR_NO_FILESYSTEM(13)。
* 本函数(以及 write_atomic / stat / remove)对**任何**非 FR_OK 一律转 -1,
* 所以这些码的差异不影响对外语义——也正因如此,谁都不该去枚举错误码集合,
* 除了自愈触发集那种"必须区分能不能靠删文件修好"的场合(见 sys_log_mcu.c
* 的 s_is_selfheal_code)。
* 函数说明:FIL 为**函数级 static**而非局部——本 FatFs 移植 FF_FS_TINY=0,
* `FIL` 内含 `buf[FF_MAX_SS]`=512 B(Lib/FatFs/ff.h),一个局部对象就吃掉
* 528 B 栈,是配置/syslog 调用链栈深的主要来源(2026-10-10 栈预算整改)。
@@ -86,7 +98,7 @@ int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nre
#define CFG_STORE_TMP_PATH_MAX 24u
/*
* 函数功能:由目标路径推导同目录的临时文件路径(P3-2)
* 函数功能:由目标路径推导同目录的临时文件路径
* 入口参数:p_path - 目标文件路径 const char *
* p_out - 输出缓冲 char *
* cap - 缓冲容量 size_t
@@ -132,7 +144,7 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len)
if (p_buf == NULL) {
return -1;
}
/* P3-2:临时文件由 p_path 推导(同目录 + 固定 8.3 名),不再硬编码卷根 */
/* 临时文件由 p_path 推导(同目录 + 固定 8.3 名),不再硬编码卷根 */
if (s_build_tmp_path(p_path, tmp_path, sizeof(tmp_path)) != 0) {
return -1;
}
@@ -153,7 +165,7 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len)
}
/* 本 FatFs 移植的 f_rename 对已存在目标返回 FR_EXIST、不覆盖(ff.c
* "new name already in use" 检查,上游同语义)——"目标存在的覆盖写"必须先
* unlink 目标再 rename。2026-09-22 台架实证(E-01 验证暴露):缺本步时
* unlink 目标再 rename。2026-09-22 台架实证:缺本步时
* 一切非首启写(UI 保存/模板重建)静默失败,模拟器后端(POSIX rename
* 覆盖语义)照不出。unlink→rename 非原子窗口掉电 = 配置文件暂失,
* 开机 load 按"首启"重建工厂模板——与函数失败语义一致,丢失边界 =
@@ -250,7 +262,7 @@ const char *cfg_store_tmp_path(void) {
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
* 限定条件:任务上下文调用(heap_4 内部临界区自护);释放必须用 cfg_store_free
* 函数说明:MCU 端 MicroLIB C 堆仅 512B(startup Heap_Size=0x200),共享核心的 KB 级
* 缓冲若走 C 库 malloc 必然返回 NULL,故统一由本接口取 FreeRTOS 堆(P0-1)
* 缓冲若走 C 库 malloc 必然返回 NULL,故统一由本接口取 FreeRTOS 堆
*/
void *cfg_store_alloc(size_t size) {
return pvPortMalloc(size);
+125
View File
@@ -0,0 +1,125 @@
/*
* 模块名称:Volume Idle Sync(写回缓存空闲 flush)— MCU 实现
* 模块功能:就绪门控 + 周期节流 + 卷锁 try + disk_ioctl(CTRL_SYNC) 的兜底
* 状态机(设计/使用约定/边界见 vol_idle_sync.h)
* 适用平台:STM32F407ZGTx + FreeRTOS
* 作者:DTU 项目组
* 创建日期:2026-10-25
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建(道F 批 2:写回缓存空闲 flush 收口)
*/
/* 头文件包含区 */
#include "vol_idle_sync.h"
#include "test_config.h"
#if !defined(TEST_SUITE_STORAGE)
#include "cmsis_os.h"
#include "FreeRTOS.h"
#include "task.h"
#include "ff.h" /* FatFs 基础类型(diskio.h 依赖 BYTE/LBA_t 等,须先纳) */
#include "diskio.h" /* disk_ioctl / CTRL_SYNC / DRESULT */
#include "vol_lock.h"
#include "sys_events.h"
/* 本模块日志仅一条失败留痕(≤60 字符),默认行缓冲即可,不调 DBG_LINE_BUF_MAX */
#include "dbg_log.h"
/* Private defines -----------------------------------------------------------*/
/* 空闲同步检查周期(= 漏网脏页的最大掉电驻留窗,另加一个主循环拍 ≈50ms)。
* 取值依据:clean 态为"脏标志 + journal"双短路(零 NAND 触点),高频不伤介质
* 只是白跑;脏态最多一次页编程 + journal 收账,与 f_close 同型,但落在卷
* 已空闲的窗口、远离活跃写会话。5 s 与主循环其余慢观测(栈/堆水位 2 s)同
* 量级而更稀疏——本模块是兜底网,不是持久性主路径(主路径 = close 内同步)。
* 锁忙(卷不空闲)同样按本周期重试:不做欠账补偿,节奏恒定好推理。 */
#define VOL_IDLE_SYNC_PERIOD_MS 5000u
/* 静态变量定义区 */
/* 状态量放文件级 static(非函数内):主机侧白盒单测(test/host/vol_idle_sync/)
* 以 #include 纳入本文件后需要复位它们;命名带 vol 前缀避免与测试桩撞名 */
static uint32_t s_vol_sync_last_ms; /* 上次尝试时刻(tick ms,uint32 回绕) */
static uint8_t s_vol_sync_armed; /* 0 = 从未尝试(首拍直接放行) */
static uint8_t s_vol_sync_fail_latch; /* 失败留痕边沿 latch:一个 episode 一条 */
/* 函数定义区 */
/*
* 函数功能:存储空闲周期同步轮询(就绪门控 → 节流 → try 卷锁 → CTRL_SYNC)
* 入口参数:无 void
* 返回值:无 void
* 限定条件:仅 defaultTask 主循环调用;内部绝不阻塞(try 锁 + 常数级耗时,
* 最坏成本 = 一次 f_close 的同步段)
* 函数说明:四级门,逐级变贵:
* ① 就绪门:sys_evtHandle 未建或 STORAGE_READY 未置 → 静默
* (挂载前 disk_ioctl 只会 RES_NOTRDY,且 bring-up 失败重试期
* 卷状态未定,一律不碰);
* ② 节流门:距上次尝试不足 VOL_IDLE_SYNC_PERIOD_MS → 返回。
* 无符号差值判据((uint32)(now - last) < 周期)天然跨 32 位回绕
* 正确,与 stack_probe_poll 同一 idiom;勿"优化"成大小比较;
* ③ 空闲门:vol_lock_wait(0) try——锁被持有 = 有文件操作序列在做
* (syslog 落盘 / 配置保存 / lftpd open...close),本轮跳过,
* 绝不改变"文件持有期内不同步"的既有语义;
* ④ 动作:disk_ioctl(0, CTRL_SYNC)——FatFs 官方幂等落盘原语
* (f_close 尾部同一条路径):FTL 脏页刷入 + dhara_map_sync。
* clean 时两级短路立即返回,零 NAND 操作。
* 失败留痕:RES_ERROR 只按"边沿"打一条 DBG_ERROR(latch),持续失败
* 不刷屏;下一次成功后 latch 复位,新 episode 再报。底层失败形态
* (Cache flush FAIL / Map sync failed)已由 nand_ftl 就地打点,
* 本条只补"空闲路径也救不回"的上下文,不重复堆日志锚点。
*/
void vol_idle_sync_poll(void) {
const uint32_t now = (uint32_t)xTaskGetTickCount();
DRESULT dr;
/* ① 就绪门 */
if (sys_evtHandle == NULL) {
return;
}
if ((osEventFlagsGet(sys_evtHandle) & SYS_EVT_STORAGE_READY) == 0u) {
return;
}
/* ② 节流门(回绕安全判据,见函数说明②) */
if ((s_vol_sync_armed != 0u)
&& ((uint32_t)(now - s_vol_sync_last_ms)
< (uint32_t)pdMS_TO_TICKS(VOL_IDLE_SYNC_PERIOD_MS))) {
return;
}
s_vol_sync_last_ms = now;
s_vol_sync_armed = 1u;
/* ③ 空闲门:try 锁,拿不到 = 卷不空闲,本轮跳过(下周期再试) */
if (vol_lock_wait(0u) == 0u) {
return;
}
/* ④ 幂等落盘:脏页刷入 + journal 收账;clean 时零 NAND 触点 */
dr = disk_ioctl(0, CTRL_SYNC, NULL);
vol_lock_release(); /* 成败都放锁:同步失败不产生锁泄漏 */
if (dr != RES_OK) {
if (s_vol_sync_fail_latch == 0u) {
s_vol_sync_fail_latch = 1u;
DBG_ERROR("[FS] idle sync FAIL (dr=%d)", (int)dr);
}
} else {
s_vol_sync_fail_latch = 0u; /* 成功:重新武装,下个 episode 可再报 */
}
}
#else /* TEST_SUITE_STORAGE */
/*
* 函数功能:STORAGE 套件构建下的空实现(编译期裁剪落点)
* 入口参数:无 void
* 返回值:无 void
* 限定条件:仅当 test_config.h 选中 TEST_SUITE_STORAGE 时编入本分支
* 函数说明:该构建里挂载/盘写由 testTask 的 storage_test_run 独占(测试含
* nand_ftl_deinit 掉电模拟),后台同步与它并发会互相踩坏 FTL/FatFs
* 状态——必须彻底静默,而非仅靠运行时门控。
*/
void vol_idle_sync_poll(void) {
}
#endif /* TEST_SUITE_STORAGE */
+50
View File
@@ -0,0 +1,50 @@
/*
* 模块名称:Volume Idle Sync(写回缓存空闲 flush)
* 模块功能:随 defaultTask 主循环周期轮询,在存储**空闲**时对 NAND FatFs 卷
* 补发一次 CTRL_SYNC(FatFs 幂等落盘语义 = FTL 单页写回缓存脏页刷入
* + dhara journal 待办收账,见 docs/架构设计/STM32F4-Base存储架构说明.md
* §5.5),作为写回缓存持久性的兜底网:
* - 正常写事务语义不变:f_close 内部 sync_fs 已同步,本模块零干预;
* - 价值在漏网兜底:任何"缓存留脏、之后再无调用触发 sync"的状态
* (异常路径中途放锁等)其掉电驻留窗从"无限期,直到下一笔写"
* 收敛为"不超过本模块周期";
* - 卷不空闲(卷锁被持有:syslog 落盘 / 配置保存 / lftpd
* open...close 序列进行中)本轮跳过——不改变文件持有期(如 FTP
* STOR 进行中)的既有语义。
* clean 态零 NAND 触点(脏标志与 journal 双短路);脏态成本与一次
* f_close 同型。周期 5 s(.c 内 VOL_IDLE_SYNC_PERIOD_MS)。
* ⚠ 边界(勿当成掉电保护):本板无掉电检测硬件,"掉电前强制 flush"
* 不可行;"单次写事务进行中掉电"的丢失边界也不因本模块改变。
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2);仅 MCU 端,
* 主机侧 L0.5 单测 test/host/vol_idle_sync/
* 作者:DTU 项目组
* 创建日期:2026-10-25(道F 批 2:写回缓存空闲 flush 收口)
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:STORAGE_READY 门控 + 周期节流 +
* 卷锁 try + disk_ioctl(CTRL_SYNC) + 失败边沿 latch
*/
#ifndef VOL_IDLE_SYNC_H
#define VOL_IDLE_SYNC_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* 函数功能:存储空闲周期同步的轮询入口(内部自带就绪门控 / 节流 / 非阻塞
* try 锁 / clean 短路,调用点每轮无条件调用即可)
* 入口参数:无 void
* 返回值:无 void
* 限定条件:仅 defaultTask 主循环调用(存储属主任务;本函数内部绝不阻塞——
* defaultTask 是看门狗喂狗点,见 vol_lock.h 使用约定);
* TEST_SUITE_STORAGE 构建下为空实现(卷由 testTask 独占,后台同步
* 必须彻底静默)
*/
void vol_idle_sync_poll(void);
#ifdef __cplusplus
}
#endif
#endif /* VOL_IDLE_SYNC_H */
+61 -2
View File
@@ -6,6 +6,13 @@
* 创建日期:2026-10-02
* 修改记录:
* v1.0 2026-10-02 王建锋 创建
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 新增 4 个观测计数(T-a/T2/T3/T4,
* 共 16 B 静态)与 vol_lock_get_stats() 只读口 —— 不取锁、
* 零外部调用、零局部变量,13 处调用点一字未动;
* ② 修 :38「返回值」注释与代码相反的错(fail-open 原被
* 归给 0,实际返 1);③ 刻意不记时刻:时刻要读 tick,
* 等于给 defaultTask 取锁链添帧,改由消费方观测并自盖
* (命名 obs_first_ms/obs_last_ms,见 vol_lock.h 告诫 ②)
*/
/* 头文件包含区 */
@@ -16,6 +23,22 @@
/* 静态变量定义区 */
static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */
/* 观测计数(道G task-17「卷锁 5 态可观测性」)—— 本模块唯一的新增状态,共 16 B。
* 为什么计数必须在锁层、而且只有计数没有别的:
* ① 13 处调用点各自 return、历史上零痕;在每个调用点各写一份 = 13 份真源。
* ② 不能在这里打日志或投记录:唯一能落盘的通道(/log/wdev.log、run.log)自己也要
* 取这把卷锁,而卷锁**非递归** —— 被卷忙挡住时既写不出东西,还会自取死锁。
* ③ 也不读 tick 记时刻:那会给 defaultTask 的取锁链添一个 callee 与几字节帧,而
* 这条链的栈余量正是要省着花的;"什么时候开始忙"交给消费方观测(口径见 vol_lock.h
* 的告诫 ②:观测时刻不等于发生时刻,别写成首次发生)。
* 并发:多个任务可同时自增同一个计数,竞态下少计不会多计(判据是"是否 > 0 / 是否还
* 在增长",不是精确值),所以不为此上临界区 —— 上临界区反倒把观测做成了故障本身。
* volatile:跨任务读写且无锁保护,禁止把读侧重复读优化掉。 */
static volatile uint32_t s_st_acquired; /* T-a 真取到互斥 */
static volatile uint32_t s_st_try_timeout; /* T2 try(timeout=0)失败:卷被别人占住 */
static volatile uint32_t s_st_blk_timeout; /* T3 分片等待超时:ftpTask 正常退让 */
static volatile uint32_t s_st_failopen; /* T4 锁不存在却返 1:谎报成功那一态 */
/* 函数定义区 */
/*
@@ -35,18 +58,35 @@ int vol_lock_init(void) {
/*
* 函数功能:获取卷锁(首次调用自动 vol_lock_init)
* 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t
* 返回值:1 - 已持锁;0 - 超时/未取得,或锁创建失败(fail-open) int
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(返 1 但此刻并未串行化)**;
* 0 - 未取得(timeout=0 的 try 失败,或 timeout>0 的分片等待超时) int
* 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证
* 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞
* 函数说明:⚠ 原 v1.0 这行「返回值」把 fail-open 归给 0,与本函数 :46 的代码和
* TC-VOLLOCK-008 都相反(道G task-17 修正)。"返 1"有两种成因,调用方
* 无需各自分辨(它们只关心能不能干活),但整机的判据必须看
* vol_lock_get_stats().failopen —— 累计 > 0 = 本 boot 至少发生过一次
* "无互斥读写卷"。本函数只自增计数:不打日志、不投记录、不读 tick、
* 不新增局部变量(理由见上面计数块)。
*/
int vol_lock_wait(uint32_t timeout_ms) {
if (s_lock == NULL) {
(void)vol_lock_init();
if (s_lock == NULL) {
s_st_failopen++; /* T4:返 1 但零互斥 —— 谎报成功那一态 */
return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */
}
}
return (osMutexWait(s_lock, timeout_ms) == osOK) ? 1 : 0;
if (osMutexWait(s_lock, timeout_ms) == osOK) {
s_st_acquired++; /* T-a:真持锁 */
return 1;
}
if (timeout_ms == 0u) {
s_st_try_timeout++; /* T2:压根没等(喂狗点/绘制路径只有这一态会发生) */
} else {
s_st_blk_timeout++; /* T3:等了一会儿没等到(分片退让),语义与 T2 不同 */
}
return 0;
}
/*
@@ -60,3 +100,22 @@ void vol_lock_release(void) {
(void)osMutexRelease(s_lock);
}
}
/*
* 函数功能:读卷锁观测计数(契约与消费方告诫见 vol_lock.h 的函数头)
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
* 返 回 值:无 void
* 限定条件:不取卷锁、不关中断、不阻塞;**零外部调用、零局部变量**,因此不改变
* 任何调用方的栈最坏链(这条是本卡选型的结论,不要"顺手优化"掉)
* 函数说明:逐字段拷而非整体赋值 —— 每个字段都是某个真实值,不会读到半更新的字;
* 四元组之间不保证同一瞬间(诊断够用,理由见 .h 的告诫 ①)
*/
void vol_lock_get_stats(vol_lock_stats_t *p_out) {
if (p_out == NULL) {
return;
}
p_out->acquired = s_st_acquired;
p_out->try_timeout = s_st_try_timeout;
p_out->blk_timeout = s_st_blk_timeout;
p_out->failopen = s_st_failopen;
}
+62 -1
View File
@@ -13,6 +13,13 @@
* 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点)
* → IWDG 误复位;单写者卷属主假设在多任务 FatFs
* 调用下不成立,须显式串行化)
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 修契约——vol_lock.c 原 :38 的
* 「返回值」把 fail-open 归给 0,而代码(:46)与
* TC-VOLLOCK-008 都是返 1;现在 .h/.c 两处都按代码写实,
* 并显式警告"返 1 也可能是零互斥";② 新增 5 态计数与
* vol_lock_get_stats() 只读口(不取锁、任意上下文可调);
* ③ 使用约定补「可观测性」段(为何计数在锁层、为何不得
* 在失败分支里取锁打日志)
*
* 使用约定(铁律):
* - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir /
@@ -23,6 +30,12 @@
* - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat),
* 防长等待导致 FTP 心跳位缺席误复位;
* - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。
*
* 可观测性(道G task-17):锁层自带 5 态计数 + `vol_lock_get_stats()` 只读口。
* 为什么计数必须在**锁层**:唯一能把事写下来的通道(`/log/wdev.log` 等)自己也要
* 取这把锁,而卷锁**非递归**——被卷忙挡住时既写不出日志,也不许在失败分支里再取
* 同一把锁。所以锁层只自增计数;一次性留痕交给 defaultTask(syslog 唯一写者)按边沿
* 补投自检记录。调用方 13 处 try 的分支与返回值一字不动(不动分支 = 不动行为)。
*/
#ifndef VOL_LOCK_H
@@ -45,11 +58,59 @@ int vol_lock_init(void);
/*
* 函数功能:获取卷锁
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
* 返回值:1 - 已获取;0 - 超时未获取(卷被占用) int
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(此时并未串行化)**;
* 0 - 未取得(try 超时,或分片等待超时) int
* 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0)
* 函数说明:⚠ 道G task-17 修掉的契约缺口——本函数只有 0/1 两值,而"返 1"有两种
* 成因:真取到锁,与 `osMutexNew` 失败时的 fail-open(返 1 但对卷的访问
* **零互斥**)。要求 13 处调用方各自区分是不现实的(它们只关心"能不能干活"),
* 判据请改用 `vol_lock_get_stats()` 的 `failopen` 计数:累计 > 0 =
* 本 boot 内至少发生过一次"无互斥读写卷"。
* 本函数内只**自增计数**:不打日志、不投记录、不阻塞(理由见 .c 计数块)。
*/
int vol_lock_wait(uint32_t timeout_ms);
/*
* 卷锁观测计数(道G task-17「卷锁 5 态可观测性」)。四个字段就是四态,语义**不得**合并:
* T-a `acquired` = 真取到互斥的次数
* T2 `try_timeout` = timeout=0 的 try 失败次数(卷被别的任务占住;defaultTask/uiTask
* 的喂狗与绘制路径只有这一态会发生)
* T3 `blk_timeout` = timeout>0 的分片等待超时次数(ftpTask 100 ms 分片正常退让,
* 语义与 T2 不同:那是"等了一会儿没等到",这是"压根没等")
* T4 `failopen` = 锁不存在却返 1 的次数(**谎报成功**那一态,见 wait 的函数头)
* T1「从未调用过」不占计数:三个计数全 0 就说明这条路径没跑过——区分"没跑过"与
* "跑了但一直忙"正是本卡的目的(此前两者在日志上完全一样)。
* ⚠ 本层**刻意不记时刻**:记时刻就得读 tick,等于给 defaultTask 的取锁链添一个
* callee 与几字节帧,而这条链的余量我们整天都在省着花(诊断不值那几个字节)。
* 代价是"什么时候开始忙"只能由消费方观测,见下面 get_stats 的第② 条告诫。
*/
typedef struct {
uint32_t acquired;
uint32_t try_timeout;
uint32_t blk_timeout;
uint32_t failopen;
} vol_lock_stats_t;
/*
* 函数功能:读卷锁观测计数(纯拷字段)
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
* 返 回 值:无 void
* 限定条件:任意上下文可调——**不取卷锁**、不关中断、不阻塞(卷被长期占住时正是最
* 需要读这几位的时刻,观测口再抢锁就把观测做成故障的一部分)
* 函数说明:① 四个字段各是 32 位对齐量,Cortex-M4 上单字读写原子,但**不保证是同一
* 瞬间的四元组快照**(诊断够用)。计数本身跨任务读-改-写,竞态下可能
* **少计不会多计** → 判据用「是否 > 0 / 与上一轮比是否增长」,别拿精确值当账。
* ② ⚠ **给消费方的告诫**(消费方 = 看门狗/自检记录道,接口按此写就不会错):
* 本层不产出时刻。消费方若自行盖时间戳,那是「**被观测到的时刻**」,不是
* 「**发生的时刻**」——真实发生可以早到一整个轮询周期之前。命名请用
* `obs_first_ms` / `obs_last_ms`(观测时刻)这类,**别用** `first_ms`/`last_ms`,
* 文案里也别写「首次/末次发生」(那就是新的谎报)。
* 一次性留痕自己做边沿:照 `sys_log.h` 的 `syslog_post_dropped()` 先例(唯一
* 写者按差值补发,一个 episode 一条),锁层自身绝不产日志、不投记录。
* ③ 本函数零外部调用、零局部变量(除出参),因此不改变任何调用方的栈最坏链。
*/
void vol_lock_get_stats(vol_lock_stats_t *p_out);
/*
* 函数功能:释放卷锁(获取方任务释放)
* 入口参数:无
+62 -1
View File
@@ -7,7 +7,7 @@
| 文件 | 作用 |
|---|---|
| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` |
| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录,七类各一个 `/log` 文件(run / selftest / accid / alarm / start / oper / yk)|
| `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) |
| `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 |
| `soe_rec.c/h` | **SOE 记录纯编解码**(B9):64B 定长记录(CP56Time2a 时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS 依赖,主机单测覆盖 |
@@ -23,6 +23,67 @@
内核启动后由 `syslog_drain` 惰性创建真队列并排空
- ⚠️ 写盘必须校验 `f_write` 返回的**字节数**(该 FatFs 移植在簇分配失败时返回 `FR_OK` + 短写,
不报错误码);追加写必须 `FA_WRITE|FA_OPEN_APPEND`(`FA_OPEN_APPEND` 单独用**不含写位**)
- **清除记录 = 请求 / 落地分离**:非写者上下文(uiTask 等)只能 `syslog_request_clear(id)`
置一个标志(零盘触点、零卷锁),真正的 `f_unlink` 在 **defaultTask** 的 `syslog_drain()`
**尾部**执行——顺序也是判据的一部分:排在条目排空之后,否则本轮刚排空的旧记录会被
写回去、`syslog_count()` 不归零。结果读 `syslog_clear_state(id)`
(`NONE/OK/WAIT/FAIL`),事实复核读 `syslog_count()`。
落地函数 `syslog_clear_apply(id)` 是 MCU 后端专属(模拟器不实现 → 共享代码误调在
链接期就报错):卷锁一律 try,卷占用返 -1 且**不动原文件**;幂等只认 `FR_NO_FILE`,
`FR_NO_PATH` 如实返 -1 且**不做任何卷级动作**(不补目录、不重格式化)
- 删除的作用域 = 上表七个记录文件(`syslog_request_clear(id)` 逐 id 独立请求);取证文件
`wdev.log` 与 `soe.log` 各有各的属主与通道,不在本 API 作用域,想清它们要另开 API。
清除成功会往**操作记录**里落一条 `clear <文件>`——但清 `oper.log` 自己时**刻意不投**,
否则"我清除了记录"这条会把刚删空的文件又写成 1 条,直接砸掉「删除后 `syslog_count()`
归零」这条判据(`TC-SYSLOG-133b` 钉住)
- ⚠ 宿主后端(`simulator/sim/sim_syslog.c`)与 MCU 端**判据相同、时序不同**:模拟器单线程,
七类对应工作目录下七份宿主文件(`sim_accid.log` 等),`syslog_post_quiet()` 在本端
等价于 `syslog_post()`(宿主进程没有那条栈硬线,也没有队列与丢弃);
`syslog_request_clear()` 调用内就删完了,因此 `WAIT` 态在模拟器上永不出现
(同 `syslog_post` 在两端"有队列 / 直写无队列"的差异)
### 五类记录与「记录类型 = 日志 ID」(道F task-10,2026-10-26;task-11 记录 5 页按此做)
**决策一:记录类型就是 `syslog_id_t` 本身,一类记录 = `/log` 下一个严格 8.3 短名的文件。**
64B 定长记录体里**没有类型字段位**:`[0..18]` 时标 / `[19]` 空格 / `[20..29]` epoch /
`[30]` 空格 / `[31..58]` 描述 / 尾部补零,这个布局被 UI 记录页与主机解码工具按**固定偏移**
读了了很久(run.log / selftest.log 是既有文件,既有读者)。塞类型字节要么挪走 epoch
(上电/重启分类就断),要么挪走描述(屏上少字),且两端读者同批改——所以
"这条记录是什么类型"由**它在哪个文件里**回答。`TC-SYSLOG-136` 把这条钉成机器断言:
同一条内容在七类之间逐字节相同,记录体不编码类型。
**决策二:不动 `soe_type_t`(二进制 SOE 通道保持现有 8 型)。** 加 SOE 类型必须同步 UI 侧
`ui_w_soe.c` 的类型名表(那是另一位队友的写域),而 SOE 页与这五页本就是两套读者
(二进制解码器 vs 文本列表)。**别把新类型顺手加进 SOE**:那会同时造出
"同一个事件两个真源"。
| 记录 | 日志 ID / 文件 | 容量上限 | 写入点 | 触发判据 |
|---|---|---|---|---|
| 事故 | `SYSLOG_ACCIDENT` / `accid.log` | 512 | `App/task/protect_task.c:181` | `asserted` 上升沿**且**该元件跳闸投退项 = 1 |
| 保护告警 | `SYSLOG_ALARM` / `alarm.log` | 1024 | 同文件 `184` | `asserted` 上升沿**且**跳闸投退项 = 0 或该元件无此项 |
| 保护启动 | `SYSLOG_START` / `start.log` | 1024 | 同文件 `176` | `picked` 上升沿(越限电平,早于/等于动作) |
| 操作 | `SYSLOG_OPER` / `oper.log` | 512 | `App/protocol/iec104/iec104_pts.c:574/602`、`App/task/default_task.c:242`、`App/log/sys_log_mcu.c:646`(宿主 `sim_syslog.c:245`) | 强制复归受理 / 软压板投退落地 / 定值**真落盘成功** / 清除记录成功 |
| 遥控 | `SYSLOG_YK` / `yk.log` | 256 | `App/protocol/iec104/iec104_pts.c:573/576/582/586/596/598/609` | 104 双点命令的选择段、执行段、非法 DPC、未知 IOA 各一条 |
- **"哪些元件算跳闸"只有一个真源**:定值表 `g_prot_set_meta` 里的 `PS_*_TRIP`(枚举注释
即"0 告警 / 1 跳闸")+ 实例表 `k_pe[]` 的 `trip_ctl` 列。分类入口
`prot_elm_record_kind()`(`App/algo/prot_elm.c`)里**没有第二份元件名单**,
没有该投退项的五项(OV/UV/3U0/OF/UF)填 `PROT_ELM_NO_TRIP=0xFF` → 恒告警;
`TC-PROTELEM-015` 逐元钉住"逐项独立 / 改定值即改类别 / 无此项者不受别人定值影响"。
- **描述只用 ROM 常量或 `prot_elm_name()` 直传**:所有写入点零 `snprintf`、零拼接。
最长一条 20 字节 < `SYSLOG_DESC_MAX`(28);`TC-IEC104-030` 逐条量长度(超 28 会被
投递侧静默截断,屏上少几个字,没人会去数)。
- ⚠️ **两个投递口,按调用者的栈预算选**:`syslog_post()` 的两个"满态丢弃留痕"分支
内联着日志行缓冲(每处约 216 B 栈),因此 `syslog_post_quiet()` 给静态最坏链吃紧的
任务用(**protectTask、iec104Task 必须用它**),丢弃只累加 `syslog_post_dropped()`、
由唯一写者 defaultTask 在 `syslog_drain()` 里按差值补一条留痕——同 `soe_store_post`
当年那条 `872 → 1192 B FAIL` 的实证纪律。收/丢判据两个口完全一致。
- **读侧契约(给 task-11)**:`syslog_count(id)` + `syslog_get_record(id, idx, ts, ts_len,
desc, desc_len)` 与运行/自检记录**同一枚 reader**,"最近 20 条"= 从 `count-20` 起读;
`count()==0` 就是空态,页面显示"无记录"。**不要**在共享代码里调 MCU 专属的
`syslog_clear_apply()`(模拟器不实现 → 链接期报错是故意的);清除一律走
`syslog_request_clear()` + `syslog_clear_state()`,并把 `WAIT` 当正常态看(模拟器端
永不出现 `WAIT`,见下条)。
### console_log(控制台单写者)
+365 -49
View File
@@ -8,17 +8,17 @@
* 修改记录:
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变,
* 仅变更文件位置:HAL 库生成文件不得包含应用代码)
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架 gen-288 实证):round-3 的
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架日志代次 288 实证):第 3 轮的
* "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后
* 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms
* 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙
* 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、
* 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮
* drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),
* 保证喂狗节奏 ≤ ~300ms。根治(USART1 TX-only 灭 ORE 机制)
* 另行推进(.ioc 结构性修改 + CubeMX 重生成,见 docs/任务规划/登记簿.md S-3)。
* 2026-10-02 王建锋 round 8b 加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
* 台架 round 8a 取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
* 保证喂狗节奏 ≤ ~300ms。根治(把 USART1 改成只发不收)已于 2026-10-26
* 由用户裁定不做,本缓解即长期方案;机制详述 docs/子系统设计/控制台单写者日志设计说明.md §12。
* 2026-10-02 王建锋 第 8b 轮加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
* 台架第 8a 轮取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
* 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 =
* HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖
* TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE →
@@ -31,6 +31,37 @@
* adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化
* 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。
* 提供就绪查询供周期任务门控自身日志,不改直发路径本身。
* 2026-10-26 王建锋 **O1 第二个实例(台架实证)修复:裸发路径加「行级单一所有者」**。
* 台架同镜像两次启动(fw 戳 Oct 26 2026 16:27:01,排除构建差异):16:28 那次
* protectTask 的未标定 INFO 完好,16:37 那次同一行变成
* "protection [blocked (UA/UN/IA VALI2D not all s0et)" —— 多出的 3 个字节是
* '[' '2' '0',而 DBG 行前缀恰是 '[' + sys_clock_get_str() 的 "2026-…",
* **即另一条行的行首三个字符**;串口 framing 错只会改字节、不会让行变长,
* 故只可能是另一个写者在同一行内插入。机理 = 写者注册前(内核启动 ->
* default_task.c 的 dbg_console_writer_attach,中间隔着整段 storage_bring_up)
* 所有任务的整行都走 dbg_console_write_raw 逐字节轮询 USART1->DR,**零互斥**
* (docs/子系统设计/控制台单写者日志设计说明.md 在册 O1;20261012_B6 那次
* "st[art:" 同一形态)。修法 = con_tx_try_claim()/con_tx_release() 一对
* **非阻塞**所有权(只在几条指令的临界区里做 test-and-set,绝不把 7.8 ms
* 的发送包进临界区、绝不关中断、绝不等待);抢不到所有权的那条行**不丢**,
* 落进既有环形缓冲延后发(竞争失败不该变成丢行,更不该变成 [ERR] 假红——
* 那个窗口每 3 次启动命中 2 次),只有环也放不下才计入既有 dropped 账
* (真溢出仍然看得见)。顺带堵掉第二处洞:**attach 恰好撞上别人已发出的半行**
* ——dbg_console_drain 现在也必须先拿到所有权才开轮,拿不到就让位
* (tail 不推进,环里一条不少,下轮 ≤50 ms 后重发,这条路径永不丢)。
* 另导出 dbg_console_tx_begin()/tx_end() 供"一条行分多段裸发"的调用方收口
* (Src/freertos.c 故障钩子是下一张卡),本模块不改任何调用方文件。
* 2026-10-24 王建锋 主机单测(test/host/console_log)照出的四条一并修复:
* 冷却改判独立标志 s_con_cooling(旧写法拿 tick 值当"是否在冷却"哨兵,
* 故障恰好落在 HAL_GetTick()==0 那一拍时冷却等于没记);
* con_append 的"复核空位 + 整行拷贝 + 推进 head"收进同一临界区(旧写法
* 判据在区外,多生产者可在环将满时同时过判据 -> head 越过容量把未发出
* 的旧行静默覆盖,既不报错也不计丢弃);
* drain 失败分诊:Instance==NULL(未初始化)不再走外设软复位自愈(那会
* 在 HAL 未就绪时重配 RCC/GPIO),改记 dropped;僵尸通道仍自愈;
* 三本账分开(docs §13-O2):新增 dbg_console_tx_timeout(),dropped 只记
* 真丢的,tx_fault 仍只记软复位自愈次数;[ERR] dropped 行改为"发成功才
* 销账",通道不可用期间不再把丢弃证据一起冲掉。
*/
#include "console_log.h"
@@ -55,7 +86,10 @@
* ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit
* 发出 → 行与行不可能再交错(UART 只有这一个调用点);
* ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART;
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接阻塞发送(无并发或必须裸发)。
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接发送,但**整行独占**
* 由 con_tx_try_claim() 保证(道G task-23:未注册期原来就是"多个任务都直发、
* 零互斥",O1 两个实例都出在这里);抢不到独占的那条行不丢,落环形缓冲延后发,
* 与当场直发逐字节相同。ISR / 调度器未启动两种上下文放行(不许等)。
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
*/
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
@@ -64,13 +98,13 @@
#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */
#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */
#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益
且拖长主循环周期 → 喂狗节奏饥饿(gen-288 实证);
且拖长主循环周期 → 喂狗节奏饥饿(台架日志代次 288 实证);
冷却期内 drain 只让位、不发送,下轮重试 */
/* ==== round 8b:与时间源无关的硬上界(DWT CYCCNT)====
/* ==== 第 8b 轮:与时间源无关的硬上界(DWT CYCCNT)====
* HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基
* = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 →
* IWDG 误复位(round 8a 台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
* IWDG 误复位(第 8a 轮台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
* 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */
#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */
/*
@@ -89,20 +123,74 @@
static uint8_t s_con_ring[DBG_CON_RING_SIZE];
static volatile uint16_t s_con_head; /* 生产者写入位置 */
static volatile uint16_t s_con_tail; /* 写者读取位置 */
static volatile uint32_t s_con_drop; /* 因缓冲满丢弃的行数 */
static volatile uint32_t s_con_drop; /* 真没落地的丢弃数(一次一行/一块) */
static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */
static volatile uint8_t s_con_tx_fault_log; /* TX 故障留痕待上报(由写者任务在 drain 之外补记 run.log) */
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(0 = 无冷却) */
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(只存时刻,不再兼作哨兵) */
static volatile uint8_t s_con_cooling; /* 1 = 正在故障冷却(独立标志:tick 恰好等于 0 的那一拍也必须算在冷却) */
static volatile uint32_t s_con_tx_timeout; /* 发送块超时次数(写者排空所见,与丢弃账分开) */
static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保护) */
static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */
static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */
static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */
/* 行级单一所有者(道G task-23,O1 第二实例):USART1 数据寄存器同一时刻只允许
* 一个上下文在逐字节轮询写入。NULL = 空闲。
* ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见
* con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给
* 启动窗口塞进 7.8 ms 的中断延迟与最坏喂狗抖动,比交错本身更糟)。 */
/*
* 本模块"只有几条指令"的关中断窗口(道G task-26 收口时定,2026-10-26)
* ---------------------------------------------------------------------
* 刻意**不用** taskENTER_CRITICAL()/taskEXIT_CRITICAL(),改用 CMSIS 的 PRIMASK
* 保存 + 恢复。理由是一条只在**静态调用图**上看得见、真机上却很贵的坑:
* taskEXIT_CRITICAL() => vPortExitCritical() => configASSERT()
* => vApplicationAssertFailed() (本仓断言钩子自 2026-09-29 起就调
* dbg_console_write_raw 上报 file:line)
* => 又回到本模块 => 又进临界区
* armlink 因此在本模块周围长出三条互递归环(con_tx_try_claim => vPortExitCritical、
* dbg_console_write_raw => con_tx_release、con_ring_try => vPortExitCritical),并把
* 这坨算进**每一条含临界区的任务链**:实测 vApplicationAssertFailed 的 Max Depth 从
* 约 40 B 变 128 B + In Cycle,defaultTask 3,040->3,120、netTask 1,920->2,000、
* iec104Task 1,464->1,544 —— 三条同涨 80 B 就是"加深在共享帧上"的铁证,iec104 的
* margin 因此掉到 1,016 < 1,024 ISR 底线。
* 换成 PRIMASK 之后:
* ① 三个原语都是 core_cmFunc.h 里的静态内联 —— 零调用帧、零断言,那三条环不成立;
* ② **屏蔽面变宽是有意的**:本 port(RVDS/ARM_CM4F)的临界区走 BASEPRI,只挡
* 优先级数值 >= configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,PRIMASK 连更高
* 优先级的也一起挡(NMI 与 HardFault 除外)。这是**语义变强**,不是变弱;窗口
* 只有几条指令(一次 test-and-set / 一次清属主 / 一次整行入环拷贝,与换之前
* 同宽度同时长),代价是几微秒级的额外屏蔽,读代码的人必须知道这一点。
* ③ 不依赖调度器与 uxCriticalNesting,未注册期与中断里同样可用。
* ⚠ **为什么必须把 PRIMASK 存进局部变量、退出时原样写回,而不是直接 __enable_irq()**:
* 本模块的窗口可能嵌套在调用者已有的临界区里(断言/故障钩子、写者 drain 轮内的
* 留痕行都是)。无条件开中断 = **替别人把任务级临界区提前打开**,那是比交错严重
* 一个量级的竞态;保存/恢复则天然支持嵌套。保存量放局部而不是共享静态量,是因为
* 窗口可能被更高优先级上下文再次进入,共享量会被它改写、退出时就恢复错了。
* ⚠ 只允许包住"几条指令"的量级,**绝不允许**包住串口发送本身:一行 90 B @115200
* 约 7.8 ms,把它关进中断等于给启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动
* (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b/177)。
*/
#define CON_CRIT_ENTER(pm_var) do { (pm_var) = __get_PRIMASK(); __disable_irq(); } while (0)
#define CON_CRIT_EXIT(pm_var) do { __set_PRIMASK(pm_var); } while (0)
static volatile TaskHandle_t s_con_tx_owner;
static volatile uint8_t s_con_tx_depth; /* 同一任务重入深度(drain 轮内的裸发留痕行) */
/* con_tx_try_claim 的三种结果(区分开是必要的:**放行**的那些上下文没有持有凭证,
* 绝不能在释放时把别人的所有权清掉) */
#define CON_TX_NO 0u /* 别人正持有整行 -> 调用方让路(不阻塞) */
#define CON_TX_BYPASS 1u /* 放行:本上下文不许等也无需凭证(ISR / 调度器未启动) */
#define CON_TX_HELD 2u /* 已持有:调用方发完必须 con_tx_release */
/*
* 函数功能:计算环形缓冲已用字节数(head-tail 差值回绕取模,无锁)
* 入口参数:无
* 返回值:已用字节数 uint16_t
*/
static uint8_t con_ring_try(const char *p_buf, uint16_t len);
/* 定义在下方 con_append 旁;裸发让路路径要用,故前置声明
* (ARMCC 对隐式声明报警告,本仓红线是 0 警告) */
static uint16_t con_used(void) {
return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U));
}
@@ -179,7 +267,7 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
con_dwt_ensure();
if (huart1.Instance == NULL) {
return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位 */
return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位(不是超时,不记超时账) */
}
if (s_con_dwt_up == 0U) {
return con_tx_counted(huart1.Instance, p_buf, len);
@@ -190,33 +278,169 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
for (i = 0U; i < len; i++) {
while ((u->SR & USART_SR_TXE) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT; /* 有界:本块放弃(调用方自愈) */
return HAL_TIMEOUT; /* 有界:本块放弃(记账由调用方按返回值做) */
}
}
u->DR = (uint16_t)p_buf[i];
}
while ((u->SR & USART_SR_TC) == 0U) {
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
return HAL_TIMEOUT;
return HAL_TIMEOUT; /* TC 超时:字节已交给发送器,调用方分账 */
}
}
return HAL_OK;
}
/*
* 函数功能:控制台裸发送(异常/早期路径专用:有界、单次、不加锁)
* 函数功能:尝试取得"USART1 整行发送所有权"(**非阻塞**,道G task-23)
* 返回值:CON_TX_HELD 已持有(发完必须 con_tx_release)/ CON_TX_BYPASS 放行且不记账
* / CON_TX_NO 别人正持有整行,调用方须让路
* 限定条件:任意上下文(内部只有一次几条指令的临界区,绝不睡眠、绝不长期关中断)
* 函数说明:三条规则各有硬理由,改动前请连读 docs/子系统设计/控制台单写者日志设计说明.md
* 的「行级单一所有者」一节:
* ① IPSR != 0(异常/中断,含四个 Fault 的崩溃上报)-> **放行**:这些上下文不许阻塞,
* 且一发生就要复位,日志是唯一遗言,绝不允许在这里"让路"掉。已知残余(登记在同一
* 篇文档):ISR 可以打断某任务已发出半行的裸发,两侧仍可能交错——但 Fault 行以
* "[ERR]" 起头且整段在同一次 con_tx_bounded 内,标记校验按字节 grep 仍能命中,
* 不构成漏检;把钩子改成"一条行一次发完"是另一张卡的活(API 已备:tx_begin/tx_end)。
* ② 调度器未启动(xTaskGetCurrentTaskHandle() == NULL,osKernelStart 之前)-> **放行**:
* 此刻全系统只有一个执行流,本就没有第二个写者;早期启动日志一条都不该丢。
* ③ 已调度且空闲 -> 临界区里"看 + 认领"(几条指令),拿到即整行独占;已被持有 ->
* 立刻返回 CON_TX_NO,**不排队**:裸发一行 @115200 约 7.8 ms,等锁等于把自己的
* 日志节拍压到别人的串口耗时上,adcTask(AboveNormal,1 kHz 消费)可能被压住
* 数十拍,比交错更糟。
*/
static uint8_t con_tx_try_claim(void) {
TaskHandle_t me;
uint32_t pm;
uint8_t got = 0U;
if (__get_IPSR() != 0U) {
return (uint8_t)CON_TX_BYPASS;
}
me = xTaskGetCurrentTaskHandle();
if (me == NULL) {
return (uint8_t)CON_TX_BYPASS;
}
if (s_con_tx_owner == me) {
if (s_con_tx_depth < 255U) {
s_con_tx_depth++;
}
return (uint8_t)CON_TX_HELD;
}
CON_CRIT_ENTER(pm); /* 窗口选型与嵌套语义理由见 CON_CRIT_ENTER 定义处 */
if (s_con_tx_owner == NULL) {
s_con_tx_owner = me;
s_con_tx_depth = 1U;
got = 1U;
}
CON_CRIT_EXIT(pm);
return (got != 0U) ? (uint8_t)CON_TX_HELD : (uint8_t)CON_TX_NO;
}
/*
* 函数功能:归还整行发送所有权(与 con_tx_try_claim 成对)
* 入口参数:held - con_tx_try_claim 的返回值(只有 CON_TX_HELD 才真正归还)
* 返回值:无
* 限定条件:与取锁同上下文;CON_TX_BYPASS / CON_TX_NO 一律空操作——放行的那些上下文
* 从未持有凭证,误清会把别人正持有的整行放开,等于把这次修复改没
* 函数说明:同任务嵌套按深度递减(写者一轮 drain 内还会再裸发 [WARN]/[ERR] 留痕行,
* 那是嵌套持有者,最外层释放时才清 owner)
*/
static void con_tx_release(uint8_t held) {
TaskHandle_t me;
uint32_t pm;
if (held != (uint8_t)CON_TX_HELD) {
return;
}
me = xTaskGetCurrentTaskHandle();
if (s_con_tx_owner != me) {
return;
}
if (s_con_tx_depth > 1U) {
s_con_tx_depth--;
return;
}
CON_CRIT_ENTER(pm);
if (s_con_tx_owner == me) {
s_con_tx_owner = NULL;
s_con_tx_depth = 0U;
}
CON_CRIT_EXIT(pm);
}
/*
* 函数功能:把"一条行分几段裸发"的调用方收成一个整行独占者(道G task-23)
* 返回值:1 - 已独占(此后本任务每次 dbg_console_write_raw 都直接发,段间无人插入)
* 0 - 别人正持有;调用方**照发即可**(本函数不改变任何调用方的既有语义,
* 绝不阻塞、绝不吞字,只是段间仍可能被插——调用方自己权衡)
* 限定条件:任务上下文(ISR 内调用会返回 1 但不记账,见 con_tx_try_claim ①)
* 函数说明:给多段裸发的诊断/崩溃钩子用(Src/freertos.c 的钩子把一条行拆成
* head/name/":"/" (resetting)" 等多次直发,段与段之间就是插字节的位置)。
* 配对使用:tx_begin() -> 若干次 write_raw -> tx_end()。嵌套安全(按深度计)。
*/
uint8_t dbg_console_tx_begin(void) {
uint8_t held = con_tx_try_claim();
return (held == (uint8_t)CON_TX_NO) ? 0U : 1U;
}
/*
* 函数功能:结束一次整行独占(与 dbg_console_tx_begin 成对)
* 入口参数:无
* 返回值:无
* 限定条件:只在 dbg_console_tx_begin() 之后调用;未持有时为空操作(幂等)
*/
void dbg_console_tx_end(void) {
con_tx_release((uint8_t)CON_TX_HELD);
}
/*
* 函数功能:控制台裸发送(异常/早期路径专用:有界、单次;**整行独占发送器**)
* 入口参数:buf - 文本缓冲;len - 字节数(超上限截断)
* 限定条件:串口已初始化;仅用于写者未注册、中断/异常上下文、崩溃上报
* 返回值:无
* 限定条件:串口已初始化;写者未注册、中断/异常上下文、崩溃上报、诊断转储
* 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。
* 道G task-23 起本函数是裸发的**唯一漏斗**(所有裸发调用点都经过它,所以调用方
* 文件一个都不用改):
* · 拿到所有权 -> 整行一次发完,中途不会有别人的字节插进来;
* · 拿不到 -> **本行不丢**,改投既有环形缓冲延后发。竞争失败不该变成丢行,更不该
* 变成 [ERR]:台架那个未注册窗口每 3 次启动命中 2 次,按"丢弃 + [ERR]"处理只会
* 把静默缺陷换成常红的门禁([ERR] 是 @test 的 Forbidden 标记)。
* 入环的是**同一批字节原文**,本模块与 drain 都不补前缀、不重打时间戳(DBG_* 的
* 前缀在调用点就已 snprintf 进调用方缓冲)-> 延后发出去的行与它当场直发时
* 逐字节相同(判据⑦),取证时不会出现"证据说谎";
* · 环也放不下 -> 这才计入既有 s_con_drop(真溢出,仍然可见,不静默);
* · ISR / 调度器未启动 -> 放行直发(con_tx_try_claim ①②)。
*/
void dbg_console_write_raw(const char *buf, unsigned len) {
uint8_t held;
if ((buf == NULL) || (len == 0U)) {
return;
}
if (len > DBG_CON_CHUNK_MAX) {
len = DBG_CON_CHUNK_MAX;
}
held = con_tx_try_claim();
if (held == (uint8_t)CON_TX_NO) {
if (con_ring_try(buf, (uint16_t)len) != 0U) {
/* 延后发:写者空出来就排出去(未注册时也一样——注册后第一轮 drain 就发)。
* 唤醒事件只在事件组已建时发(内核前 sys_evtHandle 还是 NULL) */
if (sys_evtHandle != NULL) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX);
}
} else {
s_con_drop++; /* 让了路又放不下环 = 真丢,进既有那本账 */
}
return;
}
(void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len);
con_tx_release(held);
}
/*
@@ -268,6 +492,7 @@ static void con_uart_reinit(void) {
void dbg_console_drain(void) {
uint16_t used;
uint32_t t0;
uint8_t held;
if (s_con_writer_up == 0U) {
return;
@@ -276,18 +501,32 @@ void dbg_console_drain(void) {
return; /* 重入保护:写者自记录不触发嵌套 drain */
}
/* 整行所有权(道G task-23):本函数按块直写 USART1->DR,若此刻别的应用上下文
* 还在未注册期那条裸发里发到自己一半(attach 就发生在 defaultTask 里,与别的
* 任务裸发**可以**前后脚撞上),两边就会逐字节交错——那正是本卡要修的形态。
* 拿不到就**本轮让位**:tail 不推进 => 环里一条不少,主循环 ≤50 ms 后再试,
* 所以这条路径**永不丢弃**,也就不该在这里加任何计数(加了就是把让位谎报成丢行)。 */
held = con_tx_try_claim();
if (held == (uint8_t)CON_TX_NO) {
return;
}
s_con_draining = 1U;
t0 = (uint32_t)HAL_GetTick();
/* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送——
* 本轮 drain 直接让位主循环,保住喂狗节奏(gen-288 停喂误复位的
* 直接诱因 = 背靠连发的块超时链)。无符号差值跨 49.7 天回绕安全 */
if (s_con_cool_until != 0U) {
* 本轮 drain 直接让位主循环,保住喂狗节奏(日志代次 288 停喂误复位的
* 直接诱因 = 背靠连发的块超时链)。是否在冷却看 s_con_cooling 独立标志,
* **不再拿 tick 值当哨兵**(2026-10-24 实测:故障恰好落在 tick==0 那一拍时,旧写法
* 记出的 cool_until==0 等于没记冷却,下一轮立刻再耗一次 100ms 块超时)。
* 无符号差值跨 49.7 天回绕安全 */
if (s_con_cooling != 0U) {
if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) {
s_con_draining = 0U;
con_tx_release(held); /* 让位也要归还凭证,否则别人永远进不来 */
return;
}
s_con_cool_until = 0U;
s_con_cooling = 0U;
}
while ((used = con_used()) != 0U) {
@@ -309,12 +548,28 @@ void dbg_console_drain(void) {
taskEXIT_CRITICAL();
if (st != HAL_OK) {
if (st == HAL_ERROR) {
/* 2026-10-24 修复:USART1 **未初始化**(huart1.Instance == NULL,
* 只可能出现在 HAL_Init 早期或断言/故障上报路径)与"僵尸通道"是两回事:
* 这里**绝不做外设重初始化**——那会在 HAL 尚未就绪时跑 MspInit 重配
* RCC/GPIO;也不计 tx_fault(那本账的语义是"软复位自愈次数")。
* 本块字节确实一个都没上线 -> 计入 dropped(只记真丢的),
* 末尾的 [ERR] dropped 行负责留痕;本轮到此为止(失败即止)。 */
s_con_drop++;
break;
}
/* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁,
* 继续背靠连发只会把主循环耗在块超时链上);本块丢弃
* (行已出环,不可重放) */
* (行已出环,不可重放),本块在超时账与丢弃账上各记一笔 */
s_con_tx_timeout++; /* 新增账:块超时次数(TXE 与 TC 两条硬上界
* 都算,口径 = 写者排空时观察到的块超时) */
con_uart_reinit();
s_con_tx_fault++;
s_con_drop++; /* 确认不了的块算真丢(一次丢弃事件),
* 旧写法只涨 tx_fault -> 账上看不出日志在丢,
* docs §13-O2 记的正是这条 */
s_con_cool_until = (uint32_t)HAL_GetTick();
s_con_cooling = 1U;
if (s_con_tx_fault == 1U) {
static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n";
@@ -338,31 +593,96 @@ void dbg_console_drain(void) {
s_con_draining = 0U;
/* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失) */
/* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失)。
* 账上有两种成因,都算"真没落地":① 环满等不到空位(con_append);② 抢不到整行
* 所有权**且**环也放不下(dbg_console_write_raw,道G task-23)。正常让路不入这本账,
* 所以 [ERR] 行至今仍是"控制台在丢东西"的唯一信号,没被稀释。 */
if (s_con_drop != 0U) {
char note[64];
uint32_t d = s_con_drop;
int n;
s_con_drop = 0U;
n = snprintf(note, sizeof(note), "\r\n[ERR] CONSOLE dropped %lu lines\r\n",
(unsigned long)d);
if (n > 0) {
if (n > (int)sizeof(note) - 1) {
n = (int)sizeof(note) - 1;
}
dbg_console_write_raw(note, (unsigned)n);
/* 只有**真发出去了才销账**(2026-10-24 一并修正):旧写法先把计数清零
* 再裸发,通道不可用/僵尸时那一发也失败 -> 丢弃证据被一起冲掉,[ERR] 行
* 永远出不来("永不静默"变成"永不显示")。留到下一轮或串口恢复后补发。 */
/* 所有权仍在手里(本凭证覆盖整轮,含这条留痕行) */
if (con_tx_bounded((const uint8_t *)note, (uint16_t)n) == HAL_OK) {
/* 按"本次报出去的这条"扣账而不是清零:期间生产者新丢的行留下轮报。
* 递减只发生在这一处(单写者 + s_con_draining 重入保护),生产者只增,
* 故 s_con_drop >= d 恒成立,无需再写下溢分支(写了也永远测不到)。 */
s_con_drop -= d;
}
}
}
con_tx_release(held);
}
/*
* 函数功能:尝试把一整行原文放进环形缓冲(**只试一次,绝不等待**)
* 入口参数:p_buf - 整行文本;len - 字节数(调用方已钳位)
* 返回值:1 - 已入环(head 已推进);0 - 空位不够,什么都没写
* 限定条件:任务上下文(内部是一次几条指令至多拷满 192B 的临界区,不睡眠)
* 函数说明:两个调用方共用同一份判据,避免长出"两套入环语义":
* ① con_append 的常规路径(外面套有界等待,等写者腾位);
* ② dbg_console_write_raw 抢不到整行所有权时的让路路径(**不套等待**:延后发一条行
* 可以,把调用者压住 200ms 就是另一场事故)。
* 复核空位 + 整行拷贝 + 推进 head **必须在同一个临界区内**(2026-10-24 修复):旧写法
* 先出区判 free_space >= len 再进区写,两个生产者可在环 >95% 满时同时过判据,head
* 合计越过容量 -> con_used() 回绕变小 -> 后写的行覆盖尚未发出的旧行,且既不报错也不
* 计丢弃(静默损坏)。等待(vTaskDelay/DWT 判据)刻意留在区外。
* ⚠ 入环的是调用方给的**字节原文**:本函数与 drain 都不补前缀、不重打时间戳
* (DBG_* 的前缀在调用点就已 snprintf 进调用方缓冲)——延后发与当场直发逐字节
* 相同,这是 task-23 判据⑦的结构保证。
*/
static uint8_t con_ring_try(const char *p_buf, uint16_t len)
{
uint32_t pm;
uint8_t fitted = 0U;
/* 窗口用 PRIMASK 而不是 taskENTER_CRITICAL():后者会经 vPortExitCritical 里的
* configASSERT 接回断言钩子,再回到本模块,静态调用图上长出一条环,armlink 把它
* 整条记进**每一条含临界区的任务链**(实测三条链同涨 80 B)。理由全文见
* CON_CRIT_ENTER 定义处。屏蔽时长与换之前一致(就是这一次整行拷贝)。 */
CON_CRIT_ENTER(pm);
if ((uint16_t)((DBG_CON_RING_SIZE - 1U) - con_used()) >= len) {
uint16_t head = s_con_head;
uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head);
uint16_t i;
if (first > len) {
first = len;
}
for (i = 0U; i < first; i++) {
s_con_ring[head + i] = (uint8_t)p_buf[i];
}
for (i = first; i < len; i++) {
s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] =
(uint8_t)p_buf[i];
}
s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U));
fitted = 1U;
}
CON_CRIT_EXIT(pm);
return fitted;
}
/*
* 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文)
* 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped)
* 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行;
* "复核空位 -> 整行拷贝 -> 推进 head"是一个原子的临界区动作;
* 环满等待(vTaskDelay / DWT 判据)一律在临界区之外,区内绝不睡眠。
* 写者自身记录日志时先排空缓冲,避免自等。
* 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)——
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(round 8b)。
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(第 8b 轮加固)。
*/
static int con_append(const char *buf, uint16_t len) {
uint32_t waited = 0U;
@@ -392,10 +712,9 @@ static int con_append(const char *buf, uint16_t len) {
}
for (;;) {
uint16_t used = con_used();
uint16_t free_space = (uint16_t)((DBG_CON_RING_SIZE - 1U) - used);
if (free_space >= len) {
/* 判据与拷贝都在 con_ring_try 的那一个临界区里完成(函数头有为什么);
* 本函数只负责"等多久"——等待一律在区外,区内绝不睡眠 */
if (con_ring_try(buf, len) != 0U) {
break;
}
if (have_dwt != 0U) {
@@ -411,26 +730,6 @@ static int con_append(const char *buf, uint16_t len) {
waited++;
}
taskENTER_CRITICAL();
{
uint16_t head = s_con_head;
uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head);
uint16_t i;
if (first > len) {
first = len;
}
for (i = 0U; i < first; i++) {
s_con_ring[head + i] = (uint8_t)buf[i];
}
for (i = first; i < len; i++) {
s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] =
(uint8_t)buf[i];
}
s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U));
}
taskEXIT_CRITICAL();
if (sys_evtHandle != NULL) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); /* 唤醒写者 */
}
@@ -472,6 +771,23 @@ uint32_t dbg_console_tx_fault(void) {
return s_con_tx_fault;
}
/*
* 函数功能:已发生的发送块超时次数(诊断用)
* 返回值:块超时累计次数 uint32_t
* 限定条件:无(任意上下文可读,含 ISR)
* 函数说明:三本账分开(docs/子系统设计/控制台单写者日志设计说明.md §13-O2)——
* dbg_console_dropped() = **真没落地**的丢弃数(缓冲满丢的一行,或一个
* 没能确认送达而出环的块;[ERR] 行报的就是它);
* dbg_console_tx_fault() = 外设软复位**自愈次数**(语义不变);
* dbg_console_tx_timeout() = 本账:写者排空时观察到的**块超时**次数
* (TXE 与 TC 两条硬上界都算)。
* 裸发路径(内核前 / ISR / 两条告警行自身)的超时不记本账——那些上下文里
* 没有"块"的概念,也只可能发生在启动极早期,混进来只会让运维读数失去意义。
* UART 物理失效但字节已发出的情形只涨本账,不污染另两本。 */
uint32_t dbg_console_tx_timeout(void) {
return s_con_tx_timeout;
}
/*
* 函数功能:取走"控制台 TX 故障待上报"标志(读后清)
* 返回值:1 - 需要补记一条 run.log 证据,0 - 无
+21 -3
View File
@@ -15,6 +15,9 @@
* 修改记录:
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出:
* HAL 库生成文件不得包含应用代码)
* 2026-10-26 王建锋 道G task-23:裸发路径加「行级单一所有者」(O1 第二实例),
* 新增 dbg_console_tx_begin()/dbg_console_tx_end() 供"一条行分多段裸发"
* 的调用方收口;dbg_console_write_raw() 的"不加锁"三个字从接口上取消
*/
#ifndef CONSOLE_LOG_H
@@ -32,15 +35,27 @@ extern "C" {
* dbg_console_write_line - 生产者入口:调用方已格式化好的整行文本一次入环形缓冲
* (刻意**不用**自研可变参数函数——规范 S12 7.6 禁止;格式化在
* DBG_PRINT 宏内用库 snprintf 完成)
* dbg_console_write_raw - 异常/早期路径:有界单次直发(不加锁)
* dbg_console_write_raw - 异常/早期路径:有界单次直发;**整行独占发送器**
* (道G task-23:拿不到独占就把整行原文转入环形缓冲延后发,
* 与当场直发逐字节相同——不补前缀、不重打时间戳;
* 连环都放不下才计入 dbg_console_dropped())
* dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者
* dbg_console_writer_ready - 写者是否已注册(1=已注册)。供**周期性任务**在写者就绪前
* 抑制日志用:写者未注册时 con_append 走无锁直发路径
* (仅假设 defaultTask 在日志),其余任务此时落日志会与
* defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测)
* dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈)
* dbg_console_dropped - 因缓冲满丢弃的行数(诊断)
* dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断)
* dbg_console_dropped - 真没落地的丢弃数:① 缓冲满丢的一行;② 一个没能确认
* 送达而出环的块;③ 抢不到整行所有权**且**环也放不下的裸发
* (道G task-23)。诊断;[ERR] CONSOLE dropped 行报的就是它。
* ⚠ 抢不到所有权但成功让路入环的那条行**不入本账**(它没丢,
* 只是晚一点发)——这本账必须保持"红得有意义",因为
* [ERR] 是 @test 的 Forbidden 标记)
* dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断,语义不变)
* dbg_console_tx_timeout - 写者排空时观察到的块超时次数(TXE / TC 两条硬上界都算)。
* 三本账分开:看日志有没有在丢 -> dropped;看外设自愈了几次
* -> tx_fault;看 UART 物理失效(含"字节已发出但 TC 不置位")
* -> tx_timeout。别把三本账混成一个数(docs §13-O2 )
* dbg_console_tx_fault_log_take
* - 取走"TX 故障待上报"标志(读后清)。**必须**由写者任务在
* dbg_console_drain() 之外调用后自行 syslog_post——本模块
@@ -50,11 +65,14 @@ extern "C" {
#define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */
int dbg_console_write_line(const char *line, unsigned len);
void dbg_console_write_raw(const char *buf, unsigned len);
uint8_t dbg_console_tx_begin(void); /* 1=已独占一条行;0=别人持有(调用方照发即可) */
void dbg_console_tx_end(void); /* 与 tx_begin 成对;未持有时空操作(幂等) */
void dbg_console_writer_attach(void);
uint8_t dbg_console_writer_ready(void);
void dbg_console_drain(void);
uint32_t dbg_console_dropped(void);
uint32_t dbg_console_tx_fault(void);
uint32_t dbg_console_tx_timeout(void);
uint8_t dbg_console_tx_fault_log_take(void);
/*
+7 -1
View File
@@ -3,7 +3,7 @@
* 模块功能:定长 64B SOE(Sequence-of-Events)记录的构建与解析:
* CP56Time2a 时标 + 记录类型 + 元件号 + 单调序号 + 值 + 校验和。
* **零 I/O、零 OS 依赖**(仅 stdint/string)→ 主机侧 L0.5 离线单测
* (落实登记簿 A-4 约束 1"协议/记录与硬件 I/O 分离")。
* (分层约束:协议/记录与硬件 I/O 分离)。
* 记录口径(docs/算法/继电保护功能复现规划.md §3.4 L4-4/L4-5,B9):
* 落盘文件 = /log/soe.log(8.3 合规;FF_USE_LFN=0 约束),定长 64B 一条
* (照 run.log 先例:count = filesize/64 O(1)、容量满删除重建)。
@@ -61,6 +61,8 @@ extern "C" {
* 4 软压板投入 value = 1
* 5 采样闭锁 value = adc_health 闭锁位图(触发时的 hf & mask)
* 6 采样恢复 value = 0
* 7 标定闭锁 value = UA/UN/IA VALID 未齐的通道位图(卡 5,2026-10-25)
* 8 标定复归 value = 0
*/
typedef enum {
SOE_T_ELM_ASSERT = 1u,
@@ -69,6 +71,10 @@ typedef enum {
SOE_T_PLATE_IN,
SOE_T_SAMPLE_BLOCK,
SOE_T_SAMPLE_RESUME,
/* 未标定闭锁(「数据可信」卡 5,2026-10-25 道A 追加):UA/UN/IA 的 VALID
* 未全置位 → 判据停走;与采样闭锁分型,边沿各留一条记录 */
SOE_T_CAL_BLOCK,
SOE_T_CAL_RESUME,
SOE_T_COUNT /* 哨兵值,非有效类型 */
} soe_type_t;
+183 -5
View File
@@ -3,6 +3,16 @@
* 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build +
* SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部)
* 排空落 /log/soe.log(64B 定长,容量满删除重建)。
* 修改记录:
* v1.0 2026-10-12 王建锋 创建初始版本(B9,设计见 docs/算法/继电保护功能复现规划.md §3.4)
* v1.1 2026-10-24 王建锋 仅注释/文档更正(D 项,无行为改动):s_soe_append
* 函数头原写"打开/写入结构性错误 → unlink 重试一次
* (同 run.log)",与实现不符且方向错误——实现里
* f_open 失败直接返回 -1、不 unlink,这是**有意**的:
* SOE 是事件序列,打开失败就删文件等于销毁已发生的
* 事件。改为如实描述"仅写失败才重试一次",并补上
* 与 sys_log_mcu.c s_is_selfheal_code 同源的逐码表
* (名字/参数类错误码两侧都绝不 unlink)
* 创建日期:2026-10-12(B9)
*/
@@ -38,6 +48,16 @@ static osMessageQueueId_t s_queue; /* 运行期队列(drain 首次调
static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" };
static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */
/* 只读遍历(UI 记录页)用的读盘窗口,与 App/log/sys_log_mcu.c 读侧同构:
* 定长记录按 8 条一窗整块取回(8 × 64 B = 512 B),命中窗口零 I/O;
* 代数由写者在卷锁区间内自增,读者也在卷锁区间内采样代数 → 窗口不会跨写失效 */
#define SOE_RD_WINDOW 8u
static uint8_t s_rd_win[SOE_RD_WINDOW][SOE_REC_SIZE];
static uint32_t s_rd_win_base;
static uint32_t s_rd_win_cnt;
static volatile uint32_t s_rd_gen; /* 写者落盘/重建次数(对齐 32 位读写在 ARM 上原子) */
static uint32_t s_rd_win_gen;
static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */
static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */
static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */
@@ -88,6 +108,30 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) {
}
}
/*
* ── 自愈触发集逐码依据(与 sys_log_mcu.c 的同名资料块同源;2026-10-24 逐条核对
* Lib/FatFs/ff.c)─────────────────────────────────────────────────────────
* 进触发集(run.log 侧允许 unlink 重建一次):介质/结构类 FR_DISK_ERR(1) /
* FR_INT_ERR(2) / FR_NO_FILESYSTEM(13) / FR_DENIED(7)。本模块**只在写失败路径**
* 上碰自己的文件——打开失败连这些码也不 unlink。
* 不算自愈:FR_NO_FILE(4)——带 FA_OPEN_APPEND 打开时文件会被直接建出来;返回本
* 码的唯一现实场景是同名对象是目录(ff.c:3854),unlink 修不好它。
* 两侧都绝不 unlink:名字/参数类 FR_INVALID_NAME(6) / FR_NO_PATH(5) /
* FR_INVALID_OBJECT(9),以及 FR_NOT_READY(3) / FR_NOT_ENABLED(12) /
* FR_TIMEOUT(15)——同名同因,删了也修不好,反而每轮丢一份历史(§10.1 第 10 条
* 严禁的正是这种归集)。
* 写失败路径不接这套判定:f_write 的返回码里不存在名字/参数类(能走到那里说明
* 已经打开成功),而簇分配失败是 FR_OK + 短写、根本没有错误码可判,只能无条件
* 重试一次。
* 为什么 SOE 比 run.log 保守(两侧资料块各写一份、互指,改动必须同步):run.log
* 是滚动运行日志,坏了重建损失有限;SOE 是**事件序列**,文件里是一条条已经发生
* 过的保护事件,"打开失败就删整个历史文件"等于**销毁事件证据**——SOE 的存在就
* 是为了留这些事件,删了比写不进去更糟。正确出路是等 syslog_mcu_mounted() 每 2s
* 重试补目录 / 等卷恢复,条目留在待发环与队列里,不丢。
* (run.log 侧的同一张表 + 同一理由见 App/log/sys_log_mcu.c 的同名资料块,测试
* 侧两侧对齐由 TC-SYSLOG-101/102 与 TC-SOESTORE-022/022b 分别钉住。)
*/
/*
* 函数功能:追加一条 64B SOE 记录到 /log/soe.log
* 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t *
@@ -98,11 +142,20 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) {
* ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE,
* 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证);
* ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码);
* ④ 容量满 unlink 重建;打开/写入结构性错误 → unlink 重试一次
* (L1 文件级自愈,同 run.log)。
* ④ 容量满 unlink 重建;**仅写入失败(含 FR_OK+短写)才 unlink 重试
* 一次**——f_open 失败一律直接返回 -1、不 unlink。
* (2026-10-24 定案:本条旧注释写的是"打开/写入结构性错误都 unlink
* 重试一次(同 run.log)",与实际实现不符,也与本模块的语义相反:
* run.log 是**滚动运行日志**,坏了重建损失有限;SOE 是**事件序列**,
* 文件里是已经发生的事件,打开失败就把整个历史文件删掉等于销毁
* 证据,方向是错的。正确出路是让 syslog_mcu_mounted() 每 2s 重试
* 补目录/等卷恢复,条目留在待发环与队列里,不丢。)
* ⑤ 自愈触发集与 sys_log_mcu.c 的 s_is_selfheal_code() 同源,但本模块取
* 更保守的子集:打开失败**任何错误码都不 unlink**,只有写失败才重试
* 一次。逐码依据与两侧差异见上方同名资料块。
* ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、
* 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃
* 计数,绝不升级成卷操作。
* 计数,绝不升级成卷操作(全模块无 f_mount/f_mkfs/f_opendir)。
*/
static int s_soe_append(const uint8_t *rec) {
static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈;
@@ -121,6 +174,9 @@ static int s_soe_append(const uint8_t *rec) {
? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u;
if (cnt >= SOE_STORE_MAX) {
(void)f_unlink(SOE_PATH);
/* 代数自增(读者窗口失效):**unlink 当场就加**,不等写成功——文件已没了,
* 旧窗口里那几条记录在任何索引上都不再存在,晚一步都会有读者按陈旧窗口读数 */
s_rd_gen++;
DBG_INFO("cap full, recreate " SOE_PATH);
}
@@ -129,8 +185,9 @@ static int s_soe_append(const uint8_t *rec) {
fr = f_write(&f, rec, SOE_REC_SIZE, &bw);
(void)f_close(&f);
if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) {
/* L1 文件级自愈:陈旧/损坏条目 → 删除重建、重试一次(同 run.log;
* unlink 返回值刻意不检查——失败也只是重试失败 → 留痕,不升级) */
/* L1 文件级自愈:**只有写失败**才删除重建、重试一次(打开失败走下面
* 的 else,不删——见函数头 ④)。unlink 返回值刻意不检查:失败也只是
* 重试失败 → 留痕计数,绝不升级成卷操作。 */
(void)f_unlink(SOE_PATH);
bw = 0u;
fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND);
@@ -143,10 +200,13 @@ static int s_soe_append(const uint8_t *rec) {
}
}
} else {
/* 打开失败:只留痕、**不 unlink**(SOE 是事件序列,删文件 = 销毁历史;
* 名字/参数类错误码 unlink 也修不好,见函数头的逐码表) */
rc = -1;
}
if (rc == 0) {
s_fault_reported = 0u;
s_rd_gen++; /* 落盘成功 → 读者窗口作废(在卷锁区间内自增,与读者同锁同调) */
} else if (s_fault_reported == 0u) {
s_fault_reported = 1u;
DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr);
@@ -208,3 +268,121 @@ void soe_store_drain(void) {
uint32_t soe_store_dropped(void) {
return s_drop_cnt;
}
/* ========================= 只读遍历(UI 记录页) ============================ */
/*
* 函数功能:已落盘的 SOE 记录条数(= filesize / SOE_REC_SIZE,O(1))
* 入口参数:无
* 返回值:条数;文件缺失、未挂载或 f_stat 失败返回 0 uint32_t
* 限定条件:任意任务上下文可调(uiTask 的 SOE 记录页用);只做 f_stat,不取卷锁
* ——与 App/log/sys_log_mcu.c 的 syslog_count() 同一口径,不另起第二套
* 函数说明:条数按**整条 64 B 槽**相除,写者留下的半条尾槽天然不计入,这是
* "读者看不到半成品记录"的第一道保障(另两道见 soe_store_read 头注)
*/
uint32_t soe_store_count(void) {
FILINFO finfo;
if (f_stat(SOE_PATH, &finfo) != FR_OK) {
return 0u;
}
return (uint32_t)finfo.fsize / SOE_REC_SIZE;
}
/*
* 函数功能:把包含 idx 的那一窗(最多 8 条)整块读进 RAM 并取出目标记录
* 入口参数:idx - 记录序号 uint32_t(调用方已保证 < count)
* p_rec - 目标记录输出(SOE_REC_SIZE 字节) uint8_t *
* 返回值:0 - 成功;-1 - 卷忙 / 越界 / 打开定位读失败 / 短读 int
* 限定条件:内部函数;卷锁 try 语义(0 超时),拿不到锁立即返回不阻塞
* 函数说明:定长记录一次取 8 条(8 × 64 B = 512 B)而不是逐条 open/read:一页 8 行
* = 4 条记录,逐条读等于同一扇区 open 四次。记录页只在开页与翻页时重算,命中
* 窗口即零 I/O——整套"窗口 + 代数 + try 锁"结论与 App/log/sys_log_mcu.c 读侧
* 同源,刻意不另起第二套读盘路径(两套读盘规则必然漂移,是本仓反复在防的)
*/
static int s_rd_fill(uint32_t idx, uint8_t *p_rec) {
static FIL f; /* static:FF_FS_TINY=0 → FIL=528 B 栈;
* open~close 全程在卷锁区间内,跨任务由卷锁串行化 */
FRESULT fr;
UINT br = 0u;
uint32_t total;
uint32_t base;
uint32_t want;
if (vol_lock_wait(0u) == 0u) {
return -1; /* 卷忙不是故障:uiTask 下一轮重绘自然重试 */
}
/* 等锁期间写者可能已推进(甚至已 unlink 重建)→ 锁内重新取条数再校验边界 */
total = soe_store_count();
if ((total == 0u) || (idx >= total)) {
vol_lock_release();
return -1;
}
base = (idx / SOE_RD_WINDOW) * SOE_RD_WINDOW;
want = total - base;
if (want > SOE_RD_WINDOW) {
want = SOE_RD_WINDOW;
}
fr = f_open(&f, SOE_PATH, FA_READ);
if (fr == FR_OK) {
fr = f_lseek(&f, (FSIZE_t)base * SOE_REC_SIZE);
if (fr == FR_OK) {
fr = f_read(&f, s_rd_win, want * SOE_REC_SIZE, &br);
}
(void)f_close(&f);
}
if ((fr != FR_OK) || (br != (UINT)(want * SOE_REC_SIZE))) {
s_rd_win_cnt = 0u; /* 作废窗口:坏窗口留着会让后续行继续读到垃圾 */
s_rd_win_gen = 0u;
vol_lock_release();
return -1;
}
s_rd_win_base = base;
s_rd_win_cnt = want;
/* 代数在**卷锁区间内**采样:写者也只在持锁时自增,二者同侧 → 窗口与代数一致 */
s_rd_win_gen = s_rd_gen;
vol_lock_release();
(void)memcpy(p_rec, (const uint8_t *)s_rd_win + ((size_t)(idx - base) * SOE_REC_SIZE),
SOE_REC_SIZE);
return 0;
}
/*
* 函数功能:读取第 idx 条 SOE 记录(解析后的结构体;idx 0 = 最早一条)
* 入口参数:idx - 记录序号 uint32_t
* p_out - 输出(不可为 NULL) soe_rec_t *
* 返回值:0 - 成功;-1 - p_out 为空 / 越界 / 卷忙 / I/O 失败 / 记录校验不过 int
* 限定条件:uiTask 上下文(记录页逐行取数);**不阻塞**——卷忙立即 -1,页面这行
* 退化显示"读失败",下一轮重绘再试;SOE 页坏不得拖住主界面节拍
* 函数说明:读者不得看到半成品记录,三道保障缺一不可:
* ① count 只数整条 64 B 槽(半条尾槽不计入);
* ② 窗口代数:写者每次成功落盘/重建在卷锁区间内自增 s_rd_gen,读者
* 比对填充时记下的代数,不匹配即重填——对齐 32 位读写在 ARM 上原子,
* 跨任务无需临界区,最坏多读一轮(同 syslog 读侧结论);
* ③ 单条硬门槛:soe_rec_parse() 校版本 + 校验和,撕裂/半写一律 -3 拒收。
* count 与 read 之间新落一条的竞态**容忍**为"本次遍历少看到一条"
* (200 ms 重绘自然追上),绝不容忍读到半条——所以边界一律以锁内
* 重取到的 total 为准,不用调用方传进来的旧 total。
* soe_rec_parse 的 -1/-2/-3 在本 API 折成一个 -1:调用方(UI)对三者
* 的处理完全相同(这行显示读不到),把内部码外溢只会诱导页面写死分支
*/
int soe_store_read(uint32_t idx, soe_rec_t *p_out) {
uint8_t rec[SOE_REC_SIZE];
if (p_out == NULL) {
return -1;
}
/* 快路径:同代数且落在窗口内 → 零 I/O(代数比较用无锁读,最坏多刷一轮) */
if ((s_rd_win_cnt != 0u) && (s_rd_win_gen == s_rd_gen) &&
(idx >= s_rd_win_base) && (idx < s_rd_win_base + s_rd_win_cnt)) {
(void)memcpy(rec, (const uint8_t *)s_rd_win
+ ((size_t)(idx - s_rd_win_base) * SOE_REC_SIZE), SOE_REC_SIZE);
} else if (s_rd_fill(idx, rec) != 0) {
return -1;
} else {
/* 窗口未命中的分支已在 s_rd_fill 里完成取数与校验,这里不重复读盘 */
}
if (soe_rec_parse(rec, p_out) != 0) {
return -1;
}
return 0;
}
+37
View File
@@ -59,6 +59,43 @@ void soe_store_drain(void);
*/
uint32_t soe_store_dropped(void);
/* ---------------- 只读遍历(UI 的 SOE 记录页用;写侧零影响) ----------------
* 读者不得看到半成品记录,三道保障缺一不可(实现见 soe_store.c 同号函数):
* 1) count 只数**整条 64 B 槽**(filesize / SOE_REC_SIZE 整除),写者留下的
* 半条尾槽天然不计入;
* 2) 窗口代数:写者每次成功落盘 / 容量重建,在**卷锁区间内**自增内部代数,
* 读者比对填窗时记下的代数,不符即重填。对齐的 32 位读写在 ARM 上原子,
* 跨任务不需要临界区;最坏多读一轮,不会读出错数据;
* 3) 单条硬门槛:soe_rec_parse() 校版本 + 校验和,撕裂/半写一律拒收(-3)。
* 容忍的竞态只有一个:count 与 read 之间新落一条 → 本次遍历少看到一条,下一轮
* 重绘(uiTask 200 ms 节拍)自然追上。**不容忍**的是读到半条,所以边界一律以
* 卷锁内重新取到的条数为准,不用调用方传进来的旧值。
* 与 App/log/sys_log_mcu.c 的 syslog_count/syslog_get_record 同构(同一套窗口 +
* 代数 + try 锁纪律),刻意不另起第二套读盘路径。
*/
/*
* 函数功能:已落盘的 SOE 记录条数(= filesize / SOE_REC_SIZE,O(1))
* 入口参数:无
* 返回值:条数;文件缺失、未挂载或 f_stat 失败返回 0 uint32_t
* 限定条件:任意任务上下文可调(uiTask 记录页用);只 f_stat,不取卷锁
* 函数说明:见上方"只读遍历"总说明第 1 道保障
*/
uint32_t soe_store_count(void);
/*
* 函数功能:读取第 idx 条 SOE 记录(解析后的结构体,idx 0 = 最早一条)
* 入口参数:idx - 记录序号 uint32_t
* p_out - 输出(不可为 NULL) soe_rec_t *
* 返回值:0 - 成功;-1 - p_out 为空 / 越界 / 卷忙 / I/O 失败 / 版本或校验和不过 int
* 限定条件:uiTask 等读上下文;**不阻塞**——卷被占用立即返回 -1(调用方这行退化
* 显示"读失败",下轮重绘再试),排障页不得拖住主界面节拍
* 函数说明:soe_rec_parse 的 -1/-2/-3 在此折成一个 -1:调用方对三者的处置完全相同
* (这行读不到),把内部码外溢只会诱导页面为区分不出来的差异写分支。
* 时间字段取记录内的 CP56(事件自己的时标),不用"现在的时间"换算
*/
int soe_store_read(uint32_t idx, soe_rec_t *p_out);
#ifdef __cplusplus
}
#endif
+176 -7
View File
@@ -7,6 +7,20 @@
* 创建日期:2026-09-14
* 修改记录:
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4)
* v1.2 2026-10-26 王建锋 新增「记录删除」API(UI 装置维护「清除记录」的前置,
* 道F task-4;判据 docs/任务规划/UI补齐.md §2 第 3 行
* 「不允许只做一个没有实际效果的按钮」):
* 共享层 syslog_request_clear() / syslog_clear_state()
* (双端都实现,UI 侧只准用这两个)+ MCU 后端专属
* syslog_clear_apply()(落地半边,仅 defaultTask)。
* 请求与落地分离的理由与执行点写在下面声明处
* v1.1 2026-10-24 王建锋 仅契约注释更正,与 App/log/sys_log_mcu.c 的 C/F 两项
* 整改同步(无签名、无行为改动):
* syslog_get_record 写死容量要求(ts_len > SYSLOG_TS_LEN、
* desc_len > SYSLOG_DESC_MAX)并明确"容量不足一律 -1、
* 不截断、不写调用方缓冲"——旧注释像是"给少了会被截断",
* 实际是整条读不到;syslog_post 写明必须假设丢弃存在、
* 且丢弃留痕已按通道节流(节流后无丢弃计数)
*/
#ifndef SYS_LOG_H
@@ -21,10 +35,28 @@ extern "C" {
/*
* 日志 ID:决定落盘文件与容量上限
*
* ⚠ **记录类型 = 日志 ID 本身**(一类记录 = /log 下一个严格 8.3 短名的文件),
* 记录体内**没有**类型字段位(Lead 裁定 2026-10-26,task-10):64B 定长格式被
* UI 记录页(文本读者)与主机解码工具同时按固定偏移读,塞类型字节等于当场砸掉
* 这两批既有读者;而运行/自检记录已经在这份格式里跑了很久,不能为五类新记录
* 改版本位。于是"这条记录是什么类型"由它在哪个文件里回答。
* 同理二进制 SOE 通道的 soe_type_t 本轮**不动**:改它必须同步 UI 侧的类型名表
* (类型名表在 UI 侧 <Drivers/BSP/UI/ui_w_soe.c>,不在本道写域),而且 SOE
* 页与这五页是两套读者。
* 口径与五个写入点已登记进 App/log/README.md(task-11「记录 5 页」按此做)。
*/
typedef enum {
SYSLOG_RUN = 0u, /* 运行记录 /log/run.log:上电/重启事件 */
SYSLOG_SELFTEST, /* 自检记录 /log/selftest.log:开机自检故障(仅故障才写) */
/* ↓ 五类记录(docs/UI设计/页面扩充详细方案.md §3.6 的五行,task-10 落写入点)
* ⚠ 只准**尾部追加**:id 值即枚举序 = 两端 s_paths[]/s_caps[] 的下标,
* 中间插值会让老文件被新 id 读成别的类型(静默错类别,比崩更难查)。 */
SYSLOG_ACCIDENT, /* 事故记录 /log/accid.log:跳闸投退=1 的元件动作 */
SYSLOG_ALARM, /* 保护告警 /log/alarm.log:跳闸投退=0 或该项不存在的元件动作 */
SYSLOG_START, /* 保护启动 /log/start.log:元件 picked 上升沿 */
SYSLOG_OPER, /* 操作记录 /log/oper.log:强制复归 / 清除记录 / 对时 / 定值保存 */
SYSLOG_YK, /* 遥控记录 /log/yk.log:104 遥控的选择 / 执行 / 拒绝 */
SYSLOG_COUNT /* 哨兵值,非有效 ID */
} syslog_id_t;
@@ -46,9 +78,21 @@ typedef enum {
/*
* 容量上限(条数;满时删除重建,设计文档 §4.3)
*
* ⚠ **上限是逻辑上限,不是预分配**:文件按追加写自然增长,一条不写就不占簇;
* 到顶才 unlink 重建(照 run.log 先例)。七类全满的名义峰值 =
* (1024+512+512+1024+1024+512+256) × 64B = **304 KB**,NAND 2 GB、且这些
* 记录只在事件边沿才写(判据动作/遥控/人工操作),不构成容量或磨损瓶颈。
* 告警/启动给到 1024 是因为一次系统故障会连片触发多个元件,别让最想了解
* 故障过程的两类记录最先被截掉。
*/
#define SYSLOG_RUN_MAX 1024u /* /log/run.log:64KB */
#define SYSLOG_SELFTEST_MAX 512u /* /log/selftest.log:32KB */
#define SYSLOG_ACCIDENT_MAX 512u /* /log/accid.log:32KB(跳闸稀有,换长历史) */
#define SYSLOG_ALARM_MAX 1024u /* /log/alarm.log:64KB */
#define SYSLOG_START_MAX 1024u /* /log/start.log:64KB */
#define SYSLOG_OPER_MAX 512u /* /log/oper.log:32KB */
#define SYSLOG_YK_MAX 256u /* /log/yk.log:16KB(低频,与事故同稀有) */
/*
* 上电/重启分类窗口(秒):run.log 末条 epoch 距 now < 该值 → "reboot",否则 "power on"
@@ -58,12 +102,52 @@ typedef enum {
/*
* 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃)
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(UTF-8,≤SYSLOG_DESC_MAX 字节;超长截断) const char *
* desc - 描述串(UTF-8;超过 SYSLOG_DESC_MAX 的部分丢弃,落盘只留前 28 字节)
* 本参数**投递时即拷贝进待发条目**:调用方缓冲区可立即复用,
* 不要求生命周期覆盖到落盘(2026-10-24 台架实证后由「只存指针」
* 改为条目内嵌定长缓冲,理由见 sys_log_mcu.c 的 syslog_entry_t 注释)
* 指针本身不可为 NULL const char *
* 返回值:无
* 限定条件:MCU 端 FreeRTOS 启动前调用走静态待发数组(由 defaultTask 挂载后落盘)
* 函数说明:调用方**必须假设投递可能被丢弃**——MCU 后端(sys_log_mcu.c)队列 8 槽、
* 启动前待发数组 8 槽,卷被 ftpTask 长期占用时 defaultTask 会把整批回投
* 队列(队列因此可长时间处于满态),此时本函数直接丢弃该条,并只在
* "转入故障"的那一次留痕(丢弃留痕按正常→故障节流,F 项整改 2026-10-24;
* 节流后**丢弃总数没有计数**,要计量得另加计数器)。必须留住的证据走
* sys_log_evidence(),不要靠 syslog_post 重试。
* 模拟器后端(simulator/sim/sim_syslog.c)是单线程直写宿主文件、**没有
* 队列与丢弃**,因此上面这段满态语义只约束 MCU 端。
*/
void syslog_post(syslog_id_t id, const char *desc);
/*
* 函数功能:投递一条日志——**调用链上不带日志行缓冲**的那一个版本
* 入口参数:id - 日志 ID syslog_id_t
* desc - 描述串(NUL 结尾;超过 SYSLOG_DESC_MAX 截断),同 syslog_post
* 返回值:无
* 限定条件:任意上下文;MCU 端不碰 FatFs、不碰卷锁、不阻塞
* 函数说明:为什么要有第二个投递口(不是洁癖):每个 DBG_* 调用点在调用者栈上
* 内联约 216 B 行缓冲,而 syslog_post 的两个"满态丢弃留痕"分支就躺在
* 它自己的帧里——于是"调一次 post"在静态最坏链上等于"带上这两处缓冲"。
* protectTask 的静态最坏链是 B9 写进 DoD 的硬线(872 B / 57%,当年在
* post 里加一条 DBG_* 实测垫到 1192 B = stack_report FAIL),iec104Task
* 同样是深链任务,所以这五类记录的写入点一律走本函数。
* 代价是丢弃时不当场留痕:只累加 syslog_post_dropped(),由唯一写者
* defaultTask 在 syslog_drain() 里按差值补一条(照 soe_store 的先例)。
* 与 syslog_post 的差别**只有留痕路径**,收/丢的判据逐条相同。
* 模拟器后端没有队列也没有栈预算问题,本函数等价于 syslog_post。
*/
void syslog_post_quiet(syslog_id_t id, const char *desc);
/*
* 函数功能:取 syslog_post_quiet() 通道的累计丢弃条数(可观测性)
* 入口参数:无
* 返回值:MCU 端 = 队列满 / 启动前数组满累计次数;模拟器端恒 0(直写不丢) uint32_t
* 函数说明:与 soe_store_dropped() 同款定位。留痕由 drain 按差值上报,本函数是
* 给单测与台架读数用的,不参与任何判定路径。
*/
uint32_t syslog_post_dropped(void);
/*
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
* 入口参数:id - 日志 ID syslog_id_t
@@ -75,11 +159,19 @@ uint32_t syslog_count(syslog_id_t id);
* 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾)
* 入口参数:id - 日志 ID syslog_id_t
* idx - 记录序号(0 起) uint32_t
* p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节;不可为 NULL) char *
* ts_len- p_ts 容量 size_t
* p_desc- 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节;不可为 NULL) char *
* desc_len - p_desc 容量 size_t
* 返回值:0 - 成功;-1 - 越界/读失败 int
* p_ts - 时间戳输出缓冲(不可为 NULL) char *
* ts_len- p_ts 容量,必须 > SYSLOG_TS_LEN(即 ≥ SYSLOG_TS_LEN+1 = 20 字节) size_t
* p_desc- 描述输出缓冲(不可为 NULL) char *
* desc_len - p_desc 容量,必须 > SYSLOG_DESC_MAX(即 ≥ 29 字节) size_t
* 返回值:0 - 成功;-1 - 越界 / 读失败 / **缓冲容量不足** int
* 限定条件:容量不足一律返回 -1,**不做静默截断、也不写调用方一个字节**
* (C 项整改 2026-10-24;旧注释只写"≥…+1 字节"像是"给少了会被截断",
* 照它给 28 字节缓冲的结果是整条读不到而不是读到 27 字符)。
* 描述字段在写入侧已截到 SYSLOG_DESC_MAX,所以按上面的最小容量给缓冲
* 就一定装得下,读侧不需要第二道截断。
* MCU 后端与模拟器后端(sim_syslog.c)在这条守卫上取值相同,行为一致;
* 模拟器侧那份同样不可达的截断支还留在文件里(未列入本轮改动范围)。
* 调用方:uiTask 的日志页(行缓冲 32B)与 SYSLOG 取证打印。
*/
int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len);
@@ -91,6 +183,58 @@ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, c
*/
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch);
/*
* 记录删除(「清除记录」):请求 → 落地分离
*
* 为什么必须分两步:defaultTask 是本模块**唯一写者**(存储架构说明 §10.1 第 7 条),
* uiTask 直接 f_unlink 会与写者的追加写/「容量满 unlink 重建」交错,而且删除要走
* 卷锁 + FAT 回写——uiTask 在 IWDG required_mask 内、每 100 ms 喂一次心跳,
* 给它一条最坏数百 ms 的存储路径就是拿误复位当代价。故 UI 侧只登记请求,
* 真正的删除在 defaultTask 的 syslog_drain() 尾部执行
* (范式同 App/algo/prot_elm.c 的 prot_elm_request_reset():单字节标志 + 消费者落地)。
*
* 状态码(syslog_clear_state 返回值;写成宏而不是枚举,与本头既有 SYSLOG_* 常量同族)
*/
#define SYSLOG_CLEAR_NONE 0 /* 从未请求过清除(上电初值) */
#define SYSLOG_CLEAR_OK 1 /* 已清空:文件不在盘上,同拍 syslog_count() 必为 0 */
#define SYSLOG_CLEAR_WAIT (-1) /* 请求排队中:等 defaultTask 下一轮 drain(含卷占用顺延、卷未就绪挂起) */
#define SYSLOG_CLEAR_FAIL (-2) /* 最近一次落地失败:原文件未被删除,可再发一次请求 */
/*
* 函数功能:登记「清除某日志全部记录」的请求(任意上下文;不碰存储、不阻塞)
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:0 - 请求已登记(同一 id 重复登记合并为一次,幂等)
* -1 - id 越界(请求未登记,状态一个字节都没动) int
* 限定条件:非唯一写者的上下文(uiTask / netTask / iec104Task)**只能**用本函数发起
* 删除,不得直调 MCU 后端的 syslog_clear_apply()
* 函数说明:本函数**不等于"已清除"**,只代表"已受理"——MCU 端的删除动作发生在
* defaultTask 的下一次 syslog_drain()(50 ms 节拍),结果读
* syslog_clear_state(),事实复核读 syslog_count()。
* ⚠ 两端时序有别(同 syslog_post 的队列/直写差异,那边写在 post 的头注释里):
* MCU 端返回后状态通常是 SYSLOG_CLEAR_WAIT;模拟器端单线程、本调用内
* 同步落地,返回后立即是 OK/FAIL,**永远不会读到 WAIT**。UI 侧要按
* "WAIT 也正常"写判断,别把 WAIT 当失败。
* 刻意**不**在删除成功后补一条 "records cleared" 留痕:判据要求
* syslog_count() 归零,写了立刻变 1;要留痕归操作记录/SOE
* (docs/任务规划/UI补齐.md 第 6 项的写入方),不是本 API 的职责。
*/
int syslog_request_clear(syslog_id_t id);
/*
* 函数功能:查「清除记录」的状态(给 UI 出真实反馈,不是"按了就算成功")
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:SYSLOG_CLEAR_NONE(0) / SYSLOG_CLEAR_OK(1) / SYSLOG_CLEAR_WAIT(-1) /
* SYSLOG_CLEAR_FAIL(-2);id 越界按 NONE 返回(等价"从没请求过") int
* 限定条件:任意上下文(MCU 端只读两个 volatile 单字节,Cortex-M 上原子,无锁)
* 函数说明:排队位优先于结果字节——否则连点两次会先把上一次的结果当成这次的,
* 还在排队的请求被显示成"已清空"。
* 无锁交接的最坏竞态只有两种,都不产生错误数据:① 写者已销账、尚未写
* 结果的那几个指令周期里读 → 读到上一次的结果(同向,多为 OK);
* ② 上一次失败被新请求清零 → 那次失败不再显示(用户已在重新发起,
* 语义正确)。为它上临界区要引 FreeRTOS 头 + 宿主桩,不值。
*/
int syslog_clear_state(syslog_id_t id);
/*
* MCU 后端专属 API(sys_log_mcu.c;模拟器端无对应实现,勿在共享代码调用)
*/
@@ -116,16 +260,41 @@ void syslog_mcu_mounted(void);
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试)
* 本函数尾部同时消费「清除记录」请求(syslog_request_clear 的落地半边,
* 排在条目排空之后——删除必须是本轮对这两个文件的最后一次盘上动作,
* 先删后写会把刚排空的旧记录又落回去,"清除"就变成没效果的按钮)
*/
void syslog_drain(void);
/*
* 函数功能:立即删除某日志的全部记录(「清除记录」的落地半边,MCU 后端专属)
* 入口参数:id - 日志 ID syslog_id_t
* 返回值:0 - 记录已清空(含"文件本就不存在"的幂等成功,见下)
* -1 - 未清空:卷被占用或删除失败,**原文件一个字节都没少** int
* 限定条件:仅 defaultTask(syslog 唯一写者)上下文,且调用处**不得持卷锁**
* (卷锁非递归锁);卷锁一律 try(vol_lock_wait(0),本任务 = 看门狗
* 喂狗点,阻塞等锁 = 停喂 = IWDG 误复位)
* 函数说明:其它上下文请调 syslog_request_clear()(本头文件上方);本函数在
* MCU 后端之外**没有任何实现**,共享代码误调会在链接期报错,
* 这比运行时断言硬。产品路径上唯一的调用者是 syslog_drain 尾部的
* 请求消费循环,直接调用只用于板级套件与主机单测。
* 幂等口径:只有 FR_NO_FILE 算"已经没有什么可删了"→ 返 0;
* FR_NO_PATH(/log 目录不在)**不算**幂等——记录确实读不到,但成因是
* 目录没了而不是"被清空",补目录归 syslog_mcu_mounted() 每 2s 的重试,
* 这里如实返 -1,不把卷异常洗成"清除成功"。
* 名字/参数类错误码(FR_INVALID_NAME 等)一律只返 -1 不做任何补救动作:
* 本函数压根没有卷级动作(无 f_mkdir / 无 f_mkfs / 不重试 unlink),
* 这正是存储架构说明 §10.1 第 10 条在删除路径上的落地形态。
*/
int syslog_clear_apply(syslog_id_t id);
/*
* 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式)
* 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char *
* 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,**不阻断判定**) int
* 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞,
* 本上下文 = 看门狗喂狗点)
* 函数说明:round 7 取证通道(设计见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:
* 函数说明:第 7 轮取证通道(设计见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:
* 误复位后 RAM 态随复位消失、stall 行经僵尸串口丢失,故证据必须落盘)。
* 内部在释放卷锁后立刻追加一条 s_append(SYSLOG_RUN, ...) 写另一个
* 文件——FTL 单页缓存语义为"写到不同 LPN 才把前一脏页刷进 NAND",
+853 -110
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
* 模块名称:IEC 60870-5-104 ASDU 编解码(应用层,纯逻辑)
* 模块功能:104 规约应用服务数据单元(ASDU)的类型/传送原因常量、组帧与解析。
* **零 I/O、零平台依赖**(仅 stdint.h)→ 主机侧 L0.5 可离线单测
* (登记簿 A-4 约束 1"协议解析与硬件 I/O 分离")。
* (分层约束:协议解析与硬件 I/O 分离)。
* 口径来源:docs/算法/继电保护功能复现规划.md §1.5(B0 产品定义)——
* 公共地址 2 字节、传送原因 2 字节、信息体地址 IOA **3 字节**(101 为 2 字节);
* 遥测上送**短浮点** M_ME_NC_1(系数 = 1);遥信双点上双点、其余单点;
+130
View File
@@ -0,0 +1,130 @@
/*
* 模块名称:IEC 60870-5-104 通讯参数段([comm] 配置)实现
* 模块功能:/config.ini [comm] 段的 RAM 副本 / 工厂默认 / 键表与注册 +
* getter 合法带自愈 + setter 守卫。契约、线程约定与生效语义
* (改后重启生效、不挂回调不置事件位、主站地址不做)→
* iec104_comm.h 文件头;键表口径 → docs/子系统设计/网络配置说明.md
* §3.6;热生效代价分析 → docs/子系统设计/IEC104上行设计说明.md §8。
* 适用平台:STM32F407ZGTx / PC 模拟器(双端编译:模拟器运行期无 104 任务,
* 本 TU 不产生行为,按双端注册口径入 CMakeLists 保持构建事实一致)
* 创建日期:2026-10-26(道G task-8,「UI 补齐」第 5 项非 UI 前置)
* 修改记录:
* 2026-10-26 DTU 项目组 创建:实现体自 iec104_pts.c 尾段**同笔整体迁移**
* (零逻辑改动)。动因 = coding-standard
* CODE-RULE-17 (2.2)「非 static 函数须在配对头文件
* 声明」按文件名配对判定——初版把实现放 iec104_pts.c
* 而声明在 iec104_comm.h,钩子按规则判红;本模块
* 本就是独立配置段,配对拆分即正解。
*/
#include "iec104_comm.h"
#include <string.h>
#include "cfg_file.h"
/* ---------------- [comm] RAM 副本与工厂默认 ----------------
* 引擎属主 = defaultTask:本段只被 cfg_file_load/save/poll 通用遍历,本文件
* 不触引擎任何写路径;内核前注册点 = app_main.c(「先注册后 load」契约,同
* adc_cal_init / run_par_init)。App/protocol 零日志惯例保持——注册失败留痕
* 由调用方 app_main.c 锚点负责,getter/setter 纯 RAM 不打日志(iec104Task
* 深链省栈,AGENTS 日志成本口径)。 */
/* RAM 副本(引擎原地更新;u16 半字对齐读原子,撕裂域 = 单字段新旧值) */
typedef struct {
uint16_t ca; /* 公共地址(= 链路地址,ASDU 头 2B,点表 §1.5 口径) */
uint16_t port; /* 被控站 TCP 监听端口 */
} iec104_comm_t;
static iec104_comm_t s_comm;
static const iec104_comm_t s_comm_rom = {
IEC104_COMM_DEF_CA, IEC104_COMM_DEF_PORT
};
/* 键表(s_comm 布局内偏移,同 net_cfg/ftp_cfg 写法) */
static const cfg_key_def_t s_comm_keys[] = {
{ "ca", CFG_VAL_UINT, offsetof(iec104_comm_t, ca), 2u },
{ "port", CFG_VAL_UINT, offsetof(iec104_comm_t, port), 2u },
};
/* 函数定义区 */
/*
* 函数功能:置工厂默认并向引擎注册 [comm] 段(幂等,内核前)
* 返回值:0 注册成功;-1 重复注册/段数超限(RAM 副本已复位,adc_cal 同款) int
* 限定条件:先于 defaultTask 的 cfg_file_load;不挂回调(改后重启生效)
*/
int iec104_comm_init(void) {
cfg_section_reg_t reg;
(void)memcpy(&s_comm, &s_comm_rom, sizeof(s_comm));
(void)memset(&reg, 0, sizeof(reg));
reg.name = IEC104_COMM_SEC;
reg.p_data = &s_comm;
reg.p_defaults = &s_comm_rom;
reg.data_size = sizeof(s_comm);
reg.p_keys = s_comm_keys;
reg.key_count = (uint8_t)(sizeof(s_comm_keys) / sizeof(s_comm_keys[0]));
reg.p_cb = NULL; /* 不挂回调:iec104Task 启动读 getter(D5 口径) */
return cfg_file_register(&reg);
}
/*
* 函数功能:取生效公共地址
* 返回值:RAM 副本 ca,带外回退工厂默认;恒 ∈ IEC104_COMM_CA_MIN..MAX uint16_t
* 限定条件:无(任意上下文,纯读)
*/
uint16_t iec104_comm_ca(void) {
uint16_t v = s_comm.ca;
/* 只判下界。上界 IEC104_COMM_CA_MAX 就是 uint16_t 的最大值,写成
* 「v > 65535」对 uint16_t 恒假(cppcheck compareValueOutOfTypeRangeError),
* 留着只会让下一个读代码的人以为上界真被守过。合法带口径不变,
* 仍照 iec104_comm.h 的常量注释(ca 1..65535 / port 1024..65535)。 */
if (v < IEC104_COMM_CA_MIN) {
return IEC104_COMM_DEF_CA;
}
return v;
}
/*
* 函数功能:取生效监听端口
* 返回值:RAM 副本 port,带外回退工厂默认;恒 ∈ IEC104_COMM_PORT_MIN..MAX uint16_t
* 限定条件:无(任意上下文,纯读)
*/
uint16_t iec104_comm_port(void) {
uint16_t v = s_comm.port;
if (v < IEC104_COMM_PORT_MIN) { /* 上界恒假,同 iec104_comm_ca 处注释 */
return IEC104_COMM_DEF_PORT;
}
return v;
}
/*
* 函数功能:写 ca 进 RAM 副本(守卫 = getter 同带;非法不提交)
* 入口参数:ca - 公共地址 uint16_t
* 返回值:0 提交;-1 带外(副本不动) int
* 限定条件:UI 保存交接路(task-9);落盘走 SYS_EVT_CFG_SAVE 整档外科 save
*/
int iec104_comm_ca_set(uint16_t ca) {
if (ca < IEC104_COMM_CA_MIN) { /* 上界恒假,同 iec104_comm_ca 处注释 */
return -1;
}
s_comm.ca = ca;
return 0;
}
/*
* 函数功能:写 port 进 RAM 副本(守卫 = getter 同带;非法不提交)
* 入口参数:port - 监听端口 uint16_t
* 返回值:0 提交;-1 带外(副本不动) int
* 限定条件:同 iec104_comm_ca_set
*/
int iec104_comm_port_set(uint16_t port) {
if (port < IEC104_COMM_PORT_MIN) { /* 上界恒假,同 iec104_comm_ca 处注释 */
return -1;
}
s_comm.port = port;
return 0;
}
+99
View File
@@ -0,0 +1,99 @@
/*
* 模块名称:IEC 60870-5-104 通讯参数段([comm] 配置)公共接口
* 模块功能:/config.ini [comm] 段的对外契约——公共地址(CA = ASDU 公共地址,
* 2B)与被控站 TCP 监听端口的工厂默认、getter/setter 与合法带。
* 实现 = 配对文件 App/protocol/iec104/iec104_comm.c(coding-standard
* CODE-RULE-17 (2.2)「非 static 函数须在**配对头文件**声明」按文件名
* 判定;初版曾寄身 iec104_pts.c 尾段,2026-10-26 lead 纠偏迁出)。
* 修改记录:
* 2026-10-26 DTU 项目组 创建(道G task-8);同日实现位置改配对拆分。
* 线程约定:注册/复位 = app_main_init 内核前(run_par_init / adc_cal_init 同款
* "先注册后 load"契约);load/save/poll = defaultTask(配置引擎属主,
* netTask/uiTask 不得直接触碰引擎);getter = 任意消费上下文(u16
* 半字对齐读天然原子,重载瞬间最坏 = 单字段新旧值之一);setter =
* UI 保存交接路(task-9,net_cfg_set_* 先例)。
* 生效语义:**改后重启生效**(docs/任务规划/UI补齐.md §2 第 5 行验收口径)——
* iec104Task 启动时取一次 getter 填 link cfg;端口另在下次重 bind
* (拆链/网络代次重建)自然生效,不必整机重启。不挂段回调、不新增
* 事件位、不走 SYS_EVT_NET_RECONFIG(网络代次链语义不同;且 CH395F
* 单连接 socket 上"换端口/拒绝重听"= close + re-listen,扫描主机
* 可打成风暴,详见 docs/子系统设计/IEC104上行设计说明.md §8)。
* 主站地址(accept 过滤)本卡**不做**:现无既有消费点,理由同上 §8。
* 创建日期:2026-10-26(道G task-8,「UI 补齐」第 5 项非 UI 前置)
*/
#ifndef IEC104_COMM_H
#define IEC104_COMM_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
/* 段名(cfg_file 引擎注册用;≤ CFG_FILE_MAX_NAME_LEN) */
#define IEC104_COMM_SEC "comm"
/* 工厂默认:CA = 1(点表口径,V0.1 沿用);端口 = 2404(DL/T 634.5104 标准,
* 与 App/task/iec104_serv.h 的 IEC104_SERV_PORT 同值——该宏现仅作
* ui_data_feed 快照兼容出口保留,运行期真源 = 本段 getter) */
#define IEC104_COMM_DEF_CA 1u
#define IEC104_COMM_DEF_PORT 2404u
/* getter 合法带(带外 = RAM 副本被直写/手改时的自愈回退,adc_cal「合法带哨兵」
* 同款先例):
* ca 1..65535:0 为非法公共地址(104 半字地址域,点表无 0 号站)
* port 1024..65535:拒绝把监听端口配到 1..1023 系统保留段,避免误配顶掉
* 系统服务;与 8888/7003 带内相撞属配置者责任(启动锚点
* "i104: listening on %u" 打印实际端口,可核) */
#define IEC104_COMM_CA_MIN 1u
#define IEC104_COMM_CA_MAX 65535u
#define IEC104_COMM_PORT_MIN 1024u
#define IEC104_COMM_PORT_MAX 65535u
/*
* 函数功能:置工厂默认并向配置引擎注册 [comm] 段(幂等,内核前调用)
* 返回值:0 - 注册成功;-1 - 重复注册/段名冲突/段数超限(RAM 副本已复位
* 工厂默认,引擎侧仍按既有注册表工作) int
* 限定条件:必须先于 defaultTask 的 cfg_file_load(net_cfg_load 内);
* 重复调用 = 仅复位 RAM 副本(单测复位依赖此语义,adc_cal 同款)
* 函数说明:不挂变更回调(改后重启生效口径,见文件头)
*/
int iec104_comm_init(void);
/*
* 函数功能:取生效公共地址(消费点 = iec104Task 启动填 link cfg)
* 返回值:RAM 副本 ca,带外回退 IEC104_COMM_DEF_CA;恒 ∈ 1..65535 uint16_t
* 限定条件:无(任意上下文,纯读)
*/
uint16_t iec104_comm_ca(void);
/*
* 函数功能:取生效监听端口(消费点 = iec104_serv s_bind_listen + 启动日志)
* 返回值:RAM 副本 port,带外回退 IEC104_COMM_DEF_PORT;恒 ∈ 1024..65535 uint16_t
* 限定条件:无(任意上下文,纯读)
*/
uint16_t iec104_comm_port(void);
/*
* 函数功能:写 ca 到 RAM 副本(守卫 = getter 合法带;非法值不提交)
* 入口参数:ca - 公共地址 1..65535 uint16_t
* 返回值:0 - 提交;-1 - 带外(副本不动) int
* 限定条件:调用方 = UI 保存交接路(task-9);落盘经 SYS_EVT_CFG_SAVE
* 整档外科 save(net_cfg_save_request 同款交接,本模块不写盘)
*/
int iec104_comm_ca_set(uint16_t ca);
/*
* 函数功能:写 port 到 RAM 副本(守卫 = getter 合法带;非法值不提交)
* 入口参数:port - 监听端口 1024..65535 uint16_t
* 返回值:0 - 提交;-1 - 带外(副本不动) int
* 限定条件:同 iec104_comm_ca_set
*/
int iec104_comm_port_set(uint16_t port);
#ifdef __cplusplus
}
#endif
#endif /* IEC104_COMM_H */
+12 -6
View File
@@ -161,18 +161,23 @@ static int s_send_asdu(iec104_link_t *lk, uint8_t type, uint8_t vsq,
*/
static void s_cmd_con(iec104_link_t *lk, uint8_t type, const uint8_t *obj,
uint16_t obj_len, uint8_t cot, uint8_t pn) {
uint8_t body[BODY_MAX];
uint16_t n = obj_len;
if (n > BODY_MAX) {
n = 0u;
n = 0u; /* 超长回显退化为空信息体 */
}
if (n > 0u) {
uint8_t body[BODY_MAX]; /* 作用域收进真要回显的分支,不把未初始化缓冲传下去 */
(void)memcpy(body, obj, n);
(void)s_send_asdu(lk, type, 1u, cot,
(pn != 0u) ? IEC104_COT_BIT_PN : 0u,
0u, 0u, body, n, 0u);
} else {
(void)s_send_asdu(lk, type, 1u, cot,
(pn != 0u) ? IEC104_COT_BIT_PN : 0u,
0u, 0u, NULL, 0u, 0u);
}
(void)s_send_asdu(lk, type, 1u, cot,
(pn != 0u) ? IEC104_COT_BIT_PN : 0u,
0u, 0u, body, n, 0u);
}
/* ---------------- 数据上送 ---------------- */
@@ -280,12 +285,13 @@ static void s_spont_step(iec104_link_t *lk, uint8_t *budget) {
uint8_t body[8]; /* 最大 = SIQ + CP56(7)(M_SP_TB_1) */
uint8_t i;
uint8_t d;
uint8_t blen;
uint8_t type;
int rc;
while ((*budget > 0u) && ((lk->pend_yx != 0u) || (lk->pend_yc != 0u))) {
if (lk->pend_yx != 0u) {
uint8_t blen;
i = 0u;
while (i < 33u) {
if ((lk->pend_yx & (1uLL << i)) != 0u) {
+4 -3
View File
@@ -9,7 +9,7 @@
* → clock_sync 回调)、试验(C_TS)与进程复位(C_RP,清快照)。
* **零 I/O**:收 = on_rx 喂 TCP 流片段,发 = send_apdu 回调,
* 时基 = 调用方注入 now_ms(tick ms,回绕安全:差值法)
* → 全栈(asdu+pts+link)主机侧 L0.5 可离线跑(登记簿 A-4 约束 1)。
* → 全栈(asdu+pts+link)主机侧 L0.5 可离线跑(分层约束)。
* 参数(DL/T 634.5104 标准默认,§1.5 未另定):k=12 w=8 t1=15s t2=10s t3=20s。
* 端口:TCP **2404**(§1.5 B0 定义;本 DTU = 被控站/服务器,主站连入)。
* 线程约定:单实例单任务上下文(板端 = 104 服务在 netTask 消息循环内驱动,
@@ -25,8 +25,9 @@
#include "iec104_pts.h"
/* 服务端口(§1.5:以太网 TCP 2404) */
#define IEC104_PORT 2404U
/* 服务端口真源已迁 [comm] 段(道G task-8):工厂默认 2404 = IEC104_COMM_DEF_PORT,
* 运行期取 iec104_comm_port()(App/protocol/iec104/iec104_comm.h)。原死 define
* IEC104_PORT 全仓零 .c 消费,2026-10-26 删除(旧引用见 docs/UI设计/页面扩充详细方案.md)。 */
/* 链路参数(标准默认值) */
#define IEC104_K_MAX 12U /* 未确认 I 帧上限 */
+348 -103
View File
@@ -1,43 +1,94 @@
/*
* 模块名称:IEC 60870-5-104 点表与数据源适配(实现)
* 模块功能:实现 iec104_pts.h——YC/YX/YK/定值点表(ROM)、板端默认源
* (phasor / prot_elm / prot_set)、COV 快照比对(遥信变位 +
* 遥测死区)。点表口径见 iec104_pts.h 文件头与设计文档 §3。
* (**L2 遥测快照**(卡 9)/ prot_elm / prot_set)、COV 比对(遥信变位 +
* 遥测死区 2 点确认 + 品质变化)。点表口径见 iec104_pts.h 文件头与
* 设计文档 §3。
* 线程约定:init/bind 建服务前;其余仅 104 服务任务上下文(快照非原子)。
* 创建日期:2026-10-12(B4)
* 修改记录:
* 2026-10-25 DTU 项目组 卡 6(「数据可信」):YC 死区由拟定码值域重定为
* 二次值工程量域(额定 0.5 % 规则),判据依据见
* k_yc 表头注释;数值随卡 9 迁运行参数,本表将退化为
* ROM 默认载体。
* 2026-10-25 DTU 项目组 卡 9/卡 11(「数据可信」,道D):YC 板端源改读
* L2 遥测快照(App/algo/meas.h,meas_get 无锁双区);
* 削顶五路由快照品质 CLIPPED 驱动线上 IV(不再绑死 -1,
* V0.2 消除双转换器无共同采样触发后零改动自动启用);0x401d 绑频率源(卡 11);
* 死区改运行时副本 + set/get 入口;COV 遥测腿 = 死区
* 连续 2 点确认(板端按快照 seq 去重)+ 品质翻转即报。
* 单测 = test/host/iec104 TC-IEC104-025~028。
* 卡 10 2026-10-25 DTU 项目组 死区真源收编 App/algo/run_par.c(本层 s_yc_db
* 副本删除,set/get 转转发壳,壳保留清确认计数语义);
* 定值读写路由 = run_par 表先、prot_set 后(0x8020~
* 0x8058 运行参数段 C_RD/C_SE,系数覆盖直通 adc_cal)。
* 2026-10-26 DTU 项目组 道G task-8「UI 补齐」第 5 项非 UI 前置:新增
* [comm] 通讯参数段(ca/port)初版落本文件尾部;
* s_src_yk_exec() 等遥控/记录投递路径零改动。
* 2026-10-26 DTU 项目组 同日晚纠偏:[comm] 实现整体迁出至配对模块
* iec104_comm.c(coding-standard CODE-RULE-17 (2.2)
* 按文件名配对判定,本文件的公开符号必须声明于
* iec104_pts.h——[comm] 不属点表,拆出正解;契约
* = iec104_comm.h,本文件不再引用 cfg_file)。
*/
#include "iec104_pts.h"
#include <string.h>
#include "iec104_asdu.h" /* QDS/DIQ 品质位常量 */
#include "phasor.h" /* 板端源:遥测 = 基波有效值 */
#include "meas.h" /* 板端源:遥测 = L2 遥测快照(卡 9;品质域位同源) */
#include "run_par.h" /* 卡 10:死区真源 + 运行参数段 104 读写路由 */
#include "prot_elm.h" /* 板端源:遥信 = 元件状态 */
#include "prot_set.h" /* 板端源:定值 + 闭锁 */
#include "sys_log.h" /* task-10:遥控记录(SYSLOG_YK)/ 操作记录(SYSLOG_OPER) */
#include "adc_input.h" /* adc_semantic_t(phasor 实例按语义绑定) */
/* ---------------- YC 表(§1.5 点号 4011+;死区 = 拟定码值,L1-5 后复核) -- */
/* ---------------- YC 表(§1.5 点号 4011+;死区列 = ROM 默认值) ------------ */
/* 源类型(「数据可信」卡 11:YC 表加 src 类型区分相量/频率) */
#define PTS_SRC_MEAS 0u /* L2 快照槽 sem(电气量,yc/qds 直读) */
#define PTS_SRC_FREQ 1u /* 快照频率(freq_valid 门 → IV) */
typedef struct {
uint32_t ioa;
int8_t sem; /* 绑定的 phasor 语义通道(-1 = 无 phasor 源,恒无效) */
float deadband; /* 死区(工程量绝对值) */
int8_t sem; /* 快照 yc 槽 = 语义通道(-1 = 非测量量点,当前仅频率) */
float db_default; /* 死区工厂默认(生效真源 = run_par [run_par] 段,卡 10) */
uint8_t src; /* PTS_SRC_* 源类型 */
} pts_yc_def_t;
/* 死区规则 = 额定二次值的 0.5 %(「数据可信」§2.3 表第 2 行建议规则,卡 6 采纳;
* 额定基准 100 V / 1 A 与 App/algo/prot_set.c 头部同源,勿在此另立口径):
* 电压类 0.5 % × 100 V = 0.5 V 电流类 0.5 % × 1 A = 0.005 A
* 频率点不适用"额定百分比"(额定附近 0.5 Hz 与旧值同量级,维持 0.5 并随
* 卡 10 运行参数段可调)。自卡 9 起本表**只作工厂默认载体**:生效死区 =
* App/algo/run_par.c 的 [run_par] 引擎副本(卡 10 真源收编,本层不再存副本);削顶五路不再 sem=-1 绑死,而是绑快照槽、由品质 CLIPPED
* 驱动线上 IV——V0.2 削顶消失后点位零改动自动启用(docs/任务规划/
* 数据可信.md §10 的「双转换器无共同采样触发」行)。
* ⚠ 过渡期(eng=0 引脚电压域,卡 12 标定前):上送值本身非二次值,死区
* 数字按本域偏保守(迟报不漏报);标定落地后复核(「数据可信」§2.3 表
* 第 2 行)。判定 = 死区连续 2 点确认(卡 9),见 iec104_pts.h COV 说明。 */
static const pts_yc_def_t k_yc[] = {
{ 0x4011u, (int8_t)ADC_SEM_UA, 10.0f }, /* 1Ua */
{ 0x4012u, -1, 10.0f }, /* 1Ub:V0.1 削顶 → IV */
{ 0x4013u, -1, 10.0f }, /* 1Uc:同上 */
{ 0x4014u, (int8_t)ADC_SEM_UN, 10.0f }, /* 1U0(外部零序专用通道) */
{ 0x4015u, (int8_t)ADC_SEM_IA, 10.0f }, /* 1Ia */
{ 0x4016u, -1, 10.0f }, /* 1Ib */
{ 0x4017u, -1, 10.0f }, /* 1Ic */
{ 0x4018u, -1, 10.0f }, /* 1I0 */
{ 0x401Du, -1, 0.5f }, /* 1f:L1-7 未实现 → IV */
{ 0x4011u, (int8_t)ADC_SEM_UA, 0.5f, PTS_SRC_MEAS }, /* 1Ua:0.5 % × 100 V */
{ 0x4012u, (int8_t)ADC_SEM_UB, 0.5f, PTS_SRC_MEAS }, /* 1Ub:削顶 → 品质驱动 IV */
{ 0x4013u, (int8_t)ADC_SEM_UC, 0.5f, PTS_SRC_MEAS }, /* 1Uc:同上 */
{ 0x4014u, (int8_t)ADC_SEM_UN, 0.5f, PTS_SRC_MEAS }, /* 1U0(外部零序专用通道) */
{ 0x4015u, (int8_t)ADC_SEM_IA, 0.005f, PTS_SRC_MEAS }, /* 1Ia:0.5 % × 1 A */
{ 0x4016u, (int8_t)ADC_SEM_IB, 0.005f, PTS_SRC_MEAS }, /* 1Ib:削顶 → 品质驱动 IV */
{ 0x4017u, (int8_t)ADC_SEM_IC, 0.005f, PTS_SRC_MEAS }, /* 1Ic:同上 */
{ 0x4018u, (int8_t)ADC_SEM_IN, 0.005f, PTS_SRC_MEAS }, /* 1I0:同上 */
{ 0x401Du, -1, 0.5f, PTS_SRC_FREQ }, /* 1f:卡 11 绑频率源,
* freq_valid=0 → IV */
};
#define YC_N ((uint8_t)(sizeof(k_yc) / sizeof(k_yc[0])))
/* phasor 实例下标缓存(init 时按 sem+harm==1 查表;-1 = 未找到) */
static int8_t s_yc_phasor_idx[YC_N];
/* ===== 遥测 COV 运行时状态(卡 9;仅 iec104Task 上下文访问,无并发) =====
* 判据链:新测量点 → |v − 上次上送值| > 死区?→ 连续 2 点确认 → 置变化位。
* 基准 = **上次上送值**(非上次读值——否则慢漂移永不出死区,参考系统同款);
* 品质(线上 qds)翻转 = 事件:不参与 2 点计数、当拍直报。 */
static float s_yc_rep[YC_N]; /* 上次上送值(建线/确认上送时更新) */
static uint8_t s_yc_repq[YC_N]; /* 上次上送的线上 qds */
static uint8_t s_yc_cnf[YC_N]; /* 越死区连续计数(1 = 待确认;2 = 确认) */
static uint32_t s_yc_seq; /* COV 已消费到的快照 seq(板端源测点去重) */
static uint8_t s_yc_seqmode; /* 1 = 板端默认源(测点 = meas 换帧) */
/* ---------------- YX 表(IOA = 1..33;双点 = 开关位置类) ------------------ */
#define YX_N 33u
@@ -48,63 +99,62 @@ static int8_t s_yc_phasor_idx[YC_N];
| (1uLL << 17u) | (1uLL << 22u) | (1uLL << 30u)))
/* ---------------- 数据源(默认 = 板端;bind_src 可整体替换) -------------- */
static int s_src_yc_read(uint8_t idx, float *out);
static int s_src_yc_read_ex(uint8_t idx, float *out, uint16_t *qds);
static uint64_t s_src_yx_bits(void);
static uint64_t s_src_yx_valid(void);
static int s_src_yk_exec(uint32_t ioa, uint8_t dpc, uint8_t select);
static int s_src_set_read(uint32_t addr, float *out);
static int s_src_set_write(uint32_t addr, float v, uint8_t select);
/* 板端默认源:遥测走**带品质**的 ex 通道(legacy yc_read 槽置 NULL——
* s_yc_point 优先 ex;注入源两种槽位均可,见 iec104_pts.h) */
static const iec104_pts_src_t k_default_src = {
s_src_yc_read, s_src_yx_bits, s_src_yx_valid,
s_src_yk_exec, s_src_set_read, s_src_set_write
NULL, s_src_yx_bits, s_src_yx_valid,
s_src_yk_exec, s_src_set_read, s_src_set_write, s_src_yc_read_ex
};
static const iec104_pts_src_t *s_src;
/* ---------------- COV 快照 ---------------- */
static float s_yc_last[YC_N];
static uint16_t s_yc_have; /* bit i = 已有上次快照 */
/* ---------------- COV 快照(遥信侧;遥测侧状态见上方 s_yc_* 块) --------- */
static uint16_t s_yc_have; /* bit i = 已建立上送基线 */
static uint64_t s_yx_last;
static uint8_t s_yx_snap_ok;
/* ============================ 公开接口 ================================== */
/*
* 函数功能:初始化点表模块(扫 phasor 基波实例映射、恢复默认源、清快照)
* 函数功能:初始化点表模块(死区装 ROM 默认、清 COV/快照状态、恢复默认源)
* 限定条件:内核启动前或 104 服务启动前,调一次
* 函数说明:卡 9 起不再扫 phasor 实例(数据源 = 快照,槽位 = 语义通道,编译期
* 直接可定);重复调用 = 全状态复位(单测 reset 依赖此语义)。
*/
void iec104_pts_init(void) {
uint8_t i;
uint8_t n;
(void)memset(s_yc_phasor_idx, -1, sizeof(s_yc_phasor_idx));
n = phasor_inst_count();
for (i = 0u; i < YC_N; i++) {
uint8_t j;
if (k_yc[i].sem < 0) {
continue;
}
for (j = 0u; j < n; j++) {
const phasor_inst_t *inst = phasor_inst(j);
if ((inst != NULL) && ((uint8_t)inst->sem == (uint8_t)k_yc[i].sem)
&& (inst->harm == 1u)) {
s_yc_phasor_idx[i] = (int8_t)j;
break;
}
}
}
s_src = &k_default_src;
s_yc_have = 0u;
/* 死区不在本层复位——真源 = run_par(cfg 引擎副本),复位入口唯一
* run_par_init();本函数只复位 COV 状态(单测 reset 依赖此语义) */
(void)memset(s_yc_rep, 0, sizeof(s_yc_rep));
(void)memset(s_yc_repq, 0, sizeof(s_yc_repq));
(void)memset(s_yc_cnf, 0, sizeof(s_yc_cnf));
s_yc_have = 0u;
s_yc_seq = 0u;
s_yc_seqmode = 1u;
s_src = &k_default_src;
s_yx_snap_ok = 0u;
s_yx_last = 0u;
s_yx_last = 0u;
}
/*
* 函数功能:替换数据源(NULL = 恢复板端默认源;单测注入假源)
* 函数说明:注入源同时把 COV 测点口径切为**每次调用一测点**(假源无快照节拍,
* 步进由调用方控制);默认源 = 测点按 meas 换帧(seq 去重)。换源
* 不重建基线(基线状态统一由 init 复位,防跨源半截确认序列)。
*/
void iec104_pts_bind_src(const iec104_pts_src_t *src) {
s_src = (src != NULL) ? src : &k_default_src;
if (src != NULL) {
s_src = src;
s_yc_seqmode = 0u;
} else {
s_src = &k_default_src;
s_yc_seqmode = 1u;
}
}
/*
@@ -122,35 +172,108 @@ uint32_t iec104_pts_yc_ioa(uint8_t idx) {
}
/*
* 函数功能:读遥测(值 + 品质 descriptor;源 -2 → NT,其余失败 → IV)
* 返 回 值:0 有效 / -1 无效
* 函数功能:品质域位映射(uint16 meas 域 → uint8 线上 QDS,卡 9)
* 入口参数:q16 - 源侧品质(低字节与线上同值;高字节为 MEAS_QDS_CLIPPED 域内位)
* 返 回 值:线上 QDS 字节 uint8_t
* 函数说明:CLIPPED(硬件削顶,104 无对应位)映射为 IV——「削顶通道 104 恒
* IV」口径(docs/任务规划/数据可信.md §8 风险表末行)改由快照品质
* 驱动,不再靠点表绑死 -1;OV/NT/IV 透传,其余高位丢弃。
*/
int iec104_pts_yc_read(uint8_t idx, float *v, uint8_t *qds) {
int rc;
static uint8_t s_q_map(uint16_t q16) {
uint8_t w = (uint8_t)(q16 & (uint16_t)(IEC104_QDS_OV | IEC104_QDS_NT
| IEC104_QDS_IV));
if (v != NULL) {
*v = 0.0f;
if ((q16 & (uint16_t)MEAS_QDS_CLIPPED) != 0u) {
w |= (uint8_t)IEC104_QDS_IV;
}
if ((idx >= YC_N) || (s_src == NULL) || (s_src->yc_read == NULL)) {
if (qds != NULL) {
*qds = IEC104_QDS_IV;
}
return w;
}
/*
* 函数功能:读单点遥测(统一源通道 + 品质归一,yc_read 与 COV 共用唯一读径)
* 入口参数:idx - YC 表下标 v/q - 输出(值 / 线上 qds;调用方保证非空)
* 返 回 值:0 有效;-1 IV(含 CLIPPED 映射);-2 NT(未就绪) int
* 函数说明:ex 源在场 → 品质由源给(板端 = 快照槽位透传);否则退 legacy
* yc_read,品质从返回值推导(-1→IV / -2→NT / 0→无位)——旧注入源
* 行为与卡 9 前**完全一致**(单测假源零改动)。判定顺序 IV > NT:
* 开机首帧 qds=IV|NT 按无效处理(NT 只留给纯暂态)。任何无效 →
* 信息体清零(线上口径:主站只信品质)。
*/
static int s_yc_point(uint8_t idx, float *v, uint8_t *q) {
int rc;
uint16_t q16 = 0u;
if ((idx >= YC_N) || (s_src == NULL)
|| ((s_src->yc_read == NULL) && (s_src->yc_read_ex == NULL))) {
*v = 0.0f;
*q = (uint8_t)IEC104_QDS_IV;
return -1;
}
rc = s_src->yc_read(idx, v);
if (rc == 0) {
if (qds != NULL) {
*qds = 0u;
}
return 0;
if (s_src->yc_read_ex != NULL) {
rc = s_src->yc_read_ex(idx, v, &q16);
} else {
rc = s_src->yc_read(idx, v);
q16 = (rc == -2) ? (uint16_t)MEAS_QDS_NT
: ((rc != 0) ? (uint16_t)MEAS_QDS_IV : 0u);
}
if (v != NULL) {
(void)rc; /* 有效性由归一后的 q 终裁(IV/NT 即无效),源返回值不直通 */
*q = s_q_map(q16);
rc = ((*q & (uint8_t)IEC104_QDS_IV) != 0u) ? -1
: (((*q & (uint8_t)IEC104_QDS_NT) != 0u) ? -2 : 0);
if (rc != 0) {
*v = 0.0f;
}
if (qds != NULL) {
*qds = (rc == -2) ? IEC104_QDS_NT : IEC104_QDS_IV;
return rc;
}
/*
* 函数功能:读遥测(值 + 线上品质位;无效值清零,判据见 s_yc_point)
* 返 回 值:0 有效 / -1 无效(NT 亦无效,品质位与 IV 区分) int
*/
int iec104_pts_yc_read(uint8_t idx, float *v, uint8_t *qds) {
float val = 0.0f;
uint8_t q = (uint8_t)IEC104_QDS_IV;
(void)s_yc_point(idx, &val, &q);
if (v != NULL) {
*v = val;
}
return -1;
if (qds != NULL) {
*qds = q;
}
return (q == 0u) ? 0 : -1;
}
/*
* 函数功能:写死区(卡 9 API 壳,转发 run_par 真源 + 清本层确认计数)
* 入口参数:idx - YC 表下标 db - 工程量绝对死区(≥0,分档上限见 run_par)
* 返 回 值:0 成功;-1 越界/负值/NaN/越程(run_par 副本不动) int
* 限定条件:**iec104Task 上下文**(run_par 副本写 = 引擎对齐 int32 单字,
* 104 执行段之外调用需自行避免与 104 写并发)
* 函数说明:成功时清零该点待确认计数——跨死区档位不保留半截确认序列;
* 不隐式重建基线(上送基准延续,新死区即刻生效)。落盘由 run_par
* 脏标志 + SYS_EVT_RUN_PAR_SAVE 走 defaultTask(卡 10 持久化链)。
*/
int iec104_pts_yc_set_deadband(uint8_t idx, float db) {
if (idx >= YC_N) {
return -1;
}
if (run_par_deadband_set(idx, db) != 0) {
return -1; /* 守卫(NaN/负/越程分档上限)单一实现,在 run_par */
}
s_yc_cnf[idx] = 0u; /* 壳语义:跨死区档位不保留半截确认序列 */
return 0;
}
/*
* 函数功能:读当前生效死区(转发 run_par 真源)
* 入口参数:idx - YC 表下标
* 返 回 值:生效死区;idx 越界返 0.0 float
* 限定条件:任意上下文(引擎副本 int32 单字读,Cortex-M 原子)
* 函数说明:UI 回显 / 读写往返校验用;工厂默认查点表 db_default 列。
*/
float iec104_pts_yc_deadband(uint8_t idx) {
return (idx < YC_N) ? run_par_deadband(idx) : 0.0f;
}
/*
@@ -225,28 +348,72 @@ void iec104_pts_cov_check(iec104_pts_cov_t *cov) {
s_yx_snap_ok = 1u;
}
/* YC:死区比对(仅对"本次有效 且 上次有快照"的点判定) */
if ((s_src != NULL) && (s_src->yc_read != NULL)) {
uint8_t i;
/* YC(卡 9):测点推进 = 板端源按快照换帧(100 ms poll vs 200 ms 快照,
* 不去重则同一帧计两次 → "连续 2 点确认"退化成"连续 2 次轮询",单帧
* 噪声即可被确认,抗噪语义失效);注入源 = 每次调用一测点。
* 判定 = 死区连续 2 点确认 + 品质翻转直报(口径全文 → iec104_pts.h)。 */
if ((s_src != NULL)
&& ((s_src->yc_read != NULL) || (s_src->yc_read_ex != NULL))) {
uint8_t step = 1u;
for (i = 0u; i < YC_N; i++) {
float v;
if (s_yc_seqmode != 0u) {
uint32_t sq = meas_seq();
if (s_src->yc_read(i, &v) != 0) {
continue;
if (sq == s_yc_seq) {
step = 0u; /* 未换帧:本 poll 不算新测量点 */
} else {
s_yc_seq = sq;
}
if (((s_yc_have >> i) & 1u) != 0u) {
float d = v - s_yc_last[i];
}
if (step != 0u) {
uint8_t i;
if (d < 0.0f) {
d = -d;
for (i = 0u; i < YC_N; i++) {
float v;
uint8_t q;
int rc = s_yc_point(i, &v, &q);
if (((s_yc_have >> i) & 1u) == 0u) {
if (rc == 0) { /* 首个有效读数只建线不上报(初值归总召) */
s_yc_have |= (uint16_t)(1u << i);
s_yc_rep[i] = v;
s_yc_repq[i] = q;
s_yc_cnf[i] = 0u;
}
continue;
}
if (d > k_yc[i].deadband) {
if (q != s_yc_repq[i]) {
/* 品质翻转 = 事件直报(含 0x401d 的 IV 进出,卡 11) */
local.yc_changed |= (uint16_t)(1u << i);
s_yc_repq[i] = q;
s_yc_cnf[i] = 0u;
if (rc == 0) {
s_yc_rep[i] = v;
}
continue;
}
if (rc != 0) {
s_yc_cnf[i] = 0u; /* 无效期间不计数(品质未变 = 状态延续) */
continue;
}
{
float d = v - s_yc_rep[i];
if (d < 0.0f) {
d = -d;
}
if (d > run_par_deadband(i)) {
s_yc_cnf[i]++;
if (s_yc_cnf[i] >= 2u) { /* 连续 2 越 → 确认:报 + 移基准 */
local.yc_changed |= (uint16_t)(1u << i);
s_yc_rep[i] = v;
s_yc_cnf[i] = 0u;
}
} else {
s_yc_cnf[i] = 0u; /* 回摆入死区 → 撤销候选(抗噪核心) */
}
}
}
s_yc_last[i] = v;
s_yc_have |= (uint16_t)(1u << i);
}
}
@@ -291,24 +458,44 @@ int iec104_pts_set_write(uint32_t addr, float v, uint8_t select) {
/* ============================ 板端默认源 ================================= */
/*
* 函数功能:板端默认源 - 遥测 = 基波相量有效值(fAmSQ)
* 返 回 值:0 / -1 无 phasor 源(削顶通道/频率未实现)/ -2 未满一周波(NT)
* 函数功能:板端默认源 - 遥测 = L2 遥测快照直读(「数据可信」卡 9/卡 11)
* 入口参数:idx - YC 表下标 out/qds - 值 + uint16 域品质(MEAS_QDS_*)
* 返 回 值:0 / -1 越界或频率未新鲜(IV)/ -2 快照从未发布(NT) int
* 限定条件:iec104Task 上下文(meas_get 为双区无锁读,App/algo/meas.h)
* 函数说明:**不做任何本地判断**——电气路值/品质整体透传快照槽(削顶路快照
* 已置 CLIPPED,s_q_map 映上线 IV;开机首帧 IV|NT 由 s_yc_point 按
* IV 处理),"新不新、能不能信"全在发布侧一处决定(卡 8 设计意图,
* 两处判断必打架)。频率路(PTS_SRC_FREQ):snap.freq_valid==0 →
* IV(卡 11 DoD 原句);有效时值 = snap.freq_hz,与 freq_get() 同源。
*/
static int s_src_yc_read(uint8_t idx, float *out) {
phasor_t p;
int8_t pi;
static int s_src_yc_read_ex(uint8_t idx, float *out, uint16_t *qds) {
meas_snap_t snap;
if ((idx >= YC_N) || (out == NULL)) {
if ((idx >= YC_N) || (out == NULL) || (qds == NULL)) {
return -1;
}
pi = s_yc_phasor_idx[idx];
if (pi < 0) {
return -1; /* 无 phasor 源(削顶通道/频率未实现) */
if (meas_get(&snap) != 0) {
*out = 0.0f; /* 从未发布(开机 <200 ms)= 暂态 */
*qds = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT);
return -2;
}
if (phasor_get((uint8_t)pi, &p) != 0) {
return -2; /* 未满一周波:未就绪(NT) */
if (k_yc[idx].src == (uint8_t)PTS_SRC_FREQ) {
if (snap.freq_valid == 0u) {
*out = 0.0f;
*qds = (uint16_t)MEAS_QDS_IV;
return -1;
}
*out = snap.freq_hz;
*qds = 0u;
return 0;
}
*out = p.fAmSQ; /* 有效值口径(系数已折算) */
if (k_yc[idx].sem < 0) {
*out = 0.0f; /* 理论不可达:现无未绑测量量点 */
*qds = (uint16_t)MEAS_QDS_IV;
return -1;
}
*out = snap.yc[(uint8_t)k_yc[idx].sem];
*qds = snap.qds[(uint8_t)k_yc[idx].sem];
return 0;
}
@@ -366,31 +553,77 @@ static uint64_t s_src_yx_valid(void) {
* YK12 走 prot_set_set_sealed(原子写+封存,B7);protectTask 下一拍
* 读到压板态即复位元件并停判据(protect_task.c)。
*/
/* 遥控记录的文案表(task-10)。**全部 ROM 常量、零拼接**:本回调跑在
* iec104Task,而 iec104_serv.c 的日志缓冲已经因为"深链模块"被收到 128 B,
* 这里再 snprintf 一条"YK ioa=0x600B dpc=2 sel=0 rc=0"就是往那条链上加缓冲;
* 记录类型由 SYSLOG_YK 这个文件本身回答,屏上话术归 UI(App/log/README.md 口径)。
* 最长一条 20 字符 < SYSLOG_DESC_MAX(28)。 */
#define YK_REC_YK11_SEL "YK11 select"
#define YK_REC_YK11_EXEC "YK11 exec"
#define YK_REC_YK12_SEL "YK12 select"
#define YK_REC_YK12_ON "YK12 exec on"
#define YK_REC_YK12_OFF "YK12 exec off"
#define YK_REC_YK12_FAIL "YK12 exec fail"
#define YK_REC_DPC_BAD "YK12 bad dpc"
#define YK_REC_UNKNOWN "YK unknown ioa"
static int s_src_yk_exec(uint32_t ioa, uint8_t dpc, uint8_t select) {
switch (ioa) {
case 0x600Au: /* YK11 信号复归 → 元件状态复位(异步一拍) */
(void)dpc; /* 复归与双点值无关 */
if (select == 0u) {
prot_elm_request_reset();
/* 遥控记录(执行段已受理)+ 操作记录(本地/远端强制复归)。
* 两条不是重复留痕:前者回答"主站下了什么命令、结果如何",后者
* 回答"装置被谁改动了状态"——UI 的遥控记录页与操作记录页各读一份。
* YK11 没有可失败的下游(request_reset 只置单字节标志),所以它
* 只有受理态,没有 fail 分支。 */
syslog_post_quiet(SYSLOG_YK, YK_REC_YK11_EXEC);
syslog_post_quiet(SYSLOG_OPER, "force reset");
} else {
syslog_post_quiet(SYSLOG_YK, YK_REC_YK11_SEL);
}
return 0;
case 0x600Bu: /* YK12 全局软压板(PS_GLOBAL_EN)两段式 */
case 0x600Bu: { /* YK12 全局软压板(PS_GLOBAL_EN)两段式 */
int rc;
if ((dpc != (uint8_t)IEC104_DPC_ON) && (dpc != (uint8_t)IEC104_DPC_OFF)) {
syslog_post_quiet(SYSLOG_YK, YK_REC_DPC_BAD);
return -1; /* 非法双点命令:选择段即拒 */
}
if (select != 0u) {
syslog_post_quiet(SYSLOG_YK, YK_REC_YK12_SEL);
return 0; /* 选择段 = 只校验 */
}
return prot_set_set_sealed(PS_GLOBAL_EN,
(dpc == (uint8_t)IEC104_DPC_ON)
? PROT_SET_Q10(1) : PROT_SET_Q10(0));
/* 执行段:set_sealed 自带范围校验与封存,失败 = 值被拒、RAM 未改。
* 这里**必须接住返回值再落记录**(bsp-err:错误码不得裸丢弃;
* 原实现是 return 直出,现在多一条记录后仍然原值返回,语义不变)。 */
rc = prot_set_set_sealed(PS_GLOBAL_EN,
(dpc == (uint8_t)IEC104_DPC_ON)
? PROT_SET_Q10(1) : PROT_SET_Q10(0));
if (rc != 0) {
syslog_post_quiet(SYSLOG_YK, YK_REC_YK12_FAIL);
} else {
syslog_post_quiet(SYSLOG_YK,
(dpc == (uint8_t)IEC104_DPC_ON) ? YK_REC_YK12_ON
: YK_REC_YK12_OFF);
/* 压板投退是一次真实的运维动作(保护退出/恢复服务)→ 同时进操作记录 */
syslog_post_quiet(SYSLOG_OPER,
(dpc == (uint8_t)IEC104_DPC_ON) ? "plate in"
: "plate out");
}
return rc;
}
default:
syslog_post_quiet(SYSLOG_YK, YK_REC_UNKNOWN);
return -1; /* 其余 YK:无出口硬件/无对应能力 → 拒绝 */
}
}
/*
* 函数功能:板端默认源 - 定值读(扫 g_prot_set_meta 通讯地址映射)
* 函数功能:板端默认源 - 定值读(**路由顺序 = run_par 表先、未命中落 prot_set**:
* 0x8027~0x802A 属 prot_set 告警段,run_par 命中集为精确地址匹配、
* 永不覆盖之;两表共存靠「先专属表、后通用扫」分流,勿反序——反序
* 当前无冲突但失去 run_par 守卫先行留痕的意义)
* 返 回 值:0 / -1 未知地址(本地项 comm_addr=0 不可寻址,B7 起显式排除)
*/
static int s_src_set_read(uint32_t addr, float *out) {
@@ -399,6 +632,9 @@ static int s_src_set_read(uint32_t addr, float *out) {
if (out == NULL) {
return -1;
}
if (run_par_104_read(addr, out) == 0) {
return 0; /* 运行参数段命中(卡 10) */
}
for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) {
if ((g_prot_set_meta[i].comm_addr != 0u)
&& (g_prot_set_meta[i].comm_addr == (uint16_t)addr)) {
@@ -410,13 +646,22 @@ static int s_src_set_read(uint32_t addr, float *out) {
}
/*
* 函数功能:板端默认源 - 定值写(Q10 范围校验;执行段 prot_set_set_sealed
* 原子写+封存,B7 收口 B5 竞态窗口;置脏 → 104 任务通知落盘)
* 返 回 值:0 / -1 越界或未知地址(本地项 comm_addr=0 不可寻址)
* 函数功能:板端默认源 - 定值写(**先 run_par 后 prot_set**,分流理由见
* s_src_set_read 头注;prot_set 腿 = Q10 范围校验 + 执行段
* prot_set_set_sealed 原子写+封存,B7 收口 B5 竞态窗口)
* 返 回 值:0 / -1 越界或未知地址(本地项 comm_addr=0 不可寻址);两条落盘
* 链独立:run_par 腿脏 → SYS_EVT_RUN_PAR_SAVE(cfg_file),
* prot_set 腿脏 → SYS_EVT_PROT_SET_SAVE(/prot_set.bin)
*/
static int s_src_set_write(uint32_t addr, float v, uint8_t select) {
uint32_t i;
int32_t q10;
int rp;
rp = run_par_104_write(addr, v, select);
if (rp != -2) {
return rp; /* 本段地址:0 接受 / -1 拒绝,不再下探 */
}
for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) {
const prot_set_meta_t *meta = &g_prot_set_meta[i];
+41 -8
View File
@@ -3,11 +3,14 @@
* 模块功能:104 点表(遥测 YC / 遥信 YX / 遥控 YK / 定值)的定义、读取与变化检测
* (COV:遥信变位 + 遥测死区)。**数据源经 vtable 注入**:板端绑
* phasor/prot_elm/prot_set 真实源;主机单测绑假源 → 协议栈全链
* (asdu+pts+link)可离线跑(登记簿 A-4 约束 1)。
* (asdu+pts+link)可离线跑(分层约束:协议解析与硬件 I/O 分离)。
* 点表口径(docs/算法/继电保护功能复现规划.md §1.5,验收 = 省电科院点表):
* - YC:IOA 0x4011~0x4018 + 0x401d(频率),短浮点、系数 1;
* ⚠ V0.1 有效通道仅 UA/UN/IA(TPAFE 三路,§1.6)——其余点**存在但置 IV**
* (无效位),频率随 L1-7 落地前同样置 IV;
* - YC:IOA 0x4011~0x4018 + 0x401d(频率),短浮点、系数 1;数据源 =
* L2 遥测快照(App/algo/meas.h,「数据可信」卡 9/11):电气 8 路绑快照槽,
* 有效性由快照品质驱动——V0.1 削顶路(docs/算法/继电保护功能复现规划.md
* §1.6)快照置 CLIPPED → 本层映射为线上 IV(V0.2 后自动启用,绑定零
* 改动,见 docs/任务规划/数据可信.md §10「双转换器无共同采样触发」行);0x401d 绑频率源,
* freq_valid()==0 → IV;
* - YX:IOA 1~33(§1.5 表(3));双点 = 开关位置类(YX1~4,本期无硬件 → IV),
* 保护动作类 YX15~23 映射 prot_elm 状态,闭锁类 YX31~33 映射 prot_set;
* - YK:IOA 0x6000+(§1.5 表(3));V0.1 无出口硬件(规划 R7)——
@@ -21,6 +24,13 @@
* 按未知 IOA(COT 14 + EVI)应答,登记为待办(设计文档)。
* 线程约定:init/bind 内核启动前或建服务前;cov_check/read 仅 104 服务任务上下文。
* 创建日期:2026-10-12(B4)
* 修改记录:
* 2026-10-25 DTU 项目组 卡 9/卡 11(「数据可信」,道D):YC 数据源 = L2 遥测
* 快照(削顶路由品质驱动 IV);死区改运行时副本 +
* set/get 入口;COV 遥测腿 = 死区 2 点确认(seq 去重)
* + 品质变化即报;0x401d 绑频率源(freq_valid → IV)。
* 2026-10-25 DTU 项目组 卡 10:死区真源迁 run_par([run_par] cfg 段 +
* 0x8020~0x8058 读写映射;本层 set/get 为转发壳)。
*/
#ifndef IEC104_PTS_H
#define IEC104_PTS_H
@@ -42,6 +52,11 @@ typedef struct {
int (*set_read)(uint32_t addr, float *out);
/* 定值写:select=1 预置校验(范围)/ 0 执行写入。返回 0 / -1 越界或未知 */
int (*set_write)(uint32_t addr, float v, uint8_t select);
/* 遥测读(带品质,卡 9 追加于表尾——旧源 NULL = 走 yc_read 退路,品质由
* 返回值推导:-1 → IV、-2 → NT、0 → 无位,行为与旧源完全一致)。
* qds = uint16 域位(App/algo/meas.h MEAS_QDS_*:低字节 = 线上 QDS 同值,
* 高字节 CLIPPED 由本层映射线上 IV);返回 0 / -1 IV / -2 NT */
int (*yc_read_ex)(uint8_t idx, float *out, uint16_t *qds);
} iec104_pts_src_t;
/* ---------------- 变化检测结果(cov_check 输出) ---------------- */
@@ -65,8 +80,17 @@ void iec104_pts_bind_src(const iec104_pts_src_t *src);
/* ---------------- YC ---------------- */
uint8_t iec104_pts_yc_count(void);
uint32_t iec104_pts_yc_ioa(uint8_t idx);
/* 读 YC:v 有效值(无效时为 0);qds 输出品质位(IV/NT 等)。返回 0/无效-1 */
/* 读 YC:v 有效值(无效时为 0);qds 输出线上品质位(IV/NT/OV,CLIPPED 已映射
* 为 IV)。返回 0/无效-1(NT 亦无效,品质位区分) */
int iec104_pts_yc_read(uint8_t idx, float *v, uint8_t *qds);
/* 死区写(卡 9 API;卡 10 起 = 转发 App/algo/run_par.c 真源 + 清本层确认计数):
* db = 工程量绝对值 ≥0(电压 50 V / 电流 1 A / 频率 5 Hz 分档上限);越界/负值/
* NaN → -1 且真源不动;成功时清零该点待确认计数(跨档位不残留半截确认——此壳
* 行为有单测钉住)。仅 104 服务任务上下文调用。落盘 = run_par 脏 → 事件 →
* defaultTask(卡 10 持久化链,docs/子系统设计/IEC104上行设计说明.md §3.4)。 */
int iec104_pts_yc_set_deadband(uint8_t idx, float db);
/* 读当前生效死区(越界 idx 返 0) */
float iec104_pts_yc_deadband(uint8_t idx);
/* ---------------- YX ---------------- */
uint8_t iec104_pts_yx_count(void);
@@ -77,10 +101,19 @@ void iec104_pts_yx_read(uint8_t idx, uint8_t *out);
/* ---------------- COV(每 poll 一次) ---------------- */
/*
* 函数功能:读全表快照并与上次比对(变位 + 死区),输出变化集
* 函数功能:读全表并与上次比对(遥信变位 + 遥测死区 2 点确认 + 品质变化),
* 输出变化集
* 入口参数:cov - 输出(可为 NULL = 只刷快照不取变化)
* 函数说明:首次调用只建快照、变化集全 0(突发由初始化/总召覆盖)。
* 死区 = 点表内置绝对值(§1.5 运行参数 8020H 段本期取内置默认)。
* 函数说明:遥测判据(「数据可信」卡 9,对标参考系统 CalfYcMea 死区 2 点确认,
* 短板评测口径):**连续 2 个新测量点** |v - 上次上送值| > 死区才置变化位
* (单点尖峰不确认——噪声回摆即撤销候选);品质位(线上 qds)相对上次
* 上送值翻转 → 当拍直接置位(不等 2 点,有效性变化必须即时可见,
* 含 0x401d 的 IV 进出)。死区 = 运行时副本(ROM 默认 = 点表列,
* iec104_pts_yc_set_deadband() 覆盖——卡 10 运行参数段的接线点)。
* 测量点定义:板端默认源 = **快照换帧**(meas seq 前进;100 ms poll 对
* 200 ms 快照,同一帧重复比对会计数两次 → seq 去重是确认语义成立的
* 前提);注入源(单测假源)= 每次调用一测点。首次读到有效值只建基线
* 不上报(突发由总召覆盖)。
*/
void iec104_pts_cov_check(iec104_pts_cov_t *cov);
+41 -17
View File
@@ -10,10 +10,10 @@
* v1.1 2026-08-28 王建锋 改为 TCP Server,迁入独立任务 timeSyncTask,
* 所有 socket 操作经 net_socket 线程安全 API 走消息队列,
* netTask 仅负责 net_poll/net_process_messages,多任务并发安全
* v1.2 2026-10-02 王建锋 round 8b 消抖:等待片长 200ms→50ms,并把各处长
* v1.2 2026-10-02 王建锋 第 8b 轮消抖:等待片长 200ms→50ms,并把各处长
* 等待(500/2000/200ms)统一走 time_sync_delay_fed()
* 按 ≤50ms 分片、每片喂心跳——消除"心跳滑出两个连续
* 100ms 抽样窗"的节拍抖动(round 7 台架 snap=0x17)
* 100ms 抽样窗"的节拍抖动(第 7 轮台架 snap=0x17)
*/
#include <string.h>
@@ -33,10 +33,10 @@
#define TIME_SYNC_PORT 8888
/*
* E-02 头阻塞修复 + L3 pending 化(timeSync 侧;lftpd 侧见 lftpd_inet.c):
* 头阻塞修复 + L3 pending 化(timeSync 侧;lftpd 侧见 lftpd_inet.c):
* 旧实现用单次 30s 阻塞 net_recv(默认 NET_RECV_TIMEOUT_MS)等时间包——等待期间
* netTask(全部 socket 操作的唯一执行者)被独占,其余任务的网络操作(远端显示 /
* FTP)整体停摆(docs/问题记录/错误码传播审计.md E-02)。
* FTP)整体停摆(历史审计遗留问题)。
* 2026-09-22 先落地分片有界读(5ms 片),同日 L3 pending 化后改为"挂起请求":
* 每次等待 = 一次挂起登记(netTask 零占用,完成按主循环节拍检查),调用方
* 单次超时 = 剩余总预算(总等待 ≤ TIME_SYNC_TOTAL_BUDGET_MS,语义与旧 30s
@@ -47,7 +47,7 @@
/* 看门狗分片等待:单次 net_recv_to 的等待片长上限。须显著小于看门狗抽样窗
* (task_watchdog.c WD_POLL_PERIOD_MS=100ms)——片长 ≤50ms 时每片前喂的心跳
* 必然落在同一个 100ms 抽样窗内,消除"心跳恰好滑过两个连续窗口"的边界抖动
* (round 7 台架稳态 snap=0x17 即抽样窗 vs 心跳节拍的良性错位,round 8b 消抖)。
* (第 7 轮台架稳态 snap=0x17 即抽样窗 vs 心跳节拍的良性错位,第 8b 轮消抖)。
* 语义上仅把一次长等待拆成多次短等待,总预算不变。 */
#define TIME_SYNC_WAIT_SLICE_MS 50U
@@ -59,7 +59,7 @@
* 函数说明:看门狗抽样窗为 100ms(task_watchdog.c WD_POLL_PERIOD_MS)。把任意长
* 等待切成 ≤TIME_SYNC_WAIT_SLICE_MS 的短片、每片前喂心跳,使心跳必然
* 落在单个抽样窗内——语义等价于一次长延时,仅把"心跳节奏"钉在窗内
* (round 8b 消抖;不改变总延时预算)。
* (第 8b 轮消抖;不改变总延时预算)。
*/
static void time_sync_delay_fed(uint32_t ms) {
while (ms > 0U) {
@@ -124,9 +124,11 @@ static int time_sync_open_listener(void) {
* 入口参数:conn_sock - 已建立的连接 Socket 描述符(单连接模式下即监听 Socket 自身)
* 返回值:无
* 函数说明:pending 读取 PC 发来的时间包("TIME"+uint32 大端 本地时间),解析后
* 写回 RTC,然后关闭连接。每次等待 = 一次挂起请求(netTask 零占用,
* L3 pending 化),单次超时 = 剩余总预算(≤30s,语义与旧 30s 阻塞一致);
* 跨调用累积,容忍时间包被 TCP 分段送达
* 写回 RTC。**不在此 close**(单连接模式下 conn_sock 即
* 监听 fd,close 连监听一起毁、重建窗 ~0.3-1s;net 层 auto_relisten
* 已把同 fd 收回监听,外层直接续 accept——lftpd 样板)。每次等待 =
* 一次挂起请求(netTask 零占用),单次超时 = 剩余总预算
* (≤30s,语义与旧 30s 阻塞一致);跨调用累积,容忍时间包被 TCP 分段送达
*/
static void time_sync_handle_conn(int conn_sock) {
uint8_t buf[16];
@@ -177,9 +179,18 @@ static void time_sync_handle_conn(int conn_sock) {
sys_clock_set_unix(epoch);
DBG_INFO("time_sync: wall clock updated (epoch=%lu)", (unsigned long)epoch);
}
} else {
/* 头阻塞收口(docs/网络/网络自愈架构设计.md §5.2 E13):本层放弃会话 → 主动拆除。
* 挂起但 TCP 仍活的客户端(PC 内核自动 ACK keepalive,芯片僵尸判不死)
* 会永久占住单连接监听;EOF 路径 drop 幂等返 0(net 层已 relisten)。
* 模拟器多连接后端 conn≠ls,由 sim 实现按数据 fd 关闭。 */
(void)net_drop_conn(conn_sock);
}
net_close(conn_sock);
/* 原 net_close(conn_sock) 已删——单连接模式 conn==监听 fd,
* close 毁监听(AGENTS 模块约定:EOF 时应用层不得 close);net 层
* auto_relisten 已把同 fd 收回 LISTENING,外层循环直接续 accept。
* 模拟器多连接后端 conn≠ls,由调用方(外层)负责关数据 fd。 */
}
/*
@@ -187,8 +198,10 @@ static void time_sync_handle_conn(int conn_sock) {
* 入口参数:argument - 未使用
* 返回值:无
* 限定条件:net_init() 已由 netTask 完成;netTask 正在处理消息队列
* 函数说明:等待网络栈就绪后,循环:建监听 -> 等连接 -> 处理 -> 关闭 -> 重建监听。
* 全部 socket 操作经消息队列由 netTask 串行执行,与本任务/其它网络任务并发安全。
* 函数说明:等待网络栈就绪后,循环:建监听 -> 等连接 -> 处理 -> **同 fd 续
* accept**(会话收尾不 close,监听跨会话常驻,仅网络
* 代次变化时重建)。全部 socket 操作经消息队列由 netTask 串行执行,
* 与本任务/其它网络任务并发安全。
*/
void time_sync_task(void *argument) {
(void)argument;
@@ -213,6 +226,9 @@ void time_sync_task(void *argument) {
}
}
/* 监听 fd 跨会话常驻:仅首次/网络代次变化/被毁时重建 */
int ls = -1;
for (;;) {
/* 心跳:本任务在 required_mask 内。以下每一处可能长于 100ms 抽样窗的
* 阻塞点(监听重试 2s、accept 轮询 200ms/次、收尾 50ms)都就近补心跳。 */
@@ -221,11 +237,13 @@ void time_sync_task(void *argument) {
/* 记录本次监听对应的网络代次;代次变化(网络重建)则放弃旧监听重建 */
uint32_t gen = net_get_generation();
int ls = time_sync_open_listener();
if (ls < 0) {
DBG_ERROR("time_sync: open listener FAIL, retry later");
time_sync_delay_fed(2000U);
continue;
ls = time_sync_open_listener();
if (ls < 0) {
DBG_ERROR("time_sync: open listener FAIL, retry later");
time_sync_delay_fed(2000U);
continue;
}
}
/* 等待 PC 连接:net_accept 在无连接时立即返回 -1,循环重试同一监听 Socket */
@@ -235,6 +253,7 @@ void time_sync_task(void *argument) {
if (conn < 0) {
if (net_get_generation() != gen) {
net_close(ls); /* 网络重建:旧监听失效,回外层重建 */
ls = -1;
break;
}
time_sync_delay_fed(200U);
@@ -247,7 +266,12 @@ void time_sync_task(void *argument) {
}
time_sync_handle_conn(conn);
/* conn(单连接模式下即 ls)已被 net_close 释放,下次循环重建监听 */
/* 会话收尾不 close(单连接模式 conn==ls,close 连监听
* 一起毁);net 层 auto_relisten 已把同 fd 收回 LISTENING,下轮循环
* 直接续 accept。模拟器多连接后端(conn≠ls)才关数据 fd。 */
if (conn != ls) {
net_close(conn);
}
time_sync_delay_fed(50U);
}
+202 -49
View File
@@ -7,15 +7,15 @@
* 状态说明:**B1 已落地(2026-10-10)**——本任务从空桩转为真实消费者:
* 用 tpafe5160_snapshot() 原子取数(AGENTS 铁律),统计帧数/无新帧次数,
* 并纳入看门狗 required_mask(TASK_WD_ADC)。
* **采样节拍(v2.2,2026-10-10)**:由 **TIM3 更新中断(1 kHz)** 硬件触发——
* **采样节拍**:由 **TIM3 更新中断(1 kHz)** 硬件触发——
* ISR 内调 tpafe5160_start_conv_irq()(Src/main.c 的
* HAL_TIM_PeriodElapsedCallback);本任务**不再自行触发**,退化为**纯消费者**。
* **消费唤醒(v2.7,2026-10-10,Task #32 P1-5 方案 a)**:由 1 ms `osDelayUntil`
* **消费唤醒**:由 1 ms `osDelayUntil`
* 轮询改为 **TIM3 ISR 置位 `ADC_WAKE_FLAG` 的事件驱动**——唤醒与生产同源锁定,
* 消除 0~1 ms 的 tick 量化相位抖动与"超拍永久落后 → 空转"风险;本任务优先级
* 同步由 Normal 提到 **AboveNormal**(唤醒即抢占)。详见
* docs/子系统设计/ADC采样节拍设计说明.md §10。
* **L1 采集层(v2.3,2026-10-10)**:取数改走 App/algo/adc_input.c——
* docs/子系统设计/ADC采样节拍设计说明.md §6。
* **L1 采集层**:取数改走 App/algo/adc_input.c——
* 本任务每拍调 adc_input_poll()(TPAFE 快照 + STM32 ADC1 9 通道扫描),
* 经 adc_input_raw() **按语义**读通道(L1-3/L1-4),不再直接触碰驱动缓冲。
* V0.1 上只有 TPAFE 三路(UA/UN/IA)测量有效;本任务只取数,不做保护运算。
@@ -28,7 +28,7 @@
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-05 王建锋 P3-11:注明"预留桩(未启用)",避免误认为已实现
* v1.1 2026-10-05 王建锋 注明"预留桩(未启用)",避免误认为已实现
* v2.0 2026-10-10 王建锋 B1:实现真实消费者(1ms 节拍 + 原子快照 + 帧统计 +
* 看门狗心跳),由 Src/freertos.c 创建为 adcTask
* v2.1 2026-10-10 王建锋 日志门控:首帧/周期统计仅在 dbg_console_writer_ready()
@@ -49,25 +49,25 @@
* ⚠ L1-5 通道标定未实现(缺标准源)→ 暂以**原始码值**
* 充作工程量(系数 = 1),故相量读数只证明**链路通**,
* 不可作幅值精度判据(同 L1-3 的替代口径)。
* v2.5 2026-10-10 王建锋 Task #32 的 P2 测量埋点:单拍加常开耗时探针
* v2.5 2026-10-10 王建锋 常开耗时测量埋点:单拍加探针
* (task_prof ADC_BEAT),用于判定本任务是否超拍
* (名义节拍 1 ms = 168000 周期)而挤占低优先级任务
* v2.6 2026-10-10 王建锋 Task #32 的 P0 修复(两处):
* §5 无新帧仍压环 → 改为**仅 r == 1 时**压环 + 重算
* v2.6 2026-10-10 王建锋 两处正确性修复:
* 无新帧仍压环 → 改为**仅 r == 1 时**压环 + 重算
* (消除重复采样污染 20 点 DFT 窗的系统性误差);
* §4.3 osDelayUntil 返回值被丢弃 → 落后时一次性重同步
* osDelayUntil 返回值被丢弃 → 落后时一次性重同步
* (原写法会永久落后 → 不再阻塞 → 空转抢占 uiTask)
* v2.7 2026-10-10 王建锋 Task #32 P1-5 方案 a(事件驱动消费):
* v2.7 2026-10-10 王建锋 事件驱动消费改造:
* 消费唤醒 osDelayUntil(1ms) → osThreadFlagsWait(ADC_WAKE_FLAG,
* osWaitForever),标志由 TIM3 ISR 经 adc_tick_irq_notify() 置位;
* 删除 ADC_TASK_BEAT_MS 与 next(P0-2 的重同步因此不再需要);
* 删除 ADC_TASK_BEAT_MS 与 next(上一轮加的重同步钩子因此不再需要);
* 新增"消费者就绪"门 s_consumer_ready——TIM3 在 app_main_init()
* 就启动,**远早于** MX_FREERTOS_Init()/osKernelStart(),
* ISR 不得在此之前对内核动手(同 Src/main.c 里 TIM7 的
* 2026-10-02 实测坑)。优先级 Normal → AboveNormal(见 freertos.c)
* v2.8 2026-10-10 王建锋 节拍源 TIM6 → **TIM3**(F4 上 ADC1 的外部触发没有
* TIM6 TRGO,只有 TIM2/TIM3/TIM8 系 → 换 TIM3 才能让
* 两片转换器由同一硬件事件驱动);STM32 侧改 DMA 双缓冲
* v2.8 2026-10-10 王建锋 节拍源换 TIM3(F4 上 ADC1 收不到 TIM6/TIM7 的
* TRGO,换源判据见 docs/子系统设计/ADC采样节拍设计说明.md
* §3.2);STM32 侧改 DMA 双缓冲
* 消费,adc_input_poll() 不再忙等;心跳日志增
* adc1_seq / adc1_hw(adc1_err 语义改为"本拍无新帧")
* v2.9 2026-10-11 王建锋 L1-6 采样有效性校验:每拍调 adc_health_check()
@@ -75,28 +75,52 @@
* 单调性/错误计数增量)——严重事件与故障电平上升沿
* 打 DBG_ERROR + 每类每次开机一次 SYSLOG_SELFTEST;
* 恢复打 DBG_INFO;心跳日志增 tgap(TPAFE 侧丢帧,
* 与 adc1_gap 对称)。判定不在本文件(A-4:算法与
* 与 adc1_gap 对称)。判定不在本文件(算法与
* I/O 分离),本文件只做"消费计数 → 告警"的编排
* v3.0 2026-10-21 王建锋 修复"任务初始化入口从未被调用":adc_task_func 进入
* 循环之前先调 adc_task_init()。原实现下判定层状态靠
* .bss 清零 + 首拍建基线兜住,功能没错,但函数头写的
* "限定条件:adc_task_init() 已调用"是假的,会误导后来
* 者;该入口同时不再打日志——它落在本任务静态最坏链上,
* 每条 DBG_* 约 216 B 栈,而这一行毫无诊断价值(本任务
* 存活由首帧日志与 TASK_WD_ADC 心跳证明)
* v3.1 2026-10-25 DTU 项目组 L1.5 折算 + L5-2 贴轨观测(「数据可信」卡 2,道A):
* frame[s] 由 (float)code 改 adc_cal_apply()——工程量 =
* (code+b)×k,现口径 = ADC 引脚电压 V(eng=0,卡 1 冻结的
* ROM 系数,docs/硬件平台/ADC通道映射说明.md §7.1);同拍
* 经 adc_health_rail_watch() 逐通道贴轨观察(L5-2 残余,
* 门限 adc_cal_rail_ths,码值域与标定正交),锁存边沿 →
* DBG_ERROR + 每开机一次 SYSLOG;心跳增 cal: 行(mV 定点
* + valid/clipped 位图 + rail 锁存),台架注入口径见该行判读
* v3.2 2026-10-25 DTU 项目组 L2 遥测快照发布(「数据可信」卡 8,道D,M5):新模块
* App/algo/meas.c——本任务为唯一发布者,新帧拍内
* freq_run() 之后按 200 ms **tick 经过**分频调
* meas_tick()(全仓调用点 = 1,评审判据);品质注入源 =
* adc_cal_flags() 削顶位图,频率 = 同拍 freq 读数;心跳新增
* meas: 行(seq/v/ua·un·ia_mv/f/fv),读数比对类判据待台架实测
*/
#include "adc_task.h"
#include "cmsis_os.h"
#include "task_handles.h" /* adcTaskHandle(原为本文件局部 extern) */
#include "adc_input.h" /* L1 采集层:adc_input_poll()/adc_input_raw()(按语义取数) */
#include "sample_ring.h" /* L2 输入:sample_ring_push_frame()(采样环缓冲) */
#include "phasor.h" /* L2 相量层:phasor_run()/phasor_get()(全周傅氏) */
#include "freq.h" /* L2 派生量:freq_run()/freq_get()(测频,B8) */
#include "adc_health.h" /* L1-6:采样有效性校验(静默/丢帧/单调性/错误增量) */
#include "adc_health.h" /* L1-6:采样有效性校验(静默/丢帧/单调性/错误增量/贴轨) */
#include "adc_cal.h" /* L1.5:通道标定折算(「数据可信」卡 2;flags 供 cal 心跳行) */
#include "meas.h" /* L2 发布侧:遥测快照 meas_tick()(「数据可信」卡 8,道D) */
#include "tpafe5160.h"
#include "task_watchdog.h"
#include "console_log.h"
#include "sys_log.h" /* SYSLOG_SELFTEST:采样故障留痕(仅故障才写) */
#include "task_prof.h" /* P2 常开探针:单拍耗时(Task #32) */
#include "task_prof.h" /* 常开耗时探针:单拍耗时 */
#define DBG_TAG "[ADC_TASK]"
#include "dbg_log.h"
/* 心跳日志周期(ms):周期性上报帧统计,作为采集链路存活的观测面。
* (原 ADC_TASK_BEAT_MS = 1U 的消费轮询周期已随 v2.7 事件驱动改造删除——
* (原 ADC_TASK_BEAT_MS = 1U 的消费轮询周期已随事件驱动改造删除——
* 消费节拍改由 TIM3 ISR 置位 ADC_WAKE_FLAG 提供,与生产同源,不再自定周期) */
#define ADC_TASK_REPORT_MS 10000U
@@ -114,8 +138,17 @@ static uint32_t s_frames_skip;
static uint32_t s_health_prev;
static uint32_t s_health_logged;
/* adcTask 的内核句柄,定义在 Src/freertos.c(引用方式同 App/task/default_task.c) */
extern osThreadId_t adcTaskHandle;
/* L5-2 贴轨编排状态(锁存本体在 adc_health 内,这里只留编排层视角):
* s_rail_edge_latched - 本开机以来出现过锁存边沿的通道位图(心跳 cal: 行展示)
* s_rail_syslogged - SYSLOG 留痕只写一次的闸门(init 重置) */
static uint32_t s_rail_edge_latched;
static uint8_t s_rail_syslogged;
/* 遥测快照发布闸门(「数据可信」卡 8):上次发布拍时基。分频用 **tick 经过**
* 对比 MEAS_PERIOD_MS,非拍计数——idle/skip 拍会把拍计数口径拉得超过名义
* 200 ms(与 v2.6 修"重复采样污染 DFT 窗"同族误差,勿"优化"成拍计数) */
static uint32_t s_meas_ms;
/* 消费者就绪门:由 adcTask 自己在进入等待前置 1;单写者 = 本任务,ISR 只读。
* ⚠ 为什么必须有这道门:TIM3 在 app_main_init()(Src/main.c:140)里就
@@ -135,8 +168,8 @@ static volatile uint8_t s_consumer_ready;
* 返 回 值:无
* 限定条件:仅 ISR 上下文。TIM3 抢占优先级 = 6 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY
* (=5) → osThreadFlagsSet 走 xTaskNotifyFromISR + portYIELD_FROM_ISR,合法且立即切换
* 函数说明:Task #32 P1-5 方案 a——ISR **只发唤醒**,全部计算留在任务里(拒绝"ISR 内计算":
* adc_input_poll() 内含 2 ms 忙等,绝不可入 ISR)。线程标志粘滞 → 单拍偶超 1 ms 时
* 函数说明:ISR **只发唤醒**,全部计算留在任务里——"ISR 内计算"是反模式:取数与相量
* 计算都读写任务级共享数据,且本回调属 CubeMX 生成区,重生成即丢。线程标志粘滞 → 单拍偶超 1 ms 时
* 多个脉冲合并成一次唤醒,下一轮 adc_input_poll() 返回 r==0 记 idle,不会重复处理同一帧
*/
void adc_tick_irq_notify(void) {
@@ -153,7 +186,9 @@ void adc_tick_irq_notify(void) {
* 限定条件:仅任务上下文;调用方须保证 dbg_console_writer_ready() 为真
* 函数说明:MicroLIB 的 snprintf **不支持 %f** → 以 ×0.1 定点整数打印
* (打印值 = 实际值 × 10,分辨率 0.1)。逐实例数据驱动输出,
* 实例表加行即自动覆盖。⚠ L1-5 标定未实现 → 读数单位是**原始码值**。
* 实例表加行即自动覆盖。输入自 v3.1 起为 L1.5 折算后的工程量
* (现口径 = ADC 引脚电压 V,见 cal: 心跳行与文件头 v3.1 记录);
* 二次值语义仅在 adc_cal_flags() 出 VALID 后成立。
*/
static void log_phasors(const char *tag) {
uint8_t k;
@@ -230,14 +265,21 @@ static void adc_health_report(const adc_health_status_t *hs) {
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:tpafe5160_init() 已在 app_main_init()(内核启动前)完成
* 函数说明:驱动初始化不在本任务(见限定条件);此处仅清帧统计。
* 函数说明:驱动初始化不在本任务(见限定条件);此处清帧统计 + 清判定层参照系
* (adc_health_init())。由 adc_task_func 进入循环之前调用一次;幂等,
* 可重复调用(运行期要重建判定参照系,走的也是这里)。
* ⚠ 本函数内禁止加日志:它在 adcTask 的静态最坏调用链上,每条 DBG_*
* 约 216 B 栈,而本任务栈余量按 1024 B ISR 底线核算。
*/
int adc_task_init(void) {
s_frames_consumed = 0u;
s_frames_idle = 0u;
s_frames_skip = 0u;
adc_health_init();
DBG_INFO("adc_task_init");
s_rail_edge_latched = 0u;
s_rail_syslogged = 0u;
s_meas_ms = 0u;
adc_health_init(); /* 内含 L5-2 贴轨观察器复位(rail_init) */
(void)meas_init(); /* 遥测快照双区/序号复位(幂等;发布状态属本任务,init 无日志) */
return 0;
}
@@ -245,10 +287,11 @@ int adc_task_init(void) {
* 函数功能:ADC 任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
* 返 回 值:无
* 限定条件:adc_task_init() 已调用;内核已启动
* 限定条件:内核已启动。本函数自己在进入循环前调 adc_task_init()——不依赖外部
* 调用(历史上该入口全库无调用点,判定层状态只靠 .bss 清零兜住)
* 函数说明:每拍采集一次(adc_input_poll():TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 乒乓缓冲)
* 并统计。采样节拍由 TIM3 硬件定时器提供(ISR 内触发 CONVST),本任务不再触发;
* **本任务的唤醒同样由该 TIM3 ISR 置位 ADC_WAKE_FLAG 驱动**(v2.7 事件驱动,
* **本任务的唤醒同样由该 TIM3 ISR 置位 ADC_WAKE_FLAG 驱动**(事件驱动,
* 不再自定 1 ms 周期)。稳态下 consumed 每拍 +1、idle 不再增长;idle 持续增长 =
* EXTI 未在读帧或 TIM3 未启动(异常),由周期心跳日志暴露。
* ⚠ 若 TIM3 停摆,本任务会**永久阻塞**在 osThreadFlagsWait → TASK_WD_ADC 缺席 →
@@ -261,6 +304,10 @@ void adc_task_func(void *arg) {
(void)arg;
/* 任务自有状态清零(含判定层参照系)。放在开闸之前:此刻 ISR 还不会唤醒
* 本任务,init 与首拍之间不存在"用半初始化状态做判定"的窗口 */
(void)adc_task_init();
/* 开闸:此刻任务已存在、调度器必已运行 → ISR 的唤醒从此生效。
* 放在进入循环之前:置位与等待之间不会漏唤醒(线程标志粘滞) */
s_consumer_ready = 1u;
@@ -269,8 +316,8 @@ void adc_task_func(void *arg) {
int r;
uint32_t now = osKernelGetTickCount(); /* 本拍时基:健康判定 + 心跳共用一份 */
/* P2 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测**持续**超过,
* 本任务(v2.7 起为 osPriorityAboveNormal)会持续抢占 uiTask
/* 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测**持续**超过,
* 本任务(osPriorityAboveNormal)会持续抢占 uiTask
* (osPriorityBelowNormal),是"UI 心跳缺席"的候选占用者之一 */
task_prof_enter(TASK_PROF_ADC_BEAT);
@@ -278,9 +325,8 @@ void adc_task_func(void *arg) {
* DMA 双缓冲(仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。
* 采样节拍由 TIM3 更新事件提供(ISR 内触发 CONVST + TRGO 触发 ADC1),
* 本任务既不触发也不等待转换。
* P2 探针:本段单独计时。v2.0(DMA)起本段**零忙等**——原轮询版常态
* adcp=122 us、超时拍最坏 2~3 ms(HAL_ADC_PollForConversion 是不让出的
* 忙等),现在两档都降到 us 量级(只剩 TPAFE 快照临界区 + 拷 9 个半字)。
* 探针:本段单独计时。本段**零忙等**(转换由 TIM3 TRGO 触发、DMA 搬运,
* 不再有逐通道轮询超时),只剩 TPAFE 快照临界区 + 拷 9 个半字 → us 量级。
* ⚠ adcm(整拍)的 ~2.1 ms 峰值来自每 10 s 一次的心跳日志,不是本段,勿混读 */
task_prof_enter(TASK_PROF_ADC_POLL);
r = adc_input_poll();
@@ -296,31 +342,45 @@ void adc_task_func(void *arg) {
/* 2) L2 输入 + 相量重算:把本拍 9 路语义值转浮点帧压入采样环,再重算全部
* 相量实例(L2-6 滑动窗:每拍以"当前时刻为终止的前一周波"整窗重算)。
*
* ⚠ P0 修复(2026-10-10,Task #32 §5):**仅在本拍确有新帧(r == 1)时**才压环。
* ⚠ **仅在本拍确有新帧(r == 1)时**才压环。
* 原实现在 r == 0(TPAFE 无新帧)时**仍然**压环 + phasor_run(),只有 r == -1
* 才跳过 —— 于是把**上一拍的重复采样值**塞进 20 点 DFT 窗,等效采样间隔被拉长。
* 证据是日志里的恒等式 `ring = frames + idle`(09:36:21: 9957+44=10001 ✓;
* 09:36:31: 19352+649=20001 ✓)。本例 idle 占 3.2% → 窗长从 20.0 ms 漂到约
* 20.6 ms → 对 50 Hz 信号相当于约 3% 的频率失配 → 幅值/相位**系统性**误差
* (不是随机噪声)。修好后 `ring` 只在真实新帧上增长(ring == frames)。
* 量级参考(台架曾测得 idle 占 3.2%):窗长从 20.0 ms 漂到约 20.6 ms → 对 50 Hz
* 相当于约 3% 频率失配 → 幅值/相位**系统性**误差(不是随机噪声)。
* 回归判据 = 恒等式 `ring == frames`(只有真新帧才入环)。
*
* 任一通道读不到(尚未首次成功)→ 本拍**整帧跳过**:不向窗内注入 0 点
* (单个 0 点会显著污染 20 点窗),并计入 skip 观测。 */
if (r == 1) {
float frame[ADC_SEM_COUNT];
uint8_t s;
uint8_t all_ok = 1u;
float frame[ADC_SEM_COUNT];
uint8_t s;
uint8_t all_ok = 1u;
uint32_t rail_edge = 0u;
for (s = 0u; s < (uint8_t)ADC_SEM_COUNT; s++) {
int32_t code = 0;
int32_t code = 0;
int32_t lo_th = 0;
int32_t hi_th = 0;
if (adc_input_raw((adc_semantic_t)s, &code) != 0) {
all_ok = 0u;
break;
}
/* ⚠ L1-5 通道标定尚未实现(缺标准源)→ 暂以**原始码值**充作工程量
* (系数 = 1)。标定落地后此处改为 工程量 = (code + b) × k(定点 2^10)。 */
frame[s] = (float)code;
/* L1.5 折算(「数据可信」卡 2,§4.1 模型):工程量 = (code + b) × k。
* 现口径 = **ADC 引脚电压 V**(eng=0,卡 1 冻结的 ROM 系数,见
* docs/硬件平台/ADC通道映射说明.md §7.1);卡 0b/卡 12 写入标定
* 系数(adc_cal_flags() 出 VALID)后才按二次值消费。TEMP 透传码值
* 不折算(决策 5)。*/
frame[s] = adc_cal_apply((adc_semantic_t)s, code);
/* L5-2 码值越界(贴轨)观察:在**码值域**判定,与标定正交——
* 连续一周波贴轨带且此前见过释放沿才锁存(空载恒 0 不误报)。
* TEMP 无「轨」语义,adc_cal_rail_ths() 返 -1 自然跳过。*/
if (adc_cal_rail_ths((adc_semantic_t)s, &lo_th, &hi_th) == 0) {
if (adc_health_rail_watch(s, code, lo_th, hi_th) != 0u) {
rail_edge |= (1uL << s);
}
}
}
if (all_ok != 0u) {
@@ -329,9 +389,43 @@ void adc_task_func(void *arg) {
/* L2 派生量测频(B8):同拍紧随 phasor_run——本拍使用区已
* 切换,freq 读的就是本拍相量(1 ms 同上下文,无竞态) */
freq_run();
/* L2 遥测快照发布(「数据可信」卡 8,道D,M5):200 ms tick 经过分频。
* 唯一发布者 = 本任务(全仓 meas_tick 调用点数 = 1 为评审判据,
* 「数据可信」§8 风险表"快照双写"行);只在**压环成功的真新帧拍**发布
* ——idle/skip 拍压环未推进,发布 = 重复旧窗。品质注入唯一源 =
* adc_cal_flags()(§4.2 三态表;纯读,每 200 ms 才跑一遍)。 */
if ((uint32_t)(now - s_meas_ms) >= MEAS_PERIOD_MS) {
uint32_t cm = 0u;
uint8_t m;
s_meas_ms = now;
for (m = 0u; m < (uint8_t)ADC_SEM_COUNT; m++) {
if ((adc_cal_flags((adc_semantic_t)m) & ADC_CAL_F_CLIPPED) != 0u) {
cm |= (1uL << m);
}
}
meas_tick(now, freq_valid(), freq_last(), cm);
}
} else {
s_frames_skip++;
}
/* 贴轨锁存边沿留痕:帧循环后统一处理(skip 拍也已在循环内逐通道
* 观察过,不因取数半途失败而漏报已观察的通道)。SYSLOG 每开机只
* 留痕一次——贴轨是持续工况,逐拍写会把 selftest.log 刷满 */
if (rail_edge != 0u) {
s_rail_edge_latched |= rail_edge;
if (dbg_console_writer_ready() != 0u) {
DBG_ERROR("rail: edge=0x%lx sticky=0x%lx (code-domain, 20-run+release)",
(unsigned long)rail_edge,
(unsigned long)adc_health_rail_sticky());
}
if (s_rail_syslogged == 0u) {
s_rail_syslogged = 1u;
(void)syslog_post(SYSLOG_SELFTEST, "adc code rail fault");
}
}
}
/* 2b) L1-6 采样有效性校验(每拍,常数次整数运算):把驱动侧观测计数交给
@@ -398,7 +492,7 @@ void adc_task_func(void *arg) {
{
if ((int32_t)(now - report_at) >= 0) {
if (dbg_console_writer_ready() != 0u) {
/* STM32 侧观测量(v2.0 DMA 版),四类各有独立成因:
/* STM32 侧观测量(DMA 版),四类各有独立成因:
* adc1_rc = 最近一拍取帧状态(0 OK / -2 本拍无新帧)
* adc1_seq = DMA 整帧完成计数(正常时 ≈ seq,恒定小偏移)
* adc1_gap = 丢帧:seq 一次前进 >1 → 本任务超拍(单拍 > 1 ms)
@@ -424,21 +518,80 @@ void adc_task_func(void *arg) {
(unsigned long)adc_health_tpafe_gaps());
log_phasors("hb");
/* L1.5/L5-2 台架观测面(×1000 mV 定点,MicroLIB 无 %f):
* ua/un/ia_mv = 三路 TPAFE 引脚电压毫伏(未标定域,**不是**
* 二次值;卡 12 注入验证的读数落点,偏差判据 ≤2 %)
* vl = VALID 位图(默认构建应恒 0x00——未标定即未标定,
* 卡 5 的闭锁判据在 protect 侧同源消费这个位)
* cl = CLIPPED 位图(V0.1 应恒 0xE6 = UB/UC/IB/IC/IN)
* rail/edge = 贴轨锁存/历史边沿位图(注入饱和电平后应起)*/
{
int32_t c = 0;
long ua_mv = 0L, un_mv = 0L, ia_mv = 0L;
uint32_t vl = 0u, cl = 0u;
uint8_t i;
if (adc_input_raw(ADC_SEM_UA, &c) == 0) {
ua_mv = (long)(adc_cal_apply(ADC_SEM_UA, c) * 1000.0f);
}
if (adc_input_raw(ADC_SEM_UN, &c) == 0) {
un_mv = (long)(adc_cal_apply(ADC_SEM_UN, c) * 1000.0f);
}
if (adc_input_raw(ADC_SEM_IA, &c) == 0) {
ia_mv = (long)(adc_cal_apply(ADC_SEM_IA, c) * 1000.0f);
}
for (i = 0u; i < 8u; i++) {
const uint32_t f = adc_cal_flags((adc_semantic_t)i);
if ((f & ADC_CAL_F_VALID) != 0u) {
vl |= (1uL << i);
}
if ((f & ADC_CAL_F_CLIPPED) != 0u) {
cl |= (1uL << i);
}
}
DBG_INFO("cal: ua_mv=%ld un_mv=%ld ia_mv=%ld vl=0x%02lx cl=0x%02lx rail=0x%03lx edge=0x%03lx",
ua_mv, un_mv, ia_mv,
(unsigned long)vl, (unsigned long)cl,
(unsigned long)adc_health_rail_sticky(),
(unsigned long)s_rail_edge_latched);
}
/* M5 遥测快照台架观测面(定点打印,MicroLIB 无 %f):
* seq = 发布帧数(10 s 心跳间隔内应 +500±,不涨 = 发布闸门停)
* v = 整帧有效;ua/un/ia = 基波有效值毫伏(未标定域 = 引脚电压;
* 本行是 200 ms 快照轨,cal: 行是当拍瞬时码折算——同量级不同
* 相位,勿互为判据)
* f/fv = 频率读数与新鲜度(x0.01)
* 读数与注入源比对类判据 = **待台架实测**(实测采样类跳过口径) */
{
meas_snap_t ms;
(void)meas_get(&ms); /* 未就绪(-2) = 清零帧,以下打印全 0 */
DBG_INFO("meas: seq=%lu v=%u ua_mv=%ld un_mv=%ld ia_mv=%ld f=%ld fv=%u",
(unsigned long)ms.seq, (unsigned)ms.valid,
(long)(ms.yc[ADC_SEM_UA] * 1000.0f),
(long)(ms.yc[ADC_SEM_UN] * 1000.0f),
(long)(ms.yc[ADC_SEM_IA] * 1000.0f),
(long)(ms.freq_hz * 100.0f),
(unsigned)ms.freq_valid);
}
}
report_at = now + ADC_TASK_REPORT_MS;
}
}
/* 7) 等下一次节拍唤醒(v2.7 事件驱动,Task #32 P1-5 方案 a)
/* 7) 等下一次节拍唤醒(事件驱动)
* 原实现是 osDelayUntil(next += 1ms):把消费唤醒**钉在 FreeRTOS tick(1 ms)边界**
* → 与 TIM3 生产之间有 0~1 ms 随机相位抖动(日志里的 idle 正是这么来的),
* 且一旦超拍,next 就永久落后 → 任务不再阻塞 → 空转抢占低优先级 uiTask
* (P0-2 修的正是它)。现在唤醒与生产**同源**:TIM3 ISR 触发 CONVST 后置位
* (空转抢占低优先级任务正是这么发生的)。现在唤醒与生产**同源**:TIM3 ISR 触发 CONVST 后置位
* ADC_WAKE_FLAG,本任务被唤醒的延迟 = ISR + 调度(µs 级;本任务已提至
* AboveNormal → 立即抢占),无 tick 量化、无累积漂移,也就不再需要重同步钩子。
* 标志是命令位(等待即消费)→ osFlagsWaitAny,不加 osFlagsNoClear;
* osWaitForever 无超时 = 刻意的 fail-safe(TIM3 停摆 → 无心跳 → IWDG 复位)。 */
task_prof_exit(TASK_PROF_ADC_BEAT); /* P2 探针:单拍耗时结算(不含等待) */
task_prof_exit(TASK_PROF_ADC_BEAT); /* 探针:单拍耗时结算(不含等待) */
(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-10 王建锋 Task #32 P1-5 方案 a:新增 ADC_WAKE_FLAG 与
* v1.1 2026-10-10 王建锋 新增 ADC_WAKE_FLAG 与
* adc_tick_irq_notify()——消费节拍由 osDelayUntil(1ms)
* 轮询改为 TIM3 ISR 事件驱动唤醒
*/
@@ -21,7 +21,7 @@ extern "C" {
int adc_task_init(void);
void adc_task_func(void *arg);
/* Task #32 P1-5(方案 a,2026-10-10):事件驱动消费。
/* 事件驱动消费的唤醒标志。
* TIM3 1 kHz 更新中断触发 CONVST 后,用本标志唤醒 adcTask 消费一拍。
* 线程标志在此是**命令位**(等待即消费),不是状态位 → 用 osFlagsWaitAny,
* 不加 osFlagsNoClear(AGENTS「就绪屏障位必须 osFlagsNoClear」只约束状态位) */
+42 -40
View File
@@ -2,7 +2,7 @@
* 模块名称:Default Application Task
* 模块功能:主控任务(defaultTask)任务体实现:
* ① 启动段:存储 bring-up(挂载 + 空卷自动建卷 + 卷健康探针两击 +
* 自愈重格式化,E-31 失败后主循环 3×2s 有界重试)+ 网络配置加载
* 自愈重格式化,挂载失败后主循环 3×2s 有界重试)+ 网络配置加载
* + STORAGE_READY 屏障 + "保存网络配置"回调注册
* ② 控制台单写者:dbg_console_writer_attach 注册 + 主循环 dbg_console_drain
* 排空(串口唯一发送者,见 App/log/console_log.c 设计说明)
@@ -19,10 +19,13 @@
* FTP 文件操作与 syslog/配置的卷访问自此串行化
* (2026-10-02 台架:并发 FTL 写 → journal 竞争 →
* 本任务主循环喂狗点饥饿 → IWDG 误复位)
* v1.2 2026-10-10 王建锋 Task #32 的 P2 测量接线:主循环周期上报各任务最坏
* v1.2 2026-10-10 王建锋 任务耗时测量接线:主循环周期上报各任务最坏
* 耗时(task_prof_report),并在**心跳位图出现 1→0 边沿**
* 的瞬间留一份现场快照(task_prof_report_raw,裸 UART);
* 另给控制台排空与 syslog 落盘各加一个耗时探针
* v1.3 2026-10-25 DTU 项目组 写回缓存空闲 flush 接线:主循环 syslog 排空
* 之后调 vol_idle_sync_poll()(卷空闲周期 CTRL_SYNC 兜底,
* 模块 = App/fs/vol_idle_sync.[ch],道F 批 2)
*/
#include "default_task.h"
@@ -35,7 +38,9 @@
#include "nand_ftl.h"
#include "net_cfg.h"
#include "cfg_file.h"
#include "run_par.h" /* 运行参数段落盘(卡 10,配置引擎属主 = 本任务) */
#include "prot_set_store.h"
#include "prot_set.h" /* task-10:prot_set_dirty_peek(区分"无待写"与"真落盘") */
#include "sys_log.h"
#include "sys_events.h"
/* 深调用链模块(本任务 = 存储 bring-up + 配置引擎属主 + syslog 唯一写者,
@@ -44,29 +49,10 @@
#define DBG_LINE_BUF_MAX 128U
#include "dbg_log.h"
#include "task_watchdog.h"
#include "task_prof.h" /* P2 测量:各任务最坏耗时上报(Task #32) */
#include "task_prof.h" /* 各任务最坏耗时上报 */
#include "vol_lock.h"
/* 任务句柄与属性块(CubeMX 生成全局,定义于 Src/freertos.c):
* 栈水位观测需各任务句柄 + 属性块 stack_size(配置栈大小的唯一定义处) */
extern osThreadId_t defaultTaskHandle;
extern const osThreadAttr_t defaultTask_attributes;
extern osThreadId_t netTaskHandle;
extern const osThreadAttr_t netTask_attributes;
extern osThreadId_t ftpTaskHandle;
extern const osThreadAttr_t ftpTask_attributes;
extern osThreadId_t testTaskHandle;
extern const osThreadAttr_t testTask_attributes;
extern osThreadId_t uiTaskHandle;
extern const osThreadAttr_t uiTask_attributes;
extern osThreadId_t timeSyncTaskHandle;
extern const osThreadAttr_t timeSyncTask_attributes;
extern osThreadId_t adcTaskHandle;
extern const osThreadAttr_t adcTask_attributes;
extern osThreadId_t iec104TaskHandle;
extern const osThreadAttr_t iec104Task_attributes;
extern osThreadId_t protectTaskHandle;
extern const osThreadAttr_t protectTask_attributes;
#include "vol_idle_sync.h" /* 写回缓存空闲 flush 兜底(App/fs/vol_idle_sync.h) */
#include "task_handles.h" /* 任务句柄与属性块的引用契约(定义处仍是 Src/freertos.c) */
/* Private defines -----------------------------------------------------------*/
/* 栈水位观测节奏:首次报告须晚于 bring-up 深链(mount/mkfs/探针/cfg/syslog),
@@ -80,7 +66,7 @@ extern const osThreadAttr_t protectTask_attributes;
* 2026-10-12(B5):堆扩至 56KB(.ioc configTOTAL_HEAP_SIZE=57344,CCM 腾给
* protectTask/iec104Task 栈),阈值**不随堆缩放**——它是绝对字节安全线。 */
#define HEAP_LOW_WATER_B 3072u
/* E-31 自愈策略(2026-09-21 审计 P0 修复):启动期存储挂载失败后,
/* 存储自愈策略(2026-09-21 审计 P0 修复):启动期存储挂载失败后,
* 主循环按 2s 周期重跑完整 bring-up 的次数上限;耗尽则维持无存储降级
* (自检已上报、卷保留、不重格式化),等下次上电重新 resume */
#define STORAGE_BRINGUP_RETRIES 3u
@@ -121,7 +107,7 @@ static FRESULT storage_bring_up(FATFS *fs);
* 只在内核运行期被调度,启动段允许使用 CMSIS 对象
* 函数说明:启动段 = 存储 bring-up + 保存回调注册 + 控制台写者注册;
* 主循环 = 事件等待(配置保存/控制台待发,50ms 兜底)→ 控制台排空
* → 配置保存执行 → 2s 配置轮询(含 E-31 有界重试)→ LED 心跳
* → 配置保存执行 → 2s 配置轮询(含存储自愈有界重试)→ LED 心跳
* → syslog 落盘 → 栈水位观测
*/
void default_task_func(void *argument) {
@@ -136,7 +122,7 @@ void default_task_func(void *argument) {
* 完整 bring-up 序列(挂载 + 空卷自动建卷 + 健康探针 + 自愈重格式化
* + 日志挂载 + 配置加载 + 就绪屏障)实现于 storage_bring_up()。
*
* E-31(2026-09-21 审计,P0 修复):dhara_map_resume 失败不再被当
* 存储自愈(2026-09-21 审计,P0 修复):dhara_map_resume 失败不再被当
* "新卷"静默重格式化(扫描中任何一次瞬态毛刺都会销毁全部用户数据)——
* nand_ftl 仅把 DHARA_E_TOO_BAD(真空白)判为新卷,其余失败使
* disk_initialize 失败 → f_mount 失败 → 下方自检上报并保留卷;
@@ -146,7 +132,7 @@ void default_task_func(void *argument) {
* 小写两击确认、FR_INVALID_NAME 不入重格式化集、写保护类只上报,
* 详见该函数注释)。 */
#if !defined(TEST_SUITE_STORAGE)
/* E-31 自愈:存储 bring-up 剩余重试次数(0 = 已就绪或已耗尽);
/* 存储自愈:存储 bring-up 剩余重试次数(0 = 已就绪或已耗尽);
* 须在启动块之前声明(启动块失败路径写入、主循环读取) */
uint32_t storage_retry = 0u;
#endif
@@ -161,12 +147,12 @@ void default_task_func(void *argument) {
if (fr != FR_OK) {
/* 存储不可写:自检故障仅能滞留待发队列(串口已告警) */
syslog_post(SYSLOG_SELFTEST, "filesystem fault");
/* E-31 自愈:启动主循环中的有界重试(每 2s 一次) */
/* 存储自愈:启动主循环中的有界重试(每 2s 一次) */
storage_retry = STORAGE_BRINGUP_RETRIES;
}
/* 注册"保存网络配置"请求通知:UI(uiTask)投递 → 置事件 → 本任务立即执行写盘
* (配置引擎属主 = 本任务;UI 保存不再直接触碰配置文件引擎,P1-3) */
* (配置引擎属主 = 本任务;UI 保存不再直接触碰配置文件引擎) */
net_cfg_set_save_req_cb(default_task_net_cfg_save_notify);
#else
/* STORAGE 套件构建:挂载/配置引擎/日志盘写全部跳过,存储由 testTask 独占 */
@@ -192,14 +178,14 @@ void default_task_func(void *argument) {
task_wd_start();
for (;;) {
/* round 8 取证:进段打点——每步前记录段号到 .noinit 槽(复位不丢)。
/* 第 8 轮取证:进段打点——每步前记录段号到 .noinit 槽(复位不丢)。
* 误复位后开机 `PREVRUN seg=N` 即"上一轮主循环死在哪个调用点"
* (round 7 初判"poller 自身停摆"经 round 8b 更正:真因是 IWDG 超时被静默
* (第 7 轮初判"poller 自身停摆"经第 8b 轮更正:真因是 IWDG 超时被静默
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。 */
task_wd_seg(TASK_WD_SEG_POLL);
/* 心跳抽样与喂狗判定(内部节流至 100ms;全员到位才喂,否则累计停喂) */
task_wd_poll();
/* P2 测量(Task #32):某位由"到过"变为"缺席"的瞬间留一份现场快照。
/* 耗时测量:某位由"到过"变为"缺席"的瞬间留一份现场快照。
* 走裸 UART 直发——缺席窗口内环形缓冲可能积压,这两行必须出去。
*
* ⚠ 判据用**位图边沿**,不能用 miss 累计数:`task_wd_miss_count()` 返回的是
@@ -223,7 +209,7 @@ void default_task_func(void *argument) {
if (sys_evtHandle != NULL) {
(void)osEventFlagsWait(sys_evtHandle,
SYS_EVT_CFG_SAVE | SYS_EVT_CONSOLE_TX
| SYS_EVT_PROT_SET_SAVE,
| SYS_EVT_PROT_SET_SAVE | SYS_EVT_RUN_PAR_SAVE,
osFlagsWaitAny, 50u);
}
/* 控制台唯一发送点:把环形缓冲中的日志按块发出(无待发时立即返回) */
@@ -245,14 +231,25 @@ void default_task_func(void *argument) {
task_wd_seg(TASK_WD_SEG_CFGSAVE);
net_cfg_save_run();
/* 保护定值落盘(B7):内部消费脏标志,无改动 = 空操作;
* 写失败脏回置 → iec104Task 下轮 poll 再投递事件 = 天然重试 */
(void)prot_set_store_save();
* 写失败脏回置 → iec104Task 下轮 poll 再投递事件 = 天然重试。
* task-10「操作记录」写入点之一:⚠ 不能按 prot_set_store_save() 的返回值
* 记——它对"无待写"同样返 0,照返回值记就是每 50 ms 刷一条**没发生过**的
* 操作(正是"不许用静态演示数据充数"的反面)。先 peek 脏位(非消费,
* iec104_serv.c 通知属主用的就是同一枚),只有"确实有待写且这趟写成功"
* 才算一次整定操作。失败不记(判据 = 动作成功后写一条),脏位保留、
* 下轮重试成功后才留痕。 */
if ((prot_set_dirty_peek() != 0u) && (prot_set_store_save() == 0)) {
syslog_post(SYSLOG_OPER, "settings saved");
}
/* 运行参数段落盘(卡 10):同款脏语义;save = 引擎整档外科写
* ([run_par] 9 键 + 被 adc_cal_set 联动改写的 [adc_cal] 两段一趟落) */
(void)run_par_store_save();
#endif
/* 配置文件变更轮询(2s 周期;沉降去抖在引擎内) */
if ((cfg_poll_cnt++ % 40u) == 0u) {
#if !defined(TEST_SUITE_STORAGE)
task_wd_seg(TASK_WD_SEG_CFGPOLL);
/* E-31 自愈:启动期挂载失败时,每 2s 重跑完整 bring-up——
/* 存储自愈:启动期挂载失败时,每 2s 重跑完整 bring-up——
* 瞬态毛刺(如 dhara resume 扫描的瞬时 SPI 故障)通常第二次
* 即恢复;成功后由 storage_bring_up 补跑启动时跳过的
* "存储就绪"序列(日志挂载/配置加载/屏障置位,均幂等);
@@ -269,7 +266,7 @@ void default_task_func(void *argument) {
syslog_post(SYSLOG_SELFTEST, "filesystem fault");
}
}
/* P2-9:卷已就绪(STORAGE_READY)但日志后端未就绪(建 /log 曾失败,
/* 卷已就绪(STORAGE_READY)但日志后端未就绪(建 /log 曾失败,
* s_fs_ready 恒 0 → syslog_drain 永久 no-op)→ 2s 周期重试(幂等;
* 就绪后 syslog_mcu_mounted 立即返回,仅一次 volatile 读)。 */
if (sys_evtHandle != NULL
@@ -295,11 +292,16 @@ void default_task_func(void *argument) {
task_prof_enter(TASK_PROF_DEF_SYSLOG);
syslog_drain();
task_prof_exit(TASK_PROF_DEF_SYSLOG);
/* 写回缓存空闲 flush 兜底:卷空闲(try 锁)+ 周期节流 + 就绪门控全在
* 模块内(App/fs/vol_idle_sync.h);clean 态零 NAND 触点,脏态最坏 =
* 一次 f_close 同型的同步段。放在 drain 之后:本拍若写过日志,close
* 已同步过,这里随即 clean 短路——漏网脏页的驻留窗由周期封顶 */
vol_idle_sync_poll();
#endif
/* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */
task_wd_seg(TASK_WD_SEG_STACK);
stack_probe_poll();
/* P2 测量(Task #32):各任务最坏耗时 / 次数 / "仍在段内"快照。
/* 各任务最坏耗时 / 次数 / "仍在段内"快照上报。
* 内部节流(首次 5s,此后每 2s);判据:
* uisn 持续增长 → uiTask 卡在 ui_net_sink_poll 的往返里(阻塞)
* uipn 持续增长而 uisn 为 0 → 被调度饿死(本轮迟迟跑不完)
@@ -417,7 +419,7 @@ static void stack_probe_poll(void) {
* 下整体不参与编译(卷由 testTask 独占)
* 函数说明:
* 由 defaultTask 调用:启动期一次;若失败,主循环每 2s 重试至多
* STORAGE_BRINGUP_RETRIES 次(E-31 自愈策略,2026-09-21):dhara_map_resume
* STORAGE_BRINGUP_RETRIES 次(存储自愈策略,2026-09-21):dhara_map_resume
* 失败分类修复后(nand_ftl.c 仅真空白判新卷,其余失败保留卷并返回
* disk_initialize 失败),重试让瞬态毛刺(SPI 瞬时故障/扫描窗口抖动)
* 在数秒内免重启恢复,成功时补跑启动期跳过的就绪序列(日志挂载/配置
+71 -19
View File
@@ -1,22 +1,31 @@
/*
* 模块名称:IEC 60870-5-104 被控站服务(板端胶水任务)
* 模块功能:2404 监听 + 单连接 socket 收发 + 把字节流喂给 iec104_link 纯逻辑
* 层;对时经 sys_clock_set_unix 落墙钟;数据源走 iec104_pts 默认源
* 模块功能:监听端口来自 [comm] 段(工厂默认 2404)的 TCP server + 单连接
* socket 收发 + 把字节流喂给 iec104_link 纯逻辑层;对时经
* sys_clock_set_unix 落墙钟;数据源走 iec104_pts 默认源
* (phasor / prot_elm / prot_set)。协议语义全部在 App/protocol/iec104,
* 本文件只做 I/O 编排(A-4 约束 1)。
* 本文件只做 I/O 编排(分层约束)。
* 线程约定:iec104Task 独占(NET_READY 屏障后循环,100ms 节拍 + 看门狗心跳)。
* 创建日期:2026-10-12(B4)
* 修改记录:
* 2026-10-26 DTU 项目组 道G task-8:公共地址与监听端口取值来源改
* [comm] 段 getter(iec104_comm.h,改后重启生效;
* 端口另在下次重 bind 自然生效)。只改取值来源,
* 链路编排零改动。
*/
#include "iec104_serv.h"
#define DBG_LINE_BUF_MAX 128U /* 深链模块日志缓冲收紧(time_sync 先例) */
#define DBG_TAG "[I104]"
#include "iec104_comm.h" /* [comm] 段 getter:ca / 监听端口(道G task-8) */
#include "iec104_link.h"
#include "iec104_pts.h"
#include "net_socket.h"
#include "prot_set.h" /* 定值脏标志 peek(B7 落盘通知) */
#include "run_par.h" /* 运行参数脏 peek(卡 10 落盘通知,同款语义) */
#include "sys_clock.h"
#include "sys_log.h" /* task-19:对时成功落一条操作记录(SYSLOG_OPER) */
#include "sys_events.h"
#include "task_watchdog.h"
#include "dbg_log.h"
@@ -30,9 +39,9 @@
/* 接收搬运缓冲(静态,不入栈;link 层内部另有 512B 重组缓冲) */
#define IEC104_RX_SZ 128u
/* 兜底节拍:门控判"无数据"时每 32 轮无条件收一次(ui_net_sink RM 先例,
* 防 RECV_OK 中断丢失把数据永久藏住);须为 2 的幂 */
* 防 RECV_OK 事件未收到把数据永久藏住);须为 2 的幂 */
#define IEC104_LIVENESS_DIV 32u
/* 连续发送失败阈值:同 ui_net_sink E-04(短写=对端收坏帧) */
/* 连续发送失败阈值:同 ui_net_sink 先例(短写=对端收坏帧) */
#define IEC104_SEND_FAIL_MAX 3u
/* ---------------- 服务状态(iec104Task 独占) ---------------- */
@@ -50,15 +59,17 @@ static void s_send_apdu(void *ctx, const uint8_t *apdu, uint16_t len);
static int s_clock_sync(void *ctx, uint64_t epoch_ms);
static uint64_t s_epoch_ms(void *ctx);
/* V0.1 公共地址固定 1(点表口径;可配置项挂二期) */
static const iec104_link_cfg_t s_cfg = { 1u, s_send_apdu, s_clock_sync, s_epoch_ms };
/* 链路 cfg:ca 占位工厂默认,任务启动经 s_apply_comm_ca() 从 [comm] 段取真值
* (道G task-8;点表口径默认 1);非 const 即此一处写、其余只读 */
static iec104_link_cfg_t s_cfg = { IEC104_COMM_DEF_CA,
s_send_apdu, s_clock_sync, s_epoch_ms };
/*
* 函数功能:link 层发送回调——经 net_send 串行 RPC 发出一个 APDU
* 入口参数:ctx 未用;apdu/len = 完整 APCI 帧
* 返回值:无
* 限定条件:s_client >= 0(在 link 调用栈内执行,即本任务上下文)
* 函数说明:短写/失败累计(E-04 先例);连续 3 次 → 置 s_conn_dead,
* 函数说明:短写/失败累计(ui_net_sink 先例);连续 3 次 → 置 s_conn_dead,
* 由 s_service_link 在 link 调用返回后统一拆链(严禁在回调里拆)
*/
static void s_send_apdu(void *ctx, const uint8_t *apdu, uint16_t len) {
@@ -84,19 +95,34 @@ static void s_send_apdu(void *ctx, const uint8_t *apdu, uint16_t len) {
}
/*
* 函数功能:link 层对时回调——epoch 毫秒落系统墙钟(含 SD2506 RTC 写回)
* 函数功能:link 层对时回调——epoch 毫秒落系统墙钟(含 SD2506 RTC 写回),
* 成功时顺带落一条操作记录(道F task-19:「时钟设置」是四处操作记录
* 写入点之一,另外三处见 App/log/README.md 的五类记录对照表)
* 入口参数:ctx 未用;epoch_ms = C_CS 解析出的 UTC 时刻
* 返回值:0 成功 / -1 失败(link 层回 actcon+PN)
* 函数说明:uint32 秒域上限 2106-02-07,越界显式拒绝(CP56 域到 2127)
* 函数说明:uint32 秒域上限 2106-02-07,越界显式拒绝(CP56 域到 2127)。
* **只在真正改成功之后才投记录**:越界拒绝与 sys_clock_set_unix 失败两条腿
* 一条都不投——操作记录是审计面,写进去一条没发生过的"对时"比少一条更坏
* (TC-I104SRV-050b / 051 分别钉这两条腿)。
* 投递用 syslog_post_quiet():iec104Task 是深链任务(本文件顶部已把
* DBG_LINE_BUF_MAX 收到 128 就是同一件事的证据),不许在它的调用链上
* 内联日志行缓冲;两个投递口的收/丢判据一致,差别只在留痕位置
* (口径与理由见 App/log/README.md「两个投递口」)。
* 文案是 ROM 常量、17 字节 < SYSLOG_DESC_MAX(28):iec104Task 里不 snprintf。
*/
static int s_clock_sync(void *ctx, uint64_t epoch_ms) {
uint64_t secs = epoch_ms / 1000ULL;
int rc;
(void)ctx;
if (secs > 0xFFFFFFFFULL) {
return -1;
}
return sys_clock_set_unix((uint32_t)secs);
rc = sys_clock_set_unix((uint32_t)secs);
if (rc == 0) {
syslog_post_quiet(SYSLOG_OPER, "clock set by 104");
}
return rc;
}
/*
@@ -117,7 +143,18 @@ static uint64_t s_epoch_ms(void *ctx) {
}
/*
* 函数功能:创建并监听 2404(socket+bind+listen,抄 ui_net_sink bind_listen)
* 函数功能:把 [comm] 段公共地址取进链路 cfg(道G task-8 取值来源改造)
* 返回值:无
* 限定条件:iec104_link_init 之前调用(任务启动一次)——ca 生效语义 =
* 改后重启;端口不同:s_bind_listen 每次现取 iec104_comm_port(),
* 下次重 bind(拆链重建/网络代次重建)即生效,不必整机重启
*/
static void s_apply_comm_ca(void) {
s_cfg.ca = iec104_comm_ca();
}
/*
* 函数功能:创建并监听 [comm] port(socket+bind+listen,抄 ui_net_sink bind_listen)
* 返回值:0 成功(s_listen 写回)/ -1 失败
* 限定条件:net 子系统可用(NET_READY 屏障后)
*/
@@ -131,7 +168,7 @@ static int s_bind_listen(void) {
}
(void)memset(&addr, 0, sizeof(addr));
addr.sin_family = NET_AF_INET;
addr.sin_port = net_htons(IEC104_SERV_PORT);
addr.sin_port = net_htons(iec104_comm_port());
addr.sin_addr.s_addr = NET_INADDR_ANY;
if (net_bind(ls, (struct net_sockaddr *)&addr, (int)sizeof(addr)) < 0) {
net_close(ls);
@@ -189,8 +226,8 @@ static void s_service_link(uint32_t now) {
* 函数功能:单次服务循环(代次自愈 → accept → 门控收 → 喂 link → 节拍)
* 返回值:无
* 限定条件:NET_READY 已置位;s_cfg/s_link 已 init
* 函数说明:门控(net_can_accept/net_can_read 纯 RAM 读)+ 兜底节拍照
* ui_net_sink I-1/I-2;EOF 时 client==listen 严禁 close(net 层
* 函数说明:门控(net_listener_armed/net_can_read 纯 RAM 读)+ 兜底节拍照
* ui_net_sink 先例;EOF 时 client==listen 严禁 close(net 层
* 已 auto_relisten,close 会连监听一起毁)
*/
static void s_serv_poll(void) {
@@ -224,7 +261,13 @@ static void s_serv_poll(void) {
if (s_client < 0) {
int c;
if (net_can_accept(s_listen) == 0) {
/* F-NET-4:门控从 net_can_accept(只认 ESTABLISHED,已退役)
* 迁 net_listener_armed——旧门控把 LISTENING 窗口全挡掉,:2404 的丢
* CONNECT 探针(net_accept_locked 内)因此从不运行(与已修的 :7003
* 同型);armed=ESTABLISHED 待 accept / LISTENING /
* CLOSED+auto_relisten+残留(ESTABLISHED 放行由台架实证补齐:
* 漏它则本任务 200ms 周期抢不过对账② ~400ms 去抖、整场饿死) */
if (net_listener_armed(s_listen) == 0) {
if (net_get_sock(s_listen) == NULL) {
s_listen = -1; /* 控制块消失:交回上方重建 */
}
@@ -252,7 +295,10 @@ static void s_serv_poll(void) {
s_liveness_ctr = 0u;
}
n = net_recv(s_client, s_rx, (int)sizeof(s_rx), NET_MSG_DONTWAIT);
/* DONTWAIT 已转正为具名 API net_recv_nb——本任务在 IWDG
* required_mask 内且有 100ms 周期职责(喂狗/链路节拍/上送),禁止无限
* 阻塞 recv(文档契约见 net_socket.h net_recv 注释),非阻塞轮询是义务 */
n = net_recv_nb(s_client, s_rx, (int)sizeof(s_rx));
if (n > 0) {
iec104_link_on_rx(&s_link, s_rx, (uint16_t)n, now);
} else if (n == 0) {
@@ -297,13 +343,14 @@ void iec104_serv_task(void *argument) {
}
iec104_pts_init();
s_apply_comm_ca();
iec104_link_init(&s_link, &s_cfg, NULL);
s_gen = net_get_generation();
if (s_bind_listen() < 0) {
DBG_ERROR("i104: bind port %u FAIL, retry in loop",
(unsigned)IEC104_SERV_PORT);
(unsigned)iec104_comm_port());
} else {
DBG_INFO("i104: listening on %u", (unsigned)IEC104_SERV_PORT);
DBG_INFO("i104: listening on %u", (unsigned)iec104_comm_port());
}
for (;;) {
@@ -315,6 +362,11 @@ void iec104_serv_task(void *argument) {
if ((prot_set_dirty_peek() != 0u) && (sys_evtHandle != NULL)) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_PROT_SET_SAVE);
}
/* 运行参数整定(C_SE 0x8020~0x8058 段)→ 通知属主落盘 config.ini
* (卡 10;peek 非消费 = 写盘失败天然重试,与上块 B7 语义完全同款) */
if ((run_par_dirty_peek() != 0u) && (sys_evtHandle != NULL)) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_RUN_PAR_SAVE);
}
osDelay(IEC104_POLL_MS);
}
}
+4 -1
View File
@@ -7,7 +7,10 @@
#ifndef IEC104_SERV_H
#define IEC104_SERV_H
/* IEC 60870-5-104 被控站监听端口(DL/T 634.5104 标准端口) */
/* IEC 60870-5-104 被控站监听端口(DL/T 634.5104 标准端口)。
* 道G task-8 起运行期真源 = [comm] 段(App/protocol/iec104/iec104_comm.h,
* 工厂默认 IEC104_COMM_DEF_PORT 与本宏同值);本宏保留仅作快照兼容出口
* (App/app/ui_data_feed.c 的 port_104——该处切 getter 属 UI 道 task-9)。 */
#define IEC104_SERV_PORT 2404u
/*
+19 -29
View File
@@ -11,16 +11,20 @@
* v1.2 2026-07-21 王建锋 网络初始化移入本任务(net_init + TCP 监听)
* v1.3 2026-07-22 王建锋 单连接模式重构,增加大文件/边界/KeepAlive 测试
* v1.4 2026-09-14 王建锋 新增自检埋点:PHY 链路 3s 未建立 → sys_log "comms fault"
* v1.5 2026-09-15 王建锋 配置加载移交 defaultTask(配置引擎属主,P1-3);
* v1.5 2026-09-15 王建锋 配置加载移交 defaultTask(配置引擎属主);
* 本任务只读 RAM 副本
* v1.6 2026-09-15 王建锋 P1-6:入口加 CH395F 硬件层测试模式自挂起守卫
* v1.6 2026-09-15 王建锋 入口加 CH395F 硬件层测试模式自挂起守卫
* (基础线程无条件创建,该模式下不运行);
* freertos.c USER CODE 区重复创建已删除
* v1.7 2026-10-02 王建锋 P1-8:初始组网失败/重建失败 → 自检记录 + 30s 周期重试
* v1.7 2026-10-02 王建锋 初始组网失败/重建失败 → 自检记录 + 30s 周期重试
* (原实现:初始失败 = netTask 无限驻留;重建失败 = 静默丢配置)
* v1.8 2026-10-10 王建锋 Task #32 的 P2 测量埋点:net_poll() 单独计时——
* v1.8 2026-10-10 王建锋 任务耗时测量埋点:net_poll() 单独计时——
* 本任务每 ~1ms 分派一次消息队列,若驱动轮询被拉长,
* 其它任务(尤其 UI 的 net_accept 往返)的等待随之拉长
* v1.9 2026-10-21 王建锋 删除 net_task_init():空壳入口(体内只有一条
* DBG_INFO),全库无调用点,而函数头却写"创建 TCP
* Server 监听 Socket,注册回调等"——描述与实际不符,
* 监听实际在 net_stack_bring_up() 与各服务里做
*/
#include <string.h>
@@ -34,7 +38,7 @@
#include "sys_log.h"
#include "test_config.h" /* 测试模式宏(ENABLE_HW_LAYER_TESTS);产品构建全未定义,无作用 */
#include "task_watchdog.h"
#include "task_prof.h" /* P2 探针:net_poll 耗时(Task #32) */
#include "task_prof.h" /* 探针:net_poll 耗时 */
#define DBG_TAG "[NET_TASK]"
#include "dbg_log.h"
@@ -48,7 +52,7 @@
* 函数说明:只置事件位;真正的重建在主循环内完成(CH395F SPI 归 netTask 独占,
* UI 不得直接操作)
*/
/* P1-8(2026-10-02):重建失败延迟重试的截止 tick;0 = 无待重试 */
/* 重建失败延迟重试的截止 tick;0 = 无待重试 */
static uint32_t s_reinit_retry_at;
static void net_reconfig_request_cb(void) {
@@ -110,14 +114,12 @@ int net_stack_bring_up(void) {
ch395f_set_phy(CH395F_PHY_100M_FULL);
/* 强制 PHY 模式后链路会短暂掉线再重建,CH395F 会锁存 GINT_STAT_PHY_CHANGE(0x04) 全局中断标志。
* 这里轮询该标志等待"链路状态变化"(每次读取即清除),同时用 ch395f_get_phy_status()
* 确认链路是否已真正建立——避免因 PHY_CHANGE 只反映"掉线"边沿而误判就绪。
* 链路一就绪(phy != DISCONN)即跳出;最多等约 3s(60 × 50ms),超时仍按实际状态继续(由下方日志暴露问题)。
* 注:此处读取全局中断状态属"启动期一次性链路等待",net_poll 尚未启动,不与其中断集中处理冲突;
* NET 模式下该标志后续仍会被 net_poll 正常消费/清除(见 net_socket.c)。 */
* 这里只轮询 ch395f_get_phy_status() 确认链路是否已真正建立——**不读全局中断寄存器**:
* 它读即清,唯一读者 = net_poll(F-NET-7,用户裁定 2026-10-13 测试代码同样禁自行读取)。
* 锁存的 PHY_CHANGE 会在 net_poll 首轮被正常消费(喂驱动链路态缓存,无害)。
* 链路一就绪(phy != DISCONN)即跳出;最多等约 3s(60 × 50ms),超时仍按实际状态继续(由下方日志暴露问题)。 */
uint8_t phy = CH395F_PHY_DISCONN;
for (unsigned int i = 0U; i < 60U; i++) {
(void)ch395f_get_glob_int_status_all(); /* 读取并清除 PHY_CHANGE 等全局中断标志 */
phy = ch395f_get_phy_status();
if (phy != CH395F_PHY_DISCONN) {
break;
@@ -147,18 +149,6 @@ int net_stack_bring_up(void) {
return 0;
}
/*
* 函数功能:网络任务初始化
* 入口参数:无
* 返 回 值:0 - 成功,-1 - 失败
* 限定条件:CH395F 协议栈已初始化
* 函数说明:创建 TCP Server 监听 Socket,注册回调等
*/
int net_task_init(void) {
DBG_INFO("net_task_init");
return 0;
}
/*
* 函数功能:网络任务主函数
* 入口参数:arg - FreeRTOS 传入参数(未使用)
@@ -196,7 +186,7 @@ void net_task_func(void const *arg) {
* 2026-10-02 台架修复:10s 超时等待改 100ms 分片 + 每片喂心跳——原实现
* 在等待期无心跳:若存储 bring-up 失败,本等待跑满 10s 超时,而 IWDG
* 此时已启动(defaultTask 在 bring-up 后即启动),NET 位长期缺失 →
* 必需掩码不可达 → 复位循环,E-31 的 2s 自愈重试根本没有机会。
* 必需掩码不可达 → 复位循环,存储自愈的 2s 重试根本没有机会。
* 分片喂位保证"存储未就绪"降级态下 NET 位持续新鲜。 */
uint32_t rounds_left = 100u; /* 100 × 100ms = 原 10s 超时语义 */
for (;;) {
@@ -212,7 +202,7 @@ void net_task_func(void const *arg) {
}
}
/* P1-8(2026-10-02):初始组网失败不自驻留——自检台账留痕 + 30s 周期重试
/* 初始组网失败不自驻留——自检台账留痕 + 30s 周期重试
* (1s 分片、每片喂心跳):线缆/芯片恢复即可当场完成组网;原 for(;;)osDelay
* 会令看门狗 NET 位持续陈旧,并阻塞全部下游就绪屏障 */
for (;;) {
@@ -245,7 +235,7 @@ void net_task_func(void const *arg) {
/* UI 保存网口配置后请求立即应用:在此重建网络(新 IP/掩码/网关/MAC)。
* 重建完成后代次号已递增,各 TCP 服务据此重建监听;期间连接会断开(预期)。
* P2-11(2026-10-04):用 osEventFlagsWait(timeout=0) 原子"取走并清除"RECONFIG
* 用 osEventFlagsWait(timeout=0) 原子"取走并清除"RECONFIG
* 位,替换原 Get+Clear 两步;原写法在 Get 与 Clear 之间若有生产者在同一位上
* Set,该次请求会被 Clear 一并吞掉(丢事件窗口)。 */
if (sys_evtHandle != NULL) {
@@ -257,7 +247,7 @@ void net_task_func(void const *arg) {
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_READY);
s_reinit_retry_at = 0U;
} else {
/* P1-8(2026-10-02):重建失败 → 自检留痕 + 30s 延迟重试(重事件位),
/* 重建失败 → 自检留痕 + 30s 延迟重试(重事件位),
* 新配置不再静默丢失(原实现仅一条 DBG_ERROR 后丢弃) */
DBG_ERROR("netTask: re-init FAIL, retry in 30s");
(void)syslog_post(SYSLOG_SELFTEST, "net reinit fail");
@@ -265,7 +255,7 @@ void net_task_func(void const *arg) {
}
} else if ((s_reinit_retry_at != 0U)
&& ((int32_t)((uint32_t)HAL_GetTick() - s_reinit_retry_at) >= 0)) {
/* P1-8:30s 倒计时到 → 重新触发重建(符号差处理 tick 回绕) */
/* 30s 倒计时到 → 重新触发重建(符号差处理 tick 回绕) */
s_reinit_retry_at = 0U;
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_NET_RECONFIG);
}
+2 -2
View File
@@ -8,7 +8,8 @@
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-08-24 王建锋 提取 net_stack_bring_up() 供硬件层/NET 层两种测试模式共用
* v1.2 2026-09-15 王建锋 硬件层模式描述修正:netTask 恒创建(基础线程),
* 该模式下自挂起不运行(P1-6 守卫)
* 该模式下自挂起不运行(入口自挂起守卫)
* v1.3 2026-10-21 王建锋 删除 net_task_init() 声明(空壳入口、无调用点)
*/
#ifndef NET_TASK_H
@@ -23,7 +24,6 @@ extern "C" {
* netTask 自挂起(创建但不运行),由 testTask 调用后独占 CH395F。 */
int net_stack_bring_up(void);
int net_task_init(void);
void net_task_func(void const *arg);
#ifdef __cplusplus
+160 -19
View File
@@ -8,11 +8,42 @@
* 可忽略;失配 → prot_set 内部闭锁,eval 自动复位全元件);
* ③ prot_elm_run()(phasor rms → 判据步进)+ 动作位图边沿日志;
* ④ 全局软压板 PS_GLOBAL_EN(104 YK12 两段式投退,B7):退出 =
* 复位元件 + 停判据(与采样闭锁同构,独立边沿日志)。
* 复位元件 + 停判据(与采样闭锁同构,独立边沿日志);
* ⑤ 未标定闭锁(「数据可信」卡 5,M4 唯一新增行为变更):UA/UN/IA
* 的 adc_cal VALID 位未全置位 → 工程量还只是 ADC 引脚电压
* (eng=0),拿去比二次值定值必然错判 → 复位元件 + 本拍不 eval。
* 默认构建(未标定)恒闭锁是**期望行为**:边沿用 DBG_INFO 而非
* [ERR](未标定是出厂常态、不是故障;每次开机都走这条沿,
* [ERR] 还会误触 @test 禁用标记);台架旁路只走编译期宏
* DTU_BENCH(「数据可信」§4.3:不进 cfg、不进 104,本仓任何
* 提交目标都不定义它),且该宏**只作用于判定本体**
* adc_cal_prot_blocked()(App/algo/adc_cal.c,全仓唯一一处 #ifndef;
* 道F task-16 之前本文件另有一份判定,两处可被分别开关 = 屏上
* 「未标定」与判据停能量分叉,这就是收敛的原因)。判定优先级 =
* 采样可信在前、标定可用在后(数据都旧了谈不上标定)。
* 定值/元件初始化在 app_main_init(内核前,B5);本任务只消费。
* 线程约定:prot_elm_run / prot_set_selfcheck 仅本任务上下文(prot_elm.h);
* 跨上下文复归(104 YK11)经 prot_elm_request_reset 落请求。
* 创建日期:2026-10-12(B5)
* 修改记录:
* v1.0 2026-10-12 王建锋 创建:L5-4 采样故障闭锁 + L5-1 定值巡检 + 判据节拍
* v1.1 2026-10-21 王建锋 ①之外增加**兜底新鲜度门**:本任务用自己的时基直接
* 看两个中断侧计数器是否还在前进(ADC_HF_STALE)。
* adc_health 的 stall 位依赖 adcTask 每拍跑 check(),
* 节拍源一停本任务就再没有任何人判"数据新不新",
* 而相量冻在最后一帧上还会被本任务继续用于判据
* v1.2 2026-10-25 DTU 项目组 卡 5:新增第⑤路「未标定闭锁」(adc_cal_flags
* VALID 口径,docs/任务规划/数据可信.md §4.3):
* 采样恢复分支内、巡检/压板之前判;边沿 INFO 对
* + SOE_T_CAL_BLOCK/RESUME(不用 [ERR]:未标定是
* 常态非故障,且 @test 禁用 [ERR]);DTU_BENCH
* 编译期旁路(本仓目标不定义 = 产品恒强制闭锁)
* v1.3 2026-10-26 DTU 项目组 道F task-16:本文件的三路判定 s_cal_locked()
* 删除,改调 App/algo/adc_cal.c 的 adc_cal_prot_blocked()
(同一事实的单一真源;DTU_BENCH 旁路随之收成一处)。
行为不变:判定式、调用位置(采样恢复分支内、巡检/压板
之前)、边沿日志与 SOE 分型全部照旧;s_prev_cal_lock
仍只作边沿日志用,**不是**判据缓存
*/
#include "protect_task.h"
@@ -22,21 +53,41 @@
#define DBG_LINE_BUF_MAX 128U
#include "dbg_log.h"
#include "adc_input.h" /* adc_input_stm32_seq():DMA 完成计数(中断侧写) */
#include "adc_health.h"
#include "adc_cal.h" /* 卡 5:adc_cal_prot_blocked() = 未标定闭锁判定单点(道F task-16) */
#include "console_log.h" /* dbg_console_writer_ready():启动锚点行门控(B6) */
#include "prot_elm.h"
#include "prot_set.h"
#include "soe_store.h" /* B9:动作/复归边沿 → SOE 投递(零 FS,队列异步) */
#include "sys_log.h" /* task-10:事故 / 保护告警 / 保护启动 三类文本记录 */
#include "task_watchdog.h"
#include "tpafe5160.h" /* tpafe5160_frame_seq():帧序号(BUSY EXTI 写) */
/* L5-4 采样故障电平闭锁位:stall(可自愈)+ backward(锁存)都须停判据。
* 锁存类(FRSTDATA/HW_ERR 等)不在此列——它们是一次性事件痕迹,不构成
* "当前测量不可信";持续 stall/backward 才是。 */
#define PROT_HF_LOCK_MASK (ADC_HF_TPAFE_STALL | ADC_HF_STM32_STALL | ADC_HF_SEQ_BACKWARD)
#define PROT_HF_LOCK_MASK (ADC_HF_TPAFE_STALL | ADC_HF_STM32_STALL | \
ADC_HF_SEQ_BACKWARD | ADC_HF_STALE)
/* 兜底新鲜度门限。判定层的 stall 是 100 ms,但那是"每拍都在跑的观察者"的口径;
* 本门每 20 ms 才观测一次,最坏比判定层晚一整周期 → 取 100 + 20 = 120 ms,让
* "判定层先报(带日志与自检留痕)、本门兜底(判定层死了也报)"的时序阶梯成立。
* 排查时 hf 里 0x1/0x2 与 0x100 的先后,本身就是"判定层当时活着吗"的答案。 */
#define PROT_STALE_AGE_MS (ADC_HEALTH_STALL_MS + PROTECT_TASK_CYCLE_MS)
/* 兜底门的参照系:两条车道各存一份。⚠ primed 标志也必须各自独立——若共用一个,
* 先被观测的那条会把它置 1,另一条首拍就拿 adv_ms = 0 去算年龄 → 开机即误锁。
* 状态放调用方而不是复用 adc_health 的模块级状态:那个是"每拍一次、单写者"的
* 契约,两个任务同写会互相刷新差值基线,把现有判定层一起弄坏。 */
static adc_health_age_t s_age_tp; /* TPAFE 车道 */
static adc_health_age_t s_age_a1; /* STM32 车道 */
static uint32_t s_prev_active; /* 上拍动作位图(边沿日志用) */
static uint32_t s_prev_pick; /* 上拍启动位图(「保护启动」记录边沿用,task-10) */
static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用) */
static uint8_t s_prev_plate_out; /* 上拍全局软压板退出态(边沿日志用,B7) */
static uint8_t s_prev_cal_lock; /* 上拍未标定闭锁态(边沿日志用,卡 5) */
/*
* 函数功能:动作位图变化沿日志 + SOE 投递(逐变化元件)
@@ -72,6 +123,61 @@ static void s_log_active_edge(uint32_t active) {
s_prev_active = active;
}
/*
* 函数功能:把元件状态边沿落成三类文本记录(事故 / 保护告警 / 保护启动)
* 入口参数:active - 本拍 prot_elm_active_mask()(asserted 位图) uint32_t
* pick - 本拍 prot_elm_pick_mask()(picked 位图) uint32_t
* 返 回 值:无
* 限定条件:仅本任务上下文;**只投递、不碰盘**(唯一写者是 defaultTask),
* 投递走 syslog_post_quiet()——本任务静态最坏链是 B9 写进 DoD 的硬线
* (872 B / 57%),syslog_post 的两个满态留痕分支各内联约 216 B 行缓冲,
* 在这里调它等于把链垫回 stack_report 的 FAIL 区(同本文件不用
* snprintf/DBG 拼记录的纪律,见 s_log_active_edge 函数头)
* 函数说明:三类记录的**类型就是目标文件**(一类一个 /log 文件,记录体内没有
* 类型字段位;决策与理由见 App/log/README.md)。判据:
* 1) picked 上升沿 → SYSLOG_START("保护启动"):越限电平一到就记,
* 不等定时限到点——只记动作会让"启动了但没到点"那段在记录里消失;
* 2) asserted上升沿 → 归 事故 还是 告警 由 prot_elm_record_kind() 判,
* 其判据唯一来源是定值表里的 PS_*_TRIP(0 告警 / 1 跳闸);本函数
* 不另立"哪些元件算跳闸"的名单,避免与 g_prot_set_meta / k_pe[] 打架。
* 返回沿(asserted/picked 掉落)**不记**:复归留痕已由 s_log_active_edge
* 的 SOE 通道承担,再写一份就是同一事件两个真源。
* 同拍既启动又动作(延时段 0ms / 速断)→ 两条都记,顺序先启动后动作,
* 与物理事件次序一致。
* desc 直接用 prot_elm_name() 的 ROM 常量("OC1-IA" 等,最长 6 字节
* << SYSLOG_DESC_MAX=28),零格式化、零栈缓冲。
*/
static void s_log_record_edge(uint32_t active, uint32_t pick) {
uint32_t p_chg = pick ^ s_prev_pick;
uint32_t a_chg = active ^ s_prev_active;
uint32_t i;
for (i = 0u; (i < (uint32_t)PE_COUNT) && (((p_chg | a_chg) != 0u)); i++) {
const uint32_t bit = 1u << i;
if (((p_chg & bit) != 0u) && ((pick & bit) != 0u)) {
syslog_post_quiet(SYSLOG_START, prot_elm_name((prot_elm_id_t)i));
}
if (((a_chg & bit) != 0u) && ((active & bit) != 0u)) {
switch (prot_elm_record_kind((prot_elm_id_t)i)) {
case PROT_REC_TRIP:
syslog_post_quiet(SYSLOG_ACCIDENT, prot_elm_name((prot_elm_id_t)i));
break;
case PROT_REC_ALARM:
syslog_post_quiet(SYSLOG_ALARM, prot_elm_name((prot_elm_id_t)i));
break;
default:
/* PROT_REC_NONE:元件号越界。位图来自 prot_elm 自己的表,正常
* 走不到这里;真走到了就是表与位图不一致,一条都不能落——
* 落错类别的审计记录比少一条记录更坏。 */
break;
}
}
}
s_prev_pick = pick;
s_prev_active = active;
}
/*
* 函数功能:protectTask 线程入口
* 入口参数:argument - 未用
@@ -89,6 +195,18 @@ void protect_task(void *argument) {
uint32_t now = osKernelGetTickCount();
uint32_t hf = adc_health_flags();
/* 兜底新鲜度门(L5-4 的独立通道):节拍源停摆时 adcTask 会永久阻塞在
* osThreadFlagsWait(那是刻意的 fail-safe),check() 不再执行 → stall 位
* 永远是 0。这里用本任务自己的时基直接读两个**中断侧递增**的计数器,
* 与 adcTask 的存亡无关;两者都只要 TIM3 还在触发就会前进。
* STM32 车道只更新参照系、不额外置位:任一侧老了就同一个位闭锁。 */
if (adc_health_age_stale(&s_age_tp, tpafe5160_frame_seq(), now,
PROT_STALE_AGE_MS) != 0u) {
hf |= ADC_HF_STALE;
}
(void)adc_health_age_stale(&s_age_a1, adc_input_stm32_seq(), now,
PROT_STALE_AGE_MS);
/* 启动锚点行:控制台写者注册后首拍补打(2026-10-10 门控纪律,adcTask
* 首帧同款)。写者注册前 con_append 走无锁裸发,与 defaultTask 启动日志
* 逐字节交错——B6 台架实证:本行曾损坏为 "st[art: cycle=220ms elm=70"
@@ -118,33 +236,56 @@ void protect_task(void *argument) {
SOE_ELM_NONE, 0u);
s_prev_hf_lock = 0u;
}
/* L5-1 巡检:定值区与封存校验和不符 → prot_set 闭锁(下一拍起
* eval 自动复位全元件)。B7 收口:104 定值写改走
* prot_set_set_sealed(影子区写+封存后单字交换),selfcheck 另有
* seqlock 式复核(提交撞见 = 本轮跳过),误锁窗口已消除。 */
(void)prot_set_selfcheck();
/* 全局软压板(PS_GLOBAL_EN,104 YK12 两段式投退,B7):退出 =
* 保护停止判据并复位元件状态(重新投入从干净快照起步) */
if (prot_set_get_ctl(PS_GLOBAL_EN) == 0u) {
if (s_prev_plate_out == 0u) {
DBG_INFO("global plate OUT -> protection disabled");
(void)soe_store_post((uint8_t)SOE_T_PLATE_OUT,
/* 卡 5:未标定闭锁——夹在「采样可信」与「巡检/压板」之间:
* eng=0 域(引脚电压)拿去比二次值定值必然错判(UV 尤甚),
* 标定 VALID 未全置位前整条判据链停走(「数据可信」§4.3 决策 1) */
/* 判定本体在 adc_cal_prot_blocked()(标定层单点,道F task-16):本任务
* 只是消费者,不再自带一份三路判定 */
if (adc_cal_prot_blocked() != 0u) {
if (s_prev_cal_lock == 0u) {
DBG_INFO("uncalibrated -> protection blocked (UA/UN/IA VALID not all set)");
(void)soe_store_post((uint8_t)SOE_T_CAL_BLOCK,
SOE_ELM_NONE, 0u);
s_prev_plate_out = 1u;
s_prev_cal_lock = 1u;
}
prot_elm_init();
} else {
if (s_prev_plate_out != 0u) {
DBG_INFO("global plate IN -> protection enabled");
(void)soe_store_post((uint8_t)SOE_T_PLATE_IN,
if (s_prev_cal_lock != 0u) {
DBG_INFO("calibration VALID -> protection unlocked");
(void)soe_store_post((uint8_t)SOE_T_CAL_RESUME,
SOE_ELM_NONE, 1u);
s_prev_plate_out = 0u;
s_prev_cal_lock = 0u;
}
/* L5-1 巡检:定值区与封存校验和不符 → prot_set 闭锁(下一拍起
* eval 自动复位全元件)。B7 收口:104 定值写改走
* prot_set_set_sealed(影子区写+封存后单字交换),selfcheck 另有
* seqlock 式复核(提交撞见 = 本轮跳过),误锁窗口已消除。 */
(void)prot_set_selfcheck();
/* 全局软压板(PS_GLOBAL_EN,104 YK12 两段式投退,B7):退出 =
* 保护停止判据并复位元件状态(重新投入从干净快照起步) */
if (prot_set_get_ctl(PS_GLOBAL_EN) == 0u) {
if (s_prev_plate_out == 0u) {
DBG_INFO("global plate OUT -> protection disabled");
(void)soe_store_post((uint8_t)SOE_T_PLATE_OUT,
SOE_ELM_NONE, 0u);
s_prev_plate_out = 1u;
}
prot_elm_init();
} else {
if (s_prev_plate_out != 0u) {
DBG_INFO("global plate IN -> protection enabled");
(void)soe_store_post((uint8_t)SOE_T_PLATE_IN,
SOE_ELM_NONE, 1u);
s_prev_plate_out = 0u;
}
prot_elm_run(now);
}
prot_elm_run(now);
}
}
s_log_active_edge(prot_elm_active_mask());
/* 三类文本记录(task-10):放在 SOE 边沿之后、喂狗之前,纯 RAM 投递 */
s_log_record_edge(prot_elm_active_mask(), prot_elm_pick_mask());
task_wd_beat(TASK_WD_PROTECT);
osDelay(PROTECT_TASK_CYCLE_MS);
+1 -1
View File
@@ -9,7 +9,7 @@
* 创建日期:2026-07-19
* 修改记录:
* v1.0 2026-07-19 王建锋 创建初始版本
* v1.1 2026-10-05 王建锋 P3-11:注明"预留桩(未启用)",避免误认为已实现
* v1.1 2026-10-05 王建锋 注明"预留桩(未启用)",避免误认为已实现
*/
#include "rs485_task.h"
+55
View File
@@ -0,0 +1,55 @@
/*
* 模块名称:Task Handles(内核任务句柄与属性块的集中声明)
* 模块功能:把散落在各 .c 里的跨文件裸 extern 收拢成一份有名字的契约,供栈水位观测、
* 看门狗位图、按句柄取剩余栈等消费方引用
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-10-22
* 修改记录:
* v1.0 2026-10-22 王建锋 从 App/task/default_task.c 的 18 行局部 extern 收拢而来。
* 唯一定义处仍是 Src/freertos.c(CubeMX 生成的任务表),本文件
* 只是引用契约:新增/改名任务必须同步这里,否则报错落在链接期
* 而不是静默——这是有意的(原先每加一个任务就往 default_task.c
* 再抄两行 extern,抄漏一行就是读别人的栈)。
*/
#ifndef TASK_HANDLES_H
#define TASK_HANDLES_H
#include "cmsis_os.h" /* osThreadId_t / osThreadAttr_t */
#ifdef __cplusplus
extern "C" {
#endif
/*
* 以下符号全部定义于 Src/freertos.c(CubeMX 任务表,只写 USER CODE 区,不可外移)。
* p_attr->stack_size 是「配置栈大小」的唯一起点;实测最坏链与余量台账见
* docs/内存管理/RAM使用分析与优化方案.md 的 2.3 与 4.0.6 小节(此处不重复数字)。
* testTask 仅在启用板级测试套件时创建,未创建时句柄为 NULL(消费方须判空)。
*/
extern osThreadId_t defaultTaskHandle;
extern osThreadId_t netTaskHandle;
extern osThreadId_t ftpTaskHandle;
extern osThreadId_t testTaskHandle;
extern osThreadId_t uiTaskHandle;
extern osThreadId_t timeSyncTaskHandle;
extern osThreadId_t adcTaskHandle;
extern osThreadId_t iec104TaskHandle;
extern osThreadId_t protectTaskHandle;
extern const osThreadAttr_t defaultTask_attributes;
extern const osThreadAttr_t netTask_attributes;
extern const osThreadAttr_t ftpTask_attributes;
extern const osThreadAttr_t testTask_attributes;
extern const osThreadAttr_t uiTask_attributes;
extern const osThreadAttr_t timeSyncTask_attributes;
extern const osThreadAttr_t adcTask_attributes;
extern const osThreadAttr_t iec104Task_attributes;
extern const osThreadAttr_t protectTask_attributes;
#ifdef __cplusplus
}
#endif
#endif /* TASK_HANDLES_H */
+354 -8
View File
@@ -6,11 +6,11 @@
* 创建日期:2026-08-30
* 修改记录:
* v1.1 2026-09-14 王建锋 新增待机显示页初始化(ui_standby_init,空闲 60s 切入)
* v1.2 2026-10-10 王建锋 Task #32 的 P2 测量埋点:主循环加常开探针
* v1.2 2026-10-10 王建锋 任务耗时测量埋点:主循环加常开探针
* (循环周期 / 单轮工作耗时 / ui_net_sink_poll 往返),
* 用于把"心跳缺席"拆成 饿死 / 阻塞在往返 / 自身拖长
* 三种互斥机理(判读见 docs/子系统设计/看门狗设计说明.md §11.4)
* v1.3 2026-10-10 王建锋 Task #32 的 P1-4a(解耦心跳,看门狗设计说明 §11.3):把 task_wd_beat
* v1.3 2026-10-10 王建锋 解耦心跳(docs/子系统设计/看门狗设计说明.md §11.3):把 task_wd_beat
* (TASK_WD_UI) 提到 ui_net_sink_poll() **之前**。原写法心跳
* 在推流往返之后 → 心跳周期 = 渲染 + 一次 netTask 往返 + 10ms,
* 实测 uiw≈uis≈50ms(心跳被 netTask 往返主导),心跳实际测的是
@@ -18,6 +18,20 @@
* "UI 自己被调度到了"。注:若往返**永久**阻塞,本改仍不能救
* (心跳只发一次)→ 真要彻底去掉依赖须走事件驱动 accept
* (NET_EVENT_CONNECTED 回调),待复现快照确认阻塞机理后再定。
* v1.4 2026-10-25 DTU 项目组 F3(大窗判读):向 ui_net_sink 注册心跳回调
* (ui_task_sink_beat)——推流段内**每个可等待 net 往返**
* (accept/recv/send/close/重建)返回点即喂本任务心跳,
* 风暴期「单轮多个慢往返之和」不再串成 800ms 缺席链
* (2026-10-25 实测 bit4 8 连击、uisn=154ms 仍在段内;
* 按位连击判据 = docs/子系统设计/看门狗设计说明.md §5.3)。
* 单个往返自身仍无上界——该上界与彻底解耦(事件驱动
* accept)归登记簿「net 层跨上下文收口」(批 3),本票只做心跳续命。
* v1.5 2026-10-26 DTU 项目组 道F task-3 装置维护三页的执行侧:新增
* ui_data_maint_ops_t 六件适配(时钟=写+回读 RTC、
* 复归=prot_elm_request_reset、清除=syslog_request_clear
* /clear_state 直通)并在 ui_menu_init 之前注入;记录
* reader 的 2 条 if 链改成 7 条映射表 s_log_map,并加
* 编译期守卫把 UI_W_REC_LOG_* == syslog_id_t 枚举序钉死
*/
#include "ui_task.h"
@@ -30,7 +44,16 @@
#include "ui_menu.h"
#include "ui_net_sink.h"
#include "ui_standby.h"
#include "ui_w_records.h" /* task-6 记录 reader 注入点(UI 页面不再 include sys_log.h) */
#include "ui_w_soe.h" /* task-15 SOE 记录 reader 注入点(同上:UI 不 include soe_store.h) */
#include <stdio.h> /* task-15 snprintf:CP56 → UI 视图里的 char[20] 时标 */
#include "ui_data.h" /* task-14 快照存侧(BSP):本任务只负责先复位再喂帧 */
#include "ui_data_feed.h" /* task-14 数据装配节拍入口(App/app,UI 侧唯一发布者) */
#include "sys_log.h"
#include "soe_store.h" /* task-15 SOE 只读遍历(count/read);写侧仍归 protectTask/defaultTask */
#include "ui_tokens.h" /* UI 设计令牌:循环周期 UI_TASK_TICK_MS / 待机阈值 UI_STANDBY_IDLE_MS */
#include "sys_clock.h"
#include "prot_elm.h" /* 道F task-3 装置维护:强制复归请求 + 动作态元件位图 */
#include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */
#include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */
#include "task_watchdog.h"
@@ -48,6 +71,302 @@ static void ui_task_time_cb(char *buf, size_t len) {
}
}
/*
* UI 记录源编号 → syslog_id_t 映射表(道F task-6 起 7 类,task-3 装置维护「清除记录」
* 要在 7 行上分行显示;表长 = UI_W_REC_LOG_N,两端映射表同源同长)
*
* ⚠ 顺序契约(Lead 裁定 2026-10-26):UI_W_REC_LOG_* 逐字等于 syslog_id_t 枚举序,
* 只准尾部追加——枚举序就是 App/log 两端 s_paths[]/s_caps[] 的下标,中间插值会
* 安静地把事故读成告警。下面的编译期守卫就是这条契约在本端的机械防线(记录侧
* 已有 TC-SYSLOG-130/131/131b,UI 侧对应的一条在 test/host/ui_data TC-UIDATA-024,
* 两端各钉一次,谁中间插号谁编译不过 / 单测红)。
* 即便如此,适配层**始终显式逐项映射**、从不把 UI 编号强转成 syslog_id_t:
* 等值是今天的事实,不是页面可以依赖的契约(UI_W_REC_LOG_* 对页面仍是不透明号)。
*/
static const syslog_id_t s_log_map[UI_W_REC_LOG_N] = {
SYSLOG_RUN, SYSLOG_SELFTEST, SYSLOG_ACCIDENT, SYSLOG_ALARM,
SYSLOG_START, SYSLOG_OPER, SYSLOG_YK
};
#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L)
typedef char ui_task_log_order_check_t[
((UI_W_REC_LOG_RUN == (uint8_t)SYSLOG_RUN) &&
(UI_W_REC_LOG_SELFTEST == (uint8_t)SYSLOG_SELFTEST) &&
(UI_W_REC_LOG_ACCIDENT == (uint8_t)SYSLOG_ACCIDENT) &&
(UI_W_REC_LOG_ALARM == (uint8_t)SYSLOG_ALARM) &&
(UI_W_REC_LOG_START == (uint8_t)SYSLOG_START) &&
(UI_W_REC_LOG_OPER == (uint8_t)SYSLOG_OPER) &&
(UI_W_REC_LOG_YK == (uint8_t)SYSLOG_YK) &&
(UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT)) ? 1 : -1];
#endif
/*
* 函数功能:记录 reader 适配——UI 不透明编号 → syslog_id_t 后取条数
* 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:记录条数;未知编号返回 0(页面退化为空态,与合并前直调同形) uint16_t
* 限定条件:uiTask 上下文同步调用(记录页与清除记录页的绘制路径,无 I/O 之外开销:
* syslog_count 读的是文件长度/内存计数,不逐条读盘)
*/
static uint16_t ui_task_rec_count(uint8_t log_id) {
if (log_id >= (uint8_t)UI_W_REC_LOG_N) {
return 0u;
}
return (uint16_t)syslog_count(s_log_map[log_id]);
}
/*
* 函数功能:记录 reader 适配——读记录字段直通 sys_log(错误码不改写)
* 入口参数:log_id/idx/ts/ts_len/desc/desc_len - 契约见 ui_w_records.h ui_rec_reader_t
* 返回值:0 成功;-1 编号/序号越界(未知编号同 -1);-2 读失败 int
* 限定条件:同上
*/
static int ui_task_rec_get(uint8_t log_id, uint32_t idx, char *ts, uint32_t ts_len,
char *desc, uint32_t desc_len) {
if (log_id >= (uint8_t)UI_W_REC_LOG_N) {
return -1;
}
return syslog_get_record(s_log_map[log_id], idx, ts, (size_t)ts_len, desc,
(size_t)desc_len);
}
/*
* ============================ 道F task-3:装置维护写通道(UI 注入执行侧)
*
* 为什么在这一层实现而不是在页面里:页面属 Drivers/BSP/UI,反调 App 侧的
* sys_clock / prot_elm / sys_log 是分层方向的反例(layer-deps 拦),更关键的是
* **模拟器根本不链 sys_clock**(simulator/dbg_log.h:4「模拟器无 sys_clock/RTC」)——
* 页面里用一次就是"MCU 编得过、模拟器链接期爆"。写动作一律走 ui_data_maint_ops_t,
* 两端各给实现(口径 docs/UI设计/UI_数据供给设计说明.md §6.2/§7.2)。
*
* uiTask 阻塞上界(IWDG 800ms 窗、本任务在 required_mask 内):本通道里只有
* 时钟设置会碰硬件——sys_clock_set_unix 内含 1 次 SD2506 写,再叠加本适配层刻意
* 加的回读复核 1 次读,各 SD2506_I2C_TIMEOUT_MS=100ms ⇒ 最坏 ≈200ms 落在一次
* "保存"按键上(心跳在循环尾,仍远小于 800ms;对时任务同样在本机上做过这件事)。
* 复归/清除两条只写内存标志或投递给别的任务,无 I/O。
*/
/*
* 函数功能:设置墙钟并**回读复核**(真机 = 写 RAM 基准 + 写 SD2506 RTC)
* 入口参数:a_epoch - Unix 秒(本地时间域,与 sys_clock 同一口径,无时区偏移) uint32_t
* 返回值:0 - 已生效且 RTC 回读一致;-1 - 越界 / 未生效 / RTC 回读不符 int
* 限定条件:uiTask 上下文(页面"保存"按键直达,同步返回终态)
* 函数说明:⚠ **sys_clock_set() 恒返 0**(App/util/sys_clock.c:462 起:RTC 写失败
* 只打 DBG_ERROR,不改返回值),所以本函数不能拿它的返回值当结论——
* 回读是唯一真判据;而只回读 sys_clock_get() 证明的仅是"RAM 基准换了",
* 面板对时的用户意图是**掉电后还要是对的**,故必须回读 RTC 本身。
* RTC 坏了但墙钟已改时本函数返 -1(屏上"保存行=ERR + 值列跟着新时间",
* 两个事实各说各的,比笼统报 OK 诚实);秒位允许 ±3 与"分/时/日回绕"两种
* 形态,因为两次读之间墙钟与 RTC 都在走。
* 上限 UI_DATA_CLOCK_EPOCH_MAX(2038) 在 UI 层已挡,这里再挡一次:
* 执行侧不信任调用侧(0 还兼作"未建钟"哨兵,面板永远不该发 0)
*/
static int ui_task_maint_clock_set(uint32_t a_epoch) {
sd2506_time_t want;
sd2506_time_t got;
uint32_t now;
int ds;
if ((a_epoch == 0u) || (a_epoch > (uint32_t)UI_DATA_CLOCK_EPOCH_MAX)) {
return -1;
}
if (sys_clock_set_unix(a_epoch) != 0) {
return -1; /* 今天到不了这条腿(sys_clock_set 恒 0),留着是因为它是契约 */
}
now = sys_clock_get();
if ((now == UINT32_MAX) || ((now + 2u) < a_epoch) || (now > (a_epoch + 2u))) {
return -1; /* RAM 基准没跟上:连"现在就对"都没成立 */
}
if (sys_clock_get_time(&want) != 0) {
return -1;
}
if (sd2506_get_time(&got) != BSP_OK) {
return -1; /* RTC 读不动:掉电保持这件事无法证明 */
}
if ((got.year != want.year) || (got.month != want.month) || (got.day != want.day) ||
(got.hour != want.hour) || (got.minute != want.minute)) {
return -1;
}
ds = (int)got.second - (int)want.second;
if (ds < 0) {
ds = -ds;
}
if ((ds > 3) &&
!(((want.second >= 58u) && (got.second <= 3u)) ||
((got.second >= 58u) && (want.second <= 3u)))) {
return -1;
}
return 0;
}
/*
* 函数功能:请求强制复归(跨上下文:protectTask 下一拍消费)
* 入口参数:无
* 返回值:0 - 已投递(prot_elm_request_reset 无拒绝分支) int
* 限定条件:uiTask 上下文;不等待执行结果
* 函数说明:只投递不阻塞(契约:UI 页面二次确认后调,页面立刻给"已请求复归")。
* **不在这里投操作记录**:远端 104 YK11 那条已经在
* App/protocol/iec104/iec104_pts.c 投过,在这里再投一次就会给每一次
* 主站复归多记一条"面板复归",审计面直接失真。本地这条由页面投
* (只有页面知道"这是人按的"),措辞也不同串:见 Drivers/BSP/UI/ui_page_reset.c
*/
static int ui_task_maint_reset_req(void) {
prot_elm_request_reset();
return 0;
}
/*
* 函数功能:取当前处于动作态的保护元件个数(复归页观测行 "保护动作 N" 的 N)
* 入口参数:无
* 返回值:位图中置 1 的位数(纯 RAM 读,无锁) uint32_t
* 限定条件:uiTask 绘制路径每帧可直调
* 函数说明:给个数而不是位图,是为了让 160x160 上一行放得下、也让"复归前 N>0 →
* 复归后 N=0"这个台架判据肉眼可读;判据前提(产品构建处于未标定闭锁,
* App/task/protect_task.c:98 停走判据 ⇒ 闭锁期 N 恒 0)已写进页面注释
*/
static uint32_t ui_task_maint_active_count(void) {
uint32_t mask = prot_elm_active_mask();
uint32_t n = 0u;
while (mask != 0u) {
n += (mask & 0x1u);
mask >>= 1;
}
return n;
}
/*
* 函数功能:投一条本地操作记录(面板动作留痕)
* 入口参数:a_desc - 描述串(ASCII,长度须 <= SYSLOG_DESC_MAX=28) const char *
* 返回值:无
* 限定条件:uiTask 上下文;只在动作真正被受理之后由页面调用
* 函数说明:用 quiet 口(与 iec104_serv.c 对时留痕同一选择):uiTask 不属于深链
* 任务,但 quiet 口的收/丢判据与 syslog_post 一致且不在调用链上摊开
* 行缓冲,留痕这件事没理由选更贵的那个口。
* 丢条不回报页面:syslog 侧有累计丢弃计数(syslog_post_quiet_dropped())
* 可查;页面上"动作做了没"与"这条审计写没写"是两件事,不该混在一个
* 态标里——把后者做成 ERR 会让人以为复归没做而再按一次
*/
static void ui_task_maint_oper_note(const char *a_desc) {
if (a_desc == NULL) {
return;
}
syslog_post_quiet(SYSLOG_OPER, a_desc);
}
/*
* 函数功能:请求清除某一类记录(只投递,落地在 defaultTask 的 syslog_drain 尾部)
* 入口参数:a_log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:0 - 已受理(MCU 端随后通常是 SYSLOG_CLEAR_WAIT);-1 - 越界编号 int
* 限定条件:uiTask 上下文
* 函数说明:本层是 UI 编号 → syslog_id_t 的**唯一**映射点之一(与 s_log_map 同表),
* 绝不在 UI 侧做 f_unlink / 也不调 MCU 专属的 syslog_clear_apply():
* 记录卷的属主是 defaultTask,uiTask 去碰 FAT 既违反唯一写者,又给
* IWDG required_mask 内的任务添一条无上界的存储路径(口径 App/log/README.md)
*/
static int ui_task_maint_clear_req(uint8_t a_log_id) {
if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) {
return -1;
}
return (syslog_request_clear(s_log_map[a_log_id]) == 0) ? 0 : -1;
}
/*
* 函数功能:取上条清除请求的状态(值直通,不改写、不翻译)
* 入口参数:a_log_id - UI 记录源编号 uint8_t
* 返回值:UI_DATA_CLEAR_ST_NONE/_OK/_WAIT/_FAIL;越界编号按 NONE int
* 限定条件:uiTask 绘制路径每帧可直调(纯内存状态读)
* 函数说明:SYSLOG_CLEAR_* 与 UI_DATA_CLEAR_ST_* 逐值相等(0/1/-1/-2),由
* test/host/ui_data TC-UIDATA-024 钉住;这里保持直通,是为了让页面
* 显示的就是日志侧的事实,而不是"适配层以为的事实"
*/
static int ui_task_maint_clear_state(uint8_t a_log_id) {
if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) {
return UI_DATA_CLEAR_ST_NONE;
}
return syslog_clear_state(s_log_map[a_log_id]);
}
/* 维护通道函数表(六件齐全;ui_data_maint_bind 拒绝残表,缺件就是装配错) */
static const ui_data_maint_ops_t s_maint_ops = {
ui_task_maint_clock_set,
ui_task_maint_reset_req,
ui_task_maint_active_count,
ui_task_maint_oper_note,
ui_task_maint_clear_req,
ui_task_maint_clear_state
};
static const ui_rec_reader_t s_rec_reader = { ui_task_rec_count, ui_task_rec_get };
/*
* 函数功能:SOE 记录条数适配(道E task-15 reader 注入)——读落盘文件的完整槽数
* 入口参数:无
* 返回值:条数;超过 uint16 上限时钳到 65535(组件契约是 uint16_t) uint16_t
* 限定条件:uiTask 上下文同步调用(SOE 记录页绘制路径)
*/
static uint16_t ui_task_soe_count(void) {
uint32_t n = soe_store_count();
return (n > 65535u) ? 65535u : (uint16_t)n;
}
/*
* 函数功能:读第 idx 条 SOE 记录并翻成 UI 视图(二进制 → 不透明显示字段)
* 入口参数:idx - 记录序号 uint32_t
* p_out - UI 侧记录视图(契约见 Drivers/BSP/UI/ui_w_soe.h) ui_w_soe_rec_t *
* 返回值:0 - 成功;-1 - 读失败 / 卷忙 / 校验和不过(页面这行退化显示,不猜) int
* 限定条件:uiTask 绘制路径;soe_store_read() 是 try 锁非阻塞,绝不拖住 UI 节拍
* 函数说明:时标**取记录内的 CP56**(事件自己的时间),不用"现在"换算。两条口径
* 叠起来正好与文本记录页一致,别"顺手补 8 小时":
* ① sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
* sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)");
* ② App/log/soe_rec.c 的 epoch→CP56 分解不加任何时区偏移。
* 所以这里打出来的墙上时钟 == 运行/自检记录页里 sys_clock_get_str() 的口径;
* 哪天真要引入显式时区,两侧必须一起改(跨模块陷阱,注释即防线)。
* 时钟无效(IV)不写在这里:它是显示语义,由 UI 侧按 SOE_FLG_IV 决定
* (组件把那行名字打"--"、值打"时钟无效"),App 只交代原始记录。
*/
static int ui_task_soe_get(uint32_t idx, ui_w_soe_rec_t *p_out) {
soe_rec_t rec;
uint32_t sec;
if ((p_out == NULL) || (soe_store_read(idx, &rec) != 0)) {
return -1;
}
/* CP56 字节表见 App/log/soe_rec.c 的 s_soe_epoch_to_cp56:
* [0..1]=分内毫秒(秒域时基 → 恒为秒×1000,亚秒不造假)[2]=分 [3]=时
* [4]=日 [5]=月 [6]=年-2000 */
sec = (((uint32_t)rec.cp56[1] << 8) | (uint32_t)rec.cp56[0]) / 1000u;
(void)snprintf(p_out->ts, sizeof(p_out->ts), "%04u-%02u-%02u %02u:%02u:%02u",
(unsigned)(2000u + ((uint32_t)rec.cp56[6] & 0x7Fu)),
(unsigned)((uint32_t)rec.cp56[5] & 0x0Fu),
(unsigned)((uint32_t)rec.cp56[4] & 0x1Fu),
(unsigned)((uint32_t)rec.cp56[3] & 0x1Fu),
(unsigned)((uint32_t)rec.cp56[2] & 0x3Fu),
(unsigned)sec);
p_out->type = rec.type;
p_out->elm = rec.elm_id;
p_out->flags = rec.flags;
p_out->seq = rec.seq;
p_out->value = rec.value;
return 0;
}
static const ui_soe_reader_t s_soe_reader = { ui_task_soe_count, ui_task_soe_get };
/*
* 函数功能:推流往返边界的心跳续命钩(F3,注册给 ui_net_sink)
* 入口参数:无
* 返回值:无
* 限定条件:uiTask 轮询上下文(sink 在往返返回处调用),只做置位、不阻塞
* 函数说明:为何经回调注入而非 sink 直调 task_wd_beat()——分层边界(BSP 不得
* include App 头,layer-deps 门禁);心跳属主仍是本任务。
*/
static void ui_task_sink_beat(void) {
task_wd_beat(TASK_WD_UI);
}
#define DBG_TAG "[UI_TASK]"
#include "dbg_log.h"
@@ -102,13 +421,30 @@ void ui_task_func(void *argument) {
for (;;) { osDelay(1000); }
}
DBG_INFO("UI port init: OK");
/* 记录 reader 注入:必须在 ui_menu_init 之前(记录页在此前不可能被打开,
* 但契约把"启动期先绑定"写死;未绑定窗口组件按空态防御——见 ui_w_records.h) */
ui_w_records_bind(&s_rec_reader);
ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页数据入口(未绑定按"无记录"空态) */
/* 装置维护写通道注入(道F task-3:时钟设置 / 强制复归 / 清除记录):同样必须先于
* ui_menu_init。未注入的窗口里六个入口一律安全返回(页面显 ERR 或空态),
* 既不崩也不假成功;但残表是真故障,必须能在启动日志里看见,不能静默 */
if (ui_data_maint_bind(&s_maint_ops) != 0) {
DBG_ERROR("UI maint bind: FAIL");
}
/* 快照层复位:必须先于 ui_menu_init(首帧之前任何页面被打开都按"无数据"
* 渲染 "--",不会读到上电残留)——UI_数据供给设计说明.md §5.3 */
ui_data_init();
/* 首帧立即发布:待机屏/菜单屏在启动早期就可能被远端推流打开,等 200 ms
* 会闪一下 "--"(同文档 §5.3);本调用在 ui_menu_init 之前,页面尚未建立,
* 发布路径不触 LVGL,安全 */
ui_data_feed_tick();
DBG_INFO("UI task: ui_menu_init enter");
if (ui_menu_init() != 0) {
DBG_ERROR("UI menu init: FAIL");
for (;;) { osDelay(1000); }
}
DBG_INFO("UI menu init: OK");
if (ui_standby_init(60000u, ui_task_time_cb) != 0) {
if (ui_standby_init(UI_STANDBY_IDLE_MS, ui_task_time_cb) != 0) {
DBG_ERROR("UI standby init: FAIL");
} else {
DBG_INFO("UI standby: ready (idle=60s)");
@@ -118,6 +454,9 @@ void ui_task_func(void *argument) {
} else {
DBG_INFO("UI net sink: started (port %u)", (unsigned)UI_NET_SINK_PORT);
}
/* F3:往返边界心跳钩(契约见 ui_net_sink.h;须在首轮 poll 之前注册)。
* start 失败也要注:失败路径的 close/重建重试同样经往返 */
ui_net_sink_set_beat_cb(ui_task_sink_beat);
#if defined(TEST_LATENCY_PROBE)
/* M3 探针 ui_loop:主循环实测周期(= LVGL 渲染 + 推流轮询 + osDelay(10))。
@@ -127,7 +466,7 @@ void ui_task_func(void *argument) {
uint8_t tm_loop_valid = 0U;
#endif
/* P2 常开探针(Task #32):TEST_LATENCY_PROBE 只在 CH395F 套件构建下打开,
/* 常开探针:TEST_LATENCY_PROBE 只在 CH395F 套件构建下打开,
* 且样本消费者是 testTask(产品构建里不跑)→ 产品构建拿不到任何 UI 周期数据。
* 故此处独立埋点,恒定生效、只写本模块的静态计数,由 defaultTask 周期上报。 */
@@ -149,13 +488,20 @@ void ui_task_func(void *argument) {
lat_mark(&tm_loop);
tm_loop_valid = 1U;
#endif
ui_port_step(10);
/* 心跳**先于**推流往返发出(P1-4a,看门狗设计说明 §11.3):UI 心跳测的是
/* 数据装配节拍(200 ms 内部自分频,未到期时只比一次 tick):
* 排在渲染之前,本轮绘制的就是本轮刚发布的那一帧
* (UI_数据供给设计说明.md §5.1 单写者同上下文的前提就在这儿) */
ui_data_feed_tick();
ui_port_step(UI_TASK_TICK_MS);
/* 心跳**先于**推流往返发出(判读口径 docs/子系统设计/看门狗设计说明.md §11.3):UI 心跳测的是
* "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后,
* 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后,
* 即使往返偶发变慢,心跳也不被其拖垮。
* ⚠ 若往返**永久**阻塞(xTaskNotifyWait portMAX_DELAY 不返回),本改只能发
* 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头 v1.3 说明。 */
* 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头修改记录 v1.3。
* v1.4 补充:sink 内每个往返**返回点**已各自补喂(set_beat_cb 注入),
* 「单轮多个慢往返之和」不再构成缺席链;剩单往返 >800ms 才报——那是
* netTask 自身停摆,按位判据抓对了人(上界归「net 层跨上下文收口」)。 */
task_wd_beat(TASK_WD_UI);
/* 推流轮询段单独 enter/exit:它内部经 net_accept → net_wait_result 走到
* xTaskNotifyWait(portMAX_DELAY) 的**无限期阻塞**。若 uiTask 永久卡在这里,
@@ -167,6 +513,6 @@ void ui_task_func(void *argument) {
/* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */
task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0);
osDelay(10);
osDelay(UI_TASK_TICK_MS);
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
> 两模块**零调用者**且未编入任何构建——CRC 从未被链接使用:名义上的关联点 `net_cfg_legacy.c`
> (旧档 `/cfg/netcfg.bin` 迁移,**该文件亦已于 2026-10-10 删除**)自带静态 `s_crc16_modbus()`,
> 从不调用本模块;环形缓冲由 `App/log/console_log.c` 内联实现(`s_con_ring`)。
> 保留只会造成"有统一实现"的误导,故整体移除(详见 `docs/任务规划/排期详述.md` E-15(C5))。
> 保留只会造成"有统一实现"的误导,故整体移除。
## 设计要点
+1 -1
View File
@@ -5,7 +5,7 @@
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;标准向量由
* v1.0 2026-10-11 王建锋 创建初始版本(FTP 鉴权;标准向量由
* test/host/sha256 主机单测钉死)
*/
+1 -1
View File
@@ -7,7 +7,7 @@
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权:[ftp] pwd_hash 校验)
* v1.0 2026-10-11 王建锋 创建初始版本(FTP 鉴权:[ftp] pwd_hash 校验)
*/
#ifndef SHA256_H
+5 -5
View File
@@ -77,7 +77,7 @@ static volatile uint32_t s_base_unix_secs = UINT32_MAX;
* 修改记录:
* 2026-10-02 修正:原式错用 (wy%100 + wy/100/4 - wy/100/15)(缺 K/4、
* 5J/世纪项,且把 1-based 月当 2-based 用),实测 1970~2099
* 全部日期星期皆错(主机侧单测 TC-CLK-014/019 发现)。
* 全部日期星期皆错(主机侧单测 TC-SYSCLK-014/019 发现)。
*/
static uint8_t rtc_calc_week(uint16_t y, uint8_t m, uint8_t d) {
uint32_t wy = y;
@@ -206,7 +206,7 @@ int sys_clock_init(void) {
* 驱动内部已 +2000)。此处【禁止】再 +2000,否则年份虚增到 4026,
* 换算出的 Unix 秒数远超 uint32_t 上限被截断回绕,时间彻底错乱。
* 返回值必须检查:I2C 失败时字段为垃圾,不得用于建立基准。 */
rtc_ok = (sd2506_get_time(&sd_time) == SD2506_OK) ? 1 : 0;
rtc_ok = (sd2506_get_time(&sd_time) == BSP_OK) ? 1 : 0;
/* ============================================================
* Step 1.1:开发期校时(可选,由 RTC_DEFAULT_INIT_* 宏控制)
@@ -235,11 +235,11 @@ int sys_clock_init(void) {
init_t.week = rtc_calc_week(init_t.year, init_t.month, init_t.day);
/* 写回 SD2506:断电后该时间被保留,下次上电即为有效值 */
if (sd2506_set_time(&init_t) == SD2506_OK) {
if (sd2506_set_time(&init_t) == BSP_OK) {
DBG_INFO("sys_clock_init: RTC set %04d-%02d-%02d %02d:%02d:%02d",
init_t.year, init_t.month, init_t.day,
init_t.hour, init_t.minute, init_t.second);
rtc_ok = (sd2506_get_time(&sd_time) == SD2506_OK) ? 1 : 0; /* 重新读取校准后的值(同样检查返回值) */
rtc_ok = (sd2506_get_time(&sd_time) == BSP_OK) ? 1 : 0; /* 重新读取校准后的值(同样检查返回值) */
} else {
DBG_ERROR("sys_clock_init: RTC default write FAIL");
}
@@ -460,7 +460,7 @@ int sys_clock_set(uint32_t unix_secs, const sd2506_time_t *sd_time) {
/* Step 2: 写回 SD2506 RTC(若提供了 sd_time) */
if (sd_time != NULL) {
if (sd2506_set_time(sd_time) == SD2506_OK) {
if (sd2506_set_time(sd_time) == BSP_OK) {
DBG_INFO("sys_clock_set: SD2506 RTC written");
} else {
DBG_ERROR("sys_clock_set: SD2506 RTC write FAIL");
+1 -1
View File
@@ -7,7 +7,7 @@
* 作者:王建锋
* 创建日期:2026-10-10
* 修改记录:
* v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点)
* v1.0 2026-10-10 王建锋 创建:常开任务耗时探针
*
* 设计依据:docs/子系统设计/看门狗设计说明.md §11.4(探针字段语义与实测基线)
*/
+1 -1
View File
@@ -9,7 +9,7 @@
* 作者:王建锋
* 创建日期:2026-10-10
* 修改记录:
* v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点)
* v1.0 2026-10-10 王建锋 创建:常开任务耗时探针
*
* 设计背景(docs/子系统设计/看门狗设计说明.md §11.4):
* uiTask 偶发心跳缺席导致 IWDG 复位,但"占用者"无法从既有日志判定。
+115 -47
View File
@@ -8,26 +8,34 @@
* 创建日期:2026-09-29
* 修改记录:
* v1.0 2026-09-29 王建锋 创建:心跳位图 + 抽样判定 + IWDG 延后启动
* v1.1 2026-10-02 王建锋 round 7 纯取证埋点:近 8 次抽样位图环 + 最大喂狗
* v1.1 2026-10-02 王建锋 第 7 轮纯取证埋点:近 8 次抽样位图环 + 最大喂狗
* 间隔统计 + 停喂现场落 /log/wdev.log(掉电不丢);
* 全部在 TASK_WATCHDOG_UNIT_TEST 守卫内,判定状态机
* 与主机侧单测逐字不变(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)
* v1.2 2026-10-02 王建锋 round 8 取证:复位不丢的"进段打点"(.noinit 槽 +
* PREVRUN 开机上报 + STALL 行附 seg)——round 7 结论为
* v1.2 2026-10-02 王建锋 第 8 轮取证:复位不丢的"进段打点"(.noinit 槽 +
* PREVRUN 开机上报 + STALL 行附 seg)——第 7 轮结论为
* "poller 自身停摆",需定位 defaultTask 主循环阻塞点
* v1.3 2026-10-02 王建锋 round 8b 取证:第二个 .noinit 槽"时间源存活性"
* v1.3 2026-10-02 王建锋 第 8b 轮取证:第二个 .noinit 槽"时间源存活性"
* (TIM7 ISR 每 1ms 写 uwTick/FreeRTOS tick/计数)+
* TICKRUN 开机上报(含相对进段时刻的增量 duw/dfr)——
* round 8a 定位到 console 通道后,需钉死是 TIM7(HAL tick)
* 第 8a 轮定位到 console 通道后,需钉死是 TIM7(HAL tick)
* 还是 SysTick(FreeRTOS tick) 停摆(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)
*
* v1.5 2026-10-25 DTU 项目组 **停喂判定按位分账**(大窗判读 F2):stall/停喂的唯一
* 判据从"复合连续缺席窗数"改为"任一必需位的连续缺席窗数"
* (同一任务 800ms 没跑过才停喂申报复位);复合缺席窗仍照打
* "MISS start"(降级为诊断行,不再代表停喂)——根治"本窗缺 A、
* 下窗缺 B"式互不相干亚秒抖动被累计成复合 800ms 假停摆并停喂
* (2026-10-25 大窗 16:24/16:28 两起实证;真单任务停摆如
* 16:39 型 UI 单串 800ms+ 仍必报)。判据正文同步
* docs/子系统设计/看门狗设计说明.md §4;主机单测 TC-WATCHDOG-017/018
* v1.4 2026-10-02 王建锋 **P0 修复**:wd_platform_start() 补写 IWDG 的
* PR/RLR(= WD_IWDG_PRESCALER / WD_IWDG_RELOAD,即设计值
* /64 + 1249 → 1701~2498ms)。此前为"延后启动"删掉
* HAL_IWDG_Init() 时,一并丢掉了**唯一**写这两只寄存器的
* 路径 → 实机停在复位默认值 PR=0/RLR=4095(≈349~512ms),
* 比设计短约 5 倍 → 800ms 容忍分支成死代码、任何 >400ms
* 心跳缺口直接 IWDG 误复位(round 3~8 全部"空 STOR 必
* 心跳缺口直接 IWDG 误复位(第 3~8 轮全部"空 STOR 必
* 复位"的共同根因)。SWD 现场改回设计值后同一用例 100% 通过
*
* 设计依据:docs/子系统设计/看门狗设计说明.md
@@ -56,8 +64,18 @@
#include "FreeRTOS.h" /* xTaskGetCurrentTaskHandle / TaskHandle_t(FTL 心跳路由) */
#include "task.h"
#include "lftpd.h" /* lftpd_task_wd_beat() 弱钩子声明(本文件提供强符号,见下方) */
#include "sys_log.h" /* SYSLOG_RUN(flush 诱导落盘)+ sys_log_evidence()(round 7 取证落盘) */
#include "sys_log.h" /* SYSLOG_RUN(flush 诱导落盘)+ sys_log_evidence()(第 7 轮取证落盘) */
#include "console_log.h" /* dbg_console_tx_fault()(僵尸通道计数) */
/* 测试构建可见性(2026-10-25 台架实测缺陷):wd_required_mask() 里那条
* ENABLE_HW_LAYER_TESTS 分支要按"硬件层独占测试构建"剔除 netTask,而该宏定义在
* test/board/ch395f_test.h(由 test_config.h 拉入)。本文件此前没 include 它 →
* 宏在本 TU 永远不可见 → **那条分支自加入起就是死代码**,HW 层测试构建仍按产品
* 掩码 0x1F7 要求 netTask 供位,而 netTask 按设计自挂起 → 每 ~110ms 刷一条
* [ERR][WD] task stall(台架一轮刷出 468 条)。做法与 App/task/net_task.c 的
* #include "test_config.h" 一致;产品构建 TEST_SUITE_* 全注释、宏未定义,
* 走的仍是原分支(实测产品固件 Code/RO/RW/ZI 逐项不变)。
* 证据:boot log 20261025_050539(req=0x1F7 而 mask 最高只到 0x1F5)。 */
#include "test_config.h"
#endif
#define DBG_TAG "[WD]"
@@ -101,14 +119,22 @@
* 单字读-改-写在 Cortex-M4 上天然原子,无需临界区(§3.3) */
static volatile uint32_t s_hb_bits;
static uint32_t s_miss_streak; /* 连续未达标轮数(达到容忍上限即停喂) */
static uint32_t s_miss_streak; /* 复合缺席连续窗数(饱和封顶)——自 v1.5 起仅作
* "MISS start" 诊断行的 episode 边沿检测,不再
* 参与停喂判定(停喂按 s_miss_bits 逐位分账) */
/* 按位分账(2026-10-25 大窗判读 F2):每个必需位"连续缺席抽样窗数"。位出现即清零,
* 缺席饱和到容忍值。停喂/stall 声明的唯一判据 = 任一必需位连击 >= WD_MISS_TOLERANCE,
* 即"同一个任务连续 8 个窗没跑过"。多任务各缺各的(本窗 A、下窗 B)各自清零,
* 不再串成复合 800ms 停喂——旧复合判据把互不相干的亚秒抖动累计成真停摆申报
* (2026-10-25 大窗 16:24/16:28 两起实证;判据正文 docs/子系统设计/看门狗设计说明.md §4)。 */
static uint8_t s_miss_bits[TASK_WD_ID_MAX];
static uint32_t s_miss_total; /* 累计未达标轮数(观测用) */
static uint32_t s_last_mask; /* 上次抽样到的位图(定位卡死任务用) */
static uint32_t s_started; /* 1 = IWDG 已启动 */
static uint32_t s_last_tick; /* 上次抽样时刻(100ms 节流基准) */
static uint32_t s_stall_evidenced; /* 1 = 本停摆 episode 已落盘证据(门闩;见 task_wd_poll) */
/* ==== round 7 纯取证状态(仅 MCU 构建;判定状态机逐字不变)====
/* ==== 第 7 轮纯取证状态(仅 MCU 构建;判定状态机逐字不变)====
* 设计背景见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:误复位后 RAM 态随复位消失,
* 故把"停喂现场"快照落盘(/log/wdev.log)供复位后回读定性。 */
#ifndef TASK_WATCHDOG_UNIT_TEST
@@ -119,7 +145,7 @@ static uint32_t s_last_feed_tick; /* 上次喂狗时刻(ms) *
static uint32_t s_max_feed_gap; /* 观测到的最大喂狗间隔(ms) */
static uint32_t s_feed_seen; /* 1 = 已记录首个喂狗时刻 */
/* ==== round 8 取证:复位不丢的"进段打点"槽(.noinit → SRAM 保留)====
/* ==== 第 8 轮取证:复位不丢的"进段打点"槽(.noinit → SRAM 保留)====
* IWDG/引脚复位不改变 SRAM 内容,故复位后仍能读出"上一轮 defaultTask 主循环
* 最后走到哪一段"。magic 用于区分"本模块写过"与上电时的随机值。
* 布局:[0]=magic [1]=段号 [2]=进段时刻(ms) [3]=循环序号
@@ -129,7 +155,7 @@ static uint32_t s_feed_seen; /* 1 = 已记录首个喂狗时
#define WD_SEG_MAGIC 0x57445347u /* 'WDSG' */
__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_seg_trail[4];
/* ==== round 8b 取证:复位不丢的"时间源存活性"槽(.noinit → SRAM 保留)====
/* ==== 第 8b 轮取证:复位不丢的"时间源存活性"槽(.noinit → SRAM 保留)====
* 两个槽(均在 UNINIT_CCM,复位不清):
* s_wd_tick_cur [0]=magic [1]=uwTick(最新) [2]=本轮 TIM7 中断计数 [3]=保留
* —— TIM7 ISR 每 1ms 更新("本轮活动值")
@@ -212,7 +238,7 @@ static uint32_t wd_required_mask(void) {
#ifndef TASK_WATCHDOG_UNIT_TEST
/*
* 函数功能:记录喂狗时刻并累计最大喂狗间隔(round 7 纯取证)
* 函数功能:记录喂狗时刻并累计最大喂狗间隔(第 7 轮纯取证)
* 入口参数:无
* 返回值:无
* 限定条件:由 wd_platform_feed() 内调用(任意喂狗路径均经过)
@@ -234,7 +260,7 @@ static void wd_feed_track(void) {
}
/*
* 函数功能:把停喂现场落盘到 /log/wdev.log(round 7 纯取证)
* 函数功能:把停喂现场落盘到 /log/wdev.log(第 7 轮纯取证)
* 入口参数:snapshot - 本次抽样到的位图 uint32_t
* 返回值:无
* 限定条件:task_wd_poll 停喂分支(defaultTask 上下文)
@@ -266,20 +292,25 @@ static void wd_stall_evidence(uint32_t snapshot) {
/* 裸 UART 直发(不经环形缓冲/不依赖 defaultTask 排空):停喂窗口里
* 环形缓冲的排空本身可能停摆,裸发是本行证据的可靠通道(与盘上
* 证据互补——卷锁被阻塞方持有时盘上通道会失败,见 §0.5.4 判据缺口) */
/* 整行独占(道G task-26):本行两条裸发(正文 + CRLF),段间原本是缝;这行是
* 停喂判据的核心证据(盘上通道在卷锁被阻塞方持有时会失败,串口是唯一保底)。
* begin 非阻塞:拿不到也照发,停喂现场绝不能因为抢锁而丢。 */
(void)dbg_console_tx_begin();
dbg_console_write_raw(line, (unsigned)strlen(line));
dbg_console_write_raw("\r\n", 2u);
dbg_console_tx_end();
(void)sys_log_evidence(line);
}
}
/* ==================== round 8 取证:复位不丢的"进段打点" ====================
* 见 task_watchdog.h 的说明(round 7 初判"poller 自身停摆"经 round 8b 更正:
/* ==================== 第 8 轮取证:复位不丢的"进段打点" ====================
* 见 task_watchdog.h 的说明(第 7 轮初判"poller 自身停摆"经第 8b 轮更正:
* 真因是 IWDG 超时被静默配成复位默认值,见 docs/子系统设计/看门狗设计说明.md
* 2026-10-02 变更记录)→ 需定位 defaultTask 主循环冻结在哪个调用点。
* 槽放 .noinit(scatter UNINIT_CCM):IWDG/引脚复位不改变 SRAM,复位后仍可读。 */
/*
* 函数功能:记录"即将进入的主循环段"到复位不丢的 .noinit 槽(round 8 取证)
* 函数功能:记录"即将进入的主循环段"到复位不丢的 .noinit 槽(第 8 轮取证)
* 入口参数:seg - 段号(task_wd_seg_t) uint32_t
* 返回值:无
* 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写)
@@ -294,7 +325,7 @@ void task_wd_seg(uint32_t seg) {
}
/*
* 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证)
* 函数功能:开机快照上一轮的"时间源存活性"(第 8b 轮取证)
* 入口参数:无
* 返回值:无
* 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1)
@@ -320,7 +351,7 @@ void task_wd_tick_snapshot(void) {
}
/*
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证)
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(第 8b 轮取证)
* 入口参数:无
* 返回值:无
* 限定条件:TIM7 中断上下文调用(HAL_TIM_PeriodElapsedCallback 内,每 1ms)
@@ -337,13 +368,13 @@ void task_wd_tick_probe(void) {
}
/*
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证)
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(第 8 轮取证)
* 入口参数:无
* 返回值:无
* 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发)
* 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=<ms>`(裸 UART,
* 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。
* round 8b:紧接着打 `TICKRUN uw= n= duw=`——uw 为 TIM7 最后一次记下的
* 第 8b 轮:紧接着打 `TICKRUN uw= n= duw=`——uw 为 TIM7 最后一次记下的
* HAL tick、n 为 TIM7 中断计数、duw = uw - t(时间源是否活到复位)。
*/
void task_wd_seg_report(void) {
@@ -365,7 +396,7 @@ void task_wd_seg_report(void) {
}
s_wd_seg_trail[0] = 0u; /* 一次复位只报一次 */
/* round 8b:时间源存活性(取自开机快照 prev,增量相对上一行的 t = 最后进段时刻)。
/* 第 8b 轮:时间源存活性(取自开机快照 prev,增量相对上一行的 t = 最后进段时刻)。
* duw≈0 → TIM7 与 defaultTask 同刻停摆(HAL 时基停摆);duw≈2000+ → TIM7 活到
* 复位(冻结另有阻塞点);n = 上一轮 TIM7 中断总数(≈该轮存活毫秒数)。 */
if (s_wd_tick_prev[0] == WD_TICK_MAGIC) {
@@ -429,15 +460,22 @@ void task_wd_start(void) {
* 入口参数:无
* 返回值:无
* 限定条件:仅 defaultTask 主循环调用(唯一喂狗者);内部节流到 100ms 一次
* 函数说明:取走并清零位图 → 与必需掩码比较:
* 全中 → 清零缺失计数并喂狗;
* 有缺 → 累计;连续达到 WD_MISS_TOLERANCE 次后**停止喂狗**,
* 让 IWDG 自然溢出复位(这样 RCC_CSR 记录的是权威的
* IWDGRSTF,而不是 NVIC_SystemReset 留下的 SFTRSTF —— §5.3)。
* 函数说明:取走并清零位图 → 与必需掩码比较(v1.5 起按位分账,判据推导见设计说明 §4):
* 全中 → 清零复合边沿与证据门闩并喂狗;
* 有缺但每位连击 < 容忍 → **继续喂狗**(单窗/亚秒级缺席是负载抖动,不是
* 停摆;只按位记账并在复合边沿打一条诊断行);
* 任一位连击 >= WD_MISS_TOLERANCE(同一任务 800ms 没跑过)= 真停摆 →
* 申报 [WD] task stall 并**停止喂狗**,让 IWDG 自然溢出复位
* (RCC_CSR 记权威 IWDGRSTF 而非 SFTRSTF —— §5.3)。
* ⚠ 判定故意不用"复合连续缺席窗数"当停喂门槛:那会把"本窗缺 A、下窗缺
* B"这类各自远低于容忍度的抖动串成假停摆(2026-10-25 大窗实证)。
*/
void task_wd_poll(void) {
uint32_t now;
uint32_t snapshot;
uint32_t mask_now;
uint32_t worst;
uint32_t i;
if (s_started == 0u) {
return; /* 未启动:不做任何判定 */
@@ -454,51 +492,72 @@ void task_wd_poll(void) {
s_last_mask = snapshot;
#ifndef TASK_WATCHDOG_UNIT_TEST
/* round 7 取证:留存近 8 次抽样位图(环形),停喂时随证据落盘 */
/* 第 7 轮取证:留存近 8 次抽样位图(环形),停喂时随证据落盘 */
s_snap_hist[s_snap_hist_idx] = snapshot;
s_snap_hist_idx = (s_snap_hist_idx + 1u) % WD_SNAP_HIST_DEPTH;
#endif
if (snapshot == wd_required_mask()) {
mask_now = wd_required_mask();
/* 按位分账:位出现→该位清零;缺席→+1(饱和封顶)。掩码外的位不入账
* (监视范围随构建配置变化时,账本自动跟随,不会留旧账)。 */
worst = 0u;
for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) {
if ((mask_now & (1u << i)) == 0u) {
s_miss_bits[i] = 0u;
continue;
}
if ((snapshot & (1u << i)) != 0u) {
s_miss_bits[i] = 0u;
} else if (s_miss_bits[i] < (uint8_t)WD_MISS_TOLERANCE) {
s_miss_bits[i]++;
}
if ((uint32_t)s_miss_bits[i] > worst) {
worst = (uint32_t)s_miss_bits[i];
}
}
if (snapshot == mask_now) {
s_miss_streak = 0u;
s_stall_evidenced = 0u; /* 恢复:允许下次停摆再落一次证据 */
wd_platform_feed(); /* 全员健康:喂狗 */
return;
}
/* 有任务缺席:累计(不喂狗) */
/* 本窗有缺席位(复合缺席):记总数;**复合边沿**(上一窗全中)只打一条
* 诊断标记行——v1.5 起该行不再意味着停喂,停喂只看按位连击 */
s_miss_total++;
if (s_miss_streak < WD_MISS_TOLERANCE) {
s_miss_streak++;
}
if (s_miss_streak == 0u) {
/* 复合缺席 episode 起点裸 UART 标记(每 episode 一条):证明 poller 仍在
* 抽样、并给出**首次**缺席位图——定位谁先停跑(第 7 轮出身)。⚠ v1.5 起
* 本行为**纯诊断**——复合边沿 ≠ 停喂,stall 判定权在下面的按位连击。 */
#ifndef TASK_WATCHDOG_UNIT_TEST
if (s_miss_streak == 1u) {
/* 停喂起点裸 UART 标记(每次缺席 episode 仅一条):证明 poller 仍在
* 抽样、并给出**首次**缺席的位图——定位是哪一任务先停摆(round 7) */
char m[96];
int mn = snprintf(m, sizeof(m),
"MISS start snap=0x%08X req=0x%08X t=%lu\r\n",
(unsigned)snapshot, (unsigned)wd_required_mask(),
(unsigned)snapshot, (unsigned)mask_now,
(unsigned long)now);
if (mn > 0) {
dbg_console_write_raw(m, (unsigned)strlen(m));
}
}
#endif
}
if (s_miss_streak < WD_MISS_TOLERANCE) {
s_miss_streak++; /* 饱和封顶;仅供复合 episode 边沿 */
}
if (s_miss_streak >= WD_MISS_TOLERANCE) {
/* 连续 800ms 有任务未动过 → 已停喂 → 约 2.5s 后 IWDG 复位。
* 最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */
if (worst >= WD_MISS_TOLERANCE) {
/* 同一位连续缺席 8 窗(同一任务 800ms 没跑过)= 真停摆 → 停喂 → 约 2.5s
* 后 IWDG 复位。最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */
DBG_ERROR("[WD] task stall: mask=0x%08X req=0x%08X miss=%lu",
(unsigned)snapshot,
(unsigned)wd_required_mask(),
(unsigned long)s_miss_total);
#ifndef TASK_WATCHDOG_UNIT_TEST
/* round 7 取证:把现场落 /log/wdev.log(掉电不丢)。
/* 第 7 轮取证:把现场落 /log/wdev.log(掉电不丢)。
* 串口 stall 行经僵尸通道可能丢失,故此处独立留盘上证据。
* ⚠ 本 episode 只写**一次**(s_stall_evidenced 门闩;注意 s_miss_streak
* 达上限后会**饱和**在容忍值,不能用它判"首次"):证据落盘经
* ⚠ 本 episode 只写**一次**(s_stall_evidenced 门闩;注意 worst 连击达
* 上限后会**饱和**在容忍值,不能用它判"首次"):证据落盘经
* sys_log_evidence() 走 NAND 写 → FTL 路径调 nand_ftl_wd_feed()
* **直接喂狗**。若每轮 100ms 都写,等于在"停喂"窗口里持续喂狗 →
* IWDG 永不溢出 → 停喂语义被证据机制自身抵消(2026-10-03 台架 V2
@@ -510,6 +569,11 @@ void task_wd_poll(void) {
wd_stall_evidence(snapshot);
}
#endif
} else {
/* 有缺席位、但每一位连击都未到容忍值(含"本窗缺 A、下窗缺 B"的交替型):
* 照常喂狗——多任务各自亚秒抖动不再串成复合停喂(按位分账收口的正身,
* 2026-10-25 大窗 16:24/16:28 型事件的根治)。 */
wd_platform_feed();
}
}
@@ -556,7 +620,7 @@ uint32_t task_wd_is_armed(void) {
*/
__weak void wd_platform_feed(void) {
IWDG->KR = WD_IWDG_KEY_FEED; /* 重装计数器(喂狗) */
wd_feed_track(); /* round 7 取证:记录喂狗时刻/最大间隔 */
wd_feed_track(); /* 第 7 轮取证:记录喂狗时刻/最大间隔 */
}
/* ==== 为什么 IWDG 参数必须在这里写(2026-10-02 台架实测,P0 根因)====
@@ -568,10 +632,10 @@ __weak void wd_platform_feed(void) {
* 实测读数:`IWDG->PR = 0`、`IWDG->RLR = 4095` → 实际超时 4×4096/LSI
* ≈ 349~512ms,比设计的 1701~2498ms **短约 5 倍**。后果有二:
* ① `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位
* → 停喂判定分支成为死代码(这正是 round 7 观察到"STALL 从未执行"
* → 停喂判定分支成为死代码(这正是第 7 轮观察到"STALL 从未执行"
* 的原因,当时误判为"poller 自身停摆");
* ② 任何 >400ms 的心跳缺口都直接 IWDG 误复位。空 STOR(0 字节截断)
* → dhara/FTL flush 使 NET/FTP/UI 心跳缺 ~400ms → 复位(round 3~8
* → dhara/FTL flush 使 NET/FTP/UI 心跳缺 ~400ms → 复位(第 3~8 轮
* 全部"空 STOR 必复位"的共同根因,P0-B 出口阻塞点)。
* 实测验证:SWD 现场把 PR/RLR 改成 4/1249 后,同一个空 STOR 由 5/5 复位
* 变为 100% 成功(`226 Closing data connection.`)。
@@ -714,8 +778,12 @@ void nand_ftl_wd_feed(void) {
*/
#ifdef TASK_WATCHDOG_UNIT_TEST
void task_wd_test_reset(void) {
uint32_t i;
s_hb_bits = 0u;
s_miss_streak = 0u;
for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) {
s_miss_bits[i] = 0u; /* 按位账本一并清零(v1.5 判定输入) */
}
s_miss_total = 0u;
s_last_mask = 0u;
s_started = 0u;
+10 -10
View File
@@ -85,10 +85,10 @@ uint32_t task_wd_miss_count(void); /* 累计未达标轮数 */
uint32_t task_wd_is_armed(void); /* 1 = IWDG 已启动 */
/*
* ==================== round 8 取证:复位不丢的"进段打点" ====================
* 背景(2026-10-02 台架 round 7~8 取证,完整链条见
* ==================== 第 8 轮取证:复位不丢的"进段打点" ====================
* 背景(2026-10-02 台架第 7~8 轮取证,完整链条见
* docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录):误复位时判定分支(STALL)从未
* 执行,当时判为**喂狗点 task_wd_poll 自身停摆**(round 8b 已更正:真因是 IWDG 超时
* 执行,当时判为**喂狗点 task_wd_poll 自身停摆**(第 8b 轮已更正:真因是 IWDG 超时
* 被静默配成复位默认值),而非某个被监视任务饿死。RAM 态随复位消失、串口又可能来不及打,
* 故需要一条**复位后仍可读**的"defaultTask 主循环最后走到哪一段"轨迹。
*
@@ -96,7 +96,7 @@ uint32_t task_wd_is_armed(void); /* 1 = IWDG 已启动 */
* MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct 的 UNINIT_CCM)——IWDG/引脚复位
* 不改变 SRAM 内容,故复位后仍能读出上一轮的最后进段。槽布局:
* [0]=magic(校验是否为本模块写过)[1]=段号 [2]=进段时刻(ms) [3]=循环序号
* round 8b 追加"时间源存活性"槽(同样在 .noinit):
* 第 8b 轮追加"时间源存活性"槽(同样在 .noinit):
* cur = [magic, uwTick, 本轮 TIM7 计数, 0](TIM7 ISR 每 1ms 更新)
* prev = [magic, uwTick, 上一轮 TIM7 计数, 0](main() 在 HAL_Init 之前快照)
* 注意:**仅 MCU 构建存在**(TASK_WATCHDOG_UNIT_TEST 下不编入,主机单测不受影响)。
@@ -117,7 +117,7 @@ typedef enum {
} task_wd_seg_t;
/*
* 函数功能:记录"即将进入的主循环段"(round 8 取证;**仅 MCU 构建**)
* 函数功能:记录"即将进入的主循环段"(第 8 轮取证;**仅 MCU 构建**)
* 入口参数:seg - 段号(task_wd_seg_t)
* 返回值:无
* 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写)
@@ -126,20 +126,20 @@ typedef enum {
void task_wd_seg(uint32_t seg);
/*
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证;**仅 MCU 构建**)
* 函数功能:开机上报上一轮最后进段 + 时间源存活性(第 8 轮取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发)
* 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=<ms>`(裸 UART,
* 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。
* round 8b:紧接着打一行 `TICKRUN uw= n= duw=`——uw 为 TIM7 在**上一轮**
* 第 8b 轮:紧接着打一行 `TICKRUN uw= n= duw=`——uw 为 TIM7 在**上一轮**
* 最后一次记下的 HAL tick、n 为上一轮 TIM7 中断总数、duw = uw - t
* (相对进段时刻的增量),据此判定 TIM7 是否在冻结同刻停摆。
*/
void task_wd_seg_report(void);
/*
* 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证;**仅 MCU 构建**)
* 函数功能:开机快照上一轮的"时间源存活性"(第 8b 轮取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1)
@@ -150,13 +150,13 @@ void task_wd_seg_report(void);
void task_wd_tick_snapshot(void);
/*
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证;**仅 MCU 构建**)
* 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(第 8b 轮取证;**仅 MCU 构建**)
* 入口参数:无
* 返回值:无
* 限定条件:TIM7 中断上下文调用(`HAL_TIM_PeriodElapsedCallback` 内,每 1ms)
* 函数说明:更新 cur 槽 = [magic, uwTick, 本轮 TIM7 中断计数, 0](纯 SRAM 写,无阻塞)。
* 与 task_wd_seg 的"进段打点"配合,复位后由 task_wd_seg_report() 上报。
* 背景(2026-10-02 台架 round 8b 取证,见 docs/子系统设计/看门狗设计说明.md
* 背景(2026-10-02 台架第 8b 轮取证,见 docs/子系统设计/看门狗设计说明.md
* 2026-10-02 变更记录):HAL_UART_Transmit 的超时判据
* 依赖 uwTick,时间源停摆即死等——必须先把"TIM7 是否停摆"钉死再定向修复。
* ⚠ **本函数内严禁调用任何 FreeRTOS/CMSIS-RTOS API**:TIM7 在 HAL_Init
+4 -4
View File
@@ -21,9 +21,9 @@
| Trap | 问题 | 状态 |
|---|---|---|
| Trap 21 | TC-NET-604 "RX 窗口冻结" 判定为**非缺陷**(原报告系测试程序自吞数据 + 判读工具误判) | ✅ 结案 |
| Trap 22 | **快速复连丢 CONNECT/DISCONNECT 中断 → 监听静默死亡**;修复 = `net_reconcile_tick()` 周期对账自愈 | ✅ 已修 |
| Trap 23 | **上传中止后首笔复连被静默截断(≤2048B)并误报成功**;修复 = 异常释放后复监听隔离排空 | ✅ 已修 |
| TC-NET-604 | "RX 窗口冻结" 判定为**非缺陷**(原报告系测试程序自吞数据 + 判读工具误判) | ✅ 结案 |
| TRAP-NET-17 | **快速复连事件面未收到 → 监听静默死亡**(E14 复核:非芯片丢事件,是读清消费竞态);修复 = `net_reconcile_tick()` 周期对账自愈 | ✅ 已修 |
| TRAP-NET-18 | **上传中止后首笔复连被静默截断(≤2048B)并误报成功**;修复 = 异常释放后复监听隔离排空 | ✅ 已修 |
> 完整台账与复现配方见 `docs/问题记录/CH395F_Trap_Records.md`(**改驱动前必读**)。
@@ -31,4 +31,4 @@
- 测试指南:`docs/测试说明/CH395F_Test_Guide.md`
- 上层抽象:`../NET/README.md`
- 台架探针:`MDK-ARM/tcp_reconnect_probe.py`、`MDK-ARM/ftp_abort_probe.py`
- 台架探针:`MDK-ARM/tcp_reconnect_probe.py`、`test/host/ftp_abort_probe.py`
+386 -23
View File
@@ -8,9 +8,9 @@
* 修改记录:
* 2026-03-14 王建锋 创建初始版本,按代码规范重构
* 2026-07-18 王建锋 改进write_send_buf/read_recv_buf使用SPI2 DMA批量传输
* 2026-10-02 王建锋 E-34:3 处字节级 SPI 调用由 HAL_MAX_DELAY 改有限超时(100ms)
* 2026-10-02 王建锋 3 处字节级 SPI 调用由 HAL_MAX_DELAY 改有限超时(100ms)
* + 返回值检查 + 故障锁存(沿程只记一条日志);原 SPI 异常时调用方永久挂死
* 2026-10-04 王建锋 P2-2:ch395f_spi_dma_transfer 由 void 改为返回状态(0 成功/-1 失败),
* 2026-10-04 王建锋 ch395f_spi_dma_transfer 由 void 改为返回状态(0 成功/-1 失败),
* DMA 初始化失败/超时中止均锁存通道故障;read_recv_buf 在 DMA 失败时不再把
* rx_buf 内容拷入调用方缓冲(改零填充),消除"失败当成功"的静默数据损坏
*/
@@ -20,6 +20,7 @@
#include <stdio.h>
#include "ch395f.h"
#include "spi.h"
#include "gd5f2gq5ue.h" /* SPI1 分支转调 gd5f_spi1_notify_*(HAL SPI 回调全局唯一,本文件代管分发) */
#include "usart.h"
/*
@@ -39,8 +40,7 @@
#define CH395F_CHECK_EXIST_TEST_DATA 0x57U /* 芯片检测测试数据 */
#define CH395F_RESET_DELAY_MS 20U /* 复位后等待延时(ms) */
#define CH395F_INIT_TIMEOUT_MS 500U /* 初始化超时时间(ms) */
#define CH395F_SPI_BEGIN_DELAY_MS 1U /* SPI事务开始延时(ms) */
#define CH395F_SPI_BYTE_TIMEOUT_MS 100U /* E-34:字节级SPI有限超时(原 HAL_MAX_DELAY) */
#define CH395F_SPI_BYTE_TIMEOUT_MS 100U /* 字节级SPI有限超时(原 HAL_MAX_DELAY) */
/* DMA缓冲大小使用ch395f.h中公开的CH395F_SPI_DMA_BUF_SIZE */
#define CH395F_DMA_BUF_SIZE CH395F_SPI_DMA_BUF_SIZE
@@ -48,7 +48,7 @@
* 静态变量定义区 - DMA传输相关
*/
static volatile uint8_t s_spi2_dma_done = 0U; /* DMA传输完成标志 */
static uint8_t s_spi_fault = 0U; /* E-34/P2-2:SPI 通道故障锁存(字节级或 DMA 级超时/出错置位,MCU复位清除) */
static uint8_t s_spi_fault = 0U; /* SPI 通道故障锁存(字节级或 DMA 级超时/出错置位,MCU复位清除) */
__attribute__((aligned(4)))
static uint8_t s_spi2_dma_tx_buf[CH395F_DMA_BUF_SIZE]; /* DMA发送缓冲区 */
@@ -90,7 +90,7 @@ static void ch395f_spi_end(void) {
}
/*
* 函数功能:锁存一次 SPI 通道故障(E-34/P2-2:仅首条故障记日志)
* 函数功能:锁存一次 SPI 通道故障(仅首条故障记日志)
* 入口参数:kind - 故障类别标签("byte"/"dma-hal"/"dma-timeout") const char*
* 返回值:无
* 限定条件:无
@@ -114,7 +114,7 @@ static void ch395f_spi_fault_set(const char *kind) {
* 2. 阻塞等待DMA完成回调置标志位
* 3. 超时(1秒)时强制中止SPI DMA并打印错误信息
* 4. 传输完成后检查SPI错误码并清除溢出标志
* 5. P2-2(2026-10-04):失败路径锁存通道故障并返回 -1,调用方据此
* 5.(2026-10-04):失败路径锁存通道故障并返回 -1,调用方据此
* 判定 rx_buf 内容不可信,不再"失败当成功"继续消费
*/
static int ch395f_spi_dma_transfer(uint16_t len) {
@@ -174,14 +174,11 @@ static int ch395f_spi_dma_transfer(uint16_t len) {
* 入口参数:hspi - SPI句柄指针 SPI_HandleTypeDef*
* 函数说明:由HAL SPI驱动在DMA TX+RX完成后调用
*/
/* SPI1 DMA完成标志(定义在gd5f2gq5ue.c中) */
extern volatile uint8_t g_spi1_dma_done;
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI2) {
s_spi2_dma_done = 1U;
} else if (hspi->Instance == SPI1) {
g_spi1_dma_done = 1U;
gd5f_spi1_notify_done(); /* 归 GD5F 所有:本文件只代 HAL 分发,不碰它的标志 */
}
}
@@ -194,7 +191,7 @@ void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI2) {
s_spi2_dma_done = 1U;
} else if (hspi->Instance == SPI1) {
g_spi1_dma_done = 1U;
gd5f_spi1_notify_error(); /* 勿删:少了这行 GD5F 的 DMA 出错只能靠超时退出 */
}
}
@@ -212,7 +209,7 @@ void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi) {
uint8_t ch395f_write_cmd(uint8_t cmd) {
uint8_t rx_data = 0xFFU;
/* 通过SPI发送命令并接收回显(E-34:有限超时+返回值检查;失败返回0xFF占位并锁存故障) */
/* 通过SPI发送命令并接收回显(有限超时+返回值检查;失败返回0xFF占位并锁存故障) */
if (HAL_SPI_TransmitReceive(&hspi2, &cmd, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
@@ -229,7 +226,7 @@ uint8_t ch395f_write_cmd(uint8_t cmd) {
uint8_t ch395f_write_data(uint8_t data) {
uint8_t rx_data = 0xFFU;
/* 通过SPI发送数据并接收回显(E-34:有限超时+返回值检查) */
/* 通过SPI发送数据并接收回显(有限超时+返回值检查) */
if (HAL_SPI_TransmitReceive(&hspi2, &data, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
@@ -247,7 +244,7 @@ uint8_t ch395f_read_data(void) {
uint8_t tx_dummy = 0xFFU;
uint8_t rx_data = 0xFFU;
/* 发送0xFF作为时钟源,读取芯片返回数据(E-34:有限超时+返回值检查) */
/* 发送0xFF作为时钟源,读取芯片返回数据(有限超时+返回值检查) */
if (HAL_SPI_TransmitReceive(&hspi2, &tx_dummy, &rx_data, 1,
CH395F_SPI_BYTE_TIMEOUT_MS) != HAL_OK) {
ch395f_spi_fault_set("byte");
@@ -256,7 +253,7 @@ uint8_t ch395f_read_data(void) {
}
/*
* 函数功能:查询 SPI 通道故障锁存(E-34 / P2-2)
* 函数功能:查询 SPI 通道故障锁存
* 返回值:0 - 正常 1 - 曾发生 SPI 超时/错误(字节级或 DMA 级)
* 限定条件:无
* 函数说明:锁存式(不自动清除,MCU 复位前一直有效);上层据此判定
@@ -694,7 +691,7 @@ ch395f_status_t ch395f_init(void) {
}
/*
* 函数功能:设置CH395的IP地址(低字节在前)
* 函数功能:设置CH395的IP地址(点分十进制序:p_ip[0]=第一段,见头文件声明注)
* 入口参数:p_ip - 4字节IP地址指针 uint8_t* 192.168.x.x格式
* 限定条件:1. p_ip不可为NULL
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_IP 先于 INIT_CH395)
@@ -723,7 +720,7 @@ void ch395f_set_ip_addr(uint8_t *p_ip) {
}
/*
* 函数功能:设置CH395的网关IP地址(低字节在前)
* 函数功能:设置CH395的网关IP地址(点分十进制序,同 set_ip_addr)
* 入口参数:p_gwip - 4字节网关IP指针 uint8_t*
* 限定条件:1. p_gwip不可为NULL
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_GWIP 先于 INIT_CH395)
@@ -752,7 +749,7 @@ void ch395f_set_gwip_addr(uint8_t *p_gwip) {
}
/*
* 函数功能:设置CH395的子网掩码(低字节在前)
* 函数功能:设置CH395的子网掩码(点分十进制序,同 set_ip_addr)
* 入口参数:p_mask - 4字节子网掩码指针 uint8_t*
* 限定条件:1. p_mask不可为NULL
* 2. 必须在 ch395f_init() 之前调用(手册 §9.2.1:SET_MASK 先于 INIT_CH395)
@@ -1182,9 +1179,9 @@ void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
}
if (len > (uint16_t)CH395F_SEND_BUF_SIZE_BYTES) {
/* §5.32:单次写入长度不得大于该 Socket 的发送缓冲区(本仓 = 2 块 = 1KB)。
* 超出不会被命令拒绝,而是写穿发送缓冲导致数据错乱(Trap 20)→ 只告警不拦截,
* 超出不会被命令拒绝,而是写穿发送缓冲导致数据错乱(TRAP-NET-16)→ 只告警不拦截,
* 分块责任在调用方(NET 层 NET_SEND_CHUNK_MAX)。 */
DBG_ERROR("write_send_buf OVERSIZE-SEND BUF: sock %u len %u > send buf %u (Trap 20)",
DBG_ERROR("write_send_buf OVERSIZE-SEND BUF: sock %u len %u > send buf %u (TRAP-NET-16)",
(unsigned)sock, (unsigned)len, (unsigned)CH395F_SEND_BUF_SIZE_BYTES);
}
@@ -1200,7 +1197,7 @@ void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
/* 数据同步屏障:确保memcpy写入对DMA可见 */
__DSB();
/* CS低 → DMA批量传输 → CS高(P2-2:失败已由 transfer 锁存通道故障并记日志,
/* CS低 → DMA批量传输 → CS高(失败已由 transfer 锁存通道故障并记日志,
* 调用方经 ch395f_spi_byte_fault() 查询;此处无后续处理,失败即放弃本次发送) */
ch395f_spi_begin();
(void)ch395f_spi_dma_transfer(total);
@@ -1253,7 +1250,7 @@ void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) {
rc = ch395f_spi_dma_transfer(total);
ch395f_spi_end();
/* P2-2(2026-10-04):DMA 失败时 rx_buf 内容不可信,禁止拷入调用方缓冲
/* (2026-10-04):DMA 失败时 rx_buf 内容不可信,禁止拷入调用方缓冲
* (否则把总线残留/半截数据当有效载荷 → 静默数据损坏);改零填充并返回,
* 调用方据 ch395f_spi_byte_fault() 判定通道异常。 */
if (rc != 0) {
@@ -1409,3 +1406,369 @@ void ch395f_get_unreach_info(uint8_t *p_buf) {
}
ch395f_spi_end();
}
/* ============================================================================
* 自愈驱动原语(设计 docs/网络/网络自愈架构设计.md §4.4;
* 形态定案锚点 同文档 §5.2 E2/E3/E4)
* ========================================================================== */
/* 每 Socket 状态缓存(刷新点:主动回读 ch395f_get_sock_state;
* 以及 net_poll 每轮对在用 Socket 各刷一次——E2 实测 18.1us/次,
* 4 监听 ~72us 远小于 1ms 轮询周期) */
static ch395f_sock_state_t g_sock_state[8];
/* sock_reset 非阻塞状态机槽位(每 Socket 一个;t0 用 16 位 tick 差值,
* 各阶段超时 <=2s,远小于 65535ms 回绕窗) */
#define CH395F_RST_ST_IDLE 0U
#define CH395F_RST_ST_RX_DRAIN 1U
#define CH395F_RST_ST_CLOSE 2U
#define CH395F_RST_ST_MIN_WAIT 3U
#define CH395F_RST_ST_EVT_DRAIN 4U
#define CH395F_RST_ST_WAIT_CLOSE 5U
#define CH395F_RST_RX_MAX_ROUNDS 4U /* RX 缓冲 2KB,单读即空;4 为安全上限 */
#define CH395F_RST_QUIESCENT 2U /* 排空事件判据:连续 2 轮无新位(E8 M5 口径) */
#define CH395F_RST_RX_TMO_MS 500U
#define CH395F_RST_EVT_TMO_MS 500U
#define CH395F_RST_CLS_TMO_MS 2000U
typedef struct {
uint16_t t0; /* 当前阶段起始 tick */
uint8_t state; /* CH395F_RST_ST_* */
uint8_t flags; /* bit7=mode(1=QUARANTINE) bit6..4=quiescent 轮数 bit3..0=drain 轮数(饱和) */
} ch395f_rst_slot_t;
static ch395f_rst_slot_t g_rst[8];
/* 链路态缓存(F-DRV-13) */
static uint8_t g_link_up;
static uint8_t g_link_valid;
static uint16_t g_link_changes;
/* 边界 fault 计数(sn 越界等;日志只留痕一次,其余只计数) */
static uint16_t g_p2_faults;
static uint8_t g_p2_fault_logged;
/*
* 函数功能:驱动原语边界异常留痕(首次 DBG_ERROR,之后只计数)
* 入口参数:tag - 出错点短标签 const char*
*/
static void ch395f_p2_fault_note(const char *tag) {
g_p2_faults++;
if (g_p2_fault_logged == 0U) {
g_p2_fault_logged = 1U;
DBG_ERROR("ch395f P2 boundary fault @%s (further ones counted only)", tag);
}
}
/*
* 函数功能:读空并丢弃 RX(排空契约形态=整缓冲单读,与 ch395f_read_recv_buf
* 同形但不拷出——复位残渣是垃圾,零新增 RAM)
* 入口参数:sn - Socket 索引 uint8_t
* len - 本次读出字节数(<=CH395F_SPI_DMA_MAX_PAYLOAD) uint16_t
*/
static void ch395f_rst_rx_discard(uint8_t sn, uint16_t len) {
s_spi2_dma_tx_buf[0] = CH395F_CMD_READ_RECV_BUF_SN;
s_spi2_dma_tx_buf[1] = sn;
s_spi2_dma_tx_buf[2] = len & 0xFFU;
s_spi2_dma_tx_buf[3] = (len >> 8) & 0xFFU;
memset(&s_spi2_dma_tx_buf[4], 0xFFU, len);
ch395f_spi_begin();
(void)ch395f_spi_dma_transfer(4U + len);
ch395f_spi_end();
}
/*
* 函数功能:主动回读本 Socket 状态并刷新驱动缓存(缓存刷新点之一,另一个
* 是 net_poll 每轮全量刷新)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:类型化状态;sn>7 返回 {CLOSED, 哨兵} 并计一次边界 fault
* 限定条件:芯片已初始化;netTask 上下文(与其它 CH395F 事务串行)
*/
ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn) {
ch395f_sock_state_t st;
uint8_t raw[2];
if (sn >= 8U) {
ch395f_p2_fault_note("get_state sn");
st.open = CH395F_SOCKET_CLOSED;
st.tcp_state = CH395F_SOCK_STATE_NONE;
return st;
}
ch395f_get_socket_status(sn, raw);
st.open = raw[0];
st.tcp_state = (raw[0] == CH395F_SOCKET_OPEN) ? raw[1] : CH395F_SOCK_STATE_NONE;
g_sock_state[sn] = st;
return st;
}
/*
* 函数功能:读驱动缓存(零 SPI 成本;新鲜度取决于刷新点,见上)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:最近一次刷新的状态;sn>7 返回 {CLOSED, 哨兵}
* 限定条件:芯片已初始化
*/
ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn) {
if (sn >= 8U) {
ch395f_sock_state_t st;
ch395f_p2_fault_note("cached sn");
st.open = CH395F_SOCKET_CLOSED;
st.tcp_state = CH395F_SOCK_STATE_NONE;
return st;
}
return g_sock_state[sn];
}
/*
* 函数功能:判"本 Socket 正在监听"(读缓存:open=OPEN 且 TCP=LISTEN)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:1=在听 0=不在听 int
* 限定条件:缓存新鲜度同 ch395f_sock_state_cached
*/
int ch395f_sock_is_listening(uint8_t sn) {
ch395f_sock_state_t c = ch395f_sock_state_cached(sn);
return (c.open == CH395F_SOCKET_OPEN && c.tcp_state == CH395F_TCP_LISTEN) ? 1 : 0;
}
/*
* 函数功能:事件护栏——只读本 Socket SINT(读即清),永不触碰全局寄存器
* (F-NET-7 收窄:两级预清会清掉其它 Socket 的事件位)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:SINT 位图;sn>7 返回 0 uint8_t
* 限定条件:芯片已初始化;全局寄存器唯一读者=net_poll(驱动侧契约)
*/
uint8_t ch395f_sock_events(uint8_t sn) {
if (sn >= 8U) {
ch395f_p2_fault_note("events sn");
return 0U;
}
/* 契约:只读本 Socket SINT(0x30 读即清),永不触碰全局寄存器 0x19——
* 全局寄存器读即清且含全部 Socket 事件位,唯一读者=net_poll(F-NET-7) */
return ch395f_get_sock_int_status(sn);
}
/*
* 函数功能:启动 Socket 复合复位(非阻塞状态机,每 net_poll 步进;E4 定案序列
* 读空 RX -> CLOSE -> 排空事件(quiescent) -> 轮询 byte0=00H)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* mode - 复位模式(GRACEFUL 100ms / QUARANTINE 400ms 最短等待)
* 返回值:0=已启动 -1=参数错或已在运行(调用方逻辑错误) int
* 限定条件:netTask 上下文;调用前本层已放弃该会话(复位丢弃 RX 残渣)
*/
int ch395f_sock_reset_begin(uint8_t sn, ch395f_reset_mode_t mode) {
if (sn >= 8U) {
ch395f_p2_fault_note("reset_begin sn");
return -1;
}
if (g_rst[sn].state != CH395F_RST_ST_IDLE) {
/* 已在运行:调用方逻辑错误(同一 Socket 不允许并发复位) */
return -1;
}
g_rst[sn].state = CH395F_RST_ST_RX_DRAIN;
g_rst[sn].flags = (mode == CH395F_RESET_QUARANTINE) ? 0x80U : 0x00U;
g_rst[sn].t0 = (uint16_t)HAL_GetTick();
return 0;
}
/*
* 函数功能:复位状态机步进(net_poll 或测试循环周期调用,禁复制忙等)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:0=完成(含未启动,幂等) 1=进行中 -1=失败(超时/参数,槽位已复位可重开) int
* 限定条件:netTask 上下文
*/
int ch395f_sock_reset_poll(uint8_t sn) {
ch395f_rst_slot_t *p;
uint16_t now;
uint16_t el;
if (sn >= 8U) {
ch395f_p2_fault_note("reset_poll sn");
return -1;
}
p = &g_rst[sn];
if (p->state == CH395F_RST_ST_IDLE) {
return 0; /* 未启动/已完成:幂等 */
}
now = (uint16_t)HAL_GetTick();
el = (uint16_t)(now - p->t0);
switch (p->state) {
case CH395F_RST_ST_RX_DRAIN: {
/* E4 定案第 1 步:close 前 RX 必须为空(带脏缓冲关闭 -> 拆链停滞 ->
* 毒化下一会话且级联自持,C 变体 0/20 实证) */
uint16_t len = ch395f_get_recv_len(sn);
uint8_t rounds = (uint8_t)(p->flags & 0x0FU);
if (len == 0U) {
p->state = CH395F_RST_ST_CLOSE;
p->t0 = now;
break;
}
if (rounds < CH395F_RST_RX_MAX_ROUNDS) {
ch395f_rst_rx_discard(sn, (len > (uint16_t)CH395F_SPI_DMA_MAX_PAYLOAD)
? (uint16_t)CH395F_SPI_DMA_MAX_PAYLOAD : len);
p->flags = (uint8_t)((p->flags & 0xF0U) | (rounds + 1U));
} else {
/* 排不空(异常形态):不再恋战,继续 CLOSE,失败由 WAIT_CLOSE 超时兜底 */
ch395f_p2_fault_note("rx drain stall");
p->state = CH395F_RST_ST_CLOSE;
p->t0 = now;
}
if ((uint16_t)(now - p->t0) > CH395F_RST_RX_TMO_MS) {
p->state = CH395F_RST_ST_IDLE;
ch395f_p2_fault_note("rx drain tmo");
return -1;
}
break;
}
case CH395F_RST_ST_CLOSE:
/* E4 定案第 2 步:CLOSE(裸命令,不复制 poll_cmd_status 忙等——
* E3 实证其对 CLOSE 无判别力;完成判据=状态回读,E3 定案) */
ch395f_spi_begin();
ch395f_write_cmd(CH395F_CMD_CLOSE_SOCKET_SN);
ch395f_write_data(sn);
ch395f_spi_end();
p->state = CH395F_RST_ST_MIN_WAIT;
p->t0 = now;
break;
case CH395F_RST_ST_MIN_WAIT: {
uint16_t need = ((p->flags & 0x80U) != 0U) ? 400U : 100U;
if (el >= need) {
p->state = CH395F_RST_ST_EVT_DRAIN;
p->t0 = now;
p->flags = (uint8_t)(p->flags & 0x8FU); /* quiescent 计数清零 */
}
break;
}
case CH395F_RST_ST_EVT_DRAIN: {
/* E4 定案第 3 步:排空事件至 quiescent(陈旧粘滞位会误导事件驱动
* accept——E4 C 变体签名 0x04 陈旧 RECV;读即清,连续 2 轮无新位即静) */
uint8_t bits = ch395f_sock_events(sn);
if (bits == 0U) {
uint8_t q = (uint8_t)((p->flags >> 4) & 0x07U);
if ((q + 1U) >= CH395F_RST_QUIESCENT) {
p->state = CH395F_RST_ST_WAIT_CLOSE;
p->t0 = now;
} else {
p->flags = (uint8_t)((p->flags & 0x8FU) | ((q + 1U) << 4));
}
} else {
p->flags = (uint8_t)(p->flags & 0x8FU); /* 有新位:quiescent 计数清零 */
}
if (el > CH395F_RST_EVT_TMO_MS) {
p->state = CH395F_RST_ST_IDLE;
ch395f_p2_fault_note("evt drain tmo");
return -1;
}
break;
}
case CH395F_RST_ST_WAIT_CLOSE:
/* E4 定案第 4 步:轮询 byte0=00H(E4 实测 closed_ms=0——命令落定即空,
* 但为契约必需步) */
if (ch395f_cmd_done(sn, CH395F_DONE_CLOSE) == 1) {
p->state = CH395F_RST_ST_IDLE;
return 0;
}
/* 二段关闭实证(TC-NET-1501 台架 2026-10-13):LISTEN 态 Socket 的 CLOSE 返
* SUCCESS 但只拆监听(TCP 01->00),byte0 恒 05;需再发一次 CLOSE 才落
* 00(生产 net 层靠 TRAP-NET-09 open 重试吸收此行为)。复位状态机自行补发,
* 补发标记=drain 字段置 0xF(本阶段已用尽,复用安全)。 */
{
ch395f_sock_state_t c = ch395f_sock_state_cached(sn);
if (c.open == CH395F_SOCKET_OPEN && c.tcp_state == CH395F_TCP_CLOSED
&& (p->flags & 0x0FU) != 0x0FU) {
p->flags = (uint8_t)((p->flags & 0xF0U) | 0x0FU);
ch395f_spi_begin();
ch395f_write_cmd(CH395F_CMD_CLOSE_SOCKET_SN);
ch395f_write_data(sn);
ch395f_spi_end();
}
}
if (el > CH395F_RST_CLS_TMO_MS) {
p->state = CH395F_RST_ST_IDLE;
ch395f_p2_fault_note("wait closed tmo");
return -1;
}
break;
default:
p->state = CH395F_RST_ST_IDLE;
ch395f_p2_fault_note("bad rst state");
return -1;
}
return 1;
}
/*
* 函数功能:非阻塞命令完成查询(E3 定案=状态回读判据;单次回读 18.1us,E2 背书)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* what - 完成判据(OPEN=byte0 05H / LISTEN=+TCP 01H / CLOSE=byte0 00H)
* 返回值:1=已完成 0=未完成 -1=参数错 int
* 限定条件:netTask 上下文;勿复制 poll_cmd_status 忙等(E3 实证无判别力)
*/
int ch395f_cmd_done(uint8_t sn, ch395f_done_what_t what) {
ch395f_sock_state_t st;
if (sn >= 8U) {
return -1;
}
st = ch395f_get_sock_state(sn); /* 主动回读,单次 18.1us(E2) */
switch (what) {
case CH395F_DONE_OPEN:
return (st.open == CH395F_SOCKET_OPEN) ? 1 : 0;
case CH395F_DONE_LISTEN:
return (st.open == CH395F_SOCKET_OPEN && st.tcp_state == CH395F_TCP_LISTEN) ? 1 : 0;
case CH395F_DONE_CLOSE:
return (st.open == CH395F_SOCKET_CLOSED) ? 1 : 0;
default:
return -1;
}
}
/*
* 函数功能:喂全局中断位(net_poll 读全局后喂 bit0~3;
* bit2 PHY_CHANGE 置位时重读 PHY 更新 link_up 缓存 + 变化计数)
* 入口参数:glob_bits - 全局中断状态低字节语义位图 uint16_t
* 返回值:无
* 限定条件:netTask 上下文(会发 SPI 读 PHY)
*/
void ch395f_glob_event_feed(uint16_t glob_bits) {
uint8_t up;
if ((glob_bits & CH395F_GINT_STAT_PHY_CHANGE) == 0U) {
return;
}
/* B9 判据(TRAP-NET-09 修正):PHY != DISCONN 即 link up */
up = (ch395f_get_phy_status() != CH395F_PHY_DISCONN) ? 1U : 0U;
if (g_link_valid == 0U) {
g_link_valid = 1U;
g_link_up = up;
} else if (up != g_link_up) {
g_link_up = up;
g_link_changes++;
}
}
/*
* 函数功能:读链路态缓存(首次查询惰性读 PHY 建档,不依赖 feed 已发生)
* 入口参数:无
* 返回值:1=link up 0=down uint8_t(B9 判据:PHY != DISCONN 即 up)
* 限定条件:芯片已初始化
*/
uint8_t ch395f_link_up_cached(void) {
if (g_link_valid == 0U) {
/* 惰性建档(首次查询):不依赖 feed 已发生过 */
g_link_up = (ch395f_get_phy_status() != CH395F_PHY_DISCONN) ? 1U : 0U;
g_link_valid = 1U;
}
return g_link_up;
}
/*
* 函数功能:读链路变化计数(net 层订阅:计数变化 -> 日志/可选重配)
* 入口参数:无
* 返回值:自建档以来的 PHY 连接状态翻转次数 uint16_t
* 限定条件:无
*/
uint16_t ch395f_link_change_count(void) {
return g_link_changes;
}
+138 -23
View File
@@ -28,12 +28,10 @@ extern "C" {
/* 无输入数据,无输出数据 */
#define CH395F_CMD_RESET_ALL 0x05U
#define CH395F_CMD_ENTER_SLEEP 0x03U
/* 无输入数据,1 字节输出 */
#define CH395F_CMD_GET_IC_VER 0x01U
#define CH395F_CMD_GET_CMD_STATUS 0x2CU
#define CH395F_CMD_GET_GLOB_INT_STATUS 0x29U
#define CH395F_CMD_GET_PHY_STATUS 0x26U
#define CH395F_CMD_GET_DHCP_STATUS 0x42U
@@ -119,7 +117,7 @@ extern "C" {
* 由 net_init 经 ch395f_set_tcp_mss() 下发(须在 ch395f_init 之前);此处为单一真值源。
* 维持 1024:8 Socket 共享 24KB 缓冲时,单 Socket 接收缓冲 = 2×MSS = 2KB(手册 §9.2.8
* 满足上限的最大值)。MSS=512 裕量方案未采纳;当年支撑该决策的台架判据不可信,
* 已随 TC-NET-604 结案为非缺陷一并撤回(2026-09-23,见 Trap 21)——维持 1024 的
* 已随 TC-NET-604 结案为非缺陷一并撤回(2026-09-23)——维持 1024 的
* 现有理由 = 手册允许的最大值、满缓冲周期段数少。MSS 只影响芯片分包与窗口动态,
* 与下方缓冲块布局解耦。
*/
@@ -136,16 +134,17 @@ extern "C" {
#define CH395F_RECV_BLOCKS 4U
#define CH395F_SEND_BLOCKS 2U
/* 单 Socket 硬件收发缓冲字节数(= 块数 × 块大小)。写入发送 FIFO 的单次长度严禁超过此值,
* 否则会写穿有限的发送缓冲(见 Trap 20)。 */
* 否则会写穿有限的发送缓冲(见 TRAP-NET-16)。 */
#define CH395F_SEND_BUF_SIZE_BYTES (CH395F_SEND_BLOCKS * CH395F_RAM_BLOCK_SIZE)
#define CH395F_RECV_BUF_SIZE_BYTES (CH395F_RECV_BLOCKS * CH395F_RAM_BLOCK_SIZE)
/*
* SPI DMA 事务限制
* CH395F_SPI_DMA_MAX_PAYLOAD: 单次 DMA 事务最大有效载荷(总缓冲 - 4 字节命令头)
* 接收方向需一次性读空整个 Socket 接收缓冲(= 接收缓冲大小,避免 Trap 13 部分读
* 导致剩余数据不可见),故总缓冲 = 接收缓冲大小 + 4(4 为命令头开销),
* 有效载荷 = 接收缓冲大小(2KB)。
* 缓冲按"一条命令读空整个 Socket 接收缓冲(2KB)"定容——排空契约(TRAP-NET-20,
* bit3=1 残数据由 MCU 读出)的形态本身就是整缓冲单读。
* (旧解释"部分读致 recv_len 归零、剩余数据不可见"未证实且与手册 §5.34 相左,
* 定容不依赖该解释;结案见 `CH395F_Trap_Records.md` 台架纪律。)
* 2026-09-22:与 MSS 解耦(此前 = 2×MSS+4,MSS 下调会把 DMA 缓冲缩到装不下整缓冲
* → 整缓冲读会被截断丢弃)。
*/
@@ -160,11 +159,9 @@ extern "C" {
#define CH395F_CMD_SET_KEEPALIVE_INTVL 0x57U
#define CH395F_CMD_SET_KEEPALIVE_CNT 0x58U
#define CH395F_CMD_SET_KEEPALIVE_SN 0x59U
#define CH395F_CMD_EEPROM_ERASE 0xE9U
#define CH395F_CMD_EEPROM_WRITE 0xEAU
#define CH395F_CMD_EEPROM_READ 0xEBU
#define CH395F_CMD_READ_GPIO_REG 0xECH
#define CH395F_CMD_WRITE_GPIO_REG 0xEDU
/* EEPROM(0xE9..0xEB)/GPIO(0xEC/0xED)/SLEEP(0x03) 命令宏与
* GET_GLOB_INT_STATUS(0x29,只有 2 字节版 0x19 有实现) 死声明已删除——
* 全仓零引用已核实;需要时按手册 §5.x 重新登记 */
/*
* CH395 命令执行状态码(CMD_GET_CMD_STATUS 返回值,与芯片手册一致)
@@ -199,10 +196,13 @@ extern "C" {
#define CH395F_PHY_AUTO_NEGOTIATE 0x20U /* 自动协商 */
/*
* 功能参数标志位(CMD_SET_FUN_PARA)
* 功能参数标志位(CMD_SET_FUN_PARA,§5.52 表)
* bit3 SOCKET_CLOSE 需芯片版本 ≥0x46(版本门控见 net_init);
* bit1 TCP_SERVER 多连接模式——本项目单连接模型不用(红线,勿置 1);
* bit0 保留必须为 0(TRAP-NET-15 定案更正:0x09→0x08)
*/
#define CH395F_FUN_PARA_TCP_SERVER (1UL << 1) /* TCP Server 多连接模式 */
#define CH395F_FUN_PARA_SOCKET_CLOSE (1UL << 3) /* Socket 关闭控制(0=芯片自动,1=外部控制) */
#define CH395F_FUN_PARA_SOCKET_CLOSE (1UL << 3) /* Socket 关闭控制(0=芯片自动,1=外部控制;TRAP-NET-20 排空契约) */
#define CH395F_FUN_PARA_DISABLE_SEND_OK (1UL << 4) /* 禁用 SEND_OK 中断 */
/*
@@ -235,16 +235,15 @@ extern "C" {
#define CH395F_TCP_TIME_WAIT 0x0AU
/*
* 全局中断状态位(CMD_GET_GLOB_INT_STATUS 1字节版,仅Socket 0~3)
* 全局中断状态位低字节(bit0~3 全局事件;§5.16——2 字节版
* GET_GLOB_INT_STATUS_ALL 低字节同语义。Socket 事件位(bit4 起)由
* net_poll 按 1U<<(i+4) 计算,旧 SOCK0..3 单列宏已删(
* 全仓零引用;1 字节版命令 0x29 亦已随死声明清理)
*/
#define CH395F_GINT_STAT_UNREACH 0x01U
#define CH395F_GINT_STAT_IP_CONFLI 0x02U
#define CH395F_GINT_STAT_PHY_CHANGE 0x04U
#define CH395F_GINT_STAT_DHCP 0x08U
#define CH395F_GINT_STAT_SOCK0 0x10U
#define CH395F_GINT_STAT_SOCK1 0x20U
#define CH395F_GINT_STAT_SOCK2 0x40U
#define CH395F_GINT_STAT_SOCK3 0x80U
/*
* 全局中断状态位(CMD_GET_GLOB_INT_STATUS_ALL 2字节版,支持Socket 0~7)
@@ -308,7 +307,7 @@ uint8_t ch395f_write_data(uint8_t data);
uint8_t ch395f_read_data(void);
/*
* 函数功能:查询 SPI 通道故障锁存(E-34 / P2-2)
* 函数功能:查询 SPI 通道故障锁存
* 返回值:0 - 正常 1 - 曾发生 SPI 超时/错误(字节级或 DMA 级)
* 限定条件:无
* 函数说明:锁存式(MCU 复位前一直有效);上层据此判定"SPI 通道异常"
@@ -377,21 +376,23 @@ ch395f_status_t ch395f_init(void);
*/
/*
* 函数功能:设置 CH395 IP 地址(低字节在前)
* 函数功能:设置 CH395 IP 地址(点分十进制序:p_ip[0]=第一段,如 192;
* 字节序曾误注为"低字节在前"——调用方 parse_ip_string 产出
* 点分数组直传、芯片按首段在前接受,台架多年 IP 生效实证)
* 入口参数:p_ip - 4 字节 IP 地址指针 uint8_t*
* 限定条件:指针非空,芯片已初始化
*/
void ch395f_set_ip_addr(uint8_t *p_ip);
/*
* 函数功能:设置网关 IP 地址(低字节在前)
* 函数功能:设置网关 IP 地址(点分十进制序,同 ch395f_set_ip_addr)
* 入口参数:p_gwip - 4 字节网关 IP 指针 uint8_t*
* 限定条件:指针非空,芯片已初始化
*/
void ch395f_set_gwip_addr(uint8_t *p_gwip);
/*
* 函数功能:设置子网掩码(低字节在前)
* 函数功能:设置子网掩码(点分十进制序,同 ch395f_set_ip_addr)
* 入口参数:p_mask - 4 字节子网掩码指针 uint8_t*
* 限定条件:指针非空,芯片已初始化
*/
@@ -480,6 +481,8 @@ void ch395f_set_proto_type(uint8_t sock, uint8_t proto);
* 入口参数:sock - Socket 索引 uint8_t 0 - 7
* p_ip - 4 字节目标 IP 指针 uint8_t*
* 限定条件:指针非空
* 函数说明:字节序与 ch395f_get_remot_ipp 输出一致(net_connect 直传其输出,
* 台架出站连接往返实证)
*/
void ch395f_set_des_ip(uint8_t sock, uint8_t *p_ip);
@@ -577,6 +580,12 @@ uint16_t ch395f_get_recv_len(uint8_t sock);
* 函数功能:获取全局中断状态(2 字节版,支持 Socket 0~7)
* 返回值:中断状态 uint16_t(低字节=Socket 0~3 + PHY/DHCP,高字节=Socket 4~7 + DHCPv6)
* 限定条件:芯片已初始化
* ⚠ 护栏(F-NET-7,I7 不变量):本寄存器**读即清**且含全部 Socket 事件位——
* 任何未配对 SINT 消费的读取都会吞掉其它 Socket 的事件通知。
* **唯一读者 = net_poll(net_socket.c);测试/台架代码同样禁自行读取**
* (用户裁定 2026-10-13)——等事件一律直读本 Socket ch395f_get_sock_int_status()。
* 唯一例外:被测对象就是本 API 的板级用例(寄存器断言 / Phase 12 L1
* 时延基准,硬件层独占上下文、无并发消费者)。
*/
uint16_t ch395f_get_glob_int_status_all(void);
@@ -743,6 +752,112 @@ void ch395f_set_ttl(uint8_t sock, uint8_t ttl);
*/
void ch395f_get_unreach_info(uint8_t *p_buf);
/*
* 函数声明区 - 自愈驱动原语(设计 docs/网络/网络自愈架构设计.md §4.4;
* 形态定案锚点 同文档 §5.2 E2/E3/E4)
* E2 实测:单次状态回读 18.1us;LISTEN 可见延迟 100% <=10ms。
*/
/* 类型化状态回读 + 每 Socket 缓存(F-DRV-1/3) */
#define CH395F_SOCK_STATE_NONE 0xFFU /* open=CLOSED 时 tcp_state 哨兵("无意义") */
typedef struct {
uint8_t open; /* byte0:CH395F_SOCKET_CLOSED / CH395F_SOCKET_OPEN */
uint8_t tcp_state; /* byte1:CH395F_TCP_*;open=CLOSED 时为 CH395F_SOCK_STATE_NONE */
} ch395f_sock_state_t;
/*
* 函数功能:主动回读本 Socket 状态并刷新驱动缓存(缓存刷新点之一,另一个
* 是 net_poll 每轮全量刷新)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:类型化状态;sn>7 返回 {CLOSED, 哨兵} 并计一次边界 fault uint8_t×2
* 限定条件:芯片已初始化;netTask 上下文(与其它 CH395F 事务串行)
*/
ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn);
/*
* 函数功能:读驱动缓存(零 SPI 成本;数据新鲜度取决于刷新点,见上)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:最近一次刷新的状态;sn>7 返回 {CLOSED, 哨兵}
*/
ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn);
/*
* 函数功能:判"本 Socket 正在监听"(读缓存:open=OPEN 且 TCP=LISTEN)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:1=在听 0=不在 int
*/
int ch395f_sock_is_listening(uint8_t sn);
/*
* 函数功能:事件护栏——只读本 Socket SINT(0x30,读即清),永不触碰全局寄存器
* (F-NET-7 收窄:两级预清"先读全局再读本 socket"会清掉其它 Socket 的
* 事件位;全局寄存器唯一读者=net_poll 写成驱动侧契约)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:SINT 位图;sn>7 返回 0 uint8_t
*/
uint8_t ch395f_sock_events(uint8_t sn);
/* 复合复位模式(F-DRV-4;两模式都必须排 RX——E4 定案,差异只剩最短等待) */
typedef enum {
CH395F_RESET_GRACEFUL = 0, /* 最短等待 100ms(TRAP-NET-18 二阶段实测值) */
CH395F_RESET_QUARANTINE /* 最短等待 400ms */
} ch395f_reset_mode_t;
/*
* 函数功能:启动 Socket 复合复位(非阻塞状态机,每 net_poll 步进;内部序列
* E4 定案:读空 RX -> CLOSE -> 排空事件(quiescent) -> 轮询 byte0=00H)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* mode - 复位模式 ch395f_reset_mode_t
* 返回值:0=已启动 -1=参数错或已在运行(调用方逻辑错误) int
* 限定条件:netTask 上下文;调用前本层已放弃该会话(复位会丢弃 RX 残渣)
*/
int ch395f_sock_reset_begin(uint8_t sn, ch395f_reset_mode_t mode);
/*
* 函数功能:复位状态机步进(在 net_poll 或测试循环里周期调用,禁忙等复制)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* 返回值:0=完成(含未启动,幂等) 1=进行中 -1=失败(超时/参数,槽位已复位可重开) int
*/
int ch395f_sock_reset_poll(uint8_t sn);
/* 命令完成判据(F-DRV-8;E3 定案=状态回读,GET_CMD_STATUS 对这三条无判别力) */
typedef enum {
CH395F_DONE_OPEN = 0, /* open 完成 = byte0 变 05H */
CH395F_DONE_LISTEN, /* listen 完成 = byte0=05H 且 TCP=01H(E2:<=10ms 可见) */
CH395F_DONE_CLOSE /* close 完成 = byte0=00H */
} ch395f_done_what_t;
/*
* 函数功能:非阻塞命令完成查询(单次主动回读 18.1us,E2 背书;勿复制
* poll_cmd_status 式忙等——E3 实证其对 OPEN/LISTEN/CLOSE 恒白等且无判别力)
* 入口参数:sn - Socket 索引 uint8_t 0 - 7
* what - 完成判据 ch395f_done_what_t
* 返回值:1=已完成 0=未完成 -1=参数错 int
*/
int ch395f_cmd_done(uint8_t sn, ch395f_done_what_t what);
/* 链路态缓存(F-DRV-13;net_poll 读全局后喂 bit0~3) */
/*
* 函数功能:喂全局中断位(net_poll 读 GET_GLOB_INT_STATUS_ALL 后调用;
* bit2 PHY_CHANGE 置位时驱动重读 PHY 更新 link_up 缓存 + 变化计数)
* 入口参数:glob_bits - 全局中断状态低字节语义位图 uint16_t
*/
void ch395f_glob_event_feed(uint16_t glob_bits);
/*
* 函数功能:读链路态缓存(首次查询惰性读 PHY 建档)
* 返回值:1=link up 0=down uint8_t(判据 B9:PHY != DISCONN 即 up)
*/
uint8_t ch395f_link_up_cached(void);
/*
* 函数功能:读链路变化计数(net 层订阅用:计数变化 -> 日志/可选重配)
* 返回值:自建档以来的 PHY 连接状态翻转次数 uint16_t
*/
uint16_t ch395f_link_change_count(void);
#ifdef __cplusplus
}
#endif
+95 -36
View File
@@ -13,6 +13,7 @@
/* 头文件包含区 */
#include <string.h>
#include "gd5f2gq5ue.h"
#include "spi.h" /* SPI1 句柄由 Inc/spi.h 统一导出,本文件不再重复 extern(2026-10-22 分层纠偏) */
/*
* 调试输出配置
@@ -43,11 +44,15 @@ static uint8_t s_bbt_page_buf[GD5F_BBT_HDR_SIZE + GD5F_BBT_SIZE]; /* BBT 读
static uint8_t s_bbt_best[GD5F_BBT_SIZE]; /* 最高版本位图缓存 */
static const uint8_t s_bbt_magic[4] = { 'G', 'B', 'B', 'T' }; /* BBT 存储魔数 "GBBT" */
/* 外部 SPI 句柄声明 */
extern SPI_HandleTypeDef hspi1;
/* DMA 完成标志(ch395f.c 中的 HAL_SPI_TxRxCpltCallback 会设置此标志) */
volatile uint8_t g_spi1_dma_done;
/* DMA 完成标志(本模块私有,2026-10-22 收编):
* HAL 的 SPI 完成/错误回调是**全局唯一符号**(整个工程只能有一份实现),当前由
* Drivers/BSP/CH395F/ch395f.c 实现并按 hspi->Instance 分发——SPI1 分支必须转调本
* 模块导出的 gd5f_spi1_notify_done() / gd5f_spi1_notify_error(),不得反过来由
* CH395F 直接写本模块的变量(原先是跨模块裸 extern,删掉那两行不会报错,只会让
* 下面的 DMA 等待循环在出错时退化成干等 GD5F_SPI_TIMEOUT)。
*/
static volatile uint8_t s_spi1_dma_done;
/* DMA 阈值(字节数超过此值使用 DMA 传输):取值定义在头文件,理由如下——
* 阈值以下走 CPU 轮询。存在把**栈上小缓冲**直接传进本驱动做收发的大小
@@ -57,7 +62,7 @@ volatile uint8_t g_spi1_dma_done;
* 源/目的全部为文件作用域静态缓冲。 */
/* DMA 可达内存窗口 GD5F_DMA_RAM_BASE/END 与 CCM 窗口常量已上移到 gd5f2gq5ue.h
* (N-26,2026-10-12:nand_ftl.c 的 init 期自检也要用),并带编译期守卫
* (DMA 可达性三层防线,2026-10-12:nand_ftl.c 的 init 期自检也要用),并带编译期守卫
* (白名单窗口与 CCM 永不相交、阈值契约)。此处只留语义提醒:
* 窗口必须与分散加载 RW_IRAM1(0x20000000,128KB,SRAM1+SRAM2)一致;
* CCM(0x10000000,64KB)DMA1/DMA2 物理不可达,RW_CCM 区任何缓冲都不得进 DMA。
@@ -69,11 +74,11 @@ volatile uint8_t g_spi1_dma_done;
* 入口参数:hspi - 触发回调的 SPI 句柄 SPI_HandleTypeDef*
* 返回值:无
* 限定条件:由 HAL DMA 中断调用
* 函数说明:仅当 SPI1 传输完成时置位 g_spi1_dma_done 标志
* 函数说明:仅当 SPI1 传输完成时置位 s_spi1_dma_done 标志
*/
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI1) {
g_spi1_dma_done = 1U;
s_spi1_dma_done = 1U;
}
}
@@ -85,10 +90,32 @@ void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) {
*/
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
if (hspi->Instance == SPI1) {
g_spi1_dma_done = 1U;
s_spi1_dma_done = 1U;
}
}
/*
* 函数功能:SPI1 DMA 完成通知(供实际注册 HAL 回调的一方转调)
* 入口参数:无
* 返回值:无
* 限定条件:由 HAL SPI 回调(中断上下文)调用;只写一个标志,不阻塞
* 函数说明:HAL 的 SPI 回调在整个工程里只能有一份实现,本模块不抢占 Error 回调
* 的所有权,只导出这两个通知入口。**转调方不得省略本调用**——少了它,
* 出错路径只能靠 GD5F_SPI_TIMEOUT 超时退出(能自保但会静默变慢)。
*/
void gd5f_spi1_notify_done(void) {
s_spi1_dma_done = 1U;
}
/*
* 函数功能:SPI1 DMA 错误通知(释放等待者;语义与 notify_done 相同)
* 入口参数:无
* 返回值:无
* 限定条件:由 HAL SPI 错误回调(中断上下文)调用
*/
void gd5f_spi1_notify_error(void) {
s_spi1_dma_done = 1U;
}
/* ======================== 私有函数声明 ======================== */
static int gd5f_page_program(uint32_t page_addr, uint16_t column,
@@ -103,7 +130,7 @@ static int gd5f_private_bbt_find_best(void);
static int gd5f_bbt_load(void);
static int gd5f_bbt_save(void);
/* DMA 可达性守卫:实现见下方(N-26 起为公开 API,声明在 gd5f2gq5ue.h) */
/* DMA 可达性守卫:实现见下方(随 DMA 可达性三层防线升为公开 API,声明在 gd5f2gq5ue.h) */
/* ======================== DMA 可达性守卫 ======================== */
@@ -143,16 +170,16 @@ int gd5f_dma_buf_ok(const void *p_buf, size_t size) {
/*
* 函数功能:等待芯片操作完成(轮询 OIP 位)
* 入口参数:timeout_ms - 超时时间 uint32_t > 0
* 返回值:GD5F_OK(0) - 操作完成;GD5F_BUSY_TIMEOUT(-2) - 超时(可重试);
* 返回值:GD5F_OK(0) - 操作完成;GD5F_WAIT_EXPIRED(-2) - 超时(可重试);
* GD5F_ERROR(-1) - SPI 传输真故障(不可重试,须上报自检)
* 限定条件:SPI 已初始化
* 函数说明:循环读取状态寄存器直到 OIP 位清零或超时。
* E-53(2026-10-03,GD5F 错误通道统一):三处 HAL 调用的返回值**必须**检查
* (2026-10-03,GD5F 错误通道统一):三处 HAL 调用的返回值**必须**检查
* ——原实现全部丢弃,失败时 `status` 保持初值 0 → `(status & OIP) == 0`
* 立即成立 → **把"操作完成"伪造出来**,后续命令在未就绪的芯片上执行,
* 最终表现为"静默报成功但数据没落盘"。现区分两类失败:
* ① SPI 层失败(HAL≠OK)→ GD5F_ERROR(真故障);
* ② OIP 长时间不落 → GD5F_BUSY_TIMEOUT(可重试)。
* ② OIP 长时间不落 → GD5F_WAIT_EXPIRED(可重试)。
* 调用点据此分级:超时重试 / 真故障上报自检并降级(见审计 §5 第二档 #1)。
*/
/* 看门狗心跳弱钩子:默认空实现(无看门狗/模拟器构建);强符号由
@@ -190,7 +217,7 @@ int32_t gd5f_wait_busy(uint32_t timeout_ms) {
GD5F_CS_HIGH();
if (hs != HAL_OK) {
/* E-53:SPI 真故障——绝不伪造成"操作完成"(详见函数说明) */
/* SPI 真故障——绝不伪造成"操作完成"(详见函数说明) */
return GD5F_ERROR;
}
@@ -198,7 +225,7 @@ int32_t gd5f_wait_busy(uint32_t timeout_ms) {
return GD5F_OK;
}
if ((HAL_GetTick() - tick_start) >= timeout_ms) {
return GD5F_BUSY_TIMEOUT;
return GD5F_WAIT_EXPIRED;
}
if ((uint32_t)(HAL_GetTick() - last_beat) >= 100u) {
last_beat = HAL_GetTick();
@@ -271,7 +298,7 @@ static void gd5f_page_read_issue(uint32_t page_addr) {
* 函数说明:13h 单次发令 + WIP 轮询(与 Linux 主线 / GigaDevice 厂商驱动
* 形状完全一致:13h×1 → OIP 轮询 → 0Bh)。
* 2026-09-20 台架异常史实(归因、证伪、沉降/二次发令的加与除)
* 统一记于 docs/问题记录/GD5F2GQ5UE_Trap_Records.md Trap 05,
* 统一记于 docs/问题记录/GD5F2GQ5UE_Trap_Records.md TRAP-GD5F-05,
* 复现手段见测试 Phase 11 用例。
*/
int32_t gd5f_page_read(uint32_t page_addr) {
@@ -319,16 +346,16 @@ int32_t gd5f_read_from_cache(uint16_t column, uint8_t *p_buf,
if (use_dma != 0U) {
uint32_t tick_start = HAL_GetTick();
g_spi1_dma_done = 0U;
s_spi1_dma_done = 0U;
if (HAL_SPI_Receive_DMA(&hspi1, p_buf, (uint16_t)size) != HAL_OK) {
GD5F_CS_HIGH();
return GD5F_ERROR;
}
while (g_spi1_dma_done == 0U) {
while (s_spi1_dma_done == 0U) {
if ((HAL_GetTick() - tick_start) >= GD5F_SPI_TIMEOUT) {
HAL_SPI_DMAStop(&hspi1);
GD5F_CS_HIGH();
return GD5F_BUSY_TIMEOUT;
return GD5F_WAIT_EXPIRED;
}
}
} else {
@@ -400,17 +427,17 @@ int32_t gd5f_program_load(uint16_t column, const uint8_t *p_buf, size_t size) {
if (use_dma != 0U) {
uint32_t tick_start = HAL_GetTick();
g_spi1_dma_done = 0U;
s_spi1_dma_done = 0U;
if (HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)p_buf, (uint16_t)size)
!= HAL_OK) {
GD5F_CS_HIGH();
return GD5F_ERROR;
}
while (g_spi1_dma_done == 0U) {
while (s_spi1_dma_done == 0U) {
if ((HAL_GetTick() - tick_start) >= GD5F_SPI_TIMEOUT) {
HAL_SPI_DMAStop(&hspi1);
GD5F_CS_HIGH();
return GD5F_BUSY_TIMEOUT;
return GD5F_WAIT_EXPIRED;
}
}
} else {
@@ -473,8 +500,8 @@ int32_t gd5f_check_ecc(void) {
* 返回值:GD5F_OK - 成功;其它 - SPI 传输/等待失败错误码
* 限定条件:SPI 已初始化
* 函数说明:先写使能,发 1Fh + 地址 + 数据,等待操作完成。
* E-53(2026-10-03):HAL 传输与 wait_busy 的返回值必须检查并上报——
* 原实现恒返回 GD5F_OK,"配置未生效"对上层不可见(E-10 同族问题)。
* (2026-10-03):HAL 传输与 wait_busy 的返回值必须检查并上报——
* 原实现恒返回 GD5F_OK,"配置未生效"对上层不可见(同族问题)。
*/
static int gd5f_set_feature(uint8_t addr, uint8_t data) {
uint8_t cmd[3];
@@ -507,10 +534,10 @@ static int gd5f_set_feature(uint8_t addr, uint8_t data) {
* 出口参数:p_data - 读到的字节输出指针 uint8_t* 不为 NULL
* 返回值:GD5F_OK - 成功;GD5F_ERROR - SPI 传输失败
* 限定条件:SPI 已初始化
* 函数说明:E-10(2026-10-03):init 需**回读** B0h 校验 ECC 使能位真正生效
* 函数说明:(2026-10-03):init 需**回读** B0h 校验 ECC 使能位真正生效
* (原实现只写不读,"init 返回 OK 但 ECC 未打开"→ 存储栈在无 ECC
* 保护下静默降级)。与 gd5f_read_status 同构(后者固定 C0h),
* 此处参数化地址;同样检查 HAL 返回值(E-53)。
* 此处参数化地址;同样检查 HAL 返回值。
*/
static int gd5f_private_get_feature(uint8_t addr, uint8_t *p_data) {
uint8_t cmd = GD5F_CMD_GET_FEATURE;
@@ -573,7 +600,7 @@ int32_t gd5f_block_erase(uint32_t block_addr) {
* 限定条件:SPI 已初始化,ECC 尚未使能
* 函数说明:读取每个块首页(page 0)的 spare byte 0,
* 若不为 0xFF 则为出厂坏块(符合 datasheet §12.4)。
* E-42(2026-10-03,GD5F 错误通道统一):原实现丢弃 page_read /
* (2026-10-03,GD5F 错误通道统一):原实现丢弃 page_read /
* read_from_cache 的返回值且对外恒返回 GD5F_OK——SPI 读失败时
* `spare[8]` 保持上一轮的**陈旧内容**,且"整片扫描全失败也返回成功",
* 上层以为坏块表可用(rebuild 路径还会把错表持久化覆盖好副本)。
@@ -632,26 +659,58 @@ void gd5f2gq5ue_bbt_clear(void) {
* 返回值:GD5F_OK - 成功,其他 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:临时关闭 ECC 重新扫描每块 page0 spare[0] 的出厂坏块标记,覆盖 RAM BBT,
* 再写回持久化 BBT 池(版本 +1,掉电安全),从而清除 TC-STO-03 等运行时
* 再写回持久化 BBT 池(版本 +1,掉电安全),从而清除 TC-STORAGE-03 等运行时
* 标记的坏块。调用后建议再执行 nand_ftl_format() 清空 dhara map。
*/
int32_t gd5f2gq5ue_bbt_rebuild(void) {
int ret;
int restore_ret;
uint8_t feat = 0;
DBG_INFO("Rebuilding BBT from factory scan...");
/* 1. 关闭 ECC 以读取原始出厂坏块标记(与 gd5f2gq5ue_init 的扫描前提一致) */
gd5f_set_feature(0xB0, 0x00);
gd5f_wait_busy(100);
/* 1. 关闭 ECC 以读取原始出厂坏块标记(与 gd5f2gq5ue_init 的扫描前提一致)。
* 2026-10-23(bsp-err 的 consume-result 检查上线当天照出来的一处真窟窿):这两行原是**裸调用**——关 ECC 失败就是没关掉,扫描会在
* ECC 语义下读 spare,把厂标好块误判成坏块,"重建 BBT"反而制造坏块。 */
ret = gd5f_set_feature(0xB0, 0x00);
if (ret != GD5F_OK) {
DBG_ERROR("BBT rebuild: ECC off failed (%d), aborted", ret);
return ret;
}
if (gd5f_wait_busy(100) != GD5F_OK) {
/* 命令发了但没等到完成 = 状态未知;宁可什么都不做(ECC 仍在,是安全侧) */
DBG_ERROR("BBT rebuild: wait after ECC off failed, aborted");
return GD5F_ERROR;
}
/* 2. 重新扫描出厂坏块(覆盖 RAM BBT,剔除运行时注入的坏块) */
ret = gd5f_bbt_scan();
/* 3. 重新使能 ECC(无论扫描成败都须恢复,否则后续 I/O 在无 ECC 下运行) */
gd5f_set_feature(0xB0, 0x10);
gd5f_wait_busy(100);
/* 3. 重新使能 ECC:无论扫描成败都必须恢复,而且要**验证真的生效**
* (2026-10-23,由 bsp-err 的 consume-result 检查照出)。原先这两行返回值全丢:SPI 一抖 ECC 就保持关闭,而
* gd5f_check_ecc() 在 ECC 关闭时恒读"无 ECC 错"——整条链再没有任何信号,设备
* 从此在无位翻转保护下读写却"一切正常"。init 路径有"写完回读 B0h 校验
* ECC_EN 位",这里补同一套;ECC 没恢复比扫描失败更严重,所以优先返回它。 */
restore_ret = gd5f_set_feature(0xB0, 0x10);
if ((gd5f_wait_busy(100) != GD5F_OK) && (restore_ret == GD5F_OK)) {
restore_ret = GD5F_WAIT_EXPIRED;
}
if (restore_ret == GD5F_OK) {
restore_ret = gd5f_private_get_feature(GD5F_REG_FEATURE, &feat);
}
if ((restore_ret == GD5F_OK) && ((feat & GD5F_FEATURE_ECC_EN) == 0u)) {
restore_ret = GD5F_ERROR; /* 写了但没生效,与写失败同罪 */
}
if (restore_ret != GD5F_OK) {
DBG_ERROR("BBT rebuild: ECC restore FAILED (ret=%d B0h=0x%02X), NAND UNPROTECTED",
restore_ret, feat);
/* 没持久化成功就别让 RAM 留着"比盘上更新"的表:回读盘上副本,使 RAM 与
* 下次上电必然看到的状态一致(否则本次运行按新表跑、重启又退回旧表)。 */
(void)gd5f_bbt_load();
return restore_ret;
}
/* E-42(2026-10-03,数据安全优先):扫描失败 → **放弃持久化**(保留持久区
/* (2026-10-03,数据安全优先):扫描失败 → **放弃持久化**(保留持久区
* 既有好副本),并从盘上回读 BBT 恢复 RAM 视图。原实现无论扫描成败都照常
* save(),会把"读失败拼出来的错表"覆盖掉正确副本。 */
if (ret != GD5F_OK) {
@@ -747,7 +806,7 @@ int32_t gd5f2gq5ue_init(void) {
DBG_INFO("Scanning bad blocks...");
ret = gd5f_bbt_scan();
if (ret != GD5F_OK) {
/* E-10:扫坏块失败 → init 失败,不静默继续(上层据此上报自检并降级) */
/* 扫坏块失败 → init 失败,不静默继续(上层据此上报自检并降级) */
DBG_ERROR("BBT scan failed: %d", ret);
return ret;
}
@@ -758,7 +817,7 @@ int32_t gd5f2gq5ue_init(void) {
DBG_ERROR("Enable ECC failed: %d", ret);
return ret;
}
/* E-10:回读 B0h 校验 ECC 使能位(bit4)**真正生效**——原实现只写不读,
/* 回读 B0h 校验 ECC 使能位(bit4)**真正生效**——原实现只写不读,
* "init 返回 OK 但 ECC 未真正打开"会使存储栈在无 ECC 保护下静默降级。 */
{
uint8_t feat = 0;
+95 -7
View File
@@ -62,7 +62,7 @@ extern "C" {
/*
* 页读取就绪判定 = WIP 轮询(与 Linux 主线 / GigaDevice 厂商驱动完全一致,
* 单发令形状)。2026-09-20 沉降 / 13h 二次发令史实统一记于
* GD5F2GQ5UE_Trap_Records.md Trap 05。
* GD5F2GQ5UE_Trap_Records.md TRAP-GD5F-05。
*/
#define GD5F_TOTAL_BLOCKS 2048
#define GD5F_TOTAL_SIZE (GD5F_TOTAL_BLOCKS * GD5F_BLOCK_SIZE)
@@ -77,7 +77,7 @@ extern "C" {
*/
#define GD5F_DMA_THRESHOLD 1024
/* ==================== DMA 可达内存窗口(N-26,2026-10-12) ====================
/* ==================== DMA 可达内存窗口(DMA 可达性三层防线,2026-10-12) ====================
* DMA1/DMA2 物理不可达 CCM(0x10000000~0x1000FFFF):以 CCM 缓冲做 DMA 源/目的
* 会静默不落地(2026-09-20 首轮 CCM 扩容实验实证根因;口径见
* docs/内存管理/内存内容分配策略.md §2 与 §4 规则 R2/R3)。
@@ -93,7 +93,7 @@ extern "C" {
#define GD5F_CCM_RAM_BASE 0x10000000UL
#define GD5F_CCM_RAM_END 0x10010000UL
/* 编译期守卫(N-26):AC5 无 _Static_assert(C11 不支持),用项目惯用的
/* 编译期守卫(DMA 可达性三层防线一环):AC5 无 _Static_assert(C11 不支持),用项目惯用的
* 负数组 typedef(同 App/cfg/cfg_file.c、Drivers/BSP/UI/ui_menu_tab.h)——
* 条件不成立则数组尺寸 -1 → 编译错。
* ① DMA 白名单窗口与 CCM 窗口**永不得重叠**(有人改上面常量时立即爆炸);
@@ -116,7 +116,7 @@ typedef char gd5f_dma_threshold_contract_check_t[
/* 返回值定义 */
#define GD5F_OK 0
#define GD5F_ERROR -1
#define GD5F_BUSY_TIMEOUT -2
#define GD5F_WAIT_EXPIRED -2 /* 旧名 GD5F_BUSY_TIMEOUT;timeout-name 检查:**返回码不许叫 *_TIMEOUT** */
#define GD5F_ECC_ERROR -3
#define GD5F_PROGRAM_FAIL -4
#define GD5F_ERASE_FAIL -5
@@ -160,7 +160,7 @@ int32_t gd5f2gq5ue_read_id(uint8_t *p_mid, uint8_t *p_did);
* 返回值:GD5F_OK(0) - SPI 传输完成;其它 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:自动处理跨页读取,每次读取不超过当前页剩余空间。
* ⚠ **raw 契约(E-56,2026-10-03 显式文档化)**:返回 0 只表示 **SPI 传输完成**,
* ⚠ **raw 契约(2026-10-03 显式文档化)**:返回 0 只表示 **SPI 传输完成**,
* **不代表数据无位翻转**——ECC 不可纠正页(ECCS 非零)在 SPI 层属正常完成,
* 本函数仍返回 0。数据正确性由 **ECC 层**保证:生产主路径经 dhara glue
* (`nand_ftl.c`)自有 ECC 检查保护;**直接使用本 raw API** 的调用方
@@ -225,7 +225,7 @@ void gd5f2gq5ue_print_bbt(void);
* 入口参数:无
* 返回值:GD5F_OK - 成功,其他 - 错误码
* 限定条件:gd5f2gq5ue_init() 已成功调用
* 函数说明:用于测试(如 TC-STO-03 注入坏块后)或出厂重置后恢复。
* 函数说明:用于测试(如 TC-STORAGE-03 注入坏块后)或出厂重置后恢复。
* 临时关闭 ECC 重新扫描每块 page0 spare[0] 的出厂坏块标记,覆盖 RAM BBT,
* 再写回持久化 BBT 池(版本 +1,掉电安全),从而清除运行时 mark 的坏块。
* 调用后建议再执行 nand_ftl_format() 清空 dhara map。
@@ -272,14 +272,89 @@ int32_t gd5f_bbt_reload(void);
/* ==================== SPI 原语(FTL 共享) ==================== */
/*
* 函数功能:等待芯片空闲(轮询状态位 BUSY/OIP)
* 入口参数:timeout_ms - 等待上限 uint32_t 毫秒
* 返 回 值:GD5F_OK - 已空闲;GD5F_WAIT_EXPIRED - 时限内未空闲(**可重试**,旧名 BUSY_TIMEOUT);GD5F_ERROR - SPI 读取失败
* 限定条件:SPI 已初始化;不得在中断里调用
* 函数说明:把"超时"与"读状态本身失败"分开返回,两者处置不同(前者再等、后者查硬件)
*/
int32_t gd5f_wait_busy(uint32_t timeout_ms);
/*
* 函数功能:发送 06h WRITE ENABLE(编程/擦除前必须成功执行一次)
* 入口参数:无
* 返 回 值:GD5F_OK - 成功;其它 - SPI 传输或等待失败错误码
* 限定条件:SPI 已初始化
* 函数说明:本函数返回值必须被检查并上报(错误通道统一纪律),忽略它会让后续 program/erase 静默无效
*/
int32_t gd5f_write_enable(void);
/*
* 函数功能:读取状态寄存器(0Fh / READ STATUS)
* 入口参数:p_status - 状态字节输出 uint8_t* 不得为 NULL
* 返 回 值:GD5F_OK - 成功(*p_status 有效);GD5F_ERROR - SPI 失败(*p_status 不可信,调用方不得读用)
* 限定条件:SPI 已初始化
* 函数说明:同族纪律——失败时出参内容不可信,故判成败看返回值而不是看 *p_status 是否像样
*/
int32_t gd5f_read_status(uint8_t *p_status);
/*
* 函数功能:页读取(13h:把数据从存储阵列装进内部缓存)
* 入口参数:page_addr - 物理页地址 uint32_t 0 - GD5F_TOTAL_PAGES-1
* 返 回 值:GD5F_OK - 命令已发出且等待完成;其它 - 透传底层 SPI/等待错误码
* 限定条件:SPI 已初始化;**本函数成功不代表数据无位翻转**,正确性由随后的 gd5f_check_ecc() 判定
* 函数说明:装载完成后须用 gd5f_read_from_cache() 取数据
*/
int32_t gd5f_page_read(uint32_t page_addr);
/*
* 函数功能:从内部缓存读出数据(0Bh READ FROM CACHE)
* 入口参数:column - 缓存内起始列 uint16_t 0 - GD5F_PAGE_SIZE-1
* p_buf - 数据输出缓冲 uint8_t* 不得为 NULL
* size - 读取字节数 uint32_t 1 - GD5F_PAGE_SIZE-column
* 返 回 值:GD5F_OK - 成功;GD5F_ERROR - 参数非法或 SPI 失败(**此时 p_buf 内容不可信**)
* 限定条件:必须先成功调用 gd5f_page_read() 完成阵列→缓存装载
* 函数说明:同族——调用方不得在失败分支继续使用 p_buf(FTL 侧据此把读失败保守判为"非空页")
*/
int32_t gd5f_read_from_cache(uint16_t column, uint8_t *p_buf, uint32_t size);
/*
* 函数功能:把数据装进内部缓存(02h PROGRAM LOAD)
* 入口参数:column - 缓存内起始列 uint16_t 0 - GD5F_PAGE_SIZE-1
* p_buf - 待写数据 const uint8_t* 不得为 NULL
* size - 字节数 uint32_t 1 - GD5F_PAGE_SIZE-column
* 返 回 值:GD5F_OK - 装载完成;GD5F_WAIT_EXPIRED - 装载后等待 OIP 超时(**可重试**);GD5F_ERROR - 参数/SPI 失败
* 限定条件:先成功执行 gd5f_write_enable();缓冲须在 DMA 可达窗口内(GD5F_DMA_RAM_* 白名单)
* 函数说明:装载本身不写阵列,真正的编程由 gd5f_program_exec() 触发
*/
int32_t gd5f_program_load(uint16_t column, const uint8_t *p_buf, uint32_t size);
/*
* 函数功能:触发页编程(10h PROGRAM EXEC)并检查 P_FAIL
* 入口参数:page_addr - 目标物理页地址 uint32_t 合法页号
* 返 回 值:GD5F_OK - 编程完成且无 P_FAIL;**GD5F_PROGRAM_FAIL - 芯片置 P_FAIL(块退化的信号)**;其它 - 透传底层/SPI 错误
* 限定条件:本调用前须已 gd5f_program_load() 成功
* 函数说明:P_FAIL 是**器件自己认定这一页写不下去**的位,与 SPI 层错误性质不同(FTL 据此标坏块)
*/
int32_t gd5f_program_exec(uint32_t page_addr);
/*
* 函数功能:擦除一个块(D8h 擦除序列 + 等待 + 检查 E_FAIL)
* 入口参数:block_addr - 物理块地址 uint32_t 0 - GD5F_TOTAL_BLOCKS-1
* 返 回 值:GD5F_OK - 擦净;**GD5F_ERASE_FAIL - 芯片置 E_FAIL(坏块信号)**;其它 - 透传底层/SPI 错误
* 限定条件:先成功执行 gd5f_write_enable()
* 函数说明:E_FAIL 与 SPI 错误的区别同 program_exec;擦除超时归 GD5F_WAIT_EXPIRED(可重试)
*/
int32_t gd5f_block_erase(uint32_t block_addr);
/*
* 函数功能:检查上一次页读取的 ECC 结果(读 00h/BCh 特征寄存器的 ECC 状态)
* 入口参数:无
* 返 回 值:GD5F_OK - 无不可纠错误(可能含已纠正的位翻转);GD5F_ECC_ERROR - **存在不可纠正的 ECC 错误**
* 限定条件:SPI 已初始化且 ECC 已使能(init 期扫描在使能前调用本函数无意义)
* 函数说明:这是数据正确性的唯一判定点——SPI 层读到"内容"不等于"内容对"(见 §8.1 与本头"同族纪律"注释)
*/
int32_t gd5f_check_ecc(void);
/*
@@ -288,7 +363,7 @@ int32_t gd5f_check_ecc(void);
* size - 传输字节数 size_t
* 返回值:1 - 整段 DMA 可达;0 - 不可达(含 size=0 / 地址回绕 / 越窗)
* 限定条件:无
* 函数说明:N-26(2026-10-12)由 gd5f2gq5ue.c 私有守卫升级为公开 API——
* 函数说明:2026-10-12 由 gd5f2gq5ue.c 私有守卫升级为公开 API(DMA 可达性三层防线一环)——
* 驱动内部每次 >阈值 传输前用它(不可达即回落 CPU 轮询),
* FTL 层(nand_ftl.c)在 init 期用它自检静态页缓冲的落位。
*/
@@ -324,6 +399,19 @@ void nand_ftl_wd_beat(void);
*/
void nand_ftl_wd_feed(void);
/*
* 函数功能:SPI1 DMA 完成 / 错误通知(由实际注册 HAL SPI 回调的模块转调)
* 入口参数:无
* 返回值:无
* 限定条件:中断上下文(HAL SPI 回调内),两者只写一个完成标志
* 函数说明:HAL 的 SPI 完成/错误回调是全局唯一符号,本模块只实现 TxCplt/RxCplt
* 两个(供自己的收发用),Error 回调的所有权留给回调的实际注册方
* (当前 = Drivers/BSP/CH395F/ch395f.c,按 hspi->Instance 分发)。**SPI1
* 分支必须转调这两个函数**,否则本模块的 DMA 等待在出错时只能靠超时
* 兜底。2026-10-22:取代原先被 CH395F 跨模块裸 extern 直接写的全局变量。
*/
void gd5f_spi1_notify_done(void);
void gd5f_spi1_notify_error(void);
#ifdef __cplusplus
}
#endif
+233 -58
View File
@@ -6,12 +6,25 @@
* 创建日期:2026-07-20
* 修改记录:
* 2026-07-20 王建锋 创建初始版本
* 2026-09-21 E-31 resume 失败按错误分类:仅真空白判新卷,其余失败
* 2026-09-21 resume 失败按错误分类:仅真空白判新卷,其余失败
* 返回 mount 层(禁止静默清 map 触发整卷重格式化)
* 2026-10-02 王建锋 resume 成功后增打 journal head/tail/epoch:复盘
* "复位后所有写入丢失"时 sectors used 不足以判定
* journal 回退(覆盖写不改 count),head/tail/epoch 是
* 唯一能暴露 resume 锚错 checkpoint 的证据
* 2026-10-15 王建锋 落实 dhara 移植层两条硬约束:① is_free 改**整页扫描**——
* 旧实现只看页首 64 B,会把"页首 0xFF、后段有数据"的
* 已编程页误判空闲,dhara 遂往已编程页再编程 = 数据丢失
* (页首有数据的绝大多数页走快路径,成本与旧实现同价);
* ② HAL 六个入口全部加几何上界守卫——越界页/块号(含
* DHARA_PAGE_NONE)旧会被地址位截断成真实页 → 静默读写
* 错页,现报 DHARA_E_CORRUPT_MAP(不能用 BAD_BLOCK:会
* 误标无辜好块;不能用 RECOVER:会触发回收);
* ③ 补主机侧移植层单测 test/host/nand_port/(L0.5);
* ④ 几何字面量收成 FTL_LOG2_PAGE_SIZE / FTL_LOG2_PPB
* 单一真源 + 两条负数组编译期守卫:越界上界依赖
* log2_ppb,换料时只改 GD5F_* 而忘改字面量会**静默**
* 把守卫算大 = 越界守卫失效,现改为直接编译错
*/
/* 头文件包含区 */
@@ -21,6 +34,7 @@
#include "diskio.h"
#include "nand.h"
#include "map.h"
#include "bsp_err.h" /* C2:把 dhara 错误归类成跨模块可判定的失败类 */
/* FTL 分区起始地址(块 0,偏移 0,占用全部 256MB) */
#define FTL_FATFS_OFFSET 0U
@@ -41,10 +55,58 @@
#define FTL_NUM_BLOCKS (GD5F_TOTAL_BLOCKS - FTL_START_BLOCK)
#define FTL_START_PAGE (FTL_START_BLOCK * GD5F_PAGES_PER_BLOCK)
/* dhara 几何字面量的**单一真源**(TRAP-GD5F-07 遗留 3 的收口):disk_initialize 与
* 下方守卫都取这两个宏,不再各写一遍 11 / 6。 */
#define FTL_LOG2_PAGE_SIZE 11u
#define FTL_LOG2_PPB 6u
/* 编译期守卫:`ftl_page_in_range()` 的越界上界 = `num_blocks << log2_ppb`,
* **完全依赖 log2_ppb**。将来换料(例如 128 页/块的兼容片)只改了 GD5F_* 常量
* 而忘了同步这里的字面量,守卫会静默算错——偏大就是越界守卫失效,正是它要防的
* 那一类错误,而且不报任何错。下面两条把"字面量 == GD5F 常量的 log2"钉死,
* 不一致直接编译错。负数组 typedef 是 AC5 下的项目惯用法(AC5 不支持 C11
* _Static_assert),同本目录 gd5f2gq5ue.h 的 DMA 契约守卫。 */
typedef char ftl_geometry_log2_page_size_check_t[
(FTL_PAGE_SIZE == (1 << FTL_LOG2_PAGE_SIZE)) ? 1 : -1];
typedef char ftl_geometry_log2_ppb_check_t[
(GD5F_PAGES_PER_BLOCK == (1 << FTL_LOG2_PPB)) ? 1 : -1];
/* dhara 逻辑地址到物理地址转换 */
static inline uint32_t ftl_phy_block(dhara_block_t b) { return b + FTL_START_BLOCK; }
static inline uint32_t ftl_phy_page(dhara_page_t p) { return p + FTL_START_PAGE; }
/*
* dhara 移植层硬约束(2026-10-15):移植层地址守卫
*
* 旧实现把 dhara 传来的页/块号直接加偏移送进 SPI 命令,**不做任何上界校验**。
* 越界页号(含 dhara 的"无页"哨兵 DHARA_PAGE_NONE = 0xffffffff,journal.h:46)
* 经 ftl_phy_page() 相加后会被芯片地址位截断成某个**真实存在的页** → 静默读/写
* 错页,比报错严重得多(数据错而传输"成功",与 CCM-DMA 那条同族)。
*
* 为什么报 DHARA_E_CORRUPT_MAP 而不是报告原建议的 DHARA_E_UNSUPPORTED:
* 本仓裁剪版 dhara 的错误枚举(Lib/dhara/error.h:20-31)里**没有**
* E_UNSUPPORTED,写上去直接编译失败。
* 为什么不用 DHARA_E_BAD_BLOCK:journal.c:904 的 recover_from 只对 BAD_BLOCK
* 走"标坏块 + 跳块重试"——对一个凭空的地址标坏块 = 毁掉一个无辜的好块。
* 为什么不会触发静默清卷:map.c:275 的 try_recover 只对 E_RECOVER 启动回收,
* 其余错误一律原样上抛(教训:单次瞬态毛刺不得销毁整卷数据)。
* is_free / is_bad 没有错误通道 → 一律保守取值(判非空 / 判坏),与同族
* 口径一致:宁可少用一个块,不可把不可信的内容当有效。
*/
static int ftl_page_in_range(const struct dhara_nand *n, dhara_page_t p) {
if ((n == NULL) || (n->num_blocks == 0u)) {
return 0;
}
return p < ((dhara_page_t)n->num_blocks << n->log2_ppb);
}
static int ftl_block_in_range(const struct dhara_nand *n, dhara_block_t b) {
if ((n == NULL) || (n->num_blocks == 0u)) {
return 0;
}
return b < n->num_blocks;
}
/* ======================== 私有变量 ======================== */
/* dhara 核心数据结构 */
@@ -65,56 +127,122 @@ static uint8_t s_initialized;
/* ======================== 私有函数声明 ======================== */
static int ftl_flush_cache(void);
static int ftl_read_page(dhara_sector_t lpn);
static bsp_err_t ftl_flush_cache(void);
static bsp_err_t ftl_read_page(dhara_sector_t lpn);
/* SPI 原语从 gd5f2gq5ue.h 获取 */
/* ======================== dhara 错误的可读名与失败类 ======================== */
/*
* 函数功能:把 dhara 错误枚举翻成短字符串(**本仓 dhara 没编 error.c,没有 dhara_strerror()**,
* 用它会链接失败,故自己给一张小表)
* 入口参数:err - dhara 错误枚举值
* 返回值:静态字符串常量("NONE" / "ECC" / "MAP_FULL" ...,越界返回 "OOB")
* 限定条件:只用于日志,不得用于判等
* 函数说明:以前这里只打 `err=%d`——现场拿到一串数字还得翻 Lib/dhara/error.h 才知道是什么,
* 存储故障又大多是"看完日志就没机会复现"的那类,可读名是排障成本最低的改法。
*/
static const char *ftl_err_name(dhara_error_t err) {
static const char *const s_names[] = {
"NONE", "BAD_BLOCK", "ECC", "TOO_BAD", "RECOVER",
"JOURNAL_FULL", "NOT_FOUND", "MAP_FULL", "CORRUPT_MAP",
};
if ((unsigned int)err < (sizeof(s_names) / sizeof(s_names[0]))) {
return s_names[err];
}
return "OOB";
}
/*
* 函数功能:把 dhara 错误归类成统一失败类(C2)
* 入口参数:err - dhara 错误枚举值
* 返回值:BSP_ERR_CRC(位翻转不可纠 / 页太坏);BSP_ERR_NO_SPACE(journal 或映射层预算
* 耗尽);其余(坏块 / 找回失败 / 映射损坏 / 越界)→ BSP_ERR_IO
* 限定条件:入参应是**失败**的 err(DHARA_E_NONE 会得 BSP_OK)
* 函数说明:分这三类的理由是它们在**处置上真的不同**,不是为了码好看:
* · ECC/TOO_BAD → 该重试读或标页坏,**不是**卷的问题;
* · JOURNAL_FULL/MAP_FULL → 介质还健康,是**块池/日志预算见底**,此时重格式化只是
* 把问题推回去(教训正是"别把非空白错误当新卷清 map"),该走容量恢复流程;
* · BAD_BLOCK/CORRUPT_MAP/RECOVER → 映射或介质完整性故障,该保卷 + 上报,不许自愈重格式化。
* 本函数先把这三类**打出来**(就地日志,最早且唯一同时知道 LPN 与原始码的位置);
* 要让 defaultTask 的卷健康探针按类分支,还需要一个跨调用存活的快照(C4 待还)。
*/
static bsp_err_t ftl_class_from_dhara(dhara_error_t err) {
switch (err) {
case DHARA_E_NONE:
return BSP_OK;
case DHARA_E_ECC:
case DHARA_E_TOO_BAD:
return BSP_ERR_CRC;
case DHARA_E_JOURNAL_FULL:
case DHARA_E_MAP_FULL:
return BSP_ERR_NO_SPACE;
default:
return BSP_ERR_IO;
}
}
/* ======================== 页面缓存管理 ======================== */
static int ftl_flush_cache(void) {
static bsp_err_t ftl_flush_cache(void) {
if (!s_cache_dirty) {
return GD5F_OK;
return BSP_OK;
}
dhara_error_t err;
if (dhara_map_write(&s_map, s_cached_lpn, s_cache_buf, &err) < 0) {
DBG_ERROR("Cache flush failed: LPN=%lu err=%d", s_cached_lpn, err);
return GD5F_ERROR;
bsp_err_t cls = ftl_class_from_dhara(err); /* 只在失败分支需要(cppcheck variableScope) */
DBG_ERROR("Cache flush FAIL: LPN=%lu dhara=%s class=%d",
s_cached_lpn, ftl_err_name(err), cls);
return cls;
}
s_cache_dirty = 0;
return GD5F_OK;
return BSP_OK;
}
static int ftl_read_page(dhara_sector_t lpn) {
static bsp_err_t ftl_read_page(dhara_sector_t lpn) {
dhara_error_t err;
if (dhara_map_read(&s_map, lpn, s_cache_buf, &err) < 0) {
DBG_ERROR("Page read failed: LPN=%lu err=%d", lpn, err);
return GD5F_ERROR;
bsp_err_t cls = ftl_class_from_dhara(err); /* 只在失败分支需要(cppcheck variableScope) */
DBG_ERROR("Page read FAIL: LPN=%lu dhara=%s class=%d",
lpn, ftl_err_name(err), cls);
return cls;
}
s_cached_lpn = lpn;
s_cache_dirty = 0;
return GD5F_OK;
return BSP_OK;
}
/* ======================== dhara NAND HAL ======================== */
int dhara_nand_is_bad(const struct dhara_nand *n, dhara_block_t b) {
(void)n;
if (ftl_block_in_range(n, b) == 0) {
return 1; /* 越界块:保守判坏,让 dhara 跳过 */
}
return gd5f2gq5ue_is_block_bad(ftl_phy_block(b));
}
void dhara_nand_mark_bad(const struct dhara_nand *n, dhara_block_t b) {
(void)n;
if (ftl_block_in_range(n, b) == 0) {
return; /* 越界块:绝不标坏,避免毁掉无辜好块 */
}
gd5f2gq5ue_mark_block_bad(ftl_phy_block(b));
}
int dhara_nand_erase(const struct dhara_nand *n, dhara_block_t b, dhara_error_t *err) {
(void)n;
int ret = gd5f_block_erase(ftl_phy_block(b));
int ret;
if (ftl_block_in_range(n, b) == 0) {
dhara_set_error(err, DHARA_E_CORRUPT_MAP);
return -1;
}
ret = gd5f_block_erase(ftl_phy_block(b));
if (ret == GD5F_ERASE_FAIL || ret != GD5F_OK) {
dhara_nand_mark_bad(n, b);
dhara_set_error(err, DHARA_E_BAD_BLOCK);
@@ -125,7 +253,13 @@ int dhara_nand_erase(const struct dhara_nand *n, dhara_block_t b, dhara_error_t
int dhara_nand_prog(const struct dhara_nand *n, dhara_page_t p, const uint8_t *data, dhara_error_t *err) {
int ret;
uint32_t phy_pg = ftl_phy_page(p);
uint32_t phy_pg;
if ((ftl_page_in_range(n, p) == 0) || (data == NULL)) {
dhara_set_error(err, DHARA_E_CORRUPT_MAP);
return -1;
}
phy_pg = ftl_phy_page(p);
gd5f_write_enable();
ret = gd5f_program_load(0, data, FTL_PAGE_SIZE);
@@ -145,42 +279,66 @@ int dhara_nand_prog(const struct dhara_nand *n, dhara_page_t p, const uint8_t *d
/* 编程正确性以 P_FAIL 状态位(gd5f_program_exec 内检查)+ 读时 ECC +
* journal 读时门(magic/CRC)为准;数据路径不做整页回读比对——
* 2026-09-20 曾加回读校验(GD5F_PROG_VERIFY),2026-09-21 经评估整体
* 移除(依据见 docs/内存管理/存储栈RAM优化调研.md 与 GD5F Trap Records Trap 05) */
* 移除(依据见 docs/内存管理/存储栈RAM优化调研.md 与 GD5F Trap Records TRAP-GD5F-05) */
return 0;
}
/*
* dhara 移植层硬约束(2026-10-15):整页扫描
*
* 旧实现只看页首 64 B。dhara 的 journal 用 is_free 判断"检查点组是否未编程"
* (journal.c:416 cp_free、journal.c:520 找下一空闲页),其正确性前提是
* **is_free 答"空闲"的页必须真的全 0xFF**——journal.c:405-414 的注释论证了
* "只要组内被编程过的页含非 0xFF 字节就能识别出来",可若那一页恰好页首 64 B
* 是 0xFF、后段才有数据,旧实现就会误判空闲。误判的后果不是报错,而是
* **往已编程的页再编程一次** → 位碰撞、journal/map 指向垃圾 = 数据丢失。
* 本卷上跑的是 FatFS 数据页,"页首一段 0xFF、后段有数据"完全常见(刚格式化的
* 卷、稀疏文件块、日志尾部),所以必须整页判。
*
* 成本处理:先查页首 64 B——页首就有数据的页(绝大多数已编程页)当场返回非空,
* 与旧实现同价;只有"目前读到的全是 0xFF"的页才继续扫后段。分块用栈上 64 B
* 缓冲,不新增 2 KB 静态 RAM(页缓冲三块不可合并的论证见
* docs/内存管理/存储栈RAM优化调研.md §5)。
*/
int dhara_nand_is_free(const struct dhara_nand *n, dhara_page_t p) {
(void)n;
uint32_t phy_pg = ftl_phy_page(p);
int fre;
uint8_t buf[64];
uint32_t phy_pg;
uint32_t off;
uint32_t i;
if (ftl_page_in_range(n, p) == 0) {
return 0; /* 越界页号:保守判非空 */
}
phy_pg = ftl_phy_page(p);
if (gd5f_page_read(phy_pg) != GD5F_OK) {
fre = 0;
} else {
uint8_t buf[64];
return 0;
}
if (gd5f_read_from_cache(0, buf, sizeof(buf)) != GD5F_OK) {
/* E-42 同族(2026-10-03):读失败 → buf 可能为陈旧/垃圾内容,
for (off = 0; off < FTL_PAGE_SIZE; off += sizeof(buf)) {
if (gd5f_read_from_cache((uint16_t)off, buf, sizeof(buf)) != GD5F_OK) {
/* 同族(2026-10-03):读失败 → buf 可能为陈旧/垃圾内容,
* 保守判为非空(块不可复用),避免把"没读到内容的块"判成空闲。 */
fre = 0;
} else {
uint32_t i;
fre = 1;
for (i = 0; i < sizeof(buf); i++) {
if (buf[i] != 0xFF) {
fre = 0;
break;
}
return 0;
}
for (i = 0; i < sizeof(buf); i++) {
if (buf[i] != 0xFFu) {
return 0; /* 页首命中即返回 = 快路径,不读后段 */
}
}
}
return fre;
return 1;
}
int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, size_t length, uint8_t *data, dhara_error_t *err) {
(void)n;
uint32_t phy_pg = ftl_phy_page(p);
uint32_t phy_pg;
if ((ftl_page_in_range(n, p) == 0) || (data == NULL) ||
(offset >= FTL_PAGE_SIZE) || (length > (FTL_PAGE_SIZE - offset))) {
dhara_set_error(err, DHARA_E_CORRUPT_MAP);
return -1;
}
phy_pg = ftl_phy_page(p);
if (gd5f_page_read(phy_pg) != GD5F_OK) {
dhara_set_error(err, DHARA_E_ECC);
@@ -192,7 +350,7 @@ int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, s
return -1;
}
/* E-42 同族(2026-10-03):read_from_cache 返回值必须检查——失败时 data 内容
/* 同族(2026-10-03):read_from_cache 返回值必须检查——失败时 data 内容
* 不可信,原实现仍返回 0(成功),会把未读到的数据当有效数据交给上层。 */
if (gd5f_read_from_cache((uint16_t)offset, data, length) != GD5F_OK) {
dhara_set_error(err, DHARA_E_ECC);
@@ -201,8 +359,23 @@ int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, s
return 0;
}
/*
* dhara 移植层硬约束(2026-10-15):src / dst 双端守卫
*
* 报告原判断"dhara map 在回收/擦除路径会用 DHARA_NO_PAGE 试探"经逐点核对
* **不成立**:本仓裁剪版没有 DHARA_NO_PAGE,哨兵叫 DHARA_PAGE_NONE
* (journal.h:46),而 map.c 三处 dhara_journal_copy 调用点都已先挡掉它
* (map.c:260 挡 pad_queue 的 p、map.c:434/440 挡 alt_page、map.c:242 的 src
* 来自 trace_path 的真实页)。但"移植层不做任何上界校验"这个风险是真的:
* 哨兵一旦从别处漏进来(未来改动、被污染的 meta),ftl_phy_page() 会把
* 0xffffffff 截断成某个真实页 → **静默搬走无辜数据**。故 src/dst 各挡一次,
* dst 越界尤其致命——那是一次写入。
*/
int dhara_nand_copy(const struct dhara_nand *n, dhara_page_t src, dhara_page_t dst, dhara_error_t *err) {
(void)n;
if ((ftl_page_in_range(n, src) == 0) || (ftl_page_in_range(n, dst) == 0)) {
dhara_set_error(err, DHARA_E_CORRUPT_MAP);
return -1;
}
if (dhara_nand_read(n, src, 0, FTL_PAGE_SIZE, s_copy_buf, err) < 0) {
return -1;
@@ -228,15 +401,16 @@ DSTATUS disk_initialize(BYTE pdrv) {
DBG_INFO("Initializing NAND FTL...");
/* N-26 启动期地址自检(2026-10-12):本文件三个页缓冲是 SPI1 DMA 路径上
/* DMA 可达性三层防线——启动期地址自检(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 内部使用,一并守住防将来越窗)。
* 2048 B > GD5F_DMA_THRESHOLD;s_page_buf 页内虽只攒 132 B 元数据,
* 但编程的是**整页**(journal.c:992),传输长度同为 2048 B、同样走 DMA,
* 一并守住防将来越窗)。
* 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) ||
@@ -246,8 +420,8 @@ DSTATUS disk_initialize(BYTE pdrv) {
return STA_NOINIT;
}
s_nand.log2_page_size = 11;
s_nand.log2_ppb = 6;
s_nand.log2_page_size = FTL_LOG2_PAGE_SIZE;
s_nand.log2_ppb = FTL_LOG2_PPB;
/* dhara 可见块数 = 总块数 - 保留块池(BBT 存储区不交给 dhara,避免被擦写) */
s_nand.num_blocks = gd5f_get_usable_blocks();
@@ -265,10 +439,10 @@ DSTATUS disk_initialize(BYTE pdrv) {
* 显式格式化后);失败的 resume 已把 journal 复位为空态 →
* 按新卷处理,由上层自动建卷(此处 dhara_map_clear 为空操作:
* resume 失败时 count 已清零,map 本已为空)。 */
DBG_INFO("No valid map found (err=%d), creating fresh", err);
DBG_INFO("No valid map found (dhara=%s), creating fresh", ftl_err_name(err));
dhara_map_clear(&s_map);
} else {
/* E-31(2026-09-21 审计 P0 修复):非"空白"错误(ECC /
/* (2026-09-21 审计 P0 修复):非"空白"错误(ECC /
* BAD_BLOCK / CORRUPT_MAP / 瞬态 I/O 等)= 真实完整性故障——
* 芯片上 journal 可能仍持有有效 checkpoint。旧行为把任何失败
* 当新卷清 map,上层随即整卷重格式化 → 单次瞬态毛刺即静默销毁
@@ -276,8 +450,9 @@ DSTATUS disk_initialize(BYTE pdrv) {
* defaultTask 自检上报 "filesystem fault" 并保留卷;瞬态故障
* 由主循环重试/下次上电重新 resume;重格式化只走显式命令
* (nand_ftl_format)。 */
DBG_ERROR("Map resume failed (err=%d), volume kept, "
"storage unavailable", err);
DBG_ERROR("Map resume failed (dhara=%s class=%d), volume kept, "
"storage unavailable", ftl_err_name(err),
ftl_class_from_dhara(err));
return STA_NOINIT;
}
} else {
@@ -346,10 +521,10 @@ DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) {
}
if (lpn != s_cached_lpn) {
if (ftl_flush_cache() != GD5F_OK) {
if (ftl_flush_cache() != BSP_OK) {
return RES_ERROR;
}
if (ftl_read_page(lpn) != GD5F_OK) {
if (ftl_read_page(lpn) != BSP_OK) {
return RES_ERROR;
}
}
@@ -381,10 +556,10 @@ DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) {
}
if (lpn != s_cached_lpn) {
if (ftl_flush_cache() != GD5F_OK) {
if (ftl_flush_cache() != BSP_OK) {
return RES_ERROR;
}
if (ftl_read_page(lpn) != GD5F_OK) {
if (ftl_read_page(lpn) != BSP_OK) {
return RES_ERROR;
}
}
@@ -393,7 +568,7 @@ DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) {
s_cache_dirty = 1;
if (offset == 0 && batch == FTL_SECTORS_PER_PAGE) {
if (ftl_flush_cache() != GD5F_OK) {
if (ftl_flush_cache() != BSP_OK) {
return RES_ERROR;
}
}
@@ -416,7 +591,7 @@ DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) {
switch (cmd) {
case CTRL_SYNC:
if (ftl_flush_cache() != GD5F_OK) {
if (ftl_flush_cache() != BSP_OK) {
return RES_ERROR;
}
{
@@ -459,7 +634,7 @@ int nand_ftl_format(void) {
}
}
if (ftl_flush_cache() != GD5F_OK) {
if (ftl_flush_cache() != BSP_OK) {
return GD5F_ERROR;
}
+43 -1
View File
@@ -1,3 +1,15 @@
/*
* 模块名称:SPI NAND FTL(dhara 移植层 + FatFs 磁盘接口)
* 模块功能:把 GD5F SPI NAND 经 dhara 映射层暴露成 FatFs 的 0 号磁盘
* (disk_initialize/status/ioctl/read/write),并提供显式格式化入口。
* 适用平台:STM32F4 系列 + GD5F2GQ5UE
* 作者:王建锋
* 创建日期:2026-09
* 修改记录:
* 2026-10-23 王建锋 补模块头与函数注释(return-doc 检查);返回值语义写明"失败类目前
* 只进日志、不外传",避免调用方以为能拿到 ECC / 预算耗尽的区别
*/
#ifndef NAND_FTL_H
#define NAND_FTL_H
@@ -7,12 +19,42 @@ extern "C" {
#include <stdint.h>
/*
* 函数功能:初始化 FTL(等价于 disk_initialize(0):SPI 起链 + dhara_map_resume)
* 入口参数:无
* 返回值:0 - 成功(映射可 resume 或判为真空白卷);-1 - 失败(disk_initialize 非 0)
* 限定条件:由 FatFs 的 f_mount(forced=1) 间接触发,或测试代码直接调用
* 函数说明:⚠ 这里的 -1 **抹掉了失败类**——"ECC 读不动"、"journal 满"与"映射损坏"对外
* 都只是 -1。分类信息目前只在失败点就地进日志(见 nand_ftl.c 的 ftl_class_from_dhara
* 与 ../架构设计/STM32F4-Base存储架构说明.md §10.4);要让调用方按类分支,需要跨调用
* 存活的失败类快照(C4 待还项,当前无消费者故未做)。
* 调用方**不得**把 -1 解读成"卷坏了,可以重格式化"——教训正在这条上。
*/
int nand_ftl_init(void);
/*
* 函数功能:清空 dhara 映射树并同步(**破坏性**:整卷逻辑地址→物理映射作废)
* 入口参数:无
* 返回值:GD5F_OK(0) - 已清空并同步;GD5F_ERROR(-1) - 初始化/刷写缓存/同步失败(卷状态未定,
* 须重新挂载判定,不要连环重试)
* 限定条件:只允许走显式命令或恢复流程调用;不得因"某次读写失败"自动调用
* 函数说明:新卷从空树写起,避免旧 journal 残留与新卷共存(结构卫生)。
* 容量恢复动作的区分(A 清日志 / B 重挂载 / C 全片擦除 / D 残留清理)
* → ../测试说明/存储维护与容量恢复.md。
*/
int nand_ftl_format(void);
/*
* 函数功能:复位 FTL 内存态,使下次 nand_ftl_init() 重新走 dhara_map_resume
* 入口参数:无
* 返回值:无
* 限定条件:测试用途(掉电恢复 / 坏块注入后重建场景模拟设备重启)
* 函数说明:只清 RAM 状态位,不碰闪存内容。
*/
void nand_ftl_deinit(void);
#ifdef __cplusplus
}
#endif
#endif
#endif /* NAND_FTL_H */
+23 -14
View File
@@ -7,7 +7,8 @@
| 文件 | 作用 |
|---|---|
| `net_socket.c/h` | **BSD Socket API 实现**:`net_socket` / `net_connect` / `net_send` / `net_recv` / `net_close` / `net_listen` / `net_accept`,含 L3 挂起(pending)与错误码 |
| `net_socket.c/h` | **BSD Socket API 实现**:`net_socket` / `net_connect` / `net_send` / `net_recv` / `net_close` / `net_listen` / `net_accept`,含挂起 recv(pending)与错误码 |
| `net_pending.c/h` | **挂起 recv / send 请求表**(阻塞挂起模型:登记 / 每轮完成检查(recv)/ 推进(send)/ 取消 / close 结算;真机专属,模拟器走 `net_socket_sim.c`) |
| `net_select.c/h` | `select`/`poll` 多路复用(基于芯片中断事件 + 周期轮询) |
| `net_types.h` | 网络层公共类型定义 |
| `net_config.h` | 网络层编译期配置(缓冲区大小、超时、对账周期等) |
@@ -15,26 +16,34 @@
## 核心机制
### 阻塞模型(L3 pending)
### 阻塞模型(挂起 recv / send / pending)
- 一期(RECV)已落地:`net_recv` 等支持带超时的阻塞(`pending` 挂起 + tick 唤醒)
- 二期(ACCEPT + SEND)待做 → `docs/任务规划/登记簿.md` N-4
- **recv 挂起(pending)已落地**:`net_recv` 阻塞不占 netTask——登记挂起请求 + 每轮 tick 唤醒
- **send 挂起已落地**:`net_send` 快路径有界自旋(`NET_SEND_SPIN_MS` = 2ms),超预算登记
挂起 send,由 `net_send_pending_tick` 按主循环节拍分块推进;netTask 最坏停滞由 1s 压到 2ms
- **ACCEPT 决定不挂起化(2026-10-15)**(调用方轮询问题,不是队头阻塞;pending 化收益仅 RPC 开销与抖动)
- 架构细节:`docs/网络/Net层阻塞模型架构设计.md`
### 周期对账自愈(Trap 22 修复)
### 周期对账自愈(TRAP-NET-17 修复;对账与监听重建合一)
- `net_reconcile_tick()`(200ms)回读芯片状态修正三类失同步:
- `net_reconcile_tick()`(100ms,判据读**驱动状态缓存**——唯一
刷新点 `net_poll` 每轮全量刷新,E2 实测单次回读 18.1us)修正失同步:
本层 ESTABLISHED 而芯片已断 → 断开;本层 LISTENING 而芯片已握手 → 补 accept;
本层 LISTENING 而芯片监听消失 → 重建
- **去抖(连续 2 次)+ 冷却(1s)**——单次状态读的重建动作**必须去抖**,
否则"刚 listen 未生效"瞬态会反复重建直至 `relisten give up`(三监听口全死)
本层 LISTENING/CLOSED(监听型 fd)而芯片监听消失 → 重建(形态③④);
本层 CLOSED 而芯片在听 → 只重同步(U11);芯片静默回收 → RESYNC(⑤)
- **分级去抖(②=1 轮 / ③④⑤=2 轮 / ①=4 轮)+ 冷却(1s)**——监听失位的重建
动作**必须去抖**,否则"刚 listen 未生效"瞬态会反复重建直至
`relisten retry budget exhausted`(三监听口全死);①的 4 轮窗是 F-NET-17 M3
可疑电平的让位租约(勿改小)
### 异常复监听隔离(Trap 23 修复)
### 异常复监听隔离(TRAP-NET-18 修复)
- 异常释放(TIMEOUT)的 socket 复监听前隔离:排空 RX 残渣 + 主动 close + 等 CLOSED
- 400ms 起、800ms 上限强制放行(上限须 < 重试放弃时限 2000ms)
- 400ms 起;**800ms 上限只留痕、不强行放行**(芯片未回 CLOSED 时 open 注定吃 USE 错,
真正兜底 = D10 预算到期 + 对账形态④ 重新登记)
- 仅异常路径生效,正常 FIN 路径零改动
### 错误码(E-03 修复)
### 错误码
- **按任务隔离**:`s_net_errno_slots[8]`(每槽 `{任务句柄, err}`),替代原全局 `s_net_errno`
- `net_get_errno()` 只读本任务槽位,**对外语义不变**(成功不清零)
@@ -54,12 +63,12 @@
- 就绪屏障位(`SYS_EVT_NET_READY` 等)等待必须 `osFlagsNoClear`,**不能用默认消费型**
- 每次 CH395F SPI 事务须 `ch395f_spi_begin()/end()` 包裹
- **改任何自愈/重建逻辑必须去抖**,否则会引发重建风暴
- 相关陷阱:`docs/问题记录/CH395F_Trap_Records.md` Trap 21/22/23
- 相关陷阱:`docs/问题记录/CH395F_Trap_Records.md` TRAP-NET-17/-18
## 相关文档
- 上层用法:`docs/网络/BSD_Socket_API_使用指南.md`
- 内部调用关系:`docs/网络/BSD_Socket_API_函数调用关系.md`
- 架构设计:`docs/网络/Net层阻塞模型架构设计.md`(阻塞等待 / pending 请求模型)
- 二期设计基线:`docs/任务规划/基线与待确认.md` §2.1
- ACCEPT 挂起化不实施的判据:`docs/网络/Net层阻塞模型架构设计.md` §10
- 测试:`docs/测试说明/CH395F_Test_Guide.md`、`docs/测试说明/FTP_Test_Guide.md`
+4 -4
View File
@@ -29,13 +29,13 @@ lftpd(本目录)
- **ftpTask 就绪屏障**:`osEventFlagsWait` 等两位就绪必须加 `osFlagsNoClear`,
否则位被先到的消费者清掉 → FTP 21 端口静默不监听(无日志无报错)
- **lftpd 大局部缓冲**:已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 静态化 9 处
- **lftpd 大局部缓冲**:已按 `docs/内存管理/RAM使用分析与优化方案.md` §4.1.4 静态化 9 处
大局部缓冲(BSS +4.6KB),使 ftpTask 栈峰值从 3480B 降到 1184B——**勿改回局部变量**
- **上传中止截断**:FTP STOR 中途断开后首笔复连被截断(Trap 23)已在 `net_socket.c` 侧修复
- **上传中止截断**:FTP STOR 中途断开后首笔复连被截断(TRAP-NET-18)已在 `net_socket.c` 侧修复
- 本目录受 pre-commit 的**部分排除**(第三方代码)
## 相关文档
- 测试指南:`docs/测试说明/FTP_Test_Guide.md`
- 台架探针:`MDK-ARM/ftp_abort_probe.py`、`MDK-ARM/ftp_freeze_probe.py`
- 内存优化:`docs/内存管理/RAM使用分析与优化方案.md` §1.4
- 台架探针:`test/host/ftp_abort_probe.py`、`MDK-ARM/ftp_freeze_probe.py`
- 内存优化:`docs/内存管理/RAM使用分析与优化方案.md` §4.1.4
+114 -50
View File
@@ -64,7 +64,7 @@ static command_t commands[] = {
{ NULL, NULL },
};
/* N-19 FTP 鉴权:全局失败计数/锁定状态(跨会话持久——单连接服务器,全局
/* FTP 鉴权:全局失败计数/锁定状态(跨会话持久——单连接服务器,全局
* 计数即可;成功清零、锁定期满自动放行,见 lftpd_auth.c) */
static ftp_auth_state_t s_auth_state;
@@ -104,6 +104,32 @@ static int send_response(int socket, int code, bool include_code,
#define send_multiline_response_end(socket, code, format, ...) \
send_response(socket, code, true, false, format, ##__VA_ARGS__)
/* 一块装满就整块发出、清空继续装。
* 为什么仍要"批量":定案三(../问题记录/CH395F_Trap_Records.md,docs 里那条 LIST 吞包
* 定案)实测同一连接上**背靠背 ~60 B 小段写**会毒化 CH395F 的 TX(芯片报 SEND_OK 但对端
* 全丢,无 RST 无 DISCONNECT),而 RETR 的 1 KB 满块连发安全——所以这里只发大块,
* 绝不退回"每行一发"。
* 为什么必须"续发"而不是截断:原先装满就 break 并回 -1,cmd_list 据此回 550,客户端
* 既拿到**不完整**的目录、又收到错误码(实测 24 条目/1512 B 之后全部被吞)。目录大
* 小不该由一个缓冲区上限决定。
* 返回:0 = 已发出(或本就空),-1 = 写失败 */
static int list_batch_flush(int socket, char *batch, int *p_blen)
{
int len = *p_blen;
if (len <= 0) {
return 0;
}
lftpd_task_wd_beat(); /* 看门狗:目录列表分块发送处 */
if (lftpd_inet_write(socket, batch, len) < 0) {
return -1;
}
*p_blen = 0;
return 0;
}
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\r\n";
@@ -118,11 +144,9 @@ 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;
int fail = 0;
while (lftpd_io_readdir(dp, name, sizeof(name), &size, &is_dir) == 0) {
int n;
@@ -133,25 +157,45 @@ static int send_list(int socket, const char *path)
n = snprintf(batch + blen, sizeof(batch) - blen, file_format,
(unsigned long)size, name);
}
if (n < 0 || (size_t)n >= sizeof(batch) - blen) {
trunc = 1;
if (n < 0) {
fail = 1;
break;
}
if ((size_t)n >= sizeof(batch) - blen) {
if (list_batch_flush(socket, batch, &blen) != 0) {
fail = 1;
break;
}
/* 单行就超过整块 → 明确失败,绝不静默少发条目
* (8.3 短名 + 定长格式远小于 1536,真发生说明格式被改坏) */
if ((size_t)n >= sizeof(batch)) {
fail = 1;
break;
}
if (is_dir) {
n = snprintf(batch, sizeof(batch), directory_format,
(unsigned long)size, name);
} else {
n = snprintf(batch, sizeof(batch), file_format,
(unsigned long)size, name);
}
if (n < 0 || (size_t)n >= sizeof(batch)) {
fail = 1;
break;
}
}
blen += n;
}
lftpd_io_closedir(dp);
lftpd_io_closedir(dp); /* 所有出口都必须走到:卷锁持有至 closedir */
/* lftpd_inet_write 成功返回字节数、失败返回 -1(非 0=成功语义) */
if (blen > 0) {
if (!fail && blen > 0) {
/* lftpd_inet_write 成功返回字节数、失败返回 -1(非 0=成功语义) */
if (lftpd_inet_write(socket, batch, blen) < 0) {
return -1;
fail = 1;
}
}
if (trunc) {
return -1;
}
return 0;
return fail ? -1 : 0;
}
static int send_nlst(int socket, const char *path)
@@ -165,34 +209,44 @@ 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;
int fail = 0;
while (lftpd_io_readdir(dp, name, sizeof(name), &size, &is_dir) == 0) {
if (!is_dir) {
int n = snprintf(batch + blen, sizeof(batch) - blen, "%s\r\n", name);
if (n < 0 || (size_t)n >= sizeof(batch) - blen) {
trunc = 1;
if (n < 0) {
fail = 1;
break;
}
if ((size_t)n >= sizeof(batch) - blen) {
if (list_batch_flush(socket, batch, &blen) != 0) { /* 同 send_list:满块续发 */
fail = 1;
break;
}
if ((size_t)n >= sizeof(batch)) {
fail = 1;
break;
}
n = snprintf(batch, sizeof(batch), "%s\r\n", name);
if (n < 0 || (size_t)n >= sizeof(batch)) {
fail = 1;
break;
}
}
blen += n;
}
}
lftpd_io_closedir(dp);
lftpd_io_closedir(dp); /* 所有出口都必须走到:卷锁持有至 closedir */
/* lftpd_inet_write 成功返回字节数、失败返回 -1(非 0=成功语义) */
if (blen > 0) {
if (lftpd_inet_write(socket, batch, blen) < 0) {
return -1;
if (!fail && blen > 0) {
if (lftpd_inet_write(socket, batch, blen) < 0) { /* 成功返回字节数,非 0=成功 */
fail = 1;
}
}
if (trunc) {
return -1;
}
return 0;
return fail ? -1 : 0;
}
static int send_file(int socket, const char *path)
@@ -250,7 +304,7 @@ static int receive_file(int socket, const char *path)
if (err < 0) {
/* 异常结束(连接重置/超时/预算耗尽):删除截断文件防残留并返回错误码
* → STOR 回报 450 而非误报 226(Trap 23 待办 #3) */
* → STOR 回报 450 而非误报 226(TRAP-NET-18 待办 #3) */
lftpd_io_unlink(path);
return err;
}
@@ -289,7 +343,7 @@ 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 时拒绝(鉴权门已保证此处必为已登录态) */
/* 写命令门——[ftp] write=0 时拒绝(鉴权门已保证此处必为已登录态) */
const ftp_cfg_t *cfg = ftp_cfg_get();
if ((cfg == NULL) || (cfg->write_en == 0u)) {
@@ -368,7 +422,8 @@ static int cmd_list(lftpd_client_t *client, const char *arg)
send_simple_response(client->socket, 150, STATUS_150);
int data_sock = lftpd_inet_accept_timeout(client->data_socket, 5000);
if (data_sock < 0) {
lftpd_log_error("%s accept failed, sk=%d", __func__, client->data_socket);
lftpd_log_peer("%s data accept failed (peer gone?), sk=%d",
__func__, client->data_socket);
send_simple_response(client->socket, 425, STATUS_425);
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
@@ -400,7 +455,8 @@ static int cmd_nlst(lftpd_client_t *client, const char *arg)
send_simple_response(client->socket, 150, STATUS_150);
int data_sock = lftpd_inet_accept_timeout(client->data_socket, 5000);
if (data_sock < 0) {
lftpd_log_error("%s accept failed, sk=%d", __func__, client->data_socket);
lftpd_log_peer("%s data accept failed (peer gone?), sk=%d",
__func__, client->data_socket);
send_simple_response(client->socket, 425, STATUS_425);
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
@@ -462,19 +518,14 @@ static int cmd_pass(lftpd_client_t *client, const char *arg)
static int cmd_pasv(lftpd_client_t *client, const char *arg)
{
int listener_socket = -1;
int retry;
if (client->data_socket >= 0) {
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
}
for (retry = 0; retry < 3; retry++) {
listener_socket = lftpd_inet_listen(0);
if (listener_socket >= 0) break;
vTaskDelay(pdMS_TO_TICKS(200));
}
/* 原 3×200ms 应用层重试已删(lftpd_inet_listen 单次化)——
* 失败直接 425,FTP 客户端自带传输重试语义;服务端不再叠阻塞重试 */
int listener_socket = lftpd_inet_listen(0);
if (listener_socket < 0) {
send_simple_response(client->socket, 425, STATUS_425);
return -1;
@@ -494,6 +545,10 @@ static int cmd_pasv(lftpd_client_t *client, const char *arg)
return -1;
}
/* s_addr 是网络序整数(本层唯一约定,见 net_inet_addr 的声明处),这里
* 先转主机序再按高字节到低字节拆 = 线序顺位,PASV 227 的 IP 文本因此正确
* (TRAP-NET-21 已定案"双重反转恰好正确,勿误判";2026-10-24 字节序统一后复核:
* 本行不属于被统一掉的那一族,语义未变,勿"顺手改对"成直接拆 s_addr) */
uint32_t ip = net_htonl(local_addr.sin_addr.s_addr);
send_simple_response(client->socket, 227, STATUS_227,
(ip >> 24) & 0xff,
@@ -531,7 +586,8 @@ static int cmd_retr(lftpd_client_t *client, const char *arg)
send_simple_response(client->socket, 150, STATUS_150);
int data_sock = lftpd_inet_accept_timeout(client->data_socket, 5000);
if (data_sock < 0) {
lftpd_log_error("%s accept failed, sk=%d", __func__, client->data_socket);
lftpd_log_peer("%s data accept failed (peer gone?), sk=%d",
__func__, client->data_socket);
send_simple_response(client->socket, 425, STATUS_425);
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
@@ -582,7 +638,7 @@ 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 时拒绝(鉴权门已保证此处必为已登录态) */
/* 写命令门——[ftp] write=0 时拒绝(鉴权门已保证此处必为已登录态) */
const ftp_cfg_t *cfg = ftp_cfg_get();
if ((cfg == NULL) || (cfg->write_en == 0u)) {
@@ -598,7 +654,8 @@ static int cmd_stor(lftpd_client_t *client, const char *arg)
send_simple_response(client->socket, 150, STATUS_150);
int data_sock = lftpd_inet_accept_timeout(client->data_socket, 5000);
if (data_sock < 0) {
lftpd_log_error("%s accept failed, sk=%d", __func__, client->data_socket);
lftpd_log_peer("%s data accept failed (peer gone?), sk=%d",
__func__, client->data_socket);
send_simple_response(client->socket, 425, STATUS_425);
lftpd_inet_close(client->data_socket);
client->data_socket = -1;
@@ -663,7 +720,7 @@ static int handle_control_channel(lftpd_client_t *client)
{
int err = send_simple_response(client->socket, 220, STATUS_220);
if (err != 0) {
lftpd_log_error("error sending welcome message");
lftpd_log_peer("peer gone before welcome acked");
goto cleanup;
}
@@ -673,7 +730,9 @@ static int handle_control_channel(lftpd_client_t *client)
int line_len = lftpd_inet_read_line(client->socket, read_buffer,
sizeof(read_buffer));
if (line_len != 0) {
lftpd_log_error("error reading next command");
/* 绝大多数是客户端正常收尾(关窗口 / QUIT 前先断了 TCP);
* 真读错也由返回值区分得出来,留 ret=%d 便于排障 */
lftpd_log_peer("control channel closed by peer (ret=%d)", line_len);
goto cleanup;
}
@@ -698,7 +757,7 @@ static int handle_control_channel(lftpd_client_t *client)
command_tmp[i] = (char)toupper((int)command_tmp[i]);
}
/* N-19:未鉴权命令门——仅 QUIT/FEAT/SYST/NOOP 放行(客户端兼容需要,
/* 未鉴权命令门——仅 QUIT/FEAT/SYST/NOOP 放行(客户端兼容需要,
* 且无信息量);其余一律 530。USER/PASS 本身也在放行名单外,
* 但它们在 gate 之前不可能通过 → 单独放行见下(USER/PASS 例外) */
if (client->auth_logged_in == 0) {
@@ -722,7 +781,7 @@ static int handle_control_channel(lftpd_client_t *client)
if (index < (int)strlen(read_buffer)) {
strncpy(arg_buf, read_buffer + index + 1, sizeof(arg_buf) - 1);
arg_buf[sizeof(arg_buf) - 1] = '\0';
arg = lftpd_string_trim(arg_buf);
arg = lftpd_string_ltrim(arg_buf);
}
lftpd_log_info("CMD %s %s", command_tmp, arg ? arg : "");
@@ -758,7 +817,7 @@ 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 即消失) */
/* 出厂默认口令在用告警(台架锚点;改了 [ftp] pwd_hash 即消失) */
if (ftp_cfg_is_default_pwd() != 0) {
lftpd_log_info("using factory default password");
}
@@ -791,7 +850,7 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
int client_socket = lftpd_inet_accept(lftpd->server_socket);
if (client_socket < 0) {
lftpd_log_error("error accepting client socket");
lftpd_log_peer("listener rebuilt (reason logged by accept layer)");
lftpd_inet_close(lftpd->server_socket);
lftpd->server_socket = -1; /* 监听器已不可用(网络重建):下轮重建 */
continue;
@@ -800,11 +859,16 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd)
{
struct net_sockaddr_in addr;
int addrlen = sizeof(addr);
if (net_getsockname(client_socket,
/* E13 定案 修复:原用 getsockname 打印的是**本机** IP:21(且
* ntoa 段序反转成 "100.1.168.192")——改 getpeername 打印真实
* 对端(现场排障需要"谁连进来");未连接则退回无地址形态 */
if (net_getpeername(client_socket,
(struct net_sockaddr *)&addr, &addrlen) == 0) {
char ip_str[16];
net_inet_ntoa(addr.sin_addr.s_addr, ip_str);
int remote_port = lftpd_inet_get_socket_port(client_socket);
/* 对端端口:直接取 getpeername 结果(原 get_socket_port
* 走 getsockname,打印的是本机监听端口) */
int remote_port = (int)net_ntohs(addr.sin_port);
lftpd_log_info("connection received from %s:%d...",
ip_str, remote_port);
} else {
+1 -1
View File
@@ -6,7 +6,7 @@ typedef struct {
char directory[256];
int socket;
int data_socket;
/* N-19 FTP 鉴权会话态(每会话 memset 0 = 未登录;跨会话锁定计数在
/* FTP 鉴权会话态(每会话 memset 0 = 未登录;跨会话锁定计数在
* lftpd.c 的 s_auth_state,不在此处) */
char auth_user[32]; /* USER 阶段暂存的用户名 */
uint8_t auth_user_ok; /* 1 = 用户名与 [ftp] user 匹配 */
+1 -1
View File
@@ -5,7 +5,7 @@
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;契约由
* v1.0 2026-10-11 王建锋 创建初始版本(FTP 鉴权;契约由
* test/host/ftp_auth 主机单测钉死)
*/
+1 -1
View File
@@ -7,7 +7,7 @@
* 作者:王建锋
* 创建日期:2026-10-11
* 修改记录:
* v1.0 2026-10-11 王建锋 创建初始版本(N-19 FTP 鉴权;lftpd.c 为唯一消费者)
* v1.0 2026-10-11 王建锋 创建初始版本(FTP 鉴权;lftpd.c 为唯一消费者)
*/
#ifndef LFTPD_AUTH_H
+50 -51
View File
@@ -6,16 +6,13 @@
#include <string.h>
/*
* E-02 头阻塞修复 + L3 pending 化(2026-09-22,M3 台架实证):
* netTask 是全部 socket 操作的唯一执行者;lftpd 原先用默认 30s 的**阻塞**
* net_recv 等待对端下一行/下一块数据 → 只要客户端连上却不发完整行,netTask
* 就被长期占用,其余任务(含远端显示)的网络操作整体冻结(实测一次空闲 FTP
* 登录让 LCDMEM 整帧等待 17.7s)。
* 2026-09-22 先落地分片有界读(5ms 片;实测 100ms 片下受压帧仍达 ~750ms,
* 收到 5ms 后降到 ~10ms 量级,见 m3_data 修复后数据),同日 L3 pending 化
* 后改为"挂起请求":每次等待 = 一次挂起登记(netTask 零占用,完成按主循环
* 节拍检查),调用方单次超时 = 剩余总预算(总等待 ≤ LFTPD_TOTAL_BUDGET_MS,
* **总超时语义与旧实现一致**),旧分片协议退役。
* 头阻塞修复 + recv pending 化(M3 台架实证):
* netTask 是全部 socket 操作的唯一执行者;lftpd 若用**阻塞** net_recv 等待对端
* 下一行/下一块数据 → 只要客户端连上却不发完整行,netTask 就被长期占用,
* 其余任务(含远端显示)的网络操作整体冻结(实测一次空闲 FTP 登录让 LCDMEM
* 整帧等待 17.7s)。
* 现形态:每次等待 = 一次挂起登记(netTask 零占用,完成按主循环节拍检查),
* 调用方单次超时 = 剩余总预算(总等待 ≤ LFTPD_TOTAL_BUDGET_MS)。
*/
#define LFTPD_TOTAL_BUDGET_MS 30000U
@@ -34,39 +31,35 @@ void lftpd_task_wd_beat(void)
int lftpd_inet_listen(int port)
{
int retry;
for (retry = 0; retry < 5; retry++) {
lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(重试间隔 500ms 在 800ms 容差内) */
int s = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (s < 0) {
if (retry == 0) DBG_ERROR("create socket");
vTaskDelay(pdMS_TO_TICKS(500));
continue;
}
struct net_sockaddr_in addr;
addr.sin_family = NET_AF_INET;
addr.sin_port = net_htons((uint16_t)port);
addr.sin_addr.s_addr = 0;
if (net_bind(s, (struct net_sockaddr *)&addr, sizeof(addr)) < 0) {
DBG_ERROR("bind port %d", port);
net_close(s);
return -1;
}
if (net_listen(s) >= 0) {
return s;
}
net_close(s);
if (retry < 4) {
vTaskDelay(pdMS_TO_TICKS(2000));
}
/* 应用层阻塞重试退役(原 5 轮 500ms/2000ms,最坏 10s 阻塞
* ftpTask)——TRAP-NET-09 的启动形态已由就绪屏障消除(FTP_READY 等 NET_READY,
* lftpd_start 外层本就有监听失败重建循环);芯片忙窗失败属罕见,PASV 数据
* 监听交给 FTP 客户端自身的重试语义(主流 FTP 服务器 passive listen 失败
* 亦直接 425,不在服务端叠应用层阻塞重试) */
lftpd_task_wd_beat(); /* 看门狗:入口喂心跳 */
int s = net_socket(NET_AF_INET, NET_SOCK_STREAM);
if (s < 0) {
DBG_ERROR("create socket");
return -1;
}
DBG_ERROR("listen failed after 5 retries (total 10s wait)");
struct net_sockaddr_in addr;
addr.sin_family = NET_AF_INET;
addr.sin_port = net_htons((uint16_t)port);
addr.sin_addr.s_addr = 0;
if (net_bind(s, (struct net_sockaddr *)&addr, sizeof(addr)) < 0) {
DBG_ERROR("bind port %d", port);
net_close(s);
return -1;
}
if (net_listen(s) >= 0) {
return s;
}
DBG_ERROR("listen port %d failed", port);
net_close(s);
return -1;
}
@@ -95,7 +88,8 @@ int lftpd_inet_accept(int socket)
return s;
}
if (net_get_generation() != gen) {
DBG_ERROR("network re-init: abort accept, relisten");
/* 运维改 IP 的正常后果,不是缺陷 → info(级别划分见存储架构说明 §10.3) */
DBG_INFO("network re-init: abort accept, relisten");
return -1;
}
lftpd_task_wd_beat(); /* 看门狗:自旋轮次喂心跳(无限等待,可能长时间无客户端) */
@@ -112,10 +106,14 @@ int lftpd_inet_accept_timeout(int socket, int timeout_ms)
if (s >= 0) {
return s;
}
/* 数据连接已被对端关闭时提前退出,不等待超时 */
net_sock_t *p_sock = net_get_sock(socket);
if (p_sock == NULL || p_sock->state != NET_SOCK_STATE_LISTENING) {
DBG_ERROR("data socket closed, accept aborted");
/* F-NET-15 M2(自愈专项实验 2026-10-12):镜像门控退役——原直读 p_sock->state 在
* relisten 忙期(CLOSED 瞬态)误判"已关闭",扔掉 ~4.9s 等待预算;armed 门控
* 认"监听器在服役(LISTENING)或待重建且 RX 有残留(closed-with-data 救回窗)",
* 与 :7003 的监听生命周期 P1 同型修复 */
if (net_listener_armed(socket) == 0) {
/* **故意留 error**:控制连接还活着、我方数据监听却已不在服役 →
* 这是本侧异常,不是对端行为 */
DBG_ERROR("data listener disarmed, accept aborted");
return -1;
}
lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(数据连接等待最长 5s) */
@@ -123,7 +121,8 @@ int lftpd_inet_accept_timeout(int socket, int timeout_ms)
elapsed += 100;
}
DBG_ERROR("accept timeout (%dms)", timeout_ms);
/* 客户端连上控制通道却没来建数据连接(中止/被防火墙拦)= 对端行为 → info */
DBG_INFO("accept timeout (%dms) - peer never opened data channel", timeout_ms);
return -1;
}
@@ -135,8 +134,8 @@ int lftpd_inet_read_line(int socket, char *buffer, size_t buffer_len)
int total_read_len = 0;
while (total_read_len < (int)buffer_len) {
/* 单次预算 = 剩余总预算(L3 pending:等待期 netTask 零占用,调用方
* 在自己上下文睡任务通知,旧片间 vTaskDelay(1) 让出退役) */
/* 单次预算 = 剩余总预算(pending:等待期 netTask 零占用,调用方
* 在自己上下文睡任务通知) */
TickType_t elapsed = xTaskGetTickCount() - t0;
uint32_t rem = (elapsed >= (TickType_t)pdMS_TO_TICKS(LFTPD_TOTAL_BUDGET_MS)) ? 0U
: (uint32_t)((TickType_t)LFTPD_TOTAL_BUDGET_MS - elapsed);
@@ -217,8 +216,8 @@ int lftpd_inet_write(int socket, const void *buf, int len)
int lftpd_inet_read(int socket, void *buf, int len)
{
/* L3 pending 读:总预算 30s 不变,等待期 netTask 零占用
* (空闲数据连接不再冻结全网;旧分片协议随 L3 pending 化退役) */
/* pending 读:总预算 30s 不变,等待期 netTask 零占用
* (空闲数据连接不再冻结全网) */
TickType_t t0 = xTaskGetTickCount();
for (;;) {
+6 -4
View File
@@ -3,14 +3,16 @@
#include "private/lftpd_string.h"
char *lftpd_string_trim(char *s)
char *lftpd_string_ltrim(char *s)
{
char *p = s;
/* 只去行首空白:返回跳过后的指针,不搬移缓冲。
* 原先此处的"去尾部空白"循环,起点是 strlen(p) 那一格 NUL,isspace('\0') 为假
* → 从未执行过;而 FTP 参数的尾随空格属于参数本体(见本文件头与
* private/lftpd_string.h 的说明),2026-10-23 删掉这条死代码并改名为 *_ltrim,
* 让函数名与实际行为一致(**行为零变化**)。 */
for (int i = 0, len = (int)strlen(s); i < len && isspace((int)s[i]); i++) {
p++;
}
for (int i = (int)strlen(p); i >= 0 && isspace((int)p[i]); i--) {
p[i] = '\0';
}
return p;
}

Some files were not shown because too many files have changed in this diff Show More