Skip to content

Commit 708b5af

Browse files
committed
Merge remote-tracking branch 'origin/master' into claude/pythonbpf-state-replication-g23gij
# Conflicts: # tests/test_config.toml
2 parents 507f075 + dc628ee commit 708b5af

73 files changed

Lines changed: 2890 additions & 264 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Dependencies:
4848

4949
* `bpftool`
5050
* `clang`
51-
* Python ≥ 3.8
51+
* Python ≥ 3.10
5252

5353
Install via pip:
5454

‎docs/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ user-guide/maps
6969
user-guide/structs
7070
user-guide/compilation
7171
user-guide/helpers
72+
user-guide/integers
7273
```
7374

7475
```{toctree}

‎docs/user-guide/index.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ PythonBPF uses Python's `ctypes` module for type definitions:
4444
* `c_void_p` - Void pointers
4545
* `str(N)` - Fixed-length strings (e.g., `str(16)` for 16-byte string)
4646

47+
Integers follow C's rules for width, sign, conversion and arithmetic; see
48+
{doc}`integers` for the details and the places where this differs from Python.
49+
4750
## Example Structure
4851

4952
A typical PythonBPF program follows this structure:

‎docs/user-guide/integers.md‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Integer Semantics
2+
3+
PythonBPF programs are Python syntax, but the integers in them behave as C integers: the
4+
program runs in the kernel as BPF bytecode, where every value is a fixed-width machine
5+
word. This page describes the rules the compiler applies. They are C's rules, applied to
6+
the `ctypes` types you declare, so a program's arithmetic matches what the equivalent C
7+
program compiled with clang would compute.
8+
9+
```{note}
10+
This is one of the few places where PythonBPF deliberately differs from Python. Python
11+
integers have arbitrary precision and no unsigned types; BPF has neither. The
12+
[divergences from Python](#divergences-from-python) are listed at the end of this page.
13+
```
14+
15+
## Types
16+
17+
An integer's type is the `ctypes` type it was declared with, and the type carries both
18+
a width and a sign:
19+
20+
| Signed | Unsigned | Width |
21+
|---|---|---|
22+
| `c_int8` | `c_uint8` | 8 |
23+
| `c_int16` | `c_uint16` | 16 |
24+
| `c_int32` | `c_uint32` | 32 |
25+
| `c_int64` | `c_uint64` | 64 |
26+
27+
Every declaration site uses these types: local variables initialised with a constructor
28+
call, `@bpfglobal` variables, `@struct` fields, map keys and values, and fields read from
29+
`vmlinux` structures. Helper functions return the type of the kernel's signature, so
30+
`pid()` and `ktime()` are unsigned while `probe_read`-style helpers return a signed
31+
`long`.
32+
33+
```python
34+
count = c_uint32(0) # a 32-bit unsigned local
35+
delta = c_int64(-1) # a 64-bit signed local
36+
```
37+
38+
A local assigned without a constructor takes its type from the expression:
39+
40+
```python
41+
now = ktime() # c_uint64, the helper's return type
42+
total = count + 1 # the type of the addition (see below), held in a 64-bit slot
43+
```
44+
45+
Undeclared locals are always 64 bits wide; the inferred type only decides their sign.
46+
Declare the local with a constructor when a narrower width matters.
47+
48+
### Literals
49+
50+
A literal has the type a C compiler gives it: `int` (32-bit signed) if the value fits,
51+
`long long` (64-bit signed) otherwise. This matters for mixed arithmetic: in
52+
`count / -2` with `count` a `c_uint32`, the literal `-2` is a 32-bit `int`, so the
53+
division happens in `c_uint32` exactly as it would in C.
54+
55+
## Assignment and conversion
56+
57+
Assigning a value to a variable of a different integer type converts it, and the
58+
variable's declared type is what the stored value *is* afterwards:
59+
60+
* **Widening preserves the value.** The conversion looks at the *source*'s sign: an
61+
unsigned source is zero-extended, a signed source is sign-extended. So a `c_uint32`
62+
holding `0xFFFFFFFF` stored into a `c_int64` gives `4294967295`, and a `c_int32`
63+
holding `-1` stored into a `c_uint64` gives `0xFFFFFFFFFFFFFFFF`. This is what C and
64+
`ctypes` both do.
65+
* **Narrowing truncates.** Only the low bits survive.
66+
* **Same width reinterprets.** A `c_uint32` `0xFFFFFFFF` stored into a `c_int32` reads
67+
as `-1`.
68+
69+
The same rules apply to explicit conversions written as constructor calls
70+
(`c_int64(count)`), to struct field stores and to `return`.
71+
72+
## Arithmetic
73+
74+
Each binary operation is typed on its own, from its two operands, following C's usual
75+
arithmetic conversions:
76+
77+
1. Operands narrower than 32 bits are promoted to `c_int32`.
78+
2. If both operands have the same sign, the result has the wider width and that sign.
79+
3. If the signs differ, the unsigned type wins when it is at least as wide as the signed
80+
one; otherwise the signed type wins.
81+
82+
The operation is then performed in that type, and its result has that type. The variable
83+
receiving the result plays no part until the final store. Two consequences worth knowing:
84+
85+
* **Intermediate results wrap at their own width.** `c_uint32(0x80000000) * c_uint32(2)`
86+
is a `c_uint32` multiplication, so it wraps to `0` before being stored, even if the
87+
destination is a `c_uint64`. Widen an operand first if you want a 64-bit product.
88+
* **Mixed signs go unsigned.** `c_uint32(10) / c_int32(-2)` is an unsigned division by
89+
`0xFFFFFFFE`, giving `0`, not `-5`.
90+
91+
The sign of the operation's type selects the instruction for the operations where it
92+
matters:
93+
94+
| Operator | Signed type | Unsigned type |
95+
|---|---|---|
96+
| `/`, `//` | truncating signed division | unsigned division |
97+
| `%` | remainder with the dividend's sign | unsigned remainder |
98+
| `>>` | arithmetic shift (sign bit shifts in) | logical shift (zeros shift in) |
99+
| `<`, `<=`, `>`, `>=` | signed comparison | unsigned comparison |
100+
101+
`+`, `-`, `*`, `<<`, `&`, `|`, `^`, `==` and `!=` produce the same bits for either sign.
102+
103+
Unary minus on an unsigned value follows C too: `-x` is `2^N - x` in the value's type.
104+
105+
## Comparisons
106+
107+
A comparison converts both operands with the same usual arithmetic conversions and then
108+
compares in the resulting type. `c_uint64(10) > c_int64(-1)` is therefore an unsigned
109+
comparison in which `-1` is the largest possible value, and the result is false. The
110+
result of a comparison is `1` or `0`, as in C.
111+
112+
## A verifier gotcha: packet pointer fields
113+
114+
A few context fields are declared as 32-bit integers but are pointers as far as the
115+
kernel verifier is concerned: `data`, `data_end` and `data_meta` on `xdp_md`, and `data`
116+
and `data_end` on `__sk_buff`. Because they are `c_uint32`, arithmetic on them directly
117+
is a 32-bit operation, exactly as in C, and the verifier rejects 32-bit arithmetic on a
118+
pointer:
119+
120+
```
121+
R0 32-bit pointer arithmetic prohibited
122+
```
123+
124+
C programs cast these fields through `(void *)(long)` before using them for the same
125+
reason. Until PythonBPF does this for you, copy the field into a local first, which is a
126+
64-bit slot, or cast it with `c_void_p`:
127+
128+
```python
129+
data = ctx.data # 64-bit local
130+
end = ctx.data_end
131+
if data + 34 < end: # 64-bit pointer arithmetic, accepted
132+
...
133+
```
134+
135+
```{note}
136+
This is a known gap. The plan is to give these fields pointer rank automatically so that
137+
no cast or copy is needed; this section will go away when that lands.
138+
```
139+
140+
## Divergences from Python
141+
142+
Because the semantics are C's, some Python behaviour does not carry over:
143+
144+
* `/` is integer division; there is no floating-point result.
145+
* `//` and `/` are the same operation, and both truncate toward zero: `-7 // 2` is `-3`,
146+
where Python gives `-4`.
147+
* `%` takes the sign of the dividend: `-7 % 2` is `-1`, where Python gives `1`.
148+
* Integers have a fixed width and wrap on overflow; there is no arbitrary precision.
149+
* Unsigned types exist, and mixing them with signed values follows C's conversions
150+
rather than Python's mathematical integers.
151+
152+
The test programs under `tests/passing_tests/signedness/` show each rule with its
153+
expected value, and `tests/c-form/signedness.bpf.c` is the equivalent C program.

‎pyproject.toml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ classifiers = [
1515
"Intended Audience :: Developers",
1616
"Operating System :: POSIX :: Linux",
1717
"Programming Language :: Python :: 3",
18-
"Programming Language :: Python :: 3.8",
19-
"Programming Language :: Python :: 3.9",
2018
"Programming Language :: Python :: 3.10",
2119
"Programming Language :: Python :: 3.11",
2220
"Programming Language :: Python :: 3.12",

0 commit comments

Comments
 (0)