266 lines
8.0 KiB
Python
Executable File
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()
|