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 / 行尾空格全清。已提交本地,未推送。
378 lines
14 KiB
Python
378 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""Parse Keil MDK-ARM .map file and generate structured RAM/Flash usage report.
|
||
|
||
Usage:
|
||
python scripts/ram_report.py [MAP_FILE] [-o OUTPUT]
|
||
|
||
Output: Markdown tables to stdout or file, ready for inclusion in docs.
|
||
"""
|
||
import re
|
||
import sys
|
||
import os
|
||
import pathlib
|
||
import argparse
|
||
from datetime import datetime
|
||
|
||
MAP_DEFAULT = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"MDK-ARM", "STM32F407-Demo", "STM32F407-Demo.map",
|
||
)
|
||
|
||
# Rows in "Image component sizes" that re-aggregate objects (would double-count sums).
|
||
TOTALS_ROWS = {"Object Totals", "Library Totals", "User Totals"}
|
||
|
||
# ARM library aggregate rows (e.g. mc_w.l / mf_w.l / m_wm.l): the linker prints one
|
||
# aggregate line per library in addition to the individual member .o lines, so any
|
||
# "<lib>.l" row is a re-aggregation and must be dropped from per-file sums.
|
||
# (2026-10-11: sha256's 64-bit shifts pulled in m_wm.l — the hardcoded name list
|
||
# missed it and the self-consistency check went +58; match the name pattern instead.)
|
||
_LIB_AGG_RE = re.compile(r'^[a-z]{1,2}_[a-z0-9]+\.l$')
|
||
|
||
|
||
def _is_aggregate(name):
|
||
"""Aggregate rows to drop from per-file sums: library-name summaries and Totals lines.
|
||
|
||
'(incl. Padding)' / '(incl. Generated)' are linker metadata ledger rows; they ARE
|
||
kept so that per-file sums reconcile exactly with Grand Totals (self-check Δ=0).
|
||
"""
|
||
return name in TOTALS_ROWS or bool(_LIB_AGG_RE.match(name))
|
||
|
||
|
||
def parse_execution_region_header(line):
|
||
"""Parse: Execution Region RW_IRAM1 (Exec base: 0x20000000, ..., Size: 0x0001e8b0, Max: 0x00020000, ...)"""
|
||
m = re.search(
|
||
r'Execution Region (\w+) \((.*?)\)', line)
|
||
if not m:
|
||
return None
|
||
name = m.group(1)
|
||
body = m.group(2)
|
||
# Split by comma first (handles multi-word keys like "Exec base")
|
||
params = {}
|
||
for part in body.split(","):
|
||
part = part.strip()
|
||
if ":" in part:
|
||
k, v = part.split(":", 1)
|
||
params[k.strip()] = v.strip()
|
||
return {
|
||
"name": name,
|
||
"exec_base": int(params.get("Exec base", "0"), 16),
|
||
"size": int(params.get("Size", "0"), 16),
|
||
"max": int(params.get("Max", "0"), 16),
|
||
}
|
||
|
||
|
||
def parse_map(path):
|
||
text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
|
||
lines = text.splitlines()
|
||
|
||
result = {
|
||
"regions": [],
|
||
"iram1_entries": [],
|
||
"grand_totals": None,
|
||
"totals": {},
|
||
"components": [],
|
||
}
|
||
|
||
section = None # track which section we're in
|
||
region_name = None
|
||
skip_header = 0
|
||
|
||
i = 0
|
||
while i < len(lines):
|
||
ln = lines[i].rstrip()
|
||
|
||
# Detect execution region headers
|
||
if "Execution Region" in ln and "Exec base:" in ln:
|
||
region = parse_execution_region_header(ln)
|
||
if region:
|
||
result["regions"].append(region)
|
||
region_name = region["name"]
|
||
section = "region_entries"
|
||
skip_header = 2 # skip header line + blank
|
||
i += 1
|
||
continue
|
||
|
||
# Detect "Image component sizes"
|
||
if ln.strip() == "Image component sizes":
|
||
section = "components"
|
||
skip_header = 3 # skip header + blank + column header
|
||
i += 1
|
||
continue
|
||
|
||
# Detect Grand Totals
|
||
if "Grand Totals" in ln:
|
||
parts = ln.split()
|
||
# 219976 11898 11496 528 124576 5416149 Grand Totals
|
||
nums = []
|
||
for p in parts:
|
||
try:
|
||
nums.append(int(p))
|
||
except ValueError:
|
||
if "Grand" in p:
|
||
break
|
||
if len(nums) >= 6:
|
||
result["grand_totals"] = {
|
||
"code": nums[0], "inc_data": nums[1], "ro_data": nums[2],
|
||
"rw_data": nums[3], "zi_data": nums[4], "debug": nums[5],
|
||
}
|
||
section = None
|
||
i += 1
|
||
continue
|
||
|
||
# Detect Total RO/RW/ROM lines
|
||
m_tot = re.match(r'\s+Total (RO|RW|ROM)\s+Size.*?\(\s*(\d+)\s*\(\s*([\d.]+)kB\)', ln)
|
||
if m_tot:
|
||
result["totals"][m_tot.group(1)] = {
|
||
"bytes": int(m_tot.group(2)),
|
||
"kb": float(m_tot.group(3)),
|
||
}
|
||
i += 1
|
||
continue
|
||
|
||
# Parse region entries
|
||
if section == "region_entries" and region_name == "RW_IRAM1":
|
||
if skip_header > 0:
|
||
skip_header -= 1
|
||
i += 1
|
||
continue
|
||
if ln.strip() == "" or ln.strip().startswith("Exec Addr") or ln.strip().startswith("Load Addr"):
|
||
i += 1
|
||
continue
|
||
if ln.strip().startswith("0x"):
|
||
entry = _parse_region_entry(ln)
|
||
if entry:
|
||
result["iram1_entries"].append(entry)
|
||
elif not ln.strip().startswith("0x") and ln.strip():
|
||
# Hit a new section or summary
|
||
section = None
|
||
|
||
# Parse component sizes
|
||
if section == "components":
|
||
if skip_header > 0:
|
||
skip_header -= 1
|
||
i += 1
|
||
continue
|
||
if ln.strip() == "" or "Object Name" in ln:
|
||
i += 1
|
||
continue
|
||
comp = _parse_component_line(ln)
|
||
if comp:
|
||
result["components"].append(comp)
|
||
|
||
i += 1
|
||
|
||
return result
|
||
|
||
|
||
def _parse_region_entry(ln):
|
||
"""Parse a Memory Map entry line like:
|
||
0x20000000 COMPRESSED 0x00000020 Data RW 292 .data freertos.o
|
||
0x20000039 COMPRESSED 0x00000003 PAD
|
||
"""
|
||
parts = ln.split()
|
||
if len(parts) < 4:
|
||
return None
|
||
try:
|
||
exec_addr = int(parts[0], 16)
|
||
except ValueError:
|
||
return None
|
||
load_addr = parts[1]
|
||
try:
|
||
size = int(parts[2], 16)
|
||
except ValueError:
|
||
return None
|
||
typ = parts[3]
|
||
if typ == "PAD":
|
||
return {"exec_addr": exec_addr, "load_addr": load_addr, "size": size,
|
||
"type": "PAD", "attr": "", "idx": 0, "section": "", "object": ""}
|
||
if len(parts) < 7:
|
||
return None
|
||
attr = parts[4] if parts[4] not in (".data", ".bss", ".text") else ""
|
||
# Find idx (integer) and section (.something) and object (*.o)
|
||
idx = 0
|
||
section = ""
|
||
obj_parts = []
|
||
for j in range(4, len(parts)):
|
||
p = parts[j]
|
||
if p.isdigit():
|
||
idx = int(p)
|
||
elif p.startswith("."):
|
||
section = p
|
||
elif ".o" in p or ".l(" in p:
|
||
obj_parts.append(p)
|
||
elif section and not p.startswith("."):
|
||
obj_parts.append(p)
|
||
obj = " ".join(obj_parts) if obj_parts else ""
|
||
return {"exec_addr": exec_addr, "load_addr": load_addr, "size": size,
|
||
"type": typ, "attr": attr, "idx": idx, "section": section, "object": obj}
|
||
|
||
|
||
def _parse_component_line(ln):
|
||
"""Parse: 2738 536 262 1 4104 41449 ch395f.o"""
|
||
parts = ln.split()
|
||
if len(parts) < 7:
|
||
return None
|
||
try:
|
||
code = int(parts[0])
|
||
inc_data = int(parts[1])
|
||
ro_data = int(parts[2])
|
||
rw_data = int(parts[3])
|
||
zi_data = int(parts[4])
|
||
debug = int(parts[5])
|
||
except ValueError:
|
||
return None
|
||
obj_name = " ".join(parts[6:])
|
||
return {
|
||
"code": code, "inc_data": inc_data, "ro_data": ro_data,
|
||
"rw_data": rw_data, "zi_data": zi_data, "debug": debug,
|
||
"object": obj_name,
|
||
}
|
||
|
||
|
||
def fmt(n):
|
||
"""Format number with comma separator."""
|
||
return f"{n:,}"
|
||
|
||
|
||
def hexfmt(n):
|
||
return f"0x{n:08X}"
|
||
|
||
|
||
def generate_report(data, map_path):
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
lines = []
|
||
w = lines.append
|
||
|
||
w(f"# STM32F4-Base RAM/Flash Usage Report")
|
||
w(f"")
|
||
w(f"> Generated: {now} Map: `{os.path.basename(map_path)}`")
|
||
w("")
|
||
|
||
# Grand totals
|
||
gt = data["grand_totals"]
|
||
if gt:
|
||
total_ro = gt["code"] + gt["ro_data"]
|
||
total_rw = gt["rw_data"] + gt["zi_data"]
|
||
total_rom = total_ro + gt["rw_data"]
|
||
w("## 1. Grand Totals (全局总量)")
|
||
w("")
|
||
w("| Metric | Value |")
|
||
w("|---|---|")
|
||
w(f"| Code (inc. data) | {fmt(gt['code'])} B ({gt['code']/1024:.1f} KB) |")
|
||
w(f"| RO Data | {fmt(gt['ro_data'])} B ({gt['ro_data']/1024:.1f} KB) |")
|
||
w(f"| RW Data (initialized) | {fmt(gt['rw_data'])} B ({gt['rw_data']/1024:.2f} KB) |")
|
||
w(f"| ZI Data (zero-init) | {fmt(gt['zi_data'])} B ({gt['zi_data']/1024:.1f} KB) |")
|
||
w(f"| **Total RO (Code + RO)** | **{fmt(total_ro)} B ({total_ro/1024:.1f} KB)** |")
|
||
w(f"| **Total RW (RW + ZI) = SRAM used** | **{fmt(total_rw)} B ({total_rw/1024:.2f} KB)** |")
|
||
w(f"| **Total ROM (Code + RO + RW init)** | **{fmt(total_rom)} B ({total_rom/1024:.1f} KB)** |")
|
||
w("")
|
||
|
||
# Execution regions
|
||
w("## 2. 执行区水位 (Execution Regions)")
|
||
w("")
|
||
w("| Region | Base | Size | Max | Util% |")
|
||
w("|---|---|---|---|---|")
|
||
for r in data["regions"]:
|
||
util = r["size"] / r["max"] * 100 if r["max"] else 0
|
||
w(f"| {r['name']} | {hexfmt(r['exec_base'])} | {fmt(r['size'])} B ({r['size']/1024:.2f} KB) | {fmt(r['max'])} B ({r['max']/1024:.0f} KB) | {util:.1f}% |")
|
||
w("")
|
||
|
||
# IRAM1 entries (ZI + RW symbol detail)
|
||
zii_entries = [e for e in data["iram1_entries"] if e["type"] in ("Zero", "Data") and e["size"] > 0]
|
||
zii_entries.sort(key=lambda e: e["size"], reverse=True)
|
||
w("## 3. RW_IRAM1 逐符号明细(ZI + RW,按大小降序)")
|
||
w("")
|
||
w("| # | Address | Size (B) | Size (KB) | Type | Section | Object |")
|
||
w("|---|---|---|---|---|---|---|")
|
||
for idx, e in enumerate(zii_entries, 1):
|
||
kb = e["size"] / 1024
|
||
w(f"| {idx} | {hexfmt(e['exec_addr'])} | {fmt(e['size'])} | {kb:.2f} | {e['type']} | {e['section']} | {e['object']} |")
|
||
total_zii = sum(e["size"] for e in zii_entries)
|
||
w(f"| | | **{fmt(total_zii)}** | **{total_zii/1024:.2f}** | | | **Total** |")
|
||
w("")
|
||
|
||
# Image component sizes (sorted by RW+ZI desc) — 仅保留 *.o 个体行,求和与 Grand Totals 对齐
|
||
comps = [c for c in data["components"] if not _is_aggregate(c["object"])]
|
||
comps.sort(key=lambda c: c["rw_data"] + c["zi_data"], reverse=True)
|
||
top_n = [c for c in comps if c["rw_data"] + c["zi_data"] > 0]
|
||
w("## 4. Image Component Sizes(RW + ZI > 0,按大小降序)")
|
||
w("")
|
||
w("| # | Object | Code | RO | RW | ZI | RW+ZI |")
|
||
w("|---|---|---|---|---|---|---|")
|
||
for idx, c in enumerate(top_n, 1):
|
||
rw_zi = c["rw_data"] + c["zi_data"]
|
||
w(f"| {idx} | {c['object']} | {fmt(c['code'])} | {fmt(c['ro_data'])} | {fmt(c['rw_data'])} | {fmt(c['zi_data'])} | {fmt(rw_zi)} |")
|
||
total_comp_rw = sum(c["rw_data"] for c in comps)
|
||
total_comp_zi = sum(c["zi_data"] for c in comps)
|
||
total_comp_code = sum(c["code"] for c in comps)
|
||
total_comp_ro = sum(c["ro_data"] for c in comps)
|
||
w(f"| | **All .o files** | **{fmt(total_comp_code)}** | **{fmt(total_comp_ro)}** | **{fmt(total_comp_rw)}** | **{fmt(total_comp_zi)}** | **{fmt(total_comp_rw+total_comp_zi)}** |")
|
||
w("")
|
||
|
||
# Flash breakdown (sorted by Code+RO desc)
|
||
flash_comps = sorted(comps, key=lambda c: c["code"] + c["ro_data"], reverse=True)
|
||
flash_top = [c for c in flash_comps if c["code"] + c["ro_data"] > 100]
|
||
w("## 5. Flash (Code + RO Data) Top-30(按大小降序)")
|
||
w("")
|
||
w("| # | Object | Code | RO Data | Code+RO |")
|
||
w("|---|---|---|---|---|")
|
||
for idx, c in enumerate(flash_top[:30], 1):
|
||
c_ro = c["code"] + c["ro_data"]
|
||
w(f"| {idx} | {c['object']} | {fmt(c['code'])} | {fmt(c['ro_data'])} | {fmt(c_ro)} |")
|
||
total_code = sum(c["code"] for c in comps)
|
||
total_ro_d = sum(c["ro_data"] for c in comps)
|
||
w(f"| | **All .o files** | **{fmt(total_code)}** | **{fmt(total_ro_d)}** | **{fmt(total_code+total_ro_d)}** |")
|
||
w("")
|
||
|
||
# Self-consistency check against Grand Totals
|
||
# Per-file sums exclude "(incl. Padding/Generated)" linker metadata; their bytes are
|
||
# counted in Grand Totals, so a small positive delta (~300 B here) is expected.
|
||
if gt:
|
||
tol = 8
|
||
deltas = {
|
||
"Code": total_comp_code - gt["code"],
|
||
"RO": total_comp_ro - gt["ro_data"],
|
||
"RW": total_comp_rw - gt["rw_data"],
|
||
"ZI": total_comp_zi - gt["zi_data"],
|
||
}
|
||
ok = all(abs(d) <= tol for d in deltas.values())
|
||
status = "PASS" if ok else "FAIL"
|
||
if all(d == 0 for d in deltas.values()):
|
||
note = "精确一致(含 '('incl. Generated)'-'(incl. Padding)' 台账行)"
|
||
else:
|
||
note = "差值=图像对齐填充(incl. Padding/Generated 行)"
|
||
deltas_txt = " / ".join(f"{k}Δ={d:+d}" for k, d in deltas.items())
|
||
w(f"> 自洽校验:per-file {fmt(total_comp_code)}/{fmt(total_comp_ro)}/{fmt(total_comp_rw)}/{fmt(total_comp_zi)}"
|
||
f" vs Grand Totals {fmt(gt['code'])}/{fmt(gt['ro_data'])}/{fmt(gt['rw_data'])}/{fmt(gt['zi_data'])}"
|
||
f"({deltas_txt};{note})→ **{status}**")
|
||
w("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Parse Keil .map → Markdown RAM/Flash report")
|
||
ap.add_argument("map_file", nargs="?", default=MAP_DEFAULT, help="Path to .map file")
|
||
ap.add_argument("-o", "--output", help="Output file (default: stdout)")
|
||
args = ap.parse_args()
|
||
|
||
if not os.path.exists(args.map_file):
|
||
print(f"ERROR: Map file not found: {args.map_file}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
data = parse_map(args.map_file)
|
||
report = generate_report(data, args.map_file)
|
||
|
||
if args.output:
|
||
pathlib.Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||
# newline="\n":Windows 下 write_text 默认把 \n 翻成 \r\n,会跟 .gitattributes
|
||
# (* text=auto eol=lf)与 data/ 目录既有快照的行尾不一致(2026-10-22 实测)。
|
||
pathlib.Path(args.output).write_text(report, encoding="utf-8", newline="\n")
|
||
print(f"OK: Report written to {args.output}", file=sys.stderr)
|
||
else:
|
||
print(report)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|