| #!/usr/bin/env python3 |
| """Diff membrowse JSON reports per source file. |
| |
| Membrowse 1.2.9 truncates `source_file`, so paths come from `object_file`. |
| Memory-layout regions classify flash/RAM; known section names are the fallback. |
| """ |
| import json |
| import os |
| import subprocess |
| |
| from membrowse_report import extract_ld_scripts, extract_defsyms, ninja_commands |
| |
| # Fallback names verified against in-repo linker scripts; anything else counts as flash. |
| RAM_SECTIONS = ('.bss', '.noinit', '.stack', '.heap', |
| 'NonCacheable', 'm_usb_global') |
| # These sections have a flash load image and RAM run address. |
| BOTH_SECTIONS = ('.data', '.relocate', '.ramfunc', '.fastrun', '.itcm', '.dtcm', '.ccmram', |
| '.time_critical', '.fast') |
| |
| _RAM_REGION_HINTS = ('ram', 'tcm', 'ddr') |
| _FLASH_REGION_HINTS = ('flash', 'rom') |
| |
| |
| def _find_ninja_build_dir(elf_path): |
| """Nearest ancestor of `elf_path` containing build.ninja, or None.""" |
| d = os.path.dirname(os.path.abspath(elf_path)) |
| while True: |
| if os.path.isfile(os.path.join(d, 'build.ninja')): |
| return d |
| parent = os.path.dirname(d) |
| if parent == d: |
| return None |
| d = parent |
| |
| |
| def _ld_scripts_and_defsyms(elf_path): |
| """Return linker scripts and defsyms from the ELF's ninja build graph.""" |
| build_dir = _find_ninja_build_dir(elf_path) |
| if build_dir is None: |
| raise RuntimeError(f'no build.ninja found above {elf_path} - cannot ' |
| f'determine its linker scripts') |
| target = os.path.relpath(os.path.abspath(elf_path), build_dir) |
| commands = ninja_commands('ninja', build_dir, target) |
| ld_scripts = extract_ld_scripts(commands) |
| if not ld_scripts: |
| raise RuntimeError(f'no linker script found in the ninja build graph ' |
| f'for {elf_path}') |
| return ld_scripts, extract_defsyms(commands) |
| |
| |
| def report_for_elf(elf_path, map_path=None): |
| """Run membrowse local report on one elf, return parsed JSON dict.""" |
| ld_scripts, defsyms = _ld_scripts_and_defsyms(elf_path) |
| cmd = ['membrowse', 'report', elf_path, ' '.join(ld_scripts), |
| '--json', '--all-symbols'] |
| for sym in defsyms: |
| cmd += ['--def', sym] |
| if map_path and os.path.isfile(map_path): |
| cmd += ['--map-file', map_path] |
| r = subprocess.run(cmd, capture_output=True, text=True) |
| if r.returncode != 0: |
| raise RuntimeError(f'membrowse report failed for {elf_path}: {r.stderr}') |
| return json.loads(r.stdout) |
| |
| |
| def _bucket_by_name(section): |
| """Guess the flash/RAM bucket set from a symbol's section name.""" |
| s = section or '' |
| if any(s.startswith(p) for p in BOTH_SECTIONS): |
| return frozenset({'flash', 'ram'}) |
| if any(s.startswith(p) for p in RAM_SECTIONS): |
| return frozenset({'ram'}) |
| return frozenset({'flash'}) |
| |
| |
| def _classify_region(name): |
| """Classify by region name; membrowse 1.2.9 reports parsed regions as UNKNOWN.""" |
| n = (name or '').lower() |
| if any(h in n for h in _FLASH_REGION_HINTS): |
| return 'flash' |
| if any(h in n for h in _RAM_REGION_HINTS): |
| return 'ram' |
| return None |
| |
| |
| def _section_regions(memory_layout): |
| """Map each section to its memory-layout regions and ELF section types.""" |
| index = {} |
| for region_name, region in memory_layout.items(): |
| for entry in region.get('sections') or []: |
| name = entry.get('name') |
| if not name: |
| continue |
| index.setdefault(name, []).append((region_name, entry.get('type'))) |
| return index |
| |
| |
| def _bucket_from_layout(section_name, section_regions, region_bucket): |
| """Bucket set for `section_name` from the layout, or None if the layout has |
| no usable answer (caller falls back to _bucket_by_name()).""" |
| regions = section_regions.get(section_name) |
| if not regions: |
| return None |
| distinct = {r for r, _ in regions} |
| if len(distinct) > 1: |
| # A split may span flash/RAM or multiple RAM banks; union classifications. |
| buckets = {b for b in (region_bucket.get(r) for r in distinct) if b} |
| return frozenset(buckets) or None |
| region_name, section_type = regions[0] |
| if any(section_name.startswith(p) for p in BOTH_SECTIONS): |
| return frozenset({'flash', 'ram'}) |
| bucket = region_bucket.get(region_name) |
| if bucket is not None: |
| return frozenset({bucket}) |
| # Vendor region names fall back to membrowse's ELF section classification. |
| if section_type == 'data': |
| return frozenset({'ram'}) |
| if section_type in ('code', 'rodata'): |
| return frozenset({'flash'}) |
| return None # SECTION_TYPE_UNKNOWN (not SHF_ALLOC) or missing - no signal |
| |
| |
| def _relative_key(src, filters): |
| """Source path relative to the first matching filter, object suffix stripped, |
| or None when no filter matches.""" |
| for f in filters: |
| idx = src.find(f) |
| if idx < 0: |
| continue |
| key = src[idx + len(f):] |
| for suffix in ('.obj', '.o'): |
| if key.endswith(suffix): |
| return key[:-len(suffix)] |
| return key |
| return None |
| |
| |
| def per_file_sizes(report, filters): |
| """Return flash/RAM sizes keyed by filtered relative source path.""" |
| layout = report.get('memory_layout') or {} |
| section_regions = _section_regions(layout) |
| region_bucket = {name: _classify_region(name) for name in layout} |
| by_file = {} |
| for sym in report.get('symbols', []): |
| src = sym.get('object_file') or sym.get('source_file') or '' |
| if not src or not sym.get('size'): |
| continue |
| key = _relative_key(src, filters) |
| if key is None: |
| continue |
| entry = by_file.setdefault(key, {'flash': 0, 'ram': 0}) |
| buckets = _bucket_from_layout(sym.get('section'), section_regions, region_bucket) |
| for b in buckets or _bucket_by_name(sym.get('section')): |
| entry[b] += sym['size'] |
| return by_file |
| |
| |
| def _fmt(delta): |
| return f'+{delta}' if delta > 0 else str(delta) |
| |
| |
| def compare_reports(base_by_file, cur_by_file): |
| """Markdown per-file delta table; files sorted by |flash delta| desc.""" |
| rows = [] |
| for path in sorted(set(base_by_file) | set(cur_by_file)): |
| b = base_by_file.get(path, {'flash': 0, 'ram': 0}) |
| c = cur_by_file.get(path, {'flash': 0, 'ram': 0}) |
| df, dr = c['flash'] - b['flash'], c['ram'] - b['ram'] |
| rows.append((path, b, c, df, dr)) |
| rows.sort(key=lambda r: abs(r[3]), reverse=True) |
| |
| lines = ['| File | Flash base | Flash new | Flash Δ | RAM base | RAM new | RAM Δ |', |
| '|------|-----------:|----------:|--------:|---------:|--------:|------:|'] |
| tb = {'flash': 0, 'ram': 0} |
| tc = {'flash': 0, 'ram': 0} |
| for path, b, c, df, dr in rows: |
| # totals run over ALL rows; the table prints only changed ones |
| tb['flash'] += b['flash']; tb['ram'] += b['ram'] |
| tc['flash'] += c['flash']; tc['ram'] += c['ram'] |
| if df == 0 and dr == 0: |
| continue |
| lines.append(f'| {path} | {b["flash"]} | {c["flash"]} | {_fmt(df)} ' |
| f'| {b["ram"]} | {c["ram"]} | {_fmt(dr)} |') |
| lines.append(f'| **TOTAL** | {tb["flash"]} | {tc["flash"]} | ' |
| f'{_fmt(tc["flash"] - tb["flash"])} | {tb["ram"]} | {tc["ram"]} | ' |
| f'{_fmt(tc["ram"] - tb["ram"])} |') |
| if len(lines) == 3 and rows: |
| lines.insert(2, '| _no per-file changes_ | | | | | | |') |
| return '\n'.join(lines) + '\n' |