From dc7335d039dde162b5da1ccd05e07c3037bbc11f Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:32:38 +0200 Subject: [PATCH 01/20] real-hw-test: add UEFI application scaffold --- real-hw-test/.gitignore | 2 + real-hw-test/Cargo.lock | 160 +++++++++++++++++++++++++++++++++++++++ real-hw-test/Cargo.toml | 10 +++ real-hw-test/Makefile | 27 +++++++ real-hw-test/src/main.rs | 18 +++++ 5 files changed, 217 insertions(+) create mode 100644 real-hw-test/.gitignore create mode 100644 real-hw-test/Cargo.lock create mode 100644 real-hw-test/Cargo.toml create mode 100644 real-hw-test/Makefile create mode 100644 real-hw-test/src/main.rs diff --git a/real-hw-test/.gitignore b/real-hw-test/.gitignore new file mode 100644 index 0000000..495a75e --- /dev/null +++ b/real-hw-test/.gitignore @@ -0,0 +1,2 @@ +/build/ + diff --git a/real-hw-test/Cargo.lock b/real-hw-test/Cargo.lock new file mode 100644 index 0000000..00b70b6 --- /dev/null +++ b/real-hw-test/Cargo.lock @@ -0,0 +1,160 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "uart-16550-real-hw-test" +version = "0.1.0" +dependencies = [ + "uart_16550", + "uefi", +] + +[[package]] +name = "uart_16550" +version = "0.8.0" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f2e1b4a439d82899da4dc0ee8ac742db07a9ff493bc4f31d345c24e12e17e9" +dependencies = [ + "bitflags", + "cfg-if", + "log", + "ptr_meta", + "ucs2", + "uefi-macros", + "uefi-raw", + "uguid", +] + +[[package]] +name = "uefi-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4687412b5ac74d245d5bfb1733ede50c31be19bf8a4b6a967a29b451bab49e67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uefi-raw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a36f49db0b04a8dfd246a26ddf8d2526a2ffd8cf946ae7fe603fd0c5fbd2786" +dependencies = [ + "bitflags", + "uguid", +] + +[[package]] +name = "uguid" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8352f8c05e47892e7eaf13b34abd76a7f4aeaf817b716e88789381927f199c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/real-hw-test/Cargo.toml b/real-hw-test/Cargo.toml new file mode 100644 index 0000000..85dce59 --- /dev/null +++ b/real-hw-test/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "uart-16550-real-hw-test" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +uart_16550 = { path = ".." } +uefi = { version = "0.38.0", features = ["global_allocator", "panic_handler"] } + diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile new file mode 100644 index 0000000..12b9c12 --- /dev/null +++ b/real-hw-test/Makefile @@ -0,0 +1,27 @@ +CARGO ?= cargo +TARGET := x86_64-unknown-uefi +TARGET_DIR := ../target/real-hw-test +PROFILE := release +BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi +ARTIFACT := build/BOOTX64.EFI + +.PHONY: all artifact check clean + +all: artifact + +artifact: $(ARTIFACT) + +$(ARTIFACT): + CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked --target $(TARGET) --release + mkdir -p $(dir $(ARTIFACT)) + cp $(BINARY) $(ARTIFACT) + +check: + $(CARGO) fmt --check + CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked --target $(TARGET) --release -- -D warnings + $(MAKE) artifact + +clean: + $(CARGO) clean --target-dir $(TARGET_DIR) + rm -rf build + diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs new file mode 100644 index 0000000..ea59b92 --- /dev/null +++ b/real-hw-test/src/main.rs @@ -0,0 +1,18 @@ +#![no_main] +#![no_std] +#![deny(clippy::undocumented_unsafe_blocks)] + +//! Manual UEFI integration test for this repository's `uart_16550` driver. +//! +//! The phases isolate firmware ownership, hardware discovery, direct register +//! access, and public driver APIs so the screen identifies the failing layer. + +use uefi::prelude::*; + +/// Starts the UEFI test and returns success while later commits add phases. +#[entry] +fn main() -> Status { + uefi::helpers::init().expect("UEFI helpers should initialize"); + uefi::println!("uart_16550 real-hardware test"); + Status::SUCCESS +} From 3ed11412f6c5097e15e95e2a8fd03cfb6b65566c Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 02/20] real-hw-test: add QEMU development environment --- real-hw-test/Makefile | 18 ++++++++++--- real-hw-test/scripts/run-qemu.sh | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100755 real-hw-test/scripts/run-qemu.sh diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 12b9c12..9968453 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -1,27 +1,37 @@ CARGO ?= cargo +QEMU ?= qemu-system-x86_64 +QEMU_ACCEL ?= kvm TARGET := x86_64-unknown-uefi TARGET_DIR := ../target/real-hw-test PROFILE := release BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi ARTIFACT := build/BOOTX64.EFI +BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked +CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.PHONY: all artifact check clean +.PHONY: all artifact check qemu qemu-tcg clean all: artifact artifact: $(ARTIFACT) $(ARTIFACT): - CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked --target $(TARGET) --release + $(BUILD) --target $(TARGET) --release mkdir -p $(dir $(ARTIFACT)) cp $(BINARY) $(ARTIFACT) check: $(CARGO) fmt --check - CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked --target $(TARGET) --release -- -D warnings + $(CLIPPY) --target $(TARGET) --release -- -D warnings $(MAKE) artifact +qemu: artifact + QEMU="$(QEMU)" QEMU_ACCEL="$(QEMU_ACCEL)" OVMF="$(OVMF)" \ + ./scripts/run-qemu.sh $(QEMU_ARGS) + +qemu-tcg: + $(MAKE) qemu QEMU_ACCEL=tcg + clean: $(CARGO) clean --target-dir $(TARGET_DIR) rm -rf build - diff --git a/real-hw-test/scripts/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh new file mode 100755 index 0000000..49be207 --- /dev/null +++ b/real-hw-test/scripts/run-qemu.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +qemu=${QEMU:-qemu-system-x86_64} +accel=${QEMU_ACCEL:-kvm} +ovmf=${OVMF:-} +esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp} +artifact=${ARTIFACT:-build/BOOTX64.EFI} + +if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ + >&2 + exit 2 +fi +if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 +fi +if ! command -v "$qemu" >/dev/null 2>&1; then + echo "error: QEMU executable not found: $qemu" >&2 + exit 2 +fi +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi + +# Recreate the virtual ESP so QEMU never boots a stale application. +rm -rf "$esp_dir" +mkdir -p "$esp_dir/EFI/BOOT" +cp "$artifact" "$esp_dir/EFI/BOOT/BOOTX64.EFI" + +echo "QEMU COM1 is attached to this terminal." +echo "QEMU will print a /dev/pts/... path for the PCI serial device." + +exec "$qemu" \ + -machine "q35,accel=$accel" \ + -m 256M \ + -bios "$ovmf" \ + -drive "format=raw,file=fat:rw:$esp_dir" \ + -nic none \ + -monitor none \ + -serial stdio \ + -chardev pty,id=pci_serial \ + -device pci-serial,chardev=pci_serial \ + "$@" From e3cd523a0360eb1d9378e397ae6aa5d04566a962 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 03/20] nix: add flake development environment The dev shell supplies QEMU, OVMF, and rustup. The .envrc enables direnv integration. --- real-hw-test/.envrc | 1 + real-hw-test/flake.lock | 27 +++++++++++++++++++++++++++ real-hw-test/flake.nix | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 real-hw-test/.envrc create mode 100644 real-hw-test/flake.lock create mode 100644 real-hw-test/flake.nix diff --git a/real-hw-test/.envrc b/real-hw-test/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/real-hw-test/.envrc @@ -0,0 +1 @@ +use flake diff --git a/real-hw-test/flake.lock b/real-hw-test/flake.lock new file mode 100644 index 0000000..bc9ba69 --- /dev/null +++ b/real-hw-test/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1786719841, + "narHash": "sha256-QcpQOT0NQEFkI77t+YXPZqDJc35iIodG7zinieOwFUg=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "8be7bd0c83f12e2e3bbba07c9044d6fed9e66f7f", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix new file mode 100644 index 0000000..0ae349e --- /dev/null +++ b/real-hw-test/flake.nix @@ -0,0 +1,33 @@ +{ + description = "uart_16550 UEFI real-hardware test"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + + outputs = + { nixpkgs, ... }: + let + systems = [ "x86_64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + devShells = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + qemu_kvm + rustup + ]; + env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; + }; + } + ); + + formatter = forAllSystems ( + system: nixpkgs.legacyPackages.${system}.nixfmt-tree + ); + }; +} From 6cc4a1de1511a4e5086aa49d8bdc361342165a61 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 04/20] real-hw-test: discover and disconnect serial devices --- real-hw-test/Cargo.toml | 2 +- real-hw-test/Makefile | 5 +- real-hw-test/src/device.rs | 81 ++++++++++++ real-hw-test/src/discovery.rs | 40 ++++++ real-hw-test/src/discovery/acpi.rs | 201 +++++++++++++++++++++++++++++ real-hw-test/src/discovery/pci.rs | 168 ++++++++++++++++++++++++ real-hw-test/src/firmware.rs | 92 +++++++++++++ real-hw-test/src/main.rs | 25 ++++ real-hw-test/src/raw_uart.rs | 75 +++++++++++ 9 files changed, 687 insertions(+), 2 deletions(-) create mode 100644 real-hw-test/src/device.rs create mode 100644 real-hw-test/src/discovery.rs create mode 100644 real-hw-test/src/discovery/acpi.rs create mode 100644 real-hw-test/src/discovery/pci.rs create mode 100644 real-hw-test/src/firmware.rs create mode 100644 real-hw-test/src/raw_uart.rs diff --git a/real-hw-test/Cargo.toml b/real-hw-test/Cargo.toml index 85dce59..f874a58 100644 --- a/real-hw-test/Cargo.toml +++ b/real-hw-test/Cargo.toml @@ -6,5 +6,5 @@ publish = false [dependencies] uart_16550 = { path = ".." } -uefi = { version = "0.38.0", features = ["global_allocator", "panic_handler"] } +uefi = { version = "0.38.0", features = ["alloc", "global_allocator", "panic_handler"] } diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 9968453..a951075 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -15,7 +15,7 @@ all: artifact artifact: $(ARTIFACT) -$(ARTIFACT): +$(ARTIFACT): FORCE $(BUILD) --target $(TARGET) --release mkdir -p $(dir $(ARTIFACT)) cp $(BINARY) $(ARTIFACT) @@ -35,3 +35,6 @@ qemu-tcg: clean: $(CARGO) clean --target-dir $(TARGET_DIR) rm -rf build + +.PHONY: FORCE +FORCE: diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs new file mode 100644 index 0000000..ed68715 --- /dev/null +++ b/real-hw-test/src/device.rs @@ -0,0 +1,81 @@ +use alloc::vec::Vec; +use core::fmt::{self, Display, Formatter}; + +use uart_16550::spec::CLK_FREQUENCY_HZ; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// A byte-addressable 16550 register block reached through PIO or MMIO. +pub enum Address { + Port(u16), + Mmio { base: usize, stride: u8 }, +} + +impl Display for Address { + /// Formats an address in the form used by on-screen diagnostics. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Port(port) => write!(f, "PIO 0x{port:04x}"), + Self::Mmio { base, stride } => { + write!(f, "MMIO 0x{base:x}, stride {stride}") + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Records how discovery found a candidate so duplicate descriptions remain useful. +pub enum Source { + RequiredCom1, + LegacyProbe, + AcpiSpcr, + Pci { + segment: u32, + bus: u8, + device: u8, + function: u8, + }, +} + +#[derive(Debug)] +/// A deduplicated UART address, clock, and all firmware or bus provenance. +pub struct Candidate { + pub address: Address, + pub clock_hz: u32, + pub sources: Vec, +} + +#[derive(Debug, Default)] +/// The candidate list shared by raw, driver, and interactive test phases. +pub struct Inventory { + candidates: Vec, +} + +impl Inventory { + /// Adds a source to an address, merging descriptions to avoid duplicate tests. + pub fn add(&mut self, address: Address, clock_hz: Option, source: Source) { + if let Some(candidate) = self + .candidates + .iter_mut() + .find(|candidate| candidate.address == address) + { + if !candidate.sources.contains(&source) { + candidate.sources.push(source); + } + if let Some(clock_hz) = clock_hz.filter(|clock| *clock != 0) { + candidate.clock_hz = clock_hz; + } + return; + } + + self.candidates.push(Candidate { + address, + clock_hz: clock_hz.unwrap_or(CLK_FREQUENCY_HZ), + sources: alloc::vec![source], + }); + } + + /// Returns candidates in discovery order for stable on-screen summaries. + pub fn candidates(&self) -> &[Candidate] { + &self.candidates + } +} diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs new file mode 100644 index 0000000..f81bb65 --- /dev/null +++ b/real-hw-test/src/discovery.rs @@ -0,0 +1,40 @@ +//! UART discovery through legacy probing, ACPI SPCR, and PCI enumeration. +//! +//! Multiple discovery paths cover fixed COM ports and dynamically described +//! UARTs, including QEMU's independent PCI serial controller. + +use crate::device::{Address, Inventory, Source}; +use crate::raw_uart::RawUart; + +mod acpi; +mod pci; + +/// Combines every discovery source into a deduplicated test inventory. +pub fn discover() -> Inventory { + let mut inventory = Inventory::default(); + discover_legacy(&mut inventory); + acpi::discover(&mut inventory); + pci::discover(&mut inventory); + inventory +} + +/// Probes conventional COM addresses while always retaining COM1 as a baseline. +fn discover_legacy(inventory: &mut Inventory) { + const PORTS: [u16; 4] = [0x3f8, 0x2f8, 0x3e8, 0x2e8]; + + uefi::println!("\nLegacy UART probes:"); + for (index, port) in PORTS.into_iter().enumerate() { + let address = Address::Port(port); + let passed = RawUart::new(address).scratch_test(); + uefi::println!( + " {address}: scratch test {}", + if passed { "PASS" } else { "FAIL" } + ); + + if index == 0 { + inventory.add(address, None, Source::RequiredCom1); + } else if passed { + inventory.add(address, None, Source::LegacyProbe); + } + } +} diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs new file mode 100644 index 0000000..c783627 --- /dev/null +++ b/real-hw-test/src/discovery/acpi.rs @@ -0,0 +1,201 @@ +//! Conservative ACPI SPCR discovery for firmware-described serial consoles. +//! +//! SPCR matters where a debug UART is not at a conventional COM address. Strict +//! validation prevents treating an incompatible layout as a 16550 device. + +use core::slice; + +use uefi::system; +use uefi::table::cfg::ConfigTableEntry; + +use crate::device::{Address, Inventory, Source}; + +const SDT_HEADER_LEN: usize = 36; +const MAX_TABLE_LEN: usize = 1024 * 1024; + +/// Locates SPCR from UEFI configuration tables and safely skips invalid data. +pub fn discover(inventory: &mut Inventory) { + uefi::println!("\nACPI SPCR discovery:"); + let rsdp = system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) + .or_else(|| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) + }) + .map(|entry| entry.address as usize) + }); + + let Some(rsdp) = rsdp else { + uefi::println!(" SKIP: no ACPI RSDP in the UEFI configuration table"); + return; + }; + + match find_spcr(rsdp) { + Ok(Some(spcr)) => add_spcr(inventory, spcr), + Ok(None) => uefi::println!(" SKIP: no SPCR table"), + Err(reason) => uefi::println!(" SKIP: invalid ACPI data: {reason}"), + } +} + +/// The SPCR subset needed to validate and add a byte-access UART candidate. +#[derive(Clone, Copy)] +struct SpcrInfo { + interface: u8, + address_space: u8, + bit_width: u8, + bit_offset: u8, + access_size: u8, + base: u64, + clock_hz: Option, +} + +/// Accepts only SPCR layouts that the byte-oriented driver can safely access. +fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { + uefi::println!( + concat!( + " interface=0x{:02x} space={} base=0x{:x} width={} ", + "offset={} access={} clock={:?}", + ), + spcr.interface, + spcr.address_space, + spcr.base, + spcr.bit_width, + spcr.bit_offset, + spcr.access_size, + spcr.clock_hz + ); + + if !matches!(spcr.interface, 0x00 | 0x01 | 0x12) { + uefi::println!(" SKIP: SPCR interface is not 16450/16550-compatible"); + return; + } + if spcr.bit_offset != 0 || !matches!(spcr.bit_width, 0 | 8) { + uefi::println!(" SKIP: UART registers are not byte-aligned byte fields"); + return; + } + if !matches!(spcr.access_size, 0 | 1) { + uefi::println!(" SKIP: uart_16550 requires byte register accesses"); + return; + } + + let address = match spcr.address_space { + 0 if spcr.base <= (usize::MAX - 7) as u64 => Address::Mmio { + base: spcr.base as usize, + stride: 1, + }, + 1 if spcr.base <= u64::from(u16::MAX - 7) => Address::Port(spcr.base as u16), + 0 | 1 => { + uefi::println!(" SKIP: SPCR base address is out of range"); + return; + } + _ => { + uefi::println!(" SKIP: unsupported ACPI address space"); + return; + } + }; + + uefi::println!(" candidate: {address}"); + inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); +} + +/// Validates RSDP and XSDT/RSDT data before finding and decoding an SPCR table. +fn find_spcr(rsdp_address: usize) -> Result, &'static str> { + let rsdp = acpi_bytes(rsdp_address, 36)?; + if &rsdp[..8] != b"RSD PTR " || !checksum_ok(&rsdp[..20]) { + return Err("bad RSDP signature or checksum"); + } + + let revision = rsdp[15]; + let (root_address, entry_size) = if revision >= 2 { + let length = read_u32(rsdp, 20) as usize; + if !(36..=4096).contains(&length) { + return Err("invalid RSDP length"); + } + let full = acpi_bytes(rsdp_address, length)?; + if !checksum_ok(full) { + return Err("bad extended RSDP checksum"); + } + (read_u64(full, 24) as usize, 8) + } else { + (read_u32(rsdp, 16) as usize, 4) + }; + + let root = sdt(root_address)?; + let expected = if entry_size == 8 { b"XSDT" } else { b"RSDT" }; + if &root[..4] != expected { + return Err("root table has the wrong signature"); + } + + for entry in root[SDT_HEADER_LEN..].chunks_exact(entry_size) { + let address = if entry_size == 8 { + read_u64(entry, 0) as usize + } else { + read_u32(entry, 0) as usize + }; + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + if &header[..4] != b"SPCR" { + continue; + } + let table = sdt(address)?; + if table.len() < 80 { + return Err("SPCR is too short"); + } + let clock = read_u32(table, 76); + return Ok(Some(SpcrInfo { + interface: table[36], + address_space: table[40], + bit_width: table[41], + bit_offset: table[42], + access_size: table[43], + base: read_u64(table, 44), + clock_hz: (clock != 0).then_some(clock), + })); + } + Ok(None) +} + +/// Borrows mapped firmware ACPI memory after rejecting a null physical address. +fn acpi_bytes(address: usize, length: usize) -> Result<&'static [u8], &'static str> { + if address == 0 { + return Err("null ACPI table address"); + } + // SAFETY: UEFI keeps firmware ACPI memory mapped while boot services run. + Ok(unsafe { slice::from_raw_parts(address as *const u8, length) }) +} + +/// Validates an SDT's declared bounded length and complete ACPI checksum. +fn sdt(address: usize) -> Result<&'static [u8], &'static str> { + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + let length = read_u32(header, 4) as usize; + if !(SDT_HEADER_LEN..=MAX_TABLE_LEN).contains(&length) { + return Err("invalid SDT length"); + } + let table = acpi_bytes(address, length)?; + checksum_ok(table) + .then_some(table) + .ok_or("bad SDT checksum") +} + +/// Applies ACPI's wrapping-byte checksum rule to one complete table region. +fn checksum_ok(bytes: &[u8]) -> bool { + bytes.iter().fold(0_u8, |sum, byte| sum.wrapping_add(*byte)) == 0 +} + +/// Decodes a bounds-checked little-endian 32-bit ACPI field without raw offsets. +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + let value = bytes[offset..offset + 4] + .try_into() + .expect("caller validated ACPI field bounds"); + u32::from_le_bytes(value) +} + +/// Decodes a bounds-checked little-endian 64-bit ACPI field without raw offsets. +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + let value = bytes[offset..offset + 8] + .try_into() + .expect("caller validated ACPI field bounds"); + u64::from_le_bytes(value) +} diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs new file mode 100644 index 0000000..53e6f84 --- /dev/null +++ b/real-hw-test/src/discovery/pci.rs @@ -0,0 +1,168 @@ +//! Conservative PCI serial-controller discovery through UEFI root bridges. +//! +//! It verifies BAR-backed PIO/MMIO paths and gives QEMU a device independent of +//! legacy COM1. + +use alloc::vec::Vec; + +use uefi::Status; +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; +use uefi::proto::pci::PciIoAddress; +use uefi::proto::pci::root_bridge::PciRootBridgeIo; + +use crate::device::{Address, Inventory, Source}; + +/// Opens each root bridge read-only and searches it for serial-class endpoints. +pub fn discover(inventory: &mut Inventory) { + uefi::println!("\nPCI serial-controller discovery:"); + let handles = match boot::find_handles::() { + Ok(handles) => handles, + Err(error) if error.status() == Status::NOT_FOUND => { + uefi::println!(" SKIP: no PCI root bridge protocol"); + return; + } + Err(error) => { + uefi::println!(" SKIP: PCI root bridge lookup failed: {error:?}"); + return; + } + }; + + for handle in handles { + let params = OpenProtocolParams { + handle, + agent: boot::image_handle(), + controller: None, + }; + let root = { + // SAFETY: GetProtocol is read-only and firmware retains the interface. + unsafe { + boot::open_protocol::(params, OpenProtocolAttributes::GetProtocol) + } + }; + match root { + Ok(mut root) => discover_root(&mut root, inventory), + Err(error) => uefi::println!(" root bridge open failed: {error:?}"), + } + } +} + +/// Enumerates one segment and forwards serial-class functions for BAR inspection. +fn discover_root(root: &mut PciRootBridgeIo, inventory: &mut Inventory) { + let segment = root.segment_nr(); + let tree = match root.enumerate() { + Ok(tree) => tree, + Err(error) => { + uefi::println!(" segment {segment}: enumeration failed: {error:?}"); + return; + } + }; + let addresses: Vec<_> = tree.iter().copied().collect(); + + for address in addresses { + let Ok(class_register) = config_u32(root, address, 0x08) else { + continue; + }; + let class = (class_register >> 24) as u8; + let subclass = (class_register >> 16) as u8; + if class != 0x07 || subclass != 0x00 { + continue; + } + + inspect_serial_controller(root, segment, address, class_register, inventory); + } +} + +/// Validates one endpoint's interface, decoding state, and BAR0 before using it. +fn inspect_serial_controller( + root: &mut PciRootBridgeIo, + segment: u32, + address: PciIoAddress, + class_register: u32, + inventory: &mut Inventory, +) { + let identity = config_u32(root, address, 0x00).unwrap_or(u32::MAX); + let command = config_u16(root, address, 0x04).unwrap_or(0); + let header_type = config_u8(root, address, 0x0e).unwrap_or(0xff) & 0x7f; + let prog_if = (class_register >> 8) as u8; + let bar0 = config_u32(root, address, 0x10).unwrap_or(0); + let bar1 = config_u32(root, address, 0x14).unwrap_or(0); + let vendor = identity as u16; + let device_id = (identity >> 16) as u16; + let (bus, device, function) = (address.bus, address.dev, address.fun); + + uefi::println!( + concat!( + " {:04x}:{:02x}:{:02x}.{}: ", + "{:04x}:{:04x} prog-if=0x{:02x} ", + "command=0x{:04x} BAR0=0x{:08x}", + ), + segment, + bus, + device, + function, + vendor, + device_id, + prog_if, + command, + bar0, + ); + if header_type != 0 || !(0x02..=0x06).contains(&prog_if) { + uefi::println!(" SKIP: not an unambiguous 16550-compatible endpoint"); + return; + } + + let candidate = if bar0 & 1 != 0 { + let base = bar0 & !0x3; + if command & 1 == 0 || base > u32::from(u16::MAX - 7) { + None + } else { + Some(Address::Port(base as u16)) + } + } else { + let memory_type = (bar0 >> 1) & 0x3; + let base = match memory_type { + 0 => u64::from(bar0 & !0xf), + 2 => (u64::from(bar1) << 32) | u64::from(bar0 & !0xf), + _ => 0, + }; + if command & 2 == 0 || base == 0 || base > usize::MAX as u64 { + None + } else { + Some(Address::Mmio { + base: base as usize, + stride: 1, + }) + } + }; + + let Some(candidate) = candidate else { + uefi::println!(" SKIP: BAR0 is disabled, invalid, or unsupported"); + return; + }; + uefi::println!(" candidate: {candidate}"); + inventory.add( + candidate, + None, + Source::Pci { + segment, + bus, + device, + function, + }, + ); +} + +/// Reads one byte from PCI configuration space through the root bridge. +fn config_u8(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} + +/// Reads one 16-bit PCI configuration value through the root bridge. +fn config_u16(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} + +/// Reads one 32-bit PCI configuration value through the root bridge. +fn config_u32(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs new file mode 100644 index 0000000..4667d92 --- /dev/null +++ b/real-hw-test/src/firmware.rs @@ -0,0 +1,92 @@ +//! UEFI console input and Serial I/O ownership handoff. +//! +//! The test records the firmware baseline, then disconnects serial controllers +//! so firmware and the driver never program a UART concurrently. + +use alloc::vec::Vec; +use core::time::Duration; + +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams, SearchType}; +use uefi::proto::console::serial::Serial; +use uefi::proto::console::text::Key; +use uefi::{Handle, Status, system}; + +/// Collects Serial I/O handles, treating an absent protocol as an empty list. +fn serial_handles() -> Result, Status> { + match boot::locate_handle_buffer(SearchType::from_proto::()) { + Ok(handles) => Ok(handles.iter().copied().collect()), + Err(error) if error.status() == Status::NOT_FOUND => Ok(Vec::new()), + Err(error) => Err(error.status()), + } +} + +/// Polls Simple Text Input until local Enter while keeping errors visible. +pub fn wait_for_enter() { + loop { + match system::with_stdin(|input| input.read_key()) { + Ok(Some(Key::Printable(key))) if key == '\r' || key == '\n' => return, + Ok(_) => boot::stall(Duration::from_millis(20)), + Err(error) => { + uefi::println!("WARN: keyboard read failed: {error:?}"); + boot::stall(Duration::from_millis(100)); + } + } + } +} + +/// Records firmware serial state, then releases every Serial I/O controller. +pub fn disconnect_serial_controllers() -> bool { + let handles = match serial_handles() { + Ok(handles) => handles, + Err(status) => { + uefi::println!("FAIL: cannot enumerate UEFI SerialIo handles: {status:?}"); + return false; + } + }; + + uefi::println!("UEFI exposes {} SerialIo handle(s).", handles.len()); + for (index, handle) in handles.iter().copied().enumerate() { + let params = OpenProtocolParams { + handle, + agent: boot::image_handle(), + controller: None, + }; + let protocol = { + // SAFETY: GetProtocol is non-exclusive and dropped before disconnect. + unsafe { boot::open_protocol::(params, OpenProtocolAttributes::GetProtocol) } + }; + match protocol { + Ok(serial) => { + let mode = serial.io_mode(); + uefi::println!( + " [{index}] baud={} data={} parity={:?} stop={:?} timeout={} us fifo={}", + mode.baud_rate, + mode.data_bits, + mode.parity, + mode.stop_bits, + mode.timeout, + mode.receive_fifo_depth + ); + } + Err(error) => uefi::println!(" [{index}] mode unavailable: {error:?}"), + } + } + + uefi::println!("UEFI SERIAL BASELINE: firmware still owns serial output"); + uefi::println!("Confirm the baseline, set the remote to 9600 8N1, then press Enter."); + wait_for_enter(); + + let mut success = true; + for (index, handle) in handles.into_iter().enumerate() { + match boot::disconnect_controller(handle, None, None) { + Ok(()) => uefi::println!(" [{index}] disconnected"), + Err(error) => { + uefi::println!(" [{index}] FAIL: disconnect_controller: {error:?}"); + success = false; + } + } + } + + uefi::println!("UEFI SCREEN CHECK: serial controller disconnection complete"); + success +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index ea59b92..ae50b87 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -7,6 +7,13 @@ //! The phases isolate firmware ownership, hardware discovery, direct register //! access, and public driver APIs so the screen identifies the failing layer. +extern crate alloc; + +mod device; +mod discovery; +mod firmware; +mod raw_uart; + use uefi::prelude::*; /// Starts the UEFI test and returns success while later commits add phases. @@ -14,5 +21,23 @@ use uefi::prelude::*; fn main() -> Status { uefi::helpers::init().expect("UEFI helpers should initialize"); uefi::println!("uart_16550 real-hardware test"); + + if !firmware::disconnect_serial_controllers() { + uefi::println!("FAIL: firmware serial ownership was not released"); + return Status::DEVICE_ERROR; + } + + let inventory = discovery::discover(); + uefi::println!("\nUsable UART candidates: {}", inventory.candidates().len()); + for (index, candidate) in inventory.candidates().iter().enumerate() { + uefi::println!( + " [{index}] {} clock={} Hz sources={:?}", + candidate.address, + candidate.clock_hz, + candidate.sources + ); + } + uefi::println!("\nDiscovery complete. Press Enter to return to firmware."); + firmware::wait_for_enter(); Status::SUCCESS } diff --git a/real-hw-test/src/raw_uart.rs b/real-hw-test/src/raw_uart.rs new file mode 100644 index 0000000..c13c8bc --- /dev/null +++ b/real-hw-test/src/raw_uart.rs @@ -0,0 +1,75 @@ +use core::arch::asm; + +use crate::device::Address; + +#[derive(Clone, Copy, Debug)] +pub struct RawUart { + address: Address, +} + +impl RawUart { + pub const fn new(address: Address) -> Self { + Self { address } + } + + pub unsafe fn read(&mut self, offset: u8) -> u8 { + debug_assert!(offset < 8); + match self.address { + Address::Port(base) => { + let port = base + u16::from(offset); + let value: u8; + // SAFETY: ownership of the discovered UART was established before probing. + unsafe { + asm!( + "in al, dx", + in("dx") port, + out("al") value, + options(nomem, nostack, preserves_flags) + ); + } + value + } + Address::Mmio { base, stride } => { + let address = base + usize::from(offset) * usize::from(stride); + // SAFETY: the firmware-provided MMIO range is live while boot services are active. + unsafe { core::ptr::read_volatile(address as *const u8) } + } + } + } + + pub unsafe fn write(&mut self, offset: u8, value: u8) { + debug_assert!(offset < 8); + match self.address { + Address::Port(base) => { + let port = base + u16::from(offset); + // SAFETY: ownership of the discovered UART was established before probing. + unsafe { + asm!( + "out dx, al", + in("dx") port, + in("al") value, + options(nomem, nostack, preserves_flags) + ); + } + } + Address::Mmio { base, stride } => { + let address = base + usize::from(offset) * usize::from(stride); + // SAFETY: the firmware-provided MMIO range is live while boot services are active. + unsafe { core::ptr::write_volatile(address as *mut u8, value) }; + } + } + } + + pub fn scratch_test(&mut self) -> bool { + const SPR: u8 = 7; + + // The old value is restored because firmware may use the scratch register diagnostically. + let old = unsafe { self.read(SPR) }; + let passed = [0x42, 0x73].into_iter().all(|pattern| unsafe { + self.write(SPR, pattern); + self.read(SPR) == pattern + }); + unsafe { self.write(SPR, old) }; + passed + } +} From ae93c9f6545b219c157f27f0970f27d9a79713e3 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:45:10 +0200 Subject: [PATCH 05/20] real-hw-test: add barebones UART preflight --- real-hw-test/src/main.rs | 16 ++- real-hw-test/src/preflight.rs | 59 ++++++++ real-hw-test/src/raw_uart.rs | 264 ++++++++++++++++++++++++++++++++-- 3 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 real-hw-test/src/preflight.rs diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index ae50b87..ec5c0a3 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -12,6 +12,7 @@ extern crate alloc; mod device; mod discovery; mod firmware; +mod preflight; mod raw_uart; use uefi::prelude::*; @@ -37,7 +38,18 @@ fn main() -> Status { candidate.sources ); } - uefi::println!("\nDiscovery complete. Press Enter to return to firmware."); + + let preflight = preflight::run(inventory.candidates()); + let passed = preflight.iter().filter(|result| result.passed).count(); + uefi::println!( + "\nBarebones summary: {passed}/{} candidate(s) passed.", + preflight.len() + ); + uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); - Status::SUCCESS + if passed == preflight.len() { + Status::SUCCESS + } else { + Status::DEVICE_ERROR + } } diff --git a/real-hw-test/src/preflight.rs b/real-hw-test/src/preflight.rs new file mode 100644 index 0000000..3ce86c8 --- /dev/null +++ b/real-hw-test/src/preflight.rs @@ -0,0 +1,59 @@ +//! Barebones register-level UART checks before the crate driver is constructed. +//! +//! This independent path validates addresses, clocks, FIFOs, and loopback so a +//! later public-API failure is easier to diagnose. + +use alloc::vec::Vec; + +use crate::device::Candidate; +use crate::raw_uart::RawUart; + +#[derive(Clone, Copy, Debug)] +/// The automatic raw preflight outcome used to gate driver construction. +pub struct Result { + pub passed: bool, +} + +/// Runs the independent register-level preflight for every discovered UART. +pub fn run(candidates: &[Candidate]) -> Vec { + candidates.iter().map(run_one).collect() +} + +/// Initializes, snapshots, loopbacks, validates, and transmits on one UART. +fn run_one(candidate: &Candidate) -> Result { + uefi::println!("\nBarebones preflight: {}", candidate.address); + let mut uart = RawUart::new(candidate.address); + uart.snapshot().print("initial"); + + let divisor = match uart.initialize(candidate.clock_hz) { + Ok(divisor) => { + uefi::println!(" PASS: initialized at 9600 8N1 (divisor {divisor})"); + divisor + } + Err(error) => return fail("initialization", error), + }; + uart.snapshot().print("after raw init"); + + if let Err(error) = uart.test_loopback() { + return fail("single-byte/FIFO loopback", error); + } + uefi::println!(" PASS: single-byte and 16-byte loopback"); + uart.snapshot().print("after raw loopback"); + + if let Err(error) = uart.validate_configuration(divisor) { + return fail("register validation", error); + } + uefi::println!(" PASS: register invariants"); + + if let Err(error) = uart.send_bytes(b"[barebones] uart transmit test\r\n") { + return fail("transmit payload", error); + } + uefi::println!(" PASS: barebones transmit payload queued"); + Result { passed: true } +} + +/// Prints a consistently labelled raw-preflight failure result. +fn fail(stage: &str, error: crate::raw_uart::PreflightError) -> Result { + uefi::println!(" FAIL: {stage}: {error:?}"); + Result { passed: false } +} diff --git a/real-hw-test/src/raw_uart.rs b/real-hw-test/src/raw_uart.rs index c13c8bc..6a3852e 100644 --- a/real-hw-test/src/raw_uart.rs +++ b/real-hw-test/src/raw_uart.rs @@ -1,24 +1,103 @@ +//! Minimal synchronous 16550 access for the independent reference path. +//! +//! Direct PIO/MMIO operations validate a candidate before `Uart16550` exists, +//! avoiding a circular test that verifies the driver only with itself. + use core::arch::asm; +use core::hint; use crate::device::Address; +const DATA: u8 = 0; +const IER: u8 = 1; +const ISR_FCR: u8 = 2; +const LCR: u8 = 3; +const MCR: u8 = 4; +const LSR: u8 = 5; +const MSR: u8 = 6; +const SPR: u8 = 7; + +const LCR_DLAB: u8 = 1 << 7; +const LSR_DATA_READY: u8 = 1 << 0; +const LSR_THR_EMPTY: u8 = 1 << 5; +const LSR_TRANSMITTER_EMPTY: u8 = 1 << 6; +const MCR_LOOP_BACK: u8 = 1 << 4; + +const POLL_LIMIT: usize = 2_000_000; + +/// An independently programmed UART used to establish a hardware baseline. #[derive(Clone, Copy, Debug)] pub struct RawUart { address: Address, } +/// A diagnostic snapshot of normal and divisor-latch 16550 registers. +#[derive(Clone, Copy, Debug)] +pub struct RegisterSnapshot { + pub ier: u8, + pub isr: u8, + pub lcr: u8, + pub mcr: u8, + pub lsr: u8, + pub msr: u8, + pub spr: u8, + pub dll: u8, + pub dlm: u8, +} + +impl RegisterSnapshot { + /// Reassembles the divisor-latch bytes captured while DLAB was enabled. + pub const fn divisor(self) -> u16 { + (self.dlm as u16) << 8 | self.dll as u16 + } + + /// Prints byte values so hardware-specific deviations remain comparable. + pub fn print(self, label: &str) { + uefi::println!( + concat!( + " {}: IER={:02x} ISR={:02x} LCR={:02x} ", + "MCR={:02x} LSR={:02x} MSR={:02x} SPR={:02x} ", + "divisor={}", + ), + label, + self.ier, + self.isr, + self.lcr, + self.mcr, + self.lsr, + self.msr, + self.spr, + self.divisor() + ); + } +} + +/// Identifies the raw preflight stage that detected a non-working UART. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreflightError { + ScratchRegister, + InvalidClock, + TransmitterTimeout, + ReceiverTimeout, + UnexpectedByte { expected: u8, actual: u8 }, + UnexpectedMessage, + RegisterMismatch, +} + impl RawUart { + /// Creates a reference accessor without touching the candidate yet. pub const fn new(address: Address) -> Self { Self { address } } - pub unsafe fn read(&mut self, offset: u8) -> u8 { + /// Reads one register through the candidate's PIO or MMIO mapping. + pub fn read(&mut self, offset: u8) -> u8 { debug_assert!(offset < 8); match self.address { Address::Port(base) => { let port = base + u16::from(offset); let value: u8; - // SAFETY: ownership of the discovered UART was established before probing. + // SAFETY: discovery assigned an owned 16550-compatible PIO port. unsafe { asm!( "in al, dx", @@ -31,18 +110,19 @@ impl RawUart { } Address::Mmio { base, stride } => { let address = base + usize::from(offset) * usize::from(stride); - // SAFETY: the firmware-provided MMIO range is live while boot services are active. + // SAFETY: discovery validated the live firmware MMIO range. unsafe { core::ptr::read_volatile(address as *const u8) } } } } - pub unsafe fn write(&mut self, offset: u8, value: u8) { + /// Writes one register through the candidate's PIO or MMIO mapping. + pub fn write(&mut self, offset: u8, value: u8) { debug_assert!(offset < 8); match self.address { Address::Port(base) => { let port = base + u16::from(offset); - // SAFETY: ownership of the discovered UART was established before probing. + // SAFETY: discovery assigned an owned 16550-compatible PIO port. unsafe { asm!( "out dx, al", @@ -54,22 +134,182 @@ impl RawUart { } Address::Mmio { base, stride } => { let address = base + usize::from(offset) * usize::from(stride); - // SAFETY: the firmware-provided MMIO range is live while boot services are active. + // SAFETY: discovery validated the live firmware MMIO range. unsafe { core::ptr::write_volatile(address as *mut u8, value) }; } } } + /// Writes two patterns and restores the scratch register to detect UARTs. pub fn scratch_test(&mut self) -> bool { - const SPR: u8 = 7; - - // The old value is restored because firmware may use the scratch register diagnostically. - let old = unsafe { self.read(SPR) }; - let passed = [0x42, 0x73].into_iter().all(|pattern| unsafe { + // Restore the old value because firmware may inspect it diagnostically. + let old = self.read(SPR); + let passed = [0x42, 0x73].into_iter().all(|pattern| { self.write(SPR, pattern); self.read(SPR) == pattern }); - unsafe { self.write(SPR, old) }; + self.write(SPR, old); passed } + + /// Captures normal and banked registers while restoring the original LCR. + pub fn snapshot(&mut self) -> RegisterSnapshot { + let original_lcr = self.read(LCR); + self.write(LCR, original_lcr & !LCR_DLAB); + let ier = self.read(IER); + let snapshot = RegisterSnapshot { + ier, + isr: self.read(ISR_FCR), + lcr: original_lcr, + mcr: self.read(MCR), + lsr: self.read(LSR), + msr: self.read(MSR), + spr: self.read(SPR), + dll: 0, + dlm: 0, + }; + self.write(LCR, original_lcr | LCR_DLAB); + let snapshot = RegisterSnapshot { + dll: self.read(DATA), + dlm: self.read(IER), + ..snapshot + }; + self.write(LCR, original_lcr); + snapshot + } + + /// Programs polling-mode 9600 8N1 after validating the clock and scratch. + pub fn initialize(&mut self, clock_hz: u32) -> Result { + if !self.scratch_test() { + return Err(PreflightError::ScratchRegister); + } + let denominator = 16 * 9_600; + if clock_hz == 0 || !clock_hz.is_multiple_of(denominator) { + return Err(PreflightError::InvalidClock); + } + let divisor = u16::try_from(clock_hz / denominator) + .ok() + .filter(|divisor| *divisor != 0) + .ok_or(PreflightError::InvalidClock)?; + + self.write(LCR, 0); + self.write(IER, 0); + self.write(LCR, LCR_DLAB); + self.write(DATA, divisor as u8); + self.write(IER, (divisor >> 8) as u8); + self.write(LCR, 0x03); + self.write(ISR_FCR, 0xc7); + self.write(MCR, 0x0b); + self.wait_for_lsr(LSR_TRANSMITTER_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + + // Acknowledge stale line/modem deltas inherited from firmware. + let _ = self.read(LSR); + let _ = self.read(MSR); + Ok(divisor) + } + + /// Checks one-byte and FIFO-sized internal transfers, restoring MCR after. + pub fn test_loopback(&mut self) -> Result<(), PreflightError> { + const MESSAGE: [u8; 16] = *b"hello world!1337"; + let old_mcr = self.read(MCR); + self.write(MCR, MCR_LOOP_BACK); + self.write(ISR_FCR, 0xc7); + self.drain_receive_fifo(); + + let result = (|| { + self.send_byte(0x42)?; + let byte = self.receive_byte()?; + if byte != 0x42 { + return Err(PreflightError::UnexpectedByte { + expected: 0x42, + actual: byte, + }); + } + + self.wait_for_lsr(LSR_THR_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + for byte in MESSAGE { + self.write(DATA, byte); + } + let mut received = [0_u8; MESSAGE.len()]; + for byte in &mut received { + *byte = self.receive_byte()?; + } + (received == MESSAGE) + .then_some(()) + .ok_or(PreflightError::UnexpectedMessage) + })(); + + self.write(MCR, old_mcr); + self.write(ISR_FCR, 0xc7); + result + } + + /// Confirms raw initialization survived loopback and matches invariants. + pub fn validate_configuration(&mut self, divisor: u16) -> Result<(), PreflightError> { + self.wait_for_lsr(LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + let snapshot = self.snapshot(); + let matches = snapshot.ier == 0 + && snapshot.lcr == 0x03 + && snapshot.mcr & 0x1f == 0x0b + && snapshot.lsr & (LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY) + == LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY + && snapshot.isr & 0xc0 == 0xc0 + && snapshot.divisor() == divisor; + matches + .then_some(()) + .ok_or(PreflightError::RegisterMismatch) + } + + /// Sends an entire diagnostic payload with bounded polling per byte. + pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<(), PreflightError> { + for &byte in bytes { + self.send_byte(byte)?; + } + Ok(()) + } + + /// Waits for an empty transmit holding register before sending one byte. + fn send_byte(&mut self, byte: u8) -> Result<(), PreflightError> { + self.wait_for_lsr(LSR_THR_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + self.write(DATA, byte); + Ok(()) + } + + /// Waits for a received byte, bounding failures instead of hanging firmware. + fn receive_byte(&mut self) -> Result { + self.wait_for_lsr(LSR_DATA_READY, true) + .ok_or(PreflightError::ReceiverTimeout)?; + Ok(self.read(DATA)) + } + + /// Removes one FIFO of stale input that could otherwise falsify loopback. + fn drain_receive_fifo(&mut self) { + for _ in 0..16 { + if self.read(LSR) & LSR_DATA_READY == 0 { + break; + } + let _ = self.read(DATA); + } + } + + /// Polls line-status bits up to a fixed limit and returns the matching LSR. + fn wait_for_lsr(&mut self, mask: u8, all: bool) -> Option { + for _ in 0..POLL_LIMIT { + let lsr = self.read(LSR); + let ready = if all { + lsr & mask == mask + } else { + lsr & mask != 0 + }; + if ready { + return Some(lsr); + } + hint::spin_loop(); + } + None + } } From f97247207fbe327834fe6791dab637080c1ae1af Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:46:48 +0200 Subject: [PATCH 06/20] real-hw-test: exercise uart_16550 devices --- real-hw-test/src/driver_test.rs | 275 ++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 18 ++- 2 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 real-hw-test/src/driver_test.rs diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs new file mode 100644 index 0000000..c209ef1 --- /dev/null +++ b/real-hw-test/src/driver_test.rs @@ -0,0 +1,275 @@ +//! Checks performed through the public `uart_16550` API. +//! +//! These run after raw preflight so a driver failure can be distinguished from +//! an absent or non-responsive UART. + +use alloc::vec::Vec; +use core::ptr::NonNull; + +use uart_16550::backend::{MmioBackend, PioBackend}; +use uart_16550::spec::registers::{LSR, MCR}; +use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; + +use crate::device::{Address, Candidate}; +use crate::preflight; + +/// The public-driver backend selected for a PIO or MMIO candidate. +pub enum Driver { + Port(Uart16550), + Mmio(Uart16550), +} + +/// The automatic driver result retained for summary and interactive phases. +pub struct Result { + pub passed: bool, + pub connection_warning: bool, + pub driver: Option, +} + +impl Driver { + /// Constructs the public backend matching the candidate's address form. + fn new(address: Address) -> core::result::Result { + match address { + Address::Port(port) => { + // SAFETY: firmware serial consumers were disconnected before candidate discovery. + unsafe { Uart16550::new_port(port) } + .map(Self::Port) + .map_err(|_| "invalid PIO address") + } + Address::Mmio { base, stride } => { + let address = NonNull::new(base as *mut u8).ok_or("null MMIO address")?; + // SAFETY: ACPI/PCI supplied the active MMIO register range and stride. + unsafe { Uart16550::new_mmio(address, stride) } + .map(Self::Mmio) + .map_err(|_| "invalid MMIO address or stride") + } + } + } + + /// Initializes either backend with the same configuration for equal coverage. + fn init(&mut self, config: Config) -> core::result::Result<(), uart_16550::InitError> { + match self { + Self::Port(uart) => uart.init(config), + Self::Mmio(uart) => uart.init(config), + } + } + + /// Captures a typed register dump for diagnostics and invariant checks. + pub fn dump(&mut self) -> ConfigRegisterDump { + match self { + Self::Port(uart) => uart.config_register_dump(), + Self::Mmio(uart) => uart.config_register_dump(), + } + } + + /// Exercises the crate's loopback implementation through the chosen backend. + pub fn test_loopback(&mut self) -> core::result::Result<(), uart_16550::LoopbackError> { + match self { + Self::Port(uart) => uart.test_loopback(), + Self::Mmio(uart) => uart.test_loopback(), + } + } + + /// Samples modem-control inputs to diagnose remote cable wiring. + pub fn check_connected( + &mut self, + ) -> core::result::Result<(), uart_16550::RemoteReadyToReceiveError> { + match self { + Self::Port(uart) => uart.check_connected(), + Self::Mmio(uart) => uart.check_connected(), + } + } + + /// Delegates the crate's transmitter-readiness check to either backend. + fn ready_to_send(&mut self) -> core::result::Result<(), uart_16550::ByteSendError> { + match self { + Self::Port(uart) => uart.ready_to_send(), + Self::Mmio(uart) => uart.ready_to_send(), + } + } + + /// Sends one byte with the crate's fallible API for explicit coverage. + fn try_send_byte(&mut self, byte: u8) -> core::result::Result<(), uart_16550::ByteSendError> { + match self { + Self::Port(uart) => uart.try_send_byte(byte), + Self::Mmio(uart) => uart.try_send_byte(byte), + } + } + + /// Attempts a slice write and returns the crate's partial-write progress. + fn send_bytes(&mut self, bytes: &[u8]) -> usize { + match self { + Self::Port(uart) => uart.send_bytes(bytes), + Self::Mmio(uart) => uart.send_bytes(bytes), + } + } + + /// Completes a slice write through the crate's synchronous convenience API. + pub fn send_bytes_exact(&mut self, bytes: &[u8]) { + match self { + Self::Port(uart) => uart.send_bytes_exact(bytes), + Self::Mmio(uart) => uart.send_bytes_exact(bytes), + } + } + /// Polls one received byte so interactive checks never block keyboard input. + pub fn try_receive_byte(&mut self) -> core::result::Result { + match self { + Self::Port(uart) => uart.try_receive_byte(), + Self::Mmio(uart) => uart.try_receive_byte(), + } + } +} + +/// Runs driver checks only after the independent preflight established hardware. +pub fn run(candidates: &[Candidate], preflight: &[preflight::Result]) -> Vec { + candidates + .iter() + .zip(preflight) + .map(|(candidate, preflight)| { + if preflight.passed { + run_one(candidate) + } else { + uefi::println!( + "\nSKIP uart_16550 checks for {}: barebones preflight failed", + candidate.address + ); + Result { + passed: false, + connection_warning: false, + driver: None, + } + } + }) + .collect() +} + +/// Exercises init, registers, loopback, modem inputs, and transmit APIs once. +fn run_one(candidate: &Candidate) -> Result { + uefi::println!("\nuart_16550 checks: {}", candidate.address); + let mut driver = match Driver::new(candidate.address) { + Ok(driver) => driver, + Err(error) => return fail("construct driver", error), + }; + let config = Config { + frequency: candidate.clock_hz, + ..Config::default() + }; + + if let Err(error) = driver.init(config.clone()) { + uefi::println!(" FAIL: init: {error:?}"); + return failed_driver(driver); + } + uefi::println!(" PASS: init"); + + let dump = driver.dump(); + print_dump("after init", &dump); + if !valid_dump(&dump, &config) { + uefi::println!(" FAIL: initialized register values do not match Config"); + return failed_driver(driver); + } + uefi::println!(" PASS: initialized register values"); + + if let Err(error) = driver.test_loopback() { + uefi::println!(" FAIL: test_loopback: {error:?}"); + return failed_driver(driver); + } + uefi::println!(" PASS: test_loopback"); + let dump = driver.dump(); + print_dump("after crate loopback", &dump); + if !valid_dump(&dump, &config) { + uefi::println!(" FAIL: loopback did not restore configured registers"); + return failed_driver(driver); + } + + let connection_warning = match driver.check_connected() { + Ok(()) => { + uefi::println!(" PASS: DSR and CTS report a connected peer"); + false + } + Err(error) => { + uefi::println!(" WARN: connection signals: {error:?}"); + true + } + }; + + if let Err(error) = exercise_send_apis(&mut driver) { + uefi::println!(" FAIL: send API checks: {error}"); + return failed_driver(driver); + } + uefi::println!(" PASS: try_send_byte/send_bytes/send_bytes_exact"); + + Result { + passed: true, + connection_warning, + driver: Some(driver), + } +} + +/// Uses every send API in one recognizable payload for remote verification. +fn exercise_send_apis(driver: &mut Driver) -> core::result::Result<(), &'static str> { + driver.ready_to_send().map_err(|_| "not ready to send")?; + driver + .try_send_byte(b'[') + .map_err(|_| "try_send_byte failed")?; + + let chunk = b"send_bytes] "; + let written = driver.send_bytes(chunk); + if written == 0 { + return Err("send_bytes wrote nothing"); + } + driver.send_bytes_exact(&chunk[written..]); + driver.send_bytes_exact(b"[uart_16550] uart transmit test\r\n"); + Ok(()) +} + +/// Verifies the dump reflects the requested 9600 8N1 polling configuration. +fn valid_dump(dump: &ConfigRegisterDump, config: &Config) -> bool { + dump.ier.is_empty() + && dump.lcr.bits() == 0x03 + && dump + .mcr + .contains(MCR::DTR | MCR::RTS | MCR::OUT_2_INT_ENABLE) + && !dump.mcr.contains(MCR::LOOP_BACK) + && dump.lsr.contains(LSR::THR_EMPTY | LSR::TRANSMITTER_EMPTY) + && dump.isr.bits() & 0xc0 == 0xc0 + && dump.baud_rate(config) == BaudRate::Baud9600 +} + +/// Prints every crate-exposed configuration register on one diagnostic line. +pub fn print_dump(label: &str, dump: &ConfigRegisterDump) { + uefi::println!( + concat!( + " {}: IER={:?} ISR={:?} LCR={:?} MCR={:?} ", + "LSR={:?} MSR={:?} SPR={:02x} DLL={:02x} DLM={:02x}", + ), + label, + dump.ier, + dump.isr, + dump.lcr, + dump.mcr, + dump.lsr, + dump.msr, + dump.spr, + dump.dll, + dump.dlm + ); +} + +/// Reports failures that occur before a driver can be retained for diagnostics. +fn fail(stage: &str, error: &str) -> Result { + uefi::println!(" FAIL: {stage}: {error}"); + Result { + passed: false, + connection_warning: false, + driver: None, + } +} + +/// Retains a constructed driver after failure without allowing interactive use. +fn failed_driver(driver: Driver) -> Result { + Result { + passed: false, + connection_warning: false, + driver: Some(driver), + } +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index ec5c0a3..e0bcc45 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -11,6 +11,7 @@ extern crate alloc; mod device; mod discovery; +mod driver_test; mod firmware; mod preflight; mod raw_uart; @@ -40,14 +41,23 @@ fn main() -> Status { } let preflight = preflight::run(inventory.candidates()); - let passed = preflight.iter().filter(|result| result.passed).count(); + let drivers = driver_test::run(inventory.candidates(), &preflight); + let passed = drivers.iter().filter(|result| result.passed).count(); + let warnings = drivers + .iter() + .filter(|result| result.connection_warning) + .count(); + let initialized = drivers + .iter() + .filter(|result| result.driver.is_some()) + .count(); uefi::println!( - "\nBarebones summary: {passed}/{} candidate(s) passed.", - preflight.len() + "\nAutomatic summary: {passed}/{} passed, {warnings} connection warning(s), {initialized} initialized.", + drivers.len() ); uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); - if passed == preflight.len() { + if passed == drivers.len() { Status::SUCCESS } else { Status::DEVICE_ERROR From ce0bb7e805bec9eccc9f0a5a363ae67d3fb9c9fc Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:47:44 +0200 Subject: [PATCH 07/20] real-hw-test: add interactive serial checks --- real-hw-test/src/driver_test.rs | 5 ++ real-hw-test/src/interactive.rs | 93 +++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 20 ++++++- 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 real-hw-test/src/interactive.rs diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index c209ef1..81ac6ec 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -23,6 +23,7 @@ pub enum Driver { pub struct Result { pub passed: bool, pub connection_warning: bool, + pub interactive_skipped: bool, pub driver: Option, } @@ -136,6 +137,7 @@ pub fn run(candidates: &[Candidate], preflight: &[preflight::Result]) -> Vec Result { Result { passed: true, connection_warning, + interactive_skipped: false, driver: Some(driver), } } @@ -261,6 +264,7 @@ fn fail(stage: &str, error: &str) -> Result { Result { passed: false, connection_warning: false, + interactive_skipped: false, driver: None, } } @@ -270,6 +274,7 @@ fn failed_driver(driver: Driver) -> Result { Result { passed: false, connection_warning: false, + interactive_skipped: false, driver: Some(driver), } } diff --git a/real-hw-test/src/interactive.rs b/real-hw-test/src/interactive.rs new file mode 100644 index 0000000..fd9d4b3 --- /dev/null +++ b/real-hw-test/src/interactive.rs @@ -0,0 +1,93 @@ +//! Operator-driven cable, modem-status, reconnect, transmit, and receive checks. +//! +//! Polling keeps interrupts out of scope while a human validates the physical +//! path that deterministic loopback cannot cover. + +use core::time::Duration; + +use uefi::boot; +use uefi::proto::console::text::{Key, ScanCode}; +use uefi::system; + +use crate::device::Candidate; +use crate::driver_test::{self, Driver}; + +/// Offers interactive checks only for UARTs that passed automatic driver tests. +pub fn run(candidates: &[Candidate], results: &mut [driver_test::Result]) { + uefi::println!("\nInteractive phase (synchronous polling; UART interrupts stay disabled)"); + for (candidate, result) in candidates.iter().zip(results) { + if !result.passed { + continue; + } + let Some(driver) = result.driver.as_mut() else { + continue; + }; + result.interactive_skipped = run_one(candidate, driver); + } +} + +/// Polls one UART while keyboard Escape provides an out-of-band skip control. +fn run_one(candidate: &Candidate, driver: &mut Driver) -> bool { + uefi::println!("\nInteractive UART: {}", candidate.address); + uefi::println!("Serial commands: r=registers t=transmit c=connection l=loopback q=next"); + uefi::println!("Other printable ASCII is echoed. Escape locally or over serial skips."); + + loop { + if local_escape_pressed() { + uefi::println!(" WARN: interactive checks skipped from local keyboard"); + return true; + } + + let Ok(byte) = driver.try_receive_byte() else { + boot::stall(Duration::from_millis(2)); + continue; + }; + match byte { + 0x1b => { + uefi::println!(" WARN: interactive checks skipped from serial Escape"); + return true; + } + b'r' | b'R' => driver_test::print_dump("interactive", &driver.dump()), + b't' | b'T' => { + driver.send_bytes_exact(b"[interactive] uart transmit test\r\n"); + uefi::println!(" transmitted interactive test line"); + } + b'c' | b'C' => { + match driver.check_connected() { + Ok(()) => uefi::println!(" connection: DSR and CTS asserted"), + Err(error) => uefi::println!(" connection warning: {error:?}"), + } + driver_test::print_dump("after connection check", &driver.dump()); + } + b'l' | b'L' => match driver.test_loopback() { + Ok(()) => uefi::println!(" PASS: interactive loopback"), + Err(error) => uefi::println!(" FAIL: interactive loopback: {error:?}"), + }, + b'q' | b'Q' => { + uefi::println!(" interactive UART complete"); + return false; + } + 0x20..=0x7e => { + uefi::println!( + " received ASCII '{}' (0x{byte:02x}); echoing", + char::from(byte) + ); + driver.send_bytes_exact(&[byte]); + } + _ => uefi::println!(" received non-printable byte 0x{byte:02x}"), + } + } +} + +/// Checks Simple Text Input without blocking so serial polling remains responsive. +fn local_escape_pressed() -> bool { + match system::with_stdin(|input| input.read_key()) { + Ok(Some(Key::Special(scan_code))) => scan_code == ScanCode::ESCAPE, + Ok(Some(Key::Printable(key))) => key == '\u{1b}', + Ok(_) => false, + Err(error) => { + uefi::println!(" WARN: local keyboard read failed: {error:?}"); + false + } + } +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index e0bcc45..8bb1371 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -13,6 +13,7 @@ mod device; mod discovery; mod driver_test; mod firmware; +mod interactive; mod preflight; mod raw_uart; @@ -41,7 +42,8 @@ fn main() -> Status { } let preflight = preflight::run(inventory.candidates()); - let drivers = driver_test::run(inventory.candidates(), &preflight); + let mut drivers = driver_test::run(inventory.candidates(), &preflight); + interactive::run(inventory.candidates(), &mut drivers); let passed = drivers.iter().filter(|result| result.passed).count(); let warnings = drivers .iter() @@ -51,10 +53,24 @@ fn main() -> Status { .iter() .filter(|result| result.driver.is_some()) .count(); + let skipped = drivers + .iter() + .filter(|result| result.interactive_skipped) + .count(); uefi::println!( - "\nAutomatic summary: {passed}/{} passed, {warnings} connection warning(s), {initialized} initialized.", + "\nFinal summary: {passed}/{} passed, {warnings} connection warning(s), {skipped} interactive skip(s), {initialized} initialized.", drivers.len() ); + for (index, (candidate, result)) in inventory.candidates().iter().zip(&drivers).enumerate() { + let status = if !result.passed { + "FAIL" + } else if result.connection_warning || result.interactive_skipped { + "WARN" + } else { + "PASS" + }; + uefi::println!(" [{index}] {status}: {}", candidate.address); + } uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); if passed == drivers.len() { From aa61f5f4cc8f49ad606168b54623ca4a1488d975 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:53:32 +0200 Subject: [PATCH 08/20] real-hw-test: document and guard hardware deployment --- real-hw-test/.gitignore | 2 +- real-hw-test/Makefile | 6 +- real-hw-test/README.md | 227 ++++++++++++++++++++++++++++ real-hw-test/flake.nix | 1 + real-hw-test/scripts/install-usb.sh | 68 +++++++++ 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 real-hw-test/README.md create mode 100755 real-hw-test/scripts/install-usb.sh diff --git a/real-hw-test/.gitignore b/real-hw-test/.gitignore index 495a75e..76daa49 100644 --- a/real-hw-test/.gitignore +++ b/real-hw-test/.gitignore @@ -1,2 +1,2 @@ /build/ - +/target/ diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index a951075..820b99b 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -9,7 +9,7 @@ ARTIFACT := build/BOOTX64.EFI BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.PHONY: all artifact check qemu qemu-tcg clean +.PHONY: all artifact check qemu qemu-tcg install clean all: artifact @@ -23,6 +23,7 @@ $(ARTIFACT): FORCE check: $(CARGO) fmt --check $(CLIPPY) --target $(TARGET) --release -- -D warnings + bash -n scripts/*.sh $(MAKE) artifact qemu: artifact @@ -32,6 +33,9 @@ qemu: artifact qemu-tcg: $(MAKE) qemu QEMU_ACCEL=tcg +install: artifact + USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh $(ARTIFACT) + clean: $(CARGO) clean --target-dir $(TARGET_DIR) rm -rf build diff --git a/real-hw-test/README.md b/real-hw-test/README.md new file mode 100644 index 0000000..3cc4992 --- /dev/null +++ b/real-hw-test/README.md @@ -0,0 +1,227 @@ +# uart_16550 UEFI real-hardware test + +This subproject builds an x86_64 UEFI application that takes ownership of +16550-compatible UARTs and exercises this repository's driver. It is a manual +integration test: automatic register and loopback checks run first, followed by +an interactive serial menu. + +All diagnostics use UEFI Simple Text Output and are intended to stay visible on +the test machine's monitor. Bytes written directly to a UART are deliberately +short, recognizable test payloads. + +## TL;DR + +1. Run `make artifact`, then deploy `build/BOOTX64.EFI` with `make install` to + a mounted GPT/FAT32 EFI partition. +2. Boot with a monitor and USB keyboard. Leave the monitor connected: it is the + authoritative diagnostic channel after firmware serial ownership is released. +3. Confirm the firmware baseline, configure the remote to 9600 8N1, and press + Enter on the local keyboard. +4. Look for automatic `PASS` lines and recognizable serial payloads, then use + the interactive commands to test the cable and reconnect behavior. + +## Test scope + +The application runs UARTs synchronously using polling. It disables UART +interrupts, installs no interrupt handler, and does **not** validate interrupt +delivery or interrupt-driven transmit/receive behavior. + +It also disables the UEFI image watchdog because an interactive serial phase +may legitimately take longer than the firmware's normal five-minute limit. A +failure to disable it is reported as a warning on screen. + +It discovers: + +- COM1 at `0x3f8` unconditionally; +- conventional legacy ports at `0x2f8`, `0x3e8`, and `0x2e8` when their scratch + registers behave like a UART; +- compatible byte-access UARTs advertised by ACPI SPCR; +- PCI serial-class controllers with an enabled, unambiguous, 16550-compatible + BAR0. + +Unsupported ACPI interfaces and ambiguous or vendor-specific PCI layouts are +reported but not accessed. + +## Recommended real-hardware setup + +Boot the application on an x86_64 machine with: + +- UEFI firmware and Secure Boot disabled, unless you sign the application; +- a monitor connected to the machine; +- a USB keyboard for Enter/Escape navigation; +- a physical serial port connected to another machine using the required + RS-232/null-modem wiring or an appropriate USB serial cable; +- Linux and Minicom on the remote machine. + +For example, adjust the remote device name as needed: + +```console +minicom -D /dev/ttyUSB0 -b 9600 +``` + +Use 9600 baud, 8 data bits, no parity, one stop bit, and no hardware flow +control. Firmware may use a different rate before takeover. The application +prints the UEFI `SerialIo` mode and waits for Enter before switching the UARTs +to 9600 8N1. + +### Remote already connected + +1. Boot the USB media and watch the monitor. +2. Confirm the `UEFI SERIAL BASELINE` line also appears remotely when firmware + serial redirection is active. Absence is valid when firmware exposes no + serial console. +3. Set Minicom to 9600 8N1 and press Enter on the test machine's USB keyboard. +4. Confirm `[barebones]` and `[uart_16550]` payloads appear remotely. +5. Use the interactive commands below. + +### Connect or reconnect during the test + +It is also valid to start without the remote cable connected. Let the automatic +tests finish, connect the cable during the interactive phase, and then: + +- type `c` to inspect DSR/CTS and modem-status changes; +- type `r` before and after reconnecting to compare registers; +- type `t` to send a known line to Minicom; +- type another printable ASCII character to test receive and echo. + +Some USB serial and null-modem wiring does not expose DSR or CTS. A connection +warning is therefore diagnostic and does not fail otherwise working traffic. +Press Escape on the local USB keyboard or send byte `0x1b` from the remote +terminal to skip a UART that has no connected remote. + +## Build + +Install the Rust UEFI target once if necessary: + +```console +rustup target add x86_64-unknown-uefi +``` + +Then build and stage the removable-media filename: + +```console +make artifact +file build/BOOTX64.EFI +``` + +The resulting file is `build/BOOTX64.EFI`. + +Run all static build checks with: + +```console +make check +``` + +## Install on USB media + +Prepare and mount an EFI partition yourself. The install target intentionally +does not partition, format, mount, or unmount devices. It verifies that the +mount is backed by a partition on a GPT disk and that `lsblk` identifies the +filesystem as FAT32 before copying anything. + +Inspect the target carefully: + +```console +lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,PTTYPE,MOUNTPOINTS +make install USB_MOUNT=/run/media/$USER/EFI +``` + +The file is copied to `EFI/BOOT/BOOTX64.EFI`. If the disk is not GPT, the +filesystem is not FAT32, the path is not an exact mount point, or the mount is +not writable, installation stops with a diagnostic. Unmount the partition +cleanly before removing it. + +## Run under QEMU + +The included Nix development shell supplies QEMU and OVMF: + +```console +nix develop +make qemu +``` + +The graphical QEMU window is the UEFI monitor and keyboard. COM1 is connected +to the terminal that launched QEMU. A `pci-serial` device is also present; QEMU +prints its `/dev/pts/...` path during startup. Open that PTY in a second terminal +to exercise PCI discovery and BAR-backed UART access: + +```console +minicom -D /dev/pts/NUMBER -b 9600 +``` + +Outside Nix, provide the combined OVMF image explicitly: + +```console +OVMF=/path/to/OVMF.fd make qemu +``` + +KVM is used by default. Use software emulation when KVM is unavailable: + +```console +make qemu-tcg +# equivalent: QEMU_ACCEL=tcg make qemu +``` + +`QEMU`, `QEMU_ARGS`, and `ESP_DIR` can override the executable, add QEMU +arguments, or relocate the temporary directory-backed EFI system partition. +QEMU data stays below the repository's ignored `target/real-hw-test/` tree. + +## Reading the test output + +The UEFI monitor is authoritative. Before takeover, `UEFI SERIAL BASELINE` +confirms firmware still owns the serial output. After controllers are +disconnected, remote output may stop; continue reading diagnostics on the +monitor. + +Good signs are: + +- `PASS` for raw initialization, loopback, register invariants, crate `init`, + crate loopback, and send APIs; +- `[barebones]` and `[uart_16550]` lines on the remote terminal; +- `PASS: interactive loopback`, echoed printable characters, and transmitted + `[interactive]` lines during manual testing; +- a final summary with each required UART marked `PASS`. + +`WARN: connection signals` or a DSR/CTS warning can be expected with a +three-wire or USB serial cable that does not provide modem-control lines. A +local or serial Escape skip is also a warning, not an automatic test failure. + +Investigate `FAIL`, `SKIP`, transmit or receive timeouts, a failed +`disconnect_controller`, an initialization/register/loopback mismatch, or a +final summary containing `FAIL`. Start with the candidate address, its reported +clock, 9600 8N1 settings, cable crossover and ground, and the remote terminal. + +## Interactive commands + +Commands are read from the UART currently named on the monitor: + +| Input | Expected result | +| --- | --- | +| `r` | Register snapshot appears on the UEFI screen. | +| `t` | `[interactive]` test line appears on the remote terminal. | +| `c` | Screen shows DSR/CTS status and a fresh register dump. | +| `l` | Screen reports `PASS: interactive loopback` or a failure. | +| `q` | This UART completes and the next candidate begins. | +| Printable ASCII | Screen shows the byte and the remote receives its echo. | +| Local Escape or serial `0x1b` | Skip this UART with a `WARN` diagnostic. | + +The final screen reports: + +- `PASS`: required automatic checks succeeded; +- `WARN`: automatic checks succeeded but connection signals were absent or the + interactive phase was skipped; +- `FAIL`: presence, initialization, register, loopback, or transmit readiness + failed. + +## Troubleshooting + +- No firmware baseline remotely: firmware may not expose or use `SerialIo`. + COM1 is still probed and tested after takeover. +- Garbled characters: confirm both ends use 9600 8N1 after the Enter prompt. +- No traffic: verify TX/RX crossover, common ground, RS-232 voltage conversion, + and whether a null-modem adapter is required. +- DSR/CTS warning with working bytes: the cable likely omits modem-control + lines; leave hardware flow control disabled. +- PCI controller is skipped: its programming interface, BAR, decoding state, + or layout was not safe to treat as a standard 16550 endpoint. +- QEMU does not start with KVM: use `make qemu-tcg`. diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index 0ae349e..fa615f0 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -20,6 +20,7 @@ packages = with pkgs; [ qemu_kvm rustup + util-linux ]; env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; }; diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh new file mode 100755 index 0000000..4f1bb21 --- /dev/null +++ b/real-hw-test/scripts/install-usb.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact=${1:-build/BOOTX64.EFI} +mount_input=${USB_MOUNT:-} + +fail() { + echo "error: $*" >&2 + exit 2 +} + +for command in findmnt lsblk install readlink sync; do + command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command" +done + +[[ -n "$mount_input" ]] || fail \ + "USB_MOUNT is unset; use 'make install USB_MOUNT=/path/to/mounted/efi-partition'" +[[ -r "$artifact" ]] || fail "UEFI artifact is missing: $artifact (run 'make artifact')" + +mount_path=$(readlink -f -- "$mount_input") || fail "cannot resolve USB_MOUNT: $mount_input" +[[ "$mount_path" != / ]] || fail "refusing to install into the root filesystem" +[[ -d "$mount_path" ]] || fail "USB_MOUNT is not a directory: $mount_path" +[[ -w "$mount_path" ]] || fail "USB_MOUNT is not writable: $mount_path" + +mounted_target=$(findmnt -n -T "$mount_path" -o TARGET) || fail \ + "USB_MOUNT is not on a mounted filesystem: $mount_path" +mounted_target=$(readlink -f -- "$mounted_target") || fail \ + "cannot resolve the filesystem mount point: $mounted_target" +[[ "$mounted_target" == "$mount_path" ]] || fail \ + "USB_MOUNT must be the mount point itself; '$mount_path' is inside '$mounted_target'" + +source_name=$(findmnt -n -T "$mount_path" -o SOURCE) || fail \ + "cannot determine the mounted source device" +mount_fstype=$(findmnt -n -T "$mount_path" -o FSTYPE) || fail \ + "cannot determine the mounted filesystem type" +source_name=${source_name%%\[*\]} +source_device=$(readlink -f -- "$source_name") || fail \ + "cannot resolve mounted source device: $source_name" +[[ "$source_device" == /dev/* ]] || fail \ + "mounted source is not a block device: $source_name" + +device_type=$(lsblk -dnro TYPE "$source_device") +[[ "$device_type" == part ]] || fail \ + "EFI media must be a partition on a GPT disk; $source_device is type '$device_type'" + +parent_name=$(lsblk -dnro PKNAME "$source_device") +[[ -n "$parent_name" ]] || fail "cannot identify the parent disk of $source_device" +parent_device=/dev/$parent_name +partition_table=$(lsblk -dnro PTTYPE "$parent_device") +[[ "$partition_table" == gpt ]] || fail \ + "$parent_device uses '${partition_table:-no recognized partition table}', expected GPT" + +block_fstype=$(lsblk -dnro FSTYPE "$source_device") +fat_version=$(lsblk -dnro FSVER "$source_device") +[[ "$mount_fstype" == vfat && "$block_fstype" == vfat ]] || fail \ + "$source_device is '$mount_fstype'/'$block_fstype', expected a mounted FAT filesystem" +[[ "$fat_version" == FAT32 ]] || fail \ + "$source_device reports '${fat_version:-an unknown FAT version}', expected FAT32" + +target=$mount_path/EFI/BOOT/BOOTX64.EFI +echo "Installing to validated media:" +echo " disk: $parent_device (GPT)" +echo " partition: $source_device (FAT32)" +echo " mount: $mount_path" +echo " destination: $target" +install -D -m 0644 -- "$artifact" "$target" +sync "$target" +echo "Installation complete. Unmount the media cleanly before removing it." From f374e111cc8ff059927cc82d238a7df5a1ea2af6 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 14:12:59 +0200 Subject: [PATCH 09/20] real-hw-test: disable firmware watchdog Manual hardware checks can wait forever for an operator. Disable the UEFI image watchdog so a long session does not reset the machine. Report firmware failures on screen. --- real-hw-test/src/firmware.rs | 9 +++++++++ real-hw-test/src/main.rs | 1 + 2 files changed, 10 insertions(+) diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs index 4667d92..5a4978a 100644 --- a/real-hw-test/src/firmware.rs +++ b/real-hw-test/src/firmware.rs @@ -11,6 +11,15 @@ use uefi::proto::console::serial::Serial; use uefi::proto::console::text::Key; use uefi::{Handle, Status, system}; +/// Disables UEFI's image watchdog so manual prompts do not reset the machine. +pub fn disable_watchdog() { + // Manual serial phases may run beyond UEFI's default five-minute limit. + match boot::set_watchdog_timer(0, 0, None) { + Ok(()) => uefi::println!("Firmware watchdog disabled for manual testing."), + Err(error) => uefi::println!("WARN: could not disable firmware watchdog: {error:?}"), + } +} + /// Collects Serial I/O handles, treating an absent protocol as an empty list. fn serial_handles() -> Result, Status> { match boot::locate_handle_buffer(SearchType::from_proto::()) { diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index 8bb1371..292e6b3 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -24,6 +24,7 @@ use uefi::prelude::*; fn main() -> Status { uefi::helpers::init().expect("UEFI helpers should initialize"); uefi::println!("uart_16550 real-hardware test"); + firmware::disable_watchdog(); if !firmware::disconnect_serial_controllers() { uefi::println!("FAIL: firmware serial ownership was not released"); From e201f0d3378c010f0b4339eb068f5ea2f4282378 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:04:43 +0200 Subject: [PATCH 10/20] mmio: use wrapping pointer offsets MMIO addresses do not describe a Rust allocation, so use the wrapping pointer primitive rather than an in-bounds pointer offset. The UART constructor already rejects a register range that would wrap. --- src/backend/mmio.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/backend/mmio.rs b/src/backend/mmio.rs index 1c0f2b8..61b0c71 100644 --- a/src/backend/mmio.rs +++ b/src/backend/mmio.rs @@ -24,9 +24,10 @@ unsafe impl Send for MmioAddress {} impl RegisterAddress for MmioAddress { #[inline(always)] fn add_offset(self, offset: u8) -> Self { - // SAFETY: We ensure on a higher level that the base address is valid - // and that this will not wrap. - let address = unsafe { self.0.add(offset as usize) }; + // MMIO is not a Rust allocation; the constructor already rejects + // register ranges whose address arithmetic would wrap. + let address = self.0.as_ptr().wrapping_add(offset as usize); + let address = NonNull::new(address).expect("validated MMIO address offset cannot be null"); Self(address) } } @@ -120,9 +121,11 @@ impl Backend for MmioBackend { #[inline(always)] unsafe fn _read_register(&mut self, address: MmioAddress) -> u8 { debug_assert!(address >= self.base()); - let upper_bound_incl = (NUM_REGISTERS - 1) * usize::from(u8::from(self.stride)); + let register_count = NUM_REGISTERS - 1; + let upper_bound_incl = register_count * usize::from(u8::from(self.stride)); + let last_address = self.base().0.as_ptr().wrapping_add(upper_bound_incl); // Address is in the device's address range - debug_assert!(address.0.as_ptr() <= self.base().0.as_ptr().wrapping_add(upper_bound_incl)); + debug_assert!(address.0.as_ptr() <= last_address); // SAFETY: The caller ensured that the MMIO address is safe to use. unsafe { arch::mmio_read_register(address) } @@ -131,9 +134,11 @@ impl Backend for MmioBackend { #[inline(always)] unsafe fn _write_register(&mut self, address: MmioAddress, value: u8) { debug_assert!(address >= self.base()); - let upper_bound_incl = (NUM_REGISTERS - 1) * usize::from(u8::from(self.stride)); + let register_count = NUM_REGISTERS - 1; + let upper_bound_incl = register_count * usize::from(u8::from(self.stride)); + let last_address = self.base().0.as_ptr().wrapping_add(upper_bound_incl); // Address is in the device's address range - debug_assert!(address.0.as_ptr() <= self.base().0.as_ptr().wrapping_add(upper_bound_incl)); + debug_assert!(address.0.as_ptr() <= last_address); // SAFETY: The caller ensured that the MMIO address is safe to use. unsafe { arch::mmio_write_register(address, value) } From 3707925c51d5daadcd02c21b33cf52cbd040976b Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:34:38 +0200 Subject: [PATCH 11/20] real-hw-test: persist diagnostics on the boot volume Mirror every test diagnostic to a dated file below /uart_16550_test_logs while retaining UEFI console output. Keep one flushed FAT file handle so a failed write is reported as critical and aborts the test instead of losing failure evidence. --- real-hw-test/Cargo.lock | 113 +++++++++++++++++++++++++++- real-hw-test/Cargo.toml | 4 +- real-hw-test/README.md | 6 +- real-hw-test/src/discovery.rs | 1 + real-hw-test/src/discovery/acpi.rs | 1 + real-hw-test/src/discovery/pci.rs | 1 + real-hw-test/src/driver_test.rs | 1 + real-hw-test/src/firmware.rs | 2 + real-hw-test/src/interactive.rs | 1 + real-hw-test/src/logging.rs | 116 +++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 20 +++++ real-hw-test/src/preflight.rs | 1 + real-hw-test/src/raw_uart.rs | 1 + 13 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 real-hw-test/src/logging.rs diff --git a/real-hw-test/Cargo.lock b/real-hw-test/Cargo.lock index 00b70b6..25b62a1 100644 --- a/real-hw-test/Cargo.lock +++ b/real-hw-test/Cargo.lock @@ -8,6 +8,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -20,12 +26,91 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "jiff-core", + "jiff-static", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -86,10 +171,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "uart-16550-real-hw-test" version = "0.1.0" dependencies = [ + "jiff", "uart_16550", "uefi", ] @@ -98,7 +204,7 @@ dependencies = [ name = "uart_16550" version = "0.8.0" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -116,8 +222,9 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f2e1b4a439d82899da4dc0ee8ac742db07a9ff493bc4f31d345c24e12e17e9" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", + "jiff", "log", "ptr_meta", "ucs2", @@ -143,7 +250,7 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a36f49db0b04a8dfd246a26ddf8d2526a2ffd8cf946ae7fe603fd0c5fbd2786" dependencies = [ - "bitflags", + "bitflags 2.13.1", "uguid", ] diff --git a/real-hw-test/Cargo.toml b/real-hw-test/Cargo.toml index f874a58..7f528e5 100644 --- a/real-hw-test/Cargo.toml +++ b/real-hw-test/Cargo.toml @@ -5,6 +5,6 @@ edition = "2024" publish = false [dependencies] +jiff = { version = "0.2", default-features = false } uart_16550 = { path = ".." } -uefi = { version = "0.38.0", features = ["alloc", "global_allocator", "panic_handler"] } - +uefi = { version = "0.38.0", features = ["alloc", "global_allocator", "jiff02", "panic_handler"] } diff --git a/real-hw-test/README.md b/real-hw-test/README.md index 3cc4992..d7bf143 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -6,8 +6,10 @@ integration test: automatic register and loopback checks run first, followed by an interactive serial menu. All diagnostics use UEFI Simple Text Output and are intended to stay visible on -the test machine's monitor. Bytes written directly to a UART are deliberately -short, recognizable test payloads. +the test machine's monitor. They are also persisted, line by line, on the boot +volume as `/uart_16550_test_logs/uart_16550_YYYY-MM-DD_HH-MM-SS.txt`. A log +creation, write, or flush failure is critical and aborts the test. Bytes +written directly to a UART are deliberately short, recognizable test payloads. ## TL;DR diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index f81bb65..d0402f1 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -5,6 +5,7 @@ use crate::device::{Address, Inventory, Source}; use crate::raw_uart::RawUart; +use crate::uefi; mod acpi; mod pci; diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index c783627..89dd8a0 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -9,6 +9,7 @@ use uefi::system; use uefi::table::cfg::ConfigTableEntry; use crate::device::{Address, Inventory, Source}; +use crate::uefi; const SDT_HEADER_LEN: usize = 36; const MAX_TABLE_LEN: usize = 1024 * 1024; diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs index 53e6f84..3f467ef 100644 --- a/real-hw-test/src/discovery/pci.rs +++ b/real-hw-test/src/discovery/pci.rs @@ -11,6 +11,7 @@ use uefi::proto::pci::PciIoAddress; use uefi::proto::pci::root_bridge::PciRootBridgeIo; use crate::device::{Address, Inventory, Source}; +use crate::uefi; /// Opens each root bridge read-only and searches it for serial-class endpoints. pub fn discover(inventory: &mut Inventory) { diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 81ac6ec..7546612 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -12,6 +12,7 @@ use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; use crate::device::{Address, Candidate}; use crate::preflight; +use crate::uefi; /// The public-driver backend selected for a PIO or MMIO candidate. pub enum Driver { diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs index 5a4978a..933231c 100644 --- a/real-hw-test/src/firmware.rs +++ b/real-hw-test/src/firmware.rs @@ -11,6 +11,8 @@ use uefi::proto::console::serial::Serial; use uefi::proto::console::text::Key; use uefi::{Handle, Status, system}; +use crate::uefi; + /// Disables UEFI's image watchdog so manual prompts do not reset the machine. pub fn disable_watchdog() { // Manual serial phases may run beyond UEFI's default five-minute limit. diff --git a/real-hw-test/src/interactive.rs b/real-hw-test/src/interactive.rs index fd9d4b3..96b4754 100644 --- a/real-hw-test/src/interactive.rs +++ b/real-hw-test/src/interactive.rs @@ -11,6 +11,7 @@ use uefi::system; use crate::device::Candidate; use crate::driver_test::{self, Driver}; +use crate::uefi; /// Offers interactive checks only for UARTs that passed automatic driver tests. pub fn run(candidates: &[Candidate], results: &mut [driver_test::Result]) { diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs new file mode 100644 index 0000000..e6e06c4 --- /dev/null +++ b/real-hw-test/src/logging.rs @@ -0,0 +1,116 @@ +//! Fail-closed screen and file diagnostics for one integration-test run. +//! +//! Persisting the screen transcript makes a physical-hardware failure +//! inspectable after reboot. A write failure aborts instead of silently losing +//! diagnostics that are needed to interpret the hardware result. + +use alloc::format; +use alloc::string::String; +use core::cell::UnsafeCell; +use core::fmt::{Arguments, Write}; + +use jiff::civil::DateTime; +use uefi::boot; +use uefi::fs::PathBuf; +use uefi::proto::media::file::{File, FileAttribute, FileMode, RegularFile}; +use uefi::runtime; + +/// Owns the opened log file and flushes each diagnostic before displaying it. +struct Logger { + file: RegularFile, +} + +/// Holds the single logger used by this synchronous, interrupt-free test. +struct LoggerSlot(UnsafeCell>); + +// SAFETY: The test is synchronous and deliberately does not enable interrupts, +// so no concurrent caller can access the logger. +unsafe impl Sync for LoggerSlot {} + +/// Stores the logger after initialization and before the first test diagnostic. +static LOGGER: LoggerSlot = LoggerSlot(UnsafeCell::new(None)); + +/// Creates the dated log file on the volume that contains this UEFI image. +pub fn init() -> Result<(), &'static str> { + let time = runtime::get_time().map_err(|_| "could not read UEFI time")?; + let time = DateTime::try_from(time).map_err(|_| "UEFI time is invalid")?; + let file_name = format!( + "uart_16550_{:04}-{:02}-{:02}_{:02}-{:02}-{:02}.txt", + time.year(), + time.month(), + time.day(), + time.hour(), + time.minute(), + time.second(), + ); + let file_name = + uefi::CString16::try_from(file_name.as_str()).map_err(|_| "log path is invalid")?; + let mut protocol = boot::get_image_file_system(boot::image_handle()) + .map_err(|_| "could not open image file system")?; + let directory = PathBuf::from(uefi::cstr16!("/uart_16550_test_logs")); + let directory: &uefi::CStr16 = directory.as_ref(); + let mut root = protocol + .open_volume() + .map_err(|_| "could not open image volume")?; + let mut directory = match root.open(directory, FileMode::ReadWrite, FileAttribute::empty()) { + Ok(handle) => handle, + Err(_) => root + .open( + directory, + FileMode::CreateReadWrite, + FileAttribute::DIRECTORY, + ) + .map_err(|_| "could not create /uart_16550_test_logs")?, + } + .into_directory() + .ok_or("/uart_16550_test_logs is not a directory")?; + let file = directory + .open( + file_name.as_ref(), + FileMode::CreateReadWrite, + FileAttribute::empty(), + ) + .map_err(|_| "could not create test log file")? + .into_regular_file() + .ok_or("test log path is not a regular file")?; + + // SAFETY: Initialization runs once before any test diagnostics are emitted. + unsafe { *LOGGER.0.get() = Some(Logger::new(file)) }; + Ok(()) +} + +impl Logger { + /// Retains one file handle so each write extends the same run transcript. + fn new(file: RegularFile) -> Self { + Self { file } + } + + /// Appends one formatted line and flushes it to FAT before console output. + fn write_line(&mut self, args: Arguments<'_>) -> Result<(), &'static str> { + let mut line = String::new(); + line.write_fmt(args) + .map_err(|_| "could not format test diagnostic")?; + line.push('\n'); + self.file + .write(line.as_bytes()) + .map_err(|_| "could not write test log file")?; + self.file + .flush() + .map_err(|_| "could not flush test log file") + } +} + +/// Writes a diagnostic to persistent storage first, then displays it on screen. +pub fn println(args: Arguments<'_>) { + // SAFETY: The test runs synchronously and `init` installs the sole logger. + let logger = unsafe { (&mut *LOGGER.0.get()).as_mut() }; + let Some(logger) = logger else { + uefi_rs::println!("CRITICAL: test logger was not initialized"); + panic!("test logger was not initialized"); + }; + if let Err(error) = logger.write_line(args) { + uefi_rs::println!("CRITICAL: {error}; aborting test"); + panic!("test log write failed"); + } + uefi_rs::println!("{}", args); +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index 292e6b3..03daaa8 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -8,12 +8,28 @@ //! access, and public driver APIs so the screen identifies the failing layer. extern crate alloc; +extern crate uefi as uefi_rs; + +/// Routes existing UEFI diagnostics through the fail-closed test logger. +mod uefi { + pub use crate::test_println as println; + pub use uefi_rs::*; +} + +/// Mirrors UEFI diagnostics to the screen and the test-run log file. +#[macro_export] +macro_rules! test_println { + ($($arg:tt)*) => { + $crate::logging::println(core::format_args!($($arg)*)) + }; +} mod device; mod discovery; mod driver_test; mod firmware; mod interactive; +mod logging; mod preflight; mod raw_uart; @@ -23,6 +39,10 @@ use uefi::prelude::*; #[entry] fn main() -> Status { uefi::helpers::init().expect("UEFI helpers should initialize"); + if let Err(error) = logging::init() { + uefi_rs::println!("CRITICAL: cannot create test log: {error}"); + return Status::DEVICE_ERROR; + } uefi::println!("uart_16550 real-hardware test"); firmware::disable_watchdog(); diff --git a/real-hw-test/src/preflight.rs b/real-hw-test/src/preflight.rs index 3ce86c8..757a8f3 100644 --- a/real-hw-test/src/preflight.rs +++ b/real-hw-test/src/preflight.rs @@ -7,6 +7,7 @@ use alloc::vec::Vec; use crate::device::Candidate; use crate::raw_uart::RawUart; +use crate::uefi; #[derive(Clone, Copy, Debug)] /// The automatic raw preflight outcome used to gate driver construction. diff --git a/real-hw-test/src/raw_uart.rs b/real-hw-test/src/raw_uart.rs index 6a3852e..a38c378 100644 --- a/real-hw-test/src/raw_uart.rs +++ b/real-hw-test/src/raw_uart.rs @@ -7,6 +7,7 @@ use core::arch::asm; use core::hint; use crate::device::Address; +use crate::uefi; const DATA: u8 = 0; const IER: u8 = 1; From 39d48682bd313980ec436ef7fa34467757ede2c2 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:41:38 +0200 Subject: [PATCH 12/20] real-hw-test: bound nonblocking transmit checks A real UART can still be draining the byte accepted by try_send_byte when send_bytes is called. Retry the nonblocking API for one second instead of treating temporary backpressure as a driver failure. Preserve an earlier modem-signal warning if a later driver check fails. --- real-hw-test/src/driver_test.rs | 60 +++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 7546612..7d35221 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -5,6 +5,7 @@ use alloc::vec::Vec; use core::ptr::NonNull; +use core::time::Duration; use uart_16550::backend::{MmioBackend, PioBackend}; use uart_16550::spec::registers::{LSR, MCR}; @@ -13,6 +14,9 @@ use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; use crate::device::{Address, Candidate}; use crate::preflight; use crate::uefi; +use uefi::boot; + +const SEND_TIMEOUT_MS: u64 = 1_000; /// The public-driver backend selected for a PIO or MMIO candidate. pub enum Driver { @@ -113,6 +117,7 @@ impl Driver { Self::Mmio(uart) => uart.send_bytes_exact(bytes), } } + /// Polls one received byte so interactive checks never block keyboard input. pub fn try_receive_byte(&mut self) -> core::result::Result { match self { @@ -160,7 +165,7 @@ fn run_one(candidate: &Candidate) -> Result { if let Err(error) = driver.init(config.clone()) { uefi::println!(" FAIL: init: {error:?}"); - return failed_driver(driver); + return failed_driver(driver, false); } uefi::println!(" PASS: init"); @@ -168,20 +173,20 @@ fn run_one(candidate: &Candidate) -> Result { print_dump("after init", &dump); if !valid_dump(&dump, &config) { uefi::println!(" FAIL: initialized register values do not match Config"); - return failed_driver(driver); + return failed_driver(driver, false); } uefi::println!(" PASS: initialized register values"); if let Err(error) = driver.test_loopback() { uefi::println!(" FAIL: test_loopback: {error:?}"); - return failed_driver(driver); + return failed_driver(driver, false); } uefi::println!(" PASS: test_loopback"); let dump = driver.dump(); print_dump("after crate loopback", &dump); if !valid_dump(&dump, &config) { uefi::println!(" FAIL: loopback did not restore configured registers"); - return failed_driver(driver); + return failed_driver(driver, false); } let connection_warning = match driver.check_connected() { @@ -197,7 +202,8 @@ fn run_one(candidate: &Candidate) -> Result { if let Err(error) = exercise_send_apis(&mut driver) { uefi::println!(" FAIL: send API checks: {error}"); - return failed_driver(driver); + print_dump("after send API failure", &driver.dump()); + return failed_driver(driver, connection_warning); } uefi::println!(" PASS: try_send_byte/send_bytes/send_bytes_exact"); @@ -216,16 +222,42 @@ fn exercise_send_apis(driver: &mut Driver) -> core::result::Result<(), &'static .try_send_byte(b'[') .map_err(|_| "try_send_byte failed")?; - let chunk = b"send_bytes] "; - let written = driver.send_bytes(chunk); - if written == 0 { - return Err("send_bytes wrote nothing"); - } - driver.send_bytes_exact(&chunk[written..]); - driver.send_bytes_exact(b"[uart_16550] uart transmit test\r\n"); + send_all_with_timeout(driver, b"send_bytes")?; + wait_until_ready_to_send(driver)?; + // Call the convenience API only while THR is empty to keep this test bounded. + driver.send_bytes_exact(b"]"); + send_all_with_timeout(driver, b" [uart_16550] uart transmit test\r\n")?; Ok(()) } +/// Retries the nonblocking send API long enough for a physical UART to drain. +fn send_all_with_timeout( + driver: &mut Driver, + bytes: &[u8], +) -> core::result::Result<(), &'static str> { + let mut remaining = bytes; + for _ in 0..SEND_TIMEOUT_MS { + let written = driver.send_bytes(remaining); + remaining = &remaining[written..]; + if remaining.is_empty() { + return Ok(()); + } + boot::stall(Duration::from_millis(1)); + } + Err("send_bytes timed out") +} + +/// Bounds the prerequisite for `send_bytes_exact`, which has no timeout API. +fn wait_until_ready_to_send(driver: &mut Driver) -> core::result::Result<(), &'static str> { + for _ in 0..SEND_TIMEOUT_MS { + if driver.ready_to_send().is_ok() { + return Ok(()); + } + boot::stall(Duration::from_millis(1)); + } + Err("transmitter did not become ready") +} + /// Verifies the dump reflects the requested 9600 8N1 polling configuration. fn valid_dump(dump: &ConfigRegisterDump, config: &Config) -> bool { dump.ier.is_empty() @@ -271,10 +303,10 @@ fn fail(stage: &str, error: &str) -> Result { } /// Retains a constructed driver after failure without allowing interactive use. -fn failed_driver(driver: Driver) -> Result { +fn failed_driver(driver: Driver, connection_warning: bool) -> Result { Result { passed: false, - connection_warning: false, + connection_warning, interactive_skipped: false, driver: Some(driver), } From ac3c2cb5d53beefd18c66837fb435ef59dd1fd2e Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:42:33 +0200 Subject: [PATCH 13/20] real-hw-test: report persisted log location Report the dated USB-drive log path before each normal test exit. This keeps the result on screen when the automated checks fail as well as when they complete successfully. --- real-hw-test/src/logging.rs | 22 +++++++++++++++++++--- real-hw-test/src/main.rs | 15 +++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index e6e06c4..80c99e9 100644 --- a/real-hw-test/src/logging.rs +++ b/real-hw-test/src/logging.rs @@ -18,6 +18,7 @@ use uefi::runtime; /// Owns the opened log file and flushes each diagnostic before displaying it. struct Logger { file: RegularFile, + path: String, } /// Holds the single logger used by this synchronous, interrupt-free test. @@ -43,6 +44,7 @@ pub fn init() -> Result<(), &'static str> { time.minute(), time.second(), ); + let path = format!("/uart_16550_test_logs/{file_name}"); let file_name = uefi::CString16::try_from(file_name.as_str()).map_err(|_| "log path is invalid")?; let mut protocol = boot::get_image_file_system(boot::image_handle()) @@ -75,14 +77,14 @@ pub fn init() -> Result<(), &'static str> { .ok_or("test log path is not a regular file")?; // SAFETY: Initialization runs once before any test diagnostics are emitted. - unsafe { *LOGGER.0.get() = Some(Logger::new(file)) }; + unsafe { *LOGGER.0.get() = Some(Logger::new(file, path)) }; Ok(()) } impl Logger { /// Retains one file handle so each write extends the same run transcript. - fn new(file: RegularFile) -> Self { - Self { file } + fn new(file: RegularFile, path: String) -> Self { + Self { file, path } } /// Appends one formatted line and flushes it to FAT before console output. @@ -114,3 +116,17 @@ pub fn println(args: Arguments<'_>) { } uefi_rs::println!("{}", args); } + +/// Reports the USB-drive location after a completed or failed test run. +pub fn report_location() { + // SAFETY: The test runs synchronously and `init` installs the sole logger. + let path = unsafe { (&*LOGGER.0.get()).as_ref() } + .map(|logger| logger.path.clone()) + .unwrap_or_else(|| { + uefi_rs::println!("CRITICAL: test logger was not initialized"); + panic!("test logger was not initialized"); + }); + println(core::format_args!( + "Logs were written to the USB drive: {path}" + )); +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index 03daaa8..bb4e2a9 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -48,6 +48,7 @@ fn main() -> Status { if !firmware::disconnect_serial_controllers() { uefi::println!("FAIL: firmware serial ownership was not released"); + logging::report_location(); return Status::DEVICE_ERROR; } @@ -79,8 +80,16 @@ fn main() -> Status { .filter(|result| result.interactive_skipped) .count(); uefi::println!( - "\nFinal summary: {passed}/{} passed, {warnings} connection warning(s), {skipped} interactive skip(s), {initialized} initialized.", - drivers.len() + concat!( + "\nFinal summary: {}/{} passed, {} connection ", + "warning(s), {} interactive skip(s), {} ", + "initialized.", + ), + passed, + drivers.len(), + warnings, + skipped, + initialized, ); for (index, (candidate, result)) in inventory.candidates().iter().zip(&drivers).enumerate() { let status = if !result.passed { @@ -92,6 +101,8 @@ fn main() -> Status { }; uefi::println!(" [{index}] {status}: {}", candidate.address); } + + logging::report_location(); uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); if passed == drivers.len() { From 338454cfe6e4cd33b3cb5b57a8f53fd40448b727 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 16:03:29 +0200 Subject: [PATCH 14/20] ci: smoke-test UEFI UART paths in QEMU Run the unmodified interactive image headlessly under TCG. The harness answers the operator prompts through QEMU-monitor sendkey and judges the run by the log persisted on the boot volume plus both serial captures. Require automatic checks for legacy COM1 and a PCI UART. --- .github/workflows/real-hw-test.yml | 45 +++++++ real-hw-test/Makefile | 5 +- real-hw-test/README.md | 22 ++++ real-hw-test/flake.nix | 3 + real-hw-test/scripts/run-qemu-ci.sh | 177 ++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/real-hw-test.yml create mode 100755 real-hw-test/scripts/run-qemu-ci.sh diff --git a/.github/workflows/real-hw-test.yml b/.github/workflows/real-hw-test.yml new file mode 100644 index 0000000..1867029 --- /dev/null +++ b/.github/workflows/real-hw-test.yml @@ -0,0 +1,45 @@ +name: UEFI real-hardware test smoke test + +on: [pull_request, merge_group] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +jobs: + qemu: + name: Headless QEMU TCG + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-uefi + - name: Install QEMU, OVMF, and harness tools + run: | + sudo apt-get update + sudo apt-get install -y dosfstools mtools ovmf qemu-system-x86 socat + - name: Run deterministic UEFI checks + working-directory: real-hw-test + run: | + # The ovmf package layout varies across Ubuntu releases: combined vs + # split images, 4M variants, and symlinks. Prefer a combined image. + OVMF="" + for candidate in /usr/share/ovmf/OVMF.fd /usr/share/OVMF/OVMF.fd; do + if [[ -r "$candidate" ]]; then + OVMF=$candidate + break + fi + done + if [[ -z "$OVMF" ]]; then + OVMF=$(find -L /usr/share/OVMF /usr/share/ovmf -maxdepth 1 \ + -name 'OVMF_CODE*.fd' -readable 2>/dev/null | sort | head -n1) + fi + if [[ -z "$OVMF" ]]; then + echo "error: the ovmf package did not install a usable image:" >&2 + ls -la /usr/share/OVMF /usr/share/ovmf >&2 || true + exit 1 + fi + make ci-qemu OVMF="$OVMF" diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 820b99b..1d0ee0e 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -9,7 +9,7 @@ ARTIFACT := build/BOOTX64.EFI BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.PHONY: all artifact check qemu qemu-tcg install clean +.PHONY: all artifact check qemu qemu-tcg ci-qemu install clean all: artifact @@ -33,6 +33,9 @@ qemu: artifact qemu-tcg: $(MAKE) qemu QEMU_ACCEL=tcg +ci-qemu: artifact + QEMU="$(QEMU)" OVMF="$(OVMF)" ./scripts/run-qemu-ci.sh $(ARTIFACT) + install: artifact USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh $(ARTIFACT) diff --git a/real-hw-test/README.md b/real-hw-test/README.md index d7bf143..c9b3fee 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -168,6 +168,28 @@ make qemu-tcg arguments, or relocate the temporary directory-backed EFI system partition. QEMU data stays below the repository's ignored `target/real-hw-test/` tree. +### Headless CI smoke test + +`make ci-qemu` boots the same artifact as `make artifact` headlessly with QEMU +TCG; the application contains no CI-specific code. A host-side script answers +the operator prompts and skips the interactive phase through QEMU-monitor +`sendkey`, then judges the run by the log the application persists on its boot +volume and by the serial captures. It requires both legacy COM1 and the QEMU +PCI serial controller to be discovered and every deterministic raw and +`uart_16550` check to pass. + +The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and OVMF; +the Nix development shell provides all of them. + +```console +make ci-qemu +``` + +This smoke test is useful for debugging the test application and preventing its +automatic QEMU paths from regressing. It does not replace the manual test of a +real cable, reconnect behavior, firmware-specific ownership handoff, or +physical hardware. + ## Reading the test output The UEFI monitor is authoritative. Before takeover, `UEFI SERIAL BASELINE` diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index fa615f0..ade40a8 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -18,8 +18,11 @@ { default = pkgs.mkShell { packages = with pkgs; [ + dosfstools + mtools qemu_kvm rustup + socat util-linux ]; env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh new file mode 100755 index 0000000..6df9373 --- /dev/null +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +qemu=${QEMU:-qemu-system-x86_64} +ovmf=${OVMF:-} +artifact=${1:-build/BOOTX64.EFI} +run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci} +timeout_s=${CI_TIMEOUT:-300} + +disk=$run_dir/disk.img +monitor=$run_dir/monitor.sock +com1_log=$run_dir/com1.log +pci_log=$run_dir/pci-serial.log +persisted_log=$run_dir/persisted.log + +if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 + exit 2 +fi +if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 +fi +for tool in "$qemu" socat truncate mkfs.vfat mmd mcopy; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: required tool not found: $tool" >&2 + exit 2 + fi +done +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi +if [[ -z "$run_dir" || "$run_dir" == "/" ]]; then + echo "error: refusing unsafe CI_RUN_DIR: $run_dir" >&2 + exit 2 +fi + +# A fresh boot image and logs ensure the result cannot come from a previous run. +rm -rf "$run_dir" +mkdir -p "$run_dir" + +# A real FAT image instead of QEMU's experimental fat:rw: directory makes the +# log that the application persists on its boot volume readable on the host. +truncate -s 64M "$disk" +mkfs.vfat "$disk" >/dev/null +mmd -i "$disk" ::/EFI ::/EFI/BOOT +mcopy -i "$disk" "$artifact" ::/EFI/BOOT/BOOTX64.EFI + +"$qemu" \ + -machine q35,accel=tcg \ + -m 256M \ + -bios "$ovmf" \ + -drive "format=raw,file=$disk" \ + -nic none \ + -display none \ + -monitor "unix:$monitor,server,nowait" \ + -serial "file:$com1_log" \ + -chardev "file,id=pci_serial,path=$pci_log" \ + -device pci-serial,chardev=pci_serial \ + -no-reboot & +qemu_pid=$! +trap 'kill -9 "$qemu_pid" 2>/dev/null || true' EXIT +deadline=$(($(date +%s) + timeout_s)) + +monitor_cmd() { + printf '%s\n' "$1" | socat -t 1 - "UNIX-CONNECT:$monitor" >/dev/null 2>&1 \ + || true +} + +qemu_alive() { + kill -0 "$qemu_pid" 2>/dev/null +} + +# Mid-run extraction from the live FAT image is best-effort progress polling; +# only the extraction after QEMU quit is authoritative. +extract_persisted_log() { + rm -rf "$run_dir/logs" + mkdir -p "$run_dir/logs" + MTOOLS_SKIP_CHECK=1 mcopy -n -s -i "$disk" ::/uart_16550_test_logs \ + "$run_dir/logs/" >/dev/null 2>&1 || true + cat "$run_dir/logs/uart_16550_test_logs"/*.txt 2>/dev/null || true +} + +dump_logs() { + if [[ -s "$persisted_log" ]]; then + echo "--- $persisted_log ---" >&2 + cat "$persisted_log" >&2 + fi + for log in "$com1_log" "$pci_log"; do + if [[ -s "$log" ]]; then + echo "--- $log ---" >&2 + sed -n '1,200p' "$log" >&2 + fi + done +} + +fail_run() { + extract_persisted_log > "$persisted_log" + dump_logs + echo "FAIL: $1" >&2 + exit 1 +} + +# The application persists every line before displaying it, so the extracted +# log doubles as the progress signal. No key is sent before the first operator +# prompt: an Escape while OVMF still owns the keyboard would enter firmware +# setup instead of the boot target. +until extract_persisted_log | grep -qF 'then press Enter.'; do + qemu_alive || fail_run "QEMU exited before the first operator prompt" + if (($(date +%s) >= deadline)); then + fail_run "timeout waiting for the first operator prompt" + fi + sleep 2 +done + +# Enter satisfies both operator confirmations, which discard every other key, +# and Escape skips each per-UART interactive phase, which ignores Enter. +# Blindly alternating both keys drives the application to its final prompt. +while (($(date +%s) < deadline)); do + if extract_persisted_log | grep -qF 'Press Enter to return to firmware.'; then + break + fi + # A queued Enter can finish the final prompt early; the persisted log then + # already contains every line the assertions below need. + qemu_alive || break + monitor_cmd 'sendkey ret' + sleep 1 + monitor_cmd 'sendkey esc' + sleep 1 +done + +# A monitor quit lets QEMU commit the final FAT state before extraction. +monitor_cmd quit +for _ in $(seq 15); do + qemu_alive || break + sleep 1 +done +kill -9 "$qemu_pid" 2>/dev/null || true +wait "$qemu_pid" 2>/dev/null || true + +extract_persisted_log > "$persisted_log" +if ! grep -qF 'Press Enter to return to firmware.' "$persisted_log"; then + dump_logs + echo "FAIL: the application did not reach its final prompt in ${timeout_s}s" >&2 + exit 1 +fi + +failures=0 +assert_log() { + local file=$1 + shift + if ! grep -q "$@" "$file"; then + echo "FAIL: expected $file to match: $*" >&2 + failures=$((failures + 1)) + fi +} + +# The same requirements the guest previously verified itself: COM1 and the PCI +# UART are both discovered and every automatic check passed. +assert_log "$persisted_log" -F 'PIO 0x03f8' +assert_log "$persisted_log" -F 'RequiredCom1' +assert_log "$persisted_log" -E 'sources=\[.*Pci' +assert_log "$persisted_log" -F 'Final summary: 2/2 passed' +assert_log "$persisted_log" -F '2 interactive skip(s)' +# The transmit payloads prove that bytes really left through both UARTs. +assert_log "$com1_log" -F '[barebones] uart transmit test' +assert_log "$com1_log" -F '[uart_16550] uart transmit test' +assert_log "$pci_log" -F '[barebones] uart transmit test' +assert_log "$pci_log" -F '[uart_16550] uart transmit test' + +if ((failures > 0)); then + dump_logs + exit 1 +fi + +echo "PASS: headless TCG run drove COM1 and PCI UART checks to completion" From 60752aaa323de53da14acc80aa3f4819710e0e86 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:11 +0200 Subject: [PATCH 15/20] real-hw-test: gate x86-specific code paths Port I/O instructions, legacy COM probing, and the PIO driver backend exist only on x86. Compiler-enforced cfg gates keep every port-address path out of the builds of other architectures. --- real-hw-test/src/device.rs | 9 ++++++++- real-hw-test/src/discovery.rs | 8 +++++++- real-hw-test/src/discovery/acpi.rs | 13 ++++++++++++- real-hw-test/src/driver_test.rs | 15 ++++++++++++++- real-hw-test/src/raw_uart.rs | 3 +++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index ed68715..66ab8bd 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -6,14 +6,19 @@ use uart_16550::spec::CLK_FREQUENCY_HZ; #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// A byte-addressable 16550 register block reached through PIO or MMIO. pub enum Address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Port(u16), - Mmio { base: usize, stride: u8 }, + Mmio { + base: usize, + stride: u8, + }, } impl Display for Address { /// Formats an address in the form used by on-screen diagnostics. fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(port) => write!(f, "PIO 0x{port:04x}"), Self::Mmio { base, stride } => { write!(f, "MMIO 0x{base:x}, stride {stride}") @@ -25,7 +30,9 @@ impl Display for Address { #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Records how discovery found a candidate so duplicate descriptions remain useful. pub enum Source { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] RequiredCom1, + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, AcpiSpcr, Pci { diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index d0402f1..cc75e03 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -3,8 +3,12 @@ //! Multiple discovery paths cover fixed COM ports and dynamically described //! UARTs, including QEMU's independent PCI serial controller. -use crate::device::{Address, Inventory, Source}; +use crate::device::Inventory; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use crate::device::{Address, Source}; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use crate::raw_uart::RawUart; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use crate::uefi; mod acpi; @@ -13,6 +17,7 @@ mod pci; /// Combines every discovery source into a deduplicated test inventory. pub fn discover() -> Inventory { let mut inventory = Inventory::default(); + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] discover_legacy(&mut inventory); acpi::discover(&mut inventory); pci::discover(&mut inventory); @@ -20,6 +25,7 @@ pub fn discover() -> Inventory { } /// Probes conventional COM addresses while always retaining COM1 as a baseline. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] fn discover_legacy(inventory: &mut Inventory) { const PORTS: [u16; 4] = [0x3f8, 0x2f8, 0x3e8, 0x2e8]; diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index 89dd8a0..8e5a562 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -87,11 +87,22 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { base: spcr.base as usize, stride: 1, }, + 0 => { + uefi::println!(" SKIP: SPCR base address is out of range"); + return; + } + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] 1 if spcr.base <= u64::from(u16::MAX - 7) => Address::Port(spcr.base as u16), - 0 | 1 => { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 1 => { uefi::println!(" SKIP: SPCR base address is out of range"); return; } + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] + 1 => { + uefi::println!(" SKIP: System I/O access requires x86 port instructions"); + return; + } _ => { uefi::println!(" SKIP: unsupported ACPI address space"); return; diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 7d35221..0e9485c 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -7,7 +7,9 @@ use alloc::vec::Vec; use core::ptr::NonNull; use core::time::Duration; -use uart_16550::backend::{MmioBackend, PioBackend}; +use uart_16550::backend::MmioBackend; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use uart_16550::backend::PioBackend; use uart_16550::spec::registers::{LSR, MCR}; use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; @@ -20,6 +22,7 @@ const SEND_TIMEOUT_MS: u64 = 1_000; /// The public-driver backend selected for a PIO or MMIO candidate. pub enum Driver { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Port(Uart16550), Mmio(Uart16550), } @@ -36,6 +39,7 @@ impl Driver { /// Constructs the public backend matching the candidate's address form. fn new(address: Address) -> core::result::Result { match address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Address::Port(port) => { // SAFETY: firmware serial consumers were disconnected before candidate discovery. unsafe { Uart16550::new_port(port) } @@ -55,6 +59,7 @@ impl Driver { /// Initializes either backend with the same configuration for equal coverage. fn init(&mut self, config: Config) -> core::result::Result<(), uart_16550::InitError> { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.init(config), Self::Mmio(uart) => uart.init(config), } @@ -63,6 +68,7 @@ impl Driver { /// Captures a typed register dump for diagnostics and invariant checks. pub fn dump(&mut self) -> ConfigRegisterDump { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.config_register_dump(), Self::Mmio(uart) => uart.config_register_dump(), } @@ -71,6 +77,7 @@ impl Driver { /// Exercises the crate's loopback implementation through the chosen backend. pub fn test_loopback(&mut self) -> core::result::Result<(), uart_16550::LoopbackError> { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.test_loopback(), Self::Mmio(uart) => uart.test_loopback(), } @@ -81,6 +88,7 @@ impl Driver { &mut self, ) -> core::result::Result<(), uart_16550::RemoteReadyToReceiveError> { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.check_connected(), Self::Mmio(uart) => uart.check_connected(), } @@ -89,6 +97,7 @@ impl Driver { /// Delegates the crate's transmitter-readiness check to either backend. fn ready_to_send(&mut self) -> core::result::Result<(), uart_16550::ByteSendError> { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.ready_to_send(), Self::Mmio(uart) => uart.ready_to_send(), } @@ -97,6 +106,7 @@ impl Driver { /// Sends one byte with the crate's fallible API for explicit coverage. fn try_send_byte(&mut self, byte: u8) -> core::result::Result<(), uart_16550::ByteSendError> { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.try_send_byte(byte), Self::Mmio(uart) => uart.try_send_byte(byte), } @@ -105,6 +115,7 @@ impl Driver { /// Attempts a slice write and returns the crate's partial-write progress. fn send_bytes(&mut self, bytes: &[u8]) -> usize { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.send_bytes(bytes), Self::Mmio(uart) => uart.send_bytes(bytes), } @@ -113,6 +124,7 @@ impl Driver { /// Completes a slice write through the crate's synchronous convenience API. pub fn send_bytes_exact(&mut self, bytes: &[u8]) { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.send_bytes_exact(bytes), Self::Mmio(uart) => uart.send_bytes_exact(bytes), } @@ -121,6 +133,7 @@ impl Driver { /// Polls one received byte so interactive checks never block keyboard input. pub fn try_receive_byte(&mut self) -> core::result::Result { match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.try_receive_byte(), Self::Mmio(uart) => uart.try_receive_byte(), } diff --git a/real-hw-test/src/raw_uart.rs b/real-hw-test/src/raw_uart.rs index a38c378..3a97026 100644 --- a/real-hw-test/src/raw_uart.rs +++ b/real-hw-test/src/raw_uart.rs @@ -3,6 +3,7 @@ //! Direct PIO/MMIO operations validate a candidate before `Uart16550` exists, //! avoiding a circular test that verifies the driver only with itself. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use core::arch::asm; use core::hint; @@ -95,6 +96,7 @@ impl RawUart { pub fn read(&mut self, offset: u8) -> u8 { debug_assert!(offset < 8); match self.address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Address::Port(base) => { let port = base + u16::from(offset); let value: u8; @@ -121,6 +123,7 @@ impl RawUart { pub fn write(&mut self, offset: u8, value: u8) { debug_assert!(offset < 8); match self.address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Address::Port(base) => { let port = base + u16::from(offset); // SAFETY: discovery assigned an owned 16550-compatible PIO port. From 99ab20f33c3cc684033458deb100372405b02921 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:20 +0200 Subject: [PATCH 16/20] real-hw-test: reach PCI I/O BARs through the ACPI I/O window Without x86 port instructions, PCI I/O space is a memory-mapped window behind the root bridge. Firmware hides the CPU-side base inside its protocol implementation, but AML resource templates embed plain address space descriptors, so a strictly validated DSDT byte scan recovers the translated window without an AML interpreter. Firmware also leaves the decoding of endpoints it never binds disabled; an assigned BAR of an unambiguous UART is therefore enabled explicitly. --- real-hw-test/src/discovery/acpi.rs | 159 +++++++++++++++++++++++------ real-hw-test/src/discovery/pci.rs | 61 ++++++++++- 2 files changed, 189 insertions(+), 31 deletions(-) diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index 8e5a562..bad6745 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -17,19 +17,7 @@ const MAX_TABLE_LEN: usize = 1024 * 1024; /// Locates SPCR from UEFI configuration tables and safely skips invalid data. pub fn discover(inventory: &mut Inventory) { uefi::println!("\nACPI SPCR discovery:"); - let rsdp = system::with_config_table(|tables| { - tables - .iter() - .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) - .or_else(|| { - tables - .iter() - .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) - }) - .map(|entry| entry.address as usize) - }); - - let Some(rsdp) = rsdp else { + let Some(rsdp) = rsdp() else { uefi::println!(" SKIP: no ACPI RSDP in the UEFI configuration table"); return; }; @@ -41,6 +29,21 @@ pub fn discover(inventory: &mut Inventory) { } } +/// Returns the RSDP address from the UEFI configuration table, preferring ACPI 2. +fn rsdp() -> Option { + system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) + .or_else(|| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) + }) + .map(|entry| entry.address as usize) + }) +} + /// The SPCR subset needed to validate and add a byte-access UART candidate. #[derive(Clone, Copy)] struct SpcrInfo { @@ -113,8 +116,31 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); } -/// Validates RSDP and XSDT/RSDT data before finding and decoding an SPCR table. +/// Finds and decodes an SPCR table, requiring the fields this test consumes. fn find_spcr(rsdp_address: usize) -> Result, &'static str> { + let Some(table) = find_table(rsdp_address, b"SPCR")? else { + return Ok(None); + }; + if table.len() < 80 { + return Err("SPCR is too short"); + } + let clock = read_u32(table, 76); + Ok(Some(SpcrInfo { + interface: table[36], + address_space: table[40], + bit_width: table[41], + bit_offset: table[42], + access_size: table[43], + base: read_u64(table, 44), + clock_hz: (clock != 0).then_some(clock), + })) +} + +/// Validates RSDP and XSDT/RSDT data before returning one table by signature. +fn find_table( + rsdp_address: usize, + signature: &[u8; 4], +) -> Result, &'static str> { let rsdp = acpi_bytes(rsdp_address, 36)?; if &rsdp[..8] != b"RSD PTR " || !checksum_ok(&rsdp[..20]) { return Err("bad RSDP signature or checksum"); @@ -148,27 +174,102 @@ fn find_spcr(rsdp_address: usize) -> Result, &'static str> { read_u32(entry, 0) as usize }; let header = acpi_bytes(address, SDT_HEADER_LEN)?; - if &header[..4] != b"SPCR" { + if &header[..4] != signature { continue; } - let table = sdt(address)?; - if table.len() < 80 { - return Err("SPCR is too short"); - } - let clock = read_u32(table, 76); - return Ok(Some(SpcrInfo { - interface: table[36], - address_space: table[40], - bit_width: table[41], - bit_offset: table[42], - access_size: table[43], - base: read_u64(table, 44), - clock_hz: (clock != 0).then_some(clock), - })); + return sdt(address).map(Some); } Ok(None) } +/// The CPU-visible MMIO window ACPI declares for the PCI I/O address space. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IoWindow { + pub pci_min: u64, + pub pci_max: u64, + pub cpu_base: u64, +} + +/// Recovers the PCI I/O window translation from the DSDT's resource bytes. +/// +/// AML resource templates embed plain ACPI address-space descriptors, so a +/// strictly validated byte scan finds the root bridge's translated I/O range +/// without an AML interpreter. Ambiguous DSDTs yield no window. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub fn pci_io_window() -> Option { + let fadt = find_table(rsdp()?, b"FACP").ok().flatten()?; + let dsdt_address = if fadt.len() >= 148 && read_u64(fadt, 140) != 0 { + read_u64(fadt, 140) as usize + } else if fadt.len() >= 44 { + read_u32(fadt, 40) as usize + } else { + return None; + }; + let dsdt = sdt(dsdt_address).ok()?; + + let mut found: Option = None; + let mut offset = 0; + while offset < dsdt.len() { + let (window, size) = match parse_io_descriptor(&dsdt[offset..]) { + Some(parsed) => parsed, + None => { + offset += 1; + continue; + } + }; + offset += size; + match found { + None => found = Some(window), + Some(previous) if previous == window => {} + Some(_) => return None, + } + } + found +} + +/// Decodes one translated DWord/QWord I/O descriptor at the slice's start. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn parse_io_descriptor(bytes: &[u8]) -> Option<(IoWindow, usize)> { + const DWORD_IO: (u8, u16, usize) = (0x87, 23, 4); + const QWORD_IO: (u8, u16, usize) = (0x8a, 43, 8); + const TYPE_IO: u8 = 1; + + let (_, body_len, field_size) = [DWORD_IO, QWORD_IO] + .into_iter() + .find(|(tag, _, _)| bytes.first() == Some(tag))?; + let size = 3 + usize::from(body_len); + if bytes.len() < size + || u16::from_le_bytes([bytes[1], bytes[2]]) != body_len + || bytes[3] != TYPE_IO + { + return None; + } + let field = |index: usize| { + let offset = 6 + index * field_size; + if field_size == 8 { + read_u64(bytes, offset) + } else { + u64::from(read_u32(bytes, offset)) + } + }; + let (pci_min, pci_max, translation, length) = (field(1), field(2), field(3), field(4)); + + // Only an arithmetically consistent, actually translated window is usable. + let consistent = pci_min <= pci_max + && length == pci_max - pci_min + 1 + && translation != 0 + && translation.checked_add(pci_max).is_some(); + consistent.then_some(( + IoWindow { + pci_min, + pci_max, + cpu_base: pci_min + translation, + }, + size, + )) +} + /// Borrows mapped firmware ACPI memory after rejecting a null physical address. fn acpi_bytes(address: usize, length: usize) -> Result<&'static [u8], &'static str> { if address == 0 { diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs index 3f467ef..5f38ed0 100644 --- a/real-hw-test/src/discovery/pci.rs +++ b/real-hw-test/src/discovery/pci.rs @@ -112,12 +112,27 @@ fn inspect_serial_controller( return; } + // Firmware enables decoding only for endpoints it binds a driver to; an + // otherwise valid UART may therefore arrive with its assigned BAR disabled. + let needed_enable: u16 = if bar0 & 1 != 0 { 0x0001 } else { 0x0002 }; + let command = if command & needed_enable == 0 { + match enable_decoding(root, address, command | needed_enable) { + Some(command) => command, + None => { + uefi::println!(" SKIP: could not enable BAR0 decoding"); + return; + } + } + } else { + command + }; + let candidate = if bar0 & 1 != 0 { let base = bar0 & !0x3; - if command & 1 == 0 || base > u32::from(u16::MAX - 7) { + if command & 1 == 0 { None } else { - Some(Address::Port(base as u16)) + io_bar_address(root, base) } } else { let memory_type = (bar0 >> 1) & 0x3; @@ -153,6 +168,48 @@ fn inspect_serial_controller( ); } +/// Sets a missing decode-enable bit and returns the verified command register. +fn enable_decoding(root: &mut PciRootBridgeIo, address: PciIoAddress, command: u16) -> Option { + root.pci() + .write_one(address.with_register(0x04), command) + .ok()?; + let command = config_u16(root, address, 0x04).ok()?; + uefi::println!(" enabled BAR0 decoding: command=0x{command:04x}"); + Some(command) +} + +/// Uses an I/O BAR directly: x86 port instructions reach PCI I/O space as-is. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +fn io_bar_address(_root: &mut PciRootBridgeIo, base: u32) -> Option
{ + (base <= u32::from(u16::MAX - 7)).then_some(Address::Port(base as u16)) +} + +/// Translates an I/O BAR into the platform's memory-mapped I/O window. +/// +/// Without port instructions, PCI I/O space is reached through an MMIO +/// aperture. Firmware hides its CPU-side base inside the root bridge protocol, +/// so the window is taken from the platform's ACPI description instead. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn io_bar_address(_root: &mut PciRootBridgeIo, base: u32) -> Option
{ + let Some(window) = super::acpi::pci_io_window() else { + uefi::println!(" SKIP: no unambiguous ACPI PCI I/O window"); + return None; + }; + let base = u64::from(base); + if base < window.pci_min || base + 7 > window.pci_max { + uefi::println!(" SKIP: I/O BAR lies outside the ACPI I/O window"); + return None; + } + let translated = base + .checked_sub(window.pci_min)? + .checked_add(window.cpu_base)?; + uefi::println!(" I/O window translation: 0x{base:x} -> 0x{translated:x}"); + Some(Address::Mmio { + base: usize::try_from(translated).ok()?, + stride: 1, + }) +} + /// Reads one byte from PCI configuration space through the root bridge. fn config_u8(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { root.pci().read_one(address.with_register(offset)) From 0bd2baf07141d50b24bf1462b3d1493a30292fd5 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:30 +0200 Subject: [PATCH 17/20] real-hw-test: parameterize the build and QEMU tooling per architecture ARCH selects the Rust target, the removable-media file name, and the matching QEMU machine: q35 with OVMF on x86_64, virt with pflash EDK2, ramfb, and a USB keyboard on aarch64. The dev shell switches to the full QEMU because qemu_kvm only carries the host architecture. --- real-hw-test/Makefile | 27 +++++++++-- real-hw-test/flake.nix | 6 ++- real-hw-test/scripts/install-usb.sh | 3 +- real-hw-test/scripts/run-qemu.sh | 71 ++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 24 deletions(-) diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 1d0ee0e..3e92864 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -1,11 +1,26 @@ CARGO ?= cargo +ARCH ?= x86_64 + +ifeq ($(ARCH),x86_64) +TARGET := x86_64-unknown-uefi +BOOT_NAME := BOOTX64.EFI QEMU ?= qemu-system-x86_64 QEMU_ACCEL ?= kvm -TARGET := x86_64-unknown-uefi +else ifeq ($(ARCH),aarch64) +TARGET := aarch64-unknown-uefi +BOOT_NAME := BOOTAA64.EFI +QEMU ?= qemu-system-aarch64 +# The development host is typically x86_64, so hardware acceleration is not +# assumed for the aarch64 guest. +QEMU_ACCEL ?= tcg +else +$(error unsupported ARCH '$(ARCH)'; supported: x86_64, aarch64) +endif + TARGET_DIR := ../target/real-hw-test PROFILE := release BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi -ARTIFACT := build/BOOTX64.EFI +ARTIFACT := build/$(BOOT_NAME) BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked @@ -22,19 +37,21 @@ $(ARTIFACT): FORCE check: $(CARGO) fmt --check - $(CLIPPY) --target $(TARGET) --release -- -D warnings + $(CLIPPY) --target x86_64-unknown-uefi --release -- -D warnings + $(CLIPPY) --target aarch64-unknown-uefi --release -- -D warnings bash -n scripts/*.sh $(MAKE) artifact qemu: artifact - QEMU="$(QEMU)" QEMU_ACCEL="$(QEMU_ACCEL)" OVMF="$(OVMF)" \ + ARCH="$(ARCH)" QEMU="$(QEMU)" QEMU_ACCEL="$(QEMU_ACCEL)" OVMF="$(OVMF)" \ ./scripts/run-qemu.sh $(QEMU_ARGS) qemu-tcg: $(MAKE) qemu QEMU_ACCEL=tcg ci-qemu: artifact - QEMU="$(QEMU)" OVMF="$(OVMF)" ./scripts/run-qemu-ci.sh $(ARTIFACT) + ARCH="$(ARCH)" QEMU="$(QEMU)" OVMF="$(OVMF)" \ + ./scripts/run-qemu-ci.sh $(ARTIFACT) install: artifact USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh $(ARTIFACT) diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index ade40a8..bfe148c 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -20,12 +20,16 @@ packages = with pkgs; [ dosfstools mtools - qemu_kvm + # The full QEMU: qemu_kvm carries only the host architecture's + # system emulator, but the aarch64 test needs qemu-system-aarch64. + qemu rustup socat util-linux ]; env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; + env.AAVMF_CODE = "${pkgs.qemu}/share/qemu/edk2-aarch64-code.fd"; + env.AAVMF_VARS = "${pkgs.qemu}/share/qemu/edk2-arm-vars.fd"; }; } ); diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh index 4f1bb21..34712f4 100755 --- a/real-hw-test/scripts/install-usb.sh +++ b/real-hw-test/scripts/install-usb.sh @@ -57,7 +57,8 @@ fat_version=$(lsblk -dnro FSVER "$source_device") [[ "$fat_version" == FAT32 ]] || fail \ "$source_device reports '${fat_version:-an unknown FAT version}', expected FAT32" -target=$mount_path/EFI/BOOT/BOOTX64.EFI +# The artifact already carries its architecture's removable-media file name. +target=$mount_path/EFI/BOOT/$(basename "$artifact") echo "Installing to validated media:" echo " disk: $parent_device (GPT)" echo " partition: $source_device (FAT32)" diff --git a/real-hw-test/scripts/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh index 49be207..2d375cd 100755 --- a/real-hw-test/scripts/run-qemu.sh +++ b/real-hw-test/scripts/run-qemu.sh @@ -1,25 +1,59 @@ #!/usr/bin/env bash set -euo pipefail -qemu=${QEMU:-qemu-system-x86_64} +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} accel=${QEMU_ACCEL:-kvm} ovmf=${OVMF:-} -esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp} -artifact=${ARTIFACT:-build/BOOTX64.EFI} +esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp-$arch} +artifact=${ARTIFACT:-} -if [[ -z "$ovmf" ]]; then - echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ - >&2 - exit 2 -fi -if [[ ! -r "$ovmf" ]]; then - echo "error: OVMF firmware is not readable: $ovmf" >&2 - exit 2 -fi if ! command -v "$qemu" >/dev/null 2>&1; then echo "error: QEMU executable not found: $qemu" >&2 exit 2 fi + +# Per-architecture machine, firmware, display, and input configuration. +case "$arch" in +x86_64) + artifact=${artifact:-build/BOOTX64.EFI} + if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ + >&2 + exit 2 + fi + if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 + fi + machine_args=(-machine "q35,accel=$accel" -m 256M -bios "$ovmf") + ;; +aarch64) + artifact=${artifact:-build/BOOTAA64.EFI} + # QEMU ships pflash-style EDK2 images next to its own installation. + share_dir=$(dirname "$(readlink -f "$(command -v "$qemu")")")/../share/qemu + aavmf_code=${AAVMF_CODE:-$share_dir/edk2-aarch64-code.fd} + aavmf_vars=${AAVMF_VARS:-$share_dir/edk2-arm-vars.fd} + for firmware in "$aavmf_code" "$aavmf_vars"; do + if [[ ! -r "$firmware" ]]; then + echo "error: aarch64 firmware is not readable: $firmware" >&2 + echo " set AAVMF_CODE and AAVMF_VARS" >&2 + exit 2 + fi + done + # virt has no built-in display or keyboard; ramfb and a USB keyboard give + # the operator the UEFI monitor and Enter/Escape navigation. + machine_args=(-machine "virt,accel=$accel" -cpu max -m 512M + -drive "if=pflash,format=raw,file=$aavmf_code,readonly=on" + -drive "if=pflash,format=raw,file=$esp_dir-vars.fd" + -device ramfb -device qemu-xhci -device usb-kbd) + ;; +*) + echo "error: unsupported ARCH '$arch'; supported: x86_64, aarch64" >&2 + exit 2 + ;; +esac + if [[ ! -r "$artifact" ]]; then echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 exit 2 @@ -28,15 +62,18 @@ fi # Recreate the virtual ESP so QEMU never boots a stale application. rm -rf "$esp_dir" mkdir -p "$esp_dir/EFI/BOOT" -cp "$artifact" "$esp_dir/EFI/BOOT/BOOTX64.EFI" +cp "$artifact" "$esp_dir/EFI/BOOT/$(basename "$artifact")" +if [[ "$arch" == aarch64 ]]; then + # Writable per-run variable store; the template may be read-only on disk. + cp "$aavmf_vars" "$esp_dir-vars.fd" + chmod u+w "$esp_dir-vars.fd" +fi -echo "QEMU COM1 is attached to this terminal." +echo "QEMU serial console is attached to this terminal." echo "QEMU will print a /dev/pts/... path for the PCI serial device." exec "$qemu" \ - -machine "q35,accel=$accel" \ - -m 256M \ - -bios "$ovmf" \ + "${machine_args[@]}" \ -drive "format=raw,file=fat:rw:$esp_dir" \ -nic none \ -monitor none \ From 090dff39578b278c619250f204c1c56fc4ffe2ff Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:30 +0200 Subject: [PATCH 18/20] ci: smoke-test aarch64 UEFI UART paths in QEMU The aarch64 virt machine has no 16550 except the PCI serial device, so the run must reject the PL011 console via SPCR and drive the PCI UART through the translated I/O window with the MMIO backend. --- .github/workflows/real-hw-test.yml | 56 ++++++++------ real-hw-test/README.md | 70 ++++++++++++----- real-hw-test/scripts/run-qemu-ci.sh | 113 ++++++++++++++++++++-------- 3 files changed, 168 insertions(+), 71 deletions(-) diff --git a/.github/workflows/real-hw-test.yml b/.github/workflows/real-hw-test.yml index 1867029..c5c1fdb 100644 --- a/.github/workflows/real-hw-test.yml +++ b/.github/workflows/real-hw-test.yml @@ -8,38 +8,52 @@ concurrency: jobs: qemu: - name: Headless QEMU TCG + name: Headless QEMU TCG (${{ matrix.arch }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] steps: - uses: actions/checkout@v7 - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-unknown-uefi - - name: Install QEMU, OVMF, and harness tools + targets: x86_64-unknown-uefi,aarch64-unknown-uefi + - name: Install QEMU, firmware, and harness tools run: | sudo apt-get update - sudo apt-get install -y dosfstools mtools ovmf qemu-system-x86 socat + if [[ "${{ matrix.arch }}" == x86_64 ]]; then + sudo apt-get install -y dosfstools mtools ovmf qemu-system-x86 socat + else + sudo apt-get install -y dosfstools mtools qemu-efi-aarch64 qemu-system-arm socat + fi - name: Run deterministic UEFI checks working-directory: real-hw-test run: | - # The ovmf package layout varies across Ubuntu releases: combined vs - # split images, 4M variants, and symlinks. Prefer a combined image. - OVMF="" - for candidate in /usr/share/ovmf/OVMF.fd /usr/share/OVMF/OVMF.fd; do - if [[ -r "$candidate" ]]; then - OVMF=$candidate - break + if [[ "${{ matrix.arch }}" == x86_64 ]]; then + # The ovmf package layout varies across Ubuntu releases: combined + # vs split images, 4M variants, and symlinks. Prefer combined. + OVMF="" + for candidate in /usr/share/ovmf/OVMF.fd /usr/share/OVMF/OVMF.fd; do + if [[ -r "$candidate" ]]; then + OVMF=$candidate + break + fi + done + if [[ -z "$OVMF" ]]; then + OVMF=$(find -L /usr/share/OVMF /usr/share/ovmf -maxdepth 1 \ + -name 'OVMF_CODE*.fd' -readable 2>/dev/null | sort | head -n1) fi - done - if [[ -z "$OVMF" ]]; then - OVMF=$(find -L /usr/share/OVMF /usr/share/ovmf -maxdepth 1 \ - -name 'OVMF_CODE*.fd' -readable 2>/dev/null | sort | head -n1) - fi - if [[ -z "$OVMF" ]]; then - echo "error: the ovmf package did not install a usable image:" >&2 - ls -la /usr/share/OVMF /usr/share/ovmf >&2 || true - exit 1 + if [[ -z "$OVMF" ]]; then + echo "error: the ovmf package did not install a usable image:" >&2 + ls -la /usr/share/OVMF /usr/share/ovmf >&2 || true + exit 1 + fi + make ci-qemu OVMF="$OVMF" + else + export AAVMF_CODE=/usr/share/AAVMF/AAVMF_CODE.fd + export AAVMF_VARS=/usr/share/AAVMF/AAVMF_VARS.fd + make ci-qemu ARCH=aarch64 fi - make ci-qemu OVMF="$OVMF" diff --git a/real-hw-test/README.md b/real-hw-test/README.md index c9b3fee..14fc372 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -1,9 +1,9 @@ # uart_16550 UEFI real-hardware test -This subproject builds an x86_64 UEFI application that takes ownership of -16550-compatible UARTs and exercises this repository's driver. It is a manual -integration test: automatic register and loopback checks run first, followed by -an interactive serial menu. +This subproject builds a UEFI application (x86_64 by default, aarch64 via +`ARCH=aarch64`) that takes ownership of 16550-compatible UARTs and exercises +this repository's driver. It is a manual integration test: automatic register +and loopback checks run first, followed by an interactive serial menu. All diagnostics use UEFI Simple Text Output and are intended to stay visible on the test machine's monitor. They are also persisted, line by line, on the boot @@ -34,19 +34,22 @@ failure to disable it is reported as a warning on screen. It discovers: -- COM1 at `0x3f8` unconditionally; +- COM1 at `0x3f8` unconditionally (x86_64 only); - conventional legacy ports at `0x2f8`, `0x3e8`, and `0x2e8` when their scratch - registers behave like a UART; + registers behave like a UART (x86_64 only); - compatible byte-access UARTs advertised by ACPI SPCR; -- PCI serial-class controllers with an enabled, unambiguous, 16550-compatible - BAR0. +- PCI serial-class controllers with an assigned, unambiguous, 16550-compatible + BAR0, enabling its decoding when firmware left the endpoint unbound. Unsupported ACPI interfaces and ambiguous or vendor-specific PCI layouts are -reported but not accessed. +reported but not accessed. Without x86 port instructions, an I/O BAR is reached +through the memory-mapped PCI I/O window that the platform's ACPI DSDT +declares; a missing or ambiguous window skips the device. ## Recommended real-hardware setup -Boot the application on an x86_64 machine with: +Boot the application on an x86_64 machine (see "Architecture support" for the +state of other architectures) with: - UEFI firmware and Secure Boot disabled, unless you sign the application; - a monitor connected to the machine; @@ -93,10 +96,10 @@ terminal to skip a UART that has no connected remote. ## Build -Install the Rust UEFI target once if necessary: +Install the Rust UEFI targets once if necessary: ```console -rustup target add x86_64-unknown-uefi +rustup target add x86_64-unknown-uefi aarch64-unknown-uefi ``` Then build and stage the removable-media filename: @@ -106,7 +109,9 @@ make artifact file build/BOOTX64.EFI ``` -The resulting file is `build/BOOTX64.EFI`. +The resulting file is `build/BOOTX64.EFI`. `make artifact ARCH=aarch64` +produces `build/BOOTAA64.EFI` instead; every `make` target accepts the same +`ARCH` variable. Run all static build checks with: @@ -168,21 +173,31 @@ make qemu-tcg arguments, or relocate the temporary directory-backed EFI system partition. QEMU data stays below the repository's ignored `target/real-hw-test/` tree. +`make qemu ARCH=aarch64` runs the aarch64 build on QEMU's `virt` machine with +the EDK2 firmware bundled with QEMU (override via `AAVMF_CODE`/`AAVMF_VARS`). +The terminal shows the PL011 firmware console, which the application correctly +rejects as a UART candidate; the only 16550 is the `pci-serial` device, reached +through the memory-mapped PCI I/O window. TCG is the default accelerator for +the aarch64 guest. + ### Headless CI smoke test `make ci-qemu` boots the same artifact as `make artifact` headlessly with QEMU TCG; the application contains no CI-specific code. A host-side script answers the operator prompts and skips the interactive phase through QEMU-monitor `sendkey`, then judges the run by the log the application persists on its boot -volume and by the serial captures. It requires both legacy COM1 and the QEMU -PCI serial controller to be discovered and every deterministic raw and -`uart_16550` check to pass. +volume and by the serial captures. On x86_64 it requires both legacy COM1 and +the QEMU PCI serial controller to be discovered; on aarch64 it requires the +PL011 console to be rejected and the PCI UART to be driven through the +translated I/O window. Every deterministic raw and `uart_16550` check must +pass. -The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and OVMF; -the Nix development shell provides all of them. +The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and the +firmware; the Nix development shell provides all of them. ```console make ci-qemu +make ci-qemu ARCH=aarch64 ``` This smoke test is useful for debugging the test application and preventing its @@ -190,6 +205,25 @@ automatic QEMU paths from regressing. It does not replace the manual test of a real cable, reconnect behavior, firmware-specific ownership handoff, or physical hardware. +## Architecture support + +x86_64 is the primary target and the only one exercised on physical hardware so +far. aarch64 is fully validated under QEMU; on real aarch64 machines the test +is expected to find little today: + +- Server-class Arm platforms describe a PL011 or SBSA Generic UART in SPCR, + which is not 16550-compatible and is deliberately rejected. +- Boards whose EDK2 ports do describe a 16550 (for example RK3588) declare + 32-bit registers at stride 4; the driver only performs byte accesses, so + such SPCR layouts are rejected as well. +- Boards booting through U-Boot's EFI implementation publish a device tree + instead of ACPI; the test has no device-tree discovery. + +riscv64 is currently not supported because Rust has no riscv64 UEFI target; +building would require a custom target JSON on nightly with `-Zbuild-std`. +QEMU's riscv64 `virt` machine would otherwise be a good fit: its ns16550a is +MMIO-mapped and described by an SPCR with the 16550 interface type. + ## Reading the test output The UEFI monitor is authoritative. Before takeover, `UEFI SERIAL BASELINE` diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh index 6df9373..b693ae9 100755 --- a/real-hw-test/scripts/run-qemu-ci.sh +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -1,26 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -qemu=${QEMU:-qemu-system-x86_64} +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} ovmf=${OVMF:-} artifact=${1:-build/BOOTX64.EFI} -run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci} +run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci-$arch} timeout_s=${CI_TIMEOUT:-300} +boot_name=$(basename "$artifact") disk=$run_dir/disk.img monitor=$run_dir/monitor.sock -com1_log=$run_dir/com1.log +console_log=$run_dir/console.log pci_log=$run_dir/pci-serial.log persisted_log=$run_dir/persisted.log -if [[ -z "$ovmf" ]]; then - echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 - exit 2 -fi -if [[ ! -r "$ovmf" ]]; then - echo "error: OVMF firmware is not readable: $ovmf" >&2 - exit 2 -fi for tool in "$qemu" socat truncate mkfs.vfat mmd mcopy; do if ! command -v "$tool" >/dev/null 2>&1; then echo "error: required tool not found: $tool" >&2 @@ -36,26 +30,67 @@ if [[ -z "$run_dir" || "$run_dir" == "/" ]]; then exit 2 fi +# Per-architecture machine, firmware, and input configuration. Both guests get +# a PCI 16550; only q35 additionally has legacy COM1 and a built-in keyboard. +case "$arch" in +x86_64) + if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 + exit 2 + fi + if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 + fi + machine_args=(-machine q35,accel=tcg -m 256M -bios "$ovmf") + ;; +aarch64) + # QEMU ships pflash-style EDK2 images next to its own installation. + share_dir=$(dirname "$(readlink -f "$(command -v "$qemu")")")/../share/qemu + aavmf_code=${AAVMF_CODE:-$share_dir/edk2-aarch64-code.fd} + aavmf_vars=${AAVMF_VARS:-$share_dir/edk2-arm-vars.fd} + for firmware in "$aavmf_code" "$aavmf_vars"; do + if [[ ! -r "$firmware" ]]; then + echo "error: aarch64 firmware is not readable: $firmware" >&2 + echo " set AAVMF_CODE and AAVMF_VARS" >&2 + exit 2 + fi + done + # The UEFI keyboard prompts need an input device; virt has none built in. + machine_args=(-machine virt,accel=tcg -cpu max -m 512M + -drive "if=pflash,format=raw,file=$aavmf_code,readonly=on" + -drive "if=pflash,format=raw,file=$run_dir/vars.fd" + -device qemu-xhci -device usb-kbd) + ;; +*) + echo "error: unsupported ARCH '$arch'; supported: x86_64, aarch64" >&2 + exit 2 + ;; +esac + # A fresh boot image and logs ensure the result cannot come from a previous run. rm -rf "$run_dir" mkdir -p "$run_dir" +if [[ "$arch" == aarch64 ]]; then + # Writable per-run variable store; the template may be read-only on disk. + cp "$aavmf_vars" "$run_dir/vars.fd" + chmod u+w "$run_dir/vars.fd" +fi # A real FAT image instead of QEMU's experimental fat:rw: directory makes the # log that the application persists on its boot volume readable on the host. truncate -s 64M "$disk" mkfs.vfat "$disk" >/dev/null mmd -i "$disk" ::/EFI ::/EFI/BOOT -mcopy -i "$disk" "$artifact" ::/EFI/BOOT/BOOTX64.EFI +mcopy -i "$disk" "$artifact" "::/EFI/BOOT/$boot_name" "$qemu" \ - -machine q35,accel=tcg \ - -m 256M \ - -bios "$ovmf" \ + "${machine_args[@]}" \ -drive "format=raw,file=$disk" \ -nic none \ -display none \ -monitor "unix:$monitor,server,nowait" \ - -serial "file:$com1_log" \ + -serial "file:$console_log" \ -chardev "file,id=pci_serial,path=$pci_log" \ -device pci-serial,chardev=pci_serial \ -no-reboot & @@ -87,7 +122,7 @@ dump_logs() { echo "--- $persisted_log ---" >&2 cat "$persisted_log" >&2 fi - for log in "$com1_log" "$pci_log"; do + for log in "$console_log" "$pci_log"; do if [[ -s "$log" ]]; then echo "--- $log ---" >&2 sed -n '1,200p' "$log" >&2 @@ -104,8 +139,8 @@ fail_run() { # The application persists every line before displaying it, so the extracted # log doubles as the progress signal. No key is sent before the first operator -# prompt: an Escape while OVMF still owns the keyboard would enter firmware -# setup instead of the boot target. +# prompt: an Escape while firmware still owns the keyboard would enter the +# firmware setup menu instead of the boot target. until extract_persisted_log | grep -qF 'then press Enter.'; do qemu_alive || fail_run "QEMU exited before the first operator prompt" if (($(date +%s) >= deadline)); then @@ -156,22 +191,36 @@ assert_log() { fi } -# The same requirements the guest previously verified itself: COM1 and the PCI -# UART are both discovered and every automatic check passed. -assert_log "$persisted_log" -F 'PIO 0x03f8' -assert_log "$persisted_log" -F 'RequiredCom1' -assert_log "$persisted_log" -E 'sources=\[.*Pci' -assert_log "$persisted_log" -F 'Final summary: 2/2 passed' -assert_log "$persisted_log" -F '2 interactive skip(s)' -# The transmit payloads prove that bytes really left through both UARTs. -assert_log "$com1_log" -F '[barebones] uart transmit test' -assert_log "$com1_log" -F '[uart_16550] uart transmit test' -assert_log "$pci_log" -F '[barebones] uart transmit test' -assert_log "$pci_log" -F '[uart_16550] uart transmit test' +# The per-architecture UART topology every run must fully discover and pass. +case "$arch" in +x86_64) + # Legacy COM1 plus the PCI UART, both driven through their captures. + assert_log "$persisted_log" -F 'PIO 0x03f8' + assert_log "$persisted_log" -F 'RequiredCom1' + assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F 'Final summary: 2/2 passed' + assert_log "$persisted_log" -F '2 interactive skip(s)' + assert_log "$console_log" -F '[barebones] uart transmit test' + assert_log "$console_log" -F '[uart_16550] uart transmit test' + assert_log "$pci_log" -F '[barebones] uart transmit test' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' + ;; +aarch64) + # The PL011 console must be rejected; the PCI UART is reached through the + # ACPI-described I/O window and driven via the MMIO backend. + assert_log "$persisted_log" -F 'SKIP: SPCR interface is not 16450/16550-compatible' + assert_log "$persisted_log" -F 'I/O window translation:' + assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F 'Final summary: 1/1 passed' + assert_log "$persisted_log" -F '1 interactive skip(s)' + assert_log "$pci_log" -F '[barebones] uart transmit test' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' + ;; +esac if ((failures > 0)); then dump_logs exit 1 fi -echo "PASS: headless TCG run drove COM1 and PCI UART checks to completion" +echo "PASS: headless $arch TCG run drove the UART checks to completion" From 7adbe81f8d175c7731370c0eeeec2c2ddd26a50f Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:29:21 +0200 Subject: [PATCH 19/20] real-hw-test: record the architecture in persisted logs One USB stick can carry runs from several machines; the architecture in the log file name and the on-screen banner keeps them apart. --- real-hw-test/README.md | 2 +- real-hw-test/src/logging.rs | 3 ++- real-hw-test/src/main.rs | 10 +++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/real-hw-test/README.md b/real-hw-test/README.md index 14fc372..617acfb 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -7,7 +7,7 @@ and loopback checks run first, followed by an interactive serial menu. All diagnostics use UEFI Simple Text Output and are intended to stay visible on the test machine's monitor. They are also persisted, line by line, on the boot -volume as `/uart_16550_test_logs/uart_16550_YYYY-MM-DD_HH-MM-SS.txt`. A log +volume as `/uart_16550_test_logs/uart_16550__YYYY-MM-DD_HH-MM-SS.txt`. A log creation, write, or flush failure is critical and aborts the test. Bytes written directly to a UART are deliberately short, recognizable test payloads. diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index 80c99e9..fd840c1 100644 --- a/real-hw-test/src/logging.rs +++ b/real-hw-test/src/logging.rs @@ -36,7 +36,8 @@ pub fn init() -> Result<(), &'static str> { let time = runtime::get_time().map_err(|_| "could not read UEFI time")?; let time = DateTime::try_from(time).map_err(|_| "UEFI time is invalid")?; let file_name = format!( - "uart_16550_{:04}-{:02}-{:02}_{:02}-{:02}-{:02}.txt", + "uart_16550_{}_{:04}-{:02}-{:02}_{:02}-{:02}-{:02}.txt", + crate::ARCH_NAME, time.year(), time.month(), time.day(), diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index bb4e2a9..fc59208 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -35,6 +35,14 @@ mod raw_uart; use uefi::prelude::*; +/// The target architecture, recorded in diagnostics and log file names. +#[cfg(target_arch = "aarch64")] +pub const ARCH_NAME: &str = "aarch64"; +#[cfg(target_arch = "x86_64")] +pub const ARCH_NAME: &str = "x86_64"; +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +compile_error!("unsupported architecture; supported: x86_64, aarch64"); + /// Starts the UEFI test and returns success while later commits add phases. #[entry] fn main() -> Status { @@ -43,7 +51,7 @@ fn main() -> Status { uefi_rs::println!("CRITICAL: cannot create test log: {error}"); return Status::DEVICE_ERROR; } - uefi::println!("uart_16550 real-hardware test"); + uefi::println!("uart_16550 real-hardware test ({ARCH_NAME})"); firmware::disable_watchdog(); if !firmware::disconnect_serial_controllers() { From 128bac27c1d87bbaa1619493eb63a3488d301ad9 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:29:21 +0200 Subject: [PATCH 20/20] real-hw-test: build and deploy every architecture at once 'make artifacts' cross-compiles all supported architectures, and the USB install copies every artifact under build/ to its removable-media path, so one stick boots the test on any supported machine. --- real-hw-test/Makefile | 9 +++++++-- real-hw-test/README.md | 13 +++++++++---- real-hw-test/scripts/install-usb.sh | 27 ++++++++++++++++++++------- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 3e92864..d11922d 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -1,5 +1,6 @@ CARGO ?= cargo ARCH ?= x86_64 +ARCHS := x86_64 aarch64 ifeq ($(ARCH),x86_64) TARGET := x86_64-unknown-uefi @@ -24,7 +25,7 @@ ARTIFACT := build/$(BOOT_NAME) BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.PHONY: all artifact check qemu qemu-tcg ci-qemu install clean +.PHONY: all artifact artifacts check qemu qemu-tcg ci-qemu install clean all: artifact @@ -35,6 +36,9 @@ $(ARTIFACT): FORCE mkdir -p $(dir $(ARTIFACT)) cp $(BINARY) $(ARTIFACT) +artifacts: + for arch in $(ARCHS); do $(MAKE) artifact ARCH=$$arch || exit 1; done + check: $(CARGO) fmt --check $(CLIPPY) --target x86_64-unknown-uefi --release -- -D warnings @@ -53,8 +57,9 @@ ci-qemu: artifact ARCH="$(ARCH)" QEMU="$(QEMU)" OVMF="$(OVMF)" \ ./scripts/run-qemu-ci.sh $(ARTIFACT) +# Deploys every built architecture; 'make artifacts' first covers them all. install: artifact - USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh $(ARTIFACT) + USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh clean: $(CARGO) clean --target-dir $(TARGET_DIR) diff --git a/real-hw-test/README.md b/real-hw-test/README.md index 617acfb..bf8c08d 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -13,8 +13,9 @@ written directly to a UART are deliberately short, recognizable test payloads. ## TL;DR -1. Run `make artifact`, then deploy `build/BOOTX64.EFI` with `make install` to - a mounted GPT/FAT32 EFI partition. +1. Run `make artifact` (or `make artifacts` for every architecture), then + deploy the built images with `make install` to a mounted GPT/FAT32 EFI + partition. 2. Boot with a monitor and USB keyboard. Leave the monitor connected: it is the authoritative diagnostic channel after firmware serial ownership is released. 3. Confirm the firmware baseline, configure the remote to 9600 8N1, and press @@ -111,7 +112,8 @@ file build/BOOTX64.EFI The resulting file is `build/BOOTX64.EFI`. `make artifact ARCH=aarch64` produces `build/BOOTAA64.EFI` instead; every `make` target accepts the same -`ARCH` variable. +`ARCH` variable. `make artifacts` cross-compiles every supported architecture +in one step. Run all static build checks with: @@ -133,7 +135,10 @@ lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,PTTYPE,MOUNTPOINTS make install USB_MOUNT=/run/media/$USER/EFI ``` -The file is copied to `EFI/BOOT/BOOTX64.EFI`. If the disk is not GPT, the +Every artifact present in `build/` is copied to its removable-media path, so +one stick can boot every architecture built beforehand (for example with +`make artifacts`): `EFI/BOOT/BOOTX64.EFI`, `EFI/BOOT/BOOTAA64.EFI`, and so on. +If the disk is not GPT, the filesystem is not FAT32, the path is not an exact mount point, or the mount is not writable, installation stops with a diagnostic. Unmount the partition cleanly before removing it. diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh index 34712f4..f045f83 100755 --- a/real-hw-test/scripts/install-usb.sh +++ b/real-hw-test/scripts/install-usb.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -artifact=${1:-build/BOOTX64.EFI} +artifacts=("$@") mount_input=${USB_MOUNT:-} fail() { @@ -13,9 +13,20 @@ for command in findmnt lsblk install readlink sync; do command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command" done +# Without explicit arguments, deploy every architecture that has been built. +if [[ ${#artifacts[@]} -eq 0 ]]; then + for artifact in build/BOOT*.EFI; do + [[ -e "$artifact" ]] && artifacts+=("$artifact") + done +fi + [[ -n "$mount_input" ]] || fail \ "USB_MOUNT is unset; use 'make install USB_MOUNT=/path/to/mounted/efi-partition'" -[[ -r "$artifact" ]] || fail "UEFI artifact is missing: $artifact (run 'make artifact')" +[[ ${#artifacts[@]} -gt 0 ]] || fail \ + "no UEFI artifacts in build/ (run 'make artifact' or 'make artifacts')" +for artifact in "${artifacts[@]}"; do + [[ -r "$artifact" ]] || fail "UEFI artifact is missing: $artifact (run 'make artifact')" +done mount_path=$(readlink -f -- "$mount_input") || fail "cannot resolve USB_MOUNT: $mount_input" [[ "$mount_path" != / ]] || fail "refusing to install into the root filesystem" @@ -57,13 +68,15 @@ fat_version=$(lsblk -dnro FSVER "$source_device") [[ "$fat_version" == FAT32 ]] || fail \ "$source_device reports '${fat_version:-an unknown FAT version}', expected FAT32" -# The artifact already carries its architecture's removable-media file name. -target=$mount_path/EFI/BOOT/$(basename "$artifact") echo "Installing to validated media:" echo " disk: $parent_device (GPT)" echo " partition: $source_device (FAT32)" echo " mount: $mount_path" -echo " destination: $target" -install -D -m 0644 -- "$artifact" "$target" -sync "$target" +# Each artifact already carries its architecture's removable-media file name. +for artifact in "${artifacts[@]}"; do + target=$mount_path/EFI/BOOT/$(basename "$artifact") + echo " destination: $target" + install -D -m 0644 -- "$artifact" "$target" + sync "$target" +done echo "Installation complete. Unmount the media cleanly before removing it."