diff --git a/pythonbpf/allocation_pass.py b/pythonbpf/allocation_pass.py index 293839da..466800b7 100644 --- a/pythonbpf/allocation_pass.py +++ b/pythonbpf/allocation_pass.py @@ -349,6 +349,9 @@ def _allocate_for_attribute( vmlinux_struct_name ): # Handle vmlinux struct field access + # 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}'" @@ -370,16 +373,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/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/class_handler.py b/pythonbpf/vmlinux_parser/class_handler.py index 0c66ba21..6e9febde 100644 --- a/pythonbpf/vmlinux_parser/class_handler.py +++ b/pythonbpf/vmlinux_parser/class_handler.py @@ -43,6 +43,120 @@ 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 + 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: + """ + 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 +426,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/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 diff --git a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py index c4f5642c..41afa1d4 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py +++ b/pythonbpf/vmlinux_parser/ir_gen/debug_info_gen.py @@ -43,10 +43,33 @@ 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, + ) + + 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 @@ -75,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, diff --git a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py index 6a7088cd..5e6b1ec2 100644 --- a/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py +++ b/pythonbpf/vmlinux_parser/ir_gen/ir_generation.py @@ -132,6 +132,20 @@ 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. 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): # Function pointer case - generate a simple field accessor @@ -263,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: diff --git a/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py b/pythonbpf/vmlinux_parser/vmlinux_exports_handler.py index 3ab07cbd..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,44 +365,69 @@ 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 + 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)