boot2

Playing with the boostrap
git clone https://git.ryansepassi.com/git/boot2.git
Log | Files | Refs | README

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:
Mboot/boot4.sh | 4+---
Mbootprep/stage1-flatten.sh | 17+++++++++--------
Mdocs/TCC.md | 151+++++++++++++++----------------------------------------------------------------
Atests/cc/341-riscv64-u32-register-narrow.c | 42++++++++++++++++++++++++++++++++++++++++++
Atests/cc/341-riscv64-u32-register-narrow.expected-exit | 1+
Avendor/tcc/patches/riscv64-cvt-int-narrow.after | 14++++++++++++++
Avendor/tcc/patches/riscv64-cvt-int-narrow.before | 1+
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) {