summaryrefslogtreecommitdiffstats
path: root/userspace/cuda/tools
diff options
context:
space:
mode:
Diffstat (limited to 'userspace/cuda/tools')
-rwxr-xr-xuserspace/cuda/tools/cudatool.py154
-rwxr-xr-xuserspace/cuda/tools/diff_corpus.py221
-rw-r--r--userspace/cuda/tools/ghidra/AnalyzeFunctions.java343
-rwxr-xr-xuserspace/cuda/tools/name_candidates.py224
-rwxr-xr-xuserspace/cuda/tools/subsystem_digest.py190
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())