Skip to content

Commit aa13482

Browse files
Core: Write struct fields through a map-lookup pointer, null-checked
`s = m.lookup(k); s.count = 5` (and `s.count += 1`) compiled without an error to IR llc rejects: "invalid getelementptr indices" on `getelementptr inbounds i64*, i64** %s, i32 0, i32 0`. Both the assignment and augmented-assignment paths indexed into the local's own slot, which for a lookup or cast local holds a pointer to the struct, not the struct. Reads already got this right in access_struct_field. with_struct_field_ptr now resolves a field for a write the same way: directly for a struct local; for a pointer local, by loading the pointer, casting it to the struct type, and emitting the store only on the non-null path (emit_if_not_null, the write-side counterpart of _null_checked_operation). The write lands in the map entry itself, so `s.count += 1` is an in-place update with no update() call, and the maps/structs guide examples that did update(k, s) after a field assignment now just assign.
1 parent dc628ee commit aa13482

8 files changed

Lines changed: 145 additions & 28 deletions

File tree

‎docs/user-guide/maps.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,8 @@ def track_stats(ctx: c_void_p) -> c_int64:
353353
stats = process_stats.lookup(process_id)
354354

355355
if stats:
356-
stats.count = stats.count + 1
357-
process_stats.update(process_id, stats)
356+
# A field assignment writes into the map entry itself
357+
stats.count += 1
358358
else:
359359
new_stats = Stats()
360360
new_stats.count = 1

‎docs/user-guide/structs.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,8 @@ def track_syscalls(ctx: c_void_p) -> c_int64:
161161
s = stats.lookup(process_id)
162162

163163
if s:
164-
# Update existing stats
165-
s.syscall_count = s.syscall_count + 1
166-
stats.update(process_id, s)
164+
# A field assignment writes into the map entry itself
165+
s.syscall_count += 1
167166
else:
168167
# Create new stats
169168
new_stats = ProcessStats()

‎pythonbpf/assign_pass.py‎

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from inspect import isclass
44

55
from llvmlite import ir
6-
from pythonbpf.expr import eval_expr, convert
6+
from pythonbpf.expr import eval_expr, convert, with_struct_field_ptr
77
from pythonbpf.helper import emit_probe_read_kernel_str_call
88
from pythonbpf.type_deducer import ctypes_to_ir
99
from pythonbpf.vmlinux_parser.dependency_node import Field
@@ -30,8 +30,7 @@ def handle_struct_field_assignment(
3030
logger.error(f"Field '{field_name}' not found in struct '{struct_type}'")
3131
return
3232

33-
# Get field pointer and evaluate value
34-
field_ptr = struct_info.gep(builder, local_sym_tab[var_name].var, field_name)
33+
# Python evaluates the value before the target.
3534
field_type = struct_info.field_type(field_name)
3635
val_result = eval_expr(func, compilation_context, builder, rval, local_sym_tab)
3736

@@ -43,24 +42,29 @@ def handle_struct_field_assignment(
4342

4443
# Special case: i8* string to [N x i8] char array
4544
if _is_char_array(field_type) and _is_i8_ptr(val_type):
46-
_copy_string_to_char_array(
47-
func,
48-
builder,
49-
val,
50-
field_ptr,
51-
field_type,
52-
local_sym_tab,
53-
)
54-
logger.info(f"Copied string to char array {var_name}.{field_name}")
55-
return
5645

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-
val = convert(builder, val, val_type, field_type)
46+
def store(builder, field_ptr):
47+
_copy_string_to_char_array(
48+
func,
49+
builder,
50+
val,
51+
field_ptr,
52+
field_type,
53+
local_sym_tab,
54+
)
55+
56+
else:
57+
# Same implicit widening/truncation as assignment to a local:
58+
# expressions evaluate in i64, but a field may be narrower.
59+
if isinstance(val_type, ir.IntType) and isinstance(field_type, ir.IntType):
60+
val = convert(builder, val, val_type, field_type)
61+
62+
def store(builder, field_ptr):
63+
builder.store(val, field_ptr)
6164

62-
# Regular assignment
63-
builder.store(val, field_ptr)
65+
with_struct_field_ptr(
66+
func, builder, local_sym_tab[var_name], struct_info, field_name, store
67+
)
6468
logger.info(f"Assigned to struct field {var_name}.{field_name}")
6569

6670

‎pythonbpf/expr/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
to_promoted,
88
)
99
from .operators import usual_arithmetic_conversions
10-
from .ir_ops import deref_to_depth, access_struct_field
10+
from .ir_ops import deref_to_depth, access_struct_field, with_struct_field_ptr
1111
from .operators import apply_binop
1212
from .call_registry import CallHandlerRegistry
1313
from .vmlinux_registry import VmlinuxHandlerRegistry
@@ -25,6 +25,7 @@
2525
"deref_to_depth",
2626
"apply_binop",
2727
"access_struct_field",
28+
"with_struct_field_ptr",
2829
"CallHandlerRegistry",
2930
"VmlinuxHandlerRegistry",
3031
]

‎pythonbpf/expr/ir_ops.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,42 @@ def _null_checked_operation(func, builder, ptr, operation, result_type, name_pre
6262
return phi
6363

6464

65+
def emit_if_not_null(func, builder, ptr, emit, name_prefix):
66+
"""Emit `emit(builder)` only on the path where `ptr` is non-null. The
67+
write-side counterpart of _null_checked_operation: a store has no value to
68+
merge, so the null path simply skips it."""
69+
not_null_block = func.append_basic_block(name=f"{name_prefix}_not_null")
70+
merge_block = func.append_basic_block(name=f"{name_prefix}_merge")
71+
72+
is_not_null = builder.icmp_signed("!=", ptr, ir.Constant(ptr.type, None))
73+
builder.cbranch(is_not_null, not_null_block, merge_block)
74+
75+
builder.position_at_end(not_null_block)
76+
emit(builder)
77+
builder.branch(merge_block)
78+
79+
builder.position_at_end(merge_block)
80+
81+
82+
def with_struct_field_ptr(func, builder, symbol, struct_info, field_name, emit):
83+
"""Call `emit(builder, field_ptr)` with a pointer to `field_name` of the
84+
struct `symbol` holds. A struct local holds the struct itself; a
85+
map-lookup or cast local holds a pointer to it, which may be null, and
86+
`emit` then runs only when it is not, as the read of the field yields 0
87+
there instead (see access_struct_field)."""
88+
if not isinstance(symbol.ir_type, ir.PointerType):
89+
emit(builder, struct_info.gep(builder, symbol.var, field_name))
90+
return
91+
92+
struct_ptr = builder.load(symbol.var)
93+
94+
def emit_through_ptr(builder):
95+
typed_ptr = builder.bitcast(struct_ptr, struct_info.ir_type.as_pointer())
96+
emit(builder, struct_info.gep(builder, typed_ptr, field_name))
97+
98+
emit_if_not_null(func, builder, struct_ptr, emit_through_ptr, f"field_{field_name}")
99+
100+
65101
def access_struct_field(
66102
builder, var_ptr, var_type, var_metadata, field_name, structs_sym_tab, func=None
67103
):

‎pythonbpf/functions/functions_pass.py‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
to_promoted,
1818
canonicalise,
1919
usual_arithmetic_conversions,
20+
access_struct_field,
21+
with_struct_field_ptr,
2022
VmlinuxHandlerRegistry,
2123
)
2224
from pythonbpf.assign_pass import (
@@ -207,6 +209,9 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab):
207209
read, and the operator table is apply_binop, the same one binary-op
208210
evaluation uses.
209211
"""
212+
# Set for a field reached through a pointer (a map-lookup or cast local):
213+
# it is read and written through null checks rather than as a plain slot.
214+
field_via_pointer = None
210215
if isinstance(stmt.target, ast.Name):
211216
name = stmt.target.id
212217
# One table: a declared global is a local_sym_tab entry whose slot is
@@ -250,8 +255,12 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab):
250255
struct_info = structs_sym_tab[metadata]
251256
if field_name not in struct_info.fields:
252257
raise SyntaxError(f"Field '{field_name}' not found in struct '{metadata}'")
253-
slot = struct_info.gep(builder, local_sym_tab[var_name].var, field_name)
258+
symbol = local_sym_tab[var_name]
254259
slot_type = struct_info.field_type(field_name)
260+
if isinstance(symbol.ir_type, ir.PointerType):
261+
field_via_pointer = (symbol, struct_info, field_name)
262+
else:
263+
slot = struct_info.gep(builder, symbol.var, field_name)
255264
elif isinstance(stmt.target, ast.Attribute):
256265
raise SyntaxError(
257266
f"augmented assignment to a nested struct field "
@@ -269,7 +278,19 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab):
269278
)
270279

271280
# Python evaluates the target's current value before the right-hand side.
272-
current = builder.load(slot)
281+
if field_via_pointer is not None:
282+
symbol, struct_info, field_name = field_via_pointer
283+
current, _ = access_struct_field(
284+
builder,
285+
symbol.var,
286+
symbol.ir_type,
287+
symbol.metadata,
288+
field_name,
289+
compilation_context.structs_sym_tab,
290+
func,
291+
)
292+
else:
293+
current = builder.load(slot)
273294
rhs, rhs_ty = get_typed_operand(
274295
func, compilation_context, stmt.value, builder, local_sym_tab
275296
)
@@ -287,7 +308,16 @@ def handle_aug_assign(func, compilation_context, builder, stmt, local_sym_tab):
287308
apply_binop(builder, stmt.op, current, rhs, signedness(result_ty)),
288309
result_ty,
289310
)
290-
builder.store(convert(builder, result, result_ty, slot_type), slot)
311+
result = convert(builder, result, result_ty, slot_type)
312+
if field_via_pointer is not None:
313+
with_struct_field_ptr(
314+
func,
315+
builder,
316+
*field_via_pointer,
317+
lambda builder, field_ptr: builder.store(result, field_ptr),
318+
)
319+
else:
320+
builder.store(result, slot)
291321

292322

293323
def handle_cond(func, compilation_context, builder, cond, local_sym_tab):
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Assignment to a field of a struct map value writes through the lookup
2+
# pointer into the map, null-checked: `stats.count += 1` updates the entry in
3+
# place, with no update() call. Also a sub-64-bit field and a plain store.
4+
from pythonbpf import bpf, map, section, bpfglobal, compile, struct
5+
from pythonbpf.maps import HashMap
6+
from ctypes import c_void_p, c_int64, c_uint64, c_uint32
7+
8+
9+
@bpf
10+
@struct
11+
class stats_t:
12+
count: c_uint64
13+
flags: c_uint32
14+
15+
16+
@bpf
17+
@map
18+
def stats() -> HashMap:
19+
return HashMap(key=c_uint32, value=stats_t, max_entries=16)
20+
21+
22+
@bpf
23+
@section("tracepoint/raw_syscalls/sys_enter")
24+
def prog(ctx: c_void_p) -> c_int64:
25+
k = c_uint32(0)
26+
s = stats.lookup(k)
27+
if s:
28+
s.count += 1
29+
s.flags = 3
30+
s.flags <<= 1
31+
return s.count
32+
return c_int64(0)
33+
34+
35+
@bpf
36+
@bpfglobal
37+
def LICENSE() -> str:
38+
return "GPL"
39+
40+
41+
compile()

‎tests/test_signedness_ir.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@
8181
[r"trunc i64 .* to i32", r"zext i32 .* to i64"],
8282
[r"sext i32 .* to i64"],
8383
),
84+
# A field of a struct map value is written through the lookup pointer,
85+
# behind a null check, never by indexing into the local's own slot.
86+
"assign/struct_map_value_field.py": (
87+
[r"field_count_not_null", r"field_flags_not_null", r"store i32 .*, i32\* %"],
88+
[r"getelementptr inbounds i64\*, i64\*\*"],
89+
),
8490
# `return p` on a map lookup dereferences through a null check and returns
8591
# the i64, never the pointer.
8692
"return/map_value.py": (

0 commit comments

Comments
 (0)