QEMU Machines
yoe ships two QEMU machines that serve as the default development and CI
targets: qemu-arm64 and qemu-x86_64. Neither corresponds to physical
hardware — both target QEMU’s emulated virt/q35 machines and exist to let
you iterate on userspace and the kernel without booting real silicon each time.
This page covers what each machine ships, how the boot path differs between
them, and what yoe qemu actually does at run time.
Machine descriptors live at:
modules/module-core/machines/qemu-arm64.starmodules/module-core/machines/qemu-x86_64.star
Both lean entirely on module-core and module-alpine — no board-specific BSP
units.
Comparison at a glance
| Aspect | qemu-arm64 | qemu-x86_64 |
|---|---|---|
| Arch | arm64 | x86_64 |
| QEMU machine | virt | q35 |
| CPU | host | host |
| Firmware | none (direct kernel boot) | seabios (QEMU default) |
| Bootloader | none — QEMU -kernel | syslinux in the rootfs |
| Console | ttyAMA0 (PL011 UART) | ttyS0 (16550 UART) |
| Root device | /dev/vda1 (single part) | /dev/vda2 |
| Kernel unit | linux (generic) | linux (x86_64_defconfig) |
| Extra packages | none | syslinux (Alpine only) |
| Default forwards | 2222:22, 8080:80, 8118:8118 | same |
Both default to 4 GB RAM and display = "none"; the -nographic flag sends
serial to the controlling terminal. 4 GB is the floor for memory-heavy unit
builds inside the guest — the kernel link step alone needs well over 1 GB, so a
self-hosted yoe build of linux is OOM-killed on a smaller VM.
Pass --display to yoe run (e.g. yoe run qt-image --display) to drop
-nographic and let QEMU open its native window for the guest framebuffer. The
launcher attaches a virtio-vga adapter for the DRM virtio-gpu driver and keeps
serial multiplexed onto host stdio so kernel logs still appear in the terminal
that started the run. The kernel’s graphics.cfg fragment turns on the relevant
FB/DRM bits (DRM_VIRTIO_GPU, DRM_BOCHS, FB_VESA, FB_EFI,
DRM_FBDEV_EMULATION), so /dev/fb0 is present from the first boot — needed by
linuxfb-backed UIs like the qt-image demo.
qemu-arm64
machine(
name = "qemu-arm64",
arch = "arm64",
kernel = kernel(
unit = "linux",
defconfig = "defconfig",
cmdline = "console=ttyAMA0 root=/dev/vda1 rw",
),
partitions = [
partition(label = "rootfs", type = "ext4", size = "512M", root = True),
],
qemu = qemu_config(
machine = "virt", cpu = "host", memory = "4G",
display = "none",
ports = ["2222:22", "8080:80", "8118:8118"],
),
)
There is no bootloader and no boot partition. yoe qemu invokes QEMU with
-kernel <vmlinuz> taken from the built image’s own /boot, and passes the
machine’s cmdline via -append. The kernel is whichever one the image ships:
Alpine installs /boot/vmlinuz and mounts the rootfs directly, while Debian
installs a versioned /boot/vmlinuz-<ver> alongside an initrd.img-<ver> that
QEMU also receives via -initrd. QEMU loads these straight into emulated DRAM
on the virt machine and starts the A53 cores at the kernel entry point.
This is the one place in yoe where direct-kernel boot is the correct path, not a
shortcut. The virt machine has no analog in physical silicon — there is no
ROM, no SPL, no need for U-Boot. (For physical aarch64 boards, see
BeaglePlay for the full ROM → SPL → TF-A → U-Boot →
kernel chain.)
EFI-only kernels boot through UEFI firmware. Direct -kernel boot only
works for a bare arm64 Image (Alpine and Debian ship one — recognizable by the
ARMd magic at offset 56). Ubuntu builds its arm64 kernels as EFI zboot: a
zstd-compressed EFI/PE application with no bare-Image header, which the
firmware-less -kernel path cannot start (the guest would hang with a silent
console). The launcher detects this — an EFI-only /boot/vmlinuz — and boots it
through edk2/AAVMF UEFI firmware (-bios), which still picks up the
-kernel/-initrd/-append that follow via fw_cfg and runs the kernel’s own
EFI stub to decompress and start it. The firmware comes from the host’s
qemu-efi-aarch64 / edk2-aarch64 package; if it is missing, yoe run fails
with an actionable message rather than launching a guest that never prints. Bare
Image kernels are untouched and keep the plain direct-kernel path.
On real hardware: this UEFI handoff is a QEMU-side accommodation for the dev/CI machine. Booting an EFI-zboot kernel on a physical low-end SoC (e.g. a TI AM62 whose U-Boot uses the classic
booti/extlinux flow, which also expects a bareImage) needs a separate decision when such a target is added — either the board’s U-Boot UEFI path (bootefi), or unwrapping the zboot payload back to a bareImageat image-assembly time. The bare-Imagedistros sidestep the question entirely.
The single ext4 partition becomes /dev/vda1 through QEMU’s virtio-blk disk.
The disk is presented to the guest as a raw image file, attached with
-drive file=...,format=raw,if=virtio.
qemu-x86_64
machine(
name = "qemu-x86_64",
arch = "x86_64",
kernel = kernel(
unit = "linux",
defconfig = "x86_64_defconfig",
cmdline = "console=ttyS0 root=/dev/vda2 rw",
),
distro_packages = {"alpine": ["syslinux"]},
partitions = [
partition(label = "rootfs", type = "ext4", size = "600M", root = True),
],
qemu = qemu_config(
machine = "q35", cpu = "host", memory = "4G",
firmware = "seabios",
display = "none",
ports = ["2222:22", "8080:80", "8118:8118"],
),
)
x86_64 goes through a real bootloader: SeaBIOS (QEMU’s built-in legacy BIOS,
used by default on q35) reads the MBR off the virtio disk and jumps into
syslinux, which loads the kernel from the ext4 rootfs.
syslinux sits under distro_packages rather than the plain packages list
because it is the bootloader for Alpine images only: the from-source unit
installs mbr.bin into the rootfs for the disk-creation step. Apt images
(Debian, Ubuntu) get extlinux from the glibc toolchain container at
disk-creation time instead, so syslinux must never enter their closure —
distro_packages = {"alpine": [...]} keeps it out. A board package that every
distro needs (GPU firmware, U-Boot stages, config.txt) goes in packages,
which merges into every image regardless of distro.
This mirrors how a physical x86 board with legacy BIOS boots, so the same image
will also boot on bare metal that lacks UEFI. (UEFI/OVMF support is set up in
internal/device/qemu.go — pass firmware = "ovmf" instead to swap SeaBIOS for
OVMF and boot via EFI.)
Why root=/dev/vda2 when there’s only one partition declared? syslinux
installation inserts its own boot sector ahead of the data partition, so the
visible partition index starts at 2 once the image is on disk. The rootfs is
still that single ext4 — it just lives at vda2 from Linux’s view.
What runs inside the guest
Both machines pick up the generic linux unit from module-core, not a
board-specific kernel. That unit builds arch/<arch>/boot/{Image,bzImage} plus
the standard module set; no out-of-tree drivers, no custom defconfig fragment.
The userspace stack is whatever the project includes via its package list plus
the rootfs base — busybox, OpenRC, apk-tools, and any apks pulled through
module-alpine. See libc, init, and the Rootfs Base for the
userspace layout.
Networking
yoe qemu wires a single virtio-net device through QEMU’s user-mode networking
(SLIRP). The default forwards in the machine descriptor land SSH on host port
2222 and a couple of HTTP ports for app dev. Extra forwards can be passed on the
CLI (yoe run --port 9000:9000, repeatable). A --port entry whose guest
port matches a machine forward replaces that forward; an entry with a new guest
port is appended.
That replace-on-match behavior is what makes --port usable for qemu-in-qemu —
see Running inside a QEMU guest
below.
Tuning at run time
Three knobs override the machine descriptor for a given developer without
editing checked-in .star files:
| Knob | Local override | CLI flag | Persisted by |
|---|---|---|---|
| RAM | qemu_memory = "8G" | --memory 8G | --memory and TUI |
| Display | qemu_display = "on" | --display | TUI |
| Forwards | qemu_ports = [...] | --port h:g | TUI |
All three live in local.star and apply the next time you run the same image.
The TUI editor is on Setup → QEMU settings (press s, move down to QEMU
settings, press Enter). The Ports section lists the effective forwards
yoe run would bind — the machine’s declarations with any local.star
overrides already merged in — and every row is editable: highlight a forward,
press Enter, and change its host port (e.g. move a colliding 8080:8080 to
18080:8080). Because the override replaces by guest port, editing a
machine-declared forward writes a local.star entry for that guest port that
supersedes the machine default; pressing d on an overridden forward reverts it
to the machine default, while a/+ adds a brand-new forward. A
machine-declared forward with no override can’t be deleted (there is nothing in
local.star to remove) — change its host port instead. The order at run time is
machine ← local.star ← CLI, so a one-off --port still beats a saved entry
for the same guest port.
How yoe qemu runs
The launcher in internal/device/qemu.go:
- Picks the binary by arch:
qemu-system-aarch64,qemu-system-x86_64, orqemu-system-riscv64. - Builds the arg list:
-machine,-cpu,-m,-nographicby default (or-device virtio-vga -serial mon:stdiowhenyoe run --displayis set, which lets QEMU open its native window and still leaves the serial console muxed onto host stdio), the virtio-blk drive, the virtio-net device with port forwards, and-biosif a firmware (OVMF/AAVMF) is set. On a same-arch host it adds-enable-kvmwhen/dev/kvmis present; when it is not (notably qemu-in-qemu without nested virtualization) it drops KVM, downgrades ahostCPU tomax, and runs under TCG software emulation instead — slower, but it still boots. - If the machine has no
firmware, appends-kernel <vmlinuz> -append <cmdline>for the direct-boot path (this is what qemu-arm64 uses), taking the kernel from the built image’s/bootand adding-initrd <initrd.img>only when the image ships a real initramfs (e.g. Debian). The launcher reads these straight off the host rootfs, so the image build makes the kernel world-readable (Ubuntu otherwise ships it root-only, which would fail with “could not load kernel”). An image that carries no initramfs boots through the kernel’s built-in drivers (Alpine, and Ubuntu — whose kernel only recommends an initramfs generator); the launcher resolves the/boot/initrd.imgsymlink and omits-initrdwhen it dangles, rather than handing QEMU a path it would reject with “could not load initrd”. - Tries host QEMU first; falls back to running QEMU inside the
toolchain-muslcontainer with the project bind-mounted at/projectif the host doesn’t have it installed.
The image yoe passes is whatever the assembly step produced, attached
read-write. Restart-and-iterate workflows: rebuild, then re-run yoe qemu — the
image is regenerated, the guest starts clean.
When to use which
- qemu-x86_64 is the right default for most development. KVM acceleration on an x86 host is essentially native speed; the boot path matches legacy-BIOS bare metal so what you debug here is what runs on similar hardware.
- qemu-arm64 is for catching arch-specific bugs (byte ordering, alignment, ARM64-only paths in code) without finding a board. It runs under TCG (software emulation) on x86 hosts, which is slow but faithful. On aarch64 hosts (an Apple Silicon Mac, an Ampere server) it uses KVM and is fast.
- For anything physical-board-shaped — secure boot, vendor blobs, display, real I/O — use the actual board’s machine descriptor.
Running inside a QEMU guest (qemu-in-qemu)
yoe run works from within a guest that is itself running under QEMU — useful
for exercising a self-hosted yoe build. Two things differ from a run on the
bare host, and yoe run handles both:
-
Port forwards collide. The outer guest already holds the machine’s default host forwards (
2222,8080,8118), so a nested run cannot bind them. Remap the host side with--port; an entry whose guest port matches a default forward replaces it:yoe run base-image --port 12222:22 --port 18080:80 --port 18118:8118 -
No KVM. A guest has no
/dev/kvmunless its host was started with nested virtualization.yoe rundetects this and falls back to TCG software emulation automatically — it printsusing TCG software emulation (slower)and boots. No flag is needed; expect roughly a 10–20× slowdown versus KVM.
To get full-speed nested runs instead of TCG, enable nested virtualization on
the bare-metal host (kvm_intel/kvm_amd module option nested=1) and start
the outer guest with a passthrough CPU so /dev/kvm appears inside it.