三只 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 / 结尾换行齐。
20 KiB
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(最坏调用链累计)vsSrc/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_localL0c / 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.htest/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"不可归因于被测改动。判据写成"栈水位不劣于上一轮"必然假红。 - 可执行修法(二选一,别都留着):
- 同一份像**≥3 次启动取最大值互比,并加容差(±128 B 量级**);
- 硬线改用
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 堆分配失败。
验证(这轮测量算数了吗)
- 报告头
- Source:的相对路径落在我要量的那棵树下(不是裸文件名,更不是别的树); - Linker run:(或 .htm 的Last Updated:/ mtime)与本轮那次构建对得上,且不同于上一轮那次的时刻 ——相同就是复述,回去先做一次构建再比;- 树身份已按
tree/commit + 时刻 + 产物来源写进提交正文/台账,而不是只留在终端输出里; - 结论里互比的数字必须来自同一棵树同一次链接;跨树比较要各自给出树与时刻;
- 涉及劣化判定时注明依据的是静态最坏链(硬线)还是运行时峰值(趋势),并已计入跨启动抖动。
- 要入库的数字:本轮已按「
@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)需替换解析层,工作流不变。