diff options
| author | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-09-23 12:11:04 -0400 |
|---|---|---|
| committer | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-09-23 12:11:04 -0400 |
| commit | 21b8ebc9db1d0ae649d6406087ea22b54e3b5775 (patch) | |
| tree | 192aa5d3c76e0cf3a785936d9902c6848609d37e /userspace/cuda/tools/cudatool.py | |
| parent | 631bd20c63a4058775a4fa549b9473915c918020 (diff) | |
| download | open-iov-21b8ebc9db1d0ae649d6406087ea22b54e3b5775.tar.gz open-iov-21b8ebc9db1d0ae649d6406087ea22b54e3b5775.zip | |
userspace/cuda: add CUDA userspace driver analysis, tooling and name maps
Add the analysis of the NVIDIA CUDA userspace driver used by Open-IOV VMs:
* docs/01-08: architecture (layers, object model, threads, sync, memory,
channels, launch), build and execution model, the driver/kernel/GPU
interface contract, virtualization approaches, the 570.144 decompilation
map with the corrected libcuda crash path and the current engine-topology
blocker, tooling, and a version-upgrade playbook.
* tools/: version-agnostic toolchain - stdlib ELF/interface toolkit
(exports, unwind-table function map, wrapper resolution, version delta,
crash address decode), callgraph walker, corpus annotator, and Ghidra
analysis/decompilation/name-map scripts with resumable parallel drivers.
* 570.144/annotations/: our name map (exports + resolved wrappers + verified
internals) and subsystem index, tracked so naming work accumulates.
* READ-ME-FIRST.md: reading guide with the fast lookup recipes.
The full decompiled corpus and annotated corpus stay untracked (vendor-
derived, gitignored under the version run/ tree).
Diffstat (limited to 'userspace/cuda/tools/cudatool.py')
| -rwxr-xr-x | userspace/cuda/tools/cudatool.py | 515 |
1 files changed, 515 insertions, 0 deletions
diff --git a/userspace/cuda/tools/cudatool.py b/userspace/cuda/tools/cudatool.py new file mode 100755 index 0000000..2e4c1c6 --- /dev/null +++ b/userspace/cuda/tools/cudatool.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# cudatool.py - Open-IOV CUDA userspace driver analysis toolkit. +# +# A dependency-free (stdlib-only) ELF toolkit for the NVIDIA CUDA userspace +# driver, usable on every driver version without a Ghidra project. It gives +# the fast facts that do not require decompilation: exports, function ranges +# derived from the exception-handling tables, the CUDA forward-compatibility +# note, and a dmesg/crash-address decoder. It is also the version-delta tool: +# `delta` compares two driver versions and reports exactly what changed at +# the interface level, which is what keeps the Open-IOV side anti-fragile +# across driver upgrades. +# +# Commands +# info <lib> ELF/version summary +# exports <lib> [--json] exported symbols (file vaddrs) +# functions <lib> [--json] function ranges from .eh_frame_hdr +# nearest <lib> <hex_addr> nearest preceding function/export for a vaddr +# crash <lib> <map_base> <ip> dmesg mapping base + ip -> file vaddr + symbol +# 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 +# note <lib> CUDA forward-compatibility note (JSON) +# +# Address convention: every address printed by this tool is a FILE VIRTUAL +# ADDRESS, i.e. the `libcuda+0x...` numbers used in the project docs. Ghidra +# may load the same file at a non-zero image base (0x100000 for these builds); +# use tools/ghidra_decompile_at.sh which translates file vaddrs automatically. +# +# License note: this tool only *reads* the vendor binary for interoperability +# analysis; it contains no vendor code and is published under BSD-3-Clause. + +import argparse +import json +import re +import struct +import sys + +ELF_MAGIC = b"\x7fELF" + + +class ELFError(Exception): + pass + + +class ELF: + """Minimal 64-bit little-endian ELF reader.""" + + def __init__(self, path): + self.path = path + with open(path, "rb") as f: + self.data = f.read() + if self.data[:4] != ELF_MAGIC: + raise ELFError(f"{path}: not an ELF file") + if self.data[4] != 2: + raise ELFError(f"{path}: not ELF64") + if self.data[5] != 1: + raise ELFError(f"{path}: not little-endian") + (self.e_type, self.e_machine, _v, self.e_entry, self.e_phoff, self.e_shoff, + self.e_flags, self.e_ehsize, self.e_phentsize, self.e_phnum, + self.e_shentsize, self.e_shnum, self.e_shstrndx) = struct.unpack_from( + "<HHIQQQIHHHHHH", self.data, 16) + + self.program_headers = [] + for i in range(self.e_phnum): + off = self.e_phoff + i * self.e_phentsize + (p_type, p_flags, p_offset, p_vaddr, p_paddr, p_filesz, p_memsz, + p_align) = struct.unpack_from("<IIQQQQQQ", self.data, off) + self.program_headers.append(dict( + type=p_type, flags=p_flags, offset=p_offset, vaddr=p_vaddr, + paddr=p_paddr, filesz=p_filesz, memsz=p_memsz, align=p_align, + index=i)) + + self.sections = [] + self.section_by_name = {} + for i in range(self.e_shnum): + off = self.e_shoff + i * self.e_shentsize + (sh_name, sh_type, sh_flags, sh_addr, sh_offset, sh_size, + sh_link, sh_info, sh_addralign, sh_entsize) = struct.unpack_from( + "<IIQQQQIIQQ", self.data, off) + self.sections.append(dict( + index=i, name_off=sh_name, type=sh_type, flags=sh_flags, + addr=sh_addr, offset=sh_offset, size=sh_size, link=sh_link, + info=sh_info, addralign=sh_addralign, entsize=sh_entsize)) + if self.e_shstrndx: + shstr = self.sections[self.e_shstrndx] + base = shstr["offset"] + for s in self.sections: + end = self.data.find(b"\0", base + s["name_off"]) + s["name"] = self.data[base + s["name_off"]:end].decode( + "utf-8", "replace") + self.section_by_name[s["name"]] = s + + # ---- helpers --------------------------------------------------------- + + def section(self, name): + return self.section_by_name.get(name) + + def exec_segment_base(self): + """Base address reported by dmesg/libcuda mappings: first executable + PT_LOAD vaddr aligned as Linux maps it (page-aligned start).""" + for ph in self.program_headers: + if ph["type"] == 1 and (ph["flags"] & 1): # PT_LOAD | PF_X + return ph["vaddr"] & ~0xFFF + raise ELFError("no executable PT_LOAD segment") + + def read_string(self, offset): + end = self.data.find(b"\0", offset) + return self.data[offset:end].decode("utf-8", "replace") + + def build_id(self): + for s in self.sections: + if s["name"] == ".note.gnu.build-id": + off = s["offset"] + namesz, descsz, ntype = struct.unpack_from("<III", self.data, off) + name = self.data[off + 12:off + 12 + namesz].rstrip(b"\0") + if name == b"GNU" and ntype == 3: + desc = self.data[off + 12 + ((namesz + 3) & ~3): + off + 12 + ((namesz + 3) & ~3) + descsz] + return desc.hex() + return None + + # ---- symbols --------------------------------------------------------- + + def dynamic_symbols(self): + symtab = self.section(".dynsym") + if not symtab: + return [] + strtab = self.sections[symtab["link"]] + out = [] + n = symtab["size"] // 24 + for i in range(n): + off = symtab["offset"] + i * 24 + st_name, st_info, st_other, st_shndx, st_value, st_size = \ + struct.unpack_from("<IBBHQQ", self.data, off) + name = self.read_string(strtab["offset"] + st_name) + out.append(dict( + name=name, + value=st_value, + size=st_size, + type=st_info & 0xF, + bind=st_info >> 4, + shndx=st_shndx, + defined=st_shndx != 0, + )) + return out + + def exports(self): + return [s for s in self.dynamic_symbols() + if s["defined"] and s["type"] == 2 and s["value"]] + + def imports(self): + return [s for s in self.dynamic_symbols() + if not s["defined"] and s["name"]] + + # ---- exception handling function table ------------------------------- + + def function_ranges(self): + """Return [(start, end)] from .eh_frame_hdr. + + .eh_frame_hdr layout (DWARF .eh_frame_hdr, version 1): + u8 version; u8 eh_frame_ptr_enc; u8 fde_count_enc; u8 table_enc; + encoded eh_frame_ptr; encoded fde_count; + table[count] { encoded pc_begin; encoded fde_addr; } + + GCC emits: eh_frame_ptr_enc = DW_EH_PE_pcrel|sdata4 (0x1b), + fde_count_enc = udata4 (0x03), table_enc = DW_EH_PE_datarel|sdata4 + (0x3b). The datarel anchor is the start of .eh_frame_hdr. The parser + below handles the encodings generally (pcrel/datarel + 1/2/4/8-byte + and ULEB/SLEB), so it also works for other toolchains. + """ + hdr = self.section(".eh_frame_hdr") + if not hdr: + return [] + base = hdr["offset"] + anchor = hdr["addr"] + version = self.data[base] + if version != 1: + raise ELFError(f"unsupported .eh_frame_hdr version {version}") + eh_ptr_enc, fde_count_enc, table_enc = self.data[base + 1:base + 4] + + def enc_size(enc): + return {0x0: 0, 0x1: 1, 0x2: 2, 0x3: 4, 0x4: 8, + 0x9: 1, 0xA: 2, 0xB: 4, 0xC: 8}.get(enc & 0x0F, 0) + + def decode(enc, ptr, field_addr): + fmt = enc & 0x0F + if fmt == 0x0: + return 0 + if fmt == 0x1: + val, shift = 0, 0 + while True: + b = self.data[ptr] + ptr += 1 + val |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + return val + if fmt == 0x2: + return struct.unpack_from("<H", self.data, ptr)[0] + if fmt == 0x3: + return struct.unpack_from("<I", self.data, ptr)[0] + if fmt == 0x4: + return struct.unpack_from("<Q", self.data, ptr)[0] + if fmt == 0x9: + val, shift = 0, 0 + while True: + b = self.data[ptr] + ptr += 1 + val |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + if b & 0x40: + val -= 1 << shift + break + return val + if fmt == 0xA: + return struct.unpack_from("<h", self.data, ptr)[0] + if fmt == 0xB: + return struct.unpack_from("<i", self.data, ptr)[0] + if fmt == 0xC: + return struct.unpack_from("<q", self.data, ptr)[0] + raise ELFError(f"unsupported eh encoding 0x{enc:02x}") + + def absolute(enc, ptr): + val = decode(enc, ptr, anchor) + kind = enc & 0x70 + if kind == 0x10: # DW_EH_PE_pcrel: relative to field address + val += anchor + (ptr - base) + elif kind == 0x30: # DW_EH_PE_datarel + val += anchor + return val + + p = base + 4 + p += enc_size(eh_ptr_enc) # skip eh_frame_ptr + count = decode(fde_count_enc, p, anchor) + p += enc_size(fde_count_enc) + starts = [] + for _ in range(count): + pc = absolute(table_enc, p) + p += enc_size(table_enc) + p += enc_size(table_enc) # skip fde address + starts.append(pc) + starts.sort() + out = [] + for i, pc in enumerate(starts): + end = starts[i + 1] if i + 1 < len(starts) else None + out.append((pc, end)) + return out + + # ---- CUDA forward compatibility note --------------------------------- + + def cuda_note(self): + s = self.section(".note.cuda.fwd_compatibility") + if not s: + return None + off = s["offset"] + namesz, descsz, ntype = struct.unpack_from("<III", self.data, off) + name_off = off + 12 + name = self.data[name_off:name_off + namesz].rstrip(b"\0").decode() + desc_off = name_off + ((namesz + 3) & ~3) + desc = self.data[desc_off:desc_off + descsz].rstrip(b"\0").decode() + try: + parsed = json.loads(desc) + except json.JSONDecodeError: + parsed = None + return dict(owner=name, raw=desc, parsed=parsed) + + def debuglink(self): + s = self.section(".gnu_debuglink") + if not s: + return None + name = self.read_string(s["offset"]) + crc = struct.unpack_from("<I", self.data, s["offset"] + (len(name) + 4) // 4 * 4)[0] + return dict(name=name, crc=crc) + + # ---- export stubs -> generated wrapper --------------------------------- + + def relative_relocs(self): + """Map address -> R_X86_64_RELATIVE addend (the initial slot value).""" + out = {} + s = self.section(".rela.dyn") + if not s: + return out + n = s["size"] // 24 + for i in range(n): + off = s["offset"] + i * 24 + r_offset, r_info, r_addend = struct.unpack_from("<QQq", self.data, off) + if (r_info & 0xFFFFFFFF) == 8: # R_X86_64_RELATIVE + out[r_offset] = r_addend + return out + + def wrappers(self): + """Resolve each exported cu* stub to its initial wrapper target. + + The generated stubs are: + cmp dword ptr [rip+d], 0x321cba00 (7 bytes) + je +0xc (2 bytes) + jmp qword ptr [rip+d] (6 bytes, disp at stub+14) + so the interposition slot is stub + 18 + disp32. Its initial value is + set by a R_X86_64_RELATIVE relocation and points at the generated + wrapper (tracing/tool dispatch), whose first callee is the hand-written + implementation. + """ + relocs = self.relative_relocs() + out = [] + for s in self.exports(): + if not s["name"].startswith("cu"): + continue + a = s["value"] + # sanity-check the cmp/jmp pattern before trusting the arithmetic + if self.data[a] != 0x81 or self.data[a + 10] != 0x74 or \ + self.data[a + 12] != 0xFF or self.data[a + 13] != 0x25: + continue + disp = struct.unpack_from("<i", self.data, a + 14)[0] + slot = a + 18 + disp + target = relocs.get(slot) + out.append(dict(name=s["name"], stub=a, slot=slot, wrapper=target)) + return out + + def text_bounds(self): + t = self.section(".text") + if not t: + return None + return (t["addr"], t["addr"] + t["size"]) + + +# ---- commands ------------------------------------------------------------ + + +def cmd_info(args): + elf = ELF(args.lib) + tb = elf.text_bounds() + note = elf.cuda_note() + print(f"file : {args.lib}") + print(f"build id : {elf.build_id()}") + print(f"e_type : {elf.e_type} (3=DYN shared object)") + print(f"exec segment base: 0x{elf.exec_segment_base():x}") + print(f".text : 0x{tb[0]:x} .. 0x{tb[1]:x} ({tb[1]-tb[0]} bytes)") + print(f"exports : {len(elf.exports())}") + print(f"imports : {len(elf.imports())}") + if note and note["parsed"]: + p = note["parsed"] + print(f"cuda note : CUDA {p.get('CUDA version')}, " + f"driver ABI {p.get('driver')}, device {p.get('device')}") + dl = elf.debuglink() + if dl: + print(f"debuglink : {dl['name']} (crc 0x{dl['crc']:08x})") + for s in elf.sections: + print(f" section {s['name']:<28} addr=0x{s['addr']:08x} " + f"off=0x{s['offset']:08x} size={s['size']}") + + +def cmd_exports(args): + elf = ELF(args.lib) + ex = sorted(elf.exports(), key=lambda s: s["value"]) + if args.json: + print(json.dumps([{k: v for k, v in s.items()} for s in ex], indent=1)) + return + for s in ex: + print(f"0x{s['value']:08x} {s['size']:>8} {s['name']}") + + +def cmd_functions(args): + elf = ELF(args.lib) + fr = elf.function_ranges() + if args.json: + print(json.dumps([dict(start=hex(a), end=hex(b) if b else None) + for a, b in fr], indent=1)) + return + for a, b in fr: + print(f"0x{a:08x} 0x{b:08x}" if b else f"0x{a:08x} -") + + +def cmd_nearest(args): + elf = ELF(args.lib) + target = int(args.addr, 16) + ex = sorted(elf.exports(), key=lambda s: s["value"]) + prev = None + for s in ex: + if s["value"] <= target: + prev = s + else: + break + fr = elf.function_ranges() + fstart = None + for a, b in fr: + if a <= target and (b is None or target < b): + fstart = a + break + print(f"vaddr : 0x{target:x}") + print(f"function start : 0x{fstart:x}" if fstart is not None else + "function start : <not covered by .eh_frame_hdr>") + if prev: + print(f"nearest export : {prev['name']} + 0x{target - prev['value']:x} " + f"(export at 0x{prev['value']:x})") + else: + print("nearest export : <none>") + + +def cmd_crash(args): + elf = ELF(args.lib) + base = int(args.map_base, 16) + ip = int(args.ip, 16) + vaddr = ip - base + elf.exec_segment_base() + print(f"file vaddr : 0x{vaddr:x}") + args.addr = hex(vaddr) + cmd_nearest(args) + + +def cmd_strings(args): + elf = ELF(args.lib) + pat = re.compile(args.grep) if args.grep else None + rodata = [s for s in elf.sections if s["type"] == 1 and not (s["flags"] & 4)] + seen = set() + for s in rodata: + blob = elf.data[s["offset"]:s["offset"] + s["size"]] + for m in re.finditer(rb"[\x20-\x7e]{5,}", blob): + text = m.group().decode() + if pat and not pat.search(text): + continue + key = (s["name"], m.start()) + if key in seen: + continue + seen.add(key) + print(f"0x{s['addr'] + m.start():08x} {text}") + + +def cmd_delta(args): + a, b = ELF(args.libA), ELF(args.libB) + ea = {s["name"]: s for s in a.exports()} + eb = {s["name"]: s for s in b.exports()} + added = sorted(set(eb) - set(ea)) + removed = sorted(set(ea) - set(eb)) + moved = [] + resized = [] + for n in sorted(set(ea) & set(eb)): + if ea[n]["value"] != eb[n]["value"]: + moved.append((n, ea[n]["value"], eb[n]["value"])) + if ea[n]["size"] != eb[n]["size"]: + resized.append((n, ea[n]["size"], eb[n]["size"])) + print(f"A: {args.libA}") + print(f" build-id {a.build_id()} exports {len(ea)}") + print(f"B: {args.libB}") + print(f" build-id {b.build_id()} exports {len(eb)}") + na, nb = a.cuda_note(), b.cuda_note() + print(f"note A: {na['raw'] if na else None}") + print(f"note B: {nb['raw'] if nb else None}") + tb_a, tb_b = a.text_bounds(), b.text_bounds() + print(f"text A: 0x{tb_a[0]:x}..0x{tb_a[1]:x} ({tb_a[1]-tb_a[0]})") + print(f"text B: 0x{tb_b[0]:x}..0x{tb_b[1]:x} ({tb_b[1]-tb_b[0]})") + print(f"exports added ({len(added)}): " + ", ".join(added[:40]) + + (" ..." if len(added) > 40 else "")) + print(f"exports removed ({len(removed)}): " + ", ".join(removed[:40]) + + (" ..." if len(removed) > 40 else "")) + print(f"exports moved ({len(moved)})" + + (": " + ", ".join(f"{n} 0x{x:x}->0x{y:x}" for n, x, y in moved[:10]) + if moved else "")) + print(f"exports resized ({len(resized)})" + + (": " + ", ".join(f"{n} {x}->{y}" for n, x, y in resized[:10]) + if resized else "")) + print(f"imports A={len(a.imports())} B={len(b.imports())}") + + +def cmd_wrappers(args): + elf = ELF(args.lib) + ws = elf.wrappers() + if args.json: + print(json.dumps(ws, indent=1)) + return + for w in ws: + if w["wrapper"] is None: + continue + print(f"0x{w['stub']:08x} -> 0x{w['wrapper']:08x} {w['name']}") + + +def cmd_note(args): + elf = ELF(args.lib) + print(json.dumps(elf.cuda_note(), indent=2)) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("info"); p.add_argument("lib"); p.set_defaults(fn=cmd_info) + p = sub.add_parser("exports"); p.add_argument("lib") + p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_exports) + p = sub.add_parser("functions"); p.add_argument("lib") + p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_functions) + p = sub.add_parser("nearest"); p.add_argument("lib"); p.add_argument("addr") + p.set_defaults(fn=cmd_nearest) + p = sub.add_parser("crash"); p.add_argument("lib") + p.add_argument("map_base"); p.add_argument("ip"); p.set_defaults(fn=cmd_crash) + p = sub.add_parser("strings"); p.add_argument("lib") + p.add_argument("--grep"); p.set_defaults(fn=cmd_strings) + p = sub.add_parser("delta"); p.add_argument("libA"); p.add_argument("libB") + p.set_defaults(fn=cmd_delta) + 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) + + args = ap.parse_args() + try: + args.fn(args) + except ELFError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() |
