commit a1819a20019d757fd95370553aec757420f5f026
parent 54367ac4c8f926a97caf8a56ea6fef6381975169
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Fri, 17 Jul 2026 15:09:10 -0700
tcc: canonicalize riscv64 64-to-32 casts
Diffstat:
7 files changed, 97 insertions(+), 133 deletions(-)
diff --git a/boot/boot4.sh b/boot/boot4.sh
@@ -54,9 +54,7 @@
## After a codegen-altering tcc patch, tcc0 (still built by the old
## cc.scm) may lag tcc1 by a bounce. Set this to accept tcc2 even
## when tcc1 != tcc2; the next boot4 run, started from this run's
-## tcc2, will reach tcc1 == tcc2 with no extra knob. riscv64 also
-## needs this today: cc.scm miscompiles an immediate-fold predicate
-## in riscv64-gen.c, so tcc0 ≠ tcc1 there (see docs/TCC.md).
+## tcc2, will reach tcc1 == tcc2 with no extra knob.
##
## Usage: boot/boot4.sh <arch>
## <arch> ∈ {aarch64, amd64, riscv64} for either DRIVER (default podman).
diff --git a/bootprep/stage1-flatten.sh b/bootprep/stage1-flatten.sh
@@ -260,15 +260,16 @@ awk '{ sub(/\t#.*$/, ""); print }' "$SRC/lib/alloca86_64-bt.S" \
> "$SRC/lib/alloca86_64-bt.S.tmp"
mv "$SRC/lib/alloca86_64-bt.S.tmp" "$SRC/lib/alloca86_64-bt.S"
-# riscv64 int->llong cast: stock tcc 0.9.26 leaves unsigned int values
-# in their native register width, but RV64 32-bit ops sign-extend bits
-# 63:32, so widening an `unsigned int` to `unsigned long` reads garbage
-# upper bits. Make gen_cvt_sxtw do the right thing for both signs, and
-# always invoke it on riscv64. Hits be64() in the seed kernel's DTB
-# parser; without the fix the kernel sees mem_start sign-extended to
-# 0xffffffff80000000 and the boot panics during MMU bring-up. Patch is
-# gated by the call-site / function name so it no-ops on other arches.
+# riscv64 integer casts: stock tcc 0.9.26 neither canonicalizes an
+# unspilled 64->32-bit cast nor zero-extends an unsigned 32->64-bit cast.
+# The former lets stale high bits affect SLT/SLTU; the latter turns values
+# such as 0x80000000 into 0xffffffff80000000. Make gen_cvt_sxtw handle
+# both signs when widening and invoke its ADDIW path when narrowing.
+# The widening bug hits be64() in the seed kernel's DTB parser and makes
+# MMU setup panic. Each patch is gated on RISC-V-specific context so it
+# is a no-op on other targets.
apply_our_patch riscv64-cvt-int-zext "$SRC/tccgen.c"
+apply_our_patch riscv64-cvt-int-narrow "$SRC/tccgen.c"
apply_our_patch riscv64-gen-cvt-sxtw "$SRC/riscv64-gen.c"
apply_our_patch riscv64-load-ptr-zext "$SRC/riscv64-gen.c"
diff --git a/docs/TCC.md b/docs/TCC.md
@@ -146,136 +146,43 @@ with `assert fail: 0` then SIGSEGV). The per-file workaround is
load-bearing for any tcc-0.9.26-on-QEMU build, not specific to our
path.
-## Known limitations (riscv64)
+## riscv64 integer-width codegen
-aarch64 and amd64 are at full self-host parity (cc.scm path matches
-the gcc-built control on every fixture). riscv64 has two real open
-items, both rooted in tcc's riscv64 backend rather than in cc.scm
-or the P1 pipeline.
+RV64 keeps 32-bit values in sign-extended register form, including
+`unsigned int`; an unsigned value is zero-extended only when it is
+widened to a 64-bit C type. `LW` for an unsigned 32-bit lvalue is
+therefore intentional. Changing it to `LWU` breaks comparisons against
+the backend's sign-extended 32-bit constants.
-### riscv64: u32 narrowing leaves dirty upper bits
+The widening direction is handled by `riscv64-cvt-int-zext` and
+`riscv64-gen-cvt-sxtw`: signed `int` widens with `ADDIW`, while
+`unsigned int` widens with `SLLI 32; SRLI 32`.
-`tests/cc/335-ternary-merge-arith-conv` fails on riscv64 in both
-`tcc-cc[stage2]` and `tcc-cc[stage3]` (identical behavior — the
-fixed-point property holds, the bug is in tcc's RISC-V codegen, not
-in cc.scm or the P1 pipeline). aarch64 and amd64 are green.
-
-The proximate trigger is in `riscv64-gen.c::load()`:
+The narrowing direction needs an explicit operation too. Upstream tcc
+only relabels an unspilled 64-bit register when casting it to `int` or
+`unsigned int`. The stale upper half can then affect an RV64 relational
+comparison, whose `SLT`/`SLTU` operates on all 64 bits. This is the minimal
+failing shape:
```c
-func3 = size == 1 ? 0 : size == 2 ? 1 : size == 4 ? 2 : 3;
-if (size < 4 && !is_float(sv->type.t) && (sv->type.t & VT_UNSIGNED))
- func3 |= 4; // promotes lb→lbu, lh→lhu, but skips lw→lwu
-```
-
-The `func3 |= 4` promotion to LWU is gated on `size < 4`, so a 4-byte
-unsigned load uses LW (sign-extending) instead of LWU (zero-extending).
-`gen_cast` to `VT_INT|VT_UNSIGNED` from a wider source emits no
-narrowing — it relies on the use-time load to truncate, but with LW
-the high u32 bits of the source leak through. `(u32)x` where `x` is
-`u64` with bit 31 set then evaluates to `0xFFFFFFFFFFFFFFFF`. This
-same bug is present in upstream tcc mob.
-
-**Why the one-line patch isn't enough.** Widening the gate to
-`size <= 4` (so 4-byte unsigned loads use LWU) regresses
-`017-int-arith` and `128-cast-signedness`. They were passing because
-two compensating bugs canceled out: stock tcc on riscv64 also
-sign-extends unsigned 32-bit immediate constants (`LUI`/`ADDI` with a
-bit-31-set value), so a comparison between an `unsigned int`
-variable (loaded with sign-extending LW) and an `unsigned int`
-constant (loaded with sign-extending LUI/ADDI) had matching dirty
-upper bits and `BEQ` saw them as equal. Fixing only the load breaks
-that join, because the compare path also lies — `BEQ` is a 64-bit
-instruction but C semantics require 32-bit width for `unsigned int ==
-unsigned int`.
-
-**Full fix shape.** Three coupled pieces: (1) load — emit LWU for
-unsigned 4-byte loads; (2) immediate — clear bits 32–63 when
-materializing an unsigned 32-bit constant with bit 31 set; (3)
-compare — eagerly canonicalize 32-bit-typed values into zero-extended
-or sign-extended form (per `VT_UNSIGNED`) after every op that can
-leave the upper half dirty. Pieces 2 and 3 overlap: if values are
-canonicalized at every produce site, the load fix becomes one of many
-sites that need to do it. This is what gcc/clang's RISC-V backends
-do, and it's beyond the scope of the literal-block `simple-patches`
-mechanism — file upstream or write a real canonicalization pass.
-
-For now: known limitation, document, move on. The scalar codegen
-elsewhere on riscv64 is fine — only u32 narrowing of a wider source
-trips it.
-
-### riscv64: tcc1 ≠ tcc2 (cc.scm behavioral bug)
-
-`boot3.sh` + `boot4.sh` produce three staged compilers:
-
-- `tcc0` = tcc-source compiled by cc.scm (boot3 output)
-- `tcc1` = tcc-source compiled by tcc0 (boot4)
-- `tcc2` = tcc-source compiled by tcc1 (boot4)
-
-The fixed-point check is **`tcc1 == tcc2`** (asserted at the end of
-`boot4.sh`). It holds on aarch64 and amd64: `tcc0(tcc.flat.c)` and
-`tcc1(tcc.flat.c)` are byte-identical, so the two stages' identical
-archive-based link lines yield identical binaries. On riscv64 it does
-*not* hold: `tcc0(tcc.flat.c)` produces a 616100-byte `.o` while
-`tcc1(tcc.flat.c)` and `tcc2(tcc.flat.c)` produce a byte-identical
-615892-byte `.o` — 208 bytes larger from tcc0 (200 in `.text` + 8
-ripple in symtab/reloc offsets). Until this is fixed, riscv64 boot4
-needs `TCC_BOOTSTRAP_RELAX_FIXEDPOINT=1`.
-
-This is a **bug to investigate**, not just a "fatter code"
-observation. cc.scm should be a *faithful* (semantics-preserving)
-compiler — slower or larger output is acceptable, but tcc0 and tcc1
-must produce byte-identical output when run on the same source.
-That they don't on riscv64 means cc.scm's translation of tcc.flat.c
-into tcc0 changed what tcc0 *does at runtime*, not just how it's
-encoded. We don't care about peephole optimizations being missed; we
-do care that tcc0 makes different codegen decisions than tcc1
-makes.
-
-#### What's known
-
-The visible symptom: tcc0 emits 4 RISCV codegen patterns differently
-than tcc1 does:
-
-| Source pattern | tcc0 emits | tcc1 emits | Δ |
-|---|---|---|---|
-| `x = x - imm` (i32) | `addiw t,zero,imm; addw rd,rs,t` | `addiw rd,rs,imm` | +4 B |
-| `x = x & imm` | `addiw t,zero,imm; and rd,rs,t` | `andi rd,rs,imm` | +4 B |
-| zero-ext after `sext.w` | `sext.w r,r; slli r,r,0x20; srli r,r,0x20` | `sext.w r,r` | +8 B |
-| `x == 0xFFFFFFFF` (i32) | `addiw t,zero,-1; slli/srli; beq x,t,L` | `addi x,x,1; beqz x,L` | +8 B |
-
-These are decision points in `riscv64-gen.c` (immediate-folding,
-zero-ext elision). Same source code, same input C, but the running
-tcc0 takes the slow branch where the running tcc1 takes the fast
-one — even though both are compiled from the same `tcc.flat.c`.
-
-#### Hypothesis to test
-
-cc.scm likely miscompiles an integer comparison or bit-test inside
-the immediate-fits-in-instruction guard in `riscv64-gen.c`. Most of
-the missed patterns share the shape `if (small_int_fits) { fold } else
-{ materialize }`. If cc.scm gets the predicate wrong (e.g. signed vs.
-unsigned compare, or wrong branch on a particular bit pattern), tcc0
-falls into the materialize path on inputs where tcc1 takes the fold
-path.
-
-#### Repro / starting point
+typedef unsigned int u32;
+typedef unsigned long u64;
+static u64 ident(u64 x) { return x; }
-```sh
-# In the riscv64 container with boot3+boot4 outputs present:
-$TCC0 -nostdlib -c -o /tmp/flat-tcc0.o tcc.flat.c
-$TCC1 -nostdlib -c -o /tmp/flat-tcc1.o tcc.flat.c
-# wc -c /tmp/flat-tcc0.o /tmp/flat-tcc1.o → 616100 vs 615892
-# objdump -d both, normalize addresses, diff to find divergent functions
+(u32)ident(0x1234567800000000UL) < 1U; /* must be true */
```
-The first divergent function in disassembly is `tal_free_impl` — a
-small refcount-decrement that hits the "x = x - 1" pattern. Good
-starting point because the function is short and the source path is
-narrow.
-
-Until this is fixed, tcc1 is the "shake-out" stage and tcc2 is the
-canonical compiler.
+Stock codegen compares the unchanged `0x1234567800000000` register and
+returns false. The `riscv64-cvt-int-narrow` patch makes `gen_cast()`
+emit `ADDIW r,r,0` at a 64-to-32-bit boundary, producing the canonical
+sign-extended low word before any comparison. The regression is
+`tests/cc/341-riscv64-u32-register-narrow`.
+
+The self-host chain is now a clean fixed point: tcc0 and tcc1 emit
+byte-identical `tcc.flat.c` objects, and boot4 verifies `tcc1 == tcc2`
+without `TCC_BOOTSTRAP_RELAX_FIXEDPOINT`. An older 208-byte object-code
+difference documented here predated the widening patches; it came from
+TCC's old RV64 widening behavior, not a current cc.scm miscompile.
## AT-series patches (post-bootstrap uniformity)
diff --git a/tests/cc/341-riscv64-u32-register-narrow.c b/tests/cc/341-riscv64-u32-register-narrow.c
@@ -0,0 +1,42 @@
+/* A 64-bit expression narrowed to unsigned int must discard bits 63:32
+ * even when it stays in a register. RISC-V comparisons operate on the
+ * full XLEN register, so stale upper bits otherwise make an immediately
+ * following unsigned relational comparison use the pre-cast u64 value.
+ *
+ * Keep the value behind a call: local lvalues are loaded with LW and do
+ * not exercise the unspilled-register path. */
+typedef unsigned int u32;
+typedef unsigned long u64;
+
+static u64 ident(u64 x)
+{
+ return x;
+}
+
+int main(void)
+{
+ u32 one = 1;
+
+ if (!((u32)ident(0x1234567800000000UL) < 1U))
+ return 1;
+ if ((u32)ident(0x1234567800000000UL) >= one)
+ return 2;
+ if (!((u32)ident(0x1234567800000000UL) <
+ (u32)ident(0xabcdef0100000001UL)))
+ return 3;
+
+ if ((u32)ident(0x12345678ffffffffUL) != 0xffffffffU)
+ return 4;
+ if ((u64)(u32)ident(0x12345678ffffffffUL) != 0xffffffffUL)
+ return 5;
+ if ((u32)ident(0x1234567880000000UL) != 0x80000000U)
+ return 6;
+ if ((u64)(u32)ident(0x1234567880000000UL) != 0x80000000UL)
+ return 7;
+ if (!((int)ident(0x1234567800000000UL) < 1))
+ return 8;
+ if ((int)ident(0x12345678ffffffffUL) != -1)
+ return 9;
+
+ return 0;
+}
diff --git a/tests/cc/341-riscv64-u32-register-narrow.expected-exit b/tests/cc/341-riscv64-u32-register-narrow.expected-exit
@@ -0,0 +1 @@
+0
diff --git a/vendor/tcc/patches/riscv64-cvt-int-narrow.after b/vendor/tcc/patches/riscv64-cvt-int-narrow.after
@@ -0,0 +1,14 @@
+#if defined(TCC_TARGET_RISCV64)
+ } else if ((dbt & VT_BTYPE) == VT_INT &&
+ ((sbt & VT_BTYPE) == VT_LLONG ||
+ (sbt & VT_BTYPE) == VT_PTR ||
+ (sbt & VT_BTYPE) == VT_FUNC)) {
+ /* RV64 keeps every 32-bit register value sign-extended,
+ including unsigned int. A cast from a 64-bit register
+ must actually discard bits 63:32; relabelling alone lets
+ those bits affect a following 64-bit SLT/SLTU. */
+ gv(RC_INT);
+ vtop->type.t = VT_INT;
+ gen_cvt_sxtw();
+#endif
+ } else if (dbt == VT_BOOL) {
diff --git a/vendor/tcc/patches/riscv64-cvt-int-narrow.before b/vendor/tcc/patches/riscv64-cvt-int-narrow.before
@@ -0,0 +1 @@
+ } else if (dbt == VT_BOOL) {