diff --git a/.github/scripts/check-british-spelling.sh b/.github/scripts/check-british-spelling.sh index 3a9d849..dde10ba 100644 --- a/.github/scripts/check-british-spelling.sh +++ b/.github/scripts/check-british-spelling.sh @@ -70,6 +70,7 @@ ALLOWED_PATTERNS=( 'FORCE_COLOR|NO_COLOR' # de facto environment-variable standards '--color' # command-line flags of external tools 'colorspace|ColorSpace' # graphics API names (Vulkan, DRM) + 'virtualization=' # QEMU -machine property name, quoted literally ) allow_re=$(IFS='|'; echo "${ALLOWED_PATTERNS[*]}") diff --git a/.github/workflows/ci_main.yml b/.github/workflows/ci_main.yml index 5b08fcf..3b65129 100644 --- a/.github/workflows/ci_main.yml +++ b/.github/workflows/ci_main.yml @@ -121,6 +121,7 @@ jobs: set -euo pipefail cargo xtask boot-test --arch aarch64 --expect "Kaya!" cargo xtask boot-test --arch aarch64 --release --expect "Kaya!" + cargo xtask boot-test --arch aarch64 --features provoke-exception --expect "BRK instruction" ci-success: name: CI Success diff --git a/.github/workflows/ci_pr.yml b/.github/workflows/ci_pr.yml index 006d2b2..e1e2313 100644 --- a/.github/workflows/ci_pr.yml +++ b/.github/workflows/ci_pr.yml @@ -109,13 +109,19 @@ jobs: # -kernel, so that path needs a UEFI stub before it can boot at all. # `cargo xtask boot-test --arch x86_64` says so explicitly rather # than failing obscurely. + # Two boots: the ordinary path must greet, and the provoked path + # must fault into the exception reporter and decode the BRK — so the + # vector table is proven on every pull request, not trusted. - name: Boot in the devcontainer and expect the greeting uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 with: imageName: ${{ env.DEVCONTAINER_IMAGE }} cacheFrom: ${{ env.DEVCONTAINER_IMAGE }} push: never - runCmd: cargo xtask boot-test --arch aarch64 --expect "Kaya!" + runCmd: | + set -euo pipefail + cargo xtask boot-test --arch aarch64 --expect "Kaya!" + cargo xtask boot-test --arch aarch64 --features provoke-exception --expect "BRK instruction" ci-success: name: CI Success diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c810cc..a505a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ Release codenames follow the six Noongar seasons — Birak, Bunuru, Djeran, Maku ### Added +- **Exception vectors, and a reporter that says what went wrong in one line** — the instrument the + soft-float bug had to be diagnosed without. `boot.s` installs a 2 KiB-aligned sixteen-entry vector + table into `VBAR_EL1` before the first Rust instruction runs, so even the earliest fault is reported + rather than vectoring to address zero and dying mute. Every entry routes to a reporter that prints + which vector fired, the exception class decoded into a sentence — EC 0x07 names `CPACR_EL1.FPEN` + outright — and raw `ESR`/`ELR`/`FAR`/`SPSR`, then halts. + - The common stub forces `SPSel` back to SP_ELx first, so even the never-used SP_EL0 group lands + on a real stack instead of the uninitialised SP_EL0. + - **Self-testing in CI**: a `provoke-exception` feature (never on by default) boots, greets, then + executes `brk #0`; the boot job greps the console for the decoded "BRK instruction" report. The + handler is proven on every pull request, not trusted. + - `cargo xtask` grows `--features` passthrough for exactly that. + - Terminal by design: until there is a scheduler, every exception is a report and a halt — + recovery is policy, and there is nothing yet to recover to. When the timer interrupt arrives, + the IRQ entries grow a real save/restore frame; the clobber-freely discipline in `vectors.s` is + documented as ending on that day. + - The EL1 assumption in `boot.s` is now load-bearing (`VBAR_EL1`, `SPSel`) and its header says so: + an explicit CurrentEL check and EL2 descent are owed before real hardware or + `virtualization=on`. - **The kernel boots.** On QEMU aarch64 `virt` it reaches the PL011 UART and prints `Kaya!` and the resident critter (constitution §8). Nothing else: no scheduler, no IPC, no capability table, no MMU. What it establishes is the chain diff --git a/kernel/Cargo.toml b/kernel/Cargo.toml index fdbf4f3..a2d7697 100644 --- a/kernel/Cargo.toml +++ b/kernel/Cargo.toml @@ -18,5 +18,11 @@ path = "src/main.rs" test = false bench = false +[features] +# Boot, greet, then take a deliberate BRK — so the boot self-test can prove the +# exception vector table and reporter work end to end. Never on by default; +# enabled by `cargo xtask boot-test --features provoke-exception`. +provoke-exception = [] + [lints] workspace = true diff --git a/kernel/src/arch/aarch64/boot.s b/kernel/src/arch/aarch64/boot.s index aed629c..8dfe4c0 100644 --- a/kernel/src/arch/aarch64/boot.s +++ b/kernel/src/arch/aarch64/boot.s @@ -11,11 +11,15 @@ // Entry state on QEMU 'virt': // - The image is loaded at 0x4000_0000 and entered at _start with the MMU off, // caches off, and interrupts masked. -// - Execution begins at EL1 by default. This stub does not care which -// exception level it is at, because it only touches memory-mapped I/O and -// never programs a system register that is EL-specific. That stops being -// true the moment the MMU or the exception vectors are set up, at which -// point this stub must gain an explicit EL2-to-EL1 descent. +// - Execution begins at EL1: QEMU's built-in loader enters a `-kernel` image +// at EL1 unless the machine is created with virtualization=on, and the +// xtask harness never passes that. This stub now DOES program EL1-specific +// registers (VBAR_EL1 below; SPSel in the vector stub), so the EL1 +// assumption is load-bearing. Before real hardware or virtualization=on, +// this stub owes an explicit CurrentEL check and EL2-to-EL1 descent — +// at EL2 the writes below would be legal but useless, and a later fault +// would vector through VBAR_EL2 = 0 into exactly the silent death the +// table exists to prevent. // - Every core enters here simultaneously. Only the one with MPIDR_EL1.Aff0 // == 0 continues; the rest are parked until there is a scheduler able to // receive them. @@ -53,6 +57,15 @@ _start: b .Lzero_bss .Lbss_zeroed: + // Install the exception vector table before entering Rust, so that even + // the first Rust instruction faults diagnosably instead of vectoring to + // address zero. VBAR_EL1 requires 2 KiB alignment; vectors.s guarantees it. + // The isb orders the write before any instruction that could fault. + adrp x1, __vectors + add x1, x1, :lo12:__vectors + msr vbar_el1, x1 + isb + // Into Rust. The architecture-specific entry shim calls the kernel proper. bl rust_entry diff --git a/kernel/src/arch/aarch64/exception.rs b/kernel/src/arch/aarch64/exception.rs new file mode 100644 index 0000000..f7a9169 --- /dev/null +++ b/kernel/src/arch/aarch64/exception.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! The exception reporter — the instrument every later subsystem is debugged +//! with. +//! +//! Until there is a scheduler, every exception is terminal: the only job is to +//! say *what* happened, *where*, and *why*, precisely enough that no one ever +//! again spends an afternoon in a QEMU instruction trace working out that +//! `CPACR_EL1.FPEN` was zero. That bug — the kernel dying one instruction +//! before its first console character, on an FP/SIMD trap to a vector table +//! that did not exist — is the reason this module exists and the standard its +//! output is held to: the decoded line for exception class 0x07 names the +//! register that causes it. +//! +//! The reporter prints line by line, on the deliberately lock-free console, +//! for the same reason the panic handler does: when the machine is dying, half +//! a message on the wire is evidence, and a lock is a way to hang instead of +//! speak. It never returns — recovery is a policy decision, and there is +//! nothing yet to recover to. +//! +//! Designated `unsafe` module (`kernel/src/arch/**`): the entry point below +//! carries `#[unsafe(no_mangle)]` so the vector stub in `vectors.s` can reach +//! it by name. +#![allow(unsafe_code)] + +use crate::console::kprintln; + +/// Names the table entry an exception arrived through. +/// +/// The vector index encodes group (bits 3:2) and kind (bits 1:0), matching the +/// architectural table layout in `vectors.s`. +const fn vector_name(index: u64) -> (&'static str, &'static str) { + let group = match index >> 2 { + 0 => "current EL, SP_EL0", + 1 => "current EL, SP_ELx", + 2 => "lower EL, AArch64", + _ => "lower EL, AArch32", + }; + let kind = match index & 0b11 { + 0 => "synchronous", + 1 => "IRQ", + 2 => "FIQ", + _ => "SError", + }; + (kind, group) +} + +/// Decodes `ESR_EL1.EC` (bits 31:26) into the sentence a person needs. +/// +/// Only classes this kernel can plausibly meet are named; everything else +/// reports its raw value rather than guessing. The list grows as subsystems +/// do — a decoded class costs one line here and saves a datasheet lookup at +/// the worst possible moment there. +const fn exception_class_name(ec: u64) -> &'static str { + match ec { + 0b00_0000 => "unknown reason", + 0b00_0001 => "trapped WFI/WFE", + 0b00_0111 => { + "FP/SIMD access trapped by CPACR_EL1.FPEN — the kernel is soft-float; \ + something emitted a vector instruction" + } + 0b00_1110 => "illegal execution state", + 0b01_0101 => "SVC (AArch64 supervisor call)", + 0b01_1000 => "trapped MSR/MRS or system instruction", + 0b10_0000 => "instruction abort from lower EL (execute fault)", + 0b10_0001 => "instruction abort, same EL (execute fault — jumped somewhere unmapped?)", + 0b10_0010 => "PC alignment fault", + 0b10_0100 => "data abort from lower EL (read/write fault)", + 0b10_0101 => "data abort, same EL (read/write fault — FAR holds the address)", + 0b10_0110 => "SP alignment fault", + 0b10_1100 => "trapped floating-point exception (AArch64)", + 0b10_1111 => "SError interrupt", + 0b11_0000 => "breakpoint from lower EL", + 0b11_0001 => "breakpoint, same EL", + 0b11_0010 => "software step from lower EL", + 0b11_0011 => "software step, same EL", + 0b11_0100 => "watchpoint from lower EL", + 0b11_0101 => "watchpoint, same EL", + 0b11_1100 => "BRK instruction (AArch64)", + _ => "unrecognised exception class — decode ESR against the Arm ARM", + } +} + +/// The exception reporter. Reached only from the vector stubs in `vectors.s`. +/// +/// Prints the decoded exception, then halts this core for good. Written as +/// separate lines so a fault partway through the report still leaves the +/// earlier lines on the wire. +/// +/// # Safety +/// +/// Not for calling from Rust — `vectors.s` branches here with the syndrome +/// registers marshalled into the argument registers. The `no_mangle` exists +/// solely so the assembly can name it. +#[unsafe(no_mangle)] +extern "C" fn aarch64_exception(index: u64, esr: u64, elr: u64, far: u64, spsr: u64) -> ! { + let (kind, group) = vector_name(index); + let ec = (esr >> 26) & 0x3f; + + kprintln!(); + kprintln!("[EXCEPTION] {kind}, {group} (vector {index})"); + kprintln!(" EC = {ec:#04x} — {}", exception_class_name(ec)); + kprintln!(" ESR = {esr:#018x}"); + kprintln!(" ELR = {elr:#018x} (faulting PC)"); + kprintln!(" FAR = {far:#018x}"); + kprintln!(" SPSR = {spsr:#018x}"); + kprintln!(); + kprintln!("Core halted."); + + super::halt() +} diff --git a/kernel/src/arch/aarch64/mod.rs b/kernel/src/arch/aarch64/mod.rs index 5d50461..006dbee 100644 --- a/kernel/src/arch/aarch64/mod.rs +++ b/kernel/src/arch/aarch64/mod.rs @@ -8,12 +8,16 @@ //! `// SAFETY:` justification. #![allow(unsafe_code)] +mod exception; mod uart; -// The boot stub is assembled by LLVM as part of this crate rather than by an -// external assembler, so the toolchain stays exactly as pinned — no host `as` -// creeps into the build. +// The boot stub and the exception vector table are assembled by LLVM as part +// of this crate rather than by an external assembler, so the toolchain stays +// exactly as pinned — no host `as` creeps into the build. boot.s installs the +// table into VBAR_EL1 before Rust runs; the table's stubs call into +// `exception::aarch64_exception`. core::arch::global_asm!(include_str!("boot.s")); +core::arch::global_asm!(include_str!("vectors.s")); /// The Rust entry point, called by `_start` in `boot.s` once there is a stack /// and `.bss` has been zeroed. @@ -36,6 +40,25 @@ pub(super) fn console_write_str(s: &str) { uart::write_str(s); } +/// Deliberately takes a synchronous exception, to prove the vector table and +/// reporter work end to end. +/// +/// `brk #0` is the canonical choice: unconditional, undefined-behaviour-free, +/// and it arrives at the reporter as exception class 0x3C ("BRK instruction"), +/// which the boot self-test greps the console for. Compiled only under the +/// `provoke-exception` feature, which nothing enables by default. +#[cfg(feature = "provoke-exception")] +pub(super) fn provoke_exception() -> ! { + // SAFETY: `brk #0` has no operands and no memory effects; its entire + // purpose is to raise a synchronous exception, which the vector table + // installed by boot.s routes to the never-returning reporter. `noreturn` + // is sound because execution cannot proceed past a BRK whose handler + // halts. + unsafe { + core::arch::asm!("brk #0", options(nomem, nostack, noreturn)); + } +} + /// Parks this core in a low-power wait state, permanently. pub(super) fn halt() -> ! { loop { diff --git a/kernel/src/arch/aarch64/vectors.s b/kernel/src/arch/aarch64/vectors.s new file mode 100644 index 0000000..4fd63e7 --- /dev/null +++ b/kernel/src/arch/aarch64/vectors.s @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// AArch64 exception vector table. +// +// The architecture dictates nearly everything here, which is why this file is +// short: VBAR_EL1 points at a 2 KiB-aligned table of sixteen entries, each 128 +// bytes apart, in four groups of four. Groups, in order: exceptions taken from +// the current EL while on SP_EL0; from the current EL on SP_ELx; from a lower +// EL running AArch64; from a lower EL running AArch32. Within each group: +// synchronous, IRQ, FIQ, SError. +// +// Every entry does the same two things — load its own index into x0 and branch +// to the common stub — because at this stage of the kernel every exception has +// the same meaning: something unexpected happened, and the only job is to say +// so on the console with enough precision that nobody spends an afternoon in +// an instruction trace. The handler never returns, so registers are clobbered +// freely and no frame is saved. This table is an instrument, not a +// context-switch path: when IRQs become real (timer, scheduler), the relevant +// entries grow a full register save and a return path, and the clobbering +// stops being acceptable. +// +// Lower-EL entries are unreachable until userspace exists, and the SP_EL0 +// group is unreachable while the kernel stays on SP_ELx — but the architecture +// requires the slots, and wiring them to the reporter costs nothing. The +// common stub forces SPSel back to SP_ELx before touching memory, so even a +// freak SP_EL0 entry lands on the boot stack rather than on SP_EL0, which is +// never initialised. +// +// Register discipline in the stub: x0 carries the vector index from the entry; +// x1-x4 receive the four syndrome registers; nothing is preserved, because +// aarch64_exception is declared `-> !`. + +.section .text.vectors, "ax" +.balign 2048 +.globl __vectors + +__vectors: + // --- Group 0: current EL, SP_EL0 --------------------------------------- + .balign 0x80 + mov x0, #0 // synchronous + b .Lvector_common + .balign 0x80 + mov x0, #1 // IRQ + b .Lvector_common + .balign 0x80 + mov x0, #2 // FIQ + b .Lvector_common + .balign 0x80 + mov x0, #3 // SError + b .Lvector_common + + // --- Group 1: current EL, SP_ELx --------------------------------------- + // The group the kernel actually lives in today. + .balign 0x80 + mov x0, #4 // synchronous + b .Lvector_common + .balign 0x80 + mov x0, #5 // IRQ + b .Lvector_common + .balign 0x80 + mov x0, #6 // FIQ + b .Lvector_common + .balign 0x80 + mov x0, #7 // SError + b .Lvector_common + + // --- Group 2: lower EL, AArch64 ---------------------------------------- + // Unreachable until userspace exists. + .balign 0x80 + mov x0, #8 // synchronous + b .Lvector_common + .balign 0x80 + mov x0, #9 // IRQ + b .Lvector_common + .balign 0x80 + mov x0, #10 // FIQ + b .Lvector_common + .balign 0x80 + mov x0, #11 // SError + b .Lvector_common + + // --- Group 3: lower EL, AArch32 ---------------------------------------- + // Doubly unreachable: no userspace, and AArch32 is out of scope (§6). + .balign 0x80 + mov x0, #12 // synchronous + b .Lvector_common + .balign 0x80 + mov x0, #13 // IRQ + b .Lvector_common + .balign 0x80 + mov x0, #14 // FIQ + b .Lvector_common + .balign 0x80 + mov x0, #15 // SError + b .Lvector_common + +.Lvector_common: + // Force SP_ELx selection so the Rust handler runs on the boot stack even + // if the exception arrived through the SP_EL0 group. + msr spsel, #1 + + // The four syndrome registers, in argument order for aarch64_exception: + // x1 = ESR_EL1 — what happened (exception class + syndrome) + // x2 = ELR_EL1 — where it happened (faulting PC) + // x3 = FAR_EL1 — which address, for aborts and alignment faults + // x4 = SPSR_EL1 — the interrupted state + mrs x1, esr_el1 + mrs x2, elr_el1 + mrs x3, far_el1 + mrs x4, spsr_el1 + + bl aarch64_exception + + // aarch64_exception is `-> !`; reaching here means the handler itself is + // broken. Park rather than fall through into whatever follows. +.Lvector_hang: + wfe + b .Lvector_hang diff --git a/kernel/src/arch/mod.rs b/kernel/src/arch/mod.rs index 598e005..3b16b23 100644 --- a/kernel/src/arch/mod.rs +++ b/kernel/src/arch/mod.rs @@ -40,3 +40,13 @@ pub(crate) fn console_write_str(s: &str) { pub(crate) fn halt() -> ! { imp::halt() } + +/// Deliberately takes a synchronous exception, so the boot self-test can prove +/// the exception reporter works end to end rather than trusting that it would. +/// +/// Compiled only under the `provoke-exception` feature; nothing enables it by +/// default, and the ordinary boot path never calls it. +#[cfg(feature = "provoke-exception")] +pub(crate) fn provoke_exception() -> ! { + imp::provoke_exception() +} diff --git a/kernel/src/arch/x86_64/mod.rs b/kernel/src/arch/x86_64/mod.rs index e306034..bd80f84 100644 --- a/kernel/src/arch/x86_64/mod.rs +++ b/kernel/src/arch/x86_64/mod.rs @@ -57,6 +57,24 @@ extern "C" fn _start() -> ! { #[allow(clippy::missing_const_for_fn)] pub(super) fn console_write_str(_s: &str) {} +/// Deliberately takes an exception — the x86_64 half of the HAL surface. +/// +/// `ud2` is the architectural counterpart of AArch64's `brk`: an instruction +/// that exists to fault. There is no IDT yet, so on this target the fault has +/// nowhere diagnosable to land — but this target cannot boot either, so the +/// function is unreachable in practice. It exists because the boundary rule +/// says both implementations present the same surface: the moment the x86_64 +/// boot path and IDT land, this becomes the same self-test it is on AArch64. +#[cfg(feature = "provoke-exception")] +pub(super) fn provoke_exception() -> ! { + // SAFETY: `ud2` has no operands and no memory effects; it raises invalid + // opcode unconditionally, so execution cannot proceed past it and + // `noreturn` is sound. + unsafe { + core::arch::asm!("ud2", options(nomem, nostack, noreturn)); + } +} + /// Parks this core in a low-power halt state, permanently. pub(super) fn halt() -> ! { loop { diff --git a/kernel/src/main.rs b/kernel/src/main.rs index ce0e154..bafedbe 100644 --- a/kernel/src/main.rs +++ b/kernel/src/main.rs @@ -30,5 +30,13 @@ mod panic; /// smaller claim but the one everything else rests on. pub(crate) fn kernel_main() -> ! { console::greet(); + + // The exception self-test: greet first (proving the ordinary path), then + // fault on purpose and let CI grep the console for the decoded report. + // Nothing enables this feature by default. + #[cfg(feature = "provoke-exception")] + arch::provoke_exception(); + + #[cfg(not(feature = "provoke-exception"))] arch::halt() } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 890cf91..684226d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -113,12 +113,18 @@ fn run() -> Result<()> { None => Arch::Aarch64, }; let release = args.iter().any(|a| a == "--release"); + let features = flag_value(&args, "--features"); match command.as_str() { - "build" => build(arch, release).map(|image| { + "build" => build(arch, release, features.as_deref()).map(|image| { println!("xtask: {}", image.display()); }), - "run-qemu" => run_qemu(arch, release, args.iter().any(|a| a == "--debug")), + "run-qemu" => run_qemu( + arch, + release, + args.iter().any(|a| a == "--debug"), + features.as_deref(), + ), "boot-test" => { let expect = flag_value(&args, "--expect").ok_or("boot-test requires --expect ")?; @@ -126,7 +132,7 @@ fn run() -> Result<()> { Some(value) => Duration::from_secs(value.parse::()?), None => Duration::from_secs(30), }; - boot_test(arch, release, &expect, timeout) + boot_test(arch, release, &expect, timeout, features.as_deref()) } "help" | "--help" | "-h" => { print_usage(); @@ -154,6 +160,8 @@ Commands: Options: --arch Target architecture (default: aarch64) --release Build with optimisations + --features Kernel cargo features, comma-separated + (e.g. provoke-exception, for the exception self-test) --debug run-qemu only: halt at reset and await gdb on :1234 --expect boot-test only: the string that must appear --timeout boot-test only: deadline (default: 30)" @@ -182,7 +190,7 @@ fn cargo() -> String { } /// Cross-compiles the kernel and returns the path to the resulting image. -fn build(arch: Arch, release: bool) -> Result { +fn build(arch: Arch, release: bool, features: Option<&str>) -> Result { let root = workspace_root()?; let mut command = Command::new(cargo()); @@ -196,6 +204,9 @@ fn build(arch: Arch, release: bool) -> Result { if release { command.arg("--release"); } + if let Some(features) = features { + command.args(["--features", features]); + } println!("xtask: building setonix-kernel for {}", arch.triple()); let status = command.status()?; @@ -228,9 +239,9 @@ fn require_bootable(arch: Arch) -> Result<()> { } /// Boots the kernel with the serial console attached to this terminal. -fn run_qemu(arch: Arch, release: bool, debug: bool) -> Result<()> { +fn run_qemu(arch: Arch, release: bool, debug: bool, features: Option<&str>) -> Result<()> { require_bootable(arch)?; - let image = build(arch, release)?; + let image = build(arch, release, features)?; let mut command = Command::new(arch.qemu()); command.args(arch.machine_args()); @@ -254,9 +265,15 @@ fn run_qemu(arch: Arch, release: bool, debug: bool) -> Result<()> { } /// Boots the kernel and requires `expect` to appear on the serial console. -fn boot_test(arch: Arch, release: bool, expect: &str, timeout: Duration) -> Result<()> { +fn boot_test( + arch: Arch, + release: bool, + expect: &str, + timeout: Duration, + features: Option<&str>, +) -> Result<()> { require_bootable(arch)?; - let image = build(arch, release)?; + let image = build(arch, release, features)?; let mut command = Command::new(arch.qemu()); command.args(arch.machine_args());