← board

Pointer difference as a variadic argument pushes 8 bytes on 32-bit

Repro

#include <stdio.h>
int main(void) {
  const char *s = "abcdef";
  const char *t = s + 3;
  printf("ptrdiff  : %d %d\n",   t - s, 7);
  printf("ptr-cast : %d %d\n",   (int)(t - s), 7);
  printf("three    : %d %d %d\n", t - s, 7, 8);
  return 0;
}
ptrdiff ptr-cast three
gcc 3 7 3 7 3 7 8
pxx x86-64 3 7 3 7 3 7 8
pxx aarch64 3 7 3 7 3 7 8
pxx i386 3 0 3 7 3 0 7
pxx arm32 3 0 3 7 3 0 7

Every argument after the pointer difference is shifted by one 4-byte slot: the callee reads the (zero) high half as the next argument, that argument as the one after, and so on.

Not UB — %d is the correct specifier here

ptrdiff_t is int-sized on ILP32, so %d with a pointer difference is correct on i386/arm32, and gcc agrees. This is a genuine width bug, not a format-specifier mistake.

The frontend already knows the right width — only the argument push is wrong:

i386:  sizeof(ptrdiff_t)=4  sizeof(void*)=4  sizeof(t - s)=4

sizeof(t - s) reports 4 while the varargs path pushes 8.

What narrows it

Why it matters

printf("%d bytes\n", p - buf) is one of the most ordinary things C code does, and on 32-bit it silently corrupts every argument after it. There is no diagnostic anywhere; the first value even prints correctly, which is what makes the following ones look like a library bug. That misdirection is the expensive part: three crtl functions were the prime suspects for an hour.

Same family as [[bug-a-i386-int64-arg-high-half-uninitialized]] and [[bug-c-int64-to-double-cast-truncates-on-32bit]] — 32-bit argument width.

Gate

The repro above matches gcc on i386 and arm32; tools/gcc_diff_probe.sh --target i386 and --target arm32 clean; self-host fixedpoint; cross.

Resolution (2026-08-05)

The pointer-difference lowering (compiler/ir.inc, the tkMinus arm where both operands are pointer-based) hardcoded tyInt64 for the subtraction result, the stride constant and the scaling divide. ptrdiff_t is pointer-width — 8 bytes on LP64, 4 on ILP32 — and the variadic push believes the node's tag, so on i386/arm32 a bare p - q claimed 8 bytes for a 4-byte value and every later argument read one slot low.

Retyped to tyNativeInt, which is signed (TypeSigned case 15) and pointer-width, so TypeSize gives 8 on 64-bit and 4 on 32-bit and both the push width and the divide width follow automatically. That is the same "pointer-width, not Int64" rule as bug-a-explicit-int64-cast-of-nativeint-does-not-extend-on-32bit, fixed an hour earlier — the two are the same mistake in different places.

The ticket's own explanation of why the frontend looked innocent is confirmed: sizeof(t - s) was already 4, because sizeof reads the C type, while the IR node carried the wrong tag.

It is NOT a no-op on 64-bit — worth knowing

I expected tyNativeInt vs tyInt64 to be indistinguishable on LP64, where both are 8 bytes. The emitted binaries differ on x86-64 and aarch64. The type tag itself steers codegen down different (equivalent) paths — several sites branch on the exact kind, not on the width. Behaviour is identical and matches gcc on all four targets, but the blast radius is wider than the width argument suggests, which is why this was gated on the full four-target probe rather than the 32-bit pair.

Verified

tools/gcc_diff_probe.sh on all four targets, baselined against HEAD without this hunk (not against pinned, which lags by unrelated commits):

target before after
x86-64 1 new, 1 known 1 new, 1 known (unchanged, both pre-existing)
i386 0 new, 3 known 0 new, 0 known
arm32 0 new, 3 known 0 new, 0 known
aarch64 0 new, 0 known 0 new, 0 known

Zero new divergences; six known divergences cleared. The three per 32-bit target are the known-tagged string.h probes — str-chr-nul, str-str-empty, mem-chr-miss — i.e. exactly the strchr/strstr/memchr cases the ticket named as the misdirection. They were never crtl bugs; they were this bug in the probes' own printf. Confirming the ticket's hypothesis outright.

Their known tags are now staletools/gcc_diff_probe.sh is test tooling (Track T's lane), so filed rather than edited: task-t-drop-stale-known-tags-on-string-h-probes.

Locked in as test/cptrdiff_vararg_b.c — strides 1 / 4 / 24, negative differences, three differences in a row, the last-argument position that always worked, the explicit cast that always worked, and sizeof(p-q) == sizeof(void*). Self-checks through sprintf (a real variadic call) and returns 42, matching the surrounding C-test convention rather than relying on eyeballed stdout. Exit 42 on x86-64, i386, arm32, aarch64 and gcc.

Gate: testmgr --tier quick 15/15; tools/selfhost_fixedpoint.sh converges in 2 rounds from pinned and agrees with compiler/pascal26.

Log