Code ·

Running WSL2 inside Windows-on-ARM on Apple Silicon, with no nested-virt hardware


I wanted to run and automatically test Windows-on-ARM binaries from a Mac. Most of that is easy: build a Windows 11 ARM VM, push an .exe in over SSH, run it, capture the exit code. Even x86 binaries work, through Windows’ own emulation.

The hard part is the binaries that need a hypervisor inside the guest. WSL2 is the obvious one. Its lightweight utility VM needs the in-guest hypervisor turned on, and on Apple Silicon that is exactly where the usual path falls apart.

Why this is hard

The hardware-accelerated path on Apple Silicon (QEMU on Hypervisor.framework, hvf) cannot give a Windows guest a usable in-guest hypervisor. Apple’s own nested virtualization arrived in macOS 26, but it is nVHE-only and limited to M3 and later. So if you are on an M2, or an older macOS, or you just want WSL2 to come up reliably, the hardware route is a dead end.

The way around it is almost stubbornly simple: stop trying to accelerate, and emulate the whole machine instead.

The trick: emulate, and expose EL3

Run the guest under full software emulation (TCG), same architecture, aarch64-on-aarch64. Because nothing is being accelerated, the host chip no longer has to provide nested virtualization. A faster chip just makes it quicker; the capability itself is not gated on the hardware.

The one detail that took a while to pin down is the firmware. Windows only enables its in-guest hypervisor when the CPU exposes EL3, but it must not boot through a foreign EL3 firmware. The combination that works is to build EDK2 as an ArmVirtQemuKernel image and load it via QEMU’s -kernel with secure=on. The CPU then has EL3, but boots into EL2, which is what Windows wants to see:

-accel tcg,thread=multi -cpu neoverse-n1 \
-M virt,virtualization=on,gic-version=3,secure=on,highmem=on \
-kernel QEMU_EFI.fd

I am not the first to find this. dariaphoebe described the manual version in a UTM thread earlier this year, using a prebuilt firmware image and a hand-assembled config. What follows is mostly about turning that into something reproducible, and fast enough to use in a test loop.

Making it reproducible

Two things were worth doing on top of the manual recipe.

First, build the firmware yourself. The prebuilt QEMU_EFI.fd images that floated around came from a snapshot endpoint that has since been deprecated. Building EDK2 as ArmVirtQemuKernel in a container removes that dependency entirely and pins exactly what you boot.

Second, wrap the launch in a small library so the dozen fiddly flags below are not something you reassemble by hand each time.

The details that actually took the time

Getting Windows 11 ARM to install and boot under QEMU at all, before any of the WSL2 work, is a sequence of small cliffs. Each of these is the difference between a black screen and a desktop.

Display: ramfb, not virtio-gpu. With virtio-gpu-pci the Windows bootloader screen just freezes. Switching to -device ramfb is the single most important fix, and it is not obvious from any error, because there is no error.

System disk: NVMe, not AHCI. WinPE can happily install onto an AHCI disk using the inbox storahci driver. But the EDK2 firmware does not enumerate ich9-ahci, so on the post-install reboot it cannot find the disk and drops to the EFI shell. The install hangs forever at a reboot that looks like it should just work. NVMe satisfies both sides: EDK2 enumerates it as BLK NVMe, and Windows has the inbox stornvme driver.

Install media: removable USB. Modern Windows Setup only scans removable media for autounattend.xml, and WinPE always has an inbox USB driver, while it has none for the controller a plain -cdrom attaches. Attaching the install ISO as usb-storage,removable=on fixes both the “media driver missing” screen and the unattended-install pickup.

Boot: hammer the keys. The USB install media does not auto-boot; you land in the EFI shell and run fs0:\efi\boot\bootaa64.efi yourself. That launches Setup’s “Press any key to boot from CD…” prompt, which waits about five seconds. Under TCG the guest is slow enough that capturing the screen and then sending a key misses the window every time, and you fall back to the shell with no error. The fix is to stop being polite about it: the moment the bootloader launches, drive QEMU’s monitor sendkey over a unix socket in a tight loop with no gaps.

Performance, and why snapshots matter

Booting an emulated Windows all the way into “WSL2 ready” takes a few minutes. The slow part is the in-guest hypervisor coming up at boot, and there is no way around paying it once.

The trick is to pay it exactly once. Snapshot the warm state and resume it:

  • Put the system disk on virtio-blk, not NVMe. NVMe cannot be snapshotted; virtio-blk can.
  • Boot once into “Windows booted, WSL2 ready,” then savevm a resumable snapshot.
  • For each test run, loadvm that snapshot. It comes back in about ten seconds.

For reference, a plain hvf boot with no in-guest hypervisor is around 20 to 30 seconds, so the resumed-emulation path is in the same ballpark for everyday use, despite running fully emulated.

One last gotcha on the WSL2 side: the inbox wsl.exe is a stub. On a headless or SSH session it will not do anything useful. Install the WSL package directly from its .msixbundle and call the real binary by its full path, "C:\Program Files\WSL\wsl.exe". And wsl --import plus the utility VM only come up on this EL3 firmware path; they will not run under hvf.

Where this stands

All of the above is verified end to end on an M2 Max, macOS 26, QEMU 11: build a golden image from your own ISO, clone it, boot it, install WSL2, snapshot the warm state, and resume it in seconds to run a command and capture its output. It is slower than hardware acceleration would be, but it works today, on hardware that has no nested virtualization at all.

I packaged the whole thing as frost. It never redistributes Windows: you bring your own ISO and license, the image is built locally and owned by you. The firmware build, the QEMU config, and the snapshot/resume harness are all there.

→ github.com/solcreek/frost

If you are trying the same thing and getting stuck at the EFI shell, the disk bus and the press-any-key window are the two places I would look first. If you find a cleaner path, I would like to hear it.