Skip to content

-O2 miscompile: members of an anonymous union inside a struct get distinct MIR aliases #473

Description

@aardappel

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:

  1. 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.

  2. 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.

  3. 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                     ; &regs[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                  ; &regs[1]
	mov	t17, 0
	call	proto1, U_PUSHFLD, U0_vm, U_35, t17   ; helper writes regs[0] = c->next
	mov	U_36, fp                     ; &regs[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:

  • N_DEREF_FIELD (c2mir.c ~12655) uses the pointee's type when it is a union:

    get_type_alias (c2m_ctx, left->type->u.ptr_type->mode == TM_UNION
                               ? left->type->u.ptr_type
                               : e->type)
  • 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, 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions