f4b9bd57d5
范围:AGENTS.md 一处指针、部署环境要求 §2.2/§3.1/§3.3、RAM 方案 6 处现状 数字、内存配图生成器 + 3 张 SVG、两份报告脚本的行尾与路径修正、新增两份 .map/.htm 数据快照。未改任何 .c/.h(仍跑 @build 复核:0 错误 0 警告)。 部署环境要求.md - §3.3 删"任务栈预算"副本表、改为指针:该表已缺 adcTask/iec104Task/ protectTask,defaultTask/timeSyncTask 两值停在旧数,注释却写"由 gen_task_table.py 同源"——该钩子的校验面只有 README.md / AGENTS.md 两处受管块,抄出来的副本没有门禁、改栈后必漂。指针指向 Src/freertos.c + 两份受管表 + RAM 方案 §2.3/§0.6,并留一条"别再抄"的成因说明。 - §2.2 链接布局补 `RW_CCM 0x10000000`(scatter 给 0x0000FF00 = 65,280 B, 2026-10-11 起承载 FreeRTOS 堆;另有 256 B UNINIT_CCM 尾区),写明 CCM 不可 DMA。原文"SRAM3/CCM 故意未映射"与 STM32F407-Demo.sct 直接矛盾。 - §3.1 MCU 行更正为**单精度 FPU(VFPv4-SP,无硬件双精度)、无 TrustZone** (原写"双精度 FPU / TrustZone"是芯片事实错误,也正是报告 F-6 双精度软 浮点被长期误判的土壤);SRAM 行改为"两块都在用,仅 SRAM3 未映射"。 - §3.3 资源预算表补 CCM 行,并把"主 SRAM 125,104 B / 95.4% / 极度紧张" 更正为 75,264 B / 57.4%(余 55,808 B);红线改标在 CCM(余 7,904 B)。 - 变更记录补 2026-10-22 行(含上一提交里的资源表刷新那一笔)。 AGENTS.md:任务表注里 "RAM使用分析与优化方案.md §1.4" 是**指错小节**——§1.4 确实 存在(该文档 §4「方案」下的阶段 1 子项,讲 lftpd 大局部缓冲静态化),但栈余量 台账在 §2.3(任务栈配置与实测峰值)与 §0.6(静态最坏链与判定),故改指这两处 再加 内存内容分配策略.md §6。根因是该文档里 §1(背景)与 §4 下的 1.1~1.5 同号 撞面,人和 doc-links(自引用放过)都会看错——本会话我上一轮就被它带错过一次。 配图与数据(draw-diagram 流程:改生成器顶部数据 → 重跑 → 逐文本核越界) - 新增 data/ram_report_20261022.md、data/stack_report_20261022.md;后者 运行时水位取自 2026-10-22 真机 [STACK] 心跳(8 任务高水位 + heap free=15,800 / min_ever=8,464),静态侧来自同日 .htm(全局最深链 3,024 B)。 - gen_memory_svg.py:三组图数据从 2026-09-21 / 10-10 快照换成 2026-10-22 实测;任务栈 6 → 9 行,堆总量 48KB → 56KB,判定 6/6 → 9/9 PASS,新增 AboveNormal 配色;Flash 子条改为"窄段不压字 + 下方四列图例";任务栈 画布 440 → 620 高。CCM 段总量按 scatter 用 65,280 B(与台账同口径)。 - 三张 SVG 重生成;用"CJK≈1.0em / 其余≈0.58em + 锚点"逐文本估算边界, 修掉 5 处文字越界/压字(生成器内置 ElementTree 自检亦通过)。 - ram_report.py / stack_report.py 两处工具性修正:write_text 未指定 newline 参数 → Windows 下把 data/ 快照写成 CRLF(与 .gitattributes 及既有快照 不符);stack_report 的 relative_to 在传相对 -i 时抛 ValueError,且会把 本机绝对路径写进入库快照 → 统一走 resolve+relative_to 的相对路径回退。 RAM 方案:6 处"当前状态"随 .map/[STACK] 更正——RW_IRAM1 89,168 → 75,264、 RW_CCM 49,184 → 57,376、FreeRTOS 堆 48KB → 56KB(两处)、CCM 余量 ~16KB → 7,904 B、min_ever 4,848 → 8,464 B、6 任务全过 → 9/9 PASS;S-1 历史句保留。 门禁:pre-commit 对全部改动文件 Passed(doc-links --sections-strict、 task-table、mem-numbers、mem-budget、id-check 增量拦截);字节卫生自查 BOM / CR / U+FFFD / 行尾空格全清。已提交本地,未推送。
274 lines
11 KiB
Python
274 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""Parse the Keil static call graph (*.htm) and report per-task stack budgets.
|
||
|
||
UV4 / armlink writes "MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm" on every build
|
||
("Static Call Graph"). Every function entry carries:
|
||
|
||
<STRONG><a name="[7a]"></a>StartDefaultTask</STRONG> (Thumb, 508 bytes, Stack size 560 bytes, ...)
|
||
[Stack]<UL><LI>Max Depth = 2776<LI>Call Chain = StartDefaultTask => ... => SPI_WaitFlagStateUntilTimeout</UL>
|
||
|
||
i.e. the function's own frame ("Stack size N bytes") plus the worst-case
|
||
cumulative depth of its whole subtree ("Max Depth = N") and the chain that
|
||
reaches it. That is a build-time stack observability source: no target needed.
|
||
|
||
This tool turns it into:
|
||
1. a per-task budget table (static worst-case depth vs configured stack),
|
||
2. the worst call chain of every task,
|
||
3. a frame-size ranking (which functions are the width hogs),
|
||
4. optionally, runtime high-water-mark data overlaid from a serial log.
|
||
|
||
Runtime lines are produced by stack_probe_poll() in Src/freertos.c:
|
||
[STACK] <task> peak_used=<B>/<cfg> free_min=<B> (<pct>%)
|
||
[STACK] heap free=<B> min_ever=<B>
|
||
|
||
Usage:
|
||
python scripts/stack_report.py [-i HTML] [-s SERIAL_LOG] [-o OUTPUT]
|
||
[--isr-margin 1024] [--min-free-pct 30]
|
||
|
||
Exit code: 0 = all tasks pass, 1 = at least one task fails, 2 = parse error.
|
||
"""
|
||
import argparse
|
||
import os
|
||
import pathlib
|
||
import re
|
||
import sys
|
||
|
||
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"
|
||
|
||
# Entry function of each task -> FreeRTOS task name (attribute prefix in freertos.c).
|
||
ROOTS = {
|
||
"StartDefaultTask": "defaultTask",
|
||
"StartNetTask": "netTask",
|
||
"StartFtpTask": "ftpTask",
|
||
"StartUiTask": "uiTask",
|
||
"start_test_task": "testTask",
|
||
"time_sync_task": "timeSyncTask",
|
||
"adc_task_func": "adcTask",
|
||
"iec104_serv_task": "iec104Task",
|
||
"protect_task": "protectTask",
|
||
}
|
||
|
||
# Runtime lines use the short labels emitted by stack_probe_poll() in freertos.c.
|
||
RUNTIME_ALIAS = {
|
||
"defaultTask": "default",
|
||
"netTask": "net",
|
||
"ftpTask": "ftp",
|
||
"uiTask": "ui",
|
||
"testTask": "test",
|
||
"timeSyncTask": "timesync",
|
||
"adcTask": "adc",
|
||
"iec104Task": "iec104",
|
||
"protectTask": "protect",
|
||
}
|
||
|
||
FUNC_RE = re.compile(
|
||
r'<STRONG><a name="\[[0-9a-f]+\]"></a>([^<]+)</STRONG>\s*\(([^)]*)\)', re.I)
|
||
FRAME_RE = re.compile(r'Stack size\s+(\d+)\s+bytes', re.I)
|
||
DEPTH_RE = re.compile(r'Max Depth\s*=\s*(\d+)', re.I)
|
||
CHAIN_RE = re.compile(r'Call Chain\s*=\s*(.*?)</UL>', re.I | re.S)
|
||
UPDATED_RE = re.compile(r'Last Updated:\s*(.*?)$', re.M)
|
||
MAXSTACK_RE = re.compile(r'Maximum Stack Usage\s*=\s*(\d+)\s*bytes', re.I)
|
||
ATTR_RE = re.compile(r'osThreadAttr_t\s+(\w+?)_attributes\s*=\s*\{(.*?)\};', re.S)
|
||
STACKSIZE_RE = re.compile(r'\.stack_size\s*=\s*(\d+)\s*(?:\*\s*(\d+))?')
|
||
RUNTIME_RE = re.compile(
|
||
r'\[STACK\]\s+(\w+)\s+peak_used=(\d+)/(\d+)\s+free_min=(\d+)\s+\((\d+)%\)')
|
||
HEAP_RE = re.compile(r'\[STACK\]\s+heap\s+free=(\d+)\s+min_ever=(\d+)')
|
||
TAG_RE = re.compile(r'<[^>]+>')
|
||
|
||
|
||
def _clean(text):
|
||
"""Strip HTML markup/entities from a chain fragment."""
|
||
text = TAG_RE.sub('', text)
|
||
text = text.replace('⇒', '->').replace('>', '>').replace('<', '<')
|
||
text = text.replace(' ', ' ').replace('&', '&')
|
||
return ' '.join(text.split())
|
||
|
||
|
||
def parse_callgraph(path):
|
||
"""Return (meta, functions); functions maps name -> frame/depth/chain bytes."""
|
||
text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace')
|
||
meta = {"max_stack": 0, "updated": ""}
|
||
m = MAXSTACK_RE.search(text)
|
||
if m:
|
||
meta["max_stack"] = int(m.group(1))
|
||
m = UPDATED_RE.search(text)
|
||
if m:
|
||
meta["updated"] = m.group(1).strip()
|
||
|
||
functions = {}
|
||
matches = list(FUNC_RE.finditer(text))
|
||
for i, hit in enumerate(matches):
|
||
name = hit.group(1).strip()
|
||
head = hit.group(2)
|
||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||
body = text[hit.end():end]
|
||
fm = FRAME_RE.search(head)
|
||
dm = DEPTH_RE.search(body)
|
||
cm = CHAIN_RE.search(body)
|
||
frame = int(fm.group(1)) if fm else 0
|
||
depth = int(dm.group(1)) if dm else frame
|
||
chain = _clean(cm.group(1)) if cm else name
|
||
# Same name may appear once per object; keep the deepest entry.
|
||
old = functions.get(name)
|
||
if old is None or depth > old["depth"]:
|
||
functions[name] = {"frame": frame, "depth": depth, "chain": chain}
|
||
return meta, functions
|
||
|
||
|
||
def parse_task_stacks(path):
|
||
"""Parse configured stack sizes (bytes) from Src/freertos.c attributes."""
|
||
text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace')
|
||
stacks = {}
|
||
for attr_name, body in ATTR_RE.findall(text):
|
||
m = STACKSIZE_RE.search(body)
|
||
if not m:
|
||
continue
|
||
size = int(m.group(1)) * (int(m.group(2)) if m.group(2) else 1)
|
||
stacks[attr_name] = size
|
||
return stacks
|
||
|
||
|
||
def parse_runtime(path):
|
||
"""Extract the last [STACK] block from a serial log: task -> peak/free."""
|
||
text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace')
|
||
tasks = {}
|
||
for m in RUNTIME_RE.finditer(text):
|
||
tasks[m.group(1)] = {
|
||
"peak": int(m.group(2)), "cfg": int(m.group(3)),
|
||
"free": int(m.group(4)), "pct": int(m.group(5)),
|
||
}
|
||
heap = None
|
||
hits = list(HEAP_RE.finditer(text))
|
||
if hits:
|
||
heap = {"free": int(hits[-1].group(1)), "min_ever": int(hits[-1].group(2))}
|
||
return tasks, heap
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description=__doc__,
|
||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument('-i', '--html', default=str(HTM_DEFAULT),
|
||
help='static call graph HTML (default: MDK-ARM output)')
|
||
ap.add_argument('-s', '--serial', default=None,
|
||
help='serial log with [STACK] lines (optional, runtime overlay)')
|
||
ap.add_argument('-o', '--output', default=None, help='write markdown report here')
|
||
ap.add_argument('--isr-margin', type=int, default=1024,
|
||
help='bytes reserved for ISR nesting (default 1024)')
|
||
ap.add_argument('--min-free-pct', type=int, default=30,
|
||
help='required free margin in %% of the stack (default 30)')
|
||
args = ap.parse_args()
|
||
|
||
html_path = pathlib.Path(args.html)
|
||
if not html_path.is_file():
|
||
print(f'ERROR: call graph HTML not found: {html_path}', file=sys.stderr)
|
||
print(' build first: .venv\\Scripts\\python.exe .dsh\\skills\\keil-build\\scripts\\keil_build.py', file=sys.stderr)
|
||
return 2
|
||
|
||
meta, functions = parse_callgraph(html_path)
|
||
stacks = parse_task_stacks(FREERTOS_C)
|
||
runtime, heap = ({}, None)
|
||
if args.serial:
|
||
runtime, heap = parse_runtime(args.serial)
|
||
|
||
out = []
|
||
add = out.append
|
||
add('# Stack budget report')
|
||
add('')
|
||
# 显示用相对路径:传相对 -i 时 relative_to 会抛 ValueError(2026-10-22 实测踩到),
|
||
# 且绝不把本机绝对路径写进入库的 data/ 快照。
|
||
def _disp(pth):
|
||
try:
|
||
return pathlib.Path(pth).resolve().relative_to(REPO_ROOT)
|
||
except (ValueError, OSError):
|
||
return pathlib.Path(pth).name
|
||
|
||
add(f'- Source: `{_disp(html_path)}` (Keil static call graph)')
|
||
if meta["updated"]:
|
||
add(f'- Linker run: {meta["updated"]}')
|
||
add(f'- Global maximum stack usage: **{meta["max_stack"]} B** (any call tree)')
|
||
if runtime:
|
||
add(f'- Runtime watermark: {len(runtime)} task(s) from 串口采集日志 `{_disp(args.serial)}`')
|
||
add('')
|
||
add('Rule: `margin = cfg - max(static_max_depth, runtime_peak)` must be '
|
||
f'>= max({args.isr_margin} B ISR, {args.min_free_pct}% of cfg).')
|
||
add('')
|
||
add('| Task | Entry | Cfg (B) | Static max (B) | Runtime peak (B) | Worst (B) | Margin (B) | Margin % | Verdict |')
|
||
add('|---|---|---|---|---|---|---|---|---|')
|
||
|
||
failures = 0
|
||
rows = []
|
||
for entry, task in ROOTS.items():
|
||
fn = functions.get(entry)
|
||
cfg = stacks.get(task, 0)
|
||
if fn is None or cfg == 0:
|
||
rows.append((task, entry, cfg, None, None, None, None, None,
|
||
'N/A (not in image)'))
|
||
continue
|
||
depth = fn["depth"]
|
||
rt = runtime.get(task) or runtime.get(RUNTIME_ALIAS.get(task, ""))
|
||
rt_peak = rt["peak"] if rt else 0
|
||
# The verdict must use the worse of the two sources: the static graph is an
|
||
# upper bound for straight-line code but a LOWER bound once indirect calls /
|
||
# recursion are involved, and only the runtime watermark covers paths that
|
||
# actually ran (e.g. ftpTask's file-transfer loop).
|
||
worst = max(depth, rt_peak)
|
||
margin = cfg - worst
|
||
pct = (margin * 100) // cfg if cfg else 0
|
||
need = max(args.isr_margin, (cfg * args.min_free_pct) // 100)
|
||
ok = margin >= need
|
||
if not ok:
|
||
failures += 1
|
||
rows.append((task, entry, cfg, depth, rt_peak if rt else None, worst,
|
||
margin, pct, 'PASS' if ok else 'FAIL'))
|
||
for (task, entry, cfg, depth, rt_peak, worst, margin, pct, verdict) in rows:
|
||
if depth is None:
|
||
add(f'| {task} | `{entry}` | {cfg or "-"} | - | {rt_peak if rt_peak else "-"} '
|
||
f'| - | - | - | {verdict} |')
|
||
else:
|
||
add(f'| {task} | `{entry}` | {cfg} | {depth} | {rt_peak if rt_peak else "-"} '
|
||
f'| {worst} | {margin} | {pct}% | {verdict} |')
|
||
add('')
|
||
add('`Static max` = armlink worst-case call-tree depth; `Runtime peak` = measured '
|
||
'high-water mark (boot + whatever workload ran before the sample); '
|
||
'`Worst` = max of the two, which is what the verdict is computed from.')
|
||
add('')
|
||
|
||
if heap:
|
||
add(f'Runtime heap: free={heap["free"]} B, min_ever={heap["min_ever"]} B')
|
||
add('')
|
||
|
||
add('## Worst call chain per task')
|
||
add('')
|
||
for entry, task in ROOTS.items():
|
||
fn = functions.get(entry)
|
||
if fn is None:
|
||
continue
|
||
add(f'### {task}: {fn["depth"]} B (own frame {fn["frame"]} B)')
|
||
add('')
|
||
add(f'```\n{fn["chain"]}\n```')
|
||
add('')
|
||
|
||
add('## Frame-size ranking (own frame, top 15)')
|
||
add('')
|
||
add('| Bytes | Function |')
|
||
add('|---|---|')
|
||
for name, info in sorted(functions.items(), key=lambda kv: -kv[1]["frame"])[:15]:
|
||
add(f'| {info["frame"]} | `{name}` |')
|
||
add('')
|
||
|
||
report = '\n'.join(out)
|
||
if args.output:
|
||
# newline="\n":避免 Windows 换行翻译污染 data/ 快照的 LF 行尾(与 .gitattributes 一致)。
|
||
pathlib.Path(args.output).write_text(report, encoding='utf-8', newline='\n')
|
||
print(f'written: {args.output}')
|
||
else:
|
||
print(report)
|
||
|
||
return 1 if failures else 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|