Architecture¶
KernLens (kernlens) is a small, dependency-light toolkit for Linux
kernel debugging from Python and GDB. It is deliberately split into a
pure-Python core that can be unit tested anywhere and a GDB adapter that
keeps gdb-dependent modules out of the core import path. Importing
kernlens or kernlens.gdb is safe outside GDB; resolving a component
that imports gdb requires a debugger session.
Goals¶
- Pure Python core. Encoding, bit math, address classification, page-table arithmetic, formatting, and logging work without GDB, Linux, or a target kernel.
- Explicit state. Pure helpers are static and module-level state is
immutable. Mutable state lives in explicit
Logger,Styler,DebugConfig, adapter-component, andkdbginstances. - Predictable 64-bit semantics. Pointer and page-table helpers mask inputs to 64 bits, so negative Python integers behave like the corresponding unsigned kernel values.
- Validate at the boundary. Public helpers reject impossible widths, alignments, type codes, and offsets instead of silently producing nonsense.
- Plain-text output. The formatters return
str; color is an opt-in decoration, never a structural requirement. - Test without a debugger. The GDB adapter is integration-tested
against a fake
gdbmodule.
Naming¶
GDB commands are the short, stable surface: kernlens-<verb> plus a
kl-<verb> alias. The Python API uses one readable, medium-length
style — snake_case functions, PascalCase classes, no cryptic
C-library abbreviations, and no overlong project-specific names. The
full rules and abbreviation table live in naming.md.
Repository layout¶
.
├── .github/workflows/
│ ├── ci.yml # tests, typing, generators, wheel check
│ ├── docs.yml # builds the site strictly, deploys to Pages
│ └── release.yml # tag -> PyPI -> GitHub Release
├── src/kernlens/
│ ├── __init__.py # public re-exports and __version__
│ ├── cli.py # `kernlens` / `kl` console script: path/install/doctor
│ ├── doctor.py # host-side load-path checks
│ ├── gdbinit.py # GDB startup file, shipped as package data
│ ├── install.py # startup path + ~/.gdbinit marked block
│ ├── core/
│ │ ├── constants.py # layout constants
│ │ ├── validation.py # boundary validators
│ │ ├── bitops.py # BitOps / BITS
│ │ ├── codec.py # IntCodec / ByteOrderCodec / LE / BE
│ │ ├── fmt.py # IntFmt
│ │ ├── addr.py # KAddr
│ │ └── paging.py # KPageTable
│ ├── output/
│ │ ├── palette.py # Palette (raw ANSI)
│ │ ├── colors.py # semantic colour roles
│ │ ├── levels.py # LogLevel
│ │ ├── logger.py # Logger
│ │ └── styler.py # Styler
│ ├── commands/
│ │ ├── __init__.py # package exports + registry accessors
│ │ ├── framework.py # @Command framework: argparse + guards + registry
│ │ ├── formatting.py # shared output/parse helpers
│ │ ├── help.py # GROUP_ORDER + kl-help overview / detail renderers
│ │ ├── overview.py # kl-status
│ │ ├── kernel_base.py# kl-base (+ show/text/offset/idt/rebase)
│ │ ├── memory.py # kl-slab (list/info/contains/details) / kl-sym / kl-bt / ...
│ │ ├── metadata.py # kl-kcmdline / kl-kversion / kl-kconfig / ...
│ │ ├── misc.py # kl-cpu / kl-percpu / kl-msr / kl-color
│ │ ├── paging.py # kl-addr / kl-pt / kl-walk / kl-p2v / ...
│ │ └── primitives.py # kl-bits / kl-int
│ ├── gdb/
│ │ ├── __init__.py # lazy component exports
│ │ ├── _types.py # Pyright protocols
│ │ ├── config.py # DebugConfig
│ │ ├── styler.py # KernelStyler
│ │ ├── reader.py # MemoryReader
│ │ ├── symbols.py # SymbolResolver
│ │ ├── kaslr.py # Kaslr
│ │ ├── kbase.py # Kbase / IDT-based text base recovery
│ │ ├── cpu.py # CpuInfo
│ │ ├── walker.py # PageTableWalker / PageWalk / WalkLevel
│ │ ├── btf.py # self-contained BTF type parser (no libbpf)
│ │ ├── layouts.py # verified fallback struct layouts
│ │ ├── kernel.py # KernelInspector (thin composition of inspectors/)
│ │ ├── inspectors/ # one module per kernel subsystem
│ │ │ ├── _base.py # InspectorBase + shared read primitives
│ │ │ ├── metadata.py # version / cmdline / CONFIG_*
│ │ │ ├── modules.py # modules list + sys_call_table
│ │ │ ├── structpage.py # x86_64 struct page
│ │ │ ├── kallsyms.py # embedded symbol table
│ │ │ ├── dmesg.py # printk_ringbuffer
│ │ │ ├── bpf.py # prog_idr / map_idr
│ │ │ ├── buddy.py # buddy free lists / zone metadata
│ │ │ ├── slabcache.py # SLUB cache list / object lookup
│ │ │ └── dmabuf.py # dma-buf + scatterlists
│ │ ├── slub.py # SlubInspector
│ │ ├── api.py # public API bootstrap for sourced scripts
│ │ └── facade.py # KernelDebugger / kdbg + _BINDINGS table
│ └── py.typed # PEP 561 marker
├── tests/
│ ├── contract_spec.py # frozen external-contract constants
│ ├── unit/ # pure-Python tests
│ └── integration/ # adapter, gdbinit and host-runnable examples
├── docs/
│ ├── en/ # the default language; built on the site root
│ │ ├── architecture.md # layout, API map, and design notes
│ │ ├── compatibility.md # measured / expected / unsupported
│ │ ├── releasing.md # what has to agree before a tag
│ │ ├── index.md # generated from README.md (front page)
│ │ ├── api.md # Python API reference
│ │ ├── commands.md # generated command/alias table
│ │ ├── naming.md # command, example and Python names
│ │ └── tutorial/ # one generated page per command (30)
│ ├── zh-CN/ # the Chinese pages, same paths, own directory
│ │ ├── index.md # generated from README.zh-CN.md
│ │ ├── api.md # the same API reference, in Chinese
│ │ └── tutorial/ # index translated; the pages are not
│ ├── assets/ # generated demo.svg + demo.cast, extra.css,
│ │ # plus the hand-written social-card.svg and
│ │ # its rendered social-card.png, plus a copy
│ │ # of images/banner.svg (see below)
│ └── llms.txt # generated plain-text index of the site
├── overrides/ # mkdocs theme overrides: the language switcher
├── hooks/
│ └── language.py # per-page <html lang>, and the Chinese title
├── demo/
│ ├── Dockerfile # builds the kernel, then the runtime image
│ ├── entrypoint.sh # boot QEMU, wait for userspace, attach GDB
│ ├── demo.gdb # load the image, connect, source gdbinit.py
│ ├── demo-commands.gdb # the address-free tour
│ ├── kernel/
│ │ ├── config.fragment # options the commands read, on defconfig
│ │ └── init.c # the whole userspace: mounts and boot marker
│ └── README.md # how to build and run it
├── bin/kernlens # checkout launcher; `bin/kl` is the same tool
├── install.sh # one-command checkout installer
├── gdbinit.py # checkout shim -> src/kernlens/gdbinit.py
├── examples/
│ ├── api/ # scripting the API
│ │ ├── README.md # what each of these is for
│ │ ├── hello_sample.py # `_sample` the minimum that works
│ │ ├── installed_api_tour.py # `_tour` names gdbinit.py installs
│ │ ├── kernel_facts_tour.py # `_tour` `kfacts` -- the target
│ │ ├── address_tour.py # `_tour` `atour` -- nine views
│ │ ├── slab_tour.py # `_tour` `stour` -- cache down
│ │ ├── symbols_tour.py # `_tour` `ytour` -- resolve/read
│ │ ├── intcodec_tour.py # `_tour` `icodec` -- every width
│ │ ├── intfmt_tour.py # `_tour` `ifmt` -- format by kind
│ │ ├── bitops_tour.py # `_tour` `btour` -- bitfields
│ │ ├── bits_tour.py # `_tour` `bittour` -- kl-bits
│ │ ├── int_tour.py # `_tour` `inttour` -- kl-int
│ │ ├── freelist_walk.py # `_walk` `flwalk` -- SLUB chain
│ │ ├── slab_snapshot.py # `_snapshot` report, no target
│ │ └── gdbinit.example # sample ~/.gdbinit (not a script)
│ ├── tracing/ # breakpoints that log and continue
│ │ ├── README.md
│ │ ├── gdb_tracer.py # `_tracer` generic, env-configured
│ │ ├── cve_2025_38616_tracer.py # `_tracer` one CVE's UAF chain
│ │ └── kaslr_retarget_tour.py # `_tour` `retarget` -- fix the base
│ └── breakpoints/ # breakpoints whose purpose is to stop
│ ├── README.md
│ ├── conditional_bp.py # `_bp` `kbp-object`
│ ├── watchfield_bp.py # `_bp` `kwatch-field`
│ └── per_cpu_bp.py # `_bp` `pbreak`
├── images/banner.svg
├── mkdocs.yml # documentation site, built and deployed by CI
├── .dockerignore # the demo image's build context
├── CITATION.cff # how to cite the project; version and date
├── pyproject.toml
├── tools/ # generators and the example contract
│ ├── example_manifest.py # what each example must exercise
│ ├── check_examples.py # static check; --check runs in CI
│ ├── run_examples_gdb.py # replays the GDB-only examples
│ ├── check_example_capture.py # checks that record against the tree
│ ├── gen_examples_readme.py # renders the README coverage block
│ ├── gen_tutorial.py # renders docs/en/tutorial/ from capture
│ ├── gen_tutorial_index.py # renders each locale's tutorial index
│ ├── gen_commands.py # renders the command table per locale
│ ├── gen_demo.py # renders docs/assets/demo.*
│ ├── gen_docs_site.py # renders the two index pages + llms.txt
│ ├── check_wheel.py # the wheel contains the startup file
│ ├── release.py # version gate + release notes
│ ├── gen_layouts.py # verifies struct layouts against BTF
│ └── abi_ranges.py # struct-drift survey across releases
├── uv.lock
├── CONTRIBUTING.md # setup, tests, the layout generator
├── CONTRIBUTING.zh-CN.md
├── CREDITS.md # third-party attribution
└── CHANGELOG.md
docs/assets/banner.svg is a copy of images/banner.svg, which sits
beside src/ and is therefore outside docs_dir. The front page used
to link it from raw.githubusercontent.com; the copy is made by
tools/gen_docs_site.py instead, so the site is self-contained, shows
the banner at the commit being built, and a fork shows its own. Editing
the source and not regenerating leaves the site on the old words, which
--check catches.
docs/assets/social-card.svg is the only asset here that is hand-written
rather than generated, because it is prose in a different medium: the
one screenshot the project gets to show before anyone clicks. Its PNG
neighbour is what link unfurlers receive, since most reject
image/svg+xml; re-render it after any edit, because nothing in CI can
tell that the two disagree:
chromium --headless --hide-scrollbars --window-size=1200,630 \
--screenshot=docs/assets/social-card.png \
"file://$PWD/docs/assets/social-card.svg"
overrides/main.html points the Open Graph tags at that PNG and builds
the URL from site_url, so the front page and every tutorial page unfurl
with a real image instead of a bare link. tests/unit/test_docs_site.py
holds the surrounding invariants: a text asset in docs/assets/ may not
contain a raw ESC byte, because an SGR sequence in an XML or JSON
artifact makes it unparseable.
Public API map¶
The package root is a convenience surface, not a compatibility layer: it re-exports the pure-Python core and output names. The GDB adapter is opt-in and uses canonical submodule imports. There are no historical module aliases.
| Import | Main public names | Needs GDB |
|---|---|---|
from kernlens import ... |
BitOps, IntCodec, IntFmt, KAddr, KPageTable, Logger, Palette, Styler, constants |
no |
from kernlens.core import ... |
same core names at their canonical layer | no |
from kernlens.output import ... |
Logger, LogLevel, Palette, Styler, colors |
no |
from kernlens.commands import ... |
Command, CommandCategory, registry, register_all, GROUP_ORDER |
yes |
from kernlens.gdb import ... |
lazy component exports such as DebugConfig, MemoryReader, KernelDebugger |
when a gdb-backed name is resolved |
from kernlens.gdb.api import ... |
install_public_api, core_functions, FACADE_FUNCTIONS, kdbg |
yes |
from kernlens.gdb.facade import kdbg |
KernelDebugger composition and the kdbg singleton |
yes |
from kernlens import BitOps, IntCodec, IntFmt, KAddr, KPageTable
# Inside a GDB Python session:
from kernlens.gdb.facade import kdbg
The core and output layers never import gdb; output also does not
import core. This is enforced by tests/unit/test_structure.py.
Dependency graph¶
core.constants / core.validation
├── core.bitops ── core.codec
├── core.fmt ───── core.codec
├── core.addr
└── core.paging
output.palette
├── output.colors
├── output.levels
├── output.logger
└── output.styler
gdb._types / gdb.config
├── gdb.styler ── output
├── gdb.reader
├── gdb.symbols ── output
├── gdb.kaslr
├── gdb.kbase ── core.addr
├── gdb.walker ── core.paging
├── gdb.cpu
├── gdb.inspectors.* ── core.addr, gdb.reader
├── gdb.kernel ── gdb.inspectors.*
├── gdb.slub ── output
└── gdb.facade ── every component
commands.framework ── gdb.facade, output.message, output.tree
├── commands.formatting ── gdb.facade, output.colors
├── commands.overview / kernel_base / memory / metadata / misc /
│ paging / primitives ── commands.framework
└── commands.help ── commands.framework (registry passed in)
There are no import cycles. core.codec is dependency-free;
core.bitops and core.fmt import it; output is independent of
core; the gdb components import only core.*, output, and their
sibling modules, and gdb.facade composes all of them. The
commands.framework module owns the @Command decorator, the
CommandDefinition record, the OnlyWhen* guards, and the registry;
the domain modules import it (plus commands.formatting) and never
commands.help — help.py reads the registry through
framework.registry() instead of importing the command modules.
Module responsibilities¶
core.constants / core.validation¶
- Page and pointer constants:
PAGE_SHIFT,PAGE_SIZE,PAGE_MASK,PTR_BITS,PTR_HEX_DIGITS,PTR_MASK. - x86-64 address-space boundaries:
KERNEL_BASE,KERNEL_BOTTOM,PAGE_OFFSET_BASE,VMEMAP_BASE,VMALLOC_START/END,MODULE_START/END,USER_TOP,MAX_ERRNO. - Validation helpers:
check_bits,check_pow2,check_nonneg.
The modules are pure data/validation; public constants are re-exported
by kernlens/__init__.py.
core.bitops¶
A stateless namespace of integer bit operations. Inputs are masked to the requested width before operating; methods that take an explicit width validate it. Families:
- byte swap (
byte_swap,byte_swap16/32/64); - width reinterpretation (
sign_extend,truncate,to_u*,to_i*,coerce); - width metadata (
mask,sign_bit, ranges); - population and scans (
popcount,leading_zeros,trailing_zeros,first_set,last_set); - power-of-two and logarithms;
- rotation;
- alignment;
- single bits and bit fields;
cpumaskword helpers.
BITS is the conventional short alias.
core.codec¶
Byte-order-aware integer <-> bytes conversion built on struct. The
statics take a trailing byteorder keyword. ByteOrder is the enum of
"<" / ">"; IntType describes the eight integer types (4 widths x
2 signednesses) and exposes code, width, bits, signed, mask,
min_value, and max_value.
ByteOrderCodec binds one order to the statics (LE.pack_u16(...)).
The byteorder parameters accept either a ByteOrder member or a
bound codec, so IntFmt.bytes_u16_array(data, byteorder=LE) and
IntCodec.pack_u16(value, BE) both work.
Validation is delegated to struct for range and buffer-size errors;
negative array counts are rejected explicitly.
core.fmt¶
Integer and byte-string formatting on top of IntCodec. It returns
plain strings and has no color or logging dependency. It covers
addresses, sized hex, offsets, human-readable sizes, ERR_PTR-style
flags, percentages, timestamps, durations, byte hex/ASCII/C-string
views, hexdumps, and typed array formatting.
core.addr¶
x86-64 address classification and direct-map translation:
- user/kernel and canonical checks (48-bit and 57-bit);
- direct map, vmemmap, vmalloc, module, and text ranges;
- virtual <-> physical and pfn <-> page conversions;
- page alignment;
- KASLR link/runtime conversion;
ERR_PTRencode/decode;- pointer tag extraction.
All inputs are masked to 64 bits. The layout constants assume a reasonably recent x86-64 Linux with a 64 TiB direct map and 52-bit physical addresses; callers can pass explicit bases/sizes where the kernel configuration differs.
core.paging¶
Pure page-table geometry and PTE decoding. It never reads memory. It supports 4-level and 5-level paging, index extraction, splitting a virtual address into indices plus offset, canonicalization, entry address computation, PTE flag decoding, huge-page alignment/base helpers, and physical-address reconstruction for 4 KiB, 2 MiB, and 1 GiB leaves.
gdb.walker.PageTableWalker¶
KPageTable is pure arithmetic, so it cannot translate a live address.
PageTableWalker closes that gap without moving memory access into the
core: it reads 8-byte entries through MemoryReader, starting at
$cr3 (falling back to init_top_pgt / swapper_pg_dir) and choosing
4-level or 5-level geometry from CR4.LA57.
walk(va, *, five_level=None, root=None) accepts explicit roots and
paging modes for replaying captured tables and returns a frozen
PageWalk with one WalkLevel per decoded level, the final physical
address, the leaf size (4 KiB / 2 MiB / 1 GiB), and a note when the
walk stopped early. Unreadable, absent, and non-present entries do not
raise, because the walker is usable from breakpoint callbacks. The
kernlens-walk command is the GDB frontend (per-level detail) and
kernlens-pagewalk mirrors pwndbg's deepest-level-first presentation
with an optional --pgd root override.
output.Logger / output.Styler¶
Logger is a small severity-leveled writer with level filtering,
prefix control, stream injection, once / throttle / counter
state, checks, banners, and rules. Styler owns all presentation:
colors, alignment, key/value pairs, containers, tables, boxes, columns,
blocks, highlights, and progress bars.
Styler never requires color. With color_enabled=False, every method
returns the same plain text that the colored path wraps in SGR
sequences. Layout methods share fill, aligns, and
max_col_width; box drawing uses Unicode by default but accepts custom
characters and joints.
output.colors maps meaning onto raw ANSI sequences (a field label is
always LABEL, a tri-state flag TRUE / FALSE / MISSING, and so
on). The command-layer formatting helpers read these roles, so a
restyle touches one file instead of every command module.
gdb/ components¶
The GDB adapter is split by responsibility. Only the modules that
touch the debuggee import gdb; config.py and styler.py stay
debugger-independent:
| Module | Responsibility |
|---|---|
config.py |
KASLR slide, header width, indentation, freelist scan limit |
styler.py |
kernel-specific symbol/location/SLUB formatters on top of Styler |
reader.py |
safe expression/memory reads with self-healing KASLR translation |
symbols.py |
pc -> symbol, file:line, backtrace, call chain |
kaslr.py |
breakpoint specs and link-time _text |
kbase.py |
IDT gate decoding and runtime kernel text-base recovery |
cpu.py |
CPU count, online mask, current CPU, per-CPU offsets |
walker.py |
CR3-based walks, 4/5-level selection, huge-page leaves |
btf.py |
self-contained BTF parser (.BTF in the image, or btf_path); no libbpf/bpftool |
layouts.py |
the verified fallback struct layouts used only when no DWARF and no BTF is available |
inspectors/ |
one mixin per kernel subsystem (see below); each owns its constants, dataclasses, and methods |
kernel.py |
KernelInspector, a thin composition of the inspectors/ mixins behind one object |
slub.py |
struct page/struct slab decoding, freelists, partial lists, slab block printing |
facade.py |
KernelDebugger composition, the _BINDINGS table, and the kdbg singleton |
KernelDebugger composes one instance of each component and republishes
the commonly used methods directly onto the facade. The republication
is driven by a single _BINDINGS table (140 rows of (public_name,
component, member, free_function)); api.FACADE_FUNCTIONS and
api.INSTANCE_NAMES are derived from it, so the public surface is
declared exactly once. Each row is bound as
self.<name>: Final = self.<component>.<member>, which keeps the
component method's real signature (parameter names and defaults
included) and gives pyright --verifytypes a declared type instead of
an inference; tests/unit/test_structure.py fails if a binding is
added without the Final annotation. Component accessors (kdbg.logger,
kdbg.styler, kdbg.reader, kdbg.symbols, kdbg.kaslr, kdbg.kbase,
kdbg.cpu, kdbg.walker, kdbg.kernel, kdbg.slub) remain available.
KernelDebugger.indent is bound to the Styler.indent text formatter;
the configuration prefix is exposed separately as indent_str to avoid
a name collision on the facade.
gdb/inspectors/¶
KernelInspector used to be a 1.6k-line class. It is now split by
kernel subsystem: each InspectorBase subclass is a mixin that owns its
domain's constants, dataclasses, and methods, and KernelInspector
combines them behind one API.
| Module | Subsystem |
|---|---|
_base.py |
InspectorBase (reader + byte/word reads, _cstring, _walk_list, vmemmap_base / page_offset_base) and _ByteReader; also the three-tier struct-offset resolver |
metadata.py |
linux_banner, saved_command_line, the IKCFG CONFIG_* image |
modules.py |
the struct module list and sys_call_table |
structpage.py |
x86_64 struct page type/refcount packing |
kallsyms.py |
the embedded kallsyms table (token expansion + addresses) |
dmesg.py |
the printk_ringbuffer (printk_rb_static) and tk_core |
bpf.py |
BPF prog_idr / map_idr (xarray walk) |
buddy.py |
node_data[].node_zones[].free_area[] and zone/node metadata |
slabcache.py |
the global slab_caches list and object->cache lookup |
dmabuf.py |
the dma-buf list (debugfs_list / db_list) and scatterlists |
Every mixin declares __slots__ = (); the cache slots live only on
InspectorBase, the one base they all share (two sibling bases with
non-empty slots would raise "instance lay-out conflict").
tests/unit/test_structure.py enforces the package shape and that the
inspectors never import kernel, facade, or api.
Struct-offset resolution¶
Nothing may hardcode a struct field offset, because the layout depends
on the kernel version and on CONFIG_* (for example CONFIG_SECURITY
inserts void *security before struct bpf_map::map_type, moving it
from 0x10 to 0x18). The inspectors call
InspectorBase._layout_offset(type, path) / _layout_sizeof(type),
which resolve through a three-tier chain and cache the result:
- DWARF --
gdb.parse_and_evalon the loadedvmlinux; - BTF --
gdb/btf.pyparsing the image's.BTFsection (orreader.config.btf_path); BTF is real type information, so a hit here is not a fallback and does not warn; - the registry --
gdb/layouts.py, whoseStructVariantentries are gated onversion_min/version_maxandCONFIG_*predicates.
The resolvers pass the registry value as the default argument of
_offset_of / _sizeof; the base records the key in _fallbacks the
first time a default is used, and CommandDefinition.invoke prints one
message.warn per struct type (via framework.warn_fallback_layouts)
when that set is non-empty. reset() clears the offset cache, the
fallback report, and the cached BTF handle.
Only the loaded image's BTF is consulted -- never the host's -- so a
cross-kernel session cannot silently borrow the wrong layout.
tools/gen_layouts.py regenerates and verifies the registry against a
real kernel, and tools/abi_ranges.py reads a local linux-stable
clone to report which release ranges need a row of their own (a member
list is an upper bound on change -- it says where to probe, not what the
value is).
commands/¶
The GDB commands follow pwndbg's convention. A command is a plain
function named kernlens_<verb> decorated with
@Command(parser, category=..., aliases=[...], examples=..., notes=...)
and (optionally) a guard:
_parser = argparse.ArgumentParser(description="Displays the loaded modules.")
_parser.add_argument("module_name", nargs="?", default=None)
@Command(_parser, category=CommandCategory.KERNEL_METADATA, aliases=["kl-kmod"])
@OnlyWithKernelSymbols
def kernlens_kmod(module_name: str | None = None) -> None:
...
The framework (commands/framework.py) reproduces pwndbg's semantics:
- the decorator records a
CommandDefinition(function, parser, category, aliases, examples, notes) and derives the command name from the function name, so the registry is populated without a hand-maintained list; -h/--helpprint the parser, whose epilog carries the examples, notes, and alias list;- a guard that fails prints
message.error(...)and returns instead of raising, and anygdb.GdbErrorfrom the body is reported asmessage.errorand swallowed — a command never aborts the session; register_all()instantiates onegdb.Commandsubclass per definition (plus one perkl-alias) and is idempotent, so sourcinggdbinit.pytwice is safe.
Output uses the pwndbg print idiom with the shared
output.message (error/warn/hint/notice/success/info/on/
off) and output.tree.IndentContextManager helpers. commands/help.py
renders the grouped kl-help overview from the categories and the
per-command detail from the parser; it reads the registry through the
framework, so it never imports the domain modules.
src/kernlens/gdbinit.py is only a bootstrap: its bootstrap(namespace)
imports the package, calls register_all(), and installs the public
Python API. The root gdbinit.py is a shim that calls it with the
namespace GDB sourced.
Loading inside GDB¶
Normal package import:
Importing kernlens.gdb itself is safe outside GDB; its public names are
resolved lazily from their own modules. Resolving a component that
imports gdb (reader, symbols, kaslr, kbase, cpu, walker,
slub, facade) requires a debugger session; config and styler do
not.
gdbinit.py is a thin bootstrap: it imports kernlens.commands, which
registers the kernlens-* commands and their kl-* aliases, then calls
register_all(). The complete command table, aliases, and invocation
examples live in the project README and the generated
command reference; kl-help lists
them grouped by category and kl-help kl-<command> (or
kl-help kernlens-<command>) shows one in detail.
The startup file is package data, so source $(kernlens path) works from
a wheel as well as from a checkout: it adds the parent of the kernlens/
directory (src/ or site-packages) to sys.path before importing,
because GDB's embedded Python usually sees neither. The root
gdbinit.py is a checkout shim: it adds src/ and calls the packaged
bootstrap() with its own globals.
It also calls kernlens.gdb.api.install_public_api() with its own
globals. GDB sources Python files into __main__, so a later
source anything.py can use KAddr, KPageTable, IntFmt, kdbg,
read_kvar, walk, sym, head_to_page, print_slab, find_kbase,
and the other names in FACADE_FUNCTIONS without imports. Stateless
core methods are also exported as free functions (byte_swap64,
popcount, pack_u16, virt_to_phys, split_4, ...) from BitOps,
IntCodec, KAddr, KPageTable, and IntFmt; core_functions()
returns that mapping.
Class-qualified access (BitOps.byte_swap64), identifier-safe aliases
(BitOps_byte_swap64), and dotted keys in KERNLENS_QUALIFIED are
available when collision-free names are preferred. Stateful
components (logger, styler, config, reader, symbols, kaslr,
kbase, cpu, walker, slub) are installed as the default instances owned
by kdbg; KERNLENS_INSTANCES maps them, and KERNLENS_CLASSES
supports constructing independent instances. Constructors allocate
fresh state (for example a new Logger has its own counters and
minimum level); mutating a local instance never changes the global
defaults. KERNLENS_API records the installed mapping. This is a
GDB-only bootstrap; ordinary host code should import from kernlens or
kernlens.gdb explicitly.
SLUB inspection model¶
SlubInspector targets contemporary SLUB layouts:
- Head -> page. For a direct-map object address, subtract
page_offset_base, shift byPAGE_SHIFTto get a pfn, and addpfn * sizeof(struct page)tovmemmap_base. - Tail -> head. A
struct pagewithcompound_head & 1is a tail page; clear the low bit to recover the head. - Cache.
struct slab.slab_cachepoints at thekmem_cache; the cache name is read as a C string. - Counters.
struct slab.counterspacksinuse:16,objects:15, andfrozen:1. - Freelist.
kmem_cache.offsetis the freeptr slot. A value of0is valid: SLUB stores the pointer at the start of the object for ordinary caches. WhenCONFIG_SLAB_FREELIST_HARDENEDis on, the stored word isptr ^ random ^ swab(ptr_addr);decode_freeptrinverts that.freelist_distwalks the chain with a scan limit and cycle detection. - Partial lists.
kmem_cache.node[node].partialis alist_head; the slab is recovered by subtracting theslab_listoffset.in_partialscans with a bound and cycle guard.
Per-CPU slabs are read through kmem_cache.cpu_slab plus
__per_cpu_offset[cpu]. print_slab prints the head, per-CPU
freelists, node partial lists, and the head slab's freelist chain.
The code assumes the kernel has struct slab (5.17+) and the usual
page_offset_base / vmemmap_base symbols. Kernels with a different
SLUB layout need a small adapter rather than changes to the generic
core.
Error handling and limits¶
- Public pure-Python helpers raise
ValueErrorfor invalid widths, alignments, counts, offsets, and ranges. IntCodecletsstruct.errorpropagate for overflow and short buffers.- GDB-facing readers return
Nonewhen a read fails; callers decide whether that is fatal. - Page-table walks are bounded by the paging level count (4 or 5) and
return a
noteinstead of raising on an unreadable or missing entry. - Freelist walks are bounded by
DebugConfig.freelist_scan_limit(default 4096) and detect cycles. Partial-list scans are capped at 10,000 links and stop on the list head or a repeated link. - KASLR is self-healing.
MemoryReaderappliesDebugConfig.kaslr_offsetto KASLR-aware reads and, when the slide is unset (or a read fails at it), asks the facade's IDT-based detector for the current slide and retries. A link-time read is tried last, so a debugger that already carries the relocation (add-symbol-file vmlinux <runtime>) still works. Whenever the slide changes the reader fires anon_changehook that dropsKernelInspector's address-derived caches, which is what makes every ported command follow a KASLR change without a manualkl-base.kl-baseremains the explicit, user-facing way to pin the slide: itsoffsetandtextsubcommands override it by hand.
Testing strategy¶
- Unit tests cover every pure-Python module, including validation errors and boundary values.
- Integration tests install a fake
gdbmodule before importingkernlens.gdb.facade. The fake implementsparse_and_eval,execute,find_pc_line,newest_frame, anderror, and provides a deterministic memory/symbol model.tests/integration/test_gdb_facade.pycoversMemoryReader,SymbolResolver,Kaslr,CpuInfo,SlubInspector,PageTableWalker, and the facade without a live kernel, including complete 4-level and 5-level walks over synthetic tables. tests/integration/test_gdbinit.pyexecutes the real checkout shim (gdbinit.py) against a recording fakegdband a stub facade, covering command registration, aliases, argument validation, and output formatting.tests/unit/test_cli.pycovers the host-side console script, andtools/check_wheel.pychecks the built wheel.tests/contract_spec.pyfreezes the observable external contract (the canonical command set, the facade's bound names, the installed free functions, and the component instances).tests/unit/test_contract.pychecks the constants are self-consistent, and both integration suites assert that the live registry, aliases, facade, and public API match the frozen set — so the internal package layout can be restructured without silently changing the public surface.- Examples are intentionally small and can be run as smoke tests
(
uv run python examples/api/slab_snapshot.pyfrom a checkout).
Development is managed with uv. uv sync installs the project and the
dev dependency group from the committed uv.lock; run tests with
either runner inside that environment:
uv sync
uv run pytest --cov=kernlens
uv run python -m unittest discover -s tests/unit
uv run python -m unittest discover -s tests/integration
Runtime requirements¶
- Python 3.10+.
- Linux x86-64 kernel targets.
- GDB built with Python 3.10+ (the adapter uses the standard GDB Python API; no debugger-specific extension is required).
- UTF-8 terminal for the default box-drawing and progress characters;
custom
chars/jointsare available for ASCII-only terminals. typing_extensionsis only required on Python < 3.12 (fortyping.override).