merge: v2-on-ui 并入 master(收口 CI 分支与 B9/SOE)

将开发主线 v2-on-ui 整体并入远端默认分支 master。

关系:master 在 2026-10-12 17:53 已做过一次并入(提交 116c6e8),
其后再度领先 master 29 个提交(含 CI 分支合入 c077e9a、
B9 动作记录+时标 SOE),无分叉、无并集合并残留。
非破坏性预演 `git merge-tree --write-tree` rc=0,结果树 f6282938。
按项目惯例用 --no-ff 保留合并记录。

内容概要(相对 master 66186e1 之后的收口):
- 远程 CI 分支 v2-on-ui-ci 合入:docs/CI/ 独立成册、runner 保活/日志编码/gitlint 空转修复
- B9 动作记录 + 时标 SOE(L4-4/L4-5)+ 104 M_SP_TB_1 自发
- 此前的并入已含:B4 IEC104 上行、N-32 监听生命周期根治、N-27 RAM 台账与 92% 门禁、
  N-19 FTP 鉴权、多任务并行开发机制等
This commit is contained in:
2026-10-12 20:05:24 +08:00
75 changed files with 6073 additions and 1710 deletions
+1 -1
View File
@@ -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",
+31 -1
View File
@@ -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 <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的 `_wt/<ID>-<slug>/` 反推 `<ID>` → `build-cli` |
| `--lock-wait <N>` | 锁被占时等待 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/<ID>-<slug>/` 反推持有者名,意味着"手工 `lock acquire build --task <ID>`
后再跑 `@build`"仍**幂等**(同名即视为自己持有),不会自我死锁。
- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨
烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。
## 本仓库
+186 -27
View File
@@ -6,6 +6,7 @@
python <skill>/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/<ID>-<slug>/`
反推 `<ID>`(与工作树命名约定一致,故"手工锁了同名任务"仍幂等)→ 否则 `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: <proj dir>/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/<ID>-<slug>/` 的 `<ID>` > "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 <action> build --task <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: <proj dir>/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/<ID>-* / 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())
+15 -3
View File
@@ -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 <ID>` → 烧录/`@test`/探针 →
`lock release bench --task <ID> --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 的衔接
@@ -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 <ID>")
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)
+131 -106
View File
@@ -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=<path> 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 日志(供下载/排障)
+13 -2
View File
@@ -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 加)
+9 -8
View File
@@ -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_TABLE:BEGIN generated by scripts/gen_task_table.py -->
| 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 <UTF-8 无 BOM 文件>` 多行正文**(中文长标题极易超限,且 `-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) |
+113 -20
View File
@@ -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<br/>按语义取原始码值"]
RAW --> RING["sample_ring_push_frame()<br/>整帧入环(40 点窗)"]
RING --> RUN["phasor_run()<br/>逐实例 20 点重算 + 双区切换"]
RUN --> FREQ["freq_run()<br/>共轭乘积相位增量 + 自适应窗均值(B8)"]
RUN --> GET["phasor_get(idx)"]
FREQ --> FGET["freq_get() / freq_valid()"]
GET --> CONS["消费者:adcTask 日志(现状)<br/>UI / 自检 / L3 保护任务(待接)"]
FGET --> PCONS["消费者:prot_elm_run() OF/UF 判据(B8)<br/>adcTask 心跳 freq 行"]
```
初始化落点:`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` 已在 `<IncludePath>` 内。
本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)。
- **挂载**:八个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `<IncludePath>` 内。
本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)——纯算法且模拟器无消费路径的
新文件(`prot_set.c` / `freq.c`)改在 `scripts/check_registration.py` 的 `SIM_EXEMPT` 登记豁免
(`registration-check` 钩子反向校验,N-23)。
- **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码:
| 套件 | 命令 | pre-commit 钩子 |
@@ -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。
+318
View File
@@ -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 <math.h>
#include <stddef.h>
#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;
}
+121
View File
@@ -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 <stdint.h>
/* 测频标称采样率(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 */
+39 -11
View File
@@ -8,6 +8,7 @@
#include "prot_elm.h"
#include "prot_set.h"
#include "phasor.h"
#include "freq.h"
#include <stddef.h>
@@ -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;
}
+10 -2
View File
@@ -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 <stdint.h>
/* 元件实例编号(枚举序 = 实例表序 = 测量数组序) */
/* 元件实例编号(枚举序 = 实例表序 = 测量数组序;尾追加,编号不回收) */
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;
+17 -6
View File
@@ -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;
+22 -5
View File
@@ -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)
+7
View File
@@ -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。 */
+43 -5
View File
@@ -13,21 +13,50 @@
#include <stddef.h>
#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;
}
/*
+23
View File
@@ -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`
+160
View File
@@ -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 <string.h>
/* 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;
}
+119
View File
@@ -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 <stdint.h>
#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 */
+210
View File
@@ -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 <string.h>
#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;
}
+66
View File
@@ -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 <stdint.h>
#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 */
+5
View File
@@ -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();
}
/*
+1
View File
@@ -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 执行) */
+26 -6
View File
@@ -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 位保留,下次续发 */
}
+4
View File
@@ -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;
/* 链路实例(胶水层静态持有,零堆) */
+15
View File
@@ -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++;
}
+19 -1
View File
@@ -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 失败
+23 -3
View File
@@ -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);
+12 -10
View File
@@ -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) {
+15
View File
@@ -902,6 +902,16 @@
<FileType>1</FileType>
<FilePath>..\App\log\console_log.c</FilePath>
</File>
<File>
<FileName>soe_rec.c</FileName>
<FileType>1</FileType>
<FilePath>..\App\log\soe_rec.c</FilePath>
</File>
<File>
<FileName>soe_store.c</FileName>
<FileType>1</FileType>
<FilePath>..\App\log\soe_store.c</FilePath>
</File>
<File>
<FileName>sys_clock.c</FileName>
<FileType>1</FileType>
@@ -1007,6 +1017,11 @@
<FileType>1</FileType>
<FilePath>..\App\algo\prot_elm.c</FilePath>
</File>
<File>
<FileName>freq.c</FileName>
<FileType>1</FileType>
<FilePath>..\App\algo\freq.c</FilePath>
</File>
</Files>
</Group>
<Group>
+2 -2
View File
@@ -69,12 +69,12 @@ main.c
<!-- TASK_TABLE:BEGIN generated by scripts/gen_task_table.py -->
| 任务 | 栈大小 | 优先级 | 职责 |
|------|--------|--------|------|
| 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 | |
+1 -1
View File
@@ -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
+17 -7
View File
@@ -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 */
+89
View File
@@ -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,与日常开发规范混在一起每次会话都被读,造成困惑 |
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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 补登记 |
+1 -1
View File
@@ -50,7 +50,7 @@
<text x="768.5" y="454.0" font-size="12.5" font-weight="700" fill="#d97706">net_pending_tick()</text>
<text x="768.5" y="472.0" font-size="11" fill="#64748b">了结挂起</text>
<rect x="961.0" y="434.0" width="190.5" height="46" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-opacity="0.6"/>
<text x="971.0" y="454.0" font-size="12.5" font-weight="700" fill="#7c3aed">net_relisten_retry_tick()</text>
<text x="971.0" y="454.0" font-size="12.5" font-weight="700" fill="#7c3aed">net_listener_retry_tick()</text>
<text x="971.0" y="472.0" font-size="11" fill="#64748b">重试对账</text>
<rect x="1163.5" y="434.0" width="190.5" height="46" rx="8" fill="#ffffff" stroke="#64748b" stroke-opacity="0.6"/>
<text x="1173.5" y="454.0" font-size="12.5" font-weight="700" fill="#64748b">排空消息队列</text>

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+12 -1
View File
@@ -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 检测脚本 |
+3 -3
View File
@@ -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(配主机侧移植层测试)
↓
+15 -17
View File
@@ -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)。
-1
View File
@@ -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 |
@@ -36,10 +36,10 @@
<!-- MEM_SNAPSHOT:BEGIN generated by scripts/check_mem_numbers.py -->
| 区域 | 用量 | 容量 | 占比 |
|---|---|---|---|
| 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%** |
<!-- MEM_SNAPSHOT:END -->
各区的容量与常量来源:
@@ -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 | 空闲 | 未观测(未取句柄) |
+5 -4
View File
@@ -15,10 +15,10 @@
<!-- MEM_BUDGET:BEGIN generated by scripts/ram_baseline.py -->
| 区域 | 用量 | 容量 | 占比 | 门禁(≤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 | — | 参考 |
<!-- MEM_BUDGET:END -->
- **门禁**: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 |
@@ -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 节 |
+35 -26
View File
@@ -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 缺陷记录) |
@@ -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 轮换 |
-371
View File
@@ -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 编辑配置文件(推荐,永久生效)
配置文件位置(按部署方式二选一):
| 部署方式 | 配置文件路径 |
|---|---|
| 二进制 / 直接跑 | `<gitea 目录>/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` 配不配都行;<br>若坚持用 `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 同理:默认 `<ProgramFiles(x86)>\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://<host>/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: <K> runner(s), <A>/<B> assertions passed, fail=<F>`(逐模块另有 `::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<Y | 有单测用例未通过 | 看该模块的 `::error::unit test failed` 行与其 `ci-artifacts/l05-<模块>.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 拉取 / 端到端验证清单 / 故障排查 / 日常维护) |
@@ -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 <key> <value>` / `config get <key>`。
---
## 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 |
<workdir>\.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<pwd> -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 "<user>:<pass>" -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 拉取的零依赖改法 |
@@ -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 <branch> <base>` 后再删。
3. **直调 Win32 建联接被拒**(Win32 4392):改走 `mklink /J`(详见 3.3)。
@@ -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。
---
@@ -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 同步更正 |
@@ -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) |
+314
View File
@@ -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<n>`、`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-<nn>`。
---
## 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-<nnn>` | 缺陷 / 问题发现 | `P0-x`~`P3-x`(在册项)、新发现 | `[计划] docs/问题记录/缺陷登记簿.md` | `DEF-001` |
| `WI-<nnn>` | 工作项 / 任务卡 | `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-<nnn>` | 决策记录 | `docs/子系统设计/网络Socket监听生命周期设计说明.md` 的 `D1~D10` | `[计划] docs/架构设计/决策记录登记簿.md` | `DR-001` |
| `OQ-<nnn>` | 待确认 / 开放问题 | `B0-a/b/c`、`待确认-N` | `docs/任务规划/基线与待确认.md` | `OQ-001` |
| `RSK-<nn>` | 风险(**文档内编号**) | 保护规划 `R1~R8`、看门狗 `R1~R7` | 各文档风险表(引用必须带文档名) | 各文档自 `RSK-01` |
| `TRAP-<MOD>-<nn>` | 陷阱记录 | `Trap 07`、`TRAP-07`、`陷阱 1` | `docs/问题记录/<模块>_Trap_Records.md` | 保留原号(`TRAP-NET-01~25`、`TRAP-GD5F-01~06`、`TRAP-RTC-01~`) |
| `GAP-<nn>` | 模拟器差距 | `H-1~H-7`、`L-1~L-4`、差距清单 `N-1~N-6` | `docs/测试说明/模拟器差距清单.md` | `GAP-01` |
| `SC-<nn>` | 实机验证场景 | 实机预检 `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-<nnn>` | 台架日志代次 | `gen 181`、`gen-288`、`gen68` | `docs/测试说明/串口助手设计说明.md` | 数字不变,仅改字面 |
| `RUN-<yyyymmdd>-<n>` | 台架轮次 | `round 1~9` | `docs/测试说明/实机测试实施指南.md` | 日期制 |
| `MS-<nn>` | 里程碑 | `M1~M3` | 波次文件 | `MS-01` |
| `RC-<nn>` | net 周期对账类型 | `①②③④`(唯一被代码逻辑引用的圈号链) | `docs/子系统设计/网络Socket监听生命周期设计说明.md` | `RC-01`~`RC-04` |
| `PAT-<nn>` | 缺陷模式(九类) | `docs/问题记录/错误码传播审计.md` §1.1 的 `P1~P9` | 同文 | `PAT-01`~`PAT-09` |
| `ST-<n>` | 专项内部实施阶段(**文档内编号**) | 保护规划 §7.1 的 `P0~P5`、方案阶段 `P0~P3` | 各专项文档 | 各文档自 `ST-0` |
| `UL-<n>` | UI 标准化四层(**文档内编号**) | 报告 UI 四层 `L0~L3` | `docs/UI设计/UI_设计架构.md` | `UL-0` |
| `LAT-<nn>` | 链路延迟分段(**文档内编号**) | `docs/测试说明/链路延迟测试说明.md` 的 `L1~L11` | 同文 | `LAT-01` |
| `CS-<n>` | CubeMX 定制存活三层 | `AGENTS.md` 的 `L1 写进 .ioc → L2 USER CODE → L3 审计` | `docs/子系统设计/看门狗设计说明.md` §10 | `CS-1`~`CS-3` |
### 4.3 冻结前缀(历史快照,正文不回改)
| 冻结对象 | 处置 |
|---|---|
| `E-<nn>` | 冻结于 `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<n>-<nn>`。
- **禁止引用**(易变):`WI-*`、`W<n>`、`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-<MOD>-<nn>` | 数字不变,补模块码 |
| `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-<日期>-<n>` | 无日期的历史轮次留别名表 |
| `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 三批全做 |
+16 -12
View File
@@ -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 补错误码回传约束 |
@@ -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<br/>(结果回传的唯一通道)"]
subgraph NT["netTask 主循环 — 1ms/tick;芯片操作唯一执行者"]
NPM["netProcessMessages<br/>先跑 3 个 tick,再排空队列分派"]
NPM["netProcessMessages<br/>先跑 4 个 tick,再排空队列分派"]
NP["netPoll<br/>INT# GPIO 轮询 → 全局中断 → 各 Socket SINT → 每轮一批"]
T1["① netRelistenRetryTick<br/>D10 重监听重试:30ms / 2s 时限"]
T1["① netListenerRetryTick<br/>D10 重监听重试:30ms / 2s 时限"]
T2["② netPendingTick<br/>L3 挂起 recv:①数据 ②到点 ③断连 ④看门狗 1s"]
T3["③ netReconcileTick<br/>Trap 22 对账:200ms / 去抖 2 次 / 冷却 1s"]
T3["③ netReconcileTick<br/>Trap 22 对账:200ms / 去抖 / 冷却(只剩①②)"]
T4["④ netListenerPump<br/>N-32 唯一重建入口:100ms / 去抖 / 冷却"]
DISP["队列分派 switch<br/>SEND · RECV · CLOSE · CONNECT · LISTEN<br/>ACCEPT · RESYNC_LISTEN · RELISTEN"]
subgraph LK["_locked / 内部分派目标(仅 netTask 上下文)"]
LL["netListenLocked<br/>初次监听(3×500ms 阻塞重试)"]
@@ -56,10 +59,11 @@ flowchart TB
LR["netRecvLocked<br/>快路径 / DONTWAIT / 登记挂起"]
LA["netAcceptLocked<br/>先 netPoll + 状态判定"]
LC["netCloseLocked<br/>了结挂起 + close + 等 CLOSED + 排空中断"]
RLS["netRelistenTry → netRelistenLocked<br/>单次尝试 + Trap 23 排渣隔离"]
RLS["netListenerTry → netRelistenLocked<br/>单次尝试 + Trap 23 排渣隔离"]
RSY["RESYNC 分支:改状态 + 取消挂起<br/>(不发芯片命令)"]
end
EV["事件处理器<br/>handleConnect · handleDisconnect<br/>handleRecv · handleSend · handleTimeout"]
ST["netListenerSettle<br/>只置沉降/隔离事实(100ms / 400ms)"]
FE["fireEvent → 用户回调"]
PR["s_net_pending<br/>(每 fd 至多一个挂起)"]
CH[("ch395f.* 驱动<br/>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 + 回调<br/>+ 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<br/>③ 本层 LISTENING 而芯片监听消失 → net_relisten_try
Note over N: 另两类:① 本层 ESTABLISHED 而芯片已断 → handle_disconnect_event<br/>③④ 本层 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 头阻塞与错误码回传全审计
@@ -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 |
+7 -1
View File
@@ -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`,本表只登记不展开。
## 使用方式
+3
View File
@@ -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 专属"),
+179
View File
@@ -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. <repo>\.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=<path> 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
+212
View File
@@ -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 <file>`, 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
}
+121 -6
View File
@@ -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
+534
View File
@@ -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 <RegistrationToken> --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 : <line>" 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
+1 -1
View File
@@ -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
1 date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi
2 2026-10-12 205516 207636 18044 18300 1036 1080 152484 153120 223560 225936 21.3 21.5 96096 96776 73.3 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
+374
View File
@@ -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 <math.h>
#include <stdint.h>
#include <stdio.h>
#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;
}
+38
View File
@@ -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))
+31 -7
View File
@@ -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)
+38 -9
View File
@@ -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:
+166 -4
View File
@@ -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 <math.h>
#include <stdint.h>
#include <stdio.h>
@@ -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);
+86 -4
View File
@@ -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 <stdint.h>
#include <stdio.h>
@@ -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);
+159
View File
@@ -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 <soe.log 路径>
可选:--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("<I", rec, OFF_SEQ)[0]
epoch = struct.unpack_from("<I", rec, OFF_EPOCH)[0]
value = struct.unpack_from("<I", rec, OFF_VALUE)[0]
dt, iv = cp56_decode(rec[OFF_CP56:OFF_CP56 + 7])
if rtype not in TYPE_NAMES:
fails.append("rec %d: bad type %d" % (i, rtype))
if rtype in (1, 2) and elm >= 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())
+39
View File
@@ -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))
+213
View File
@@ -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 <stdint.h>
#include <stdio.h>
#include <string.h>
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;
}
+1 -1
View File
@@ -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)")
+3 -3
View File
@@ -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('</svg>')
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"))))