Skip to content

Bare-metal RV32 support and a qemu smoke test - #24

Closed
pawlex wants to merge 1 commit into
hchunhui:masterfrom
pawlex:rv32-baremetal
Closed

Bare-metal RV32 support and a qemu smoke test#24
pawlex wants to merge 1 commit into
hchunhui:masterfrom
pawlex:rv32-baremetal

Conversation

@pawlex

@pawlex pawlex commented Aug 14, 2026

Copy link
Copy Markdown

Runs the i386 core on a bare-metal 32-bit RISC-V target — no OS, no heap,
no host libc beyond picolibc. Verified under qemu-system-riscv32.

make -C rv32 run

Default builds are unaffected. With no new macros defined and on a
non-RISC-V target, i386.c compiles exactly as before — the three
changes are all behind guards.

Changes to i386.c

change guard why
x87 becomes opt-out -DI386_DISABLE_FPU The integer core uses no floating point; fpu.c holds the project's only FP references. Dropping it avoids pulling soft-float into targets without an FPU. Stubs for the disabled case already existed.
get_nticks() uses rdcycle/rdcycleh #if defined(__riscv) && __riscv_xlen == 32 Bare metal has no clock_gettime. Feeds the emulated RDTSC; counting CPU cycles is arguably closer to real x86 TSC semantics than the host build's nanoseconds.
two mallocs become static -DTINY386_STATIC_ALLOC No heap. CPUI386 512 B + 12 KB TLB. Assumes a single CPU instance.

Plus baremetal_stubs.c for usleep(), called on the idle path at
i386.c:5030. Nothing else was needed — picolibc's semihosting crt0
supplies stdout/stderr/_exit, so no fprintf/abort stubs and
printf works under qemu.

Smoke test

rv32/ holds a small flat-32-bit payload whose every emitted value is
computed rather than constant, so the output can't be produced by
anything but real execution — squares via IMUL and a decrementing ECX
through LOOP, a memory round-trip surviving an XOR EAX,EAX clobber,
and a byte store read back with MOVZX.

   OUT32 port=0x080 <= 0x00000019     25 = 5^2
   ...
   OUT32 port=0x080 <= 0xa5a5a5a5     memory round-trip
   OUT32 port=0x080 <= 0x00000042     byte store + movzx
   OUT32 port=0x080 <= 0xdeadbeef     end marker
RESULT: PASS

Footprint

-Os -DNDEBUG, FPU off, picolibc:

target .text
rv32im 159,361 B
rv32imc 119,917 B
x86-64 (host reference, with x87) 140,422 B

plus ~12 KB writable state excluding guest RAM.

Why the Makefile defaults to rv32im

Compressed instructions are 25% smaller, which looks decisive until the
core running it is measured. Tested against VexRiscv on a Lattice
ECP5 LFE5U-85F
(yosys 0.68 + nextpnr-ecp5, out-of-context):

variant LUT4 Fmax
VexRiscv_Full 3,908 96.55 MHz
VexRiscv_IMAC (RVC) 4,533 57.43 MHz

The decompressor lands on the critical path — 40% of the clock for 25%
of the code. Smaller code helps I-cache hit rate but would need to be
worth 68% to break even. Override with make ARCH=rv32imc where
compressed support is cheap. rv32/README.md has the full variant table.

Not covered

rdcycle is unexercised (the payload never runs RDTSC), interrupts are
untouched, and the payload is flat 32-bit via cpui386_reset_pm() rather
than real mode.

Runs the i386 core on a bare-metal 32-bit RISC-V target -- no OS, no
heap, no host libc beyond picolibc. Verified under qemu-system-riscv32
with `make -C rv32 run`.

Three changes to i386.c, all conditional. Default builds are unaffected:
with no new macros defined and on a non-RISC-V target, i386.c compiles
exactly as before.

  -DI386_DISABLE_FPU      x87 becomes opt-OUT rather than always on. The
                          integer core uses no floating point (fpu.c
                          holds the project's only FP references), so
                          this avoids pulling soft-float into targets
                          without an FPU. Stubs for the disabled case
                          already existed.

  __riscv && xlen == 32   get_nticks() reads the 64-bit cycle counter via
                          rdcycle/rdcycleh instead of clock_gettime,
                          which bare metal does not have. This feeds the
                          emulated RDTSC, and counting CPU cycles is
                          arguably closer to real x86 TSC semantics than
                          the host build's nanoseconds. Note the `cycle`
                          CSR is optional in RISC-V.

  -DTINY386_STATIC_ALLOC  the two mallocs become static (CPUI386 512 B,
                          TLB table 12 KB). Assumes a single CPU
                          instance.

baremetal_stubs.c adds usleep(), called on an idle path at i386.c:5030;
a no-op suffices for a functional test, though an embedded integration
would make it a WFI or a yield. Nothing else was needed -- picolibc's
semihosting crt0 supplies stdout/stderr/_exit, so no fprintf or abort
stubs are required and printf works under qemu.

rv32/ holds the smoke test: a small flat-32-bit payload whose every
emitted value is COMPUTED rather than constant, so the output cannot be
produced by anything but real execution -- squares via IMUL and a
decrementing ECX through LOOP, a memory round-trip that survives an
XOR EAX,EAX clobber, and a byte store read back with MOVZX.

Footprint (-Os -DNDEBUG, FPU off, picolibc):

  rv32im   .text  159,361 B
  rv32imc  .text  119,917 B
  x86-64   .text  140,422 B   (host reference, with x87)

plus ~12 KB writable state excluding guest RAM.

The Makefile defaults to rv32im rather than rv32imc despite compressed
instructions being 25% smaller. Measured against VexRiscv on a Lattice
ECP5 LFE5U-85F (yosys 0.68 + nextpnr-ecp5, out-of-context): the RVC
decompressor lands on the critical path, costing 40% of Fmax (57.43 MHz
vs 96.55) and 16% more logic. Smaller code helps I-cache hit rate but
would have to be worth 68% to break even, which for a throughput-bound
interpreter it is not. Override with `make ARCH=rv32imc` where compressed
support is cheap. rv32/README.md carries the full variant table.

Not covered by the test, and documented as such: rdcycle is unexercised
(the payload never executes RDTSC), interrupts are untouched, and the
payload runs flat 32-bit via cpui386_reset_pm rather than real mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hchunhui

Copy link
Copy Markdown
Owner

There is no longer a hardcoded I386_ENABLE_FPU in the current main branch (see commit 5805950).

For other changes, consider adding clock_gettime() and malloc() (implemented as a simple bump allocator) in baremetal_stubs.c.

@pawlex

pawlex commented Aug 14, 2026

Copy link
Copy Markdown
Author

understood -- realized after PR this was working with a stale branch. If there is interest from upstream, I will re-generate after integration is complete.

@pawlex pawlex closed this Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants