SageBoot is the unified, modular, multi-architecture bootloader for SageOS. It provides standard low-level hardware initialization, memory discovery, configuration parsing, secure kernel validation, and handoff across 8 target architectures.
| Arch | CPU/Platform | Status | Notes |
|---|---|---|---|
| rv64 | RISC-V 64 (QEMU Virt, S-mode) | β Verified | Boots via OpenSBI, prints banner and RAM test output |
| arm64 | AArch64 (QEMU Virt) | π‘ Builds, QEMU WIP | SIMD/FP alignment fault in generated C code |
| x64 | x86_64 PC (Multiboot v1) | π‘ Reaches main, no output |
3 boot bugs fixed; one #UD left, see below |
| rp2040 | ARM Cortex-M0+ (Raspberry Pi Pico) | π¦ Builds | No QEMU support for Cortex-M0+ |
| rp2350_arm | ARM Cortex-M33 (Raspberry Pi Pico 2) | π¦ Builds | No QEMU support for Cortex-M33 |
| rp2350_rv | RISC-V Hazard3 32-bit (RP2350) | π¦ Builds | Links with soft-float ABI; custom compiler-rt stubs |
| mips | MIPS32 r2 (BCM5357, WN3000RP) | π¦ Builds | Needs mipsel-linux-gnu-as cross-toolchain |
| esp32 | Xtensa LX6 (ESP32-D0WD-V3 / ESP-WROOM-32) | π¦ Builds | Builds cleanly; not yet run on hardware. Toolchain is not on PATH |
$ bash test/test_all.sh
PASS: 1 FAIL: 3 SKIP: 4 Total: 8
PASS rv64 RISC-V 64 QEMU Virt boots OK
FAIL arm64 AArch64 QEMU Virt no serial output
FAIL x64 x86_64 PC (Multiboot) no serial output
SKIP rp2040 RP2040 Cortex-M0+ no QEMU available
SKIP rp2350_arm RP2350 ARM Cortex-M33 no QEMU available
FAIL rp2350_rv RP2350 RISC-V Hazard3 output mismatch
SKIP mips MIPS 74Kc (Netgear WN3000RP) missing cross-toolchain
SKIP esp32 ESP32 Xtensa LX6 no QEMU available
All eight architectures build. rv64 boots under QEMU. The three failures are runtime problems, not build problems.
x64: three real bugs found and fixed, one left. The suite still reports "no serial output", but the guest now gets a great deal further than it used to, and each step was confirmed from QEMU register dumps rather than guessed at:
-
Long mode was entered with a 32-bit CS.
CR0.PGwas set before the GDT was loaded and before the far jump, so the very next instruction fetch ran in long mode under the bootloader's 32-bit CS. The fault landed on an IDT that still described the real-mode IVT (base 0, limit 0x3ff), so it escalated to a double fault and then a reset. Observed directly:CS=0008 CS64withIDT=0000000000000000 000003ffandcheck_exception old: 0x8 new: 0xe. Reordered to the canonical sequence: page tables, PAE, CR3, LME,lgdt,ljmp, thenCR0.PG.orq $0x80000001, %raxalso had to become twobtsqs -- a 64-bit immediate that large cannot be encoded in oneor. -
.bsswas zeroed after the page tables were built.pml4_tableandpdp_tablelive in.bss, and the clear was step 13 while the tables were populated at step 3, so it wiped them and leftCR3pointing at zeroes. The clear now happens first, before anything is built. -
The GDT descriptors were not long-mode descriptors.
0x00209A0000000000and0x0000920000000000have comments claiming "64-bit" but decode toL=0and limit0-- a 32-bit code segment and a zero-length data segment. The far jump to selector 0x08 therefore loaded a 32-bit CS, long mode was never entered, and the first segment load faulted (8E /rwithmod=11needsREX.Bin 64-bit mode). Replaced with0x00AF9A000000FFFFand0x00CF92000000FFFF. The Multiboot header'sbss_end_addralso pointed at_code_end, which is below__bss_start, so Multiboot1 was asked to zero a backwards range; it now points at__bss_end.After these, QEMU shows
CR0=8000801b(PE+PG),CR3=112000,CR4=61f(PAE, OSFXSR, OSXMMEXCPT) and execution reachingmainat0x1004e0viacall 1004e0 <main>. An explicitfninitwas also removed: it raised#NMon QEMU'spcmachine and the SSE state is initialised by the C runtime anyway. -
Descriptor pointers were mis-encoded.
lgdt/lidttake a memory operand only, and a RIP-relative reference to a table in.rodatawas encoded by the assembler with the symbol's absolute address in the displacement slot, so the CPU read the pointer from the wrong place. The linker script merges.rodatainto.text, but the two are still separate input sections at assembly time. Fixed by loading the address with an absolute 32-bitmovl $sym, %eaxand using(%eax), which is anR_X86_64_32the linker resolves correctly. Notelgdtis emitted in.code32, so it needs%eaxrather than%rax.
Where x64 actually gets to now. A minimal 256-entry IDT is installed, all
entries pointing at a diagnostic handler that prints the vector number and the
faulting RIP to COM1 and then halts. That is a diagnostic, not a real handler,
and it should be replaced before this port is trusted -- but without it every
fault was unrecoverable (#UD -> #PF -> #DF -> triple fault -> reset ->
SeaBIOS), which is why the earlier failures destroyed their own evidence.
With the IDT in place the guest runs for thousands of basic blocks with no
faults at all, and a raw outb of a marker byte written immediately before
call main does reach the serial port. So: the handoff works, long mode works,
paging works, and port I/O works, and the guest does reach main. The remaining
silence is therefore inside main, before its first uart_print -- the Sage
runtime's start-up, not the boot path. (The byte that arrived was 0x14 rather
than the 0x2A written, which is unexplained and is the first loose end to pick
up; it may simply be a QEMU serial-file artefact.)
With the diagnostic IDT installed the exception chain is short and readable:
0: #UD at pc=0x100d40 EAX=0x914 SP=0x11ce9c
1: #PF e=0x32
2: #DF
0x100d40 is mid-instruction: the instruction there is the
call sage_gc_mark_value at 0x100d3d, five bytes long. An #UD reported
part-way through an instruction is not that instruction being invalid -- it means
control flow arrived there from elsewhere. So this is a jumped-to-a-bogus-address
in the GC's array-marking loop, not an illegal opcode in the boot path.
Proven this round, all of it measured rather than inferred:
- Port I/O works. A raw
outbof0x41written by the first instruction of the image reaches the console. So the ROM loads the image at the address we think, the first instruction executes, COM1 is the QEMU serial device, and-serial file:captures it. Every earlier claim that the console was dead was wrong, including the claim that a0x14byte proved port I/O -- that byte was almost certainly SeaBIOS output after the triple fault. - The
switch (value.type)jump table is correct.sage_gc_mark_valuedispatches withjmp *0x110c40(,%rdi,8). All 13 entries were read out of the ELF and each lands inside.text, at plausible per-case addresses. So the codegen is fine. - Every
value.typereaching the GC is in range. A guard was added tosage_gc_mark_valuethat reports and halts on a tag outside 0..12, using raw port I/O. It never fired. So a wild jump is not an out-of-range type tag. LIDTdoes not take effect.sidtqread back from inside the guest gives an IDT limit whose low byte is0xd2, not the0x07ffthat was installed, and a base that is not0x100110. Thelidtqblock provably executes, and the 10-byte pointer in the image is byte-for-byte correct (limit0x07ff, base0x0000000000100110). Theqsuffix changes nothing, becauseLIDTtakes a 10-byte operand in 64-bit mode regardless.
The IDTR readback itself is unreliable and was removed: it emitted one byte of the ten it should have. That is the third diagnostic in a row to behave unexpectedly on this target, and it is why the IDT is still not delivering reports from inside the guest.
Two earlier misreadings worth recording so they are not repeated: the
mov -0x8(%rax,%r14,1),%edi in the array loop is a 32-bit int field for the
SageValue struct-passing ABI, not a truncated pointer; and the loop's stride of
16 is sizeof(SageValue), which is correct on x86-64. A compiled probe confirms
SageValue=16, SageSlot=24, SageFunction=40, all as expected.
Honest status: x64 does not work yet. The boot path is sound -- long mode,
paging, the GDT, port I/O and entry to main are all confirmed -- and the
remaining fault is in the Sage runtime's GC, which walks a corrupt array and
jumps somewhere invalid. Because the same runtime boots correctly on rv64, this
is not x64-specific in the boot code; it is a runtime/GC issue that this
allocator and layout happen to expose.
Next steps, in order: work out why LIDT does not load the table (the image data
is provably correct, so the fault is in how the descriptor is being read or in
the segment state at that point); then, with the in-guest handler working, get a
report from the GC walk itself; then replace the diagnostic IDT with real
handlers. The diagnostic IDT, the panic handler and the first-instruction marker
are all left in place because each is individually proven to work.
arm64 hits a SIMD/FP alignment fault. rp2350_rv produces only the OpenSBI banner -- the kernel is never reached, so the memory map or the QEMU load address is wrong rather than the image being malformed.
Until recently, all six previously-working clang architectures failed to build, with 17 errors each:
bootloader.c:293:29: error: call to undeclared function 'atomic_load_explicit'
bootloader.c:293:70: error: use of undeclared identifier 'memory_order_acquire'
bootloader.c:332:33: error: call to undeclared function 'atomic_fetch_add_explicit'
compat/include/stdatomic.h declared only atomic_int and atomic_long and
nothing else, while the current sage compiler emits the full C11 atomics API
and the emitted C does #include <stdatomic.h>. Implicit function
declarations are a hard error under current clang defaults, so it was not a
warning. The header is now a complete implementation.
Two implementation details worth knowing before changing it again:
- Neither builtin family covers everything. The older
__atomic_*family is complete but clang rejects a pointer to an_Atomictype as its address argument ("address argument to atomic operation must be a pointer to integer"). The C11-aware__c11_atomic_*family accepts those pointers, but clang 21 provides no__c11_atomic_compare_exchange,__c11_atomic_is_lock_free,__c11_atomic_test_and_setor__c11_atomic_clear-- verified by probing each onriscv64-none-elf. So the types are plain integers and the atomicity comes from the builtins. A bare read or write of anatomic_intoutside these functions is therefore not atomic; C11 already leaves that undefined and the generated C does not do it, and layout stays identical to a real_Atomic int. - Cortex-M0+ has no atomics at all. ARMv6-M has no LDREX/STREX, so GCC emits
calls to
__atomic_load_4,__atomic_fetch_add_4and__atomic_compare_exchange_4in libatomic, which does not exist for a freestanding link. That is hardware, not a missing header, so the whole family is implemented in software for__ARM_ARCH < 7with interrupts masked (CPSID/CPSIE) around each read-modify-write. Sound because SageBoot is single-core and never enables interrupts; that is the premise, so it is stated in the header rather than assumed. The*_LOCK_FREEmacros report 0 there instead of claiming 2.
esp32 is a build, not a verified boot. It has not been flashed or run.
SageBoot/
βββ arch/ # Architecture-specific directories
β βββ x64/ # x86_64 (PC / Multiboot v1)
β βββ rv64/ # RISC-V 64 (SBI / Supervisor)
β βββ arm64/ # AArch64 (ARM64)
β βββ mips/ # MIPS32 (mipsel / WN3000RP)
β βββ rp2040/ # RP2040 Cortex-M0+ (Raspberry Pi Pico)
β βββ rp2350_arm/ # RP2350 Cortex-M33 (Raspberry Pi Pico 2)
β βββ rp2350_rv/ # RP2350 RISC-V Hazard3 (Raspberry Pi Pico 2)
β βββ esp32/ # ESP32 Xtensa LX6 (classic ESP32, D0WD-V3)
βββ compat/ # Cross-platform freestanding C library shims
β βββ compat.c # Memory/string/printf + soft-float stubs
β βββ include/ # Standard C header declarations
βββ src/ # Unified Stage 1 Bootloader (Pure SageLang)
β βββ bootloader.sage # Main entry, verification, and boot coordinator
β βββ menu.sage # Text-mode interactive boot menu UI
β βββ config.sage # Config parser for boot.cfg
β βββ fs_fat.sage # Minimal FAT12/16/32 directory parser
β βββ elf.sage # ELF64 segment loader & entry point detector
β βββ handoff.sage # Standardized handoff protocol builder
βββ test/ # Test suite
β βββ test_all.sh # Multi-architecture build + QEMU test runner
βββ patch_bootloader.py # Code patcher for arch-specific boot jump
βββ Makefile # Cross-compilation orchestrator
- 8 Target Architectures: x86_64 (Multiboot v1), AArch64, RISC-V 64 (SBI), MIPS32, RP2040 (Cortex-M0+), RP2350 (ARM & RISC-V), ESP32 (Xtensa LX6)
- Indentation-Based Logic: Stage 1 bootloader written in SageLang for memory safety and readability
- Dynamic Boot Menu: Built-in interactive text menu interface with customizable timeout settings
- Configuration Parsing: Reads and parses
boot.cfgto configure boot parameters dynamically - Secure Boot & Verification: SHA-256 and cryptographic verification stubs for kernel validation
- ELF64 & SGVM Loader: Parsers for raw executable ELF segments and VM bytecode containers
- Unified Boot Handoff: Standardized
SAGEOSBIstructure passing memory maps, framebuffers, kernel metadata, ACPI RSDP, and boot arguments - Soft-Float ABI Support: Full software IEEE 754 double-precision math for RISC-V 32-bit via
compat.cstubs
- SageLang compiler (
sagebinary) - LLVM/clang with cross-compilation targets
- Architecture-specific binutils (
riscv64-linux-gnu-*,aarch64-linux-gnu-*, etc.)
# Build for a specific architecture
make ARCH=rv64 # RISC-V 64 (default)
make ARCH=x64 # x86_64 Multiboot
make ARCH=arm64 # AArch64
make ARCH=rp2040 # RP2040 Cortex-M0+
make ARCH=rp2350_arm # RP2350 Cortex-M33
make ARCH=rp2350_rv # RP2350 RISC-V Hazard3
make ARCH=mips # MIPS 74KcThe build pipeline:
- Compiles
src/bootloader.sageβbootloader.cvia SageLang C backend - Patches
bootloader.cwith arch-specific jump code viapatch_bootloader.py - Assembles
arch/$(ARCH)/boot.Sand compiles C sources with clang (freestanding) - Links with
arch/$(ARCH)/linker.ldβsageboot.elf+sageboot.bin
# Run the full test suite
bash test/test_all.sh
# Manual QEMU boot (rv64 example)
qemu-system-riscv64 -machine virt -cpu rv64 -m 512M \
-bios default -serial stdio -kernel sageboot.bingraph TD
A[Power On / Reset Vector / Firmware] --> B[Stage 0: arch/.../boot.S runs]
B --> C[Initialize minimal Debug UART & System Timings]
C --> D[Load Stage 1 bootloader.sage into RAM]
D --> E[Jump to Stage 1 entry main]
E --> F[Stage 1: Read boot.cfg & scan storage partition]
F --> G[Present Interactive Boot Selection Menu]
G --> H[Read and parse SageOS Kernel ELF64 / SGVM]
H --> I[Perform Secure Boot Signature Verification]
I --> J[Construct SAGEOSBI handoff struct]
J --> K[Disable UART queues & jump to Kernel main]
Architecture-specific documentation is available in docs/:
docs/ARCHITECTURE.mdβ Overall architecture referencedocs/BUILD.mdβ Detailed build and cross-compilation guidedocs/HACKING.mdβ Developer guide for adding new architectures
MIT
ARCH=esp32. Everything in arch/esp32/ is derived from hardware facts
verified on a real ESP32-D0WD-V3 rather than recalled:
config.sageβ UART0 is0x3FF40000, and the status register is at+0x1C, not+0x04. The same register serves both directions the Sage sources need: bit 0 isrxfifo_full(whatmenu.sagepolls) and bits 23:16 aretxfifo_cnt. Writing to+0x1Cpokestxfifo_cntand the write-1-to-clear interrupt bits and wedges the console, which looks exactly like a firmware bug β it cost two debugging rounds before being pinned down. Also definesUART0_DATA,UART0_LSRandFLASH_BASE, whichbootloader.sageandmenu.sagereference but which only the mips config previously defined; every other architecture was missing them.boot.Sβ disarms the RTC, TG0 and TG1 watchdogs before anything else. The ROM leaves all three armed and an image that does not disarm them is reset before producing any output, which is indistinguishable from a failed image load. Sets botha1anda15, because in the Xtensa windowed ABIa15is the callee-saved stack pointer but every prologue'sentry a1, Npushes ontoa1; setting onlya15leaves nested calls walking off the end of whatever the ROM left there. The entry is a singlejstepping over the literal pool, sincel32rresolves only backwards and the ROM jumps to the start of the loaded segment. All peripheral addresses come from the literal pool rather than shift-built immediates:moviis 12-bit andaddi8-bit, so0x3ff480a4cannot be formed by either.linker.ldβ IRAM0 at0x40080000for code and the.datainitialisers, and the single contiguous DRAM block0x3FFCE000β0x40000000for.bss/heap/stack. Internal DRAM begins at0x3FFB0000and has a hole from0x3FFB6000to0x3FFCE000. Two earlier ESP32 maps in this project treated0x3FFB0000as 320 KB of "IRAM0" and0x3FF80000as DRAM; neither is true, and because the ROM loader will write a.dataimage anywhere, both link and flash cleanly and only fail at run time.
The Xtensa toolchain ships with the Arduino ESP32 core rather than on PATH:
make ARCH=esp32 # uses the default prefix
make ARCH=esp32 XTENSA_PREFIX=/opt/xtensasrc/bootloader.sage is written for the QEMU targets and is not yet correct
for a real ESP32:
- It looks for the kernel at
hw.RAM_START + 0x01000000β a 16 MB offset. The ESP32 has 520 KB of internal SRAM in total, so that address is unmapped. The app partition is at flash offset0x10000, i.e.0x40010000through DROM, which is whatKERNEL_LOAD_ADDRis set to inarch/esp32/config.sage. - It reads a hardcoded
boot.cfgstring rather than the real partition table thatgen_partitions.pywrites at0x8000. - The only
FLASH_BASE-based load path is the mips TRX one, guarded byif hw.ARCH_NAME == "mips".
src/bootloader.sage is shared by every architecture, so giving esp32 different
loader logic means either a per-arch loader source or moving the
kernel-location decision behind a config.sage hook. That refactor is the
remaining work before the ESP32 port can actually boot something, and it is
deliberately not done here.