Files
Mulberry/tools/python/add_line_numbers_to_asm.py
T
2026-07-12 15:06:31 -05:00

266 lines
8.0 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright 2025 karas84 (https://github.com/karas84)
# SPDX-License-Identifier: MIT
#
# This script inserts source line debug information (.loc directives) into assembly files
# using data extracted from a JSON "stdump" file generated by the ccc tool (version 2.1),
# available at https://github.com/chaoticgd/ccc.
#
# Original concept by Mc-muffin (https://github.com/Mc-muffin).
from __future__ import annotations
import re
import io
import sys
import json
import argparse
from typing import Callable, Protocol, cast
from pathlib import Path
class STDUMPJson:
def __init__(self, json_path: Path):
self._json = json.loads(json_path.read_text())
self._line_cache = self._build_line_cache()
self._validate_sub_source_files()
def _build_line_cache(self):
line_cache: dict[int, int] = {}
functions = self._json["functions"]
for fun in functions:
if "line_numbers" not in fun:
continue
for addr, num in fun["line_numbers"]:
# there may be more lines for the same address. Ghidra seems to
# keep the last one, so that's what we are also doing
line_cache[addr] = num
return line_cache
def _validate_sub_source_files(self):
functions = self._json["functions"]
for fun in functions:
if (line_numbers := fun.get("line_numbers")) is None:
continue
if (sub_source_files := fun.get("sub_source_files")) is None:
continue
line_numbers = cast(list[tuple[int, int]], line_numbers)
sub_source_files = cast(list[tuple[int, str]], sub_source_files)
# # some functions may have "unterminated" inlines, but they usually have just one line number
# if len(sub_source_files) % 2 != 0: # and len(line_numbers) != 1:
# print(fun["name"], len(line_numbers))
# for addr, source in sub_source_files:
def find_function(self, name: str):
functions = self._json["functions"]
fun = next((f for f in functions if f["name"] == name), None)
return fun
def get_line(self, addr: int):
return self._line_cache.get(addr)
def make_range_checker(lst: list[tuple[int, str]], delimiter: str) -> Callable[[int], bool]:
"""
Given a sorted list of (number, label) where number increases,
build a checker that returns True if x is in a valid interval.
Interpretation:
- Each entry (n, label) marks the interval starting at n and going
up to the next entry's n (exclusive). The last entry's interval
goes to +inf.
- An interval starting at n is valid iff label == delimiter.
- If the first entry's label != delimiter, everything before the first n is valid.
"""
if not lst:
# no markers -> everything valid
return lambda x: True
# ensure sorted by number
lst_sorted = sorted(lst, key=lambda t: t[0])
assert lst == lst_sorted
nums = [t[0] for t in lst_sorted]
labels = [t[1] for t in lst_sorted]
# Precompute intervals as (start, end_exclusive, is_valid)
intervals: list[tuple[int, int | None, bool]] = []
n_items = len(nums)
for i in range(n_items):
start = nums[i]
end_exclusive: int | None
if i + 1 < n_items:
end_exclusive = nums[i + 1]
else:
end_exclusive = None # means to +inf
is_valid = labels[i] == delimiter
intervals.append((start, end_exclusive, is_valid))
first_before_is_valid = labels[0] != delimiter
def is_valid_fn(x: int) -> bool:
# before first number
if x < nums[0]:
return first_before_is_valid
# find the interval that contains x
for start, end_exclusive, valid_flag in intervals:
if end_exclusive is None:
if x >= start:
return valid_flag
else:
if start <= x < end_exclusive:
return valid_flag
# fallback (shouldn't happen)
return False
return is_valid_fn
def is_always_valid_fn(addr: int):
return True
def add_lines_to_asm(
asm_path: Path,
stdump_json_path: Path,
fun_start_offset: int,
keep_original_numbers: bool,
asm_out: Path | None,
):
stdump_json = STDUMPJson(stdump_json_path)
function_name = asm_path.stem
if (fun := stdump_json.find_function(function_name)) is None:
raise RuntimeError(f"Cannot find function '{function_name}' in ccc's JSON")
sub_source_files = fun.get("sub_source_files")
# print(len(sub_source_files) if sub_source_files is not None else None)
asm_lines = asm_path.read_text().splitlines()
re_instr = re.compile(r"^\s*\/\* [A-Z0-9]+ ([A-Z0-9]{8}) [A-Z0-9]{8} \*\/ .*$")
line_dict: dict[int, int] = {}
relative_path: str = fun["relative_path"]
non_func_addrs: list[int] = []
if sub_source_files:
checker = make_range_checker(sub_source_files, relative_path)
else:
checker = is_always_valid_fn
start_line_num: int = sys.maxsize
for line in asm_lines:
if m := re_instr.match(line):
instr_addr = int(m.group(1), 16)
line_num = stdump_json.get_line(instr_addr)
if line_num:
line_dict[instr_addr] = line_num
if not checker(instr_addr):
non_func_addrs.append(instr_addr)
elif line_num:
start_line_num = min(start_line_num, line_num)
if start_line_num == sys.maxsize:
start_line_num = 1
min_line_num = 0 if keep_original_numbers else start_line_num - 1
new_asm_lines: list[str] = []
asm_n: int = 0
for line in asm_lines:
if m := re_instr.match(line):
asm_n += 1
instr_addr = int(m.group(1), 16)
line_num = line_dict.get(instr_addr)
if line_num is not None and instr_addr in non_func_addrs:
new_asm_lines.append(f" .loc 1 {line_num} # inline")
new_asm_lines.append(line)
continue
if asm_n == 1 and line_num is None:
# sometimes we don't have a number for the first line of assembly,
# so we reuse the first known line among the ones we have
line_num = start_line_num
if line_num is not None:
new_line_num = (line_num - min_line_num) + fun_start_offset
new_asm_lines.append(f" .loc 1 {new_line_num} ")
new_asm_lines.append(line)
stream = io.StringIO()
stream.write(""".section .debug
.previous
.text
.file 1 "source.c"
.set noat
.set noreorder
""")
stream.write("\n".join(new_asm_lines))
if asm_out is not None:
asm_out.write_text(stream.getvalue())
else:
print(stream.getvalue())
def main():
class ArgProtocol(Protocol):
asm_path: Path
stdump_json_path: Path
asm_out: Path | None
offset: int
keep_original: bool
parser = argparse.ArgumentParser(description="Add line debug info to assembly files")
parser.add_argument("--asm-path", required=True, type=Path, help="Path to the assembly file to add lines to")
parser.add_argument("--stdump-json-path", required=True, type=Path, help="Path to ccc's json stdump")
parser.add_argument("--asm-out", type=Path, required=False, help="Path to output asm (defaults to stdout)")
parser.add_argument(
"--offset", type=int, required=False, default=0, help="Offset to apply to line numbers (default: 0)"
)
parser.add_argument(
"--keep-original",
action="store_true",
help="Don't start line numbers from 1 (+ offset) but keep the original line numbers (+offset) instead",
)
args = cast(ArgProtocol, parser.parse_args())
fun_start_num = args.offset
add_lines_to_asm(
args.asm_path,
args.stdump_json_path,
fun_start_num,
args.keep_original,
args.asm_out,
)
if __name__ == "__main__":
main()