Skip to content

The AArch64 kernel is entered with the MMU off, and refuses an entry with it on - #677

Merged
Japabu merged 2 commits into
mainfrom
wt/toyos-a64handover
Oct 1, 2026
Merged

Japabu merged 2 commits into
mainfrom
wt/toyos-a64handover

Conversation

@Japabu

@Japabu Japabu commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

What it fixes

All 16 virt_* guests died at the AArch64 kernel's first instruction on CI's firmware, Debian's AAVMF_CODE.no-secboot.fd 2026.05-2. The runs are 36863809437 (#671's guest check) and 36843762360 (main's nightly at 06788146b).

  • Each console ends at the loader's Loader log: the kernel handoff begins and then edk2's own Synchronous Exception at 0x00000000BC33EB20. That PC is inside the image at 0xbc200000, and it is the same entered at EL1 and at EL2.
  • The same tests pass on the dev host's firmware, Homebrew QEMU 11.1.1's own edk2-stable202408.

Root cause

1. The image is not executable under firmware's tables. The loader allocates the kernel image from the uefi crate's pool, which allocates EfiLoaderData, and it branched to _start with firmware's page tables still live.

  • edk2's ArmVirtQemu has defaulted PcdDxeNxMemoryProtectionPolicy to 0xC000000000007FD5 since 2997ae3873 ("ArmVirtPkg: make EFI_LOADER_DATA non-executable", 2022-08-02). Bit 2 of that value is EfiLoaderData, and Debian 2026.05-2 keeps the default.
  • QEMU builds its own firmware with pcds = nx.broken.shim.grub, 0xC000000000007FD1 (roms/edk2-build.config at v11.1.1). Each decompressed DXE volume has one movz x1, #imm; movk x1, #0xc000, lsl #48; tst site: 0x7fd5 in Debian's 2026.05-2, 0x7fd1 in Homebrew's.
  • UEFI 2.11 §2.3.6 promises only an identity map of RAM ("The state of other system control register bits is not dictated by this specification"), and maps EFI_MEMORY_XP to UXN/PXN at EL1&0 and XN at EL2. Debian's no-secboot build sets PcdUninstallMemAttrProtocol=TRUE, so a loader cannot ask for the image to be made executable.

2. A second dependency waited behind the first. The EL2 entry wrote HCR_EL2.E2H clear under firmware's live EL2 tables.

  • edk2 47e28a6d44 ("ArmVirtPkg/ArmPlatformLibQemu: Enable early ID map on EL2+VHE") sets E2H at EL2, and CI's loader printed HCR_EL2.E2H 1. It is first in edk2-stable202502 (git tag --contains).
  • b8df7d9c8e (stable202605) makes that early map LPA2 where PARange is 52 bits, as on -cpu max. Clearing E2H makes the CPU read the same TCR_EL2 in the non-VHE layout, under the PC.

The firmware ruling this keeps

The owner ruled on 2026-09-28 (dfe61e503) that a guest's firmware comes from the QEMU installation, inside "Rust and QEMU".

So the dev host boots Homebrew's stable202408 and CI boots Debian's 2026.05-2 from its image's snapshot. That is two firmwares, which is how this defect was caught. Homebrew's needs neither the handover nor the EL2 arm below: it leaves EfiLoaderData executable (0x7fd1), never sets E2H, and main's suite passes on it.

Decisions

  • The loader hands over with the MMU off, at the level firmware ran it (bootloader/src/arch/aarch64.rs).
    • It cleans the image, KernelArgs and its own last instructions to the point of coherency, and invalidates the instruction cache. It clears SCTLR_ELx.M, in sctlr_el2 at EL2, which names EL2's register whatever E2H holds. Then it branches to _start.
    • Linux's arm64 efi_enter_kernel (v6.1, efi-entry.S) hands over the same way, clearing C besides, and UEFI allows it: "An application written to this specification may alter the processor execution mode".
    • Rejected: allocating the image as EfiLoaderCode. That still rests on firmware's policy for the type, and keeps the E2H flip under a live MMU; m1b measures it.
    • Rejected: EFI_MEMORY_ATTRIBUTE_PROTOCOL, which Debian uninstalls.
  • SCTLR_ELx.C stays as firmware left it. With M clear it governs no access. In the Arm ARM's pseudocode, AArch64.S1Translate returns AArch64.S1DisabledOutput whenever stage 1 is disabled, and that gives a data access Device-nGnRnE, or Normal Write-Back at EL1&0 where HCR_EL2.DC is 1, without reading C; AArch64.S1DCacheEnabled, the one reader of C, is called only with stage 1 enabled. SCTLR_EL1.C: "When the Effective value of the HCR_EL2.DC bit in the current Security state is 1, the PE ignores SCTLR_EL1.C." The kernel writes SCTLR_EL1 whole as its MMU comes on and never writes SCTLR_EL2.
  • The kernel's EL1 write of SCTLR_MMU_OFF goes, with its constant: neither entry arrives under firmware's tables now.
  • The entry refuses SCTLR_ELx.M set at the level it was entered at. Each arm of apply_declaration reads its own SCTLR and branches to refused_mmu_on_at_entry on bit 0, before the EL1 arm's translation registers and before the EL2 arm's HCR_EL2 write. The # Safety section names what both arms rest on.
    • The refusal prints nothing: the entry holds no console. The kernel finds its UART in the SPCR (serial::init) and draws its panel only once the declaration it refuses has turned on the map both are reached through. Its symbol is what names it, so the controls below read the halted PC over QMP.
  • virt_el2_drop boots -cpu cortex-a72 (Profile::VirtEl2NoVhe), which has no FEAT_VHE.
    • There HCR_EL2.E2H is RES0, firmware runs EL2 non-VHE, and the sctlr_el1 encoding at EL2 names EL1's own register. So only the loader's EL2 arm turns EL2's MMU off. On Debian's 2026.05-2 every -cpu max EL2 guest runs with E2H set, where that encoding is SCTLR_EL2's, and nothing told the two arms apart.
    • The test asserts HCR_EL2.E2H 0, on the loader's line, the premise that tells them apart.
    • Without the EL2 arm, EL2's MMU stays on wherever E2H is clear at the handover. On CI's lanes that is this guest alone, and Debian's firmware maps the image execute-never at EL2. On the dev host, whose firmware never sets E2H, it is every EL2 guest, and the entry's refusal halts each.
    • No guest test is added. One test's CPU changes, because the behaviour is an exception-level transition under a firmware's translation regime: no type expresses it, the host cannot execute it, and the T14 is x86-64.
    • src/arch.rs's "The CPU every guest of this architecture gets under accel" goes, false now.
  • The handover's standing guard is CI's AArch64 lanes on Debian's firmware. Nothing asserts which firmware CI boots beyond --ci guest's instrument line.
  • issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md files the third finding as a hazard. A host QEMU that ships an edk2 from stable202502 to stable202608 hangs every virt guest under HVF in ExitBootServices. QEMU's HVF reads ID_AA64PFR0_EL1.GIC as 0, and those builds then run their GICv2 driver on a GICv3 machine. Homebrew's QEMU 11.1.1 ships stable202408. The file keeps the measured cause, d3's readings and the exit.

Controls and oracles

The orchestrator's, on the dev host (M4 Pro, Homebrew QEMU 11.1.1 with its edk2-stable202408): virt_early_* under HVF, the other virt_* and x86-64 under TCG.

Measured on b0e2f55d1 (#676), whose tree is this branch's first commit's change: 75aa4afec^{tree} is git merge-tree --write-tree origin/main b0e2f55d1. f182b6cfa adds C0 to it. Patches: #676, issuecomment-5937056807. deb is measurement only: every TCG virt guest boots Debian's 2026.05-2 AAVMF_CODE.no-secboot.fd and AAVMF_VARS.fd, the bytes CI's AArch64 lanes boot, and each boot log is asserted to carry that firmware's banner.

run patch filter result
whole none all 21/21
C1 KernelArgs not cleaned virt_early_ (HVF) 2/2
C2 the handover's own instructions not cleaned virt_early_ (HVF) 2/2
deb Debian's 2026.05-2 for the TCG guests virt_ 16/16
deb-n0 deb, with bootloader/ and kernel/ at main's virt_ 2/16: the 14 TCG guests red
deb-d deb, with the loader's EL2 arm deleted (D) virt_ 15/16: only virt_el2_drop (cortex-a72) red
n0 bootloader/ and kernel/ at main's virt_ 16/16
d D virt_ 3/16: the 13 EL2 guests red
d-r0 D, with the entry's refusal reverted virt_ 16/16
e1 the loader's EL1 arm leaves M set virt_ 13/16: virt_el1_smp, virt_early_panic, virt_early_fault red
  • deb against deb-n0: on CI's firmware the fix is what boots the 14 TCG guests.
  • deb against deb-d: on CI's firmware the EL2 arm is what boots virt_el2_drop.
  • d against d-r0: without the EL2 arm the entry's refusal is what halts the 13 EL2 guests on the dev host's firmware.
  • C1 and C2 are green under HVF: no tier here reds without either clean, so both rest on the Arm ARM and Linux's efi_enter_kernel, not on a measured red.

At 75aa4afec: the whole suite, 21/21 (issuecomment-5937436034).

m1b, on 8d74557fa (#676) with Debian's 2026.05-2: main's handover with the image allocated as EfiLoaderCode and the kernel's reservation accepting it, 1/16. virt_el1_smp green, so the XN half alone; the 13 EL2 guests red after HCR_EL2.E2H 1 with no exception, so the E2H half; the 2 HVF guests hang in ExitBootServices.

This round's runs, at f182b6cfa, the orchestrator's (issuecomment-5938455090)

Every patch here carries Q, a measurement-only harness change: each AArch64 guest gets a QMP socket, and a boot timeout appends a [halt] line, the boot CPU's PC from QMP's info registers, resolved by toyos_elf::SymTab::resolve against the kernel ELF the loader handed the guest (KernelArgs, found by the loader's Kernel arguments: line, and its kernel_elf_addr, each read back with pmemsave). R and R1 are the review's patches; E1 and e2 delete the bic #(1 << 0) of the loader's EL1 and EL2 arm. Patches: a comment on this pull request.

job patches filter predicted result
a64handover-r6-whole none all 21/21 21/21 (25.0 s)
a64handover-r6-R Q, R virt_ 0/16, each [halt] in refused_mmu_on_at_entry 0/16, every [halt] at refused_mmu_on_at_entry+0x4, at EL1h and EL2h
a64handover-r6-E1 (run 1) Q, E1 virt_e 1/4: virt_el2_drop green; virt_el1_smp, virt_early_panic, virt_early_fault red, each [halt] in refused_mmu_on_at_entry at EL1 1/4, as predicted, three [halt] at refused_mmu_on_at_entry+0x4 at EL1h
a64handover-r6-e2 (run 2) Q, e2 virt_el2_drop 0/1, its [halt] in refused_mmu_on_at_entry at EL2 0/1, [halt] at refused_mmu_on_at_entry+0x4 at EL2h
a64handover-r6-E1-R1 Q, E1, R1 virt_e measurement: no [halt] in refused_mmu_on_at_entry; each EL1 guest green, or red with its [halt] elsewhere 4/4 green: the EL1 check is what halts those guests

Oracles, each independent of the author:

  • CI's recorded failure.
  • UEFI 2.11 §2.3.6 and its attribute table.
  • The Arm ARM: an _el1 encoding at EL2 is redirected to EL2's register when E2H is set, and E2H is RES0 without FEAT_VHE. For C0, its shared pseudocode (AArch64.S1Translate, AArch64.S1DisabledOutput, AArch64.S1DCacheEnabled) and SCTLR_EL1.C, SCTLR_EL2.C and HCR_EL2.DC, read in Arm's 2023-03 machine-readable release (ISA_A64_xml_A_profile-2023-03, sha256 4be65585693d1bf1f1765bcc1a2493ce5df99718cc33d6e98fc3e83f76019bfd; SysReg_xml_A_profile-2023-03, sha256 a136e9eef95aed87b6f9b50ec0c328c9f5966e04d61f66b759cc098a3c0e44a4).
  • Linux v6.1's efi_enter_kernel, a differential implementation of the same handoff.
  • edk2's source and commits, and QEMU v11.1.1's (hvf.c, virt.c).
  • Each firmware binary, disassembled.

Gates

At f182b6cfa:

gate exit
cargo run -- --ci host: "[ci] Host: 59 step(s), all green"; the licence gate "6 exception(s) stand, and nothing else is refused" 0
cargo run -- --clippy: "clippy: 17 invocations clean" 0
cargo run -- --build-only --arch aarch64: "Build finished." 0
the same with --kernel-feature boot-actuators --kernel-feature test-actuators: "Build finished." 0
cargo test --test toyos-build --no-run 0
R, E1, e2 and E1 with R1, each with Q: applied to f182b6cfa, built both ways, the harness compiled, reverted, the tree clean 0 each

At 75aa4afec (issuecomment-5937375906):

gate exit
cargo run -- --ci host: "[ci] Host: 59 step(s), all green"; the licence gate "6 exception(s) stand, and nothing else is refused" 0
cargo run -- --clippy: "clippy: 17 invocations clean" 0
cargo run -- --build-only --arch aarch64: "Build finished." 0
cargo test --test toyos-build --no-run 0

Unsure

  • cortex-a72 is QEMU's TCG model; no silicon here runs it.

Size

git diff --shortstat origin/main...HEAD: 7 files, +167 −52.

  • Production: the loader +51 −12, the kernel +44 −30, src/arch.rs −2.
  • Tests: the harness +25 −8.
  • The issue +47.

🤖 Generated with Claude Code

https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L

…with it on

Supersedes #676: the same change, cut from main so that history does not carry the firmware images #676's earlier rounds added and removed.

## What it fixes

All 16 `virt_*` guests died at the AArch64 kernel's first instruction on CI's firmware, Debian's `AAVMF_CODE.no-secboot.fd` 2026.05-2. The runs are 36863809437 (#671's `guest` check) and 36843762360 (main's nightly at `06788146b`).

- Each console ends at the loader's `Loader log: the kernel handoff begins` and then edk2's own `Synchronous Exception at 0x00000000BC33EB20`. That PC is inside the image at `0xbc200000`, and it is the same entered at EL1 and at EL2.
- The same tests pass on the dev host's firmware, Homebrew QEMU 11.1.1's own `edk2-stable202408`.

## Root cause

**1. The image is not executable under firmware's tables.** The loader allocates the kernel image from the `uefi` crate's pool, which allocates `EfiLoaderData`, and it branched to `_start` with firmware's page tables still live.

- edk2's ArmVirtQemu has defaulted `PcdDxeNxMemoryProtectionPolicy` to `0xC000000000007FD5` since `2997ae3873` ("ArmVirtPkg: make EFI_LOADER_DATA non-executable", 2022-08-02). Bit 2 of that value is `EfiLoaderData`, and Debian 2026.05-2 keeps the default.
- QEMU builds its own firmware with `pcds = nx.broken.shim.grub`, `0xC000000000007FD1` (`roms/edk2-build.config` at v11.1.1). Each decompressed DXE volume has one `movz x1, #imm; movk x1, #0xc000, lsl #48; tst` site: `0x7fd5` in Debian's 2026.05-2, `0x7fd1` in Homebrew's.
- UEFI 2.11 §2.3.6 promises only an identity map of RAM ("The state of other system control register bits is not dictated by this specification"), and maps `EFI_MEMORY_XP` to UXN/PXN at EL1&0 and XN at EL2. Debian's no-secboot build sets `PcdUninstallMemAttrProtocol=TRUE`, so a loader cannot ask for the image to be made executable.

**2. A second dependency waited behind the first.** The EL2 entry wrote `HCR_EL2.E2H` clear under firmware's live EL2 tables.

- edk2 `47e28a6d44` ("ArmVirtPkg/ArmPlatformLibQemu: Enable early ID map on EL2+VHE") sets `E2H` at EL2, and CI's loader printed `HCR_EL2.E2H 1`. It is first in `edk2-stable202502` (`git tag --contains`); the message of `979551738` says stable202411, which is wrong.
- `b8df7d9c8e` (stable202605) makes that early map LPA2 where `PARange` is 52 bits, as on `-cpu max`. Clearing `E2H` makes the CPU read the same `TCR_EL2` in the non-VHE layout, under the PC.

## The firmware ruling this keeps

The owner ruled on 2026-09-28 (`dfe61e503`) that a guest's firmware comes from the QEMU installation, inside "Rust and QEMU". Rounds 1 to 4 of this branch committed Debian's edk2 builds on a brief that did not know the ruling. Its last commit restores main's `src/firmware.rs` (the search of QEMU's `firmware/*.json` descriptors that QEMU's interop spec describes) and its callers. It deletes `qemu-firmware/`, its `NOTICE` sections, its two `licenses/` texts, its `COMMITTED_FILES` rows and the per-accelerator table. `nightly.yml` and the host-tools ledger are main's again, so the containers install the same firmware packages as main.

So the dev host boots Homebrew's stable202408 and CI boots Debian's 2026.05-2 from its image's snapshot. That is two firmwares, which is how this defect was caught. Homebrew's needs neither the handover nor the EL2 arm below: it leaves `EfiLoaderData` executable (`0x7fd1`), never sets `E2H`, and main's suite passes on it.

## Decisions

- **The loader hands over with the MMU off, at the level firmware ran it** (`bootloader/src/arch/aarch64.rs`). That is the state PSCI's `CPU_ON` starts every other CPU in.
  - It cleans the image, `KernelArgs` and its own last instructions to the point of coherency, and invalidates the instruction cache. It clears `SCTLR_ELx.M` and `.C`, in `sctlr_el2` at EL2, which names EL2's register whatever `E2H` holds. Then it branches to `_start`.
  - Linux's arm64 `efi_enter_kernel` (v6.1, `efi-entry.S`) does the same, and UEFI allows it: "An application written to this specification may alter the processor execution mode".
  - Rejected: allocating the image as `EfiLoaderCode`. That still rests on firmware's policy for the type, and keeps the `E2H` flip under a live MMU; m1b measures it.
  - Rejected: `EFI_MEMORY_ATTRIBUTE_PROTOCOL`, which Debian uninstalls.
- **The kernel's EL1 write of `SCTLR_MMU_OFF` goes**, with its constant: neither entry arrives under firmware's tables now.
- **The entry refuses `SCTLR_ELx.M` set** at the level it was entered at. Each arm of `apply_declaration` reads its own `SCTLR` and branches to `refused_mmu_on_at_entry` on bit 0, before the EL1 arm's translation registers and before the EL2 arm's `HCR_EL2` write. Main's kernel turned the MMU off at EL1 itself, so a kernel started by a loader that hands over under firmware's tables would have rewritten them under a live walk in silence. The `# Safety` section names what both arms rest on.
- **`virt_el2_drop` boots `-cpu cortex-a72`** (`Profile::VirtEl2NoVhe`), which has no FEAT_VHE.
  - There `HCR_EL2.E2H` is RES0, firmware runs EL2 non-VHE, and the `sctlr_el1` encoding at EL2 names EL1's own register. So only the loader's EL2 arm turns EL2's MMU off. On Debian's 2026.05-2 every `-cpu max` EL2 guest runs with `E2H` set, where that encoding is `SCTLR_EL2`'s, and nothing told the two arms apart.
  - The test asserts `HCR_EL2.E2H 0,` on the loader's line, the premise that tells them apart.
  - Without the EL2 arm, EL2's MMU stays on wherever `E2H` is clear at the handover. On CI's lanes that is this guest alone, and Debian's firmware maps the image execute-never at EL2. On the dev host, whose firmware never sets `E2H`, it is every EL2 guest, and the entry's refusal halts each.
  - No guest test is added. One test's CPU changes, because the behaviour is an exception-level transition under a firmware's translation regime: no type expresses it, the host cannot execute it, and the T14 is x86-64.
  - `src/arch.rs`'s "The CPU every guest of this architecture gets under `accel`" goes, false now.
- **The handover's standing guard is CI's AArch64 lanes on Debian's firmware.** Nothing asserts which firmware CI boots beyond `--ci guest`'s instrument line.
- **`issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md` files the third finding as a hazard.** A host QEMU that ships an edk2 from stable202502 to stable202608 hangs every `virt` guest under HVF in `ExitBootServices`. QEMU's HVF reads `ID_AA64PFR0_EL1.GIC` as 0, and those builds then run their GICv2 driver on a GICv3 machine. Homebrew's QEMU 11.1.1 ships stable202408. The file keeps the measured cause, d3's readings and the exit.

## Controls and oracles

Measured by the orchestrator at earlier heads of this branch. Where a row names 2026.05-2, its guests booted CI's bytes: the committed `QEMU_EFI.fd` and `QEMU_VARS.fd` were the first 3 MiB and 768 KiB of the package's `AAVMF_CODE.no-secboot.fd` and `AAVMF_VARS.fd`, which hold only zeros after them.

| head | run | firmware | result |
|---|---|---|---|
| `8d74557fa` | n0: `979551738` reverted whole | 2026.05-2 | 0/16: the 14 TCG consoles end in CI's recorded failure, 28 `Synchronous Exception` lines; the 2 HVF guests hang in `ExitBootServices` |
| `ef659fc30` | n0 | 2026.05-2 under TCG, 2024.11-5 under HVF | 2/16: the 14 TCG guests red, with 28 `version 2026.05-2` banners and 28 `Synchronous Exception` lines; the 2 HVF guests green |
| `8d74557fa` | m1b: n0 with the image as `EfiLoaderCode` | 2026.05-2 | 1/16: `virt_el1_smp` green, so the XN half alone; the 13 EL2 guests red after `HCR_EL2.E2H 1` with no exception, so the `E2H` half; the 2 HVF guests hang in `ExitBootServices` |
| `8d74557fa` | h1: the fix | Homebrew's stable202408 | 16/16, in two runs |
| `8d74557fa` | d1, d2: the HVF guests | 2026.05-2 | 0/2 each, in `GicV2ExitBootServicesEvent` |
| `7af1d3307` | d3: `ID_AA64PFR0_EL1` read by the loader | 2024.11-5 | 4/4; `GIC` 0 under HVF, 1 under TCG |

Oracles, each independent of the author:

- CI's recorded failure.
- UEFI 2.11 §2.3.6 and its attribute table.
- The Arm ARM: an `_el1` encoding at EL2 is redirected to EL2's register when `E2H` is set, and `E2H` is RES0 without FEAT_VHE.
- Linux v6.1's `efi_enter_kernel`, a differential implementation of the same handoff.
- edk2's source and commits, and QEMU v11.1.1's (`hvf.c`, `virt.c`).
- Each firmware binary, disassembled.

## Gates, at `b0e2f55d1`

| gate | exit |
|---|---|
| `cargo run -- --ci host`: "Host: 56 step(s), all green"; the licence gate "6 exception(s) stand, and nothing else is refused" | 0 |
| `cargo run -- --clippy`: "clippy: 17 invocations clean" | 0 |
| `cargo run -- --build-only --arch aarch64` | 0 |
| the same with `--kernel-feature boot-actuators --kernel-feature test-actuators` | 0 |
| `cargo test --test toyos-build --no-run` | 0 |
| C1, C2, D, n0, D with the refusal reverted, the EL1 arm leaving `M` set, and a measurement-only harness patch that boots Debian's 2026.05-2 for TCG `virt` guests, alone, with n0 and with D: each applied to `b0e2f55d1`, built both ways, the harness compiled, reverted, the tree clean. The patches are in a comment on this pull request. | 0 each |

## Unsure

- No guest has booted `b0e2f55d1`. The whole suite, C1 and C2 under HVF, n0 and D on Debian's firmware and on Homebrew's, D with the refusal reverted, and the EL1 arm leaving `M` set are the orchestrator's runs.
- The cleans of `KernelArgs` (C1) and of the handover's own instructions (C2) are new dependencies, and neither is measured. HVF is the only tier here with real caches.
- `cortex-a72` is QEMU's TCG model; no silicon here runs it.
- This branch's history keeps the deleted firmware, in `5f9508138`, `7af1d3307` and `ef659fc30`. Its objects take 4,383,389 bytes on disk beyond main's (`git rev-list --objects --disk-usage origin/main..HEAD`), and a merge commit carries them into main's history.

## Size

`git diff --shortstat origin/main...HEAD`: 7 files, +176 −57.

- Production: the loader +54 −12, the kernel +44 −30, `src/arch.rs` −2.
- Tests: the harness +31 −13.
- The issue +47.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence at 75aa4afec, this branch's one commit, whose parent is 63cb34ad7 (origin/main).

The change is #676's. #676's net diff is git diff a97ff80df b0e2f55d1, a97ff80df being git merge-base origin/main b0e2f55d1. No commit on main since a97ff80df touches its 7 files (git diff --stat a97ff80df origin/main -- <the 7 files> prints nothing), and git apply --3way applied all 7 cleanly.

  • For each file, this branch's git diff origin/main -- <file> is byte-identical to git diff a97ff80df b0e2f55d1 -- <file>, index lines included (cmp, exit 0 each): bootloader/src/arch/aarch64.rs, issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md, kernel/src/arch/aarch64/boot.rs, kernel/src/arch/aarch64/control_regs.rs, src/arch.rs, tests/common/qemu.rs, tests/toyos.rs.
  • The commit's tree, 117b369c8, is the tree git merge-tree --write-tree origin/main b0e2f55d1 writes, so merging The AArch64 kernel is entered with the MMU off, and refuses an entry with it on #676 into main would land these exact bytes.

History. git log origin/main..HEAD --stat shows one commit, those 7 files, +176 −57, and no path under qemu-firmware/. The objects new to origin/main..HEAD are that commit, 13 trees and 7 blobs. Two blobs exceed 100 kB: tests/common/qemu.rs (104,590 bytes) and tests/toyos.rs (165,257 bytes). They are the whole-file versions of two harness sources that are 103,984 and 164,977 bytes on main, and the same blobs #676's head carries.

measure this branch #676
git rev-list --objects --disk-usage origin/main..<head> 67,772 bytes 4,383,389 bytes
thin pack over main (git pack-objects --revs --thin --stdout) 10,812 bytes 4,304,162 bytes

Gates at 75aa4afec

gate exit
cargo run -- --ci host: "[ci] Host: 59 step(s), all green"; the licence gate "6 exception(s) stand, and nothing else is refused" 0
cargo run -- --clippy: "clippy: 17 invocations clean" 0
cargo run -- --build-only --arch aarch64: "Build finished." 0
cargo test --test toyos-build --no-run 0

No guest run: guests are the orchestrator's.

@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Guest runs at b0e2f55d1 (#676 round 5; the re-cut branch carries the same net change), orchestrator's, this Mac (M4 Pro, Homebrew QEMU 11.1.1 with its edk2-stable202408; virt_early_* under HVF, the other virt_* and x86-64 under TCG), one at a time. Patches: orch/aarch64fw/r5/patches/ as posted in comment 5937056807.

job patch result predicted
aarch64fw-r5-whole none 21/21 (21.9 s) 21/21
aarch64fw-r5-c1 (virt_early_, HVF) C1: KernelArgs clean removed 2/2 green measurement
aarch64fw-r5-c2 (virt_early_, HVF) C2: the 2:–5: clean removed 2/2 green measurement
aarch64fw-r5-deb (virt_) TCG guests on Debian 2026.05-2, banner asserted 16/16 16/16
aarch64fw-r5-deb-n0 (virt_) Debian 2026.05-2 + handover reverted 2/16: the 14 TCG guests red 2/16
aarch64fw-r5-deb-d (virt_) Debian 2026.05-2 + EL2 arm deleted (D) 15/16: only virt_el2_drop (cortex-a72, no VHE) red 15/16
aarch64fw-r5-n0 (virt_) handover reverted, dev host's firmware 16/16 green 16/16
aarch64fw-r5-d (virt_) EL2 arm deleted, dev host's firmware 3/16: the 13 EL2 guests red (refusal) 3/16
aarch64fw-r5-d-r0 (virt_) D with the entry refusal reverted 16/16 green 16/16
aarch64fw-r5-e1 (virt_) EL1 arm leaves M set 13/16: virt_el1_smp, virt_early_panic, virt_early_fault red (refusal) 13/16

C1 and C2 are green under HVF: no tier on this host sees a missing clean, so both cleans rest on the Arm ARM and Linux's efi_enter_kernel, not on a measured red.

@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Guest run at 75aa4afec (this head), orchestrator's, this Mac (M4 Pro, Homebrew QEMU 11.1.1; virt_early_* under HVF, the rest under TCG): a64handover-whole, the whole suite unmutated, 21/21 (100.1 s, the first build in a fresh worktree included).

@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 2, at 75aa4afec (round 1: #676 at ef659fc30, issuecomment-5936508876), against main's .claude/agents/reviewer.md.

Earlier BLOCKERs:

  • 1, src/firmware.rs:44, no test fails if TCG/KVM virt stops booting firmware that sets E2H and maps EfiLoaderData XN — CLOSED, moot. git diff origin/main HEAD -- src/firmware.rs .github NOTICE licenses src/licence.rs issues/build/the-build-runs-host-tools-outside-rust-and-qemu.md is empty and no qemu-firmware/ exists. So CI's lanes boot Debian's 2026.05-2 from the image's snapshot, and deb-n0 (5937389756), which boots those bytes here, reds the 14 TCG guests. n0 is 16/16 on the dev host's firmware, and the body owns that: "standing guard is CI's AArch64 lanes".
  • 2, bootloader/src/arch/aarch64.rs:147-155, the EL2 arm survives deletion — CLOSED. deb-d is 15/16, only virt_el2_drop red. d is 3/16, the 13 EL2 guests halted, and d-r0's 16/16 shows that the EL2 refusal is what halts them.
  • 3, the cleans of KernelArgs and of 2:–5: — CLOSED. C1 and C2 are 2/2 green under HVF, so no tier here sees a missing clean, and both rest on the Arm ARM and Linux v6.1. The body still says "neither is measured" (REMOVE).

Measured for this review:

  • CI: host was pending when this review began. It completed success at 75aa4afec (run 36903495769), and gh pr checks 677 exits 0.
  • 75aa4afec^{tree} = git merge-tree --write-tree origin/main b0e2f55d1 = 117b369c8, and its parent 63cb34ad7 is origin/main. Since a97ff80df, main has changed kernel/ and bootloader/ only in Cargo.toml comments, and tests/ not at all. So 5937389756's runs boot this head's kernel, loader and harness sources.
  • Size: 7 files, +176 −57. Production +98 −44 (loader +54 −12, kernel +44 −30, src/arch.rs −2); tests +31 −13; issue +47. Objects beyond main: 67,772 bytes.
  • The posted n0 is byte-identical to git diff HEAD origin/main over the three production files (cmp exit 0). C1, C2, D, E1, d-r0, n0 and deb each pass git apply --check on 75aa4afec.
  • Profile::VirtEl2NoVhe adds no guest test. It moves one, virt_el2_drop, which asserts its premise (HCR_EL2.E2H 0,). The other 12 VirtEl2 guests stay on -cpu max, so on CI's firmware every one of them still takes the E2H 1 → 0 drop, behind the read-back refusal. It is minimal: a variant, its CPU, and four existing arms.
  • The refusal, read: it tests bit 0 of each level's own SCTLR (sctlr_el2 names EL2's register whatever E2H holds), before MAIR_EL1 at EL1 and before HCR_EL2 at EL2. Nothing in _start, ap_start or apply_declaration stores before it.
  • The hazard issue has status: assigned and kind: tooling, names its holder, and has an exit two guests can show.

BLOCKER

  • kernel/src/arch/aarch64/boot.rs:134-135, :156-157 — nothing measured fails under R (both refusals test C, bit 2) or under R1 (the EL1 pair deleted). Whole leaves M and C both clear, d leaves both set, and e1's "(refusal)" names no halt site. Under R1, e1's three guests write MAIR_EL1 and TCR_EL1 under firmware's live walk, and nothing measured says where that leaves them. E1 and e2 each move only M away from whole's state, so QMP's PC in refused_mmu_on_at_entry under each (runs 1, 2) shows that each level refuses on M.

NOTE

  • bootloader/src/arch/aarch64.rs:152, :158 — bic x9, x9, #(1 << 2) is inert. With M clear, stage 1 gives every data access Device-nGnRnE (Normal under HCR_EL2.DC), whatever C holds; the kernel writes SCTLR_EL1 whole, and EL2's MMU never comes back on. C0 deletes both, together with the prose that names C (:99's "and data cache off", :100's PSCI clause, :125). R then reds every virt guest at head (runs 3, 4), which gives the bit a standing red where run 2 is a one-off.
  • PR body, Controls and Gates — the Decisions' claims about this head stand on 5937389756's rows: deb 16/16, deb-n0 2/16, deb-d 15/16, n0 16/16, d 3/16, d-r0 16/16, e1 13/16, C1 and C2 2/2. The gates stand on 5937375906's rows at 75aa4afec. The body carries neither, and main's record needs both.

REMOVE

  • PR body, "The firmware ruling this keeps": "Rounds 1 to 4 of this branch committed … the same firmware packages as main." — false of this branch, whose one commit touches none of those files.
  • PR body, Controls: "Measured by the orchestrator at earlier heads of this branch. Where a row names 2026.05-2, its guests booted CI's bytes: the committed QEMU_EFI.fd …" — those heads and that commit are The AArch64 kernel is entered with the MMU off, and refuses an entry with it on #676's.
  • PR body, Root cause 2: "the message of 979551738 says stable202411, which is wrong" — corrects a commit that main's history never carries.
  • PR body, Decisions: "Main's kernel turned the MMU off at EL1 itself, so a kernel started by a loader that hands over under firmware's tables would have rewritten them under a live walk in silence." — reads as main's EL1 entry writing under a live walk, where it turned the MMU off first.
  • PR body, Unsure: "No guest has booted b0e2f55d1. …" — false since 5937389756.
  • PR body, Unsure: "The cleans of KernelArgs (C1) and of the handover's own instructions (C2) are new dependencies, and neither is measured. HVF is the only tier here with real caches." — false since C1 and C2.
  • issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md:11 — "Homebrew's QEMU 11.1.1 ships stable202408 today." — rots.
  • issues/build/edk2-stable202502-to-202608-hangs-at-exitbootservices-under-hvf.md:18 — "QEMU master is the same." — rots.
  • tests/toyos.rs:1608-1610 — "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" — restates Profile::VirtEl2NoVhe's doc (tests/common/qemu.rs:743-746).

Patches. Each one, and each pair in the table, passes git apply --check on 75aa4afec. E1 is 5937056807's.

e2, a measurement (the EL2 arm leaves M set):

--- a/bootloader/src/arch/aarch64.rs
+++ b/bootloader/src/arch/aarch64.rs
@@ -148,7 +148,6 @@
             "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",

R1, a mutation:

--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -131,8 +131,6 @@
         "cmp x21, #1",
         "b.ne 9f",
         // 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",

R, a mutation:

--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -132,7 +132,7 @@
         "b.ne 9f",
         // EL1: refused with its MMU on; else the declaration, then translation on.
         "mrs x1, sctlr_el1",
-        "tbnz x1, #0, {refuse_mmu}",
+        "tbnz x1, #2, {refuse_mmu}",
         "msr mair_el1, x4",
         "msr tcr_el1, x2",
         "msr ttbr0_el1, x3",
@@ -154,7 +154,7 @@
         // the drop.
         "2:",
         "mrs x1, sctlr_el2",
-        "tbnz x1, #0, {refuse_mmu}",
+        "tbnz x1, #2, {refuse_mmu}",
         "ldr x1, ={hcr}",
         "msr hcr_el2, x1",
         "isb",

C0, the NOTE's change:

--- a/bootloader/src/arch/aarch64.rs
+++ b/bootloader/src/arch/aarch64.rs
@@ -149,13 +149,11 @@
             "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",
run patches on 75aa4afec guests firmware predicted
1 E1 virt_el1_smp, virt_early_panic the dev host's red; QMP's PC in refused_mmu_on_at_entry in each
2 e2 virt_el2_drop the dev host's red; QMP's PC in refused_mmu_on_at_entry
3 C0 whole the dev host's 21/21
4 C0, R virt_ the dev host's 0/16

R or R1 on top of run 1, and R on top of run 2, move that PC.

SEND BACK

Both `bic x9, x9, #(1 << 2)` in `enter_kernel` go, with the prose that
named `C`: "and data cache" and the PSCI `CPU_ON` clause in its doc, and
`.C` in its SAFETY comment.

With `M` clear, `C` governs no access, so clearing it bought nothing. The
Arm ARM's shared pseudocode (DDI 0487), as Arm's 2023-03 machine-readable
release prints it (ISA_A64_xml_A_profile-2023-03, shared_pseudocode.xml):
`AArch64.S1Translate` returns `AArch64.S1DisabledOutput` whenever
`AArch64.S1Enabled` is false, which is `SCTLR_ELx.M` clear or, at EL1&0,
`HCR_EL2.DC` or `.TGE` set. `S1DisabledOutput` gives a data access
Device-nGnRnE, or Normal Inner and Outer Write-Back at EL1&0 where
`HCR_EL2.DC` is 1, and an instruction fetch Normal memory cacheable by
`SCTLR_ELx.I`; it reads `C` for none of them. `AArch64.S1DCacheEnabled`,
the only reader of `C`, has two callers, both on paths with stage 1
enabled: `S1Translate` after its walk, and `S1Walk`. `SCTLR_EL1.C`'s own
description (SysReg_xml_A_profile-2023-03, AArch64-sctlr_el1.xml): "When
the Effective value of the HCR_EL2.DC bit in the current Security state
is 1, the PE ignores SCTLR_EL1.C." Past the handover nothing reads the
`C` firmware left: the kernel writes `SCTLR_EL1` whole as its MMU comes
on, and never writes `SCTLR_EL2`.

The review's REMOVEs go with it: the HVF hazard issue's two sentences on
what Homebrew's QEMU and QEMU master ship today, and `virt_el2_drop`'s
clause restating `Profile::VirtEl2NoVhe`'s doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation and measurement patches for f182b6cfa. Each job patch was applied to f182b6cfa (git apply --check, then git apply), built with cargo run -- --build-only --arch aarch64 with and without --kernel-feature boot-actuators --kernel-feature test-actuators, the harness compiled (cargo test --test toyos-build --no-run), reverted with git apply -R, and the tree shown clean: exit 0 each.

A job patch is Q followed by its mutations: R.patch is Q, R; E1.patch is Q, E1; e2.patch is Q, e2; E1-R1.patch is Q, E1, R1. R and R1 are the review's, byte for byte (cmp, exit 0 each). E1 and e2 delete the bic x9, x9, #(1 << 0) of the loader's EL1 and EL2 arm.

Q is measurement only, never landed: every AArch64 guest gets a QMP socket, and a boot timeout appends a [halt] line, the boot CPU's PC from info registers resolved by toyos_elf::SymTab::resolve against the kernel ELF the loader handed the guest. KernelArgs is found by the loader's Kernel arguments: line; it and the ELF at its kernel_elf_addr are read back with pmemsave.

Q
--- a/tests/common/qemu.rs
+++ b/tests/common/qemu.rs
@@ -2144,7 +2144,8 @@
 
 /// The QMP socket `options` asks for, named in `dir`.
 fn qmp_socket(dir: &Path, options: &BootOptions) -> Option<PathBuf> {
-    options.qmp.then(|| dir.join("qmp.sock"))
+    // MEASUREMENT ONLY: every AArch64 guest gets one, for `where_halted`.
+    (options.qmp || options.profile.arch() == Arch::Aarch64).then(|| dir.join("qmp.sock"))
 }
 
 /// Every file one boot owns, so that adding another does not lengthen a
@@ -2225,7 +2226,7 @@
     let boot_log = if options.mute {
         String::new()
     } else {
-        wait_for_ready(&mut child, &rx, options, &uart_log)
+        wait_for_ready(&mut child, &rx, options, &uart_log, sockets.qmp.as_deref())
     };
 
     QemuInstance {
@@ -2273,14 +2274,72 @@
         eprintln!("[serial {seq}] {line}");
     }
     tx.send(line).is_ok()
+}
+
+/// MEASUREMENT ONLY, never landed: where the boot CPU of a guest that never
+/// reached its marker is, as one `[halt]` line. QMP's `info registers` gives
+/// its PC; the loader's `Kernel arguments:` line says where `KernelArgs` is,
+/// whose `kernel_memory_addr` is the image's physical base and whose
+/// `kernel_elf_addr` is the ELF the loader handed the kernel. Both are read
+/// back with `pmemsave`, and the PC is resolved against that ELF's `.symtab`
+/// by the kernel's own symbolizer, `toyos_elf::SymTab::resolve`.
+fn where_halted(socket: &Path, seen: &str, scratch: &Path) -> String {
+    let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+        let mut monitor = QmpMonitor::open(socket);
+        let registers = monitor.human("info registers");
+        let Some(pc) = registers.split("PC=").nth(1).and_then(|s| u64::from_str_radix(s.get(..16)?, 16).ok())
+        else {
+            return format!("[halt] no PC in `info registers`:\n{registers}");
+        };
+        let pstate = registers.split("PSTATE=").nth(1).and_then(|s| s.lines().next()).unwrap_or("?");
+        let at = resolve_in_guest(&mut monitor, pc, seen, scratch).unwrap_or_else(|why| format!("unresolved: {why}"));
+        format!("[halt] PC {pc:#x} is {at}; PSTATE={pstate}\n[halt] info registers:\n{registers}")
+    }));
+    match read {
+        Ok(line) => format!("\n{line}"),
+        Err(_) => "\n[halt] the QMP reading itself panicked; its message is above".to_string(),
+    }
+}
+
+fn resolve_in_guest(monitor: &mut QmpMonitor, pc: u64, seen: &str, scratch: &Path) -> Result<String, String> {
+    use toyos_abi::boot::KernelArgs;
+    let args = seen
+        .lines()
+        .find_map(|l| u64::from_str_radix(l.split("Kernel arguments: 0x").nth(1)?.split('+').next()?, 16).ok())
+        .ok_or("the loader never said where KernelArgs is")?;
+    let raw = pmem(monitor, args, std::mem::size_of::<KernelArgs>(), &scratch.with_extension("halt-args"))?;
+    let word = |at: usize| u64::from_le_bytes(raw[at..at + 8].try_into().expect("eight bytes"));
+    let base = word(std::mem::offset_of!(KernelArgs, kernel_memory_addr));
+    let (elf_at, elf_len) =
+        (word(std::mem::offset_of!(KernelArgs, kernel_elf_addr)), word(std::mem::offset_of!(KernelArgs, kernel_elf_size)));
+    let elf = pmem(monitor, elf_at, elf_len as usize, &scratch.with_extension("halt-elf"))?;
+    let (syms, strs) = toyos_symbols::locate(&elf).ok_or("the handed ELF has no readable `.symtab`")?;
+    let table = toyos_elf::SymTab::new(syms, strs);
+    Ok(match pc.checked_sub(base).and_then(|offset| table.resolve(offset)) {
+        Some((name, within)) => format!("{name}+{within:#x}, in the kernel image at {base:#x}"),
+        None => format!("in no kernel function; the kernel image is at {base:#x}, its ELF at {elf_at:#x}+{elf_len:#x}"),
+    })
 }
 
+/// `bytes` of guest physical memory at `phys`, through `pmemsave` into `out`.
+fn pmem(monitor: &mut QmpMonitor, phys: u64, bytes: usize, out: &Path) -> Result<Vec<u8>, String> {
+    let _ = fs::remove_file(out);
+    let command = format!("pmemsave {phys:#x} {bytes} \\\"{}\\\"", out.display());
+    let said = monitor.human(&command);
+    let read = fs::read(out).map_err(|e| format!("{command:?} wrote no file ({e}); the monitor said {said:?}"))?;
+    if read.len() != bytes {
+        return Err(format!("pmemsave {phys:#x} wrote {} bytes and not {bytes}; the monitor said {said:?}", read.len()));
+    }
+    Ok(read)
+}
+
 /// Returns every line seen on the way to the marker — see [`QemuInstance::boot_log`].
 fn wait_for_ready(
     child: &mut Child,
     rx: &Receiver<String>,
     options: &BootOptions,
     uart_log: &Path,
+    qmp: Option<&Path>,
 ) -> String {
     let no_timeout = options.debug_wait;
     let ready = options.ready_marker;
@@ -2313,13 +2372,14 @@
     let mut seen = String::new();
     loop {
         if !no_timeout && start.elapsed() > boot_timeout {
+            let halted = qmp.map(|socket| where_halted(socket, &seen, uart_log)).unwrap_or_default();
             let _ = child.kill();
             // With what it did say. A timeout that discards the console is the
             // one failure in this harness that arrives with no evidence at all,
             // and "the guest printed nothing" and "the guest printed sixty
             // lines and then stopped" are different machines.
             panic!(
-                "[qemu] Boot timed out waiting for {ready}; the console carried:\n{}",
+                "[qemu] Boot timed out waiting for {ready}; the console carried:\n{}{halted}",
                 if seen.is_empty() { "nothing at all".to_string() } else { seen.clone() }
             );
         }
R
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -132,7 +132,7 @@
         "b.ne 9f",
         // EL1: refused with its MMU on; else the declaration, then translation on.
         "mrs x1, sctlr_el1",
-        "tbnz x1, #0, {refuse_mmu}",
+        "tbnz x1, #2, {refuse_mmu}",
         "msr mair_el1, x4",
         "msr tcr_el1, x2",
         "msr ttbr0_el1, x3",
@@ -154,7 +154,7 @@
         // the drop.
         "2:",
         "mrs x1, sctlr_el2",
-        "tbnz x1, #0, {refuse_mmu}",
+        "tbnz x1, #2, {refuse_mmu}",
         "ldr x1, ={hcr}",
         "msr hcr_el2, x1",
         "isb",
R1
--- a/kernel/src/arch/aarch64/boot.rs
+++ b/kernel/src/arch/aarch64/boot.rs
@@ -131,8 +131,6 @@
         "cmp x21, #1",
         "b.ne 9f",
         // 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",
E1
--- a/bootloader/src/arch/aarch64.rs
+++ b/bootloader/src/arch/aarch64.rs
@@ -152,7 +152,6 @@
             "b 4f",
             "3:",
             "mrs x9, sctlr_el1",
-            "bic x9, x9, #(1 << 0)",
             "msr sctlr_el1, x9",
             "4:",
             "isb",
e2
--- a/bootloader/src/arch/aarch64.rs
+++ b/bootloader/src/arch/aarch64.rs
@@ -147,7 +147,6 @@
             "cmp x9, #(2 << 2)",
             "b.ne 3f",
             "mrs x9, sctlr_el2",
-            "bic x9, x9, #(1 << 0)",
             "msr sctlr_el2, x9",
             "b 4f",
             "3:",

@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Guest runs at f182b6cfa (this head), orchestrator's, this Mac (M4 Pro, Homebrew QEMU 11.1.1 and its edk2-stable202408; virt_early_* under HVF, the rest under TCG), one at a time. Each patch carries the measurement-only [halt] harness change (QMP PC at the boot timeout).

job patch result predicted
a64handover-r6-whole none 21/21 (25.0 s) 21/21
a64handover-r6-R (virt_) both refusals test bit 2 (C) 0/16; every halt is refused_mmu_on_at_entry+0x4, at EL1h (virt_el1_smp, HVF pair) and EL2h (the EL2 guests) 0/16
a64handover-r6-E1 (virt_e) the loader leaves EL1's M set 1/4: virt_early_fault, virt_early_panic, virt_el1_smp red, each [halt] PC … is refused_mmu_on_at_entry+0x4 … EL1h 1/4
a64handover-r6-e2 (virt_el2_drop) the loader's EL2 arm leaves M set 0/1: [halt] PC … is refused_mmu_on_at_entry+0x4 … EL2h 0/1
a64handover-r6-E1-R1 (virt_e) E1 with the EL1 check deleted 4/4 green measurement

C0 gives the refusal's bit a standing red (R reds all 16). E1 against E1-R1 shows the EL1 check is what halts those three guests: without it, the kernel entered at EL1 with M set reprograms translation under firmware's live walk and, on these tiers, survives.

@Japabu

Japabu commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 6, at f182b6cfa (round 5: issuecomment-5937855783, at 75aa4afec), against main's .claude/agents/reviewer.md: git diff 75aa4afec..f182b6cfa and one layer down.

Earlier BLOCKER:

  • kernel/src/arch/aarch64/boot.rs:134-135, :156-157: the refusal's bit and its EL1 check had no red. CLOSED by issuecomment-5938455090, at this head:
    • R is 0/16, every [halt] at refused_mmu_on_at_entry+0x4, at both EL1h and EL2h, so a refusal that tests C is now a standing red at both levels.
    • Whole is 21/21 with C set and M clear at the entry, so neither check fires on C.
    • E1 is 1/4 and e2 is 0/1. Each halts at the refusal, at the level where it alone left M set.
    • E1 with R1 is 4/4, so the EL1 check is what halts those three guests. At EL2h only :157 branches to that symbol (git grep).

Measured for this review:

  • CI: host was in progress when this review began. Run 36909897317 has since completed success at f182b6cfa, and gh pr checks 677 exits 0.
  • C0 is right by the Arm ARM. I fetched both 2023-03 releases from developer.arm.com, and each hashes to the body's sha256. In shared_pseudocode.xml:
    • The first test in AArch64.S1Translate returns AArch64.S1DisabledOutput when AArch64.S1Enabled is false.
    • For EL1&0, S1Enabled is (!EL2Enabled() || HCR_EL2.<DC,TGE> == '00') && SCTLR_EL1.M == '1'. For EL2 and EL2&0 it is SCTLR_EL2.M.
    • S1DisabledOutput reads no C. It gives Normal WB at EL1&0 under walkparams.dc (HCR_EL2.DC), a fetch cacheable by I, and Device-nGnRnE for every other access.
    • AArch64.S1DCacheEnabled is the only reader of SCTLR_EL{1,2,3}.C in the AArch64 and shared pseudocode. Its callers are S1Translate, after its walk, and S1Walk, which only S1Translate calls, past that test.
    • The quoted SCTLR_EL1.C sentence is verbatim in AArch64-sctlr_el1.xml.
    • The kernel writes SCTLR_EL2 nowhere (git grep).
  • The commit's citation is accurate. One clause is loose: under HCR_EL2.DC a fetch at EL1&0 also takes the Write-Back branch, not the one I decides. Nothing about C rests on that clause.
  • With M clear, nothing in the pseudocode reads C. So b0e2f55d1's rows carry over to this head, as the body says ("f182b6cfa adds C0 to it").
  • Body: every row is measured except this round's table. That table is marked "PENDING, the orchestrator's", and every result in it is pending. All of round 5's REMOVEs are gone: those in the body, the issue's two, and the one in tests/toyos.rs.
  • 5938314241's R, R1, E1, e2 and Q each pass git apply --check at f182b6cfa, and the tree stays clean.
  • Size (git diff --shortstat origin/main...HEAD): 7 files, +167 −52.
    • Production +95 −44: the loader +51 −12, the kernel +44 −30, src/arch.rs −2.
    • Tests +25 −8, and the issue +47.
    • This round is +16 −20.

BLOCKER

  • None.

NOTE

  • PR body, "This round's runs": every result is pending, so main's record lacks this head's measurement. 5938455090 fills the table:

    run result
    whole 21/21
    R 0/16, every halt at the refusal, at EL1h and EL2h
    E1 1/4, three halts at the refusal at EL1h
    e2 0/1, at the refusal at EL2h
    E1-R1 4/4

    Once it is filled, "PENDING" leaves the heading.

REMOVE

  • PR body, Decisions: "C now reaches the entry as firmware set it, so R, both refusals testing bit 2, is predicted to halt every virt guest: the control that tells the bit apart." The R row now measures this prediction.
  • PR body, Controls: "e1 reds the EL1 guests, but names no halt site; run 1 below reads it." The E1 row names the halt site.
  • PR body, Unsure: "Where E1 with R1 leaves the EL1 guests. …" E1 with R1 answered it: 4/4 green.

LAND AFTER NAMED CHANGES

@Japabu
Japabu added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 76d0d93 Oct 1, 2026
1 check passed
@Japabu
Japabu deleted the wt/toyos-a64handover branch October 1, 2026 19:28
Japabu added a commit that referenced this pull request Oct 1, 2026
#677 (76d0d93) touches no file this branch does: the AArch64 loader hands
the kernel over with the MMU off, and the kernel refuses an entry with it on.
It is the fix `issues/kernel/every-aarch64-guest-dies-at-the-kernels-entry-on-cis-firmware.md`
waits on; that issue's exit is CI's `guest` check, which this merge is the
first of this branch to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016t9wjdQkB8SH7bmfUoiy6L
Japabu added a commit that referenced this pull request Oct 2, 2026
…ernel issues CI met

Filed, each with its owner, evidence and exit:
- a runner image that moves cc, c++ or CMake moves every LLVM key, so
  every pull request and merge group builds the cold path until main
  saves the new keys;
- a toolchain job that ends inside `--ci bootstrap` saves no layer it
  built: run 36913380100 built all four, reded in that step and saved
  none, and run 36934214557 built the LLVM again.

Closed on job 110649069031 (run 36934214557, `guest / suite` under KVM on
the merge of f1ccb0b into 76d0d93: "test result: ok. 21 passed, 21
total"), which meets both exits as written:
- every-aarch64-guest-dies-at-the-kernels-entry-on-cis-firmware: the
  sixteen `virt_*` tests are green in CI's `guest` check, since #677
  enters the AArch64 kernel with the MMU off;
- the-nested-nmi-report-interleaves-with-another-cpus-console-line:
  `nested_nmi_is_loud` is green there, since #675 writes the report
  under the console's registers, which `nested_nmi`'s own comment says.
Neither was cited outside the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UDZQ6fSKw14e4w2TKTRfm
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.

1 participant