← board

csmith seed 79: checksum disagrees with the gcc oracle

Reproduce

tools/csmith_fuzz.py --seed 79

or by hand (the harness saved everything under /tmp/csmith-findings/, which is volatile — regenerate from the seed, csmith is deterministic):

csmith --seed 79 --output t.c
gcc -O0 -w -I library_candidates/csmith/include t.c -o t_gcc && ./t_gcc
./compiler/pascal26 -I library_candidates/csmith/include t.c t_pxx && ./t_pxx
gcc: checksum = B4981522
pxx: checksum = D1BEECFE

Why this is worth the reduction work

csmith output is free of undefined behaviour by construction and ends by checksumming every global, so a checksum difference is a real miscompile in one of the two compilers and it is not gcc. It is also, by the campaign's own record, the kind of bug the human-written corpora (lua, sqlite, tcc, zlib, c-testsuite) never reach — those are written by people who avoid dark corners.

Next step

Reduce it. 1588 lines is not a diagnosis; creduce/cvise against the "checksums differ" property is the standard move, and the campaign ticket records that the previous nine all reduced to a few lines each. Then the fix lands in whichever lane the reduction points at (IR/codegen → A, C frontend → C).

Reduction in progress 2026-08-13 — and the interestingness test is the hard part

Two reductions were run and both thrown away, because the reducer found the cheapest way to make the checksums differ: introduce undefined behaviour. Recording the layers, because the obvious test is not enough and this is reusable for the next csmith finding:

guard catches does NOT catch
-Werror=uninitialized at -O0 nothing useful — gcc's analysis needs the optimiser, so it silently never fires uninitialised reads
-Werror=uninitialized/maybe-uninitialized at -O2 most uninitialised locals
UBSan + ASan signed overflow, OOB, bad shifts uninitialised reads
gcc -O0 output == gcc -O2 output a lot, cheaply — a UB-free program cannot notice the optimiser UB that happens to be stable across -O
valgrind --error-exitcode the uninitialised reads everything above misses

First run (UBSan only) reduced 1588 lines to a loop over uninitialised i/j/k. Second run (plus -O0/-O2 agreement) reduced to the same shape with the locals hoisted to globals-by-another-name and one still uninitialised — gcc had not warned at -O0. Only valgrind rejects it.

A THIRD discard, and a guard nobody would think of first

Run 3 (all the UB layers, valgrind included) reduced cleanly to 14 lines — valgrind-clean, UBSan-clean, -O0 == -O2 — and was still bogus:

int g_7; int32_t *g_6 = &g_7;
*l_403 = 4578424 > g_6;          /* an INT compared with a POINTER */

The checksum is then a function of where the globals land, and pxx's image base is not gcc's. Nothing about codegen; the reducer had simply found the cheapest remaining way to make two numbers differ.

The guard: gcc PIE and gcc -no-pie must agree. -no-pie moves the whole image, so an address-independent program answers the same under both. On the 14-line file they differ (56772008 vs 9ADD2096 — and the no-PIE build agrees with pxx, which is the tell); on the original csmith program both are B4981522. Running the same binary twice does NOT catch it: the addresses are stable per build.

So the interestingness test needs FOUR families of guard — uninitialised reads (valgrind), UB (UBSan/ASan + -O0==-O2), address dependence (PIE vs no-PIE), and only then the checksum difference. The script lives beside the repro in the scratch tree; it is worth copying into the next reduction rather than re-deriving, because each of these cost a full reduction run to discover.

FIXED 2026-08-13 — a suffix RE-RUNS the constant ladder, it does not widen a rung

Run 4 (all five guards) reduced the 1588 lines to 15, and the payload is one line:

int32_t g_7 = 2324699082;          /* as int32: -1970268214 */
int32_t *g_6 = &g_7;
*l_403 = 0x9745DC78L > *g_6;       /* gcc: 1   pxx: 0 */

Minimal, outside csmith — every row's expectation is gcc's:

expression (int32_t g = -1970268214) gcc pxx before
0x9745DC78L > g 1 0
0x9745DC78 > g 1 1
2537528440L > g 1 1
0x9745DC78LL > g 1 0
0x9745DC78UL > g 0 0

The cause

0x9745DC78 is 2537528440: it overflows int but fits unsigned int, so the UNSUFFIXED hex ladder types it unsigned int — correct, and cparser.inc did that right. The l/L promotion then read that rung and widened it, tyUInt32 -> tyUInt64.

But C99 6.4.4.1 says the suffix re-runs the ladder with a different candidate list: for a hex constant suffixed l/L the candidates are long, then unsigned long. 2537528440 fits a signed 64-bit long, so it is a positive long — signedness is not inherited from the rung the unsuffixed ladder happened to stop at.

As unsigned long it dragged the other operand up with it: the negative int32 converted to a huge unsigned, and > answered 0. Silent, and exactly the class of bug the human-written C corpora never reach.

Fix: on the l/L promotion, tyUInt32 becomes tyInt64 — unless the literal also carried u/U, which is what the unsigned rungs are for.

Verified

Track A note: this is a C-frontend typing bug (cparser.inc), no IR or backend involvement.

Log