diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index fa05f149..ae4e16f9 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -19,7 +19,7 @@ "hal-iwdg-enabled": "1a8ed6cff34f7ba6", "ioc-keepusercode": "b5bea41b6c623f7c", "ioc-iwdg-present": "d3a6e667048c95db", - "ioc-task-stacks": "3262f737d92dcbee", + "ioc-task-stacks": "58ee94dd2a093177", "adc1-hal-enabled": "1a8ed6cff34f7ba6", "adc1-calibration-call": "8d5f4b40fb218913", "adc1-calibration-impl": "d33bc4323e243248", diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index a84e07b2..3862b8fc 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -18,7 +18,37 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测): 工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` > `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。 -退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ `2`=UV4 未找到/无效 / `3`=用法。 +退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ **`2`=环境错误**(UV4 找不到或 +路径无效、工程找不到、档案解析失败)/ `3`=用法错误 / `4`=build 锁被占用(并发 `-b` 不可行)。 + +> 2026-10-12 修正:旧实现把环境错误写成 `raise SystemExit("...")` → 实际退出码是 **1**, +> 而用法错误由 argparse 抛 `SystemExit(2)` → 恰好占了"环境错误"的位置。故 `AGENTS.md` / +> `new-machine-setup` 里写的"报 exit 2 = 环境问题"当时**不成立**。现按文档口径改正: +> 环境错误 → 2(`BuildEnvError`),用法错误 → 3(覆盖 argparse 的 `error()`)。 + +## build 锁:脚本自持自释(2026-10-12) + +**UV4 不能两个实例同时 `-b`,但构建锁不该由调用方"记得释放"**——那正是泄漏源: +Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被接管。 +故本脚本**自己**管:进入 acquire → `try` 构建 → `finally` **无条件** release +(构建失败、异常、`SystemExit` 都会释放)。 + +| 开关 | 作用 | +|---|---| +| `--lock-task ` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的 `_wt/-/` 反推 `` → `build-cli` | +| `--lock-wait ` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) | +| `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) | +| `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) | + +- **TTL 自报**(2026-10-12):acquire 时按 `--timeout` 声明陈旧窗口 + = `timeout/3600 + 15min`,下限 0.5h(默认 600s → **0.5h**)。这样崩溃/强杀留下的锁, + 其自动接管窗口与本次构建上限同量级,而不再是全局 6h —— 编译很快,兜底窗口也该短。 +- 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁 + 并告警**(保持本 skill 可独立搬运)。 +- ⚠ 从 `_wt/-/` 反推持有者名,意味着"手工 `lock acquire build --task ` + 后再跑 `@build`"仍**幂等**(同名即视为自己持有),不会自我死锁。 +- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨 + 烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。 ## 本仓库 diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index a11ea3f7..8baf8571 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -6,6 +6,7 @@ python /scripts/keil_build.py [--project X.uvprojx] [--uv4 UV4.exe] [--target 名字] [--log build_log.txt] [--timeout 600] + [--lock-task ID] [--lock-wait N] [--no-lock] 解析顺序(命令行 > 项目档案 > 环境 > 探测): 工程 --project > 档案 "uvprojx" > 自动搜 cwd 下唯一 *.uvprojx @@ -19,7 +20,33 @@ 3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)" (0E0W 精确匹配同时把"只有警告"的构建判 FAIL) -退出码: 0=PASS(0错0警) 1=构建失败(看打印的报错行/日志) 2=UV4 未找到/无效 3=用法错误 +**build 锁:本脚本自持自释(2026-10-12)** + UV4 不能两个实例同时 `-b`,所以构建锁不该由调用方"记得释放"——那正是泄漏源 + (Agent 手工 `lock acquire` 后忘了 `release`,锁要挂到 TTL 才被接管)。 + 改为:本脚本进入时 acquire、退出时**无条件** release(try/finally,构建失败/异常/ + SystemExit 都会释放)。 + - 锁工具 = 同级 skill `task-worktree/scripts/task_worktree.py`(**找不到则降级为 + 不加锁并告警**,保持本 skill 可移植) + - 持有者名 `--lock-task`:默认 `$DTU_TASK_ID` → 否则从 cwd 的 `_wt/-/` + 反推 ``(与工作树命名约定一致,故"手工锁了同名任务"仍幂等)→ 否则 `build-cli` + - 被占用:默认**立即失败**(退出码 4,不静默等待);`--lock-wait N` 可等待 N 秒 + - 调用方**已持有** build 锁时(如 `task-worktree done` 的门禁),设 + `DTU_LOCK_HELD=build` 让本脚本跳过自持自释,避免自我死锁 + - **TTL 自报**:acquire 时按 `--timeout` 声明陈旧窗口(`timeout/3600 + 15min`, + 下限 0.5h)。故崩溃/强杀留下的锁,其自动接管窗口与本次构建上限同量级, + 而不再是全局 6h(编译很快,兜底窗口也该短)。 + +退出码(与 task-worktree 同一约定;2026-10-12 修正——此前实现与文档不符): + 0 = PASS(0 错 0 警) + 1 = 构建失败(看打印的报错行 / 日志) + 2 = **环境/前置错误**(UV4 找不到或路径无效、工程找不到、档案解析失败) + 3 = 用法错误 + 4 = build 锁被占用(并发 -b 不可行) + +⚠ 修正说明:旧实现把环境错误写成 `raise SystemExit("...")` → **实际退出码是 1**, +而用法错误由 argparse 抛 SystemExit(2) → 恰好占用了文档里"环境错误"的位置。 +故 AGENTS.md / `new-machine-setup` SKILL.md 里写的"报 exit 2 = 环境问题"当时**不成立**。 +现已按文档口径改正:环境错误 `BuildEnvError` → 2,argparse 用法错误 → 3。 """ import argparse @@ -30,6 +57,23 @@ import subprocess import sys from pathlib import Path +LOCK_NAME = "build" + + +class BuildEnvError(Exception): + """环境/前置错误(UV4 或工程找不到、档案解析失败)→ 退出码 2。""" + + +class _ArgumentParser(argparse.ArgumentParser): + """用法错误 → 退出码 3。 + + argparse 默认 `error()` 走 `SystemExit(2)`,与"环境错误=2"冲突,故覆盖之。 + """ + + def error(self, message): + self.print_usage(sys.stderr) + self.exit(3, "{}: error: {}\n".format(self.prog, message)) + def find_config_file(): env = os.environ.get("DSH_SERIAL_CONFIG") @@ -67,26 +111,26 @@ def find_uv4(cli_arg, cfg): if val: if Path(val).is_file(): return Path(val), label - raise SystemExit(("BUILD_ENV_ERROR: {} is set but not a file: {} " - "(fix the value; no silent fallback)").format(label, val)) + raise BuildEnvError(("BUILD_ENV_ERROR: {} is set but not a file: {} " + "(fix the value; no silent fallback)").format(label, val)) w = shutil.which("UV4.exe") or shutil.which("UV4") if w: return Path(w), "PATH" - raise SystemExit("BUILD_ENV_ERROR: UV4.exe not found " - "(--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)") + raise BuildEnvError("BUILD_ENV_ERROR: UV4.exe not found " + "(--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)") def find_project(cli_arg, cfg): if cli_arg: p = Path(cli_arg) if not p.is_file(): - raise SystemExit("BUILD_ENV_ERROR: --project not found: {}".format(p)) + raise BuildEnvError("BUILD_ENV_ERROR: --project not found: {}".format(p)) return p v = cfg.get("uvprojx") if v: p = Path(v) if not p.is_file(): - raise SystemExit("BUILD_ENV_ERROR: config 'uvprojx' not found: {}".format(p)) + raise BuildEnvError("BUILD_ENV_ERROR: config 'uvprojx' not found: {}".format(p)) return p hits = sorted(p for p in Path.cwd().rglob("*.uvprojx") if not any(part.startswith(".") for part in p.parts)) @@ -94,32 +138,100 @@ def find_project(cli_arg, cfg): print("PROJ: auto-detected {}".format(hits[0])) return hits[0] if not hits: - raise SystemExit("BUILD_ENV_ERROR: no *.uvprojx under cwd " - "(--project or config 'uvprojx')") - raise SystemExit("BUILD_ENV_ERROR: multiple .uvprojx found, pick one " - "(--project or config 'uvprojx'):\n " + - "\n ".join(str(h) for h in hits)) + raise BuildEnvError("BUILD_ENV_ERROR: no *.uvprojx under cwd " + "(--project or config 'uvprojx')") + raise BuildEnvError("BUILD_ENV_ERROR: multiple .uvprojx found, pick one " + "(--project or config 'uvprojx'):\n " + + "\n ".join(str(h) for h in hits)) -def main(argv=None): - for stream in (sys.stdout, sys.stderr): - try: - stream.reconfigure(encoding="utf-8", errors="replace") - except Exception: - pass +# --------------------------------------------------------------- build 锁(自持自释) - ap = argparse.ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)") - ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)") - ap.add_argument("--uv4", default=None, help="UV4.exe path") - ap.add_argument("--target", default=None, help="build target name (-t; default: project default)") - ap.add_argument("--log", default=None, help="build log path (default: /build_log.txt)") - ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds") - args = ap.parse_args(argv) +def find_lock_tool(): + """定位 task-worktree 的 lock CLI(同级 skill)。找不到 → None(降级为不加锁)。 + + keil-build 与 task-worktree 是兄弟目录:.dsh/skills/{keil-build,task-worktree}/ + 故从本文件上溯 2 级即到 skills/。找不到不报错——保持本 skill 可独立搬运。 + """ + try: + skills = Path(__file__).resolve().parents[2] + except IndexError: + return None + cand = skills / "task-worktree" / "scripts" / "task_worktree.py" + return cand if cand.is_file() else None + + +def default_lock_task(): + """持有者名:$DTU_TASK_ID > cwd 反推 `_wt/-/` 的 `` > "build-cli"。""" + v = os.environ.get("DTU_TASK_ID") + if v: + return v + try: + cwd = Path.cwd().resolve() + except OSError: + return "build-cli" + for p in (cwd, *cwd.parents): + if p.parent.name == "_wt": + return p.name.split("-", 1)[0] or p.name + return "build-cli" + + +def build_lock_ttl_hours(timeout_s): + """build 锁自报的陈旧窗口(小时)≈ UV4 超时上限 + 15min 余量,下限 0.5h。 + + 这样「崩溃/被强杀」留下的锁,其自动接管窗口与本次构建的真实上限同量级, + 而不是全局 6h —— 编译很快,锁的兜底窗口也该短。 + """ + try: + t = max(0.0, float(timeout_s)) + except (TypeError, ValueError): + t = 600.0 + return max(0.5, t / 3600.0 + 0.25) + + +def lock_cli(tool, action, task, wait=0, force=False, ttl=None): + """调 task-worktree 的 `lock build --task `。返回 (ok, 输出)。""" + cmd = [sys.executable, str(tool), "lock", action, LOCK_NAME, "--task", task] + if action == "acquire" and wait > 0: + cmd += ["--wait", str(wait)] + if action == "acquire" and ttl: + cmd += ["--ttl", "{:.4f}".format(ttl)] + if force: + cmd += ["--force"] + try: + r = subprocess.run(cmd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + stdin=subprocess.DEVNULL) + except OSError as e: + return False, "lock CLI 调用失败:{}".format(e) + return r.returncode == 0, (r.stdout + r.stderr).strip() + + +def acquire_build_lock(args): + """返回 (state, tool, task, note)。state ∈ ok / caller / skipped / busy。""" + if args.no_lock: + return "skipped", None, None, "--no-lock" + held = [x.strip() for x in (os.environ.get("DTU_LOCK_HELD") or "").split(",") + if x.strip()] + if LOCK_NAME in held: + return "caller", None, None, "调用方已持有(DTU_LOCK_HELD)" + tool = find_lock_tool() + if tool is None: + return "skipped", None, None, "未找到 task-worktree skill" + task = args.lock_task or default_lock_task() + ok, out = lock_cli(tool, "acquire", task, wait=args.lock_wait, + ttl=build_lock_ttl_hours(args.timeout)) + if not ok: + return "busy", tool, task, out + return "ok", tool, task, "" + + +def run_build(args): + """真正的构建主体(锁由 main 负责)。返回退出码。""" cfg, err = load_config() if err: - print("BUILD_ENV_ERROR: {}".format(err)) - return 3 + raise BuildEnvError("BUILD_ENV_ERROR: {}".format(err)) proj = find_project(args.project, cfg) uv4, src = find_uv4(args.uv4, cfg) @@ -184,5 +296,52 @@ def main(argv=None): return 0 +def main(argv=None): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + ap = _ArgumentParser(description="Keil UV4 CLI build + verdict (pure stdlib)") + ap.add_argument("--project", default=None, help="*.uvprojx (default: config/auto-detect)") + ap.add_argument("--uv4", default=None, help="UV4.exe path") + ap.add_argument("--target", default=None, help="build target name (-t; default: project default)") + ap.add_argument("--log", default=None, help="build log path (default: /build_log.txt)") + ap.add_argument("--timeout", type=int, default=600, help="UV4 timeout seconds") + ap.add_argument("--lock-task", default=None, + help="build 锁持有者名 (default: $DTU_TASK_ID / _wt/-* / build-cli)") + ap.add_argument("--lock-wait", type=int, default=0, + help="build 锁等待秒数 (0=不等待,占用即退出码 4)") + ap.add_argument("--no-lock", action="store_true", + help="完全不加 build 锁(危险:并发 -b 会互相干扰)") + args = ap.parse_args(argv) + + state, tool, task, note = acquire_build_lock(args) + if state == "busy": + print("BUILD_LOCK_BUSY: build 锁被占用(UV4 不能并发构建)") + if note: + for ln in note.splitlines(): + print("BUILD_LOCK_BUSY: {}".format(ln)) + print("BUILD_LOCK_BUSY: 用 --lock-wait N 等待,或等对方 release") + return 4 + if state == "ok": + print("LOCK: 已获取 build 锁(task={})".format(task)) + elif state == "caller": + print("LOCK: 由调用方持有,跳过自持自释") + else: + print("WARN: 本次构建**未加 build 锁**({})——并发 -b 会互相干扰".format(note)) + + try: + return run_build(args) + except BuildEnvError as e: + print(str(e)) + return 2 + finally: + if state == "ok": + ok, out = lock_cli(tool, "release", task) + print("LOCK: {}".format(out or ("已释放 build 锁" if ok else "释放 build 锁失败"))) + + if __name__ == "__main__": sys.exit(main()) diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index b8c53f84..d7cad2eb 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -55,8 +55,11 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突; 1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。 2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。 -3. **`build` 与 `bench` 都必须持锁**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、 +3. **`build` 与 `bench` 都必须串行**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、 设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。** + - `build` 锁由 **`@build` 自持自释**,调用方无需手工 acquire/release; + - `bench` 锁**必须手工**:`lock acquire bench --task ` → 烧录/`@test`/探针 → + `lock release bench --task --bench-pass`。 4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、 `.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。 5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再 @@ -100,9 +103,18 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突; - **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB, 两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。 - **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。 - 所以判「陈旧」只看**时效**(默认 6 小时,`LOCK_TTL_HOURS`)加显式 `--force`;pid 仅作排障记录。 + 所以判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。 同任务重复 acquire 是幂等的(不会把自己锁死)。 -- **陈旧锁可自动接管**:超过 6 小时未释放即视为陈旧,下次 acquire 会接管并提示;活锁需 `--force`。 +- **TTL 按锁分设**(2026-10-12):`build` = **1h**、`bench` = **6h**(`LOCK_TTL_HOURS`)。 + 口径是「兜底窗口应与操作真实时长同量级」——编译是秒~分钟级,6h 的兜底太钝; + bench 是会话级(flash→`@test`→探针),才配得上小时级。持有者还可用 + `lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。 +- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示; + 未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败)。 +- **`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)。 ## 与其它 skill 的衔接 diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 7a7ab99c..6f5ab81d 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -29,7 +29,14 @@ EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3 BRANCH_PREFIX = "task/" MAX_CONCURRENT = 3 LOCK_NAMES = ("build", "bench") -LOCK_TTL_HOURS = 6.0 # 超过此持有时间即视为陈旧锁,下次 acquire 自动接管 +# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级: +# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长, +# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。 +# bench:会话级(flash → @test → 探针,天然几十分钟~数小时)→ 保留 6h。 +# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)), +# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。 +LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0} +DEFAULT_LOCK_TTL_HOURS = 6.0 JUNCTION_SPECS = ( (".venv", "Python 环境"), ("simulator/SDL2", "模拟器 SDL2 SDK"), @@ -212,6 +219,26 @@ def lock_age_hours(info) -> float: return float("inf") +def lock_ttl_hours(name: str, info=None) -> float: + """锁 `name` 的陈旧判定窗口(小时)。 + + 优先用锁 payload 里持有者自报的 `ttl`(例如 keil_build.py 按其 --timeout 声明), + 否则回落到该锁的默认窗口(build 1h / bench 6h)。 + """ + if info: + try: + v = info.get("ttl") + if v: + return max(0.0, float(v)) + except (TypeError, ValueError): + pass + return LOCK_TTL_HOURS.get(name, DEFAULT_LOCK_TTL_HOURS) + + +def lock_is_stale(name: str, info) -> bool: + return lock_age_hours(info) > lock_ttl_hours(name, info) + + def lock_path(name: str) -> Path: return LOCKS_DIR / (name + ".lock") @@ -226,12 +253,14 @@ def lock_read(name: str): return {"task": "?", "pid": 0, "epoch": 0, "at": "?"} -def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = ""): +def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = "", + ttl: float = None): """抢占独占资源。 ⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建 - 在另一个进程里跑。因此判「陈旧」只看**时效**(超过 LOCK_TTL_HOURS), - 外加显式 `--force`;pid 仅作记录便于排障。 + 在另一个进程里跑。因此判「陈旧」只看**时效**(超过该锁的 TTL),外加显式 + `--force`;pid 仅作记录便于排障。TTL 按锁分设(build 1h / bench 6h), + 持有者也可用 `ttl`(小时)自报更精确的窗口(如 build 按 --timeout 声明)。 """ LOCKS_DIR.mkdir(parents=True, exist_ok=True) p = lock_path(name) @@ -244,9 +273,10 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: if info.get("task") == task: return True, info age = lock_age_hours(info) - if force or age > LOCK_TTL_HOURS: - print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时{})".format( - name, info.get("task"), age, ",--force" if force else ",视为陈旧")) + ttl_h = lock_ttl_hours(name, info) + if force or age > ttl_h: + print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时 / TTL {:.1f} 小时{})".format( + name, info.get("task"), age, ttl_h, ",--force" if force else ",视为陈旧")) try: p.unlink() except OSError: @@ -254,8 +284,8 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: continue if time.time() >= deadline: return False, info - print("[lock] {} 被 {} 占用(已持有 {:.1f} 小时),等待…".format( - name, info.get("task"), age)) + print("[lock] {} 被 {} 占用(已持有 {:.1f} / TTL {:.1f} 小时),等待…".format( + name, info.get("task"), age, ttl_h)) time.sleep(2) else: now = time.time() @@ -265,6 +295,7 @@ def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: "epoch": now, "at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)), "note": note, + "ttl": ttl, } os.write(fd, (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")) os.close(fd) @@ -379,11 +410,16 @@ def run_gates(cwd: Path, skip_build: bool) -> bool: if not got: print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task"))) return False + # 告知 @build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring), + # 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**, + # 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。 + os.environ["DTU_LOCK_HELD"] = "build" try: if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd, "@build(0 错 0 警)"): ok = False finally: + os.environ.pop("DTU_LOCK_HELD", None) lock_release("build", "__done__", force=True) return ok @@ -626,13 +662,14 @@ def cmd_lock(a) -> int: for n in LOCK_NAMES: info = lock_read(n) if not info: - print("{:<6} 空闲".format(n)) + print("{:<6} 空闲(TTL {:.1f}h)".format(n, lock_ttl_hours(n))) else: age = lock_age_hours(info) - mark = " [陈旧,可被自动接管]" if age > LOCK_TTL_HOURS else "" - print("{:<6} {} 持有(pid={},于 {},已 {:.1f} 小时,备注 {}){}".format( + ttl_h = lock_ttl_hours(n, info) + mark = " [陈旧,可被自动接管]" if age > ttl_h else "" + print("{:<6} {} 持有(pid={},于 {},已 {:.1f} / TTL {:.1f} 小时,备注 {}){}".format( n, info.get("task"), info.get("pid"), info.get("at"), age, - info.get("note") or "-", mark)) + ttl_h, info.get("note") or "-", mark)) return EXIT_OK if a.name not in LOCK_NAMES: @@ -641,12 +678,15 @@ def cmd_lock(a) -> int: if a.action == "acquire": if not a.task: raise EnvError("acquire 需要 --task ") - got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, note=a.note or "") + got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, + note=a.note or "", ttl=a.ttl) if not got: print("获取失败:{} 被 {} 占用(pid={},于 {})".format( a.name, info.get("task"), info.get("pid"), info.get("at"))) return EXIT_FAIL - print("已获取 {} 锁(task={} pid={})".format(a.name, a.task, os.getpid())) + print("已获取 {} 锁(task={} pid={} ttl={})".format( + a.name, a.task, os.getpid(), + "{:.2f}h".format(a.ttl) if a.ttl else "默认")) return EXIT_OK if a.bench_pass: @@ -661,6 +701,27 @@ def cmd_lock(a) -> int: return EXIT_OK if ok else EXIT_FAIL +def release_stale_locks() -> None: + """`done` 前置:陈旧锁自动接管;仍被有效占用的则拒绝收口。 + + 「陈旧」= 持有时间 > 该锁 TTL(build 1h / bench 6h,或持有者自报的 ttl)。 + 崩溃/遗忘留下的锁不该阻塞收口——故自动释放而非硬失败(2026-10-12)。 + """ + for n in LOCK_NAMES: + info = lock_read(n) + if not info: + continue + age = lock_age_hours(info) + ttl_h = lock_ttl_hours(n, info) + if age > ttl_h: + ok, msg = lock_release(n, force=True) + print("[i] {} 锁已陈旧({} 持有 {:.1f}h > TTL {:.1f}h),自动接管:{}".format( + n, info.get("task"), age, ttl_h, msg)) + continue + raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} / TTL {:.1f} 小时),先 release".format( + n, info.get("task"), age, ttl_h)) + + def cmd_done(a) -> int: st = load_state(a.task_id) wt = Path(st["path"]) @@ -678,11 +739,7 @@ def cmd_done(a) -> int: cur = git_out("rev-parse", "--abbrev-ref", "HEAD", cwd=main) if cur != base: raise Fail("主工作树当前在 {},不是集成分支 {};先切过去再 done".format(cur, base)) - for n in LOCK_NAMES: - info = lock_read(n) - if info: - raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} 小时),先 release".format( - n, info.get("task"), lock_age_hours(info))) + release_stale_locks() ahead = int(git_out("rev-list", "--count", "{}..{}".format(base, branch), cwd=wt) or "0") already = git("merge-base", "--is-ancestor", branch, base, cwd=main, check=False).returncode == 0 @@ -811,6 +868,8 @@ def build_parser(): p.add_argument("--wait", type=int, default=0, help="等待秒数(0=不等待)") p.add_argument("--force", action="store_true") p.add_argument("--note", default="") + p.add_argument("--ttl", type=float, default=None, + help="本次持有自报的陈旧窗口(小时);缺省用该锁默认值(build 1h / bench 6h)") p.add_argument("--bench-pass", action="store_true", help="release bench 时顺带为该任务记录台架 PASS") p.set_defaults(func=cmd_lock) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 90eb771d..53302745 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,7 +21,7 @@ # # 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`, # 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。 -# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 CI配置指南 §4.3); +# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3); # 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。 # ⚠️ `ci-artifacts/` 已 gitignore(运行时产物)。 # @@ -43,6 +43,36 @@ # 那些由 Gitea 按 UTF-8 读,不喂给 PowerShell,安全)。 # ⚠️ 与 AGENTS.md「字符串字面量必须纯 ASCII」同源;改脚本时务必守住。 # +# 🔴🔴 工具链探测必须【可离线自测 + 保留机器级安装兜底】(2026-10-11 第三次实测修正): +# `33a4aa4`(DTU_* 变量改造)把 Python 探测内联进本文件,并把候选**收窄成两个** +# (PATH python + 仓库 .venv)——而 runner 上这两个都是死路: +# · SYSTEM 账号的 PATH python = `C:\msys64\ucrt64\bin\python.exe`,**没有** pre-commit; +# · 仓库 `.venv` 已 gitignore,runner 的全新工作区里**根本不存在**。 +# 唯一可用的机器级解释器(`C:\ProgramData\anaconda3\python.exe`)不再被探测,而文档 +# 要求的机器级 `DTU_PRECOMMIT_PYTHON` 从未设置(Machine 作用域一个 DTU_* 都没有) +# → run 14/15 的 L0 **全部死在"环境自检",一个钩子都没跑**。cppcheck 同理: +# 旧写法"不在 PATH 就把 C:\Program Files\Cppcheck 加进 PATH"的兜底也被删了。 +# **故探测逻辑搬进 `scripts/ci_find_python.ps1`**(两个 job 共用、可离线跑、失败时 +# 打印全部候选),并恢复常见机器级安装位置兜底;`DTU_*` 仍是最高优先级、 +# 且"显式设置就必须有效、不静默回退"的语义不变。 +# +# 🔴 gitlint 属 commit-msg stage,`--all-files` **永远跑不到它**(2026-10-11 审计): +# `.pre-commit-config.yaml` 里 gitlint 那一条**永远不进 CI 报告**(报告数 = 定义数 − 1; +# 2026-10-12 实测 `master` 18 定义 / 17 报告、`v2-on-ui` 26 定义 / 25 报告)—— +# 缺的正是 gitlint:`stages: [commit-msg]` 写在 **gitlint 上游的 .pre-commit-hooks.yaml** +# 里(项目配置里 grep 不到这一行),而 `pre-commit run --all-files` 只跑 pre-commit stage。 +# 即 AGENTS.md 的"单行中文标题 ≤100 字符"门禁**本地有、CI 无**, +# 且表现为"全绿"而非失败,所以一直没人发现。现由 `scripts/ci_gitlint_range.ps1` +# 逐条校验本次 push/PR 引入的提交(消息文件必须写在**仓库内**且 **UTF-8 无 BOM**: +# 跨盘符会被 pre-commit 拒绝 `path is on mount 'C:', start on mount 'D:'`, +# BOM 会占掉标题 1 个字符、扭曲 100 上限)。 +# +# 🔴 写 `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_OUTPUT` 必须显式 `-Encoding ascii` +# (2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**——往 runner +# 用 UTF-8 建的同一个文件里追加,落盘就是 `B\x00B\x00B\x00=\x001\x00` 这种带 NUL 的行 +# (实测字节:`42 00 42 00 42 00 3D 00 31 00`)。本文件写入的这些键值全是 ASCII, +# 故统一显式 ascii;`GITHUB_STEP_SUMMARY` 是 Markdown,保持 utf8。 +# # ⚠️ 前置(部署到 Gitea 时需确认): # - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置) # - 已注册一个 **Windows** runner(labels 含 `windows`),且装有: @@ -97,98 +127,73 @@ jobs: # NOTE: keep every run: block pure ASCII. The runner writes this block # to a BOM-less .ps1 executed by Windows PowerShell 5.1, which decodes # it as GBK -- non-ASCII bytes there break string quoting. - $repoRoot = (Get-Location).Path - # --- pick a Python that actually has pre-commit ------------------- - # The runner runs as SYSTEM, whose PATH is NOT the dev user's PATH. - # Probe order per the DTU_* env var plan (kept in sync with - # scripts/ci_local.py): - # DTU_PRECOMMIT_PYTHON (explicit; invalid value = hard error) - # -> PATH python -> repo .venv + # Discovery lives in scripts/ci_find_python.ps1 so that + # * both jobs share ONE implementation, + # * it can be run (and its failure list inspected) offline, + # * the well-known machine-wide install locations stay probed. + # The previous inline 2-candidate probe is what killed every L0 run + # since 33a4aa4 -- see the file header. # - # CAUTION 1: a failing native command writes to stderr; with - # $ErrorActionPreference='Stop' that would abort the whole script - # (NativeCommandError) before the next candidate is tried. So relax - # the preference around each probe, and restore it afterwards. - # - # CAUTION 2: pre-commit may be installed in the USER site of some - # account (e.g. %APPDATA%\Roaming\Python\...), which SYSTEM cannot - # see at all -- no python.exe path can help then. Probe with `-s` - # (no user-site) so we only accept an interpreter that works for - # SYSTEM, and say so clearly when none is found. - $py = $null - $saved = $ErrorActionPreference - $envPy = $env:DTU_PRECOMMIT_PYTHON - if ($envPy) { - if (-not (Test-Path $envPy)) { - $ErrorActionPreference = 'Stop' - Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy" - Write-Host "::error::fix the variable (full path to a python.exe with pre-commit), or unset it to use auto-discovery" - exit 1 - } - $ErrorActionPreference = 'Continue' - & $envPy -s -m pre_commit --version 2>&1 | Out-Null - $rc = $LASTEXITCODE - $ErrorActionPreference = $saved - if ($rc -ne 0) { - $ErrorActionPreference = 'Stop' - Write-Host "::error::DTU_PRECOMMIT_PYTHON does not have a SYSTEM-visible pre-commit: $envPy" - exit 1 - } - $py = $envPy - } else { - $candidates = @( - (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source), - (Join-Path $repoRoot '.venv\Scripts\python.exe') - ) - foreach ($c in $candidates) { - if (-not $c) { continue } - if (-not (Test-Path $c)) { continue } - $ErrorActionPreference = 'Continue' - & $c -s -m pre_commit --version 2>&1 | Out-Null - $rc = $LASTEXITCODE - $ErrorActionPreference = $saved - if ($rc -eq 0) { $py = $c; break } - } - } - $ErrorActionPreference = 'Stop' + # The script prints diagnostics with Write-Host and emits ONLY the + # chosen path on the success stream, so capturing it is exact. + # It also appends PYTHON= to GITHUB_ENV (as ASCII) for later + # steps, which is why $env:PYTHON works in the steps below. + $py = & './scripts/ci_find_python.ps1' -NeedPreCommit -WriteEnv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not $py) { - Write-Host "::error::no Python with a SYSTEM-visible pre-commit (DTU_PRECOMMIT_PYTHON not set; tried PATH python and repo .venv)" - Write-Host "::error::pre-commit is probably installed only in a USER site (invisible to SYSTEM)." - Write-Host "::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON for the runner service and restart it, or install pre-commit globally for the SYSTEM account (see the Gitea runner deployment guide in docs/)" - exit 1 + Write-Host "::error::ci_find_python.ps1 succeeded but returned no path" + exit 2 } - Write-Host "using python: $py" - & $py --version - & $py -s -m pre_commit --version - "PYTHON=$py" | Out-File -FilePath $env:GITHUB_ENV -Append + $py = "$py".Trim() # --- cppcheck ----------------------------------------------------- - # Order per the DTU_* plan: DTU_CPPCHECK (explicit; invalid = hard - # error, no fallback) -> PATH (Get-Command cppcheck). No fixed - # install-dir guess: a USER-scope PATH entry is invisible to SYSTEM, - # so on the runner set a MACHINE-level DTU_CPPCHECK if cppcheck is - # not on SYSTEM's PATH. + # Order: DTU_CPPCHECK (explicit; invalid = hard error, no fallback) + # -> PATH -> the standard install directory. + # + # The directory fallback is NOT cosmetic: the runner service runs as + # SYSTEM, whose PATH has no Cppcheck entry (a USER-scope PATH entry is + # invisible to it), and no machine-level DTU_CPPCHECK is set on a + # stock runner. Dropping this fallback in 33a4aa4 is what made L0 die + # on cppcheck as well, right after the Python probe had been fixed. + $found = $null $envCpp = $env:DTU_CPPCHECK if ($envCpp) { if (-not (Test-Path $envCpp)) { Write-Host "::error::DTU_CPPCHECK is set but does not exist: $envCpp" + Write-Host "::error::fix the variable (full path to cppcheck.exe), or unset it to use auto-discovery" exit 1 } - $dir = Split-Path -Parent $envCpp - $env:Path = "$env:Path;$dir" - $dir | Out-File -FilePath $env:GITHUB_PATH -Append - Write-Host "using cppcheck from DTU_CPPCHECK: $envCpp" - & $envCpp --version + $found = $envCpp + Write-Host "using cppcheck from DTU_CPPCHECK: $found" } else { - $cpp = Get-Command cppcheck -ErrorAction SilentlyContinue - if (-not $cpp) { - Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH) -- set a machine-level DTU_CPPCHECK, or add its directory to the machine PATH, then re-run" - exit 1 + $cmd = Get-Command cppcheck -ErrorAction SilentlyContinue + if ($cmd) { + $found = $cmd.Source + Write-Host "found cppcheck on PATH: $found" + } else { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if (-not $pf) { $pf = 'C:\Program Files' } + if (-not $pf86) { $pf86 = 'C:\Program Files (x86)' } + foreach ($cand in @( + (Join-Path $pf 'Cppcheck\cppcheck.exe'), + (Join-Path $pf86 'Cppcheck\cppcheck.exe'))) { + if (Test-Path $cand) { $found = $cand; break } + } } - Write-Host "found cppcheck: $($cpp.Source)" - cppcheck --version } + if (-not $found) { + Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH, not in the standard install dir)" + Write-Host "::error::set a machine-level DTU_CPPCHECK (see scripts/ci_runner_bootstrap.ps1 -SetEnv) or add its directory to the machine PATH, then re-run" + exit 1 + } + $cppDir = Split-Path -Parent $found + $env:Path = "$env:Path;$cppDir" + # ASCII on purpose: Out-File's PS 5.1 default is UTF-16LE, and other + # steps append to this same file -- mixed encodings corrupt it. + $cppDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding ascii + & $found --version 2>&1 | Out-Host Write-Host "runner environment check passed" @@ -196,6 +201,36 @@ jobs: shell: powershell run: "& $env:PYTHON -s -m pre_commit install --install-hooks" + - name: 提交信息门禁(gitlint,commit-msg stage) + shell: powershell + env: + DTU_BASE_SHA: ${{ github.event.before }} + DTU_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + DTU_HEAD_SHA: ${{ github.sha }} + run: | + # Pure ASCII only (see file header). + # + # WHY THIS STEP EXISTS: gitlint is a `commit-msg` stage hook, and + # `pre-commit run --all-files` only runs the `pre-commit` stage -- so + # the commit-message rule was enforced locally and NEVER here, while + # the job still reported green: the reported hook count is always + # (defined count - 1), so a silently missing hook is invisible. + # Recount that difference whenever hooks are added or removed. + # pre-commit has no notion of "the commits of this push", so the + # script feeds each commit message to the hook one at a time. + # + # Range: PR -> base..head; push -> event.before..sha, falling back to + # the newest commits when `before` is all-zero (new branch) or + # unreachable (force push). Merge commits are skipped (git writes them). + $base = $env:DTU_PR_BASE_SHA + if (-not $base) { $base = $env:DTU_BASE_SHA } + & './scripts/ci_gitlint_range.ps1' -BaseSha $base -HeadSha $env:DTU_HEAD_SHA ` + -MaxCommits 50 -PythonExe $env:PYTHON + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::commit message gate failed (gitlint) -- see the per-commit output above" + exit $LASTEXITCODE + } + - name: 决定跳过哪些钩子(Keil 依赖) id: skip shell: powershell @@ -213,14 +248,14 @@ jobs: $map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue if ($map) { Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally" - "value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "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)" | 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 - "sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=mem-numbers" | 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)." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } @@ -339,10 +374,10 @@ jobs: } } if ($found) { - "value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii } else { Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping L0.5. Install VS BuildTools and re-run." - "value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii } - name: 运行主机侧单测 @@ -350,28 +385,18 @@ jobs: if: steps.msvc.outputs.value == 'yes' shell: powershell run: | - # Resolve a working Python first (SYSTEM's PATH python may be unusable). - # Order per the DTU_* plan: DTU_PRECOMMIT_PYTHON (explicit; invalid = - # hard error) -> PATH python -> repo .venv. - $repoRoot = (Get-Location).Path - $py = $null - $envPy = $env:DTU_PRECOMMIT_PYTHON - if ($envPy) { - if (-not (Test-Path $envPy)) { - Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy" - exit 1 - } - $py = $envPy - } + # Resolve a working Python with the SAME shared probe as L0 (no + # -NeedPreCommit: these harnesses are stdlib-only). The old inline + # probe here had the same 2-candidate blind spot as L0 -- it only + # survived by luck, because the stdlib tests also run on the msys64 + # python that happens to be on SYSTEM's PATH. + $py = & './scripts/ci_find_python.ps1' -WriteEnv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not $py) { - foreach ($c in @( - (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source), - (Join-Path $repoRoot '.venv\Scripts\python.exe') - )) { - if ($c -and (Test-Path $c)) { $py = $c; break } - } + Write-Host "::error::ci_find_python.ps1 succeeded but returned no path" + exit 2 } - if (-not $py) { Write-Host "::error::no python found (set DTU_PRECOMMIT_PYTHON or fix the runner PATH)"; exit 1 } + $py = "$py".Trim() Write-Host "using python: $py" New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null @@ -423,8 +448,8 @@ jobs: Write-Host "::notice::$agg" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8 - "count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii exit $fail - name: 归档 L0.5 日志(供下载/排障) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8aceebb4..3c26255e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -352,7 +352,18 @@ repos: name: 保护元件框架单测(主机侧) entry: python test/host/prot_elm/run_unit_test.py language: system - files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$ + files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|App/algo/freq\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 7j2) 频率测量层(L2.5,B8)主机侧单元测试(2026-10-12 加) + # 自适应窗测频(K_eff 对齐负序泄漏纹波周期)的 J1~J6 判据 + # (扫频/噪声/收敛/阶跃/浮空 valid=0/钳制恢复)是纯合成信号 + # 可穷举的精度契约;板端无标准源照不出来(规划 §3.3 L3-5)。 + - id: freq-unit-test + name: 频率测量单测(主机侧) + entry: python test/host/freq/run_unit_test.py + language: system + 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 鉴权) @@ -388,7 +399,7 @@ repos: 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)\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$ + 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)$ pass_filenames: false # 8) 测试套件开关门禁(2026-10-01 加) diff --git a/AGENTS.md b/AGENTS.md index c46a9c43..843910ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,18 +46,18 @@ - 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,`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`)。→ `App/algo/README.md` - 全景 → `docs/架构设计/项目架构总览.md`;启动时序与内核前模式 → `docs/架构设计/项目启动顺序管理.md` | Task | 栈 (words) | 优先级 | |---|---|---| -| defaultTask | 1024 | Normal | +| defaultTask | 1280 | Normal | | netTask | 960 | Normal | | ftpTask | 1024 | Normal | | testTask | 2048 | BelowNormal | | uiTask | 4096 | BelowNormal | -| timeSyncTask | 512 | Normal | +| timeSyncTask | 640 | Normal | | adcTask | 640 | AboveNormal | | iec104Task | 640 | Normal | | protectTask | 512 | Normal | @@ -76,7 +76,7 @@ ## 构建与验证 -- `@build` = `& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`(仓库根执行)。退出码 **0=PASS / 1=构建失败 / 2=UV4 未找到 / 3=用法**;日志 `MDK-ARM/build_log.txt`;换机必设 `DTU_KEIL_UV4` → 细节 **`keil-build` skill** + `docs/环境与部署/本地工具路径环境变量方案.md` +- `@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 即本次不通过**(刻意) - **烧录必须 `-Rst -Run`**(`stlink-flash` skill 的 `flash`):裸 `-P` 会报 `Programming Complete`,但目标核处于死循环/异常时写入并不落地("反复重下载、芯片一直是旧镜像、二分法失效"的根因)。**烧后必须回读校验**(`stlink_flash.py cmp` = `-CmpFile`,`No difference found` 才算真正写入);⚠ `-CmpFile` 连接时会**复位目标** → 必须排在**采日志之后** → `docs/测试说明/ST-LINK_CLI_复位使用说明.md` @@ -89,12 +89,12 @@ ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **31 个钩子**(逐个详解 → `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` + 主机侧单测 13 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23)+ `mem-budget`(RAM 92% 预算门禁,N-27) +- 任一失败即拦截,共 **32 个钩子**(逐个详解 → `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` + 主机侧单测 14 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23)+ `mem-budget`(RAM 92% 预算门禁,N-27) - **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见 `docs/代码规范/pre-commit使用说明.md` §5.5) - ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/` - 提交信息:标题 ≤100 字符、**正文每行 ≤80**(`.gitlint` + gitlint 默认)→ 长说明一律用**短标题 + `git commit -F ` 多行正文**(中文长标题极易超限,且 `-m` 会被终端代码页转码破坏) -- **CI**(`.gitea/workflows/ci.yml`)只跑 L0 静态门禁 + L0.5 主机侧单测,**刻意不跑 `@build`**(需 Keil 授权)、不含 L1/L2/L3(需真机);本地等价自查 `& '.venv\Scripts\python.exe' scripts/ci_local.py`——**runner 未就绪时这是 L0 的唯一保障** +- **CI**(`.gitea/workflows/ci.yml`)只跑 L0 静态门禁 + L0.5 主机侧单测,**刻意不跑 `@build`**(需 Keil 授权)、不含 L1/L2/L3(需真机)。**远程 CI 的全部资料(服务端开关 / runner 部署与保活 / workflow / 排障 / 实测坑)集中在 [`docs/CI/README.md`](docs/CI/README.md),不放本节**——它**只跑在本机一台 runner `win-stm32-ci` 上**,其他机器与项目都不涉及。日常改代码的等价自查是 `& '.venv\Scripts\python.exe' scripts/ci_local.py`(L0 + L0b + L0.5)——**runner 不在线也不影响本地门禁,不能因"已配 CI"放宽本地要求** ## 平台约束 @@ -172,7 +172,7 @@ | ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` | | 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` | | RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` | -| pre-commit 31 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | +| pre-commit 32 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | | 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` | | 自动化测试 / 实机测试 / 板级套件 | `docs/测试说明/自动化测试流程.md`、`实机测试实施指南.md`、`board-test-suite` skill | | 烧录与复位语义 | `docs/测试说明/ST-LINK_CLI_复位使用说明.md` | @@ -180,4 +180,5 @@ | UI 架构 / 模拟器 / UI 自动化回归 | **`lvgl-simulator` skill**(构建/自检/报错速查,用到自动加载)、`docs/UI设计/UI_设计架构.md`、`LVGL模拟器部署与使用说明.md`、`UI_自动化测试流程.md`;UI 改动回归走 **`ui-verify` skill** | | 待办与审计发现登记簿 | `docs/任务规划/README.md` | | 多任务并行开发(开空间 / 合并回收 / 台架与构建排队) | **`task-worktree` skill**(`@task`,用到自动加载)、`docs/环境与部署/并行任务工作树方案.md` | -| 换机 / 工具路径环境变量 / CI | `docs/环境与部署/` | +| 换机 / 工具路径环境变量 | `docs/环境与部署/` | +| 远程 CI(Gitea Actions,**只跑本机一台 runner**) | [`docs/CI/README.md`](docs/CI/README.md) | diff --git a/App/algo/README.md b/App/algo/README.md index ee3b12d1..e218e991 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -16,11 +16,12 @@ | `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-6(软件侧) | +| `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`(纯枚举 + 声明)← `sample_ring` ← `phasor`; -`prot_set` **独立**(仅 `stdint.h`);`prot_elm` ← `prot_set` + `phasor`。 -`sample_ring.c` / `phasor.c` / `adc_health.c` / `prot_set.c` / `prot_elm.c` **零平台依赖** +依赖关系(自下而上,无环):`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`(快照),主机侧以桩头替身。 @@ -38,12 +39,15 @@ flowchart TD POLL --> RAW["adc_input_raw(ch) × 9
按语义取原始码值"] RAW --> RING["sample_ring_push_frame()
整帧入环(40 点窗)"] RING --> RUN["phasor_run()
逐实例 20 点重算 + 双区切换"] + RUN --> FREQ["freq_run()
共轭乘积相位增量 + 自适应窗均值(B8)"] RUN --> GET["phasor_get(idx)"] + FREQ --> FGET["freq_get() / freq_valid()"] GET --> CONS["消费者:adcTask 日志(现状)
UI / 自检 / L3 保护任务(待接)"] + FGET --> PCONS["消费者:prot_elm_run() OF/UF 判据(B8)
adcTask 心跳 freq 行"] ``` -初始化落点:`adc_input_init()` 与 `phasor_init()` 由 `App/app/app_main.c` 的 -`app_main_init()` 在**内核启动前**调用(三者 `*_init()` 均无 RTOS 依赖、幂等); +初始化落点:`adc_input_init()` 与 `phasor_init()`、`freq_init()` 由 `App/app/app_main.c` 的 +`app_main_init()` 在**内核启动前**调用(各 `*_init()` 均无 RTOS 依赖、幂等); `phasor_init()` 内部会连带 `sample_ring_init()`,等价于"刚上电"。 ## 3. 逐文件说明 @@ -176,6 +180,19 @@ flowchart TD (基址 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 子集) @@ -185,24 +202,88 @@ flowchart TD | API | 干什么 | 注意 | |---|---|---| -| `prot_elm_init()` | 复位状态 + 解析相量实例索引(按名前缀 `C01/C02/C03`) | 须在 `phasor_init()` 之后调 | +| `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)` | 适配层:`phasor_get()` 组测量 → eval | 任一相量未就绪 → **整拍跳过**(不引入陈旧判据) | +| `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` 是唯一欠量实例)。 + 过量/欠量方向由实例表 `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 外部通道)。 - 零序过流(IN 走 STM32)与过频/低频(待频率算法)**故意不在表中**。 +- **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. 关键约定 @@ -224,17 +305,22 @@ flowchart TD | `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[31]` + 封存/位图/闭锁 | 136 B(**未接线前被链接器移除,实测 0**) | -| `prot_elm.c` `s_rt[7]`(16 B/实例) | 112 B(同上,实测 0) | +| `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`)前后各跑一次 +改容量(`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` 已在 `` 内。 - 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)。 +- **挂载**:八个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 + 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)——纯算法且模拟器无消费路径的 + 新文件(`prot_set.c` / `freq.c`)改在 `scripts/check_registration.py` 的 `SIM_EXEMPT` 登记豁免 + (`registration-check` 钩子反向校验,N-23)。 - **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码: | 套件 | 命令 | pre-commit 钩子 | @@ -242,19 +328,26 @@ flowchart TD | 相量层(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` | - | 定值模型(30 用例:范围校验 / all-or-nothing 装载 / 封存-自检-闭锁 / Q10 往返 / 元数据表自检) | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` | - | 元件框架(27 用例:滞回与定时限 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕 / 位图聚合) | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-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(...)` 行、 - `ring` / `skip` 计数、`adc1_rc` / `adc1_err`、`tgap`);健康判据 = 零 `[ERR]` + `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. 新增一个算法模块的固定动作 1. 在本目录建 `xxx.c/h`,文件头写模块注释块(规范 V1.0),**尽量零平台依赖**以便免桩单测。 2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(目录已在 IncludePath, - 若新建子目录则必须补 IncludePath)。 + 若新建子目录则必须补 IncludePath)。模拟器侧二选一:有消费路径 → 加 + `simulator/CMakeLists.txt`;**纯算法无消费路径 → 加 `scripts/check_registration.py` + 的 `SIM_EXEMPT`**(照 `prot_set.c` / `freq.c` 先例写理由),否则 `registration-check` 钩子拦。 3. 加 `test/host/<模块>/` 主机单测 + `.pre-commit-config.yaml` 钩子(照 `phasor-unit-test` 抄)。 4. 消费者写在 `App/task/`,本层只出"数";跨转换器运算的禁令见 §4。 diff --git a/App/algo/freq.c b/App/algo/freq.c new file mode 100644 index 00000000..0e634a71 --- /dev/null +++ b/App/algo/freq.c @@ -0,0 +1,318 @@ +/* + * 模块名称:频率测量层(L2 派生量)——实现 + * 模块功能:见 freq.h。相邻两拍基波相量共轭乘积取角(= 每拍相位增量,免 + * 显式解缠绕)+ **自适应窗长滑动平均** + 瞬时值钳制 + 幅值门。 + * 输入 = phasor 使用区基波相量(UA 优先,UA 无幅值用 IA)。 + * + * 精度问题的根与解(2026-10-12 单测实测驱动,数据见 test/host/freq 输出): + * 非整周波窗长让 20 点矩形窗 DFT 混入**负序泄漏项** S2(45/55 Hz 处 + * |S2/S1| ≈ 3~5%),相量端轨迹是椭圆 → 逐拍相位差带周期纹波 + * δ(φ),周期 = π/ω = fs/(2f) ≈ 9.1~11.1 拍。固定窗长平均时残差 + * = [δ(m+K)-δ(m)]/K,K 不整除纹波周期 → 扫频最坏 0.2~0.32 Hz(两轮实测: + * 固定 40 拍算术平均 0.317 Hz;固定 60 拍复数和 Kay 0.197 Hz——Kay 的 + * 交叉项和角同样不随 K² 衰减,勿再尝试)。 + * **自适应窗长**:K_eff = round(M·Fs/(2·f_hat))(M=8,≈4 个泄漏纹波周期 + * ≈4 个信号周波),使窗长恰为纹波周期的整数倍 → δ(m+K)≈δ(m),残差进入 + * 0.01 Hz 量级。f_hat 用上一拍读数(收敛后误差 <0.1 Hz → K_eff 偏差 <1 拍, + * 自洽)。 + * **精度优先于响应的显式取舍(Lead 批准 2026-10-12)**: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 是 + * 工程整定,不是判据失守。 + * 另:单拍假值钳制带取 35~65 Hz(宽于系统合理带 40.5~60.5)——贴边钳制 + * 会非对称丢弃纹波半周 → 均值偏置(实测 45 Hz 点 +0.16 Hz,已修)。 + * 创建日期:2026-10-12(B8) + */ +#include "freq.h" + +#include +#include + +#include "phasor.h" + +/* 圆周率(本层自带,避免依赖 math.h 的 M_* 扩展宏;ARMCC math.h 不保证定义) */ +#define FREQ_PI 3.14159265358979f + +/* 相位增量 -> 频率 换算系数:Fs/(2π)(Hz/rad) */ +#define FREQ_HZ_PER_RAD (FREQ_FS_HZ / (2.0f * FREQ_PI)) + +/* 自适应窗长目标纹波周期数 M:K_eff = round(M·Fs/(2·f_hat))。 + * 45~55 Hz 段 K_eff ∈ [73, 89] ⊂ [FREQ_AVG_MIN, FREQ_AVG_TAPS]。 */ +#define FREQ_RIPPLE_M 8u + +/* 源相量候选(优先级序):UA 基波 = "C02",IA 基波 = "C01"。 + * 按**名字前缀**解析索引(同 prot_elm 口径,不写死下标),开机解析一次。 */ +#define FREQ_SRC_N 2 +static const char *const k_src_prefix[FREQ_SRC_N] = { "C02", "C01" }; + +/* 测频内部状态(BSS ≈ 96*(4+1) + 常数 ≈ 520 B) */ +static float fq_d[FREQ_AVG_TAPS]; /* 每拍相位增量环(rad) */ +static uint8_t fq_d_ok[FREQ_AVG_TAPS]; /* 对应拍是否有效(假值拍 = 0) */ +static uint8_t fq_head; /* 环写指针 */ +static uint8_t fq_count; /* 已入环样本数(饱和于 TAPS) */ +static float fq_last; /* 最近有效读数(Hz) */ +static float fq_prev_re; /* 上一拍源相量(实部) */ +static float fq_prev_im; /* 上一拍源相量(虚部) */ +static uint8_t fq_prev_valid; /* 上一拍是否有相量(首拍建基准) */ +static uint8_t fq_src_idx[FREQ_SRC_N]; /* 候选实例索引;0xFF = 未找到 */ +static uint8_t fq_src_resolved; /* 源已解析标志 */ +static uint8_t fq_prev_src; /* 上一拍所用候选(切换即废基准) */ +static uint8_t fq_started; /* 启动收敛完成(样本数 >= START_TAPS) */ +static uint8_t fq_gate_ok; /* 本拍幅值门 OK 且已收敛 */ + +/* + * 函数功能:解析候选源相量实例索引(开机一次) + * 入口参数:无 + * 返 回 值:无(结果写 fq_src_idx[],未找到 = 0xFF) + * 限定条件:phasor_init() 之后(开机一次) + * 函数说明:按实例名前缀匹配("C02"=UA 基波 / "C01"=IA 基波)且 harm==1, + * 与 prot_elm 同款朴素比较;**不写死实例下标**。 + */ +static void fq_resolve_src(void) { + uint8_t p; + + for (p = 0u; p < FREQ_SRC_N; p++) { + uint8_t i, n = phasor_inst_count(); + + fq_src_idx[p] = 0xFFu; + for (i = 0u; i < n; i++) { + const phasor_inst_t *pi = phasor_inst(i); + uint8_t j = 0u, hit = 1u; + + if (pi == 0) { + continue; + } + while ((k_src_prefix[p][j] != '\0') && (pi->name[j] != '\0')) { + if (k_src_prefix[p][j] != pi->name[j]) { + hit = 0u; + break; + } + j++; + } + if ((hit != 0u) && (k_src_prefix[p][j] == '\0') && (pi->harm == 1u)) { + fq_src_idx[p] = i; + break; + } + } + } + fq_src_resolved = 1u; +} + +/* + * 函数功能:把相位增量入环 + * 入口参数:d - 本拍相位增量(rad);ok - 本拍是否有效(钳制失败 = 0) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:假值拍也**占位**(ok=0),保持环时序 = 拍时序(自适应窗长 + * 的"窗 = 最近 K_eff 拍"语义依赖这一点);求和时跳过占位拍。 + */ +static void fq_push(float d, uint8_t ok) { + fq_d[fq_head] = d; + fq_d_ok[fq_head] = ok; + fq_head++; + if (fq_head >= FREQ_AVG_TAPS) { + fq_head = 0u; + } + if (fq_count < FREQ_AVG_TAPS) { + fq_count++; + if ((fq_started == 0u) && (fq_count >= FREQ_START_TAPS)) { + fq_started = 1u; + } + } +} + +/* + * 函数功能:按自适应窗长求最近 K_eff 拍的相位增量均值 -> 频率 + * 入口参数:无 + * 返 回 值:频率读数(Hz);窗内无有效样本时返回 fq_last(保持) + * 限定条件:fq_started != 0 + * 函数说明:K_eff = clamp(round(M·Fs/(2·f_hat)), FREQ_AVG_MIN, FREQ_AVG_TAPS) + * (f_hat = 上一读数,首轮用标称 50 Hz 起步)。窗 = 最近 K_eff 拍 + * (含占位拍,占位不计和但计窗推进),恰为泄漏纹波周期整数倍 + * -> δ(m+K)≈δ(m) 相消。每拍重算和(<=96 次加法,us 级)。 + */ +static float fq_read_freq(void) { + float f_hat = (fq_last > 30.0f) ? fq_last : 50.0f; + uint16_t k_eff; + uint16_t i; + uint16_t idx; + float sum = 0.0f; + uint16_t n_ok = 0u; + + /* K_eff = M*Fs/(2*f_hat):四舍五入 + 钳到环容量区间 */ + k_eff = (uint16_t)(((float)FREQ_RIPPLE_M * FREQ_FS_HZ) / (2.0f * f_hat) + 0.5f); + if (k_eff < FREQ_AVG_MIN) { + k_eff = FREQ_AVG_MIN; + } + if (k_eff > FREQ_AVG_TAPS) { + k_eff = FREQ_AVG_TAPS; + } + if (k_eff > (uint16_t)fq_count) { + k_eff = (uint16_t)fq_count; + } + + /* 从写指针回退 K_eff 拍求和(跳过占位拍) */ + idx = fq_head; + for (i = 0u; i < k_eff; i++) { + if (idx == 0u) { + idx = FREQ_AVG_TAPS; + } + idx--; + if (fq_d_ok[idx] != 0u) { + sum += fq_d[idx]; + n_ok++; + } + } + if (n_ok == 0u) { + return fq_last; + } + return (sum / (float)n_ok) * FREQ_HZ_PER_RAD; +} + +/* + * 函数功能:频率测量层初始化(幂等) + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用(无 RTOS 依赖) + * 函数说明:清增量环/占位标志/计数/读数/相位基准/有效标志,并复位源解析。 + */ +int freq_init(void) { + uint8_t i; + + for (i = 0u; i < FREQ_AVG_TAPS; i++) { + fq_d[i] = 0.0f; + fq_d_ok[i] = 0u; + } + fq_head = 0u; + fq_count = 0u; + fq_last = 0.0f; + fq_prev_re = 0.0f; + fq_prev_im = 0.0f; + fq_prev_valid = 0u; + fq_src_idx[0] = 0xFFu; + fq_src_idx[1] = 0xFFu; + fq_src_resolved = 0u; + fq_prev_src = 0u; + fq_started = 0u; + fq_gate_ok = 0u; + return 0; +} + +/* + * 函数功能:每拍测频步进 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅采集消费者任务上下文(adcTask),须在 phasor_run() 之后同拍调用 + * 函数说明:流程 = 选源(UA->IA 过幅值门)-> 共轭乘积 P = Z_now·conj(Z_prev) + * 取角(免显式解缠绕)-> 钳制(假值拍占位 ok=0)-> 自适应窗均值。 + * 相量未就绪/全部候选门失败:本拍不更新(读数保持)、清 gate、废基准。 + * 基准在"断流"或**源切换**后重建(UA/IA 相位无共同基准)。 + */ +void freq_run(void) { + phasor_t ph = { 0.0f, 0.0f, 0.0f, 0.0f }; + float pr; + float pi; + float dphi; + float f_inst; + uint8_t pick = FREQ_SRC_N; /* FREQ_SRC_N = 无合格源 */ + uint8_t p; + + if (fq_src_resolved == 0u) { + fq_resolve_src(); + } + + /* 源选择(每拍动态):优先级 UA(C02) -> IA(C01),取第一个过幅值门的。 + * 台架只注电流时 UA 门失败 -> 自动落 IA;两路都无幅值 = 无有效波形。 */ + for (p = 0u; p < FREQ_SRC_N; p++) { + if ((fq_src_idx[p] == 0xFFu) || (phasor_get(fq_src_idx[p], &ph) != 0)) { + continue; + } + if (ph.fAmSQ >= FREQ_AMP_MIN) { + pick = p; + break; + } + } + if (pick >= FREQ_SRC_N) { + /* 相量未就绪或全部候选幅值门失败(浮空/断线 rms≈0 -> 相位无意义): + * 停更新、清 gate、废基准(读数保持) */ + fq_prev_valid = 0u; + fq_gate_ok = 0u; + return; + } + + if ((fq_prev_valid == 0u) || (pick != fq_prev_src)) { + /* 首拍 / 断流后首拍 / 源切换:只建基准,不出数 */ + fq_prev_re = ph.fRe; + fq_prev_im = ph.fIm; + fq_prev_src = pick; + fq_prev_valid = 1u; + fq_gate_ok = 0u; + return; + } + + /* P = Z_now * conj(Z_prev):arg(P) = 本拍相对上拍的相位增量 + * (纯音 = +2*pi*f/Fs;|P| 大 = 信号强,钳制用角不受幅值影响) */ + pr = (ph.fRe * fq_prev_re) + (ph.fIm * fq_prev_im); + pi = (ph.fIm * fq_prev_re) - (ph.fRe * fq_prev_im); + fq_prev_re = ph.fRe; + fq_prev_im = ph.fIm; + + dphi = atan2f(pi, pr); + f_inst = dphi * FREQ_HZ_PER_RAD; + + /* 假值钳制:单拍折算越出 35~65 Hz(采样间隙窗滑 2 拍 -> ≈2f=90~110、 + * 相位跳变、深度畸变过渡段)-> 占位不入和,读数保持;基准已推进。 + * ⚠ 钳制带刻意宽于系统合理带(见 freq.h FREQ_INST_* 注释):贴边会 + * 非对称丢弃泄漏纹波半周造成均值偏置。 */ + if ((f_inst < FREQ_INST_MIN_HZ) || (f_inst > FREQ_INST_MAX_HZ)) { + fq_push(0.0f, 0u); + } else { + fq_push(dphi, 1u); + } + + if (fq_started != 0u) { + fq_last = fq_read_freq(); + fq_gate_ok = 1u; + } else { + fq_gate_ok = 0u; + } +} + +/* + * 函数功能:读测得频率(见 freq.h) + * 入口参数:out - 输出(NULL -> -1) + * 返 回 值:0 已有读数 / -1 参数错 / -2 从未启动收敛 + * 限定条件:任意上下文(float 单字读原子) + */ +int freq_get(float *out) { + if (out == 0) { + return -1; + } + if (fq_started == 0u) { + *out = 0.0f; + return -2; + } + *out = fq_last; + return 0; +} + +/* + * 函数功能:读"本拍有效"标志(见 freq.h) + * 入口参数:无 + * 返 回 值:1 新鲜 / 0 未启动或幅值门失效 + * 限定条件:无 + */ +uint8_t freq_valid(void) { + return fq_gate_ok; +} + +/* + * 函数功能:读最近一次有效读数(见 freq.h) + * 入口参数:无 + * 返 回 值:Hz;从未收敛返回 0.0 + * 限定条件:无 + */ +float freq_last(void) { + return fq_last; +} diff --git a/App/algo/freq.h b/App/algo/freq.h new file mode 100644 index 00000000..1fe8860f --- /dev/null +++ b/App/algo/freq.h @@ -0,0 +1,121 @@ +/* + * 模块名称:频率测量层(L2 派生量,L3-5 过频/低频的测量输入) + * 模块功能:由 L2 相量使用区的基波相量导出系统频率——相邻两拍基波相量取 + * **共轭乘积** P = Z(m)·conj(Z(m-1))(其角 = 每拍相位增量,纯音 = + * +2πf/Fs,免显式解缠绕),再对**自适应窗长**的相位增量做滑动平均: + * K_eff = round(8·Fs/(2·f_hat)) ≈ 泄漏纹波周期 fs/(2f) 的整数倍 + * -> 非整周波 DFT 负序泄漏造成的 ~10 ms 周期相位纹波相消 + * (定长窗平均实测扫频误差 0.2~0.32 Hz,自适应窗进入 0.01 Hz 量级, + * 推导与实测见 freq.c 文件头与 test/host/freq 输出)。 + * 覆盖规划 §3.3 L3-5 的测量侧与 §4.2 测频范围(45~55 Hz 建议段)。 + * + * 原理与判据(详见 App/algo/README.md §3.7 与 test/host/freq/): + * - 采样率 Fs = 1000 Hz(TIM3 节拍,与 phasor 同源);45~55 Hz 段每拍 + * 相位增量 ≈ 0.28~0.35 rad,距 ±π 缠绕边界裕度巨大,无歧义; + * - 瞬时值钳制:单拍相位差折算越出 35~65 Hz(**假值带**,宽于系统合理带 + * 40.5~60.5=参考 CN_HERTZ_SOFTFR_MIN/MAX,规划 §10.1——贴边会非对称 + * 丢弃泄漏纹波半周造成偏置,见 FREQ_INST_* 注释)判为采样间隙/相位 + * 跳变假值,本拍不入窗; + * - 幅值门:源相量有效值 < FREQ_AMP_MIN 判"无有效波形"(浮空输入/PT 断线), + * 停止更新、valid 清零、读数保持 → 台架浮空 rms≈0 不发散; + * - 信号源优先级:UA 基波 → IA 基波(UA 无幅值时用 IA,台架只注电流亦可测频)。 + * + * ⚠ 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(输入 = phasor 使用区) + * → 主机侧可免桩单测(L0.5,落实登记簿 A-4)。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:DTU 项目组 + * 创建日期:2026-10-12(B8) + * 修改记录: + * v1.0 2026-10-12 DTU 项目组 创建:相位解缠绕测频 + 滑动平均 + 幅值门 + */ + +#ifndef FREQ_H +#define FREQ_H + +#include + +/* 测频标称采样率(Hz):TIM3 1 kHz 节拍,与 phasor/sample_ring 同一时基 */ +#define FREQ_FS_HZ 1000.0f + +/* 单拍(逐拍相位差折算)**假值钳制带**(Hz):越出即判采样间隙/相位跳变/ + * 深度畸变过渡段产生的粗大假值,本拍不入窗(读数保持)。 + * ⚠ 与"系统合理频率带"分开定:非整周波 DFT 泄漏让单拍相位差本身带 + * ±5 Hz 量级纹波(45/55 Hz 端实测),钳制带若贴住 40.5~60.5(参考 + * CN_HERTZ_SOFTFR_MIN/MAX,规划 §10.1)会**非对称丢弃纹波半周** → 均值 + * 偏置(实测 45 Hz 点 +0.16 Hz)。放宽到 35~65 Hz 后仍拦得住窗滑 2 拍的 + * ≈2f(90~110 Hz)与相位跳变假值,又不吃掉正常纹波。 + * 系统合理带 40.5~60.5 由消费侧把关(OF/UF 定值范围 50~55 / 45~50), + * 测量层不做截断——测量层只保证"测得准"。 */ +#define FREQ_INST_MIN_HZ 35.0f +#define FREQ_INST_MAX_HZ 65.0f + +/* 幅值门限(有效值,**原始码值口径**,L1-5 标定未实现):低于该值判 + * "无有效波形"。⚠ 拟定值(远小于正常波形千级码值、远大于浮空噪声), + * 标定落地后须按工程量口径复核(同 prot_set 默认值"拟定"口径)。 */ +#define FREQ_AMP_MIN 50.0f + +/* 相位增量滑窗环容量(拍):96 拍 = 96 ms 上限。实际平均窗长**自适应** + * (freq.c fq_read_freq:K_eff = round(8·Fs/(2·f_hat)),45~55 Hz 段 73~89 拍), + * 使窗长恰为 DFT 负序泄漏纹波周期 fs/(2f) 的整数倍 -> 纹波相消。 + * 保护定值延时 ≥100 ms,~90 ms 窗不影响动作时序判据。 */ +#define FREQ_AVG_TAPS 96u + +/* 自适应窗长下限(拍):K_eff 钳位区间下界(启动段窗未满时按已有拍数)。 */ +#define FREQ_AVG_MIN 72u + +/* 判"启动收敛完成"所需最少样本数(拍):20 拍 = 20 ms(首个有效相量后 + * 21 拍内出首个读数)。窗未满时按已有样本求均值(部分窗),收敛快。 */ +#define FREQ_START_TAPS 20u + +/* + * 函数功能:频率测量层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用(无 RTOS 依赖);须在 phasor_init() 之后无要求 + * (本层只在使用区就绪后才产出) + * 函数说明:清平均缓冲/计数/读数/有效标志;可重复调用(幂等)。 + */ +int freq_init(void); + +/* + * 函数功能:每拍测频步进(读 phasor 使用区 → 相位差 → 钳制 → 滑动平均) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅采集消费者任务上下文(adcTask),**须在 phasor_run() 之后** + * 同拍调用(本拍相量已切换使用区) + * 函数说明:相量未就绪(-2)→ 本拍不更新;幅值门失效 → 停更新、valid 清零、 + * 读数保持;单拍乘积角折算越出 40.5~60.5 Hz → 假值拍入 0(不进窗)。 + * 窗未满 FREQ_AVG_TAPS 时按已有乘积求和(部分窗),收敛快。 + */ +void freq_run(void); + +/* + * 函数功能:读测得频率(工程量 Hz) + * 入口参数:out - 输出(NULL 直接返回 -1) + * 返 回 值:0 - 已有读数(out = 现值,可能为保持值);-1 参数错; + * -2 - 从未启动收敛(尚无任何有效读数,out 写 0) + * 限定条件:无(任意上下文;float 单字读,Cortex-M 原子) + * 函数说明:本函数判"**有没有数**";判"数新不新"(幅值门是否仍 OK) + * 用 freq_valid()。保护元件消费口径 = get==0 && valid==1。 + */ +int freq_get(float *out); + +/* + * 函数功能:读"本拍有效"标志(幅值门 OK 且已启动收敛) + * 入口参数:无 + * 返 回 值:1 - 读数新鲜;0 - 未启动或幅值门失效(读数仍保持) + * 限定条件:无 + * 函数说明:供心跳日志与保护元件判"测量可信"用。 + */ +uint8_t freq_valid(void); + +/* + * 函数功能:读最近一次有效读数(不判新鲜度) + * 入口参数:无 + * 返 回 值:Hz;从未收敛返回 0.0 + * 限定条件:无 + * 函数说明:单字 float 读,原子。 + */ +float freq_last(void); + +#endif /* FREQ_H */ diff --git a/App/algo/prot_elm.c b/App/algo/prot_elm.c index 77e5f62c..9a15b599 100644 --- a/App/algo/prot_elm.c +++ b/App/algo/prot_elm.c @@ -8,6 +8,7 @@ #include "prot_elm.h" #include "prot_set.h" #include "phasor.h" +#include "freq.h" #include @@ -15,21 +16,33 @@ typedef struct { const char *name; /* 日志名 */ uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */ + uint8_t src; /* 测量源:0 = phasor 基波有效值,1 = freq 测频 */ prot_set_id_t en_id; /* 投退定值 */ 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"…),run() 适配层取数用 */ + const char *pprefix; /* 相量实例名前缀("C01"…);src=1 时为 0(不用) */ } pe_cfg_t; +/* 测量源枚举(pe_cfg_t.src) */ +#define PE_SRC_PHASOR 0u +#define PE_SRC_FREQ 1u + +/* freq 无有效读数时给欠量元件的"必不启动"填充值(远大于任何欠量定值上限) */ +#define PE_MEAS_BIG 1e30f + static const pe_cfg_t k_pe[PE_COUNT] = { - { "OC1-IA", 1u, PS_OC1_EN, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" }, - { "OC2-IA", 1u, PS_OC2_EN, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" }, - { "OC3-IA", 1u, PS_OC3_EN, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" }, - { "OL-IA", 1u, PS_OL_EN, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" }, - { "OV-UA", 1u, PS_OV_EN, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" }, - { "UV-UA", 0u, PS_UV_EN, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" }, - { "3U0-UN", 1u, PS_3U0_EN, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" }, + { "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" }, + /* 过频/低频(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 }, }; /* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */ @@ -89,7 +102,8 @@ void prot_elm_init(void) { s_rt[i].picked = 0u; s_rt[i].asserted = 0u; s_rt[i].t0 = 0u; - s_rt[i].pidx = pe_find_phasor(k_pe[i].pprefix); + /* src=FREQ 的行无相量绑定(pprefix=0)→ pidx 置未解析(run 不查它) */ + s_rt[i].pidx = (k_pe[i].pprefix != 0) ? pe_find_phasor(k_pe[i].pprefix) : 0xFFu; } } @@ -172,11 +186,15 @@ void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]) { } /* - * 函数功能:适配层——从 phasor 使用区取 rms 组装测量数组后调判据核 + * 函数功能:适配层——按实例表 src 组装测量数组后调判据核 * 入口参数:now_ms - 单调时基(tick ms) * 返 回 值:无 * 限定条件:仅保护消费者任务上下文;工程量↔码值换算 = 1(L1-5 标定未实现) - * 函数说明:任一相量未就绪(未满一周波)→ **整拍跳过**,不引入陈旧/半新测量。 + * 函数说明:src=PHASOR 行任一相量未就绪(未满一周波)→ **整拍跳过**, + * 不引入陈旧/半新测量(B5 口径)。src=FREQ 行:freq 有有效读数 + * (started 且幅值门 OK)→ 填测得频率;否则按方向填"必不启动"值 + * (过量 0 / 欠量 PE_MEAS_BIG → 滞回比较恒假 → 元件复位), + * 不整拍跳过——频率源缺失不得饿死电流/电压类元件。 */ void prot_elm_run(uint32_t now_ms) { float meas[PE_COUNT]; @@ -184,6 +202,16 @@ void prot_elm_run(uint32_t now_ms) { uint32_t i; for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + if (k_pe[i].src == PE_SRC_FREQ) { + float f; + + if ((freq_get(&f) == 0) && (freq_valid() != 0u)) { + meas[i] = f; + } else { + meas[i] = (k_pe[i].over != 0u) ? 0.0f : PE_MEAS_BIG; + } + continue; + } if ((s_rt[i].pidx == 0xFFu) || (phasor_get(s_rt[i].pidx, &p) != 0)) { return; } diff --git a/App/algo/prot_elm.h b/App/algo/prot_elm.h index d23a39c3..e0ebc2fa 100644 --- a/App/algo/prot_elm.h +++ b/App/algo/prot_elm.h @@ -9,7 +9,13 @@ * OL 过负荷(A 相 IA,L3-3) * OV/UV 过压/欠压(UA,L3-4) * 3U0 零序过压(UN 外部零序电压通道,L3-6) - * ⚠ 过频/低频(L3-5)随频率测量算法(P2)落地,本表暂不含。 + * OF/UF 过频/低频(测量量 = freq 层测得频率,L3-5,B8 落地) + * + * 测量数组口径:相量类实例 = 基波有效值(A/V);OF/UF = 测得频率(Hz)。 + * run() 按实例表 src 字段取数(0=phasor rms,1=freq)。freq 无有效读数 + * (未启动收敛或幅值门失效)→ 该行按方向填"必不启动"值(过量填 0、 + * 欠量填极大值 → 滞回核比较恒假 → 元件复位不误动),**不整拍跳过** + * (否则浮空台架会把 OC/UV 等全部饿死,B5 行为回归)。 * * 与参考系统的口径差异(有意简化,均已在规划文档登记): * - 返回门槛 = 定值 × 返回系数(参考经 CN_SET_RET 独立定值,等价); @@ -30,7 +36,7 @@ #include -/* 元件实例编号(枚举序 = 实例表序 = 测量数组序) */ +/* 元件实例编号(枚举序 = 实例表序 = 测量数组序;尾追加,编号不回收) */ typedef enum { PE_OC1 = 0, /* 过流 Ⅰ 段(IA) */ PE_OC2, /* 过流 Ⅱ 段(IA) */ @@ -39,6 +45,8 @@ typedef enum { PE_OV, /* 过压(UA) */ PE_UV, /* 欠压(UA,欠量方向) */ PE_3U0, /* 零序过压(UN) */ + PE_OF, /* 过频(测量量 = freq 层测得频率,B8,L3-5) */ + PE_UF, /* 低频(测量量 = freq 层测得频率,欠量方向,B8,L3-5) */ PE_COUNT } prot_elm_id_t; diff --git a/App/algo/prot_set.c b/App/algo/prot_set.c index 4b07260f..71a88759 100644 --- a/App/algo/prot_set.c +++ b/App/algo/prot_set.c @@ -47,6 +47,17 @@ const prot_set_meta_t g_prot_set_meta[PS_ID_COUNT] = { { 0x8825, 0u, PROT_SET_Q10(100.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10000.0) }, { 0x8826, 0u, PROT_SET_Q10(5.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10.0) }, { 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* 全局软压板投退(YK12,B7) */ + /* --- 过频 / 低频(L3-5,B8):地址全 0 = 本地项(参考逐项地址不在 + * 本仓库,规划 §5.1 仅示"8820~8882 含低压/低频"类目); + * 默认值/范围**拟定**(DTU 馈线典型量级),待对照参考出厂表修订 --- */ + { 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OF 投退(本地) */ + { 0x0000, 0u, PROT_SET_Q10(51.0), PROT_SET_Q10(50.0), PROT_SET_Q10(55.0) }, /* OF 动作频率 Hz */ + { 0x0000, 0u, PROT_SET_Q10(1000.0), 0, PROT_SET_Q10(60000) }, /* OF 延时 ms */ + { 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* UF 投退(本地) */ + { 0x0000, 0u, PROT_SET_Q10(49.0), PROT_SET_Q10(45.0), PROT_SET_Q10(50.0) }, /* UF 动作频率 Hz */ + { 0x0000, 0u, PROT_SET_Q10(1000.0), 0, PROT_SET_Q10(60000) }, /* UF 延时 ms */ + { 0x0000, 0u, PROT_SET_Q10(0.99), PROT_SET_Q10(0.9), PROT_SET_Q10(1.0) }, /* 过频返回系数 */ + { 0x0000, 0u, PROT_SET_Q10(1.01), PROT_SET_Q10(1.0), PROT_SET_Q10(1.1) }, /* 低频返回系数 */ }; static uint32_t s_val[2][PS_ID_COUNT]; /* 双缓冲值区(uint32 承载 int32,校验和按字覆盖) @@ -55,7 +66,7 @@ static uint32_t s_val[2][PS_ID_COUNT]; /* 双缓冲值区(uint32 承载 int3 static volatile uint8_t s_act; /* 活跃区下标(0/1);交换 = 单字存储 */ static uint32_t s_sum; /* 封存的正码 */ static uint32_t s_sumn; /* 封存的反码 */ -static uint32_t s_bad_mask; /* load_raw/parse 越界位图 */ +static uint64_t s_bad_mask; /* load_raw/parse 越界位图(B8:40 项 → 64 位) */ static uint8_t s_locked; /* 定值非法 → 闭锁(L5-1) */ static uint8_t s_dirty; /* 未落盘定值改动(B7 持久化脏标志) */ @@ -105,7 +116,7 @@ void prot_set_init(void) { * 入口参数:无 * 返 回 值:bit i = 第 i 项越界(load_raw 失败后有效) */ -uint32_t prot_set_bad_mask(void) { +uint64_t prot_set_bad_mask(void) { return s_bad_mask; } @@ -117,7 +128,7 @@ uint32_t prot_set_bad_mask(void) { * 越界项经 prot_set_bad_mask() 指认。 */ int prot_set_load_raw(const int32_t *vals) { - uint32_t mask = 0u; + uint64_t mask = 0u; uint32_t cur_act; uint32_t *shadow; uint32_t i; @@ -127,7 +138,7 @@ int prot_set_load_raw(const int32_t *vals) { } for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { if ((vals[i] < g_prot_set_meta[i].min) || (vals[i] > g_prot_set_meta[i].max)) { - mask |= (1uL << i); + mask |= (1uLL << i); } } s_bad_mask = mask; @@ -390,7 +401,7 @@ int prot_set_store_parse(const uint8_t *p_buf, uint32_t len) { uint32_t cur_act; uint32_t *shadow; uint32_t sum = 0u; - uint32_t mask = 0u; + uint64_t mask = 0u; uint32_t i; if ((p_buf == 0) || (len != PROT_SET_BLOB_SZ)) { @@ -422,7 +433,7 @@ int prot_set_store_parse(const uint8_t *p_buf, uint32_t len) { for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { if (((int32_t)shadow[i] < g_prot_set_meta[i].min) || ((int32_t)shadow[i] > g_prot_set_meta[i].max)) { - mask |= (1uL << i); + mask |= (1uLL << i); } } s_bad_mask = mask; diff --git a/App/algo/prot_set.h b/App/algo/prot_set.h index f26c54ff..122a6282 100644 --- a/App/algo/prot_set.h +++ b/App/algo/prot_set.h @@ -70,6 +70,20 @@ typedef enum { PS_CT_SEC, /* 0x8826 CT 二次额定(A) */ /* --- 全局软压板(B7):104 YK12 两段式投退;0 = 保护退出服务 --- */ PS_GLOBAL_EN, /* 本地项(无通讯地址,经 YK12 写、C_RD 不可读) */ + /* --- 过频 / 低频(L3-5,B8):投退/动作频率/延时 + 各自返回系数 --- + * ⚠ 通讯地址:参考 g_tCommSetParaTab 的保护定值段(0x8820~0x8882) + * 含"低压/低频"类目(规划 §5.1),但 OF/UF 的**逐项地址不在本仓库** + * (参考 Comm 源文件未随仓库提供)→ 按"找不到对应即本地项"规则全部 + * comm_addr=0 登记本地项(104 C_SE 不可寻址),待对照参考表后补。 + * ⚠ 默认值/范围为**拟定**(同全表口径,规划 §5.4 落实进度注)。 */ + PS_OF_EN, /* 过频投退(本地) */ + PS_OF_HZ, /* 过频动作频率(Hz) */ + PS_OF_DLY, /* 过频延时(ms) */ + PS_UF_EN, /* 低频投退(本地) */ + PS_UF_HZ, /* 低频动作频率(Hz) */ + PS_UF_DLY, /* 低频延时(ms) */ + PS_RET_OF, /* 过频返回系数(返回门槛 = 定值 × 系数,<1;本地) */ + PS_RET_UF, /* 低频返回系数(返回门槛 = 定值 × 系数,>1;本地) */ PS_ID_COUNT } prot_set_id_t; @@ -97,8 +111,9 @@ void prot_set_init(void); */ int prot_set_load_raw(const int32_t *vals); -/* 越界位图(bit i = 第 i 项越界;load_raw 失败后有效) */ -uint32_t prot_set_bad_mask(void); +/* 越界位图(bit i = 第 i 项越界;load_raw 失败后有效。B8:项数 32→40, + * 位图随项数扩为 64 位——消费方仅主机单测,板端闭锁走 prot_set_locked()) */ +uint64_t prot_set_bad_mask(void); /* * 函数功能:单项写入(范围校验;**不自动重封校验和**——批量改完必须 @@ -137,10 +152,12 @@ void prot_set_dirty_mark(void); /* 写盘失败回置(等下轮重试) /* ---------------- 持久化 blob(B7;纯逻辑,主机可单测) ---------------- * 格式(小端):magic 'PSET'(4B) + version u16 + count u16 * + values u32×count + sum32 u32 + ~sum32 u32 - * 版本 1;count 必须等于 PS_ID_COUNT(定值表增项 → 旧 blob 整体作废, - * 编号不回收但格式换代,宁可用出厂默认也不半装载)。 */ + * 版本 2(B8:定值表 32→40 项,OF/UF 尾追加);count 必须等于 PS_ID_COUNT + * (定值表增项 → 旧 blob 整体作废,编号不回收但格式换代,宁可用出厂默认 + * 也不半装载)。ver 1 旧档在 parse 即被版本闸拒绝 → 出厂默认 + 日志 + * (措辞 = "version change",非 CORRUPT,见 prot_set_store/default_task)。 */ #define PROT_SET_BLOB_MAGIC 0x50534554uL /* 字节序 P,S,E,T */ -#define PROT_SET_BLOB_VER 1u +#define PROT_SET_BLOB_VER 2u #define PROT_SET_BLOB_HDR_SZ 8u /* magic(4)+ver(2)+cnt(2) */ #define PROT_SET_BLOB_SZ (PROT_SET_BLOB_HDR_SZ + 4u * (uint32_t)PS_ID_COUNT + 8u) diff --git a/App/app/app_main.c b/App/app/app_main.c index 416a6d46..3c992002 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -25,6 +25,7 @@ #include "tpafe5160.h" #include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */ #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" @@ -242,6 +243,12 @@ int app_main_init(void) { (void)phasor_init(); DBG_INFO("phasor layer init: OK (inst=%u)", (unsigned)phasor_inst_count()); + /* 4c-2) 测频层(L2 派生量,B8)初始化:清平均窗/相位基准/有效标志。 + * 纯算法(输入 = phasor 使用区),消费在 adcTask(freq_run 每拍) + * 与 protectTask(OF/UF 判据经 prot_elm_run 取数)。 */ + (void)freq_init(); + DBG_INFO("freq layer init: OK"); + /* 4d) 保护定值 + 元件层初始化(B5):出厂默认定值封存校验和 + 元件状态 * 复位并解析相量索引(须在 phasor_init 之后)。内核前完成 → iec104 * 定值读/protectTask 首拍前定值必已装载。判据消费在 protectTask。 */ diff --git a/App/cfg/prot_set_store.c b/App/cfg/prot_set_store.c index 4e0f3b85..df3691fe 100644 --- a/App/cfg/prot_set_store.c +++ b/App/cfg/prot_set_store.c @@ -13,21 +13,50 @@ #include +#define DBG_TAG "[CFG]" +#include "dbg_log.h" + #include "cfg_file_store.h" #include "prot_set.h" #define PROT_SET_STORE_PATH "/prot_set.bin" -/* blob 收发缓冲(静态:defaultTask 栈深已是全项目最深链,勿再压 144 B 局部) */ +/* blob 收发缓冲(静态:defaultTask 栈深已是全项目最深链,勿再压局部 blob; + * B8 定值表 32→40 项 → blob 144→176 B,随 PROT_SET_BLOB_SZ 自动适配) */ static uint8_t s_blob[PROT_SET_BLOB_SZ]; +/* + * 函数功能:从小端缓冲读 16 位值(仅头部判别用,与 prot_set.c 内部同字节序) + * 入口参数:p - 读位置 + * 返 回 值:值 uint16_t + */ +static uint16_t s_ld16(const uint8_t *p) { + return (uint16_t)((uint32_t)p[0] | ((uint32_t)p[1] << 8)); +} + +/* + * 函数功能:从小端缓冲读 32 位值 + * 入口参数:p - 读位置 + * 返 回 值:值 uint32_t + */ +static uint32_t s_ld32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) + | ((uint32_t)p[3] << 24); +} + /* * 函数功能:开机装载定值 blob * 入口参数:无 - * 返 回 值:0 已装载 / 1 文件缺失或读失败(保持默认)/ -1 损坏被拒 + * 返 回 值:0 已装载 / 1 文件缺失或读失败(保持默认)/ + * 2 版本或项数换代(旧档被拒,保持默认;本函数已落 INFO 日志)/ + * -1 损坏被拒 * 函数说明:读失败与"文件不存在"同归 1——启动期卷锁无竞争(本任务独占 * bring-up),try-lock 失败即真 I/O 故障,此时保持出厂默认并让 * 调用方按"无档"处理是安全侧(定值仍封存自洽,L5-1 不误锁)。 + * **换代与损坏分开**(B8):magic 完好但 ver/cnt 与当前固件不符 = + * 定值表升级(如 B8 32→40 项),是**预期升级路径**不是损坏 → + * 归 2 并打 INFO("version change"措辞),调用方不得按 CORRUPT + * 报 ERROR/自检留痕;magic 或校验对被破坏才是 -1。 */ int prot_set_store_load(void) { size_t nread = 0u; @@ -35,10 +64,19 @@ int prot_set_store_load(void) { if (cfg_store_read(PROT_SET_STORE_PATH, s_blob, sizeof(s_blob), &nread) != 0) { return 1; } - if (prot_set_store_parse(s_blob, (uint32_t)nread) != 0) { - return -1; + if (prot_set_store_parse(s_blob, (uint32_t)nread) == 0) { + return 0; } - return 0; + /* 拒绝原因分流:头部 magic 完好 + 版本/项数与当前固件不符 = 换代 */ + if ((nread >= (size_t)PROT_SET_BLOB_HDR_SZ) + && (s_ld32(s_blob) == (uint32_t)PROT_SET_BLOB_MAGIC) + && ((s_ld16(s_blob + 4u) != (uint16_t)PROT_SET_BLOB_VER) + || (s_ld16(s_blob + 6u) != (uint16_t)PS_ID_COUNT))) { + DBG_INFO("prot set blob version change (ver=%u cnt=%u), defaults in use", + (unsigned)s_ld16(s_blob + 4u), (unsigned)s_ld16(s_blob + 6u)); + return 2; + } + return -1; } /* diff --git a/App/log/README.md b/App/log/README.md index a456f949..80ea9e78 100644 --- a/App/log/README.md +++ b/App/log/README.md @@ -10,6 +10,8 @@ | `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` | | `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 依赖,主机单测覆盖 | +| `soe_store.c/h` | **SOE 落盘胶水**(B9):`/log/soe.log`,protectTask 投递(零文件系统)→ defaultTask 排空落盘 | ## 关键机制 @@ -27,6 +29,27 @@ - 多任务的生产者 → **单写者消费者**,消除任务间日志行交错 - 设计详见 `docs/子系统设计/控制台单写者日志设计说明.md` +### soe(SOE 事件顺序记录,B9) + +- **记录 = 64 B 定长二进制**(`soe_rec.h` 布局),落 `/log/soe.log`; + 与 run.log 分文件——run.log 是文本记录、有 UI/主机读取方,二进制 SOE + 混入会破坏读取器;分文件保持"定长 64B + 条数=fsize/64 + 满删重建 + + FTP 下载 + 主机解码"同一套先例 +- **生产者 = protectTask**(元件动作/返回、软压板投退、采样闭锁/恢复六类 + 边沿):`soe_store_post()` 纯 RAM(取时标 + 组记录 + 队列/待发环), + **零 NAND I/O**;⚠️ **post 内禁 `DBG_*`**——会把 protectTask 静态最坏链 + 垫高一帧(实证 872→1192 B FAIL),丢弃留痕由 drain(defaultTask)差值上报 +- **唯一写者 = defaultTask**:挂 `syslog_drain()` 尾部(继承其卷就绪门), + 50 ms 节拍 → SOE 落盘延迟 ≤50 ms;写盘纪律全套照 syslog(卷锁 try / + `FA_WRITE|FA_OPEN_APPEND` / 校验字节数 / 满删重建 / 失败留痕去重), + **绝不把名字/参数类错误码归入卷损坏触发集** +- **时标口径**:sys_clock 秒域时基 → CP56 ms 域 = 秒×1000(亚秒恒 0, + 不造假);墙钟未建 → IV 位如实置位;seq 仅上电生命周期内单调 + (容量重建不重置,仅复位重置——主机解码按 boot 段分段断言) +- 主机侧:`test/host/soe/decode_soe.py` 解码 + 断言(校验和/seq 单调/ + CP56↔epoch UTC 交叉);⚠️ 交叉校验用 `calendar.timegm`,不是本地时区的 + `dt.timestamp()`(+08 机差 28800 s,首跑实证坑) + ## 相关文档 - `docs/子系统设计/控制台单写者日志设计说明.md` diff --git a/App/log/soe_rec.c b/App/log/soe_rec.c new file mode 100644 index 00000000..687bfa1d --- /dev/null +++ b/App/log/soe_rec.c @@ -0,0 +1,160 @@ +/* + * 模块名称:SOE Record — 事件顺序记录编解码(实现) + * 模块功能:实现 soe_rec.h——64B 定长记录的构建(epoch→CP56Time2a,Hinnant + * civil 日历算法,与 iec104_asdu.c 同一算法族但本模块零依赖)与 + * 解析(版本 + sum8 校验和硬门槛)。 + * 线程约定:全部纯函数,无状态 → 任意上下文可调(生产者 = protectTask, + * 落盘/读取 = defaultTask / 主机工具)。 + * 创建日期:2026-10-12(B9) + */ +#include "soe_rec.h" + +#include + +/* 2000-01-01 00:00:00 UTC 的 epoch 秒(CP56 年字段下限,钳位用) */ +#define EPOCH_2000_S 946684800uL +/* 2127-12-31 23:59:59 UTC(年字段 7bit 上限 2000+127,钳位用; + * = 2128-01-01(4986057600) - 1,含 2000..2124 共 32 个闰日) */ +#define EPOCH_2127_END_S 4986057599uLL + +/* + * 函数功能:epoch 秒 → 小端 32 位写入 + */ +static void s_soe_put_u32(uint8_t *buf, uint32_t v) { + buf[0] = (uint8_t)(v & 0xFFu); + buf[1] = (uint8_t)((v >> 8) & 0xFFu); + buf[2] = (uint8_t)((v >> 16) & 0xFFu); + buf[3] = (uint8_t)((v >> 24) & 0xFFu); +} + +/* + * 函数功能:小端 32 位读取 + */ +static uint32_t s_soe_get_u32(const uint8_t *buf) { + return (uint32_t)buf[0] | ((uint32_t)buf[1] << 8) + | ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 24); +} + +/* + * 函数功能:距 1970-01-01 天数 → 公历(Hinnant civil_from_days,与 + * iec104_asdu.c 同一算法;纯整数,ARMCC/MSVC 双端可移植) + * 入口参数:z_in = 天数;y/m/d = 输出 + */ +static void s_soe_civil_from_days(uint32_t z_in, uint32_t *y, uint32_t *m, uint32_t *d) { + uint32_t z = z_in + 719468u; + uint32_t era = z / 146097u; + uint32_t doe = z - (era * 146097u); + uint32_t yoe = (doe - (doe / 1460u) + (doe / 36524u) - (doe / 146096u)) / 365u; + uint32_t yy = yoe + (era * 400u); + uint32_t doy = doe - ((365u * yoe) + (yoe / 4u) - (yoe / 100u)); + uint32_t mp = ((5u * doy) + 2u) / 153u; + uint32_t mon = mp + ((mp < 10u) ? 3u : 0u); + + *d = doy - (((153u * mp) + 2u) / 5u) + 1u; + if (mp >= 10u) { + mon = mp - 9u; + } + *m = mon; + *y = yy + ((mon <= 2u) ? 1u : 0u); +} + +/* + * 函数功能:epoch 秒 → CP56Time2a(7B,UTC 分解) + * 入口参数:epoch_s - Unix 秒;buf - 目标 7B;iv - 1 = 置 IV 位(时间无效) + * 返回值:无 + * 函数说明:钳位 [2000-01-01, 2127-12-31](CP56 年字段 7bit 域;epoch_s 为 + * uint32 → 上界钳位是防御性死分支,2106 后域扩展时生效); + * ms 域 = 秒×1000(秒域时基如实编码,亚秒恒 0,不造假)。 + */ +static void s_soe_epoch_to_cp56(uint32_t epoch_s, uint8_t *buf, uint8_t iv) { + uint64_t clamped; + uint32_t days, rem, y, m, d, ms_min; + + clamped = epoch_s; + if (clamped < (uint64_t)EPOCH_2000_S) { + clamped = (uint64_t)EPOCH_2000_S; + } + if (clamped > EPOCH_2127_END_S) { + clamped = EPOCH_2127_END_S; + } + days = (uint32_t)(clamped / 86400uLL); + rem = (uint32_t)(clamped % 86400uLL); + s_soe_civil_from_days(days, &y, &m, &d); + if (y > 2127u) { /* 钳位上界的日历化(防 epoch 上界跨日) */ + y = 2127u; + m = 12u; + d = 31u; + rem = 86399u; + } + ms_min = (rem % 60u) * 1000u; /* 秒域时基:秒如实入 ms 域(×1000), + * 亚秒部分恒 0——不造假(B9 口径) */ + buf[0] = (uint8_t)(ms_min & 0xFFu); + buf[1] = (uint8_t)(ms_min >> 8); + buf[2] = (uint8_t)(((rem / 60u) % 60u) & 0x3Fu); /* min;bit7 IV 下方置 */ + buf[3] = (uint8_t)((rem / 3600u) % 24u); /* hour */ + buf[4] = (uint8_t)(d & 0x1Fu); /* day */ + buf[5] = (uint8_t)(m & 0x0Fu); /* month */ + buf[6] = (uint8_t)((y - 2000u) & 0x7Fu); /* year */ + if (iv != 0u) { + buf[2] |= 0x80u; /* IV:时间无效 */ + } +} + +/* + * 函数功能:sum8([0..62]) 校验和 + */ +static uint8_t s_soe_sum8(const uint8_t *rec) { + uint8_t s = 0u; + uint32_t i; + + for (i = 0u; i < SOE_OFF_SUM; i++) { + s = (uint8_t)(s + rec[i]); + } + return s; +} + +/* + * 函数功能:构建一条 64B SOE 记录(见 soe_rec.h 声明注释) + */ +void soe_rec_build(uint8_t *rec, uint8_t type, uint8_t elm_id, uint8_t flags, + uint32_t seq, uint32_t epoch_s, uint32_t value) { + if (rec == NULL) { + return; + } + (void)memset(rec, 0, SOE_REC_SIZE); + rec[SOE_OFF_VER] = (uint8_t)SOE_REC_VER; + rec[SOE_OFF_TYPE] = type; + rec[SOE_OFF_ELM] = elm_id; + rec[SOE_OFF_FLAGS] = flags; + s_soe_put_u32(rec + SOE_OFF_SEQ, seq); + s_soe_epoch_to_cp56(epoch_s, rec + SOE_OFF_CP56, + ((flags & SOE_FLG_IV) != 0u) ? 1u : 0u); + s_soe_put_u32(rec + SOE_OFF_EPOCH, + ((flags & SOE_FLG_IV) != 0u) ? 0u : epoch_s); + s_soe_put_u32(rec + SOE_OFF_VALUE, value); + rec[SOE_OFF_SUM] = s_soe_sum8(rec); +} + +/* + * 函数功能:解析一条 64B SOE 记录(见 soe_rec.h 声明注释) + */ +int soe_rec_parse(const uint8_t *rec, soe_rec_t *out) { + if ((rec == NULL) || (out == NULL)) { + return -1; + } + if (rec[SOE_OFF_VER] != (uint8_t)SOE_REC_VER) { + return -2; + } + if (rec[SOE_OFF_SUM] != s_soe_sum8(rec)) { + return -3; + } + out->ver = rec[SOE_OFF_VER]; + out->type = rec[SOE_OFF_TYPE]; + out->elm_id = rec[SOE_OFF_ELM]; + out->flags = rec[SOE_OFF_FLAGS]; + out->seq = s_soe_get_u32(rec + SOE_OFF_SEQ); + (void)memcpy(out->cp56, rec + SOE_OFF_CP56, sizeof(out->cp56)); + out->epoch_s = s_soe_get_u32(rec + SOE_OFF_EPOCH); + out->value = s_soe_get_u32(rec + SOE_OFF_VALUE); + return 0; +} diff --git a/App/log/soe_rec.h b/App/log/soe_rec.h new file mode 100644 index 00000000..430de0c8 --- /dev/null +++ b/App/log/soe_rec.h @@ -0,0 +1,119 @@ +/* + * 模块名称:SOE Record — 事件顺序记录编解码(纯逻辑,双端可测) + * 模块功能:定长 64B SOE(Sequence-of-Events)记录的构建与解析: + * CP56Time2a 时标 + 记录类型 + 元件号 + 单调序号 + 值 + 校验和。 + * **零 I/O、零 OS 依赖**(仅 stdint/string)→ 主机侧 L0.5 离线单测 + * (落实登记簿 A-4 约束 1"协议/记录与硬件 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)、容量满删除重建)。 + * 独立于 run.log 的理由:run.log 是文本记录(时间戳+epoch+UTF-8 描述, + * syslog_get_record/UI 按文本读),SOE 是二进制结构化记录(时标/类型/ + * 序号/值),混入同一文件会破坏现有文本读者。 + * 记录布局(64B,多字节字段一律小端): + * [0] 版本 SOE_REC_VER + * [1] 类型 soe_type_t + * [2] 元件号(prot_elm_id_t;SOE_ELM_NONE=0xFF = 非元件事件) + * [3] 标志位(bit7 IV = 记录时时钟无效,CP56 IV 位同步置位) + * [4..7] 序号 seq(uint32 小端;上电内单调,跨复位重启计数——见 soe_store.h) + * [8..14] CP56Time2a(7B,IEC 60870-5-4 §5.1.6;秒域时基 → ms 字段恒 0) + * [15..18] epoch 秒(uint32 小端;IV 时置 0,主机解码交叉校验用) + * [19..22] 值 value(uint32 小端;语义随类型,见 soe_type_t 注释) + * [23..62] 保留(恒 0) + * [63] 校验和 = sum8([0..62])(撕裂/半写检测,解析侧拒绝不匹配) + * 创建日期:2026-10-12(B9) + */ +#ifndef SOE_REC_H +#define SOE_REC_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 记录尺寸与版本(定长;改布局必须升 SOE_REC_VER) */ +#define SOE_REC_SIZE 64u +#define SOE_REC_VER 1u + +/* 字段偏移(解析/主机解码工具共用口径) */ +#define SOE_OFF_VER 0u +#define SOE_OFF_TYPE 1u +#define SOE_OFF_ELM 2u +#define SOE_OFF_FLAGS 3u +#define SOE_OFF_SEQ 4u /* 4B */ +#define SOE_OFF_CP56 8u /* 7B */ +#define SOE_OFF_EPOCH 15u /* 4B */ +#define SOE_OFF_VALUE 19u /* 4B */ +#define SOE_OFF_SUM 63u + +/* 标志位 */ +#define SOE_FLG_IV 0x80u /* bit7:记录时时钟无效(CP56 IV 位同步置位) */ + +/* 非元件事件的元件号 */ +#define SOE_ELM_NONE 0xFFu + +/* + * 记录类型(值语义随类型): + * 1 元件动作 value = 元件状态打包(bit0 asserted / bit1 picked / bit2 enabled) + * 2 元件返回 value = 同上(返回瞬间的旧状态快照) + * 3 软压板退出 value = 0 + * 4 软压板投入 value = 1 + * 5 采样闭锁 value = adc_health 闭锁位图(触发时的 hf & mask) + * 6 采样恢复 value = 0 + */ +typedef enum { + SOE_T_ELM_ASSERT = 1u, + SOE_T_ELM_RETURN, + SOE_T_PLATE_OUT, + SOE_T_PLATE_IN, + SOE_T_SAMPLE_BLOCK, + SOE_T_SAMPLE_RESUME, + SOE_T_COUNT /* 哨兵值,非有效类型 */ +} soe_type_t; + +/* 解析产物(字段原样拷贝;CP56 不在此层换算,主机工具解码) */ +typedef struct { + uint8_t ver; + uint8_t type; + uint8_t elm_id; + uint8_t flags; + uint32_t seq; + uint8_t cp56[7]; + uint32_t epoch_s; + uint32_t value; +} soe_rec_t; + +/* + * 函数功能:构建一条 64B SOE 记录(纯计算,无 I/O;任意上下文可调) + * 入口参数:rec - 输出缓冲(SOE_REC_SIZE 字节,不可为 NULL) uint8_t * + * type - 记录类型 soe_type_t(越界按 SOE_T_COUNT 处理为保留 0) + * elm_id - 元件号(非元件事件传 SOE_ELM_NONE) uint8_t + * flags - 标志位(SOE_FLG_IV = 时钟无效) uint8_t + * seq - 单调序号 uint32_t + * epoch_s - 墙钟 epoch 秒(IV 时传 0;同时 flags 须带 SOE_FLG_IV) + * value - 类型相关值 uint32_t + * 返回值:无 + * 函数说明:epoch_s → CP56Time2a(UTC 分解,Hinnant civil 日历算法,与 + * iec104_asdu.c 同一算法族;钳位 [2000-01-01, 2127-12-31], + * ms 字段恒 0——sys_clock 秒域时基,如实登记)。 + * flags 带 IV 时 CP56 byte2 bit7 置位(标准"时间无效"语义)。 + */ +void soe_rec_build(uint8_t *rec, uint8_t type, uint8_t elm_id, uint8_t flags, + uint32_t seq, uint32_t epoch_s, uint32_t value); + +/* + * 函数功能:解析一条 64B SOE 记录(校验版本 + 校验和) + * 入口参数:rec - 原始记录(SOE_REC_SIZE 字节,不可为 NULL) const uint8_t * + * out - 解析输出(不可为 NULL) soe_rec_t * + * 返回值:0 成功 / -1 参数错 / -2 版本不符 / -3 校验和不符(撕裂/半写) int + * 函数说明:只读,不改动 rec。类型/标志不做强校验(前向兼容:新版本类型 + * 旧解析器可读),版本与校验和是硬门槛。 + */ +int soe_rec_parse(const uint8_t *rec, soe_rec_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* SOE_REC_H */ diff --git a/App/log/soe_store.c b/App/log/soe_store.c new file mode 100644 index 00000000..719b0b64 --- /dev/null +++ b/App/log/soe_store.c @@ -0,0 +1,210 @@ +/* + * 模块名称:SOE Store — 事件顺序记录落盘(实现) + * 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build + + * SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部) + * 排空落 /log/soe.log(64B 定长,容量满删除重建)。 + * 创建日期:2026-10-12(B9) + */ + +/* 头文件包含区 */ +#include + +#define DBG_TAG "[SOE]" +#include "dbg_log.h" + +#include "soe_store.h" +#include "soe_rec.h" +#include "ff.h" +#include "cmsis_os.h" +#include "sys_clock.h" +#include "vol_lock.h" + +/* 静态变量定义区 */ + +#define SOE_PATH "/log/soe.log" +#define SOE_QUEUE_MAX 8u +#define SOE_RING_MAX 8u /* 待发环容量(须为 2 的幂) */ +#define SOE_RING_MASK (SOE_RING_MAX - 1u) + +/* 待发环(SPSC 无锁):队列惰性创建(defaultTask 首次 drain)之前的窗口 + * 兜底。生产者 = protectTask(只写 head 侧),消费者 = defaultTask(只写 + * tail 侧)——单核 Cortex-M + volatile 下按 console_log 环形缓冲同一模型, + * 免锁安全。条目 = 完整 64B 记录(post 侧已组好,drain 侧原样落盘)。 */ +static uint8_t s_ring[SOE_RING_MAX][SOE_REC_SIZE]; +static volatile uint8_t s_ring_head; /* 生产者写位 */ +static volatile uint8_t s_ring_tail; /* 消费者读位 */ + +static osMessageQueueId_t s_queue; /* 运行期队列(drain 首次调用惰性创建) */ +static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" }; + +static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */ +static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */ +static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */ +static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */ + +/* 函数定义区 */ + +/* + * 函数功能:投递一条 SOE 记录(见 soe_store.h 声明注释) + * 函数说明:时标 = sys_clock_get()(纯 RAM,无 I/O);UINT32_MAX(墙钟未建) + * → flags 置 IV、epoch 字段 0、CP56 IV 位如实置位(B9 口径: + * 不造假时标)。 + * ⚠ 本函数**不得调用 DBG_***:protectTask 静态最坏链 = 经 + * s_log_active_edge 的 console 尾链(872 B,B5 实测),post 内任何 + * 日志调用点都会把该尾链再垫高一帧(stack_report 实证 +320 B → + * FAIL)。丢弃只累加计数,留痕由 drain(defaultTask)差值上报。 + */ +void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) { + uint8_t rec[SOE_REC_SIZE]; /* 64B:protectTask 栈(链对比见 B9) */ + uint32_t epoch = sys_clock_get(); + uint8_t flags = 0u; + uint32_t seq; + + if (epoch == UINT32_MAX) { + epoch = 0u; + flags = SOE_FLG_IV; + } + seq = s_seq; + s_seq = seq + 1u; + soe_rec_build(rec, type, elm_id, flags, seq, epoch, value); + + if ((s_queue != NULL) && (osKernelGetState() == osKernelRunning)) { + if (osMessageQueuePut(s_queue, rec, 0u, 0u) == osOK) { + return; + } + } else { + uint8_t head; /* 队列未建窗口(内核启动 → defaultTask + * 首次 drain):SPSC 待发环 */ + head = s_ring_head; + if ((uint8_t)(head - s_ring_tail) < SOE_RING_MAX) { + (void)memcpy(s_ring[head & SOE_RING_MASK], rec, SOE_REC_SIZE); + s_ring_head = (uint8_t)(head + 1u); + return; + } + } + /* 队列满 / 环满:丢弃 + 计数(留痕在 drain 侧,见函数头警告) */ + if (s_drop_cnt < UINT32_MAX) { + s_drop_cnt++; + } +} + +/* + * 函数功能:追加一条 64B SOE 记录到 /log/soe.log + * 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t * + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int + * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载、/log 已建 + * 函数说明:照 sys_log_mcu.c s_append 的台架实证纪律—— + * ① vol_lock try(本任务 = 看门狗喂狗点,绝不阻塞等锁); + * ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE, + * 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证); + * ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码); + * ④ 容量满 unlink 重建;打开/写入结构性错误 → unlink 重试一次 + * (L1 文件级自愈,同 run.log)。 + * ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、 + * 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃 + * 计数,绝不升级成卷操作。 + */ +static int s_soe_append(const uint8_t *rec) { + static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈; + * 全程在 vol_lock 区间内,跨任务由卷锁串行化 */ + FRESULT fr; + UINT bw = 0u; + FILINFO finfo; + uint32_t cnt; + int rc = 0; + + if (vol_lock_wait(0u) == 0u) { + return -2; + } + /* 容量护栏:满 → 删除重建(seq 不重置:RAM 计数继续,主机按 boot 段看) */ + cnt = (f_stat(SOE_PATH, &finfo) == FR_OK) + ? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u; + if (cnt >= SOE_STORE_MAX) { + (void)f_unlink(SOE_PATH); + DBG_INFO("cap full, recreate " SOE_PATH); + } + + fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + 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 返回值刻意不检查——失败也只是重试失败 → 留痕,不升级) */ + (void)f_unlink(SOE_PATH); + bw = 0u; + fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + fr = f_write(&f, rec, SOE_REC_SIZE, &bw); + (void)f_close(&f); + } + if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { + rc = -1; + } + } + } else { + rc = -1; + } + if (rc == 0) { + s_fault_reported = 0u; + } else if (s_fault_reported == 0u) { + s_fault_reported = 1u; + DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr); + } + vol_lock_release(); + return rc; +} + +/* + * 函数功能:排空 SOE 待发(待发环 + 队列,FIFO)并落盘 + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 调用(syslog_drain 尾部,卷就绪门之后); + * 首次调用惰性创建消息队列(内核对象创建在 osKernelStart 之后) + */ +void soe_store_drain(void) { + uint8_t rec[SOE_REC_SIZE]; + + /* 丢弃留痕(defaultTask 上下文 = 日志安全区):post 侧禁 DBG(protect 链), + * 此处按差值上报一次,不刷屏 */ + if (s_drop_cnt != s_drop_logged) { + s_drop_logged = s_drop_cnt; + DBG_ERROR("soe dropped total=%lu (queue/ring full)", + (unsigned long)s_drop_cnt); + } + if (s_queue == NULL) { + s_queue = osMessageQueueNew(SOE_QUEUE_MAX, SOE_REC_SIZE, &s_queue_attr); + if (s_queue == NULL) { + DBG_ERROR("queue create fail"); /* 条目滞留待发环,下轮再试 */ + } + } + /* 待发环优先(保时序:环内条目早于队列条目) */ + while (s_ring_tail != s_ring_head) { + uint8_t tail = s_ring_tail; + (void)memcpy(rec, s_ring[tail & SOE_RING_MASK], SOE_REC_SIZE); + if (s_soe_append(rec) != 0) { + return; /* 写失败/卷忙:不推进 tail,下轮重试 */ + } + s_ring_tail = (uint8_t)(tail + 1u); + } + if (s_queue == NULL) { + return; + } + for (;;) { + if (osMessageQueueGet(s_queue, rec, 0u, 0u) != osOK) { + break; /* 队列空 */ + } + if (s_soe_append(rec) == -2) { + break; /* 卷被占用:本轮顺延(条目已出队,尽力而为) */ + } + /* rc==-1 写失败:s_soe_append 已留痕;继续排空(SOE 尽力而为语义, + * 单条坏记录不卡死后续事件) */ + } +} + +/* + * 函数功能:累计丢弃计数(见 soe_store.h 声明注释) + */ +uint32_t soe_store_dropped(void) { + return s_drop_cnt; +} diff --git a/App/log/soe_store.h b/App/log/soe_store.h new file mode 100644 index 00000000..48f10fdd --- /dev/null +++ b/App/log/soe_store.h @@ -0,0 +1,66 @@ +/* + * 模块名称:SOE Store — 事件顺序记录落盘(MCU 胶水,单写者) + * 模块功能:protectTask 动作/复归边沿 → soe_store_post() 投递(纯 RAM: + * sys_clock 取时 + soe_rec_build + 消息队列,**零文件系统**); + * defaultTask(/log 唯一写者,经 syslog_drain 尾部挂接)消费落盘 + * /log/soe.log(定长 64B,容量满删除重建,照 run.log 先例)。 + * 记录格式/类型:见 soe_rec.h(纯编解码在 soe_rec.c,主机单测覆盖)。 + * 线程约定:post = protectTask(唯一生产者;内核启动后任意任务上下文亦可, + * 队列未建时走静态待发数组,照 syslog_post 先例); + * drain = 仅 defaultTask(syslog_drain 尾部调用,继承其卷就绪门)。 + * seq 口径:RAM 计数器,上电内单调;容量满 unlink 重建**不重置**(计数继续), + * 仅设备复位后重置(主机解码断言单调性须按"同一 boot 段"分段看)。 + * 创建日期:2026-10-12(B9) + */ +#ifndef SOE_STORE_H +#define SOE_STORE_H + +#include + +#include "soe_rec.h" /* 记录类型/标志/SOE_ELM_NONE 常量(post 入参口径) */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 容量上限(条数;满时删除重建,照 SYSLOG_RUN_MAX 先例):1024 × 64B = 64KB */ +#define SOE_STORE_MAX 1024u + +/* + * 函数功能:投递一条 SOE 记录(构建 + 入队;非阻塞,队列满则丢弃并计数) + * 入口参数:type - 记录类型 soe_type_t uint8_t + * elm_id - 元件号(非元件事件传 SOE_ELM_NONE) uint8_t + * value - 类型相关值(见 soe_rec.h 类型表) uint32_t + * 返回值:无 + * 限定条件:内核启动后调用(protectTask 上下文);**绝不触碰文件系统**—— + * 取时标(sys_clock 纯 RAM)+ 组记录 + osMessageQueuePut(0 超时)。 + * 队列未创建(defaultTask 首次 drain 前)走静态待发数组;数组满 + * = 丢弃 + 计数 + 首次留痕日志(绝不阻塞、绝不崩)。 + */ +void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value); + +/* + * 函数功能:排空 SOE 待发(静态数组 + 队列,FIFO)并落 /log/soe.log + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask(唯一写者)调用,且由 syslog_drain 在"卷就绪"门 + * 之后挂接(继承 s_fs_ready 语义,勿单独调用——/log 目录可能未建) + * 函数说明:写路径照 sys_log_mcu.c s_append 全套实证纪律:vol_lock try(busy + * = -2 顺延)、FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 + * WRITE)、校验写回字节数(FR_OK+短写=失败)、容量满 unlink 重建。 + * unlink/名字类错误码**严禁**归入任何"卷损坏"触发集(存储架构 + * §10.1 教训)——本模块无任何重格式化路径,故障只留痕不升级。 + */ +void soe_store_drain(void); + +/* + * 函数功能:累计丢弃计数(队列满/待发数组满;可观测性) + * 返回值:丢弃条数 uint32_t + */ +uint32_t soe_store_dropped(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SOE_STORE_H */ diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 701d99bc..a5530985 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -32,6 +32,7 @@ #include "dbg_log.h" #include "sys_log.h" +#include "soe_store.h" /* B9:SOE 落盘挂本函数尾部(同写者、同卷就绪门) */ #include "ff.h" #include "cmsis_os.h" #include "sys_clock.h" @@ -307,6 +308,10 @@ void syslog_drain(void) { } } } + /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 + (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask + 只投递不写盘(动作路径零 NAND I/O,DoD 硬约束)。 */ + soe_store_drain(); } /* diff --git a/App/protocol/iec104/iec104_asdu.h b/App/protocol/iec104/iec104_asdu.h index 7f47d631..da1a6e68 100644 --- a/App/protocol/iec104/iec104_asdu.h +++ b/App/protocol/iec104/iec104_asdu.h @@ -20,6 +20,7 @@ /* ---------------- 类型标识(DL/T 634.5104,仅定义本期子集) ---------------- */ #define IEC104_T_M_SP_NA_1 1U /* 单点遥信 SIQ */ #define IEC104_T_M_DP_NA_1 3U /* 双点遥信 DIQ */ +#define IEC104_T_M_SP_TB_1 30U /* 单点遥信+CP56时标 SIQ+7B(B9 SOE 自发) */ #define IEC104_T_M_ME_NC_1 13U /* 遥测短浮点 float+QDS(§1.5 口径) */ #define IEC104_T_C_SE_NC_1 50U /* 定值写(短浮点)float+QDS */ #define IEC104_T_C_DC_NA_1 52U /* 双点遥控 QDC(82H 选 / 02H 执行) */ diff --git a/App/protocol/iec104/iec104_link.c b/App/protocol/iec104/iec104_link.c index df881608..25533a74 100644 --- a/App/protocol/iec104/iec104_link.c +++ b/App/protocol/iec104/iec104_link.c @@ -270,11 +270,18 @@ static int s_scan_step(iec104_link_t *lk, uint8_t *budget, uint8_t periodic) { /* * 函数功能:突发(COV)上送——pend 暂存逐点 SQ=0 COT=3,发成功才清位 + * 函数说明:单点遥信(idx≥4)自发用 **M_SP_TB_1**(SIQ + CP56Time2a,B9: + * 变位带时标 = SOE 上送路径);双点(idx<4,V0.1 恒 IV)保持 + * M_DP_NA_1;总召/周期扫描仍走 s_scan_step 的 M_SP_NA_1/M_DP_NA_1 + * (时标只用于自发,标准语义——防"全量换型")。 + * 时标 = cfg->epoch_ms(ctx);NULL/UINT64_MAX → CP56 IV 位如实置位。 */ static void s_spont_step(iec104_link_t *lk, uint8_t *budget) { - uint8_t body[5]; + 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))) { @@ -288,11 +295,24 @@ static void s_spont_step(iec104_link_t *lk, uint8_t *budget) { } iec104_pts_yx_read(i, &d); body[0] = d; - rc = s_send_asdu(lk, - (i < 4u) ? IEC104_T_M_DP_NA_1 - : IEC104_T_M_SP_NA_1, - 1u, IEC104_COT_SPONT, 0u, 1u, - (uint32_t)i + 1u, body, 1u, 0u); + blen = 1u; + if (i < 4u) { + type = (uint8_t)IEC104_T_M_DP_NA_1; + } else { + uint64_t ems = (lk->cfg->epoch_ms != NULL) + ? lk->cfg->epoch_ms(lk->ctx) + : UINT64_MAX; + + type = (uint8_t)IEC104_T_M_SP_TB_1; + (void)iec104_epoch_ms_to_cp56((ems == UINT64_MAX) ? 0u : ems, + &body[1]); + if (ems == UINT64_MAX) { + body[1 + 2] |= 0x80u; /* CP56 byte2 bit7 IV:时钟无效如实上送 */ + } + blen = 8u; + } + rc = s_send_asdu(lk, type, 1u, IEC104_COT_SPONT, 0u, 1u, + (uint32_t)i + 1u, body, blen, 0u); if (rc != 0) { return; /* k 窗满:pend 位保留,下次续发 */ } diff --git a/App/protocol/iec104/iec104_link.h b/App/protocol/iec104/iec104_link.h index 0405a998..c150876c 100644 --- a/App/protocol/iec104/iec104_link.h +++ b/App/protocol/iec104/iec104_link.h @@ -56,6 +56,10 @@ typedef struct { void (*send_apdu)(void *ctx, const uint8_t *apdu, uint16_t len); /* C_CS 时钟同步:epoch 毫秒;NULL 或返回 -1 → 否定确认 */ int (*clock_sync)(void *ctx, uint64_t epoch_ms); + /* 时标源(B9):当前 epoch 毫秒(M_SP_TB_1 自发用)。 + * NULL 或返回 UINT64_MAX = 时钟无效 → CP56 IV 位如实置位。 + * 纯读(sys_clock 推算),不得阻塞、不得有副作用。 */ + uint64_t (*epoch_ms)(void *ctx); } iec104_link_cfg_t; /* 链路实例(胶水层静态持有,零堆) */ diff --git a/App/task/adc_task.c b/App/task/adc_task.c index c6211766..a738d25f 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -84,6 +84,7 @@ #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 "tpafe5160.h" #include "task_watchdog.h" @@ -169,6 +170,17 @@ static void log_phasors(const char *tag) { (long)(p.fAmSQ * 10.0f)); } } + + /* 测频行(B8,L3-5 测量侧台架可见性):同样 x0.01 定点(分辨率 0.01 Hz)。 + * valid=0 = 未启动收敛或幅值门失效(浮空输入 rms≈0),此时 f 为保持值 + * (从未收敛则 0)——判读:正常带波形应 valid=1 且 f≈5000。 */ + { + float f = 0.0f; + + (void)freq_get(&f); + DBG_INFO("freq(%s): f=%ld valid=%u (x0.01)", + tag, (long)(f * 100.0f), (unsigned)freq_valid()); + } } /* @@ -314,6 +326,9 @@ void adc_task_func(void *arg) { if (all_ok != 0u) { (void)sample_ring_push_frame(frame, (uint8_t)ADC_SEM_COUNT); phasor_run(); + /* L2 派生量测频(B8):同拍紧随 phasor_run——本拍使用区已 + * 切换,freq 读的就是本拍相量(1 ms 同上下文,无竞态) */ + freq_run(); } else { s_frames_skip++; } diff --git a/App/task/iec104_serv.c b/App/task/iec104_serv.c index db6ba923..ab6d2ab7 100644 --- a/App/task/iec104_serv.c +++ b/App/task/iec104_serv.c @@ -48,9 +48,10 @@ static uint8_t s_conn_dead; /* 发送连败 → 本轮 link 调用返回 /* 前向声明(cfg 初始化引用) */ 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 }; +static const iec104_link_cfg_t s_cfg = { 1u, s_send_apdu, s_clock_sync, s_epoch_ms }; /* * 函数功能:link 层发送回调——经 net_send 串行 RPC 发出一个 APDU @@ -98,6 +99,23 @@ static int s_clock_sync(void *ctx, uint64_t epoch_ms) { return sys_clock_set_unix((uint32_t)secs); } +/* + * 函数功能:link 层时标源——当前墙钟 epoch 毫秒(B9:M_SP_TB_1 自发用) + * 入口参数:ctx 未用 + * 返回值:epoch 毫秒;墙钟未建(UINT32_MAX)→ UINT64_MAX(link 置 CP56 IV) + * 函数说明:sys_clock 秒域时基 → ms 字段恒 0(不造假亚秒,B9 口径); + * 纯 RAM 推算,无 I/O、无副作用。 + */ +static uint64_t s_epoch_ms(void *ctx) { + uint32_t s = sys_clock_get(); + + (void)ctx; + if (s == UINT32_MAX) { + return UINT64_MAX; + } + return (uint64_t)s * 1000ULL; +} + /* * 函数功能:创建并监听 2404(socket+bind+listen,抄 ui_net_sink bind_listen) * 返回值:0 成功(s_listen 写回)/ -1 失败 diff --git a/App/task/protect_task.c b/App/task/protect_task.c index 62d8c2c6..6647094e 100644 --- a/App/task/protect_task.c +++ b/App/task/protect_task.c @@ -3,7 +3,8 @@ * 模块功能:见 protect_task.h。每拍三件事: * ① L5-4 采样故障闭锁:adc_health_flags() 电平位命中 → 元件状态 * 复位(防陈旧判据)+ 本拍不 eval; - * ② prot_set_selfcheck()(L5-1 定值完整性巡检,31 项 sum32,开销 + * ② prot_set_selfcheck()(L5-1 定值完整性巡检,40 项 sum32—— + * B8 后 PS_ID_COUNT=40,开销 * 可忽略;失配 → prot_set 内部闭锁,eval 自动复位全元件); * ③ prot_elm_run()(phasor rms → 判据步进)+ 动作位图边沿日志; * ④ 全局软压板 PS_GLOBAL_EN(104 YK12 两段式投退,B7):退出 = @@ -25,6 +26,7 @@ #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 "task_watchdog.h" /* L5-4 采样故障电平闭锁位:stall(可自愈)+ backward(锁存)都须停判据。 @@ -37,9 +39,12 @@ static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用) static uint8_t s_prev_plate_out; /* 上拍全局软压板退出态(边沿日志用,B7) */ /* - * 函数功能:动作位图变化沿日志(逐变化元件打名字与状态) + * 函数功能:动作位图变化沿日志 + SOE 投递(逐变化元件) * 入口参数:active - 本拍 prot_elm_active_mask() - * 函数说明:V0.1 无出口硬件(规划 R7),动作 = 遥信 + 日志;SOE 落盘随 L4。 + * 函数说明:V0.1 无出口硬件(规划 R7),动作 = 遥信 + 日志 + SOE。 + * SOE 走 soe_store_post(纯 RAM 投递,落盘由 defaultTask 异步执行, + * 本任务零 NAND I/O——protect 静态最坏链不变,B9 DoD 硬约束)。 + * value = 元件状态打包(bit0 asserted / bit1 picked / bit2 enabled)。 */ static void s_log_active_edge(uint32_t active) { uint32_t chg = active ^ s_prev_active; @@ -56,6 +61,12 @@ static void s_log_active_edge(uint32_t active) { DBG_INFO("elm %s asserted=%u picked=%u", prot_elm_name((prot_elm_id_t)i), (unsigned)st.asserted, (unsigned)st.picked); + (void)soe_store_post( + ((active & bit) != 0u) ? (uint8_t)SOE_T_ELM_ASSERT + : (uint8_t)SOE_T_ELM_RETURN, + (uint8_t)i, + (uint32_t)st.asserted | ((uint32_t)st.picked << 1) + | ((uint32_t)st.enabled << 2)); } } s_prev_active = active; @@ -94,12 +105,17 @@ void protect_task(void *argument) { if (s_prev_hf_lock == 0u) { DBG_ERROR("sampling FAULT -> protection blocked (hf=0x%08lX)", (unsigned long)(hf & PROT_HF_LOCK_MASK)); + (void)soe_store_post((uint8_t)SOE_T_SAMPLE_BLOCK, + SOE_ELM_NONE, + hf & (uint32_t)PROT_HF_LOCK_MASK); } s_prev_hf_lock = 1u; prot_elm_init(); } else { if (s_prev_hf_lock != 0u) { DBG_INFO("sampling recovered -> protection resumed"); + (void)soe_store_post((uint8_t)SOE_T_SAMPLE_RESUME, + SOE_ELM_NONE, 0u); s_prev_hf_lock = 0u; } /* L5-1 巡检:定值区与封存校验和不符 → prot_set 闭锁(下一拍起 @@ -112,12 +128,16 @@ void protect_task(void *argument) { 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); diff --git a/Drivers/BSP/NET/net_socket.c b/Drivers/BSP/NET/net_socket.c index b6a6858a..d565980c 100644 --- a/Drivers/BSP/NET/net_socket.c +++ b/Drivers/BSP/NET/net_socket.c @@ -651,19 +651,21 @@ static void net_reconcile_tick(void) { * 失败=具体错误码),调用方 net_wait_result 拾取,对外统一 -1。 * 限定条件:必须在 netTask 上下文中调用(独占 CH395F 与 s_net_socks 访问权); * 由 net_task.c 在 net_stack_bring_up 之后每次 1ms 循环调用。 - * 函数说明:1. 三个自愈 tick 均非阻塞(绝不 osDelay 忙等,netTask 永不冻结),先于消息分派执行 - * 2. net_relisten_retry_tick(D10):断连后按 30ms + * 函数说明:1. 四个自愈 tick 均非阻塞(绝不 osDelay 忙等,netTask 永不冻结),先于消息分派执行 + * 2. net_listener_retry_tick(D10):断连后按 30ms * (NET_RELISTEN_RETRY_MS)节拍重试 open+listen;2s(NET_RELISTEN_DEADLINE_MS) * 总时限到期放弃留痕 * 3. D10:本 fd 有在途挂起 recv 时跳过(L3 保险) * 4. net_pending_tick(L3):按节拍了结挂起 recv(数据/到点/断连) * 5. L3 看门狗:到点后 1s(NET_PENDING_WATCHDOG_MS)未了结 → 强制了结 + DBG_ERROR 留痕 - * 6. net_reconcile_tick(Trap 22):每 200ms(NET_RECONCILE_PERIOD_MS)对账 - * 7. 对账以连续 2 次失配去抖 + 1s 冷却,自愈三类失同步 - * 8. net_msg_t 按值入队,netTask 侧副本调用方不可见 → 结果只能搭任务通知值回传 - * 9. 32 位整体即结果,不做位域压缩 - * 10. RECV 特例:无数据+阻塞 → 登记 pending 哨兵 NET_PENDING_REGISTERED(0x70000000) - * 11. RECV 特例 do_notify=0,回执推迟到 net_pending_tick 完成时 + * 6. net_reconcile_tick(Trap 22):每 200ms(NET_RECONCILE_PERIOD_MS)对账①② + * 7. 对账以连续 2 次失配去抖 + 1s 冷却,自愈两类失同步 + * 8. net_listener_pump(N-32 P2):每 100ms 监听器 pump,本层**唯一**重建入口 + * (原对账③④;本层 CLOSED 而芯片在听时只重同步)——置于对账之后 + * 9. net_msg_t 按值入队,netTask 侧副本调用方不可见 → 结果只能搭任务通知值回传 + * 10. 32 位整体即结果,不做位域压缩 + * 11. RECV 特例:无数据+阻塞 → 登记 pending 哨兵 NET_PENDING_REGISTERED(0x70000000) + * 12. RECV 特例 do_notify=0,回执推迟到 net_pending_tick 完成时 */ void net_process_messages(void) { net_msg_t msg; @@ -1922,7 +1924,7 @@ static int net_recv_locked(net_sock_t *p_sock, void *buf, int len, int flags, if ((p_sock->state == NET_SOCK_STATE_CLOSED) || (p_sock->session_dead != 0U)) { /* 启用自动重监听:先把监听器重新置回 LISTEN。 * ⚠ 内层 `state == CLOSED` 门是**必需的**,不是冗余: - * `net_relisten_try` 无状态门,对已 LISTENING 的 fd 再 open+listen + * `net_listener_try` 无状态门,对已 LISTENING 的 fd 再 open+listen * 会让 ch395f_open_socket 返 USE,其失败分支会 ch395f_close_socket * —— 把正听着新 SYN 的 Socket 当场关掉(U12 同源事故)。 * session_dead + 已回 LISTENING = 重建已由别处完成,这里只报 EOF。 @@ -3072,7 +3074,7 @@ int net_resync_listen(int sockfd) { * 函数说明:1. 用于"断连后监听器可能已失位"的自愈场景(2026-09-22 run9 实证) * 2. 一次性 net_listen 落在 Trap 09 窗口失败且被静默忽略 → 监听器永久丢失 * 3. 后续连接全部拒绝 - * 4. 与 auto_relisten 内部路径同一实现(net_relisten_locked:单次尝试 + 非阻塞重试,见 net_relisten_retry_tick) + * 4. 与 auto_relisten 内部路径同一实现(net_relisten_locked:单次尝试 + 非阻塞重试,见 net_listener_retry_tick) * 5. 芯片侧已在监听时:首次 open 失败 → close 重建 → 监听短暂中断 ~1s */ int net_relisten(int sockfd) { diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 293a6a46..d885e2c1 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -902,6 +902,16 @@ 1 ..\App\log\console_log.c + + soe_rec.c + 1 + ..\App\log\soe_rec.c + + + soe_store.c + 1 + ..\App\log\soe_store.c + sys_clock.c 1 @@ -1007,6 +1017,11 @@ 1 ..\App\algo\prot_elm.c + + freq.c + 1 + ..\App\algo\freq.c + diff --git a/README.md b/README.md index 13436d06..1ba2a7f1 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,12 @@ main.c | 任务 | 栈大小 | 优先级 | 职责 | |------|--------|--------|------| -| defaultTask | 1024 words | Normal | FTL + FatFS 挂载、配置引擎属主、syslog 唯一写者 | +| defaultTask | 1280 words | Normal | FTL + FatFS 挂载、配置引擎属主、syslog 唯一写者 | | netTask | 960 words | Normal | 网络初始化 + CH395F 轮询 | | ftpTask | 1024 words | Normal | FTP 服务(等待网络+存储就绪) | | testTask | 2048 words | BelowNormal | 硬件测试入口 | | uiTask | 4096 words | BelowNormal | LVGL UI 界面 | -| timeSyncTask | 512 words | Normal | 墙钟时间同步 | +| timeSyncTask | 640 words | Normal | 墙钟时间同步 | | adcTask | 640 words | AboveNormal | TPAFE5160 采样(TIM3 1kHz 事件驱动 + 原子快照 + STM32 ADC DMA) | | iec104Task | 640 words | Normal | | | protectTask | 512 words | Normal | | diff --git a/STM32F407-Demo.ioc b/STM32F407-Demo.ioc index 54b08b4c..8bc3cd4b 100644 --- a/STM32F407-Demo.ioc +++ b/STM32F407-Demo.ioc @@ -119,7 +119,7 @@ FREERTOS.Events01=sys_evt,Dynamic,NULL FREERTOS.FootprintOK=true FREERTOS.IPParameters=Tasks01,configENABLE_FPU,FootprintOK,configTOTAL_HEAP_SIZE,Queues01,Events01,configCHECK_FOR_STACK_OVERFLOW,configUSE_MALLOC_FAILED_HOOK FREERTOS.Queues01=netMsgQueue,8,net_msg_t,0,Dynamic,NULL,NULL -FREERTOS.Tasks01=defaultTask,24,1024,StartDefaultTask,Default,NULL,Dynamic,NULL,NULL;netTask,24,960,StartNetTask,Default,NULL,Dynamic,NULL,NULL;ftpTask,24,1024,StartFtpTask,As weak,NULL,Dynamic,NULL,NULL;testTask,16,2048,start_test_task,As weak,NULL,Dynamic,NULL,NULL;uiTask,16,4096,StartUiTask,Default,NULL,Dynamic,NULL,NULL +FREERTOS.Tasks01=defaultTask,24,1280,StartDefaultTask,Default,NULL,Dynamic,NULL,NULL;netTask,24,960,StartNetTask,Default,NULL,Dynamic,NULL,NULL;ftpTask,24,1024,StartFtpTask,As weak,NULL,Dynamic,NULL,NULL;testTask,16,2048,start_test_task,As weak,NULL,Dynamic,NULL,NULL;uiTask,16,4096,StartUiTask,Default,NULL,Dynamic,NULL,NULL FREERTOS.configCHECK_FOR_STACK_OVERFLOW=2 FREERTOS.configENABLE_FPU=1 FREERTOS.configTOTAL_HEAP_SIZE=57344 diff --git a/Src/freertos.c b/Src/freertos.c index 31d2fbe1..e9fde86c 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -94,7 +94,7 @@ static int hook_ptr_ok(const void *p) * 底线规则:余量 ≥ max(1024 B ISR, 30%)。完整核算见 docs/内存管理/RAM使用分析与优化方案.md, * 全表(含 ftpTask 1024 / uiTask 4096 的现值)见 AGENTS.md 任务表。 * - * defaultTask 1024 words:扛 mount+mkfs+FAT 探针+cfg 加载+syslog 写+配置引擎 + * defaultTask 1280 words:扛 mount+mkfs+FAT 探针+cfg 加载+syslog 写+配置引擎 * (全深层 FatFS I/O)。原 512,2KB 实测峰值溢出——开启 * configCHECK_FOR_STACK_OVERFLOW=2 后被钩子抓到(2026-09-21)。 * 2026-10-10 栈整改(**未抬栈**,靠削链达标):静态最坏链 5312 → 2776 B、 @@ -104,22 +104,32 @@ static int hook_ptr_ok(const void *p) * + 时间戳缓冲并入行缓冲(每个日志调用点原内联 192+32 B);④ 断言/故障钩子 * 脱离 HAL 发送回退 + 缓冲静态化(configASSERT 在 FreeRTOS 内部可达 → * 钩子在**每个**任务的最坏链尾)。详见 docs/内存管理/RAM使用分析与优化方案.md - * netTask 960 words:静态最坏链 1872 B(2026-10-10 整改后;整改前 2664 B: - * 发送停滞→断连→重监听→SPI 故障→日志尾,日志主导);3840 B 配置余量 1968 B - * (51%),过 stack_report 规则。运行时水位仅 ~0.9 KB(错误尾未 + * N-33(2026-10-12):B7 定值持久化把静态最坏链推到 **3016 B**(storage_bring_up → + * cfg 写 → f_rename → sync_fs → dhara GC → 控制台回显尾),1024 words 时余 + * 1080 B < 30% 线(4096×30% = 1228 B)→ 抬至 **1280 words**(5120 B),余 + * 2104 B / 41%;堆开销 +1024 B。栈值事实源仍是 `.ioc` 的 `FREERTOS.Tasks01`。 + * netTask 960 words:静态最坏链 1944 B(2026-10-12 N32 监听模块化后复测,链尾经 + * net_listener_pump→net_relisten_locked→SPI 故障→日志尾;2026-10-10 整改后曾为 + * 1872 B,整改前 2664 B);3840 B 配置余量 1896 B + * (49%),过 stack_report 规则。运行时水位仅 ~0.9 KB(错误尾未 * 触发,不可作依据)。曾试 net_drain_chip_rx scratch 静态化,实测仅省 16 B 栈 * (ARMCC 已叠放不重叠局部量)反增 256 B BSS,已回退——见 net_socket.c 注释。 * testTask 2048 words:存储套件 deep call chain + 局部缓冲,原 1024(4 KB)实测溢出 * (2026-09-20)。仅启用测试套件时创建。 - * timeSyncTask 512 words:本区下方手工定义(不在 `.ioc` 的 Tasks01 里 → 重生成会丢, + * timeSyncTask 640 words:本区下方手工定义(不在 `.ioc` 的 Tasks01 里 → 重生成会丢, * 靠审计 WARN 提示),建监听 → net_bind → CH395F → 日志尾。2026-10-10 整改后 * 静态最坏链 1024 B(整改前 1736 B)、实测峰值 616 B;⚠ 余量 1024 B **正好** * 等于 ISR 底线 → 本任务链上再加任何调用(或新日志行)都必须先抬栈。 + * N-33(2026-10-12):链已加深到 **1264 B**(同一控制台回显尾:`dbg_console_drain` + * → `MX_USART1_UART_Init` → `Error_Handler` → 回显 Cycle),512 words 时余 + * 784 B < 1024 B ISR 底线 → 抬至 **640 words**(2.5 KB),余 1296 B / 50%; + * 堆开销 +512 B。 * adcTask 640 words:本区下方手工定义(同上,不在 `.ioc` 里)。B1(2026-10-10) * ADC 采集消费者;L2 相量(2026-10-11)+ L1-6 有效性校验(同日)后链上为 * adc_input_poll + sample_ring/phasor + adc_health_report(DBG_ERROR + * syslog_post 回显尾)。静态最坏链 1080 B,512 words 时余 968 B < 1024 B * ISR 底线 → 抬至 640 words(2.5 KB,堆余量 min_ever 7152→≈6640 B,可承受)。 + * 2026-10-12 复测链 1320 B(L3 保护接线后),余 1240 B / 48% 仍 PASS。 * 栈值以 `scripts/stack_report.py` 实测最坏链为准,改动前后各跑一次。 * iec104Task 640 words:B4(2026-10-12)104 上行服务(2404 监听 + link 状态 * 机)。link/pts 缓冲全静态;stack_report 实测静态最坏链 1456 B(拆链 @@ -133,7 +143,7 @@ static int hook_ptr_ok(const void *p) osThreadId_t timeSyncTaskHandle; const osThreadAttr_t timeSyncTask_attributes = { .name = "timeSyncTask", - .stack_size = 512 * 4, + .stack_size = 640 * 4, .priority = (osPriority_t) osPriorityNormal, }; /* B1(2026-10-10):ADC 采集消费者(TIM6 1 kHz 采样节拍 + 原子快照 + 帧统计)。 @@ -176,7 +186,7 @@ const osThreadAttr_t protectTask_attributes = { osThreadId_t defaultTaskHandle; const osThreadAttr_t defaultTask_attributes = { .name = "defaultTask", - .stack_size = 1024 * 4, + .stack_size = 1280 * 4, .priority = (osPriority_t) osPriorityNormal, }; /* Definitions for netTask */ diff --git a/docs/CI/README.md b/docs/CI/README.md new file mode 100644 index 00000000..aed237e6 --- /dev/null +++ b/docs/CI/README.md @@ -0,0 +1,89 @@ +# 远程 CI(Gitea Actions)—— 本目录是唯一入口 + +> **适用范围(先读这句,可以省下很多时间)** +> 本仓库的远程 CI **只跑在本机这一台 runner 上**:runner 名 `win-stm32-ci`,装在 +> `D:\Docker\act_runner`,连的是本机原生 Gitea `http://127.0.0.1:3001`。 +> **其他机器、其他项目都不涉及,也不需要配这套东西。** +> +> 日常改代码**不需要读本目录**:提交前的等价自查是本地门禁,runner 在不在线都照样跑—— +> +> ```powershell +> & '.venv\Scripts\python.exe' scripts/ci_local.py # L0 静态 + L0b 提交信息 + L0.5 主机单测 +> ``` +> +> 只有在这三种情况下才需要进来:**① 换机/重装后要重新部署 runner;② CI 不出任务或莫名 +> 取消;③ 改 `.gitea/workflows/ci.yml` 本身。** + +## 索引 + +全部内容是**一篇**文档:[`远程CI完整指南.md`](远程CI完整指南.md)(2026-10-12 由原「环境与部署」 +目录下的两篇指南 `CI配置指南.md` 与 `Gitea_Runner部署指南.md` 合并而来,**两篇均已删除**, +去重后为单一来源)。 + +| 想干什么 | 去看 | +|---|---| +| 先搞清 CI 需要哪三样东西就位、本机拓扑、部署前置清单 | §0 / §0.1 / §0.2 | +| **CI 不守哪些门禁**(体积门禁盲区 S14、gitlint 空转) | §0.0 + §7 排障表"钩子条数对不上"行 | +| 服务端开启 Actions、`DEFAULT_ACTIONS_URL` 到底管什么 | §1(含 §1.2.1,**这条最容易搞反**) | +| 下载 / 注册 runner(`--instance` 用回环、labels 必须精确匹配 `runs-on`) | §2.2 | +| `config.yaml` 各字段语义(capacity / timeout / labels / 日志目录…) | §2.3 | +| **常驻:为什么必须看门狗、不能只起 daemon、也不能做成 Windows 服务** | §2.4 + §3.0 | +| runner 装什么软件、为什么变量必须对 **SYSTEM** 可见、机器级 `DTU_*` | §4(§4.1.1~§4.1.3 是三个真坑) | +| workflow 位置 / 触发 / 内网拉不到 `actions/checkout` 的三种处置 | §5(§5.3 零依赖写法) | +| 两个 job 各自跑哪些步骤、两条 Python 探测链要同序同步 | §5.5 | +| 端到端验证清单、无网页登录时直查 MySQL 核验 | §6 / §6.1 | +| 出问题了按症状查表(含 `0x41301`、1053、72h 被杀、`.ps1` 无 BOM 乱码…) | §7 | +| 日常维护(改钩子集 / 改 workflow 后要不要动 runner) | §8 | +| 一键自检脚本、最小操作清单 TL;DR | §9 / §10 | + +## 常用命令速查 + +```powershell +# 本地等价门禁(不依赖 runner,日常用这个) +& '.venv\Scripts\python.exe' scripts/ci_local.py + +# runner 体检(四项:计划任务 / 进程 / Gitea 可达 / 机器级变量)—— 须管理员 +& '.\scripts\ci_runner_bootstrap.ps1' -Status + +# 干跑:只打印将要写入的启动器与任务对象,不落盘不注册(免管理员) +& '.\scripts\ci_runner_bootstrap.ps1' -DryRun + +# 部署 / 修复:写机器级 DTU_* + 生成看门狗启动器 + 注册开机计划任务 + 立即启动(须管理员) +& '.\scripts\ci_runner_bootstrap.ps1' -SetEnv -StartNow +``` + +> ⚠️ 上面三条 `ci_runner_bootstrap.ps1` 是**本机运维**动作,会改系统计划任务与机器级环境 +> 变量;返回码 `0x41301` 是"任务正在运行"、`next run` 为空是开机触发器的正常表现, +> 都不是故障(详见指南 §3.4)。 + +> ✅ **机器侧遗留已清理(2026-10-12)**:旧方案的 `deploy_runner_task.ps1` 与 `run_runner.cmd` +> 已从 `D:\Docker\act_runner\` **删除**(它们跑单次 daemon,首次 Declare 失败就永久停摆, +> 机理见指南 §3.2;`check_env.ps1` 里指向它们的提示也已改指 bootstrap)。 +> 现行部署只有 `ci_runner_bootstrap.ps1` 这一条路。 + +> 🔴 **重跑部署前先知道这条**(2026-10-12 实测):`Unregister-ScheduledTask` **不会**带走旧实例 +> 启动的进程。旧看门狗会作为孤儿继续养着它的 daemon,而那个 daemon 占着 `.runner.lock`, +> 于是新看门狗每 10 秒失败一次(`runner file is already locked by another process`)—— +> **而 runner 在网页上照样显示 online**,CI 看着正常,任务其实谁也没管。 +> 现在 `-StartNow` 会先清掉这些残留进程再启动(约离线几秒),`-Status` 也会报出重复实例。 +> **2026-10-12 已实测收敛**:清掉 3 个残留后 `daemon up: pid … (single instance)`, +> 监管链 `svchost(任务) → 看门狗 → daemon` 恢复,runner 回 online。 + +## 相关文档 + +- 测试分层与门禁决策(L0~L3 各层由谁把关):[`../测试说明/测试开发标准流程.md`](../测试说明/测试开发标准流程.md) +- workflow 本体:[`../../.gitea/workflows/ci.yml`](../../.gitea/workflows/ci.yml) +- 本地等价脚本:[`../../scripts/ci_local.py`](../../scripts/ci_local.py) +- runner 机器的工具链环境要求:[`../环境与部署/项目部署环境要求.md`](../环境与部署/项目部署环境要求.md) +- `DTU_*` 工具路径变量机制:[`../环境与部署/本地工具路径环境变量方案.md`](../环境与部署/本地工具路径环境变量方案.md) + +## 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-12 | 🐛 **首次真跑出 run**:`L0.5` **成功**(`10 runner(s), 647/647, fail=0`,与本地逐字一致);`L0` **假失败**——gitlint 因 runner 无 git 身份而 `exit 254`(`Missing git configuration: please set user.name`),旧文案把它说成"提交信息违规",且该步 `exit 1` 连带让 26 个钩子全部没跑。已在 `ci_gitlint_range.ps1` 用 `GIT_CONFIG_*` 注入一次性身份(不写文件)并区分 254/1 退出码。另记:**新建分支的首次 push 不触发 run**,需 `workflow_dispatch` 或再推一个非文档提交。详见指南 §7 | +| 2026-10-12 | 🔀 本轮 CI 修复随 `master` **合入 `v2-on-ui`**(该分支另有 21 个功能提交;冲突仅 `AGENTS.md` 与 `docs/README.md` 两处,解法是"保留其新版结构 + 把 CI 指针搬进对应位置")。因该分支门禁更多(**26 钩子 / 10 单测套件** vs master 的 18 / 3),指南里写死的数字已改为**规则 + 分分支快照**,见指南 §6 / §7 / §10 | +| 2026-10-12 | ✅ 双看门狗缺陷**实测收敛**(管理员 `-StartNow` 清掉 3 个残留 → 单实例、链路恢复、runner online)。另修两处:daemon 日志走 stderr 被 PS 5.1 `2>&1` 包成 ErrorRecord(2 行真日志写成 8 行,每 4 行 3 行噪声)→ 启动器改 `ForEach-Object { "$_" }`;`-Status` 诚实化(非管理员读不到 SYSTEM 进程命令行 → 明说需提权而非谎报"无看门狗";重启循环判据改看进程存活时长,不再靠不轮转日志的尾部字符串报假警)。详见指南 §7.3 | +| 2026-10-12 | 🔴 **修复"双看门狗抢 `.runner.lock`"缺陷**:`Unregister-ScheduledTask` 不停旧实例进程 → 孤儿看门狗养着旧 daemon 占锁,新看门狗每 10 秒失败一次,而 runner 网页照样 online、`-Status` 也说"在跑"。现 `-StartNow` 先清残留进程再启动、`-Status` 报重复实例与锁冲突。同日清理机器侧:`config.yaml` 头部错误指引("改用 sc.exe / New-Service")改为看门狗口径,删除已废弃的 `deploy_runner_task.ps1` 与 `run_runner.cmd`,`check_env.ps1` 提示同步改指 bootstrap | +| 2026-10-12 | **单一口径清理**:合并稿初版对 4 处分歧"两说并存",现统一为本机实测现状——注册口径(`gitea-runner v4.0.1` / `D:\Docker\act_runner` / `127.0.0.1:3001`,旧 `act_runner v0.2.11` + `C:\act_runner` 段删去可复制命令只留警示)、`config.yaml`(`fetch_timeout: 1m0s`、`timeout: 30m0s`)、常驻方式(只认看门狗,`deploy_runner_task.ps1` 标废弃)、验收数字(L0.5 `288/288`,历史数字标注为快照)。并补"机器侧遗留脚本勿误用"警示 | +| 2026-10-12 | **新建本目录**:远程 CI 内容原先散在 `docs/环境与部署/`(两篇互相重叠的指南)、`docs/测试说明/测试开发标准流程.md` §9、`AGENTS.md` 提交门禁节共 4 处,约 2000 行;现合并为单一指南并把 `AGENTS.md` 压成一行指针。动因:远程 CI 只涉及本机一台 runner,与日常开发规范混在一起每次会话都被读,造成困惑 | diff --git a/docs/CI/远程CI完整指南.md b/docs/CI/远程CI完整指南.md new file mode 100644 index 00000000..b5bdb967 --- /dev/null +++ b/docs/CI/远程CI完整指南.md @@ -0,0 +1,1370 @@ +# 远程 CI 完整指南(Gitea Actions · 本机 Windows Runner) + +> **适用范围(先读这一段)**:本仓库的远程 CI **只跑在本机**——runner 名 `win-stm32-ci`, +> 装在 `D:\Docker\act_runner`,连接的是**本机原生 Gitea**(`http://127.0.0.1:3001`)。 +> **其他机器、其他项目都不需要、也不会用到本文这套配置**,勿照搬。 +> 日常改代码的等价自查是本地跑 [`scripts/ci_local.py`](../../scripts/ci_local.py) +> (与 CI 同一套判定),**不依赖远程 CI 是否在线**——runner 掉线不影响本地门禁。 + +> **适用**:为 STM32F4-Base 部署 / 排障 CI(`.gitea/workflows/ci.yml`,L0 + L0.5)。 +> **前置阅读**:[docs/测试说明/测试开发标准流程.md §9](../测试说明/测试开发标准流程.md)(CI 覆盖边界与判定细节)、 +> [docs/环境与部署/项目部署环境要求.md §2.11](../环境与部署/项目部署环境要求.md)(runner 环境要求)。 +> 本文只讲**怎么配**,不重复"为什么这么设计"。 +> 本篇由原两篇 CI 指南 `CI配置指南.md` 与 `Gitea_Runner部署指南.md`(原位于「环境与部署」目录, +> **两篇均已删除**)于 2026-10-12 合并而成,去重后为单一来源。 + +> **适用环境(2026-10-02 实测确认)**: +> - Gitea **1.25.4**,**Windows 原生二进制**(`D:\Docker\Gitea\gitea.exe`,**不是 Docker**), +> 以 **Windows 服务**运行(`tasklist` 显示 Session = `Services`),监听 `0.0.0.0:3001` +> - 配置 `D:\Docker\Gitea\custom\conf\app.ini`,`[actions] ENABLED = true` **已开启** ✅ +> - 数据库 MySQL(`127.0.0.1:3306`),仓库根 `D:\Docker\Gitea\data\gitea-repositories` +> - 对外域名 `http://gitea.bitnasdaq.vip/`(经反代),本地直连 `http://127.0.0.1:3001` ✅ +> - Docker Desktop **已装但 daemon 未运行** → **本文不使用容器,全程原生** + +> ⚠️ **重要版本变更(2026-10-02 实测)**:act_runner 已更名为 **`gitea-runner`**, +> 当前最新版 **v4.0.1**(发布页 `https://gitea.com/gitea/runner/releases`, +> 注意仓库路径已从 `gitea/act_runner` 变为 **`gitea/runner`**)。 +> **v4 移除了 `daemon install/start/stop/status` 子命令**(那些是 v0.2.x 的接口)。 +> 另:v4 的 `log` 段只有 `level` 和 `job`(**无 `file` 键**),配置用 +> `gitea-runner config init / set / get / generate` 管理。 + +> 🔴 **常驻方式修正(2026-10-02 实测推翻)**:`gitea-runner daemon` 是**纯控制台程序**, +> **不支持作为 Windows 服务运行**(二进制内无服务管理代码,也无 `service.go`); +> 用 `sc.exe` / `New-Service` 注册后 `Start-Service` 必然失败。 +> **正确方式是 Windows 任务计划程序** —— 见 **§3**;且 2026-10-11 实测进一步要求 +> 在计划任务之上加**看门狗循环重拉** —— 见 **§2.4**。 + +--- + +## 0.0 ⚠️ CI 不覆盖的体积门禁(必读,S14) + +> **CI 不守 Flash/RAM 体积门禁。** `mem-numbers` 钩子的事实源是 Keil 构建产物 +> `MDK-ARM/**/*.map`(已 gitignore),而 CI 刻意不跑 `@build` → **无 `.map` → 该钩子被跳过**。 +> +> 后果:**新增任务 / 栈 / 缓冲 / 代码导致的体积增长(或文档内存数字失真)不会在 CI 被拦下。** +> +> 兜底(每次改代码后必做): +> ```powershell +> & '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # 0/0 且生成 .map +> & '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --scan +> ``` +> 若 CI 允许在带 Keil 的 runner 上跑,则该钩子会在有 `.map` 时**照常执行**(workflow 自适应)。 +> +> CI 运行时该跳过以 **`::warning::`** 明示(不是弱化的 notice),并写入 job 的 +> **step-summary** 醒目行,避免被忽略。 + +--- + +## 0. 全景:CI 需要三样东西就位 + +``` +① Gitea 服务端开关 ② Windows Runner 注册 ③ 仓库里的 workflow 文件 + [actions] ENABLED=true labels 含 windows .gitea/workflows/ci.yml + │ │ │ + └────────────┬───────────┴──────────────────────────────┘ + ▼ + push / PR 触发 → runner 拉起容器/壳 → 跑 L0 + L0.5 → 结果回显到提交 +``` + +**三者缺一,CI 就是空转的**(workflow 存在但永不执行 / 执行但拉不到 runner)。 +当前仓库状态:**③ 已就绪(本机提交 `e904865`,尚未 push);① ② 需人工确认。** + +### 0.1 本机拓扑:Gitea 和 Runner 在同一条机器上 + +**Gitea 和 Runner 在同一条机器上** —— 这是本部署最关键的事实: + +``` +┌─────────────────────────── 本机 (Windows) ────────────────────────────┐ +│ │ +│ gitea.exe ── Windows 服务 ── 监听 0.0.0.0:3001 │ +│ ▲ │ +│ │ ① runner 注册 / 心跳 / 领任务:走 http://127.0.0.1:3001 │ +│ │ (本地回环,不经公网、不依赖 DNS/反代) │ +│ │ │ +│ gitea-runner.exe ── 计划任务 GiteaActionsRunner(win-stm32-ci) │ +│ │ │ +│ └─ ② 领到 job 后,直接在**本机**跑: │ +│ git fetch / pre-commit / cppcheck / cl.exe │ +│ (标签 windows:host = 用宿主环境,非容器) │ +│ │ +│ 外部用户 → https://gitea.bitnasdaq.vip → 反代 → 127.0.0.1:3001 │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +**两个地址要分清**(最容易搞错的地方): + +| 用途 | 地址 | 说明 | +|---|---|---| +| **Runner 连接 Gitea** | `http://127.0.0.1:3001` | 本机回环,**最稳**。用 `gitea.bitnasdaq.vip` 也行,但会多绕一圈 DNS + 反代,且若反代没配对内网 IP 会失败 | +| **用户在浏览器访问** | `https://gitea.bitnasdaq.vip` | 走反代;注册时网页上显示的示例命令用的是这个域名,**你要手动换成 127.0.0.1:3001** | + +> ⚠️ **注册时用哪个地址,决定了 runner 以后一直连哪里**。 +> 注册信息存在 `.runner` 里,之后改起来要重新注册,**建议一开始就用 `http://127.0.0.1:3001`**。 + +### 0.2 部署前置清单(需人工确认) + +> 2026-10-12 自 `docs/测试说明/测试开发标准流程.md` §9.4 并入——这几项是"CI 能不能跑起来"的前提, +> 属于机器侧知识,故与本文其余内容放在一起,不再散在测试流程文档里。 + +| 项 | 要求 | +|---|---| +| Gitea Actions | 实例开启 `[actions] ENABLED = true`(site admin 配置),见 §1 | +| Windows runner | 已注册且 **labels 含 `windows`**;装有 Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 C++ 工具链 + Windows SDK) | +| **runner 必须有看门狗** | `gitea-runner` v4 的 `daemon` **无服务支持、且首次 Declare 失败就直接退出进程** → Gitea 重启一次,CI 就静默停摆(2026-10-11 实测:run 16~21 共 12 个 job 零日志)。用 **`scripts/ci_runner_bootstrap.ps1`** 装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 计划任务,见 §2.4 | +| 机器级工具变量 | runner 以 **SYSTEM** 运行,看不见交互用户的 PATH/用户级 site。用 `ci_runner_bootstrap.ps1 -SetEnv` 写入 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL`,见 §4.1.3 | +| 缺 MSVC 时 | L0.5 降级为 WARNING 跳过(不硬失败),但**等于该层裸奔**,须尽快补装 | + +--- + +## 1. 服务端:开启 Gitea Actions + +### 1.1 确认版本与开关 + +Gitea **≥ 1.21** 才内置 Actions(实测本实例 **1.25.4**,满足)。以 **site admin** 登录后: + +- 管理后台 → **站点管理 → 配置 / Site Administration → Configuration**,搜索 `actions`: + +```ini +[actions] +ENABLED = true +``` + +### 1.2 编辑配置文件(推荐,永久生效) + +配置文件位置(按部署方式二选一): + +| 部署方式 | 配置文件路径 | +|---|---| +| 二进制 / 直接跑 | `/custom/conf/app.ini` | +| Docker | 容器内 `/data/gitea/conf/app.ini`(挂载卷里的 `custom/conf/app.ini`) | + +在 `app.ini` 追加(若无 `[actions]` 段): + +```ini +[actions] +ENABLED = true +``` + +改完 **重启 Gitea 服务**(Docker:`docker restart <容器名>`;二进制:重启进程)。 + +#### 1.2.1 `DEFAULT_ACTIONS_URL` 是什么(**容易搞反**) + +它**不是你的 Gitea 地址**,而是控制 **`uses: actions/checkout@v4` 这类 action 从哪里下载**: + +| 取值 | 行为 | +|---|---| +| **不配(默认)** | 从 **[github.com](https://github.com)** 拉取 action | +| `self` | 从**你自己的 Gitea 实例**拉取(要求 action 已镜像进你的实例) | +| 具体 URL(如 `https://gitea.com`) | 从该地址拉取 | + +> ⚠️ **不要把你的实例域名写进去当普通 URL**——要"从自己实例拉"就用关键字 **`self`**; +> 且**写了 `self` 还得先把 action 镜像进自己实例**,否则 `actions/checkout` 依然拉不到。 + +**按你的网络环境选择**: + +| 场景 | 配置 | +|---|---| +| 服务器**能**访问 github.com | **什么都不配**(默认走 github.com,最省事) | +| 内网,**不能**访问 github.com | 建议**改用零依赖写法**(见本文 §5.3 方案 3),`DEFAULT_ACTIONS_URL` 配不配都行;
若坚持用 `actions/checkout@v4`,则需 `DEFAULT_ACTIONS_URL = self` **且**把该 action 镜像进实例 | + +> Gitea 官方 FAQ 原文:*"From `https://github.com` by default, or from your own instance +> when `[actions].DEFAULT_ACTIONS_URL` is `self`."* 使用别处 action 也可在 workflow 里直接写全路径: +> `uses: https://gitea.com/owner/repo@ref` / `uses: self:owner/repo@ref`。 + +### 1.3 验证 + +浏览器打开仓库地址,顶部/侧栏应出现 **Actions** 标签页。 +若仍提示"此仓库未启用 Actions",说明开关没生效,回到 1.1 检查。 + +> ⚠️ 若实例装在**内网且不能访问公网**,`actions/checkout@v4` 这类官方 action 会拉不下来。 +> 处置见本文 §5.3。 + +--- + +## 2. Runner 端:注册 Windows Runner + +CI 的两个 job 都是 `runs-on: windows`,所以**必须有一个 labels 含 `windows` 的 runner**。 +推荐用 `act_runner`(Gitea 官方 runner)。 + +### 2.1 获取注册令牌 + +- **仓库级 runner**(推荐,只服务本仓库): + 仓库 → **设置 / Settings → Actions → Runners → 创建 Runner**,复制页面显示的 + **Registration Token**(形如 `xxxYYYzzz...`)。 +- **全局 runner**(服务整个实例):站点管理 → Actions → Runners。 + +记下 Gitea 地址,例如 `https://gitea.bitnasdaq.vip`。 + +本机(原生 Gitea)的具体操作路径: + +1. 浏览器登录 `https://gitea.bitnasdaq.vip` +2. 进仓库 **`wandering1/STM32F4-Base`** +3. **Settings(设置)→ Actions → Runners → 创建 Runner / Create new Runner** +4. 页面会显示一段命令,形如: + +``` +./gitea-runner register --no-interactive --instance https://gitea.bitnasdaq.vip --token xxxxxxxxxxxxxxxxxxxx +``` + +**复制其中的 `--token` 后面那一串**(这是 Registration Token,一次性用途)。 +页面上的 `--instance` 值**不要照抄**——见 §2.2.3。 + +> 💡 **Token 存在于 `action_runner_token` 表**(MySQL,见本文 §6.1)。 +> 每仓库一个有效 token,**注册一次后即被消费**;过期(约 1 小时)或已用则需重新创建。 + +### 2.2 下载并注册 runner + +**本机现状(唯一口径)**:二进制 **`gitea-runner.exe` v4.0.1**(实测 20,623,872 B ≈ 20.6 MB), +装在 **`D:\Docker\act_runner`**,注册 `--instance` 用 **`http://127.0.0.1:3001`**。 +具体步骤见 §2.2.1 起,命令集与字段语义见 §2.2.2 / §2.3。 + +> 🗑 **历史备查,勿照抄**:本文初版写的是 `act_runner` **v0.2.11** + `C:\act_runner` + +> `--instance https://gitea.bitnasdaq.vip`。三点均已过时,且**本机从未这样部署过** +> (2026-10-12 核实:`C:\act_runner` 至今不存在): +> ① v0.2.x 与 v4 的**二进制名与命令集都不同**(v4 已无 `daemon install/start/stop/status`,见 §3.0); +> ② 目录不同;③ `--instance` 填公网域名会多绕一圈 DNS + 反代,反代没配对内网 IP 就直接失败。 +> **照抄那段会装出一个连不上 Gitea 的 runner**,故原命令块已删除,只留此警示。 + +**关键点**:`--labels "windows:host"` 中的 `windows` 必须**逐字**与 workflow 的 +`runs-on: windows` 对应(Gitea runner 的 label 名是自定义的,默认是 `ubuntu-latest`, +**不注册 `windows` 这个 label 就永远匹配不到 job**)。 + +> `:host` 表示直接跑在宿主 Windows 上(不是容器)。本场景**必须用 `host`**—— +> 容器里拿不到宿主已装的 Keil/MSVC/cppcheck,且 Windows 容器支持很差。 + +#### 2.2.1 选对版本并下载 gitea-runner + +| 项 | 值 | +|---|---| +| 架构 | **amd64**(`PROCESSOR_ARCHITECTURE = AMD64`,实测) | +| 版本 | **v4.0.1**(2026-10-02 实测最新稳定版) | +| 下载页 | https://gitea.com/gitea/**runner**/releases ← 注意仓库名已从 `act_runner` 改为 `runner` | +| 文件名 | `gitea-runner-4.0.1-windows-amd64.exe` ← 二进制名也从 `act_runner` 改为 **`gitea-runner`** | + +```powershell +# 建目录(与 Gitea 同级,便于管理) +New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null +cd "D:\Docker\act_runner" + +# 下载 v4.0.1(版本号请替换为 releases 页当前最新) +Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" ` + -OutFile "gitea-runner.exe" + +# 校验能跑 +.\gitea-runner.exe --version +``` + +**期望输出**:`gitea-runner version v4.0.1`。 + +> 💡 **本机实测**(2026-10-02):下载 20.6 MB,耗时约 5 秒,`gitea.com` 可直连(`HTTP 200`)。 + +#### 2.2.2 v4.0.1 的命令集(与 v0.2.x 的差异) + +``` +Available Commands: + bug-report Print information useful when filing a bug report + cache-server Start a cache server for the cache action + config Generate, read and edit config files + daemon Run as a runner daemon + exec Run workflow locally. + help Help about any command + register Register a runner to the server +``` + +> ⚠️ **没有 `daemon install/start/stop/status`** —— 这些是 v0.2.x 的接口,v4 已移除。 +> 常驻必须用 Windows 原生方式(见 §3)。 +> +> `config` 子命令很方便,建议优先用它而不是手改 YAML: +> `config init`(生成最小配置)/ `config generate`(打印全部选项) +> / `config set ` / `config get `。 + +#### 2.2.3 执行注册(关键:instance 用 127.0.0.1) + +```powershell +cd "D:\Docker\act_runner" + +.\gitea-runner.exe register ` + --no-interactive ` + --instance "http://127.0.0.1:3001" ` + --token "粘贴刚才复制的 Registration Token" ` + --name "win-stm32-ci" ` + --labels "windows:host" +``` + +**四个参数逐一说明**: + +| 参数 | 值 | 为什么 | +|---|---|---| +| `--instance` | `http://127.0.0.1:3001` | **本机回环**。因为 Gitea 就在这台机器上,回环最稳、最快、不受反代/DNS 影响 | +| `--token` | 网页复制的那串 | 绑定到具体仓库(仓库级 runner) | +| `--name` | `win-stm32-ci` | runner 显示名,随意但建议有辨识度 | +| `--labels` | **`windows:host`** | 🔴 **最容易踩的坑**:`windows` 必须与 workflow 的 `runs-on: windows` **逐字一致**;`:host` 表示用**宿主环境**直接跑(非容器)——本场景**必须 host**,容器里拿不到 MSVC/cppcheck | + +> ⚠️ **`--labels` 写错的后果**:runner 会注册成功、显示绿色,但**任务永远排队不执行** +> (没有任何 runner 匹配 `runs-on: windows`)。这是最隐蔽的失败模式。 + +**期望输出**:`Runner registered successfully`,并在 `D:\Docker\act_runner\` 生成 `.runner` 文件(记录注册凭据)。 + +#### 2.2.4 验证注册 + +浏览器刷新 **Settings → Actions → Runners**,应看到: + +| 列 | 期望值 | +|---|---| +| Name | `win-stm32-ci` | +| Labels | `windows`(`:host` 是执行方式,UI 可能只显示 `windows`) | +| Status | 🟢 **Idle** | + +### 2.3 配置 `config.yaml`(可选但建议) + +`D:\Docker\act_runner\config.yaml` 里确认/补充(本机完整落地版见 §2.3.2): + +```yaml +runner: + labels: + - "windows:host" # 与 register 一致 + capacity: 1 # 并行 job 数;单机建议 1,避免 cppcheck 抢 CPU + timeout: 30m0s + fetch_timeout: 1m0s # 🔴 不得超过 RPC timeout(1m0s),否则被自动截断并告警 +cache: + enabled: true +``` + +#### 2.3.1 用 `config` 子命令管理(v4 推荐) + +**推荐用 `config` 子命令生成/修改**(v4 新增,比手改 YAML 安全): + +```powershell +cd "D:\Docker\act_runner" +.\gitea-runner.exe config init # 生成最小 config.yaml +.\gitea-runner.exe config set runner.capacity 1 # 逐项设置 +.\gitea-runner.exe config set runner.timeout 30m +.\gitea-runner.exe config set cache.enabled true +.\gitea-runner.exe config set log.level info +.\gitea-runner.exe config get runner.capacity # 校验 +.\gitea-runner.exe config generate # 打印全部可用选项(含注释) +``` + +#### 2.3.2 本机已落地的配置与字段语义 + +**本机已落地的配置**(`D:\Docker\act_runner\config.yaml`): + +```yaml +log: + # trace/debug/info/warn/error/fatal/panic;排障时临时改 debug + level: info + # 每个 job 的任务日志另存一份到本地(防上报 Gitea 失败时日志丢失) + job: + dir: "D:\\Docker\\act_runner\\log\\job" + retention: 168h + max_size: 100MB + +runner: + file: .runner # 注册凭据(由 register 生成,勿手改) + capacity: 1 # 并行 job 数;单机建议 1(cppcheck 吃 CPU) + timeout: 30m0s # 单 job 超时;L0+L0.5 约 1~3 分钟 + fetch_timeout: 1m0s # 🔴 不得超过 RPC timeout(1m0s):原写 5m0s 会被自动截断并刷告警,2026-10-02 已改 + fetch_interval: 2s + # 🔴 标签:windows 必须与 workflow 的 runs-on: windows 逐字一致 + labels: + - "windows:host" + +cache: + enabled: true + dir: "D:\\Docker\\act_runner\\cache" + +container: + network: "" + privileged: false + options: "" + valid_volumes: [] + docker_host: "" + force_pull: false +``` + +> ⚠️ **v4 的 `log` 段只有 `level` 和 `job` 两个键** —— **没有 `file` 键** +> (`config set log.file ...` 会报 `unknown config key`)。 +> 想要本地日志用 `log.job.dir`(按 job 落文件),或把服务输出重定向到文件。 +> +> ⚠️ **改了 `runner.labels` 必须重启 runner 才生效** +> (Gitea 官方 FAQ:*"A runner declares its labels every time it starts ... so change them there and restart the runner."*) + +### 2.4 常驻方式:**必须用看门狗,不能只起 daemon**(2026-10-11 实测重写) + +> 🔴 **`daemon install` 在 v4 已不存在**:本机 runner 是 `gitea-runner v4.0.1`,命令集只有 +> `bug-report / cache-server / config / daemon / exec / register`,**没有任何 Windows 服务 +> 管理代码**(`daemon` 是纯控制台程序,无法向 SCM 回报 `SERVICE_RUNNING`)。 +> 旧版本文写的 `act_runner.exe daemon install` 在本机**不可用**。 + +> 🔴🔴 更关键的坑:**`daemon` 在首次 Declare 失败时直接退出进程**(不重试)。 +> runner 日志(`D:\Docker\act_runner\log\runner-stdout.log`)实测: +> ``` +> level=error msg="fail to invoke Declare" error="unavailable: +> dial tcp 127.0.0.1:3001: connectex: ... actively refused it." +> Error: unavailable: dial tcp 127.0.0.1:3001 ... +> ``` +> 之后**没有**任何重试记录——进程已经没了。于是 **Gitea 重启一次 / 机器重启一次, +> CI 就静默停摆**:2026-10-08 起 runner 掉线,run **16~21 共 12 个 job 一行日志都没产生**; +> 又因 `ci.yml` 的 `concurrency.cancel-in-progress: true`,队列里的 run 会被**下一次 push 直接取消** +> (16~20 = `cancelled`、21 = 长期 `queued`)——症状看起来是"任务凭空消失",而不是"任务失败", +> 极难从 UI 反推根因。 + +> 🔴 **为什么"已经有计划任务"仍然会死**(2026-10-11 实测,别以为注册过就安全): +> 旧任务跑的是 `run_runner.cmd`,里面是**单次** `gitea-runner.exe daemon ...`(无循环、无重启语义)。 +> 开机时 Gitea 往往还没起来 → daemon 首次 Declare 失败**立刻退出** → 任务"成功结束" → +> **计划任务认为一切正常,永远不会再拉**。日志里 10-09 18:50 连续三次 +> `Starting runner daemon` + 三次 Declare 失败,就是三次"起了就死、死了没人管"。 +> 所以必须换成**看门狗**(等就绪 + 循环重拉),单靠计划任务的"失败重启"救不了 +> ——daemon 是"正常退出",不是"失败"。 + +**正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"**(管理员权限,仓库根目录): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow +``` + +它做三件事(细节见脚本头注释): + +| 动作 | 内容 | +|---|---| +| ① 机器级工具变量 | 按 CI 同一套探测写 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL`(已存在则跳过,`-Force` 才覆盖) | +| ② 看门狗启动器 | 生成 `D:\Docker\act_runner\run_runner_watchdog.ps1`:先轮询 `/api/v1/version` 直到 Gitea 应答,再起 `daemon`;daemon 退出 → 记日志 → 10s 后重拉,**永不放弃** | +| ③ 计划任务 | `GiteaActionsRunner`:开机 +1 分钟触发、以 **SYSTEM** + 最高权限运行、**运行时限=不限**、失败每 1 分钟重启(×999)、不允许多实例 | + +**先干跑,再真装**(不需要管理员,把 action/trigger/settings 全构建一遍并打印,什么都不写): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun +``` + +随时体检(只读,不改任何东西): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status +``` + +输出会告诉你:计划任务是否存在(含上次运行时间与返回码)、daemon 是否在跑、Gitea 是否可达、 +三个机器级变量是否已设。 + +> ⚠️ **`-Status` 要用管理员跑**:非管理员会话**不一定看得见** `GiteaActionsRunner` +> (本次排障就被这一点误导过一次,以为任务从未注册)。脚本在非管理员下会额外提示这一点。 + +> 🔴 **改这个脚本时守住两条**(都是踩出来的): +> ① `StartWhenAvailable` 是**任务设置**属性(`MSFT_TaskSettings`,被 `MSFT_TaskSettings3` 继承), +> **触发器上没有这个属性**——写 `$trigger.StartWhenAvailable` 会抛 `SetValueInvocationException` +> 中断脚本(实测,且 CIM 属性列表里看不到它,只能直接赋值); +> ② **必须"先构建全部对象、后注销旧任务"**——早期版本先 `Unregister` 再构建,一旦构建阶段抛异常, +> 旧任务已被删、新任务没建成,CI 直接裸奔。 + +注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci` +状态为 **Idle / 绿色**。 + +> ⚠️ **`.runner` 里的 `address` 是 runner 实际连接的地址**(本机 `http://127.0.0.1:3001`), +> 与 `ROOT_URL`(`http://gitea.bitnasdaq.vip/`)不是一回事;bootstrap 会以 `.runner` 为准。 + +--- + +## 3. 常驻底层:为什么不能用 Windows 服务 + 任务计划程序手动方式 + +> 本节是 2026-10-02 的实测记录("不能用 Windows 服务"的根因)与任务对象字段说明, +> 保留作根因依据与手动排障参考。**正式部署一律用 §2.4 的 `scripts/ci_runner_bootstrap.ps1`**: +> §3.2 的旧脚本 `deploy_runner_task.ps1` 生成的是**单次** daemon,2026-10-11 实测证明它 +> 首次 Declare 失败就退出、死了没人管;§3.3 已改写为与现行 bootstrap **等价**的看门狗任务 +> 对象,只用于理解字段或在别的机器上手搓。 + +### 3.0 🔴 关键结论:**不能用 Windows 服务** + +> **本条为 2026-10-02 实测推翻既有假设后的修正结论,务必先读。** + +先前文档(及 `install_service.ps1`)主张用 `sc.exe` / `New-Service` 把 +`gitea-runner daemon` 注册成 Windows 服务。**实测不可行**,`Start-Service` 必然失败: + +``` +Start-Service : 由于以下错误无法启动服务"Gitea Actions Runner (gitea-runner)" ... +``` + +**根因(对 `gitea-runner.exe` v4.0.1 二进制逆向确认)**: + +| 证据 | 结论 | +|---|---| +| 命令集只有 `bug-report` / `cache-server` / `config` / `daemon` / `exec` / `register`(对应源码 `internal/app/cmd/{bug_report,cache-server,cmd,config,daemon,exec,register}.go`) | **没有 `service.go`,没有任何服务管理子命令** | +| `daemon --help` 仅 `--labels` / `--once` 两个 flag | daemon 无服务模式开关 | +| 二进制内虽出现 `OpenSCManagerW` / `CreateServiceW` / `StartServiceCtrlDispatcherW` 等符号 | 它们来自 **Docker SDK**(容器作业路径),**不是** runner 的服务安装代码——切勿据此误判 | + +**`daemon` 是纯控制台程序**:它从不调用 `StartServiceCtrlDispatcher`, +因此 SCM 启动它后收不到 `SERVICE_RUNNING` 回报,判定超时 → 报错 1053。 + +**正确做法:Windows 任务计划程序(Task Scheduler)。** +它是控制台程序常驻的标准手段,功能上等价于服务: + +| 服务能做的 | 任务计划程序对应能力 | +|---|---| +| 开机自启 | 触发器 `-AtStartup`(+ 延迟 30s 让 Gitea 先起) | +| 不登录也运行 | 以 `SYSTEM` 账户运行 | +| 最高权限 | `-RunLevel Highest` | +| 崩溃自动重启 | `-RestartCount 999 -RestartInterval 1min` | +| 开机/手动启停 | `Enable/Disable/Start/Stop-ScheduledTask` | + +### 3.1 前台先试跑(强烈建议) + +**先别急着常驻**——用前台模式确认能连上: + +```powershell +cd "D:\Docker\act_runner" +.\gitea-runner.exe daemon --config .\config.yaml +``` + +看到这两行即成功: + +``` +level=info msg="Starting runner daemon" +level=info msg="runner: win-stm32-ci, with version: v4.0.1, with labels: [windows], declare successfully" +``` + +**让这个窗口开着**,去 Gitea 页面确认 runner 是 Idle,然后 Ctrl+C 停掉、再做常驻。 + +### 3.2 🗑 已废弃:`deploy_runner_task.ps1`(2026-10-02 方案,被 §2.4 推翻) + +**不要再用它部署。** 它生成的 `run_runner.cmd` 跑的是**单次** `gitea-runner.exe daemon` +(无循环、无重启语义),而 §2.4 已实测证明 daemon 首次 Declare 失败就**正常退出**—— +计划任务把它记成"成功结束",`-RestartCount` 永不触发,于是 Gitea 重启一次 CI 就永久停摆。 + +脚本流程(供辨认用):提权检查 → 前置文件检查 → 清理旧 Windows 服务 → 生成 `run_runner.cmd` +→ `Register-ScheduledTask`(开机+登录双触发器、SYSTEM、最高权限、崩溃重启)→ 启动 → 校验存活。 +它的"崩溃重启"参数看着齐全,但**对"正常退出"完全无效**——这正是它失效的那一点。 + +> ✅ **已于 2026-10-12 删除** `D:\Docker\act_runner\deploy_runner_task.ps1` 与 `run_runner.cmd` +> (删除前核实:只有 `check_env.ps1` 的一句提示引用它,已同步改指 `ci_runner_bootstrap.ps1`; +> 计划任务的动作目标是 `run_runner_watchdog.ps1`,与这两个文件无关,删除不影响在跑的 runner)。 +> 现行部署一律用仓库里的 `scripts/ci_runner_bootstrap.ps1`。 + +### 3.3 手动等价命令(等价于 §2.4 的 bootstrap,用于理解任务对象) + +日常请直接跑 `scripts/ci_runner_bootstrap.ps1 -SetEnv -StartNow`。下面是它注册的**等价**对象, +只在需要理解字段或在别的机器上手搓时使用: + +```powershell +$Dir = 'D:\Docker\act_runner' +$launcher = Join-Path $Dir 'run_runner_watchdog.ps1' # 由 bootstrap 生成的看门狗 + +# ① 动作:跑【看门狗】而不是 daemon 本体(单次 daemon 就是 §3.2 失效的根因) +$action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" ` + -WorkingDirectory $Dir + +# ② 触发器:只要开机触发 +$trigger = New-ScheduledTaskTrigger -AtStartup +$trigger.Delay = 'PT1M' # 给 Gitea 启动留时间(看门狗自己也会等,双保险) + +# ③ 设置 +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew +$settings.StartWhenAvailable = $true # 🔴 属【任务设置】,写在触发器上会抛异常 + +Register-ScheduledTask -TaskName 'GiteaActionsRunner' -Action $action -Trigger $trigger ` + -Settings $settings -User 'SYSTEM' -RunLevel Highest ` + -Description 'Gitea Actions Runner for STM32F4-Base CI (labels: windows:host)' + +# ④ 启动 +Start-ScheduledTask -TaskName 'GiteaActionsRunner' +``` + +> ⚠️ **`ExecutionTimeLimit = 0`(不限时长)必须显式设置**—— +> 任务计划程序默认 72 小时强制结束任务,会让 runner 每 3 天被杀一次。 + +> ⚠️ **`-WorkingDirectory` 只对 `New-ScheduledTaskAction` 有效**(这是它区别于 +> WinPS 5.1 `New-Service` 的地方)。SYSTEM 账户的默认工作目录不可控,所以启动器里 +> 仍要显式 `Set-Location` 或用绝对路径。 + +> 🗑 旧方案还额外挂了 `-AtLogOn` 触发器并以 `-LogonType ServiceAccount` 运行。现行口径 +> **只留开机触发**:runner 与登录会话无关,多一个触发器只会让人误以为"必须有人登录才行"。 + +### 3.4 任务管理速查 + +```powershell +Get-ScheduledTask -TaskName GiteaActionsRunner # 状态 +Get-ScheduledTaskInfo -TaskName GiteaActionsRunner # 上次运行结果 +Start-ScheduledTask -TaskName GiteaActionsRunner # 启动 +Stop-ScheduledTask -TaskName GiteaActionsRunner # 停止 +Disable-ScheduledTask -TaskName GiteaActionsRunner # 禁用自启 +Enable-ScheduledTask -TaskName GiteaActionsRunner # 恢复自启 +Unregister-ScheduledTask -TaskName GiteaActionsRunner -Confirm:$false # 完全卸载 +``` + +改完 `config.yaml` 后需 **Stop 再 Start** 任务才生效(**纯注释改动不需要**——注释不参与解析)。 + +> 🔴 **重跑 `ci_runner_bootstrap.ps1` 会先注销再注册任务,而注销不会带走旧实例的进程**: +> 旧看门狗变成孤儿、继续养着旧 daemon,旧 daemon 占住 `.runner.lock`,新看门狗就永远起不来 +> (症状见 §7"online 却每 10 秒重启"那行)。2026-10-12 起 `-StartNow` 会**先停掉残留的看门狗与 +> daemon 再启动**并回显单实例确认;`-Status` 也会把重复实例与锁冲突报出来。 + +> 💡 **`LastTaskResult` 数值解读**(别被非 0 吓到): +> +> | 值 | 含义 | +> |---|---| +> | `267009` (`0x41301`) | ✅ **`SCHED_S_TASK_RUNNING` —— 任务正在运行,不是错误**(常驻任务常态值) | +> | `0` (`0x0`) | ✅ 上次运行正常结束 | +> | `267011` (`0x41303`) | 任务尚未运行过 | +> | `267014` (`0x41306`) | 任务被用户终止 | +> +> **判断 runner 是否健康,看这三项而不是 `LastTaskResult`**: +> ① `Get-Process gitea-runner` 有进程(其父进程应是跑 `run_runner_watchdog.ps1` 的 powershell); +> ② `log\runner-daemon.log` 最新一行是 `declare successfully`; +> ③ Gitea 页面 runner 显示 **Idle**(或 MySQL 查 `action_runner.last_online`)。 +> +> ⚠️ **`Get-ScheduledTaskInfo` 的 `Next Run Time` 为空是正常的**——触发器是"开机 +1 分钟", +> 没有下一个日历时间可报;常驻进程靠看门狗保活,不靠计划器反复拉起。别把空值当成"任务坏了"。 + +### 3.5 ⚠️ 清理遗留的无效服务 + +若早前按旧文档建过服务,删掉它(留着只会误导): + +```powershell +Stop-Service gitea-runner -Force -ErrorAction SilentlyContinue +sc.exe delete gitea-runner +``` + +> **`gitea-runner` 这个名字将不再作为服务存在。** 之后提到"gitea-runner" +> 一律指**计划任务 `GiteaActionsRunner`** 或**进程 `gitea-runner.exe`**。 + +### 3.6 ⚠️ 权限要求(本机实测的硬约束) + +**注册计划任务必须用「提权的管理员」Shell**。本机实测: + +| 检查项 | 结果 | +|---|---| +| 当前用户 | `DESKTOP-KM0OG7Q\Administrator` | +| 是否提权(`IsInRole(Administrator)`) | **False** | +| 非提权下注册任务 | ❌ `拒绝访问`(`Register-ScheduledTask` 需要 elevated token) | + +> **"是 Administrator 用户" ≠ "有管理员权限"** —— Windows 的 UAC 会把普通登录会话降权。 +> 必须**右键 PowerShell → 以管理员身份运行**,标题栏会显示「管理员」字样。 + +> 💡 计划任务以 **`SYSTEM`** 账户运行,拥有完整权限,可正常访问本地 +> `127.0.0.1:3001` 的 Gitea。 + +--- + +## 4. Runner 软件环境:装什么 + +runner 是一台干净的 Windows,**下面这些必须装**,否则 job 会失败或降级跳过。 + +| 项 | 必需性 | 影响 | 安装 | +|---|---|---|---| +| **Python 3.x** | 🔴 必需 | `l0-gates` 环境自检失败 → **job 硬失败** | `winget install Python.Python.3.12` | +| **cppcheck** | 🔴 必需 | 同上(`cppcheck --version` 探测不过) | `winget install Cppcheck.Cppcheck` | +| **pre-commit** | 🔴 必需 | 同上(`python -m pre_commit --version` 探测) | `pip install pre-commit` 或 `uv tool install pre-commit` | +| **MSVC(VS + Windows SDK)** | 🟡 建议 | 缺则 `l05-host-unit-tests` **告警降级跳过**(不硬失败,但 L0.5 裸奔) | `winget install Microsoft.VisualStudio.2022.BuildTools`,勾"使用 C++ 的桌面开发" | +| Keil / ST-LINK | ⛔ 不需要 | CI 刻意不跑 `@build` | — | +| 开发板 / 串口 | ⛔ 不需要 | L1/L2/L3 不上 CI | — | + +Git 另算:`git --version` 任意版本即可(你已在用)。 + +**本机实测状态(2026-10-02)**: + +``` +Python : C:\ProgramData\anaconda3\python.exe → 3.12.4 ✅ +cppcheck : C:\Program Files\Cppcheck\cppcheck.exe → 2.21.0 ✅ +pre-commit: 4.6.0 (anaconda3) ✅ +MSVC : VS2019 BuildTools 14.29.30133 (Hostx64\x64\cl.exe) ✅ +``` + +**→ 四项全齐,无需额外安装。** + +### 4.1 关键:变量必须对 **SYSTEM** 可见(不是"对你可见") + +runner 以 **SYSTEM 服务/计划任务**运行,它看到的 PATH 与 site-packages **和你登录时完全不同**: + +| 你以为 | SYSTEM 实际看到 | 后果 | +|---|---|---| +| `python` 在 PATH 里 | PATH 上是 `C:\msys64\ucrt64\bin\python.exe`,**没有 pre-commit** | L0 环境自检失败 | +| `pip install --user pre-commit` 装好了 | **用户级 site 对 SYSTEM 完全不可见**(换任何 python 路径都没用) | 同上 | +| 仓库里有 `.venv` | `.venv` 已 `.gitignore`,runner 全新工作区**根本没有** | 同上 | +| `cppcheck` 在 PATH 里 | 机器 PATH 无 `C:\Program Files\Cppcheck` | L0 环境自检失败 | + +> 机制补充(2026-10-02 实测):计划任务以 **`SYSTEM`** 账户运行时,继承的是**系统级 PATH** +> (注册表 `HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` 的 `Path`), +> **不含任何用户级 PATH**。核对方法:用 `check_env.ps1`(见 §9)的「系统 PATH 视角」检查项, +> 或以 SYSTEM 身份跑一次 `where python` 验证。 + +CI 侧的探测统一走 **`scripts/ci_find_python.ps1`**(两个 job 共用、可离线自测): + +``` +DTU_PRECOMMIT_PYTHON(显式;设了就必须有效,不静默回退) + -> PATH python -> 仓库 .venv -> 常见机器级安装(Program Files\Python3xx、 + %LOCALAPPDATA%\Programs\Python\Python3xx、%ProgramData%\anaconda3|miniconda3) +``` + +cppcheck 同理:`DTU_CPPCHECK` → PATH → `%ProgramFiles%\Cppcheck\cppcheck.exe` +(及 `%ProgramFiles(x86)%\Cppcheck\cppcheck.exe`)。 + +**推荐做法**:跑一次 bootstrap,把机器级变量固化下来(探测逻辑与 CI 完全一致): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv +``` + +手工自检(三条都必须对 SYSTEM 成立,而不只是对你的终端成立): + +```powershell +python --version # 期望 Python 3.x +cppcheck --version # 期望 Cppcheck 2.21.0 或更高 +python -m pre_commit --version +``` + +> ⚠️ 改了机器级变量后**必须重启 runner**(计划任务:`Stop-ScheduledTask` + `Start-ScheduledTask`) +> ——环境变量只在**进程启动时**读取,daemon 不重启就吃不到新值。 + +#### 4.1.1 ⚠️⚠️ 关键坑:`pre-commit` 必须在「全局 site-packages」,装到用户态等于没装 + +这是 2026-10-02 排查 **L0 job 反复失败**(`::error::no Python with a SYSTEM-visible pre-commit`)的真正根因, +也是本环境最隐蔽的一个坑。 + +**现象**:`pre-commit` 在你自己的终端里好好的(`pre-commit --version` 有输出), +但 CI 的 `l0-gates` job 三个候选 Python 全部探测失败: + +``` +::error::no Python with a SYSTEM-visible pre-commit. Tried: + C:\msys64\ucrt64\bin\python.exe | + \.venv\Scripts\python.exe | + C:\ProgramData\anaconda3\python.exe +``` + +> 📌 上为 2026-10-02 的历史报错原文。workflow 现已改为 +> **`DTU_PRECOMMIT_PYTHON`(机器级)→ PATH 的 `python` → 工作区 `.venv`** 顺序(§4.1.3), +> 且报错行会指明是变量无效还是自动发现落空。 + +**根因(两层)**: + +1. **用户态 site-packages 对 SYSTEM 不可见**。 + `pip install pre-commit`(不带提权)默认装到 **user-site**: + `C:\Users\Administrator\AppData\Roaming\Python\Python312\site-packages`。 + 而 SYSTEM 账户的 `%APPDATA%` 是 + `C:\Windows\system32\config\systemprofile\AppData\Roaming` —— **完全是另一个目录**, + 它根本看不到 Administrator 的 user-site。**任何 `python.exe` 路径都救不了这个**。 + +2. **pip 的「已满足」判定陷阱**(更阴)。 + 就算你**提权**执行 `pip install --no-user pre-commit`,pip 在判断 + 「这个包是不是已经装了」时看的是**整个 `sys.path`**——其中包含 user-site。 + 既然 user-site 里已有同版本的包,pip 会直接打印 + `Requirement already satisfied: xxx in ...\roaming\...\site-packages` + 然后**完全跳过**安装到全局目录。 + 结果:pip 报"成功",全局目录里却什么都没有。 + + > 实测:9 个依赖里 8 个因版本与用户态不同而被 pip 升级(侥幸落到全局), + > 唯独 `cfgv` 用户态已是同版本 **3.5.0** → 被静默跳过 → + > `-s` 视角下 `ModuleNotFoundError: No module named 'cfgv'`。 + +**诊断命令**(`-s` = 禁用 user-site,模拟 SYSTEM 视角): + +```powershell +# 看全局目录到底有没有(而不是 pip 怎么说) +& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version + +# 逐个依赖点名,找出被静默跳过的那个 +& 'C:\ProgramData\anaconda3\python.exe' -s -c "import cfgv, identify, nodeenv, virtualenv, distlib, filelock, platformdirs, yaml; print('all ok')" + +# 反查 pip 为何跳过(会显示 "Requirement already satisfied ... roaming ...") +& 'C:\ProgramData\anaconda3\python.exe' -m pip install --no-user --dry-run cfgv +``` + +**修复(必须提权)**:脚本 `install_precommit_global.ps1` + +`install_precommit_global2.ps1`(逐包安装 + 逐个 import 复验); +对**被静默跳过**的包补一支 `fix_cfgv_global.ps1`,核心是强制重装: + +```powershell +& $Py -m pip install --no-user --force-reinstall --no-deps ` + --disable-pip-version-check --no-warn-script-location cfgv +``` + +> `--force-reinstall` 无视"已满足"判定,`--no-deps` 避免牵连其它包。 +> 三个脚本都在 `D:\Docker\act_runner\`。 + +**验证(四条全过才算好)**: + +```powershell +$Py = 'C:\ProgramData\anaconda3\python.exe' +& $Py -s -m pre_commit --version # pre-commit 4.6.2 +Test-Path 'C:\ProgramData\anaconda3\Lib\site-packages\cfgv.py' # True +# 关键:确认文件真在【全局】目录,而不是 roaming +Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*' +``` + +> ⚠️ 别再往 `.venv` 里装就以为 CI 能用了——CI 虽然会检查 `.venv\Scripts\python.exe`, +> 但 `actions/checkout@v4` 拉的是**干净克隆**,`.venv` 是 gitignore 的,**根本不存在**。 + +#### 4.1.2 ⚠️ 关键坑:`doc-links` 钩子「本地过、CI 挂」= 引用了 gitignore 的运行时文件 + +**现象**:`文档链接存在性 (D-3/D-7)` 钩子在**本地** `pre-commit run --all-files` 通过, +一上 CI 就 `Failed`(exit 1),报的行/路径你在本地明明存在。 + +**根因**:CI 的 `actions/checkout@v4` 拉的是**干净克隆**,而钩子校验的是 +**仓库根相对路径是否存在**(`check_backtick_paths`,见 `scripts/check_doc_links.py`)。 +若文档引用了 **gitignore 的运行时产物**—— +如 `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建,`.gitignore:132` 排除整个 `state/`)—— +你本机因为跑过工具所以文件在,**克隆里则不存在** → 判为失效引用。 + +**修复**:按 [docs/文档规范.md §3.3](../文档规范.md) 给该行加**状态词**(脚本的 `STATUS_HINTS`: +`计划 / 未创建 / 尚未创建 / 已删除 / 删除 / 不存在 / 缺失 / 断链 / 待建 / 待创建`)。 + +```markdown +| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) | +``` + +> ⚠️ **状态词必须与引用在【同一行】且 ≤40 字符**(`STATUS_PROXIMITY`)。 +> 把说明写在**上一行**是无效的——脚本逐行调用 `has_status_hint(line, ...)`, +> 跨行的文字不在窗口内。 + +**本地复现 CI 条件的姿势**(否则永远测不出来): + +```bash +# 把运行时目录临时移开,模拟干净克隆,跑完再移回 +mv .dsh/skills/serial-assistant/scripts/state .dsh/skills/serial-assistant/scripts/state.bak_ci +& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all # 或全局 python -m pre_commit run doc-links --all-files +mv .dsh/skills/serial-assistant/scripts/state.bak_ci .dsh/skills/serial-assistant/scripts/state +``` + +> 更一般的教训:**任何校验"文件是否存在/数字是否一致"的钩子,都要在 +> "无本地产物"的干净克隆里验一次**,别只在开发机上跑。 + +#### 4.1.3 机器级 `DTU_*` 环境变量(runner 的正式覆盖通道,2026-10-02 起) + +workflow 已改为统一消费 `DTU_*` 接口(见 [本地工具路径环境变量方案.md §3.3](../环境与部署/本地工具路径环境变量方案.md)): +**显式变量优先 → 自动发现回退**,三个 job 步骤相关的变量: + +| 变量 | 作用 | 未设置时的回退 | +|---|---|---| +| `DTU_PRECOMMIT_PYTHON` | 指向装有 pre-commit 的 python.exe(L0/L0.5 用) | PATH 的 `python` → 工作区 `.venv`(干净克隆里通常没有) | +| `DTU_CPPCHECK` | 指向 cppcheck.exe(L0 钩子用) | SYSTEM 的 PATH(用户级 PATH 不可见) | +| `DTU_MSVC_CL` | 指向 x64 `cl.exe`(L0.5 用) | `%ProgramFiles%`/`%ProgramFiles(x86)%` 推导的 VS 标准根自动探测 | + +设置(**必须管理员**;scope 用 `Machine` 才能被 SYSTEM 任务看到): + +```powershell +[Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine') # 示例:本机 runner +[Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine') +[Environment]::SetEnvironmentVariable('DTU_MSVC_CL', 'C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe', 'Machine') +# ★ 设完必须重启 runner 任务(环境块是任务启动时快照的) +Stop-ScheduledTask -TaskName GiteaActionsRunner +Start-ScheduledTask -TaskName GiteaActionsRunner +``` + +> 与 §4.1.1 的关系:机器级 `DTU_PRECOMMIT_PYTHON` 是**正式通道**(指向哪个解释器由你说了算, +> 值无效时 job 直接报变量名、不猜);"把 pre-commit 装进全局 site-packages"仍是**兜底** +> (变量未设 + 全局 site 可见也能过)。两条路并存不冲突:变量设了就用变量,没设走探测。 +> 显式变量值无效(文件不存在 / 无 pre-commit 模块)→ job **硬失败**并指明变量名——**不会** +> 静默回退到别的解释器,避免 runner 悄悄换了 Python 导致钩子行为漂移。 + +### 4.2 MSVC 的路径要求(探测逻辑) + +`l05-host-unit-tests` 的 MSVC 探测优先读机器级 `DTU_MSVC_CL`(x64 `cl.exe` 完整路径; +值无效 → 硬失败、不静默回退);未设置时按**标准布局自动探测**(根目录由 +`%ProgramFiles` / `%ProgramFiles(x86)` 推导,不写死盘符;与 +`test/host/common/unit_test_harness.py` 的 `_default_vs_roots()` 保持一致): +2019 → `Program Files (x86)`;2022 / 2026(实际目录名 `18`)→ `Program Files`, +各取 BuildTools/Community/Professional/Enterprise 子目录,找 +`VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe`。 + +**本机实测命中第 1 条(2026-10-02)**: +`C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe` ✅ + +> ⚠️ **装在完全非标准位置(如 D 盘自定义目录)会探测不到** → 被当成"没装 MSVC"跳过 L0.5(仅告警)。 +> 处置(二选一):设机器级 `DTU_MSVC_CL` 指向 `cl.exe`(推荐;值无效会硬报错), +> 或把 VS 装回标准位置(探测按 `%ProgramFiles*` 推导,换盘符的标准布局仍命中)。 +> +> Windows SDK 同理:默认 `\Windows Kits\10\`;非标准位置用 `DTU_WINDOWS_KITS_ROOT` 指定。 + +> 📌 2026-10-02:workflow 与本地 harness 同步改为"变量优先 + 标准布局推导" +> ([本地工具路径环境变量方案.md](../环境与部署/本地工具路径环境变量方案.md))——旧写法要求"同步改两处",现已不需要。 + +### 4.3 验证运行器能找到工具链(注册前先跑一遍) + +```powershell +& '.venv\Scripts\python.exe' scripts/ci_local.py +``` + +这条命令跑的就是 CI 的同一套判定。**本地能过,runner 上基本就能过**(前提是 PATH 一致)。 + +--- + +## 5. 仓库端:workflow 文件 + +### 5.1 文件位置与提交 + +workflow 必须位于**仓库根**的 `.gitea/workflows/` 下(不是 `.github/`): + +``` +<仓库根>/ +└─ .gitea/ + └─ workflows/ + └─ ci.yml ← 本仓库已有,见该文件 +``` + +**它必须被 push 到远程才能生效**——目前本仓库的 `e904865` 还在本地 +(`origin/v2-on-ui` 尚未包含),所以**现在 push 之前 CI 不会跑**: + +```powershell +git push origin v2-on-ui # ⚠️ 需你明确要求才推送(本仓库约定:默认不推送) +``` + +### 5.2 触发条件 + +```yaml +on: + push: + paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**'] + pull_request: + paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**'] + workflow_dispatch: # 页面可手动触发 +``` + +- 纯文档改动**不触发**(省 runner); +- 想手动跑一次:仓库 → Actions → 选 `CI` → **Run workflow**。 + +### 5.3 内网无公网时的 action 拉取(重要) + +workflow 用了 `actions/checkout@v4`。**默认从 `github.com` 拉取**(见 §1.2.1; +不是从你自己的实例,也不是从 gitea.com —— 这点很反直觉);实例不通 +github.com 会在 checkout 步骤卡住/失败。三种处置: + +1. **镜像到自有实例**:`DEFAULT_ACTIONS_URL = self`,**并先把 `actions/checkout` 导入你自己的 Gitea**; +2. **指向可达镜像**:`DEFAULT_ACTIONS_URL` 指向某个能访问的 action 站(或在 workflow 里 + 直接写全路径 `uses: https:///owner/repo@ref`); +3. **零依赖**(内网最稳,推荐):把 checkout 换成纯 git 命令,不依赖任何 action: + +```yaml + - name: Checkout + shell: pwsh + run: | + git init . + git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git + git fetch --depth 1 origin $env:GITHUB_SHA + git checkout $env:GITHUB_SHA +``` + +> 方案 3 无需任何外部 action 与任何 `DEFAULT_ACTIONS_URL` 配置;代价是失去 checkout 的缓存优化 +> (对本仓库影响很小)。 +> 参考:Gitea FAQ「Where will the runner download scripts when using actions such as `actions/checkout@v4`?」 + +**本机环境判断(2026-10-02)**:Gitea 装了 `OFFLINE_MODE = true`,说明这台机器的外网访问 +**可能受限**。如果 runner 拉不到 github.com,checkout 步骤会卡住。 + +> ⚠️ 若走方案 1:**`self` 是关键字,不是写你的域名**(见 §1.2.1)。 + +**建议**:**先按原样试一次**。若 checkout 步骤报网络错误,再换成上面的零依赖写法(方案 3)。 + +### 5.4 产物存档与结果汇总(S3,2026-10-02) + +**目标**:CI 失败时**不查 MySQL / 不翻 runner 磁盘**,在 Gitea 页面即可看到完整输出与摘要。 + +**零依赖方案**(内网首选,**不用 `actions/upload-artifact`**): + +| 手段 | 说明 | +|---|---| +| `Tee-Object` 落盘 | 两个 job 把关键输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`、`ci-artifacts/l05-<模块>.log`(**留在 runner 工作区**,已 gitignore) | +| `$GITHUB_STEP_SUMMARY` | 写入 job 的 **step-summary**(Gitea UI 运行页直接可见,无需下钻日志) | +| `::notice::` 汇总行 | 控制台醒目行,便于日志检索 | + +**汇总行格式**: + +| 层 | 汇总行 | +|---|---| +| L0 | `L0: pre-commit hooks -- passed=N failed=M skipped=K (log: ci-artifacts\l0-pre-commit.log)` | +| L0.5 | `L0.5: runner(s), / assertions passed, fail=`(逐模块另有 `::notice::<模块>: N/M PASSED (failed=0)`) | + +> ⚠️ **`ci-artifacts/` 已加入 `.gitignore`**:它是 runner 工作区的运行时产物,不要提交。 +> +> ⚠️ **若后续改为 `actions/upload-artifact`**(需确认实例能拉该 action):把 `Tee` 落盘 +> 保留即可,上传步骤叠加在其后;本方案的落盘文件即上传源。 + +**PowerShell 陷阱(已在 workflow 内处理)**:runner 注入 `$ErrorActionPreference='Stop'`, +而 `& native 2>&1 | Tee-Object` 会把 native 的 stderr 变成 ErrorRecord → 在 `Stop` 下**整段中止**。 +故 pipeline 前后需 `Save/Restore` 该偏好为 `Continue`(与"环境自检"步骤同法)。 +`$LASTEXITCODE` 在 `Tee-Object` 之后**仍保留** native 退出码(实测),可直接用于判定。 + +### 5.5 两个 job 的步骤要点(2026-10-12 自 docs/测试说明/测试开发标准流程.md §9.2 并入) + +| job | `runs-on` | 步骤要点 | +|---|---|---| +| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(**Python 探测走 `scripts/ci_find_python.ps1`**、cppcheck 按 `DTU_CPPCHECK` → PATH → 标准安装目录,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **提交信息门禁 `scripts/ci_gitlint_range.ps1`**(见 §7 排障表"钩子条数对不上"行)→ **决定跳过哪些钩子**(探测 `.map`,写入 `GITHUB_OUTPUT`;无 `.map` 时 `::warning::` 声明体积门禁未跑)→ `pre-commit run --all-files --show-diff-on-failure`(`env: SKIP`,输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`)→ 打印 `::notice::L0: passed/failed/skipped` 汇总行 → 归档日志说明 | +| `l05-host-unit-tests` | `windows` | checkout → **探测 MSVC**(按 VS_ROOTS 找 `Hostx64\x64\cl.exe`)→ 遍历 `test/host/**/run_unit_test.py` 逐个跑(输出 `Tee` 到 `ci-artifacts/l05-<模块>.log`;解析各模块 `N/M PASSED` 汇总行 → 聚合 `::notice::L0.5: K runner(s), A/B assertions passed`;失败 `::error::` 并置退出码);**未探测到 MSVC 时打印醒目 WARNING 并降级跳过**(不硬失败,避免"没装 VS 的 runner 全红") | + +> 🔴 **两条 Python 探测链必须同序同步**:本地 `scripts/ci_local.py` 与 CI 侧 +> `scripts/ci_find_python.ps1` 的候选顺序要一致。2026-10-11 那次 L0 全灭,就是 workflow +> 单方面把候选收窄成"PATH 上的 python + 仓库 `.venv`"(而 runner 上这两条**都是死路**: +> PATH python 没装 pre-commit、`.venv` 被 gitignore 根本不存在)导致的。改一边必须改另一边。 + +--- + +## 6. 端到端验证清单 + +按顺序确认,任一步不过就停在那里排障: + +| # | 检查 | 期望 | +|---|---|---| +| 1 | 仓库顶部有 **Actions** 标签页 | 服务端开关已开 | +| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功且**在线**(`offline` = 后面全都不会跑,先做第 3 步) | +| 3 | `scripts\ci_runner_bootstrap.ps1 -Status` | 计划任务已注册 / daemon 在跑 / Gitea 可达 / 三个 `DTU_*` 已设——**四项全绿** | +| 4 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通(且对 SYSTEM 成立,见 §4.1) | +| 5 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 | +| 6 | `l0-gates` 日志 | `using python (…)` + `runner environment check passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 | +| 7 | `l0-gates` 的 **提交信息门禁** 步骤 | `::notice::gitlint: N commit message(s) checked, 0 failed`(**这一行以前根本不存在**,见 §7) | +| 8 | `l05-host-unit-tests` 日志 | `::notice::L0.5: runner(s), A/A assertions passed, fail=0`。**`N` = `test/host/` 下 `run_unit_test.py` 的个数**(2026-10-12 实测:`master` 3 个 / 288 断言,`v2-on-ui` 10 个);断言总数随单测增减而变,判定只看 **`fail=0` 且 A==B**,不要拿历史数字当基准 | +| 9 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "Commit message gate (gitlint)" / "L0.5 host-side unit tests" 小标题与汇总行 | +| 10 | 提交记录旁出现 ✅ | CI 全绿 | + +**本地先自查**(与 CI 同一套判定,避免推上去才发现问题): + +```powershell +& '.venv\Scripts\python.exe' scripts/ci_local.py +``` + +> ⚠️ **第 5 步的前提(2026-10-02 记录)是当时的最大阻塞**:本仓库的 `.gitea/workflows/ci.yml` 还在本地 +> (提交 `e904865` / `1e01950` / `52e6dcb` / `c305a8d` 均未 push)。**workflow 不上远程,CI 永远不会跑**, +> 哪怕 runner 配得再完美。 + +### 6.1 无网页登录时的状态核验(MySQL 直查) + +runner 是否在线,可以直接查 Gitea 数据库确认(本机 MySQL 凭据见 +`D:\Docker\Gitea\custom\conf\app.ini` 的 `[database]` 段): + +```bash +MYSQL="/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" +"$MYSQL" -h 127.0.0.1 -P 3306 -u wandering -p -D gitea -e \ + "SELECT id,name,version,last_online,agent_labels FROM action_runner;" +``` + +| 字段 | 判读 | +|---|---| +| `name` / `version` | 注册名与版本(应为 `win-stm32-ci` / `v4.0.1`) | +| `agent_labels` | 应为 `["windows"]` ← **不含 windows 就是标签配错了** | +| `last_online` | Unix 秒;**与当前时间相差 < 30 秒即在正常心跳** | + +```bash +date +%s # 对比 last_online +``` + +相关表:`action_runner`(已注册 runner)、`action_runner_token`(注册令牌, +字段 `repo_id` 对应 `repository.id`)。 + +--- + +## 7. 常见故障排查 + +| 现象 | 根因 | 处置 | +|---|---|---| +| 运行状态是 **`cancelled`** 且**一行日志都没有** | runner 掉线时 run 只在队列里等,**下一次 push 触发 `cancel-in-progress` 把它们取消**——所以表现为"任务凭空消失"而不是"任务失败" | 先按下一行修 runner;被取消的 run 可在页面 Re-run,或重新 push | +| Runners 页 runner 显示 **`offline`** | daemon 进程不在:`gitea-runner` v4 的 `daemon` **首次 Declare 失败就直接退出**(Gitea 重启/机器重启后无人拉起) | §2.4 用 `ci_runner_bootstrap.ps1` 装看门狗 + 计划任务;`-Status` 复查 | +| `l0-gates` 报 `no Python with a SYSTEM-visible pre-commit` | runner 是 SYSTEM:PATH 上的 python 无 pre-commit、用户级 site 不可见、`.venv` 未入库(§4.1 三重坑) | `ci_runner_bootstrap.ps1 -SetEnv` 写机器级 `DTU_PRECOMMIT_PYTHON`,或把 pre-commit 装进机器级解释器;**改完必须重启 runner** | +| `l0-gates` 报 `runner missing cppcheck` | 机器 PATH 无 Cppcheck 且无机器级 `DTU_CPPCHECK` | `-SetEnv`(会自动发现 `C:\Program Files\Cppcheck`)或装 `Cppcheck.Cppcheck` | +| **钩子条数对不上**(CI 报告条数 = 定义数 − 1;2026-10-12 实测 `master` 18 定义 / 17 报告,`v2-on-ui` 26 定义 / 25 报告——**增删钩子的分支必须重算这个差值**) | `gitlint` 的 `stages: [commit-msg]` 写在**上游** `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里 grep 不到,别据此断定"没限制"),`pre-commit run --all-files` **跑不到它**,且表现为全绿——静默空转 | 已由 `scripts/ci_gitlint_range.ps1` 单独校验;**以后增删钩子必须核对"定义数 == 日志条数 + gitlint"** | +| gitlint 步骤 **Failed 但提交信息其实合规**(同一条命令在本地全绿),日志里是 `exit code: 254` + `Missing git configuration: please set user.name` | `actions/checkout` 把 HOME 临时指向每次任务的临时目录,而 SYSTEM 账号没有全局 git 身份 → gitlint **在读到任何规则之前**就因配置错误退出(**254 不是规则违规**)。旧脚本末尾那句 `::error::...rule: single-line Chinese title, < 100 chars` 是**写死的猜测文案**,把环境问题伪装成"提交信息不合格";且该步 `exit 1` 会让后面的 `--all-files` **整个不跑**——看着像"门禁拦住了",实际 26 个钩子一个都没执行 | `ci_gitlint_range.ps1` 先探测身份,缺失就用 `GIT_CONFIG_COUNT/KEY/VALUE` 注入一次性身份(**不写任何文件**,开发者机器不受影响),并把 254 与真实违规分开报(**2 = 门禁没跑起来**,1 = 信息确实违规)。实测同一命令:空 HOME 下改前 254、改后 `0 failed`(2026-10-12 task 31 首次暴露) | +| push 后 Actions 页**空白/无任务** | ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 `docs/**`(被 `paths-ignore` 滤掉) ④ **新建分支的首次 push**(实测 2026-10-12:创建 `v2-on-ui-ci` 那次 push 没产生 run——runner 在线空闲、job 日志零新增;紧接着一次普通 push 立刻出 task。原因:`before` 是全零 SHA,变更文件集算不出来,`paths-ignore` 求值时被滤空) | ①②③ 逐条核对 §1 / §5.1 / §5.2;④ 用 `workflow_dispatch` 手动触发,或再推一个非文档路径的提交 | +| 任务一直 **Waiting / 排队** | 没有 label 匹配 `windows` 的 runner | 检查 `--labels "windows:host"`;Settings→Runners 是否 Idle | +| `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | runner 没装或不在 PATH | 装完后**重启 runner 服务**(新进程才吃到新 PATH) | +| `checkout` 步骤卡住/超时 | 拉不到 `actions/checkout@v4`(内网) | 见 §5.3 | +| `l05` 日志出现 `::warning::runner 未安装 MSVC` | VS 装在非默认路径或未装 | 见 §4.2(默认路径 / 同步改两处根目录) | +| `mem-numbers` 显示 `Skipped` | 正常(CI 无 Keil,无 `.map`) | 无需处理;但注意 **CI 未守体积门禁**(见 §0.0),改代码后本地 `@build` + `check_mem_numbers.py --scan` 兜底 | +| L0.5 显示 `2 runner(s), X/Y ...` 但 X.log` | +| L0.5 报 `produced no '=== ... N/M PASSED ===' summary line` | 该模块运行器未输出汇总行(改了 harness 或运行器崩溃) | 核对 `test/host/common/unit_test_harness.py` 的 `summary_marker` 与该模块 `TestBuild` 是否一致 | +| `Tee-Object` 步骤报 `TerminatorExpectedAtEndOfString` / 字符串缺少终止符 | `run:` 块混入了非 ASCII(GBK 解码错位) | 见文件头 🔴🔴;`run:` 内必须纯 ASCII | +| `l05` 报 `fatal error C1083: 无法打开包括文件 "stdio.h"` | Windows SDK 缺失 | 装 VS 时勾"使用 C++ 的桌面开发"+ SDK | +| 单测退出码 2 | 环境错误(多为无 MSVC) | 同上 | +| `pre-commit` 首次运行久 | 首次要下载各钩子的环境 | 正常,runner 缓存后会快 | +| `daemon` 启动报 **connection refused / dial tcp** | `--instance` 填了不可达地址 | 删 `.runner` → 重新 `register`,`--instance http://127.0.0.1:3001` | +| 启动报 **token** 相关错误 | Registration Token 过期(约 1 小时)或已用过 | 回网页重新创建 runner 拿新 token,重新 `register` | +| 任务跑到一半报 `python: 不是内部或外部命令` | SYSTEM 账户 PATH 没有 Python | 确认 Python 在**系统** PATH → 重启任务(§4.1) | +| **改了 `.runner` / 换机器** | 注册信息失效 | 删 `.runner` → 重新 `register` | +| `daemon install` 报 `accepts at most 0 arg(s)` | **v4 已移除该子命令,且无任何服务子命令** | **改用任务计划程序**(§3) | +| 🔴 `Start-Service gitea-runner` 报 **无法启动服务**(错误 1053) | **`daemon` 不是服务型程序**,不向 SCM 回报 `SERVICE_RUNNING` | **删除该服务,改用任务计划程序**(§3.0 / §3.5) | +| `Register-ScheduledTask` 报 **拒绝访问** | Shell 未提权(`IsAdmin=False`) | 右键 PowerShell → **以管理员身份运行**(§3.6) | +| 任务 `LastTaskResult` 非 0 | 启动器路径/权限问题,或任务被手工终止 | 看 `log\runner-watchdog.log` 与 `log\runner-daemon.log`;确认 `run_runner_watchdog.ps1` 存在且为纯 ASCII;`0x41301` 是"正在运行"不是错误 | +| 🔴 **runner 网页显示 online,但 `runner-watchdog.log` 每 10 秒刷 `starting daemon` → `daemon exited, exit code 1`** | **两个看门狗抢 `.runner.lock`**:`Unregister-ScheduledTask` 不会带走旧实例启动的进程,孤儿看门狗养着的旧 daemon 仍占着锁,新看门狗起不来(daemon 日志写 `another gitea-runner process is already using ".runner"` / `runner file is already locked by another process`)。此时 online 是**旧 daemon 在硬撑**,任务实际谁也没管 | `scripts\ci_runner_bootstrap.ps1 -Status` 会报出重复实例;**管理员**跑 `-StartNow`(现已会先清残留进程再启动,runner 约离线几秒)。应急可手工:`Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" \| ? CommandLine -match 'run_runner_watchdog'` 找出多个 pid,杀掉**旧的那个**,再 `taskkill /PID <旧 daemon>` 让在册看门狗接管。**✅ 2026-10-12 实测收敛**:`-StartNow` 输出 `stopping stale watchdog pid 26900 / 19716` → `stopping stale daemon pid 53848` → `daemon up: pid 40360 (single instance, holds .runner.lock)`;之后看门狗日志 25 秒零增长、runner 回 `online`。切换瞬间新看门狗可能先失败 1~2 次(旧 daemon 刚被杀、锁尚未释放),10 秒重拉后自愈,属预期 | +| `l0-gates` 报 `Cannot find: pwsh in PATH` | `shell: pwsh` = PS7,本机只有 WinPS 5.1 | 改 `shell: powershell` | +| 🔴 `doc-links` 钩子本地过、CI 挂 | 引用了 **gitignore 的运行时文件**(如 `.dsh/skills/serial-assistant/scripts/state/*.json`),本地存在、干净克隆里没有 | 该行加状态词「未创建/不存在」等(§4.1.2) | +| 任务跑满 72 小时被杀 | 计划任务默认 `ExecutionTimeLimit` | 设为 `0`(不限时长),见 §3.3 警示 | +| `config set log.file ...` 报 `unknown config key` | **v4 的 log 段无 `file` 键** | 用 `log.job.dir` 或输出重定向(§2.3) | +| **脚本报一堆 `源文本中包含无法识别的标记` / `字符串缺少终止符` / 中文显示成 `鐒跺悗鎵ц`** | 🔴 **`.ps1` 无 BOM,WinPS 5.1 按 GBK 解读 UTF-8** | **给脚本加 UTF-8 BOM**(§7.1) | + +### 7.1 🔴 必读:给含中文的 `.ps1` 加 UTF-8 BOM + +**这是本机实测踩过的坑**(2026-10-02)。Windows PowerShell 5.1 对**无 BOM** 的脚本文件 +按**系统 ANSI 代码页**(简中 = **GBK / CP936**)解码: + +- UTF-8 的中文字节被当 GBK 解 → 显示成 `鐒跺悗鎵ц` 这类乱码; +- 更糟的是,**乱码字符会撞坏字符串引号配对**(GBK 双字节的尾字节可能是 `"` 或 `\`) + → 解析器报 `源文本中包含无法识别的标记` / `字符串缺少终止符: "` / `缺少右"}"` + —— 报错行号看起来毫无规律,**极易误判成逻辑问题**。 + +**修法**:给文件头加 3 字节 `EF BB BF`(UTF-8 BOM)。与仓库里带 BOM 的源文件 +(`Drivers/BSP/UI/*`、`App/cfg_file.c`)是同一个道理。 + +```powershell +# 方法 A:PowerShell 重存为 UTF8BOM +$c = Get-Content "D:\Docker\act_runner\check_env.ps1" -Raw -Encoding UTF8 +[System.IO.File]::WriteAllText("D:\Docker\act_runner\check_env.ps1", $c, (New-Object System.Text.UTF8Encoding($true))) +``` + +```bash +# 方法 B:直接前缀 BOM 字节(bash/Git Bash) +cd "/d/Docker/act_runner" +for f in check_env.ps1; do + printf '\xef\xbb\xbf' > "$f.tmp" && cat "$f" >> "$f.tmp" && mv "$f.tmp" "$f" +done +``` + +**校验**:用 PowerShell 解析器(不执行)确认语法干净: + +```powershell +foreach ($f in @("check_env.ps1","run_runner_watchdog.ps1")) { + $errs = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile( + "D:\Docker\act_runner\$f", [ref]$null, [ref]$errs) + if ($errs.Count) { "$f : FAIL -> $($errs[0].Message)" } else { "$f : OK" } +} +``` + +> 💡 替代方案:改用 **PowerShell 7+(`pwsh`)**,它默认按 UTF-8 读脚本、无需 BOM。 +> 但本机只有 WinPS 5.1,故加 BOM 最省事。 +> +> 💡 **纯 ASCII 的脚本不受影响,也不需要 BOM**。现行两个文件的口径: +> `check_env.ps1` 含中文注释 → **必须带 BOM**;`run_runner_watchdog.ps1`(由 +> `scripts/ci_runner_bootstrap.ps1` 生成)**刻意写成纯 ASCII**,因此对编码免疫—— +> 生成它的模板也要求纯 ASCII,理由见仓库 `AGENTS.md` 的编码铁律。 +> 🗑 旧脚本 `deploy_runner_task.ps1` 已废弃(§3.2),不再作为示例对象。 + +### 7.2 日志位置与查看方式 + +**计划任务模式下看日志(两种)**: + +| 日志 | 位置 | +|---|---| +| runner **自身** stdout/stderr | `D:\Docker\act_runner\log\runner-daemon.log`(由看门狗落盘,显式 `-Encoding utf8`;**2026-10-12 之前的行**夹带 PowerShell 包装噪声,见 §7.3) | +| **看门狗自身**动作(等 Gitea / 拉起 / 退出码 / 重拉) | `D:\Docker\act_runner\log\runner-watchdog.log`(ASCII) | +| **每个 job** 的详细日志 | `D:\Docker\act_runner\log\job\` | +| 🗑 历史遗留 | `log\runner-stdout.log` 是旧 `run_runner.cmd` 时代的文件,**已不再写入**;它内部混着 ASCII 与 UTF-16LE 两种编码(PS 5.1 `*>>` 默认 UTF-16LE),按 UTF-8 读会看到"每个字节后跟一个 NUL"的 `t i m e = ...` 形态,属正常现象不是新故障 | + +**看日志**:v4 的 `log` 段**没有 `file` 键**,两种落盘方式: + +```yaml +# 方式 A:按 job 落文件(推荐,防上报失败丢日志) +log: + level: info + job: + dir: "D:\\Docker\\act_runner\\log\\job" +``` + +```powershell +# 方式 B:前台运行 / 包装脚本里重定向。 +# 🔴 不要写成 `.\gitea-runner.exe daemon > log 2>&1`:PS 5.1 的 `>` 就是 Out-File, +# 默认编码 UTF-16LE,且会把原生 stderr 包成 ErrorRecord(见 §7.3)。要这样写: +& '.\gitea-runner.exe' daemon 2>&1 | ForEach-Object { "$_" } | + Out-File -FilePath 'D:\Docker\act_runner\log\runner.log' -Append -Encoding utf8 +``` + +任务本身的历史记录:**任务计划程序 → 任务计划程序库 → `GiteaActionsRunner` → 历史记录**; +或用 `Get-ScheduledTaskInfo -TaskName GiteaActionsRunner` 看 `LastTaskResult`。 + +### 7.3 🔴 原生命令 stderr 的两个 PS 5.1 陷阱(本仓库实测,均已修) + +runner 的日志走 **stderr**,于是上面两个日志文件都栽在这两条上。两条都在 +`scripts/ci_runner_bootstrap.ps1` 生成的启动器里修掉了,改脚本时勿回退: + +**① 重定向的默认编码是 UTF-16LE。** `*>>` / `>` 在 PS 5.1 里就是 `Out-File`,默认 +`Unicode`(UTF-16LE)。同一文件里若还有 `Add-Content -Encoding ascii` 写的行,就会 +**一个文件两种编码**,按 UTF-8 读出来是 `t i m e = 2 0 2 6 ...`(每字节后跟 NUL)。 +→ **永远显式写 `-Encoding utf8`**(别用 ascii:job/提交文本可能含非 ASCII)。 + +**② `2>&1` 把原生 stderr 包成 ErrorRecord。** 实测(用无效子命令产生真实 stderr): + +| 写法 | 2 行真实日志写成 | 噪声 | +|---|---|---| +| `& $exe 2>&1 \| Out-File` | **8 行** | `gitea-runner.exe : ` 前缀 + 4 行 `CategoryInfo`/`FullyQualifiedErrorId` 块 | +| `& $exe 2>&1 \| ForEach-Object { "$_" } \| Out-File` | **2 行** | 0 | +| `& cmd.exe /c "… 2>&1" \| Out-File` | 2 行 | 0(等价,但要额外操心 cmd 对含空格路径的引号剥离) | + +即**每 4 行日志有 3 行是 PowerShell 噪声**,而且它就出现在文档让大家第一个打开的文件里。 +`ErrorRecord.ToString()` 就是原始 stderr 行,所以逐项字符串化即可去掉装饰, +且 `$LASTEXITCODE` 不受影响(看门狗靠它打印退出码)。 + +> 💡 顺带一条同类教训:**别用"日志尾部还有没有错误串"判断健康状态**。日志不轮转, +> 修好之后旧失败仍留在尾部几十行内——`-Status` 曾据此对健康 runner 报假警。 +> 现在改看**进程存活时长**(活过 10 秒重拉间隔就不可能在重启循环里)。 + +--- + +## 8. 日常维护 + +- **每周(或每次改完 Gitea/机器重启后)体检**:`scripts\ci_runner_bootstrap.ps1 -Status`—— + 计划任务 / daemon 进程 / Gitea 可达 / 三个机器级 `DTU_*`,四项全绿才算 CI 活着; +- **升级 runner**:`Stop-ScheduledTask GiteaActionsRunner` → 换 `gitea-runner.exe` → + `Start-ScheduledTask GiteaActionsRunner`(注册信息在 `.runner`,配置在 `config.yaml`); + 若换了目录/地址,重跑 `ci_runner_bootstrap.ps1` 重新生成看门狗与计划任务; +- **改了工具链探测逻辑**:Python 侧只有一处事实源 **`scripts/ci_find_python.ps1`** + (两个 job 共用),本地 `scripts/ci_local.py` 的候选表须与它**同序**; + MSVC 探测仍是 workflow 内联 + `test/host/common/unit_test_harness.py` **两处一致**; +- **钩子集变化**(`.pre-commit-config.yaml` 增删钩子):CI 自动跟随,无需改 workflow; + 但**必须核对条数**——`pre-commit run --all-files` 只跑 `pre-commit` stage, + `commit-msg` stage 的钩子(gitlint)不在其中,靠 `ci_gitlint_range.ps1` 单独守(见 §7); +- **`@build` / L1 / L2**:永远留在本地,不要往 CI 加(需 Keil 授权与真机); +- **runner 长时间不可用**时:CI 只是"多一道网",**提交前的本地 `ci_local.py` 仍是 L0 的唯一保障**, + 不能因"已经配了 CI"放宽(见 [docs/任务规划/登记簿.md](../任务规划/登记簿.md) 的 T-7「CI runner 未就绪(部署前置)」条)。 + +--- + +## 9. 一键检查脚本(可选) + +存为 `D:\Docker\act_runner\check_env.ps1`,注册前跑一遍: + +```powershell +Write-Host "=== Gitea 可达性 ===" -ForegroundColor Cyan +try { + $v = (Invoke-RestMethod -Uri "http://127.0.0.1:3001/api/v1/version" -TimeoutSec 8).version + Write-Host " OK Gitea $v (127.0.0.1:3001)" -ForegroundColor Green +} catch { Write-Host " FAIL 连不上 Gitea: $_" -ForegroundColor Red } + +Write-Host "=== 工具链 ===" -ForegroundColor Cyan +foreach ($c in @("python", "cppcheck", "git")) { + $p = Get-Command $c -ErrorAction SilentlyContinue + if ($p) { Write-Host " OK $c -> $($p.Source)" -ForegroundColor Green } + else { Write-Host " FAIL 缺 $c" -ForegroundColor Red } +} +$pc = (python -m pre_commit --version 2>&1) +if ($LASTEXITCODE -eq 0) { Write-Host " OK $pc" -ForegroundColor Green } +else { Write-Host " FAIL 缺 pre-commit" -ForegroundColor Red } + +Write-Host "=== DTU_* 机器级变量(§4.1.3)===" -ForegroundColor Cyan +foreach ($n in @("DTU_PRECOMMIT_PYTHON", "DTU_CPPCHECK", "DTU_MSVC_CL")) { + $v = [Environment]::GetEnvironmentVariable($n, "Machine") + if ($v) { Write-Host " OK $n = $v" -ForegroundColor Green } + else { Write-Host " -- $n(未设置:workflow 将走自动发现)" -ForegroundColor Yellow } +} + +Write-Host "=== MSVC cl.exe(DTU_MSVC_CL 优先,否则标准布局推导)===" -ForegroundColor Cyan +if ($env:DTU_MSVC_CL -and (Test-Path $env:DTU_MSVC_CL)) { + Write-Host " OK $($env:DTU_MSVC_CL)(来自 DTU_MSVC_CL)" -ForegroundColor Green +} else { + $pf = ${env:ProgramFiles} + $pf86 = ${env:ProgramFiles(x86)} + $roots = @( + (Join-Path $pf86 'Microsoft Visual Studio\2019\BuildTools'), + (Join-Path $pf86 'Microsoft Visual Studio\2019\Community'), + (Join-Path $pf86 'Microsoft Visual Studio\2019\Professional'), + (Join-Path $pf 'Microsoft Visual Studio\2022\BuildTools'), + (Join-Path $pf 'Microsoft Visual Studio\2022\Community'), + (Join-Path $pf 'Microsoft Visual Studio\2022\Professional'), + (Join-Path $pf 'Microsoft Visual Studio\2022\Enterprise'), + (Join-Path $pf 'Microsoft Visual Studio\18\BuildTools'), + (Join-Path $pf 'Microsoft Visual Studio\18\Community') + ) + $hit = $null + foreach ($r in $roots) { + if (Test-Path $r) { + $hit = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1 + if ($hit) { break } + } + } + if ($hit) { Write-Host " OK $($hit.FullName)" -ForegroundColor Green } + else { Write-Host " FAIL 未找到 MSVC (Hostx64\x64\cl.exe);可设机器级 DTU_MSVC_CL" -ForegroundColor Red } +} +``` + +--- + +## 10. 最小操作清单(TL;DR) + +```powershell +# 1) 下载(v4.0.1;注意仓库名是 gitea/runner,二进制名是 gitea-runner) +New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null +cd "D:\Docker\act_runner" +Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" -OutFile "gitea-runner.exe" + +# 2) 注册(token 从网页 Settings→Actions→Runners 拿;instance 用回环) +.\gitea-runner.exe register --no-interactive ` + --instance "http://127.0.0.1:3001" ` + --token "<粘贴 token>" ` + --name "win-stm32-ci" ` + --labels "windows:host" + +# 3) 前台试跑,确认能连上 +.\gitea-runner.exe daemon # 看到 "labels: [windows] ... declare successfully" 即成功,Ctrl+C 停 + +# 4) 常驻 —— ⚠️ daemon 不是服务型程序,且首次 Declare 失败就直接退出进程 +# 所以必须【看门狗循环 + 开机计划任务】,一条命令搞定(管理员 PowerShell,在仓库根目录): +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow + +# 体检(四项:计划任务 / daemon 进程 / Gitea 可达 / 机器级 DTU_*)—— 须管理员: +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status +# 干跑(只打印将要写入的启动器与任务对象,不落盘不注册)—— 免管理员: +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun + +# 5) 工具链必须对 SYSTEM 可见(runner 以 SYSTEM 跑,看不见交互用户的 PATH 与用户级 site) +# 正式通道 = 机器级 DTU_* 变量,上一步的 -SetEnv 已自动发现并写入;需手工覆盖时: +# (管理员 PowerShell;下面是本机实测现值,换机按实际路径改) +# [Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\Anaconda3\python.exe', 'Machine') +# [Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine') +# [Environment]::SetEnvironmentVariable('DTU_MSVC_CL', '<本机 x64 cl.exe 完整路径>', 'Machine') +# 兜底通道(只有变量没设时才会走到):把 pre-commit 装进【全局】site-packages,见 §4.1.1 +# ⚠️ 改过任何环境变量都必须 Stop 再 Start 任务——环境块是任务启动时的快照 + +# 6) 复验(-s = 模拟 SYSTEM 视角,必须过) +& 'C:\ProgramData\Anaconda3\python.exe' -s -m pre_commit --version # pre-commit 4.6.2 + +# 7) 回网页确认 runner 为 Idle → 触发 CI +# push 触发(注意:纯 .md 改动在 workflow 的 paths-ignore 里,不会触发); +# 手动触发: +# curl -u ":" -X POST -H "Content-Type: application/json" ` +# -d '{"ref":"v2-on-ui"}' ` +# "https://gitea.bitnasdaq.vip/api/v1/repos/wandering1/STM32F4-Base/actions/workflows/ci.yml/dispatches" +``` + +> 🗑 本清单第 4 步在 2026-10-02 版里是"计划任务跑**单次** daemon"(`deploy_runner_task.ps1`)。 +> 2026-10-11 实测证明单次 daemon 首次 Declare 失败即退出、计划任务的失败重启救不到它, +> 故已改写为看门狗方案;旧脚本的失效机理见 §3.2。 + +> **CI 验收标准**:两个 job 都要 `status=1`。**口径是规则,不是具体数字**(2026-10-12): +> `l0-gates` 报 **定义数 − 1 条钩子**(gitlint 属 `commit-msg` stage,不进 `--all-files` 报告, +> 见 §7),其中 `mem-numbers` 在无 `.map` 时为 `Skipped`(本机唯一常见跳过项);另有提交信息 +> 门禁 `::notice::gitlint: N checked, 0 failed`。`l05-host-unit-tests` 报 +> **` runner(s), A/A assertions passed, fail=0`**,`N` = `test/host/` 下 `run_unit_test.py` 个数。 +> 实测快照:`master` 18 定义 / 17 报告、3 套件 / 288 断言;`v2-on-ui` 26 定义 / 25 报告、10 套件。 +> 🗑 更早快照(勿当基准):run#8(2026-10-02)当时是"14 个钩子全 Passed + 39/39 单测"。 +> **判定只看 `fail=0`、`failed=0` 与 A==B**;增删钩子或单测后按上面的规则重算,别抄历史数字。 + +**配套脚本**: + +| 脚本 | 位置 | 用途 | 需要管理员 | +|---|---|---|---| +| `ci_runner_bootstrap.ps1` | **仓库 `scripts/`** | **现行唯一部署/体检入口**(`-Status` / `-DryRun` / `-SetEnv -StartNow`) | `-Status`、`-SetEnv -StartNow` 需要 | +| `check_env.ps1` | `D:\Docker\act_runner\` | 环境自检(Gitea 可达 / 工具链 / MSVC / 任务状态) | 否 | +| `run_runner_watchdog.ps1` | `D:\Docker\act_runner\` | **看门狗启动器**,由 bootstrap 生成,是计划任务的动作目标。**纯 ASCII,不需要 BOM** | 由任务以 SYSTEM 跑 | +| 🗑 `deploy_runner_task.ps1`、`run_runner.cmd` | `D:\Docker\act_runner\` | **已废弃**(单次 daemon,§3.2)。仍在磁盘上,建议改名加 `.deprecated` 或删除,免得误执行把看门狗任务覆盖回单次任务 | — | + +> 更早基于 Windows 服务的 `deploy_runner.ps1` / `install_service.ps1` 已删除 +> —— 那条路走不通(§3.0)。 + +--- + +## 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-12 | 🐛 **第一次真跑出 run,当场暴露 gitlint 门禁空转**:把本轮修复推到新分支 `v2-on-ui-ci` 后拿到 task 31/32——**L0.5 成功**(`10 runner(s), 647/647 assertions passed, fail=0`,与本地逐字一致),**L0 失败**却与提交信息毫无关系:`exit code: 254` + `Missing git configuration: please set user.name`。根因是 `actions/checkout` 把 HOME 指向每次任务的临时目录、SYSTEM 账号没有 git 身份,gitlint 在读到规则之前就配置错误退出;旧脚本还把失败原因**写死**成"标题超 100 字符",加上该步 `exit 1` 让后面 26 个钩子**一个都没跑**。修法:脚本先探测身份,缺失就用 `GIT_CONFIG_COUNT/KEY/VALUE` 注入一次性身份(不写文件、不碰开发者机器),并区分 254(门禁没跑起来 → `exit 2`)与真实违规(→ `exit 1`)。另记一条新坑:**新建分支的首次 push 不产生 run**(§7 排障表 ④) | +| 2026-10-12 | 🔀 **随 `master` 合入 `v2-on-ui`:数字口径改为分支中立**。本指南原先写死 master 的数字(18 钩子 / 17 报告、3 runner / 288 断言),而 `v2-on-ui` 是 **26 钩子 / 10 套件**——同一篇文档在两个分支上必然有一个是错的。§6 验收第 8 行、§7 排障表、§10 验收标准三处改为讲规则(**报告数 = 定义数 − 1**;**runner 数 = `test/host/` 下 `run_unit_test.py` 个数**)+ 分分支实测快照。本次合并只带文档与脚本,**零 C 代码改动**;合并后本分支 `@build` 0 错 0 警、`ci_local.py` 全绿(L0 25 钩子 + L0.5 10 runner / 647 断言) | +| 2026-10-12 | ✅ **双看门狗缺陷实测收敛** + 修掉日志里的 PS 5.1 stderr 包装噪声与 `-Status` 假警。① 管理员 `-StartNow` 杀掉 3 个残留(孤儿看门狗 26900/19716 + 占锁 daemon 53848)→ `daemon up: pid 40360 (single instance)`,监管链 `svchost → 看门狗 → daemon`,看门狗日志 25 秒零增长、runner 回 online。② 新坑:daemon 日志写 **stderr**,PS 5.1 的 `2>&1` 把每行包成 ErrorRecord → `gitea-runner.exe : ` 前缀 + 4 行 `CategoryInfo` 块,实测 2 行真日志写成 8 行(**每 4 行有 3 行是噪声**,且就在文档让大家第一个打开的文件里)。启动器改为 `2>&1 \| ForEach-Object { "$_" } \| Out-File -Encoding utf8`(实测与 cmd 包装等价但免引号剥离坑,`$LASTEXITCODE` 不受影响);§7.2 方式 B 的示例原本正是 `> log 2>&1`(UTF-16LE + 包装双坑),已改;新增 §7.3 汇总这两条。③ `-Status` 两处诚实化:非管理员**读不到 SYSTEM 进程命令行**(`CommandLine` 为 null → 过滤器静默匹配 0 个),改为明说"需提权"而不是谎报"没有看门狗";重启循环判据不再靠"日志尾部有没有错误串"(文件不轮转,修好后旧失败仍在尾部 → 对健康 runner 报假警),改看 **daemon 进程存活时长**(CIM `CreationDate` 非管理员可读,`Process.StartTime` 对 SYSTEM 进程不可读) | +| 2026-10-12 | 🔴 **实测揪出"双看门狗抢锁"缺陷并修复** + 机器侧清理。① 现象:runner 网页 online、`-Status` 说 daemon 在跑,但 `runner-watchdog.log` 每 10 秒刷 `starting daemon → daemon exited, exit code 1`,daemon 日志 `runner file is already locked by another process`。② 根因:`Unregister-ScheduledTask` **不停旧实例启动的进程**——13:22 重跑 bootstrap 时旧看门狗(12:53 起)成孤儿并继续养着旧 daemon、占住 `.runner.lock`,新看门狗永远抢不到锁,任务实际谁也没管,日志以约 20 MB/天在刷。③ 修复:`ci_runner_bootstrap.ps1` 的 `-StartNow` 现在**先停孤儿看门狗与残留 daemon 再启动**并回显单实例确认;`-Status` 新增重复实例计数与锁冲突检测。④ 机器侧清理:`config.yaml` 头部那句**错误指引**("常驻改用 Windows sc.exe / New-Service",正是 §3.0 证伪的路)改为看门狗口径并指向本文;删除已废弃的 `deploy_runner_task.ps1` 与 `run_runner.cmd`(§3.2),`check_env.ps1` 指向它们的提示同步改指 bootstrap。⑤ 核实:`config.yaml` 改后 YAML 可解析且**所有配置值与改前逐项一致**(只动注释);`check_env.ps1` 带 BOM 且语法 0 错;删除前确认计划任务动作目标是 `run_runner_watchdog.ps1`,删除不影响在跑 runner | +| 2026-10-12 | 🧹 **单一口径清理(以本机实测现状为准)**:合并稿初版对 4 处分歧采取"两说并存 + 衔接注",本次统一。① 注册口径——`act_runner v0.2.11` + `C:\act_runner` + 公网域名 instance 一段**删除可复制命令**,只留"勿照抄"警示(核实 `C:\act_runner` 至今不存在,本机是 `gitea-runner v4.0.1` + `D:\Docker\act_runner` + `127.0.0.1:3001`);② `config.yaml` 示例 `fetch_timeout` 由 `5m0s` 改为磁盘实际值 **`1m0s`**、`timeout` 统一 `30m0s`,并补"不得超过 RPC timeout(1m0s)";③ 常驻口径统一为看门狗——§3.2 `deploy_runner_task.ps1` 标为**已废弃**并说明失效机理(单次 daemon = 正常退出,失败重启永不触发),§3.3 手动等价命令**重写为看门狗版任务对象**(旧版会教人手搓出坏配置),§10 TL;DR 第 4 步改指 `ci_runner_bootstrap.ps1`;④ 数字口径——L0.5 期望值更新为 **288/288**(run#8 的 39/39 与 278/278 标注为历史快照,判定只看 `fail=0` 与 A==B),钩子数按现行 18 定义 / 17 报告。另同步:健康三查与排障表改用现行日志名(`runner-daemon.log` / `runner-watchdog.log`,`runner-stdout.log` 标为遗留且说明其混编码)、BOM 示例对象换成 `check_env.ps1` 并注明看门狗是纯 ASCII 无需 BOM、配套脚本表补 `run_runner_watchdog.ps1` 并标出磁盘上仍存的废弃脚本建议删除、补 `Next Run Time` 为空属正常的说明 | +| 2026-10-12 | **文档合并**:本次把 `CI配置指南.md` 与 `Gitea_Runner部署指南.md` 合并为本篇(`docs/CI/远程CI完整指南.md`)并迁入 `docs/CI/`,去重(runner 注册 / config.yaml / 装软件 / 排障表两篇均有)、补入 runner 看门狗与机器级变量实测教训;相对链接按新位置改写,原两篇互引改为本文内部引用 | +| 2026-10-11 | **bootstrap 自身修复**:`StartWhenAvailable` 误设到**触发器**上(该属性属**任务设置**,触发器没有)→ 脚本在"已注销旧任务、尚未注册新任务"之间抛 `SetValueInvocationException`,把 runner 任务删没了。现改为①先构建 action/trigger/settings、**后**注销旧任务,②`Register-ScheduledTask` 失败时显式告警"旧任务已删、CI 裸奔",③新增 **`-DryRun`**(免管理员即可全量校验任务对象),④`-Status` 在非管理员下额外提示"看不见任务 ≠ 任务不存在"。同时更正根因:旧任务其实存在,但跑的是**单次** `daemon`——daemon 属"正常退出"而非"失败",计划任务的失败重启**救不到**它,必须用看门狗循环重拉 | +| 2026-10-11 | **修"很多任务不执行"(runner 静默停摆)+ 补 gitlint 门禁**:① §2.4 重写——`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败即退出进程**(日志实证),Gitea/机器重启一次 CI 就停摆,**run 16~21 共 12 个 job 零日志**(`cancelled`/`queued`,叠加 `cancel-in-progress` 后表现为"任务凭空消失"而非失败);新增 **`scripts/ci_runner_bootstrap.ps1`**(等 Gitea 就绪 + 崩溃自拉起的看门狗 + 开机计划任务 + `-SetEnv` 写机器级 `DTU_*` + `-Status` 只读体检)。② §3.1 重写——SYSTEM 可见性三重坑(PATH python 是 msys64 无 pre-commit / 用户级 site 对 SYSTEM 不可见 / `.venv` 已 gitignore)是 run 14~15 L0 全灭根因(`33a4aa4` 把候选收窄成这两个、又删了 cppcheck 目录兜底,而机器级 `DTU_*` 从未设置);探测收进 **`scripts/ci_find_python.ps1`**(两 job 共用、可离线自测、失败列出全部候选)并恢复机器级安装兜底。③ 新增 **gitlint 提交信息门禁**:钩子定义数比 CI 报告条数多 **1**(当时 17/16,master 并入 `test-cppcheck` 后为 18/17),缺的正是 gitlint——其 `stages: [commit-msg]` 声明在**上游** `.pre-commit-hooks.yaml`(`--all-files` 跑不到,且表现为全绿 → 长期无人发现)→ **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR 范围(消息文件须写在仓库内 + UTF-8 无 BOM)。④ `GITHUB_ENV/PATH/OUTPUT` 写入统一显式 `-Encoding ascii`(PS 5.1 `Out-File -Append` 默认 **UTF-16LE**,实测落盘 `42 00 42 00 42 00 3D 00 31 00`)。§5 清单 8→10 条、§6 排障表 +5 行、§7 维护项重写 | +| 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 | +| 2026-10-02 | **workflow 接入 `DTU_*` 机器级变量**(`本地工具路径环境变量方案.md` 实施):`l0-gates` 的 Python 探测改为 `DTU_PRECOMMIT_PYTHON`(显式,无效即硬失败)→ PATH → `.venv`(删除 `C:\ProgramData\anaconda3` 固定候选);cppcheck 改为 `DTU_CPPCHECK` 优先 → PATH(**移除 `C:\Program Files\Cppcheck` 固定回退**);`l05` 的 MSVC 探测改为 `DTU_MSVC_CL` 优先 → `%ProgramFiles*` 推导的标准根(与 `unit_test_harness.py` 同步,不再写死盘符);新增 §5.1.3 机器级变量设置方法(`Machine` scope + 重启任务)、§5.2 与 check_env 样例、排障表 cppcheck 行、TL;DR 5b 步骤。§5.1.1 的报错原文标注为历史记录 | +| 2026-10-02 | ✅ **首次 CI 全绿(run#8:L0 + L0.5 双 job Success)**。定位并修复最后两个拦路虎:① **§5.1.1 用户态 site-packages 陷阱**——`pre-commit` 只装在 `%APPDATA%\Roaming\Python\...`(SYSTEM 不可见);且提权 `pip install --no-user` 会因 pip 扫**整个 `sys.path`**、见 user-site 已满足而**静默跳过**全局安装(9 依赖中唯 `cfgv` 因版本相同被跳过→`-s` 下 `ModuleNotFoundError`)。修法:提权 + `--force-reinstall --no-deps` 强制落全局,并以 `-s` 复验。② **§5.1.2 doc-links 假阳性**——文档引用了 gitignore 的运行时文件 `.dsh/skills/serial-assistant/scripts/state/meta.json`,本地存在、干净克隆无 → 按《文档规范》§3.3 加同状态词「运行时未创建」。排障表补 6 条(pre-commit 不可见 / GBK 中文 / pwsh / cppcheck / doc-links / MSVC 降级)。新增配套脚本 `install_precommit_global.ps1`、`install_precommit_global2.ps1`、`fix_cfgv_global.ps1` | +| 2026-10-02 | 🔴 **常驻方式重大修正(实测推翻)**:`Start-Service gitea-runner` 必然失败。经二进制逆向确认 `gitea-runner v4.0.1` **无任何服务管理代码**(命令集仅 `bug-report/cache-server/config/daemon/exec/register`,无 `service.go`;`OpenSCManagerW` 等符号来自 Docker SDK,非服务安装代码),`daemon` 是纯控制台程序、不向 SCM 回报 `SERVICE_RUNNING`。**§4 全节重写为「任务计划程序」方案**(新增 §4.0 根因分析、§4.2 `deploy_runner_task.ps1`、§4.3 手动命令 + 72h 超时警示、§4.5 清理遗留服务、§4.6 提权);删除失效的 `deploy_runner.ps1`/`install_service.ps1`;排障表补「错误 1053」「LastTaskResult 非 0」「72 小时被杀」3 条;拓扑图/§5.1/§7 日志/§10 TL;DR 同步更新。另修 `config.yaml` 的 `fetch_timeout` 5m→1m(超出 RPC 1m 会被截断并告警) | +| 2026-10-02 | **修 `.ps1` 编码坑(实测)**:无 BOM 的 UTF-8 脚本被 WinPS 5.1 按 GBK 解读 → 中文乱码(`鐒跺悗鎵ц`)并撞坏引号配对,报 `源文本中包含无法识别的标记`/`字符串缺少终止符`。**新增 §7.1「给含中文的 `.ps1` 加 UTF-8 BOM」**(两种修法 + 解析器校验命令);排障表补该条目;配套脚本已统一加 BOM | +| 2026-10-02 | **按实测修正为 v4.0.1**:act_runner 已更名 **`gitea-runner`**,发布页迁至 `gitea.com/gitea/runner`。**v4 移除 `daemon install/start/stop/status`**;**v4 `log` 段无 `file` 键**(§3 改用 `log.job.dir`);补 §1.3 命令集对照、§6.1 MySQL 直查核验、排障表(`accepts at most 0 arg(s)` / `unknown config key log.file` 等) | +| 2026-10-02 | 初版:针对**本机 Gitea 原生部署**(`D:\Docker\Gitea\gitea.exe` + Windows 服务 + `127.0.0.1:3001`)定制 Runner 端部署全流程。核心要点:`--instance` 用回环地址、`--labels "windows:host"` 必须精确匹配 `runs-on`、PATH 运行账户坑、MSVC 路径约束、内网 action 拉取的零依赖改法 | +| 2026-10-01 | **修正 `DEFAULT_ACTIONS_URL` 说明**(原 §1.2 示例误导):新增 §1.2.1 明确它控制的是 **action 下载源**(默认 `github.com`;`self` = 从自己实例拉且需先镜像 action),**不是填自己的 Gitea 地址**;§4.3 同步修正(原文误写"gitea.com 代理"),并补零依赖写法为内网首选。依据:Gitea 官方 FAQ + config-cheat-sheet | +| 2026-10-01 | 初版:Gitea Actions 配置全流程(服务端开关 / Windows runner 注册 / runner 软件环境 / workflow 位置与触发 / 内网 action 拉取 / 端到端验证清单 / 故障排查 / 日常维护) | diff --git a/docs/README.md b/docs/README.md index cca2ac08..d6bcb60b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ docs/ ├── 内存管理/ RAM 预算、分配策略与优化调研 ├── 问题记录/ 模块陷阱台账、专项审计与项目评审报告 ├── 环境与部署/ 环境搭建与部署核对清单 +├── CI/ 远程 CI(Gitea Actions)唯一入口 —— **只涉及本机一台 runner,日常开发不必读** ├── UI设计/ UI 架构、页面设计、字体与模拟器 ├── 硬件平台/ 网表分析报告与语义图 ├── 芯片手册/ 外部芯片数据手册转录(只读资料) @@ -221,6 +222,7 @@ docs/ |---|---| | [`项目部署环境要求.md`](环境与部署/项目部署环境要求.md) | 开发机软件环境、目标板资源预算、现场部署与验收步骤 | | [`并行任务工作树方案.md`](环境与部署/并行任务工作树方案.md) | **多任务并行开发机制**:`git worktree` 隔离、目录与共享/独占分配(junction 规则)、`task-worktree`(`@task`)SKILL 命令面、单写者文档与合并残留审计、台架锁;**已落地并端到端实测**(见其 §11) | +| [`CI/README.md`](CI/README.md) | **远程 CI(Gitea Actions)唯一入口**:服务端开关 / runner 部署与保活 / workflow / 排障。**只涉及本机一台 runner `win-stm32-ci`,其他机器与项目都不涉及**;日常改代码用本地门禁 `scripts/ci_local.py`,不必读这里 | ### 9.3 台账与外部资料 @@ -270,6 +272,7 @@ docs/ | 日期 | 变更 | 说明 | |---|---|---| | 2026-10-12 | 新增环境与部署 | 新建 `docs/环境与部署/并行任务工作树方案.md`(**多任务并行开发机制规格**:`git worktree` 隔离、目录与共享/独占分配、`task-worktree` / `@task` skill 设计、单写者文档与合并残留审计、台架锁;**已落地并端到端实测**);本索引 §9.2 补登记 | +| 2026-10-12 | 远程 CI 文档独立成目录 | 新建 `docs/CI/`:原散在 4 处的远程 CI 内容(`环境与部署/CI配置指南.md` 477 行 + `环境与部署/Gitea_Runner部署指南.md` 917 行 + `测试开发标准流程.md` §9 + `AGENTS.md` 提交门禁节 4 条,共约 2000 行)**合并为 `CI/远程CI完整指南.md` 一篇**,`CI/README.md` 作唯一入口;`AGENTS.md` 压成一行指针、测试流程 §9 只留覆盖边界与本地等价。动因:远程 CI **只跑在本机一台 runner**,与日常开发规范混在一起每次会话都被读,造成困惑 | | 2026-10-11 | 功能 | N-19 FTP 鉴权:`/config.ini` 新增 `[ftp]` 段(`user` / `pwd_hash` = SHA-256(user‖password) / `write`)+ 恒定时间比较 + 连续 5 次失败锁 60 s + 未登录命令门;出厂默认 admin/admin,默认口令在用有启动告警。新增 `App/util/sha256.*`、`Drivers/BSP/NETCFG/ftp_cfg.*`、`Drivers/BSP/NET/lftpd/lftpd_auth.*`;主机侧单测 `test/host/sha256/`(13 例)+ `test/host/ftp_auth/`(36 例),pre-commit 钩子 26→28;台架脚本 `test/host/ftp_auth_bench.py`。文档同步:`docs/子系统设计/网络配置说明.md`、`docs/架构设计/STM32F4-Base存储架构说明.md`、`docs/代码规范/pre-commit使用说明.md`、`docs/产品需求/产品需求设计文档.md` | | 2026-10-11 | 文档调整 | `AGENTS编写指南.md` 新建后**同日删除**(要点已沉淀入 `AGENTS.md` 宏观重构与本索引变更记录,指南类内容不单独成文);撤销 §1 目录树与 §7.1b 登记;同日 `AGENTS.md` 完成宏观重构(补项目概况/目录地图、平台约束分组、边界块) | | 2026-10-10 | 新增子系统设计 | 新建 `docs/子系统设计/ADC采样节拍设计说明.md`(ADC 采集链路 1 kHz 硬件定时器触发的 CubeMX 配置规格与代码改造清单,B1 遗留项规划);本索引 §5.1 补登记 | diff --git a/docs/images/Net层阻塞模型.svg b/docs/images/Net层阻塞模型.svg index fc2df1d5..ca8de14e 100644 --- a/docs/images/Net层阻塞模型.svg +++ b/docs/images/Net层阻塞模型.svg @@ -50,7 +50,7 @@ net_pending_tick() 了结挂起 -net_relisten_retry_tick() +net_listener_retry_tick() 重试对账 排空消息队列 diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index af3a4732..be17c617 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -396,6 +396,17 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 | **为什么需要** | 板端复现这些分支要真主站 + 真等待(t1=15 s、k 窗满要发满 12 帧);link 层 tick 注入 + pts vtable 抽象就是为 L0.5 毫秒级穷举设计;数据源依赖进触发面——测试直接 `#include` 这些生产源,签名变了立刻暴露 | | **运行器** | `test/host/iec104/run_unit_test.py`(MSVC `cl.exe`,L0.5) | +### 2.21 freq-unit-test — 频率测量层主机侧单测(2026-10-12 加,B8) + +| 项 | 说明 | +|---|---| +| **触发条件** | `App/algo/freq.*`、`App/algo/phasor.*`、`App/algo/sample_ring.*`、`test/host/freq/**` 或 `test/host/common/unit_test_harness.py` 变动 | +| **处理方式** | **阻止提交**(单测失败即拦截) | +| **被测对象** | `App/algo/freq.c/h`(L2.5 测频:共轭乘积相位增量 + 解缠绕 + 自适应窗长 K_eff 对齐负序泄漏纹波周期 + 假值钳制带 35~65 Hz + 幅值门 valid=0)——纯合成信号、零 I/O | +| **用例** | 46 断言:J1 扫频 45~55 Hz ≤0.02 Hz(实测 0.0101)/ J2 噪声 1% ≤0.10 Hz / J3 启动收敛 ≤40 ms / J4 阶跃 50→51 ≤120 ms(精度优先于响应,见 `App/algo/README.md` §3.7)/ J5 浮空 valid=0 不发散 / J6 双跳变钳制恢复 | +| **为什么需要** | 测频精度是 OF/UF 判据的地基;板端无标准源照不出来(规划 §3.3 L3-5 注入验收归 B10);定长窗负序泄漏纹波(0.317 Hz)这类坑只有扫频合成信号才能暴露 | +| **运行器** | `test/host/freq/run_unit_test.py`(MSVC `cl.exe`,L0.5) | + --- ## 3. 日常工作流 @@ -523,7 +534,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**31 个钩子**,2026-10-12 清点;本文 §2 详解非单测类——N-19 新增的两个单测钩子见 §2.16/§2.17,N-23 双端注册反向校验见 §2.18,N-27 RAM 预算门禁见 §2.19,B4 IEC104 协议栈单测见 §2.20,其余主机侧单测见 AGENTS.md 门禁条;主机侧单测共 13 个) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**32 个钩子**,2026-10-12 清点;本文 §2 详解非单测类——N-19 新增的两个单测钩子见 §2.16/§2.17,N-23 双端注册反向校验见 §2.18,N-27 RAM 预算门禁见 §2.19,B4 IEC104 协议栈单测见 §2.20,B8 频率测量单测见 §2.21,其余主机侧单测见 AGENTS.md 门禁条;主机侧单测共 14 个) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | diff --git a/docs/任务规划/README.md b/docs/任务规划/README.md index 5191e452..1d7e004f 100644 --- a/docs/任务规划/README.md +++ b/docs/任务规划/README.md @@ -90,7 +90,7 @@ | 波次 | 内容 | 为什么在这 | |---|---|---| -| **W1 采集+保护收尾** | **里程碑制 + 并行道**:M1 台架闭环(台架道 B6 ✅→N-33)∥ M2 功能面(代码道 B7 ✅→{B8∥B9})→ M3 送检(B10,Fixed Date)——任务卡/判据/写范围见 `W1-采集保护收尾.md` | **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**。B4/B5 代码已落地但未台架闭环、功能面不完整(定值不落盘 / 无频率保护 / 无动作记录 / 栈两处 FAIL)——本波收口到"可用 + 可验收";真约束只有**台架**与**送检窗口**两个稀缺资源,其余任务按写范围切分并行 | +| **W1 采集+保护收尾** | **里程碑制 + 并行道**:M1 台架闭环 ✅ 达成(B6 ✅→N-33 ✅)∥ M2 功能面(代码道 B7 ✅→{B8∥B9})→ M3 送检(B10,Fixed Date)——任务卡/判据/写范围见 `W1-采集保护收尾.md` | **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**。B4/B5 代码已落地但未台架闭环、功能面不完整(定值不落盘 / 无频率保护 / 无动作记录 / 栈两处 FAIL)——本波收口到"可用 + 可验收";真约束只有**台架**与**送检窗口**两个稀缺资源,其余任务按写范围切分并行 | | **W2 数据正确性** | **N-21** dhara `is_free`/`copy`(报告 N-12/13) | 低概率、后果重(数据丢失);修复须配主机侧移植层测试,一并补报告"覆盖率"缺口 | | **W3 预算与内存** | **N-27 后半**:R-9 池 40→16 KB → R-2 后半(54 KB→CCM)→ R-3~R-6 逐项评估 | 依赖 W1 三件套产出的台账数据;**B5 堆扩容后 CCM 只剩 ≈8 KB**(56 KB 堆),R-2 后半更被堵死(解锁链见 `docs/内存管理/RAM预算台账.md` §3) | | **W4 结构债** | **N-20 → C6(S-3)→ C3(N-2+N-24)→ C1 pending 二期 → C5(含 N-23 残余)→ N-22/N-25/N-26/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5) | @@ -103,8 +103,8 @@ ### 2.1 顺序一图 ``` -W1 收尾 台架道: B6✅→N-33(M1) ∥ 代码道: B7✅→{B8∥B9}(M2) → B10 送检(M3, Fixed Date) 详表 W1-采集保护收尾.md - (B4 IEC104 ✅ / B5 protectTask ✅ / B6 台架复测 ✅ / B7 定值持久化+软压板 ✅ 2026-10-12 已销项;台账三件套 ✅) +W1 收尾 台架道: B6✅→N-33✅(M1 ✅ 2026-10-12) ∥ 代码道: B7✅→B8✅→B9(M2) → B10 送检(M3, Fixed Date) 详表 W1-采集保护收尾.md + (B4 IEC104 ✅ / B5 protectTask ✅ / B6 台架复测 ✅ / B7 定值持久化+软压板 ✅ / N-33 栈收口 ✅ / B8 频率保护 ✅ 2026-10-12 已销项;台账三件套 ✅) ↓ W2 数据 N-21 dhara is_free/copy(配主机侧移植层测试) ↓ diff --git a/docs/任务规划/W1-采集保护收尾.md b/docs/任务规划/W1-采集保护收尾.md index 6aaf51af..d6468b36 100644 --- a/docs/任务规划/W1-采集保护收尾.md +++ b/docs/任务规划/W1-采集保护收尾.md @@ -16,7 +16,13 @@ IWDG bit8 + 堆 48→56 KB + prot_elm 28 用例); **B6/B7 已销项(2026-10-12)**——B6 证据 = `../测试说明/boot_log_data/README.md` §3 (@test PASS + 门控缺陷 MISS→FIX 快照 + PC 主站 18/18,工具 `test/host/iec104_master.py`); B7 证据 = `../子系统设计/IEC104上行设计说明.md` §3.4 + boot_log_data B7 行 -(定值/压板跨复位保持 + YK12→YX 联动,主站 42+8 判据 + 主机 63+94 断言)。 +(定值/压板跨复位保持 + YK12→YX 联动,主站 42+8 判据 + 主机 63+94 断言); +**N-33 已销项(2026-10-12)**——证据 = `../内存管理/RAM预算台账.md` §6 + +`../内存管理/RAM使用分析与优化方案.md` §2.3(抬栈 +1536 B,`stack_report` 9 任务 0 FAIL, +`@test` 复测 PASS,min_ever 实测 8464 与预算吻合); +**B8 已销项(2026-10-12,merge `37f74f0`)**——证据 = `../算法/继电保护功能复现规划.md` +变更记录 B8 行 + `App/algo/README.md` §3.7(自适应窗测频 J1~J6 判据,扫频最坏 0.0101 Hz; +定值 32→40、blob VER 1→2、prot_elm 7→9;freq 46 + prot_set 90 + prot_elm 47 断言)。 **本波目标(用户决定 2026-10-12)**:把"采集→相量→保护→上送"链路从"代码落地"收口到 **"台架闭环 + 功能面完整 + 可送检"**。规划方式 = **里程碑(时间驱动、判据退出)× @@ -26,25 +32,19 @@ B7 证据 = `../子系统设计/IEC104上行设计说明.md` §3.4 + boot_log_da | 里程碑 | 退出判据(全部满足才算完成,随后销项) | 服务分类 | |---|---|---| -| **M1 台架闭环** | N-33 `stack_report` 0 FAIL(含 `@test` 复测腿);B6 `@test` PASS ✅ 2026-10-12(min_ever 背书就绪) | Standard | -| **M2 功能面完整** | B8/B9 全部合入 + `ci_local` 全绿 + PC 客户端往返含"SOE 时标"("定值掉电保持" ✅ B7 台架 2026-10-12) | Standard | +| **M1 台架闭环 ✅ 达成(2026-10-12)** | B6 `@test` PASS ✅ + N-33 `stack_report` 9 任务 0 FAIL ✅ + `@test` 复测腿 PASS ✅——判据全满足 | Standard | +| **M2 功能面完整** | B8 ✅ 已合入(2026-10-12)+ B9 合入(台架腿进行中)+ `ci_local` 全绿 + PC 客户端往返含"SOE 时标"("定值掉电保持" ✅ B7 台架 2026-10-12) | Standard | | **M3 可送检** | B10 注入判据 + 第三方主站全流程通过,证据归档 | **Fixed Date**(送检窗口一到全道让路;窗口前 2 日特性冻结,只修稳定——Zephyr RC 语义) | ## 3. 并行道(台架 = 约束资源永不空转;代码道 = worktree 并行、绿即合) ```mermaid graph LR - subgraph BL[台架道 🔴 串行独占 bench.lock] - N33[N-33 栈收口+复测] - end subgraph CL[代码道 🟢 worktree 并行 绿即合] - B8[B8 频率保护] - B9[B9 动作记录/SOE] + B9[B9 动作记录/SOE 🔴含台架腿] end - N33 --> M1((M1)) - B8 --> M2((M2)) - B9 --> M2 - M1 --> B10[B10 送检窗口 🟡 Fixed Date] + M1((M1 ✅)) --> B10[B10 送检窗口 🟡 Fixed Date] + B9 --> M2((M2)) M2 --> B10 ``` @@ -52,19 +52,17 @@ graph LR > 资源图例:🔴 台架(COM6/ST-LINK,`bench.lock` 独占)· 🟡 外部(标准源/第三方主站)· 🟢 纯软件(可并行)。 > 状态:⬜ 未开始 · 🟦 进行中(销项行直接删除,见 `docs/任务规划/README.md` §3 维护约定)。 -> ⚠ **写范围相交**:B7 已先合入(2026-10-12,`App/algo/prot_set.*` 与 `App/protocol/iec104/iec104_pts.c` 定值段已释放);B8∥B9 写范围互不相交,可直接并行。 +> ⚠ **写范围**:B7/B8 已先合入(2026-10-12,`App/algo/` 全部与 `App/cfg/prot_set_store.*` 已释放);B9 生产码写范围与主干无冲突面,但 `test/host/iec104/iec104_test.c` 与 B8 有交集 → B9 sync 时两边叠加手工合并(B8 的 freq include + 断言 与 B9 的 M_SP_TB_1 用例共存)。 | 序 | 任务 | DoD(可验证验收判据) | 硬依赖 | 资源 | 写范围 | 状态 | 出处 | |---|---|---|---|---|---|---|---| -| **N-33** | **栈余量收口**(defaultTask 余 1080 B < 30% 线 / timeSyncTask 余 784 B < ISR 底线,2026-10-12 实测;**既有债非 B4/B5 引入**;链 = dhara GC→控制台回显尾→`(Cycle)`) | `stack_report.py` 9 任务 0 FAIL + `@build` 0/0;若抬栈:台账 §6 登记 + `@test` 复测 PASS | B6 ✅(min_ever=10000 已背书 2026-10-12,AGENTS 抬栈门禁) | 🟢(瘦链调研)+ 🔴(复测腿) | `Src/freertos.c`(USER CODE)/ 瘦链目标文件 | ⬜ | 2026-10-12 `stack_report.py` 实测 | -| **B8** | **频率测量算法 + 过频/低频保护(P2 剩余)** | 频率测量主机单测(45~55 Hz 扫描,精度判据写入文档)+ OF/UF 实例单测;`@build` 0/0;心跳行显示频率值。OV/UV/3U0 实例 B5 起已在跑,注入判据归 B10 | — | 🟢 | `App/algo/phasor.*`(或新 `freq.*`)、`App/algo/prot_elm.*`、`App/algo/prot_set.*`(定值行) | ⬜ | 保护规划 §3.3 L3-5、保护规划 §7 P2 | -| **B9** | **动作记录 + 时标 SOE(L4)** | SOE 记录编解码主机单测 + iec104 单测(M_SP_TB_1);`@build` 0/0;protectTask 静态链不变(动作路径无 NAND I/O,记录走队列异步);**台架腿**:动作边沿 → `/log` 记录可见。出口跳闸仍否定确认(R7 口径不变) | —(代码);验证腿 🔴 | 🟢 + 🔴 | `App/log/`、`App/protocol/iec104/iec104_asdu.*` + `iec104_pts.c`(YX 时标段)、`App/task/protect_task.c` | ⬜ | 保护规划 §3.4 L4、保护规划 §7 P4;`../子系统设计/IEC104上行设计说明.md` §3.2 注 | +| **B9** | **动作记录 + 时标 SOE(L4)** | SOE 记录编解码主机单测 + iec104 单测(M_SP_TB_1);`@build` 0/0;protectTask 静态链不变(动作路径无 NAND I/O,记录走队列异步);**台架腿**:动作边沿 → `/log` 记录可见。出口跳闸仍否定确认(R7 口径不变) | —(代码);验证腿 🔴 | 🟢 + 🔴 | `App/log/`、`App/protocol/iec104/iec104_asdu.*` + `iec104_pts.c`(YX 时标段)、`App/task/protect_task.c` | 🟦(worktree `B9-soe-record`,代码+首轮台架腿已完成;待 sync 追平 B8/N33 后重跑台架腿再合并) | 保护规划 §3.4 L4、保护规划 §7 P4;`../子系统设计/IEC104上行设计说明.md` §3.2 注 | | **B10** | **标准源注入 + 第三方 104 主站往返(送检窗口)** | 标准源注入:L1-5 标定复核 / L1-7 等周波 / P1 过流·过负荷 / P2 电压 + 频率类判据全通过;第三方主站总召/周期/变位/遥控全流程(`../子系统设计/IEC104上行设计说明.md` §7/§8 核对单);证据归档 | M1 + M2 | 🟡 | —(窗口内缺陷修复走 Expedite 类) | ⬜ | 保护规划 §7 P1/P2 验收;`../子系统设计/IEC104上行设计说明.md` §7/§8 | ## 5. 并行规则(执行层映射 → `../环境与部署/并行任务工作树方案.md` §7.3) > ① **WIP ≤ 2**:每道最多 1 个进行中(台架道 + 代码道各一),先完再开(Kanban); -> ② worktree 分支**存活 ≤2 天、绿即合**(`done` 预演合并;31 钩子强门禁是并行安全底座); +> ② worktree 分支**存活 ≤2 天、绿即合**(`done` 预演合并;32 钩子强门禁是并行安全底座); > ③ 写范围相交必须声明合并次序(见 §4 表注); > ④ **里程碑不拖延、功能顺延下一轮**(时间驱动,Zephyr 发布语义); > ⑤ 台架道永不空转:代码道在跑时,为下一个台架任务备料(约束优先,TOC)。 diff --git a/docs/任务规划/登记簿.md b/docs/任务规划/登记簿.md index 54c35574..7fd0c7df 100644 --- a/docs/任务规划/登记簿.md +++ b/docs/任务规划/登记簿.md @@ -49,5 +49,4 @@ | N-27 | **内存优化簇**:LVGL 池 + UI 两份缓冲 54,146 B 仍在 SRAM1,CCM 只剩 ≈8,160 B(2026-10-12 B5 堆扩容 48→56 KB 后) | R-2 后半段(迁 CCM)被堵死 → 后续功能预算看不清 | **三件套 ✅ 2026-10-12 落地**(`ram_baseline.py` 四模式 + `ram_baseline.tsv` 趋势 + `docs/内存管理/RAM预算台账.md` + `mem-budget` 钩子 92% 门禁)→ **W3** 再做 R-9(`LV_MEM_SIZE` 40→16 KB,池水位门禁数据已具备)→ R-2 后半;R-3/R-4/R-5/R-6 逐项评估(R-6 前置已满足) | **W3** | 报告 R-2 剩余/R-3~R-6/R-9/§5.5 | | N-28 | Flash 死代码评估簇:剩余 ≈10 KB(`box_shadow` 3,246 / `draw_sw_img` 2,960 / `bin_decoder` 1,972 / `image_decoder` 952 / `triangle` 930)+ 双精度 `dadd` 等 ≈1 KB;uvprojx 仍挂 201 个空 LVGL TU(F-1/F-2/F-3 已闭环,省的是编译时长) | 编译时长 + 文档噪音 | 逐项实测再关;⚠ `mask`/`mask_rect`/`grad` 是文字与矩形绘制必经路径,**不可盲关** | W4 | 报告 F-4/F-5/F-6 | | N-29 | **仓库膨胀**:`.git` 149 MB + 图片 36.3 MB | clone/备份慢,且持续恶化 | 降采样 / 拆 submodule / LFS 三选一 **+ 一次 `git filter-repo` 历史重写**。**已决策(2026-10-11):暂不处理、登记观察**——推送/协作前重评(届时未推送提交更少、重写代价最低) | **W7** | 报告 D-1 | -| N-33 | **栈余量收口:defaultTask / timeSyncTask `stack_report` FAIL**(2026-10-12 复测:defaultTask 静态 3016 B/余 1080 B < 30% 线,链含 dhara GC→控制台回显→`(Cycle)`;timeSyncTask 静态 1264 B/余 784 B < ISR 底线 1024 B)——**既有债,非 B4/B5 引入** | 深链 + ISR 嵌套场景栈溢出风险(保护链稳定性相关) | 抬栈(**B6 min_ever 已背书 2026-10-12**:台架实测 heap free=17936 / min_ever=10000 > 3072 告警线,证据 `../测试说明/boot_log_data/20261012_B6_prod_boot_log.txt`)或瘦链(控制台回显尾 / Error_Handler 环),详表见 `W1-采集保护收尾.md` | **W1** | 2026-10-12 `stack_report.py` 实测 | | U 簇 | UI 标准化:无令牌层/组件库/页注册表(手写 `case` 仍在),**27 页**未落地;golden 未挂 pre-commit、字符集差集检查仍是约定 | 新增一页成本仍是 8 处手工改动 | 按 `../问题记录/项目审查与优化建议报告.md` §6.5:U4 残余(两脚本入门禁,可逢窗口顺带)→ U1 令牌层 → U2/U3 → 页面落地;U4 golden 可直接当 U1 验收工具。**已确认(2026-10-11):本轮不排期**,等功能主线出结果后按产品需求启动 | W6 | 报告 U1~U5 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 92673e24..1a0263a3 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 223,724 B (218.48 KB) | 1,048,576 B (1024 KB) | 21.3% | -| RW_IRAM1 | 96,144 B (93.89 KB) | 131,072 B (128 KB) | 73.4% | +| ER_IROM1 | 225,936 B (220.64 KB) | 1,048,576 B (1024 KB) | 21.5% | +| RW_IRAM1 | 96,776 B (94.51 KB) | 131,072 B (128 KB) | 73.8% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **224,772 B (219.5 KB)** | 1,048,576 B (1024 KB) | **21.4%** | +| **Flash (ROM 合计)** | **227,016 B (221.7 KB)** | 1,048,576 B (1024 KB) | **21.6%** | 各区的容量与常量来源: @@ -100,16 +100,22 @@ > 堆 `min_ever` 7152 → **6640 B**(-512 B,符合预算)。 > 此前 2026-10-04 的 V5 30 min 重采曾把 defaultTask 推到 **3664B/4096(余量 432B ≈ 10%,登记 S-1)**; > 2026-10-10 的栈整改(**未抬栈**,见 §4 阶段 0 的"2026-10-10 复测")把它压回 **2152B/4096**,S-1 已销项。 +> **2026-10-12 N-33 栈收口(登记簿销项,W1/M1)**:B7 定值持久化把 defaultTask 静态链推到 **3016 B** +> (storage_bring_up→cfg 写→f_rename→dhara GC→控制台回显尾),1024 words 余 1080 B < 30% 线; +> timeSyncTask 链 **1264 B**(同一控制台回显尾),512 words 余 784 B < ISR 底线 → **抬栈** +> defaultTask 1024→**1280**、timeSyncTask 512→**640**(堆 +1536 B,台账 §6 已登记); +> 台架实测 `heap free=16400 / min_ever=8464`(与预算 −1536 吻合,>3072 告警线), +> `stack_report` 9 任务 0 FAIL + `@test` PASS(开机 641 ms)。瘦链候选(§4 ①②)刻意未做。 | 任务 | 配置栈 | 字节 | 实际职责 | 2026-10-10 实测峰值 / 静态最坏链 | |---|---|---|---|---| | uiTask | `4096*4` | 16KB | LVGL 1bpp 离屏渲染(渲染缓冲在静态区,栈只走调用链) | 4016 B(余 75%)/ 静态 1336 B → 可大幅缩减 | -| netTask | `960*4` | 3.75KB | CH395F 轮询(DMA 搬运,栈压力小) | 920 B(余 76%)/ 静态 1872 B | +| netTask | `960*4` | 3.75KB | CH395F 轮询(DMA 搬运,栈压力小) | 920 B(余 76%)/ 静态 **1944 B**(2026-10-12 N32 监听模块化后复测,链尾经 net_listener_pump) | | ftpTask | `1024*4` | 4KB | lftpd FTP 服务 | 740 B(余 81%)/ 静态 1216 B | | testTask | `2048*4` | 8KB | 测试套件(产品构建不创建;存储套件深链需要) | 未创建 / 静态 496 B | -| defaultTask | `1024*4` | 4KB | FatFS 挂载/探针 + 配置引擎 + syslog + 栈水位探针 | 2152 B(余 47%)/ 静态 2776 B | -| timeSyncTask | `512*4` | 2KB | TCP 时间同步 | 616 B(余 69%)/ 静态 **1024 B**(= ISR 底线,无再加深空间) | -| adcTask | `640*4` | 2.5KB | TPAFE5160 采样消费 + L2 相量 + L1-6 有效性校验(TIM3 1 kHz 节拍,B1→L2→L1-6 2026-10-11) | **952 / 2560**(余 1608 B / 62%,台架 `@test` 实测 2026-10-11);静态 1080 B | +| defaultTask | `1280*4` | 5KB | FatFS 挂载/探针 + 配置引擎 + syslog + 栈水位探针 | 2152 B(余 41%)/ 静态 **3016 B**(N-33 抬栈 1024→1280,2026-10-12) | +| timeSyncTask | `640*4` | 2.5KB | TCP 时间同步 | 616 B(余 50%)/ 静态 **1264 B**(N-33 抬栈 512→640,2026-10-12) | +| adcTask | `640*4` | 2.5KB | TPAFE5160 采样消费 + L2 相量 + L1-6 有效性校验(TIM3 1 kHz 节拍,B1→L2→L1-6 2026-10-11) | **952 / 2560**(余 1608 B / 62%,台架 `@test` 实测 2026-10-11);静态 **1320 B**(2026-10-12 复测,L3 接线后) | | iec104Task | `640*4` | 2.5KB | IEC104 上行服务(2404 监听 + link 状态机,B4 2026-10-12) | 未上板 / 静态 **1456 B**(拆链→重建监听→net_bind→SPI 故障→日志尾,日志主导;512 words 时余 592 B < ISR 底线 → 抬至 640,余 1104 B / 43%) | | protectTask | `512*4` | 2KB | 保护判据节拍(prot_elm_run 消费者 + L5-4 采样闭锁,B5 2026-10-12) | 未上板 / 静态 **872 B**(float 判据 + phasor 快照 + 日志尾,余 1176 B / 57%) | | idle(内核) | `configMINIMAL_STACK_SIZE=128` | 0.5KB | 空闲 | 未观测(未取句柄) | diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index f4683a58..27c57cf0 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,10 +15,10 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 223,724 B | 1,048,576 B | 21.3% | 参考 | +| ER_IROM1 (Flash) | 225,936 B | 1,048,576 B | 21.5% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 96,144 B | 131,072 B | 73.4% | PASS | +| RW_IRAM1 | 96,776 B | 131,072 B | 73.8% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| @@ -26,8 +26,8 @@ | 存储 | 7,056 B | 12 KB | OK | | UI | 54,988 B | 16 KB | ⚠ 超 37.7 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | -| 应用 | 13,119 B | 8 KB | ⚠ 超 4.8 KB | -| 其它 | 7,596 B | — | 参考 | +| 应用 | 13,247 B | 8 KB | ⚠ 超 4.9 KB | +| 其它 | 8,100 B | — | 参考 | - **门禁**:pre-commit `mem-budget` 钩子(`scripts/ram_baseline.py --gate`)—— @@ -94,3 +94,4 @@ UI 16 KB / RTOS 堆 56 KB / 应用 8 KB**。归集口径(按 `.map` 对象名 |---|---|---| | 2026-10-12 | 建档:三件套落地(本台账 + `scripts/ram_baseline.py` + `scripts/ram_baseline.tsv` + pre-commit `mem-budget` 钩子) | 登记簿 N-27 前半(W1-0);`docs/问题记录/项目审查与优化建议报告.md` §5.5 | | 2026-10-12 | **RTOS 堆配额 48 KB → 56 KB**(`QUOTAS` 同步) | B5 protectTask 立项:iec104Task(2.5KB)+protectTask(2KB)+TCB 入堆后 48KB 池 min_ever 预估跌破 3,072 B 告警线 → `.ioc` L1 参数 `configTOTAL_HEAP_SIZE` 49152→57344(CCM 水位 75.3%→87.9%,仍 <92% 门禁;CCM 空闲降至 ≈8 KB)。连带影响见 §3 R-2 后半链注记 | +| 2026-10-12 | **任务栈 +1536 B(堆内用量,配额 56 KB 不变)**:defaultTask 1024→1280 words(+1024 B)、timeSyncTask 512→640 words(+512 B);产品任务栈合计 37,632→39,168 B | 登记簿 N-33 栈收口:B7 定值持久化把 defaultTask 静态链推到 3016 B(余 1080 < 30% 线)、timeSyncTask 链 1264 B(余 784 < ISR 底线 1024);B6 台架背书 min_ever=10000 → 抬后实测 `heap free=16400 / min_ever=8464`(与预算 −1536 完全吻合,>3072 告警线);`stack_report` 9 任务 0 FAIL + `@test` PASS,见 `RAM使用分析与优化方案.md` §1.4 | diff --git a/docs/子系统设计/IEC104上行设计说明.md b/docs/子系统设计/IEC104上行设计说明.md index bc0bf441..1fd5690d 100644 --- a/docs/子系统设计/IEC104上行设计说明.md +++ b/docs/子系统设计/IEC104上行设计说明.md @@ -13,7 +13,7 @@ |---|---|---| | 链路 | APCI U/S/I、STARTDT/STOPDT/TESTFR、k/w 窗、t1/t2/t3 | 多主站并发(单连接语义,见 §5) | | 遥测 YC | 短浮点 M_ME_NC_1(系数 1):背景/周期/死区上送 | 归一化/标度、谐波轨(L2-8) | -| 遥信 YX | 单点 M_SP + 双点 M_DP:背景 + 突发(COT=3) | SOE 带时标 CP56(M_SP_TB,随 L4-4 SOE 落地) | +| 遥信 YX | 单点 M_SP + 双点 M_DP:背景 + 突发(COT=3);**突发单点带 CP56 时标 = M_SP_TB_1(B9,随 L4-4 SOE 落地)** | 双点带时标(V0.1 双点恒 IV,无意义) | | 遥控 YK | 双点 C_DC 两段式(选择 82H/执行 02H) | 带时标、升降/步位 | | 定值 | C_RD 读 + C_SE_NC 两段写(地址 = prot_set `comm_addr`) | 定值区切换/下装文件 | | 其他 | C_CS 对时(→ `sys_clock`)、C_TS、C_RP | 固有参数 8001H~800AH(字符串类)、C_CI 电度、文件传输(录波) | @@ -67,6 +67,15 @@ > > **保护类 YX(15/16~18/23/31)自 B5(2026-10-12)起为活数据**:protectTask > 20 ms 判据节拍驱动 `prot_elm` 状态 → pts COV 变位上送(≤120 ms 端到端)。 +> +> **突发单点遥信自 B9(2026-10-12)起带时标 = M_SP_TB_1(30)**:仅自发 +> (COT=3)单点组(idx≥4)换型,信息体 = SIQ + CP56Time2a(8B);**总召/ +> 周期扫描保持 M_SP_NA_1/M_DP_NA_1**(时标只用于变位事件,标准语义,防 +> "全量换型")。时标源 = `sys_clock`(link cfg `epoch_ms` 回调):秒域时基 +> → ms 域 = 秒×1000(亚秒恒 0,不造假);墙钟未建 → CP56 IV 位如实置位。 +> 与 `/log/soe.log` 落盘记录同源同时基(`App/log/soe_store.c`,见 +> `App/log/README.md`)——104 时标 = 事件上送时刻,SOE 记录 = 事件发生 +> 时刻(20 ms 判据拍),两者差 ≤ 一拍 + 排空延迟(≤50 ms)。 ### 3.3 遥控 YK(IOA 0x6000+,双点两段式) @@ -152,12 +161,13 @@ |---|---| | L0.5 | `test/host/iec104/`:APCI 重组/坏帧、STARTDT 握手、总召全流程(含 RBT/分片/预算)、COV、k/w 窗、t1/t2/t3(假时基)、遥控两段式与 PN、YK12 软压板两段转真 + 置脏(B7)、定值两段写 + set_sealed 后 selfcheck、C_RD(含本地项不可寻址)、C_CS(含闰年)、错地址/错类型 EVI;`test/host/prot_set/`:blob 往返 + 损坏整表拒绝(all-or-nothing)| | L0 | `iec104-unit-test` 钩子 + cppcheck/coding-standard | -| 台架 | `@test` 锚点(104 服务启动/监听行);**PC 主站客户端 `test/host/iec104_master.py`(纯标准库,B6 2026-10-12 就绪)**:STARTDT/总召四组+actterm/C_SE 定值两段写→YX15·16 变位(COT=3)/恢复复归/YK11 两段正答/C_RD 读回,台架往返 **18/18 PASS**(证据 `../测试说明/boot_log_data/20261012_B6_iec104_roundtrip_log.txt`);**B7 持久化模式 `--b7-pre/--b7-post/--b7-mark/--b7-check`**(中间 ST-LINK 复位):YK12 两段投退→保护退出/联动、**定值与压板态跨复位保持**(含非默认 7.5 往返),**21/21 + 13/13 + 4/4+4/4 PASS**(证据 `../测试说明/boot_log_data/20261012_B7_iec104_persist_log.txt`);lib60870 级第三方全流程归 B10 | +| 台架 | `@test` 锚点(104 服务启动/监听行);**PC 主站客户端 `test/host/iec104_master.py`(纯标准库,B6 2026-10-12 就绪)**:STARTDT/总召四组+actterm/C_SE 定值两段写→YX15·16 变位(COT=3)/恢复复归/YK11 两段正答/C_RD 读回,台架往返 **18/18 PASS**(证据 `../测试说明/boot_log_data/20261012_B6_iec104_roundtrip_log.txt`);**B7 持久化模式 `--b7-pre/--b7-post/--b7-mark/--b7-check`**(中间 ST-LINK 复位):YK12 两段投退→保护退出/联动、**定值与压板态跨复位保持**(含非默认 7.5 往返),**21/21 + 13/13 + 4/4+4/4 PASS**(证据 `../测试说明/boot_log_data/20261012_B7_iec104_persist_log.txt`);**B9 SOE 腿**(同工具同轮回归):自发 YX 断言 **M_SP_TB_1 带 ts**(工具解析 type 30/1 双型)+ FTP 下载 `/log/soe.log` 经 `test/host/soe/decode_soe.py` 解码断言(校验和/seq 单调/CP56↔epoch UTC 交叉),pre 21/21 + post 13/13 不回归;lib60870 级第三方全流程归 B10 | ## 8. V0.1 限制与待办(如实登记) 1. 固有参数 8001H~800AH 未实现(字符串类无法短浮点上送)→ 未知 IOA 应答;随送检需求再设计。 -2. SOE 带时标(M_SP_TB_1)随 L4-4 SOE 记录落地;现突发只发 COT=3。 +2. ~~SOE 带时标~~ **突发单点已带 CP56 时标 = M_SP_TB_1(B9 落地,见 §3.2)**; + 双点带时标不做(V0.1 双点恒 IV,无意义)。 3. YK1~4/13/14 否定确认——出口硬件确认后(R7)转真执行(YK12 已于 B7 转真)。 4. 遥测死区 10.0 码值为拟定值,L1-5 标定后按工程量死区(点表 8020H 段)复核。 5. 多主站/双链路冗余不做(单连接语义)。 @@ -175,3 +185,4 @@ | 2026-10-12 | B4 落地回填:承载改**专用 iec104Task**(netTask 承载会 RPC 自死锁);§5 全量回填(门控/代次/拆链/对时/看门狗位 7);抢占策略修正为**首到优先**(§8 第 7 条);§6 实测(640 words,链 1456 B,余 43% PASS) | | 2026-10-12 | **B7 定值持久化 + 软压板**:YK12 转真(§3.3,两段式 → `PS_GLOBAL_EN`);定值执行段改 `prot_set_set_sealed` 原子写+封存(§3.4,收口 B5 竞态窗口);本地项 `comm_addr=0` 不可寻址;持久化 = 脏标志 → `SYS_EVT_PROT_SET_SAVE` → defaultTask 原子写 `/prot_set.bin`,开机屏障前整表原子装载(损坏拒绝保默认 + 自检留痕);单测 +7(iec104 94 断言)+ prot_set blob 往返/损坏 33 断言 | | 2026-10-12 | **B7 台架腿闭环**(§7 台架行回填):主站工具加 `--b7-pre/--b7-post/--b7-mark/--b7-check` 四模式(中间 ST-LINK 复位);pre 21/21(YK12 OFF→低定值不动作 / ON→YX15·16 即动作)、post 13/13(**压板退出态跨复位保持** + 重启后联动在位)、mark/check 4/4+4/4(**非默认定值 7.5 跨复位保持**);开机日志 `[CFG] prot set loaded (/prot_set.bin)`;证据 `../测试说明/boot_log_data/20261012_B7_*` | +| 2026-10-12 | **B9 时标 SOE**:自发单点 YX 换 **M_SP_TB_1(30)**(SIQ+CP56,§3.2 注;总召/周期保持 NA 型);link cfg 增 `epoch_ms` 时标源(NULL/无效 → IV 如实);serv 绑 `sys_clock`(秒域 → ms 域=秒×1000);主站工具解析 type 30/1 双型;单测 iec104 94→97(TB 时标 + IV 路径,两种类型都断言);台架腿见 §7 B9 行;落盘侧见 `App/log/README.md` soe 节 | diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index 56bcf6b7..50185898 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -14,8 +14,7 @@ | `docs/UI设计/UI_自动化测试流程.md` | UI 模拟器回归(ui-verify)细节 | | `docs/代码规范/pre-commit使用说明.md` | pre-commit 门禁配置与范围 | | `docs/环境与部署/项目部署环境要求.md` | 构建 / 测试工具链环境 | -| `docs/环境与部署/CI配置指南.md` | **CI(Gitea Actions)服务端 + workflow 配置全流程与排障** | -| `docs/环境与部署/Gitea_Runner部署指南.md` | **Runner 端部署全流程**(下载/注册/常驻/排障) | +| `docs/CI/README.md` | **远程 CI(Gitea Actions)唯一入口**:服务端 / runner 部署与保活 / workflow / 排障(仅本机一台 runner,日常开发不必读) | | `docs/测试说明/<模块>_Test_Guide.md` | 各模块测试规范(CH395F / GD5F / SD2506 / FTP) | --- @@ -529,36 +528,47 @@ flowchart LR > 新增任务/栈/缓冲/代码导致的体积增长**不会在 CI 被拦**。故该跳过以 `::warning::` 明示 > (不是弱化的 notice)并写入 step-summary。**兜底必须靠本地**:改代码后 `@build`(0/0) > → `python scripts/check_mem_numbers.py --scan`。详见 -> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0。 +> [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md) §0.0。 + +> 🔴 **`gitlint` 属 `commit-msg` stage,`--all-files` 永远跑不到它(2026-10-11 审计)**: +> `.pre-commit-config.yaml` 定义 **18** 个钩子,而 CI 日志只报 **17** 个(`passed=16 skipped=1`)—— +> 缺的正是 gitlint。即"单行中文标题 ≤100 字符"这条门禁**本地有、CI 无**,且表现为**全绿** +> 而非失败,所以长期无人发现。现由 CI 侧 **`scripts/ci_gitlint_range.ps1`**(PR 用 `base..head`、 +> push 用 `event.before..sha`、跳过 merge 提交)与本地 `ci_local.py` 的 **L0b** 段分别逐条校验。 +> ⚠️ 机制根因(`stages: [commit-msg]` 写在 **gitlint 上游** 的 `.pre-commit-hooks.yaml` 里, +> 项目配置中 grep 不到)与完整排障口径见 +> [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md) §7"钩子条数对不上"行。 ### 9.2 两个 job -| job | `runs-on` | 步骤要点 | +| job | 覆盖层 | 一句话职责 | |---|---|---| -| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(Python / cppcheck / `python -m pre_commit`,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **决定跳过哪些钩子**(探测 `.map`,写入 `GITHUB_OUTPUT`;无 `.map` 时 `::warning::` 声明体积门禁未跑)→ `pre-commit run --all-files --show-diff-on-failure`(`env: SKIP`,输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`)→ 打印 `::notice::L0: passed/failed/skipped` 汇总行 → 归档日志说明 | -| `l05-host-unit-tests` | `windows` | checkout → **探测 MSVC**(按 VS_ROOTS 找 `Hostx64\x64\cl.exe`)→ 遍历 `test/host/**/run_unit_test.py` 逐个跑(输出 `Tee` 到 `ci-artifacts/l05-<模块>.log`;解析各模块 `N/M PASSED` 汇总行 → 聚合 `::notice::L0.5: K runner(s), A/B assertions passed`;失败 `::error::` 并置退出码);**未探测到 MSVC 时打印醒目 WARNING 并降级跳过**(不硬失败,避免"没装 VS 的 runner 全红") | +| `l0-gates` | **L0** | `pre-commit run --all-files`(cppcheck / 编码规范 / 文档一致性 / 主机侧单测钩子)+ **提交信息门禁**(gitlint,见上方 🔴);无 `.map` 时自适应跳过 `mem-numbers` | +| `l05-host-unit-tests` | **L0.5** | 探测 MSVC 后遍历 `test/host/**/run_unit_test.py` 逐个跑并聚合断言数;**未装 MSVC 时降级为 WARNING 跳过**(不硬失败,但等于该层裸奔) | -**结果可见性(S3,2026-10-02)**:两个 job 均写 `$GITHUB_STEP_SUMMARY`(job 运行页的 -**Summary** 区块直接可见)+ `::notice::` 汇总行;日志落 `ci-artifacts/*.log`(runner 工作区, -已 gitignore)。**刻意不用 `actions/upload-artifact`**(内网可能拉不到外层 action,见 -`CI配置指南.md` §4.3/§4.4)——零依赖方案下,"不查 MySQL / 不翻磁盘即可排障"的目标由 -step-summary 达成。 +触发:`push`(`paths-ignore: docs/** / **/*.md / .workbuddy-ai/**` → **纯文档改动不会触发 CI**) +与 `pull_request`,另留 `workflow_dispatch` 手动触发;同分支 `cancel-in-progress: true`。 +两个 job 都写 step-summary + `::notice::` 汇总行,日志落 `ci-artifacts/*.log`。 -触发:`push`(`paths-ignore: docs/** / **/*.md / .workbuddy-ai/**`)与 `pull_request`,另留 `workflow_dispatch` 手动触发。 -`concurrency: group: ci-${{ github.ref }}` + `cancel-in-progress: true`(同分支旧运行自动取消,省 runner)。 +> 📖 **步骤级细节不在本文**(探测链顺序、`GITHUB_OUTPUT` 跳过决策、`Tee` 落盘、产物存档、 +> 内网拉不到 action 的处置):见 [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md) §5.5 / §5.2 / §5.3 / §5.4(依次为:两个 job 的步骤要点 / 触发条件 / 内网 action 拉取 / 产物与汇总)。 ### 9.3 本地等价:`scripts/ci_local.py` CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runner 还没配好时**,用同一套判定在本地跑: ```powershell -& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0.5(与 CI 等价) -& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0 +& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0b + L0.5(与 CI 等价) +& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0(含 L0b 提交信息门禁) & '.venv\Scripts\python.exe' scripts/ci_local.py --l05-only # 只跑 L0.5 +& '.venv\Scripts\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验最近 20 条 ``` -- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python`;显式值无效 → 硬报错并指明变量名,不静默回退); - 仍不可用时退出码 2 并给出修复提示。 +- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python` → **常见机器级安装位置**;显式值无效 → 硬报错并指明变量名,不静默回退); + 仍不可用时退出码 2 并列出**全部已尝试路径**。 + ⚠️ 该候选表与 CI 侧 **`scripts/ci_find_python.ps1`** 必须**同序同步**——2026-10-11 的 L0 全灭就是 + workflow 单方面把候选收窄成"PATH python + 仓库 `.venv`"(runner 上两者都是死路)导致的。 +- **L0b 提交信息门禁**:与 CI 的 `ci_gitlint_range.ps1` 同语义(`--all-files` 跑不到 commit-msg stage)。 - **`.map` 自适应与 CI 完全一致**:本地有 `.map` 就照常跑 `mem-numbers`,没有就跳过。 - 退出码:`0` 通过 / `1` 门禁或单测失败 / `2` 环境错误。 @@ -567,16 +577,14 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn ### 9.4 部署前置(需人工确认) -| 项 | 要求 | -|---|---| -| Gitea Actions | 实例开启 `[actions] ENABLED = true`(site admin 配置) | -| Windows runner | 已注册且 **labels 含 `windows`**;装有 Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 C++ 工具链 + Windows SDK) | -| 缺 MSVC 时 | L0.5 降级为 WARNING 跳过(不硬失败),但**等于该层裸奔**,须尽快补装 | +CI 能不能跑起来,取决于 runner 机器上四件事是否就位:**服务端开关 / runner 注册与 labels / +看门狗常驻 / 机器级 `DTU_*` 工具变量**。清单与逐项部署步骤都在 +[`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md):§0.2(前置清单)、§2(注册)、§2.4(**看门狗**)、§4.1.3(runner 以 SYSTEM 跑,必须写 MACHINE 级变量)。 +注册细节:`--instance` 用回环、labels 必须精确匹配 `runs-on`;看门狗缺了它 Gitea 一重启 +CI 就静默停摆(2026-10-11 实测 run 16~21 共 12 个 job 零日志)。 -> 📖 **配置步骤 / 排障**(服务端开关、runner 注册命令、PATH 与 MSVC 路径要求、 -> 内网拉不到 action 的处置、故障排查表)见 -> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) -> 与 [`../环境与部署/Gitea_Runner部署指南.md`](../环境与部署/Gitea_Runner部署指南.md)。 +> ⚠️ 对测试流程只需记住一条:**runner 不在线时,`ci_local.py` 就是 L0 / L0.5 的唯一保障**。 +> "CI 显示绿"与"本地门禁通过"是两回事,见 §9.3 与 §7.1。 --- @@ -615,6 +623,7 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn | 日期 | 变更 | |---|---| +| 2026-10-12 | **§9 瘦身:机器侧内容迁出**(远程 CI 文档独立成 `docs/CI/`)。§9.2 两个 job 的**步骤级细节**并入 `docs/CI/远程CI完整指南.md` §5.5、§9.4 部署前置表并入其 §0.2;本文 §9 只保留**测试分层视角**的内容(CI 覆盖哪些层、两个 job 各守哪层、触发与 paths-ignore 语义、本地等价 `ci_local.py`、"CI 不替代本地门禁")。§9.1 两个 🔴 块压缩为结论 + 指针(机制根因与排障口径归 `docs/CI/`)。相关文档表两行 CI 条目合并为一行指向 `docs/CI/README.md` | | 2026-10-10 | §4.2.1 台账:`Drivers/BSP/NETCFG/net_cfg_legacy.c` 由 `⬜ 候选` 改为 **`⛔ 已删除`**——旧档 NCFG 迁移整体移除(`net_cfg_legacy.{c,h}` 删除、`cfg_store_legacy_path()` 撤销;旧档 `/cfg/netcfg.bin` 不读、不删、不迁移,配置回落工厂默认),该测试候选随之撤销 | | 2026-10-02 | **S6**:`test/run_all_suites.py` 新增 **`--check-build`**(只编译不烧录,纯离机,不需要 MCU/串口/ST-LINK);新增 **§3.6.1** 说明其解决的问题(测试构建无独立门禁,2026-08~09 CH395F 套件实例)、判定语义与用法。**实测 4 套件全部 `BUILD_OK: 0 errors, 0 warnings`**(即 T-4 提到的"CH395F 不可编译"已修复) | | 2026-10-02 | **S1-b + S2**:新增 **§4.2.1「L0.5 适格模块台账」**(判据 + 模块状态表 + "两条实证":sys_clock / cfg_file 首测即各抓一处生产缺陷);§4.1 登记 `test/host/cfg_file/` 新单测。同步新增 [`配置文件单元测试说明.md`](配置文件单元测试说明.md)(含抓出的 `s_encode` 返回值误判 P0 缺陷记录) | diff --git a/docs/测试说明/链路延迟测试说明.md b/docs/测试说明/链路延迟测试说明.md index 98e295a7..7f0e587e 100644 --- a/docs/测试说明/链路延迟测试说明.md +++ b/docs/测试说明/链路延迟测试说明.md @@ -663,7 +663,7 @@ sock4 auto-relisten ok / relisten retry ok ← 监听器随后自行恢复 | **会话跑完一段后无限循环、peer 空等 300s** | **D16**:Phase 13 状态机迁移目标错 | 核对顺序 L5→L3→L10→L6→L7→DONE(§8 D16) | | **`[LAT]` 段行缺失/截断、wait-idle 判不出空闲** | **D12-b**:热路径日志洪水 | 删除 net 热路径逐调用日志(§8 D12-b) | | **开机日志连续丢行**(含必检标记 `net init: OK` 等)但系统健康 | **日志并发竞态**:多任务并发 `printf` → 非重入 `fputc`/`HAL_UART_Transmit` 丢整行、UART 状态短暂卡住(2026-09-22 台架实证:boot 时 testTask 启动行与 netTask 日志并发 → 连续 4 行丢失、~2s 后自恢复) | 必现证据行采用"静默采集 + 延迟到安静窗口打印"模式(范例:P0-a 验收行曾按此模式实装,验收完成后探针已移除);根治 = S8 日志非阻塞/串行化(§10.2) | -| **`relisten` 段 FAIL、断线固定 ~588ms** | **D10** 回归(退回 `osDelay(500)`) | 核对 `net_relisten_try`/`net_relisten_retry_tick` 非阻塞重试在位(§8 D10) | +| **`relisten` 段 FAIL、断线固定 ~588ms** | **D10** 回归(退回 `osDelay(500)`) | 核对 `net_listener_try`/`net_listener_retry_tick` 非阻塞重试在位(§8 D10) | | **远端显示整帧卡住十几秒后自恢复** | **E-02** 回归 | 核对 L3 pending 在位:`net_recv_locked` 阻塞分支登记 pending(哨兵 `NET_PENDING_REGISTERED`)+ `net_pending_tick` 在主循环 + 调用方剩余预算循环(lftpd/timeSync)(§8.3 要点 4) | | **INIT 应答帧字节数字段恒为 0** | **D17** 回归 | 核对 `remo_send_init` 发满 8B(§8 D17) | | **开机首个 FTP 会话第 2 个数据连接 425** | **D20**(已定位未修,§8.2) | 复现:`test/host/ftp_regression.py`(复位开边界);现场规避:客户端重试 425(瞬态 4xx);根治方向:数据 Socket 轮换 | diff --git a/docs/环境与部署/CI配置指南.md b/docs/环境与部署/CI配置指南.md deleted file mode 100644 index bde1f0ea..00000000 --- a/docs/环境与部署/CI配置指南.md +++ /dev/null @@ -1,371 +0,0 @@ -# CI 配置指南(Gitea Actions) - -> **适用**:为 STM32F4-Base 部署 / 排障 CI(`.gitea/workflows/ci.yml`,L0 + L0.5)。 -> **前置阅读**:`docs/测试说明/测试开发标准流程.md` §9(CI 覆盖边界与判定细节)、 -> `docs/环境与部署/项目部署环境要求.md` §2.11(runner 环境要求)。 -> 本文只讲**怎么配**,不重复"为什么这么设计"。 - ---- - -## ⚠️ 0.0 CI 不覆盖的体积门禁(必读,S14) - -> **CI 不守 Flash/RAM 体积门禁。** `mem-numbers` 钩子的事实源是 Keil 构建产物 -> `MDK-ARM/**/*.map`(已 gitignore),而 CI 刻意不跑 `@build` → **无 `.map` → 该钩子被跳过**。 -> -> 后果:**新增任务 / 栈 / 缓冲 / 代码导致的体积增长(或文档内存数字失真)不会在 CI 被拦下。** -> -> 兜底(每次改代码后必做): -> ```powershell -> & '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # 0/0 且生成 .map -> & '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --scan -> ``` -> 若 CI 允许在带 Keil 的 runner 上跑,则该钩子会在有 `.map` 时**照常执行**(workflow 自适应)。 -> -> CI 运行时该跳过以 **`::warning::`** 明示(不是弱化的 notice),并写入 job 的 -> **step-summary** 醒目行,避免被忽略。 - ---- - -## 0. 全景:CI 需要三样东西就位 - -``` -① Gitea 服务端开关 ② Windows Runner 注册 ③ 仓库里的 workflow 文件 - [actions] ENABLED=true labels 含 windows .gitea/workflows/ci.yml - │ │ │ - └────────────┬───────────┴──────────────────────────────┘ - ▼ - push / PR 触发 → runner 拉起容器/壳 → 跑 L0 + L0.5 → 结果回显到提交 -``` - -**三者缺一,CI 就是空转的**(workflow 存在但永不执行 / 执行但拉不到 runner)。 -当前仓库状态:**③ 已就绪(本机提交 `e904865`,尚未 push);① ② 需人工确认。** - ---- - -## 1. 服务端:开启 Gitea Actions - -### 1.1 确认版本与开关 - -Gitea **≥ 1.21** 才内置 Actions(实测本实例 **1.25.4**,满足)。以 **site admin** 登录后: - -- 管理后台 → **站点管理 → 配置 / Site Administration → Configuration**,搜索 `actions`: - -```ini -[actions] -ENABLED = true -``` - -### 1.2 编辑配置文件(推荐,永久生效) - -配置文件位置(按部署方式二选一): - -| 部署方式 | 配置文件路径 | -|---|---| -| 二进制 / 直接跑 | `/custom/conf/app.ini` | -| Docker | 容器内 `/data/gitea/conf/app.ini`(挂载卷里的 `custom/conf/app.ini`) | - -在 `app.ini` 追加(若无 `[actions]` 段): - -```ini -[actions] -ENABLED = true -``` - -改完 **重启 Gitea 服务**(Docker:`docker restart <容器名>`;二进制:重启进程)。 - -### 1.2.1 `DEFAULT_ACTIONS_URL` 是什么(**容易搞反**) - -它**不是你的 Gitea 地址**,而是控制 **`uses: actions/checkout@v4` 这类 action 从哪里下载**: - -| 取值 | 行为 | -|---|---| -| **不配(默认)** | 从 **[github.com](https://github.com)** 拉取 action | -| `self` | 从**你自己的 Gitea 实例**拉取(要求 action 已镜像进你的实例) | -| 具体 URL(如 `https://gitea.com`) | 从该地址拉取 | - -> ⚠️ **不要把你的实例域名写进去当普通 URL**——要"从自己实例拉"就用关键字 **`self`**; -> 且**写了 `self` 还得先把 action 镜像进自己实例**,否则 `actions/checkout` 依然拉不到。 - -**按你的网络环境选择**: - -| 场景 | 配置 | -|---|---| -| 服务器**能**访问 github.com | **什么都不配**(默认走 github.com,最省事) | -| 内网,**不能**访问 github.com | 建议**改用零依赖写法**(见 §4.3 方案 3),`DEFAULT_ACTIONS_URL` 配不配都行;
若坚持用 `actions/checkout@v4`,则需 `DEFAULT_ACTIONS_URL = self` **且**把该 action 镜像进实例 | - -> Gitea 官方 FAQ 原文:*"From `https://github.com` by default, or from your own instance -> when `[actions].DEFAULT_ACTIONS_URL` is `self`."* 使用别处 action 也可在 workflow 里直接写全路径: -> `uses: https://gitea.com/owner/repo@ref` / `uses: self:owner/repo@ref`。 - -### 1.3 验证 - -浏览器打开仓库地址,顶部/侧栏应出现 **Actions** 标签页。 -若仍提示"此仓库未启用 Actions",说明开关没生效,回到 1.1 检查。 - -> ⚠️ 若实例装在**内网且不能访问公网**,`actions/checkout@v4` 这类官方 action 会拉不下来。 -> 处置见 §4.3。 - ---- - -## 2. Runner 端:注册 Windows Runner - -CI 的两个 job 都是 `runs-on: windows`,所以**必须有一个 labels 含 `windows` 的 runner**。 -推荐用 `act_runner`(Gitea 官方 runner)。 - -### 2.1 获取注册令牌 - -- **仓库级 runner**(推荐,只服务本仓库): - 仓库 → **设置 / Settings → Actions → Runners → 创建 Runner**,复制页面显示的 - **Registration Token**(形如 `xxxYYYzzz...`)。 -- **全局 runner**(服务整个实例):站点管理 → Actions → Runners。 - -记下 Gitea 地址,例如 `https://gitea.bitnasdaq.vip`。 - -### 2.2 下载并注册 act_runner - -```powershell -# 1) 下载(示例版本,请取 releases 页最新 Windows amd64) -# https://gitea.com/gitea/act_runner/releases -Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-windows-amd64.exe" ` - -OutFile "C:\act_runner\act_runner.exe" - -# 2) 注册:URL 用 Gitea 根地址,token 用 2.1 复制的 -cd C:\act_runner -.\act_runner.exe register ` - --no-interactive ` - --instance "https://gitea.bitnasdaq.vip" ` - --token "粘贴 Registration Token" ` - --name "win-stm32-ci" ` - --labels "windows:host" -``` - -**关键点**:`--labels "windows:host"` 中的 `windows` 必须**逐字**与 workflow 的 -`runs-on: windows` 对应(Gitea runner 的 label 名是自定义的,默认是 `ubuntu-latest`, -**不注册 `windows` 这个 label 就永远匹配不到 job**)。 - -> `:host` 表示直接跑在宿主 Windows 上(不是容器)。本场景**必须用 `host`**—— -> 容器里拿不到宿主已装的 Keil/MSVC/cppcheck,且 Windows 容器支持很差。 - -### 2.3 配置 `config.yaml`(可选但建议) - -`C:\act_runner\config.yaml` 里确认/补充: - -```yaml -runner: - labels: - - "windows:host" # 与 register 一致 - capacity: 1 # 并行 job 数;单机建议 1,避免 cppcheck 抢 CPU - timeout: 30m -cache: - enabled: true -``` - -### 2.4 以服务方式常驻 - -```powershell -# 注册为 Windows 服务(管理员权限) -.\act_runner.exe daemon install -.\act_runner.exe daemon start - -# 或前台调试(先看日志通不通,推荐首次这样跑) -.\act_runner.exe daemon -``` - -注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci` -状态为 **Idle / 绿色**。 - ---- - -## 3. Runner 软件环境:装什么 - -runner 是一台干净的 Windows,**下面这些必须装**,否则 job 会失败或降级跳过。 - -| 项 | 必需性 | 影响 | 安装 | -|---|---|---|---| -| **Python 3.x** | 🔴 必需 | `l0-gates` 环境自检失败 → **job 硬失败** | `winget install Python.Python.3.12` | -| **cppcheck** | 🔴 必需 | 同上(`cppcheck --version` 探测不过) | `winget install Cppcheck.Cppcheck` | -| **pre-commit** | 🔴 必需 | 同上(`python -m pre_commit --version` 探测) | `pip install pre-commit` 或 `uv tool install pre-commit` | -| **MSVC(VS + Windows SDK)** | 🟡 建议 | 缺则 `l05-host-unit-tests` **告警降级跳过**(不硬失败,但 L0.5 裸奔) | `winget install Microsoft.VisualStudio.2022.BuildTools`,勾"使用 C++ 的桌面开发" | -| Keil / ST-LINK | ⛔ 不需要 | CI 刻意不跑 `@build` | — | -| 开发板 / 串口 | ⛔ 不需要 | L1/L2/L3 不上 CI | — | - -### 3.1 关键:PATH 必须对 - -CI 的环境自检用的是 `Get-Command python` / `cppcheck`,**依赖 PATH**。 -装完后**新开一个终端**(或重启 runner 服务)让 PATH 生效,然后自检: - -```powershell -python --version # 期望 Python 3.x -cppcheck --version # 期望 Cppcheck 2.21.0 或更高 -python -m pre_commit --version -``` - -### 3.2 MSVC 的路径要求(探测逻辑) - -`l05-host-unit-tests` 的 MSVC 探测优先读机器级 `DTU_MSVC_CL`(x64 `cl.exe` 完整路径; -值无效 → 硬失败、不静默回退);未设置时按**标准布局自动探测**(根目录由 -`%ProgramFiles` / `%ProgramFiles(x86)` 推导,不写死盘符;与 -`test/host/common/unit_test_harness.py` 的 `_default_vs_roots()` 保持一致): -2019 → `Program Files (x86)`;2022 / 2026(实际目录名 `18`)→ `Program Files`, -各取 BuildTools/Community/Professional/Enterprise 子目录,找 -`VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe`。 - -> ⚠️ **装在完全非标准位置(如 D 盘自定义目录)会探测不到** → 被当成"没装 MSVC"跳过 L0.5(仅告警)。 -> 处置(二选一):设机器级 `DTU_MSVC_CL` 指向 `cl.exe`(推荐;值无效会硬报错), -> 或把 VS 装回标准位置(探测按 `%ProgramFiles*` 推导,换盘符的标准布局仍命中)。 -> -> Windows SDK 同理:默认 `\Windows Kits\10\`;非标准位置用 `DTU_WINDOWS_KITS_ROOT` 指定。 - -> 📌 2026-10-02:workflow 与本地 harness 同步改为"变量优先 + 标准布局推导" -> (`本地工具路径环境变量方案.md`)——旧写法要求"同步改两处",现已不需要。 - ---- - -## 4. 仓库端:workflow 文件 - -### 4.1 文件位置与提交 - -workflow 必须位于**仓库根**的 `.gitea/workflows/` 下(不是 `.github/`): - -``` -<仓库根>/ -└─ .gitea/ - └─ workflows/ - └─ ci.yml ← 本仓库已有,见该文件 -``` - -**它必须被 push 到远程才能生效**——目前本仓库的 `e904865` 还在本地 -(`origin/v2-on-ui` 尚未包含),所以**现在 push 之前 CI 不会跑**: - -```powershell -git push origin v2-on-ui # ⚠️ 需你明确要求才推送(本仓库约定:默认不推送) -``` - -### 4.2 触发条件 - -```yaml -on: - push: - paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**'] - pull_request: - paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**'] - workflow_dispatch: # 页面可手动触发 -``` - -- 纯文档改动**不触发**(省 runner); -- 想手动跑一次:仓库 → Actions → 选 `CI` → **Run workflow**。 - -### 4.3 内网无公网时的 action 拉取(重要) - -workflow 用了 `actions/checkout@v4`。**默认从 `github.com` 拉取**(见 §1.2.1);实例不通 -github.com 会在 checkout 步骤卡住/失败。三种处置: - -1. **镜像到自有实例**:`DEFAULT_ACTIONS_URL = self`,**并先把 `actions/checkout` 导入你自己的 Gitea**; -2. **指向可达镜像**:`DEFAULT_ACTIONS_URL` 指向某个能访问的 action 站(或在 workflow 里 - 直接写全路径 `uses: https:///owner/repo@ref`); -3. **零依赖**(内网最稳,推荐):把 checkout 换成纯 git 命令,不依赖任何 action: - -```yaml - - name: Checkout - shell: pwsh - run: | - git init . - git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git - git fetch --depth 1 origin $env:GITHUB_SHA - git checkout $env:GITHUB_SHA -``` - -> 方案 3 无需任何外部 action 与任何 `DEFAULT_ACTIONS_URL` 配置;代价是失去 checkout 的缓存优化。 -> 参考:Gitea FAQ「Where will the runner download scripts when using actions such as `actions/checkout@v4`?」 - -### 4.4 产物存档与结果汇总(S3,2026-10-02) - -**目标**:CI 失败时**不查 MySQL / 不翻 runner 磁盘**,在 Gitea 页面即可看到完整输出与摘要。 - -**零依赖方案**(内网首选,**不用 `actions/upload-artifact`**): - -| 手段 | 说明 | -|---|---| -| `Tee-Object` 落盘 | 两个 job 把关键输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`、`ci-artifacts/l05-<模块>.log`(**留在 runner 工作区**,已 gitignore) | -| `$GITHUB_STEP_SUMMARY` | 写入 job 的 **step-summary**(Gitea UI 运行页直接可见,无需下钻日志) | -| `::notice::` 汇总行 | 控制台醒目行,便于日志检索 | - -**汇总行格式**: - -| 层 | 汇总行 | -|---|---| -| L0 | `L0: pre-commit hooks -- passed=N failed=M skipped=K (log: ci-artifacts\l0-pre-commit.log)` | -| L0.5 | `L0.5: runner(s),
/ assertions passed, fail=`(逐模块另有 `::notice::<模块>: N/M PASSED (failed=0)`) | - -> ⚠️ **`ci-artifacts/` 已加入 `.gitignore`**:它是 runner 工作区的运行时产物,不要提交。 -> -> ⚠️ **若后续改为 `actions/upload-artifact`**(需确认实例能拉该 action):把 `Tee` 落盘 -> 保留即可,上传步骤叠加在其后;本方案的落盘文件即上传源。 - -**PowerShell 陷阱(已在 workflow 内处理)**:runner 注入 `$ErrorActionPreference='Stop'`, -而 `& native 2>&1 | Tee-Object` 会把 native 的 stderr 变成 ErrorRecord → 在 `Stop` 下**整段中止**。 -故 pipeline 前后需 `Save/Restore` 该偏好为 `Continue`(与"环境自检"步骤同法)。 -`$LASTEXITCODE` 在 `Tee-Object` 之后**仍保留** native 退出码(实测),可直接用于判定。 - ---- - -## 5. 端到端验证清单 - -按顺序确认,任一步不过就停在那里排障: - -| # | 检查 | 期望 | -|---|---|---| -| 1 | 仓库顶部有 **Actions** 标签页 | 服务端开关已开 | -| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功 | -| 3 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通 | -| 4 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 | -| 5 | `l0-gates` 日志 | `pre-commit ... Passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 | -| 6 | `l05-host-unit-tests` 日志 | `::notice::L0.5: 2 runner(s), 139/139 assertions passed, fail=0` | -| 7 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "L0.5 host-side unit tests" 小标题与汇总行 | -| 8 | 提交记录旁出现 ✅ | CI 全绿 | - -**本地先自查**(与 CI 同一套判定,避免推上去才发现问题): - -```powershell -& '.venv\Scripts\python.exe' scripts/ci_local.py -``` - ---- - -## 6. 常见故障排查 - -| 现象 | 根因 | 处置 | -|---|---|---| -| push 后 Actions 页**空白/无任务** | ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 `docs/**`(被 `paths-ignore` 滤掉) | 逐条核对 §1 / §4.1 / §4.2 | -| 任务一直 **Waiting / 排队** | 没有 label 匹配 `windows` 的 runner | 检查 `--labels "windows:host"`;Settings→Runners 是否 Idle | -| `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | runner 没装或不在 PATH | 装完后**重启 runner 服务**(新进程才吃到新 PATH) | -| `checkout` 步骤卡住/超时 | 拉不到 `actions/checkout@v4`(内网) | 见 §4.3 | -| `l05` 日志出现 `::warning::runner 未安装 MSVC` | VS 装在非默认路径或未装 | 见 §3.2(默认路径 / 同步改两处根目录) | -| `mem-numbers` 显示 `Skipped` | 正常(CI 无 Keil,无 `.map`) | 无需处理;但注意 **CI 未守体积门禁**(见 §0),改代码后本地 `@build` + `check_mem_numbers.py --scan` 兜底 | -| L0.5 显示 `2 runner(s), X/Y ...` 但 X.log` | -| L0.5 报 `produced no '=== ... N/M PASSED ===' summary line` | 该模块运行器未输出汇总行(改了 harness 或运行器崩溃) | 核对 `test/host/common/unit_test_harness.py` 的 `summary_marker` 与该模块 `TestBuild` 是否一致 | -| `Tee-Object` 步骤报 `TerminatorExpectedAtEndOfString` / 字符串缺少终止符 | `run:` 块混入了非 ASCII(GBK 解码错位) | 见文件头 🔴🔴;`run:` 内必须纯 ASCII | -| `l05` 报 `fatal error C1083: 无法打开包括文件 "stdio.h"` | Windows SDK 缺失 | 装 VS 时勾"使用 C++ 的桌面开发"+ SDK | -| 单测退出码 2 | 环境错误(多为无 MSVC) | 同上 | -| `pre-commit` 首次运行久 | 首次要下载各钩子的环境 | 正常,runner 缓存后会快 | - ---- - -## 7. 日常维护 - -- **升级 runner**:停服务 → 换 `act_runner.exe` → 起服务(注册信息保留在 `config.yaml`); -- **改了 `VS_ROOTS` / 工具链探测逻辑**:记得**同步改 workflow 里的探测根目录**(两处一致); -- **钩子集变化**(`.pre-commit-config.yaml` 增删钩子):CI 自动跟随,无需改 workflow; -- **`@build` / L1 / L2**:永远留在本地,不要往 CI 加(需 Keil 授权与真机); -- **runner 长时间不可用**时:CI 只是"多一道网",**提交前的本地 `ci_local.py` 仍是 L0 的唯一保障**, - 不能因"已经配了 CI"放宽(见 `docs/任务规划/README.md` 的部署前置警示块)。 - ---- - -## 变更记录 - -| 日期 | 变更 | -|---|---| -| 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 | -| 2026-10-01 | **修正 `DEFAULT_ACTIONS_URL` 说明**(原 §1.2 示例误导):新增 §1.2.1 明确它控制的是 **action 下载源**(默认 `github.com`;`self` = 从自己实例拉且需先镜像 action),**不是填自己的 Gitea 地址**;§4.3 同步修正(原文误写"gitea.com 代理"),并补零依赖写法为内网首选。依据:Gitea 官方 FAQ + config-cheat-sheet | -| 2026-10-01 | 初版:Gitea Actions 配置全流程(服务端开关 / Windows runner 注册 / runner 软件环境 / workflow 位置与触发 / 内网 action 拉取 / 端到端验证清单 / 故障排查 / 日常维护) | diff --git a/docs/环境与部署/Gitea_Runner部署指南.md b/docs/环境与部署/Gitea_Runner部署指南.md deleted file mode 100644 index 71a8a9b4..00000000 --- a/docs/环境与部署/Gitea_Runner部署指南.md +++ /dev/null @@ -1,917 +0,0 @@ -# Gitea Actions Runner 部署指南(本机 Windows 原生版) - -> **适用环境(2026-10-02 实测确认)**: -> - Gitea **1.25.4**,**Windows 原生二进制**(`D:\Docker\Gitea\gitea.exe`,**不是 Docker**), -> 以 **Windows 服务**运行(`tasklist` 显示 Session = `Services`),监听 `0.0.0.0:3001` -> - 配置 `D:\Docker\Gitea\custom\conf\app.ini`,`[actions] ENABLED = true` **已开启** ✅ -> - 数据库 MySQL(`127.0.0.1:3306`),仓库根 `D:\Docker\Gitea\data\gitea-repositories` -> - 对外域名 `http://gitea.bitnasdaq.vip/`(经反代),本地直连 `http://127.0.0.1:3001` ✅ -> - Docker Desktop **已装但 daemon 未运行** → **本指南不使用容器,全程原生** -> -> **本文只讲 Runner 端**(下载 → 注册 → 配置 → 常驻 → 验证 → 排障)。 -> Gitea 服务端开关已就绪,无需再动。CI 整体设计见 `docs/测试说明/测试开发标准流程.md` §9。 - -> ⚠️ **重要版本变更(2026-10-02 实测)**:act_runner 已更名为 **`gitea-runner`**, -> 当前最新版 **v4.0.1**(发布页 `https://gitea.com/gitea/runner/releases`, -> 注意仓库路径已从 `gitea/act_runner` 变为 **`gitea/runner`**)。 -> **v4 移除了 `daemon install/start/stop/status` 子命令**(那些是 v0.2.x 的接口)。 -> 另:v4 的 `log` 段只有 `level` 和 `job`(**无 `file` 键**),配置用 -> `gitea-runner config init / set / get / generate` 管理。 - -> 🔴 **常驻方式修正(2026-10-02 实测推翻)**:`gitea-runner daemon` 是**纯控制台程序**, -> **不支持作为 Windows 服务运行**(二进制内无服务管理代码,也无 `service.go`); -> 用 `sc.exe` / `New-Service` 注册后 `Start-Service` 必然失败。 -> **正确方式是 Windows 任务计划程序** —— 见 **§4**。 - ---- - -## 0. 先理解:这台机器上的拓扑 - -**Gitea 和 Runner 在同一条机器上** —— 这是本部署最关键的事实: - -``` -┌─────────────────────────── 本机 (Windows) ────────────────────────────┐ -│ │ -│ gitea.exe ── Windows 服务 ── 监听 0.0.0.0:3001 │ -│ ▲ │ -│ │ ① runner 注册 / 心跳 / 领任务:走 http://127.0.0.1:3001 │ -│ │ (本地回环,不经公网、不依赖 DNS/反代) │ -│ │ │ -│ gitea-runner.exe ── 计划任务 GiteaActionsRunner(win-stm32-ci) │ -│ │ │ -│ └─ ② 领到 job 后,直接在**本机**跑: │ -│ git fetch / pre-commit / cppcheck / cl.exe │ -│ (标签 windows:host = 用宿主环境,非容器) │ -│ │ -│ 外部用户 → https://gitea.bitnasdaq.vip → 反代 → 127.0.0.1:3001 │ -└───────────────────────────────────────────────────────────────────────┘ -``` - -**两个地址要分清**(最容易搞错的地方): - -| 用途 | 地址 | 说明 | -|---|---|---| -| **Runner 连接 Gitea** | `http://127.0.0.1:3001` | 本机回环,**最稳**。用 `gitea.bitnasdaq.vip` 也行,但会多绕一圈 DNS + 反代,且若反代没配对内网 IP 会失败 | -| **用户在浏览器访问** | `https://gitea.bitnasdaq.vip` | 走反代;注册时网页上显示的示例命令用的是这个域名,**你要手动换成 127.0.0.1:3001** | - -> ⚠️ **注册时用哪个地址,决定了 runner 以后一直连哪里**。 -> 注册信息存在 `.runner` 里,之后改起来要重新注册,**建议一开始就用 `http://127.0.0.1:3001`**。 - ---- - -## 1. 下载 gitea-runner - -### 1.1 选对版本 - -| 项 | 值 | -|---|---| -| 架构 | **amd64**(`PROCESSOR_ARCHITECTURE = AMD64`,实测) | -| 版本 | **v4.0.1**(2026-10-02 实测最新稳定版) | -| 下载页 | https://gitea.com/gitea/**runner**/releases ← 注意仓库名已从 `act_runner` 改为 `runner` | -| 文件名 | `gitea-runner-4.0.1-windows-amd64.exe` ← 二进制名也从 `act_runner` 改为 **`gitea-runner`** | - -### 1.2 下载 - -```powershell -# 建目录(与 Gitea 同级,便于管理) -New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null -cd "D:\Docker\act_runner" - -# 下载 v4.0.1(版本号请替换为 releases 页当前最新) -Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" ` - -OutFile "gitea-runner.exe" - -# 校验能跑 -.\gitea-runner.exe --version -``` - -**期望输出**:`gitea-runner version v4.0.1`。 - -> 💡 **本机实测**(2026-10-02):下载 20.6 MB,耗时约 5 秒,`gitea.com` 可直连(`HTTP 200`)。 - -### 1.3 v4.0.1 的命令集(与 v0.2.x 的差异) - -``` -Available Commands: - bug-report Print information useful when filing a bug report - cache-server Start a cache server for the cache action - config Generate, read and edit config files - daemon Run as a runner daemon - exec Run workflow locally. - help Help about any command - register Register a runner to the server -``` - -> ⚠️ **没有 `daemon install/start/stop/status`** —— 这些是 v0.2.x 的接口,v4 已移除。 -> 常驻必须用 Windows 原生方式(见 §4)。 -> -> `config` 子命令很方便,建议优先用它而不是手改 YAML: -> `config init`(生成最小配置)/ `config generate`(打印全部选项) -> / `config set ` / `config get `。 - ---- - -## 2. 注册 Runner - -### 2.1 先拿 Registration Token - -1. 浏览器登录 `https://gitea.bitnasdaq.vip` -2. 进仓库 **`wandering1/STM32F4-Base`** -3. **Settings(设置)→ Actions → Runners → 创建 Runner / Create new Runner** -4. 页面会显示一段命令,形如: - -``` -./gitea-runner register --no-interactive --instance https://gitea.bitnasdaq.vip --token xxxxxxxxxxxxxxxxxxxx -``` - -**复制其中的 `--token` 后面那一串**(这是 Registration Token,一次性用途)。 -页面上的 `--instance` 值**不要照抄**——见下。 - -> 💡 **Token 存在于 `action_runner_token` 表**(MySQL,见 `Gitea_Runner部署指南` §附录)。 -> 每仓库一个有效 token,**注册一次后即被消费**;过期(约 1 小时)或已用则需重新创建。 - -### 2.2 执行注册(关键:instance 用 127.0.0.1) - -```powershell -cd "D:\Docker\act_runner" - -.\gitea-runner.exe register ` - --no-interactive ` - --instance "http://127.0.0.1:3001" ` - --token "粘贴刚才复制的 Registration Token" ` - --name "win-stm32-ci" ` - --labels "windows:host" -``` - -**四个参数逐一说明**: - -| 参数 | 值 | 为什么 | -|---|---|---| -| `--instance` | `http://127.0.0.1:3001` | **本机回环**。因为 Gitea 就在这台机器上,回环最稳、最快、不受反代/DNS 影响 | -| `--token` | 网页复制的那串 | 绑定到具体仓库(仓库级 runner) | -| `--name` | `win-stm32-ci` | runner 显示名,随意但建议有辨识度 | -| `--labels` | **`windows:host`** | 🔴 **最容易踩的坑**:`windows` 必须与 workflow 的 `runs-on: windows` **逐字一致**;`:host` 表示用**宿主环境**直接跑(非容器)——本场景**必须 host**,容器里拿不到 MSVC/cppcheck | - -> ⚠️ **`--labels` 写错的后果**:runner 会注册成功、显示绿色,但**任务永远排队不执行** -> (没有任何 runner 匹配 `runs-on: windows`)。这是最隐蔽的失败模式。 - -**期望输出**:`Runner registered successfully`,并在 `D:\Docker\act_runner\` 生成 `.runner` 文件(记录注册凭据)。 - -### 2.3 验证注册 - -浏览器刷新 **Settings → Actions → Runners**,应看到: - -| 列 | 期望值 | -|---|---| -| Name | `win-stm32-ci` | -| Labels | `windows`(`:host` 是执行方式,UI 可能只显示 `windows`) | -| Status | 🟢 **Idle** | - ---- - -## 3. 配置 config.yaml - -**推荐用 `config` 子命令生成/修改**(v4 新增,比手改 YAML 安全): - -```powershell -cd "D:\Docker\act_runner" -.\gitea-runner.exe config init # 生成最小 config.yaml -.\gitea-runner.exe config set runner.capacity 1 # 逐项设置 -.\gitea-runner.exe config set runner.timeout 30m -.\gitea-runner.exe config set cache.enabled true -.\gitea-runner.exe config set log.level info -.\gitea-runner.exe config get runner.capacity # 校验 -.\gitea-runner.exe config generate # 打印全部可用选项(含注释) -``` - -**本机已落地的配置**(`D:\Docker\act_runner\config.yaml`): - -```yaml -log: - # trace/debug/info/warn/error/fatal/panic;排障时临时改 debug - level: info - # 每个 job 的任务日志另存一份到本地(防上报 Gitea 失败时日志丢失) - job: - dir: "D:\\Docker\\act_runner\\log\\job" - retention: 168h - max_size: 100MB - -runner: - file: .runner # 注册凭据(由 register 生成,勿手改) - capacity: 1 # 并行 job 数;单机建议 1(cppcheck 吃 CPU) - timeout: 30m0s # 单 job 超时;L0+L0.5 约 1~3 分钟 - fetch_timeout: 5m0s - fetch_interval: 2s - # 🔴 标签:windows 必须与 workflow 的 runs-on: windows 逐字一致 - labels: - - "windows:host" - -cache: - enabled: true - dir: "D:\\Docker\\act_runner\\cache" - -container: - network: "" - privileged: false - options: "" - valid_volumes: [] - docker_host: "" - force_pull: false -``` - -> ⚠️ **v4 的 `log` 段只有 `level` 和 `job` 两个键** —— **没有 `file` 键** -> (`config set log.file ...` 会报 `unknown config key`)。 -> 想要本地日志用 `log.job.dir`(按 job 落文件),或把服务输出重定向到文件。 -> -> ⚠️ **改了 `runner.labels` 必须重启 runner 才生效** -> (Gitea 官方 FAQ:*"A runner declares its labels every time it starts ... so change them there and restart the runner."*) - ---- - -## 4. 让 Runner 常驻(任务计划程序) - -### 4.0 🔴 关键结论:**不能用 Windows 服务** - -> **本条为 2026-10-02 实测推翻既有假设后的修正结论,务必先读。** - -先前本文档(及 `install_service.ps1`)主张用 `sc.exe` / `New-Service` 把 -`gitea-runner daemon` 注册成 Windows 服务。**实测不可行**,`Start-Service` 必然失败: - -``` -Start-Service : 由于以下错误无法启动服务"Gitea Actions Runner (gitea-runner)" ... -``` - -**根因(对 `gitea-runner.exe` v4.0.1 二进制逆向确认)**: - -| 证据 | 结论 | -|---|---| -| 命令集只有 `bug-report` / `cache-server` / `config` / `daemon` / `exec` / `register`(对应源码 `internal/app/cmd/{bug_report,cache-server,cmd,config,daemon,exec,register}.go`) | **没有 `service.go`,没有任何服务管理子命令** | -| `daemon --help` 仅 `--labels` / `--once` 两个 flag | daemon 无服务模式开关 | -| 二进制内虽出现 `OpenSCManagerW` / `CreateServiceW` / `StartServiceCtrlDispatcherW` 等符号 | 它们来自 **Docker SDK**(容器作业路径),**不是** runner 的服务安装代码——切勿据此误判 | - -**`daemon` 是纯控制台程序**:它从不调用 `StartServiceCtrlDispatcher`, -因此 SCM 启动它后收不到 `SERVICE_RUNNING` 回报,判定超时 → 报错 1053。 - -**正确做法:Windows 任务计划程序(Task Scheduler)。** -它是控制台程序常驻的标准手段,功能上等价于服务: - -| 服务能做的 | 任务计划程序对应能力 | -|---|---| -| 开机自启 | 触发器 `-AtStartup`(+ 延迟 30s 让 Gitea 先起) | -| 不登录也运行 | 以 `SYSTEM` 账户运行 | -| 最高权限 | `-RunLevel Highest` | -| 崩溃自动重启 | `-RestartCount 999 -RestartInterval 1min` | -| 开机/手动启停 | `Enable/Disable/Start/Stop-ScheduledTask` | - -### 4.1 前台先试跑(强烈建议) - -**先别急着常驻**——用前台模式确认能连上: - -```powershell -cd "D:\Docker\act_runner" -.\gitea-runner.exe daemon --config .\config.yaml -``` - -看到这两行即成功: - -``` -level=info msg="Starting runner daemon" -level=info msg="runner: win-stm32-ci, with version: v4.0.1, with labels: [windows], declare successfully" -``` - -**让这个窗口开着**,去 Gitea 页面确认 runner 是 Idle,然后 Ctrl+C 停掉、再做常驻。 - -### 4.2 一键部署脚本(推荐) - -管理员 PowerShell 执行: - -```powershell -powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\deploy_runner_task.ps1" -``` - -脚本流程:提权检查 → 前置文件检查(`gitea-runner.exe` / `config.yaml` / `.runner`) -→ **清理旧的无效 Windows 服务**(若存在)→ 生成启动包装 `run_runner.cmd` -→ `Register-ScheduledTask`(开机+登录双触发器、SYSTEM 账户、最高权限、崩溃重启) -→ 启动任务 → 校验进程存活。 - -### 4.3 手动等价命令 - -不想用脚本时,管理员 PowerShell 逐条执行: - -```powershell -$Dir = "D:\Docker\act_runner" - -# ① 包装脚本(固定工作目录 + stdout 落盘) -@" -@echo off -cd /d "$Dir" -"$Dir\gitea-runner.exe" daemon --config "$Dir\config.yaml" >> "$Dir\log\runner-stdout.log" 2>&1 -"@ | Out-File "$Dir\run_runner.cmd" -Encoding ASCII - -# ② 注册任务 -$action = New-ScheduledTaskAction -Execute "$Dir\run_runner.cmd" -WorkingDirectory $Dir -$trgBoot = New-ScheduledTaskTrigger -AtStartup -$trgBoot.Delay = 'PT30S' -$trgLogon = New-ScheduledTaskTrigger -AtLogOn -$prin = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest -$sets = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` - -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew - -Register-ScheduledTask -TaskName "GiteaActionsRunner" -Action $action ` - -Trigger @($trgBoot, $trgLogon) -Principal $prin -Settings $sets ` - -Description "Gitea Actions Runner for STM32F4-Base CI (labels: windows:host)" - -# ③ 启动 -Start-ScheduledTask -TaskName "GiteaActionsRunner" -``` - -> ⚠️ **`ExecutionTimeLimit = 0`(不限时长)必须显式设置**—— -> 任务计划程序默认 72 小时强制结束任务,会让 runner 每 3 天被杀一次。 - -> ⚠️ **`-WorkingDirectory` 只对 `New-ScheduledTaskAction` 有效**(这是它区别于 -> WinPS 5.1 `New-Service` 的地方)。不过仍建议用 `.cmd` 包装再 `cd /d`, -> 因为 SYSTEM 账户的默认工作目录不可控。 - -### 4.4 任务管理速查 - -```powershell -Get-ScheduledTask -TaskName GiteaActionsRunner # 状态 -Get-ScheduledTaskInfo -TaskName GiteaActionsRunner # 上次运行结果 -Start-ScheduledTask -TaskName GiteaActionsRunner # 启动 -Stop-ScheduledTask -TaskName GiteaActionsRunner # 停止 -Disable-ScheduledTask -TaskName GiteaActionsRunner # 禁用自启 -Enable-ScheduledTask -TaskName GiteaActionsRunner # 恢复自启 -Unregister-ScheduledTask -TaskName GiteaActionsRunner -Confirm:$false # 完全卸载 -``` - -改完 `config.yaml` 后需 **Stop 再 Start** 任务才生效。 - -> 💡 **`LastTaskResult` 数值解读**(别被非 0 吓到): -> -> | 值 | 含义 | -> |---|---| -> | `267009` (`0x41301`) | ✅ **`SCHED_S_TASK_RUNNING` —— 任务正在运行,不是错误**(常驻任务常态值) | -> | `0` (`0x0`) | ✅ 上次运行正常结束 | -> | `267011` (`0x41303`) | 任务尚未运行过 | -> | `267014` (`0x41306`) | 任务被用户终止 | -> -> **判断 runner 是否健康,看这三项而不是 `LastTaskResult`**: -> ① `Get-Process gitea-runner` 有进程; -> ② `log\runner-stdout.log` 最新一行是 `declare successfully`; -> ③ Gitea 页面 runner 显示 **Idle**(或 MySQL 查 `action_runner.last_online`)。 - -### 4.5 ⚠️ 清理遗留的无效服务 - -若早前按旧文档建过服务,删掉它(留着只会误导): - -```powershell -Stop-Service gitea-runner -Force -ErrorAction SilentlyContinue -sc.exe delete gitea-runner -``` - -> **`gitea-runner` 这个名字将不再作为服务存在。** 之后提到"gitea-runner" -> 一律指**计划任务 `GiteaActionsRunner`** 或**进程 `gitea-runner.exe`**。 - -### 4.6 ⚠️ 权限要求(本机实测的硬约束) - -**注册计划任务必须用「提权的管理员」Shell**。本机实测: - -| 检查项 | 结果 | -|---|---| -| 当前用户 | `DESKTOP-KM0OG7Q\Administrator` | -| 是否提权(`IsInRole(Administrator)`) | **False** | -| 非提权下注册任务 | ❌ `拒绝访问`(`Register-ScheduledTask` 需要 elevated token) | - -> **"是 Administrator 用户" ≠ "有管理员权限"** —— Windows 的 UAC 会把普通登录会话降权。 -> 必须**右键 PowerShell → 以管理员身份运行**,标题栏会显示「管理员」字样。 - -> 💡 计划任务以 **`SYSTEM`** 账户运行,拥有完整权限,可正常访问本地 -> `127.0.0.1:3001` 的 Gitea。 - ---- - -## 5. Runner 必须装齐的软件 - -Runner 会**直接在本机跑** CI 步骤,所以本机必须有一套工具链。 -(**你本机是开发机,大概率已经全有** —— 逐条核对下表。) - -| 项 | 检查命令 | 期望 | 缺了怎么办 | -|---|---|---|---| -| **Python 3.x** | `python --version` | `Python 3.x` | `winget install Python.Python.3.12` | -| **cppcheck** | `cppcheck --version` | `Cppcheck 2.21.0` | `winget install Cppcheck.Cppcheck` | -| **pre-commit** | `python -m pre_commit --version` | `pre-commit 4.x` | `pip install pre-commit` | -| **MSVC** | 见下 | `cl.exe` 存在 | `winget install Microsoft.VisualStudio.2022.BuildTools`(勾"使用 C++ 的桌面开发") | -| Git | `git --version` | 任意 | 你已在用 | - -**本机实测状态(2026-10-02)**: - -``` -Python : C:\ProgramData\anaconda3\python.exe → 3.12.4 ✅ -cppcheck : C:\Program Files\Cppcheck\cppcheck.exe → 2.21.0 ✅ -pre-commit: 4.6.0 (anaconda3) ✅ -MSVC : VS2019 BuildTools 14.29.30133 (Hostx64\x64\cl.exe) ✅ -``` - -**→ 四项全齐,无需额外安装。** - -### 5.1 ⚠️ 关键坑:PATH 与运行账户 - -**问题的本质**:计划任务以 **`SYSTEM`** 账户运行时,继承的是**系统级 PATH** -(注册表 `HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` 的 `Path`), -**不含任何用户级 PATH**。 - -- 你在自己的终端里能用 `python`,**不代表 SYSTEM 账户里也能用**; -- **装完任何新软件 / 改过系统 PATH 后,必须重启 runner 任务**: - -```powershell -Stop-ScheduledTask -TaskName GiteaActionsRunner -Start-ScheduledTask -TaskName GiteaActionsRunner -``` - -> **核对方法**:用 `check_env.ps1` 的「系统 PATH 视角」检查项, -> 或以 SYSTEM 身份跑一次 `where python` 验证。 - -### 5.1.1 ⚠️⚠️ 关键坑:`pre-commit` 必须在「全局 site-packages」,装到用户态等于没装 - -这是 2026-10-02 排查 **L0 job 反复失败**(`::error::no Python with a SYSTEM-visible pre-commit`)的真正根因, -也是本环境最隐蔽的一个坑。 - -**现象**:`pre-commit` 在你自己的终端里好好的(`pre-commit --version` 有输出), -但 CI 的 `l0-gates` job 三个候选 Python 全部探测失败: - -``` -::error::no Python with a SYSTEM-visible pre-commit. Tried: - C:\msys64\ucrt64\bin\python.exe | - \.venv\Scripts\python.exe | - C:\ProgramData\anaconda3\python.exe -``` - -> 📌 上为 2026-10-02 的历史报错原文。workflow 现已改为 -> **`DTU_PRECOMMIT_PYTHON`(机器级)→ PATH 的 `python` → 工作区 `.venv`** 顺序(§5.1.3), -> 且报错行会指明是变量无效还是自动发现落空。 - -**根因(两层)**: - -1. **用户态 site-packages 对 SYSTEM 不可见**。 - `pip install pre-commit`(不带提权)默认装到 **user-site**: - `C:\Users\Administrator\AppData\Roaming\Python\Python312\site-packages`。 - 而 SYSTEM 账户的 `%APPDATA%` 是 - `C:\Windows\system32\config\systemprofile\AppData\Roaming` —— **完全是另一个目录**, - 它根本看不到 Administrator 的 user-site。**任何 `python.exe` 路径都救不了这个**。 - -2. **pip 的「已满足」判定陷阱**(更阴)。 - 就算你**提权**执行 `pip install --no-user pre-commit`,pip 在判断 - 「这个包是不是已经装了」时看的是**整个 `sys.path`**——其中包含 user-site。 - 既然 user-site 里已有同版本的包,pip 会直接打印 - `Requirement already satisfied: xxx in ...\roaming\...\site-packages` - 然后**完全跳过**安装到全局目录。 - 结果:pip 报"成功",全局目录里却什么都没有。 - - > 实测:9 个依赖里 8 个因版本与用户态不同而被 pip 升级(侥幸落到全局), - > 唯独 `cfgv` 用户态已是同版本 **3.5.0** → 被静默跳过 → - > `-s` 视角下 `ModuleNotFoundError: No module named 'cfgv'`。 - -**诊断命令**(`-s` = 禁用 user-site,模拟 SYSTEM 视角): - -```powershell -# 看全局目录到底有没有(而不是 pip 怎么说) -& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version - -# 逐个依赖点名,找出被静默跳过的那个 -& 'C:\ProgramData\anaconda3\python.exe' -s -c "import cfgv, identify, nodeenv, virtualenv, distlib, filelock, platformdirs, yaml; print('all ok')" - -# 反查 pip 为何跳过(会显示 "Requirement already satisfied ... roaming ...") -& 'C:\ProgramData\anaconda3\python.exe' -m pip install --no-user --dry-run cfgv -``` - -**修复(必须提权)**:脚本 `install_precommit_global.ps1` + -`install_precommit_global2.ps1`(逐包安装 + 逐个 import 复验); -对**被静默跳过**的包补一支 `fix_cfgv_global.ps1`,核心是强制重装: - -```powershell -& $Py -m pip install --no-user --force-reinstall --no-deps ` - --disable-pip-version-check --no-warn-script-location cfgv -``` - -> `--force-reinstall` 无视"已满足"判定,`--no-deps` 避免牵连其它包。 -> 三个脚本都在 `D:\Docker\act_runner\`。 - -**验证(四条全过才算好)**: - -```powershell -$Py = 'C:\ProgramData\anaconda3\python.exe' -& $Py -s -m pre_commit --version # pre-commit 4.6.2 -Test-Path 'C:\ProgramData\anaconda3\Lib\site-packages\cfgv.py' # True -# 关键:确认文件真在【全局】目录,而不是 roaming -Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*' -``` - -> ⚠️ 别再往 `.venv` 里装就以为 CI 能用了——CI 虽然会检查 `.venv\Scripts\python.exe`, -> 但 `actions/checkout@v4` 拉的是**干净克隆**,`.venv` 是 gitignore 的,**根本不存在**。 - -### 5.1.2 ⚠️ 关键坑:`doc-links` 钩子「本地过、CI 挂」= 引用了 gitignore 的运行时文件 - -**现象**:`文档链接存在性 (D-3/D-7)` 钩子在**本地** `pre-commit run --all-files` 通过, -一上 CI 就 `Failed`(exit 1),报的行/路径你在本地明明存在。 - -**根因**:CI 的 `actions/checkout@v4` 拉的是**干净克隆**,而钩子校验的是 -**仓库根相对路径是否存在**(`check_backtick_paths`,见 `scripts/check_doc_links.py`)。 -若文档引用了 **gitignore 的运行时产物**—— -如 `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建,`.gitignore:132` 排除整个 `state/`)—— -你本机因为跑过工具所以文件在,**克隆里则不存在** → 判为失效引用。 - -**修复**:按 `docs/文档规范.md` §3.3 给该行加**状态词**(脚本的 `STATUS_HINTS`: -`计划 / 未创建 / 尚未创建 / 已删除 / 删除 / 不存在 / 缺失 / 断链 / 待建 / 待创建`)。 - -```markdown -| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) | -``` - -> ⚠️ **状态词必须与引用在【同一行】且 ≤40 字符**(`STATUS_PROXIMITY`)。 -> 把说明写在**上一行**是无效的——脚本逐行调用 `has_status_hint(line, ...)`, -> 跨行的文字不在窗口内。 - -**本地复现 CI 条件的姿势**(否则永远测不出来): - -```bash -# 把运行时目录临时移开,模拟干净克隆,跑完再移回 -mv .dsh/skills/serial-assistant/scripts/state .dsh/skills/serial-assistant/scripts/state.bak_ci -& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all # 或全局 python -m pre_commit run doc-links --all-files -mv .dsh/skills/serial-assistant/scripts/state.bak_ci .dsh/skills/serial-assistant/scripts/state -``` - -> 更一般的教训:**任何校验"文件是否存在/数字是否一致"的钩子,都要在 -> "无本地产物"的干净克隆里验一次**,别只在开发机上跑。 - -### 5.1.3 机器级 `DTU_*` 环境变量(runner 的正式覆盖通道,2026-10-02 起) - -workflow 已改为统一消费 `DTU_*` 接口(见 `本地工具路径环境变量方案.md` §3.3): -**显式变量优先 → 自动发现回退**,三个 job 步骤相关的变量: - -| 变量 | 作用 | 未设置时的回退 | -|---|---|---| -| `DTU_PRECOMMIT_PYTHON` | 指向装有 pre-commit 的 python.exe(L0/L0.5 用) | PATH 的 `python` → 工作区 `.venv`(干净克隆里通常没有) | -| `DTU_CPPCHECK` | 指向 cppcheck.exe(L0 钩子用) | SYSTEM 的 PATH(用户级 PATH 不可见) | -| `DTU_MSVC_CL` | 指向 x64 `cl.exe`(L0.5 用) | `%ProgramFiles%`/`%ProgramFiles(x86)%` 推导的 VS 标准根自动探测 | - -设置(**必须管理员**;scope 用 `Machine` 才能被 SYSTEM 任务看到): - -```powershell -[Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine') # 示例:本机 runner -[Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine') -[Environment]::SetEnvironmentVariable('DTU_MSVC_CL', 'C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe', 'Machine') -# ★ 设完必须重启 runner 任务(环境块是任务启动时快照的) -Stop-ScheduledTask -TaskName GiteaActionsRunner -Start-ScheduledTask -TaskName GiteaActionsRunner -``` - -> 与 §5.1.1 的关系:机器级 `DTU_PRECOMMIT_PYTHON` 是**正式通道**(指向哪个解释器由你说了算, -> 值无效时 job 直接报变量名、不猜);"把 pre-commit 装进全局 site-packages"仍是**兜底** -> (变量未设 + 全局 site 可见也能过)。两条路并存不冲突:变量设了就用变量,没设走探测。 -> 显式变量值无效(文件不存在 / 无 pre-commit 模块)→ job **硬失败**并指明变量名——**不会** -> 静默回退到别的解释器,避免 runner 悄悄换了 Python 导致钩子行为漂移。 - -### 5.2 ⚠️ 关键坑:MSVC 的路径必须"标准"(或显式给变量) -CI 的 MSVC 探测(`l05` job)优先读机器级 `DTU_MSVC_CL`(§5.1.3); -未设置时按**标准布局自动探测**(根目录由 `%ProgramFiles` / `%ProgramFiles(x86)` 推导, -不再写死盘符):2019 → `Program Files (x86)`;2022 / 2026(实际目录名 `18`)→ `Program Files`, -各取 BuildTools/Community/Professional/Enterprise 子目录,找 -`VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe`。本地 `unit_test_harness.py` 用同一套推导(已同步)。 - -**你本机实测命中第 1 条**: -`C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe` ✅ - -> ⚠️ 若将来 MSVC 装到 **D 盘或完全非标准路径** → 自动探测不到 → 两种处置(二选一): -> - 设机器级 `DTU_MSVC_CL` 指向 `cl.exe`(**推荐**,不动任何代码;值无效会硬报错、不静默跳过) -> - 或把 VS 装回标准根(探测逻辑按 `%ProgramFiles*` 推导,换盘符的标准布局仍命中) - -### 5.3 验证运行器能找到工具链(注册前先跑一遍) - -```powershell -& '.venv\Scripts\python.exe' scripts/ci_local.py -``` - -这条命令跑的就是 CI 的同一套判定。**本地能过,runner 上基本就能过**(前提是 PATH 一致)。 - ---- - -## 6. 端到端验证 - -按顺序做,任一步不过就停在那里查: - -| # | 动作 | 期望 | -|---|---|---| -| 1 | `.\gitea-runner.exe daemon` 前台启动 | 打印 `win-stm32-ci` + `labels: [windows]` + `declare successfully` | -| 2 | 浏览器 Settings → Actions → Runners | 🟢 Idle | -| 3 | 确认 workflow 已推到远程(**见下方注意**) | 远程分支有 `.gitea/workflows/ci.yml` | -| 4 | Push 一次(或页面 **Run workflow**) | Actions 页出现运行中任务 | -| 5 | 看 `l0-gates` 日志 | `pre-commit ... Passed`;`mem-numbers` 显示 `Skipped`(无 Keil `.map`,正常) | -| 6 | 看 `l05-host-unit-tests` 日志 | `=== Task Watchdog Tests: 39/39 PASSED (failed=0) ===` | -| 7 | 提交记录旁 | ✅ 绿灯 | - -> ⚠️ **第 3 步是当前的最大阻塞**:本仓库的 `.gitea/workflows/ci.yml` 还在本地 -> (提交 `e904865` / `1e01950` / `52e6dcb` / `c305a8d` 均未 push)。**workflow 不上远程,CI 永远不会跑**, -> 哪怕 runner 配得再完美。 - -### 6.1 无网页登录时的状态核验(MySQL 直查) - -runner 是否在线,可以直接查 Gitea 数据库确认(本机 MySQL 凭据见 -`D:\Docker\Gitea\custom\conf\app.ini` 的 `[database]` 段): - -```bash -MYSQL="/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" -"$MYSQL" -h 127.0.0.1 -P 3306 -u wandering -p -D gitea -e \ - "SELECT id,name,version,last_online,agent_labels FROM action_runner;" -``` - -| 字段 | 判读 | -|---|---| -| `name` / `version` | 注册名与版本(应为 `win-stm32-ci` / `v4.0.1`) | -| `agent_labels` | 应为 `["windows"]` ← **不含 windows 就是标签配错了** | -| `last_online` | Unix 秒;**与当前时间相差 < 30 秒即在正常心跳** | - -```bash -date +%s # 对比 last_online -``` - -相关表:`action_runner`(已注册 runner)、`action_runner_token`(注册令牌, -字段 `repo_id` 对应 `repository.id`)。 - ---- - -## 7. 排障对照表 - -| 现象 | 根因 | 处置 | -|---|---|---| -| runner 一直 **Idle**,push 后任务**排队不跑** | `--labels` 不是 `windows`(最常见) | 改 `config.yaml` 的 `runner.labels: ["windows:host"]` → 重启任务(§4.4) | -| `daemon` 启动报 **connection refused / dial tcp** | `--instance` 填了不可达地址 | 删 `.runner` → 重新 `register`,`--instance http://127.0.0.1:3001` | -| 启动报 **token** 相关错误 | Registration Token 过期(约 1 小时)或已用过 | 回网页重新创建 runner 拿新 token,重新 `register` | -| 任务跑到一半报 `python: 不是内部或外部命令` | SYSTEM 账户 PATH 没有 Python | 确认 Python 在**系统** PATH → 重启任务(§5.1) | -| `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | 同上,PATH 未生效 | 装齐 + 重启任务(§5.1) | -| `checkout` 步骤卡住 / 拉不到 action | Gitea 默认从 **github.com** 拉 action,本机不通外网 | 见 §8 | -| `l05` 日志 `::warning::runner 未安装 MSVC` | MSVC 不在标准路径 | 见 §5.2 | -| `l05` 报 `fatal error C1083: 无法打开 "stdio.h"` | 缺 Windows SDK | 装 VS 时勾"使用 C++ 的桌面开发" + SDK | -| 日志里 `pre-commit` 第一次特别慢 | 首次下载各钩子环境 | 正常,`cache.enabled: true` 后第二次快 | -| **改了 `.runner` / 换机器** | 注册信息失效 | 删 `.runner` → 重新 `register` | -| `daemon install` 报 `accepts at most 0 arg(s)` | **v4 已移除该子命令,且无任何服务子命令** | **改用任务计划程序**(§4) | -| 🔴 `Start-Service gitea-runner` 报 **无法启动服务**(错误 1053) | **`daemon` 不是服务型程序**,不向 SCM 回报 `SERVICE_RUNNING` | **删除该服务,改用任务计划程序**(§4.0 / §4.5) | -| `Register-ScheduledTask` 报 **拒绝访问** | Shell 未提权(`IsAdmin=False`) | 右键 PowerShell → **以管理员身份运行**(§4.6) | -| 任务 `LastTaskResult` 非 0 | 启动包装脚本路径/权限问题 | 看 `log\runner-stdout.log`;确认 `.cmd` 存在且为 ASCII 编码 | -| 🔴 `l0-gates` 报 `no Python with a SYSTEM-visible pre-commit` | `pre-commit` 只装在**用户态** site-packages(SYSTEM 不可见);或 pip 因 user-site 已满足而**静默跳过**全局安装 | 提权装到**全局**(§5.1.1),务必用 `-s` 复验 | -| `l0-gates` 报 `TerminatorExpectedAtEndOfString` / 缺少右引号 | `run:` 块含**中文**,被 WinPS 5.1 按 GBK 解码撞坏引号 | `run:` 块改**纯 ASCII**(中文只留 `name:`/注释) | -| `l0-gates` 报 `Cannot find: pwsh in PATH` | `shell: pwsh` = PS7,本机只有 WinPS 5.1 | 改 `shell: powershell` | -| `l0-gates` 报 `runner missing: cppcheck` | cppcheck 仅在用户级 PATH(SYSTEM 不可见) | 设机器级 `DTU_CPPCHECK`(§5.1.3,**workflow 不再回落固定目录**),或把 cppcheck 目录加入系统 PATH 后重启任务 | -| 🔴 `doc-links` 钩子本地过、CI 挂 | 引用了 **gitignore 的运行时文件**(如 `.dsh/skills/serial-assistant/scripts/state/*.json`),本地存在、干净克隆里没有 | 该行加状态词「未创建/不存在」等(§5.1.2) | -| `l05` 日志 `::warning::runner has no MSVC` 但仍 **PASS** | 缺 MSVC 时 L0.5 **降级跳过**(不硬失败) | 装 VS BuildTools(§5.2),否则该层无覆盖 | -| 任务跑满 72 小时被杀 | 计划任务默认 `ExecutionTimeLimit` | 设为 `0`(不限时长),见 §4.3 警示 | -| `config set log.file ...` 报 `unknown config key` | **v4 的 log 段无 `file` 键** | 用 `log.job.dir` 或输出重定向(§3) | -| **脚本报一堆 `源文本中包含无法识别的标记` / `字符串缺少终止符` / 中文显示成 `鐒跺悗鎵ц`** | 🔴 **`.ps1` 无 BOM,WinPS 5.1 按 GBK 解读 UTF-8** | **给脚本加 UTF-8 BOM**(§7.1) | - -### 7.1 🔴 必读:给含中文的 `.ps1` 加 UTF-8 BOM - -**这是本机实测踩过的坑**(2026-10-02)。Windows PowerShell 5.1 对**无 BOM** 的脚本文件 -按**系统 ANSI 代码页**(简中 = **GBK / CP936**)解码: - -- UTF-8 的中文字节被当 GBK 解 → 显示成 `鐒跺悗鎵ц` 这类乱码; -- 更糟的是,**乱码字符会撞坏字符串引号配对**(GBK 双字节的尾字节可能是 `"` 或 `\`) - → 解析器报 `源文本中包含无法识别的标记` / `字符串缺少终止符: "` / `缺少右"}"` - —— 报错行号看起来毫无规律,**极易误判成逻辑问题**。 - -**修法**:给文件头加 3 字节 `EF BB BF`(UTF-8 BOM)。与仓库里带 BOM 的源文件 -(`Drivers/BSP/UI/*`、`App/cfg_file.c`)是同一个道理。 - -```powershell -# 方法 A:PowerShell 重存为 UTF8BOM -$c = Get-Content "D:\Docker\act_runner\deploy_runner_task.ps1" -Raw -Encoding UTF8 -[System.IO.File]::WriteAllText("D:\Docker\act_runner\deploy_runner_task.ps1", $c, (New-Object System.Text.UTF8Encoding($true))) -``` - -```bash -# 方法 B:直接前缀 BOM 字节(bash/Git Bash) -cd "/d/Docker/act_runner" -for f in deploy_runner_task.ps1 check_env.ps1; do - printf '\xef\xbb\xbf' > "$f.tmp" && cat "$f" >> "$f.tmp" && mv "$f.tmp" "$f" -done -``` - -**校验**:用 PowerShell 解析器(不执行)确认语法干净: - -```powershell -foreach ($f in @("deploy_runner_task.ps1","check_env.ps1")) { - $errs = $null - $null = [System.Management.Automation.Language.Parser]::ParseFile( - "D:\Docker\act_runner\$f", [ref]$null, [ref]$errs) - if ($errs.Count) { "$f : FAIL -> $($errs[0].Message)" } else { "$f : OK" } -} -``` - -> 💡 替代方案:改用 **PowerShell 7+(`pwsh`)**,它默认按 UTF-8 读脚本、无需 BOM。 -> 但本机只有 WinPS 5.1,故加 BOM 最省事。 -> -> 💡 **纯 ASCII 的脚本不受影响**。本目录的 `.ps1` 均含中文注释,现已统一加 BOM。 - -| 日志 | 位置 | -|---|---| -| runner **自身** stdout/stderr | `D:\Docker\act_runner\log\runner-stdout.log` | -| **每个 job** 的详细日志 | `D:\Docker\act_runner\log\job\` | - -**看日志**:v4 的 `log` 段**没有 `file` 键**,两种落盘方式: - -```yaml -# 方式 A:按 job 落文件(推荐,防上报失败丢日志) -log: - level: info - job: - dir: "D:\\Docker\\act_runner\\log\\job" -``` - -```powershell -# 方式 B:前台运行 / 包装脚本里重定向 -.\gitea-runner.exe daemon > "D:\Docker\act_runner\log\runner.log" 2>&1 -``` - -**计划任务模式下看日志(两种)**: - -| 日志 | 位置 | -|---|---| -| runner **自身** stdout/stderr | `D:\Docker\act_runner\log\runner-stdout.log`(由 `run_runner.cmd` 重定向) | -| **每个 job** 的详细日志 | `D:\Docker\act_runner\log\job\` | - -任务本身的历史记录:**任务计划程序 → 任务计划程序库 → `GiteaActionsRunner` → 历史记录**; -或用 `Get-ScheduledTaskInfo -TaskName GiteaActionsRunner` 看 `LastTaskResult`。 - ---- - -## 8. ⚠️ 内网 action 拉取问题(你的环境很可能命中) - -workflow 里用了 `actions/checkout@v4`。Gitea **默认从 `https://github.com` 拉取这个 action** -(不是从你自己的实例,也不是从 gitea.com —— 这点很反直觉,见 -`CI配置指南.md` §1.2.1)。 - -**你的环境判断**:Gitea 装了 `OFFLINE_MODE = true`,说明这台机器的外网访问**可能受限**。 -如果 runner 拉不到 github.com,checkout 步骤会卡住。 - -### 处置:改用零依赖写法(内网首选) - -把 `.gitea/workflows/ci.yml` 里的 `actions/checkout@v4` 两步替换为纯 git 命令: - -```yaml - - name: Checkout - shell: pwsh - run: | - git init . - git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git - git fetch --depth 1 origin $env:GITHUB_SHA - git checkout $env:GITHUB_SHA -``` - -**优点**:不依赖任何外部 action、不需要配 `DEFAULT_ACTIONS_URL`、不需要镜像 action。 -**代价**:失去 checkout 的缓存优化(对本仓库影响很小)。 - -> 另两种方案(`DEFAULT_ACTIONS_URL = self` + 镜像 action 进实例 / 指向可达镜像站)见 -> `docs/环境与部署/CI配置指南.md` §4.3。**注意 `self` 是关键字,不是写你的域名。** - -**建议**:**先按原样试一次**。若 checkout 步骤报网络错误,再换成上面的零依赖写法。 - ---- - -## 9. 一键检查脚本(可选) - -存为 `D:\Docker\act_runner\check_env.ps1`,注册前跑一遍: - -```powershell -Write-Host "=== Gitea 可达性 ===" -ForegroundColor Cyan -try { - $v = (Invoke-RestMethod -Uri "http://127.0.0.1:3001/api/v1/version" -TimeoutSec 8).version - Write-Host " OK Gitea $v (127.0.0.1:3001)" -ForegroundColor Green -} catch { Write-Host " FAIL 连不上 Gitea: $_" -ForegroundColor Red } - -Write-Host "=== 工具链 ===" -ForegroundColor Cyan -foreach ($c in @("python", "cppcheck", "git")) { - $p = Get-Command $c -ErrorAction SilentlyContinue - if ($p) { Write-Host " OK $c -> $($p.Source)" -ForegroundColor Green } - else { Write-Host " FAIL 缺 $c" -ForegroundColor Red } -} -$pc = (python -m pre_commit --version 2>&1) -if ($LASTEXITCODE -eq 0) { Write-Host " OK $pc" -ForegroundColor Green } -else { Write-Host " FAIL 缺 pre-commit" -ForegroundColor Red } - -Write-Host "=== DTU_* 机器级变量(§5.1.3)===" -ForegroundColor Cyan -foreach ($n in @("DTU_PRECOMMIT_PYTHON", "DTU_CPPCHECK", "DTU_MSVC_CL")) { - $v = [Environment]::GetEnvironmentVariable($n, "Machine") - if ($v) { Write-Host " OK $n = $v" -ForegroundColor Green } - else { Write-Host " -- $n(未设置:workflow 将走自动发现)" -ForegroundColor Yellow } -} - -Write-Host "=== MSVC cl.exe(DTU_MSVC_CL 优先,否则标准布局推导)===" -ForegroundColor Cyan -if ($env:DTU_MSVC_CL -and (Test-Path $env:DTU_MSVC_CL)) { - Write-Host " OK $($env:DTU_MSVC_CL)(来自 DTU_MSVC_CL)" -ForegroundColor Green -} else { - $pf = ${env:ProgramFiles} - $pf86 = ${env:ProgramFiles(x86)} - $roots = @( - (Join-Path $pf86 'Microsoft Visual Studio\2019\BuildTools'), - (Join-Path $pf86 'Microsoft Visual Studio\2019\Community'), - (Join-Path $pf86 'Microsoft Visual Studio\2019\Professional'), - (Join-Path $pf 'Microsoft Visual Studio\2022\BuildTools'), - (Join-Path $pf 'Microsoft Visual Studio\2022\Community'), - (Join-Path $pf 'Microsoft Visual Studio\2022\Professional'), - (Join-Path $pf 'Microsoft Visual Studio\2022\Enterprise'), - (Join-Path $pf 'Microsoft Visual Studio\18\BuildTools'), - (Join-Path $pf 'Microsoft Visual Studio\18\Community') - ) - $hit = $null - foreach ($r in $roots) { - if (Test-Path $r) { - $hit = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1 - if ($hit) { break } - } - } - if ($hit) { Write-Host " OK $($hit.FullName)" -ForegroundColor Green } - else { Write-Host " FAIL 未找到 MSVC (Hostx64\x64\cl.exe);可设机器级 DTU_MSVC_CL" -ForegroundColor Red } -} -``` - ---- - -## 10. 最小操作清单(TL;DR) - -```powershell -# 1) 下载(v4.0.1;注意仓库名是 gitea/runner,二进制名是 gitea-runner) -New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null -cd "D:\Docker\act_runner" -Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" -OutFile "gitea-runner.exe" - -# 2) 注册(token 从网页 Settings→Actions→Runners 拿;instance 用回环) -.\gitea-runner.exe register --no-interactive ` - --instance "http://127.0.0.1:3001" ` - --token "<粘贴 token>" ` - --name "win-stm32-ci" ` - --labels "windows:host" - -# 3) 前台试跑,确认能连上 -.\gitea-runner.exe daemon # 看到 "labels: [windows] ... declare successfully" 即成功,Ctrl+C 停 - -# 4) 常驻 —— ⚠️ daemon 不是服务型程序,必须用【任务计划程序】(管理员 PowerShell) -powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\deploy_runner_task.ps1" - -# 等价手动: -# Register-ScheduledTask -TaskName GiteaActionsRunner -Action ( -# New-ScheduledTaskAction -Execute "D:\Docker\act_runner\run_runner.cmd") ` -# -Trigger (New-ScheduledTaskTrigger -AtStartup) ` -# -Principal (New-ScheduledTaskPrincipal -UserId SYSTEM -LogonType ServiceAccount -RunLevel Highest) -# Start-ScheduledTask -TaskName GiteaActionsRunner - -# 5) ⚠️⚠️ 把 pre-commit 装到【全局】site-packages(否则 L0 必挂,见 §5.1.1) -# —— 这是本环境最容易漏、也最难自己看出来的一步(用户态对 SYSTEM 不可见) -powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\install_precommit_global.ps1" -powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\install_precommit_global2.ps1" -# 若有包报"安装成功"但 import 失败(pip 静默跳过): -powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\fix_cfgv_global.ps1" - -# 5b) 2026-10-02 起推荐:改设机器级 DTU_* 变量(§5.1.3,显式覆盖、报错直指变量名); -# 全局 site-packages 安装保留为未设变量时的兜底。设完同样要重启 runner 任务。 -# (管理员 PowerShell;值按本机实际路径改) -# [Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine') -# [Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine') -# [Environment]::SetEnvironmentVariable('DTU_MSVC_CL', '<本机 x64 cl.exe 完整路径>', 'Machine') - -# 6) 复验(-s = 模拟 SYSTEM 视角,必须过) -& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version # pre-commit 4.6.2 - -# 7) 回网页确认 runner 为 Idle → 触发 CI -# push 触发(注意:纯 .md 改动在 workflow 的 paths-ignore 里,不会触发); -# 手动触发: -# curl -u ":" -X POST -H "Content-Type: application/json" ` -# -d '{"ref":"v2-on-ui"}' ` -# "https://gitea.bitnasdaq.vip/api/v1/repos/wandering1/STM32F4-Base/actions/workflows/ci.yml/dispatches" -``` - -> **CI 验收标准**(2026-10-02 已达成,run#8):`action_run_job` 里两个 job 均 `status=1`—— -> `l0-gates`(14 个 pre-commit 钩子全 Passed,`内存/Flash` 在无 `.map` 时 Skipped) -> + `l05-host-unit-tests`(MSVC 探测 + 39/39 单测)。 - -**配套脚本**(均在 `D:\Docker\act_runner\`,**均已加 UTF-8 BOM**,可直接在 WinPS 5.1 运行): - -| 脚本 | 用途 | 需要管理员 | -|---|---|---| -| `check_env.ps1` | 环境自检(Gitea 可达 / 工具链 / MSVC / 任务状态) | 否 | -| `deploy_runner_task.ps1` | **一键部署**(自检 + 清旧服务 + 建计划任务 + 启动 + 验证) | **是** | - -> 早前基于 Windows 服务的 `deploy_runner.ps1` / `install_service.ps1` 已删除 -> —— 那条路走不通(§4.0)。 - ---- - -## 变更记录 - -| 日期 | 变更 | -|---|---| -| 2026-10-02 | **workflow 接入 `DTU_*` 机器级变量**(`本地工具路径环境变量方案.md` 实施):`l0-gates` 的 Python 探测改为 `DTU_PRECOMMIT_PYTHON`(显式,无效即硬失败)→ PATH → `.venv`(删除 `C:\ProgramData\anaconda3` 固定候选);cppcheck 改为 `DTU_CPPCHECK` 优先 → PATH(**移除 `C:\Program Files\Cppcheck` 固定回退**);`l05` 的 MSVC 探测改为 `DTU_MSVC_CL` 优先 → `%ProgramFiles*` 推导的标准根(与 `unit_test_harness.py` 同步,不再写死盘符);新增 §5.1.3 机器级变量设置方法(`Machine` scope + 重启任务)、§5.2 与 check_env 样例、排障表 cppcheck 行、TL;DR 5b 步骤。§5.1.1 的报错原文标注为历史记录 | -| 2026-10-02 | ✅ **首次 CI 全绿(run#8:L0 + L0.5 双 job Success)**。定位并修复最后两个拦路虎:① **§5.1.1 用户态 site-packages 陷阱**——`pre-commit` 只装在 `%APPDATA%\Roaming\Python\...`(SYSTEM 不可见);且提权 `pip install --no-user` 会因 pip 扫**整个 `sys.path`**、见 user-site 已满足而**静默跳过**全局安装(9 依赖中唯 `cfgv` 因版本相同被跳过→`-s` 下 `ModuleNotFoundError`)。修法:提权 + `--force-reinstall --no-deps` 强制落全局,并以 `-s` 复验。② **§5.1.2 doc-links 假阳性**——文档引用了 gitignore 的运行时文件 `.dsh/skills/serial-assistant/scripts/state/meta.json`,本地存在、干净克隆无 → 按《文档规范》§3.3 加同状态词「运行时未创建」。排障表补 6 条(pre-commit 不可见 / GBK 中文 / pwsh / cppcheck / doc-links / MSVC 降级)。新增配套脚本 `install_precommit_global.ps1`、`install_precommit_global2.ps1`、`fix_cfgv_global.ps1` | -| 2026-10-02 | 🔴 **常驻方式重大修正(实测推翻)**:`Start-Service gitea-runner` 必然失败。经二进制逆向确认 `gitea-runner v4.0.1` **无任何服务管理代码**(命令集仅 `bug-report/cache-server/config/daemon/exec/register`,无 `service.go`;`OpenSCManagerW` 等符号来自 Docker SDK,非服务安装代码),`daemon` 是纯控制台程序、不向 SCM 回报 `SERVICE_RUNNING`。**§4 全节重写为「任务计划程序」方案**(新增 §4.0 根因分析、§4.2 `deploy_runner_task.ps1`、§4.3 手动命令 + 72h 超时警示、§4.5 清理遗留服务、§4.6 提权);删除失效的 `deploy_runner.ps1`/`install_service.ps1`;排障表补「错误 1053」「LastTaskResult 非 0」「72 小时被杀」3 条;拓扑图/§5.1/§7 日志/§10 TL;DR 同步更新。另修 `config.yaml` 的 `fetch_timeout` 5m→1m(超出 RPC 1m 会被截断并告警) | -| 2026-10-02 | **修 `.ps1` 编码坑(实测)**:无 BOM 的 UTF-8 脚本被 WinPS 5.1 按 GBK 解读 → 中文乱码(`鐒跺悗鎵ц`)并撞坏引号配对,报 `源文本中包含无法识别的标记`/`字符串缺少终止符`。**新增 §7.1「给含中文的 `.ps1` 加 UTF-8 BOM」**(两种修法 + 解析器校验命令);排障表补该条目;配套脚本已统一加 BOM | -| 2026-10-02 | **按实测修正为 v4.0.1**:act_runner 已更名 **`gitea-runner`**,发布页迁至 `gitea.com/gitea/runner`。**v4 移除 `daemon install/start/stop/status`**;**v4 `log` 段无 `file` 键**(§3 改用 `log.job.dir`);补 §1.3 命令集对照、§6.1 MySQL 直查核验、排障表(`accepts at most 0 arg(s)` / `unknown config key log.file` 等) | -| 2026-10-02 | 初版:针对**本机 Gitea 原生部署**(`D:\Docker\Gitea\gitea.exe` + Windows 服务 + `127.0.0.1:3001`)定制 Runner 端部署全流程。核心要点:`--instance` 用回环地址、`--labels "windows:host"` 必须精确匹配 `runs-on`、PATH 运行账户坑、MSVC 路径约束、内网 action 拉取的零依赖改法 | diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index 224ef741..93aeb231 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.md @@ -180,13 +180,20 @@ D:/Code/DTU Code/ - ST-LINK SN `0B250D12A218303030303032` - 设备 IP `192.168.1.100` -机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注)。 +机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注 + 自报 TTL)。 **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在另一个进程里跑。 若按 pid 判活,acquire 一退出锁就被判为陈旧 → 锁形同虚设(实测踩到)。 -故判「陈旧」只看**时效**(`LOCK_TTL_HOURS` = 6 小时)加显式 `--force`;pid 仅作排障记录。 +故判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。 同任务重复 acquire 幂等;他人持锁时 acquire 拒绝(`--wait` 可轮询等待)。 -`done` 前置检查会在任一锁被占用时拒绝回收。 + +**TTL 按锁分设**(2026-10-12):`LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}`。 +口径 = 兜底窗口应与**操作真实时长**同量级:编译是秒~分钟级(`keil_build.py` 自持自释, +窗口 ≈ UV4 运行时长),1h 已足够宽松;bench 是**会话级**(flash→`@test`→探针),才配得上 6h。 +持有者还可用 `lock acquire ... --ttl <小时>` **自报**更精确的窗口—— +`keil_build.py` 即按其 `--timeout` 自报(`timeout/3600 + 15min`,下限 0.5h)。 + +`done` 前置检查:**陈旧锁自动接管释放**(不再硬失败),仅当锁仍被有效占用时才拒绝回收。 > ⚠ 台架相关的既有陷阱(`--require-selftest-pass` 的陈旧台账、回读校验会复位目标) > 不因引入工作树而改变,仍按 `docs/测试说明/自动化测试流程.md` 执行。 @@ -366,7 +373,7 @@ docs\内存管理\RAM使用分析与优化方案.md:585 [路径引用] docs/内 **实测暴露并已修复的 4 个缺陷**: 1. **锁绑定进程存活 → 形同虚设**:`lock acquire` 是短命进程,一退出锁就被判陈旧、下次必被接管。 - 改为**时效判定**(6 小时 TTL)+ 显式 `--force`,pid 仅作记录。 + 改为**时效判定**(当时全局 6h;2026-10-12 起改为按锁分设 TTL,见 §7.1)+ 显式 `--force`,pid 仅作记录。 2. **`git branch -d` 按环境 HEAD 判「已合并」**:集成点被改走时误判、拒绝删除。 改为显式 `merge-base --is-ancestor ` 后再删。 3. **直调 Win32 建联接被拒**(Win32 4392):改走 `mklink /J`(详见 3.3)。 diff --git a/docs/环境与部署/本地工具路径环境变量方案.md b/docs/环境与部署/本地工具路径环境变量方案.md index c63b9038..611b23e7 100644 --- a/docs/环境与部署/本地工具路径环境变量方案.md +++ b/docs/环境与部署/本地工具路径环境变量方案.md @@ -81,7 +81,7 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe' - 新电脑只配置本机 `DTU_*` 环境变量(§3),clone 仓库后可直接运行各开发命令,**不修改任何脚本/工作流中的本机路径**。 - 显式变量指向无效路径时,命令以非零退出码失败且错误信息指明变量名,不静默使用其他工具。 - 仓库内不出现提交的本机绝对工具路径(文档中的示例路径除外)。 -- 跨机验证操作清单见 `项目部署环境要求.md` §2.12;runner 侧机器级变量设置见 `Gitea_Runner部署指南.md` §5.1.3。 +- 跨机验证操作清单见 `项目部署环境要求.md` §2.12;runner 侧机器级变量设置见 `docs/CI/远程CI完整指南.md` §4.1.3。 --- diff --git a/docs/环境与部署/项目部署环境要求.md b/docs/环境与部署/项目部署环境要求.md index 0b2dab5d..5c3cfada 100644 --- a/docs/环境与部署/项目部署环境要求.md +++ b/docs/环境与部署/项目部署环境要求.md @@ -84,7 +84,7 @@ Graphviz / Cppcheck 的安装位置差异一律用 `DTU_*` 环境变量表达( > 统一解析语义:变量**显式设置但无效 → 立即报错**(指出变量名与收到的路径),不静默回退到别的安装; > 未设置 → 按工具走 PATH / 标准布局自动发现(可选工具允许降级,如 Graphviz 缺失只跳渲染)。 > CI runner 以 **SYSTEM 服务**运行,用户级变量对其不可见——runner 用**机器级**变量注入(见 -> `Gitea_Runner部署指南.md` 环境变量节)。 +> `docs/CI/远程CI完整指南.md` §4.1.3)。 ### 2.2 MCU 构建工具链(必需) @@ -415,7 +415,7 @@ workflow 见 `.gitea/workflows/ci.yml`;覆盖边界与判定细节见 [`../测 | Gitea 版本 | ≥ 1.21(实测远程 **1.25.4**) | workflow 置于 `.gitea/workflows/`,兼容 GitHub Actions 语法 | | Actions 开关 | 实例 `[actions] ENABLED = true` | site admin 配置;未开启则 push 不触发任何 job | | runner OS | **Windows**(`runs-on: windows`) | labels 须含 `windows`;与开发机同平台,避免脚本语法分叉 | -| runner 软件 | Python 3.x、cppcheck、pre-commit、**MSVC**(VS C++ 工具链 + Windows SDK) | 前三个缺 → `l0-gates` **硬失败**(`::error::`);MSVC 缺 → `l05-host-unit-tests` **降级跳过并告警**(不硬失败,但等于 L0.5 裸奔)。runner 以 SYSTEM 服务运行,**用户级 PATH/变量不可见**:自动发现失败时用**机器级** `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` 注入(见 `Gitea_Runner部署指南.md` 环境变量节,§2.1.1) | +| runner 软件 | Python 3.x、cppcheck、pre-commit、**MSVC**(VS C++ 工具链 + Windows SDK) | 前三个缺 → `l0-gates` **硬失败**(`::error::`);MSVC 缺 → `l05-host-unit-tests` **降级跳过并告警**(不硬失败,但等于 L0.5 裸奔)。runner 以 SYSTEM 服务运行,**用户级 PATH/变量不可见**:自动发现失败时用**机器级** `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` 注入(见 `docs/CI/远程CI完整指南.md` §4.1.3) | | Keil | **不需要** | 刻意不跑 `@build`(需授权);`.map` 缺失时 `mem-numbers` 钩子自动跳过 | | `mem-numbers` | 有 `.map` 就跑,无则 `SKIP` | Keil 构建产物 `.map` 已 gitignore;自建带 Keil 的 runner 会照常跑该钩子 | @@ -635,7 +635,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级 | LVGL 模拟器部署 | `docs/UI设计/LVGL模拟器部署与使用说明.md`、`simulator/README.md` | | 串口调试 / 自动化测试 | `docs/测试说明/串口助手使用指南.md`、`docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md` | | **测试分层 / L0.5 主机侧单测** | `docs/测试说明/测试开发标准流程.md`(分层与门禁)· `docs/测试说明/看门狗单元测试说明.md`(L0.5 范式:MSVC 离线编译 + 平台桩) | -| **CI(Gitea Actions)** | `docs/环境与部署/Gitea_Runner部署指南.md`(**Runner 端部署全流程:下载 / 注册 / 常驻 / 排障**,针对本机原生 Gitea 定制)· `docs/环境与部署/CI配置指南.md`(服务端 + workflow 全流程)· `docs/测试说明/测试开发标准流程.md` §9(覆盖边界) | +| **CI(Gitea Actions)** | `docs/CI/README.md`(**远程 CI 唯一入口**:服务端 / Runner 部署与保活 / workflow / 排障,**仅本机一台 runner,日常开发不必读**)· `docs/测试说明/测试开发标准流程.md` §9(覆盖边界) | | pre-commit / cppcheck | `docs/代码规范/pre-commit使用说明.md`、`docs/代码规范/cppcheck使用说明.md` | | 引用关系图(文件引用) | `docs/架构设计/引用关系图说明.md`(生成工具 `tools/refgraph`;渲染依赖 Graphviz,§2.8) | | 各模块测试指南 | `docs/测试说明/CH395F_Test_Guide.md`、`GD5F2GQ5UE_Test_Guide.md`、`SD2506_Test_Guide.md`、`FTP_Test_Guide.md` | @@ -647,6 +647,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级 | 日期 | 变更 | |---|---| +| 2026-10-12 | **远程 CI 文档迁出本目录**:`CI配置指南.md` 与 `Gitea_Runner部署指南.md` 合并为 **`docs/CI/远程CI完整指南.md`**(入口 `docs/CI/README.md`),本文三处引用改指新路径与新章节号(§4.1.3 机器级变量、§7 相关文档登记)。原因:远程 CI 只涉及本机一台 runner,不应与"任何机器都要过"的部署核对清单混在一起 | | 2026-10-10 | **删除旧档 NCFG 迁移**(升级/迁移口径变更):旧固件遗留的 `/cfg/netcfg.bin` 不再"首启自动迁移"——旧档若存在则被忽略(不读、不删、不迁移),配置直接回落工厂默认(`/config.ini` `[network]` 段模板)。`net_cfg_legacy.c/h` 与 `cfg_store_legacy_path()` 已移除;携带旧档的设备升级后需经 UI 或 FTP 重新配置网口。§五 现场部署 IP 行同步 | | 2026-10-02 | **模拟器构建修复**(配置引擎重构遗留):`simulator/CMakeLists.txt` 的配置文件引擎引用同步到迁移后位置——包含目录 `App/`→`App/cfg/`(`cfg_file.h`/`cfg_file_store.h`)、源文件 `App/cfg_file.c`→`App/cfg/cfg_file.c`、包含目录 `test/`→`test/board/`(`test_config.h`/`net_latency_test.h`);全量 include 离线核查无其他断链,模拟器全量构建恢复(`--cfgtest` 四场景 PASS,rc=0)。§2.12 验证链 ⑤ 行同步更新 | | 2026-10-02 | 更正 Device Pack 必装范围:`Keil::CMSIS` pack 不再列为必装项——本工程不依赖它(核心头文件由仓库 `Drivers/CMSIS/` 提供,`.uvprojx` 只引用 DFP pack;2026-10-02 本机实测未装 CMSIS pack 构建 0/0 通过),且其 keil.com 直链(5.7.0~6.1.0)均已下架 404;必装的只有 `Keil::STM32F4xx_DFP`(3.1.1,与 `.uvprojx` PackID 一致)。§2.2 / 第 2 步 / 搭建清单 / §2.10 同步更正 | diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index c71b6763..3e531c54 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -281,7 +281,7 @@ | L3-2 | 三段式零序过流 | 零序电流 > 定值,经延时出口 | Ⅰ/Ⅱ/Ⅲ 段零序电流 + 延时 | ⬜(N-18:`IN` 走 STM32,V0.2 使能;框架已就绪,加实例表一行即可) | | L3-3 | 过负荷 | 相电流 > 定值,告警/跳闸可整定 | 过负荷电流 + 延时 | 🔶 同 L3-1(`OL-IA` 实例,默认告警不跳闸;板端接线随 B5 落地,YX23 上送) | | L3-4 | 过压 / 欠压 | 相/线电压越限,经延时出口 | 过压定值、失压定值 + 延时 | 🔶 同 L3-1(`OV-UA`/`UV-UA`,欠量方向单测覆盖;沿用参考告警段 0x8027/0x8029) | -| L3-5 | 过频 / 低频 | 频率越限,经延时出口 | 过频、低频定值 + 延时 | ⬜(随频率测量算法 P2;框架已就绪) | +| L3-5 | 过频 / 低频 | 频率越限,经延时出口 | 过频、低频定值 + 延时 | ✅ 软件侧完成(B8,2026-10-12):`freq` 层测频(自适应窗,45~55 Hz 扫频最坏 **0.0101 Hz** / 1% 噪声 0.0160 Hz)+ `OF/UF` 实例(51.0/49.0 Hz、dly 1000 ms、返回系数 0.99/1.01,本地定值 8 项);OF/UF 暂无独立遥信点,动作仅经 YX15 总聚合可见(独立分点 + 注入验收 = B10) | | L3-6 | 零序过压 | 零序电压 > 定值 | 零序过压定值 + 延时 | 🔶 同 L3-1(`3U0-UN` 实例,外部零序电压通道,不受 N-18 限制) | **扩展集(P2/P3)** @@ -467,7 +467,7 @@ | --- | --- | --- | --- | --- | | **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 —— **✅ 2026-10-11 台架闭环**(L1-6 同日落地 ✅;L1-5 标定 / L1-7 等周波为 P0 遗留项,精度复核依赖标准源) | | **P1** | 基础保护元件 | **V0.1 有效子集**:过流(A 相,`IA`)、过负荷(A 相)+ 定值模型;B/C 相过流**建接口不使能** | P0 | 标准源注入 A 相可正确动作 —— 🔶 **软件侧 2026-10-11 完成**(`prot_set` 定值模型 30 用例 + `prot_elm` 元件框架/过流三段/过负荷 27 用例,主机全绿);**板端接线已落地(B5,2026-10-12:protectTask + IWDG bit8 + 栈核算 + 104 YX 上送)**;标准源注入验收待做 | -| **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 —— 🔶 过压/欠压/零序过压随 P1 同批主机完成(同框架实例);**过频/低频待频率测量算法** | +| **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 —— 🔶 过压/欠压/零序过压随 P1 同批主机完成(同框架实例);过频/低频 ✅ **B8 已落地(2026-10-12)**:freq 层(自适应窗,扫频最坏 0.0101 Hz)+ OF/UF 实例;出口独立遥信点与注入验收归 B10 | | **P3** | 异常检测与闭锁 | L5 自检闭锁、采样通道自检;三相类(涌流/PT·CT 断线/母线接地)**建接口不使能** | P1/P2 | 自检失败闭锁;未使能元件不误动 | | **P4** | 出口、记录与定值集成 | L4 出口与记录、§5 定值模型落 cfg_file、UI 显示、上送 | P1 | 动作出口 + SOE 记录 + UI 显示闭环(**定值持久化 + 软压板 + 竞态收口 = B7 完成 2026-10-12,台架腿已验 ✅**) | | **P5** | 方向/功率类(延后) | L3-13 方向元件等 | V0.2 硬件 / 子板计量 | 跨转换器相位一致后实现 | @@ -608,3 +608,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-12 | **B5 板端接线:protectTask 立项落地** | 新增 `App/task/protect_task.c/h`(**纯编排**,判据仍在 `App/algo/`):20 ms 节拍三件事——① **L5-4 采样故障闭锁**:`adc_health_flags()` 电平位(TPAFE/STM32 stall + 序号倒退)命中 → `prot_elm_init()` 复位 + 本拍不 eval(stall 自愈自动恢复;锁存类事件位不闭锁——非"当前测量不可信");② **L5-1 定值校验和巡检**(每拍 `prot_set_selfcheck()`,31 项 sum32 开销可忽略;⚠ 与 104 定值写的竞态窗口 µs 级、同次写 seal 即清锁,如实登记,P4 定值持久化收口);③ `prot_elm_run()` + 动作位图边沿日志(`[PROT]`,V0.1 无出口 → 动作 = 遥信 + 日志)。**线程契约收口**:`prot_elm_request_reset()`(单字标志,任意上下文)——104 YK11 在 iec104Task 落请求、下一拍 eval 头部消费,杜绝与 protectTask eval 竞态(`iec104_pts.c` 改走此口);定值/元件初始化移入 `app_main_init`(内核前,phasor_init 之后)。**门禁(E-59 同款)**:`TASK_WD_PROTECT = 8` 入 `wd_required_mask()` 两分支(产品 `0xF7`→`0x1F7`、HW 层 `0xF5`→`0x1F5`,看门狗单测 39 断言同步);栈 **512 words**,`stack_report.py` 实测静态最坏链 **872 B / 余 1176 B / 57%**;**FreeRTOS 堆 48→56 KB**(`.ioc` L1 参数 + `FreeRTOSConfig.h` 同步,CCM 75.3%→87.9% < 92% 门禁,`RAM预算台账.md` §6 登记配额 48→56 KB)。任务手写于 `Src/freertos.c` USER CODE 区(Normal,V0.1 无出口不需 AboveNormal;§2.2 表已注"1 ms/高"为 P2 瞬时值口径)。单测 +1(TC-PE-010 复归请求:不当拍生效、下一拍消费,滞回保持区可区分复位与否)→ prot_elm 28 用例全绿。`@build` 0 错 0 警(Code=204348,ZI +8,196 = 堆 +8 KB);台架 `@test`(PROT 锚点 + `[STACK] protect` 水位 + 104 YX 联动)待做。§3.3 L3-1/3 注板端落地、§3.5 L5-4 置 ✅、§7 P1 里程碑更新 | | 2026-10-12 | **B7 定值持久化 + 全局软压板 + 竞态收口(W1 任务卡)** | **定值模型增项** `PS_GLOBAL_EN`(全局软压板,枚举尾追加,编号不回收);**原子写**:`prot_set_set_sealed()` 影子区拷贝→改→重封→**单字交换活跃区**(双缓冲 +124 B 静态),`prot_set_selfcheck()` 改 seqlock 式复核(封存和两读不一致 = 提交撞见 → 本轮跳过)——B5 登记的"104 写 vs 巡检"µs 级误锁窗口就此消除(§3.5 L5-1 注收口)。**持久化**:新增 `App/cfg/prot_set_store.c/h`(双端注册 + 模拟器豁免点名)——blob = magic`PSET`+版本+计数+值区+sum32 正/反码对(144 B),`/prot_set.bin` 走 `cfg_store_write_atomic`(临时文件+rename,掉电安全);**写路径** = C_SE/YK12 执行段置脏 → iec104Task `dirty_peek` 投递 `SYS_EVT_PROT_SET_SAVE`(事件位 bit7)→ defaultTask(配置引擎属主)落盘,写失败脏回置 = 下轮重试;**装载** = storage_bring_up 在 STORAGE_READY 前整表原子 parse(损坏/越界/版本换代 → 拒绝保出厂默认 + 自检留痕 `prot set corrupt`;文件缺失 = 首启正常静默)。**YK12 转真**:`iec104_pts.c` 两段式(选择段校验 DPC、执行段 set_sealed),protectTask 下一拍读退出态 → 复位元件 + 停判据(`global plate OUT/IN` 边沿日志);本地定值项 `comm_addr=0` 显式不可寻址(防 IOA=0 误命中)。单测:prot_set 63 断言(+33:软压板/原子写/脏握手/blob 往返/坏 magic/截断/坏版本/**伪造自洽越界**对抗样本)、iec104 94 断言(+7:YK12 两段转真/置脏/本地项 C_RD EVI)。`@build` 0 错 0 警(Code=205500 +1152 B,ZI +280 = 双缓冲+blob 缓冲,`ram_baseline` 已刷)。**台架腿完成(2026-10-12)**:`@test` PASS + PC 主站 `test/host/iec104_master.py --b7-pre` **21/21**(YK12 两段 OFF→0.05 A 低定值不动作 / ON→YX15·16 即动作,C_SE→C_RD 回读)、`--b7-post` **13/13**(**压板退出态跨复位保持**:开机日志 `global plate OUT -> protection disabled` + 低定值仍不动作、重启后 YK12 ON 联动在位)、`--b7-mark/--b7-check` **4/4+4/4**(**非默认定值 7.5 跨复位保持**,C_RD=7.500,与出厂默认可区分);开机日志两次 `[CFG] prot set loaded (/prot_set.bin)`;证据 `docs/测试说明/boot_log_data/`(20261012_B7_* 两份) | | 2026-10-12 | **B6 台架 `@test` 闭环复测(B4/B5 验收窗口,W1 任务卡)** | 首跑(HEAD `d3107f3`)**FAIL**:`protect task start` 锚点行字节级交错损坏(`st[art: cycle=220ms elm=70`,两次开机同形复现)= protectTask 启动行违反控制台写者注册前门控纪律(B5 台架腿未跑故未暴露,机理与实证见 `docs/子系统设计/控制台单写者日志设计说明.md` §11 2026-10-12 行);其余 8 Required / 零 `[ERR]` / 锚点×1 / 回读一致全过。**修复** = 启动锚点行改 `dbg_console_writer_ready()` 首拍补打(adcTask 首帧门控同款,+16 B)。**复测 PASS**(B7 `1fe163b`+fix):锚点行完好(+577 ms,attach 后);**首采水位** `protect` 828/2048(free_min 1220,59%)、`iec104` 424/2560(83%)、新 56 KB 堆 free=17936 **min_ever=10000**(>3072 告警线零告警,**N-33 抬栈 min_ever 背书就绪**);采样闭锁不误报。**PC 主站往返**:新增 `test/host/iec104_master.py`(纯标准库主站客户端)STARTDT→总召四组+actterm→C_SE 两段写临时压低 OC1 定值**确定性**触发 YX15·16 变位(COT=3)→恢复复归→YK11 两段正答→C_RD 读回 10.000,**18/18 PASS**(`d3107f3` 与 B7+fix 两版固件各一轮),串口侧 `[I104] accepted`/`[PROT] elm OC1-IA` 边沿/YK11 复位→UV-UA +10 s 重返联一致。证据 `docs/测试说明/boot_log_data/20261012_B6_*`(含缺陷复现 MISS 快照)。B5 行"台架 `@test` 待做"遗留**闭环**;B7 台架腿的 C_SE 写路径已随本窗验证(断电重启保持 + YK12→YX 仍待窗) | +| 2026-10-12 | **B8 频率测量层 + 过频/低频保护(L3-5,W1 任务卡)** | 新增 `App/algo/freq.c/h`(L2.5 测频:共轭乘积相位增量 + 解缠绕 + **自适应窗长** K_eff=round(8·Fs/(2·f̂))——定长窗受 20 点矩形窗**负序泄漏纹波**限制(实测最坏 0.317 Hz)、60 拍 Kay 复数和 0.197 Hz(交叉项和角不按 1/K² 衰减),自适应窗把窗长对齐纹波周期 fs/(2f) 整数倍 → **0.0101 Hz**;假值钳制带 35~65 Hz 与系统合理带分离(贴边钳制会非对称丢纹波半周 → 45 Hz 点 +0.16 Hz 偏置);幅值门 <50 码 → valid=0 保持;UA→IA 优先级;`freq_run()` 挂 adcTask `phasor_run()` 后,心跳 `freq(hb)` 行)。**定值 32→40**:尾追加 OF/UF 8 项(51.0/49.0 Hz、dly 1000 ms、返回系数 0.99/1.01;全部本地项 `comm_addr=0`,参考逐项地址不在本仓库待对照);`bad_mask`→`uint64_t`(TC-PS-011 断言 bit39 可指认);**blob VER 1→2**(144→176 B;升级语义换代/损坏分流:旧档 → `prot_set_store_load()` **rc=2 换代** INFO ≠ **rc<0 真损坏** ERROR+自检,defaultTask 不误报)。**prot_elm 7→9**:`PE_OF`/`PE_UF` + `pe_cfg_t.src`(0=相量有效值 / 1=freq 测频;freq 无读数按方向填"必不启动"值,不整拍跳过);OF/UF 暂无独立 YX 点,动作仅经 YX15 总聚合可见(独立分点 = B10 送检点表);YC `0x401D` 频率点仍 QDS=IV(接真值 + 独立分点归 B10)。单测:`test/host/freq/` **46 断言**(J1 扫频 45~55 Hz ≤0.02 实测 0.0101 / J2 噪声 1% ≤0.10 实测 0.0160 / J3 启动收敛 ≤40 ms 实测 20 / J4 阶跃 50→51 ≤120 ms 实测 98——**精度优先于响应**显式取舍:98 ms ≪ OF/UF 动作延时定值默认 1000 ms / J5 浮空 valid=0 漂移 0.13 / J6 双跳变 60 ms 回 ±0.05;三代弯路推导写 `freq.c` 文件头防重蹈)+ prot_set **90**(+27:40 项/VER 2/64 位 bad_mask/旧档拒绝)+ prot_elm **47**(+OF/UF 滞回·方向隔离·测频接线);回归 phasor 57 / iec104 94 无退化。`@build` 0 错 0 警(Code=207468);RW_IRAM1 +632 B(73.3%→73.8%)、CCM 不变、Flash +2,244 B(21.6%);**adcTask/protectTask 栈链不变**(freq_run 帧未进最坏链,无需抬栈)。§3.3 L3-5 置 ✅(软件侧)、§7 P2 更新;`freq-unit-test` 钩子登记(31→32) | diff --git a/docs/编号规范.md b/docs/编号规范.md new file mode 100644 index 00000000..70b19cd3 --- /dev/null +++ b/docs/编号规范.md @@ -0,0 +1,314 @@ +# 编号规范 + +> **适用对象**:本仓库全部 Markdown 文档、C/Python/脚本源码注释、提交信息中出现的**实体编号**(`E-44`、`N-21`、`TC-NET-601`、`FR-STO-1`、`Trap 07` 这类"字母 + 数字"的条目身份标识)。 +> **不适用**:C 语言标识符(`g_`/`s_`/`PS_*`/`TEST_SUITE_*`)、元器件位号(`U1`/`C12`/`R3`)、章节号(`§3.2`,归 [`docs/文档规范.md`](文档规范.md) §3.5 管)——见下文「划界」一节。 +> **强制程度**:§2~§5 为**强制**(规划由 `scripts/check_ids.py` 机械校验);§6~§10 为**约定 + 迁移计划**。 +> **相关文档**:[`docs/文档规范.md`](文档规范.md)(链接/章节/门禁总规范)、[`docs/任务规划/README.md`](任务规划/README.md) §0(本规范的登记簿摘要)、[`docs/测试说明/测试文档模板规范.md`](测试说明/测试文档模板规范.md) §6(测试侧编号细则)。 +> **状态**:本文件是**规划稿**——§4 的目标前缀与 §7 的迁移映射在阶段 1 落地前尚未生效,落地进度见 §9。 + +--- + +## 1. 为什么要重写编号体系 + +### 1.1 病根:前缀按"来源"分配,不按"事物类别"分配 + +每来一次审计/复审/专项,就自造一套前缀,字母很快瓜分殆尽: + +| 来源 | 自造的前缀 | +|---|---| +| 2026-09-21 错误码传播审计 | `E-xx`、缺陷模式 `P1~P9`、严重度 `P0~P2` | +| 2026-09-15/09-21 代码审计 | `P0-x` ~ `P3-x`(严重度直接进编号) | +| 2026-09 项目审查与优化建议报告 | `N-xx`、`D-x`、`S-x`、`R-x`、`F-x`、`U1~U5` | +| 台架与长期观察 | `N-x`、`S-x`、`T-x`、`gen`、`round`、`V2/V4/V5` | +| 2026-10-02 复审 | `S1~S14`、`A-1~A-7` | +| 任务规划 | `W`、`B\d`、`C\d`、`D\d`、`M\d` | +| 各模块陷阱/设计文档 | `Trap NN`、`陷阱 N`、`D1~D10`、`O1~O6`、`U1~U15`、`①~④` | + +**后果**:同一字母被 3~5 个来源共用,编号本身不再能定位事物。 + +### 1.2 已确证的事故(全部有仓内证据,非假想) + +| 类型 | 实锤 | +|---|---| +| **同字母多义** | `P1` 五义(缺陷模式 / 严重度 / 审计条目 `P1-8` / 用例优先级 / `ch395f_test_task.c` 的 Phase1 测试项 `P1-01`);`L1` 四义(算法层 / 测试分层 / UI 四层 / CubeMX 三层);`U1` 四义;`O4` 四篇文档四义 | +| **同一文件内双义** | `实机测试实施指南.md` 的 `A2/A3/A4` 既是环境预检项(§2)又是已完成事项(§10) | +| **连字符/补零偷偷承载语义** | `R1`=风险、`R-2`=内存优化项;`D7`=验证窗口、`D-7`=文档门禁、`D1~D7`=设计决策;`N-2`(登记簿)与 `N-02`(报告)同号不同义 | +| **跨文档同号不同义** | `Trap 02` 在 CH395F 是"缓冲区重叠"、在 GD5F 是"测试缓冲越界";`B10` 在规划是"送检窗口"、在陷阱审计是"孤儿监听" | +| **语义进编号 → 事实一变编号就撒谎** | `P1-x` 把严重度写进号里,而审计中大量条目事后降级(`E-59` P0→P1、dhara `is_free` P1→P2):改号则引用全断,不改则号实不符 | +| **孤儿编号(代码有、文档无)** | `I-1`/`I-2`、`Task #32` 会话号、`P1-01~P1-09`、`TC-GD5F-1100/1101/1102`、`TC-STO-06`、`TC-AH-*`、`TC-PE-*`(前两项项目已在 `docs/测试说明/实机测试实施指南.md` 自行承认) | +| **悬空引用(引用了不存在的号)** | `Trap 19` 从未存在却被 `docs/测试说明/CH395F_Test_Guide.md` 引用;`N-5/N-8/N-19/N-30/N-31` 销项删行后仍被引用 | +| **同一事物多种字面** | `Trap 07` / `TRAP-07` / `陷阱 1`;`gen 181` / `gen-288` / `gen68`;`TC-NET-704` / `TC704` / `TC-203` | +| **规范自身就不自洽** | `docs/测试说明/测试文档模板规范.md` §6.3 同一行并列 `TC-STO-03`(2 位)与 `TC-RTC-001`(3 位);`docs/测试说明/测试文档模板规范.md` §6.2 已废止 `PHY` 模块码,而阶段 ID 至今仍是 `TM-PHY-01` | +| **机器校验只覆盖一个族** | 31 个 pre-commit 钩子中只有 `TC` 有 `scripts/check_test_traceability.py`,且**未挂钩子、warn-only、正则 `TC-[A-Za-z0-9]+-\d+` 匹配不到 `TC-AI-01A`**;`E/N/S/B/C/D/A/U/O/gen/Trap` 全部裸奔 | + +> **可借鉴的先例**:`§N.M` 章节引用当年 1,578 处也全靠自觉,靠「紧邻写法 + 别名表 + 可判定比例 + 钩子」把可判定比例做到 58% 并转为拦提交([`docs/文档规范.md`](文档规范.md) §3.5)。编号走同一条路径即可收口。 + +--- + +## 2. 六条设计原则(强制) + +| # | 原则 | 理由 | +|---|---|---| +| **P-1** | **前缀 = 事物类别,不是来源文档/审计轮次**。来源是登记簿的「出处」列,不进编号 | 否则每轮审计都造一套新字母,必然瓜分殆尽 | +| **P-2** | **编号不携带语义**:严重度、状态、日期、负责人、波次一律是表格列 | 降级只改列,编号永不变、引用永不断(`P1-x` 的历史教训) | +| **P-3** | **一个前缀只有一个属主登记处**;前缀一经分配**永不复用给别类**,号段用尽即扩位(`N-99`→`N-100`),绝不换字母 | 保证"看到前缀就知道去哪张表查" | +| **P-4** | **形态唯一**:`<前缀>-<序号>`;前缀 1~3 位大写字母;序号位数按类别固定并左补零;子项只允许一级 `-<小写字母>`。**禁止**裸形(`S3`)、空格形(`Trap 07`)、中文形(`陷阱 1`)、圈号(`①②③④`) | 连字符与补零不得承载语义;字面唯一才能被机器判定 | +| **P-5** | **只增不复用**;每个类别有「下一个可用号」游标(`[计划] scripts/id_registry.json`),新增先取游标 | 并行任务与多文档同时新增时防撞号(`docs/问题记录/项目审查与优化建议报告.md` 变更记录自述"并轨时未做全局唯一性检查"就是没有游标) | +| **P-6** | **引用必须机器可判定**:跨文档引用用行内代码,首次出现紧邻属主文档名;文档内编号**必须**带文档名 | 与 `§N.M` 同一条路:能校验的约定才算存在 | + +--- + +## 3. 主轴:两层编号 + +| 层 | 适用事物 | 形态 | 引用规则 | 门禁校验 | +|---|---|---|---|---| +| **全局编号** | 会跨文档流转:需求、缺陷、待办、决策、风险、测试用例、陷阱记录 | 全局唯一前缀 + 全局序号 + **唯一属主登记处** | 行内代码;跨文档首次出现紧邻属主名 | 全库唯一;必须能在属主表查到定义行 | +| **文档内编号** | 只在定义它的那篇文档内有意义:状态机组合、方案档位、专项风险、专项阶段 | 允许存在,前缀带类别码,**号段文档内独立** | **必须紧邻文档名**(同 `§N.M` 紧邻规则) | 校验"该文档内确有此编号" | + +### 3.1 判定口诀 + +> **会不会在别的文档里被引用?会 → 全局编号;不会 → 文档内编号,且引用必须带文档名。** + +现状的病根正是把该全局的(发现、待办)做成了文档内编号(各文档自造 `O1~O4`、`A1~A6`),又把该文档内的(设计决策 `D1~D7`、状态机 `U1~U15`)做成了裸形全局编号。 + +### 3.2 编号 vs 章节引用 + +| 条目特征 | 用什么 | +|---|---| +| 会增删、会重排、只是"读第几条" | **章节引用** `` `docs/xxx.md` §N.M ``(不造编号) | +| 需要稳定身份(要销项、要被代码引用、要跨文档追溯) | **编号** | + +各设计文档的"本期不做 / 遗留可选项"清单(现状 `O1~O4` 四篇文档四义)**默认不造编号**,改用 §小节引用;确需逐条引用时才启用文档内编号 `OOS-`。 + +--- + +## 4. 编号空间总表 + +### 4.1 在册保留(零改写,仅补规则) + +| 前缀(正则) | 类别 | 属主登记处 | 游标/规则 | +|---|---|---|---| +| `FR-[A-Z0-9]+-\d{2}` | 功能需求 | `docs/产品需求/产品需求设计文档.md` §5 | 各域独立递增;**新增域写进该文 §1.2 术语与缩写** | +| `NFR-[A-Z0-9]+-\d{2}` | 非功能需求(**新建**,现 §7 全是散文无号) | `docs/产品需求/产品需求设计文档.md` §7 | 从 `NFR-<域>-01` 起 | +| `N-\d{2}` | **登记簿条目**(技术债 / 长期观察 / 硬件改版项;含义由"网络项"重定义) | `docs/任务规划/登记簿.md` | 在册最大 `N-33` → 游标 `N-34`;原 `S-x`/`T-x` 并入本族(§7.1) | +| `E-\d{2}` | 错误码/返回值传播审计发现 | `docs/问题记录/错误码传播审计.md` | **冻结**:`E-63` 为最大号,**不再新增**;新发现一律 `DEF` | +| `TC-[A-Z]{1,4}-\d{3,4}` | 测试用例 | 各测试文档 | 3 位起;分阶段套件序号 = 阶段号×100 + 项号(阶段 ≥10 自然进位 4 位,`TC-NET-1001` 合规);后缀只允许 `-<小写字母>` | +| `TM-[A-Z]{1,4}-\d{2}` | 测试阶段 | 各测试文档 | 模块码取 §6 统一表(`TM-PHY-01` → `TM-NET-01`) | +| `DP-[A-Z]{1,4}-\d{2}` | 设计原则 | 各测试文档 | **加模块码**(现 `DP-01` 跨文档会撞) | +| `L\d` / `L\d-\d{1,2}` | 采集与保护分层、层内条目 | `docs/算法/继电保护功能复现规划.md` §3 | 保留(代码侧 141 处引用,是全项目最规范的一族,作样板);`L` 前缀**专属**此族 | +| `W\d+` | 波次 | `docs/任务规划/README.md` §2.0 | 保留;游标 `W8`;子任务卡不再用 `W1-0` 形态(并入 `WI`) | + +### 4.2 目标前缀(阶段 1 改名后启用) + +| 新前缀 | 类别 | 收编的旧编号 | 新属主登记处 | 游标 | +|---|---|---|---|---| +| `DEF-` | 缺陷 / 问题发现 | `P0-x`~`P3-x`(在册项)、新发现 | `[计划] docs/问题记录/缺陷登记簿.md` | `DEF-001` | +| `WI-` | 工作项 / 任务卡 | `B\d`(功能主线)、`C\d`(结构债)、`D\d`(验证窗口)、`W1-0`、`S1~S14`、`A-1~A-7`、`P1-10`、报告 `R-x`(内存优化)、`F-x`、`U1~U5` 的未闭环项 | `docs/任务规划/` 波次与排期文件(类别做列) | `WI-001` | +| `DR-` | 决策记录 | `docs/子系统设计/网络Socket监听生命周期设计说明.md` 的 `D1~D10` | `[计划] docs/架构设计/决策记录登记簿.md` | `DR-001` | +| `OQ-` | 待确认 / 开放问题 | `B0-a/b/c`、`待确认-N` | `docs/任务规划/基线与待确认.md` | `OQ-001` | +| `RSK-` | 风险(**文档内编号**) | 保护规划 `R1~R8`、看门狗 `R1~R7` | 各文档风险表(引用必须带文档名) | 各文档自 `RSK-01` | +| `TRAP--` | 陷阱记录 | `Trap 07`、`TRAP-07`、`陷阱 1` | `docs/问题记录/<模块>_Trap_Records.md` | 保留原号(`TRAP-NET-01~25`、`TRAP-GD5F-01~06`、`TRAP-RTC-01~`) | +| `GAP-` | 模拟器差距 | `H-1~H-7`、`L-1~L-4`、差距清单 `N-1~N-6` | `docs/测试说明/模拟器差距清单.md` | `GAP-01` | +| `SC-` | 实机验证场景 | 实机预检 `A1~A8`、看门狗验证用例 `V2/V4/V5` | `docs/测试说明/实机测试实施指南.md` | `SC-01` | +| `T0/T0.5/T1/T2/T3` | **测试分层**(原 `L0/L0.5/L1/L2/L3`) | 测试分层五档 | `docs/测试说明/测试开发标准流程.md` §1 | 固定五档 | +| `G0/G0.5/G1/G2/G3` | **门禁**(必跑清单,与测试分层是两套,不合并) | 现门禁决策表 `G0~G3` | `docs/测试说明/测试开发标准流程.md` §1.1 | 固定 | +| `GEN-` | 台架日志代次 | `gen 181`、`gen-288`、`gen68` | `docs/测试说明/串口助手设计说明.md` | 数字不变,仅改字面 | +| `RUN--` | 台架轮次 | `round 1~9` | `docs/测试说明/实机测试实施指南.md` | 日期制 | +| `MS-` | 里程碑 | `M1~M3` | 波次文件 | `MS-01` | +| `RC-` | net 周期对账类型 | `①②③④`(唯一被代码逻辑引用的圈号链) | `docs/子系统设计/网络Socket监听生命周期设计说明.md` | `RC-01`~`RC-04` | +| `PAT-` | 缺陷模式(九类) | `docs/问题记录/错误码传播审计.md` §1.1 的 `P1~P9` | 同文 | `PAT-01`~`PAT-09` | +| `ST-` | 专项内部实施阶段(**文档内编号**) | 保护规划 §7.1 的 `P0~P5`、方案阶段 `P0~P3` | 各专项文档 | 各文档自 `ST-0` | +| `UL-` | UI 标准化四层(**文档内编号**) | 报告 UI 四层 `L0~L3` | `docs/UI设计/UI_设计架构.md` | `UL-0` | +| `LAT-` | 链路延迟分段(**文档内编号**) | `docs/测试说明/链路延迟测试说明.md` 的 `L1~L11` | 同文 | `LAT-01` | +| `CS-` | CubeMX 定制存活三层 | `AGENTS.md` 的 `L1 写进 .ioc → L2 USER CODE → L3 审计` | `docs/子系统设计/看门狗设计说明.md` §10 | `CS-1`~`CS-3` | + +### 4.3 冻结前缀(历史快照,正文不回改) + +| 冻结对象 | 处置 | +|---|---| +| `E-` | 冻结于 `E-63`;引用照旧,**禁止新增**;门禁拒绝游标之外的新 E 号 | +| `docs/问题记录/项目审查与优化建议报告.md` 自造的 `N-xx`/`D-x`/`S-x`/`R-x`/`F-x`/`U1~U5` | 报告是历史基线,**不回改**;引用一律紧邻文档名;未闭环项已并轨进 `N-19~N-29`,出处列保留报告编号 | +| `docs/问题记录/CH395F_Trap_Records.md` 2026-10-11 审计的 `A1~A6`/`B1~B10` | 该表是审计快照(条目已修复或推翻),**冻结**;新发现进 `DEF`;引用紧邻文档名 | +| `P0-x`~`P3-x` | 在册项迁 `DEF`;已闭环项留在历史文档,走别名表 | +| `Task #32` 等会话临时号 | **禁止入册**;现存引用(`Src/main.c`、`Drivers/BSP/UI/ui_net_sink.c` 的 `I-1/I-2`)改写为可解析形式或就地展开成事实描述 | + +### 4.4 划界:这些不是编号,不受本规范管 + +| 对象 | 例子 | 归属 | +|---|---|---| +| C 标识符 | `PS_OC1_EN`、`TEST_SUITE_STORAGE`、`ENABLE_PHASE14_TESTS`、`SYS_EVT_NET_READY`、`TASK_WD_ADC` | `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | +| 元器件位号 | `U1`、`C12`、`R3`、`L5`、`D7`(`docs/硬件平台/`、`docs/芯片手册/`) | **门禁与改写脚本一律排除**,这是最大误伤风险源 | +| 章节号 | `§3.2` | `docs/文档规范.md` §3.5 | +| **档位词** | `P0`/`P1`/`P2`/`P3`(严重度、用例优先级) | 允许继续使用,但**永不带 `-<数字>` 后缀**——`P1-8` 这类形态一律非法 | +| 图元 id | `README.md` 架构图 Mermaid 节点 `A1`/`B1`/`C1`/`D2` | 改描述性 id(`net`/`sto`/`ui`),避免与条目字面撞名 | +| 运行时计数 | 网络代次号 `net_get_generation()` | 代码概念;与台架 `GEN` 是两回事,文档里须写全"网络代次号" | + +--- + +## 5. 分配、引用、销项规则(强制) + +### 5.1 新增编号 + +1. 查 §4 总表确定类别与属主登记处;**表里没有的类别 → 先改本规范,不得自造前缀**。 +2. 到属主登记处取游标号(`[计划] scripts/id_registry.json` 记录每类下一个可用号),登记处新增一行。 +3. 严重度 / 状态 / 波次 / 出处写进表格列,**不进编号**。 + +### 5.2 引用写法 + +| 场景 | 写法 | +|---|---| +| 全局编号,跨文档首次出现 | `` `docs/任务规划/登记簿.md` N-21 ``(紧邻,同 `§N.M` 规则) | +| 全局编号,同段后续 | `` `N-21` `` | +| 文档内编号 | **必须** `` `docs/子系统设计/网络Socket监听生命周期设计说明.md` DR-003 `` | +| 尚未创建的条目文件 | 加 `[计划] ` 前缀(`docs/文档规范.md` §3.3) | +| 引用报错样例/历史字面 | 该行加 `[示例]` 标记 | + +### 5.3 代码注释引用 + +- **允许引用**(长期有效):`FR-*`、`NFR-*`、`DEF-*`、`E-*`(冻结存量)、`N-*`、`TRAP-*`、`DR-*`、`TC-*`、`L-`。 +- **禁止引用**(易变):`WI-*`、`W`、`MS-*`、`OQ-*`、`ST-*`——代码是长期资产,波次与任务卡会变。 +- 首次出现带属主文档名(现状 90% 裸引,靠"全库唯一"这一未成文假设)。 + +### 5.4 提交信息 + +标题带主编号:`fix(net): N-32 监听重建阻塞导致心跳缺口`。一次提交只挂一个主编号,其余进正文。 + +### 5.5 销项与归档 + +- 销项 = 删登记行 + **清全库引用**(沿用 `docs/任务规划/README.md` §3 与 `AGENTS.md` 既有规则)+ 游标**不回退**。 +- 号段用尽 → 扩位(`N-99`→`N-100`),**不换字母**。 +- 历史文档(审计报告、评审报告、已闭环陷阱)不回改,走别名表。 + +--- + +## 6. 模块码表(`TC` / `TM` / `DP` / `TRAP` 共用) + +现状是**板端 6 个码 + 主机侧 10 个码两套并存**,且 `docs/测试说明/ADC输入层单元测试说明.md` 明文豁免主机侧"与板端缩写表互不影响"。本规范合并为一张表,**现有码全部沿用(零改写)**,只规定新模块必须先登记: + +| 码 | 模块(源文件/目录) | 码 | 模块 | +|---|---|---|---| +| `NET` | `Drivers/BSP/CH395F` + `Drivers/BSP/NET` | `AI` | `App/algo/adc_input` | +| `GD5F` | `Drivers/BSP/GD5F2GQ5UE` | `AH` | `App/algo/adc_health` | +| `STO` | 存储栈(FTL + FatFs + `App/fs`) | `PH` | `App/algo/phasor` + `sample_ring` | +| `RTC` | `Drivers/BSP/SD2506` | `PS` | `App/algo/prot_set` | +| `ADC` | 采集节拍与通道映射(`Src/adc.c`、TIM3) | `PE` | `App/algo/prot_elm` | +| `RS485` | `Drivers/BSP/RS485` | `VL` | `App/fs/vol_lock` | +| `UI` | `Drivers/BSP/UI` | `TS` | `App/sync/time_sync` | +| `LOG` | `App/log` | `CLK` | `App/util/sys_clock` | +| `CFG` | `App/cfg` + `Drivers/BSP/NETCFG` | `WD` | `App/util/task_watchdog` | +| `SH` | `App/util/sha256` | `FA` | `Drivers/BSP/NET/lftpd` 鉴权 | +| `I104` | `App/protocol/iec104` | `TOOL` | PC 端工具(串口助手等) | + +> 废止码:`PHY`(→ `NET`)、`NAND`(→ `GD5F`)。历史文档不回改,新文档按本表。 + +--- + +## 7. 迁移映射(legacy → 新号) + +### 7.1 映射规则 + +| 旧族 | 新族 | 规则 | +|---|---|---| +| 登记簿 `N-2`…`N-33` | `N-02`…`N-33` | 仅补零,数字不变 | +| 登记簿 `S-3/S-4/S-5`、`T-7` | `N-34`… | 续号并入,别名表保留旧号 | +| 主线 `B0~B10`、结构债 `C1~C6`、验证窗口 `D2~D7`、`W1-0` | `WI-001`… | 按波次顺序连续重编,映射表逐条列出并落别名表 | +| 复审 `S1~S14`、`A-1~A-7`、报告 `R-x`/`F-x`/`U1~U5` 未闭环项 | `WI-*` | 同上;已闭环项留历史文档 | +| `P0-x`~`P3-x` 在册项 | `DEF-*` | 严重度转列 | +| 设计说明 `D1~D10` | `DR-001`~`DR-010` | 数字对齐 | +| `B0-a/b/c`、`待确认-N` | `OQ-001`… | — | +| `Trap NN` / `陷阱 N` | `TRAP--` | 数字不变,补模块码 | +| `H-1~H-7`、`L-1~L-4`、差距 `N-1~N-6` | `GAP-01`~`GAP-17` | 按文档内出现顺序 | +| 实机 `A1~A8`、看门狗 `V2/V4/V5` | `SC-01`… | — | +| 测试分层 `L0/L0.5/L1/L2/L3` | `T0/T0.5/T1/T2/T3` | 门禁 `G0~G3` 不动(两套不同尺度,不合并) | +| `gen 181`/`gen-288`/`gen68` | `GEN-181`/`GEN-288`/`GEN-068` | 数字不变 | +| `round 1~9` | `RUN-<日期>-` | 无日期的历史轮次留别名表 | +| `M1~M3` | `MS-01`~`MS-03` | — | +| 对账 `①~④` | `RC-01`~`RC-04` | 代码注释同步 | +| 审计模式 `P1~P9` | `PAT-01`~`PAT-09` | 正文冻结,新文档用新号 | +| 保护 `R1~R8`、看门狗 `R1~R7` | 各文档 `RSK-01`… | 文档内编号 + 引用带文档名 | +| 保护阶段 `P0~P5`、方案 `P0~P3` | 各文档 `ST-0`… | 与档位词 `P0` 解耦 | +| `TC-STO-01~06` | `TC-STO-001~006` | 补零 | +| `TC-AI-01A`/`02A`/`02B`/`02C`、`TC-VL-008b` | `TC-AI-001a`… / `TC-VL-008b` | 后缀统一小写字母,基号补零 | +| `TM-PHY-01~11` | `TM-NET-01~11` | 模块码 | +| `DP-01~04` | `DP-NET-01~04` | 加模块码 | + +### 7.2 孤儿编号处置(阶段 1 一并清) + +| 孤儿 | 处置 | +|---|---| +| `I-1`/`I-2`(`ui_net_sink.c`) | 在 `docs/子系统设计/网络Socket监听生命周期设计说明.md` 补定义行,或就地展开成事实描述 | +| `Task #32` 会话号及其小节引用 | 会话号,改写为可解析引用(结论已沉淀进 `docs/子系统设计/看门狗设计说明.md` §11) | +| `P1-01~P1-09`(`ch395f_test_task.c`) | 改指 `TC-NET-101`…,或补文档条目 | +| `TC-GD5F-1100/1101/1102`、`TC-STO-06` | 补进 `docs/测试说明/GD5F2GQ5UE_Test_Guide.md` | +| `TC-AH-*`、`TC-PE-*`、iec104 `TC-014/015/024` | 补单测说明文档(缺文档 = 追溯断链) | +| `Trap 19` | 悬空引用,删除或改指实际条目 | + +### 7.3 改写操作规程(每批必走) + +```powershell +# 1) 预览:映射表驱动的 dry-run,人工通读 diff(重点看 docs/硬件平台/ 是否被误伤) +& '.venv\Scripts\python.exe' [计划] scripts/rename_ids.py --map [计划] scripts/id_aliases.json --dry-run + +# 2) 落盘后三重复核 +git grep -n '<旧字面量>' # 零残留 +& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all +& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # 0 错误 0 警告 +``` + +**硬约束**:`docs/硬件平台/`、`docs/芯片手册/` 全程排除(元器件位号 `U1/C12/R3/L5/D7` 与编号字面同形);带 BOM 的文件(`Drivers/BSP/UI/*`、`App/task/ui_task.c`、`App/cfg/cfg_file.c`、`Lib/lv_conf.h`)改写后必须核对 BOM 未丢;**禁止**用 `Get-Content`/`Set-Content` 重写含中文文件。 + +--- + +## 8. 门禁:`check_ids.py` 与 `id-check` 钩子 + +配置两份:`[计划] scripts/id_registry.json`(前缀 → 属主文档 / 形态正则 / 是否冻结 / 游标)、`[计划] scripts/id_aliases.json`(旧号 → 新号,照搬 `scripts/section_aliases.json` 的成功范式)。 + +| # | 校验 | 级别 | +|---|---|---| +| 1 | 出现未登记前缀(自造编号) | 错误 | +| 2 | 形态不合规:裸形 `S3`、空格形 `Trap 07`、中文形 `陷阱 1`、补零错、`P1-8` 这类档位词带后缀 | 错误 | +| 3 | 引用查无定义且不在别名表(悬空引用) | 错误 | +| 4 | 同一字面量被两个属主重复定义(`N-16` 类事故) | 错误 | +| 5 | 冻结前缀出现游标之外的新号 | 错误 | +| 6 | 文档内编号引用未紧邻文档名 | 错误 | +| 7 | 定义了但全库无引用(孤儿条目) | 告警 | +| 8 | 打印**「编号引用可判定比例」** | 每次运行,只许涨不许跌 | + +- 排除:`Lib/`、`Middlewares/`、HAL/CMSIS、`docs/芯片手册/`、`docs/硬件平台/`(位号)、构建产物。 +- 上线节奏:**先 warn-only 清 backlog → 清零后加 `--strict` 拦提交**(与 `doc-links` 章节校验完全同一条路径)。 +- 顺带收编 `scripts/check_test_traceability.py`:正则补 `TC-[A-Z]{1,4}-\d{3,4}[a-z]?`,覆盖 7 篇「单元测试说明.md」,一并挂进 `id-check`(现为 warn-only 且未挂钩子)。 + +--- + +## 9. 落地阶段与验收 + +| 阶段 | 内容 | 验收判据 | 风险 | +|---|---|---|---| +| **0 规范** | 本文件;`docs/任务规划/README.md` §0 改为指向本规范的摘要;`docs/README.md`、`AGENTS.md`、`docs/文档规范.md` §6 登记;两份 `id_*.json` 初版 | `check_doc_links --all` 通过、`@build` 0/0 | 零(纯新增 + 指针) | +| **1-① 小族** | `RC`/`PAT`/`GEN`/`RUN`/`MS`/`GAP`/`SC`/`OQ`/`RSK`/`DR`/`TRAP`/`ST`/`UL`/`LAT`/`CS` + Mermaid 节点 id + 孤儿处置 | 每批:dry-run 通读 → 落盘 → `git grep` 零残留 → `check_doc_links --all` → `@build` 0/0 → `ci_local.py` | 中 | +| **1-② 大族** | `N` 补零 + `S`/`T` 并入;`WI` ← `B`/`C`/`D`/`W1-0`/`S1x`/`A-x`/`R-x`/`F-x`/`U-x`;测试分层 `L0~L3` → `T0~T3`(**最大机械批次**,`L0.5` 一词全库百余处) | 同上,逐族一个 commit | **高** | +| **1-③ 缺陷族** | `DEF` ← `P0-x`~`P3-x` 在册项;报告历史编号补齐紧邻文档名;档位词规则落地;别名表与游标定稿 | 同上 | 中 | +| **2 门禁** | `check_ids.py` + `id-check` 钩子(warn → strict);`check_test_traceability.py` 收编 | 全库告警清零后转 strict;可判定比例基线入档 | 中(首轮会暴露大量真悬空引用) | + +每阶段结束停下等指令再提交(`AGENTS.md` 边界:commit/push 须明确指示)。 + +--- + +## 10. 常见错误速查 + +| 现象 | 原因 | 修复 | +|---|---|---| +| `id-check` 报"未登记前缀" | 自造了一套编号 | 查 §4 总表归到既有类别;确属新类别先改本规范 | +| 同一编号在两篇文档各指一物 | 用了文档内编号却裸引 | 改全局编号,或引用补文档名(紧邻) | +| 引用的编号查不到定义 | 孤儿/悬空 | 补定义行,或删引用 | +| 降级后编号与事实不符 | 语义进了编号 | 违反 P-2;严重度改列,编号不动 | +| 硬件文档里 `U1`/`C12` 被改 | 改写脚本没排除位号目录 | 回滚该批,按 §7.3 排除清单重跑 | +| 改完中文变乱码 | 用了整文件重写 | 见 `AGENTS.md` 铁律:只用精确子串替换,BOM 按原样补回 | + +--- + +## 变更记录 + +| 日期 | 变更 | 说明 | +|---|---|---| +| (规划稿) | 建立本文档 | 全库扫描 1,385 个不同编号 / 19,306 处出现后定稿的两层编号体系(全局编号 + 文档内编号)、六条原则、目标前缀总表、三批迁移映射与 `check_ids.py` 门禁设计;用户决策:`E-xx` 冻结保留、严重度档位词 `P0~P3` 不变、文档落 `docs/编号规范.md`、阶段 1 三批全做 | diff --git a/docs/网络/BSD_Socket_API_使用指南.md b/docs/网络/BSD_Socket_API_使用指南.md index 8b132ba2..e31aeb94 100644 --- a/docs/网络/BSD_Socket_API_使用指南.md +++ b/docs/网络/BSD_Socket_API_使用指南.md @@ -60,7 +60,8 @@ | `net_config.h` | 配置宏定义(最大 Socket 数、超时时间等) | | `net_types.h` | 类型定义(地址结构、Socket 控制块等) | | `net_socket.h` | BSD Socket API 头文件,含 `net_msg_t` 消息类型定义 | -| `net_socket.c` | API 核心实现:消息队列处理与线程安全封装、内部 `_sock()` 版本;另含 L3 pending 化 recv、D10 非阻塞重监听重试(30ms/2s)、Trap 22 周期对账(200ms,去抖+冷却)、Trap 23 排渣隔离(400ms 起/800ms 上限) | +| `net_socket.c` | API 核心实现:消息队列处理与线程安全封装、内部 `_sock()` 版本;另含 L3 pending 化 recv、Trap 22 周期对账(200ms,去抖+冷却,只剩①②) | +| `net_listener.c`(**真机专属**) | **监听生命周期**(N-32 §10 模块化):`net_listener_try` / `net_listener_clear` / `net_listener_retry_tick`(D10 重试 30ms/2s)/ `net_listener_pump`(唯一重建入口,100ms)/ `net_listener_settle`(沉降/隔离 100ms/400ms);内部头,模拟器不编译 | | `net_select.h` | select/poll API 头文件 | | `net_select.c` | select/poll 实现(仅 MCU 端编译) | | `net_socket_sim.c`(`simulator/sim/`) | Winsock2 后端(模拟器端,与 `net_socket.c` 互斥编译),详见 §2.3 | @@ -155,7 +156,7 @@ sequenceDiagram | **`netTask` 外(任意应用任务)** | 公共 API:`net_send()` / `net_recv()` / `net_recv_to()` / `net_accept()` / `net_connect()` / `net_close()` / `net_listen()`,及自愈 `net_relisten()` / `net_resync_listen()`——请求入队列,netTask 串行执行,调用方阻塞等回执 | | **`netTask` 内** | 内部 API `net_send_sock()` / `net_recv_sock()` / `net_close_sock()` / `net_listen_locked()` | | **`net_socket()` / `net_bind()`** | 直接调用,无需队列;实际在就绪屏障后的服务任务(timeSyncTask / ftpTask / uiTask)中创建监听 | -| **`net_poll()` 和 `net_process_messages()`** | 必须在 `netTask` 主循环调用;`net_process_messages()` 每轮先跑 D10 重监听重试 → L3 挂起 recv 完成 → Trap 22 对账(200ms)三个非阻塞 tick,再处理队列 | +| **`net_poll()` 和 `net_process_messages()`** | 必须在 `netTask` 主循环调用;`net_process_messages()` 每轮先跑 **4 个非阻塞 tick**(D10 重监听重试 → L3 挂起 recv 完成 → Trap 22 对账 200ms → N-32 监听器 pump 100ms),再处理队列 | ### 3.4 内部 API 说明 @@ -179,14 +180,15 @@ int net_close_sock(net_sock_t *p_sock); ### 3.6 芯片/本层状态对账与重监听自愈(Trap 09 / 22 / 23) -单连接模型下"监听 Socket = 数据通道",多种芯片行为会造成**芯片侧与本层状态失同步**,纯事件(中断)驱动无法自愈。当前版本有四层自愈,全在 netTask 内、全非阻塞、不冻结主循环: +单连接模型下"监听 Socket = 数据通道",多种芯片行为会造成**芯片侧与本层状态失同步**,纯事件(中断)驱动无法自愈。当前版本有**五层自愈**,全在 netTask 内、全非阻塞、不冻结主循环: | 机制 | 触发 | 动作 | 关键参数 | |---|---|---|---| | **`auto_relisten`** | 数据通道正常 EOF / DISCONNECT | 重开 + 重新 LISTEN,回 LISTENING;应用层无需 close/重 listen | `net_listen()` 置位;`net_close()` 清 | | **D10 非阻塞重监听重试** | 重开失败(Trap 09:芯片 close 后需沉降) | 单次尝试失败即登记待重试,每轮到点重试;超时限放弃并记 `relisten give up` | 重试间隔 30ms / 时限 2s(自首次尝试);同 fd 有在途挂起 recv 时跳过(L3 保险,防毁在途连接) | -| **Trap 22 周期对账** | 芯片丢 CONNECT/DISCONNECT 中断(快速复连);或静默回收 Socket(peer FIN,无 DISCONNECT 中断) | 每 200ms 对在用 socket 回读芯片状态,修正三类失同步:① 本层 ESTABLISHED 而芯片已断 → 按断开处理;② 本层 LISTENING 而芯片已握手 → 补 accept;③ 本层 LISTENING 而芯片监听消失 → 重建 | 周期 200ms;**连续 2 次失配**才动手(去抖 ≈400ms)+ 动手后 **1s 冷却**(防"刚 open 未生效"瞬态重建风暴) | -| **Trap 23 排渣隔离** | 异常释放(芯片 TIMEOUT,如客户端中止上传):在途数据落进 RX 缓冲,直接复监听 → 下条连接把 ~2048B 残渣当自己的数据读走(上传静默截断) | 复监听前隔离:持续排空 RX 残渣 + 主动 close + 排空中断,等芯片回 CLOSED/TCP_CLOSED 才 open+listen;到期强制放行 | 隔离 400ms 起 / **800ms 上限(必须 < 2s 时限**,否则监听被放弃);仅异常路径生效,正常 FIN 路径零改动 | +| **Trap 22 周期对账** | 芯片丢 CONNECT/DISCONNECT 中断(快速复连);或静默回收 Socket(peer FIN,无 DISCONNECT 中断) | 每 200ms 对在用 socket 回读芯片状态,修正**两类**失同步:① 本层 ESTABLISHED 而芯片已断 → 按断开处理;② 本层 LISTENING 而芯片已握手 → 补 accept(③④ 已移交 pump) | 周期 200ms;**连续 2 次失配**才动手(去抖 ≈400ms)+ 动手后 **1s 冷却**(防"刚 open 未生效"瞬态重建风暴) | +| **N-32 监听器 pump**(**唯一重建入口**) | 监听型 fd 的芯片状态与本层不一致(会话结束待重建 / 本层状态滞后) | 每 100ms 检查监听型 fd:芯片失位即重建(`net_listener_try`);本层 CLOSED 而芯片**正在** LISTEN 时**只做状态重同步**、不发芯片命令 | 周期 100ms;连续 2 次失配(≈200ms)+ 动手后 1s 冷却;其它位置只置事实、不做重建动作 | +| **Trap 23 排渣隔离** | 异常释放(芯片 TIMEOUT,如客户端中止上传):在途数据落进 RX 缓冲,直接复监听 → 下条连接把 ~2048B 残渣当自己的数据读走(上传静默截断);正常 FIN 释放亦有残留 DISCONNECT 收尾问题 | 复监听前沉降/隔离:持续排空 RX 残渣 + 主动 close + 排空中断,等芯片回 CLOSED/TCP_CLOSED 才 open+listen;到期强制放行 | 沉降等级(`net_listener_settle`):异常释放 QUARANTINE **400ms** / 正常释放 GRACEFUL **100ms**(2026-09-24 起正常 FIN 亦需沉降);上限 **800ms** 强制放行(**必须 < 2s 时限**,否则监听被放弃) | **行为结论**:监听器恢复从"一次性事件"变为"登记 → 到点重试"过程: @@ -1009,11 +1011,12 @@ Socket 已创建或已绑定时可用;port 为 0(动态分配)时返回已 void net_process_messages(void); ``` -处理其他任务投递的请求(send/recv/connect/accept/close/**resync-listen / relisten**)。每次调用先跑 3 个**非阻塞** tick,再取队列消息(0 超时出队): +处理其他任务投递的请求(send/recv/connect/accept/close/**resync-listen / relisten**)。每次调用先跑 4 个**非阻塞** tick,再取队列消息(0 超时出队): -1. **D10 重监听重试**:断连后监听器恢复的到点重试(30ms 间隔 / 2s 时限,§3.6) -2. **L3 挂起 recv 完成检查**:按 ①数据 ②到点 ③断连 检查每个挂起请求并通知调用方(看门狗 1s) -3. **Trap 22 芯片/本层对账**:200ms 周期修正三类失同步(§3.6) +1. **D10 重监听重试**(`net_listener_retry_tick`):断连后监听器恢复的到点重试(30ms 间隔 / 2s 时限,§3.6) +2. **L3 挂起 recv 完成检查**(`net_pending_tick`):按 ①数据 ②到点 ③断连 检查每个挂起请求并通知调用方(看门狗 1s) +3. **Trap 22 芯片/本层对账**(`net_reconcile_tick`):200ms 周期修正**两类**失同步(①②,§3.6) +4. **N-32 监听器 pump**(`net_listener_pump`):100ms 周期,**唯一重建入口**(③④;本层 CLOSED 而芯片在听时只重同步,不发芯片命令,§3.6) > `net_msg_t.timeout_ms`:L3 分片预算(`net_recv_to()`),0=默认 `NET_RECV_TIMEOUT_MS`(30s)。消息类型含 `NET_MSG_RESYNC_LISTEN`(仅 MCU 侧状态)与 `NET_MSG_RELISTEN`(芯片侧重监听)。 @@ -1394,13 +1397,13 @@ CH395F 底层驱动返回的状态码(`ch395f_get_cmd_status()` 或 `ch395f_po 需要回传请用调用方提供的指针,或复用通知值。(2026-09-21 实证:曾按"加 `msg.err` 字段"方式修错误码, 结果 `ui_net_sink` 每次空轮询都拿到栈垃圾 → 误判为"真错误" → 连上即 `net_close`。) -### 12.10 监听器自愈行为与排渣(D10 / Trap 09、Trap 22、Trap 23) +### 12.10 监听器自愈行为与沉降/隔离(D10 / Trap 09、Trap 22、Trap 23 / N-32) -**默认行为**(无需应用代码):断连 → `auto_relisten` 重开监听;重开失败 → 30ms 节奏重试(2s 时限);芯片丢中断/静默回收 → 200ms 对账自愈;中止复连首上传 → 排渣隔离。全在 netTask 内,应用只需保证 `net_poll()` + `net_process_messages()` 主循环在跑。 +**默认行为**(无需应用代码):断连 → `auto_relisten` 重开监听;重开失败 → 30ms 节奏重试(2s 时限);芯片丢中断/静默回收 → 200ms 对账 + 100ms 监听器 pump 自愈;中止复连首上传 → 沉降/隔离。全在 netTask 内,应用只需保证 `net_poll()` + `net_process_messages()` 主循环在跑。 | 场景 | 修复前症状 | 机制 | 生产验证(2026-09-23) | |---|---|---|---| -| **Trap 22** 快速复连丢中断 → 监听静默死亡 | 待机后 UI `:7003` 快速复连 2/10 且**永久**拒连;FTP `:21` 100ms 间隔自第 29 轮死亡;设备日志**无报错**(状态静默滞留) | 周期对账 `net_reconcile_tick()`(200ms,三类失同步)+ 去抖(连续 2 次)+ 冷却(1s);`handle_disconnect_event` LISTENING 分支改为"已不可监听即重建" | `:7003` 10/10;`:21` 100ms/0ms/600ms 各 50/50;UI 20/20;FTP 正常 10/10(SHA-256);中止 RST/FIN 5/5 + 5/5;零重建风暴 | +| **Trap 22** 快速复连丢中断 → 监听静默死亡 | 待机后 UI `:7003` 快速复连 2/10 且**永久**拒连;FTP `:21` 100ms 间隔自第 29 轮死亡;设备日志**无报错**(状态静默滞留) | 周期对账 `net_reconcile_tick()`(200ms,①②)+ 监听器 pump `net_listener_pump()`(100ms,**唯一重建入口**,N-32)+ 去抖(连续 2 次)+ 冷却(1s);`handle_disconnect_event` 只置沉降事实、不再自行重建 | `:7003` 10/10;`:21` 100ms/0ms/600ms 各 50/50;UI 20/20;FTP 正常 10/10(SHA-256);中止 RST/FIN 5/5 + 5/5;零重建风暴 | | **Trap 23** 中止后首笔复连上传被静默截断 ≤2048B 并误报 226 成功 | RST/FIN 中止上传 → 紧接着的首笔上传"收"到 ~2KB 残留后判结束回 226 → 客户端以为成功 = **静默数据损坏**(第二笔起正常) | 异常释放(TIMEOUT)预重监听隔离:`net_drain_chip_rx()` 分块排空 RX 残渣 + 主动 close + 排空中断 + 等 CLOSED/TCP_CLOSED(400ms 起 / 800ms 上限 < 2s 时限) | RST 10/10、FIN 5/5 全通过;正常路径 30/30 + 20/20,SHA-256 全匹配 | | **Trap 21**(TC-NET-604)"接收冻结"虚警 | 旧判读脚本把"对端填满 2KB RX 窗口"误判为 STALL/WEDGE | **结论:非缺陷**(芯片 RX 缓冲即窗口,对端填满是常态);判据改为脚本层 STEP + 客户端内容校验(RET SHA-256),虚警工具归档(`MDK-ARM/retracted_bench_20260922/`,README 禁重跑) | 真实 FTP 路径 227 轮全通过(8KB×10 … 1MB×2);并发 UI 流 600/600 CRC;设备零 FAIL/ERROR/WARN | @@ -1412,6 +1415,7 @@ CH395F 底层驱动返回的状态码(`ch395f_get_cmd_status()` 或 `ch395f_po | 日期 | 变更 | |------|------| +| 2026-10-12 | **N-32 监听生命周期模块化 + 单一所有者**:监听生命周期抽为 `net_listener.c`(§2.2 文件表新增该行,真机专属);主循环自愈 tick 由 3 个增至 **4 个**(新增 `net_listener_pump`,100ms,**唯一重建入口**);`net_reconcile_tick` 对账收窄为 ①②,③④ 移交 pump;`handle_disconnect_event` / `handle_timeout_event` 改为 `net_listener_settle(GRACEFUL 100ms / QUARANTINE 400ms)` **只置事实**、不再自行重建;§3.3 / §3.6(四层自愈 → **五层**)/ §12.10 同步。权威设计见 `docs/子系统设计/网络Socket监听生命周期设计说明.md` §10 | | 2026-10-10 | 新增 **`net_can_accept()` / `net_can_read()` 就绪门控 API**(§10.1):纯 RAM 读控制块、**不走消息队列、不等 netTask 回执**,供实时性敏感任务在发 RPC 前判断"有没有活"(动因 = `ui_net_sink_poll()` 每 10 ms 一次 `net_accept` 把 uiTask 拖成最坏 ≈50 ms;原拟登记 N-19 / C7,当日结案不入登记簿;设计与实测见 `docs/子系统设计/看门狗设计说明.md` §11(原分析文档已并入该节));§3.5 补"netTask 运行要求"例外条;⚠ 记录两条使用约束——**不可作为唯一判据**(`readable` 只由 RECV_OK 中断置位,须保留低频兜底 recv)与**模拟器后端恒返 1**(`net_socket_sim.c`,Winsock 无 RPC 成本) | | 2026-09-23 | 同步 L3 pending 化、D10 非阻塞重监听、Trap 22 对账、Trap 23 排渣隔离后的现状:新增 `net_recv_to` / `net_sock_state` / `net_resync_listen` / `net_relisten`(§10.1)及 `net_getsockname`;§3 新增 3.6(对账与自愈四机制)、3.3/3.5 改写(recv 挂起模型、队列语义、netTask 运行要求);§4/§5 示例更新(监听由就绪屏障后服务任务建立、`net_sock_state` 轮询);§10.2 改述 `net_process_messages()` 每轮节奏(重监听重试 → 挂起 recv 完成 → 200ms 对账 → 队列),补 `timeout_ms`/新消息类型说明;新增 §11.2 分片阻塞读(`net_recv_to`);§12.1/12.3/12.4/12.9 更新,新增 §12.10(自愈行为数据与排障判据,含 Trap 21 结案) | | 2026-09-21 | **修正公开 API 的错误码回传**:`net_msg_t` 按值拷入队列 → netTask 对消息的写入调用方不可见,改用 `xTaskNotify()` 通知值回传具体错误码(负值),公开 API 恢复 errno 后统一返回 -1(此前被覆盖成 `NET_ERR`,使 `net_get_errno() != NET_ERR_WOULDBLOCK` 的"空轮询"判断恒成立 → `ui_net_sink` 连上即 `net_close`);§12.4 补单连接模型断开处理(EOF 时不得 `net_close` 监听 socket);§12.9 补错误码回传约束 | diff --git a/docs/网络/BSD_Socket_API_函数调用关系.md b/docs/网络/BSD_Socket_API_函数调用关系.md index 2100e955..250763f8 100644 --- a/docs/网络/BSD_Socket_API_函数调用关系.md +++ b/docs/网络/BSD_Socket_API_函数调用关系.md @@ -3,8 +3,10 @@ > **定位**:本文档是 `docs/网络/BSD_Socket_API_使用指南.md`(下称"使用指南")的**内部视图**伴读文档。 > 使用指南回答"**上层该怎么调**"(公开语义 / 示例 / 常见问题);本文回答"**内部函数各自做什么、互相怎么调用、为什么这么设计**"(调用关系 / 设计意图 / 踩坑)。 > -> **范围**:MCU 端 `Drivers/BSP/NET/net_socket.c`(约 2870 行,全部函数)。 +> **范围**:MCU 端 `Drivers/BSP/NET/net_socket.c`(约 3670 行)+ 其拆出的 +> `Drivers/BSP/NET/net_listener.c`(约 550 行,**监听生命周期**,N-32 §10 模块化)。 > 模拟器端后端 `simulator/sim/net_socket_sim.c`(Winsock)是另一套实现,见 `docs/网络/BSD_Socket_API_使用指南.md` §2.3;`net_select.c` 不在此展开,见 `docs/网络/BSD_Socket_API_使用指南.md` §7 / §10.5。 +> `net_listener.c` **只被 `net_socket.c` include**(内部头,不进公开 `net_socket.h`),模拟器零依赖、不编译;其设计依据见 `docs/子系统设计/网络Socket监听生命周期设计说明.md` §10。 > > **交叉引用约定**:机制标 Trap 号 → `docs/问题记录/CH395F_Trap_Records.md`;用法语义引用使用指南 §;关键参数值与 `net_config.h` / `net_socket.c` 内宏一致。 @@ -12,7 +14,7 @@ ## 1. 一句话架构 -**一切芯片(CH395F)操作收敛到唯一执行者 netTask 串行完成**;其他任务通过"入队消息 + 任务通知"表达请求,结果只经**任务通知值**回传;阻塞等待**不独占 netTask**(挂起登记 + 主循环节拍完成);监听器**常驻**(`auto_relisten` + D10 非阻塞重试 + Trap 22 周期对账 + Trap 23 排渣隔离,四层自愈)。 +**一切芯片(CH395F)操作收敛到唯一执行者 netTask 串行完成**;其他任务通过"入队消息 + 任务通知"表达请求,结果只经**任务通知值**回传;阻塞等待**不独占 netTask**(挂起登记 + 主循环节拍完成);监听器**常驻**(`auto_relisten` + D10 非阻塞重试 + Trap 22 周期对账 + Trap 23 排渣隔离 + **N-32 监听器 pump(唯一重建入口)**,五层自愈)。 --- @@ -20,7 +22,7 @@ | 执行域 | 运行位置 / 节奏 | 允许的操作 | 典型调用者 | |---|---|---|---| -| **netTask 主循环** | 独立 FreeRTOS 任务,1ms/tick | **唯一**可碰芯片(SPI2)的地方;3 个非阻塞 tick;队列分派;事件处理器;用户回调 | `net_poll` / `net_process_messages` 及其下游全部 `_locked` / `_tick` / `handle_*` | +| **netTask 主循环** | 独立 FreeRTOS 任务,1ms/tick | **唯一**可碰芯片(SPI2)的地方;4 个非阻塞 tick(D10 重试 / L3 挂起 / Trap 22 对账 / N-32 pump);队列分派;事件处理器;用户回调 | `net_poll` / `net_process_messages` 及其下游全部 `_locked` / `_tick` / `handle_*` | | **应用任务侧** | ftpTask / uiTask / timeSyncTask / testTask 等 | 经队列的公共 API(入队 + `xTaskNotifyWait` 等回执);纯 RAM 直调 API;**禁止直碰芯片** | 各服务任务 | | **直调快路径** | 同步调用,不经过队列 | `net_socket` / 查询族 / 回调注册(纯 RAM);**直碰 SPI 的三例外**:`net_bind`(UDP) / `net_sendto` / `net_recvfrom` | 初始化阶段、netTask 上下文、测试套件(硬件层测试模式下 netTask 自挂起,testTask 独占芯片) | @@ -43,11 +45,12 @@ flowchart TB XN["xTaskNotify / xTaskNotifyWait
(结果回传的唯一通道)"] subgraph NT["netTask 主循环 — 1ms/tick;芯片操作唯一执行者"] - NPM["netProcessMessages
先跑 3 个 tick,再排空队列分派"] + NPM["netProcessMessages
先跑 4 个 tick,再排空队列分派"] NP["netPoll
INT# GPIO 轮询 → 全局中断 → 各 Socket SINT → 每轮一批"] - T1["① netRelistenRetryTick
D10 重监听重试:30ms / 2s 时限"] + T1["① netListenerRetryTick
D10 重监听重试:30ms / 2s 时限"] T2["② netPendingTick
L3 挂起 recv:①数据 ②到点 ③断连 ④看门狗 1s"] - T3["③ netReconcileTick
Trap 22 对账:200ms / 去抖 2 次 / 冷却 1s"] + T3["③ netReconcileTick
Trap 22 对账:200ms / 去抖 / 冷却(只剩①②)"] + T4["④ netListenerPump
N-32 唯一重建入口:100ms / 去抖 / 冷却"] DISP["队列分派 switch
SEND · RECV · CLOSE · CONNECT · LISTEN
ACCEPT · RESYNC_LISTEN · RELISTEN"] subgraph LK["_locked / 内部分派目标(仅 netTask 上下文)"] LL["netListenLocked
初次监听(3×500ms 阻塞重试)"] @@ -56,10 +59,11 @@ flowchart TB LR["netRecvLocked
快路径 / DONTWAIT / 登记挂起"] LA["netAcceptLocked
先 netPoll + 状态判定"] LC["netCloseLocked
了结挂起 + close + 等 CLOSED + 排空中断"] - RLS["netRelistenTry → netRelistenLocked
单次尝试 + Trap 23 排渣隔离"] + RLS["netListenerTry → netRelistenLocked
单次尝试 + Trap 23 排渣隔离"] RSY["RESYNC 分支:改状态 + 取消挂起
(不发芯片命令)"] end EV["事件处理器
handleConnect · handleDisconnect
handleRecv · handleSend · handleTimeout"] + ST["netListenerSettle
只置沉降/隔离事实(100ms / 400ms)"] FE["fireEvent → 用户回调"] PR["s_net_pending
(每 fd 至多一个挂起)"] CH[("ch395f.* 驱动
SPI2 串行事务")] @@ -68,16 +72,16 @@ flowchart TB PUB -->|入队| MQ MQ --> DISP DISP --> LL & LN & LS & LR & LA & LC & RLS & RSY - NPM --> T1 & T2 & T3 & NP + NPM --> T1 & T2 & T3 & T4 & NP T1 --> RLS T3 -->|"① 已断"| EV T3 -->|"② 补 accept"| EV - T3 -->|"③ 重建监听"| RLS + T4 -->|"①② 芯片失位即重建"| RLS NP --> EV EV --> FE FE -.-> CB - EV -->|"LISTENING:监听丢失"| RLS - EV -->|"TIMEOUT:置隔离"| RLS + EV -->|"断连/超时:只置事实"| ST + ST -.->|"事实 → 由 pump 统一重建"| T4 LR -->|无数据+阻塞| PR PR --> T2 T2 -->|xTaskNotify| XN @@ -91,9 +95,10 @@ flowchart TB **读图要点**: 1. 应用侧只有两种"等":① 入队后 `xTaskNotifyWait` 等回执(同步语义);② 挂起 recv 的等待(同一通道,只是"完成"由 `netPendingTick` 判定,不由 netTask 立即处理)。 -2. `net_process_messages()` 开头的 **3 个 tick 是网络层自愈主干**:没有任何应用消息时,监听恢复 / 挂起完成 / 状态对账照常运转——"断连后即使没人发消息,监听器也能自己活过来"靠的就是它们。 -3. 事件处理器**只由 `net_poll` 驱动**,例外是 `net_reconcile_tick`(带 2 次去抖 + 1s 冷却)可再入 `handle_disconnect_event` / `handle_connect_event` / `net_relisten_try`——对账是丢中断的唯一自愈通道(Trap 22)。 +2. `net_process_messages()` 开头的 **4 个 tick 是网络层自愈主干**:没有任何应用消息时,重试 / 挂起完成 / 状态对账 / 监听重建照常运转——"断连后即使没人发消息,监听器也能自己活过来"靠的就是它们。 +3. 事件处理器**只由 `net_poll` 驱动**,例外是 `net_reconcile_tick`(带 2 次去抖 + 1s 冷却)可再入 `handle_disconnect_event` / `handle_connect_event`——对账是丢中断的自愈通道(Trap 22)。**注意职责边界(N-32 P2)**:对账与事件处理器**只置事实**(置 CLOSED / 登记沉降),**重建监听的动作统一由 `net_listener_pump` 做**(本层唯一重建入口)——"谁先看到 DISCONNECT 就谁重建"的竞争在结构上消失。 4. 回调 `event_cb` 在 **netTask 上下文**执行:回调内不得阻塞、不得发网络操作。 +5. **模块归属**:`T1`(D10 重试)/ `T4`(pump)/ `RLS` 的 `net_listener_try` / `ST`(settle)在 `net_listener.c`(真机专属、模拟器不编译);其余 tick 与 `_locked` 在 `net_socket.c`。 --- @@ -112,7 +117,7 @@ flowchart TB | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| | `net_poll()` | 事件泵:GPIO 轮询 INT#(低=有待处理)→ `GET_GLOB_INT_STATUS_ALL`(读=清)→ 按位图逐 Socket 读 SINT → 分派 5 类事件 | `HAL_GPIO_ReadPin` → `ch395f_get_glob_int_status_all` → `ch395f_get_sock_int_status` ×n → `handle_connect/disconnect/recv/send/timeout_event` | **RECV 中断是电平触发**:每次调用**只处理一批**(do-while(0)),处理完立即返回,否则主循环(echo/应用逻辑)饿死(Trap 08);SENBUF_FREE 只置 `send_ready` 不记事件 | -| `net_process_messages()` | 队列入口 + 自愈 tick 宿主:每轮**先**跑 3 个非阻塞 tick,再排空队列逐条分派,结果 `xTaskNotify` 回传 | `net_relisten_retry_tick` → `net_pending_tick` → `net_reconcile_tick` → `osMessageQueueGet`(0 超时) ×n → switch 分派 → `xTaskNotify` | ① tick 放在队列**之前**:断连后即使没有任何应用消息,监听器也能自主恢复、挂起 recv 也能完成;② RECV 若返回 `NET_PENDING_REGISTERED`(哨兵 0x70000000)则**不发即时回执**(`do_notify=0`),完成时才通知;③ 失败结果把 `s_net_errno`(具体负错误码)压入通知值——调用方恢复 errno 后对外统一 -1(BSD 语义);④ 通知值**整个 32 位**都是结果(不压位域标签,否则 32768 这类大字节数会溢出为负) | +| `net_process_messages()` | 队列入口 + 自愈 tick 宿主:每轮**先**跑 4 个非阻塞 tick,再排空队列逐条分派,结果 `xTaskNotify` 回传 | `net_listener_retry_tick` → `net_pending_tick` → `net_reconcile_tick` → `net_listener_pump` → `osMessageQueueGet`(0 超时) ×n → switch 分派 → `xTaskNotify` | ① tick 放在队列**之前**:断连后即使没有任何应用消息,监听器也能自主恢复、挂起 recv 也能完成;② RECV 若返回 `NET_PENDING_REGISTERED`(哨兵 0x70000000)则**不发即时回执**(`do_notify=0`),完成时才通知;③ 失败结果把 `s_net_errno`(具体负错误码)压入通知值——调用方恢复 errno 后对外统一 -1(BSD 语义);④ 通知值**整个 32 位**都是结果(不压位域标签,否则 32768 这类大字节数会溢出为负);⑤ **pump 置于对账之后**:对账①可能刚把会话置 CLOSED,pump 紧接着就能看到并重建 | ### 4.3 经队列的公共 API 及其 netTask 侧实现 @@ -121,20 +126,20 @@ flowchart TB | 公共 API | 消息 | netTask 侧实现 | 关键芯片命令 | 返回语义 | 设计意图 / 注意 | |---|---|---|---|---|---| | `net_listen` | `NET_MSG_LISTEN` | `net_listen_locked` | `set_proto_type` + `set_sour_port` + `open_socket`(**3×500ms 阻塞重试**)+ `tcp_listen` + 清全部中断状态 | 0 / -1 | **初次**监听允许阻塞重试(启动期无竞争);成功后置 `auto_relisten=1`——监听常驻的源头;启动前清空各 Socket/全局中断状态,防历史残留误触发事件 | -| `net_accept` | `NET_MSG_ACCEPT` | `net_accept_locked` | 无(先 `net_poll` 泵一次 + 状态判定) | 监听 fd 自身(单连接)/ -1(WOULDBLOCK) | 单连接:监听 Socket 在 CONNECT 后**自身即数据通道**,返回的就是它;CLOSED + auto_relisten 时触发**非阻塞** `net_relisten_try`;**accept 路径不做自愈**——单次状态读会误判"刚 listen 未生效"致重建风暴(2026-09-23 实测),统一交给对账 | +| `net_accept` | `NET_MSG_ACCEPT` | `net_accept_locked` | 无(先 `net_poll` 泵一次 + 状态判定) | 监听 fd 自身(单连接)/ -1(WOULDBLOCK) | 单连接:监听 Socket 在 CONNECT 后**自身即数据通道**,返回的就是它;CLOSED + auto_relisten 时触发**非阻塞** `net_listener_try`;**accept 路径不做自愈**——单次状态读会误判"刚 listen 未生效"致重建风暴(2026-09-23 实测),统一交给 `net_listener_pump`(N-32 单一重建入口) | | `net_connect` | `NET_MSG_CONNECT` | `net_connect_locked` | `set_proto_type` + `open_socket`(3×500ms 重试)+ 清残留中断 + `set_sour_port` / `set_des_ip`(大端数组)/ `set_des_port` + `tcp_connect` | 0 = 请求已发出(**异步**)/ -1 | open **先于**寄存器写入(open 会复位寄存器);open 后清残留 DISCONNECT,防新连接秒断(Trap 14/17);目的 IP 字节序写反 = CONNECT TIMEOUT(Trap 16);返回 0 ≠ 已建立——调用方以 `net_sock_state` 轮询等 ESTABLISHED | | `net_send` | `NET_MSG_SEND` | `net_send_locked` | `ch395f_write_send_buf`(分块)+ 等 SENBUF_FREE(`net_poll` 轮询) | 实际字节数 / -1 | 分块 ≤ `NET_SEND_CHUNK_MAX` = 1KB(**硬件发送 FIFO 硬上限**,Trap 20:写穿 FIFO = 数据错位);`send_ready` 门控用 `net_poll` 轮询而非 osDelay;短写**必须**告警——静默截断会让对端永久等待剩余字节(2026-09-21 实证:UI 推流应答截断 → 客户端失联) | | `net_recv` | `NET_MSG_RECV`(timeout=0→默认 30s) | `net_recv_locked` | `net_poll` + `get_recv_len` / `read_recv_buf` | >0 / 0(EOF) / -1(errno = WOULDBLOCK / TIMEDOUT / CONNRESET) | 快路径:数据已在 → 立即读出返回(允许部分读);无数据 + 阻塞 → **登记挂起后立即返回**(L3,netTask 零占用);DONTWAIT + CLOSED → EOF + 触发重监听 | | `net_recv_to` | `NET_MSG_RECV`(timeout>0) | `net_recv_locked` | 同上 | >0 / 0 / -1 / **-2(本片超时无数据)** | E-02 头阻塞修复配套:调用方把长等待切成有界片(如 5ms/剩余预算),**总超时语义不变、单次占用有界**;`timeout_ms=0` 直接拒(会被解释成默认 30s,恰是长阻塞);超时**只凭 -2 判定、不得依赖 errno**(`s_net_errno` 是跨任务共享全局量,随时被别的任务覆盖) | | `net_close` | `NET_MSG_CLOSE` | `net_close_locked` | `close_socket` + 等 CLOSED(5ms 节奏 / ≤500ms)+ 排空 Socket/全局中断 | 0 / -1 | **先**了结本 fd 挂起 recv(排空/取消 + 必通知,关不得让等待者悬挂);关后排空残留 DISCONNECT(Trap 14/17:否则下一连接被"刚建立就断开"秒断);控制块整体清零 | -| `net_relisten` | `NET_MSG_RELISTEN` | `net_relisten_try` → `net_relisten_locked` | `set_proto_type` + `set_sour_port` + `open_socket` + `tcp_listen`(单次尝试;隔离期另有 `close` + `get_socket_status` + 排空) | 0 / -1 | 无条件芯片侧重建:芯片侧已在监听 → 首次 open 失败 → close 重建(短暂中断 ~1s);失败**不阻塞**,登记 D10 重试;用途 = 代次变化后显式重建 / 判死后确定性重建;**-1 = "恢复中"**(重试轮询接管),不是永久失败 | +| `net_relisten` | `NET_MSG_RELISTEN` | `net_listener_try` → `net_relisten_locked` | `set_proto_type` + `set_sour_port` + `open_socket` + `tcp_listen`(单次尝试;沉降期另有 `close` + `get_socket_status` + 排空) | 0 / -1 | 无条件芯片侧重建:芯片侧已在监听 → 首次 open 失败 → close 重建(短暂中断 ~1s);失败**不阻塞**,登记 D10 重试;用途 = 代次变化后显式重建 / 判死后确定性重建;**-1 = "恢复中"**(重试轮询接管),不是永久失败 | | `net_resync_listen` | `NET_MSG_RESYNC_LISTEN` | 分派内直接处理(**无芯片命令**) | 无 | 0 / -1 | D14 静默回收场景:芯片已自行把 Socket 回收回监听且**不发 DISCONNECT 中断** → 本层滞留 ESTABLISHED。只修 MCU 侧(state → LISTENING + 取消该 fd 挂起 recv);**严禁**发芯片 close/重监听——会摧毁芯片侧健康监听器 → 连接永拒(run8 实证 170s 拒绝)。非 ESTABLISHED 时视为已一致,返回 0 | ### 4.4 直调 API(不经队列) | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| -| `net_socket(domain,type)` | 分配空闲控制块(`alloc_socket` 线性扫描 `in_use=0`)→ CREATED | `alloc_socket` + `net_relisten_clear` + `net_pending_clear` | 新 fd 不得继承上一次的待重试/挂起状态(复用描述符的经典坑);domain 仅 `NET_AF_INET` | +| `net_socket(domain,type)` | 分配空闲控制块(`alloc_socket` 线性扫描 `in_use=0`)→ CREATED | `alloc_socket` + `net_listener_clear` + `net_pending_clear` | 新 fd 不得继承上一次的待重试/挂起状态(复用描述符的经典坑);domain 仅 `NET_AF_INET` | | `net_bind(sockfd,addr,addrlen)` | TCP:仅记录本地端口(port=0 → 动态端口 49152 起递增)→ BOUND,**无芯片命令**;UDP:完整开 Socket → UDP_OPEN | UDP 路径:`set_proto_type(UDP)` + `set_sour_port` + `set_des_ip(0xFFFFFFFF)` + `set_des_port(0)` + `open_socket`(Socket 4~7 兜底重设缓冲) | TCP 的 bind 只是"记账",真正的芯片动作在 listen/connect;UDP 直碰 SPI(§2 三例外之一);动态端口回绕 65535→49152 | | `net_sendto` / `net_recvfrom` | UDP 收发(直碰 SPI,不经队列) | `set_des_ip` / `set_des_port` + `write_send_buf`(分块 1KB + `net_poll` 门控);`net_poll` + `get_recv_len` + `read_recv_buf` | 同 §2 上下文约束;recv 时 UDP Server 模式前 8 字节是固定头(源 IP + 端口 + 长度),先剥头填 `src_addr` 再读数据 | | `net_wait_result(kind,sockfd)` | 调用侧两段式等待:**零等待拾取** → 阻塞等回执 | `xTaskNotifyWait` ×2 | ① 零等待段兜住"入队→等待入口"间隙落地的通知(2026-09-22 台架实证丢通知);② **清除掩码必须全 1**(传 0 = 不清 → 上次结果残留被重读,run3~11 挂死/错位的真根因);③ 必须判 **pdTRUE** 而非 `v!=0`(合法结果 0 = EOF 会被误判"无通知"→ 永久挂死);④ 32 位整体作结果(32768 大字节数不截断);⑤ 本路径**禁止**加逐调用日志(曾占日志 98.6%、冲掉 [LAT] 证据行,run14 实证) | @@ -152,53 +157,77 @@ flowchart TB | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| | `net_pending_register` | 登记挂起(`net_recv_locked` 的"无数据 + 阻塞"路径) | 写 `s_net_pending[fd]` | 每 fd 至多一个:**覆盖会丢失前调用方通知 = 永久挂死**;执行任务自身(netTask)发起的等待直接拒绝(自等自通知 = 死锁,netTask 内须用 DONTWAIT) | -| `net_pending_tick` | 完成检查(`net_process_messages` 每轮调用,非阻塞) | ① `get_recv_len` + `read_recv_buf` ② 到点 ③ `get_recv_len` 排空 + `net_relisten_try` ④ `net_pending_complete` | 按 **①数据 ②到点 ③断连** 顺序检查(逐项对齐旧自旋语义);CONNECTING/LISTENING 等状态**保持等待**(CONNECT→ESTABLISHED 是合法迁移,不做状态推断取消,防误杀正在建链的等待);**看门狗**:到点后 1s 仍未了结 = 完成逻辑缺位 → 强制了结 + 错误日志 | +| `net_pending_tick` | 完成检查(`net_process_messages` 每轮调用,非阻塞) | ① `get_recv_len` + `read_recv_buf` ② 到点 ③ `get_recv_len` 排空 + `net_listener_try` ④ `net_pending_complete` | 按 **①数据 ②到点 ③断连** 顺序检查(逐项对齐旧自旋语义);CONNECTING/LISTENING 等状态**保持等待**(CONNECT→ESTABLISHED 是合法迁移,不做状态推断取消,防误杀正在建链的等待);**看门狗**:到点后 1s 仍未了结 = 完成逻辑缺位 → 强制了结 + 错误日志 | | `net_pending_complete` | 所有完成/取消路径的公共出口 | `xTaskNotify(caller, result)` + 清 active | 通知值 = 结果原值(>0 字节 / 0 EOF / 负错误码,与消息分派同一回传通道);失败结果同步 `s_net_errno`(netTask 上下文一致性);无在位挂起时静默返回(防重复通知) | | `net_pending_settle_close` | close 前**了结**本 fd 挂起 | `get_recv_len` + `read_recv_buf` + `net_pending_complete` | 关闭不得丢已到达数据(芯片缓冲 close 前可读):有数据 → 按字节数通知;无数据 → 取消(CONNRESET);先于 `ch395f_close_socket` 执行 | | `net_pending_cancel` / `cancel_all` | 显式取消(**必通知**) | `net_pending_complete` | 取消路径:陈旧 resync(单 fd)/ 网络重建(全部,`net_init` 开头,早于控制块清零)——"取消不通知 = 调用方永久挂死" | | `net_pending_clear` | 清除**不通知** | 清 `active` | 仅用于"新 fd 不得继承"(`net_socket`)与 close 后幂等清理(`net_close_locked`)——这两处前驱通知已由 settle 路径发出 | -### 4.6 D10 非阻塞重监听重试子系统(监听器自愈) +### 4.6 D10 非阻塞重监听重试子系统(`net_listener.c`) -**模型**:重开失败 = **登记待重试**(30ms 节奏 / 2s 时限),`net_relisten_retry_tick` 到点重试——任何时刻不阻塞任务。旧实现 `osDelay(500)` 忙等:netTask 冻结 500ms(全网停摆)、实测恢复 588ms、期间重连被拒(M3 台架)。 +**模型**:重开失败 = **登记待重试**(30ms 节奏 / 2s 时限),`net_listener_retry_tick` 到点重试——任何时刻不阻塞任务。旧实现 `osDelay(500)` 忙等:netTask 冻结 500ms(全网停摆)、实测恢复 588ms、期间重连被拒(M3 台架)。 + +> **N-32 §10(2026-10-12)**:本子系统的函数已从 `net_socket.c` **抽出到 `net_listener.c`**(真机专属,模拟器不编译);并新增 `net_listener_settle` 取代外部直接写 `s_relisten_quarantine[]`(沉降等级化)。 | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| -| `net_relisten_try` | 尝试一次;失败登记重试(**不阻塞**) | `net_relisten_locked` → 成功 `net_relisten_done` / 失败置 `s_relisten_wait/next/dead` | 已在重试队列 → 直接返回 -1(**不重置重试时刻**):否则 10ms 一次的 accept 轮询会不断把重试时刻往后推 → 永不重试(饥饿) | -| `net_relisten_locked` | 真正的 open + listen(**单次尝试**) | (隔离期)`net_drain_chip_rx` + `close_socket` + `get_sock_int_status` / `get_glob_int_status_all` + `get_socket_status` → `set_proto_type` + `set_sour_port` + `open_socket` + `tcp_listen` + 清中断 | ① **严禁 osDelay 忙等**:失败只清理并返回,沉降交给重试轮询;② **Trap 23 隔离门**:带隔离标记的 socket(异常释放 TIMEOUT,如中止上传)→ 持续排空 RX 残渣 + 主动 close + 排空中断 + 等芯片回 CLOSED/TCP_CLOSED,隔离满 400ms 才放行;**800ms 上限强制放行**(必须 < 2s 重试时限,否则隔离耗尽重试预算 → `relisten give up` 监听被弃——2026-09-23 实测修正);③ 成功只置 state=LISTENING,**不 memset**(fd 与 `auto_relisten` 保留——BSD 风格监听常驻) | -| `net_relisten_retry_tick` | 每轮到点重试 | `net_relisten_locked` → `net_relisten_done` / 顺延 / 放弃 | **同 fd 有在途挂起 recv 时跳过**(重开 Socket 会摧毁在途连接,L3 保险);到 2s 时限 → 清状态 + `relisten give up` 错误日志(应用可见) | -| `net_relisten_done` | 标记完成 | `net_relisten_clear` +(探针构建)恢复计时样本 | 恢复时延 = 首次尝试 → 完成(L3 探针 `relisten` 段) | -| `net_relisten_clear` | 取消该 fd 的待重试 | 清 wait/next/dead + **沉降/隔离状态** | 触发点:`net_socket`(新 fd 不继承)/ `net_close_locked`(关闭即取消);隔离状态必须一并清,否则残留标记会污染下一次复监听 | +| `net_listener_settle` | 登记"本 fd 会话已结束,重建前需沉降/隔离"(**只置事实**) | 写 `s_relisten_quarantine[fd]`(等级:GRACEFUL 100ms / QUARANTINE 400ms) | N-32 P1:取代此前外部直接写 `s_relisten_quarantine[]`;由 `handle_disconnect_event`(GRACEFUL)/ `handle_timeout_event`(QUARANTINE)调用。**只置事实,动作交 pump/try**;越界 fd 静默忽略 | +| `net_listener_try` | 尝试重建一次;失败登记重试(**不阻塞**) | `net_relisten_locked` → 成功 `net_relisten_done` / 失败置 `s_relisten_wait/next/dead` | 已在重试队列 → 直接返回 -1(**不重置重试时刻**):否则 10ms 一次的 accept 轮询会不断把重试时刻往后推 → 永不重试(饥饿) | +| `net_relisten_locked` | 真正的 open + listen(**单次尝试**) | (沉降期)`net_drain_chip_rx` + `close_socket` + `get_sock_int_status` / `get_glob_int_status_all` + `get_socket_status` → `set_proto_type` + `set_sour_port` + `open_socket` + `tcp_listen` + 清中断 | ① **严禁 osDelay 忙等**:失败只清理并返回,沉降交给重试轮询;② **Trap 23 沉降门**:带沉降标记的 socket(异常释放 TIMEOUT → QUARANTINE 400ms;正常释放 DISCONNECT → GRACEFUL 100ms)→ 排空 RX 残渣 + 主动 close + 排空中断 + 等芯片回 CLOSED/TCP_CLOSED;**上限强制放行**(必须 < 2s 重试时限,否则沉降耗尽重试预算 → `relisten give up` 监听被弃——2026-09-23 实测修正);③ 成功只置 state=LISTENING,**不 memset**(fd 与 `auto_relisten` 保留——BSD 风格监听常驻) | +| `net_listener_retry_tick` | 每轮到点重试(D10) | `net_relisten_locked` → `net_relisten_done` / 顺延 / 放弃 | **同 fd 有在途挂起 recv 时跳过**(重开 Socket 会摧毁在途连接,L3 保险);到 2s 时限 → 清状态 + `relisten give up` 错误日志(应用可见) | +| `net_relisten_done` | 标记完成 | `net_listener_clear` +(探针构建)恢复计时样本 | 恢复时延 = 首次尝试 → 完成(L3 探针 `relisten` 段) | +| `net_listener_clear` | 取消该 fd 的待重试/沉降 | 清 `wait/next/dead` + `settle_dead/settle_start` + `quarantine` | 触发点:`net_socket`(新 fd 不继承)/ `net_close_locked`(关闭即取消);沉降/隔离状态必须一并清,否则残留标记会污染下一次复监听 | | `net_drain_chip_rx` | 排空芯片 RX 残留(256B 分块,≤16 次 = 4KB 上限) | `get_recv_len` + `read_recv_buf` | Trap 23:中止/异常断开时芯片 RX 缓冲残留上一条连接的数据(实测 ~2048B),不排空会被下一条连接当自己的数据读走(静默截断);分块读避免新增静态缓冲(内存预算紧张) | -### 4.7 Trap 22 周期对账(丢中断自愈) +### 4.7 监听器 pump(N-32 P2 单一重建所有者,`net_listener.c`) + +`net_listener_pump()`(`net_process_messages` 每轮调用;周期 **100ms**): + +**设计意图(设计文档 §4 不变量 II)**:其它所有位置**只置事实**(会话结束 / 状态迁移 / 事件派发),**不做重建动作**——于是"谁先看到 DISCONNECT 就谁重建"这类**多所有者竞争在结构上消失**。本函数是**本层唯一**的周期性重建决策者。 + +**覆盖三种情形**(认领门槛:在用 + `auto_relisten` + TCP + 已绑端口): + +| # | 本层状态 / 芯片状态 | 动作 | 来源 | +|---|---|---|---| +| ① | 本层 LISTENING 而芯片监听**已失位** | 重建(`net_listener_try`) | 原对账③ | +| ② | 本层 CLOSED(会话已结束)而芯片**不在** LISTEN | 重建(`net_listener_try`) | 原对账④ | +| ③ | 本层 CLOSED 而芯片**正在** LISTEN | **只做状态重同步**(CLOSED→LISTENING),**不发任何芯片命令** | U11 缺口 | + +情形③的理由:芯片监听器是**健康**的,重下发 open+listen 会因 `USE` 错误走进关闭分支、把正听着的 Socket 关掉(U12 同源事故);只把本层状态收回 LISTENING 即可(与 `net_resync_listen` 同一纪律)。 + +**去抖 + 冷却**(沿用原对账③④ 实测结论,取值不变):同一失配需**连续 2 次**(≈200ms)才动手;同一 Socket 动手后**冷却 1s**(防抖成风暴,2026-09-23 实证)。**周期取 100ms**(对账 200ms 的 1/2):比它快、又不至于像 50ms 那样频繁 close/open 与消费者收发撞车(2026-10-12 台架实测 50ms 会把 uiTask 最坏单轮从 172ms 推到 1034ms)。 + +> `session_dead` **不在此清零**(与 `net_relisten_locked` 同一契约:重建监听 ≠ 应用已确认会话结束;清零只发生在 `handle_connect_event`)。 + +### 4.8 Trap 22 周期对账(丢中断自愈,`net_socket.c`) `net_reconcile_tick()`(`net_process_messages` 每轮调用;周期 200ms): **问题**:芯片在快速复连场景会**丢 CONNECT/DISCONNECT 中断**(或静默回收 Socket 不发中断);丢事件后纯事件驱动无法自愈——典型:对端连接一直占着单连接监听的"后备"(芯片 TCP 仍 ESTABLISHED),后续 SYN 全被拒,而应用因未收到 DISCONNECT 仍以为有客户端(永不调 accept)。 -**机制**:对每个在用 Socket(ESTABLISHED / LISTENING)回读 `GET_SOCKET_STATUS`,判定三类失同步并修正: +**机制**:对每个在用 Socket(**仅 ESTABLISHED / LISTENING**)回读 `GET_SOCKET_STATUS`,判定**两类**失同步并修正: | # | 失同步 | 动作 | 复用的实现 | |---|---|---|---| -| ① | 本层 ESTABLISHED 而芯片已无连接(sock/tcp = CLOSED) | 按断开处理 → CLOSED + 回调(应用读到 EOF 后自动重监听) | `handle_disconnect_event`(内置陈旧事件甄别) | +| ① | 本层 ESTABLISHED 而芯片已无连接(sock/tcp = CLOSED) | 按断开处理 → CLOSED + 回调(应用读到 EOF 后由 pump 重建监听) | `handle_disconnect_event`(内置陈旧事件甄别) | | ② | 本层 LISTENING 而芯片已完成握手(tcp = ESTABLISHED) | 补做 standalone accept 簿记 | `handle_connect_event` | -| ③ | 本层 LISTENING 而芯片监听已消失(异常关闭) | 重建监听 | `net_relisten_try`(D10 非阻塞重试) | + +> ⚠ **N-32 P2(2026-10-12)**:原③(本层 LISTENING 丢监听)与④(孤儿监听)已**整体移交 `net_listener_pump`**——本函数内**不再有任何重建动作**(单一所有者),认领门槛相应收窄为 ESTABLISHED/LISTENING(原为驱动④而多认领"CLOSED + auto_relisten + 已绑端口")。 **去抖 + 冷却(缺一不可)**:单次状态读可能落在"刚 open/listen 尚未生效"的瞬态——**2026-09-23 实测**:无去抖的 accept 路径自愈把监听反复重建直至 `relisten give up`(三服务口全死、89 行 ERROR)。故:同一失配需**连续 2 次**(≈400ms)才动手;同一 Socket 动手后**冷却 1s**。开销:每 200ms 每在用 Socket 一条状态命令,可忽略。 -### 4.8 事件处理器(netTask 上下文;由 `net_poll` 驱动,对账 tick 可再入) +### 4.9 事件处理器(netTask 上下文;由 `net_poll` 驱动,对账 tick 可再入) | 函数 | 设计作用 | 主要调用 | 意图 / 注意 | |---|---|---|---| -| `handle_connect_event` | LISTENING → standalone accept(回读对端地址 → ESTABLISHED + `send_ready=1` + 清残留 TIMEOUT + 回调);CONNECTING → 客户端连接成功(回读对端 + 使能 KeepAlive + 回调) | `ch395f_get_remot_ipp` + `get_sock_int_status` +(client)`set_keepalive_enable` + `fire_event` | GET_REMOT_IPP 为**小端**输出(与 set_des_ip 的大端相反)——解析别抄反 | -| `handle_disconnect_event` | 状态机迁移 + 陈旧事件甄别 + 监听丢失重建 | `ch395f_get_socket_status` →(LISTENING:芯片已不可监听 → `net_relisten_try`,仍在监听 → 判陈旧丢弃)/(ESTABLISHED:芯片仍 ESTABLISHED → 判陈旧丢弃) | ① **陈旧 DISCONNECT 甄别**(D20):同一 fd"关闭→重监听→再连接"循环复用,上一条连接的 FIN/ACK 可能在 fd 已复用时才上报 → 不甄别则新连接秒断(实测:FTP 同会话第二个数据连接必 425);② LISTENING 分支**已不可监听即重建**(Trap 22 修复的一半);③ 置 CLOSED 后**不立即关芯片 Socket**——留给 `net_recv_locked` 排空剩余数据(FIN 可能先于数据到达) | +| `handle_connect_event` | LISTENING → standalone accept(回读对端地址 → ESTABLISHED + `send_ready=1` + 清残留 TIMEOUT + 回调);CONNECTING → 客户端连接成功(回读对端 + 使能 KeepAlive + 回调) | `ch395f_get_remot_ipp` + `get_sock_int_status` +(client)`set_keepalive_enable` + `fire_event` | GET_REMOT_IPP 为**小端**输出(与 set_des_ip 的大端相反)——解析别抄反;`session_dead` 的**唯一清零点** | +| `handle_disconnect_event` | 状态机迁移 + 陈旧事件甄别 + **登记沉降事实**(不再自己重建) | `ch395f_get_socket_status` →(LISTENING:芯片已不可监听 → **`net_listener_settle(GRACEFUL)`**,仍在监听 → 判陈旧丢弃)/(ESTABLISHED:芯片仍 ESTABLISHED → 判陈旧丢弃) | ① **陈旧 DISCONNECT 甄别**(D20):同一 fd"关闭→重监听→再连接"循环复用,上一条连接的 FIN/ACK 可能在 fd 已复用时才上报 → 不甄别则新连接秒断(实测:FTP 同会话第二个数据连接必 425);② 正常释放(FIN)→ **GRACEFUL 沉降 100ms**(只置事实,重建由 `net_listener_pump` 做,N-32 P1/P2);③ 置 CLOSED 后**不立即关芯片 Socket**——留给 `net_recv_locked` 排空剩余数据(FIN 可能先于数据到达) | | `handle_recv_event` | 置 `readable` + 回调;**不读 recv_len** | `fire_event` | 不读长度是为了避免与 `net_recv_locked` 的 `ch395f_get_recv_len` 竞争(Trap 13:分批读会归零);`readable` 供 `net_select` 无 SPI 访问判读 | | `handle_send_event` | 发送缓冲已空:触发回调(`send_ready` 由 SENBUF_FREE 前置位) | `fire_event` | — | -| `handle_timeout_event` | CONNECTING → CREATED(连接超时);**ESTABLISHED → 置重监听隔离 + CLOSED**(Trap 23);LISTENING → 仅记日志 | (隔离)写 `s_relisten_quarantine[fd]` + `fire_event` | TIMEOUT = 异常释放(如客户端中止上传):对端在途数据随后落进 RX 缓冲 → 必须隔离后才许复监听;正常 FIN 释放走 disconnect 路径,**不走**隔离(零改动) | +| `handle_timeout_event` | CONNECTING → CREATED(连接超时);**ESTABLISHED → 登记隔离 + CLOSED**(Trap 23);LISTENING → 仅记日志 | **`net_listener_settle(fd, NET_LISTENER_SETTLE_QUARANTINE)`** + `fire_event` | TIMEOUT = 异常释放(如客户端中止上传):对端在途数据随后落进 RX 缓冲 → 必须隔离(400ms)后才许复监听;正常 FIN 释放走 disconnect 路径(GRACEFUL 100ms),**不走**隔离 | | `fire_event` | 调用已注册回调 | `p_sock->event_cb` | **回调上下文 = netTask**:回调内不得阻塞、不得发网络操作(会自等死锁) | -### 4.9 工具函数 +### 4.10 工具函数 | 函数 | 设计作用 | |---|---| @@ -257,7 +286,7 @@ sequenceDiagram Note over N: close / 网络重建 / 陈旧 resync → 显式 settle/cancel(必通知) ``` -### 5.3 断连 → 自动重监听(含 Trap 23 隔离) +### 5.3 断连 → 自动重监听(含 Trap 23 沉降) ```mermaid sequenceDiagram @@ -268,19 +297,19 @@ sequenceDiagram Note over C: 客户端 FIN(正常)或 RST/无响应(异常) N->>C: net_poll 读 SINT alt DISCONNECT(正常) - N->>N: handle_disconnect_event:回读甄别 → CLOSED + 回调 + N->>N: handle_disconnect_event:回读甄别 → CLOSED + 回调
+ net_listener_settle(GRACEFUL 100ms) else TIMEOUT(异常,如中止上传) - N->>N: handle_timeout_event:置重监听隔离 + CLOSED + N->>N: handle_timeout_event:net_listener_settle(QUARANTINE 400ms) + CLOSED end - A->>N: 下一次 net_recv(无数据)/ net_accept - N->>C: net_relisten_try → net_relisten_locked - opt 隔离期内(Trap 23) + Note over N: 只置事实 —— 重建统一由 net_listener_pump(100ms)决定 + N->>C: net_listener_try → net_relisten_locked + opt 沉降期内(Trap 23) N->>C: 排空 RX 残渣(256B 分块)+ close + 排空中断 C-->>N: 未到 CLOSED/TCP_CLOSED → 返回(30ms 后重试轮询复检) end N->>C: set_proto_type + set_sour_port + open_socket + tcp_listen alt open 失败(Trap 09 沉降) - N->>N: 登记重试(30ms 节奏 / 2s 时限)→ 每轮到点重试 + N->>N: 登记重试(30ms 节奏 / 2s 时限)→ net_listener_retry_tick 到点重试 else 成功 N->>N: state = LISTENING(auto_relisten 保留) N-->>A: xTaskNotify(accept 返回 / recv 返回 0) @@ -288,7 +317,7 @@ sequenceDiagram Note over N: 2s 超限时 → 清状态 + "relisten give up"(错误日志,应用可感知) ``` -### 5.4 丢中断自愈(Trap 22 对账) +### 5.4 丢中断自愈(Trap 22 对账 + N-32 pump) ```mermaid sequenceDiagram @@ -305,7 +334,7 @@ sequenceDiagram Note over N: 连续 2 次失配(去抖 ≈400ms)且不在冷却期 → 动手 N->>N: handle_connect_event:补 accept 簿记 → ESTABLISHED N-->>A: 下一次 net_accept 返回客户端(连接不再"吞掉") - Note over N: 另两类:① 本层 ESTABLISHED 而芯片已断 → handle_disconnect_event
③ 本层 LISTENING 而芯片监听消失 → net_relisten_try + Note over N: 另两类:① 本层 ESTABLISHED 而芯片已断 → handle_disconnect_event
③④ 本层 LISTENING 丢监听 / CLOSED 孤儿 → net_listener_pump(100ms,唯一重建入口) ``` ### 5.5 网络重建(IP 变更免重启) @@ -339,10 +368,11 @@ sequenceDiagram | 8 | **close 前先了结挂起 recv + 排空残留 DISCONNECT** | Trap 14/17/19:锁存的陈旧 DISCONNECT 秒断下一连接 | | 9 | **两段式等待防丢失通知**:零等待拾取 + 阻塞;清除掩码全 1;以 pdTRUE 判定 | run3~11:丢失通知 → 挂死 / 结果错位;0 结果误判"无通知" → 永久挂死 | | 10 | **网络重建先取消并通知全部挂起,再复位芯片** | 取消不通知 = 调用方永久挂死 | +| 11 | **监听重建只有一个所有者**(`net_listener_pump`);其它位置**只置事实**(置 CLOSED / 登记沉降),不做重建动作 | N-32 P2 / B10:多所有者导致"谁先看到 DISCONNECT 就谁重建"的竞争与重建风暴 | --- -## 7. ch395f 驱动接口使用图(net_socket.c → 芯片命令) +## 7. ch395f 驱动接口使用图(`net_socket.c` / `net_listener.c` → 芯片命令) | 芯片命令 | 使用处 | 说明 | |---|---|---| @@ -355,13 +385,13 @@ sequenceDiagram | `ch395f_set_send_buf` / `ch395f_set_recv_buf` | `net_init`(8 Socket 布局);`net_bind`(UDP)(Socket 4~7 兜底) | 每 Socket 收 4 块 + 发 2 块 | | `ch395f_get_glob_int_status_all` | `net_poll`;`net_listen_locked` / `net_connect_locked` / `net_close_locked` / `net_relisten_locked`(清残留) | **读 = 清**(中断清除机制) | | `ch395f_get_sock_int_status` | `net_poll`(逐 Socket SINT);各 open 路径后清残留;事件处理器清 TIMEOUT | **读 = 清** | -| `ch395f_open_socket` / `ch395f_close_socket` | `net_bind`(UDP)、`net_listen_locked`、`net_connect_locked`、`net_close_locked`、`net_relisten_locked`(含隔离期主动 close) | Trap 09:close 后需沉降才能重开 | +| `ch395f_open_socket` / `ch395f_close_socket` | `net_bind`(UDP)、`net_listen_locked`、`net_connect_locked`、`net_close_locked`、`net_relisten_locked`(含沉降期主动 close) | Trap 09:close 后需沉降才能重开 | | `ch395f_tcp_listen` / `ch395f_tcp_connect` | `net_listen_locked` / `net_relisten_locked`;`net_connect_locked` | — | | `ch395f_set_proto_type` / `set_sour_port` / `set_des_ip` / `set_des_port` | `net_bind`(UDP)、`net_listen_locked`、`net_connect_locked`、`net_relisten_locked`、`net_sendto` | open 之后写端口/IP(open 会复位寄存器);set_des_ip 期望**大端数组** | | `ch395f_write_send_buf` | `net_send_locked` / `net_sendto` | 单次 ≤1KB(硬件 FIFO 上限,Trap 20) | | `ch395f_get_recv_len` / `ch395f_read_recv_buf` | `net_recv_locked`、`net_pending_tick`、`net_pending_settle_close`、`net_recvfrom`、`net_drain_chip_rx` | `handle_recv_event` **故意不读**(防竞争,Trap 13) | -| `ch395f_get_socket_status` | `net_reconcile_tick`、`handle_disconnect_event`(两处甄别)、`net_close_locked`(等 CLOSED)、`net_relisten_locked`(隔离门)、`net_connect_locked`(诊断回读) | 输出 **2 字节**([0]=Socket 状态 [1]=TCP 状态),缓冲必须给 2 字节 | -| `ch395f_get_remot_ipp` | `handle_connect_event`、`net_connect_locked`(诊断) | **小端**输出(与 set_des_ip 相反) | +| `ch395f_get_socket_status` | `net_listener_pump`、`net_reconcile_tick`、`handle_disconnect_event`(两处甄别)、`net_close_locked`(等 CLOSED)、`net_relisten_locked`(沉降门)、`net_connect_locked`(诊断回读) | 输出 **2 字节**([0]=Socket 状态 [1]=TCP 状态),缓冲必须给 2 字节 | +| `ch395f_get_remot_ipp` | `handle_connect_event`、`net_reconcile_tick`(幽灵甄别)、`net_connect_locked`(诊断) | **小端**输出(与 set_des_ip 相反) | | `ch395f_set_keepalive_enable` | `handle_connect_event`(client 连接成功时) | — | --- @@ -370,19 +400,20 @@ sequenceDiagram | 想改什么 | 先看哪些函数 | |---|---| -| 监听恢复策略(节奏/时限/隔离) | `net_relisten_try` / `net_relisten_locked` / `net_relisten_retry_tick`;常量 `NET_RELISTEN_*_MS`(`net_socket.c` 内) | +| 监听恢复策略(节奏/时限/沉降) | `net_listener_try` / `net_relisten_locked` / `net_listener_retry_tick` / `net_listener_pump`;常量 `NET_RELISTEN_*_MS` / `NET_LISTENER_PUMP_PERIOD_MS` / `NET_LISTENER_STABLE_N` / `NET_LISTENER_COOLDOWN_MS`(`net_listener.c` 内) | | 阻塞接收等待语义 | `net_pending_register` / `net_pending_tick` / `net_pending_complete`;常量 `NET_PENDING_WATCHDOG_MS` | -| 丢中断自愈策略 | `net_reconcile_tick`(周期/去抖/冷却三常量)+ `handle_disconnect_event` LISTENING 分支 | +| 丢中断自愈策略 | `net_reconcile_tick`(周期/去抖/冷却三常量,①②)+ `net_listener_pump`(③④,唯一重建入口)+ `handle_disconnect_event` / `handle_connect_event` | | 新增一个公共 API | 模式:`net_socket.h` 声明(`NET_NO_OS` 条件编译)→ `net_msg_type_t` 加类型(如需要)→ `net_process_messages` 加 dispatch 分支 → netTask 侧 `_locked` 实现;调用侧复制"参数检查 → 入队 → `net_wait_result` → errno 恢复"骨架 | | 缓冲布局 / MSS / 重传 / KeepAlive 参数 | `net_init` + `net_config.h` + `ch395f.h`(`CH395F_RECV/SEND_BLOCKS`、`CH395F_TCP_MSS`);改完必须 0 错 0 警构建 + 全测试套件回归 | | 队列容量 / 消息结构 | CubeMX 生成的 `MX_FREERTOS_Init`(8 槽)+ `net_socket.h` 的 `net_msg_t`(**勿加回传字段**) | -| 超时参数(收发/重试/对账) | `net_config.h`(收发/重传/KeepAlive);`net_socket.c` 内 `NET_RELISTEN_*` / `NET_RECONCILE_*` / `NET_PENDING_WATCHDOG_MS` | +| 超时参数(收发/重试/对账) | `net_config.h`(收发/重传/KeepAlive);`net_listener.c` 内 `NET_RELISTEN_*` / `NET_LISTENER_*`;`net_socket.c` 内 `NET_RECONCILE_*` / `NET_PENDING_WATCHDOG_MS` | --- ## 9. 相关文档 - `docs/网络/BSD_Socket_API_使用指南.md` —— 公开 API 语义、示例、常见问题(本文的上半部) +- `docs/子系统设计/网络Socket监听生命周期设计说明.md` —— **监听生命周期权威设计**(N-32:边沿→电平、单一所有者 pump、`net_listener.c` 模块化边界 §10) - `docs/问题记录/CH395F_Trap_Records.md` —— Trap 01~23 机制细节(本文引用处) - `docs/网络/Net层阻塞模型架构设计.md` —— 阻塞等待模型的现状架构设计(pending 请求模型 / 五路完成语义) - `docs/问题记录/错误码传播审计.md` —— E-02 头阻塞与错误码回传全审计 diff --git a/docs/网络/Net层阻塞模型架构设计.md b/docs/网络/Net层阻塞模型架构设计.md index e181ed8a..fd301095 100644 --- a/docs/网络/Net层阻塞模型架构设计.md +++ b/docs/网络/Net层阻塞模型架构设计.md @@ -43,7 +43,7 @@ netTask 是**全部 socket 操作的唯一执行者**——CH395F 的 SPI 总线 | 区域 | 内容 | |---|---| | **左栏 · 调用方任务** | `net_recv` → 判"数据已在?" → **是**走快速路径;**否**(`timeout > 0`)登记 pending 后在自己上下文睡任务通知 | -| **中栏 · netTask 主循环** | ~1ms 节拍:`net_poll`(刷新 readable)→ `net_pending_tick`(了结挂起)→ `net_relisten_retry_tick` → 排空消息队列 | +| **中栏 · netTask 主循环** | ~1ms 节拍:`net_poll`(刷新 readable)→ 4 个非阻塞 tick(`net_listener_retry_tick` D10 → `net_pending_tick` 了结挂起 → `net_reconcile_tick` Trap 22 对账 → `net_listener_pump` 监听重建唯一入口)→ 排空消息队列 | | **下带 · 五路判定** | 每轮对每个挂起 fd 按序判定:数据到达 / 到点 / EOF / 同 fd 后续操作 / 网络重建;含 1s 看门狗 | | **回环** | 判定完成经 `xTaskNotify` 唤醒调用方(结果 = 字节数 / 负错误码) | @@ -75,7 +75,7 @@ netTask 是**全部 socket 操作的唯一执行者**——CH395F 的 SPI 总线 `net_close_locked` 对 `net_sock_t` 做**整块 memset**。若挂起状态置于其中,`close` 会 静默抹掉 pending——调用方永远等不到通知,**永久挂死**。独立表把"跨时间状态"与 -socket 生命周期解耦,与 `net_relisten_retry_tick` 的重试表同构。 +socket 生命周期解耦,与 `net_listener_retry_tick` 的重试表同构。 ### 3.2 登记守卫(两条硬防线) @@ -144,7 +144,7 @@ CONNECT → ESTABLISHED 是合法迁移,推断取消会误杀正在建链的 | **ACCEPT 路径未覆盖** | accept 仍用轮询计时 | 需 pending 化(二期) | | **超时精度** | 随主循环节拍(~1ms),被长操作占用时顺延 | 一期文档化、不修;二期 SEND 化后该顺延面**收窄** | | **看门狗未接自检** | 强结算 + `DBG_ERROR`,未写 selftest | 二期补齐 | -| **模拟器不可建模** | `net_socket_sim.c` 单线程 Winsock 无法复现挂起模型 | 该路径在模拟器上零覆盖,只能硬件验证 | +| **模拟器不可建模** | `net_socket_sim.c` 单线程 Winsock 无法复现挂起模型 | 该路径在模拟器上零覆盖,只能硬件验证;**监听生命周期 `net_listener.c` 亦为真机专属**(内部头只被 `net_socket.c` include,CMakeLists 不编译) | **二期扩展设计**(ACCEPT / SEND 的挂起化、门禁清单、改动量)见 `docs/任务规划/基线与待确认.md` §2.1。 @@ -218,3 +218,4 @@ CONNECT → ESTABLISHED 是合法迁移,推断取消会误杀正在建链的 | 2026-09-26 | 调研文档退役,客观实施记录独立成文 | | 2026-09-29 | **改写为架构设计文档**:以"当前如何设计/为何如此"为主线重组织;历史演进与门禁实测降级为附录 A;迁入 `docs/网络/` | | 2026-10-03 | **网络重建失败恢复(P1-8 / A2)落地并真机验证**:首启组网失败 = 自检 `net init fail` + 30s 周期重试(1s 分片喂心跳,**不自驻留**);重建(reconfig)失败 = 自检 `net reinit fail` + 30s 延迟重触发事件位(tick 回绕安全),成功即清零;PHY 链路超时仍走既有 `comms fault` 降级(语义不变)。真机:开机 `net init: OK` / `PHY: 100M FULL` / `netTask: started`,无 `init FAIL` / `retry in 30s` 误触发(**正常路径零回归**);芯片级 `net_init` 失败分支 / 拔线负向场景待专项窗口(`待处理内容.md` N-16)。**执行判据见 `测试说明/实机测试实施指南.md` §7.1 / §10** | +| 2026-10-12 | **N-32 监听生命周期模块化 + 单一所有者**:监听生命周期抽为 `net_listener.c`(`net_listener_try` / `net_listener_clear` / `net_listener_retry_tick` / `net_listener_pump` / `net_listener_settle`,真机专属、模拟器不编译);**重建动作收敛到唯一所有者 `net_listener_pump`**(100ms,去抖+冷却),对账 `net_reconcile_tick` 只剩①②;主循环自愈 tick 由 3 个增至 **4 个**。详见 `docs/子系统设计/网络Socket监听生命周期设计说明.md` §10 | diff --git a/scripts/README.md b/scripts/README.md index 37eb6f0e..6a6a7986 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -36,7 +36,13 @@ | 脚本 / 文件 | 作用 | |---|---| -| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,说明见 `docs/测试说明/测试开发标准流程.md` §9 | +| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 | +| `ci_gitlint_range.ps1` | CI 侧**提交信息门禁**:逐条校验本次 push/PR 引入的提交(`--all-files` 跑不到 `commit-msg` stage,故必须单独守) | +| `ci_find_python.ps1` | CI 侧 Python 探测(候选顺序须与 `ci_local.py` 同序同步,否则 CI 与本地判定会分叉) | +| `ci_runner_bootstrap.ps1` | **本机 runner 运维脚本**(体检 `-Status` / 干跑 `-DryRun` / 部署 `-SetEnv -StartNow`):装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 开机计划任务 + 机器级 `DTU_*` | + +> ⚠️ 后三个脚本是**本机 runner 专属运维**(远程 CI 只跑在本机一台机器上),用法、原理与 +> 踩坑全部集中在 `docs/CI/README.md`,本表只登记不展开。 ## 使用方式 diff --git a/scripts/check_registration.py b/scripts/check_registration.py index 8e300965..7cc20344 100644 --- a/scripts/check_registration.py +++ b/scripts/check_registration.py @@ -81,7 +81,10 @@ SIM_EXEMPT = [ ("App/algo/phasor.c", "L2 相量纯算法,模拟器无消费路径"), ("App/algo/prot_elm.c", "L3 保护元件纯算法,模拟器无消费路径"), ("App/algo/prot_set.c", "L5 保护定值纯算法,模拟器无消费路径"), + ("App/algo/freq.c", "L2 派生测频纯算法(输入=phasor 使用区),模拟器无消费路径"), ("App/cfg/prot_set_store.c", "定值持久化 MCU 胶水(依赖 prot_set 纯算法),模拟器无消费路径"), + ("App/log/soe_rec.c", "SOE 记录编解码纯逻辑(B9),L0.5 主机单测覆盖,模拟器无消费路径"), + ("App/log/soe_store.c", "SOE 落盘 MCU 胶水(队列+FatFs 单写者,B9),模拟器无消费路径"), ("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"), ("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"), ("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"), diff --git a/scripts/ci_find_python.ps1 b/scripts/ci_find_python.ps1 new file mode 100644 index 00000000..bbf85e29 --- /dev/null +++ b/scripts/ci_find_python.ps1 @@ -0,0 +1,179 @@ +<# +.SYNOPSIS + Pick a Python interpreter that actually works, for the CI jobs (and for + local use). Prints diagnostics with Write-Host and emits ONLY the chosen + interpreter path on the success stream, so a caller can capture it: + + $py = & './scripts/ci_find_python.ps1' -NeedPreCommit -WriteEnv + +.DESCRIPTION + Why this file exists (2026-10-11 CI incident): + + The DTU_* env-var refactor moved the interpreter probe inline into + .gitea/workflows/ci.yml and narrowed it to two candidates + (PATH python + repo .venv). On the Windows runner BOTH are dead ends: + + * PATH python for the SYSTEM account is C:\msys64\ucrt64\bin\python.exe, + which has no pre-commit module; + * the repo .venv is gitignored, so it does not exist in a fresh runner + workspace at all. + + The interpreter that does work (a machine-wide install such as + C:\ProgramData\anaconda3\python.exe) was no longer probed, and no + machine-level DTU_PRECOMMIT_PYTHON had ever been set -> every L0 job died + in "environment check" before running a single hook. + + So discovery lives here now: one implementation, shared by both CI jobs and + runnable offline, and it keeps the well-known machine-wide install + locations as fallbacks. + + Probe order (see the DTU_* local-tool-path plan under docs/ -- this file is + kept pure ASCII on purpose: PS 5.1 decodes BOM-less files as CP936): + 1. DTU_PRECOMMIT_PYTHON -- explicit override. If it is set but the file + is missing, or it cannot satisfy the requirement, this script FAILS + with the variable name and does NOT silently fall back. + 2. python on PATH + 3. \.venv\Scripts\python.exe + 4. well-known machine-wide installs (Program Files / ProgramData / + %LOCALAPPDATA%\Programs\Python), newest version first + +.PARAMETER NeedPreCommit + Require the interpreter to expose the pre-commit module (L0 needs it). + Probed with `-s` (no user site-packages) on purpose: the runner service + runs as SYSTEM, which cannot see a USER-scope site install at all, so only + an interpreter that works without user-site is accepted. + +.PARAMETER WriteEnv + Append PYTHON= to $env:GITHUB_ENV so later steps can use $env:PYTHON. + Written as ASCII: Out-File's PS 5.1 default is UTF-16LE, which appends + foreign-encoded lines to a file other steps also append to. + +.OUTPUTS + The chosen python.exe path (single line, success stream). + +.NOTES + Keep this file pure ASCII -- Windows PowerShell 5.1 decodes BOM-less files + as CP936, and non-ASCII bytes break string quoting (same trap as ci.yml). + Exit codes: 0 = found, 2 = no usable interpreter / bad DTU_* override. +#> +param( + [switch]$NeedPreCommit, + [switch]$WriteEnv +) + +# Native probes write to stderr; the runner injects $ErrorActionPreference +# ='Stop', which would turn that into a terminating NativeCommandError and +# abort the probe loop before the next candidate is tried. +$ErrorActionPreference = 'Continue' + +function Test-Interpreter { + param([string]$Exe, [bool]$RequirePreCommit) + + if ([string]::IsNullOrWhiteSpace($Exe)) { return $false } + if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false } + + $saved = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + if ($RequirePreCommit) { + & $Exe -s -m pre_commit --version 2>&1 | Out-Null + } + else { + & $Exe --version 2>&1 | Out-Null + } + return ($LASTEXITCODE -eq 0) + } + catch { + return $false + } + finally { + $ErrorActionPreference = $saved + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$require = [bool]$NeedPreCommit +$what = if ($require) { 'pre-commit' } else { 'a runnable interpreter' } + +# --- 1. explicit override: honour it or fail loudly ------------------------- +$explicit = $env:DTU_PRECOMMIT_PYTHON +if (-not [string]::IsNullOrWhiteSpace($explicit)) { + $explicit = $explicit.Trim() + if (-not (Test-Path -LiteralPath $explicit -PathType Leaf)) { + Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $explicit" + Write-Host '::error::fix the variable (full path to a python.exe) or unset it to use auto-discovery' + exit 2 + } + if (-not (Test-Interpreter -Exe $explicit -RequirePreCommit $require)) { + Write-Host "::error::DTU_PRECOMMIT_PYTHON does not provide $what (probed without user-site, i.e. SYSTEM-visible): $explicit" + Write-Host '::error::fix the variable, or unset it to use auto-discovery' + exit 2 + } + Write-Host "using python (DTU_PRECOMMIT_PYTHON): $explicit" + if ($WriteEnv -and $env:GITHUB_ENV) { + "PYTHON=$explicit" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii + } + Write-Output $explicit + exit 0 +} + +# --- 2..4 auto-discovery ---------------------------------------------------- +$programFiles = $env:ProgramFiles +$programFilesX86 = ${env:ProgramFiles(x86)} +if ([string]::IsNullOrWhiteSpace($programFiles)) { $programFiles = 'C:\Program Files' } +if ([string]::IsNullOrWhiteSpace($programFilesX86)) { $programFilesX86 = 'C:\Program Files (x86)' } + +$candidates = @() + +$onPath = Get-Command python -ErrorAction SilentlyContinue +if ($onPath) { $candidates += $onPath.Source } + +$candidates += (Join-Path $repoRoot '.venv\Scripts\python.exe') + +# Newest first: a fresh install is the usual intent. +foreach ($ver in @('Python313', 'Python312', 'Python311', 'Python310')) { + $candidates += (Join-Path $programFiles "$ver\python.exe") + $candidates += (Join-Path $programFilesX86 "$ver\python.exe") + if ($env:LOCALAPPDATA) { + $candidates += (Join-Path $env:LOCALAPPDATA "Programs\Python\$ver\python.exe") + } +} + +# Machine-wide distributions (these are what the runner actually had). +if ($env:ProgramData) { + foreach ($dist in @('anaconda3', 'miniconda3')) { + $candidates += (Join-Path $env:ProgramData "$dist\python.exe") + } +} +$candidates += 'C:\ProgramData\anaconda3\python.exe' +$candidates += 'C:\ProgramData\miniconda3\python.exe' + +$tried = New-Object System.Collections.Generic.List[string] +$chosen = $null +foreach ($c in $candidates) { + if ([string]::IsNullOrWhiteSpace($c)) { continue } + if ($tried.Contains($c)) { continue } + $tried.Add($c) | Out-Null + if (Test-Interpreter -Exe $c -RequirePreCommit $require) { $chosen = $c; break } +} + +if (-not $chosen) { + Write-Host "::error::no Python providing $what found (tried `$DTU_PRECOMMIT_PYTHON`, PATH python, repo .venv, and the well-known machine-wide installs)" + foreach ($t in $tried) { Write-Host " tried: $t" } + Write-Host '::error::note: the runner service runs as SYSTEM, so a USER-scope `pip install --user pre-commit` is invisible to it' + Write-Host '::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON (see scripts/ci_runner_bootstrap.ps1 -SetEnv), or install pre-commit into a machine-wide interpreter' + exit 2 +} + +Write-Host "using python (auto-discovered): $chosen" +# Out-Host, NOT the pipeline: a native command's stdout joins the success +# stream, which would pollute the single-line path the caller captures. +& $chosen --version 2>&1 | Out-Host +if ($require) { & $chosen -s -m pre_commit --version 2>&1 | Out-Host } + +if ($WriteEnv -and $env:GITHUB_ENV) { + "PYTHON=$chosen" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii +} + +Write-Output $chosen +exit 0 diff --git a/scripts/ci_gitlint_range.ps1 b/scripts/ci_gitlint_range.ps1 new file mode 100644 index 00000000..9f917784 --- /dev/null +++ b/scripts/ci_gitlint_range.ps1 @@ -0,0 +1,212 @@ +<# +.SYNOPSIS + Commit-message gate for CI: run the `gitlint` pre-commit hook over every + commit in a pushed / pull-request range. + +.DESCRIPTION + Why this file exists (2026-10-11 CI audit): + + CI always reports ONE hook fewer than .pre-commit-config.yaml defines + (measured 2026-10-12: master 18 defined / 17 reported, v2-on-ui 26 / 25). + The missing one is `gitlint`: its hook definition declares + `stages: [commit-msg]`, while `pre-commit run --all-files` only runs the + `pre-commit` stage. So the commit-message rule from AGENTS.md (title + < 100 chars, body lines <= 80) was enforced on developer machines and + NEVER in CI -- a silent gate gap, not a failure. + + pre-commit cannot check "the commits of this push" on its own, so this + script feeds each commit message to the hook through + `--hook-stage commit-msg --commit-msg-filename `, one commit at a + time, and aggregates the result. + + Message files are written INSIDE the workspace (under ci-artifacts/, which + is gitignored) for two hard reasons: + * pre-commit rejects a message path on another drive than the repo + ("path is on mount 'C:', start on mount 'D:'" -- measured here); + * they must be UTF-8 WITHOUT BOM, because a BOM counts as a character of + the title and would distort the 100-char rule. + +.PARAMETER BaseSha + Exclusive lower bound. Empty, all-zero (branch creation / deletion), or an + unreachable SHA (force push) falls back to the newest -MaxCommits commits. + +.PARAMETER HeadSha + Inclusive upper bound. Defaults to HEAD. + +.PARAMETER MaxCommits + Upper bound on how many commits are checked, so a big range cannot run for + hours. Merge commits are skipped (their messages are generated by git, and + the local hook is what guards hand-written ones). + +.PARAMETER PythonExe + Interpreter that provides pre-commit. Defaults to $env:PYTHON, then to + scripts/ci_find_python.ps1 auto-discovery. + +.OUTPUTS + None. Diagnostics go to the host as GitHub workflow commands. + Exit codes: 0 = all messages valid, 1 = at least one invalid, 2 = setup error. + +.NOTES + Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +#> +param( + [string]$BaseSha = '', + [string]$HeadSha = 'HEAD', + [int]$MaxCommits = 50, + [string]$PythonExe = '' +) + +$ErrorActionPreference = 'Continue' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $repoRoot +try { + # git emits UTF-8 commit messages; PS 5.1 would otherwise decode them with + # the console code page (CP936) and mangle every Chinese title. + $savedConsoleEncoding = [Console]::OutputEncoding + try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } + + try { + # --- resolve the interpreter ---------------------------------------- + if ([string]::IsNullOrWhiteSpace($PythonExe)) { $PythonExe = $env:PYTHON } + if ([string]::IsNullOrWhiteSpace($PythonExe)) { + $PythonExe = & (Join-Path $PSScriptRoot 'ci_find_python.ps1') -NeedPreCommit + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($PythonExe)) { + Write-Host '::error::gitlint: no Python with pre-commit available' + exit 2 + } + } + $PythonExe = "$PythonExe".Trim() + Write-Host "gitlint: using python $PythonExe" + + # --- git identity guard --------------------------------------------- + # Measured on CI 2026-10-12 (task 31): actions/checkout points HOME at + # a per-job temp dir and the SYSTEM account has no global identity, so + # gitlint aborted with "Missing git configuration: please set + # user.name" and exit code 254 BEFORE reading a single rule. Every push + # failed this gate no matter how good the message was, and the log made + # it look like a message violation. + # Inject a throwaway identity through GIT_CONFIG_* (git >= 2.31): it is + # inherited by the child git processes only, so NO file is written and + # a developer machine keeps its own identity untouched. gitlint needs + # an identity merely to start; it does not judge the author. + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $idName = git config user.name 2>$null + $idMail = git config user.email 2>$null + $ErrorActionPreference = $savedPref + if (-not "$idName".Trim() -or -not "$idMail".Trim()) { + if ($env:GIT_CONFIG_COUNT) { + Write-Host '::warning::gitlint: GIT_CONFIG_* already set by the caller -- leaving it alone' + } else { + $env:GIT_CONFIG_COUNT = '2' + $env:GIT_CONFIG_KEY_0 = 'user.name' + $env:GIT_CONFIG_VALUE_0 = 'dtu-ci' + $env:GIT_CONFIG_KEY_1 = 'user.email' + $env:GIT_CONFIG_VALUE_1 = 'dtu-ci@invalid.local' + Write-Host '::warning::gitlint: no git identity here -- injected a throwaway one via GIT_CONFIG_* (needed only so gitlint can start)' + } + } + + # --- resolve the commit range --------------------------------------- + $range = '' + $cleanBase = "$BaseSha".Trim() + if ($cleanBase -match '^[0-9a-fA-F]{40}$' -and $cleanBase -notmatch '^0+$') { + $probe = "$cleanBase^{commit}" + git cat-file -e $probe 2>$null + if ($LASTEXITCODE -eq 0) { $range = "$cleanBase..$HeadSha" } + else { Write-Host "::warning::gitlint: base $cleanBase is not reachable in this clone -- falling back to the newest $MaxCommits commit(s)" } + } + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + if ($range) { + $shas = git log --no-merges --format=%H $range 2>$null + } + else { + $shas = git log --no-merges --format=%H -n $MaxCommits $HeadSha 2>$null + } + $ErrorActionPreference = $savedPref + + $shas = @($shas | Where-Object { $_ -and "$_".Trim() }) + if ($shas.Count -eq 0) { + Write-Host '::notice::gitlint: no non-merge commits in range -- nothing to check' + exit 0 + } + if ($shas.Count -gt $MaxCommits) { + Write-Host "::warning::gitlint: range has $($shas.Count) commits, checking the newest $MaxCommits" + $shas = $shas[0..($MaxCommits - 1)] + } + + # --- message files: inside the workspace, UTF-8 without BOM --------- + $msgDir = Join-Path $repoRoot 'ci-artifacts\gitlint' + New-Item -ItemType Directory -Force -Path $msgDir | Out-Null + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + + $checked = 0 + $failed = 0 + $gateBroken = $false + foreach ($sha in $shas) { + $sha = "$sha".Trim() + $checked++ + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $body = git log -1 --format=%B $sha 2>$null + $ErrorActionPreference = $savedPref + $titleLine = (git log -1 --format=%s $sha 2>$null | Select-Object -First 1) + + $msgPath = Join-Path $msgDir "$sha.txt" + $text = (($body | Out-String) -replace "`r`n", "`n").TrimEnd("`n") + [System.IO.File]::WriteAllText($msgPath, $text, $utf8NoBom) + + Write-Host "----------------------------------------" + Write-Host "commit $sha title: $titleLine" + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + # ForEach-Object { "$_" } unwraps the ErrorRecords that PS 5.1's + # 2>&1 builds for every native stderr line, without disturbing + # $LASTEXITCODE (docs/CI guide, section 7.3). + $hookOut = & $PythonExe -s -m pre_commit run --hook-stage commit-msg ` + --commit-msg-filename $msgPath gitlint 2>&1 | ForEach-Object { "$_" } + $rc = $LASTEXITCODE + $ErrorActionPreference = $savedPref + $hookOut | ForEach-Object { Write-Host $_ } + + if ($rc -eq 254 -or ($hookOut -match 'Missing git configuration')) { + # The gate itself could not run. Never report that as a + # message violation -- it is a runner/environment fault and + # needs a different fix (see the identity guard above). + Write-Host "::error::gitlint: GATE BROKEN (exit $rc, git config error) at $($sha.Substring(0, 8)) -- NOT a message violation" + $gateBroken = $true + } elseif ($rc -ne 0) { + $failed++ + Write-Host "::error::gitlint: commit $($sha.Substring(0, 8)) rejected -- `"$titleLine`"" + } + } + + $summary = "gitlint: $checked commit message(s) checked, $failed failed" + Write-Host "::notice::$summary" + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '### Commit message gate (gitlint)' -Encoding utf8 + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 + } + + if ($gateBroken) { + Write-Host '::error::commit message gate could not RUN -- fix the runner git identity; do NOT read this as a message violation' + exit 2 + } + if ($failed -gt 0) { + Write-Host '::error::commit message gate failed -- see the gitlint output above for the exact rule id (rules in .gitlint; AGENTS.md: title <= 100 chars, body lines <= 80)' + exit 1 + } + exit 0 + } + finally { + try { [Console]::OutputEncoding = $savedConsoleEncoding } catch { } + } +} +finally { + Pop-Location +} diff --git a/scripts/ci_local.py b/scripts/ci_local.py index a641604d..855c8697 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -8,9 +8,16 @@ 跑同一套判定,命令与退出码与 CI 一一对应。 覆盖范围(与 CI 完全一致): - L0 静态门禁 = `pre-commit run --all-files` + L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint) L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py` + ⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游** + 的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而 + `pre-commit run --all-files` 只跑 pre-commit stage —— 它既不在 --all-files 里, + CI 也一度漏掉(18 个钩子只报 17 个,且表现为"全绿")。本脚本与 CI 的 + `scripts/ci_gitlint_range.ps1` 同语义:逐条把最近 N 条非 merge 提交的 + message 喂给钩子。 + 刻意**不含**(与 CI 同理由,见 workflow 文件头): - `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线) - `mem-numbers` / `mem-budget` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map @@ -23,6 +30,7 @@ 汇总行(S3,2026-10-02,对齐 CI 的 ::notice::): [SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K + [SUMMARY] gitlint: N commit message(s) checked, M failed [SUMMARY] L0.5: K runner(s), A/B assertions passed, fail=F 用法(仓库根目录): @@ -30,6 +38,7 @@ & '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only & '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only & '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完 + & '.venv\\Scripts\\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验条数 设计依据:`.gitea/workflows/ci.yml`;docs/测试说明/测试开发标准流程.md §9 创建日期:2026-10-01 @@ -46,6 +55,30 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent +def _global_python_candidates(): + """常见**机器级** Python 安装位置(必须与 scripts/ci_find_python.ps1 同序)。 + + 两处探测一旦不同步,就会出现"本地能跑、CI 挂"的老毛病:2026-10-11 的 + L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者 + 都是死路),而真正装了 pre-commit 的机器级解释器(如 + C:\\ProgramData\\anaconda3\\python.exe)不再被探测。 + """ + out = [] + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LOCALAPPDATA") + pd = os.environ.get("ProgramData", r"C:\ProgramData") + # 新版本优先:新装的解释器通常才是想要的。 + for ver in ("Python313", "Python312", "Python311", "Python310"): + out.append(os.path.join(pf, ver, "python.exe")) + out.append(os.path.join(pf86, ver, "python.exe")) + if local: + out.append(os.path.join(local, "Programs", "Python", ver, "python.exe")) + for dist in ("anaconda3", "miniconda3"): + out.append(os.path.join(pd, dist, "python.exe")) + return out + + def _resolve_python(): r"""挑一个真正装了 pre-commit 的解释器。 @@ -56,6 +89,7 @@ def _resolve_python(): 2. 当前解释器(sys.executable) 3. 仓库 `.venv\Scripts\python.exe` 4. PATH 上的 `python` + 5. 常见机器级安装位置(见 _global_python_candidates) 找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。 """ explicit = os.environ.get("DTU_PRECOMMIT_PYTHON", "").strip() @@ -80,12 +114,14 @@ def _resolve_python(): sys.executable, str(REPO_ROOT / ".venv" / "Scripts" / "python.exe"), shutil.which("python"), - ] + ] + _global_python_candidates() seen = set() + tried = [] for py in candidates: if not py or py in seen or not os.path.isfile(py): continue seen.add(py) + tried.append(py) try: cp = subprocess.run([py, "-m", "pre_commit", "--version"], capture_output=True, timeout=60) @@ -94,6 +130,9 @@ def _resolve_python(): if cp.returncode == 0: note = None if py == sys.executable else "(自动选用,本解释器无 pre-commit)" return py, note + print("[WARN] 未找到装有 pre-commit 的解释器,已尝试:") + for py in tried: + print(" - {}".format(py)) return sys.executable, None @@ -173,6 +212,75 @@ def run_l0(): return rc +def run_gitlint(count): + """提交信息门禁:把最近 N 条非 merge 提交的 message 逐条喂给 gitlint 钩子。 + + 与 CI 的 scripts/ci_gitlint_range.ps1 同语义(gitlint 声明 + `stages: [commit-msg]`,`pre-commit run --all-files` 永远跑不到它)。 + 返回退出码(0/1/2)。 + """ + print("") + print("=" * 64) + print(" L0b 提交信息门禁(gitlint / commit-msg stage,最近 {} 条)".format(count)) + print("=" * 64) + + def _git(*args): + return subprocess.run(["git"] + list(args), cwd=str(REPO_ROOT), + capture_output=True, timeout=60) + + try: + out = _git("log", "--no-merges", "--format=%H", "-n", str(count), "HEAD") + except (OSError, subprocess.TimeoutExpired) as e: + print("[ERROR] 无法调用 git:{}".format(e)) + return 2 + if out.returncode != 0: + print("[ERROR] git log 失败:{}".format( + out.stderr.decode("utf-8", errors="replace").strip())) + return 2 + + shas = [s.strip() for s in out.stdout.decode("utf-8", errors="replace").splitlines() + if s.strip()] + if not shas: + print("[INFO] 无可校验提交 —— 跳过") + print("[SUMMARY] gitlint: 0 commit message(s) checked, 0 failed") + return 0 + + # 消息文件必须写在【仓库内】且【UTF-8 无 BOM】: + # - 跨盘符会被 pre-commit 直接拒绝(path is on mount 'C:', start on mount 'D:') + # - BOM 会占掉标题 1 个字符,扭曲 .gitlint 的 100 字符上限 + msg_dir = REPO_ROOT / "ci-artifacts" / "gitlint-local" + msg_dir.mkdir(parents=True, exist_ok=True) + + checked = 0 + failed = 0 + try: + for sha in shas: + checked += 1 + body = _git("log", "-1", "--format=%B", sha) + title = _git("log", "-1", "--format=%s", sha) + text = body.stdout.decode("utf-8", errors="replace").rstrip("\r\n") + title_line = title.stdout.decode("utf-8", errors="replace").strip() + + msg_path = msg_dir / (sha + ".txt") + msg_path.write_text(text, encoding="utf-8", newline="\n") + + print("") + print("--- {} {}".format(sha[:8], title_line)) + rc, _ = _run_capture([PY, "-m", "pre_commit", "run", + "--hook-stage", "commit-msg", + "--commit-msg-filename", str(msg_path), + "gitlint"]) + if rc != 0: + failed += 1 + print("[FAIL] gitlint 拒绝该提交信息:{}".format(title_line)) + finally: + shutil.rmtree(str(msg_dir), ignore_errors=True) + + print("[SUMMARY] gitlint: {} commit message(s) checked, {} failed".format( + checked, failed)) + return 1 if failed else 0 + + def run_l05(args): """L0.5:主机侧单测。返回退出码(0/1/2)。""" print("") @@ -238,6 +346,8 @@ def main(argv=None): g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测") ap.add_argument("--keep-going", action="store_true", help="某步失败也继续跑后续步骤(默认首错即停)") + ap.add_argument("--commits", type=int, default=10, metavar="N", + help="提交信息门禁(gitlint)校验最近 N 条非 merge 提交(默认 10)") args = ap.parse_args(argv) print("=== 本地 CI(等价 .gitea/workflows/ci.yml)===") @@ -266,10 +376,15 @@ def main(argv=None): try: if not args.l05_only: rc = run_l0() - if rc != 0 and not args.keep_going: - print("") - print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)") - return 1 + # 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段; + # 即使 L0 已失败也照跑,一次看全所有违规项。 + rc_msg = run_gitlint(args.commits) + if rc != 0 or rc_msg != 0: + if not args.keep_going: + print("") + print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)") + return 1 + rc = rc or rc_msg if not args.l0_only: rc05 = run_l05(args) rc = rc or rc05 diff --git a/scripts/ci_runner_bootstrap.ps1 b/scripts/ci_runner_bootstrap.ps1 new file mode 100644 index 00000000..253f20eb --- /dev/null +++ b/scripts/ci_runner_bootstrap.ps1 @@ -0,0 +1,534 @@ +<# +.SYNOPSIS + Make the Gitea Actions runner on this machine survive restarts, and give it + a toolchain the SYSTEM service account can actually see. + +.DESCRIPTION + Why this file exists (2026-10-11 CI audit, the "many jobs never ran" bug): + + Six consecutive runs (16..21, i.e. 12 jobs) produced ZERO log lines. The + runner `win-stm32-ci` was simply offline, and two independent causes made + that permanent: + + 1. NO SUPERVISION. gitea-runner v4.0.1 `daemon` has no service support + (v4 removed `daemon install`), and it EXITS when the first Declare + call fails. The runner log shows exactly that after a Gitea restart: + + level=error msg="fail to invoke Declare" error="unavailable: + dial tcp 127.0.0.1:3001: connectex: ... actively refused it." + Error: unavailable: dial tcp 127.0.0.1:3001 ... + + ...and the process is gone. So one Gitea restart (or one reboot) + silently kills CI until a human notices days later. Queued runs then + get cancelled by the NEXT push, because ci.yml sets + `concurrency.cancel-in-progress: true` -- which is why the symptom + looks like "tasks vanish" rather than "tasks fail". + + 2. NO MACHINE-LEVEL TOOLCHAIN. The runner service runs as SYSTEM. + pre-commit / cppcheck were only reachable through the interactive + user's PATH, and no machine-level DTU_* variable was ever set, so + even a live runner died in the L0 environment check. + + This script installs the missing supervision (a watchdog launcher + a + scheduled task) and, with -SetEnv, writes the machine-level DTU_* variables + by discovering the tools the same way CI does. + + Run elevated, from the repo: + + powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow + +.PARAMETER RunnerDir + Directory holding gitea-runner.exe / config.yaml / .runner. + +.PARAMETER GiteaUrl + Instance address the runner registered against (must match .runner's + "address"; the runner talks to it directly, not through the public URL). + +.PARAMETER TaskName + Scheduled-task name for the watchdog. + +.PARAMETER SetEnv + Discover and write MACHINE-level DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / + DTU_MSVC_CL. Existing values are preserved unless -Force. + +.PARAMETER StartNow + Start the task after registering it. + +.PARAMETER Status + Only report: task state, daemon process, Gitea reachability. + +.PARAMETER DryRun + Build and validate the action / trigger / settings objects and print what + WOULD be done, writing and registering nothing. This is also the only way + to verify this script without elevation. + +.PARAMETER Force + With -SetEnv, overwrite existing machine-level values. + +.OUTPUTS + Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated. + +.NOTES + Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +#> +[CmdletBinding()] +param( + [string]$RunnerDir = 'D:\Docker\act_runner', + [string]$GiteaUrl = 'http://127.0.0.1:3001', + [string]$TaskName = 'GiteaActionsRunner', + [switch]$SetEnv, + [switch]$StartNow, + [switch]$Status, + [switch]$DryRun, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Info($m) { Write-Host "[INFO] $m" -ForegroundColor Cyan } +function Ok($m) { Write-Host "[ OK ] $m" -ForegroundColor Green } +function Warn($m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } +function Fail($m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +function Test-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal($id) + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-Gitea([string]$url) { + try { + Invoke-WebRequest -Uri "$url/api/v1/version" -UseBasicParsing -TimeoutSec 10 | Out-Null + return $true + } + catch { return $false } +} + +function Get-MachineEnv([string]$name) { + $v = [Environment]::GetEnvironmentVariable($name, 'Machine') + if ([string]::IsNullOrWhiteSpace($v)) { return $null } + return $v +} + +function Set-MachineEnv([string]$name, [string]$value, [string]$label) { + $cur = Get-MachineEnv $name + if ($cur) { + if ($cur -ieq $value) { Ok "$label already set: $cur"; return } + if (-not $Force) { + Warn "$label already set to a different value: $cur (use -Force to overwrite)" + return + } + Warn "$label overwriting $cur -> $value" + } + [Environment]::SetEnvironmentVariable($name, $value, 'Machine') + Ok "$label set (MACHINE scope): $value" +} + +function Find-Cppcheck { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + foreach ($c in @((Join-Path $pf 'Cppcheck\cppcheck.exe'), + (Join-Path $pf86 'Cppcheck\cppcheck.exe'))) { + if (Test-Path -LiteralPath $c) { return $c } + } + $cmd = Get-Command cppcheck -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return $null +} + +function Find-MsvcCl { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + $roots = @() + foreach ($year in @('2019', '2022', '2026', '18')) { + foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { + $roots += (Join-Path $pf86 "Microsoft Visual Studio\$year\$edition") + $roots += (Join-Path $pf "Microsoft Visual Studio\$year\$edition") + } + } + foreach ($r in $roots) { + if (-not (Test-Path -LiteralPath $r)) { continue } + $cl = Get-ChildItem -LiteralPath $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*Hostx64\x64*' } | + Select-Object -First 1 + if ($cl) { return $cl.FullName } + } + return $null +} + +# Builds the task objects in ONE place so -DryRun validates exactly what the +# real run registers. +# +# MEASURED TRAP (2026-10-11, hit on the user's machine): `StartWhenAvailable` +# is a TASK SETTINGS property (MSFT_TaskSettings, inherited by +# MSFT_TaskSettings3) -- it does NOT exist on triggers. Assigning it to the +# trigger aborts the script with a SetValueInvocationException along the lines +# of "setting StartWhenAvailable threw an exception: property +# 'StartWhenAvailable' is not found on this object" (message localized), +# and, worse, that happened AFTER the old task had already been unregistered, +# leaving CI with no task at all. Hence: build everything first, only then +# unregister the old task. +function New-WatchdogTaskPlan { + param([string]$Launcher, [string]$WorkDir) + + $action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`"" ` + -WorkingDirectory $WorkDir + + # Boot trigger + a 1 minute grace period: Gitea may still be starting when + # the session-0 boot fires (and the watchdog waits for it anyway). + $trigger = New-ScheduledTaskTrigger -AtStartup + $trigger.Delay = 'PT1M' + + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -MultipleInstances IgnoreNew + # StartWhenAvailable also covers "the machine was off when this was missed". + $settings.StartWhenAvailable = $true + + return [pscustomobject]@{ Action = $action; Trigger = $trigger; Settings = $settings } +} + +# --------------------------------------------------------------------------- +# -Status: read-only report +# --------------------------------------------------------------------------- +if ($Status) { + Info "task $TaskName" + $t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($t) { + Ok ("task state: {0}" -f $t.State) + $li = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue + if ($li) { + Info ("last run: {0} | last result: {1} | next run: {2}" -f ` + $li.LastRunTime, ('0x{0:X}' -f $li.LastTaskResult), $li.NextRunTime) + } + } + else { + Warn "task $TaskName not found" + if (-not (Test-Admin)) { + Warn ' this session is NOT elevated: a non-admin session cannot always see' + Warn ' every task, so re-run -Status elevated before concluding it is missing' + } + } + + $proc = @(Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' }) + if ($proc.Count -eq 1) { + Ok ("daemon running: pid {0}" -f $proc[0].Id) + } elseif ($proc.Count -gt 1) { + Warn (" {0} daemon processes -- only ONE can hold .runner.lock, the rest are in a restart loop" -f $proc.Count) + Warn ' fix: re-run this script elevated with -StartNow (it clears stale instances first)' + } else { Warn 'daemon not running' } + + # A watchdog that cannot get the lock fails every 10s while the runner still + # reports ONLINE, so the loop is invisible from the web UI. Count watchdogs and + # grep the daemon log for the lock error (measured 2026-10-12). + # MEASURED TRAP #3 (2026-10-12): a NON-ELEVATED session cannot read SYSTEM + # process command lines -- CommandLine comes back $null, so the filter below + # silently matches NOTHING and reporting "no watchdog" would be a lie (this + # bit us twice while diagnosing). Say what we cannot see instead. + if (-not (Test-Admin)) { + Info ' watchdog count skipped: needs elevation (SYSTEM command lines are hidden)' + } else { + $wd = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' }) + if ($wd.Count -gt 1) { + Warn (" {0} watchdog processes (pids {1}) -- an old one survived a task re-registration" -f ` + $wd.Count, (($wd | ForEach-Object { $_.ProcessId }) -join ',')) + Warn ' and is racing the new one for .runner.lock; re-run elevated with -StartNow' + } elseif ($wd.Count -eq 1) { + Ok ("watchdog running: pid {0}" -f $wd[0].ProcessId) + } else { + Warn 'no watchdog process found (task not running?)' + } + } + # Restart-loop detection. Do NOT just grep the log tail: the file is never + # rotated, so old failures stay in the last lines forever and a HEALTHY runner + # keeps "reporting" a stale error (measured false positive 2026-10-12). The + # reliable signal is a daemon that keeps dying young -- a process alive longer + # than the 10s restart interval cannot be inside that loop. CIM CreationDate is + # readable without elevation; Process.StartTime is NOT for SYSTEM processes. + $dmon = Get-CimInstance Win32_Process -Filter "Name='gitea-runner.exe'" -ErrorAction SilentlyContinue | + Select-Object -First 1 + $upSec = if ($dmon) { [int]((Get-Date) - $dmon.CreationDate).TotalSeconds } else { -1 } + if ($upSec -ge 90) { + Ok ("daemon up for {0} min -- no restart loop" -f [int]($upSec / 60)) + } else { + $dlog = Join-Path $RunnerDir 'log\runner-daemon.log' + if (Test-Path -LiteralPath $dlog) { + $tail = Get-Content -LiteralPath $dlog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue + if ($tail -match 'already locked by another process') { + Warn ' daemon keeps dying young and the log shows ".runner is already locked' + Warn ' by another process" -- a stale instance holds the lock; re-run with -StartNow' + } + } + } + + if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" } + else { Fail "Gitea NOT reachable at $GiteaUrl" } + + foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL')) { + $v = Get-MachineEnv $n + if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) } + else { Warn "MACHINE $n not set (CI auto-discovery still applies; see scripts/ci_find_python.ps1)" } + } + exit 0 +} + +# --------------------------------------------------------------------------- +# Preconditions +# --------------------------------------------------------------------------- +if (-not $DryRun -and -not (Test-Admin)) { + Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)' + Fail 'use -DryRun to validate the whole plan (action/trigger/settings) without elevation' + exit 2 +} + +$exe = Join-Path $RunnerDir 'gitea-runner.exe' +$cfg = Join-Path $RunnerDir 'config.yaml' +$regFile = Join-Path $RunnerDir '.runner' +$launcher = Join-Path $RunnerDir 'run_runner_watchdog.ps1' + +foreach ($f in @($exe, $cfg)) { + if (-not (Test-Path -LiteralPath $f)) { Fail "missing file: $f"; exit 1 } +} +if (-not (Test-Path -LiteralPath $regFile)) { + Fail "not registered: $regFile is missing" + Info 'register first, e.g.:' + Write-Host " & '$exe' register --no-interactive --instance '$GiteaUrl' --token --name win-stm32-ci --labels 'windows:host'" + exit 1 +} + +# The daemon must talk to the address baked into .runner, not the public URL. +$registeredAddress = $null +try { + $registeredAddress = (Get-Content -LiteralPath $regFile -Raw | ConvertFrom-Json).address +} +catch { Warn "cannot parse $regFile : $_" } +if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEnd('/')) { + Warn ".runner address is $registeredAddress but -GiteaUrl is $GiteaUrl -- using the .runner value for the readiness probe" + $GiteaUrl = $registeredAddress +} + +# --------------------------------------------------------------------------- +# 0. build the task plan up front; -DryRun stops here without writing anything +# --------------------------------------------------------------------------- +$plan = New-WatchdogTaskPlan -Launcher $launcher -WorkDir $RunnerDir + +if ($DryRun) { + Info 'dry run -- writing and registering NOTHING' + Info ("would write launcher : {0}" -f $launcher) + Info ("would run : {0} {1}" -f $plan.Action.Execute, $plan.Action.Arguments) + Info ("would trigger : {0} delay={1}" -f $plan.Trigger.CimClass.CimClassName, $plan.Trigger.Delay) + Info ("would settings : StartWhenAvailable={0} RestartCount={1} RestartInterval={2} ExecTimeLimit={3} MultipleInstances={4}" -f ` + $plan.Settings.StartWhenAvailable, $plan.Settings.RestartCount, $plan.Settings.RestartInterval, + $plan.Settings.ExecutionTimeLimit, $plan.Settings.MultipleInstances) + Info 'would register as : user SYSTEM, RunLevel Highest' + if (Test-Admin) { Ok 'elevation: OK (admin)' } + else { Warn 'elevation: NOT admin -- the real run must be started from an elevated shell' } + if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" } + else { Warn "Gitea not reachable at $GiteaUrl -- the watchdog would keep waiting" } + exit 0 +} + +# --------------------------------------------------------------------------- +# 1. machine-level toolchain (SYSTEM cannot see the interactive user's PATH) +# --------------------------------------------------------------------------- +if ($SetEnv) { + Info 'discovering toolchain for MACHINE scope ...' + + $findPy = Join-Path $PSScriptRoot 'ci_find_python.ps1' + if (Test-Path -LiteralPath $findPy) { + $py = & $findPy -NeedPreCommit + if ($LASTEXITCODE -eq 0 -and $py) { + Set-MachineEnv 'DTU_PRECOMMIT_PYTHON' ("$py".Trim()) 'DTU_PRECOMMIT_PYTHON' + } + else { + Warn 'no interpreter with pre-commit found -- install pre-commit into a machine-wide interpreter first' + } + } + else { Warn "not found: $findPy (skipping DTU_PRECOMMIT_PYTHON)" } + + $cpp = Find-Cppcheck + if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' } + else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' } + + $cl = Find-MsvcCl + if ($cl) { Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' } + else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' } + + Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below' +} + +# --------------------------------------------------------------------------- +# 2. watchdog launcher (the actual fix for "daemon exits and nobody notices") +# --------------------------------------------------------------------------- +Info "writing watchdog launcher: $launcher" + +$template = @' +# AUTO-GENERATED by scripts/ci_runner_bootstrap.ps1 -- do not edit by hand. +# +# gitea-runner `daemon` exits when its first Declare call fails (no retry, no +# service support in v4), so a plain scheduled task is NOT enough: a Gitea +# restart would take CI down until a human noticed. This loop +# 1) waits until Gitea answers, then +# 2) runs the daemon, and +# 3) restarts it after a short pause whenever it exits. +# Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +$ErrorActionPreference = 'Continue' + +$runnerDir = '__RUNNER_DIR__' +$runnerExe = Join-Path $runnerDir 'gitea-runner.exe' +$runnerCfg = Join-Path $runnerDir 'config.yaml' +$logDir = Join-Path $runnerDir 'log' +$log = Join-Path $logDir 'runner-watchdog.log' +$daemonLog = Join-Path $logDir 'runner-daemon.log' +$probeUrl = '__GITEA_URL__/api/v1/version' +$maxWaitSec = 1800 + +if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null +} + +function Log($m) { + $line = '{0} [watchdog] {1}' -f (Get-Date -Format 'o'), $m + Add-Content -LiteralPath $log -Value $line -Encoding ascii +} + +while ($true) { + $waited = 0 + while ($true) { + $ok = $false + try { + Invoke-WebRequest -Uri $probeUrl -UseBasicParsing -TimeoutSec 10 | Out-Null + $ok = $true + } + catch { $ok = $false } + if ($ok) { break } + if ($waited -eq 0) { Log "waiting for Gitea at $probeUrl" } + Start-Sleep -Seconds 15 + $waited += 15 + if ($waited -ge $maxWaitSec) { + Log 'Gitea still not answering after 30m -- starting anyway so the real error lands in the log' + break + } + } + + Log 'starting daemon' + # MEASURED TRAP (2026-10-12, hit by this very script): in PowerShell 5.1 the + # *>> redirection is implemented with Out-File, whose DEFAULT ENCODING IS + # UTF-16LE. Appending that into a file whose other lines come from + # Add-Content -Encoding ascii leaves ONE FILE WITH TWO ENCODINGS, and the + # daemon lines read back as "t i m e = 2 0 2 6 ..." (a NUL after every byte). + # Never rely on a redirection's default encoding -- state it explicitly. + # utf8 (not ascii) because job/commit text can legitimately be non-ASCII. + # MEASURED TRAP #2 (2026-10-12): the daemon writes its log to STDERR, and in + # PS 5.1 a native `2>&1` merge wraps every stderr line into an ErrorRecord. + # Out-File then renders "gitea-runner.exe : " plus a 4-line CategoryInfo + # block -- measured: a 2-line message became 8 lines, so 3 of every 4 lines in + # the daemon log were PowerShell noise, in the very file the docs tell people + # to read first. Stringifying each pipeline item keeps the text and drops the + # decoration (ErrorRecord.ToString() IS the original stderr line) and leaves + # $LASTEXITCODE intact. Verified against a cmd.exe wrapper; same result, and + # this form avoids cmd's quote-stripping rules for spaced paths. + & $runnerExe daemon --config $runnerCfg 2>&1 | + ForEach-Object { "$_" } | + Out-File -FilePath $daemonLog -Append -Encoding utf8 + Log ("daemon exited, exit code {0}; restarting in 10s" -f $LASTEXITCODE) + Start-Sleep -Seconds 10 +} +'@ + +$content = $template.Replace('__RUNNER_DIR__', $RunnerDir).Replace('__GITEA_URL__', $GiteaUrl.TrimEnd('/')) +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($launcher, $content, $utf8NoBom) +Ok 'launcher written' + +# --------------------------------------------------------------------------- +# 3. scheduled task: start at boot, run as SYSTEM, restart if it dies +# --------------------------------------------------------------------------- +$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +if ($existing) { + Info "task $TaskName exists -- unregistering to re-register with the current settings" + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false +} + +try { + Register-ScheduledTask -TaskName $TaskName -Action $plan.Action -Trigger $plan.Trigger ` + -Settings $plan.Settings -User 'SYSTEM' -RunLevel Highest ` + -Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises the gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' | + Out-Null +} +catch { + Fail "Register-ScheduledTask failed: $($_.Exception.Message)" + if ($existing) { + Fail "the previous task was ALREADY removed -- fix the cause and re-run this script immediately" + } + Fail 'until this succeeds the runner has no auto-start and CI stays dead' + exit 1 +} +Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)" + +# --------------------------------------------------------------------------- +# 4. start it -- but clear stale instances first, or the new watchdog never gets +# the .runner lock. +# +# MEASURED TRAP (2026-10-12): Unregister-ScheduledTask does NOT stop the process +# the old instance had started. That orphaned watchdog keeps its daemon alive and +# the daemon holds .runner.lock, so the freshly started watchdog fails every 10s: +# level=error msg="another gitea-runner process is already using \".runner\"..." +# Error: runner file is already locked by another process +# The runner still shows ONLINE (the stale daemon serves fine), so CI looks healthy +# while the log fills with a restart loop and the task supervises nothing at all. +# --------------------------------------------------------------------------- +function Stop-StaleRunnerInstances { + # Supervisors first: killing the daemon alone lets a live watchdog respawn it + # within 10s and the race starts over. + $killed = 0 + Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' } | + ForEach-Object { + Info (" stopping stale watchdog pid {0}" -f $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + $killed++ + } + Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue | ForEach-Object { + Info (" stopping stale daemon pid {0} (releases .runner.lock)" -f $_.Id) + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + $killed++ + } + return $killed +} + +if ($StartNow) { + if (-not (Test-Gitea $GiteaUrl)) { + Warn "Gitea is not answering at $GiteaUrl right now -- the watchdog will keep waiting" + } + $stale = Stop-StaleRunnerInstances + if ($stale -gt 0) { + Warn "$stale stale process(es) stopped; the runner is offline for a few seconds" + Start-Sleep -Seconds 2 + } + Start-ScheduledTask -TaskName $TaskName + Start-Sleep -Seconds 12 # the watchdog probes Gitea, then execs the daemon + $d = @(Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue) + if ($d.Count -eq 1) { + Ok ("daemon up: pid {0} (single instance, holds .runner.lock)" -f $d[0].Id) + } elseif ($d.Count -eq 0) { + Warn 'no daemon yet -- the watchdog may still be waiting for Gitea; check the watchdog log' + } else { + Warn ("{0} daemons running -- only one can hold the lock; inspect and kill the extras" -f $d.Count) + } + Ok "daemon log: Get-Content '$(Join-Path $RunnerDir 'log\runner-daemon.log')' -Tail 20" + Ok "watchdog log: Get-Content '$(Join-Path $RunnerDir 'log\runner-watchdog.log')' -Tail 20" + Info 'verify on the web UI: repo -> Settings -> Actions -> Runners -> win-stm32-ci should turn Idle' +} + +Ok 'done' +exit 0 diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 3a6276e7..9a1e1990 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -1,2 +1,2 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi -2026-10-12 205516 18044 1036 152484 223560 21.3 96096 73.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=40960;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;console_log.o=4120;ch395f.o=4106;default_task.o=2736;sys_log_mcu.o=1924 +2026-10-12 207636 18300 1080 153120 225936 21.5 96776 73.8 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=40960;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;console_log.o=4120;ch395f.o=4106;default_task.o=2736;sys_log_mcu.o=1924 diff --git a/test/host/freq/freq_test.c b/test/host/freq/freq_test.c new file mode 100644 index 00000000..75232ab9 --- /dev/null +++ b/test/host/freq/freq_test.c @@ -0,0 +1,374 @@ +/* + * 模块名称:频率测量层(L2 派生量,B8)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/freq.c + phasor.c + sample_ring.c 做离线验证。 + * + * 被测对象:真实源文件(#include 原样纳入,纯算法无需桩): + * ../../../App/algo/sample_ring.c + * ../../../App/algo/phasor.c + * ../../../App/algo/freq.c + * + * 精度判据(与 App/algo/README.md §3.7 同口径,规划 §4.2 测频范围的落实): + * J1 纯音扫频 45.00~55.00 Hz(0.5 Hz 步进):|f_meas - f_true| <= 0.02 Hz + * (实测最坏 0.0101 Hz @55.0 Hz,判据留 2 倍裕度) + * J2 噪声工况(幅值 1000 码、噪声 rms = 1% 幅值):|f_meas - f_true| <= 0.10 Hz + * (实测最坏 0.016 Hz,判据留 ~6 倍裕度) + * J3 启动收敛:首个有效相量后 <= 40 ms 出有效读数 + * J4 频率阶跃 50->51 Hz:<= 120 ms 进入 ±0.05 Hz 并保持(自适应窗 + * K_eff≈73~89 拍,阶跃后窗冲刷决定物理下限;OF/UF 定时限 >=100 ms + * 叠加其上,总动作时间仍 <220 ms,符合定时限保护常规) + * J5 零幅/浮空(rms < 幅值门):valid=0、读数保持、不发散(无 NaN/无跳变; + * 信号消失过渡段 <=20 ms 窗内残窗参与均值,读数漂移有界 <=0.3 Hz) + * J6 假值钳制:相位/幅值跳变拍产生的单拍折算越出 35~65 Hz 假值带 -> 丢弃, + * 读数在跳变后 60 ms 内回到 ±0.05 Hz + * + * 覆盖点: + * TC-FR-001 50 Hz 收敛时序(J3)+ 稳态精度(J1 子集) + * TC-FR-002 扫频 45~55 Hz @0.5 Hz(J1,逐点判 + 最坏值打印) + * TC-FR-003 噪声鲁棒(J2,最坏值打印) + * TC-FR-004 频率阶跃 50->51(J4) + * TC-FR-005 零幅浮空不发散(J5)+ 恢复后重新收敛 + * TC-FR-006 直流偏置抑制(全周傅氏滤直流 -> 测频不受影响) + * TC-FR-007 3/5 次谐波污染抑制(相量基波选择性) + * TC-FR-008 相位跳变假值钳制(J6) + * TC-FR-009 UA 无幅值 -> 自动落 IA 源 + * TC-FR-010 init 幂等 / get 参数守卫 / 未收敛 -2 + * TC-FR-011 幅值门中途失效 -> valid 清零 + 读数保持(J5 中途版) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-12(B8) + */ + +#include +#include +#include + +#define FR_PI 3.14159265358979323846 +#define FR_FS 1000.0 + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码 ===================== */ +#include "sample_ring.c" +#include "phasor.c" +#include "freq.c" + +/* ===================== 波形驱动 ===================== */ + +/* 全局采样时刻(拍):驱动函数按此生成"当前时刻"的样本,跨调用连续 */ +static uint32_t s_t = 0u; + +/* 确定性伪随机(LCG):噪声可复现 */ +static uint32_t s_rng = 0x12345678u; +static double s_noise_unit(void) { /* [-0.5, 0.5) */ + s_rng = s_rng * 1103515245u + 12345u; + return (double)((s_rng >> 8) & 0xFFFFFFu) / (double)0x1000000u - 0.5; +} + +/* + * 函数功能:合成一拍整帧并跑一遍 L2+测频链 + * 入口参数:f - 信号频率(Hz) + * amp - 基波峰值(码值) + * dc - 直流偏置(码值) + * h3, h5 - 3/5 次谐波峰值(码值) + * noise_rms- 高斯样近似噪声 rms(码值,均匀分布近似) + * ua_en - UA 通道是否带波形(0 = 只挂 0 码,测 IA 回退) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:9 路语义通道同波形(除 ua_en=0 时 UA 置 0);每拍 + * push_frame -> phasor_run -> freq_run,与 adcTask 同序。 + */ +static void drive_beat(double f, double amp, double dc, double h3, double h5, + double noise_rms, uint8_t ua_en) { + float frame[ADC_SEM_COUNT]; + double t = (double)s_t / FR_FS; + double v = amp * sin(2.0 * FR_PI * f * t) + + h3 * sin(2.0 * FR_PI * 3.0 * f * t) + + h5 * sin(2.0 * FR_PI * 5.0 * f * t) + + dc + + s_noise_unit() * noise_rms * 1.7320508; /* 均匀 -> rms 折算 */ + uint8_t c; + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + frame[c] = (float)v; + } + if (ua_en == 0u) { + frame[ADC_SEM_UA] = 0.0f; /* UA 无波形(门失败),IA 仍有 */ + } + if (f == 0.0) { + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + frame[c] = 0.0f; /* 浮空:全 0 帧 */ + } + } + (void)sample_ring_push_frame(frame, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + freq_run(); + s_t++; +} + +/* + * 函数功能:连续驱动 n 拍 + */ +static void drive_n(uint32_t n, double f, double amp, double dc, double h3, + double h5, double noise_rms, uint8_t ua_en) { + uint32_t i; + + for (i = 0u; i < n; i++) { + drive_beat(f, amp, dc, h3, h5, noise_rms, ua_en); + } +} + +/* + * 函数功能:复位整条链(采样时刻 + 三层状态) + */ +static void chain_reset(void) { + s_t = 0u; + s_rng = 0x12345678u; + (void)phasor_init(); /* 连带 sample_ring_init */ + (void)freq_init(); +} + +/* ===================== TC-FR-001:50 Hz 收敛时序 + 稳态精度 ===================== */ +static void tc_converge_50(void) { + float f = 0.0f; + uint32_t first_valid_t = 0u, phasor_ready_t = 0u; + uint32_t i; + int got = 0; + + chain_reset(); + for (i = 0u; i < 200u; i++) { + drive_beat(50.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + if (phasor_ready_t == 0u) { + phasor_t p; + if (phasor_get(1u, &p) == 0) { + phasor_ready_t = i; /* 首个有效相量拍 */ + } + } + if (!got && (freq_valid() != 0u) && (freq_get(&f) == 0)) { + first_valid_t = i; + got = 1; + } + } + CHECK(got, "TC-FR-001 freq becomes valid at 50 Hz"); + CHECK((first_valid_t >= phasor_ready_t) + && ((first_valid_t - phasor_ready_t) <= 40u), + "TC-FR-001 J3: first valid reading <= 40 ms after first phasor"); + (void)freq_get(&f); + printf(" [DATA] 50 Hz steady: f=%.4f err=%.4f Hz (first valid @%u ms, phasor @%u ms)\n", + (double)f, (double)f - 50.0, (unsigned)first_valid_t, (unsigned)phasor_ready_t); + CHECK(fabsf(f - 50.0f) <= 0.01f, "TC-FR-001 J1(50Hz): |err| <= 0.01 Hz"); +} + +/* ===================== TC-FR-002:扫频 45~55 Hz @0.5 Hz(J1) ===================== */ +static void tc_sweep(void) { + double worst = 0.0, worst_f = 0.0; + int ok = 1; + int k; + + for (k = 0; k <= 20; k++) { + double f_true = 45.0 + 0.5 * (double)k; + float f_meas = 0.0f; + double err; + + chain_reset(); + drive_n(150u, f_true, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + CHECK(freq_get(&f_meas) == 0, "TC-FR-002 freq valid at each sweep point"); + err = fabs((double)f_meas - f_true); + if (err > worst) { worst = err; worst_f = f_true; } + if (err > 0.02) { ok = 0; } + } + printf(" [DATA] sweep 45.00~55.00 Hz step 0.5: worst |err| = %.4f Hz @ %.1f Hz (J1 <= 0.02)\n", + worst, worst_f); + CHECK(ok, "TC-FR-002 J1: |f_meas - f_true| <= 0.02 Hz over 45~55 Hz"); +} + +/* ===================== TC-FR-003:噪声鲁棒(J2) ===================== */ +static void tc_noise(void) { + double worst = 0.0; + int ok = 1; + int k; + + for (k = 0; k < 5; k++) { + double f_true = 45.0 + 2.5 * (double)k; /* 45/47.5/50/52.5/55 */ + float f_meas = 0.0f; + double err; + + chain_reset(); + drive_n(200u, f_true, 1000.0, 0.0, 0.0, 0.0, 10.0, 1u); /* rms = 1% 幅值 */ + (void)freq_get(&f_meas); + err = fabs((double)f_meas - f_true); + if (err > worst) { worst = err; } + if (err > 0.10) { ok = 0; } + } + printf(" [DATA] noise rms=1%% amp: worst |err| = %.4f Hz (J2 <= 0.10)\n", worst); + CHECK(ok, "TC-FR-003 J2: |err| <= 0.10 Hz with 1% noise"); +} + +/* ===================== TC-FR-004:频率阶跃 50->51(J4) ===================== */ +static void tc_step(void) { + float f = 0.0f; + uint32_t settle = 0u; + uint32_t i; + int ok = 1; + + chain_reset(); + drive_n(150u, 50.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + (void)freq_get(&f); + printf(" [DATA] pre-step f=%.4f\n", (double)f); + + /* 阶跃拍 = t=150;判"进入 ±0.05 且保持" */ + for (i = 0u; i < 100u; i++) { + drive_beat(51.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + (void)freq_get(&f); + if (settle == 0u) { + if (fabsf(f - 51.0f) <= 0.05f) { + settle = i + 1u; /* 阶跃后第 N 拍进入带内 */ + } + } else if (fabsf(f - 51.0f) > 0.05f) { + ok = 0; /* 进入后又出带 */ + } + } + printf(" [DATA] step 50->51: settle = %u ms, final f=%.4f err=%.4f\n", + (unsigned)settle, (double)f, (double)f - 51.0); + CHECK((settle != 0u) && (settle <= 120u), "TC-FR-004 J4: settle <= 120 ms"); + CHECK(ok, "TC-FR-004 J4: stays within +/-0.05 Hz after settle"); + CHECK(fabsf(f - 51.0f) <= 0.01f, "TC-FR-004 J1(51Hz): steady |err| <= 0.01 Hz"); +} + +/* ===================== TC-FR-005:零幅浮空不发散(J5) ===================== */ +static void tc_zero_amp(void) { + float f = 123.0f; + uint32_t i; + int never_valid = 1, no_nan = 1; + + chain_reset(); + for (i = 0u; i < 500u; i++) { + drive_beat(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1u); /* 全 0 帧(浮空 rms=0) */ + if (freq_valid() != 0u) { never_valid = 0; } + (void)freq_get(&f); + if (!(f == f)) { no_nan = 0; } /* NaN 自比较为假 */ + } + CHECK(never_valid, "TC-FR-005 J5: zero-amplitude never asserts valid"); + CHECK(no_nan && (f == 0.0f), "TC-FR-005 J5: reading stays 0, no divergence/NaN"); + /* 恢复:重新注入波形后应再次收敛 */ + drive_n(150u, 49.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + (void)freq_get(&f); + printf(" [DATA] recover after float: f=%.4f err=%.4f\n", (double)f, (double)f - 49.0); + CHECK((freq_valid() != 0u) && (fabsf(f - 49.0f) <= 0.01f), + "TC-FR-005 recovers and meets J1 after waveform returns"); +} + +/* ===================== TC-FR-006:直流偏置抑制 ===================== */ +static void tc_dc_offset(void) { + float f = 0.0f; + + chain_reset(); + drive_n(150u, 50.3, 1000.0, 8000.0, 0.0, 0.0, 0.0, 1u); /* DC = 8000 码 */ + (void)freq_get(&f); + printf(" [DATA] DC=8000: f=%.4f err=%.4f\n", (double)f, (double)f - 50.3); + CHECK(fabsf(f - 50.3f) <= 0.01f, "TC-FR-006 DC offset rejected: |err| <= 0.01 Hz"); +} + +/* ===================== TC-FR-007:3/5 次谐波污染 ===================== */ +static void tc_harmonics(void) { + float f = 0.0f; + + chain_reset(); + drive_n(150u, 48.0, 1000.0, 0.0, 200.0, 100.0, 0.0, 1u); /* 20%/10% 谐波 */ + (void)freq_get(&f); + printf(" [DATA] h3=20%% h5=10%%: f=%.4f err=%.4f\n", (double)f, (double)f - 48.0); + CHECK(fabsf(f - 48.0f) <= 0.01f, "TC-FR-007 harmonics rejected: |err| <= 0.01 Hz"); +} + +/* ===================== TC-FR-008:相位跳变假值钳制(J6) ===================== */ +static void tc_phase_jump(void) { + float f = 0.0f; + uint32_t i; + int ok = 1; + + chain_reset(); + drive_n(150u, 50.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + /* 跳变:瞬时切到相位偏移 60 度的等价频率段——用 1 拍 55 Hz 高幅制造 + * 窗内畸变(f_inst 出带),随后回 50 Hz,判 60 ms 内回带 */ + drive_beat(55.0, 4000.0, 0.0, 0.0, 0.0, 0.0, 1u); /* 幅值/频率双跳变 */ + for (i = 0u; i < 60u; i++) { + drive_beat(50.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + (void)freq_get(&f); + if (i >= 59u) { + if (fabsf(f - 50.0f) > 0.05f) { ok = 0; } + } + } + printf(" [DATA] after jump: f=%.4f err=%.4f (recover window 60 ms)\n", + (double)f, (double)f - 50.0); + CHECK(ok, "TC-FR-008 J6: recovers to +/-0.05 Hz within 60 ms after jump"); + CHECK(freq_valid() != 0u, "TC-FR-008 J6: still valid after clamp path"); +} + +/* ===================== TC-FR-009:UA 无幅值 -> IA 回退 ===================== */ +static void tc_src_fallback(void) { + float f = 0.0f; + + chain_reset(); + drive_n(150u, 52.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 0u); /* UA 置 0,IA 带波形 */ + (void)freq_get(&f); + printf(" [DATA] UA=0 IA=1000: f=%.4f err=%.4f\n", (double)f, (double)f - 52.0); + CHECK((freq_valid() != 0u) && (fabsf(f - 52.0f) <= 0.01f), + "TC-FR-009 falls back to IA source when UA below amplitude gate"); +} + +/* ===================== TC-FR-010:init 幂等 / 参数守卫 ===================== */ +static void tc_init_guards(void) { + float f = 5.0f; + + chain_reset(); + CHECK(freq_get(&f) == -2, "TC-FR-010 get before convergence -> -2"); + CHECK((f == 0.0f), "TC-FR-010 get -2 writes 0 to out"); + CHECK(freq_get(NULL) == -1, "TC-FR-010 NULL out -> -1"); + CHECK(freq_valid() == 0u, "TC-FR-010 valid=0 before convergence"); + CHECK(freq_last() == 0.0f, "TC-FR-010 last=0 before convergence"); + (void)freq_init(); + CHECK(freq_get(&f) == -2, "TC-FR-010 freq_init idempotent (still -2)"); +} + +/* ===================== TC-FR-011:幅值门中途失效 -> 保持 ===================== */ +static void tc_gate_hold(void) { + float f_before = 0.0f, f_after = 0.0f; + + chain_reset(); + drive_n(150u, 50.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 1u); + (void)freq_get(&f_before); + CHECK(freq_valid() == 1u, "TC-FR-011 valid before gate loss"); + + drive_n(50u, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1u); /* 波形消失 */ + CHECK(freq_valid() == 0u, "TC-FR-011 J5: valid cleared on gate loss"); + (void)freq_get(&f_after); + printf(" [DATA] hold: before=%.4f held=%.4f delta=%.4f\n", + (double)f_before, (double)f_after, (double)(f_after - f_before)); + CHECK(fabsf(f_after - f_before) <= 0.3f, + "TC-FR-011 J5: reading bounded (|delta| <= 0.3 Hz) during gate loss"); +} + +int main(void) { + printf("=== Freq Tests (B8: phase-unwrap frequency measurement) ===\n"); + tc_converge_50(); + tc_sweep(); + tc_noise(); + tc_step(); + tc_zero_amp(); + tc_dc_offset(); + tc_harmonics(); + tc_phase_jump(); + tc_src_fallback(); + tc_init_guards(); + tc_gate_hold(); + + printf("\n=== Freq Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/freq/run_unit_test.py b/test/host/freq/run_unit_test.py new file mode 100644 index 00000000..b2360439 --- /dev/null +++ b/test/host/freq/run_unit_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""频率测量层(L2 派生量,B8)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么:用本机 MSVC 离线编译 test/host/freq/freq_test.c(#include 纳入生产 + 源文件 App/algo/freq.c + phasor.c + sample_ring.c),验证相位解缠绕 + 测频的精度判据 J1~J6(45~55 Hz 扫频 / 噪声 / 收敛 / 阶跃 / 浮空 / + 假值钳制 / 源回退),判据数字见 freq_test.c 文件头与 App/algo/README.md。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/freq/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.3(L3-5)、§4.2(测频范围)。 +创建日期:2026-10-12(B8) +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="Freq Tests", + src=os.path.join(HERE, "freq_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Freq Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/iec104/iec104_test.c b/test/host/iec104/iec104_test.c index 504f8f58..3464e93b 100644 --- a/test/host/iec104/iec104_test.c +++ b/test/host/iec104/iec104_test.c @@ -53,6 +53,7 @@ static int s_total = 0, s_passed = 0, s_failed = 0; #include "prot_set.c" #include "sample_ring.c" #include "phasor.c" +#include "freq.c" #include "prot_elm.c" #include "iec104_pts.c" #include "iec104_link.c" @@ -124,9 +125,16 @@ static int f_clock(void *ctx, uint64_t ms) s_clock_ms = ms; return s_clock_rc; } +/* B9 时标源假回调:s_epoch_val = UINT64_MAX → 时钟无效(CP56 IV) */ +static uint64_t s_epoch_val; +static uint64_t f_epoch(void *ctx) +{ + (void)ctx; + return s_epoch_val; +} static iec104_link_t g_lk; -static const iec104_link_cfg_t g_cfg = { 1u, cap_send, f_clock }; +static const iec104_link_cfg_t g_cfg = { 1u, cap_send, f_clock, f_epoch }; /* ---------------- 主站侧模拟(N(R) 单调,照真实主站) ---------------- */ static uint16_t m_ns; @@ -203,6 +211,7 @@ static void reset_all(uint32_t now) | (1uLL << 22) | (1uLL << 30); s_clock_rc = 0; s_clock_ms = 0u; + s_epoch_val = 1760234567000ULL; /* B9:固定时标(2025-10-12 UTC) */ iec104_link_init(&g_lk, &g_cfg, NULL); iec104_link_open(&g_lk, now); m_reset(); @@ -459,6 +468,7 @@ static void tc_cov_yx(void) { uint32_t now = 1000u; cap_t c; + uint64_t back; reset_all(now); handshake(now); @@ -466,16 +476,30 @@ static void tc_cov_yx(void) CHECK(cap_i_count() == 0, "TC-007 首 poll 无自发"); s_f_bits |= (1uLL << 15); /* YX16(idx15)动作 */ iec104_link_poll(&g_lk, now + 100u); - CHECK((cap_i_count() == 1) && (cap_i(0, &c) == 0) && (c.type == 1u) + /* B9:单点 YX 自发 = M_SP_TB_1(30)带 CP56Time2a 时标(SOE 上送路径) */ + CHECK((cap_i_count() == 1) && (cap_i(0, &c) == 0) && (c.type == 30u) && (c.cot == IEC104_COT_SPONT) && (c.ioa == 16u) - && (c.obj_len == 1u) && (c.obj[0] == 1u), "TC-007 YX16 变位 COT3"); + && (c.obj_len == 8u) && (c.obj[0] == 1u), "TC-007 YX16 变位 COT3 TB"); + if ((cap_i(0, &c) == 0) && (c.obj_len == 8u)) { + CHECK(iec104_cp56_to_epoch_ms(&c.obj[1], &back) == 0, + "TC-007 CP56 可解"); + CHECK(back == s_epoch_val, "TC-007 时标 = epoch_ms 源"); + } s_f_bits &= ~(1uLL << 15); /* 复归 */ iec104_link_poll(&g_lk, now + 200u); - CHECK((cap_i_count() == 2) && (cap_i(1, &c) == 0) && (c.ioa == 16u) - && (c.obj[0] == 0u), "TC-007 YX16 复归再变"); - s_f_bits |= (1uLL << 0); /* idx0 无效位 → 不触发 */ + CHECK((cap_i_count() == 2) && (cap_i(1, &c) == 0) && (c.type == 30u) + && (c.ioa == 16u) && (c.obj[0] == 0u), "TC-007 YX16 复归再变"); + /* B9 IV 路径:时标源无效 → CP56 IV 位如实置位 */ + s_epoch_val = UINT64_MAX; + s_f_bits |= (1uLL << 16); /* YX17(idx16)动作 */ iec104_link_poll(&g_lk, now + 300u); - CHECK(cap_i_count() == 2, "TC-007 无效位变位不触发"); + CHECK((cap_i_count() == 3) && (cap_i(2, &c) == 0) && (c.type == 30u) + && (c.ioa == 17u) && ((c.obj[3] & 0x80u) != 0u), + "TC-007 时钟无效 → CP56 IV 位"); + s_epoch_val = 1760234567000ULL; + s_f_bits |= (1uLL << 0); /* idx0 无效位 → 不触发 */ + iec104_link_poll(&g_lk, now + 400u); + CHECK(cap_i_count() == 3, "TC-007 无效位变位不触发"); } static void tc_cov_yc(void) diff --git a/test/host/iec104_master.py b/test/host/iec104_master.py index 168a0d14..f985ab2b 100644 --- a/test/host/iec104_master.py +++ b/test/host/iec104_master.py @@ -55,6 +55,7 @@ U_STARTDT_ACT, U_STARTDT_CON = 0x07, 0x0B U_TESTFR_ACT, U_TESTFR_CON = 0x43, 0x83 T_M_SP_NA_1, T_M_DP_NA_1, T_M_ME_NC_1 = 1, 3, 13 +T_M_SP_TB_1 = 30 # single point + CP56Time2a (B9 SOE spontaneous) T_C_SE_NC_1, T_C_DC_NA_1 = 50, 52 T_C_IC_NA_1, T_C_RD_NA_1 = 100, 102 @@ -109,6 +110,28 @@ def asdu(type_id, vsq, cot, cot_hi, ioa=None, payload=b""): return h + payload +def cp56_str(b): + """Decode CP56Time2a (7B) -> 'YYYY-MM-DD HH:MM:SS.mmm' (UTC) or 'IV-...'. + B9: M_SP_TB_1 spontaneous time tag (sec-resolution, ms field = sec*1000).""" + iv = (b[2] & 0x80) != 0 + ms_min = b[0] | (b[1] << 8) + minute = b[2] & 0x3F + hour = b[3] & 0x1F + day = b[4] & 0x1F + mon = b[5] & 0x0F + year = 2000 + (b[6] & 0x7F) + sec = ms_min // 1000 + ms = ms_min % 1000 + try: + import datetime + d = datetime.datetime(year, mon, day, hour, minute, sec, ms * 1000) + s = d.strftime("%Y-%m-%d %H:%M:%S") + (".%03d" % ms) + except ValueError: + s = "%04d-%02d-%02d %02d:%02d:%02d.%03d" % ( + year, mon, day, hour, minute, sec, ms) + return ("IV " if iv else "") + s + + class Master(object): """Minimal 104 master. k/w/t are covered by "S after every recv batch" (far faster than t1=15s / t2=10s), so no timers are needed here.""" @@ -208,26 +231,32 @@ class Master(object): n = vsq & 0x7F obj = a[6:] - if type_id in (T_M_SP_NA_1, T_M_DP_NA_1): - pts = [] + if type_id in (T_M_SP_NA_1, T_M_DP_NA_1, T_M_SP_TB_1): + # B9: M_SP_TB_1 (30) = SIQ + CP56Time2a (8B/point); NA forms = 1B. + esz = 8 if type_id == T_M_SP_TB_1 else 1 + pts = [] # (ioa, raw, elem_off_in_obj) if sq: base = get_ioa(obj) for i in range(n): - pts.append((base + i, obj[3 + i])) + pts.append((base + i, obj[3 + i * esz], 3 + i * esz)) else: off = 0 for _ in range(n): - pts.append((get_ioa(obj, off), obj[off + 3])) - off += 4 - for ioa, raw in pts: + pts.append((get_ioa(obj, off), obj[off + 3], off + 3)) + off += 3 + esz + for ioa, raw, eoff in pts: val = (raw & 0x03) if type_id == T_M_DP_NA_1 else (raw & 0x01) iv = (raw & 0x80) != 0 self.yx[ioa] = (val, iv) + ts = "" + if type_id == T_M_SP_TB_1: + ts = " ts=%s" % cp56_str(obj[eoff + 1:eoff + 8]) self.events.append(("yx", (ioa, val, iv, cot))) if ioa in YX_NAMES or cot == COT_SPONT: - log("RX I %s ioa=%d val=%d iv=%d cot=%d [N(R)=%d]" - % ("M_DP" if type_id == T_M_DP_NA_1 else "M_SP", - ioa, val, 1 if iv else 0, cot, nr)) + log("RX I %s ioa=%d val=%d iv=%d cot=%d%s [N(R)=%d]" + % ({T_M_DP_NA_1: "M_DP", T_M_SP_NA_1: "M_SP", + T_M_SP_TB_1: "M_SP_TB"}[type_id], + ioa, val, 1 if iv else 0, cot, ts, nr)) elif type_id == T_M_ME_NC_1: pts = [] if sq: diff --git a/test/host/prot_elm/prot_elm_test.c b/test/host/prot_elm/prot_elm_test.c index bd755cb4..325e7432 100644 --- a/test/host/prot_elm/prot_elm_test.c +++ b/test/host/prot_elm/prot_elm_test.c @@ -4,8 +4,9 @@ * * 被测对象:真实源文件(#include 原样纳入): * prot_elm.c + prot_set.c(判据吃定值 RAM) - * phasor.c + sample_ring.c(prot_elm.c 链接依赖;本测试只走 - * prot_elm_eval 纯判据路径,不依赖 run() 适配层) + * phasor.c + sample_ring.c + freq.c(prot_elm.c 链接依赖; + * TC-PE-001~010 走 prot_elm_eval 纯判据路径,TC-PE-013 走 + * run() 适配层 = 合成波形跑真实 L2+测频链) * * 覆盖点(docs/算法/继电保护功能复现规划.md §3.3 L3-1/3/4/6、§8 验收): * TC-PE-001 init:全状态清零、动作位图 0 @@ -19,10 +20,15 @@ * TC-PE-008 动作位图聚合 * TC-PE-009 守卫:state(NULL)/越界编号、name 越界 * TC-PE-010 跨上下文复归请求(B5):request 不当拍生效、下一拍 eval 头部消费 + * TC-PE-011 过频 OF(B8/L3-5):越限 + RET_OF 滞回 + 定时限 + * TC-PE-012 低频 UF(B8/L3-5):欠量方向 + RET_UF 滞回 + 方向隔离 + * TC-PE-013 run() 接线(B8):测频驱动 OF 动作;幅值门失效 OF 复位且 + * 相量类元件仍被评估(不整拍跳过) * * 通过:全部 TC PASS(main 返回 0)。 - * 创建日期:2026-10-11 + * 创建日期:2026-10-11(B5);2026-10-12 扩 OF/UF(B8) */ +#include #include #include @@ -37,9 +43,12 @@ static int s_total = 0, s_passed = 0, s_failed = 0; #include "prot_set.c" #include "sample_ring.c" #include "phasor.c" +#include "freq.c" #include "prot_elm.c" -/* 中性测量基线:电流 1.0 A、电压 100 V、3U0 0 V(全部不越限) */ +/* 中性测量基线:电流 1.0 A、电压 100 V、3U0 0 V、频率 50 Hz(全部不越限)。 + * OF/UF 填 50.0 = 标称:OF 定值范围下限 50.0(50>50 假)、UF 定值范围上限 + * 50.0(50<50 假)-> 两侧都不启动(定值范围约束保证,见 prot_set 表)。 */ static void mk_base(float m[PE_COUNT]) { uint32_t i; @@ -49,6 +58,8 @@ static void mk_base(float m[PE_COUNT]) { m[PE_OV] = 100.0f; m[PE_UV] = 100.0f; m[PE_3U0] = 0.0f; + m[PE_OF] = 50.0f; + m[PE_UF] = 50.0f; } static void tc_init_clear(void) { @@ -283,6 +294,154 @@ static void tc_request_reset(void) { "TC-PE-010 next eval consumes request -> cleared in hysteresis band"); } +/* ===================== OF/UF(L3-5,B8)===================== */ + +/* TC-PE-011:过频——越限 + RET_OF 滞回 + 定时限(判据核级) */ +static void tc_of_hysteresis(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OF_DLY, PROT_SET_Q10(100.0)); + /* 默认 OF_HZ=51.0,RET_OF=0.99 -> 返回门槛 = 51*0.99 = 50.49 */ + + mk_base(m); + m[PE_OF] = 50.9f; + prot_elm_eval(1000u, m); + prot_elm_state(PE_OF, &st); + CHECK((st.picked == 0u) && (st.enabled != 0u), + "TC-PE-011 OF 50.9 (<51): no pickup"); + + m[PE_OF] = 51.1f; + prot_elm_eval(1001u, m); + prot_elm_state(PE_OF, &st); + CHECK((st.picked != 0u) && (st.asserted == 0u), + "TC-PE-011 OF 51.1: pickup before delay"); + + prot_elm_eval(1100u, m); + prot_elm_state(PE_OF, &st); + CHECK(st.asserted == 0u, "TC-PE-011 OF not asserted at 99ms"); + + prot_elm_eval(1101u, m); + prot_elm_state(PE_OF, &st); + CHECK(st.asserted != 0u, "TC-PE-011 OF asserted at 100ms"); + + m[PE_OF] = 50.7f; /* 50.49 与 51 之间:滞回保持 */ + prot_elm_eval(1200u, m); + prot_elm_state(PE_OF, &st); + CHECK((st.picked != 0u) && (st.asserted != 0u), + "TC-PE-011 OF holds between return(50.49) and pickup(51)"); + + m[PE_OF] = 50.4f; /* 跌破返回门槛 */ + prot_elm_eval(1300u, m); + prot_elm_state(PE_OF, &st); + CHECK((st.picked == 0u) && (st.asserted == 0u), + "TC-PE-011 OF below return threshold: drops out"); +} + +/* TC-PE-012:低频——欠量方向 + RET_UF 滞回(与 OF 系数独立,勿混 0.95/1.05) */ +static void tc_uf_direction(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_UF_DLY, PROT_SET_Q10(50.0)); + /* 默认 UF_HZ=49.0,RET_UF=1.01 -> 返回门槛 = 49*1.01 = 49.49 */ + + mk_base(m); + m[PE_UF] = 49.1f; + prot_elm_eval(2000u, m); + prot_elm_state(PE_UF, &st); + CHECK(st.picked == 0u, "TC-PE-012 UF 49.1 (>49): no pickup"); + + m[PE_UF] = 48.9f; + prot_elm_eval(2001u, m); + prot_elm_state(PE_UF, &st); + CHECK(st.picked != 0u, "TC-PE-012 UF 48.9 (<49): pickup"); + + prot_elm_eval(2051u, m); + prot_elm_state(PE_UF, &st); + CHECK(st.asserted != 0u, "TC-PE-012 UF asserted after 50ms"); + + m[PE_UF] = 49.3f; /* 49 与 49.49 之间:保持 */ + prot_elm_eval(2100u, m); + prot_elm_state(PE_UF, &st); + CHECK(st.asserted != 0u, "TC-PE-012 UF holds below return threshold"); + + m[PE_UF] = 49.6f; /* 高于返回门槛:释放 */ + prot_elm_eval(2200u, m); + prot_elm_state(PE_UF, &st); + CHECK((st.picked == 0u) && (st.asserted == 0u), + "TC-PE-012 UF above return threshold: drops out"); + + /* OF 不得被低频误启动(方向隔离);UF 不得被过频误启动 */ + m[PE_OF] = 52.0f; + m[PE_UF] = 52.0f; + prot_elm_eval(2300u, m); + prot_elm_state(PE_OF, &st); + CHECK(st.picked != 0u, "TC-PE-012 OF picks at 52 (over direction)"); + prot_elm_state(PE_UF, &st); + CHECK(st.picked == 0u, "TC-PE-012 UF NOT picked at 52 (direction isolation)"); +} + +/* ---- run() 适配层驱动:合成波形跑真实 L2+测频链(同 freq_test 口径) ---- */ +static uint32_t s_t = 0u; + +static void drive_beat(double f, double amp) { + float frame[ADC_SEM_COUNT]; + double v = (f == 0.0) ? 0.0 : amp * sin(2.0 * 3.14159265358979 * f * (double)s_t / 1000.0); + uint8_t c; + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + frame[c] = (float)v; + } + (void)sample_ring_push_frame(frame, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + freq_run(); + s_t++; +} + +/* TC-PE-013:run() 级——freq 有效读数驱动 OF 动作;幅值门失效后 OF 复位 + * 且相量类元件仍被评估(不整拍跳过,B5 行为不回归) */ +static void tc_run_freq_wiring(void) { + prot_elm_state_t st; + uint32_t i; + + s_t = 0u; + (void)phasor_init(); + (void)freq_init(); + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OF_DLY, PROT_SET_Q10(0.0)); /* 启动同拍动作 */ + (void)prot_set_set(PS_UV_DLY, PROT_SET_Q10(0.0)); /* 观察相量类仍评估 */ + + /* 52.5 Hz(> OF 51)驱动到测频收敛(20 拍启动 + 窗)+ 判据拍 */ + for (i = 0u; i < 140u; i++) { + drive_beat(52.5, 1000.0); + prot_elm_run(i); + } + prot_elm_state(PE_OF, &st); + CHECK((st.picked != 0u) && (st.asserted != 0u), + "TC-PE-013 run(): OF asserted from measured 52.5 Hz"); + prot_elm_state(PE_UF, &st); + CHECK(st.picked == 0u, "TC-PE-013 run(): UF clear at 52.5 Hz"); + + /* 波形消失(浮空):freq 幅值门失效 -> OF 按方向填充复位; + * 同时相量 rms=0 -> UV(定值 20,dly=0)启动 = 证明未整拍跳过 */ + for (i = 0u; i < 60u; i++) { + drive_beat(0.0, 0.0); + prot_elm_run(1000u + i); + } + prot_elm_state(PE_OF, &st); + CHECK((st.picked == 0u) && (st.asserted == 0u), + "TC-PE-013 run(): OF resets when freq source loses amplitude"); + prot_elm_state(PE_UV, &st); + CHECK(st.picked != 0u, + "TC-PE-013 run(): UV still evaluated with zero-amplitude (no whole-beat skip)"); +} + int main(void) { printf("=== Prot Elm Tests ===\n"); tc_init_clear(); @@ -295,6 +454,9 @@ int main(void) { tc_active_mask(); tc_guards(); tc_request_reset(); + tc_of_hysteresis(); + tc_uf_direction(); + tc_run_freq_wiring(); printf("\n=== Prot Elm Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/prot_set/prot_set_test.c b/test/host/prot_set/prot_set_test.c index c40907ec..2b85509e 100644 --- a/test/host/prot_set/prot_set_test.c +++ b/test/host/prot_set/prot_set_test.c @@ -20,9 +20,12 @@ * (位精确 + 封存自洽 + parse 不置脏) * TC-PS-010 损坏 blob 整表拒绝(all-or-nothing):值区位翻转/坏 magic/ * 截断/超长/坏版本/伪造自洽越界 → 全部拒绝且活跃值区一字不动 + * TC-PS-011 OF/UF 定值行(B8):40 项 / blob 版本 2 / 本地项地址 0 / + * 默认值与范围 / 返回系数独立性 / 64 位 bad_mask(bit 39)/ + * B7 旧档(32 项)换代拒绝 * * 通过:全部 TC PASS(main 返回 0)。 - * 创建日期:2026-10-11 + * 创建日期:2026-10-11;2026-10-12 扩 OF/UF(B8) */ #include #include @@ -94,7 +97,7 @@ static void tc_load_raw(void) { tbl[PS_OC3_DLY] = PROT_SET_Q10(60001.0); /* 越上限 */ tbl[PS_OL_CUR] = PROT_SET_Q10(2.5); /* 合法改动 */ CHECK(prot_set_load_raw(tbl) == -1, "TC-PS-004 bad table rejected"); - CHECK(prot_set_bad_mask() == (1uL << PS_OC3_DLY), + CHECK(prot_set_bad_mask() == (1uLL << PS_OC3_DLY), "TC-PS-004 bad_mask points at the offending item"); CHECK(prot_set_get_f(PS_OL_CUR) > 1.49f && prot_set_get_f(PS_OL_CUR) < 1.51f, "TC-PS-004 rejected load keeps OLD table (all-or-nothing)"); @@ -136,7 +139,7 @@ static void tc_meta_sanity(void) { CHECK(ok_range, "TC-PS-006 meta: min<=def<=max for all items"); CHECK(ok_ctl, "TC-PS-006 meta: ctl items are 0..1"); CHECK(ok_addr, "TC-PS-006 meta: comm addresses unique"); - CHECK((uint32_t)PS_ID_COUNT <= 32u, "TC-PS-006 item count fits bad_mask bitmap"); + CHECK((uint32_t)PS_ID_COUNT <= 64u, "TC-PS-006 item count fits 64-bit bad_mask (B8: 40)"); } /* TC-PS-007 */ @@ -235,11 +238,89 @@ static void tc_store_corrupt(void) { (uint32_t)(g_prot_set_meta[PS_OC1_CUR].max + 1)); s_forge_tail(blob, len); CHECK(prot_set_store_parse(blob, len) == -1, "TC-PS-010 forged consistent out-of-range rejected"); - CHECK(prot_set_bad_mask() == (1uL << PS_OC1_CUR), "TC-PS-010 bad_mask points at item"); + CHECK(prot_set_bad_mask() == (1uLL << PS_OC1_CUR), "TC-PS-010 bad_mask points at item"); CHECK(prot_set_get(PS_OC1_CUR) == PROT_SET_Q10(10.0), "TC-PS-010 defaults still intact"); CHECK(prot_set_selfcheck() == 0, "TC-PS-010 active table still self-consistent after all rejects"); } +/* TC-PS-011(B8:OF/UF 定值行 + 40 项 + 64 位 bad_mask + 换代拒绝) */ +static void tc_of_uf_meta(void) { + uint8_t blob[PROT_SET_BLOB_SZ]; + uint32_t len = 0u; + + prot_set_init(); + + /* 结构:40 项、blob 版本 2、尺寸随项数自适应 */ + CHECK((uint32_t)PS_ID_COUNT == 40u, "TC-PS-011 PS_ID_COUNT == 40 (32 + 8 OF/UF)"); + CHECK(PROT_SET_BLOB_VER == 2u, "TC-PS-011 blob version bumped to 2 (B8)"); + CHECK((uint32_t)PROT_SET_BLOB_SZ == (PROT_SET_BLOB_HDR_SZ + 4u * 40u + 8u), + "TC-PS-011 blob size = hdr + 4*40 + tail"); + + /* 地址:参考逐项地址不在本仓库 -> 8 项全部本地项(comm_addr=0,104 不可寻址) */ + CHECK((g_prot_set_meta[PS_OF_EN].comm_addr == 0u) + && (g_prot_set_meta[PS_OF_HZ].comm_addr == 0u) + && (g_prot_set_meta[PS_OF_DLY].comm_addr == 0u) + && (g_prot_set_meta[PS_UF_EN].comm_addr == 0u) + && (g_prot_set_meta[PS_UF_HZ].comm_addr == 0u) + && (g_prot_set_meta[PS_UF_DLY].comm_addr == 0u) + && (g_prot_set_meta[PS_RET_OF].comm_addr == 0u) + && (g_prot_set_meta[PS_RET_UF].comm_addr == 0u), + "TC-PS-011 OF/UF items all local (comm_addr=0, pending reference table)"); + + /* 投退 = ctl 项(0/1),其余 = 模拟量 Q10 */ + CHECK((g_prot_set_meta[PS_OF_EN].is_ctl == 1u) && (g_prot_set_meta[PS_UF_EN].is_ctl == 1u) + && (g_prot_set_meta[PS_OF_HZ].is_ctl == 0u) && (g_prot_set_meta[PS_RET_UF].is_ctl == 0u), + "TC-PS-011 EN items are ctl, HZ/DLY/RET are Q10 analog"); + + /* 出厂默认(拟定值)与范围 */ + CHECK(prot_set_get(PS_OF_EN) == PROT_SET_Q10(1.0), "TC-PS-011 OF EN default 1"); + CHECK(prot_set_get(PS_UF_EN) == PROT_SET_Q10(1.0), "TC-PS-011 UF EN default 1"); + CHECK(prot_set_get(PS_OF_HZ) == PROT_SET_Q10(51.0), "TC-PS-011 OF default 51.0 Hz"); + CHECK(prot_set_get(PS_UF_HZ) == PROT_SET_Q10(49.0), "TC-PS-011 UF default 49.0 Hz"); + CHECK(prot_set_get(PS_OF_DLY) == PROT_SET_Q10(1000.0), "TC-PS-011 OF default 1000 ms"); + CHECK(prot_set_get(PS_UF_DLY) == PROT_SET_Q10(1000.0), "TC-PS-011 UF default 1000 ms"); + CHECK(prot_set_get(PS_RET_OF) == PROT_SET_Q10(0.99), "TC-PS-011 RET_OF default 0.99"); + CHECK(prot_set_get(PS_RET_UF) == PROT_SET_Q10(1.01), "TC-PS-011 RET_UF default 1.01"); + CHECK((g_prot_set_meta[PS_OF_HZ].min == PROT_SET_Q10(50.0)) + && (g_prot_set_meta[PS_OF_HZ].max == PROT_SET_Q10(55.0)), + "TC-PS-011 OF range 50.0~55.0 Hz"); + CHECK((g_prot_set_meta[PS_UF_HZ].min == PROT_SET_Q10(45.0)) + && (g_prot_set_meta[PS_UF_HZ].max == PROT_SET_Q10(50.0)), + "TC-PS-011 UF range 45.0~50.0 Hz"); + + /* 频率返回系数与电气量系数独立:返回**门槛** = 定值×系数(Q10 乘积 + * /1024)——OF 门槛 < 动作门槛、UF 门槛 > 动作门槛(方向相反) */ + CHECK(((int64_t)prot_set_get(PS_OF_HZ) * (int64_t)prot_set_get(PS_RET_OF)) / 1024 + < (int64_t)prot_set_get(PS_OF_HZ), + "TC-PS-011 OF return threshold (set x 0.99) below pickup"); + CHECK(((int64_t)prot_set_get(PS_UF_HZ) * (int64_t)prot_set_get(PS_RET_UF)) / 1024 + > (int64_t)prot_set_get(PS_UF_HZ), + "TC-PS-011 UF return threshold (set x 1.01) above pickup"); + + /* 投退可经 set_sealed 投/退(YK 口径同 GLOBAL_EN;Q10(2)=2048 越界拒) */ + CHECK(prot_set_set_sealed(PS_OF_EN, 0) == 0, "TC-PS-011 OF EN set_sealed OK"); + CHECK(prot_set_get_ctl(PS_OF_EN) == 0u, "TC-PS-011 OF EN readable after seal"); + CHECK(prot_set_set_sealed(PS_OF_EN, PROT_SET_Q10(2)) == -1, "TC-PS-011 OF EN rejects Q10(2)"); + CHECK(prot_set_selfcheck() == 0, "TC-PS-011 no lock after OF/UF seal ops"); + + /* 64 位 bad_mask:末项(bit 39 > 31)越界可指认 */ + (void)prot_set_set_sealed(PS_OC1_CUR, PROT_SET_Q10(11.0)); + (void)prot_set_dirty_take(); + CHECK(prot_set_store_serialize(blob, sizeof(blob), &len) == 0, "TC-PS-011 serialize ok"); + s_put32(blob + PROT_SET_BLOB_HDR_SZ + 4u * (uint32_t)PS_RET_UF, + (uint32_t)(g_prot_set_meta[PS_RET_UF].max + 1)); + s_forge_tail(blob, len); + CHECK(prot_set_store_parse(blob, len) == -1, "TC-PS-011 out-of-range RET_UF rejected"); + CHECK(prot_set_bad_mask() == (1uLL << PS_RET_UF), + "TC-PS-011 bad_mask bit 39 addressable (64-bit mask, B8)"); + CHECK(prot_set_get(PS_RET_UF) == PROT_SET_Q10(1.01), "TC-PS-011 default RET_UF intact"); + + /* 换代语义:B7 旧档(ver=1 / 32 项 = 144 B)在新固件必被拒 -> 出厂默认 */ + CHECK(prot_set_store_parse(blob, 8u + 4u * 32u + 8u) == -1, + "TC-PS-011 legacy 32-item blob length rejected"); + CHECK(prot_set_selfcheck() == 0, "TC-PS-011 active table intact after legacy reject"); +} + int main(void) { printf("=== Prot Set Tests ===\n"); tc_init(); @@ -252,6 +333,7 @@ int main(void) { tc_global_plate_dirty(); tc_store_roundtrip(); tc_store_corrupt(); + tc_of_uf_meta(); printf("\n=== Prot Set Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/soe/decode_soe.py b/test/host/soe/decode_soe.py new file mode 100644 index 00000000..448bbf38 --- /dev/null +++ b/test/host/soe/decode_soe.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""decode_soe.py - /log/soe.log 主机解码器 + 断言(B9 台架腿证据工具,纯标准库)。 + +做什么:把 FTP 下载的设备端 /log/soe.log(定长 64B 二进制记录,格式见 + App/log/soe_rec.h)逐条解码成可读行,并断言: + 1) 文件大小 = 64 的整数倍(定长不变量); + 2) 每条记录版本 = 1 且 sum8 校验和通过(撕裂/半写检测); + 3) seq 单调:同一 boot 段内严格 +1(seq 回落 = 设备复位/容量重建段边界, + 如实分段计数,不算失败); + 4) 时标:IV 记录如实报告;非 IV 记录时标必须落在合理窗口 + (2020..2099,对时链已就绪的台架前提); + 5) 类型合法(1..6);元件事件 elm_id < --max-elm(默认 16 留余量; + PE_COUNT 现 7,B8 过频/低频并入后 9——写死 7 会在 B8 合并后误报)。 + 任一断言失败 -> 退出码 1(台架腿 FAIL 判据之一)。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/soe/decode_soe.py + 可选:--expect-assert N 断言至少 N 条"元件动作"记录(台架腿用 1) + +退出码:0=全部断言 PASS / 1=断言失败 / 2=参数或文件错误。 + +格式镜像:App/log/soe_rec.h(改格式必须同步本工具)。 +创建日期:2026-10-12(B9) +""" +import argparse +import calendar +import datetime +import struct +import sys + +REC_SIZE = 64 +VER = 1 +OFF_VER, OFF_TYPE, OFF_ELM, OFF_FLAGS = 0, 1, 2, 3 +OFF_SEQ, OFF_CP56, OFF_EPOCH, OFF_VALUE, OFF_SUM = 4, 8, 15, 19, 63 +FLG_IV = 0x80 + +TYPE_NAMES = { + 1: "ELM_ASSERT", 2: "ELM_RETURN", 3: "PLATE_OUT", 4: "PLATE_IN", + 5: "SAMPLE_BLOCK", 6: "SAMPLE_RESUME", +} + + +def cp56_decode(b): + """CP56Time2a 7B -> (datetime|None, iv). ms 域 = sec*1000(B9 秒域时基)。""" + iv = (b[2] & 0x80) != 0 + ms_min = b[0] | (b[1] << 8) + minute = b[2] & 0x3F + hour = b[3] & 0x1F + day = b[4] & 0x1F + mon = b[5] & 0x0F + year = 2000 + (b[6] & 0x7F) + sec, ms = divmod(ms_min, 1000) + try: + dt = datetime.datetime(year, mon, day, hour, minute, sec, ms * 1000) + except ValueError: + dt = None + return dt, iv + + +def main(): + ap = argparse.ArgumentParser(description="decode /log/soe.log") + ap.add_argument("path", help="downloaded soe.log file") + ap.add_argument("--expect-assert", type=int, default=0, + help="min ELM_ASSERT records required (bench leg: 1)") + ap.add_argument("--max-elm", type=int, default=16, + help="exclusive upper bound for element elm_id " + "(PE_COUNT: 7 pre-B8, 9 post-B8; 16 = headroom)") + args = ap.parse_args() + + try: + with open(args.path, "rb") as f: + data = f.read() + except OSError as e: + print("ERROR: cannot read %s: %s" % (args.path, e)) + return 2 + + print("soe.log: %d bytes, %d records" % (len(data), len(data) // REC_SIZE)) + fails = [] + if len(data) == 0: + fails.append("empty file (no SOE records)") + if len(data) % REC_SIZE != 0: + fails.append("size %d not multiple of %d (torn tail)" + % (len(data), REC_SIZE)) + + n_seg = 0 + prev_seq = None + n_assert = 0 + for i in range(len(data) // REC_SIZE): + rec = data[i * REC_SIZE:(i + 1) * REC_SIZE] + if rec[OFF_VER] != VER: + fails.append("rec %d: bad version %d" % (i, rec[OFF_VER])) + continue + s = sum(rec[:OFF_SUM]) & 0xFF + if rec[OFF_SUM] != s: + fails.append("rec %d: checksum mismatch (stored 0x%02X calc 0x%02X)" + % (i, rec[OFF_SUM], s)) + continue + rtype = rec[OFF_TYPE] + elm = rec[OFF_ELM] + flags = rec[OFF_FLAGS] + seq = struct.unpack_from("= args.max_elm: + fails.append("rec %d: elm_id %d out of range (max %d)" + % (i, elm, args.max_elm - 1)) + + # seq monotonic within a boot segment; drop = new segment (reset/rebuild) + if prev_seq is None or seq <= prev_seq: + n_seg += 1 + elif seq != prev_seq + 1: + fails.append("rec %d: seq gap %d -> %d (not strictly +1)" + % (i, prev_seq, seq)) + prev_seq = seq + + if (flags & FLG_IV) != 0: + if not iv: + fails.append("rec %d: flags IV but CP56 IV clear" % i) + ts_s = "IV" + else: + if iv: + fails.append("rec %d: CP56 IV set but flags IV clear" % i) + if dt is None or not (2020 <= dt.year <= 2099): + fails.append("rec %d: timestamp out of window: %r" % (i, dt)) + ts_s = dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "BAD" + # CP56 = UTC 分解;epoch 字段 = UTC 秒 → 用 calendar.timegm(UTC)。 + # ⚠ 本机时区 +08 时 dt.timestamp() 会差 28800 s(首跑实证坑) + if dt and epoch and abs(calendar.timegm(dt.utctimetuple()) - epoch) > 2: + fails.append("rec %d: cp56/epoch cross-check off (%s vs %d)" + % (i, ts_s, epoch)) + + if rtype == 1: + n_assert += 1 + print(" #%03d seq=%-10d %-13s elm=%-3s val=0x%08X ts=%s" + % (i, seq, TYPE_NAMES.get(rtype, "?%d" % rtype), + "-" if elm == 0xFF else str(elm), value, ts_s)) + + print("segments (boot/rebuild boundaries): %d, ELM_ASSERT records: %d" + % (n_seg, n_assert)) + if n_assert < args.expect_assert: + fails.append("ELM_ASSERT count %d < expected %d" + % (n_assert, args.expect_assert)) + + if fails: + for x in fails: + print("FAIL: %s" % x) + print("SUMMARY: FAIL (%d)" % len(fails)) + return 1 + print("SUMMARY: PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/host/soe/run_unit_test.py b/test/host/soe/run_unit_test.py new file mode 100644 index 00000000..9988bb94 --- /dev/null +++ b/test/host/soe/run_unit_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""SOE 记录编解码(B9)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么:用本机 MSVC 离线编译 test/host/soe/soe_test.c(#include 纳入 + 生产源文件 soe_rec.c + iec104_asdu.c),验证 64B 定长记录构建/解析 + 往返、sum8 校验和硬门槛、CP56Time2a 时标编码(与 iec104 解码器交叉 + 互证)、IV 语义、钳位、结构不变量。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/soe/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.4(L4-4/L4-5,B9)。 +创建日期:2026-10-12 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="SOE Record Tests", + src=os.path.join(HERE, "soe_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "log"), + os.path.join(ROOT, "App", "protocol", "iec104"), + HERE, + ], + summary_marker="=== SOE Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/soe/soe_test.c b/test/host/soe/soe_test.c new file mode 100644 index 00000000..c94b1540 --- /dev/null +++ b/test/host/soe/soe_test.c @@ -0,0 +1,213 @@ +/* + * 模块名称:SOE 记录编解码主机侧单元测试(B9,L0.5) + * 模块功能:离线验证 App/log/soe_rec.c——64B 定长记录构建/解析往返、 + * 校验和硬门槛(撕裂/半写拒绝)、版本门槛、CP56Time2a 时标 + * 编码正确性(与 iec104_asdu.c 的 CP56 解码交叉验证:同一算法族 + * 两个实现互证)、IV 语义、钳位、保留区清零。 + * + * 被测对象:真实源文件(#include 原样纳入): + * soe_rec.c(B9 记录编解码) + * iec104_asdu.c(交叉验证用 CP56 解码器,纯逻辑) + * + * 覆盖点: + * TC-SOE-001 构建/解析字段往返(type/elm/flags/seq/epoch/value) + * TC-SOE-002 校验和硬门槛:单字节翻转 → -3;版本篡改 → -2 + * TC-SOE-003 CP56 编码正确性:已知 epoch → 期望字节序列; + * 与 iec104_cp56_to_epoch_ms 往返互证(含闰日 2024-02-29) + * TC-SOE-004 IV 语义:flags IV → CP56 byte2 bit7 置位 + epoch 字段 0 + * TC-SOE-005 钳位:epoch < 2000 → 2000-01-01;epoch > 2127 上界 → 2127-12-31 + * TC-SOE-006 结构不变量:size=64、保留区 [23..62] 恒 0、sum=sum8([0..62]) + * TC-SOE-007 序号域:0 / 0xFFFFFFFF 边界往返(uint32 回绕语义) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-12(B9) + */ +#include +#include +#include + +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +#include "soe_rec.c" +#include "iec104_asdu.c" + +/* ---------------- TC-SOE-001 字段往返 ---------------- */ +static void tc_roundtrip(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + int rc; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 2u, 0u, 12345u, + 1760234567u, 0xDEADBEEFu); + rc = soe_rec_parse(rec, &out); + CHECK(rc == 0, "TC-001 parse ok"); + CHECK(out.ver == SOE_REC_VER, "TC-001 ver"); + CHECK(out.type == (uint8_t)SOE_T_ELM_ASSERT, "TC-001 type"); + CHECK(out.elm_id == 2u, "TC-001 elm_id"); + CHECK(out.flags == 0u, "TC-001 flags"); + CHECK(out.seq == 12345u, "TC-001 seq"); + CHECK(out.epoch_s == 1760234567u, "TC-001 epoch"); + CHECK(out.value == 0xDEADBEEFu, "TC-001 value"); + CHECK(out.cp56[6] == (uint8_t)((2025u - 2000u) & 0x7Fu), "TC-001 cp56 year=2025"); +} + +/* ---------------- TC-SOE-002 校验和/版本硬门槛 ---------------- */ +static void tc_gates(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint32_t i; + int bad = 0; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_RETURN, 0u, 0u, 7u, 1760234567u, 3u); + /* 每个数据字节单独翻转:解析必须全部拒绝(sum 覆盖 [0..62]) */ + for (i = 0u; i < SOE_OFF_SUM; i++) { + uint8_t save = rec[i]; + rec[i] ^= 0x01u; + if (soe_rec_parse(rec, &out) == 0) { + bad = 1; + } + rec[i] = save; + } + CHECK(bad == 0, "TC-002 any byte flip rejected"); + CHECK(soe_rec_parse(rec, &out) == 0, "TC-002 intact parses ok"); + + rec[SOE_OFF_VER] = (uint8_t)(SOE_REC_VER + 1u); /* 版本篡改:sum 仍按新 + * 字节算会自洽,但版本 + * 门槛先拒 */ + CHECK(soe_rec_parse(rec, &out) == -2, "TC-002 bad version -> -2"); + CHECK(soe_rec_parse(NULL, &out) == -1, "TC-002 null args -> -1"); +} + +/* ---------------- TC-SOE-003 CP56 正确性 + 交叉往返 ---------------- */ +static void tc_cp56(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + int rc; + + /* 2026-10-12 12:34:56 UTC = 1791808496(手算:2026-10-12 = epoch 1791806400 + *(当日 00:00 UTC)+ 12*3600+34*60+56 = 45296 → 1791851696? 以解码器为准, + * 这里用"往返互证 + 显式字段"双保险,不依赖手算) */ + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 1u, 1791808496u, 0u); + (void)soe_rec_parse(rec, &out); + /* 显式字段:12:34:56 → ms 域 = 56*1000(秒域时基如实编码,亚秒 0) */ + CHECK(((uint32_t)out.cp56[0] | ((uint32_t)out.cp56[1] << 8)) == 56000u, + "TC-003 ms field = sec*1000 (56000)"); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(rc == 0, "TC-003 cross decode ok"); + CHECK(back == 1791808496000ULL, "TC-003 cross roundtrip exact"); + + /* 闰日 2024-02-29 00:00:00 UTC = 1709164800 */ + soe_rec_build(rec, (uint8_t)SOE_T_SAMPLE_BLOCK, 0u, 0u, 2u, 1709164800u, 0u); + (void)soe_rec_parse(rec, &out); + CHECK(out.cp56[4] == 29u && out.cp56[5] == 2u && out.cp56[6] == 24u, + "TC-003 leap day fields"); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK((rc == 0) && (back == 1709164800000ULL), "TC-003 leap roundtrip"); + + /* 2000-01-01 00:00:00 边界 */ + soe_rec_build(rec, (uint8_t)SOE_T_PLATE_IN, 0u, 0u, 3u, 946684800u, 0u); + (void)soe_rec_parse(rec, &out); + rc = iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK((rc == 0) && (back == 946684800000ULL), "TC-003 year-2000 boundary"); +} + +/* ---------------- TC-SOE-004 IV 语义 ---------------- */ +static void tc_iv(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 1u, SOE_FLG_IV, 9u, 0u, 1u); + (void)soe_rec_parse(rec, &out); + CHECK((out.cp56[2] & 0x80u) != 0u, "TC-004 cp56 IV bit set"); + CHECK(out.epoch_s == 0u, "TC-004 epoch field zeroed"); + CHECK(iec104_cp56_to_epoch_ms(out.cp56, &back) == -2, + "TC-004 cross decoder rejects IV"); +} + +/* ---------------- TC-SOE-005 钳位 ---------------- */ +static void tc_clamp(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + uint64_t back; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 4u, 100u, 0u); + (void)soe_rec_parse(rec, &out); + CHECK((out.cp56[4] == 1u) && (out.cp56[5] == 1u) && (out.cp56[6] == 0u), + "TC-005 clamp low -> 2000-01-01"); + (void)iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(back == 946684800000ULL, "TC-005 clamp low exact"); + + /* epoch_s 为 uint32 → 最大 2106-02-07(0xFFFFFFFF),恒在 CP56 域内: + * 上界钳位是防御性死分支(epoch 域扩展时生效),此处断言精确往返 */ + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 5u, 0xFFFFFFFFu, 0u); + (void)soe_rec_parse(rec, &out); + CHECK(out.cp56[6] == 106u, "TC-005 uint32 max -> year 2106 (no clamp needed)"); + (void)iec104_cp56_to_epoch_ms(out.cp56, &back); + CHECK(back == 4294967295000ULL, "TC-005 uint32 max exact roundtrip"); +} + +/* ---------------- TC-SOE-006 结构不变量 ---------------- */ +static void tc_layout(void) +{ + uint8_t rec[SOE_REC_SIZE]; + uint32_t i; + uint8_t s = 0u; + int zero_ok = 1; + + CHECK(SOE_REC_SIZE == 64u, "TC-006 record size 64"); + soe_rec_build(rec, (uint8_t)SOE_T_SAMPLE_RESUME, SOE_ELM_NONE, 0u, + 0u, 946684800u, 0u); + for (i = 23u; i <= 62u; i++) { + if (rec[i] != 0u) { + zero_ok = 0; + } + } + CHECK(zero_ok, "TC-006 reserved [23..62] zero"); + for (i = 0u; i < SOE_OFF_SUM; i++) { + s = (uint8_t)(s + rec[i]); + } + CHECK(rec[SOE_OFF_SUM] == s, "TC-006 sum8 covers [0..62]"); + CHECK(rec[SOE_OFF_ELM] == SOE_ELM_NONE, "TC-006 elm none"); +} + +/* ---------------- TC-SOE-007 序号边界 ---------------- */ +static void tc_seq_edges(void) +{ + uint8_t rec[SOE_REC_SIZE]; + soe_rec_t out; + + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 0u, 946684800u, 0u); + CHECK((soe_rec_parse(rec, &out) == 0) && (out.seq == 0u), "TC-007 seq 0"); + soe_rec_build(rec, (uint8_t)SOE_T_ELM_ASSERT, 0u, 0u, 0xFFFFFFFFu, + 946684800u, 0u); + CHECK((soe_rec_parse(rec, &out) == 0) && (out.seq == 0xFFFFFFFFu), + "TC-007 seq 0xFFFFFFFF"); +} + +int main(void) +{ + printf("=== SOE Record Codec Host Tests ===\n"); + tc_roundtrip(); + tc_gates(); + tc_cp56(); + tc_iv(); + tc_clamp(); + tc_layout(); + tc_seq_edges(); + printf("=== SOE Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/tools/arch_diagram/gen_memory_svg.py b/tools/arch_diagram/gen_memory_svg.py index f6e53116..58e36690 100644 --- a/tools/arch_diagram/gen_memory_svg.py +++ b/tools/arch_diagram/gen_memory_svg.py @@ -365,7 +365,7 @@ def main(): for filename, gen_func in targets: path = os.path.join(out_dir, filename) svg = gen_func() - with open(path, "w", encoding="utf-8") as f: + with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(svg) print(f"OK: {filename} ({len(svg)} bytes)") diff --git a/tools/arch_diagram/gen_net_pending_svg.py b/tools/arch_diagram/gen_net_pending_svg.py index 6bff92bf..a9fd381d 100644 --- a/tools/arch_diagram/gen_net_pending_svg.py +++ b/tools/arch_diagram/gen_net_pending_svg.py @@ -75,7 +75,7 @@ FAST_PATH = ("立即 SPI 读出", LOOP_STEPS = [ ("net_poll()", "刷新 readable", C_CHIP), ("net_pending_tick()", "了结挂起", C_TICK), - ("net_relisten_retry_tick()", "重试对账", C_ACTOR), + ("net_listener_retry_tick()", "重试对账", C_ACTOR), ("排空消息队列", "服务所有消费者", "#64748b"), ] @@ -284,13 +284,13 @@ def build(): 'viewBox="0 0 %d %d" font-family="%s">' % (W, H, W, H, FONT)] svg.extend(out) svg.append('') - return "\n".join(svg) + return "\n".join(svg) + "\n" def main(): svg = build() os.makedirs(os.path.dirname(OUT), exist_ok=True) - with open(OUT, "w", encoding="utf-8") as f: + with open(OUT, "w", encoding="utf-8", newline="\n") as f: f.write(svg) print("已生成: %s (%d bytes)" % (OUT, len(svg.encode("utf-8"))))