Skip to content

Feat/integer signedness - #101

Merged
r41k0u merged 26 commits into
masterfrom
feat/integer-signedness
Sep 24, 2026
Merged

r41k0u merged 26 commits into
masterfrom
feat/integer-signedness

Conversation

@r41k0u

@r41k0u r41k0u commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fix the inconsistent zext-sext integer signedness and expansion/contraction.

r41k0u and others added 12 commits September 17, 2026 20:34
Eight cases, each reading operands from .bss globals (so clang cannot fold)
and writing its result to a .bss global (so the runtime proof is a bpftool
map dump). The IR clang emits is the specification for the signedness work:

    s64 = u32           zext i32 -> i64        (widening is source-driven)
    u64 = s32           sext i32 -> i64
    u32 / s32           udiv i32               (usual arithmetic conversions)
    u64 > s64           icmp ugt i64
    u32 * u32 -> u64    mul i32, then zext     (wraps at 32 before widening)
    u32 >> 4            lshr i32
    s32 >> 4            ashr i32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
LLVM integer types are sign-agnostic; the sign lives in the operations. The
frontend therefore has to carry it, and until now it did not: c_uint32 became
a bare i32 at declaration and every later choice (sext, sdiv, icmp_signed)
assumed signed.

This commit adds the pieces without changing any emitted IR:

- type_deducer.IntTy, an ir.IntType subclass carrying 'signed'. It renders,
  compares and hashes as the plain type, so the 40 isinstance checks and every
  == ir.IntType(64) comparison keep working; signedness() reads it and treats a
  plain IntType as signed (today's behaviour). Constructed outside IntType's
  per-width instance cache, which is shared with subclasses and would have
  merged the two signs of a width into one object.
- ctypes_to_ir now returns IntTy, which types five declaration kinds at once:
  locals from ctypes constructors, struct fields, globals, context
  annotations, and map key/value types. is_signed_ctype replaces the private
  copy in globals_pass.
- operators.usual_arithmetic_conversions: C's rule for the type a binary
  operation is performed in.
- type_normalization.convert (source-driven zext/sext when widening, trunc
  when narrowing) and canonicalise (bring a value to the working width holding
  exactly a given type's value).

Nothing consults the sign yet. The corpus of 67 compilable test programs
produces byte-identical .ll before and after.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
The same widen-to-i64 / truncate-to-slot logic was written out by hand in
six places: local assignment, struct-field assignment, augmented assignment,
helper-argument temporaries, binary-operation operands, and the comparison
normaliser. Each now calls type_normalization.convert, which is the single
point where the sign decides between zext and sext.

Across the 67-program corpus this changes exactly one emitted instruction, and
it is a correction rather than a regression: augmented assignment on a
c_uint32 struct field now widens the field's current value with zext instead
of sext, because that slot's descriptor already carries its sign from
ctypes_to_ir. Every other site still sees plain (signed-by-default) descriptors
and is byte-identical. The remaining hand-written widenings -- the printk
formatter, the vmlinux field loader, return handling -- have their own rules
and are migrated together with the sign sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
Sign now originates wherever a type is declared or inferred, and emitted IR
changes only where a value is widened out of a type now known to be unsigned:

- Helper return types are typed per the kernel signature: ktime_get_ns,
  get_current_pid_tgid, get_current_uid_gid, get_current_cgroup_id,
  get_prandom_u32 and get_smp_processor_id are unsigned; the probe_read
  family, perf_event_output and get_stack return long.
- Undeclared locals are 64-bit with the sign of their initializer -- from the
  helper registry for helper results, and for binary operations from a small
  static inference (expr/type_inference.py) applying the same usual arithmetic
  conversions the code generator will.
- Integer literals are typed as C types them (int if it fits, else long long)
  while remaining 64-bit constants; this rank is what makes u32 / -2 promote
  to an unsigned 32-bit division.
- signedness() understands vmlinux Field descriptors, and load_ctx_field
  sign-extends signed sub-64-bit context fields instead of always
  zero-extending them.
- printk widening goes through convert.

Two sites decided whether to emit a conversion from descriptor *equality*.
With literal descriptors now narrower than the constants that carry them, that
skipped required truncations (storing an i64 into an i32 slot). Assignment and
the ctypes cast handler now always run convert, which is a no-op when the
physical widths already agree.

Corpus: the following programs change, every changed line a sext->zext swap on
a value of known unsigned type:
  failing_tests/xdp/xdp_test_1.py.ll
  passing_tests/assign/augassign_struct_field.py.ll
  passing_tests/helpers/smp_processor_id.py.ll

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
…ions

Arithmetic is now typed bottom-up through the expression tree the way C does
it. get_typed_operand evaluates an operand to (value, IntTy); a binary node
computes its type with usual_arithmetic_conversions, converts each operand to
that type per the operand's own sign (source-driven, folding literals), runs
the operation in the i64 working register, and narrows the result back to the
node's type. That last step is what makes a u32 * u32 wrap at 32 bits before
it is widened into a u64, exactly as clang emits (mul i32, zext) -- verified
against tests/c-form/signedness.bpf.c. There is no expression-wide signed or
unsigned mode; each node decides from its operands, and the assignment target
only converts the finished result.

Augmented assignment is typed identically (x op= v is x = x op v), unary minus
negates in the operand's promoted type (2^N - x for unsigned), and return
statements now convert the value to the declared return type instead of
emitting whatever width the expression happened to have.

Corpus: 13 programs change; with SSA numbering normalised every changed line
is a trunc/sext/zext inserted by canonicalisation where an operation's type is
narrower than 64 bits (int-typed literals and c_int32/c_uint32 operands).
No program changes compile status.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
…moted type

The operator table now carries both variants for the sign-sensitive
operations: / and // -> sdiv/udiv, % -> srem/urem, >> -> ashr/lshr. The
ring operations are unchanged since two's complement makes their low bits
sign-blind. apply_binop picks the variant from the promoted type computed
by the usual arithmetic conversions, so uint32(10) / int32(-2) is a udiv
in u32 (0, as C) rather than a signed division (-5).

Comparisons between two integers now go through the same promotion and
pick icmp_signed or icmp_unsigned from the promoted type, so
uint64(10) > int64(-1) is icmp ugt (false, as C). Pointer and struct
comparisons keep the depth-normalising path unchanged.

Corpus: only an XDP test comparing c_uint context fields changes, to an
unsigned predicate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
-2 parses as USub(Constant 2), so u32 / -2 reached the divider as a
mul-by-minus-one instruction rather than the constant clang folds it to.
Fold it at the unary operator; the result is a literal like any other,
with a literal's C rank, so the usual arithmetic conversions still make
u32 / -2 an unsigned division by 0xFFFFFFFE.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
A comparison's descriptor was a plain i1, which reads as signed, so
storing or returning a truth value would sign-extend true to -1. C's
relational operators yield int 0 or 1; give the results an unsigned
descriptor so any widening zero-extends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
Seven programs under passing_tests/signedness, one per case in
tests/c-form/signedness.bpf.c: unsigned and signed widening, mixed
division and remainder, unsigned comparison, u32 * u32 wrapping before
the widening, right shifts, and literal rank. Levels 1 and 2 compile
them like any other case; test_signedness_ir.py asserts the shape of the
IR against what clang emits for the C (zext vs sext, udiv vs sdiv,
icmp ugt vs sgt, lshr vs ashr, the trunc/zext pair around the wrap).

The XDP xfail note no longer claims the guard is a signed compare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
C's rules over the ctypes types: what a declaration means, literal rank,
source-driven widening with the destination typing the stored value, the
usual arithmetic conversions applied per operation, which operators and
predicates the sign selects, and the list of deliberate divergences from
Python (/ and // truncate, % takes the dividend's sign, fixed width,
unsigned types exist).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSDVsZH5NtoASyxB8FCtGU
r41k0u and others added 6 commits September 18, 2026 17:46
A pointer used where a value is expected is dereferenced to it, with a
null check, in helper arguments, printk arguments, binary-operation
operands and comparisons, all through deref_to_depth. return was the
one consumer that never joined that convention, so `return p` on a map
lookup emitted `ret i64*` and left it to llc to reject. handle_return
now goes through get_typed_operand, which is that path, and converts
the result to the declared return type like the augmented-assignment
store does.

convert() no longer passes a non-integer through silently: every
caller either checks both sides or is on an integer-only path, so what
reaches that branch is a type error, and it is raised with both types
named instead of surfacing from llc with an IR line number. Returning
a struct value is the case that now fails at compile time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An enum constant has type int in C whatever the enum's underlying type
is; clang emits `sub nsw i32` + sext for `XDP_PASS - k` and reserves the
enum's u32 for variables of the enum type. The vmlinux handler was
typing constants as i64, which made `XDP_PASS - k` with an unsigned k a
signed 64-bit operation instead of C's u32 one. The constant's C rank
is now decided once, in type_deducer.int_literal_type, shared by
literals and enum constants, and the handler returns it as the
descriptor so both operand typing and local inference read it from
the source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
return/map_value.py is the C shape `if (p) return *p;`; return_struct.py
is the type error convert() now raises; vmlinux/enum_rank.py pins the
u32 trunc/zext pair for `XDP_PASS - k`. test_signedness_ir.py takes
paths under passing_tests so it can assert on all three, and skips the
vmlinux case when vmlinux.py is not importable, as the harness does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… zero

A bool is ir.IntType(1), so convert() handled it as a 1-bit integer: a
bool slot's plain descriptor read as signed and True sign-extended to
-1 on the way out, and an integer narrowed to a bool by truncation, so
2 became false. Both predate this branch. C widens a bool to 0 or 1 and
narrows to it with != 0, and Python's truthiness agrees on the second.

signedness() now answers unsigned for any 1-bit integer whatever it is
wrapped in, convert() narrows to width 1 with icmp ne 0, and a bool
constant's slot carries the unsigned descriptor like the comparison
results already do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 24, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate issues remain in signedness propagation, conversions, binding checks, and literal typing.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 6 Medium severity

Open (6)
What changed in this PR

Adds C-like integer signedness, width conversion, and arithmetic handling across PythonBPF, with tests and documentation.

Changes:

  • Adds signed/unsigned integer descriptors and conversion rules.
  • Updates expression, assignment, helper, vmlinux, global, and return handling.
  • Adds signedness tests, reference C cases, failure coverage, and documentation.
File Summary
tests/​test_signedness_ir.py Validates generated signedness-related IR.
tests/​test_config.toml Registers signedness test expectations.
tests/​passing_tests/​vmlinux/​enum_rank.py Tests enum literal ranking.
tests/​passing_tests/​signedness/​widen_unsigned.py Tests unsigned widening.
tests/​passing_tests/​signedness/​widen_signed.py Tests signed widening.
tests/​passing_tests/​signedness/​unsigned_compare.py Tests signed and unsigned comparisons.
tests/​passing_tests/​signedness/​right_shift.py Tests arithmetic and logical shifts.
tests/​passing_tests/​signedness/​narrow_wrap.py Tests narrow-width wrapping.
tests/​passing_tests/​signedness/​mixed_division.py Tests mixed-sign division and remainder.
tests/​passing_tests/​signedness/​literal_rank.py Tests integer literal ranking.
tests/​passing_tests/​signedness/​bool_int.py Tests boolean/integer conversions.
tests/​passing_tests/​return/​map_value.py Tests map-value returns.
tests/​failing_tests/​return_struct.py Tests invalid struct returns.
tests/​c-form/​signedness.bpf.c Provides the C reference implementation.
pythonbpf/​vmlinux_parser/​vmlinux_exports_handler.py Handles signed context-field widening. Moderate issue (1 vote): signedness is lost when assigning fields to locals.
pythonbpf/​type_deducer.py Adds signed integer descriptors and literal typing. Moderate issue (1 vote): hexadecimal/octal literal rules are not preserved.
pythonbpf/​helper/​printk_formatter.py Converts integer bpf_printk arguments. Moderate issue (4 votes): expression signedness is discarded before formatting.
pythonbpf/​helper/​helper_utils.py Converts helper arguments. Moderate issue (4 votes): source signedness is discarded during conversion.
pythonbpf/​helper/​bpf_helper_handler.py Adds signedness metadata to helper results. Moderate issue (4 votes): direct helper emitters still return signed descriptors.
pythonbpf/​globals_pass.py Reuses signedness metadata for globals.
pythonbpf/​functions/​functions_pass.py Applies typed operations and returns. Moderate issue (4 votes): unsigned augmented operations use signed division, remainder, and right shift.
pythonbpf/​expr/​type_normalization.py Implements integer conversion and normalization.
pythonbpf/​expr/​type_inference.py Infers expression integer types.
pythonbpf/​expr/​operators.py Adds C arithmetic conversions and signed operators.
pythonbpf/​expr/​expr_pass.py Propagates typed operands. Moderate issues: field signedness/width can be lost (1–2 votes), map value signedness can be lost (3 votes), and binding checks are omitted for typed names (1 vote).
pythonbpf/​expr/​__init__.py Exports expression helpers.
pythonbpf/​assign_pass.py Applies typed assignment conversion.
pythonbpf/​allocation_pass.py Infers signed-aware local allocations.
docs/​user-guide/​integers.md Documents integer semantics.
docs/​user-guide/​index.md Links integer documentation.
docs/​index.md Adds integer documentation to navigation.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if isinstance(ty, ir.IntType):
return IntTy(ty.width, signedness(ty))
if val is not None and isinstance(val.type, ir.IntType):
return IntTy(val.type.width, signedness(ty))
Comment thread pythonbpf/expr/expr_pass.py Outdated
if depth == 1
else deref_to_depth(func, builder, var, depth)
)
return val, _descriptor(val, sym.ir_type if depth == 1 else base_type)
Comment on lines +285 to +287
result = canonicalise(
builder, apply_binop(builder, stmt.op, current, rhs), result_ty
)
"ktime",
param_types=[],
return_type=ir.IntType(64),
return_type=IntTy(64, False),
Comment on lines +138 to 140
if expected_type is not None and isinstance(expected_type, ir.IntType):
val = convert(builder, val, val.type, expected_type)
builder.store(val, ptr)
Comment on lines +270 to +272
def _handle_int_arg(val, builder, ty=None):
"""Widen an integer for bpf_printk to i64, per the value's sign."""
return convert(builder, val, ty if ty is not None else val.type, ir.IntType(64))
if methods is None:
raise SyntaxError(f"Unsupported binary operation: {type(op).__name__}")
return getattr(builder, method)(left, right)
return getattr(builder, methods[0] if signed else methods[1])(left, right)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now, but if BINOP_METHODS are referenced at many other places in the future then we need to use a NamedTuple instead.

Comment thread pythonbpf/expr/expr_pass.py Outdated
# A @bpfglobal: plain load off the global symbol.
return builder.load(compilation_context.bpf_globals[operand.id].var)
sym = compilation_context.bpf_globals[operand.id]
return builder.load(sym.var), _descriptor(None, sym.ir_type)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is sending a None here fine? can we not just send the actual val?

Comment thread pythonbpf/expr/expr_pass.py Outdated
"""Extract the value from an operand, handling variables and constants."""
return get_typed_operand(
func, compilation_context, operand, builder, local_sym_tab
)[0]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't like this. Might work for now. But we should either use NamedTuples or just hide get_typed_operand as _get_typed_operand.

Comment thread pythonbpf/expr/type_inference.py Outdated
value = (sym.params or {}).get("value") if sym else None
return (
_as_intty(ctypes_to_ir(value))
if isinstance(value, str) and is_ctypes(value)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assumes that value can only be a named var, which is wrong

descriptors (see type_deducer.IntTy); the physical width comes from the
value itself, which may already be wider than its descriptor says.
"""
if not (isinstance(to_ty, ir.IntType) and isinstance(val.type, ir.IntType)):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about bools?

r41k0u and others added 7 commits September 24, 2026 17:09
The registry types ktime, pid, uid, random, cgroup_id and smp_processor_id
as unsigned, and undeclared locals initialised from them already took
that sign. The expression value did not: each emitter returns
(value, plain i64), so ktime() >> 1 was an ashr and pid() // 3 an sdiv.
The dispatcher now substitutes the registered descriptor when the widths
agree, in one place instead of in every emitter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two descriptors were built from the physical value instead of the
declaration. A sub-register vmlinux field is widened to i64 by
load_ctx_field, and its descriptor took that width, so ctx.data - k with
a c_int32 k was a 64-bit subtraction where C makes it a u32 one. A
map-lookup local dereferenced past its slot took the physical pointee
(i64, signed by default) and dropped the map's declared value ctype kept
in the symbol's metadata, so p >> 63 on a c_uint64 value was an ashr.
Both now read the declaration; the physical width still comes from the
value, as everywhere else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A global's descriptor is built from the loaded value, not from None.
- get_operand_value, the [0] wrapper over get_typed_operand, had no
  callers left; removed with its export.
- infer_int_type's map branch names the map's declared value type for
  what it is (value_ctype), says that the key argument plays no part,
  and applies only to lookup: update and delete return a status, not a
  value of the map's type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The binary-operation path chooses sdiv/udiv, srem/urem and ashr/lshr from
the promoted type; the augmented-assignment path computed the same
promoted type and then called apply_binop without it, so u32 >>= 4 was
an ashr and u32 //= 3 an sdiv. Same call as the binop path now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… field rank

One IR-shape case per fix: u32 >>= / //= / %= emit lshr/udiv/urem;
ktime() >> 1 and pid() // 3 emit lshr/udiv; p >> 63 on a c_uint64 map
value is lshr; ctx.data - c_int32 is a u32 subtraction with the
trunc/zext pair rather than a 64-bit one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
xdp_md.data is a u32 in C, but the verifier tracks it as a packet
pointer and rejects 32-bit arithmetic on it: "R0 32-bit pointer
arithmetic prohibited", the same for a C program, which is why C casts
it through (void *)(long) first. The u32 ranking the test pins is the
correct one; it just has to be pinned on a field that is a scalar to the
verifier too. ingress_ifindex is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
xdp_md.data/data_end/data_meta and __sk_buff.data/data_end are u32 in
C and packet pointers to the verifier, so arithmetic on them directly
is rejected as 32-bit pointer arithmetic, for C and for us alike. A
TODO at the point where a field's rank is decided describes the planned
fix (64-bit pointer rank for those fields, no cast needed), and the
user guide documents the workaround until then: copy to a local or cast
through c_void_p.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@r41k0u

r41k0u commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

LGTM, need to fix the ctx.data thing later.

@r41k0u

r41k0u commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

@varun-r-mallya pls remember

@r41k0u
r41k0u merged commit dc628ee into master Sep 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants