Files
STM32F4-Base/.dsh/skills/mem-budget/SKILL.md
T
wandering1 251268992e docs(skills): 收录陈旧计数同步流程 + 补台账回填动作 + 去掉技能文里自伤门禁的裸形
三只 skill 文件收口(队友写成,Lead 核后入库):

· 新增 `stale-doc-count-sweep`:把今天做的「一个事实数变了之后全仓同步」钉成流程——从权威来源**实测**真值
  → 全形态扫描并**逐条分类**(当前态改 / 变更记录历史行只加当日新行 / 同数不同义的误报不动 / 带日期戳的
  测量只加「当时」锁口径)→ 逐条字面替换且每处断言命中数 → 被改文件全文通读核各小节 → 复扫残留归零 →
  跑门禁分笔提交。这份流程的价值全在**分类**那步:今天有 5 处「27 → 40」是历史事实、1 处「< 40KB」是同数
  不同义(绘制缓冲红线),一并替换就会毁掉它们。
· `mem-budget`:补上 `ram_baseline.py --update`(只往 tsv 追加趋势行)与 `--write`(才同
步台账受管区)的区别、
  以及「提交前台账怎么回填」的动作——这个坑是 Lead 本人 05:0x 踩的(用错一枚参数造出一次真的 mem-budget
  FAIL),写下来比口头转告可靠。
· `task-worktree`:把文中 4 处**自伤**的裸形改成形态描述——该节讲的正是「单字母紧跟数字」这类形态怎么被
  校验 2 计数,结果例子自己就是那种形态,等于技能文自己吃门禁余量。改完不可判定绝对数 **982 → 978**
  (余量 +4),叙述信息量不减:形态与属主都还写清。

⚠ 这条教训值得单记:**文档在讲某个反模式时,自己不要用那个反模式的字面量**——否则每次读它的人都得替它
付余量。同批 `docs/编号规范.md` §4.4 也已登记这类误判面。

自查:`check_ids --strict` exit 0、不可判定绝对数 978 / 上限 1,166;三文件 noBOM / LF / U
+FFFD=0 / 结尾换行齐。
2026-10-27 09:11:18 +08:00

20 KiB
Raw Blame History

name, description, whenToUse
name description whenToUse
mem-budget 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + 权威构建后的台账回填(ram_baseline.py --update 只往 ram_baseline.tsv 追加趋势行、--write 才同步 RAM预算台账 受管区、check_mem_numbers.py --write 同步文档快照区)+ DBG 日志成本模型(每条约 90B Flash/216B 栈)+ 跨树(worktree)测量的身份口径——脚本按 __file__ 定位产物、什么才算一次新测量、多条链同涨同一数值的共享帧/幻影环归因法。"改任务/缓冲/栈之前必须核算"、"这个数属于哪棵树、哪次构建"与"内存/Flash 数字钩子红了、提交前台账怎么回填"的固定动作和判读口径。 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、要回答"这个改动花多少 RAM/Flash";以及要复算或比对另一个 worktree 的栈数字、独立验证某个栈回归该记在哪一笔提交上、判断多次启动的栈峰值抖动算不算劣化时;还有 @build 之后要把内存数字回填进台账/文档、纠结该跑 ram_baseline.py --write 还是 --update、提交时 mem-budget / mem-numbers 两条钩子报"与 .map 不一致"时。

mem-budget — RAM/栈预算核算

固定动作(改前、改后各跑一次)

# cwd = 项目根;先 @build 保证 .htm/.map 是最新的(产物按脚本自身位置定位,见下节)
& '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py"
# 等价于分别跑:
#   scripts/ram_report.py            (.map → RAM/Flash 分区与按文件占用表)
#   scripts/stack_report.py          (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
# 报告落盘留档(改前/改后各一份,供逐帧差分):
& '.venv\Scripts\python.exe' scripts/stack_report.py -o ci-artifacts/sr_before.md
# 叠加运行时水位(有真机日志时):
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log

退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。 别整篇读输出,抓关键行:grep -aE 'iec104|margin|FAIL|ISR|Rule' ci-artifacts/sr_before.md。

「改前 / 改后」必须是两次不同的 Linker run(两个不同的产物身份):同一份 .htm/.map 上跑两遍 只是把同一个数字复述两遍,不构成"改后已复验"。

测量身份口径(先钉树与时刻,再看数字)

1. 脚本按 __file__ 定位产物,不按 cwd —— 跨树必须换脚本

# scripts/stack_report.py:36-38
REPO_ROOT   = pathlib.Path(__file__).resolve().parent.parent
HTM_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.htm"
FREERTOS_C  = REPO_ROOT / "Src" / "freertos.c"        # 配置栈来源,无命令行开关
# scripts/ram_report.py:16-18 同样用 dirname(dirname(abspath(__file__))) 定位 .map

⇒ cd 进别的 worktree 再跑主仓的脚本 量的仍是主仓那份 .htm/.map,数字不可比。 实测代价:这样一次"独立复算"量到主仓的 1,544 B / margin 1,016 B FAIL,据此写下的 +80 B 归因 被队友的逐树逐帧差分表推翻——目标 worktree 的真数是 1,464 B / margin 1,096 B / PASS。

正确做法:量哪棵树就跑那棵树自己的脚本(该树必须已在这棵树上构建过、有自己的 .htm/.map; 开树与合并的生命周期见 task-worktree skill):

'.venv/Scripts/python.exe' "<目标树>/scripts/stack_report.py"
'.venv/Scripts/python.exe' "<目标树>/scripts/ram_report.py"

⚠ 只给主仓脚本传 -i <另一棵树的 .htm>(或位置参数传另一棵树的 .map)不够: stack_report.py 的配置栈仍从脚本所在那棵树的 Src/freertos.c 读(FREERTOS_C 写死自 __file__, 没有开关),两树栈配置一旦不同就是"半棵树的混合数字"。要整支脚本换树跑。

自检(每次复算都做):报告头两行 - Source: ... 与 - Linker run: ... 就是你实际量的树和时刻。 Source 若只打印一个裸文件名(不带 MDK-ARM/... 相对路径),说明 -i 指到了脚本 REPO_ROOT 之外的产物。

2. 重跑报告脚本不算新测量(复述 ≠ 复验)

stack_report.py / ram_report.py 只是解析盘上那一份 .htm/.map;产物没变,再跑一次数字必然不变。 那只能叫"复述",不能写成"我复验过"。要拿到新数字必须先有一次新的 Linker run(增量构建也算), 并把新时刻与产物来源一并写进记录。

⚠ ram_report.py 正文里的 > Generated: … 是脚本运行时刻,重跑就变——它不是链接时刻, 不能拿来当"这是新测量"的凭证。

3. 钉身份的取数法(.htm/.map 按约定不入库 → 把时刻写进正文/台账)

grep -aE 'Last Updated|Maximum Stack Usage' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm | head -2
# → "ARM Linker, 5060960: Last Updated: Mon Oct 26 22:52:12 2026"
  • .htm 里唯一的链接时刻锚点是 Last Updated:;Linker run 只是 stack_report.py 自己输出里的标签, Date of Link、Image component 不在 .htm 里(实测按它们 grep 全空,白跑一轮)。
  • 取不到该字段时退回产物 mtime;同一笔链接里 .htm 与 .map 的 mtime 只差 ~0.1 s,可据此确认两份产物同源。 再配 git log -1 --format=%H 与该树 git status 的未跟踪文件数。
  • 记录最小格式:tree/commit + Linker run 时刻 + 产物来源(本次构建 / 盘上既有)。 本仓 .htm/.map 不入库,不钉时刻就等于把数字和树脱钩,下一个读数字的人无从判断它描述哪棵树。
  • 归属还能机械证明时顺手做掉:git log --name-only <那笔>..HEAD -- "*.c" "*.h" 逐行筛(排除 .uvprojx)。 若那笔之后没有任何提交再碰固件源文件,产物描述的就是在库那棵树,不是提交前快照。
  • 同族坑:git show :path / git show HEAD:path 读的是索引/HEAD,不是工作区。

静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方)

  • 多条任务链分毫不差同涨/同降同一个数值(实测 default/net/iec104 三条 +80 B;断环后九条链各 −80 B) ⇒ 加深在这些任务共用的被调帧 / 共享尾段上,不在任何一个调用方。别逐个调用方翻, 也别先猜"新增了大局部缓冲"——实测猜错过一次(con_ring_try 只有 24 B 帧、没有数组)。
  • 逐棵树逐帧比 frame size,不要只比总数:
    • stack_report.py 输出的「Frame-size ranking (own frame)」表 + 每任务最坏链文本,锁定链尾公共段 (实测落在 dbg_console_write_line → con_append → dbg_console_drain,据此把账记到上一笔提交上);
    • 直接在 .htm 里找环:grep -ac 'In Cycle' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm(实测 17 处), 形态为 Max Depth = 280 + In Cycle 与 Call Chain = Error_Handler => dbg_console_write_line (Cycle)。 + In Cycle 表示 armlink 把环上所有帧计入含该环的每一条任务链——链接器幻影环能在无人改栈配置时让全仓链齐涨。
    • 实测样本(无人改栈配置、全仓链却齐涨的那种):taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒ vApplicationAssertFailed ⇒ dbg_console_write_raw ⇒ 回到 console_log 内部 ⇒ 再进临界区; 它使 vApplicationAssertFailed 的 Max Depth 由 ~40 B 变 128 B + In Cycle,差 88 B ≈ 三条链同涨 80 B 的来源。 断环方向:内核临界区改 PRIMASK 保存/恢复(保存量放局部变量)→ 零调用帧、不成环; 断环的图证(该帧从「136 B + In Cycle」变成「8 B、不再 In Cycle」)比数字更有说服力。
  • Maximum Stack Usage = N bytes + Unknown(Cycles, Untraceable Function Pointers) 里的 + Unknown(...) 说明静态图对环与函数指针不可完全解析,静态数是估计值,别当精确值上报。
  • 不采信任何口头结论:用目标树的脚本 + .venv 解释器自己复算;复算同样先按上节钉身份。
  • 处置后的下一步:抬栈(Src/freertos.c 的 USER CODE 区,属 Ask-first 动作)或收紧共享缓冲 / 断环, 改前改后各一次 stack_report.py + ram_report.py,且"改后"那次必须落在一次新的 Linker run 上; 抬栈后跑 gen_task_table.py 同步任务表。

判读口径

  • 静态:stack_report.py 的 Max Depth(最坏调用链累计)vs Src/freertos.c 配置栈; 默认要求余量 ≥30%(--min-free-pct),ISR 另留 --isr-margin(默认 1024B)。 判定式 margin = cfg - max(static_max_depth, runtime_peak) 须 ≥ max(isr_margin, 30% of cfg)。
  • 运行时:开机日志 [STACK] <task> peak_used=…/… free_min=… 与静态值对照—— 静态最坏链是上界(硬线)、运行时峰值是下界证据(趋势),两者都要看;余量 <1KB 底线要上报。
  • RAM 总量:ram_report.py 对 Grand Totals 与 docs/内存管理/ 预算表核对; 链接错 L6406E = 已经溢出,用按文件表找大头。
  • 任务表一致性由 pre-commit task-table 钩子保证;mem-budget 钩子拦 RW_IRAM1/RW_CCM >92%, 并核台账受管区是否与 .map 同源;mem-numbers 钩子核文档里的内存数字—— 数字与树/时刻脱钩、或构建后只跑了 --update 没回填受管区时,响的就是这两条(回填见下一节)。

权威构建后的台账回填(三个旗标,别混)

.map 是内存/Flash 数字的唯一事实源;台账与文档里的数字由脚本从它生成——不手抄、不手改 (docs/内存管理/RAM预算台账.md 首段就写着"数字不手抄")。三份落点 = 三个旗标,一条命令只干其中一件: ram_baseline.py 的 --gate/--write/--update/--trend 是 add_mutually_exclusive_group() (scripts/ram_baseline.py:415-419),想干两件就得调两次。

落点 旗标 实际改了什么
scripts/ram_baseline.tsv 趋势基线(顺带刷 scripts/size_attestation.json 同码凭证) ram_baseline.py --update 只追加/覆盖当日那一行趋势(Code/RO/RW/ZI + 三区水位 + Top-10 RW+ZI)。不碰台账正文
docs/内存管理/RAM预算台账.md 的 <!-- MEM_BUDGET:BEGIN/END --> 受管区 ram_baseline.py --write 从 .map 重渲染区域水位 + 子系统配额对照(打印「已同步台账受管区。」或「台账受管区已是最新。」)
docs/内存管理/RAM使用分析与优化方案.md 的 <!-- MEM_SNAPSHOT:BEGIN/END --> 快照区 check_mem_numbers.py --write 从同一份 .map 重渲染内存/Flash 快照表(打印「已更新内存快照:…」)

AGENTS.md 与台账 §4 那句「每次 @build 后跑 scripts/ram_baseline.py --update」喂的是趋势行,不等于回填受管区。 实测代价:照那句跑完 --update 就提交 → mem-budget、mem-numbers 两条钩子当场红。 mem-budget 的 entry 是 python scripts/ram_baseline.py --gate(.pre-commit-config.yaml:571)——它在 92% 阈值之外 还核台账受管区与 .map 的一致性,漂移即 [FAIL] 台账受管区与 .map 不一致(区域水位或子系统配额表漂移) 并打印 修复:python scripts/ram_baseline.py --write(scripts/ram_baseline.py:221-224); mem-numbers 的 entry 是 python scripts/check_mem_numbers.py --scan(同文件 :193),不一致时打印 修复:运行 python scripts/check_mem_numbers.py --write(scripts/check_mem_numbers.py:416)。 改用两个 --write 后重提,同一笔提交触发的 26 条钩子全 Passed。

提交前固定顺序(构建 → 回填 → 真钩子提交)

# cwd = 项目根,一律 .venv 解释器
# 0) 权威构建先行(0 错 0 警):回填读的是**盘上那份 .map**,构建没落定就 --write = 把旧数字写进台账,钩子照样红
& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py

# 1) 趋势行(纪律项,不是回填):覆盖 tsv 当日行 + 顺带刷体积同码凭证
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --update

# 2) 回填台账受管区(mem-budget 钩子核的就是这一段)
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --write

# 3) 回填文档快照区(mem-numbers 钩子核的就是这一张表)
& '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --write

# 4) 逐字自跑提交时会跑的这两条 entry(比事后翻 pre-commit 输出快)
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --gate        # 0 = 阈值 + 受管区都过
& '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --scan   # 0 = 快照区与 .map 一致

# 5) 把刚落盘的落点一并 add,然后**真钩子**提交(绝不 --no-verify、绝不手改数字)
git add docs/内存管理/RAM预算台账.md docs/内存管理/RAM使用分析与优化方案.md scripts/ram_baseline.tsv scripts/size_attestation.json

退出码:ram_baseline.py 0=通过或跳过 / 1=阈值超限或受管区漂移 / 2=.map 解析失败; check_mem_numbers.py 0=一致 / 1=不一致、缺受管锚点或找不到事实源。

回填链上的坑(实测)

  • --write 说「已是最新」而钩子还红 ≠ 钩子坏了,是落点找错了:tsv / 台账受管区 / 文档快照区 三处各归一个旗标。按上表对号,别把同一个旗标重跑三遍;手改台账数字更不行——下次 --write 直接覆盖你, 钩子仍按 .map 判漂移。
  • tsv 落后只 WARN、不拦:--gate 对「tsv 末行落后于当前 .map」只打印 [WARN] …请跑 --update (ram_baseline.py:232),拦下来的是受管区漂移。所以"钩子绿"绝不等于"趋势行也补了",反之亦然,各查各的。
  • --gate 无 .map → SKIP,而脚本自己写明「SKIP 不等于 PASS」(ram_baseline.py:177): 新开的 task-worktree 从没在这棵树上构建过时必然走到这里;同一状态下 --write 直接 [FAIL] 找不到 .map 事实源,先 @build(ram_baseline.py:250)——不是代码缺陷,是这棵树还没测过体积。 无需 Keil 的强制兜底是 scripts/check_size_gate.py --local(= scripts/ci_local.py 的 L0c 步)与 CI 的 --ci。
  • --map <path> 只换读、不换写:LEDGER(ram_baseline.py:45)与 TARGETS(check_mem_numbers.py:53-54) 都由 REPO_ROOT = Path(__file__).resolve().parent.parent 算出。拿主仓脚本配 --map <另一棵树>/…map 去 --write, 等于用别的树的数字改写主仓的台账——比"半棵树混合数字"更糟,因为它落盘了。回填永远用本树自己的脚本。
  • --scan 的散句告警 --write 不替你改:正文里手抄的旧值("现 RW_IRAM1 62.6%" 这类散句)只告警, 脚本明确写着「--write 不会自动改写正文」(check_mem_numbers.py:418)→ 人工按受管区改那一行 (Edit 精确替换;含中文的文档绝不整文件重写)。
  • --update 的盖戳排在索引后面:它顺带调 check_size_gate.cmd_stamp()(ram_baseline.py:345), 而凭证指纹按 git 索引内容生成,有未 staged 的脏改动时拒盖,且这里只 [WARN]、不改返回值 → 现象是 tsv 多了一行而凭证仍是旧的,要等 ci_local L0c / CI 体积门禁那一侧才报出来。 要么先 git add 再 --update,要么补跑 python scripts/check_size_gate.py --stamp。
  • 改写型钩子当场改文件是流程不是故障:trailing-whitespace / end-of-file-fixer 报 files were modified by this hook(新建的 .c 最常触发)时,先读 ci-artifacts/cui.log 的真实报错确认是哪几条红, git add 被改写的文件后重提。两条改写红 + 两条内存红混在同一次里时,别顺手把内存那条也当"钩子误报"绕过去。
  • mem-budget 触发面很宽:files: 覆盖 App/ Drivers/BSP/ Src/ Inc/ Lib/lv_conf.h test/board/ scripts/ram_baseline. docs/内存管理/ .pre-commit-config.yaml,且 pre-commit 4.x 已无 always: true —— 纯 UI / 纯文档提交照样被它拦:盘上 .map 与台账不同源就红,跟这笔提交碰没碰内存文件无关。

运行时峰值抖动与"算不算劣化"

  • 同一份镜像连续启动 5 次,uiTask 峰值实测在 4116~4188 之间摆 72 B ⇒ 跨启动本身就有几十 B 抖动, "相对上一轮 +100 B"不可归因于被测改动。判据写成"栈水位不劣于上一轮"必然假红。
  • 可执行修法(二选一,别都留着):
    1. 同一份像**≥3 次启动取最大值互比,并加容差(±128 B 量级**);
    2. 硬线改用 stack_report.py 的静态最坏链,运行时峰值降级为趋势参考。
  • 判据一改要同步所有落点:判据正文、跑判据的脚本、记录判据的文档三处一起改,只改一处等于各说各话。

成本模型(实测,估算用)

  • DBG_* 日志不是免费的:每条调用点约 90 B Flash(字符串常量另计)+ 深链上 每帧一份行缓冲进最坏栈(实测一条链 9 帧 ≈ 1.5 KB 纯日志缓冲)。 已覆盖步骤勿加重复锚点,只在原先静默返回的黑洞处补。
  • 深调用链模块收紧行缓冲:#define DBG_LINE_BUF_MAX 128U 写在 #include "dbg_log.h" 之前;取值必须 ≥ 该模块最长日志行(时间戳 20 + 标签 + 正文),否则截断。
  • 加大局部缓冲的替代方案是静态化(实测 lftpd 9 处大局部缓冲静态化:BSS +4.6 KB, 任务峰值 3480B→1184B)——栈紧张时先找大局部变量。
  • 自研代码禁 C 库堆(MicroLIB 堆仅 512B):只用 pvPortMalloc/vPortFree(FreeRTOS 堆), 模拟器 CRT 堆照不出 MCU 堆分配失败。

验证(这轮测量算数了吗)

  1. 报告头 - Source: 的相对路径落在我要量的那棵树下(不是裸文件名,更不是别的树);
  2. - Linker run:(或 .htm 的 Last Updated: / mtime)与本轮那次构建对得上,且不同于上一轮那次的时刻 ——相同就是复述,回去先做一次构建再比;
  3. 树身份已按 tree/commit + 时刻 + 产物来源 写进提交正文/台账,而不是只留在终端输出里;
  4. 结论里互比的数字必须来自同一棵树同一次链接;跨树比较要各自给出树与时刻;
  5. 涉及劣化判定时注明依据的是静态最坏链(硬线)还是运行时峰值(趋势),并已计入跨启动抖动。
  6. 要入库的数字:本轮已按「@build → ram_baseline.py --update → ram_baseline.py --write → check_mem_numbers.py --write」回填,并逐字自跑 ram_baseline.py --gate 与 check_mem_numbers.py --scan 各返 0; 台账 / 文档 / tsv 的改动与代码同一笔入库,未手改数字、未用 --no-verify。

其它项目怎么套

模式通用:链接器 map + 调用图 htm 是免硬件的栈可观测源;固定"改前改后各跑一次 报告 + 运行时水位叠加 + 余量阈值"三件套,外加一条身份口径——脚本按自身位置定位产物, 跨树比的是"两棵树各自的脚本 + 各自的链接时刻",重跑同一份产物永远只是复述。 本仓库两个报告脚本按 Keil 输出格式解析,换工具链(GCC newlib + -fstack-usage)需替换解析层,工作流不变。