From cd1f2beb98a592ba94dd779c22b7a9337969cf7c Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 15:20:53 +0530 Subject: [PATCH 01/18] Tests: Add the ir-first-feature skill and the globals C reference Captures the development loop this project has always used for new compiler features: write a minimal C eBPF program exercising only the feature, compile it to LLVM IR and read that as the specification, diff against what PythonBPF currently emits, stop for a human decision on the Python syntax, implement against the reference, and test at the right tier. Recorded as a project skill so agents follow it too; the syntax step is an explicit hard stop. tests/c-form/global_vars.bpf.c is the loop's step 1 for global variables: the four C global classes and nothing else. Its IR established that every global is an independent symbol accessed by plain load/store, that llc manufactures the BTF VAR/DATASEC entries from DIGlobalVariable metadata, and that clang folds const but not const volatile -- the facts the globals design rests on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- .claude/skills/ir-first-feature/SKILL.md | 72 ++++++++++++++++++++++++ tests/c-form/global_vars.bpf.c | 22 ++++++++ 2 files changed, 94 insertions(+) create mode 100644 .claude/skills/ir-first-feature/SKILL.md create mode 100644 tests/c-form/global_vars.bpf.c diff --git a/.claude/skills/ir-first-feature/SKILL.md b/.claude/skills/ir-first-feature/SKILL.md new file mode 100644 index 0000000..19d8928 --- /dev/null +++ b/.claude/skills/ir-first-feature/SKILL.md @@ -0,0 +1,72 @@ +--- +name: ir-first-feature +description: PythonBPF's development loop for implementing a new compiler feature — write a minimal C eBPF reference, compile it to LLVM IR and read that as the spec, stop for a human syntax decision, then implement against the reference. Use whenever adding or extending a PythonBPF language feature (new statement/expression support, map types, globals, helpers, program constructs). +--- + +# The IR-first feature loop + +PythonBPF targets LLVM IR via llvmlite. For any new feature, clang's output for the +equivalent C is the specification — not documentation, not intuition. Follow the loop +in order; do not skip steps because the feature "looks simple". + +## 1. Write the C reference + +A minimal `.bpf.c` in `tests/c-form/` exercising **only** the target feature. Small +enough that every line of the resulting IR is attributable to the feature. Prefer no +includes (define `SEC` and the `__u*` typedefs by hand) so nothing else pollutes the IR. +Cover each variant of the feature in one file (e.g. for globals: zero-init, initialized, +const, const volatile). + +## 2. Compile and read the IR — this is the spec + +```bash +clang -target bpf -O2 -g -emit-llvm -S feature.bpf.c -o feature.ll +llc -march=bpf -filetype=obj feature.ll -o feature.o +bpftool btf dump file feature.o # what must come out the far end +``` + +Read `feature.ll` and answer, in writing: What top-level symbols/globals appear? What +do loads/stores/calls look like in the body? What `!DI*` debug metadata exists, and +what BTF does llc manufacture from it? What did -O2 fold away, and does that folding +carry semantics (it did for `const` globals)? + +Version discipline: llvmlite ≥0.49 emits LLVM 21/22-era attribute spellings +(`captures(none)`, not `nocapture`). Use a clang/llc generation that accepts them, and +compare against what `pythonbpf` + the CI's LLVM actually use. + +## 3. Diff against current PythonBPF output + +Compile the nearest thing PythonBPF can already express and diff the `.ll`s. The delta +is the actual work item — often smaller than expected (machinery like section placement +and BTF generation frequently comes free from llc). + +## 4. HARD STOP — syntax is a human decision + +Present 2–3 Pythonic syntax candidates with trade-offs (declaration site, usage site, +failure modes, precedents from FastAPI/typing/Triton-style DSLs). **Wait for a human to +choose. Never proceed on your own judgment, and never treat silence as consent.** The +maintainers own the language surface. + +## 5. Implement against the reference + +Emit IR through the existing passes (`globals_pass`, `expr_pass`, `assign_pass`, +`allocation_pass`, `debuginfo/`). Verify by **diffing your emitted `.ll` against the +clang reference for the same shapes** — "it compiles and llc accepts it" is not the +bar; llc accepts plenty of subtly wrong IR. + +## 6. Test at the right tier + +- Works now → `tests/passing_tests//`. +- Documents a gap → `tests/kernel_selftest_equivalent/` with a strict xfail in + `tests/test_config.toml` (level `"ir"`, `"llc"`, or `"verifier"`). +- Wrong-input behaviour → `tests/failing_tests/` with a config entry. +- Kernel verifier level runs in CI; locally it needs the user's sudo — ask, don't + assume. + +## House guardrails (always) + +- **Never read/cat/grep `vmlinux.py` or `vmlinux.h`** — generated, enormous, will + exhaust context. Probe with one-liners: + `.venv/bin/python -c "import vmlinux; print(vmlinux.struct_x._fields_[:3])"` +- Use `.venv/bin/python`; the system python has no llvmlite. +- Atomic commits, `Core:`/`Tests:` subject prefixes, one logical change each. diff --git a/tests/c-form/global_vars.bpf.c b/tests/c-form/global_vars.bpf.c new file mode 100644 index 0000000..1342769 --- /dev/null +++ b/tests/c-form/global_vars.bpf.c @@ -0,0 +1,22 @@ +/* Minimal reference: the four C global-variable classes, nothing else. + * No headers, so every line of IR is attributable. */ +#define SEC(name) __attribute__((section(name), used)) +typedef unsigned int __u32; +typedef unsigned long long __u64; + +__u64 counter; /* zero-init -> .bss, mutable */ +__u64 total = 7; /* init -> .data, mutable */ +const __u32 version = 3; /* const -> .rodata, clang folds */ +const volatile __u32 filter_pid; /* cfg knob -> .rodata, never folded */ + +SEC("tracepoint/syscalls/sys_enter_nanosleep") +int prog(void *ctx) +{ + if (filter_pid == 0) + return 0; + counter += 1; + total += version; + return (int)counter; +} + +char _license[] SEC("license") = "GPL"; From 3d8a2110389d589f27eb4820a05d6c74fb230dac Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 15:24:59 +0530 Subject: [PATCH 02/18] Core: Register @bpfglobal variables in a symbol table globals_processing emitted each global and forgot it, which is why nothing could ever read one back. Each integer-scalar global now lands in CompilationContext.bpf_globals as a BpfGlobalSymbol; non-scalar globals get a clear NotImplementedError instead of emitting something unusable. LICENSE stays special-cased and unaffected. Alignment becomes natural (width/8) instead of a blanket 8, matching what clang emits for the same declarations in tests/c-form/global_vars.bpf.c -- llc derives the BTF DATASEC layout from these symbols. Also adds current_func_globals to the context for the upcoming write support. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/context.py | 7 +++++++ pythonbpf/globals_pass.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/pythonbpf/context.py b/pythonbpf/context.py index 5297889..8ebd21b 100644 --- a/pythonbpf/context.py +++ b/pythonbpf/context.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from pythonbpf.structs.struct_type import StructType from pythonbpf.maps.maps_utils import MapSymbol + from pythonbpf.globals_pass import BpfGlobalSymbol logger = logging.getLogger(__name__) @@ -66,6 +67,11 @@ def __init__(self, module: ir.Module): self.global_sym_tab: list[ir.GlobalVariable] = [] self.structs_sym_tab: dict[str, "StructType"] = {} self.map_sym_tab: dict[str, "MapSymbol"] = {} + self.bpf_globals: dict[str, "BpfGlobalSymbol"] = {} + + # Names a `global` statement declared writable in the function whose + # body is currently being emitted; managed by process_func_body. + self.current_func_globals: set[str] = set() # Helper management self.scratch_pool = ScratchPoolManager() @@ -80,3 +86,4 @@ def reset(self): """Reset state between functions if necessary, though new context per compile is preferred.""" self.scratch_pool.reset() self.current_func = None + self.current_func_globals = set() diff --git a/pythonbpf/globals_pass.py b/pythonbpf/globals_pass.py index 27eecd2..78fa564 100644 --- a/pythonbpf/globals_pass.py +++ b/pythonbpf/globals_pass.py @@ -1,6 +1,7 @@ from llvmlite import ir import ast +from dataclasses import dataclass from logging import Logger import logging from .type_deducer import ctypes_to_ir @@ -8,6 +9,20 @@ logger: Logger = logging.getLogger(__name__) +@dataclass +class BpfGlobalSymbol: + """A mutable BPF global variable declared with @bpfglobal. + + Lands in .bss (zero initializer) or .data (non-zero) and is read with a + plain load / written with a plain store; libbpf exposes the sections to + userspace as global-data maps. + """ + + var: ir.GlobalVariable + ir_type: ir.Type + ctype_name: str + + def populate_global_symbol_table(tree, compilation_context): """ compilation_context: CompilationContext @@ -68,7 +83,11 @@ def _emit_global(module: ir.Module, node, name): gvar = ir.GlobalVariable(module, ty, name=name) gvar.initializer = llvm_init - gvar.align = 8 + # Natural alignment, matching what clang emits for the same declaration + # (align 4 for i32, align 8 for i64). llc derives the BTF DATASEC layout + # from these symbols, so the alignment should mirror the C reference in + # tests/c-form/global_vars.bpf.c. + gvar.align = ty.width // 8 if isinstance(ty, ir.IntType) else 8 gvar.linkage = "dso_local" gvar.global_constant = False return gvar @@ -111,7 +130,19 @@ def globals_processing(tree, compilation_context): node.body[0].value, (ast.Constant, ast.Name, ast.Call) ) ): - _emit_global(compilation_context.module, node, name) + gvar = _emit_global(compilation_context.module, node, name) + if isinstance(gvar.value_type, ir.IntType): + compilation_context.bpf_globals[name] = BpfGlobalSymbol( + var=gvar, + ir_type=gvar.value_type, + ctype_name=node.returns.id, + ) + else: + raise NotImplementedError( + f"Global '{name}': only integer scalar globals are " + f"supported so far; '{node.returns.id}' globals are " + f"planned for a later milestone" + ) else: raise SyntaxError(f"ERROR: Invalid syntax for {name} global") From e95ee717fdb7f700a1f3e27dc05dfab88fa9ea08 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 15:29:24 +0530 Subject: [PATCH 03/18] Core: Resolve @bpfglobal reads in expressions and f-strings Name resolution tried local_sym_tab, then vmlinux enums, then failed. Globals now resolve between the two, in _handle_name_expr, get_operand_value, and the printk f-string formatter, each emitting the plain 'load i64, ptr @counter' form the C reference produces. A global therefore works in conditions, binops, print arguments and as a helper scalar argument with no further changes -- those paths all funnel through eval_expr/get_operand_value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/expr/expr_pass.py | 15 +++++++++++++-- pythonbpf/helper/printk_formatter.py | 17 ++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/pythonbpf/expr/expr_pass.py b/pythonbpf/expr/expr_pass.py index 2270fdb..08967fb 100644 --- a/pythonbpf/expr/expr_pass.py +++ b/pythonbpf/expr/expr_pass.py @@ -22,12 +22,20 @@ # ============================================================================ -def _handle_name_expr(expr: ast.Name, local_sym_tab: Dict, builder: ir.IRBuilder): +def _handle_name_expr( + expr: ast.Name, compilation_context, local_sym_tab: Dict, builder: ir.IRBuilder +): """Handle ast.Name expressions.""" if expr.id in local_sym_tab: var = local_sym_tab[expr.id].var val = builder.load(var) return val, local_sym_tab[expr.id].ir_type + elif expr.id in compilation_context.bpf_globals: + # A @bpfglobal: read straight off the global symbol, exactly the + # `load i64, ptr @counter` form clang emits (tests/c-form/global_vars). + sym = compilation_context.bpf_globals[expr.id] + val = builder.load(sym.var) + return val, sym.ir_type else: # Check if it's a vmlinux enum/constant vmlinux_result = VmlinuxHandlerRegistry.handle_name(expr.id) @@ -175,6 +183,9 @@ def get_operand_value(func, compilation_context, operand, builder, local_sym_tab else: val = deref_to_depth(func, builder, var, depth) return val + elif operand.id in compilation_context.bpf_globals: + # A @bpfglobal: plain load off the global symbol. + return builder.load(compilation_context.bpf_globals[operand.id].var) else: # Check if it's a vmlinux enum/constant vmlinux_result = VmlinuxHandlerRegistry.handle_name(operand.id) @@ -662,7 +673,7 @@ def eval_expr( logger.info(f"Evaluating expression: {ast.dump(expr)}") if isinstance(expr, ast.Name): - return _handle_name_expr(expr, local_sym_tab, builder) + return _handle_name_expr(expr, compilation_context, local_sym_tab, builder) elif isinstance(expr, ast.Constant): return _handle_constant_expr(compilation_context, builder, expr) elif isinstance(expr, ast.Call): diff --git a/pythonbpf/helper/printk_formatter.py b/pythonbpf/helper/printk_formatter.py index 3328e1a..f9d3537 100644 --- a/pythonbpf/helper/printk_formatter.py +++ b/pythonbpf/helper/printk_formatter.py @@ -41,7 +41,7 @@ def handle_fstring_print( fmt_parts, exprs, local_sym_tab, - compilation_context.structs_sym_tab, + compilation_context, ) else: raise NotImplementedError(f"Unsupported f-string value type: {type(value)}") @@ -80,19 +80,21 @@ def _process_constant_in_fstring(cst, fmt_parts, exprs): ) -def _process_fval(fval, fmt_parts, exprs, local_sym_tab, struct_sym_tab): +def _process_fval(fval, fmt_parts, exprs, local_sym_tab, compilation_context): """Process formatted values in f-string.""" logger.debug(f"Processing formatted value: {ast.dump(fval)}") if isinstance(fval.value, ast.Name): - _process_name_in_fval(fval.value, fmt_parts, exprs, local_sym_tab) + _process_name_in_fval( + fval.value, fmt_parts, exprs, local_sym_tab, compilation_context + ) elif isinstance(fval.value, ast.Attribute): _process_attr_in_fval( fval.value, fmt_parts, exprs, local_sym_tab, - struct_sym_tab, + compilation_context.structs_sym_tab, ) else: raise NotImplementedError( @@ -100,11 +102,16 @@ def _process_fval(fval, fmt_parts, exprs, local_sym_tab, struct_sym_tab): ) -def _process_name_in_fval(name_node, fmt_parts, exprs, local_sym_tab): +def _process_name_in_fval( + name_node, fmt_parts, exprs, local_sym_tab, compilation_context +): """Process name nodes in formatted values.""" if local_sym_tab and name_node.id in local_sym_tab: _, var_type, tmp = local_sym_tab[name_node.id] _populate_fval(var_type, name_node, fmt_parts, exprs) + elif name_node.id in compilation_context.bpf_globals: + var_type = compilation_context.bpf_globals[name_node.id].ir_type + _populate_fval(var_type, name_node, fmt_parts, exprs) else: # Try to resolve through vmlinux registry if not in local symbol table result = VmlinuxHandlerRegistry.handle_name(name_node.id) From 930594d932f8ad130ddd6c69643612142e6b94d9 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 16:40:49 +0530 Subject: [PATCH 04/18] Core: Support writes to @bpfglobal variables via the global statement Python's own scoping marks the write: 'global cg_id' declares that assignments to cg_id in this function mean the BPF global, and the assignment then emits the plain 'store i64 %v, ptr @cg_id' of the C reference, with the same implicit widening/truncation rules as local assignments. Without the declaration, an assignment to a global's name is refused: SyntaxError: assignment to 'cg_id' shadows the BPF global of the same name -- add 'global cg_id' to write to it That is deliberate. In real Python such an assignment creates a shadowing local; silently compiling it as either a local or a global store would be wrong in one direction or the other, so it is a loud error instead. 'global' naming something that is not a @bpfglobal is also a compile error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/allocation_pass.py | 14 ++++++++++++++ pythonbpf/assign_pass.py | 24 ++++++++++++++++++++++++ pythonbpf/functions/functions_pass.py | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 466800b..9cae135 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -49,6 +49,20 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab): continue var_name = target.id + + # Writes to @bpfglobal variables use the global symbol, not a stack + # slot. Requires Python's own `global` declaration; without it an + # assignment to a global's name would silently create a local that + # shadows it, which is exactly the bug class we refuse to compile. + if var_name in compilation_context.current_func_globals: + logger.debug(f"'{var_name}' is a declared global, no allocation needed") + continue + if var_name in compilation_context.bpf_globals: + raise SyntaxError( + f"assignment to '{var_name}' shadows the BPF global of the same " + f"name — add 'global {var_name}' to write to it" + ) + # Skip if already allocated if var_name in local_sym_tab: logger.debug(f"Variable {var_name} already allocated, skipping") diff --git a/pythonbpf/assign_pass.py b/pythonbpf/assign_pass.py index 91133db..9939357 100644 --- a/pythonbpf/assign_pass.py +++ b/pythonbpf/assign_pass.py @@ -105,6 +105,30 @@ def handle_variable_assignment( ): """Handle single named variable assignment.""" + # A name declared with `global` writes the @bpfglobal symbol directly: + # the plain `store i64 %v, ptr @counter` form of the C reference. + if var_name in compilation_context.current_func_globals: + sym = compilation_context.bpf_globals[var_name] + val_result = eval_expr(func, compilation_context, builder, rval, local_sym_tab) + if val_result is None: + logger.error(f"Failed to evaluate value for global {var_name}") + return False + val, val_type = val_result + if isinstance(val_type, ir.IntType) and isinstance(sym.ir_type, ir.IntType): + # Same implicit widening/truncation rules as local assignments + if val_type.width < sym.ir_type.width: + val = builder.sext(val, sym.ir_type) + elif val_type.width > sym.ir_type.width: + val = builder.trunc(val, sym.ir_type) + elif val_type != sym.ir_type: + logger.error( + f"Type mismatch for global {var_name}: {val_type} vs {sym.ir_type}" + ) + return False + builder.store(val, sym.var) + logger.info(f"Stored to BPF global {var_name}") + return True + if var_name not in local_sym_tab: logger.error(f"Variable {var_name} not declared.") return False diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 69be323..124cdb1 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -284,6 +284,9 @@ def process_stmt( handle_assign(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.AugAssign): raise SyntaxError("Augmented assignment not supported") + elif isinstance(stmt, ast.Global): + # Declarations were collected by process_func_body; nothing to emit. + pass elif isinstance(stmt, ast.If): handle_if(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.Return): @@ -311,6 +314,22 @@ def process_func_body( local_sym_tab = {} + # Collect `global x` declarations. Python scoping rules apply: a declared + # name may be written anywhere in this function and always means the + # @bpfglobal, never a local. Undeclared writes to a global name are + # rejected in the allocation pass rather than silently shadowing. + declared_globals: set[str] = set() + for node in ast.walk(func_node): + if isinstance(node, ast.Global): + for gname in node.names: + if gname not in compilation_context.bpf_globals: + raise SyntaxError( + f"'global {gname}' in '{func_node.name}': no @bpfglobal " + f"named '{gname}' is declared" + ) + declared_globals.add(gname) + compilation_context.current_func_globals = declared_globals + # Add the context parameter (first function argument) to the local symbol table if func_node.args.args and len(func_node.args.args) > 0: context_arg = func_node.args.args[0] @@ -375,6 +394,8 @@ def process_func_body( if not did_return: builder.ret(ir.Constant(ir.IntType(64), 0)) + compilation_context.current_func_globals = set() + def process_bpf_chunk(func_node, compilation_context, return_type): """Process a single BPF chunk (function) and emit corresponding LLVM IR.""" From 4ee96e7461e6c5650848e0f8ec09af39a1213759 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 16:43:00 +0530 Subject: [PATCH 05/18] Core: Support augmented assignment by desugaring to x = x op v AugAssign previously raised 'not supported' outright. Desugaring to an ordinary assignment with a BinOp reproduces its Python semantics for Name and Attribute targets and reuses the whole existing assignment path -- locals, struct fields, and the new globals all work, so 'counter += 1' (the canonical global idiom in the kernel selftest corpus) compiles to load/add/store on @counter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/functions/functions_pass.py | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 124cdb1..494de6a 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -189,6 +189,33 @@ def handle_assign(func, compilation_context, builder, stmt, local_sym_tab): logger.error(f"Unsupported assignment target: {ast.dump(target)}") +def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): + """Handle `x += v` by desugaring to `x = x op v` and reusing handle_assign. + + That is the statement's Python semantics for the targets we support, and it + means globals come along for free: `counter += 1` under `global counter` + becomes load/add/store on @counter. + """ + if isinstance(stmt.target, ast.Name): + load_target = ast.Name(id=stmt.target.id, ctx=ast.Load()) + elif isinstance(stmt.target, ast.Attribute): + load_target = ast.Attribute( + value=stmt.target.value, attr=stmt.target.attr, ctx=ast.Load() + ) + else: + raise SyntaxError( + f"Unsupported augmented-assignment target: {ast.dump(stmt.target)}" + ) + + desugared = ast.Assign( + targets=[stmt.target], + value=ast.BinOp(left=load_target, op=stmt.op, right=stmt.value), + ) + ast.copy_location(desugared, stmt) + ast.fix_missing_locations(desugared) + handle_assign(func, compilation_context, builder, desugared, local_sym_tab) + + def handle_cond(func, compilation_context, builder, cond, local_sym_tab): val = eval_expr(func, compilation_context, builder, cond, local_sym_tab)[0] return convert_to_bool(builder, val) @@ -283,7 +310,7 @@ def process_stmt( elif isinstance(stmt, ast.Assign): handle_assign(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.AugAssign): - raise SyntaxError("Augmented assignment not supported") + handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.Global): # Declarations were collected by process_func_body; nothing to emit. pass From 26ee4d44c79e8204095bae93a554729d6db85cb8 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 17:07:22 +0530 Subject: [PATCH 06/18] Core: Emit debug info for globals so llc produces BTF VAR and DATASEC Each integer global gets a DIGlobalVariableExpression with a C-named basic type (matching signedness and width), attached via !dbg -- the same mechanism map_debug_info.py already uses. llc's BPF backend manufactures the BTF VAR and DATASEC '.bss'/'.data' entries from it: [2] VAR 'expected_pid' type_id=1, linkage=global [3] VAR 'cg_id' type_id=1, linkage=global [4] DATASEC '.bss' size=0 vlen=2 Verified against clang's output for the same shapes: the in-object DATASEC var offsets read 0 in both (libbpf patches them from the ELF symbol table at load), and the ELF symbols carry the real layout, identical to the reference (expected_pid at .bss+0, cg_id at .bss+8). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/globals_pass.py | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pythonbpf/globals_pass.py b/pythonbpf/globals_pass.py index 78fa564..6afcb9c 100644 --- a/pythonbpf/globals_pass.py +++ b/pythonbpf/globals_pass.py @@ -5,9 +5,25 @@ from logging import Logger import logging from .type_deducer import ctypes_to_ir +from .debuginfo import DebugInfoGenerator +from .debuginfo import dwarf_constants as dc logger: Logger = logging.getLogger(__name__) +_SIGNED_CTYPES = { + "c_int8", + "c_int16", + "c_int32", + "c_int64", + "c_int", + "c_short", + "c_long", + "c_longlong", + "c_byte", +} + +_C_NAME_BY_WIDTH = {8: "char", 16: "short", 32: "int", 64: "long long"} + @dataclass class BpfGlobalSymbol: @@ -93,6 +109,28 @@ def _emit_global(module: ir.Module, node, name): return gvar +def _emit_global_debug_info(compilation_context, gvar, name, ctype_name): + """Attach DIGlobalVariableExpression metadata to a BPF global. + + llc's BPF backend manufactures the BTF VAR and DATASEC ('.bss'/'.data') + entries from exactly this metadata (see tests/c-form/global_vars.bpf.c); + without it, libbpf still creates the section maps but neither bpftool nor a + future skeleton can tell which variable lives at which offset. + """ + generator = DebugInfoGenerator(compilation_context.module) + width = gvar.value_type.width + signed = ctype_name in _SIGNED_CTYPES + base = _C_NAME_BY_WIDTH[width] + if width == 8: + encoding = dc.DW_ATE_signed_char if signed else dc.DW_ATE_unsigned_char + else: + encoding = dc.DW_ATE_signed if signed else dc.DW_ATE_unsigned + cname = base if signed else f"unsigned {base}" + di_type = generator.get_basic_type(cname, width, encoding) + dv = generator.create_global_var_debug_info(name, di_type, is_local=False) + gvar.set_metadata("dbg", dv) + + def globals_processing(tree, compilation_context): """Process stuff decorated with @bpf and @bpfglobal except license and return the section name""" # Local tracking for duplicate checking if needed, or we can iterate context @@ -137,6 +175,9 @@ def globals_processing(tree, compilation_context): ir_type=gvar.value_type, ctype_name=node.returns.id, ) + _emit_global_debug_info( + compilation_context, gvar, name, node.returns.id + ) else: raise NotImplementedError( f"Global '{name}': only integer scalar globals are " From c0fda58d2789198d3314abe4d46200e26807b3e1 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Tue, 1 Sep 2026 17:10:15 +0530 Subject: [PATCH 07/18] Tests: Cover global variable reads, writes, and the error cases Five passing tests: read in condition and print; the get_cgroup_id_kern.c read-one-write-one shape; the counter += 1 idiom; two programs sharing global state (the test_autoattach.c shape); and mixed .bss/.data placement with mixed widths and signedness. Three strict expected failures documenting deliberate errors: assignment without the global statement (shadowing), a global statement naming a non-global, and a non-integer global type. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- tests/failing_tests/globals_bad_type.py | 25 +++++++++++ tests/failing_tests/globals_shadowing.py | 26 ++++++++++++ .../failing_tests/globals_undeclared_name.py | 19 +++++++++ .../globals/augassign_counter.py | 31 ++++++++++++++ tests/passing_tests/globals/bss_and_data.py | 34 +++++++++++++++ tests/passing_tests/globals/read_scalar.py | 28 +++++++++++++ .../globals/shared_between_programs.py | 41 +++++++++++++++++++ tests/passing_tests/globals/write_scalar.py | 36 ++++++++++++++++ tests/test_config.toml | 6 +++ 9 files changed, 246 insertions(+) create mode 100644 tests/failing_tests/globals_bad_type.py create mode 100644 tests/failing_tests/globals_shadowing.py create mode 100644 tests/failing_tests/globals_undeclared_name.py create mode 100644 tests/passing_tests/globals/augassign_counter.py create mode 100644 tests/passing_tests/globals/bss_and_data.py create mode 100644 tests/passing_tests/globals/read_scalar.py create mode 100644 tests/passing_tests/globals/shared_between_programs.py create mode 100644 tests/passing_tests/globals/write_scalar.py diff --git a/tests/failing_tests/globals_bad_type.py b/tests/failing_tests/globals_bad_type.py new file mode 100644 index 0000000..4c0f749 --- /dev/null +++ b/tests/failing_tests/globals_bad_type.py @@ -0,0 +1,25 @@ +# Only integer scalar globals are supported in milestone 1; anything else must +# be a clear NotImplementedError, not a half-emitted symbol. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_double + + +@bpf +@bpfglobal +def ratio() -> c_double: + return c_double(0.5) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/globals_shadowing.py b/tests/failing_tests/globals_shadowing.py new file mode 100644 index 0000000..87e5827 --- /dev/null +++ b/tests/failing_tests/globals_shadowing.py @@ -0,0 +1,26 @@ +# Writing a global's name without `global` must be a loud compile error, not a +# silently-created shadowing local (real Python) or a silent global store. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def counter() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + counter = 1 # noqa: F841 -- missing `global counter` on purpose + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/failing_tests/globals_undeclared_name.py b/tests/failing_tests/globals_undeclared_name.py new file mode 100644 index 0000000..e55a06b --- /dev/null +++ b/tests/failing_tests/globals_undeclared_name.py @@ -0,0 +1,19 @@ +# `global x` naming something that is not a @bpfglobal is a compile error. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + global nosuch + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/globals/augassign_counter.py b/tests/passing_tests/globals/augassign_counter.py new file mode 100644 index 0000000..5a95f08 --- /dev/null +++ b/tests/passing_tests/globals/augassign_counter.py @@ -0,0 +1,31 @@ +# The canonical selftest idiom: a per-object event counter. `counter += 1` +# desugars to load/add/store on the global; the local augassign checks the +# desugaring path for stack variables too. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def counter() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def tick(ctx: c_void_p) -> c_int64: + global counter + counter += 1 + x = 5 + x += 2 + print(f"count {counter} x {x}") + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/globals/bss_and_data.py b/tests/passing_tests/globals/bss_and_data.py new file mode 100644 index 0000000..b78661f --- /dev/null +++ b/tests/passing_tests/globals/bss_and_data.py @@ -0,0 +1,34 @@ +# Section placement needs no marker: a zero initializer lands the global in +# .bss, a non-zero one in .data, decided by LLVM exactly as for C. Mixed +# widths and signedness also exercise the BTF basic-type emission. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64, c_int32 + + +@bpf +@bpfglobal +def zeroed() -> c_uint64: + return c_uint64(0) + + +@bpf +@bpfglobal +def preset() -> c_int32: + return c_int32(42) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + global zeroed + zeroed = preset + 1 + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/globals/read_scalar.py b/tests/passing_tests/globals/read_scalar.py new file mode 100644 index 0000000..8fde2ae --- /dev/null +++ b/tests/passing_tests/globals/read_scalar.py @@ -0,0 +1,28 @@ +# A @bpfglobal read in a condition and in a print. Reads need no marker; +# they compile to a plain `load i64, ptr @expected_pid`. +from pythonbpf import bpf, section, bpfglobal, compile +from pythonbpf.helper import pid +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def expected_pid() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/syscalls/sys_enter_nanosleep") +def trace(ctx: c_void_p) -> c_int64: + if expected_pid == pid(): + print(f"matched {expected_pid}") + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/globals/shared_between_programs.py b/tests/passing_tests/globals/shared_between_programs.py new file mode 100644 index 0000000..915bc34 --- /dev/null +++ b/tests/passing_tests/globals/shared_between_programs.py @@ -0,0 +1,41 @@ +# The test_autoattach.c shape: two programs on different attach points writing +# flags into shared global state. Both write the same section's globals. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def prog1_called() -> c_uint64: + return c_uint64(0) + + +@bpf +@bpfglobal +def prog2_called() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("raw_tp/sys_enter") +def prog1(ctx: c_void_p) -> c_int64: + global prog1_called + prog1_called = 1 + return c_int64(0) + + +@bpf +@section("raw_tp/sys_exit") +def prog2(ctx: c_void_p) -> c_int64: + global prog2_called + prog2_called = 1 + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/globals/write_scalar.py b/tests/passing_tests/globals/write_scalar.py new file mode 100644 index 0000000..98d68cb --- /dev/null +++ b/tests/passing_tests/globals/write_scalar.py @@ -0,0 +1,36 @@ +# The get_cgroup_id_kern.c shape from the kernel selftests: read one global, +# write another, gated on a pid check. Writes require Python's own `global` +# statement and compile to `store i64 %v, ptr @cg_id`. +from pythonbpf import bpf, section, bpfglobal, compile +from pythonbpf.helper import pid, get_current_cgroup_id +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def expected_pid() -> c_uint64: + return c_uint64(0) + + +@bpf +@bpfglobal +def cg_id() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/syscalls/sys_enter_nanosleep") +def trace(ctx: c_void_p) -> c_int64: + global cg_id + if expected_pid == pid(): + cg_id = get_current_cgroup_id() + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/test_config.toml b/tests/test_config.toml index 8a6255a..5a97fda 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -32,3 +32,9 @@ "failing_tests/vmlinux/assignment_handling.py" = {reason = "Assigning vmlinux enum value (XDP_PASS) to a local variable not yet supported", level = "ir"} "failing_tests/xdp_pass.py" = {reason = "XDP program using vmlinux structs (struct_xdp_md) and complex map/struct interaction not yet supported", level = "ir"} + +"failing_tests/globals_shadowing.py" = {reason = "Assignment to a global name without a `global` statement is a deliberate compile error (would shadow the BPF global)", level = "ir"} + +"failing_tests/globals_undeclared_name.py" = {reason = "`global x` naming something that is not a @bpfglobal is a compile error", level = "ir"} + +"failing_tests/globals_bad_type.py" = {reason = "Non-integer-scalar globals are not supported in milestone 1 (NotImplementedError by design)", level = "ir"} From 20b9bb944938bd8b412b6f5f7f2f420cb589c7d2 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 06:25:10 +0530 Subject: [PATCH 08/18] Fix skill to ask Python path --- .claude/skills/ir-first-feature/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/ir-first-feature/SKILL.md b/.claude/skills/ir-first-feature/SKILL.md index 19d8928..862a764 100644 --- a/.claude/skills/ir-first-feature/SKILL.md +++ b/.claude/skills/ir-first-feature/SKILL.md @@ -68,5 +68,5 @@ bar; llc accepts plenty of subtly wrong IR. - **Never read/cat/grep `vmlinux.py` or `vmlinux.h`** — generated, enormous, will exhaust context. Probe with one-liners: `.venv/bin/python -c "import vmlinux; print(vmlinux.struct_x._fields_[:3])"` -- Use `.venv/bin/python`; the system python has no llvmlite. +- Ask the dev which Python binary to use, and remember that for future. The original authors use `.venv/bin/python` as their system python has no llvmlite. - Atomic commits, `Core:`/`Tests:` subject prefixes, one logical change each. From 7f5773e5d8df604e4138112de71c7b0d6f80da2a Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 06:40:11 +0530 Subject: [PATCH 09/18] Core: Make XDP return names respect local and global shadowing handle_return consulted the hardcoded XDP action table before any symbol table, so with a local XDP_PASS = 55 in scope, "return XDP_PASS" silently returned 2. clang treats the equivalent C (a local shadowing an enum constant) as the local winning, and every other resolution site in the compiler already resolves local, then global, then vmlinux -- the return fast path was the one place with the order inverted. It now applies only when the name is bound in neither table. Also warn when a @bpfglobal shadows a vmlinux enum constant. C rejects that outright as a redefinition; PythonBPF follows the rebinding semantics the Python file itself has (the global wins, consistently), but the collision is worth a compile-time warning rather than silence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/functions/functions_pass.py | 14 +++++++++++++- pythonbpf/globals_pass.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 494de6a..6ccf2f2 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -265,7 +265,19 @@ def handle_return(builder, stmt, local_sym_tab, ret_type, compilation_context=No logger.info(f"Handling return statement: {ast.dump(stmt)}") if stmt.value is None: return handle_none_return(builder) - elif isinstance(stmt.value, ast.Name) and is_xdp_name(stmt.value.id): + elif ( + isinstance(stmt.value, ast.Name) + and is_xdp_name(stmt.value.id) + and stmt.value.id not in local_sym_tab + and ( + compilation_context is None + or stmt.value.id not in compilation_context.bpf_globals + ) + ): + # The XDP fast path resolves names like XDP_PASS from the helper + # constant table, but only as a fallback: a local or @bpfglobal of the + # same name shadows it, mirroring C (a local shadows an enum constant) + # and the resolution order everywhere else in the compiler. return handle_xdp_return(stmt, builder, ret_type) else: # Fallback for now if ctx not passed, but caller should pass it diff --git a/pythonbpf/globals_pass.py b/pythonbpf/globals_pass.py index 6afcb9c..a1cb251 100644 --- a/pythonbpf/globals_pass.py +++ b/pythonbpf/globals_pass.py @@ -6,6 +6,7 @@ import logging from .type_deducer import ctypes_to_ir from .debuginfo import DebugInfoGenerator +from .expr import VmlinuxHandlerRegistry from .debuginfo import dwarf_constants as dc logger: Logger = logging.getLogger(__name__) @@ -169,6 +170,17 @@ def globals_processing(tree, compilation_context): ) ): gvar = _emit_global(compilation_context.module, node, name) + if VmlinuxHandlerRegistry.handle_name(name) is not None: + # C rejects this outright ("redefinition as different + # kind of symbol"); Python's rebinding semantics let + # the global win, and resolution order (local, then + # global, then vmlinux) applies it consistently. Warn + # so the shadowing is at least never silent. + logger.warning( + f"@bpfglobal '{name}' shadows a vmlinux enum " + f"constant of the same name; reads of '{name}' " + f"will use the global" + ) if isinstance(gvar.value_type, ir.IntType): compilation_context.bpf_globals[name] = BpfGlobalSymbol( var=gvar, From 274bac9313bcf957d3059309fea3a44ebf0f80a0 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 06:41:46 +0530 Subject: [PATCH 10/18] Tests: Pin name-resolution order under vmlinux collisions Two tests locking the shadowing semantics: a local named XDP_PASS returned from a program (the exact case the return fast path used to hijack), and a vmlinux-importing program where a local shadows one enum constant while a @bpfglobal shadows another, read in expressions and f-strings. The vmlinux test needs noqa on its import and both collision sites: ruff rightly flags unused imports and redefinitions in normal Python, and this file is deliberately made of them. These pin compilation; the value-level proof (ret of 55, not 2) was verified against the emitted IR and the clang reference for the same collision shapes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- .../passing_tests/return/xdp_name_shadowed.py | 22 ++++++++++ tests/passing_tests/vmlinux/enum_shadowing.py | 41 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/passing_tests/return/xdp_name_shadowed.py create mode 100644 tests/passing_tests/vmlinux/enum_shadowing.py diff --git a/tests/passing_tests/return/xdp_name_shadowed.py b/tests/passing_tests/return/xdp_name_shadowed.py new file mode 100644 index 0000000..0caacd8 --- /dev/null +++ b/tests/passing_tests/return/xdp_name_shadowed.py @@ -0,0 +1,22 @@ +# A local named after an XDP action must shadow the helper constant table, +# in return position too. clang agrees: a local legally shadows an enum +# constant, and the local's value is what returns (tests/c-form reference). +# Before the fix this returned the hardcoded 2 while XDP_PASS held 55. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64 + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + XDP_PASS = 55 + return XDP_PASS + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/vmlinux/enum_shadowing.py b/tests/passing_tests/vmlinux/enum_shadowing.py new file mode 100644 index 0000000..2d7baf6 --- /dev/null +++ b/tests/passing_tests/vmlinux/enum_shadowing.py @@ -0,0 +1,41 @@ +# Name-resolution order under collision with vmlinux enum constants: +# local wins over global wins over vmlinux, consistently in expressions, +# f-strings, and returns. +# +# clang's take on the same collisions (tests/c-form): a local shadowing an +# enum constant is legal and wins; a file-scope variable colliding with one is +# a hard error. PythonBPF follows Python's rebinding semantics instead for the +# global case -- the @bpfglobal wins -- and logs a compile-time warning so the +# shadowing is never silent. +# XDP_ABORTED's import is load-bearing despite being "unused": it is what +# registers the enum with the compiler, so the local below has something to +# shadow. Python-level unused is the point. +from vmlinux import XDP_ABORTED, XDP_TX # noqa: F401 +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +# Shadows the vmlinux enum constant XDP_TX (warns at compile time; reads of +# XDP_TX below resolve to this global, value 77, not the enum value 3). +@bpf +@bpfglobal +def XDP_TX() -> c_uint64: # noqa: F811 -- the collision is the test + return c_uint64(77) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + XDP_ABORTED = 55 # noqa: F811 -- local shadows the enum (value 0) + x = XDP_ABORTED + XDP_TX + print(f"local {XDP_ABORTED} global {XDP_TX}") + return c_int64(x) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() From aa321dbd47713473249c4f05301128ab40b0dc5e Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 07:19:07 +0530 Subject: [PATCH 11/18] Core: Lower augmented assignment directly instead of desugaring The first version synthesised an Assign(BinOp(...)) node and fed it back through handle_assign. That is a standard compiler move, but it is not how this compiler works: every pass walks the tree the user wrote, and a node invented mid-codegen is invisible to the allocation pass that already ran, carries no source location for diagnostics, and shows up in logs as a statement nobody typed. handle_aug_assign now resolves the target slot (declared global, local, or struct field), loads it, evaluates the right-hand side, applies the operator, and stores back. Semantic agreement with "x = x op v" no longer rests on shared AST but on shared value-level helpers: the RHS goes through get_operand_value like any other read, and the operator table is extracted from binary-op evaluation into apply_binop so it exists exactly once. Two behavioural refinements fall out: augmented assignment to a global name without a global statement is now the same loud SyntaxError plain assignment gives (previously it slipped past the allocation pass, which only walks Assign), and struct-field targets compute their GEP once rather than twice. Target read precedes RHS evaluation, matching Python. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/expr/__init__.py | 3 +- pythonbpf/expr/expr_pass.py | 45 ++++++-------- pythonbpf/expr/ir_ops.py | 28 +++++++++ pythonbpf/functions/functions_pass.py | 85 ++++++++++++++++++++++----- 4 files changed, 117 insertions(+), 44 deletions(-) diff --git a/pythonbpf/expr/__init__.py b/pythonbpf/expr/__init__.py index dfd2128..7ab5f8d 100644 --- a/pythonbpf/expr/__init__.py +++ b/pythonbpf/expr/__init__.py @@ -1,6 +1,6 @@ from .expr_pass import eval_expr, handle_expr, get_operand_value from .type_normalization import convert_to_bool, get_base_type_and_depth -from .ir_ops import deref_to_depth, access_struct_field +from .ir_ops import deref_to_depth, access_struct_field, apply_binop from .call_registry import CallHandlerRegistry from .vmlinux_registry import VmlinuxHandlerRegistry @@ -10,6 +10,7 @@ "convert_to_bool", "get_base_type_and_depth", "deref_to_depth", + "apply_binop", "access_struct_field", "get_operand_value", "CallHandlerRegistry", diff --git a/pythonbpf/expr/expr_pass.py b/pythonbpf/expr/expr_pass.py index 08967fb..a4854a6 100644 --- a/pythonbpf/expr/expr_pass.py +++ b/pythonbpf/expr/expr_pass.py @@ -6,7 +6,7 @@ from pythonbpf.type_deducer import ctypes_to_ir, is_ctypes from .call_registry import CallHandlerRegistry -from .ir_ops import deref_to_depth, access_struct_field +from .ir_ops import deref_to_depth, access_struct_field, apply_binop from .type_normalization import ( convert_to_bool, handle_comparator, @@ -31,8 +31,7 @@ def _handle_name_expr( val = builder.load(var) return val, local_sym_tab[expr.id].ir_type elif expr.id in compilation_context.bpf_globals: - # A @bpfglobal: read straight off the global symbol, exactly the - # `load i64, ptr @counter` form clang emits (tests/c-form/global_vars). + # A @bpfglobal sym = compilation_context.bpf_globals[expr.id] val = builder.load(sym.var) return val, sym.ir_type @@ -86,7 +85,9 @@ def _handle_attribute_expr( var_ptr, var_type, var_metadata = local_sym_tab[var_name] logger.info(f"Loading attribute {attr_name} from variable {var_name}") logger.info( - f"Variable type: {var_type}, Variable ptr: {var_ptr}, Variable Metadata: {var_metadata}" + f"Variable type: {var_type}, Variable ptr: { + var_ptr + }, Variable Metadata: {var_metadata}" ) if ( hasattr(var_metadata, "__module__") @@ -104,7 +105,9 @@ def _handle_attribute_expr( elif isinstance(var_metadata, Field): logger.error( - f"Cannot access field '{attr_name}' on already-loaded field value '{var_name}'" + f"Cannot access field '{attr_name}' on already-loaded field value '{ + var_name + }'" ) return None @@ -234,25 +237,7 @@ def _handle_binary_op_impl(func, compilation_context, rval, builder, local_sym_t right = builder.sext(right, ir.IntType(64)) # Map AST operation nodes to LLVM IR builder methods - op_map = { - ast.Add: builder.add, - ast.Sub: builder.sub, - ast.Mult: builder.mul, - ast.Div: builder.sdiv, - ast.Mod: builder.srem, - ast.LShift: builder.shl, - ast.RShift: builder.lshr, - ast.BitOr: builder.or_, - ast.BitXor: builder.xor, - ast.BitAnd: builder.and_, - ast.FloorDiv: builder.udiv, - } - - if type(op) in op_map: - result = op_map[type(op)](left, right) - return result - else: - raise SyntaxError("Unsupported binary operation") + return apply_binop(builder, op, left, right) def _handle_binary_op( @@ -315,7 +300,9 @@ def _handle_ctypes_call( # Get the IR type from the value itself actual_ir_type = value.type logger.info( - f"Converting vmlinux field {val_type.name} (IR type: {actual_ir_type}) to {call_type}" + f"Converting vmlinux field {val_type.name} (IR type: {actual_ir_type}) to { + call_type + }" ) else: actual_ir_type = val_type @@ -328,7 +315,9 @@ def _handle_ctypes_call( if actual_ir_type.width < expected_type.width: value = builder.sext(value, expected_type) logger.info( - f"Sign-extended from i{actual_ir_type.width} to i{expected_type.width}" + f"Sign-extended from i{actual_ir_type.width} to i{ + expected_type.width + }" ) elif actual_ir_type.width > expected_type.width: value = builder.trunc(value, expected_type) @@ -340,7 +329,9 @@ def _handle_ctypes_call( pass else: raise ValueError( - f"Type mismatch: expected {expected_type}, got {actual_ir_type} (original type: {val_type})" + f"Type mismatch: expected {expected_type}, got { + actual_ir_type + } (original type: {val_type})" ) return value, expected_type diff --git a/pythonbpf/expr/ir_ops.py b/pythonbpf/expr/ir_ops.py index 3f10d19..d593f90 100644 --- a/pythonbpf/expr/ir_ops.py +++ b/pythonbpf/expr/ir_ops.py @@ -1,9 +1,37 @@ +import ast import logging from llvmlite import ir logger = logging.getLogger(__name__) +BINOP_METHODS = { + ast.Add: "add", + ast.Sub: "sub", + ast.Mult: "mul", + ast.Div: "sdiv", + ast.Mod: "srem", + ast.LShift: "shl", + ast.RShift: "lshr", + ast.BitOr: "or_", + ast.BitXor: "xor", + ast.BitAnd: "and_", + ast.FloorDiv: "udiv", +} + + +def apply_binop(builder, op, left, right): + """Emit the LLVM instruction for a Python binary operator. + + Shared by binary-op evaluation and augmented assignment so the operator + table exists exactly once. + """ + method = BINOP_METHODS.get(type(op)) + if method is None: + raise SyntaxError("Unsupported binary operation") + return getattr(builder, method)(left, right) + + def deref_to_depth(func, builder, val, target_depth): """Dereference a pointer to a certain depth.""" diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 6ccf2f2..2bebd09 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -11,6 +11,8 @@ eval_expr, handle_expr, convert_to_bool, + get_operand_value, + apply_binop, VmlinuxHandlerRegistry, ) from pythonbpf.assign_pass import ( @@ -190,30 +192,81 @@ def handle_assign(func, compilation_context, builder, stmt, local_sym_tab): def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): - """Handle `x += v` by desugaring to `x = x op v` and reusing handle_assign. - - That is the statement's Python semantics for the targets we support, and it - means globals come along for free: `counter += 1` under `global counter` - becomes load/add/store on @counter. + """Handle `x += v` and friends by direct lowering: resolve the target's + slot, load it, apply the operator, store back. + + No desugaring into synthetic Assign/BinOp nodes: every pass in this + compiler walks the tree the user wrote, and nodes invented mid-codegen are + invisible to the passes that already ran and carry no source locations. + Semantic agreement with `x = x op v` comes from sharing the value-level + helpers instead — the RHS goes through get_operand_value like any other + read, and the operator table is apply_binop, the same one binary-op + evaluation uses. """ if isinstance(stmt.target, ast.Name): - load_target = ast.Name(id=stmt.target.id, ctx=ast.Load()) - elif isinstance(stmt.target, ast.Attribute): - load_target = ast.Attribute( - value=stmt.target.value, attr=stmt.target.attr, ctx=ast.Load() - ) + name = stmt.target.id + if name in compilation_context.current_func_globals: + sym = compilation_context.bpf_globals[name] + slot, slot_type = sym.var, sym.ir_type + elif name in compilation_context.bpf_globals: + raise SyntaxError( + f"augmented assignment to '{name}' shadows the BPF global of " + f"the same name — add 'global {name}' to write to it" + ) + elif name in local_sym_tab: + slot = local_sym_tab[name].var + slot_type = local_sym_tab[name].ir_type + else: + raise SyntaxError(f"augmented assignment to undefined variable '{name}'") + elif isinstance(stmt.target, ast.Attribute) and isinstance( + stmt.target.value, ast.Name + ): + var_name, field_name = stmt.target.value.id, stmt.target.attr + if var_name not in local_sym_tab: + raise SyntaxError( + f"augmented assignment to field of undefined variable '{var_name}'" + ) + metadata = local_sym_tab[var_name].metadata + structs_sym_tab = compilation_context.structs_sym_tab + if metadata not in structs_sym_tab: + raise SyntaxError( + f"augmented assignment to '{var_name}.{field_name}' is only " + f"supported for struct fields" + ) + struct_info = structs_sym_tab[metadata] + if field_name not in struct_info.fields: + raise SyntaxError(f"Field '{field_name}' not found in struct '{metadata}'") + slot = struct_info.gep(builder, local_sym_tab[var_name].var, field_name) + slot_type = struct_info.field_type(field_name) else: raise SyntaxError( f"Unsupported augmented-assignment target: {ast.dump(stmt.target)}" ) - desugared = ast.Assign( - targets=[stmt.target], - value=ast.BinOp(left=load_target, op=stmt.op, right=stmt.value), + if not isinstance(slot_type, ir.IntType): + raise SyntaxError( + f"augmented assignment needs an integer target, got {slot_type}" + ) + + # Python evaluates the target's current value before the right-hand side. + current = builder.load(slot) + rhs = get_operand_value( + func, compilation_context, stmt.value, builder, local_sym_tab ) - ast.copy_location(desugared, stmt) - ast.fix_missing_locations(desugared) - handle_assign(func, compilation_context, builder, desugared, local_sym_tab) + if rhs is None: + raise SyntaxError( + f"Failed to evaluate augmented-assignment value: {ast.dump(stmt.value)}" + ) + # Same width discipline as binary-op evaluation: compute in i64, narrow + # back to the slot's width on the way out. + if current.type.width < 64: + current = builder.sext(current, ir.IntType(64)) + if isinstance(rhs.type, ir.IntType) and rhs.type.width < 64: + rhs = builder.sext(rhs, ir.IntType(64)) + result = apply_binop(builder, stmt.op, current, rhs) + if result.type.width > slot_type.width: + result = builder.trunc(result, slot_type) + builder.store(result, slot) def handle_cond(func, compilation_context, builder, cond, local_sym_tab): From dc9aecf3817fdaa3872234265a7959cc61f06c82 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 07:22:03 +0530 Subject: [PATCH 12/18] Core: Coerce integer width when assigning to a struct field handle_struct_field_assignment stored the evaluated value as-is, and since expressions evaluate in i64 that meant any assignment to a sub-64-bit field (dat.hits = 1 with hits: c_uint32) died in llvmlite with "cannot store i64 to i32*". Apply the same implicit widening/truncation the local-assignment path already has. Surfaced by the augmented-assignment struct-field test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- pythonbpf/assign_pass.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pythonbpf/assign_pass.py b/pythonbpf/assign_pass.py index 9939357..c71bd28 100644 --- a/pythonbpf/assign_pass.py +++ b/pythonbpf/assign_pass.py @@ -54,6 +54,14 @@ def handle_struct_field_assignment( logger.info(f"Copied string to char array {var_name}.{field_name}") return + # Same implicit widening/truncation as assignment to a local: expressions + # evaluate in i64, but a field may be narrower. + if isinstance(val_type, ir.IntType) and isinstance(field_type, ir.IntType): + if val_type.width < field_type.width: + val = builder.sext(val, field_type) + elif val_type.width > field_type.width: + val = builder.trunc(val, field_type) + # Regular assignment builder.store(val, field_ptr) logger.info(f"Assigned to struct field {var_name}.{field_name}") From 1b9adc7134887349469d88f94b8b5fcf8ad5df7e Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 07:22:48 +0530 Subject: [PATCH 13/18] Tests: Pin augmented assignment on struct fields and the shadowing error A struct-field test with a non-add operator and a sub-64-bit field (the narrow-on-store path), and a strict expected failure for counter += 1 without a global statement, which now fails as loudly as plain assignment does. Ruff flags that line as F823 (referenced before assignment) -- the same UnboundLocalError Python itself would raise, which is exactly the semantics the global statement requirement mirrors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- .../globals_augassign_shadowing.py | 28 +++++++++++++++ .../assign/augassign_struct_field.py | 35 +++++++++++++++++++ tests/test_config.toml | 2 ++ 3 files changed, 65 insertions(+) create mode 100644 tests/failing_tests/globals_augassign_shadowing.py create mode 100644 tests/passing_tests/assign/augassign_struct_field.py diff --git a/tests/failing_tests/globals_augassign_shadowing.py b/tests/failing_tests/globals_augassign_shadowing.py new file mode 100644 index 0000000..ecfb5a9 --- /dev/null +++ b/tests/failing_tests/globals_augassign_shadowing.py @@ -0,0 +1,28 @@ +# `counter += 1` without `global counter` must be the same loud error as plain +# assignment. Before direct lowering this slipped past the allocation pass +# (which only walks Assign) and failed later with a misleading message. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def counter() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + counter += 1 # noqa: F823, F841 -- missing `global counter` on purpose; this is + # the UnboundLocalError shape, and the compiler must reject it just as loudly + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/passing_tests/assign/augassign_struct_field.py b/tests/passing_tests/assign/augassign_struct_field.py new file mode 100644 index 0000000..6c56370 --- /dev/null +++ b/tests/passing_tests/assign/augassign_struct_field.py @@ -0,0 +1,35 @@ +# Augmented assignment to a struct field: one GEP, load, op, store. Also a +# non-add operator and a sub-64-bit field, which exercises the narrow-on-store +# path of the direct lowering. +from pythonbpf import bpf, section, bpfglobal, compile, struct +from ctypes import c_void_p, c_int64, c_uint64, c_uint32 + + +@bpf +@struct +class data_t: + pid: c_uint64 + hits: c_uint32 + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: + x = 5 + x -= 1 + dat = data_t() + dat.pid = 10 + dat.pid += x + dat.hits = 1 + dat.hits <<= 2 + print(f"pid {dat.pid} hits {dat.hits} x {x}") + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/test_config.toml b/tests/test_config.toml index 5a97fda..6a1853e 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -38,3 +38,5 @@ "failing_tests/globals_undeclared_name.py" = {reason = "`global x` naming something that is not a @bpfglobal is a compile error", level = "ir"} "failing_tests/globals_bad_type.py" = {reason = "Non-integer-scalar globals are not supported in milestone 1 (NotImplementedError by design)", level = "ir"} + +"failing_tests/globals_augassign_shadowing.py" = {reason = "Augmented assignment to a global name without a `global` statement is a deliberate compile error (would shadow the BPF global)", level = "ir"} From e8ab1188d842024c64b48d4b49f00af62325bd87 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 07:23:27 +0530 Subject: [PATCH 14/18] Tests: Record the lower-do-not-desugar house style in the skill Captures the trade-off behind reworking augmented assignment: desugaring into synthetic AST is a legitimate compiler technique with real benefits, and it is still the wrong shape for this compiler, whose passes communicate through the source tree. Records both sides so the next person tempted by it declines for the right reasons, and states the idiom to reach for instead -- shared value-level helpers, not shared AST. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L1PX8EuP9C3o3veWGA84RF --- .claude/skills/ir-first-feature/SKILL.md | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.claude/skills/ir-first-feature/SKILL.md b/.claude/skills/ir-first-feature/SKILL.md index 862a764..7372092 100644 --- a/.claude/skills/ir-first-feature/SKILL.md +++ b/.claude/skills/ir-first-feature/SKILL.md @@ -54,6 +54,33 @@ Emit IR through the existing passes (`globals_pass`, `expr_pass`, `assign_pass`, clang reference for the same shapes** — "it compiles and llc accepts it" is not the bar; llc accepts plenty of subtly wrong IR. +### House style: lower, don't desugar + +Handlers walk the AST the user wrote and emit IR directly. **Do not synthesize new AST +nodes mid-compilation and feed them back through other handlers** — no +`ast.Assign(ast.BinOp(...))` conjured to make `x += v` reuse the assignment path. + +The temptation is legitimate, so know the argument you are declining. Desugaring is a +standard compiler move (CPython itself lowers `x += v` this way), it guarantees semantic +agreement with the composed form, it is the smallest diff, and any later fix to the +composed path applies automatically. Those are real benefits. + +They lose in this codebase for structural reasons: the passes communicate through the +source tree. Allocation runs before codegen and walks `Assign` — a synthetic `Assign` +created during codegen is invisible to it, so the two passes silently disagree about +what the function contains (it happened to be harmless for augmented assignment only +because that statement never needs a fresh slot; that is luck, not design). Synthetic +nodes carry no source location, so diagnostics point nowhere. And `ast.dump` in the logs +shows statements the user never typed, which turns every debugging session into an +archaeology exercise. + +The resolution is to move sharing down a level: **equivalence should come from shared +value-level helpers, not shared AST.** When two constructs must agree, extract the common +logic into a helper both call — the way binary-op evaluation and augmented assignment +both use `apply_binop` for the operator table and `get_operand_value` for operands — +and let each handler resolve its own target and emit its own store. Two handlers calling +one helper is the idiom; one handler manufacturing input for another is not. + ## 6. Test at the right tier - Works now → `tests/passing_tests//`. From f5c4dece3eea346106af58308dc69b59570d7005 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 07:39:04 +0530 Subject: [PATCH 15/18] Core: Unify the Python-operator tables in expr/operators.py Operator knowledge was spread across three files: the binary-op table (an op_map dict rebuilt inline on every _handle_binary_op_impl call, then extracted to ir_ops.apply_binop for augmented assignment), COMPARISON_OPS in type_normalization.py, and the unary/boolean operators as isinstance chains in expr_pass.py. Anyone asking "which operators does PythonBPF support" had to read all three. They now live in one module. BINOP_METHODS and COMPARISON_OPS are the tables themselves; UNARY_OPS and BOOL_OPS list the operators whose lowering is structural (convert_to_bool, short-circuit control flow) so the registry is complete even where the emission stays in expr_pass. apply_binop moves here from ir_ops, which goes back to being purely about IR operations. No behaviour change: 133 passed, 19 xfailed before and after. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU --- .claude/skills/ir-first-feature/SKILL.md | 4 ++ pythonbpf/expr/__init__.py | 3 +- pythonbpf/expr/expr_pass.py | 8 +++- pythonbpf/expr/ir_ops.py | 28 ------------ pythonbpf/expr/operators.py | 55 ++++++++++++++++++++++++ pythonbpf/expr/type_normalization.py | 13 +----- 6 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 pythonbpf/expr/operators.py diff --git a/.claude/skills/ir-first-feature/SKILL.md b/.claude/skills/ir-first-feature/SKILL.md index 7372092..2fbf4f2 100644 --- a/.claude/skills/ir-first-feature/SKILL.md +++ b/.claude/skills/ir-first-feature/SKILL.md @@ -81,6 +81,10 @@ both use `apply_binop` for the operator table and `get_operand_value` for operan and let each handler resolve its own target and emit its own store. Two handlers calling one helper is the idiom; one handler manufacturing input for another is not. +The operator tables themselves — binary operators, comparisons, and the supported +unary/boolean operators — live in exactly one place, `expr/operators.py`. A new operator +is added there first; if it is not in that file, the compiler does not support it. + ## 6. Test at the right tier - Works now → `tests/passing_tests//`. diff --git a/pythonbpf/expr/__init__.py b/pythonbpf/expr/__init__.py index 7ab5f8d..5002113 100644 --- a/pythonbpf/expr/__init__.py +++ b/pythonbpf/expr/__init__.py @@ -1,6 +1,7 @@ from .expr_pass import eval_expr, handle_expr, get_operand_value from .type_normalization import convert_to_bool, get_base_type_and_depth -from .ir_ops import deref_to_depth, access_struct_field, apply_binop +from .ir_ops import deref_to_depth, access_struct_field +from .operators import apply_binop from .call_registry import CallHandlerRegistry from .vmlinux_registry import VmlinuxHandlerRegistry diff --git a/pythonbpf/expr/expr_pass.py b/pythonbpf/expr/expr_pass.py index a4854a6..48d5abc 100644 --- a/pythonbpf/expr/expr_pass.py +++ b/pythonbpf/expr/expr_pass.py @@ -6,7 +6,8 @@ from pythonbpf.type_deducer import ctypes_to_ir, is_ctypes from .call_registry import CallHandlerRegistry -from .ir_ops import deref_to_depth, access_struct_field, apply_binop +from .ir_ops import deref_to_depth, access_struct_field +from .operators import apply_binop, UNARY_OPS, BOOL_OPS from .type_normalization import ( convert_to_bool, handle_comparator, @@ -375,7 +376,7 @@ def _handle_unary_op( local_sym_tab, ): """Handle ast.UnaryOp expressions.""" - if not isinstance(expr.op, ast.Not) and not isinstance(expr.op, ast.USub): + if not isinstance(expr.op, UNARY_OPS): logger.error("Only 'not' and '-' unary operators are supported") return None @@ -518,6 +519,9 @@ def _handle_boolean_op( ): """Handle `and` and `or` boolean operations.""" + if not isinstance(expr.op, BOOL_OPS): + logger.error(f"Unsupported boolean operator: {type(expr.op).__name__}") + return None if isinstance(expr.op, ast.And): return _handle_and_op(func, builder, expr, local_sym_tab, compilation_context) elif isinstance(expr.op, ast.Or): diff --git a/pythonbpf/expr/ir_ops.py b/pythonbpf/expr/ir_ops.py index d593f90..3f10d19 100644 --- a/pythonbpf/expr/ir_ops.py +++ b/pythonbpf/expr/ir_ops.py @@ -1,37 +1,9 @@ -import ast import logging from llvmlite import ir logger = logging.getLogger(__name__) -BINOP_METHODS = { - ast.Add: "add", - ast.Sub: "sub", - ast.Mult: "mul", - ast.Div: "sdiv", - ast.Mod: "srem", - ast.LShift: "shl", - ast.RShift: "lshr", - ast.BitOr: "or_", - ast.BitXor: "xor", - ast.BitAnd: "and_", - ast.FloorDiv: "udiv", -} - - -def apply_binop(builder, op, left, right): - """Emit the LLVM instruction for a Python binary operator. - - Shared by binary-op evaluation and augmented assignment so the operator - table exists exactly once. - """ - method = BINOP_METHODS.get(type(op)) - if method is None: - raise SyntaxError("Unsupported binary operation") - return getattr(builder, method)(left, right) - - def deref_to_depth(func, builder, val, target_depth): """Dereference a pointer to a certain depth.""" diff --git a/pythonbpf/expr/operators.py b/pythonbpf/expr/operators.py new file mode 100644 index 0000000..fb4cadd --- /dev/null +++ b/pythonbpf/expr/operators.py @@ -0,0 +1,55 @@ +"""Every Python operator PythonBPF understands, and the IR it maps to. + +This is the single registry. Binary operators map to IRBuilder method names, +comparisons map to icmp predicates, and the unary/boolean operators are listed +here even though their lowering is structural (they need convert_to_bool or +short-circuit control flow, so they live in expr_pass): if an operator is not +in this file, the compiler does not support it. Add new operators here first. +""" + +import ast + +# ast.BinOp.op class -> llvmlite IRBuilder method name. +# Shared by binary-op evaluation and augmented assignment. +BINOP_METHODS = { + ast.Add: "add", + ast.Sub: "sub", + ast.Mult: "mul", + ast.Div: "sdiv", + ast.Mod: "srem", + ast.LShift: "shl", + ast.RShift: "lshr", + ast.BitOr: "or_", + ast.BitXor: "xor", + ast.BitAnd: "and_", + ast.FloorDiv: "udiv", +} + +# ast.Compare op class -> icmp predicate string. +COMPARISON_OPS = { + ast.Eq: "==", + ast.NotEq: "!=", + ast.Lt: "<", + ast.LtE: "<=", + ast.Gt: ">", + ast.GtE: ">=", + ast.Is: "==", + ast.IsNot: "!=", +} + +# Lowered structurally in expr_pass (need convert_to_bool / control flow). +UNARY_OPS = (ast.Not, ast.USub) +BOOL_OPS = (ast.And, ast.Or) + + +def apply_binop(builder, op, left, right): + """Emit the LLVM instruction for a Python binary operator.""" + method = BINOP_METHODS.get(type(op)) + if method is None: + raise SyntaxError(f"Unsupported binary operation: {type(op).__name__}") + return getattr(builder, method)(left, right) + + +def comparison_predicate(op): + """icmp predicate for a Python comparison operator, or None if unsupported.""" + return COMPARISON_OPS.get(type(op)) diff --git a/pythonbpf/expr/type_normalization.py b/pythonbpf/expr/type_normalization.py index bb5e83b..edea422 100644 --- a/pythonbpf/expr/type_normalization.py +++ b/pythonbpf/expr/type_normalization.py @@ -1,21 +1,10 @@ import logging -import ast from llvmlite import ir from .ir_ops import deref_to_depth +from .operators import COMPARISON_OPS logger = logging.getLogger(__name__) -COMPARISON_OPS = { - ast.Eq: "==", - ast.NotEq: "!=", - ast.Lt: "<", - ast.LtE: "<=", - ast.Gt: ">", - ast.GtE: ">=", - ast.Is: "==", - ast.IsNot: "!=", -} - def get_base_type_and_depth(ir_type): """Get the base type for pointer types.""" From f4ec12df60918faf279403d01e98cfa8888e0b84 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 10:32:10 +0530 Subject: [PATCH 16/18] Core: Resolve assignment targets local-first, and reject global on a parameter Reads resolve a name local-first (_handle_name_expr, get_operand_value, the printk formatter), but both write paths checked the declared-global set before local_sym_tab. Normally harmless because the two tables are disjoint by construction -- except for a parameter whose name matches a @bpfglobal. With 'global ctx' in a function taking ctx, the write path stored to the global while a read of the same name picked the parameter and crashed on its None slot: the same name resolving to different storage depending on which side of an assignment it sat. Python forbids the construct outright, and now so does the compiler, with Python's own wording: SyntaxError: name 'ctx' is parameter and global. Both write paths now resolve local-first like the reads, so all name lookups read the same way; and an augmented assignment to the context parameter itself gets a clear error instead of a store to None. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU --- pythonbpf/assign_pass.py | 5 +++- pythonbpf/functions/functions_pass.py | 22 +++++++++++--- .../globals_parameter_and_global.py | 29 +++++++++++++++++++ tests/test_config.toml | 2 ++ 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 tests/failing_tests/globals_parameter_and_global.py diff --git a/pythonbpf/assign_pass.py b/pythonbpf/assign_pass.py index c71bd28..d00f433 100644 --- a/pythonbpf/assign_pass.py +++ b/pythonbpf/assign_pass.py @@ -115,7 +115,10 @@ def handle_variable_assignment( # A name declared with `global` writes the @bpfglobal symbol directly: # the plain `store i64 %v, ptr @counter` form of the C reference. - if var_name in compilation_context.current_func_globals: + if ( + var_name not in local_sym_tab + and var_name in compilation_context.current_func_globals + ): sym = compilation_context.bpf_globals[var_name] val_result = eval_expr(func, compilation_context, builder, rval, local_sym_tab) if val_result is None: diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 2bebd09..3519eb4 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -205,7 +205,18 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): """ if isinstance(stmt.target, ast.Name): name = stmt.target.id - if name in compilation_context.current_func_globals: + # Same resolution order as reads: local, then declared global. The two + # cannot both hold a name (a parameter may not be declared global, and + # an undeclared write to a global's name is refused), so this order is + # about giving the same answer as _handle_name_expr, not precedence. + if name in local_sym_tab: + slot = local_sym_tab[name].var + slot_type = local_sym_tab[name].ir_type + if slot is None: + raise SyntaxError( + f"cannot assign to '{name}': it is the context parameter" + ) + elif name in compilation_context.current_func_globals: sym = compilation_context.bpf_globals[name] slot, slot_type = sym.var, sym.ir_type elif name in compilation_context.bpf_globals: @@ -213,9 +224,6 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): f"augmented assignment to '{name}' shadows the BPF global of " f"the same name — add 'global {name}' to write to it" ) - elif name in local_sym_tab: - slot = local_sym_tab[name].var - slot_type = local_sym_tab[name].ir_type else: raise SyntaxError(f"augmented assignment to undefined variable '{name}'") elif isinstance(stmt.target, ast.Attribute) and isinstance( @@ -411,9 +419,15 @@ def process_func_body( # @bpfglobal, never a local. Undeclared writes to a global name are # rejected in the allocation pass rather than silently shadowing. declared_globals: set[str] = set() + param_names = {arg.arg for arg in func_node.args.args} for node in ast.walk(func_node): if isinstance(node, ast.Global): for gname in node.names: + if gname in param_names: + # Python's own rule and wording. Without it the read path + # (local first) and the write path would resolve the same + # name to different storage. + raise SyntaxError(f"name '{gname}' is parameter and global") if gname not in compilation_context.bpf_globals: raise SyntaxError( f"'global {gname}' in '{func_node.name}': no @bpfglobal " diff --git a/tests/failing_tests/globals_parameter_and_global.py b/tests/failing_tests/globals_parameter_and_global.py new file mode 100644 index 0000000..735e485 --- /dev/null +++ b/tests/failing_tests/globals_parameter_and_global.py @@ -0,0 +1,29 @@ +# Declaring a parameter `global` is a SyntaxError in Python itself +# ("name 'ctx' is parameter and global"), and the compiler must say the same: +# otherwise reads (local first) and writes would resolve `ctx` to different +# storage. +from pythonbpf import bpf, section, bpfglobal, compile +from ctypes import c_void_p, c_int64, c_uint64 + + +@bpf +@bpfglobal +def ctx() -> c_uint64: + return c_uint64(0) + + +@bpf +@section("tracepoint/raw_syscalls/sys_enter") +def prog(ctx: c_void_p) -> c_int64: # noqa: F811 -- the collision is the test + global ctx # noqa: F811 + ctx += 1 + return c_int64(0) + + +@bpf +@bpfglobal +def LICENSE() -> str: + return "GPL" + + +compile() diff --git a/tests/test_config.toml b/tests/test_config.toml index 6a1853e..73d68b3 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -40,3 +40,5 @@ "failing_tests/globals_bad_type.py" = {reason = "Non-integer-scalar globals are not supported in milestone 1 (NotImplementedError by design)", level = "ir"} "failing_tests/globals_augassign_shadowing.py" = {reason = "Augmented assignment to a global name without a `global` statement is a deliberate compile error (would shadow the BPF global)", level = "ir"} + +"failing_tests/globals_parameter_and_global.py" = {reason = "A parameter may not be declared global (Python: name is parameter and global)", level = "ir"} From 2b77c406debfb669725fc373603f59f33e0d9950 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 11:14:14 +0530 Subject: [PATCH 17/18] Core: Bind declared globals in local_sym_tab instead of a per-function set A 'global x' statement is a fact about one function's scope, but it was tracked as current_func_globals on the compilation-wide context -- per-function state living at the wrong level, which had to be set before each body and reset after, and which every read and write site then had to consult in a specific order relative to local_sym_tab (the source of the parameter collision fixed in f4ec12d). The declaration now does what it means: it binds the name in local_sym_tab to the @bpfglobal's storage, as a LocalSymbol whose var is the GlobalVariable and whose declared_global flag records why. A GlobalVariable is a pointer to storage exactly as an alloca is, so every existing path that loads from or stores through a symbol's var works unchanged. That deletes the dedicated global-store branch in handle_variable_assignment, the global branch in handle_aug_assign, the allocation-pass skip, and the reset -- one table, one lookup, no order to get wrong. Reads of an undeclared global (legal in Python) still fall back to bpf_globals after local_sym_tab. Python's rules fall out naturally: a parameter is already in the table when the declarations are processed, so 'global ctx' on a parameter is detected by the same membership check. LocalSymbol.__iter__ deliberately still yields three fields, since several sites tuple-unpack a symbol. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU --- pythonbpf/allocation_pass.py | 18 ++++----- pythonbpf/assign_pass.py | 27 -------------- pythonbpf/context.py | 5 --- pythonbpf/functions/functions_pass.py | 53 +++++++++++---------------- pythonbpf/local_symbol.py | 11 +++++- 5 files changed, 39 insertions(+), 75 deletions(-) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 9cae135..567e945 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -50,24 +50,20 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab): var_name = target.id - # Writes to @bpfglobal variables use the global symbol, not a stack - # slot. Requires Python's own `global` declaration; without it an - # assignment to a global's name would silently create a local that - # shadows it, which is exactly the bug class we refuse to compile. - if var_name in compilation_context.current_func_globals: - logger.debug(f"'{var_name}' is a declared global, no allocation needed") + # Already bound in this scope: a parameter, an earlier assignment, or a + # `global` declaration (whose slot is the GlobalVariable). No slot needed. + if var_name in local_sym_tab: + logger.debug(f"'{var_name}' already bound, no allocation needed") continue + + # Not declared `global`, yet named like one: in real Python this would + # create a shadowing local. Refuse rather than guess which was meant. if var_name in compilation_context.bpf_globals: raise SyntaxError( f"assignment to '{var_name}' shadows the BPF global of the same " f"name — add 'global {var_name}' to write to it" ) - # Skip if already allocated - if var_name in local_sym_tab: - logger.debug(f"Variable {var_name} already allocated, skipping") - continue - # Determine type and allocate based on rval if isinstance(rval, ast.Call): _allocate_for_call( diff --git a/pythonbpf/assign_pass.py b/pythonbpf/assign_pass.py index d00f433..5d931f7 100644 --- a/pythonbpf/assign_pass.py +++ b/pythonbpf/assign_pass.py @@ -113,33 +113,6 @@ def handle_variable_assignment( ): """Handle single named variable assignment.""" - # A name declared with `global` writes the @bpfglobal symbol directly: - # the plain `store i64 %v, ptr @counter` form of the C reference. - if ( - var_name not in local_sym_tab - and var_name in compilation_context.current_func_globals - ): - sym = compilation_context.bpf_globals[var_name] - val_result = eval_expr(func, compilation_context, builder, rval, local_sym_tab) - if val_result is None: - logger.error(f"Failed to evaluate value for global {var_name}") - return False - val, val_type = val_result - if isinstance(val_type, ir.IntType) and isinstance(sym.ir_type, ir.IntType): - # Same implicit widening/truncation rules as local assignments - if val_type.width < sym.ir_type.width: - val = builder.sext(val, sym.ir_type) - elif val_type.width > sym.ir_type.width: - val = builder.trunc(val, sym.ir_type) - elif val_type != sym.ir_type: - logger.error( - f"Type mismatch for global {var_name}: {val_type} vs {sym.ir_type}" - ) - return False - builder.store(val, sym.var) - logger.info(f"Stored to BPF global {var_name}") - return True - if var_name not in local_sym_tab: logger.error(f"Variable {var_name} not declared.") return False diff --git a/pythonbpf/context.py b/pythonbpf/context.py index 8ebd21b..21a252b 100644 --- a/pythonbpf/context.py +++ b/pythonbpf/context.py @@ -69,10 +69,6 @@ def __init__(self, module: ir.Module): self.map_sym_tab: dict[str, "MapSymbol"] = {} self.bpf_globals: dict[str, "BpfGlobalSymbol"] = {} - # Names a `global` statement declared writable in the function whose - # body is currently being emitted; managed by process_func_body. - self.current_func_globals: set[str] = set() - # Helper management self.scratch_pool = ScratchPoolManager() @@ -86,4 +82,3 @@ def reset(self): """Reset state between functions if necessary, though new context per compile is preferred.""" self.scratch_pool.reset() self.current_func = None - self.current_func_globals = set() diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 3519eb4..1982957 100644 --- a/pythonbpf/functions/functions_pass.py +++ b/pythonbpf/functions/functions_pass.py @@ -205,10 +205,8 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): """ if isinstance(stmt.target, ast.Name): name = stmt.target.id - # Same resolution order as reads: local, then declared global. The two - # cannot both hold a name (a parameter may not be declared global, and - # an undeclared write to a global's name is refused), so this order is - # about giving the same answer as _handle_name_expr, not precedence. + # One table: a declared global is a local_sym_tab entry whose slot is + # the GlobalVariable, so it needs no separate branch. if name in local_sym_tab: slot = local_sym_tab[name].var slot_type = local_sym_tab[name].ir_type @@ -216,9 +214,6 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab): raise SyntaxError( f"cannot assign to '{name}': it is the context parameter" ) - elif name in compilation_context.current_func_globals: - sym = compilation_context.bpf_globals[name] - slot, slot_type = sym.var, sym.ir_type elif name in compilation_context.bpf_globals: raise SyntaxError( f"augmented assignment to '{name}' shadows the BPF global of " @@ -414,28 +409,6 @@ def process_func_body( local_sym_tab = {} - # Collect `global x` declarations. Python scoping rules apply: a declared - # name may be written anywhere in this function and always means the - # @bpfglobal, never a local. Undeclared writes to a global name are - # rejected in the allocation pass rather than silently shadowing. - declared_globals: set[str] = set() - param_names = {arg.arg for arg in func_node.args.args} - for node in ast.walk(func_node): - if isinstance(node, ast.Global): - for gname in node.names: - if gname in param_names: - # Python's own rule and wording. Without it the read path - # (local first) and the write path would resolve the same - # name to different storage. - raise SyntaxError(f"name '{gname}' is parameter and global") - if gname not in compilation_context.bpf_globals: - raise SyntaxError( - f"'global {gname}' in '{func_node.name}': no @bpfglobal " - f"named '{gname}' is declared" - ) - declared_globals.add(gname) - compilation_context.current_func_globals = declared_globals - # Add the context parameter (first function argument) to the local symbol table if func_node.args.args and len(func_node.args.args) > 0: context_arg = func_node.args.args[0] @@ -474,6 +447,26 @@ def process_func_body( local_sym_tab[context_name] = context_type logger.info(f"Added argument '{context_name}' to local symbol table") + # A `global x` statement binds x in this function's scope to the + # @bpfglobal's storage. It goes into local_sym_tab like any other name, + # flagged, so every read and write resolves it through the one table with + # no separate lookup order to get wrong. Python's rules apply: a parameter + # cannot be declared global, and the name must be a @bpfglobal. + for node in ast.walk(func_node): + if isinstance(node, ast.Global): + for gname in node.names: + if gname in local_sym_tab: + raise SyntaxError(f"name '{gname}' is parameter and global") + if gname not in compilation_context.bpf_globals: + raise SyntaxError( + f"'global {gname}' in '{func_node.name}': no @bpfglobal " + f"named '{gname}' is declared" + ) + sym = compilation_context.bpf_globals[gname] + local_sym_tab[gname] = LocalSymbol( + sym.var, sym.ir_type, None, declared_global=True + ) + # pre-allocate dynamic variables local_sym_tab = allocate_mem( compilation_context, @@ -500,8 +493,6 @@ def process_func_body( if not did_return: builder.ret(ir.Constant(ir.IntType(64), 0)) - compilation_context.current_func_globals = set() - def process_bpf_chunk(func_node, compilation_context, return_type): """Process a single BPF chunk (function) and emit corresponding LLVM IR.""" diff --git a/pythonbpf/local_symbol.py b/pythonbpf/local_symbol.py index ccef9d2..0abcec9 100644 --- a/pythonbpf/local_symbol.py +++ b/pythonbpf/local_symbol.py @@ -5,11 +5,20 @@ @dataclass class LocalSymbol: - var: ir.AllocaInstr + """One name visible in a BPF function's scope. + + `var` is the storage the name resolves to: an alloca for locals, the + GlobalVariable for a name brought in by a `global` statement, or None for + the context parameter (which arrives as func.args[0], not a slot). + """ + + var: ir.AllocaInstr | ir.GlobalVariable | None ir_type: ir.Type metadata: Any = None + declared_global: bool = False def __iter__(self): + # Three fields on purpose: several call sites tuple-unpack a symbol. yield self.var yield self.ir_type yield self.metadata From 526f113a5af1975ef04735348cf9dfe85dd04fe9 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Thu, 3 Sep 2026 11:30:21 +0530 Subject: [PATCH 18/18] Core: Give every symbol kind a common Symbol base class LocalSymbol, BpfGlobalSymbol and MapSymbol each named the storage behind a name differently (var, var, sym) and shared no type. They now all derive from symbols.Symbol, which carries what every symbol has -- the storage pointer (alloca, GlobalVariable, or None for the context parameter) and its IR type -- and each subclass adds only what its kind needs: LocalSymbol its metadata and declared_global flag, BpfGlobalSymbol its ctypes name, MapSymbol its map type and params. MapSymbol.sym becomes var to match. local_symbol.py is folded into the new symbols.py. LocalSymbol keeps its three-field __iter__ for the call sites that tuple-unpack a symbol. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU --- pythonbpf/allocation_pass.py | 2 +- pythonbpf/context.py | 2 +- pythonbpf/globals_pass.py | 16 +----- pythonbpf/helper/bpf_helper_handler.py | 2 +- pythonbpf/local_symbol.py | 24 --------- pythonbpf/maps/maps_pass.py | 13 +++-- pythonbpf/maps/maps_utils.py | 9 ++-- pythonbpf/symbols.py | 53 +++++++++++++++++++ .../vmlinux_parser/vmlinux_exports_handler.py | 2 +- 9 files changed, 71 insertions(+), 52 deletions(-) delete mode 100644 pythonbpf/local_symbol.py create mode 100644 pythonbpf/symbols.py diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 567e945..e89d658 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -2,7 +2,7 @@ import logging import ctypes from llvmlite import ir -from .local_symbol import LocalSymbol +from .symbols import LocalSymbol from pythonbpf.helper import HelperHandlerRegistry from pythonbpf.vmlinux_parser.dependency_node import Field from .expr import VmlinuxHandlerRegistry diff --git a/pythonbpf/context.py b/pythonbpf/context.py index 21a252b..c9a2f80 100644 --- a/pythonbpf/context.py +++ b/pythonbpf/context.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from pythonbpf.structs.struct_type import StructType from pythonbpf.maps.maps_utils import MapSymbol - from pythonbpf.globals_pass import BpfGlobalSymbol + from pythonbpf.symbols import BpfGlobalSymbol logger = logging.getLogger(__name__) diff --git a/pythonbpf/globals_pass.py b/pythonbpf/globals_pass.py index a1cb251..aabe8fd 100644 --- a/pythonbpf/globals_pass.py +++ b/pythonbpf/globals_pass.py @@ -1,10 +1,10 @@ from llvmlite import ir import ast -from dataclasses import dataclass from logging import Logger import logging from .type_deducer import ctypes_to_ir +from .symbols import BpfGlobalSymbol from .debuginfo import DebugInfoGenerator from .expr import VmlinuxHandlerRegistry from .debuginfo import dwarf_constants as dc @@ -26,20 +26,6 @@ _C_NAME_BY_WIDTH = {8: "char", 16: "short", 32: "int", 64: "long long"} -@dataclass -class BpfGlobalSymbol: - """A mutable BPF global variable declared with @bpfglobal. - - Lands in .bss (zero initializer) or .data (non-zero) and is read with a - plain load / written with a plain store; libbpf exposes the sections to - userspace as global-data maps. - """ - - var: ir.GlobalVariable - ir_type: ir.Type - ctype_name: str - - def populate_global_symbol_table(tree, compilation_context): """ compilation_context: CompilationContext diff --git a/pythonbpf/helper/bpf_helper_handler.py b/pythonbpf/helper/bpf_helper_handler.py index 9fde71f..3b3e61a 100644 --- a/pythonbpf/helper/bpf_helper_handler.py +++ b/pythonbpf/helper/bpf_helper_handler.py @@ -1111,6 +1111,6 @@ def invoke_helper(method_name, map_ptr=None): if not map_sym_tab or map_name not in map_sym_tab: raise ValueError(f"Map '{map_name}' not found in symbol table") - return invoke_helper(method_name, map_sym_tab[map_name].sym) + return invoke_helper(method_name, map_sym_tab[map_name].var) return None diff --git a/pythonbpf/local_symbol.py b/pythonbpf/local_symbol.py deleted file mode 100644 index 0abcec9..0000000 --- a/pythonbpf/local_symbol.py +++ /dev/null @@ -1,24 +0,0 @@ -import llvmlite.ir as ir -from dataclasses import dataclass -from typing import Any - - -@dataclass -class LocalSymbol: - """One name visible in a BPF function's scope. - - `var` is the storage the name resolves to: an alloca for locals, the - GlobalVariable for a name brought in by a `global` statement, or None for - the context parameter (which arrives as func.args[0], not a slot). - """ - - var: ir.AllocaInstr | ir.GlobalVariable | None - ir_type: ir.Type - metadata: Any = None - declared_global: bool = False - - def __iter__(self): - # Three fields on purpose: several call sites tuple-unpack a symbol. - yield self.var - yield self.ir_type - yield self.metadata diff --git a/pythonbpf/maps/maps_pass.py b/pythonbpf/maps/maps_pass.py index ca07845..362b34b 100644 --- a/pythonbpf/maps/maps_pass.py +++ b/pythonbpf/maps/maps_pass.py @@ -50,7 +50,12 @@ def create_bpf_map(compilation_context, map_name, map_params): map_global.align = 8 logger.info(f"Created BPF map: {map_name} with params {map_params}") - return MapSymbol(type=map_params["type"], sym=map_global, params=map_params) + return MapSymbol( + var=map_global, + ir_type=map_global.value_type, + type=map_params["type"], + params=map_params, + ) def _parse_map_params(rval, expected_args=None): @@ -112,7 +117,7 @@ def process_ringbuf_map(map_name, rval, compilation_context): map_global = create_bpf_map(compilation_context, map_name, map_params) create_ringbuf_debug_info( compilation_context, - map_global.sym, + map_global.var, map_name, map_params, ) @@ -131,7 +136,7 @@ def process_hash_map(map_name, rval, compilation_context): # Generate debug info for BTF create_map_debug_info( compilation_context, - map_global.sym, + map_global.var, map_name, map_params, ) @@ -150,7 +155,7 @@ def process_perf_event_map(map_name, rval, compilation_context): # Generate debug info for BTF create_map_debug_info( compilation_context, - map_global.sym, + map_global.var, map_name, map_params, ) diff --git a/pythonbpf/maps/maps_utils.py b/pythonbpf/maps/maps_utils.py index a271697..b8d3a1c 100644 --- a/pythonbpf/maps/maps_utils.py +++ b/pythonbpf/maps/maps_utils.py @@ -1,16 +1,15 @@ from collections.abc import Callable from dataclasses import dataclass -from llvmlite import ir from typing import Any from .map_types import BPFMapType +from ..symbols import Symbol @dataclass -class MapSymbol: - """Class representing a symbol on the map""" +class MapSymbol(Symbol): + """A BPF map: var is the map's GlobalVariable in the .maps section.""" - type: BPFMapType - sym: ir.GlobalVariable + type: BPFMapType = BPFMapType.UNSPEC params: dict[str, Any] | None = None diff --git a/pythonbpf/symbols.py b/pythonbpf/symbols.py new file mode 100644 index 0000000..b6a8bd6 --- /dev/null +++ b/pythonbpf/symbols.py @@ -0,0 +1,53 @@ +"""Symbols: what a name in a BPF program resolves to. + +Every symbol table in the compiler maps a name to one of these. The base class +carries what all of them share -- the storage behind the name and its IR type; +subclasses add what each kind of name needs on top. +""" + +from dataclasses import dataclass +from typing import Any + +import llvmlite.ir as ir + + +@dataclass +class Symbol: + """The storage a name resolves to, and the type of what is stored there. + + `var` is a pointer to that storage: an alloca for a local, a GlobalVariable + for a BPF global or a map, or None for the context parameter (which arrives + as func.args[0] rather than living in a slot). + """ + + var: ir.Value | None + ir_type: ir.Type | None + + +@dataclass +class LocalSymbol(Symbol): + """One name visible in a BPF function's scope. + + `declared_global` marks a name bound by a `global` statement: its var is + the @bpfglobal's GlobalVariable rather than an alloca. + """ + + metadata: Any = None + declared_global: bool = False + + def __iter__(self): + # Three fields on purpose: several call sites tuple-unpack a symbol. + yield self.var + yield self.ir_type + yield self.metadata + + +@dataclass +class BpfGlobalSymbol(Symbol): + """A mutable BPF global variable declared with @bpfglobal. + + Lands in .bss (zero initializer) or .data (non-zero); libbpf exposes the + section to userspace as a global-data map. + """ + + ctype_name: str = "" diff --git a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py index df1b9d7..97a84c1 100644 --- a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py +++ b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py @@ -3,7 +3,7 @@ import ctypes from llvmlite import ir -from pythonbpf.local_symbol import LocalSymbol +from pythonbpf.symbols import LocalSymbol from pythonbpf.vmlinux_parser.assignment_info import AssignmentType logger = logging.getLogger(__name__)