#!/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()