diff --git a/bootloader/src/arch/aarch64.rs b/bootloader/src/arch/aarch64.rs index a85bd2560b..4b88b25c4b 100644 --- a/bootloader/src/arch/aarch64.rs +++ b/bootloader/src/arch/aarch64.rs @@ -95,13 +95,20 @@ 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` @@ -109,20 +116,55 @@ pub mod pio { /// 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::()); + 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), ); } diff --git a/issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md b/issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md new file mode 100644 index 0000000000..682ef9a4a7 --- /dev/null +++ b/issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md @@ -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. diff --git a/kernel/src/arch/aarch64/boot.rs b/kernel/src/arch/aarch64/boot.rs index bdd84b1aa0..97f358f9f6 100644 --- a/kernel/src/arch/aarch64/boot.rs +++ b/kernel/src/arch/aarch64/boot.rs @@ -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; @@ -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)] @@ -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!( @@ -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", @@ -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", @@ -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, @@ -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 diff --git a/kernel/src/arch/aarch64/control_regs.rs b/kernel/src/arch/aarch64/control_regs.rs index 0cad5e024e..6a652ab9a6 100644 --- a/kernel/src/arch/aarch64/control_regs.rs +++ b/kernel/src/arch/aarch64/control_regs.rs @@ -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` = diff --git a/src/arch.rs b/src/arch.rs index d93bc9fd6c..049d3154ae 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -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 diff --git a/tests/common/qemu.rs b/tests/common/qemu.rs index 93952d7b54..577e080b9d 100644 --- a/tests/common/qemu.rs +++ b/tests/common/qemu.rs @@ -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 @@ -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 @@ -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. @@ -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, @@ -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()), } } @@ -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") diff --git a/tests/toyos.rs b/tests/toyos.rs index 13a0f21076..170e292f4b 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -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), @@ -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, &[], @@ -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",