Skip to content

Commit 71fd1d7

Browse files
authored
Merge pull request #99 from pythonbpf/feat/global-variables
Add the dev workflow skill, implement support for all global vars using @bpfglobal (both const and non-const)
2 parents 926ce3f + d197a11 commit 71fd1d7

35 files changed

Lines changed: 1144 additions & 87 deletions
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
name: ir-first-feature
3+
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).
4+
---
5+
6+
# The IR-first feature loop
7+
8+
PythonBPF targets LLVM IR via llvmlite. For any new feature, clang's output for the
9+
equivalent C is the specification — not documentation, not intuition. Follow the loop
10+
in order; do not skip steps because the feature "looks simple".
11+
12+
## 1. Write the C reference
13+
14+
A minimal `.bpf.c` in `tests/c-form/` exercising **only** the target feature. Small
15+
enough that every line of the resulting IR is attributable to the feature. Prefer no
16+
includes (define `SEC` and the `__u*` typedefs by hand) so nothing else pollutes the IR.
17+
Cover each variant of the feature in one file (e.g. for globals: zero-init, initialized,
18+
const, const volatile).
19+
20+
## 2. Compile and read the IR — this is the spec
21+
22+
```bash
23+
clang -target bpf -O2 -g -emit-llvm -S feature.bpf.c -o feature.ll
24+
llc -march=bpf -filetype=obj feature.ll -o feature.o
25+
bpftool btf dump file feature.o # what must come out the far end
26+
```
27+
28+
Read `feature.ll` and answer, in writing: What top-level symbols/globals appear? What
29+
do loads/stores/calls look like in the body? What `!DI*` debug metadata exists, and
30+
what BTF does llc manufacture from it? What did -O2 fold away, and does that folding
31+
carry semantics (it did for `const` globals)?
32+
33+
Version discipline: llvmlite ≥0.49 emits LLVM 21/22-era attribute spellings
34+
(`captures(none)`, not `nocapture`). Use a clang/llc generation that accepts them, and
35+
compare against what `pythonbpf` + the CI's LLVM actually use.
36+
37+
## 3. Diff against current PythonBPF output
38+
39+
Compile the nearest thing PythonBPF can already express and diff the `.ll`s. The delta
40+
is the actual work item — often smaller than expected (machinery like section placement
41+
and BTF generation frequently comes free from llc).
42+
43+
## 4. HARD STOP — syntax is a human decision
44+
45+
Present 2–3 Pythonic syntax candidates with trade-offs (declaration site, usage site,
46+
failure modes, precedents from FastAPI/typing/Triton-style DSLs). **Wait for a human to
47+
choose. Never proceed on your own judgment, and never treat silence as consent.** The
48+
maintainers own the language surface.
49+
50+
## 5. Implement against the reference
51+
52+
Emit IR through the existing passes (`globals_pass`, `expr_pass`, `assign_pass`,
53+
`allocation_pass`, `debuginfo/`). Verify by **diffing your emitted `.ll` against the
54+
clang reference for the same shapes** — "it compiles and llc accepts it" is not the
55+
bar; llc accepts plenty of subtly wrong IR.
56+
57+
### House style: lower, don't desugar
58+
59+
Handlers walk the AST the user wrote and emit IR directly. **Do not synthesize new AST
60+
nodes mid-compilation and feed them back through other handlers** — no
61+
`ast.Assign(ast.BinOp(...))` conjured to make `x += v` reuse the assignment path.
62+
63+
The temptation is legitimate, so know the argument you are declining. Desugaring is a
64+
standard compiler move (CPython itself lowers `x += v` this way), it guarantees semantic
65+
agreement with the composed form, it is the smallest diff, and any later fix to the
66+
composed path applies automatically. Those are real benefits.
67+
68+
They lose in this codebase for structural reasons: the passes communicate through the
69+
source tree. Allocation runs before codegen and walks `Assign` — a synthetic `Assign`
70+
created during codegen is invisible to it, so the two passes silently disagree about
71+
what the function contains (it happened to be harmless for augmented assignment only
72+
because that statement never needs a fresh slot; that is luck, not design). Synthetic
73+
nodes carry no source location, so diagnostics point nowhere. And `ast.dump` in the logs
74+
shows statements the user never typed, which turns every debugging session into an
75+
archaeology exercise.
76+
77+
The resolution is to move sharing down a level: **equivalence should come from shared
78+
value-level helpers, not shared AST.** When two constructs must agree, extract the common
79+
logic into a helper both call — the way binary-op evaluation and augmented assignment
80+
both use `apply_binop` for the operator table and `get_operand_value` for operands —
81+
and let each handler resolve its own target and emit its own store. Two handlers calling
82+
one helper is the idiom; one handler manufacturing input for another is not.
83+
84+
The operator tables themselves — binary operators, comparisons, and the supported
85+
unary/boolean operators — live in exactly one place, `expr/operators.py`. A new operator
86+
is added there first; if it is not in that file, the compiler does not support it.
87+
88+
## 6. Test at the right tier
89+
90+
- Works now → `tests/passing_tests/<category>/`.
91+
- Documents a gap → `tests/kernel_selftest_equivalent/` with a strict xfail in
92+
`tests/test_config.toml` (level `"ir"`, `"llc"`, or `"verifier"`).
93+
- Wrong-input behaviour → `tests/failing_tests/` with a config entry.
94+
- Kernel verifier level runs in CI; locally it needs the user's sudo — ask, don't
95+
assume.
96+
97+
## House guardrails (always)
98+
99+
- **Never read/cat/grep `vmlinux.py` or `vmlinux.h`** — generated, enormous, will
100+
exhaust context. Probe with one-liners:
101+
`.venv/bin/python -c "import vmlinux; print(vmlinux.struct_x._fields_[:3])"`
102+
- 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.
103+
- Atomic commits, `Core:`/`Tests:` subject prefixes, one logical change each.

‎pythonbpf/allocation_pass.py‎

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import ctypes
44
from llvmlite import ir
5-
from .local_symbol import LocalSymbol
5+
from .symbols import LocalSymbol
66
from pythonbpf.helper import HelperHandlerRegistry
77
from pythonbpf.vmlinux_parser.dependency_node import Field
88
from .expr import VmlinuxHandlerRegistry
@@ -49,11 +49,23 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab):
4949
continue
5050

5151
var_name = target.id
52-
# Skip if already allocated
52+
53+
# Already bound in this scope: a parameter, an earlier assignment, or a
54+
# `global` declaration (whose slot is the GlobalVariable). No slot needed.
5355
if var_name in local_sym_tab:
54-
logger.debug(f"Variable {var_name} already allocated, skipping")
56+
logger.debug(f"'{var_name}' already bound, no allocation needed")
5557
continue
5658

59+
# Not declared `global`, yet named like one: Python creates a local
60+
# that shadows the global for the whole function body, and leaves the
61+
# global untouched. Do the same.
62+
shadows_global = var_name in compilation_context.bpf_globals
63+
if shadows_global:
64+
logger.info(
65+
f"'{var_name}' is assigned without a 'global' declaration, so it "
66+
f"is a local shadowing the @bpfglobal of the same name"
67+
)
68+
5769
# Determine type and allocate based on rval
5870
if isinstance(rval, ast.Call):
5971
_allocate_for_call(
@@ -65,7 +77,9 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab):
6577
_allocate_for_binop(builder, var_name, local_sym_tab)
6678
elif isinstance(rval, ast.Name):
6779
# Variable-to-variable assignment (b = a)
68-
_allocate_for_name(builder, var_name, rval, local_sym_tab)
80+
_allocate_for_name(
81+
builder, var_name, rval, local_sym_tab, compilation_context
82+
)
6983
elif isinstance(rval, ast.Attribute):
7084
# Struct field-to-variable assignment (a = dat.fld)
7185
_allocate_for_attribute(
@@ -76,6 +90,14 @@ def handle_assign_allocation(compilation_context, builder, stmt, local_sym_tab):
7690
f"Unsupported assignment value type for {var_name}: {type(rval).__name__}"
7791
)
7892

93+
if shadows_global and var_name in local_sym_tab:
94+
# Where the binding ends, so that a read above it is reported the
95+
# way Python reports it. end_lineno, not lineno, so a read on a
96+
# continuation line of a multi-line binding counts as above it too.
97+
local_sym_tab[var_name].shadows_global_from = (
98+
getattr(stmt, "end_lineno", None) or target.lineno
99+
)
100+
79101

80102
def _allocate_for_call(builder, var_name, rval, local_sym_tab, compilation_context):
81103
"""Allocate memory for variable assigned from a call."""
@@ -298,21 +320,24 @@ def allocate_temp_pool(builder, max_temps, local_sym_tab):
298320
logger.debug(f"Allocated temp variable: {temp_name}")
299321

300322

301-
def _allocate_for_name(builder, var_name, rval, local_sym_tab):
323+
def _allocate_for_name(builder, var_name, rval, local_sym_tab, compilation_context):
302324
"""Allocate memory for variable-to-variable assignment (b = a)."""
303325
source_var = rval.id
304326

305-
if source_var not in local_sym_tab:
327+
# Local first, then a BPF global: the copy takes the source's type either
328+
# way (a c_uint32 global gives a c_uint32 local).
329+
if source_var in local_sym_tab:
330+
source_symbol = local_sym_tab[source_var]
331+
elif source_var in compilation_context.bpf_globals:
332+
source_symbol = compilation_context.bpf_globals[source_var]
333+
else:
306334
logger.error(f"Source variable '{source_var}' not found in symbol table")
307335
return
308336

309-
# Get type and metadata from source variable
310-
source_symbol = local_sym_tab[source_var]
311-
312337
# Allocate with same type and alignment
313338
var = _allocate_with_type(builder, var_name, source_symbol.ir_type)
314339
local_sym_tab[var_name] = LocalSymbol(
315-
var, source_symbol.ir_type, source_symbol.metadata
340+
var, source_symbol.ir_type, getattr(source_symbol, "metadata", None)
316341
)
317342

318343
logger.info(

‎pythonbpf/assign_pass.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ def handle_struct_field_assignment(
5454
logger.info(f"Copied string to char array {var_name}.{field_name}")
5555
return
5656

57+
# Same implicit widening/truncation as assignment to a local: expressions
58+
# evaluate in i64, but a field may be narrower.
59+
if isinstance(val_type, ir.IntType) and isinstance(field_type, ir.IntType):
60+
if val_type.width < field_type.width:
61+
val = builder.sext(val, field_type)
62+
elif val_type.width > field_type.width:
63+
val = builder.trunc(val, field_type)
64+
5765
# Regular assignment
5866
builder.store(val, field_ptr)
5967
logger.info(f"Assigned to struct field {var_name}.{field_name}")

‎pythonbpf/context.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
if TYPE_CHECKING:
66
from pythonbpf.structs.struct_type import StructType
77
from pythonbpf.maps.maps_utils import MapSymbol
8+
from pythonbpf.symbols import BpfGlobalSymbol
89

910
logger = logging.getLogger(__name__)
1011

@@ -66,6 +67,7 @@ def __init__(self, module: ir.Module):
6667
self.global_sym_tab: list[ir.GlobalVariable] = []
6768
self.structs_sym_tab: dict[str, "StructType"] = {}
6869
self.map_sym_tab: dict[str, "MapSymbol"] = {}
70+
self.bpf_globals: dict[str, "BpfGlobalSymbol"] = {}
6971

7072
# Helper management
7173
self.scratch_pool = ScratchPoolManager()

‎pythonbpf/expr/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .expr_pass import eval_expr, handle_expr, get_operand_value
22
from .type_normalization import convert_to_bool, get_base_type_and_depth
33
from .ir_ops import deref_to_depth, access_struct_field
4+
from .operators import apply_binop
45
from .call_registry import CallHandlerRegistry
56
from .vmlinux_registry import VmlinuxHandlerRegistry
67

@@ -10,6 +11,7 @@
1011
"convert_to_bool",
1112
"get_base_type_and_depth",
1213
"deref_to_depth",
14+
"apply_binop",
1315
"access_struct_field",
1416
"get_operand_value",
1517
"CallHandlerRegistry",

‎pythonbpf/expr/expr_pass.py‎

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pythonbpf.type_deducer import ctypes_to_ir, is_ctypes
88
from .call_registry import CallHandlerRegistry
99
from .ir_ops import deref_to_depth, access_struct_field
10+
from .operators import apply_binop, UNARY_OPS, BOOL_OPS
1011
from .type_normalization import (
1112
convert_to_bool,
1213
handle_comparator,
@@ -22,12 +23,22 @@
2223
# ============================================================================
2324

2425

25-
def _handle_name_expr(expr: ast.Name, local_sym_tab: Dict, builder: ir.IRBuilder):
26+
def _handle_name_expr(
27+
expr: ast.Name, compilation_context, local_sym_tab: Dict, builder: ir.IRBuilder
28+
):
2629
"""Handle ast.Name expressions."""
2730
if expr.id in local_sym_tab:
28-
var = local_sym_tab[expr.id].var
29-
val = builder.load(var)
30-
return val, local_sym_tab[expr.id].ir_type
31+
sym = local_sym_tab[expr.id]
32+
# A local that shadows a @bpfglobal is unreadable above its binding,
33+
# exactly as in Python; for every other local this is a no-op.
34+
sym.check_bound_at(expr.id, expr.lineno)
35+
val = builder.load(sym.var)
36+
return val, sym.ir_type
37+
elif expr.id in compilation_context.bpf_globals:
38+
# A @bpfglobal
39+
sym = compilation_context.bpf_globals[expr.id]
40+
val = builder.load(sym.var)
41+
return val, sym.ir_type
3142
else:
3243
# Check if it's a vmlinux enum/constant
3344
vmlinux_result = VmlinuxHandlerRegistry.handle_name(expr.id)
@@ -78,7 +89,9 @@ def _handle_attribute_expr(
7889
var_ptr, var_type, var_metadata = local_sym_tab[var_name]
7990
logger.info(f"Loading attribute {attr_name} from variable {var_name}")
8091
logger.info(
81-
f"Variable type: {var_type}, Variable ptr: {var_ptr}, Variable Metadata: {var_metadata}"
92+
f"Variable type: {var_type}, Variable ptr: {
93+
var_ptr
94+
}, Variable Metadata: {var_metadata}"
8295
)
8396
if (
8497
hasattr(var_metadata, "__module__")
@@ -96,7 +109,9 @@ def _handle_attribute_expr(
96109

97110
elif isinstance(var_metadata, Field):
98111
logger.error(
99-
f"Cannot access field '{attr_name}' on already-loaded field value '{var_name}'"
112+
f"Cannot access field '{attr_name}' on already-loaded field value '{
113+
var_name
114+
}'"
100115
)
101116
return None
102117

@@ -175,6 +190,9 @@ def get_operand_value(func, compilation_context, operand, builder, local_sym_tab
175190
else:
176191
val = deref_to_depth(func, builder, var, depth)
177192
return val
193+
elif operand.id in compilation_context.bpf_globals:
194+
# A @bpfglobal: plain load off the global symbol.
195+
return builder.load(compilation_context.bpf_globals[operand.id].var)
178196
else:
179197
# Check if it's a vmlinux enum/constant
180198
vmlinux_result = VmlinuxHandlerRegistry.handle_name(operand.id)
@@ -223,25 +241,7 @@ def _handle_binary_op_impl(func, compilation_context, rval, builder, local_sym_t
223241
right = builder.sext(right, ir.IntType(64))
224242

225243
# Map AST operation nodes to LLVM IR builder methods
226-
op_map = {
227-
ast.Add: builder.add,
228-
ast.Sub: builder.sub,
229-
ast.Mult: builder.mul,
230-
ast.Div: builder.sdiv,
231-
ast.Mod: builder.srem,
232-
ast.LShift: builder.shl,
233-
ast.RShift: builder.lshr,
234-
ast.BitOr: builder.or_,
235-
ast.BitXor: builder.xor,
236-
ast.BitAnd: builder.and_,
237-
ast.FloorDiv: builder.udiv,
238-
}
239-
240-
if type(op) in op_map:
241-
result = op_map[type(op)](left, right)
242-
return result
243-
else:
244-
raise SyntaxError("Unsupported binary operation")
244+
return apply_binop(builder, op, left, right)
245245

246246

247247
def _handle_binary_op(
@@ -304,7 +304,9 @@ def _handle_ctypes_call(
304304
# Get the IR type from the value itself
305305
actual_ir_type = value.type
306306
logger.info(
307-
f"Converting vmlinux field {val_type.name} (IR type: {actual_ir_type}) to {call_type}"
307+
f"Converting vmlinux field {val_type.name} (IR type: {actual_ir_type}) to {
308+
call_type
309+
}"
308310
)
309311
else:
310312
actual_ir_type = val_type
@@ -317,7 +319,9 @@ def _handle_ctypes_call(
317319
if actual_ir_type.width < expected_type.width:
318320
value = builder.sext(value, expected_type)
319321
logger.info(
320-
f"Sign-extended from i{actual_ir_type.width} to i{expected_type.width}"
322+
f"Sign-extended from i{actual_ir_type.width} to i{
323+
expected_type.width
324+
}"
321325
)
322326
elif actual_ir_type.width > expected_type.width:
323327
value = builder.trunc(value, expected_type)
@@ -329,7 +333,9 @@ def _handle_ctypes_call(
329333
pass
330334
else:
331335
raise ValueError(
332-
f"Type mismatch: expected {expected_type}, got {actual_ir_type} (original type: {val_type})"
336+
f"Type mismatch: expected {expected_type}, got {
337+
actual_ir_type
338+
} (original type: {val_type})"
333339
)
334340

335341
return value, expected_type
@@ -373,7 +379,7 @@ def _handle_unary_op(
373379
local_sym_tab,
374380
):
375381
"""Handle ast.UnaryOp expressions."""
376-
if not isinstance(expr.op, ast.Not) and not isinstance(expr.op, ast.USub):
382+
if not isinstance(expr.op, UNARY_OPS):
377383
logger.error("Only 'not' and '-' unary operators are supported")
378384
return None
379385

@@ -516,6 +522,9 @@ def _handle_boolean_op(
516522
):
517523
"""Handle `and` and `or` boolean operations."""
518524

525+
if not isinstance(expr.op, BOOL_OPS):
526+
logger.error(f"Unsupported boolean operator: {type(expr.op).__name__}")
527+
return None
519528
if isinstance(expr.op, ast.And):
520529
return _handle_and_op(func, builder, expr, local_sym_tab, compilation_context)
521530
elif isinstance(expr.op, ast.Or):
@@ -662,7 +671,7 @@ def eval_expr(
662671

663672
logger.info(f"Evaluating expression: {ast.dump(expr)}")
664673
if isinstance(expr, ast.Name):
665-
return _handle_name_expr(expr, local_sym_tab, builder)
674+
return _handle_name_expr(expr, compilation_context, local_sym_tab, builder)
666675
elif isinstance(expr, ast.Constant):
667676
return _handle_constant_expr(compilation_context, builder, expr)
668677
elif isinstance(expr, ast.Call):

0 commit comments

Comments
 (0)