diff options
Diffstat (limited to 'userspace/cuda/tools')
| -rwxr-xr-x | userspace/cuda/tools/cudatool.py | 154 | ||||
| -rwxr-xr-x | userspace/cuda/tools/diff_corpus.py | 221 | ||||
| -rw-r--r-- | userspace/cuda/tools/ghidra/AnalyzeFunctions.java | 343 | ||||
| -rwxr-xr-x | userspace/cuda/tools/name_candidates.py | 224 | ||||
| -rwxr-xr-x | userspace/cuda/tools/subsystem_digest.py | 190 |
5 files changed, 1132 insertions, 0 deletions
diff --git a/userspace/cuda/tools/cudatool.py b/userspace/cuda/tools/cudatool.py index 2e4c1c6..3417d2b 100755 --- a/userspace/cuda/tools/cudatool.py +++ b/userspace/cuda/tools/cudatool.py @@ -21,6 +21,7 @@ # strings <lib> [--grep RE] defined strings with file offsets # delta <libA> <libB> interface-level diff between two versions # wrappers <lib> export stub -> generated wrapper target +# fdematch <libA> <libB> function-range match report across versions # note <lib> CUDA forward-compatibility note (JSON) # # Address convention: every address printed by this tool is a FILE VIRTUAL @@ -476,6 +477,151 @@ def cmd_wrappers(args): print(f"0x{w['stub']:08x} -> 0x{w['wrapper']:08x} {w['name']}") +def cmd_fdematch(args): + """Match function ranges between two libraries by unwind-table (FDE) + start/size, using the common exports as local address anchors. + + A single global address shift is not enough across patch versions: the + library is partly re-laid-out (function insertions/removals drift the + addresses locally, and the generated wrapper region is regenerated). The + matcher therefore uses the nearest common export by address as the local + anchor and looks for a B function of the same FDE size within a small + window around old_start + anchor_delta. This measures how transferable a + name map is between two driver versions, and the --names breakdown shows + which parts of the map (exports, generated wrappers, internals) survive.""" + import bisect + from collections import Counter, defaultdict + + a, b = ELF(args.libA), ELF(args.libB) + fa = {s: (e - s if e is not None else None) + for s, e in a.function_ranges()} + fb = {s: (e - s if e is not None else None) + for s, e in b.function_ranges()} + + ea = {s["name"]: s["value"] for s in a.exports()} + eb = {s["name"]: s["value"] for s in b.exports()} + common = sorted(set(ea) & set(eb)) + anchors = sorted((ea[n], eb[n] - ea[n]) for n in common) + anchor_addrs = [x for x, _ in anchors] + deltas = [d for _, d in anchors] + d_counter = Counter(d // 0x1000 * 0x1000 for d in deltas) + + def anchor_delta(v): + i = bisect.bisect_left(anchor_addrs, v) + cand = [] + if i < len(anchors): + cand.append(anchors[i]) + if i > 0: + cand.append(anchors[i - 1]) + if not cand: + return 0 + return min(cand, key=lambda t: abs(t[0] - v))[1] + + size_b = defaultdict(list) + for s, sz in fb.items(): + size_b[sz].append(s) + for k in size_b: + size_b[k].sort() + + def window_matches(v, size, w): + if size is None or size not in size_b: + return [] + target = v + anchor_delta(v) + starts = size_b[size] + lo = bisect.bisect_left(starts, target - w) + hi = bisect.bisect_right(starts, target + w) + return starts[lo:hi] + + exact = window = unique = unmatched = 0 + for v, size in fa.items(): + m = window_matches(v, size, args.window) + if not m: + unmatched += 1 + elif v + anchor_delta(v) in m: + exact += 1 + if len(m) == 1: + unique += 1 + else: + window += 1 + if len(m) == 1: + unique += 1 + matched = exact + window + total = len(fa) + rate = matched / total if total else 0.0 + + transfer = None + if args.names: + transfer = defaultdict(lambda: dict(entries=0, exact=0, window=0, + missing=0, not_in_a=0)) + with open(args.names) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) < 2: + continue + try: + v = int(p[0], 16) + except ValueError: + continue + src = p[2] if len(p) > 2 else "?" + t = transfer[src] + t["entries"] += 1 + if v not in fa: + t["not_in_a"] += 1 + continue + m = window_matches(v, fa[v], args.window) + if not m: + t["missing"] += 1 + elif v + anchor_delta(v) in m: + t["exact"] += 1 + else: + t["window"] += 1 + + if args.json: + print(json.dumps(dict( + lib_a=args.libA, lib_b=args.libB, + functions_a=total, functions_b=len(fb), + anchors=len(anchors), + delta_median=hex(deltas[len(deltas) // 2]) if deltas else "0x0", + delta_clusters=[[hex(k), v] for k, v in d_counter.most_common(6)], + window=hex(args.window), + exact=exact, window_matches=window, unique=unique, + unmatched=unmatched, match_rate=round(rate, 4), + name_map=transfer), indent=1)) + return + + print(f"fdematch A: {args.libA}") + print(f" B: {args.libB}") + print(f"functions : A={total} B={len(fb)} " + f"(ranges from .eh_frame_hdr)") + print(f"anchors : {len(anchors)} common exports") + if deltas: + print(f"delta clusters : " + ", ".join( + f"{'+' if k >= 0 else '-'}0x{abs(k):x} ({v})" + for k, v in d_counter.most_common(5))) + print(f"matching window : +/-0x{args.window:x} around " + f"nearest-anchor-shifted address, same FDE size") + print(f"exact match : {exact} ({exact/total*100:.1f}%)" + if total else "exact match : 0") + print(f"window match : {window} ({window/total*100:.1f}%)" + if total else "window match : 0") + print(f"unique match : {unique} ({unique/total*100:.1f}%)" + if total else "unique match : 0") + print(f"unmatched A : {unmatched}") + print(f"match rate : {rate*100:.1f}% of A's {total} functions") + if transfer: + print(f"name map : {args.names} (per source column)") + for src in sorted(transfer): + t = transfer[src] + got = t["exact"] + t["window"] + pct = got / t["entries"] * 100 if t["entries"] else 0.0 + print(f" {src:<14} : {got}/{t['entries']} ({pct:.1f}%) " + f"exact={t['exact']} window={t['window']} " + f"missing={t['missing']} not-in-A={t['not_in_a']}") + + def cmd_note(args): elf = ELF(args.lib) print(json.dumps(elf.cuda_note(), indent=2)) @@ -502,6 +648,14 @@ def main(): p = sub.add_parser("note"); p.add_argument("lib"); p.set_defaults(fn=cmd_note) p = sub.add_parser("wrappers"); p.add_argument("lib") p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_wrappers) + p = sub.add_parser("fdematch"); p.add_argument("libA"); p.add_argument("libB") + p.add_argument("--names", default=None, + help="names.tsv to report transferability for") + p.add_argument("--window", type=lambda x: int(x, 0), default=0x100, + help="same-size match window around the anchored address " + "(default 0x100)") + p.add_argument("--json", action="store_true") + p.set_defaults(fn=cmd_fdematch) args = ap.parse_args() try: diff --git a/userspace/cuda/tools/diff_corpus.py b/userspace/cuda/tools/diff_corpus.py new file mode 100755 index 0000000..7224ac7 --- /dev/null +++ b/userspace/cuda/tools/diff_corpus.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# diff_corpus.py - diff two decompiled corpus indexes across driver versions. +# +# Inputs are `decompiled_index.tsv` files (the merged output of the batch +# decompiler): a header line and one row per function: +# addr<TAB>file_addr<TAB>size<TAB>name<TAB>file<TAB>line<TAB>status +# `addr` is the Ghidra program address, `file_addr` the file virtual address +# (libcuda+0x...) used everywhere in the analysis. +# +# Matching (function identity across versions): +# 1. Named functions (anything whose name does not start with FUN_) match by +# name; the same name in both indexes is the same function. +# 2. The address shift between the two builds is estimated as the median of +# (file_addrB - file_addrA) over name-matched functions. +# 3. Anonymous FUN_* functions match when B has a function at +# file_addrA + shift; a size difference there is reported as a resize. +# Functions that do not land on a B function are `removed`; B functions +# that no A function maps to are `added`. +# +# This is deliberately address/size based (the corpus has no structural hash), +# so it measures exactly what matters for name-map transfer: did the function +# move, grow, disappear or appear. +# +# Usage: +# diff_corpus.py <indexA.tsv> <indexB.tsv> [--label-a A] [--label-b B] +# [--out FILE] [--max-list N] [--json] +# +# Exit status is 0 for a normal report (differences do not fail the tool). +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import json +import statistics +import sys +from collections import Counter, defaultdict + + +def load_index(path): + funcs = {} + with open(path) as f: + f.readline() # header + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + funcs[int(p[1], 16)] = dict(addr=int(p[0], 16), size=int(p[2]), + name=p[3]) + except ValueError: + continue + return funcs + + +def is_named(name): + return bool(name) and not name.startswith("FUN_") + + +def label(f, names): + if f is None: + return "<none>" + return f"{names.get(f['name'], f['name'])} (libcuda+0x{f['file']:x}, {f['size']}B)" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("index_a") + ap.add_argument("index_b") + ap.add_argument("--label-a", default=None) + ap.add_argument("--label-b", default=None) + ap.add_argument("--out", default=None, help="write the report here " + "(default: stdout)") + ap.add_argument("--max-list", type=int, default=40, + help="max entries listed per category (default 40)") + ap.add_argument("--json", action="store_true", help="emit JSON") + args = ap.parse_args() + + a = load_index(args.index_a) + b = load_index(args.index_b) + la = args.label_a or args.index_a + lb = args.label_b or args.index_b + + named_a = defaultdict(list) + named_b = defaultdict(list) + for fa, f in a.items(): + if is_named(f["name"]): + named_a[f["name"]].append(fa) + for fb, f in b.items(): + if is_named(f["name"]): + named_b[f["name"]].append(fb) + + matched_named = [] # (name, fa, fb) + for name in sorted(set(named_a) & set(named_b)): + # duplicate symbol names (multiple thunks) match in address order + for fa, fb in zip(sorted(named_a[name]), sorted(named_b[name])): + matched_named.append((name, fa, fb)) + + deltas = [fb - fa for _, fa, fb in matched_named] + shift = int(statistics.median(deltas)) if deltas else 0 + dominant = (sum(1 for d in deltas if abs(d - shift) <= 0x40) / + len(deltas)) if deltas else 0.0 + + used_a = set(fa for _, fa, _ in matched_named) + used_b = set(fb for _, _, fb in matched_named) + moved = [(n, fa, fb) for n, fa, fb in matched_named if fa != fb] + resized = [(n, fa, fb) for n, fa, fb in matched_named + if a[fa]["size"] != b[fb]["size"]] + + shifted = [] + for fa, f in sorted(a.items()): + if fa in used_a or is_named(f["name"]): + continue + fb = fa + shift + if fb in b and fb not in used_b: + shifted.append((fa, fb)) + used_a.add(fa) + used_b.add(fb) + shifted_resized = [(fa, fb) for fa, fb in shifted + if a[fa]["size"] != b[fb]["size"]] + shifted_ok = [(fa, fb) for fa, fb in shifted + if a[fa]["size"] == b[fb]["size"]] + + removed = sorted(fa for fa in a if fa not in used_a) + added = sorted(fb for fb in b if fb not in used_b) + + anon_a = sum(1 for f in a.values() if not is_named(f["name"])) + anon_b = sum(1 for f in b.values() if not is_named(f["name"])) + named_count_a = sum(len(v) for v in named_a.values()) + named_count_b = sum(len(v) for v in named_b.values()) + matched_total = len(matched_named) + len(shifted) + + result = dict( + label_a=la, label_b=lb, + total_a=len(a), total_b=len(b), + named_a=named_count_a, named_b=named_count_b, + anon_a=anon_a, anon_b=anon_b, + shift=shift, shift_dominant=round(dominant, 4), + matched_named=len(matched_named), matched_shifted=len(shifted_ok), + matched_shifted_resized=len(shifted_resized), + moved=len(moved), resized_named=len(resized), + added=len(added), removed=len(removed), + match_rate_a=round(matched_total / len(a), 4) if a else 0.0, + match_rate_b=round(matched_total / len(b), 4) if b else 0.0, + named_transfer_rate=round(len(matched_named) / named_count_a, 4) + if named_count_a else 0.0, + ) + + def hexlist(addrs): + return [f"libcuda+0x{x:x} {a[x]['name']} ({a[x]['size']}B)" + if x in a else f"libcuda+0x{x:x} {b[x]['name']} ({b[x]['size']}B)" + for x in addrs[:args.max_list]] + + if args.json: + out = dict(result) + out["added_list"] = [f"libcuda+0x{x:x} {b[x]['name']} ({b[x]['size']}B)" + for x in added[:args.max_list]] + out["removed_list"] = [f"libcuda+0x{x:x} {a[x]['name']} ({a[x]['size']}B)" + for x in removed[:args.max_list]] + text = json.dumps(out, indent=1) + else: + lines = [] + lines.append(f"corpus diff: {la} -> {lb}") + lines.append("=" * 72) + lines.append(f"functions : A={len(a)} B={len(b)} " + f"(named A={len(named_a)} B={len(named_b)}; " + f"anonymous A={anon_a} B={anon_b})") + lines.append(f"address shift : 0x{shift:x} " + f"(dominant for {dominant*100:.1f}% of name matches)") + lines.append(f"matched : {matched_total} " + f"({len(matched_named)} by name, " + f"{len(shifted_ok)} anonymous at shifted address)") + lines.append(f"match rate : A {result['match_rate_a']*100:.1f}% " + f"B {result['match_rate_b']*100:.1f}% " + f"(name map transfer {result['named_transfer_rate']*100:.1f}%)") + lines.append(f"moved : {len(moved)} named functions changed " + f"address") + lines.append(f"resized : {len(resized)} named + " + f"{len(shifted_resized)} anonymous matched functions " + f"changed size") + lines.append(f"added : {len(added)}") + lines.append(f"removed : {len(removed)}") + lines.append("") + if moved: + lines.append(f"-- moved named (first {args.max_list}) --") + for n, fa, fb in moved[:args.max_list]: + lines.append(f" {n}: libcuda+0x{fa:x} -> libcuda+0x{fb:x} " + f"({fb-fa:+#x})") + if resized: + lines.append(f"-- resized named (first {args.max_list}) --") + for n, fa, fb in resized[:args.max_list]: + lines.append(f" {n}: libcuda+0x{fa:x} {a[fa]['size']}B -> " + f"{b[fb]['size']}B") + if shifted_resized: + lines.append(f"-- resized anonymous (first {args.max_list}) --") + for fa, fb in shifted_resized[:args.max_list]: + lines.append(f" libcuda+0x{fa:x} {a[fa]['size']}B -> " + f"libcuda+0x{fb:x} {b[fb]['size']}B") + if added: + lines.append(f"-- added (first {args.max_list}) --") + lines.extend(" " + x for x in + hexlist(added)) + if removed: + lines.append(f"-- removed (first {args.max_list}) --") + lines.extend(" " + x for x in + hexlist(removed)) + text = "\n".join(lines) + + if args.out: + with open(args.out, "w") as f: + f.write(text + "\n") + else: + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userspace/cuda/tools/ghidra/AnalyzeFunctions.java b/userspace/cuda/tools/ghidra/AnalyzeFunctions.java new file mode 100644 index 0000000..e0ae6cb --- /dev/null +++ b/userspace/cuda/tools/ghidra/AnalyzeFunctions.java @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// AnalyzeFunctions.java - Ghidra post-script: run the "Decompiler Parameter ID" +// analyzer over a caller-supplied list of functions, using the analyzer's +// AddressSetView entry point instead of a whole-program analysis pass. +// +// This is the targeted type-recovery pass for the CUDA userspace driver: the +// full auto-analysis deliberately disables the parameter-ID analyzer because it +// decompiles every function and is very expensive on a 29k-function stripped +// binary. Re-running it over a curated address set (crash path, engine-record +// path, handle pool, RM alloc, resolved wrapper implementations, the largest +// .text functions) recovers parameter counts/types and local variables where +// the decompiler can infer them, without touching the rest of the program. +// +// Usage: +// analyzeHeadless <proj_dir> <proj_name> -process <program> -noanalysis \ +// -scriptPath tools/ghidra \ +// -postScript AnalyzeFunctions.java <addresses_file> \ +// [--timeout <secs>] [--out <tsv>] [--list] [--batch <n>] +// +// <addresses_file> one entry per line: "0x<file_vaddr> [# comment]". +// Addresses are FILE virtual addresses (libcuda+0x...). +// Blank lines and lines starting with '#' are ignored. +// --timeout <secs> per-function decompiler timeout for the analyzer +// (default 60, matching the analyzer's own default). +// --out <tsv> write the before/after report to this file as well. +// --list only resolve the addresses to functions and print them; +// do not run the analyzer (dry run). +// --batch <n> run the analyzer in batches of n functions (default 0 = +// one batch). Useful to checkpoint very large sets. +// +// The script translates file vaddr -> program address via the program's image +// base (0x100000 for this binary), exactly like DecompileAt.java. If an address +// is not inside a known function it is disassembled and turned into one. +// +// The analyzer class was renamed between Ghidra versions +// (DecompilerParameterIdAnalyzer -> DecompilerFunctionAnalyzer); the script +// looks up both and invokes the AddressSetView-based entry point +// added(AddressSetView, TaskMonitor) when present, falling back to +// added(Program, AddressSetView, TaskMonitor, MessageLog). + +import ghidra.app.script.GhidraScript; +import ghidra.app.services.Analyzer; +import ghidra.app.util.importer.MessageLog; +import ghidra.framework.options.Options; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSet; +import ghidra.program.model.address.AddressSetView; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Listing; +import ghidra.program.model.listing.Variable; +import ghidra.util.classfinder.ClassSearcher; +import ghidra.util.task.TaskMonitor; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +public class AnalyzeFunctions extends GhidraScript { + + private static final String[] ANALYZER_CLASSES = { + "ghidra.app.plugin.core.analysis.DecompilerFunctionAnalyzer", + "ghidra.app.plugin.core.analysis.DecompilerParameterIdAnalyzer", + }; + + private static final String OPTION_TIMEOUT = "Analysis Decompiler Timeout (sec)"; + + private static class Target { + long fileAddr; + Address entry; + String name; + int paramsBefore; + int localsBefore; + int paramsAfter = -1; + int localsAfter = -1; + } + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: AnalyzeFunctions.java <addresses_file> [--timeout secs] " + + "[--out tsv] [--list] [--batch n]"); + return; + } + + String addrFile = args[0]; + int timeoutSecs = 60; + String outFile = null; + boolean listOnly = false; + int batchSize = 0; + for (int i = 1; i < args.length; i++) { + switch (args[i]) { + case "--timeout": + timeoutSecs = Integer.parseInt(args[++i]); + break; + case "--out": + outFile = args[++i]; + break; + case "--list": + listOnly = true; + break; + case "--batch": + batchSize = Integer.parseInt(args[++i]); + break; + default: + println("unknown argument: " + args[i]); + return; + } + } + + long imageBase = currentProgram.getImageBase().getOffset(); + List<Long> fileAddrs = readAddresses(addrFile); + println("AnalyzeFunctions: " + fileAddrs.size() + " address(es), image base 0x" + + Long.toHexString(imageBase)); + + List<Target> targets = resolveTargets(fileAddrs, imageBase); + println("AnalyzeFunctions: resolved " + targets.size() + " function(s)"); + + if (listOnly) { + for (Target t : targets) { + println(String.format(" libcuda+0x%06x %s program=0x%x params=%d locals=%d", + t.fileAddr, t.name, t.entry.getOffset(), t.paramsBefore, + t.localsBefore)); + } + return; + } + + Analyzer analyzer = newAnalyzer(); + if (analyzer == null) { + println("ERROR: no Decompiler Parameter ID analyzer class found"); + return; + } + println("AnalyzeFunctions: analyzer = " + analyzer.getClass().getName() + + ", timeout = " + timeoutSecs + "s"); + + Options opts = currentProgram.getOptions("Analyzers"); + analyzer.registerOptions(opts, currentProgram); + if (opts.contains(OPTION_TIMEOUT)) { + opts.setInt(OPTION_TIMEOUT, timeoutSecs); + } + analyzer.optionsChanged(opts, currentProgram); + + int txId = currentProgram.startTransaction("AnalyzeFunctions parameter ID"); + boolean commit = false; + try { + runAnalyzer(analyzer, targets, batchSize); + commit = true; + } + finally { + currentProgram.endTransaction(txId, commit); + } + + snapshotAfter(targets, imageBase); + report(targets, outFile); + } + + private List<Long> readAddresses(String path) throws Exception { + List<Long> out = new ArrayList<>(); + try (BufferedReader r = new BufferedReader(new FileReader(path))) { + String line; + while ((line = r.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String tok = line.split("\\s+")[0]; + if (tok.startsWith("0x") || tok.startsWith("0X")) { + tok = tok.substring(2); + } + try { + out.add(Long.parseUnsignedLong(tok, 16)); + } + catch (NumberFormatException e) { + println("WARN: bad address line: " + line); + } + } + } + return out; + } + + private List<Target> resolveTargets(List<Long> fileAddrs, long imageBase) { + List<Target> targets = new ArrayList<>(); + Listing listing = currentProgram.getListing(); + for (long fileAddr : fileAddrs) { + Address addr = toAddr(fileAddr + imageBase); + Function f = getFunctionContaining(addr); + if (f == null) { + FunctionIterator it = listing.getFunctions(addr, false); + if (it.hasNext()) { + f = it.next(); + } + } + if (f == null) { + disassemble(addr); + f = createFunction(addr, null); + } + if (f == null) { + println("WARN: no function at libcuda+0x" + Long.toHexString(fileAddr)); + continue; + } + Target t = new Target(); + t.fileAddr = fileAddr; + t.entry = f.getEntryPoint(); + t.name = f.getName(); + t.paramsBefore = f.getParameterCount(); + t.localsBefore = f.getLocalVariables().length; + targets.add(t); + } + return targets; + } + + private Analyzer newAnalyzer() throws Exception { + // Preferred path: the plugin service discovers analyzers via ClassSearcher, + // which also works when the analyzer lives in a module that is not visible + // to the script's class loader (Class.forName then fails). + try { + ClassSearcher.search(monitor); + for (Class<? extends Analyzer> c : ClassSearcher.getClasses(Analyzer.class)) { + Object o = c.getDeclaredConstructor().newInstance(); + Analyzer a = (Analyzer) o; + if ("Decompiler Parameter ID".equals(a.getName())) { + return a; + } + } + } + catch (Exception e) { + println("WARN: ClassSearcher lookup failed: " + e); + } + // Fallback: direct reflection over the known class names. + for (String cn : ANALYZER_CLASSES) { + try { + Class<?> c = Class.forName(cn); + Object o = c.getDeclaredConstructor().newInstance(); + if (o instanceof Analyzer) { + return (Analyzer) o; + } + } + catch (ClassNotFoundException e) { + // try next candidate + } + } + return null; + } + + private void runAnalyzer(Analyzer analyzer, List<Target> targets, int batchSize) + throws Exception { + Method addedSet = findMethod(analyzer.getClass(), "added", + AddressSetView.class, TaskMonitor.class); + Method addedProgram = findMethod(analyzer.getClass(), "added", + ghidra.program.model.listing.Program.class, AddressSetView.class, + TaskMonitor.class, MessageLog.class); + if (addedSet == null && addedProgram == null) { + throw new NoSuchMethodException("no supported added(...) entry point on " + + analyzer.getClass().getName()); + } + + int n = (batchSize > 0) ? batchSize : targets.size(); + for (int start = 0; start < targets.size(); start += n) { + int end = Math.min(start + n, targets.size()); + AddressSet set = new AddressSet(); + for (int i = start; i < end; i++) { + set.addRange(targets.get(i).entry, targets.get(i).entry); + } + println(String.format("AnalyzeFunctions: batch %d..%d (%d functions)", + start, end - 1, end - start)); + try { + if (addedSet != null) { + addedSet.invoke(analyzer, set, monitor); + } + else { + addedProgram.invoke(analyzer, currentProgram, set, monitor, + new MessageLog()); + } + } + catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + println("WARN: analyzer threw for batch " + start + ".." + (end - 1) + + ": " + (cause == null ? e : cause)); + } + } + } + + private Method findMethod(Class<?> c, String name, Class<?>... params) { + for (Class<?> k = c; k != null; k = k.getSuperclass()) { + try { + Method m = k.getDeclaredMethod(name, params); + m.setAccessible(true); + return m; + } + catch (NoSuchMethodException e) { + // keep walking up the hierarchy + } + } + return null; + } + + private void snapshotAfter(List<Target> targets, long imageBase) { + for (Target t : targets) { + Function f = getFunctionAt(t.entry); + if (f == null) { + continue; + } + t.paramsAfter = f.getParameterCount(); + Variable[] locals = f.getLocalVariables(); + t.localsAfter = locals.length; + if (t.name.startsWith("FUN_") && !f.getName().equals(t.name)) { + t.name = f.getName(); + } + } + } + + private void report(List<Target> targets, String outFile) throws Exception { + StringBuilder sb = new StringBuilder(); + sb.append("# libcuda 570.144 targeted Decompiler Parameter ID pass\n"); + sb.append("# file_vaddr\tname\tparams_before\tparams_after\tlocals_before\tlocals_after\n"); + int improved = 0; + for (Target t : targets) { + boolean gain = t.paramsAfter > t.paramsBefore || t.localsAfter > t.localsBefore; + if (gain) { + improved++; + } + sb.append(String.format("0x%06x\t%s\t%d\t%d\t%d\t%d%s\n", + t.fileAddr, t.name, t.paramsBefore, t.paramsAfter, + t.localsBefore, t.localsAfter, gain ? "\tGAIN" : "")); + } + sb.append(String.format("# %d/%d functions gained parameters or locals\n", + improved, targets.size())); + String text = sb.toString(); + println(text); + if (outFile != null) { + try (PrintWriter w = new PrintWriter(new FileWriter(outFile))) { + w.print(text); + } + } + } +} diff --git a/userspace/cuda/tools/name_candidates.py b/userspace/cuda/tools/name_candidates.py new file mode 100755 index 0000000..7a36a7c --- /dev/null +++ b/userspace/cuda/tools/name_candidates.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# name_candidates.py - propose API-implementation names for the hand-written +# functions called by the generated CUDA API wrappers. +# +# Background: every exported cu* entry is a generated stub that jumps through +# an interposition slot to a generated wrapper (tracing/tool dispatch); the +# wrapper then calls the hand-written implementation of that API. Export +# families have several variants of one API (cuMemAlloc_v2, cuMemAlloc_ptsz, +# cuMemAlloc_ptds, ...), all of which must reach the same implementation. +# +# Method (robust, structure-only): +# 1. Group exported wrappers by BASE NAME: strip the variant suffixes +# `_v<N>`, `_ptsz`, `_ptds` (in any order/combination) from the export +# name; all exports in a group are variants of one API. +# 2. The implementation of a base name is a DIRECT CALLEE shared by ALL of +# its variants' wrappers, i.e. the intersection of the wrappers' direct +# callee sets. +# 3. Filter out global helpers: a candidate is dropped when it is called by +# >= --max-outside wrappers OUTSIDE its group (trace/init/lock helpers +# are called by dozens to hundreds of unrelated wrappers). Wrappers are +# never candidates for themselves. +# 4. Confidence: +# high - exactly one candidate survives, and either the group has +# >= 2 variants (the survivor is shared by all of them) or the +# survivor is a unique callee (called by exactly one wrapper +# in the whole library); +# medium - exactly one survivor but it is also called by another +# wrapper (possible shared implementation), or several +# survivors remain (the best-ranked one is proposed). +# Ranking among several survivors: fewest outside wrappers first, then +# largest direct-callee count (implementations do work; helpers do not), +# then lowest address, for a deterministic result. +# +# Inputs: +# <exports.tsv> elf-index exports table: addr file_addr size name +# <callgraph.tsv> elf-index callgraph: caller callee caller_file callee_file +# <names.tsv> name map: file_vaddr<TAB>name (wrapper_<export> entries +# provide the wrapper address of each export) +# <out.tsv> output (comments + one row per proposal) +# +# Output rows (TSV): +# file_vaddr proposed_name confidence evidence status +# `proposed_name` is `impl_<base>`; `evidence` carries the wrapper group, +# candidate callees before/after filtering and the outside-wrapper count; +# `status` is always `unverified` (the tool proposes, it does not prove). +# All addresses are FILE virtual addresses (libcuda+0x...). +# +# Usage: +# name_candidates.py <exports.tsv> <callgraph.tsv> <names.tsv> <out.tsv> +# [--max-outside N] [--quiet] + +import argparse +import re +import sys +from collections import defaultdict + + +SUFFIX_RE = re.compile(r"(_ptsz|_ptds)$|_v\d+$") + + +def base_name(name): + """Strip CUDA variant suffixes (_v<N>, _ptsz, _ptds) from an export name.""" + prev = None + while prev != name: + prev = name + name = SUFFIX_RE.sub("", name) + return name + + +def load_exports(path): + rows = [] + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + rows.append(dict(addr=int(p[0], 16), file=int(p[1], 16), + size=int(p[2]), name=p[3])) + except ValueError: + continue + return rows + + +def load_names(path): + names = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) >= 2: + try: + names[int(p[0], 16)] = p[1] + except ValueError: + pass + return names + + +def load_callgraph(path): + callees = defaultdict(set) + callers = defaultdict(set) + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + cf, kf = int(p[2], 16), int(p[3], 16) + except ValueError: + continue + callees[cf].add(kf) + callers[kf].add(cf) + return callees, callers + + +def hx(a): + return f"0x{a:x}" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("exports") + ap.add_argument("callgraph") + ap.add_argument("names") + ap.add_argument("out") + ap.add_argument("--max-outside", type=int, default=2, + help="drop a candidate called by >= N wrappers outside " + "its base-name group (default 2)") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + exports = load_exports(args.exports) + names = load_names(args.names) + callees, callers = load_callgraph(args.callgraph) + + wrapper_by_export = {} + for a, n in names.items(): + if n.startswith("wrapper_"): + wrapper_by_export[n[len("wrapper_"):]] = a + wrapper_set = set(wrapper_by_export.values()) + + groups = defaultdict(list) + for e in exports: + w = wrapper_by_export.get(e["name"]) + if w is not None: + groups[base_name(e["name"])].append((e["name"], w)) + + rows = [] + n_high = n_med = 0 + for group in sorted(groups): + variants = groups[group] + if not variants: + continue + variant_wrappers = {w for _, w in variants} + inter = None + for _, w in variants: + cs = set(callees.get(w, ())) + inter = cs if inter is None else (inter & cs) + if not inter: + continue + before = sorted(inter) + survivors = [] + for c in before: + if c in wrapper_set: + continue + outside = [w for w in callers.get(c, ()) + if w in wrapper_set and w not in variant_wrappers] + if len(outside) >= args.max_outside: + continue + survivors.append((len(outside), c)) + if not survivors: + continue + # rank: fewest outside wrappers, then most direct callees, then address + survivors.sort(key=lambda t: (t[0], -len(callees.get(t[1], ())), t[1])) + best_outside, best = survivors[0] + after = [c for _, c in survivors] + if len(survivors) == 1 and (len(variants) >= 2 or + len(callers.get(best, ()) & wrapper_set) == 1): + confidence = "high" + n_high += 1 + else: + confidence = "medium" + n_med += 1 + evidence = ( + f"group={group} variants=" + ",".join(n for n, _ in variants) + + "; before=" + ",".join(hx(c) for c in before) + + "; after=" + ",".join(hx(c) for c in after) + + f"; outside_wrappers={best_outside}") + rows.append((best, f"impl_{group}", confidence, evidence)) + + rows.sort() + with open(args.out, "w") as f: + f.write("# Open-IOV CUDA API-implementation candidates (our own work product; tracked).\n") + f.write("# Generated by tools/name_candidates.py from the elf-index exports,\n") + f.write("# callgraph and the version's names.tsv; see that script for the method.\n") + f.write("#\n") + f.write("# file_vaddr<TAB>proposed_name<TAB>confidence<TAB>evidence<TAB>status\n") + f.write("# confidence: high = exactly one filtered candidate, shared by >=2 variants\n") + f.write("# or a unique callee; medium = one survivor also called by another\n") + f.write("# wrapper, or several survivors (best-ranked proposed).\n") + f.write("# status: unverified (tool output). Verification = read the body in the\n") + f.write("# decompiled corpus and mark verified/corrected here; only verified\n") + f.write("# names may be copied into names.tsv.\n") + f.write("#\n") + for a, name, conf, ev in rows: + f.write(f"0x{a:08x}\t{name}\t{conf}\t{ev}\tunverified\n") + + if not args.quiet: + print(f"[name_candidates] groups={len(groups)} proposals={len(rows)} " + f"(high={n_high} medium={n_med}) -> {args.out}", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userspace/cuda/tools/subsystem_digest.py b/userspace/cuda/tools/subsystem_digest.py new file mode 100755 index 0000000..68f0d09 --- /dev/null +++ b/userspace/cuda/tools/subsystem_digest.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# subsystem_digest.py - per-subsystem call-tree digest from the tracked +# subsystem annotations plus the callgraph index. +# +# For every subsystem in subsystems.tsv it prints: +# * members (address, name, role) - the annotated functions; +# * entry functions - members that are not called by another member of the +# same subsystem (the roots of the subsystem's call tree); +# * a depth-limited call tree from each entry, with the direct callees of a +# node grouped by their subsystem; +# * a cross-subsystem edge summary (how many calls each subsystem makes into +# every other one) - the fastest way to see the layering. +# +# The digest is a navigation aid: it shows where to start reading the +# decompiled corpus for a subsystem and which other subsystems it touches, +# without opening Ghidra. Addresses are FILE virtual addresses (libcuda+0x...). +# +# Usage: +# subsystem_digest.py <subsystems.tsv> <callgraph.tsv> [--out FILE] +# [--depth N] [--max-per-node N] [--subsystem NAME] +# +# <subsystems.tsv> format (comments allowed): +# file_vaddr<TAB>name<TAB>subsystem<TAB>role<TAB>evidence +# <callgraph.tsv> format (header): +# caller<TAB>callee<TAB>caller_file<TAB>callee_file +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import sys +from collections import defaultdict + + +def load_subsystems(path): + members = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) < 4: + continue + try: + addr = int(p[0], 16) + except ValueError: + continue + members[addr] = dict(name=p[1], subsystem=p[2], + role=p[3], + evidence=p[4] if len(p) > 4 else "") + return members + + +def load_callgraph(path): + callees = defaultdict(set) + callers = defaultdict(set) + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + cf, kf = int(p[2], 16), int(p[3], 16) + except ValueError: + continue + callees[cf].add(kf) + callers[kf].add(cf) + return callees, callers + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("subsystems") + ap.add_argument("callgraph") + ap.add_argument("--out", default=None, + help="write the digest here (default: stdout)") + ap.add_argument("--depth", type=int, default=2, + help="call-tree depth below each entry (default 2)") + ap.add_argument("--max-per-node", type=int, default=8, + help="max callees shown per node (default 8)") + ap.add_argument("--subsystem", default=None, + help="only digest this subsystem") + args = ap.parse_args() + + members = load_subsystems(args.subsystems) + callees, callers = load_callgraph(args.callgraph) + + by_sub = defaultdict(dict) + for addr, m in members.items(): + by_sub[m["subsystem"]][addr] = m + + def name_of(a): + m = members.get(a) + if m: + return f"{m['name']} (libcuda+0x{a:x})" + return f"libcuda+0x{a:x}" + + def sub_of(a): + m = members.get(a) + return m["subsystem"] if m else "unknown" + + out = [] + out.append("# Subsystem call-tree digest (generated by tools/subsystem_digest.py)") + out.append(f"# subsystems.tsv: {args.subsystems}") + out.append(f"# callgraph.tsv : {args.callgraph}") + out.append("# Addresses are FILE virtual addresses (libcuda+0x...).") + out.append("# entry = annotated member with no caller inside its own subsystem.") + out.append("") + + # cross-subsystem edge counts + cross = defaultdict(int) + for addr, m in members.items(): + for c in callees.get(addr, ()): + cross[(m["subsystem"], sub_of(c))] += 1 + + selected = sorted(by_sub) + if args.subsystem: + selected = [s for s in selected if s == args.subsystem] + if not selected: + print(f"error: subsystem {args.subsystem!r} not in {args.subsystems}", + file=sys.stderr) + return 1 + + for sub in selected: + ms = by_sub[sub] + out.append("=" * 72) + out.append(f"## {sub} ({len(ms)} annotated members)") + out.append("=" * 72) + out.append("members:") + for a in sorted(ms): + m = ms[a] + out.append(f" libcuda+0x{a:x}\t{m['name']}\t{m['role']}") + entries = sorted(a for a in ms + if not (callers.get(a, set()) & set(ms))) + if not entries: + entries = sorted(ms) + out.append("entries: (no entry: every member is called from inside " + "the subsystem; showing all)") + else: + out.append("entries:") + for a in entries: + out.append(f" {name_of(a)}: {ms[a]['role']}") + out.append("call tree:") + + def walk(a, depth, indent, seen): + kids = sorted(callees.get(a, ())) + if not kids or depth <= 0: + return + groups = defaultdict(list) + for k in kids: + groups[sub_of(k)].append(k) + for g in sorted(groups): + shown = groups[g][:args.max_per_node] + more = f" (+{len(groups[g]) - args.max_per_node} more)" \ + if len(groups[g]) > args.max_per_node else "" + out.append(f"{indent}-> {g}: " + + "; ".join(name_of(k) for k in shown) + more) + for k in kids: + if k in seen: + continue + seen.add(k) + walk(k, depth - 1, indent + " ", seen) + + for a in entries: + out.append(f" {name_of(a)}") + walk(a, args.depth, " ", {a}) + out.append("") + + out.append("=" * 72) + out.append("## cross-subsystem calls (edges from -> to)") + out.append("=" * 72) + for (src, dst) in sorted(cross): + out.append(f" {src:<16} -> {dst:<16} {cross[(src, dst)]}") + + text = "\n".join(out) + if args.out: + with open(args.out, "w") as f: + f.write(text + "\n") + else: + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
