What the refusal is
Measured 2026-09-04 at the 394-applet scope (tools/busybox-applets-394.txt),
binary sha256 1968c7a7da57, commit 5f598d4a7.
FAIL i386 procps/powertop.c @520 non-empty template ... not i386
FAIL i386 networking/tls_sp_c32.c @257 non-empty template ... not i386
FAIL i386 networking/tls_pstm_mul_comba.c @280 non-empty template ... not i386
FAIL i386 networking/tls_pstm_sqr_comba.c @514 non-empty template ... not i386
FAIL i386 networking/tls_pstm_montgomery_reduce.c @410 earlyclobber constraint "=&d"
cparser.inc:7865-7877 runs its refusals most specific first, deliberately,
so the message names the missing thing rather than the outermost fact about it.
That design is why this ticket can be written at all — four of the five say
"not i386" and one says "earlyclobber", which are two different jobs.
Why "just stop defining GNUC" is the wrong repair
Measured, not assumed:
$ pascal26 gnuc.c && ./a.out -> __GNUC__=2
$ pascal26 --target=i386 gnuc.c && run -> __GNUC__=2
and the sources gate on the pair we advertise:
tls_sp_c32.c:216 #if ALLOW_ASM && defined(__GNUC__) && defined(__i386__)
tls_sp_c32.c:258 #elif ALLOW_ASM && defined(__GNUC__) && defined(__x86_64__)
powertop.c:500 #ifdef __i386__ /* no __GNUC__ gate at all */
So on i386 the source correctly selects an i386 asm arm because we told it
we are GNU on i386. Withdrawing __GNUC__ would push tls_sp_c32.c onto its
portable #else and look like a fix, and would do nothing for powertop.c,
which asks only about the architecture. It would also be a lie in the other
direction: we DO encode asm, just not here.
Note the inverse hazard this creates for anyone verifying under an older pin:
before __GNUC__ was defined, these files took their portable arms and
compiled, so a green on a pin that predates it is correct about a different
compiler. See CLAUDE.md's --pinned note, which uses tls_sp_c32.c as its
example.
Two jobs, not one
- Encode a non-empty template on i386. The x86-64 path is
AttResetBindings+AttEncodeTemplate, and with operandsCAsmBuildBlockpins each to a register. i386 is the same instruction family with a narrower register file; whetherAsmDispatchalready encodes what these five need is the first thing to measure, not to argue. =&dearlyclobber (cparser.inc:7491) is target-independent and is a separate, smaller job. Do not let it ride on job 1.
Scope
x86-64 does not see any of this — all five compile there. It is one more member
of the class where the native target is the one with the blind spot; see
feature-c-corpus-busybox-i386-the-second-architecture for the other, larger
member (the host-header fallback).
2026-09-04, frankC — the five TUs are FOUR jobs, not two, and the encoder is not one of them
Scoping pass, read-only, at HEAD 162a22dd3. Nothing below is a plan; each row
is a constraint census of the arm the i386 preprocessor actually selects, taken
from library_candidates/busybox-frankC.
The instruction encoder already does all of it, and the bytes are the SAME
The ticket's job 1 says "whether AsmDispatch already encodes what these five
need is the first thing to measure, not to argue." Measured, two ways.
It has the mnemonics. mul (asmenc.inc:1147), adc/sbb (1047,
1048 — added as "the two missing rows of this table"), cpuid (846),
mov/add from the base ALU set. Nothing the five arms name is absent.
And every byte it would emit is mode-independent. The thirteen instruction
forms those arms use, assembled by gas for both modes — 32-bit operands, base
registers below 8, [ebp+disp] frame slots — are byte-identical:
8b 08 mov (%eax),%ecx | 8b 08 mov (%rax),%ecx
03 0a add (%edx),%ecx | 03 0a add (%rdx),%ecx
8b 48 04 mov 0x4(%eax),%ecx | 8b 48 04 mov 0x4(%rax),%ecx
13 4a 04 adc 0x4(%edx),%ecx | 13 4a 04 adc 0x4(%rdx),%ecx
19 c9 sbb %ecx,%ecx | 19 c9 sbb %ecx,%ecx
f7 27 mull (%edi) | f7 27 mull (%rdi)
83 d7 00 adc $0x0,%edi | 83 d7 00 adc $0x0,%edi
0f a2 cpuid | 0f a2 cpuid
8b 45 f8 mov -0x8(%ebp),%eax | 8b 45 f8 mov -0x8(%rbp),%eax
89 45 f4 mov %eax,-0xc(%ebp) | 89 45 f4 mov %eax,-0xc(%rbp)
(all 13 rows compared, diff clean). That is not a coincidence: 32-bit operand
size is the default in both modes, and a REX prefix appears only for a 64-bit
operand or a register >= 8, neither of which these arms use. So the refusal
at cparser.inc:7959 is not standing in front of missing encoding work.
What it IS standing in front of: AN_ASM means two different things
AsmParseBody (asmenc.inc:1944-1975) routes five of six targets to a
AsmParseBodyText* capture, and only x86-64 to the byte encoder. So an
AN_ASM node's Left/Right are an AsmBytes span on x86-64 and an
InlineAsmLine span on i386 — ir_codegen386.inc:1634 replays lines through
Asm386ProcessInlineLine, which would read a byte offset as a line index.
The convention is per-TARGET and globally consistent today. Job 1 has to pick one of two, and the choice is the actual design decision in this ticket:
- give i386's
IR_ASMa second, byte-span form — much less code, and it puts two mechanisms behind one concept on one target, which is the smellroot-cause-over-microfix.mdnames; - give the AT&T reader a TEXT sink — one parser, two renderers, each target
keeping exactly one convention.
asmatt.incparses into theAsmOp*globals and then callsAsmDispatch; a renderer that formats those same globals as the Intel linesAsmParseBodyText386produces swaps only the last step.
Recommending the second, and not landing either here: it is a change to a file Track A owns and it wants its own commit.
The pool is 5 on i386, not 9 — and that alone refuses two of the five
CAsmPool (cparser.inc:7898) is nine caller-saved x86-64 registers. On i386
the caller-saved set is eax ecx edx esi edi — five — because ebx is
callee-saved and pxx's prologue does not save it. Counting each arm against
that, with clobbers taken out first exactly as CAsmBuildBlock does:
| TU | operands wanting a register | free after clobbers | verdict |
|---|---|---|---|
tls_sp_c32.c sp_256_add_8 |
4 ("=r"×4, 3 tied) |
5 ("memory" only) |
fits |
tls_pstm_mul_comba.c MULADD |
5 ("=rm"×3 + "m"×2) |
3 (%eax,%edx clobbered) |
refuses |
tls_pstm_sqr_comba.c SQRADD |
4 ("=rm"×3 + "m") |
3 (same clobbers) | refuses |
So "encode a non-empty template on i386" is not sufficient for three of the four files that report it. The two comba files run out of registers, and the reason is a design choice that costs nothing on x86-64 and everything here:
a "m" operand is pinned to a register holding its ADDRESS. gcc spills the
value to the frame and writes the operand as -8(%ebp), using no register at
all. CAsmBuildBlock already allocates a temp local per operand and the
encoder already has the [rbp+disp32] form — the AOP_MEM operand the whole
pinning design is built on. Making ATTBIND_MEM render a frame slot instead of
[reg] takes both comba files from one short to exactly fitting, and it is
a normalisation that helps x86-64 too (every "m" there currently burns a
pool entry it does not need). Do this BEFORE the pool, not after: sizing a pool
against a cost that should not exist is how the wrong number gets defended.
"=b" is a fourth job, and it is the only one that touches the prologue
procps/powertop.c:500 is cpuid, and its four outputs are "=a" "=b" "=c" "=d" — the register set the instruction writes, so ebx is not negotiable
by us. The pool note says callee-saved registers are absent "on purpose —
clobbering a callee-saved register here would break our caller". Supporting
this TU means a save/restore pair around the block for any fixed-register
constraint naming a callee-saved register. Small, self-contained, and nothing
else in the five needs it.
And job 2 as written unblocks NOTHING — measured, and it changes the ranking
tls_pstm_montgomery_reduce.c is the one TU whose message is the earlyclobber,
and the ticket says "a separate, smaller job. Do not let it ride on job 1."
Both halves are true and the conclusion a reader draws from them is not: the
refusals run most-specific-first, so & is simply the first of three things
that file needs. Its i386 arm, in full:
#define INNERMUL /* line 86 */ \
asm("mull %4\n\t" "addl %3,%%eax\n\t" "adcl $0,%%edx\n\t"
"addl %%eax,%0\n\t" "adcl $0,%%edx\n\t"
:"=g"(_c[LO]), "=&d"(cy)
:"0"(_c[LO]), "g"(cy), "g"(mu), "a"(*tmpm++) :"cc")
#define PROPCARRY /* line 102 */ \
asm(... :"=g"(_c[LO]), "=r"(cy) :"0"(_c[LO]), "1"(cy) :"cc")
Behind the &, in the order CAsmRefuseUnsupported and CAsmBuildBlock would
reach them:
"g"is not a supported class.CAsmClassSupportedreadsr,rm,mr,mand the fixed-register letters;g(register / memory / immediate) is in neither list, and it appears three times as an input plus twice as the"=g"output._c[LO]is not a plain variable. Outputs are restricted toAN_IDENTso a subscript is not evaluated twice — a deliberate correctness rule, and this output is a subscript in both macros.
Removing the earlyclobber refusal therefore moves the message and compiles
nothing. Rank it as what it is: a cleanup with no TU attached, not the
smaller half of a two-TU ticket. (Earlyclobber itself is very likely a no-op
for us — every untied operand already gets its own pinned register, so &'s
guarantee holds by construction — but "the refusal is unnecessary" and "the
file compiles" are different claims and only the first is established here.)
Summary of the decomposition
| job | unblocks | touches |
|---|---|---|
"m" → frame slot instead of a pinned register |
0 alone, prerequisite for 2 | cparser.inc, asmatt.inc |
| AT&T text sink + i386 pool | tls_sp_c32, and with the row above both combas |
asmatt.inc, cparser.inc |
| callee-saved save/restore for a fixed-register constraint | powertop.c |
cparser.inc |
"g" class + non-plain-lvalue outputs + earlyclobber |
tls_pstm_montgomery_reduce.c |
cparser.inc |
Nothing above changes ir_codegen386.inc's replay contract except job 2's
first bullet, which is exactly why the sink choice is the decision to make
first.
RESOLVED 2026-09-04 — all five TUs compile, link and run on i386
Measured in the 394-applet sweep at binary d65d31543bf1, HEAD d5b23e1cd:
i386 produces 521 of 521 objects, they link, and the linked binary is
byte-identical to the gcc -m32 oracle over 938 cases. powertop.c,
tls_sp_c32.c, tls_pstm_mul_comba.c, tls_pstm_sqr_comba.c and
tls_pstm_montgomery_reduce.c are all in that count.
The scoping section above was right that it was four jobs and not two, and
wrong about which blocker powertop.c hits first. Its first refusal was the
plain-lvalue output rule — *eax is a dereference, not an identifier — and
"=b" is real but second. Corrected when measured, not when written.
What landed, in the order that mattered:
"m"as a frame slot (asmatt.inc): an offset lane so an"m"operand's frame offset is not parked in a field namedReg, and%Nrenders as a slot rather than[reg]. This was the FIRST job, not the asm sink: the i386 caller-saved pool is five registers, and pinning"m"to an address costs one each — mul_comba wanted five with three free. Rendering as a slot makes both files fit exactly, and it helps x86-64 too.- A text sink for the AT&T reader (
asmatt.inc):AN_ASM's Left/Right span is AsmBytes on x86-64 and an InlineAsmLine index on every other target, so i386 needed a renderer rather than a second AN_ASM form. "g", earlyclobber, and side-effect-free output lvalues (cparser.inc): the output rule became a whitelist walk instead ofASTKind <> AN_IDENT, and only the contradictory earlyclobber/tied pair is still refused.cpuidand the F6/F7 memory forms (asmtext_386.inc).
A silent miscompile was found by the new test and not by the sweep, and it
was in the file the seam had just been added to. AttEncodeOne's
zero-operand branch called AsmDispatch directly, bypassing the seam, so on
i386 it encoded x86-64 bytes into AsmBytes — which that target's codegen never
reads. cpuid printed 0 00000000 00000000 00000000 against gcc's
13 756e6547 6c65746e 49656e69, with no diagnostic. That is
normalise-dont-special-case's "the second path is the one that stays broken",
found in a file being normalised, on the same day.
Tests: test/casm_gnu_operands_i386.c (the five busybox arms verbatim, oracle
gcc -m32) and test/casm_m_operand_pressure.c (nine "m" inputs against a
nine-entry pool — the pinned compiler refuses it by name, which is the positive
control).
Log
- 2026-09-04 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 7cb67ad14.