feat(门禁): 栈判据定稿——静态 margin 唯一拦提交、运行时水位只告警、采集不健康整轮作废

task-32 裁定落成可执行件(道F 交付)。不是二选一而是**分权**:静态最坏链 margin 仍是唯一拦提交项(由
`stack_report.py` 守,它本就把运行时峰值取进 max);新增 `stack_gate.py` 管台架侧——趋势只出 WARN,
**绝对底线**(free_min < 25% cfg)是运行时唯一有 FAIL 权限的一档,**采集健康门不过 = VOID / exit 2**,
不红不绿、不进台账。把「daemon 死了 status 仍报正常、rx_lines=0」那类事故机械化:门不过与设备故障
exit 1 分开记,按码聚合的人不会把端口没开当成又崩了一次栈。

容差是推导的:tol = 上取整到 16 B 的 max(128 B, 2×实测摆动),实测 80 B ⇒ 统一 160 B,登记进
`scripts/stack_tolerance.tsv` 带来源列;**改数权限写死**——只有台架重测后才允许回填,禁止为让某轮变绿
而调大容差(被这把尺考核的人不得当场决定尺子的松紧),且这句话**紧贴数值写**。

`--selftest` 14 条能红,开发中抓到作者三个真错(身份戳正则把 __DATE__ 月份写成三位数字、一度把跨轮戳
不同当 VOID 会让判据永不可用、文档 §4.0.2 被 doc-links 抓跨文档串号)。**不接 pre-commit**:它的输入
是台架串口采集,提交时既无设备也无当轮日志,挂上去要么永远 SKIP、要么拿上一轮日志判这一轮的码(与
体积门禁那条假凭证同族)。
This commit is contained in:
2026-10-27 11:31:53 +08:00
parent 728d60675f
commit 13001f2ae4
3 changed files with 362 additions and 4 deletions
@@ -100,12 +100,39 @@
判据写"崩溃遗言一整行、恰好一次、`dropped=0`",并按 `boot_log_data/README.md` §2「故障注入日志」 判据写"崩溃遗言一整行、恰好一次、`dropped=0`",并按 `boot_log_data/README.md` §2「故障注入日志」
运行时放宽 `@test`(`--forbidden` 去掉 `[ERR]`、`--max-boot-count 2`)——**不改仓库配置**。 运行时放宽 `@test`(`--forbidden` 去掉 `[ERR]`、`--max-boot-count 2`)——**不改仓库配置**。
## 5. 栈与堆的比对口径 ## 5. 栈判据(task-32 定稿:三档分权,不再是"待定")
同一份像连跑 4 次仅复位启动,uiTask 峰值实测 4116~4188 B(**自身摆动 72 B**),heap 计数逐字节稳定。 **一句话裁定:静态最坏链是唯一拦提交项(`scripts/stack_report.py` 每任务 verdict);运行时
因此:**不要**用"栈水位不劣于上一轮"这种单次比较当判据。要么取 ≥3 次的最大值对最大值, 水位只出告警(`scripts/stack_gate.py`,超容差 = WARN);采集健康门不过则整轮作废(VOID,
要么把 `scripts/stack_report.py` 的静态最坏链当硬线、运行时峰值只作趋势(定稿见 task-32)。 既不红也不绿、也不得写进台账)。**
| 档 | 判据 | 等级与权限 | 由谁跑 |
|---|---|---|---|
| 硬线 | `margin = cfg − max(静态最坏链, 运行时峰值) ≥ max(1024 B ISR, cfg×30%)` | **FAIL = 拦提交** | `scripts/stack_report.py`(看它退出码。**Lead 裁定 2026-10-27:提交时守这条的一直是 stack_report 本身;`stack_gate.py` 定为台架侧门禁、不接 pre-commit**——提交时既无设备也无当轮日志,挂上去只会「永远 SKIP」或「拿上一轮日志判这一轮的码」,后者是假凭证同族) |
| 趋势 | 同镜像 ≥3 圈取最大值 vs 基线轮 ≥3 圈最大值,`Δ > tol(task)` | **WARN = 记录 + 归因,永不单独拦提交** | `scripts/stack_gate.py` |
| 绝对底线 | 任一任务 `free_min < 25% × cfg` | **FAIL**(运行时唯一有 FAIL 权限的一条,不需要跨轮比较) | `scripts/stack_gate.py` |
| 采集健康门 | 锚点 `reset cause:` 恰 1 次 / 身份戳存在且**同轮唯一** / 每块 ≥20 行 / 每块都有 `[STACK]` 行 / ≥3 圈(基线轮同样要过门,否则本轮只跳趋势比较) | **VOID,exit 2** | `scripts/stack_gate.py` |
**容差是推导值,不是拍脑袋**:`tol(task) = 向上取整到 16 B 格的 max(128 B, 2 × 同镜像实测最大摆动)`。
实测来源:同一份像 4 圈仅复位里 uiTask 摆 72 B、adcTask 摆 80 B(task-30),2026-10-27 那份像 4 圈零摆动
(task-42)→ 最大摆动 80 B ⇒ **全任务统一 160 B**,登记在 `scripts/stack_tolerance.tsv`(含来源列)。
**谁能改这个数**:只有台架在「改栈配置 / 抬栈 / 新增 `DBG_*` 调用点」之后重测同镜像摆动才允许回填;
**禁止为了让某一轮变绿而调大容差**——被这把尺考核的人不得当场决定尺子的松紧(与 §7 里
"Forbidden 令牌不得落在英文单词内部"同源:判据的松紧与写法都要由测量侧一次性定死)。
**身份戳与归因**:跨轮比较本来就是两份镜像,正常;但 Δ 只有在**附上两次构建之间的源码 diff**
时才可归因到某笔改动。台账行必须写**两次的 `fw build` 时间戳(含 `suite=`/`dstr=`)**,
SHA 只当坐标(2026-10-26 那天 SHA 被 amend 挪了三轮)。同镜像比较里出现"零摆动"不代表判据失效,
只代表这次没噪声。
**为什么必须留 VOID 这一档**(设计输入=当天两次自纠):常驻 daemon 死掉后 `status` 会自动拉起
一个新 daemon 并"正常"应答,`rx_lines=0` 也照样返回成功 JSON;而 Forbidden 令牌 `Fault` 会命中
`de`+`fault` 制造假红。所以判据**不许依赖采集健康的自我感觉**:门不过 → exit 2 与 exit 1 分开,
按码聚合的人不会把"端口没开"记成"又崩了一次栈"。
**判据自己必须能红**:`& '.venv\Scripts\python.exe' scripts/stack_gate.py --selftest`(14 条,含
"72 B 实测摆动不算红"、"超容差只 WARN"、"单圈基线拒比"、"死采集器 VOID"、"同轮混两份身份戳 VOID"、
"绝对底线 FAIL"、"峰值取最坏一圈而非最后一圈")。**改完判据不跑这条,就等于没改。**
## 6. 锁与脚手架 ## 6. 锁与脚手架
- bench 锁是**会话级**,必须手工 acquire/release,且**用同一个名字**释放。 - bench 锁是**会话级**,必须手工 acquire/release,且**用同一个名字**释放。
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""stack_gate.py - 栈水位趋势判据(task-32 定稿的可执行形态,纯标准库)。
模块名称:台架栈水位判据器(趋势 + 采集健康门)
模块功能:把 N 段启动日志里的 [STACK] 水位汇成"本轮最大值",与基线轮最大值按每任务
容差比较,输出四档结论;**先过采集健康门,再谈比较**。
创建日期:2026-10-27(裁定见 docs/内存管理/RAM使用分析与优化方案.md §4.0.6 第③条)
为什么要有它:旧判据「栈水位不劣于上一轮」拿单次运行时峰值跨轮比较,而同一份镜像自己
就会摆(实测 uiTask 4116~4188 = 72 B、adcTask 920~1000 = 80 B),于是既假红又漏报。
本脚本把那条判据拆成三件事,等级不同、互不越权:
1) 硬线(唯一拦提交项)= scripts/stack_report.py 的每任务 verdict:
margin = cfg - max(静态最坏链, 运行时峰值) >= max(isr_margin, cfg*30%)
本脚本不重算也不替代它,只在末尾提示其退出码(接不接 pre-commit 由 Lead 定)。
2) 趋势(告警级,永不单独拦提交)= 同一身份戳、>=3 圈取最大值,与基线轮最大值比,
超每任务容差 -> WARN。容差按字节,来源见 scripts/stack_tolerance.tsv。
3) 作废(VOID,优先于前两者)= 采集健康门不过:本轮既不判过也不判不过,也不得写进台账
结论。判据不许依赖"采集看起来很健康"这种自我感觉——2026-10-27 实证:常驻 daemon
死后 status 会自动拉起一个新 daemon,仍报"正常"而 rx_lines=0。
退出码(与 verify_log.py 同族口径:测量问题绝不伪装成设备故障):
0 = PASS(含通过但有趋势 WARN)
1 = FAIL(运行时绝对底线破线:free_min < 25% of cfg —— 设备侧真消息)
2 = VOID_MEASUREMENT(采集健康门未过:本轮不可用,也不算设备的账)
3 = 用法错误
"""
import argparse
import os
import re
import sys
RUNTIME_FLOOR_PCT = 25 # 运行时绝对底线(与静态 30% 硬线错开一档:峰值只是下界证据)
MIN_CIRCLES = 3 # 少于 3 圈 = 噪声样本,不做跨轮比较
DEFAULT_TOL = 160 # 未登记任务的兜底容差(字节),推导见 scripts/stack_tolerance.tsv 头注
STACK_RE = re.compile(r"\[STACK\]\s+(\w+)\s+peak_used=(\d+)/(\d+)\s+free_min=(\d+)\s*\((\d+)%\)")
# __DATE__ 的月份是**英文名**("Oct 26 2026 22:52:09"),不是数字——早先写成 \d{3} 会让
# 每一份真实日志都判成"没有身份戳"而整轮作废(判据全红比判据漏更响,但也别拿假红当纪律)。
STAMP_RE = re.compile(r"fw build:\s*([A-Za-z]{3}\s+\d{1,2}\s+\d{4}\s+\d{2}:\d{2}:\d{2})\s+suite=(\S+)\s+dstr=(\S+)")
ANCHOR = "reset cause:"
def parse_block(text):
"""取一段日志的 (每任务峰值, 身份戳, 锚点数, 有效行数)。
同一任务在一块里出现多次时取最大值——栈峰值的语义就是"最坏那一次",不做平均。
"""
payload = [l for l in text.splitlines() if l.strip() and not l.lstrip().startswith("#")]
peaks = {}
for ln in payload:
m = STACK_RE.search(ln)
if m:
name = m.group(1)
used, cfg, free = int(m.group(2)), int(m.group(3)), int(m.group(4))
prev = peaks.get(name)
if prev is None or used > prev["used"]:
peaks[name] = {"used": used, "cfg": cfg, "free": free}
stamps = STAMP_RE.findall("\n".join(payload))
uniq = sorted(set(stamps))
stamp = None
if uniq:
stamp = uniq[0] if len(uniq) == 1 else ("MIXED:" + str(len(uniq)))
return {"peaks": peaks, "anchors": sum(1 for l in payload if ANCHOR in l),
"stamp": stamp, "lines": len(payload)}
def measurement_gate(blocks, label):
"""(ok, reasons)。任一条不满足就整轮作废:宁可不出结论,也不出错结论。"""
reasons = []
if len(blocks) < MIN_CIRCLES:
reasons.append("%s: only %d circle(s), need >= %d for a peak-vs-peak comparison"
% (label, len(blocks), MIN_CIRCLES))
stamps = set()
for i, b in enumerate(blocks):
n = i + 1
if b["anchors"] != 1:
reasons.append("%s block#%d: boot anchor count=%d (want exactly 1) -> capture did "
"not start at a reset, or the windows overlap" % (label, n, b["anchors"]))
if b["lines"] < 20:
reasons.append("%s block#%d: only %d payload line(s) -> the collector was not "
"carrying this boot (a respawned daemon still answers 'ok')"
% (label, n, b["lines"]))
if not b["peaks"]:
reasons.append("%s block#%d: zero [STACK] lines (a missing line is not zero used)"
% (label, n))
if not b["stamp"]:
reasons.append("%s block#%d: no 'fw build:' identity stamp -> cannot prove which "
"image these numbers belong to" % (label, n))
elif str(b["stamp"]).startswith("MIXED"):
reasons.append("%s block#%d: %s identity stamps inside one block -> the capture "
"crossed two images" % (label, n, b["stamp"]))
else:
stamps.add(b["stamp"])
# 同一轮内部出现多个身份戳 = 采集窗跨了一次重烧,这轮的数字不属于任何一份镜像 -> 作废。
# 注意:**跨轮**戳不同是正常的(那正是要比的两份镜像),不能按作废处理,见 evaluate()。
if len(stamps) > 1:
reasons.append("%s: this round mixes %d identity stamps %s -> one circle was reflashed "
"mid-round, these peaks belong to no single image"
% (label, len(stamps), sorted(stamps)))
return (not reasons), reasons
def load_tolerances(path):
"""容差表:每行 task<TAB>bytes[<TAB>来源],# 开头为注释。缺文件则全用默认值。"""
tol = {}
if not path or not os.path.isfile(path):
return tol, "no tolerance file -> default %d B for every task" % DEFAULT_TOL
for raw in open(path, "r", encoding="utf-8").read().splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) >= 2 and parts[1].isdigit():
tol[parts[0]] = int(parts[1])
return tol, "loaded %d task tolerance(s) from %s" % (len(tol), os.path.basename(path))
def worst_of(blocks):
"""把本轮各圈压成"每任务最大值"(峰值就该取最坏一次)。"""
agg = {}
for b in blocks:
for name, info in b["peaks"].items():
prev = agg.get(name)
if prev is None or info["used"] > prev["used"]:
agg[name] = dict(info)
return agg
def decide(cur, base, tol_map):
"""跨轮趋势:只出 WARN。FAIL 的权限只给绝对底线,硬线归 stack_report.py。"""
rows, warns = [], []
for name in sorted(cur):
info = cur[name]
bl = base.get(name)
tol = tol_map.get(name, DEFAULT_TOL)
pct = (100 * info["free"] // info["cfg"]) if info["cfg"] else 0
row = {"task": name, "cur_max": info["used"], "cfg": info["cfg"], "free_min": info["free"],
"floor_pct": pct, "tol": tol, "base_max": (bl or {}).get("used"),
"delta": (info["used"] - bl["used"]) if bl else None, "level": "PASS"}
if pct < RUNTIME_FLOOR_PCT:
row["level"] = "FAIL"
warns.append("%s free_min=%d/%d (%d%%) below the %d%% runtime floor"
% (name, info["free"], info["cfg"], pct, RUNTIME_FLOOR_PCT))
elif bl is not None and (info["used"] - bl["used"]) > tol:
row["level"] = "WARN"
warns.append("%s peak rose %d B over baseline max (%d -> %d, tol=%d B): trend only, "
"the blocking decision is stack_report.py's margin verdict"
% (name, info["used"] - bl["used"], bl["used"], info["used"], tol))
rows.append(row)
return rows, warns
def evaluate(cur_blocks, base_blocks, tol_map):
"""(exit_code, verdict, 报告行)。顺序:先门,再绝对底线,最后才是趋势告警。"""
out = []
ok_cur, why_cur = measurement_gate(cur_blocks, "current")
if not ok_cur:
out.append("[VOID_MEASUREMENT] this round is not usable:")
out.extend(" - " + r for r in why_cur)
out.append(" -> fix the measurement first (open the port and watch rx_lines grow,")
out.append(" capture from a reset, prove the identity stamp). A dead collector must")
out.append(" not become a stack FAIL, and this round must NOT enter the ledger.")
return 2, "VOID_MEASUREMENT", out
if base_blocks:
ok_base, why_base = measurement_gate(base_blocks, "baseline")
if not ok_base:
out.append("[WARN] baseline round failed its own gate -> trend skipped this run:")
out.extend(" - " + r for r in why_base)
out.append(" (we refuse to compare against numbers we cannot trust; the current")
out.append(" round still gets its absolute-floor check)")
base_blocks = []
cur = worst_of(cur_blocks)
base = worst_of(base_blocks) if base_blocks else {}
rows, warns = decide(cur, base, tol_map)
cur_stamp = cur_blocks[0]["stamp"]
base_stamp = base_blocks[0]["stamp"] if base_blocks else None
cross_image = bool(base_blocks) and cur_stamp != base_stamp
out.append("%-9s %8s %9s %6s %7s %6s %5s %s"
% ("task", "cur_max", "base_max", "delta", "cfg", "free%", "tol", "level"))
for r in rows:
out.append("%-9s %8d %9s %6s %7d %5d%% %5d %s" % (
r["task"], r["cur_max"],
"-" if r["base_max"] is None else r["base_max"],
"-" if r["delta"] is None else ("%+d" % r["delta"]),
r["cfg"], r["floor_pct"], r["tol"], r["level"]))
fails = [w for w in warns if "floor" in w]
trend = [w for w in warns if "floor" not in w]
for w in fails:
out.append("[FAIL] " + w)
for w in trend:
out.append("[WARN] " + w)
if not base_blocks:
out.append("note: no usable baseline -> trend comparison off this run (levels come from "
"the absolute runtime floor only)")
elif cross_image:
out.append("note: CROSS-IMAGE comparison (current=%s vs baseline=%s). Different stamps "
"are normal here -- but a delta is only attributable to a given change if the "
"source diff between those two builds is attached; write BOTH stamps into the "
"ledger row, never the commit SHA alone (SHAs moved three times on 2026-10-26)."
% (cur_stamp[0], base_stamp[0]))
else:
out.append("note: same-image comparison (stamps match: %s) -> a delta over tolerance is "
"measurement noise or an unexplained regression; re-measure before writing it up."
% (cur_stamp[0],))
out.append("hard line unchanged: run scripts/stack_report.py; its per-task verdict "
"(margin >= max(isr_margin, 30% of cfg), computed from the larger of the static "
"chain and the runtime peak) is the only stack criterion that blocks a commit.")
if fails:
return 1, "FAIL", out
if trend:
return 0, "PASS_WITH_WARN", out
return 0, "PASS", out
def synth(stamp, anchors, entries, filler=24):
"""造一段日志文本(selftest 用)。entries = [(task, used, cfg, free)]。"""
L = ["[RX] 2026-10-27T10:00:%02d.000+08:00 [PROF] filler=%d" % (i % 60, i) for i in range(filler)]
if stamp:
L.append("[RX] 2026-10-27T10:00:00.000+08:00 [2026-10-27 02:00:00][APP] fw build: "
+ stamp + " suite=NONE dstr=off")
for _ in range(anchors):
L.append("[RX] 2026-10-27T10:00:00.100+08:00 [2026-10-27 02:00:00][APP] reset cause: "
"software (NVIC_SystemReset) (CSR=0x14000000)")
for task, used, cfg, free in entries:
L.append("[RX] 2026-10-27T10:00:01.000+08:00 [2026-10-27 02:00:01][STACK] %s "
"peak_used=%d/%d free_min=%d (%d%%)" % (task, used, cfg, free, 100 * free // cfg))
return "\n".join(L) + "\n"
def selftest(out=print):
"""证明四档都能真的亮——不能失败的判据等于没有判据。"""
cases = []
def chk(name, got, want):
cases.append((name, got == want, "got=%r want=%r" % (got, want)))
tasks = ("default", "net", "ftp", "ui", "timesync", "adc", "iec104", "protect", "test")
clean = [(t, 1000, 4096, 3096) for t in tasks]
S = "Oct 26 2026 22:52:09"
b1 = parse_block(synth(S, 1, clean))
b2 = parse_block(synth(S, 1, [(t, 1040, 4096, 3056) for t in tasks]))
b3 = parse_block(synth(S, 1, [(t, 990, 4096, 3106) for t in tasks]))
chk("three clean circles -> PASS", evaluate([b1, b2, b3], [], {})[1], "PASS")
base72 = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4116, 16384, 12268)]))]
cur72 = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4188, 16384, 12196)]))] * 3
chk("the measured 72 B swing is not a red flag", evaluate(cur72, base72, {"ui": 160})[1], "PASS")
cur_big = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 4388, 16384, 11996)]))] * 3
base72r = base72 * 3 # 基线轮也要 >=3 圈:单圈基线是噪声样本,不许拿它比
chk("delta over tolerance = WARN not FAIL", evaluate(cur_big, base72r, {"ui": 160})[1], "PASS_WITH_WARN")
chk("single-circle baseline is refused (trend skipped, no false red)",
evaluate(cur_big, base72, {"ui": 160})[1], "PASS")
chk("cross-round different stamps still compares (with attribution note)",
0, evaluate([parse_block(synth("Oct 26 2026 22:52:09", 1, [("ui", 4400, 16384, 11984)]))] * 3,
base72r, {"ui": 160})[0])
low = [parse_block(synth("Oct 26 2026 19:43:23", 1, [("ui", 13000, 16384, 3384)]))] * 3
chk("runtime floor breach = FAIL", evaluate(low, [], {})[1], "FAIL")
dead = parse_block(synth(S, 0, []))
chk("dead collector -> VOID (not FAIL)", evaluate([dead] * 3, [], {})[1], "VOID_MEASUREMENT")
two = parse_block(synth(S, 2, clean))
chk("two boot anchors -> VOID", evaluate([two, two, two], [], {})[1], "VOID_MEASUREMENT")
nostamp = parse_block(synth("", 1, clean))
chk("missing identity stamp -> VOID", evaluate([nostamp] * 3, [], {})[1], "VOID_MEASUREMENT")
mixed = parse_block(synth(S, 1, clean) + synth("Oct 25 2026 09:00:00", 1, clean))
chk("mixed stamps in one block -> VOID", evaluate([mixed] * 3, [], {})[1], "VOID_MEASUREMENT")
cross = parse_block(synth("Oct 25 2026 09:00:00", 1, clean))
chk("round that mixes two stamps -> VOID", evaluate([b1, b2, cross], [], {})[1], "VOID_MEASUREMENT")
chk("only two circles -> VOID", evaluate([b1, b2], [], {})[1], "VOID_MEASUREMENT")
chk("unusable baseline -> trend skipped, floor still enforced",
evaluate([b1, b2, b3], [dead], {})[1], "PASS")
chk("peak takes the worst circle, not the last", worst_of([b1, b3, b2])["ui"]["used"], 1040)
bad = 0
for name, ok, detail in cases:
out("%-5s %s%s" % ("[OK]" if ok else "[FAIL]", name, "" if ok else " " + detail))
if not ok:
bad += 1
out("SELFTEST: %d case(s), %d failure(s) -> %s" % (len(cases), bad, "PASS" if bad == 0 else "FAIL"))
return 0 if bad == 0 else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="bench stack water-level trend gate (task-32 ruling)")
ap.add_argument("--block", action="append", default=[],
help="current-round boot log block, repeatable (need >= %d)" % MIN_CIRCLES)
ap.add_argument("--baseline-block", action="append", default=[],
help="previous-round boot log block, repeatable; omit = trend comparison off")
ap.add_argument("--tolerance-tsv", default="scripts/stack_tolerance.tsv",
help="per-task tolerance table (task TAB bytes); missing task -> %d B" % DEFAULT_TOL)
ap.add_argument("--selftest", action="store_true", help="run built-in classification cases and exit")
a = ap.parse_args(argv)
if a.selftest:
return selftest()
if not a.block:
print("usage error: give at least one --block (or --selftest)")
return 3
tol_map, note = load_tolerances(a.tolerance_tsv)
blocks = [parse_block(open(p, "r", encoding="utf-8", errors="replace").read()) for p in a.block]
base = [parse_block(open(p, "r", encoding="utf-8", errors="replace").read()) for p in a.baseline_block]
print("stack trend gate -- %s (floor=%d%% of cfg, min circles=%d)" % (note, RUNTIME_FLOOR_PCT, MIN_CIRCLES))
code, verdict, lines = evaluate(blocks, base, tol_map)
for ln in lines:
print(ln)
print("VERDICT: %s" % verdict)
return code
if __name__ == "__main__":
sys.exit(main())
+21
View File
@@ -0,0 +1,21 @@
# scripts/stack_tolerance.tsv -- 栈水位跨轮容差(字节)。判据裁定见
# docs/内存管理/RAM使用分析与优化方案.md §4.0.6 第③条;用法:scripts/stack_gate.py。
#
# 容差是**推导值不是拍的**:tol(task) = 向上取整到 16 B 格的 max(128 B, 2 x 同镜像实测最大摆动)。
# 实测来源(台架原始日志在 docs/测试说明/boot_log_data/):
# 2026-10-26 task-30(同一份 1d4b6a17 像、4 圈仅复位):uiTask 4116~4188 = 72 B、adcTask 80 B
# 2026-10-27 task-42(HEAD 像、4 圈仅复位):全部任务零摆动
# -> 最大同镜像摆动 80 B => tol = max(128, 160) = **160 B**,全任务统一。
# 谁改这个数:**只允许台架**在「改栈配置 / 抬栈 / 新增 DBG 日志调用点」后重测同镜像摆动并回填,
# 并在下面那列写上来源;**禁止为了让某一轮变绿而调大容差**(与 Forbidden 令牌那条同源:
# 判据的松紧不能由被它考核的人当场决定)。
# 格式:task<TAB>bytes<TAB>来源
default 160 task-30/42 实测同镜像最大摆动 80 B x2(2026-10-27)
net 160 同上
ftp 160 同上
ui 160 同上
timesync 160 同上
adc 160 同上
iec104 160 同上
protect 160 同上
test 160 同上(仅测试构建创建)
1 # scripts/stack_tolerance.tsv -- 栈水位跨轮容差(字节)。判据裁定见
2 # docs/内存管理/RAM使用分析与优化方案.md §4.0.6 第③条;用法:scripts/stack_gate.py。
3 #
4 # 容差是**推导值不是拍的**:tol(task) = 向上取整到 16 B 格的 max(128 B, 2 x 同镜像实测最大摆动)。
5 # 实测来源(台架原始日志在 docs/测试说明/boot_log_data/):
6 # 2026-10-26 task-30(同一份 1d4b6a17 像、4 圈仅复位):uiTask 4116~4188 = 72 B、adcTask 80 B
7 # 2026-10-27 task-42(HEAD 像、4 圈仅复位):全部任务零摆动
8 # -> 最大同镜像摆动 80 B => tol = max(128, 160) = **160 B**,全任务统一。
9 # 谁改这个数:**只允许台架**在「改栈配置 / 抬栈 / 新增 DBG 日志调用点」后重测同镜像摆动并回填,
10 # 并在下面那列写上来源;**禁止为了让某一轮变绿而调大容差**(与 Forbidden 令牌那条同源:
11 # 判据的松紧不能由被它考核的人当场决定)。
12 # 格式:task<TAB>bytes<TAB>来源
13 default 160 task-30/42 实测同镜像最大摆动 80 B x2(2026-10-27)
14 net 160 同上
15 ftp 160 同上
16 ui 160 同上
17 timesync 160 同上
18 adc 160 同上
19 iec104 160 同上
20 protect 160 同上
21 test 160 同上(仅测试构建创建)