Apologies in advanced if you don't want AI contributions, but this is what Opus found when it integrated your code into my project, thought you'd at least like to hear about it:
--
Summary
When a union is declared anonymously inside a struct, c2mir gives each member access an alias id
derived from that member's type instead of the union's. Two accesses to the same storage through
different members of that union therefore carry different, non-zero, distinct MIR_alias_t values,
and the generator concludes they cannot alias. From -O2 on (where GVN/CCP are enabled) this
produces wrong code: a load through one member does not see a store through another.
-O0 and -O1 are fine, gcc, clang and MSVC are all fine at every level.
Naming the union (or making the type a union rather than a struct wrapping an anonymous one)
makes the problem disappear, which is what points at the anonymous case specifically.
Version and platforms
MIR at commit a8ab7c31cd5f9b23b77d84c60b3d83e62d9d304c.
Reproduced identically on:
- Linux x86-64, c2m built with gcc 13.3.0 (Ubuntu 24.04)
- Windows x86-64, c2m built with MSVC 19.44
Reproducer
mir-gvn-O2-miscompile.c (attached, ~100 lines, no includes, no libc beyond printf/malloc)
is a reduction of code generated by the Lobster compiler: a recursive singly linked list delete,
where a local Value regs[2] array is used as a small operand stack whose address is handed to
opaque helper functions as "one past the last used slot", and those helpers write into the slots
below it. Value is the interesting bit:
typedef struct { int typeinfo; int refc; } RefObj;
typedef struct { union { long long ival; double fval; RefObj *ref; }; } Value;
The program builds a 5 element list with refcount 1 each, deletes the node with key 2, then prints
the refcounts of the remaining nodes.
$ c2m mir-gvn-O2-miscompile.c -O0 -eg
keys after deleting 2: 4 3 1 0
refcounts: 2 1 2 1 <-- correct
$ c2m mir-gvn-O2-miscompile.c -O1 -eg
refcounts: 2 1 2 1 <-- correct
$ c2m mir-gvn-O2-miscompile.c -O2 -eg
refcounts: 1 1 3 1 <-- WRONG
$ c2m mir-gvn-O2-miscompile.c -O3 -eg
refcounts: 1 1 3 1 <-- WRONG
$ gcc -O0 mir-gvn-O2-miscompile.c -o t && ./t
refcounts: 2 1 2 1
$ gcc -O2 mir-gvn-O2-miscompile.c -o t && ./t
refcounts: 2 1 2 1
$ clang -O2 ... ; cl /O2 ...
refcounts: 2 1 2 1
The list shape itself comes out right in all cases; only the reference counts are wrong, i.e. one
increment lands on the wrong object.
Two one-line variants that isolate it
Both attached, both correct at every -O level:
-
variant-plain-union-ok.c — identical except
-typedef struct { union { long long ival; double fval; RefObj *ref; }; } Value;
+typedef union { long long ival; double fval; RefObj *ref; } Value;
Making the type a plain union rather than a struct containing an anonymous union fixes it.
-
variant-same-member-ok.c — identical except every
-RefObj *_r = (regs + 0)->ref;
+RefObj *_r = (RefObj *) (regs + 0)->ival;
Reading back through the same member that was stored fixes it.
-
variant-named-union-ok.c — identical except the union is given a name and every access goes
thru it (regs[0].ival becomes regs[0].u.ival, and so on). Also correct at every level, and
this one lines up with what the N_FIELD handling in gen() already expects to see.
Together these say the problem is specifically "same address, two different members of an
anonymous union".
What the generated MIR shows
From c2m mir-gvn-O2-miscompile.c -O2 -dg2 -eg, in the branch where the key matches
(regs and locals share one alloca fp, 32, regs at fp+0, locals at fp+16):
L19:
mov U_32, fp ; ®s[0]
add I_33, fp, 16
mov U_34, I_33 ; &locals[0]
mov t16, i64:(U_34):L ; t16 = locals[0] (the node `c`)
mov i64:(U_32):L, t16 ; regs[0] = c <-- store, alias "L"
add U_35, fp, 8 ; ®s[1]
mov t17, 0
call proto1, U_PUSHFLD, U0_vm, U_35, t17 ; helper writes regs[0] = c->next
mov U_36, fp ; ®s[0]
mov U2__r, u64:(U_36):pSiie ; load regs[0] <-- load, alias "pSiie"
bf L20, U2__r
... U2__r->refc++ ...
The store and the load address the same 8 bytes at fp+0, but carry alias ids L
(long long) and pSiie (RefObj *) respectively. Since both are non-zero and different,
MIR_op.u.mem.alias's contract ("memory with the same alias is aliased") lets the generator treat
them as independent.
Where it comes from in c2mir.c
get_type_alias() (c2mir.c ~10476) deliberately returns 0 — may-alias-anything — for
TM_STRUCT, TM_ARR, TM_FUNC and character types, and otherwise builds an alias name from
the type. The union cases are handled at the two call sites in gen() for field access instead:
Neither test fires for an anonymous union nested in a struct. In the reproducer the expression is
(regs + 0)->ival, so left->type->u.ptr_type is Value, whose mode is TM_STRUCT, not
TM_UNION — the anonymous union one level down is never consulted, and each member falls back to
get_type_alias(e->type), its own type.
So the fix probably wants the field access path to walk out through any enclosing anonymous
union when picking the alias (or, more conservatively, to use alias 0 for any member of a union,
anonymous or not) rather than testing only the immediately dereferenced type's mode.
Context
I hit this adding MIR as an alternative JIT backend to the Lobster programming language, next to
libtcc. Lobster's C backend represents every VM value as exactly that struct-with-anonymous-union,
which is a common enough shape for dynamic language runtimes that it may be worth catching.
Running Lobster's test suite under MIR passes completely at -O0 and -O1 and fails at -O2,
which is how the reduction above was found.
Apologies in advanced if you don't want AI contributions, but this is what Opus found when it integrated your code into my project, thought you'd at least like to hear about it:
--
Summary
When a union is declared anonymously inside a struct, c2mir gives each member access an alias id
derived from that member's type instead of the union's. Two accesses to the same storage through
different members of that union therefore carry different, non-zero, distinct
MIR_alias_tvalues,and the generator concludes they cannot alias. From
-O2on (where GVN/CCP are enabled) thisproduces wrong code: a load through one member does not see a store through another.
-O0and-O1are fine, gcc, clang and MSVC are all fine at every level.Naming the union (or making the type a union rather than a struct wrapping an anonymous one)
makes the problem disappear, which is what points at the anonymous case specifically.
Version and platforms
MIR at commit
a8ab7c31cd5f9b23b77d84c60b3d83e62d9d304c.Reproduced identically on:
Reproducer
mir-gvn-O2-miscompile.c(attached, ~100 lines, no includes, no libc beyondprintf/malloc)is a reduction of code generated by the Lobster compiler: a recursive singly linked list delete,
where a local
Value regs[2]array is used as a small operand stack whose address is handed toopaque helper functions as "one past the last used slot", and those helpers write into the slots
below it.
Valueis the interesting bit:The program builds a 5 element list with refcount 1 each, deletes the node with key 2, then prints
the refcounts of the remaining nodes.
The list shape itself comes out right in all cases; only the reference counts are wrong, i.e. one
increment lands on the wrong object.
Two one-line variants that isolate it
Both attached, both correct at every
-Olevel:variant-plain-union-ok.c— identical exceptMaking the type a plain union rather than a struct containing an anonymous union fixes it.
variant-same-member-ok.c— identical except everyReading back through the same member that was stored fixes it.
variant-named-union-ok.c— identical except the union is given a name and every access goesthru it (
regs[0].ivalbecomesregs[0].u.ival, and so on). Also correct at every level, andthis one lines up with what the
N_FIELDhandling ingen()already expects to see.Together these say the problem is specifically "same address, two different members of an
anonymous union".
What the generated MIR shows
From
c2m mir-gvn-O2-miscompile.c -O2 -dg2 -eg, in the branch where the key matches(
regsandlocalsshare onealloca fp, 32,regsatfp+0,localsatfp+16):The store and the load address the same 8 bytes at
fp+0, but carry alias idsL(
long long) andpSiie(RefObj *) respectively. Since both are non-zero and different,MIR_op.u.mem.alias's contract ("memory with the same alias is aliased") lets the generator treatthem as independent.
Where it comes from in c2mir.c
get_type_alias()(c2mir.c ~10476) deliberately returns 0 — may-alias-anything — forTM_STRUCT,TM_ARR,TM_FUNCand character types, and otherwise builds an alias name fromthe type. The union cases are handled at the two call sites in
gen()for field access instead:N_DEREF_FIELD(c2mir.c ~12655) uses the pointee's type when it is a union:N_FIELD(c2mir.c ~12641) keeps the base's alias when its name already starts with'U'.Neither test fires for an anonymous union nested in a struct. In the reproducer the expression is
(regs + 0)->ival, soleft->type->u.ptr_typeisValue, whose mode isTM_STRUCT, notTM_UNION— the anonymous union one level down is never consulted, and each member falls back toget_type_alias(e->type), its own type.So the fix probably wants the field access path to walk out through any enclosing anonymous
union when picking the alias (or, more conservatively, to use alias 0 for any member of a union,
anonymous or not) rather than testing only the immediately dereferenced type's mode.
Context
I hit this adding MIR as an alternative JIT backend to the Lobster programming language, next to
libtcc. Lobster's C backend represents every VM value as exactly that struct-with-anonymous-union,
which is a common enough shape for dynamic language runtimes that it may be worth catching.
Running Lobster's test suite under MIR passes completely at
-O0and-O1and fails at-O2,which is how the reduction above was found.