Files
visual_debugger/gdb/debug_graph.py

444 lines
17 KiB
Python

"""Generic "pointer-memory structure" -> Debug Visualizer JSON walker.
Works on any struct/union reachable from an expression, purely by reading
DWARF debug info through gdb's Python API -- no changes to the debuggee's
source are required, and it works the same way for any language gdb (or
rust-gdb) understands.
Rule: every member of a struct/union becomes one row in that node's field
table. A member whose type is a pointer becomes an edge (and, if non-null,
is recursed into); everything else is just stringified in place. A visited-
address set makes this safe for cycles, so it handles lists, trees, and
arbitrary (possibly cyclic) graphs uniformly.
Loaded via the "source" gdb command (see .vscode/launch.json), this defines
a convenience function usable directly from the Debug Visualizer's Watch
box:
$debug_graph(list)
$debug_graph(list, "top", top) # extra name/pointer marker pairs
Besides EXPR and any explicit name/pointer pairs, every call also walks and
labels, as additional roots:
- every other local variable (and argument) visible at the current PC,
from the innermost lexical block out to function scope -- kind "local"
- every file-scope global/static visible from the current source file --
kind "global"
the explicit name/pointer pairs are labeled kind "watched". This is what
lets e.g. $debug_graph(top) -- which VS Code's expressionTemplate produces
when you just type "top" in the Watch box -- still show the list: `top` is
a bare `int*` with no struct to recurse into on its own, but `list` (a
sibling local at that point) gets auto-walked too, so `top`'s address lands
on a node already present in the combined graph. Each root's "kind" rides
along in the output JSON so the visualizer can offer show/hide toggles per
category (see vis-plugins/node-table-visualizer.js).
"""
import gdb
import json
# Marks the start of every payload this module returns (see json.dumps'
# default separators below): used to recognize our own values in
# _JsonResultPrettyPrinter without touching the user's own char* variables.
_RESULT_MARKER = '{"kind": {"nodeTable"'
def _addr_str(addr_int):
return hex(addr_int)
def _format_scalar(val):
try:
return str(val)
except gdb.error as e:
return "<error: %s>" % e
def _is_variant_like(t):
"""True for gdb's DWARF encoding of a Rust enum: a struct where one or
more discriminant fields are marked "artificial" alongside the real
variant payload fields. Deliberately loose (a C++ class with a vtable
pointer can also have an artificial field) -- callers must tolerate
_flatten_variant() finding no accessible variant and falling back
gracefully."""
if t.code != gdb.TYPE_CODE_STRUCT:
return False
try:
fields = t.fields()
except TypeError:
return False
has_artificial = any(getattr(f, "artificial", False) for f in fields)
has_payload = any(not getattr(f, "artificial", False) for f in fields)
return has_artificial and has_payload
def _flatten_variant(val, t, label):
"""For a live enum variant, yields (label, value) leaves to treat as if
they were direct fields of the enclosing struct -- unwrapping the
tuple-style __0 payload field so `next: Option<Box<Node<T>>>` reads
exactly like a plain `next` pointer field. Yields nothing for a unit
variant (e.g. None) or when no variant is accessible.
Which variant is live is determined by trying each non-artificial
field and seeing which one gdb accepts -- accessing an inactive Rust
enum variant raises gdb.error -- rather than parsing gdb's printed
text, since that text's format depends on the debugger's current
language setting."""
for f in t.fields():
if f.name is None or getattr(f, "artificial", False):
continue
try:
variant_val = val[f.name]
except gdb.error:
continue
variant_type = variant_val.type.strip_typedefs()
if variant_type.code not in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
return # unit variant (e.g. None): no payload to flatten
payload_fields = [pf for pf in variant_type.fields() if pf.name is not None]
for pf in payload_fields:
sub_label = (
label if (pf.name == "__0" and len(payload_fields) == 1) else "%s.%s" % (label, pf.name)
)
yield sub_label, variant_val[pf.name]
return
def _process_member(label, member_val, node_id, fields_out, edges, nodes, visited):
member_type = member_val.type.strip_typedefs()
if member_type.code == gdb.TYPE_CODE_PTR:
try:
ptr_int = int(member_val)
except gdb.error:
ptr_int = 0
fields_out.append({"name": label, "value": _addr_str(ptr_int), "isPointer": True})
if ptr_int:
edges.append((node_id, _addr_str(ptr_int), label))
_visit_pointer(member_val, ptr_int, nodes, edges, visited)
return
if _is_variant_like(member_type):
leaves = list(_flatten_variant(member_val, member_type, label))
if leaves:
for leaf_label, leaf_val in leaves:
_process_member(leaf_label, leaf_val, node_id, fields_out, edges, nodes, visited)
else:
# Unit variant (e.g. None) or an active variant we couldn't
# resolve -- fall back to gdb's own rendering of it.
fields_out.append(
{"name": label, "value": _format_scalar(member_val), "isPointer": False}
)
return
fields_out.append({"name": label, "value": _format_scalar(member_val), "isPointer": False})
def _describe_struct(val, node_id, nodes, edges, visited):
"""val: a gdb.Value of struct/union type (already dereferenced)."""
fields_out = []
t = val.type.strip_typedefs()
for f in t.fields():
if f.name is None or getattr(f, "is_base_class", False):
continue # anonymous member / base-class subobject
try:
member_val = val[f.name]
except gdb.error:
continue
_process_member(f.name, member_val, node_id, fields_out, edges, nodes, visited)
nodes[node_id] = fields_out
def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited):
"""Cycle-guards only on the struct/union branch (via `visited`), and
only fills in a scalar leaf if nothing has described this address yet
(via `nodes`). With auto-discovered roots (see build_graph_json), the
same address can be reached both as a struct-typed pointer (e.g. a
linked-list node) and, separately, as some other variable's plain
scalar pointer into one of that struct's fields (e.g. a `T*` pointing
at a `Node<T>`'s first member, which shares its address). Whichever
order those walks happen in, the richer struct description must win --
a scalar walk must never downgrade/overwrite an already-described
struct node, and a struct walk must always be able to upgrade a
previously-recorded scalar leaf at the same address."""
node_id = _addr_str(ptr_int)
pointee_type = ptr_val.type.strip_typedefs().target().strip_typedefs()
if pointee_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
if node_id in visited:
return
visited.add(node_id)
pointee = ptr_val.dereference()
_describe_struct(pointee, node_id, nodes, edges, visited)
else:
if node_id in nodes:
return # already described (richly or otherwise) via another path
pointee = ptr_val.dereference()
nodes[node_id] = [
{"name": "value", "value": _format_scalar(pointee), "isPointer": False}
]
def _walk_primary_root(root_val, nodes, edges, visited):
"""Walks EXPR itself (the first argument to $debug_graph). Returns the
address string of EXPR's own node, if any, so callers can skip re-adding
it as a redundant self-pointing root when auto-discovery finds the same
variable again as a local."""
t = root_val.type.strip_typedefs()
if t.code == gdb.TYPE_CODE_PTR:
ptr_int = int(root_val)
if ptr_int:
_visit_pointer(root_val, ptr_int, nodes, edges, visited)
return _addr_str(ptr_int)
return None
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
node_id = _addr_str(int(root_val.address))
visited.add(node_id)
_describe_struct(root_val, node_id, nodes, edges, visited)
return node_id
else:
nodes["root"] = [
{"name": "value", "value": _format_scalar(root_val), "isPointer": False}
]
return None
def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_addrs):
"""Walks `val` (a local/global/watched variable) same as a primary root,
then records it as a (name, value, kind) root entry -- unless its
address is in skip_addrs (already covered by the primary root)."""
t = val.type.strip_typedefs()
if t.code == gdb.TYPE_CODE_PTR:
try:
ptr_int = int(val)
except gdb.error:
return
if not ptr_int:
return
addr = _addr_str(ptr_int)
if addr in skip_addrs:
return
_visit_pointer(val, ptr_int, nodes, edges, visited)
roots_out.append((name, addr, kind))
elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION):
try:
addr = _addr_str(int(val.address))
except gdb.error:
return
if addr in skip_addrs:
return
if addr not in visited:
visited.add(addr)
_describe_struct(val, addr, nodes, edges, visited)
roots_out.append((name, addr, kind))
else:
roots_out.append((name, _format_scalar(val), kind))
def _frame_local_symbols(frame):
"""Yields every local variable/argument gdb.Symbol visible at the
current PC, walking from the innermost lexical block out to (but not
including) the enclosing file/global scope."""
seen = set()
block = frame.block()
while block is not None and not block.is_global and not block.is_static:
for sym in block:
if not (sym.is_variable or sym.is_argument):
continue
if sym.name is None or sym.name in seen:
continue
seen.add(sym.name)
yield sym
block = block.superblock
def _file_global_symbols(frame):
"""Yields every file-scope global/static gdb.Symbol visible from the
current frame's source file."""
seen = set()
try:
symtab = frame.find_sal().symtab
except gdb.error:
return
if symtab is None:
return
for block in (symtab.global_block(), symtab.static_block()):
if block is None:
continue
for sym in block:
if not sym.is_variable:
continue
if sym.name is None or sym.name in seen:
continue
seen.add(sym.name)
yield sym
def build_graph_json(root_val, watched_roots):
nodes = {}
edges = []
visited = set()
roots = []
skip_addrs = set()
used_names = set()
primary_addr = _walk_primary_root(root_val, nodes, edges, visited)
if primary_addr is not None:
skip_addrs.add(primary_addr)
for name, ptr_val in watched_roots:
used_names.add(name)
try:
ptr_int = int(ptr_val)
except gdb.error:
ptr_int = 0
addr = _addr_str(ptr_int)
roots.append((name, addr, "watched"))
if ptr_int and addr not in skip_addrs:
_visit_pointer(ptr_val, ptr_int, nodes, edges, visited)
try:
frame = gdb.selected_frame()
except gdb.error:
frame = None
if frame is not None:
for sym in _frame_local_symbols(frame):
if sym.name in used_names:
continue
used_names.add(sym.name)
try:
val = sym.value(frame)
except gdb.error:
continue
_add_named_root(sym.name, val, "local", nodes, edges, visited, roots, skip_addrs)
for sym in _file_global_symbols(frame):
if sym.name in used_names:
continue
used_names.add(sym.name)
try:
val = sym.value(frame)
except gdb.error:
continue
_add_named_root(sym.name, val, "global", nodes, edges, visited, roots, skip_addrs)
nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()]
edges_json = [
{"from": src, "to": dst, "label": label}
for (src, dst, label) in edges
if dst in nodes
]
roots_json = [{"name": name, "value": value, "kind": kind} for (name, value, kind) in roots]
return json.dumps(
{
"kind": {"nodeTable": True},
"nodes": nodes_json,
"edges": edges_json,
"roots": roots_json,
}
)
def _as_debugger_string(text):
"""Wrap `text` as a gdb.Value the Watch box will print as a proper
quoted string. Letting Python's str->gdb.Value auto-conversion do this
(i.e. just `return text`) produces a plain char array, which gdb's
*Rust* value printer renders as a bracketed list of byte codes instead
of text -- a display-time quirk based on the currently active
language, not something fixable by constructing the value differently.
Evaluating a string literal instead makes gdb's own (Rust- or
C++-aware, whichever is active) expression parser build a proper
string value, which prints correctly either way.
Under C++, VS Code's debug adapter (MIEngine) additionally gets this
value through gdb's MI *variable-object* protocol (`-var-create`), not
the plain CLI/`-data-evaluate-expression` path -- and for a raw
`char[N]` array, that protocol reports the "value" as just a length
placeholder like "[830]" with 830 one-character children, discarding
the actual text entirely. `std::string` doesn't have this problem
because libstdc++ registers a pretty-printer for it that overrides the
default array display with a plain string and no children -- the
_JsonResultPrettyPrinter registered below does the same thing for our
values specifically, without needing std::string to exist in the
debuggee (it wouldn't, since nothing in the target program uses it)."""
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
return gdb.parse_and_eval('"%s"' % escaped)
class _JsonResultPrettyPrinter:
"""Makes gdb's MI variable-object protocol (what VS Code's C++ debug
adapter uses for Watch/evaluate) show our returned string in full, with
no children -- exactly what libstdc++'s std::string pretty-printer
does for MIEngine, which is why that approach worked when toDebugGraph
was a real std::string-returning C++ method."""
def __init__(self, val):
self._val = val
def to_string(self):
try:
return self._val.string()
except (gdb.error, UnicodeDecodeError):
return str(self._val)
def display_hint(self):
return "string"
def _pretty_print_lookup(val):
t = val.type.strip_typedefs()
if t.code == gdb.TYPE_CODE_PTR:
elem_type = t.target().strip_typedefs()
elif t.code == gdb.TYPE_CODE_ARRAY:
elem_type = t.target().strip_typedefs()
else:
return None
if elem_type.code not in (gdb.TYPE_CODE_INT, gdb.TYPE_CODE_CHAR) or elem_type.sizeof != 1:
return None # not a char-like element type
try:
prefix = val.string(length=len(_RESULT_MARKER))
except (gdb.error, UnicodeDecodeError):
return None
if prefix != _RESULT_MARKER:
return None
return _JsonResultPrettyPrinter(val)
gdb.pretty_printers.append(_pretty_print_lookup)
class DebugGraphFunction(gdb.Function):
"""$debug_graph(EXPR[, name, ptr]...) -- render EXPR's pointer-linked
structure as Debug Visualizer JSON. Extra name/pointer argument pairs
are drawn as external "watched" marker pointers into the graph; every
other local (and file-scope global) visible at the current PC is also
auto-walked and added as a "local"/"global" root -- see module
docstring for why."""
def __init__(self):
super(DebugGraphFunction, self).__init__("debug_graph")
def invoke(self, *args):
if len(args) == 0:
raise gdb.GdbError("usage: $debug_graph(expr, [name, ptr]...)")
root = args[0]
extra = args[1:]
watched_roots = []
i = 0
while i + 1 < len(extra):
name_val, ptr_val = extra[i], extra[i + 1]
try:
name = name_val.string()
except (gdb.error, TypeError):
name = str(name_val)
watched_roots.append((name, ptr_val))
i += 2
return _as_debugger_string(build_graph_json(root, watched_roots))
DebugGraphFunction()