新增内存使用分析文档(报告/规划/优化建议)与 SVG 布局图生成脚本

This commit is contained in:
2026-09-13 17:05:55 +08:00
parent d3f56e6430
commit 841fabc573
8 changed files with 1546 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
#!/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", "mc_w.l", "mf_w.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; they are excluded from
per-file tables but their contribution IS counted in Grand Totals, so the per-file
sums will differ from Grand Totals by the alignment-padding delta (~300B here).
"""
return name in TOTALS_ROWS
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)
pathlib.Path(args.output).write_text(report, encoding="utf-8")
print(f"OK: Report written to {args.output}", file=sys.stderr)
else:
print(report)
if __name__ == "__main__":
main()