diff --git a/.claude/skills/ir-first-feature/SKILL.md b/.claude/skills/ir-first-feature/SKILL.md new file mode 100644 index 00000000..2fbf4f2c --- /dev/null +++ b/.claude/skills/ir-first-feature/SKILL.md @@ -0,0 +1,103 @@ +--- +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. + +### 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. + +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//`. +- 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])"` +- 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. diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 466800b7..e89d6582 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 @@ -49,11 +49,21 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab): continue var_name = target.id - # Skip if already allocated + + # 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"Variable {var_name} already allocated, skipping") + 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" + ) + # 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 91133dbe..5d931f70 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}") diff --git a/pythonbpf/context.py b/pythonbpf/context.py index 5297889f..c9a2f802 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.symbols import BpfGlobalSymbol logger = logging.getLogger(__name__) @@ -66,6 +67,7 @@ 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"] = {} # Helper management self.scratch_pool = ScratchPoolManager() diff --git a/pythonbpf/expr/__init__.py b/pythonbpf/expr/__init__.py index dfd21284..5002113e 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 +from .operators import apply_binop from .call_registry import CallHandlerRegistry from .vmlinux_registry import VmlinuxHandlerRegistry @@ -10,6 +11,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 2270fdbb..48d5abcd 100644 --- a/pythonbpf/expr/expr_pass.py +++ b/pythonbpf/expr/expr_pass.py @@ -7,6 +7,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 .operators import apply_binop, UNARY_OPS, BOOL_OPS from .type_normalization import ( convert_to_bool, handle_comparator, @@ -22,12 +23,19 @@ # ============================================================================ -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 + 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) @@ -78,7 +86,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__") @@ -96,7 +106,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 @@ -175,6 +187,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) @@ -223,25 +238,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( @@ -304,7 +301,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 @@ -317,7 +316,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) @@ -329,7 +330,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 @@ -373,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 @@ -516,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): @@ -662,7 +668,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/expr/operators.py b/pythonbpf/expr/operators.py new file mode 100644 index 00000000..fb4cadd1 --- /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 bb5e83b8..edea4228 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.""" diff --git a/pythonbpf/functions/functions_pass.py b/pythonbpf/functions/functions_pass.py index 69be3230..19829577 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 ( @@ -189,6 +191,87 @@ 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` 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): + name = stmt.target.id + # 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 + if slot is None: + raise SyntaxError( + f"cannot assign to '{name}': it is the context parameter" + ) + 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" + ) + 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)}" + ) + + 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 + ) + 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): val = eval_expr(func, compilation_context, builder, cond, local_sym_tab)[0] return convert_to_bool(builder, val) @@ -238,7 +321,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 @@ -283,7 +378,10 @@ 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 elif isinstance(stmt, ast.If): handle_if(func, compilation_context, builder, stmt, local_sym_tab) elif isinstance(stmt, ast.Return): @@ -349,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, diff --git a/pythonbpf/globals_pass.py b/pythonbpf/globals_pass.py index 27eecd22..aabe8fde 100644 --- a/pythonbpf/globals_pass.py +++ b/pythonbpf/globals_pass.py @@ -4,9 +4,27 @@ 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 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"} + def populate_global_symbol_table(tree, compilation_context): """ @@ -68,12 +86,38 @@ 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 +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 @@ -111,7 +155,33 @@ 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 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, + 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 " + 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") diff --git a/pythonbpf/helper/bpf_helper_handler.py b/pythonbpf/helper/bpf_helper_handler.py index 9fde71f4..3b3e61a9 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/helper/printk_formatter.py b/pythonbpf/helper/printk_formatter.py index 3328e1ac..f9d35370 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) diff --git a/pythonbpf/local_symbol.py b/pythonbpf/local_symbol.py deleted file mode 100644 index ccef9d2d..00000000 --- a/pythonbpf/local_symbol.py +++ /dev/null @@ -1,15 +0,0 @@ -import llvmlite.ir as ir -from dataclasses import dataclass -from typing import Any - - -@dataclass -class LocalSymbol: - var: ir.AllocaInstr - ir_type: ir.Type - metadata: Any = None - - def __iter__(self): - 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 ca078454..362b34b8 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 a271697c..b8d3a1c9 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 00000000..b6a8bd60 --- /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 df1b9d73..97a84c12 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__) diff --git a/tests/c-form/global_vars.bpf.c b/tests/c-form/global_vars.bpf.c new file mode 100644 index 00000000..13427695 --- /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"; diff --git a/tests/failing_tests/globals_augassign_shadowing.py b/tests/failing_tests/globals_augassign_shadowing.py new file mode 100644 index 00000000..ecfb5a9a --- /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/failing_tests/globals_bad_type.py b/tests/failing_tests/globals_bad_type.py new file mode 100644 index 00000000..4c0f749f --- /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_parameter_and_global.py b/tests/failing_tests/globals_parameter_and_global.py new file mode 100644 index 00000000..735e4852 --- /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/failing_tests/globals_shadowing.py b/tests/failing_tests/globals_shadowing.py new file mode 100644 index 00000000..87e58279 --- /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 00000000..e55a06b0 --- /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/assign/augassign_struct_field.py b/tests/passing_tests/assign/augassign_struct_field.py new file mode 100644 index 00000000..6c563708 --- /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/passing_tests/globals/augassign_counter.py b/tests/passing_tests/globals/augassign_counter.py new file mode 100644 index 00000000..5a95f08c --- /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 00000000..b78661fd --- /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 00000000..8fde2ae6 --- /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 00000000..915bc34d --- /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 00000000..98d68cb3 --- /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/passing_tests/return/xdp_name_shadowed.py b/tests/passing_tests/return/xdp_name_shadowed.py new file mode 100644 index 00000000..0caacd82 --- /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 00000000..2d7baf60 --- /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() diff --git a/tests/test_config.toml b/tests/test_config.toml index 8a6255a3..73d68b34 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -32,3 +32,13 @@ "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"} + +"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"}