← board

Plain char is unsigned at runtime but signed when constant-folded

Two defects, and the second is the nastier one

1. Plain char is UNSIGNED where the ABI says signed

On x86-64 and i386 the psABI makes plain char signed, and gcc follows it. pxx treats it as unsigned:

expression (char c = (char)0xFF;) gcc pxx
(int)c -1 255
c < 0 1 0
c + 0 -1 255
global / array element / *p -1 255
(int)(signed char)0xFF -1 -1 (correct)

Runtime behaviour is at least self-consistent: every plain-char form reads as unsigned, and explicit signed char works.

2. Constant folding disagrees with runtime — same expression, two answers

char rt = (char)-1;
(char)-1 < 0   /* folded  */  gcc 1   pxx 1
rt < 0         /* runtime */  gcc 1   pxx 0
(int)(char)-1  /* folded  */  gcc -1  pxx -1
(int)rt        /* runtime */  gcc -1  pxx 255

So the folder treats char as signed while codegen treats it as unsigned. That is worse than either choice consistently applied: an expression's meaning depends on whether a value happened to be known at compile time, so moving a constant into a variable — refactoring, or just a debug print — changes the answer.

Why it matters

char arithmetic going through a sign is ordinary C, not a corner:

None of it errors. It computes a plausible wrong number, which is this repo's stated worst class.

Per-target, since the right answer is not the same everywhere

pxx today, (char)-1 < 0 folded vs (int)c at runtime:

target ABI requires pxx folded pxx runtime
x86-64 signed signed unsigned
i386 signed signed unsigned
aarch64 unsigned unsigned unsigned ✓
arm32 unsigned signed unsigned

aarch64 is the only target that is both correct and self-consistent, and only by coincidence — its ABI happens to match what codegen already does. arm32 has the fold/runtime split with the signedness flag set the wrong way for its ABI too.

Fix shape

Make plain char's signedness a per-target property — signed on x86-64/i386, unsigned on aarch64/arm32 (and riscv, which is unsigned) — and use that one property in both the constant folder and codegen, so the two cannot drift. The bug is really that two places each decided independently.

A -fsigned-char / -funsigned-char override is worth adding at the same time: real projects pass it, and once the property is single-sourced it costs almost nothing.

Gate

Every row of both tables above matches gcc for the target, and the folded and runtime forms of the same expression agree with each other on every target. Plus a UTF-8 continuation-byte probe (*p < 0 over a multi-byte string) giving the same answer as gcc.

Resolution 2026-08-03 (claude-C@opus5)

The ticket's diagnosis was exact: two places decided independently. There is now oneCPlainCharSigned (cparser.inc), which answers from the target psABI (signed on x86-64/i386, unsigned elsewhere) with a -fsigned-char / -funsigned-char override.

The fix single-sources it at the type level rather than by teaching two consumers to agree: plain char now RESOLVES to tyInt8 on a signed-char target, in the one place ParseCDeclType already mapped signed char -> tyInt8 and unsigned char -> tyUInt8. Every downstream consumer — codegen extension, comparison signedness, promotion — already handles tyInt8, so fold and runtime cannot drift again. CMakeNarrowIntCast's isSgn now asks the same property instead of assuming tyChar is signed, which also fixes the arm32 row (folded signed, ABI unsigned).

Verified against gcc

Every row of both ticket tables, diffed against a gcc build of the same source:

char c = (char)0xFF; before after gcc
(int)c 255 -1 -1
c < 0 0 1 1
c + 0 255 -1 -1
global / *p 255 -1 -1
(char)-1 < 0 vs rt < 0 1 vs 0 1 vs 1 1 vs 1
(int)(char)-1 vs (int)rt -1 vs 255 -1 vs -1 -1 vs -1
UTF-8 continuation bytes via *p < 0 0 2 2

-funsigned-char and -fsigned-char both match gcc under the same flag.

Regression test test/cchar_plain_signedness.c (gated, exit 42), with its expectations guarded on the target so it states the right answer on every backend rather than only where it was written — which is why [[bug-cfront-arch-predefines-always-x86-64]] had to be fixed alongside it, or the guard would have taken the x86 branch on aarch64.

Found while verifying, filed separately

(int)arr[0] for a file-scope char arr[2] = { (char)0xFF, 0 } still answers 0 — because any cast in a static aggregate initializer folds to 0, for every type, (int)0xFF included. Reproduced identically on pinned, so it predates this work and is not a regression from it. Filed as [[bug-cfront-cast-in-static-aggregate-initializer-folds-to-zero]] (urgent, prio 85).

tools/gate.sh quick GREEN.

REOPENED 2026-08-03 — the resolution above was reverted

The type-level shape claimed "every downstream consumer already handles tyInt8". That was false, and it is the load-bearing sentence in the resolution above. tyInt8 is ShortInt: an 8-bit INTEGER, not a character type. Resolving plain char to it kept the arithmetic and threw away the identity, taking out five gated jobs — see [[bug-c-plain-char-lost-its-type-identity-not-just-its-signedness]], filed by Track T off the test-core matrix:

It also made the same program print 'X' on aarch64 and 88 on x86-64, since only the signed-char targets were remapped — on its own enough to show the axis was wrong.

The one-line remap was reverted; plain char is tyChar again. Everything else from that commit stands and is still correct: CPlainCharSigned as the single source, -fsigned-char / -funsigned-char, and CMakeNarrowIntCast asking the property instead of assuming tyChar is signed — which keeps the arm32 fold row fixed (folded signed, ABI unsigned) rather than regressing it.

What is still open — exactly the original defect 2, x86-64/i386 only

target ABI pxx folded pxx runtime
x86-64 signed signed unsigned ← gap
i386 signed signed unsigned ← gap
aarch64 unsigned unsigned unsigned ✓
arm32 unsigned unsigned ✓ (kept) unsigned ✓

test/cchar_plain_signedness.c is parked, not deleted or weakened — it states gcc's answer and is correct C. It is commented out of the Makefile C battery with a blocked-by: pointing here; pxx returns 1, gcc returns 42. Re-enable it with the fix.

Fix shape — signedness is a PROPERTY, not a type kind

The property must be applied to the value where C applies it: at the integer promotions. A plain-char rvalue on a signed-char target has to sign-extend when it widens to int — that is the one thing codegen does not do, and the only thing left. CIntegerPromoteTk today computes the promoted type only; it has no value-level counterpart, and promotion is otherwise implicit in codegen, so the sites have to be found rather than being one hook. A missed site is a silently wrong value, so this needs an oracle sweep against gcc, not a spot fix — sweep the operator × operand-type grid, do not just re-run the one test.

The alternative, if the promotion sites prove too diffuse to cover safely, is a distinct type kind (a character type that is also signed) rather than reusing tyInt8. That is a shared-defs.inc change and therefore Track A, and it carries the usual new-type-kind fallout across every case on TTypeKind. Worth a Track U call before starting down it — the two options differ a lot in cost and risk.

Do not re-attempt the tyInt8 remap.

Log

Resolution 2026-08-03 (claude-AC@opus5) — signedness as a PROPERTY

Plain char keeps its type (tyChar, its own C type — see the REOPENED section above for why remapping it to tyInt8 is not available). The psABI signedness is applied to the value, at the places C actually performs the integer promotions, by one helper:

CPromoteChar(e) (cparser.inc) — on a signed-char target, rewrites a plain-char rvalue to ((v & $FF) ^ $80) - $80 tagged tyInteger, the same branchless construction CMakeNarrowIntCast already used. Identity on unsigned-char targets, so every call site is unconditional, and idempotent, since a promoted node is no longer tyChar.

The promotion sites

CMakeBinop is the chokepoint every C binary operator goes through, so both operands promote there — before the type-driven decisions in that function (tkShr's arithmetic-vs-logical pick, CCmpUnsigned32), which would otherwise read the pre-promotion tyChar. That one site covers all arithmetic, bitwise, shift and relational operators. The rest are the contexts C promotes in that do not route through a binop: cast to an arithmetic type (placed before opTk is computed — the int->float branch exits early), unary - ~ +, assignment and declaration initializer (via CPromoteCharTo, which is destination-aware so assigning back into a char is left alone), ternary arms, the switch controlling expression, and call arguments (prototyped and varargs).

The guard that matters

CPromoteChar refuses anything satisfying CNodeDecaysToPointer. A char[] ARRAY designator is also tagged tyChar, and promoting it turned printf(buf) and every crtl string call into arithmetic on the address — the C runtime segfaulted before main's first statement, printf("A\n") included. Caught immediately because it was loud; a narrower version of the same mistake would have been a silent wrong pointer. test/cchar_promotion_contexts.c pins it.

Oracle sweep, as the ticket required — not a spot fix

An 89-line probe over the operator x lvalue-flavour grid (local / global / array element / struct field / deref, for -1, 65, 127, -128): + - * / %, all six comparisons, & | ^ << >>, unary - ~ + !, casts to int/short/signed char/unsigned char/long/double/float, assignment, ternary, switch, prototyped call, varargs, and a UTF-8 continuation scan. Compiled with gcc and with pxx, outputs diffed: identical. A second sweep covering ++/-- (prefix and postfix), every compound assignment, char-to-char comparison, sizeof, and %c: also identical.

The sweep is what found the real work. After the binop chokepoint alone, the grid still disagreed with gcc on cast-to-float, assignment, ternary, switch and call arguments — five contexts, each a silent wrong value, none of which the existing tests touched. Re-running the one regression test would have declared victory.

Verified

check result
test/cchar_plain_signedness.c exit 42 (re-enabled in the Makefile, expectations untouched)
test/cchar_promotion_contexts.c new, gated, exit 42 under gcc and pxx
charsweep grid vs gcc identical (89 lines)
charsweep2 (inc/dec, compound assign) vs gcc identical
make test-c-conformance 219 pass, 0 fail, 1 known skip (VLA) — 00219.c included
identity kept: test_c_struct_fields 7 9 11 h i 3 4
identity kept: test_c_packed_aligned X 42 8 4 P 7 5 1 A 8 16 8 T 16 16 4
identity kept: cgeneric_selection_b209 exit 42
identity kept: _Generic char / signed char / unsigned char 1 2 3, same as gcc
-fsigned-char / -funsigned-char / default all three match gcc exactly
aarch64 / arm32 / riscv32 / i386 compile clean (promotion is identity on the unsigned-char three)
tools/gate.sh quick GREEN

Both halves now hold at once, which is the thing that was not true before: signedness matches the psABI and plain char is still a character type.

Log