From ce222afa650a136bfcaa1ae851058678f4149cfa Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 04:43:49 +0530 Subject: [PATCH 1/7] Core: Make vmlinux field lookups agree on one source of truth has_field() consulted hasattr() on the ctypes class while get_field_type() indexed the parsed members dict. For any member ctypes exposes but the vmlinux parser never registered, has_field() said True and get_field_type() then died with a raw KeyError. Both now consult the parsed members dict, and a lookup that misses raises a ValueError that distinguishes "no such field" from "field exists in vmlinux.py but the parser does not support it yet". get_field_index() additionally derives the index from the ctypes _fields_ list (the C declaration order) instead of the insertion order of the parsed members dict, so it no longer depends on the two happening to coincide. No change to generated IR: every vmlinux/xdp test program compiles to a byte-identical .ll. Co-Authored-By: Claude Opus 5 (1M context) --- .../vmlinux_parser/vmlinux_exports_handler.py | 77 ++++++++++++------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py index 3ab07cbd..f46c8efa 100644 --- a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py +++ b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py @@ -370,37 +370,62 @@ def load_ctx_field(builder, ctx_arg, offset_global, field_data, struct_name=None return value + def _parsed_members(self, vmlinux_struct_name): + """ + Return the dict of fields the vmlinux parser actually produced for a struct. + + This is the single source of truth for "does the compiler know about this + field", as opposed to `hasattr(python_type, ...)` which merely reports what + ctypes exposes on the class (including members the parser never registered). + """ + if not self.is_vmlinux_struct(vmlinux_struct_name): + raise ValueError(f"{vmlinux_struct_name} is not a vmlinux struct") + return self.vmlinux_symtab[vmlinux_struct_name].members + + def _unsupported_field_error(self, vmlinux_struct_name, field_name): + """Build an actionable error for a field lookup that failed.""" + python_type = self.vmlinux_symtab[vmlinux_struct_name].python_type + if hasattr(python_type, field_name): + return ValueError( + f"Field {field_name} of vmlinux struct {vmlinux_struct_name} exists in " + "vmlinux.py but was not registered by the vmlinux parser, so it cannot " + "be accessed yet (unsupported field kind)" + ) + return ValueError( + f"Field {field_name} not found in vmlinux struct {vmlinux_struct_name}" + ) + def has_field(self, struct_name, field_name): - """Check if a vmlinux struct has a specific field""" + """Check if a vmlinux struct has a specific field the parser understands""" if self.is_vmlinux_struct(struct_name): - python_type = self.vmlinux_symtab[struct_name].python_type - return hasattr(python_type, field_name) + return field_name in self.vmlinux_symtab[struct_name].members return False def get_field_type(self, vmlinux_struct_name, field_name): """Get the type of a field in a vmlinux struct""" - if self.is_vmlinux_struct(vmlinux_struct_name): - python_type = self.vmlinux_symtab[vmlinux_struct_name].python_type - if hasattr(python_type, field_name): - return self.vmlinux_symtab[vmlinux_struct_name].members[field_name] - else: - raise ValueError( - f"Field {field_name} not found in vmlinux struct {vmlinux_struct_name}" - ) - else: - raise ValueError(f"{vmlinux_struct_name} is not a vmlinux struct") + members = self._parsed_members(vmlinux_struct_name) + if field_name in members: + return members[field_name] + raise self._unsupported_field_error(vmlinux_struct_name, field_name) def get_field_index(self, vmlinux_struct_name, field_name): - """Get the type of a field in a vmlinux struct""" - if self.is_vmlinux_struct(vmlinux_struct_name): - python_type = self.vmlinux_symtab[vmlinux_struct_name].python_type - if hasattr(python_type, field_name): - return list( - self.vmlinux_symtab[vmlinux_struct_name].members.keys() - ).index(field_name) - else: - raise ValueError( - f"Field {field_name} not found in vmlinux struct {vmlinux_struct_name}" - ) - else: - raise ValueError(f"{vmlinux_struct_name} is not a vmlinux struct") + """ + Get the declaration index of a field in a vmlinux struct. + + The index is derived from the ctypes `_fields_` list, i.e. from the C + declaration order, rather than from the insertion order of the parsed + members dict. + """ + members = self._parsed_members(vmlinux_struct_name) + if field_name not in members: + raise self._unsupported_field_error(vmlinux_struct_name, field_name) + + python_type = self.vmlinux_symtab[vmlinux_struct_name].python_type + declared_fields = getattr(python_type, "_fields_", None) + if declared_fields is not None: + for index, declared in enumerate(declared_fields): + if declared[0] == field_name: + return index + # No `_fields_` (or the field is not declared at the top level, e.g. it was + # flattened out of an anonymous member): fall back to the parsed ordering. + return list(members.keys()).index(field_name) From aa51d123de116012795b1c959db0fd38f67427a5 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 04:48:06 +0530 Subject: [PATCH 2/7] Core: Add an optional CO-RE access path to Field Carries the chain of member indices from the enclosing struct down to a field, so a field that does not sit at the top level of the struct can still describe where it lives. Defaults to None, which keeps meaning "this field is a plain top-level member". Pure data, nothing reads it yet, generated IR is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- pythonbpf/vmlinux_parser/dependency_node.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pythonbpf/vmlinux_parser/dependency_node.py b/pythonbpf/vmlinux_parser/dependency_node.py index dd413ad4..d870e4bd 100644 --- a/pythonbpf/vmlinux_parser/dependency_node.py +++ b/pythonbpf/vmlinux_parser/dependency_node.py @@ -17,6 +17,13 @@ class Field: offset: int value: Any = None ready: bool = False + # CO-RE access path from the enclosing struct down to this field, as a tuple + # of member indices. `None` means the field is a plain top-level member and + # its access path is just its own index in the struct. It is only populated + # for fields that live inside an anonymous member and were flattened into + # the parent, e.g. `struct pt_regs.cs` is member 0 of anonymous member 17, + # so its access_path is (17, 0). + access_path: Optional[tuple[int, ...]] = None def __hash__(self): """ @@ -154,6 +161,7 @@ def add_field( bitfield_size: Optional[int] = None, ready: bool = False, offset: int = 0, + access_path: Optional[tuple[int, ...]] = None, ) -> None: """Add a field to the node with an optional initial value and readiness state.""" if self.depends_on is None: @@ -168,6 +176,7 @@ def add_field( ctype_complex_type=ctype_complex_type, bitfield_size=bitfield_size, offset=offset, + access_path=access_path, ) # Invalidate readiness cache self._ready_cache = None From f2ec70759e448b835d0f6e9688cadd489a40a921 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 04:54:17 +0530 Subject: [PATCH 3/7] Core: Flatten anonymous struct/union members during vmlinux parsing On this kernel `struct pt_regs` wraps `cs` and `ss` in anonymous unions. ctypes lifts those names onto the parent class, exactly as C does, but the parser only walked the top-level `_fields_`, so `cs` and `ss` were unknown to the compiler and `ctx.cs` blew up. Register the members of an anonymous member as fields of the parent, one level deep, taking the ABSOLUTE offset ctypes reports on the parent (no arithmetic) and recording the access path (anonymous member index, member index). The anonymous member itself stays registered as before. Deliberately conservative: only scalar ctypes members are flattened. Nested vmlinux structs (`fred_cs`, `fred_ss`) and bitfields are skipped with a warning rather than half-registered, and a flattened member never overwrites an existing top-level field of the same name. Overlapping members are kept as distinct fields, since `cs` and `csx` both legitimately live at offset 136. Flattened fields are not yet emitted: IR generation and DWARF generation skip them, and critically they do not consume a top-level field index, so existing CO-RE names are untouched. Every vmlinux/xdp test program still compiles to a byte-identical .ll. Entirely inert for any struct without `_anonymous_`. Co-Authored-By: Claude Opus 5 (1M context) --- pythonbpf/vmlinux_parser/class_handler.py | 118 ++++++++++++++++++ .../vmlinux_parser/ir_gen/debug_info_gen.py | 8 +- .../vmlinux_parser/ir_gen/ir_generation.py | 8 ++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/pythonbpf/vmlinux_parser/class_handler.py b/pythonbpf/vmlinux_parser/class_handler.py index 0c66ba21..c41a0f9e 100644 --- a/pythonbpf/vmlinux_parser/class_handler.py +++ b/pythonbpf/vmlinux_parser/class_handler.py @@ -43,6 +43,119 @@ def unwrap_pointer_type(type_obj: Any) -> Any: return current_type +def _is_flattenable_scalar(member_type: Any) -> bool: + """ + Report whether an anonymous member's member can be lifted into the parent. + + Only plain ctypes scalars qualify. Pointers, arrays, function pointers and + nested vmlinux structs are deliberately excluded: they need the containing + type / array length bookkeeping that the top-level field walk does, and + half-registering them would produce silently wrong relocations. + """ + if getattr(member_type, "__module__", None) != ctypes.__name__: + return False + if not isinstance(member_type, type): + return False + if issubclass(member_type, (ctypes._Pointer, ctypes.Array)): + return False + if hasattr(member_type, "_restype_") and hasattr(member_type, "_argtypes_"): + return False + return True + + +def flatten_anonymous_members(class_obj, dep_node) -> None: + """ + Register the members of a struct's anonymous members as fields of the struct. + + C lets an anonymous struct/union member's members be named directly on the + parent (`regs->cs` where `cs` lives inside an anonymous union), and ctypes + mirrors that by exposing them on the parent class. The top-level `_fields_` + walk only sees the anonymous member itself (`_0`), so those names would + otherwise be unknown to the compiler. + + Each flattened member keeps the anonymous member registered as before, is + given the ABSOLUTE offset ctypes reports for it on the parent, and records + its CO-RE access path as (index of the anonymous member in the parent, + index of the member within the anonymous member). + + Members that overlap are expected and correct: in `struct pt_regs`, `cs` + and `csx` are both at offset 136. Each keeps its own field. + + This is a no-op for any struct without `_anonymous_`. + """ + anonymous_names = getattr(class_obj, "_anonymous_", None) + if not anonymous_names: + return + declared_fields = getattr(class_obj, "_fields_", None) + if not declared_fields: + return + + anonymous_names = set(anonymous_names) + for parent_index, declared_field in enumerate(declared_fields): + parent_name = declared_field[0] + if parent_name not in anonymous_names: + continue + parent_type = declared_field[1] + member_fields = getattr(parent_type, "_fields_", None) + if not member_fields: + logger.warning( + f"Anonymous member {dep_node.name}.{parent_name} has no _fields_, " + "its members will not be accessible" + ) + continue + + for member_index, member in enumerate(member_fields): + member_name = member[0] + member_type = member[1] + member_bitfield_size = member[2] if len(member) == 3 else None + + if member_name in dep_node.fields: + # Never let a flattened member shadow a field we already have. + logger.warning( + f"Anonymous member {dep_node.name}.{parent_name}.{member_name} " + f"collides with an existing field of {dep_node.name}, keeping " + "the existing field" + ) + continue + if member_bitfield_size is not None: + logger.warning( + f"Skipping bitfield {dep_node.name}.{parent_name}.{member_name}: " + "bitfields inside anonymous members are not supported yet" + ) + continue + if not _is_flattenable_scalar(member_type): + logger.warning( + f"Skipping {dep_node.name}.{parent_name}.{member_name} of type " + f"{member_type}: only scalar ctypes members of anonymous members " + "are supported" + ) + continue + if not hasattr(class_obj, member_name): + # ctypes did not lift the name onto the parent, so we have no + # authoritative absolute offset for it. + logger.warning( + f"Skipping {dep_node.name}.{parent_name}.{member_name}: ctypes " + "does not expose it on the parent struct" + ) + continue + + dep_node.add_field( + member_name, + member_type, + ready=False, + access_path=(parent_index, member_index), + ) + dep_node.set_field_bitfield_size(member_name, None) + # set_field_ready reads the offset straight off the ctypes struct, + # which reports the ABSOLUTE offset for a lifted anonymous member. + dep_node.set_field_ready(member_name, is_ready=True) + logger.debug( + f"Flattened {dep_node.name}.{parent_name}.{member_name} at offset " + f"{dep_node.fields[member_name].offset} with access path " + f"{(parent_index, member_index)}" + ) + + def process_vmlinux_class( node, llvm_module, @@ -312,6 +425,11 @@ def process_vmlinux_post_ast( raise ValueError( f"{elem_name} with type {elem_type} from module {module_name} not supported in recursive resolver" ) + + # Anonymous struct/union members expose their members on the parent + # in C and in ctypes, but they are invisible to the top-level + # _fields_ walk above. Register them too. No-op without _anonymous_. + flatten_anonymous_members(class_obj, new_dep_node) elif module_name == ctypes.__name__ or module_name is None: # Handle ctypes types - these don't need processing, just return logger.debug(f"Skipping ctypes type {current_symbol_name}") diff --git a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py index c4f5642c..bc4c98b1 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py +++ b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py @@ -43,7 +43,13 @@ def debug_info_generation( # Process all fields and create members for the struct members = [] - sorted_fields = sorted(struct.fields.items(), key=lambda item: item[1].offset) + # Members lifted out of an anonymous member are not members of this struct in + # DWARF terms; they belong to the anonymous member's own type. Emitting them + # here would both duplicate offsets and shift every later member's index. + sorted_fields = sorted( + (item for item in struct.fields.items() if item[1].access_path is None), + key=lambda item: item[1].offset, + ) for field_name, field in sorted_fields: try: diff --git a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py index 6a7088cd..4ddc5685 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py +++ b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py @@ -85,6 +85,10 @@ def struct_processor(self, struct, processing_stack=None): # Create a members dictionary for AssignmentInfo members_dict = {} for field_name, field in struct.fields.items(): + if field.access_path is not None: + # Member lifted out of an anonymous member. gen_ir does + # not emit a global for it yet, so it stays unexported. + continue # Get the generated field name from our dictionary, or use field_name if not found if ( struct.name in self.generated_field_names @@ -132,6 +136,10 @@ def gen_ir(self, struct, generated_debug_info): self.generated_field_names[struct.name] = {} for field_name, field in struct.fields.items(): + if field.access_path is not None: + # Member lifted out of an anonymous member. It is not a + # top-level field, so it must not consume a field index. + continue # does not take arrays and similar types into consideration yet. if callable(field.ctype_complex_type): # Function pointer case - generate a simple field accessor From 4ff3ce26c61f75e157874727e0006e7646d00817 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 04:59:08 +0530 Subject: [PATCH 4/7] Core: Emit nested CO-RE access strings for anonymous members A field lifted out of an anonymous member needs an access string that walks into that member, so `struct pt_regs.cs` is `llvm.pt_regs:0:136$0:17:0` (member 0 of anonymous member 17) rather than the flat `$0:` form. This is byte-for-byte what clang emits for the equivalent C, verified against `__builtin_preserve_access_index(ctx->cs)` on a struct with the same shape, and the indices and offsets agree with `bpftool btf dump file /sys/kernel/btf/vmlinux`. Only fields carrying an access path take the new path; every other field keeps the existing flat and indexed/array forms unchanged. The only .ll diff across all vmlinux/xdp test programs is four brand new globals for cs, csx, ss and ssx. No pre-existing relocation string changes. Co-Authored-By: Claude Opus 5 (1M context) --- .../vmlinux_parser/ir_gen/ir_generation.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py index 4ddc5685..5e6b1ec2 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py +++ b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py @@ -85,10 +85,6 @@ def struct_processor(self, struct, processing_stack=None): # Create a members dictionary for AssignmentInfo members_dict = {} for field_name, field in struct.fields.items(): - if field.access_path is not None: - # Member lifted out of an anonymous member. gen_ir does - # not emit a global for it yet, so it stays unexported. - continue # Get the generated field name from our dictionary, or use field_name if not found if ( struct.name in self.generated_field_names @@ -137,8 +133,18 @@ def gen_ir(self, struct, generated_debug_info): for field_name, field in struct.fields.items(): if field.access_path is not None: - # Member lifted out of an anonymous member. It is not a - # top-level field, so it must not consume a field index. + # Member lifted out of an anonymous member. Its access string + # comes from the recorded path, and it must not consume a + # top-level field index. + field_co_re_name, returned = self._struct_name_generator( + struct, field, field.access_path[0] + ) + globvar = ir.GlobalVariable( + self.llvm_module, ir.IntType(64), name=field_co_re_name + ) + globvar.linkage = "external" + globvar.set_metadata("llvm.preserve.access.index", debug_info) + self.generated_field_names[struct.name][field_name] = globvar continue # does not take arrays and similar types into consideration yet. if callable(field.ctype_complex_type): @@ -271,12 +277,19 @@ def _struct_name_generator( ) return name, True elif struct.name.startswith("struct_"): + if field.access_path is not None: + # Field lifted out of an anonymous member: the access string has + # to walk into the anonymous member, e.g. `0:17:0` for + # `struct pt_regs.cs`, which is member 0 of anonymous member 17. + access_string = ":".join(str(index) for index in field.access_path) + else: + access_string = str(field_index) name = ( "llvm." + struct.name.removeprefix("struct_") + f":0:{field.offset}" + "$" - + f"0:{field_index}" + + f"0:{access_string}" ) return name, True else: From 995986215435c4201cdf15af90cf449a3fd14150 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 05:09:45 +0530 Subject: [PATCH 5/7] Core: Widen any sub-64-bit context field instead of special-casing xdp_md Reading a context field narrower than a register was hardcoded to struct_xdp_md's i32 fields in three places that had to agree with each other: the destination alloca in allocation_pass, the zext in load_ctx_field, and the store in assign_pass. struct pt_regs' cs and ss are 2 bytes, so a second special case would have been needed. Replace all three with the general rule: a context field is loaded at its natural width and zero-extended to i64, so its destination is i64. allocation_pass gains the same context discriminator load_ctx_field uses (a context argument has no alloca of its own), so non-context field reads, which go through load_struct_field and bpf_probe_read_kernel, keep their natural width and are untouched. struct_xdp_md's generated IR is byte-identical, as is every other vmlinux/xdp test program's; the only diff in the whole corpus remains the four new globals from the previous commit. Co-Authored-By: Claude Opus 5 (1M context) --- pythonbpf/allocation_pass.py | 18 ++++++++++-------- pythonbpf/assign_pass.py | 16 +++++++++------- .../vmlinux_parser/vmlinux_exports_handler.py | 16 +++++++++------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 9aaaf19e..02880719 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -343,6 +343,9 @@ def _allocate_for_attribute( if VmlinuxHandlerRegistry.is_vmlinux_struct(struct_type.__name__): # Handle vmlinux struct field access vmlinux_struct_name = struct_type.__name__ + # Same discriminator handle_vmlinux_struct_field uses: a context + # argument has no alloca of its own. + is_context_field = local_sym_tab[struct_var].var is None if not VmlinuxHandlerRegistry.has_field(vmlinux_struct_name, field_name): logger.error( f"Field '{field_name}' not found in vmlinux struct '{vmlinux_struct_name}'" @@ -364,16 +367,15 @@ def _allocate_for_attribute( field_size_bits = field_size_bytes * 8 if field_size_bits in [8, 16, 32, 64]: - # Special case: struct_xdp_md i32 fields should allocate as i64 - # because load_ctx_field will zero-extend them to i64 - if ( - vmlinux_struct_name == "struct_xdp_md" - and field_size_bits == 32 - ): + # Sub-register-width context fields allocate as i64, + # because load_ctx_field zero-extends them to i64. + # Non-context fields go through load_struct_field, which + # keeps them at their natural width. + if is_context_field and field_size_bits < 64: actual_ir_type = ir.IntType(64) logger.info( - f"Allocating {var_name} as i64 for i32 field from struct_xdp_md.{field_name} " - "(will be zero-extended during load)" + f"Allocating {var_name} as i64 for i{field_size_bits} field from " + f"{vmlinux_struct_name}.{field_name} (will be zero-extended during load)" ) else: actual_ir_type = ir.IntType(field_size_bits) diff --git a/pythonbpf/assign_pass.py b/pythonbpf/assign_pass.py index e39270d8..91133dbe 100644 --- a/pythonbpf/assign_pass.py +++ b/pythonbpf/assign_pass.py @@ -185,22 +185,24 @@ def handle_variable_assignment( return False if isinstance(val_type, Field): logger.info("Handling assignment to struct field") - # Special handling for struct_xdp_md i32 fields that are zero-extended to i64 - # The load_ctx_field already extended them, so val is i64 but val_type.type shows c_uint + field_ir_type = ctypes_to_ir(val_type.type.__name__) + # Sub-register-width context fields are zero-extended to i64 by + # load_ctx_field, so val is already i64 even though the field type + # says otherwise (c_uint for xdp_md, c_ushort for pt_regs.cs/ss). if ( - hasattr(val_type, "type") - and val_type.type.__name__ == "c_uint" + isinstance(field_ir_type, ir.IntType) + and field_ir_type.width < 64 and isinstance(var_type, ir.IntType) and var_type.width == 64 ): - # This is the struct_xdp_md case - value is already i64 builder.store(val, var_ptr) logger.info( - f"Assigned zero-extended struct_xdp_md i32 field to {var_name} (i64)" + f"Assigned zero-extended i{field_ir_type.width} context field " + f"to {var_name} (i64)" ) return True # TODO: handling only ctype struct fields for now. Handle other stuff too later. - elif var_type == ctypes_to_ir(val_type.type.__name__): + elif var_type == field_ir_type: builder.store(val, var_ptr) logger.info(f"Assigned ctype struct field to {var_name}") return True diff --git a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py index f46c8efa..df1b9d73 100644 --- a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py +++ b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py @@ -315,7 +315,7 @@ def load_ctx_field(builder, ctx_arg, offset_global, field_data, struct_name=None # Determine the appropriate IR type based on field information int_width = 64 # Default to 64-bit - needs_zext = False # Track if we need zero-extension for xdp_md + needs_zext = False # Track if we need zero-extension to a full register if field_data is not None: # Try to determine the size from field metadata @@ -328,12 +328,14 @@ def load_ctx_field(builder, ctx_arg, offset_global, field_data, struct_name=None int_width = field_size_bits logger.info(f"Determined field size: {int_width} bits") - # Special handling for struct_xdp_md i32 fields - # Load as i32 but extend to i64 before storing - if struct_name == "struct_xdp_md" and int_width == 32: + # Context fields are loaded at their natural width and + # widened to a full 64-bit register, so that everything + # downstream sees one uniform integer type. + if int_width < 64: needs_zext = True logger.info( - "struct_xdp_md i32 field detected, will zero-extend to i64" + f"i{int_width} field {struct_name} detected, " + "will zero-extend to i64" ) else: logger.warning( @@ -363,10 +365,10 @@ def load_ctx_field(builder, ctx_arg, offset_global, field_data, struct_name=None # Load and return the value value = builder.load(typed_ptr) - # Zero-extend i32 to i64 for struct_xdp_md fields + # Widen sub-register-width context fields to i64 if needs_zext: value = builder.zext(value, ir.IntType(64)) - logger.info("Zero-extended i32 value to i64 for struct_xdp_md field") + logger.info(f"Zero-extended i{int_width} context field value to i64") return value From 013907758fdbe9c8819f21db048666f54a2ca9be Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 05:18:16 +0530 Subject: [PATCH 6/7] Core: Describe anonymous members in DWARF so their relocations can resolve The previous commits emit `llvm.pt_regs:0:136$0:17:0` for `ctx.cs`, which llc happily turns into a CO-RE relocation, but the local BTF it is resolved against described member 17 as a NAMED, EMPTY composite: '_0' type_id=4 bits_offset=1088 [4] STRUCT '(anon)' size=8 vlen=0 libbpf walks a CO-RE access string by member index into the local type and then matches by field NAME in the target type, so both halves of that break it: index 0 into a vlen=0 composite has nothing to land on, and a local member called `_0` cannot be paired with the kernel's anonymous one. The object would build cleanly and then fail to load. Emit anonymous members the way a C compiler does: unnamed, with a real DW_TAG_union_type (or structure, for an anonymous struct) carrying their members in declaration order. The emitted BTF now reads '(anon)' type_id=4 bits_offset=1088 [4] UNION '(anon)' size=8 vlen=3 'cs' ... 'csx' ... 'fred_cs' ... which is the same shape clang produces for the equivalent C and the same shape `bpftool btf dump file /sys/kernel/btf/vmlinux` reports. Gated on the struct declaring `_anonymous_`, and it bails back to the old opaque member for anything it cannot describe faithfully. Across every vmlinux/xdp test program the set of emitted globals, every relocation string and every instruction are unchanged; the only diffs are the new DWARF nodes and the DWARF node renumbering they cause, in the two pt_regs programs. This is the one part of the fix that cannot be verified without loading a program, so it is kept as its own commit. Co-Authored-By: Claude Opus 5 (1M context) --- pythonbpf/debuginfo/debug_info_generator.py | 15 +++ .../vmlinux_parser/ir_gen/debug_info_gen.py | 100 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/pythonbpf/debuginfo/debug_info_generator.py b/pythonbpf/debuginfo/debug_info_generator.py index 4b96d22c..8dc31ee6 100644 --- a/pythonbpf/debuginfo/debug_info_generator.py +++ b/pythonbpf/debuginfo/debug_info_generator.py @@ -151,6 +151,21 @@ def create_struct_type( is_distinct=is_distinct, ) + def create_union_type( + self, members: List[Any], size: int, is_distinct: bool + ) -> Any: + """Create an unnamed union type with the given members and size""" + return self.module.add_debug_info( + "DICompositeType", + { + "tag": dc.DW_TAG_union_type, + "file": self.module._file_metadata, + "size": size, + "elements": members, + }, + is_distinct=is_distinct, + ) + def create_struct_type_with_name( self, name: str, members: List[Any], size: int, is_distinct: bool ) -> Any: diff --git a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py index bc4c98b1..41afa1d4 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py +++ b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py @@ -51,8 +51,25 @@ def debug_info_generation( key=lambda item: item[1].offset, ) + anonymous_names = set(getattr(struct.ctype_struct, "_anonymous_", None) or ()) + for field_name, field in sorted_fields: try: + if field_name in anonymous_names: + # An anonymous member has to reach BTF unnamed and with its own + # members, or a `$...::` access string cannot be resolved + # against it at load time. + anonymous_member = _anonymous_member_debug_info( + field, generator, generated_debug_info + ) + if anonymous_member is not None: + members.append(anonymous_member) + continue + logger.warning( + f"Could not describe anonymous member {struct.name}.{field_name}, " + "falling back to an opaque member" + ) + # Get appropriate debug type for this field field_type = _get_field_debug_type( field_name, field, generator, struct, generated_debug_info @@ -81,6 +98,89 @@ def debug_info_generation( return struct_type +def _lookup_generated_debug_info( + type_name: str, generated_debug_info: List[Tuple[DependencyNode, Any]] +): + """Find already generated debug info for a vmlinux type by name.""" + for existing_struct, debug_info in generated_debug_info: + if existing_struct.name == type_name: + return debug_info, existing_struct.__sizeof__() * 8 + return None + + +def _anonymous_member_debug_info( + field, + generator: DebugInfoGenerator, + generated_debug_info: List[Tuple[DependencyNode, Any]], +): + """ + Describe an anonymous struct/union member the way a C compiler would. + + The member itself is emitted WITHOUT a name, so it lands in BTF as `(anon)`, + and its type is a real composite carrying its own members in declaration + order. Both matter: libbpf walks a CO-RE access string by member index into + the local type and then matches by name in the target type, so an opaque or + named stand-in makes any access through the anonymous member unresolvable. + + Returns None if the member cannot be described faithfully, in which case the + caller keeps the previous behaviour. + """ + anonymous_type = field.type + declared = getattr(anonymous_type, "_fields_", None) + if not declared or not isinstance(anonymous_type, type): + return None + + inner_members = [] + for declared_member in declared: + if len(declared_member) != 2: + # Bitfield members need BTF bitfield encoding we do not emit yet. + return None + member_name, member_type = declared_member + try: + member_offset_bits = getattr(anonymous_type, member_name).offset * 8 + except AttributeError: + return None + + if getattr(member_type, "__module__", None) == "vmlinux": + member_type_name = getattr(member_type, "__name__", None) + member_debug_type = ( + _lookup_generated_debug_info(member_type_name, generated_debug_info) + if member_type_name + else None + ) + if member_debug_type is None: + # Keep the member so the indices stay right, but leave its type + # opaque, exactly as nested structs are handled elsewhere. + member_debug_type = ( + generator.create_struct_type([], 0, is_distinct=True), + 0, + ) + else: + member_debug_type = _get_basic_debug_type(member_type, generator) + if not isinstance(member_debug_type, tuple) or len(member_debug_type) != 2: + return None + + inner_members.append( + generator.create_struct_member_vmlinux( + member_name, member_debug_type, member_offset_bits + ) + ) + + size_bits = ctypes.sizeof(anonymous_type) * 8 + if issubclass(anonymous_type, ctypes.Union): + composite = generator.create_union_type( + inner_members, size_bits, is_distinct=True + ) + else: + composite = generator.create_struct_type( + inner_members, size_bits, is_distinct=True + ) + + return generator.create_struct_member_vmlinux( + "", (composite, size_bits), field.offset * 8 + ) + + def _get_field_debug_type( field_name: str, field, From 1c349c87b4141c03e9f823a9d01fdba6de6d68f0 Mon Sep 17 00:00:00 2001 From: Pragyansh Chaturvedi Date: Fri, 7 Aug 2026 05:30:13 +0530 Subject: [PATCH 7/7] Core: Return the function-pointer check directly in _is_flattenable_scalar Style only, no behaviour change and no IR change. Keeps `ruff check --select SIM` clean on the files this branch touches. Co-Authored-By: Claude Opus 5 (1M context) --- pythonbpf/vmlinux_parser/class_handler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pythonbpf/vmlinux_parser/class_handler.py b/pythonbpf/vmlinux_parser/class_handler.py index c41a0f9e..6e9febde 100644 --- a/pythonbpf/vmlinux_parser/class_handler.py +++ b/pythonbpf/vmlinux_parser/class_handler.py @@ -58,9 +58,10 @@ def _is_flattenable_scalar(member_type: Any) -> bool: return False if issubclass(member_type, (ctypes._Pointer, ctypes.Array)): return False - if hasattr(member_type, "_restype_") and hasattr(member_type, "_argtypes_"): - return False - return True + is_function_pointer = hasattr(member_type, "_restype_") and hasattr( + member_type, "_argtypes_" + ) + return not is_function_pointer def flatten_anonymous_members(class_obj, dep_node) -> None: