Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 54 additions & 12 deletions bootloader/src/arch/aarch64.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,34 +95,76 @@ pub mod pio {
}
}

/// Hand the CPU to the kernel as firmware left it — its exception level, its
/// identity tables — at the image's physical entry, with `x0 = args`. The
/// kernel's entry switches to the boot map (`args.boot_pml4_addr`) itself (`kernel/src/arch/aarch64/boot.rs`),
/// because at EL2 only the kernel's own drop to EL1 can install it.
/// Hand the CPU to the kernel at the exception level firmware ran it at, with
/// that level's MMU and data cache off, at the image's physical entry with
/// `x0 = args`: the state PSCI's `CPU_ON` starts every other CPU in. Nothing
/// firmware left in a translation regime is walked past this point. UEFI
/// promises an identity map of RAM and none of its attributes (2.11 §2.3.6),
/// edk2's ArmVirtQemu maps `EfiLoaderData`, where this image is, execute-never
/// unless built otherwise, and the kernel's entry rewrites `HCR_EL2.E2H`, which
/// chooses the regime firmware's EL2 tables are walked in. That entry installs
/// the boot map (`args.boot_pml4_addr`) itself (`kernel/src/arch/aarch64/boot.rs`),
/// because at EL2 only its own drop to EL1 can.
///
/// The image is cleaned to the point of coherency first: that entry fetches
/// instructions with the MMU off for a few of them, straight from memory.
/// What runs or is read with the MMU off — the image, `args`, and this
/// function's own last instructions — is cleaned to the point of coherency
/// first.
///
/// # Safety
/// `args.boot_pml4_addr` is the boot map, `image` is the relocated kernel image, `entry_offset`
/// is its entry point's offset in it, and `args` stays where it is until the
/// kernel copies it.
pub unsafe fn enter_kernel(image: (u64, u64), entry_offset: u64, args: &KernelArgs) -> ! {
write_back(image.0, image.1 as usize);
let entry = image.0 + entry_offset;
write_back(args as *const KernelArgs as u64, size_of::<KernelArgs>());
let step = line();
// SAFETY: interrupts masked for good — the kernel's vectors are not
// installed yet — then every instruction cache line invalidated against
// the image just cleaned, and a branch to its entry with `x0 = args`, the
// boot protocol `toyos-abi::boot` and the kernel's `_start` define.
// installed yet. Every line from `2:` to `5:` is cleaned to the point of
// coherency, because those instructions are fetched with the MMU off, and
// every instruction cache line is invalidated against the image cleaned
// above. `SCTLR_ELx.M` and `.C` are cleared at the level this runs at,
// which fetches the next instruction from the same address because
// firmware's map is the identity. Then a branch to the image's entry with
// `x0 = args`, the boot protocol `toyos-abi::boot` and the kernel's
// `_start` define. `x9` and `x10` are scratch, and nothing returns to
// observe them.
unsafe {
core::arch::asm!(
"msr daifset, #0xf",
"adr x9, 2f",
"bic x9, x9, x3",
"adr x10, 5f",
"1:",
"dc civac, x9",
"add x9, x9, x2",
"cmp x9, x10",
"b.lo 1b",
"dsb sy",
"ic iallu",
"dsb ish",
"isb",
"br {entry}",
entry = in(reg) entry,
"2:",
"mrs x9, CurrentEL",
"cmp x9, #(2 << 2)",
"b.ne 3f",
"mrs x9, sctlr_el2",
"bic x9, x9, #(1 << 0)",
"bic x9, x9, #(1 << 2)",
"msr sctlr_el2, x9",
"b 4f",
"3:",
"mrs x9, sctlr_el1",
"bic x9, x9, #(1 << 0)",
"bic x9, x9, #(1 << 2)",
"msr sctlr_el1, x9",
"4:",
"isb",
"br x1",
"5:",
in("x0") args as *const KernelArgs,
in("x1") image.0 + entry_offset,
in("x2") step,
in("x3") step - 1,
options(noreturn),
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
status: assigned
kind: tooling
opened: 2026-10-01
---

# edk2 stable202502 to stable202608 hangs at ExitBootServices under HVF

When a host's QEMU installation ships an edk2 from stable202502 to
stable202608, `virt` guests under HVF never return from `ExitBootServices`.
Homebrew's QEMU 11.1.1 ships stable202408 today. Owner: the orchestrator.

Under QEMU 11.1.1's HVF a `virt` guest reads `ID_AA64PFR0_EL1.GIC` as 0,
though its GICv3's system registers answer. `hvf_arch_init_vcpu`
(`target/arm/hvf/hvf.c:1481`) writes that register once, setting `GIC` only if
`env->gicv3state` is set, and it runs while `machvirt_init` realizes the CPUs
(`hw/arm/virt.c:3140`), before `create_gic` (`:3158`) makes the GIC that sets
it. QEMU master is the same. TCG computes the field on each read
(`id_aa64pfr0_read`, `target/arm/helper.c`) and reads 1. The orchestrator's
`aarch64fw-r3-d3-pfr0` read the register from the loader: `0x1101000010110011`,
`GIC` 0, under HVF, and `0x1301001121110022` at EL1 and `0x1301001121110222`
at EL2, `GIC` 1, under TCG.

From edk2-stable202502 (`8edd5fd6d3`, `eaa60a6b10`, `e663b79f74`) to
stable202608, ArmVirtQemu's `ArmGicDxe` runs its GICv3 driver only where that
field is nonzero, and its GICv2 driver otherwise. The DT's `arm,gic-v3` node
never sets the GICv2 CPU interface's base, which stays at its 0 default
(`ArmVirtQemu.dsc`). At `ExitBootServices`, `GicV2ExitBootServicesEvent`
acknowledges interrupts until `GICC_IAR` reads 1020 to 1023. At 0x0 + 0xc the
guest reads the firmware's own flash, `0xffffffff`, so the loop never ends.

Seen at `8d74557fa` with Debian's 2026.05-2, in the orchestrator's runs
`aarch64fw-r2-d1-pinned` and `aarch64fw-r2-d2-pinned-no-vgic`.
`virt_early_panic` and `virt_early_fault`, this host's only HVF guests, stop
after the loader's last line. QMP then reads EL1h, PC `0xbf5e0a80` (the `ret`
after `MmioRead32`'s load, `X0 = 0xffffffff`) and `0xbf5dfbbc` a second later.
The 64 words dumped from 0x80 below the PC occur once in that build's
`ArmGicDxe.efi`, at file offset 0x1b3c: the loop above. With
`kernel-irqchip=off` (QEMU's own GICv3) the loop is the same. Every TCG `virt`
guest boots that build green.

## Exit condition

An edk2 release carrying `aefdbf91f4` and `377a890d80`, where the DT chooses
between split GICv2 and GICv3 drivers again, or a QEMU whose HVF sets
`ID_AA64PFR0_EL1.GIC` for an attached GICv3. It is shown by `virt_early_panic`
and `virt_early_fault` green under HVF on that firmware or that QEMU.
69 changes: 44 additions & 25 deletions kernel/src/arch/aarch64/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
//! to, and what `kernel_main` asks of this architecture at the points where
//! one differs from another.
//!
//! **The entries.** The loader leaves the boot CPU as firmware ran it — at EL2
//! or EL1, on firmware's identity tables — cleans the kernel image and its own
//! tables to the point of coherency, and jumps to [`_start`]'s physical
//! address with `x0 = &KernelArgs`. PSCI starts every other CPU at
//! [`ap_start`]'s physical address, MMU off, at the level firmware gives an
//! operating system, with `x0` its [`ApStart`]'s physical address. Each names
//! a root table and branches to [`apply_declaration`], which writes the
//! [`control_regs`](super::control_regs) declaration whole: at EL2 it writes
//! `HCR_EL2` first, halts in a named refusal unless it reads back as declared,
//! then programs EL1's registers with the MMU already on and drops with
//! `ERET`; at EL1 it turns the MMU off first, so no translation register
//! changes under a live walk. Either way the entry resumes at its link address
//! in the view at `PHYS_OFFSET`, installs the vectors on its own stack, and
//! calls `kernel_main` or `smp::ap_entry`.
//! **The entries.** Every CPU arrives at its physical address with the MMU of
//! the level it runs at off, so nothing firmware left in a translation regime —
//! its tables, their execute-never attributes, the regime `HCR_EL2.E2H` chose —
//! is walked under either entry. The loader hands the boot CPU over at the
//! level firmware ran it, EL2 or EL1, with the kernel image and [`KernelArgs`]
//! cleaned to the point of coherency, and jumps to [`_start`] with
//! `x0 = &KernelArgs`. PSCI starts every other CPU at [`ap_start`], at the
//! level firmware gives an operating system, with `x0` its [`ApStart`]'s
//! physical address. Each names a root table and branches to
//! [`apply_declaration`], which writes the [`control_regs`](super::control_regs)
//! declaration whole: at EL2 it writes `HCR_EL2` first, halts in a named
//! refusal unless it reads back as declared, then programs EL1's registers with
//! the MMU already on and drops with `ERET`; at EL1 it programs them and turns
//! the MMU on. Either way the entry resumes at its link address in the view at
//! `PHYS_OFFSET`, installs the vectors on its own stack, and calls
//! `kernel_main` or `smp::ap_entry`.

use core::mem::offset_of;

Expand All @@ -28,8 +30,8 @@ use crate::drivers::acpi::direct_phys;
use crate::log;
use crate::mm::Region;

/// Entry point: the loader jumps here at its physical address, MMU on under
/// firmware's identity map, with `x0 = &KernelArgs`.
/// Entry point: the loader jumps here at its physical address with this
/// level's MMU off, and `x0 = &KernelArgs`.
/// # Safety
/// Only the loader may call this, fresh from firmware, with `x0` holding a live [`KernelArgs`].
#[unsafe(naked)]
Expand Down Expand Up @@ -108,7 +110,10 @@ pub(super) unsafe extern "C" fn ap_start() -> ! {
/// its physical address, and never returns.
/// # Safety
/// Only [`_start`] and [`ap_start`] branch here, with `x3` a root that maps the
/// kernel image at both its physical and its link address.
/// kernel image at both its physical and its link address. The EL1 arm's
/// translation registers and the EL2 arm's `HCR_EL2` change under no walk only
/// because the level entered at has its MMU off; an entry with it on halts in
/// [`refused_mmu_on_at_entry`].
#[unsafe(naked)]
unsafe extern "C" fn apply_declaration() -> ! {
core::arch::naked_asm!(
Expand All @@ -125,10 +130,9 @@ unsafe extern "C" fn apply_declaration() -> ! {
"b.eq 2f",
"cmp x21, #1",
"b.ne 9f",
// EL1: translation off, then the declaration, then translation on.
"ldr x1, ={sctlr_off}",
"msr sctlr_el1, x1",
"isb",
// EL1: refused with its MMU on; else the declaration, then translation on.
"mrs x1, sctlr_el1",
"tbnz x1, #0, {refuse_mmu}",
"msr mair_el1, x4",
"msr tcr_el1, x2",
"msr ttbr0_el1, x3",
Expand All @@ -143,10 +147,14 @@ unsafe extern "C" fn apply_declaration() -> ! {
"msr sctlr_el1, x5",
"isb",
"br x22",
// EL2: `HCR_EL2` first, since with `E2H` set every `_el1` name
// below is an EL2 register; refused unless it reads back as declared.
// Then EL1's registers with the MMU on, EL2's others, and the drop.
// EL2: refused with its own MMU on, so the regime `E2H` chooses
// changes under no walk. Then `HCR_EL2`, since with `E2H` set every
// `_el1` name below is an EL2 register; refused unless it reads back
// as declared. Then EL1's registers with the MMU on, EL2's others, and
// the drop.
"2:",
"mrs x1, sctlr_el2",
"tbnz x1, #0, {refuse_mmu}",
"ldr x1, ={hcr}",
"msr hcr_el2, x1",
"isb",
Expand Down Expand Up @@ -194,8 +202,8 @@ unsafe extern "C" fn apply_declaration() -> ! {
ips = const regs::TCR_IPS_SHIFT,
mair = const regs::MAIR,
sctlr = const regs::SCTLR,
sctlr_off = const regs::SCTLR_MMU_OFF,
hcr = const regs::HCR_EL2,
refuse_mmu = sym refused_mmu_on_at_entry,
refuse_hcr = sym refused_hcr_el2_readback,
cnthctl = const regs::CNTHCTL_EL2,
cptr = const regs::CPTR_EL2,
Expand All @@ -206,6 +214,17 @@ unsafe extern "C" fn apply_declaration() -> ! {
);
}

/// Where a CPU halts that reaches [`apply_declaration`] with `SCTLR_ELx.M` set
/// at the level it runs at, as one a loader hands over under firmware's tables
/// does: the declaration's translation registers and `HCR_EL2` would change
/// under a live walk. Nothing can report yet, so the refusal is this symbol,
/// which the halted PC names.
#[unsafe(naked)]
#[no_mangle]
unsafe extern "C" fn refused_mmu_on_at_entry() -> ! {
core::arch::naked_asm!("1:", "wfe", "b 1b")
}

/// Where a CPU entered at EL2 halts when `HCR_EL2` reads back anything but the
/// declaration the entry wrote: `E2H` held set, which the loader refuses by
/// name first (`bootloader/src/arch/aarch64.rs`), or any other bit. There the
Expand Down
5 changes: 0 additions & 5 deletions kernel/src/arch/aarch64/control_regs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ pub use toyos_bootmap::aarch64::MAIR;
/// levels; EL0's cache maintenance and `WFI`/`WFE` trap.
pub const SCTLR: u64 = SCTLR_RES1 | 1 << 0 | 1 << 2 | 1 << 3 | 1 << 4 | 1 << 7 | 1 << 8 | 1 << 12;

/// `SCTLR_EL1` with the MMU and caches off — [`SCTLR`] less `M`, `C`, `I`,
/// `SA` and `SA0` — the only other value the entry writes: what a CPU entered at EL1 under firmware's tables is put through
/// before its translation registers change.
pub const SCTLR_MMU_OFF: u64 = SCTLR_RES1 | 1 << 7 | 1 << 8;

const SCTLR_RES1: u64 = 1 << 29 | 1 << 28 | 1 << 23 | 1 << 22 | 1 << 20 | 1 << 11;

/// `TCR_EL1` but for `IPS`: 48-bit regions from both tables (`T0SZ` = `T1SZ` =
Expand Down
2 changes: 0 additions & 2 deletions src/arch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,6 @@ impl Arch {
}
}

/// The CPU every guest of this architecture gets under `accel`.
///
/// **One declaration, read by `cargo run` and by the harness both**, because
/// the two drifted: the harness gained `+smep` and the interactive path did
/// not, so the machine an owner looked at differed from the machine the
Expand Down
25 changes: 20 additions & 5 deletions tests/common/qemu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,11 @@ pub enum Profile {
/// firmware then hands the loader the CPU at EL2, and the kernel's entry
/// has to drop from it. HVF gives a guest EL1 only.
VirtEl2,
/// [`Profile::VirtEl2`] on `-cpu cortex-a72`, which has no FEAT_VHE: any
/// firmware hands the loader EL2 with `HCR_EL2.E2H` clear, where an `_el1`
/// register name is EL1's own, so the loader's EL2 arm alone turns EL2's
/// MMU off.
VirtEl2NoVhe,
/// [`Profile::Virt`] emulated on `-cpu max` whatever the host: firmware
/// hands the loader the CPU at EL1, as HVF does, and QEMU's FADT names
/// PSCI's conduit `HVC`; unlike HVF's, the CPU has RNDR for the kernel's
Expand All @@ -751,7 +756,7 @@ impl Profile {
/// The architecture this machine is.
pub fn arch(self) -> Arch {
match self {
Self::Virt | Self::VirtEl2 | Self::VirtTcg => Arch::Aarch64,
Self::Virt | Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Arch::Aarch64,
Self::Headless
| Self::HeadlessNoIommu
| Self::Gop
Expand All @@ -762,10 +767,18 @@ impl Profile {
/// How this host provides the machine.
pub fn accel(self) -> Accel {
match self {
Self::VirtEl2 | Self::VirtTcg => Accel::Tcg,
Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Accel::Tcg,
_ => self.arch().accel(),
}
}

/// The CPU this machine has.
fn cpu(self) -> &'static str {
match self {
Self::VirtEl2NoVhe => "cortex-a72",
_ => self.arch().cpu(self.accel()),
}
}
}

/// The vIOMMU a profile puts on the machine.
Expand Down Expand Up @@ -884,7 +897,7 @@ pub const NVME_SMALL: u64 = 128 * 1024 * 1024;
impl Profile {
fn shape(self) -> Shape {
match self {
Self::VirtEl2 | Self::VirtTcg => Self::Virt.shape(),
Self::VirtEl2 | Self::VirtEl2NoVhe | Self::VirtTcg => Self::Virt.shape(),
Self::Virt => Shape {
vga: "std",
panel: None,
Expand Down Expand Up @@ -1945,7 +1958,9 @@ fn qemu_command(
// The unit a profile declares is VT-d, which `virt` has none of.
assert!(shape.iommu.is_none(), "`virt` has no VT-d");
match options.profile {
Profile::VirtEl2 => format!("{},gic-version=3,virtualization=on", arch.machine()),
Profile::VirtEl2 | Profile::VirtEl2NoVhe => {
format!("{},gic-version=3,virtualization=on", arch.machine())
}
_ => format!("{},gic-version=3", arch.machine()),
}
}
Expand All @@ -1964,7 +1979,7 @@ fn qemu_command(
qemu.arg("-machine")
.arg(&machine)
.arg("-cpu")
.arg(arch.cpu(accel))
.arg(options.profile.cpu())
.arg("-smp")
.arg(options.smp.to_string())
.arg("-m")
Expand Down
19 changes: 11 additions & 8 deletions tests/toyos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ const SCREEN_TESTS: &[(&str, qemu::Profile)] = &[
("screen_fatal_halt_composited", qemu::Profile::Metal),
("virt_early_panic", qemu::Profile::Virt),
("virt_early_fault", qemu::Profile::Virt),
("virt_el2_drop", qemu::Profile::VirtEl2),
("virt_el2_drop", qemu::Profile::VirtEl2NoVhe),
("virt_user_mode", qemu::Profile::VirtEl2),
("virt_timer_preempts", qemu::Profile::VirtEl2),
("virt_irq_storm", qemu::Profile::VirtEl2),
Expand Down Expand Up @@ -1605,12 +1605,15 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
}
"virt_el2_drop" => {
// The entry's drop from EL2, which HVF never exercises: `virt` with
// EL2 under TCG, where firmware hands the loader the CPU at EL2. A
// loader that refuses the CPU says so and stops; a drop that leaves
// `HCR_EL2` other than declared halts in a named refusal and says
// nothing; one that lands anywhere but EL1 on `SP_EL1` panics in the
// declaration's read-back. Each way the line this waits for never
// comes.
// EL2 under TCG, where firmware hands the loader the CPU at EL2, on
// a CPU without FEAT_VHE, so `E2H` is clear at the handover and
// only the loader's EL2 arm turns EL2's MMU off. A loader that
// refuses the CPU says so and stops; a handover with EL2's MMU on
// halts in a named refusal, or faults first where firmware maps the
// image execute-never; a drop that leaves `HCR_EL2` other than
// declared halts in a named refusal and says nothing; one that lands
// anywhere but EL1 on `SP_EL1` panics in the declaration's
// read-back. Each way the line this waits for never comes.
let mut qemu = QemuInstance::boot_with_options(
test_config,
&[],
Expand All @@ -1625,7 +1628,7 @@ fn run_screen_test(name: &str, profile: qemu::Profile, test_config: &Path) -> Re
let rest = qemu.drain_until(Duration::from_secs(10), |l| l.contains(EARLY_PANIC_MESSAGE));
let serial = format!("{}\n{rest}", qemu.boot_log());
for want in [
"CPU: entered at EL2, HCR_EL2.E2H ",
"CPU: entered at EL2, HCR_EL2.E2H 0,",
"ID_AA64MMFR4_EL1.E2H0 0x0: the kernel's entry writes E2H clear",
"as declared; entered at EL2, HCR_EL2 read back as declared",
"EARLY PANIC: panicked at",
Expand Down
Loading