From 5d7d578e72685dc686e70fd7996631ba53329b40 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:32:38 +0200 Subject: [PATCH 01/22] real-hw-test: add UEFI application scaffold Please note that the whole "real-hw-test" commit series was mainly created by Codex and Claude, but with significant handholding and various iterations by me. The build is parameterized by ARCH from the start: the x86_64 and aarch64 UEFI targets with their removable-media file names, 'make artifacts' to cross-compile all of them in one step, and the architecture in the on-screen banner. The uefi crate is bound as uefi_rs and re-exported through a crate-local uefi module, the one place where single items such as println! can be overridden for the whole test. --- real-hw-test/.gitignore | 2 + real-hw-test/Cargo.lock | 160 +++++++++++++++++++++++++++++++++++++++ real-hw-test/Cargo.toml | 9 +++ real-hw-test/Makefile | 45 +++++++++++ real-hw-test/src/main.rs | 36 +++++++++ 5 files changed, 252 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..2ddace2 --- /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.1" +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.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2422138a1c4483f5d04081281ec841fb17f88cf1e2263b3b860fd455ab3a8d52" +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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b86018f5a44661f30db808298ccfcbe326279239c4d17a347c5476065ff72c0" +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..c9a4e79 --- /dev/null +++ b/real-hw-test/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "uart-16550-real-hw-test" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +uart_16550 = { path = ".." } +uefi = { version = "0.40.0", features = ["global_allocator", "panic_handler"] } diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile new file mode 100644 index 0000000..0655057 --- /dev/null +++ b/real-hw-test/Makefile @@ -0,0 +1,45 @@ +CARGO ?= cargo +ARCH ?= x86_64 +ARCHS := x86_64 aarch64 + +ifeq ($(ARCH),x86_64) +TARGET := x86_64-unknown-uefi +BOOT_NAME := BOOTX64.EFI +else ifeq ($(ARCH),aarch64) +TARGET := aarch64-unknown-uefi +BOOT_NAME := BOOTAA64.EFI +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/$(BOOT_NAME) + +.PHONY: all artifact artifacts check clean + +all: artifact + +artifact: $(ARTIFACT) + +$(ARTIFACT): FORCE + CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --target $(TARGET) --release + mkdir -p $(dir $(ARTIFACT)) + cp $(BINARY) $(ARTIFACT) + +artifacts: + for arch in $(ARCHS); do $(MAKE) artifact ARCH=$$arch || exit 1; done + +check: + $(CARGO) fmt --check + CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --target $(TARGET) --release -- -D warnings + $(MAKE) artifact + +clean: + $(CARGO) clean --target-dir $(TARGET_DIR) + rm -rf build + +.PHONY: FORCE +FORCE: + diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs new file mode 100644 index 0000000..b0885dc --- /dev/null +++ b/real-hw-test/src/main.rs @@ -0,0 +1,36 @@ +#![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, and public driver +//! APIs so the screen identifies the failing layer. + +extern crate uefi as uefi_rs; + +/// The `uefi` crate is bound as `uefi_rs` so that this crate-local `uefi` +/// module can take its name: every file reaches the library through +/// `crate::uefi`, which re-exports the crate and can override single items, +/// such as `println!`, for the whole test without touching call sites. +mod uefi { + pub use uefi_rs::*; +} + +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 { + uefi::helpers::init().expect("UEFI helpers should initialize"); + uefi::println!("uart_16550 real-hardware test ({ARCH_NAME})"); + Status::SUCCESS +} From d919346984f1ddeed1f56e9bedaa1ee35d5d9628 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 02/22] real-hw-test: add QEMU development environment Booting the image under QEMU with OVMF gives a fast iteration loop that needs no physical machine or USB stick. COM1 is wired to the launching terminal, and an additional PCI serial device exposes an independently discovered UART through a PTY. QEMU follows ARCH: q35 with OVMF on x86_64, virt with pflash EDK2, ramfb, and a USB keyboard on aarch64, where TCG is the default because the development host is typically x86_64. The machine and firmware selection lives in scripts/qemu-machine.sh so that other QEMU front ends can share it. --- real-hw-test/Makefile | 22 +++++++++-- real-hw-test/scripts/qemu-machine.sh | 55 ++++++++++++++++++++++++++++ real-hw-test/scripts/run-qemu.sh | 47 ++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 real-hw-test/scripts/qemu-machine.sh create mode 100755 real-hw-test/scripts/run-qemu.sh diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 0655057..f1f4c2b 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -5,9 +5,15 @@ ARCHS := x86_64 aarch64 ifeq ($(ARCH),x86_64) TARGET := x86_64-unknown-uefi BOOT_NAME := BOOTX64.EFI +QEMU ?= qemu-system-x86_64 +QEMU_ACCEL ?= kvm 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 @@ -16,15 +22,17 @@ TARGET_DIR := ../target/real-hw-test PROFILE := release BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi ARTIFACT := build/$(BOOT_NAME) +BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build +CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy -.PHONY: all artifact artifacts check clean +.PHONY: all artifact artifacts check qemu qemu-tcg clean all: artifact artifact: $(ARTIFACT) $(ARTIFACT): FORCE - CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --target $(TARGET) --release + $(BUILD) --target $(TARGET) --release mkdir -p $(dir $(ARTIFACT)) cp $(BINARY) $(ARTIFACT) @@ -33,13 +41,19 @@ artifacts: check: $(CARGO) fmt --check - CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --target $(TARGET) --release -- -D warnings + $(CLIPPY) --target $(TARGET) --release -- -D warnings $(MAKE) artifact +qemu: artifact + ARCH="$(ARCH)" 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 .PHONY: FORCE FORCE: - diff --git a/real-hw-test/scripts/qemu-machine.sh b/real-hw-test/scripts/qemu-machine.sh new file mode 100644 index 0000000..380e63c --- /dev/null +++ b/real-hw-test/scripts/qemu-machine.sh @@ -0,0 +1,55 @@ +# Per-architecture QEMU machine and firmware selection, shared by run-qemu.sh +# and run-qemu-ci.sh. This file is sourced, not executed. +# +# qemu_machine_setup ARCH QEMU ACCEL VARS_FILE +# +# Validates the firmware for ARCH and fills the machine_args array with the +# machine type, memory, firmware, and input devices. On aarch64 it also places +# a writable copy of the EDK2 variable store at VARS_FILE, whose directory +# must exist. Firmware comes from OVMF, AAVMF_CODE, and AAVMF_VARS; the images +# QEMU ships are the aarch64 default. +qemu_machine_setup() { + local arch=$1 qemu=$2 accel=$3 vars_file=$4 + case "$arch" in + x86_64) + local ovmf=${OVMF:-} + if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ + >&2 + return 2 + fi + if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + return 2 + fi + machine_args=(-machine "q35,accel=$accel" -m 256M -bios "$ovmf") + ;; + aarch64) + # QEMU ships pflash-style EDK2 images next to its own installation. + local share_dir + share_dir=$(dirname "$(readlink -f "$(command -v "$qemu")")")/../share/qemu + local aavmf_code=${AAVMF_CODE:-$share_dir/edk2-aarch64-code.fd} + local aavmf_vars=${AAVMF_VARS:-$share_dir/edk2-arm-vars.fd} + local firmware + 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 + return 2 + fi + done + # Writable per-run variable store; the template may be read-only on disk. + cp "$aavmf_vars" "$vars_file" + chmod u+w "$vars_file" + # virt has no built-in keyboard; the UEFI prompts need one. + 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=$vars_file" + -device qemu-xhci -device usb-kbd) + ;; + *) + echo "error: unsupported ARCH '$arch'; supported: x86_64, aarch64" >&2 + return 2 + ;; + esac +} diff --git a/real-hw-test/scripts/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh new file mode 100755 index 0000000..138edc8 --- /dev/null +++ b/real-hw-test/scripts/run-qemu.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} +accel=${QEMU_ACCEL:-kvm} +esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp-$arch} +artifact=${ARTIFACT:-} + +if ! command -v "$qemu" >/dev/null 2>&1; then + echo "error: QEMU executable not found: $qemu" >&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" + +# shellcheck source=scripts/qemu-machine.sh +. "$(dirname "$0")/qemu-machine.sh" +qemu_machine_setup "$arch" "$qemu" "$accel" "$esp_dir-vars.fd" +if [[ "$arch" == aarch64 ]]; then + # The operator watches the UEFI monitor; virt has no built-in display. + machine_args+=(-device ramfb) + artifact=${artifact:-build/BOOTAA64.EFI} +else + artifact=${artifact:-build/BOOTX64.EFI} +fi + +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi +cp "$artifact" "$esp_dir/EFI/BOOT/$(basename "$artifact")" + +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_args[@]}" \ + -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 af20280a4f20fbc54027ea62eb3110ae8380c0fc Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 03/22] nix: add flake development environment The dev shell supplies QEMU, OVMF, and rustup. The .envrc enables direnv integration. The full QEMU package is used because qemu_kvm carries only the host architecture's system emulator; the aarch64 firmware paths are exported next to OVMF. --- real-hw-test/.envrc | 1 + real-hw-test/flake.lock | 27 +++++++++++++++++++++++++ real-hw-test/flake.nix | 44 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 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..b12b081 --- /dev/null +++ b/real-hw-test/flake.nix @@ -0,0 +1,44 @@ +{ + 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; [ + dosfstools + fzf + mtools + # 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"; + AAVMF_CODE = "${pkgs.qemu}/share/qemu/edk2-aarch64-code.fd"; + AAVMF_VARS = "${pkgs.qemu}/share/qemu/edk2-arm-vars.fd"; + }; + }; + } + ); + + formatter = forAllSystems ( + system: nixpkgs.legacyPackages.${system}.nixfmt-tree + ); + }; +} From e0f9a0575e3ab2ebcb51e5643c0319792e775391 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 04/22] real-hw-test: add UART inventory and firmware serial handoff Candidates from every discovery path land in one inventory deduplicated by address, so a UART described by several sources is still tested only once. Firmware serial controllers are disconnected first because the firmware and the driver under test must never program a UART concurrently; the firmware baseline is recorded on screen beforehand. COM1 at 0x3f8 is registered unconditionally: the targeted machines are required to expose it, so its absence must surface as a test failure rather than as silent non-discovery. Port I/O exists only on x86, so the port address form and the COM1 source are cfg-gated from the start. Every candidate records where it lives and which discovery paths found it, printed as 'location:' and 'found by:' lines, so an operator can tell a built-in UART from an add-in card and see when several paths describe one device. --- real-hw-test/Cargo.toml | 2 +- real-hw-test/src/device.rs | 129 ++++++++++++++++++++++++++++++++++ real-hw-test/src/discovery.rs | 19 +++++ real-hw-test/src/firmware.rs | 94 +++++++++++++++++++++++++ real-hw-test/src/main.rs | 24 +++++++ 5 files changed, 267 insertions(+), 1 deletion(-) 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/firmware.rs diff --git a/real-hw-test/Cargo.toml b/real-hw-test/Cargo.toml index c9a4e79..f0fb522 100644 --- a/real-hw-test/Cargo.toml +++ b/real-hw-test/Cargo.toml @@ -6,4 +6,4 @@ publish = false [dependencies] uart_16550 = { path = ".." } -uefi = { version = "0.40.0", features = ["global_allocator", "panic_handler"] } +uefi = { version = "0.40.0", features = ["alloc", "global_allocator", "panic_handler"] } diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs new file mode 100644 index 0000000..f415783 --- /dev/null +++ b/real-hw-test/src/device.rs @@ -0,0 +1,129 @@ +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 port I/O. +pub enum Address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Port(u16), +} + +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}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// How discovery learned about a candidate; several paths can find one UART. +pub enum Discovery { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + RequiredCom1, +} + +impl Display for Discovery { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::RequiredCom1 => "required COM1", + }) + } +} + +/// Formats every discovery path of one candidate as a comma-separated list. +pub struct Discoveries<'a>(&'a [Discovery]); + +impl Display for Discoveries<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + for (index, discovery) in self.0.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + discovery.fmt(f)?; + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Where a candidate physically lives; exactly one applies per UART. +pub enum Location { + /// A conventional I/O port: the Super I/O or LPC UART on the board. + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + LegacyPort, +} + +impl Display for Location { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::LegacyPort => f.write_str("built-in legacy port"), + } + } +} + +#[derive(Debug)] +/// A deduplicated UART address, clock, location, and every path that found it. +pub struct Candidate { + pub address: Address, + pub clock_hz: u32, + pub location: Location, + pub discoveries: Vec, +} + +impl Candidate { + /// Lists the discovery paths for on-screen diagnostics. + pub fn found_by(&self) -> Discoveries<'_> { + Discoveries(&self.discoveries) + } +} + +#[derive(Debug, Default)] +/// The candidate list shared by the driver and interactive test phases. +pub struct Inventory { + candidates: Vec, +} + +impl Inventory { + /// Adds a discovery to an address, merging descriptions so one physical UART + /// is tested exactly once. + pub fn add( + &mut self, + address: Address, + clock_hz: Option, + discovery: Discovery, + location: Location, + ) { + if let Some(candidate) = self + .candidates + .iter_mut() + .find(|candidate| candidate.address == address) + { + if !candidate.discoveries.contains(&discovery) { + candidate.discoveries.push(discovery); + } + 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), + location, + discoveries: alloc::vec![discovery], + }); + } + + /// 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..e1efd77 --- /dev/null +++ b/real-hw-test/src/discovery.rs @@ -0,0 +1,19 @@ +//! UART discovery into one deduplicated candidate inventory. +//! +//! Later commits add discovery paths; the inventory merges their findings so +//! one physical UART is tested exactly once. + +use crate::device::{Address, Discovery, Inventory, Location}; + +/// Combines every discovery source into a deduplicated test inventory. +pub fn discover() -> Inventory { + let mut inventory = Inventory::default(); + // COM1 is required wiring on the targeted machines, so it is always tested. + inventory.add( + Address::Port(0x3f8), + None, + Discovery::RequiredCom1, + Location::LegacyPort, + ); + inventory +} diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs new file mode 100644 index 0000000..b999c16 --- /dev/null +++ b/real-hw-test/src/firmware.rs @@ -0,0 +1,94 @@ +//! 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 use the 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}; + +use crate::uefi; + +/// 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 b0885dc..3b6684c 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -7,6 +7,7 @@ //! The phases isolate firmware ownership, hardware discovery, and public driver //! APIs so the screen identifies the failing layer. +extern crate alloc; extern crate uefi as uefi_rs; /// The `uefi` crate is bound as `uefi_rs` so that this crate-local `uefi` @@ -17,6 +18,10 @@ mod uefi { pub use uefi_rs::*; } +mod device; +mod discovery; +mod firmware; + use uefi::prelude::*; /// The target architecture, recorded in diagnostics and log file names. @@ -32,5 +37,24 @@ compile_error!("unsupported architecture; supported: x86_64, aarch64"); fn main() -> Status { uefi::helpers::init().expect("UEFI helpers should initialize"); uefi::println!("uart_16550 real-hardware test ({ARCH_NAME})"); + + 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", + candidate.address, + candidate.clock_hz + ); + uefi::println!(" location: {}", candidate.location); + uefi::println!(" found by: {}", candidate.found_by()); + } + uefi::println!("\nDiscovery complete. Press Enter to return to firmware."); + firmware::wait_for_enter(); Status::SUCCESS } From d60ac7530a31e49753110db18ee558b1182dc195 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 05/22] real-hw-test: probe legacy COM ports The conventional COM2-COM4 addresses are only accepted when the crate's own presence check, Uart16550::check_present(), answers, because reading an absent port yields junk. It is the same scratch-register test that init() runs first, so discovery and the later driver tests agree on what counts as a device. COM1 stays registered unconditionally. The legacy probe is x86-only and cfg-gated accordingly. --- real-hw-test/src/device.rs | 4 +++ real-hw-test/src/discovery.rs | 54 ++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index f415783..b17bcb6 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -25,6 +25,8 @@ impl Display for Address { pub enum Discovery { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] RequiredCom1, + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + LegacyProbe, } impl Display for Discovery { @@ -32,6 +34,8 @@ impl Display for Discovery { f.write_str(match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::RequiredCom1 => "required COM1", + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::LegacyProbe => "presence check at a conventional port", }) } } diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index e1efd77..77a2de3 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -1,19 +1,51 @@ -//! UART discovery into one deduplicated candidate inventory. +//! UART discovery through legacy probing. //! -//! Later commits add discovery paths; the inventory merges their findings so -//! one physical UART is tested exactly once. +//! The inventory merges every discovery path so one physical UART is tested +//! exactly once. -use crate::device::{Address, Discovery, Inventory, Location}; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use uart_16550::Uart16550; + +use crate::device::Inventory; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use crate::device::{Address, Discovery, Location}; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use crate::uefi; /// Combines every discovery source into a deduplicated test inventory. pub fn discover() -> Inventory { let mut inventory = Inventory::default(); - // COM1 is required wiring on the targeted machines, so it is always tested. - inventory.add( - Address::Port(0x3f8), - None, - Discovery::RequiredCom1, - Location::LegacyPort, - ); + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + discover_legacy(&mut inventory); 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]; + + uefi::println!("\nLegacy UART probes:"); + for (index, port) in PORTS.into_iter().enumerate() { + let address = Address::Port(port); + let present = uart_present(port); + uefi::println!( + " {address}: presence check {}", + if present { "PASS" } else { "FAIL" } + ); + + if index == 0 { + inventory.add(address, None, Discovery::RequiredCom1, Location::LegacyPort); + } else if present { + inventory.add(address, None, Discovery::LegacyProbe, Location::LegacyPort); + } + } +} + +/// Reading an absent port yields junk, so only a responding scratch register +/// qualifies an address; the crate's check is the one `init()` runs first. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +fn uart_present(port: u16) -> bool { + // SAFETY: firmware serial consumers were disconnected before discovery. + unsafe { Uart16550::new_port(port) }.is_ok_and(|mut uart| uart.check_present()) +} From b5c8489258a5f68715b6b4cca1f4cb7226ae3405 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 06/22] real-hw-test: discover UARTs described by ACPI SPCR The Serial Port Console Redirection table is how firmware names its console UART on machines without ISA-conventional COM ports, which is the norm on headless servers and on non-x86 platforms. It also carries the address space, access width, and clock, so the port can be driven without guessing; MMIO-mapped register blocks become testable here. Only 16450/16550-compatible interface types with byte-wide access are accepted; everything else (for example a PL011) is reported and skipped rather than programmed blindly. Without x86 port instructions, a System I/O SPCR is reported and skipped. Only ACPI 2.0 tables are consulted (XSDT); an RSDT fallback would serve no UEFI machine. The table's PCI identity fields (revision 2 and later) are read so that a console that is a PCI function is classified as one; PCI evidence wins when several paths describe the same address. --- real-hw-test/src/device.rs | 47 ++++- real-hw-test/src/discovery.rs | 7 +- real-hw-test/src/discovery/acpi.rs | 324 +++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 real-hw-test/src/discovery/acpi.rs diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index b17bcb6..e5cb379 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -4,10 +4,14 @@ 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 port I/O. +/// 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, + }, } impl Display for Address { @@ -16,6 +20,9 @@ impl Display for Address { 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}") + } } } } @@ -27,6 +34,7 @@ pub enum Discovery { RequiredCom1, #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, + AcpiSpcr, } impl Display for Discovery { @@ -36,6 +44,7 @@ impl Display for Discovery { Self::RequiredCom1 => "required COM1", #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::LegacyProbe => "presence check at a conventional port", + Self::AcpiSpcr => "ACPI SPCR", }) } } @@ -55,12 +64,45 @@ impl Display for Discoveries<'_> { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// A PCI function's identity from configuration space or the SPCR table. +pub struct PciFunction { + pub segment: u32, + pub bus: u8, + pub device: u8, + pub function: u8, + pub vendor_id: u16, + pub device_id: u16, +} + +impl Display for PciFunction { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "PCI {:04x}:{:02x}:{:02x}.{} {:04x}:{:04x}", + self.segment, self.bus, self.device, self.function, self.vendor_id, self.device_id + ) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Where a candidate physically lives; exactly one applies per UART. pub enum Location { /// A conventional I/O port: the Super I/O or LPC UART on the board. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyPort, + /// A memory-mapped UART that firmware describes without a PCI identity. + Platform, + Pci(PciFunction), +} + +impl Location { + /// Prefers PCI evidence: SPCR can identify a console as a PCI function. + fn merge(&mut self, incoming: Location) { + if matches!(incoming, Self::Pci(_)) && !matches!(self, Self::Pci(_)) { + *self = incoming; + } + } } impl Display for Location { @@ -68,6 +110,8 @@ impl Display for Location { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::LegacyPort => f.write_str("built-in legacy port"), + Self::Platform => f.write_str("built-in platform UART"), + Self::Pci(function) => function.fmt(f), } } } @@ -112,6 +156,7 @@ impl Inventory { if !candidate.discoveries.contains(&discovery) { candidate.discoveries.push(discovery); } + candidate.location.merge(location); if let Some(clock_hz) = clock_hz.filter(|clock| *clock != 0) { candidate.clock_hz = clock_hz; } diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index 77a2de3..609ac6f 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -1,7 +1,7 @@ -//! UART discovery through legacy probing. +//! UART discovery through legacy probing and ACPI SPCR. //! //! The inventory merges every discovery path so one physical UART is tested -//! exactly once. +//! exactly once, however firmware describes it. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use uart_16550::Uart16550; @@ -12,11 +12,14 @@ use crate::device::{Address, Discovery, Location}; #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use crate::uefi; +mod acpi; + /// 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); inventory } diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs new file mode 100644 index 0000000..82e318c --- /dev/null +++ b/real-hw-test/src/discovery/acpi.rs @@ -0,0 +1,324 @@ +//! Conservative ACPI SPCR discovery for firmware-described serial consoles. +//! +//! In practice SPCR matters on three kinds of machines: x86 servers with +//! console redirection enabled, where it names the COM port firmware uses +//! (usually the port the legacy probe finds anyway); Arm servers, where it is +//! the only standardized description of the console but almost always names +//! a PL011 or SBSA UART that is rejected here; and SoCs whose firmware +//! describes a byte-access 16550, for which it is the only discovery path. +//! Client x86 machines rarely publish the table at all. Strict validation +//! prevents treating an incompatible layout as a 16550 device; only ACPI 2.0 +//! tables (XSDT) are read, which every UEFI machine provides. + +use core::slice; + +use uefi::system; +use uefi::table::cfg::ConfigTableEntry; + +use crate::device::{Address, Discovery, Inventory, Location, PciFunction}; +use crate::uefi; + +const SDT_HEADER_LEN: usize = 36; +const MAX_TABLE_LEN: usize = 1024 * 1024; +/// Every ACPI table starts with the same header: signature, length, revision. +const SDT_SIGNATURE_LEN: usize = 4; +const SDT_LENGTH_OFFSET: usize = 4; +const SDT_REVISION_OFFSET: usize = 8; + +// RSDP layout: the ACPI 1.0 part is 20 bytes; ACPI 2.0 appends the length, +// the XSDT address, and an extended checksum. +const RSDP_SIGNATURE: &[u8; 8] = b"RSD PTR "; +const RSDP_REVISION_OFFSET: usize = 15; +const RSDP_REVISION_ACPI2: u8 = 2; +const RSDP_V1_LEN: usize = 20; +const RSDP_LENGTH_OFFSET: usize = 20; +const RSDP_XSDT_ADDRESS_OFFSET: usize = 24; +const RSDP_V2_MIN_LEN: usize = 36; +const RSDP_MAX_LEN: usize = 4096; + +// SPCR layout: the interface type, the console's Generic Address Structure, +// the PCI identity that revision 2 added, and the clock that revision 3 added. +const SPCR_MIN_LEN: usize = 80; +const SPCR_INTERFACE_OFFSET: usize = 36; +const SPCR_GAS_OFFSET: usize = 40; +const SPCR_PCI_DEVICE_ID_OFFSET: usize = 64; +const SPCR_PCI_VENDOR_ID_OFFSET: usize = 66; +const SPCR_PCI_BUS_OFFSET: usize = 68; +const SPCR_PCI_DEVICE_OFFSET: usize = 69; +const SPCR_PCI_FUNCTION_OFFSET: usize = 70; +const SPCR_PCI_SEGMENT_OFFSET: usize = 75; +const SPCR_CLOCK_OFFSET: usize = 76; +const SPCR_REVISION_WITH_PCI_IDENTITY: u8 = 2; +const SPCR_REVISION_WITH_CLOCK: u8 = 3; +/// Vendor or device ID value marking a console that is not a PCI function. +const PCI_ID_NONE: u16 = 0xffff; + +// Generic Address Structure fields, relative to the structure's start. +const GAS_ADDRESS_SPACE: usize = 0; +const GAS_BIT_WIDTH: usize = 1; +const GAS_BIT_OFFSET: usize = 2; +const GAS_ACCESS_SIZE: usize = 3; +const GAS_ADDRESS: usize = 4; + +/// Locates SPCR from UEFI configuration tables and safely skips invalid data. +pub fn discover(inventory: &mut Inventory) { + uefi::println!("\nACPI SPCR discovery:"); + 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}"), + } +} + +/// Returns the ACPI 2.0+ RSDP address from the UEFI configuration table. +fn rsdp() -> Option { + system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI2_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 { + interface: u8, + address_space: u8, + bit_width: u8, + bit_offset: u8, + access_size: u8, + base: u64, + clock_hz: Option, + pci: Option, +} + +/// The PCI identity SPCR carries when the console UART is a PCI function. +#[derive(Clone, Copy)] +struct SpcrPci { + segment: u8, + bus: u8, + device: u8, + function: u8, + vendor_id: u16, + device_id: u16, +} + +/// Accepts only SPCR layouts that the byte-oriented driver can safely access. +fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { + uefi::println!( + " 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 let Some(pci) = spcr.pci { + uefi::println!( + " PCI identity: {:04x}:{:02x}:{:02x}.{} {:04x}:{:04x}", + pci.segment, + pci.bus, + pci.device, + pci.function, + pci.vendor_id, + pci.device_id + ); + } + + 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, + }, + 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), + #[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; + } + }; + + let location = match spcr.pci { + Some(pci) => Location::Pci(PciFunction { + segment: u32::from(pci.segment), + bus: pci.bus, + device: pci.device, + function: pci.function, + vendor_id: pci.vendor_id, + device_id: pci.device_id, + }), + None => match address { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Address::Port(_) => Location::LegacyPort, + Address::Mmio { .. } => Location::Platform, + }, + }; + uefi::println!(" candidate: {address} ({location})"); + inventory.add(address, spcr.clock_hz, Discovery::AcpiSpcr, location); +} + +/// 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() < SPCR_MIN_LEN { + return Err("SPCR is too short"); + } + // Revision 3 added the clock field; older tables keep reserved bytes there. + let clock = if table[SDT_REVISION_OFFSET] >= SPCR_REVISION_WITH_CLOCK { + read_u32(table, SPCR_CLOCK_OFFSET) + } else { + 0 + }; + let device_id = read_u16(table, SPCR_PCI_DEVICE_ID_OFFSET); + let vendor_id = read_u16(table, SPCR_PCI_VENDOR_ID_OFFSET); + // The PCI identity is only trusted on revision 2 and newer tables. + let pci = (table[SDT_REVISION_OFFSET] >= SPCR_REVISION_WITH_PCI_IDENTITY + && device_id != PCI_ID_NONE + && vendor_id != PCI_ID_NONE) + .then(|| SpcrPci { + segment: table[SPCR_PCI_SEGMENT_OFFSET], + bus: table[SPCR_PCI_BUS_OFFSET], + device: table[SPCR_PCI_DEVICE_OFFSET], + function: table[SPCR_PCI_FUNCTION_OFFSET], + vendor_id, + device_id, + }); + Ok(Some(SpcrInfo { + interface: table[SPCR_INTERFACE_OFFSET], + address_space: table[SPCR_GAS_OFFSET + GAS_ADDRESS_SPACE], + bit_width: table[SPCR_GAS_OFFSET + GAS_BIT_WIDTH], + bit_offset: table[SPCR_GAS_OFFSET + GAS_BIT_OFFSET], + access_size: table[SPCR_GAS_OFFSET + GAS_ACCESS_SIZE], + base: read_u64(table, SPCR_GAS_OFFSET + GAS_ADDRESS), + clock_hz: (clock != 0).then_some(clock), + pci, + })) +} + +/// 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, RSDP_V2_MIN_LEN)?; + if &rsdp[..RSDP_SIGNATURE.len()] != RSDP_SIGNATURE || !checksum_ok(&rsdp[..RSDP_V1_LEN]) { + return Err("bad RSDP signature or checksum"); + } + + // ACPI 2.0 and newer only: every UEFI machine provides an XSDT. + if rsdp[RSDP_REVISION_OFFSET] < RSDP_REVISION_ACPI2 { + return Err("ACPI 1.0 RSDP without an XSDT"); + } + let length = read_u32(rsdp, RSDP_LENGTH_OFFSET) as usize; + if !(RSDP_V2_MIN_LEN..=RSDP_MAX_LEN).contains(&length) { + return Err("invalid RSDP length"); + } + let full = acpi_bytes(rsdp_address, length)?; + if !checksum_ok(full) { + return Err("bad extended RSDP checksum"); + } + + let root = sdt(read_u64(full, RSDP_XSDT_ADDRESS_OFFSET) as usize)?; + if &root[..SDT_SIGNATURE_LEN] != b"XSDT" { + return Err("root table has the wrong signature"); + } + + for entry in root[SDT_HEADER_LEN..].as_chunks::<8>().0 { + let address = u64::from_le_bytes(*entry) as usize; + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + if &header[..SDT_SIGNATURE_LEN] != signature { + continue; + } + return sdt(address).map(Some); + } + 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, SDT_LENGTH_OFFSET) 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 16-bit ACPI field without raw offsets. +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + let value = bytes[offset..offset + 2] + .try_into() + .expect("caller validated ACPI field bounds"); + u16::from_le_bytes(value) +} + +/// 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) +} From 631eb0a78a3033a8dbdf5a2a34ee633fb9c1b3c4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 07/22] real-hw-test: discover PCI serial-class UARTs Serial add-in cards and paravirtual devices such as QEMU pci-serial live behind BARs, so neither fixed-address probing nor SPCR sees them. Enumerate serial-class endpoints through the UEFI PCI root bridges and accept only an unambiguous 16550-compatible programming interface with a usable BAR0; vendor-specific layouts are reported but not touched. Firmware leaves the decoding of endpoints it never binds disabled; an assigned BAR of an unambiguous UART is therefore enabled explicitly. Without x86 port instructions an I/O BAR is translated through the ACPI-described PCI I/O window, which makes this the first commit that builds for aarch64, so 'make check' lints both targets from here on. Each candidate records its PCI identity: vendor and device ID, and whether the function sits on the root bridge's own bus (typically integrated) or behind a bridge (typically an add-in card); known QEMU serial devices are named. The window itself comes from ACPI: the root bridge protocol reports the I/O aperture without its CPU-side translation (QEMU virt: 0x0-0xfff, translation 0), but the DSDT's resource templates embed the translated window as fixed-format descriptors, which a strict byte scan finds without interpreting AML. --- real-hw-test/Makefile | 3 +- real-hw-test/src/device.rs | 49 ++++- real-hw-test/src/discovery.rs | 10 +- real-hw-test/src/discovery/acpi.rs | 130 +++++++++++++ real-hw-test/src/discovery/pci.rs | 297 +++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 real-hw-test/src/discovery/pci.rs diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index f1f4c2b..f59f8c2 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -41,7 +41,8 @@ artifacts: 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 $(MAKE) artifact qemu: artifact diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index e5cb379..f5d3169 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -35,6 +35,7 @@ pub enum Discovery { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, AcpiSpcr, + PciEnumeration, } impl Display for Discovery { @@ -45,6 +46,7 @@ impl Display for Discovery { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::LegacyProbe => "presence check at a conventional port", Self::AcpiSpcr => "ACPI SPCR", + Self::PciEnumeration => "PCI enumeration", }) } } @@ -64,6 +66,16 @@ impl Display for Discoveries<'_> { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Where a PCI function sits relative to its root bridge. +pub enum Attachment { + /// Directly on the root bridge's bus: typically a controller integrated on + /// the board. + OnRootBus, + /// Behind a root port or PCI-to-PCI bridge: typically an add-in card. + BehindBridge, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// A PCI function's identity from configuration space or the SPCR table. pub struct PciFunction { @@ -73,6 +85,20 @@ pub struct PciFunction { pub function: u8, pub vendor_id: u16, pub device_id: u16, + /// Unknown while only SPCR has described the function. + pub attachment: Option, +} + +impl PciFunction { + /// Names devices whose IDs identify them beyond doubt. + fn known_name(&self) -> Option<&'static str> { + match (self.vendor_id, self.device_id) { + (0x1b36, 0x0002) => Some("QEMU pci-serial"), + (0x1b36, 0x0003) => Some("QEMU pci-serial-2x"), + (0x1b36, 0x0004) => Some("QEMU pci-serial-4x"), + _ => None, + } + } } impl Display for PciFunction { @@ -81,7 +107,15 @@ impl Display for PciFunction { f, "PCI {:04x}:{:02x}:{:02x}.{} {:04x}:{:04x}", self.segment, self.bus, self.device, self.function, self.vendor_id, self.device_id - ) + )?; + if let Some(name) = self.known_name() { + write!(f, " ({name})")?; + } + match self.attachment { + Some(Attachment::OnRootBus) => f.write_str(", on the root bus"), + Some(Attachment::BehindBridge) => f.write_str(", behind a bridge"), + None => Ok(()), + } } } @@ -97,10 +131,17 @@ pub enum Location { } impl Location { - /// Prefers PCI evidence: SPCR can identify a console as a PCI function. + /// Prefers PCI evidence: SPCR and enumeration can describe one function, + /// and only enumeration knows how it is attached. fn merge(&mut self, incoming: Location) { - if matches!(incoming, Self::Pci(_)) && !matches!(self, Self::Pci(_)) { - *self = incoming; + match (self, incoming) { + (Self::Pci(current), Self::Pci(incoming)) => { + if current.attachment.is_none() { + current.attachment = incoming.attachment; + } + } + (current, Self::Pci(_)) => *current = incoming, + _ => {} } } } diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index 609ac6f..4d91ada 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -1,7 +1,9 @@ -//! UART discovery through legacy probing and ACPI SPCR. +//! UART discovery through legacy probing, ACPI SPCR, and PCI enumeration. //! -//! The inventory merges every discovery path so one physical UART is tested -//! exactly once, however firmware describes it. +//! Multiple discovery paths cover fixed COM ports as well as UARTs that +//! firmware tables or PCI configuration space describe. Under QEMU the +//! `pci-serial` device is such a UART: a 16550 behind a PCI BAR, separate from +//! the chipset's COM ports, which is what exercises the PCI path there. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use uart_16550::Uart16550; @@ -13,6 +15,7 @@ use crate::device::{Address, Discovery, Location}; use crate::uefi; mod acpi; +mod pci; /// Combines every discovery source into a deduplicated test inventory. pub fn discover() -> Inventory { @@ -20,6 +23,7 @@ pub fn discover() -> Inventory { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] discover_legacy(&mut inventory); acpi::discover(&mut inventory); + pci::discover(&mut inventory); inventory } diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index 82e318c..49120f9 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -9,6 +9,9 @@ //! Client x86 machines rarely publish the table at all. Strict validation //! prevents treating an incompatible layout as a 16550 device; only ACPI 2.0 //! tables (XSDT) are read, which every UEFI machine provides. +//! +//! Without port I/O instructions the module also recovers the PCI I/O window +//! from the DSDT, see `pci_io_window`. use core::slice; @@ -182,6 +185,7 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { function: pci.function, vendor_id: pci.vendor_id, device_id: pci.device_id, + attachment: None, }), None => match address { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] @@ -272,6 +276,132 @@ fn find_table( Ok(None) } +/// FADT offsets of the 32-bit DSDT address and its 64-bit ACPI 2.0 successor. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +const FADT_DSDT_OFFSET: usize = 40; +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +const FADT_X_DSDT_OFFSET: usize = 140; + +/// The CPU-visible MMIO window ACPI declares for the PCI I/O address space. +/// +/// Without port I/O instructions, a root bridge maps PCI I/O space into +/// memory. Firmware describes that mapping only in its ACPI resources: the +/// UEFI root bridge protocol reports the PCI-side range with a zero +/// translation (QEMU virt: 0x0-0xfff), so the window has to come from the +/// DSDT. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IoWindow { + /// First PCI I/O address the window covers. + pub pci_min: u64, + /// Last PCI I/O address the window covers, inclusive. + pub pci_max: u64, + /// CPU address at which `pci_min` is mapped; the window is linear. + pub cpu_base: u64, +} + +/// Recovers the PCI I/O window translation from the DSDT's resource bytes. +/// +/// This is not AML interpretation: AML resource templates embed ACPI +/// address-space descriptors as fixed-format bytes, the same bytes an OS hands +/// to its PCI host bridge driver. The scan matches DWord/QWord I/O descriptors +/// byte for byte and accepts only a single, arithmetically consistent, +/// translated window; anything ambiguous yields 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() >= FADT_X_DSDT_OFFSET + 8 && read_u64(fadt, FADT_X_DSDT_OFFSET) != 0 { + read_u64(fadt, FADT_X_DSDT_OFFSET) as usize + } else if fadt.len() >= FADT_DSDT_OFFSET + 4 { + read_u32(fadt, FADT_DSDT_OFFSET) 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)> { + // Large resource descriptors start with a tag byte and a 16-bit body + // length; the DWord and QWord address-space descriptors share one layout + // that differs only in the width of its address fields. + /// Tag, body length, and field width of the DWord address-space descriptor. + const DWORD_IO: (u8, u16, usize) = (0x87, 23, 4); + /// Tag, body length, and field width of the QWord address-space descriptor. + const QWORD_IO: (u8, u16, usize) = (0x8a, 43, 8); + /// The tag and the two length bytes. + const HEADER_LEN: usize = 3; + /// Resource type byte after the header; 1 selects an I/O range. + const TYPE_IO: u8 = 1; + /// The address fields follow the header, the type, and two flag bytes. + const FIELDS_OFFSET: usize = 6; + /// Field order: granularity, minimum, maximum, translation, length. + const FIELD_MIN: usize = 1; + const FIELD_MAX: usize = 2; + const FIELD_TRANSLATION: usize = 3; + const FIELD_LENGTH: usize = 4; + + let (_, body_len, field_size) = [DWORD_IO, QWORD_IO] + .into_iter() + .find(|(tag, _, _)| bytes.first() == Some(tag))?; + let size = HEADER_LEN + usize::from(body_len); + if bytes.len() < size + || u16::from_le_bytes([bytes[1], bytes[2]]) != body_len + || bytes[HEADER_LEN] != TYPE_IO + { + return None; + } + let field = |index: usize| { + let offset = FIELDS_OFFSET + 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(FIELD_MIN), + field(FIELD_MAX), + field(FIELD_TRANSLATION), + field(FIELD_LENGTH), + ); + + // 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 new file mode 100644 index 0000000..8ff689b --- /dev/null +++ b/real-hw-test/src/discovery/pci.rs @@ -0,0 +1,297 @@ +//! Conservative PCI serial-controller discovery through UEFI root bridges. +//! +//! Conservative means that only endpoints whose class code and programming +//! interface unambiguously declare a 16550-compatible UART with a plain header +//! and an assigned BAR0 are used, and that the only configuration-space write +//! is enabling decoding of that BAR. Vendor-specific layouts are reported but +//! never programmed: guessing a register map on real hardware risks touching +//! an unrelated device. This 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::configuration::ResourceRangeType; +use uefi::proto::pci::root_bridge::PciRootBridgeIo; + +use crate::device::{Address, Attachment, Discovery, Inventory, Location, PciFunction}; +use crate::uefi; + +/// BAR bit 0 selects I/O space; a memory BAR clears it. +const BAR_IO_SPACE: u32 = 1 << 0; +/// An I/O BAR carries its address in bits 2 and up. +const BAR_IO_ADDRESS_MASK: u32 = !0x3; +/// A memory BAR carries its address in bits 4 and up; bits 1-2 give its width. +const BAR_MEMORY_ADDRESS_MASK: u32 = !0xf; +/// Command register bits that enable I/O and memory decoding. +const COMMAND_IO_SPACE: u16 = 1 << 0; +const COMMAND_MEMORY_SPACE: u16 = 1 << 1; + +/// Configuration-space registers read for every candidate. +const REG_VENDOR_ID: u8 = 0x00; +const REG_COMMAND: u8 = 0x04; +const REG_CLASS: u8 = 0x08; +const REG_HEADER_TYPE: u8 = 0x0e; +const REG_BAR0: u8 = 0x10; +const REG_BAR1: u8 = 0x14; +/// Header type 0 is a general endpoint; bit 7 only flags a multi-function +/// device. +const HEADER_TYPE_GENERAL: u8 = 0; +const HEADER_TYPE_MASK: u8 = 0x7f; +/// Class 0x07 subclass 0x00 is a serial controller; the programming interface +/// names the UART generation, and 0x02 (16550) through 0x06 (16950) share the +/// 16550 register map. +const CLASS_SIMPLE_COMMUNICATION: u8 = 0x07; +const SUBCLASS_SERIAL: u8 = 0x00; +const PROG_IF_16550: u8 = 0x02; +const PROG_IF_16950: u8 = 0x06; + +/// 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(); + // The bridge's own bus starts its bus range: functions there are integrated + // controllers, functions on later buses sit behind a bridge. + let root_bus = root.configuration().ok().and_then(|descriptors| { + descriptors + .iter() + .find(|descriptor| descriptor.resource_range_type == ResourceRangeType::Bus) + .map(|descriptor| descriptor.address_min as u8) + }); + + for address in addresses { + let Ok(class_register) = config_u32(root, address, REG_CLASS) else { + continue; + }; + let (class, subclass, _) = class_code(class_register); + if class != CLASS_SIMPLE_COMMUNICATION || subclass != SUBCLASS_SERIAL { + continue; + } + + inspect_serial_controller(root, segment, root_bus, 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, + root_bus: Option, + address: PciIoAddress, + class_register: u32, + inventory: &mut Inventory, +) { + let identity = config_u32(root, address, REG_VENDOR_ID).unwrap_or(u32::MAX); + let command = config_u16(root, address, REG_COMMAND).unwrap_or(0); + let header_type = config_u8(root, address, REG_HEADER_TYPE).unwrap_or(0xff) & HEADER_TYPE_MASK; + let (_, _, prog_if) = class_code(class_register); + let bar0 = config_u32(root, address, REG_BAR0).unwrap_or(0); + let bar1 = config_u32(root, address, REG_BAR1).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!( + " {: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 != HEADER_TYPE_GENERAL || !(PROG_IF_16550..=PROG_IF_16950).contains(&prog_if) { + uefi::println!(" SKIP: not an unambiguous 16550-compatible endpoint"); + 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 = if bar0 & BAR_IO_SPACE != 0 { + COMMAND_IO_SPACE + } else { + COMMAND_MEMORY_SPACE + }; + 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 & BAR_IO_SPACE != 0 { + io_bar_candidate(root, bar0, command) + } else { + memory_bar_candidate(bar0, bar1, command) + }; + + let Some(candidate) = candidate else { + uefi::println!(" SKIP: BAR0 is disabled, invalid, or unsupported"); + return; + }; + let attachment = root_bus.map(|root_bus| { + if bus == root_bus { + Attachment::OnRootBus + } else { + Attachment::BehindBridge + } + }); + let location = Location::Pci(PciFunction { + segment, + bus, + device, + function, + vendor_id: vendor, + device_id, + attachment, + }); + uefi::println!(" candidate: {candidate} ({location})"); + inventory.add(candidate, None, Discovery::PciEnumeration, location); +} + +/// Uses an I/O BAR whose decoding is enabled, reaching it as the architecture +/// allows. +fn io_bar_candidate(root: &mut PciRootBridgeIo, bar0: u32, command: u16) -> Option
{ + if command & COMMAND_IO_SPACE == 0 { + return None; + } + io_bar_address(root, bar0 & BAR_IO_ADDRESS_MASK) +} + +/// Uses a 32- or 64-bit memory BAR whose decoding is enabled and whose address +/// fits the platform. +fn memory_bar_candidate(bar0: u32, bar1: u32, command: u16) -> Option
{ + // Bits 1-2 encode the BAR width: 0 is 32-bit, 2 is 64-bit with the high + // half in the next BAR. + let base = match (bar0 >> 1) & 0x3 { + 0 => u64::from(bar0 & BAR_MEMORY_ADDRESS_MASK), + 2 => (u64::from(bar1) << 32) | u64::from(bar0 & BAR_MEMORY_ADDRESS_MASK), + _ => return None, + }; + if command & COMMAND_MEMORY_SPACE == 0 || base == 0 || base > usize::MAX as u64 { + return None; + } + Some(Address::Mmio { + base: base as usize, + stride: 1, + }) +} + +/// 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(REG_COMMAND), command) + .ok()?; + let command = config_u16(root, address, REG_COMMAND).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, + }) +} + +/// Splits the class register into class, subclass, and programming interface. +fn class_code(register: u32) -> (u8, u8, u8) { + ( + (register >> 24) as u8, + (register >> 16) as u8, + (register >> 8) as u8, + ) +} + +/// 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)) +} From fb0ffe711b5e55f09e8ba4807ad10d71c91b1bd9 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:10:58 +0200 Subject: [PATCH 08/22] real-hw-test: exercise uart_16550 devices Run the public driver API on every discovered candidate: init, the register values it must leave behind, test_loopback with a restored configuration afterwards, DSR/CTS connection signals, and the try_send_byte/send_bytes/send_bytes_exact paths. init() carries the crate's presence check, so an absent UART fails there rather than in a later step. Absent DSR/CTS is only a warning because three-wire and USB serial cables legitimately omit modem-control lines. The PIO backend exists only on x86; its driver variant is cfg-gated. --- real-hw-test/src/driver_test.rs | 260 ++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 24 ++- 2 files changed, 282 insertions(+), 2 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..25c8d5d --- /dev/null +++ b/real-hw-test/src/driver_test.rs @@ -0,0 +1,260 @@ +//! Checks performed through the public `uart_16550` API. +//! +//! Every discovered candidate is exercised, starting with `init()`, whose +//! built-in presence check separates an absent UART from a driver failure. + +use alloc::vec::Vec; +use core::ptr::NonNull; + +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}; + +use crate::device::{Address, Candidate}; +use crate::uefi; + +/// 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), +} + +/// 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 { + #[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) } + .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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + 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 { + #[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), + } + } + + /// 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), + } + } + + /// 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), + } + } +} + +/// Exercises every candidate through the public driver API. +pub fn run(candidates: &[Candidate]) -> Vec { + candidates.iter().map(run_one).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!( + " {}: 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 3b6684c..7c3ab5b 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -20,6 +20,7 @@ mod uefi { mod device; mod discovery; +mod driver_test; mod firmware; use uefi::prelude::*; @@ -54,7 +55,26 @@ fn main() -> Status { uefi::println!(" location: {}", candidate.location); uefi::println!(" found by: {}", candidate.found_by()); } - uefi::println!("\nDiscovery complete. Press Enter to return to firmware."); + + let drivers = driver_test::run(inventory.candidates()); + 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!( + "\nAutomatic summary: {passed}/{} passed, {warnings} connection warning(s), {initialized} initialized.", + drivers.len() + ); + uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); - Status::SUCCESS + if passed == drivers.len() { + Status::SUCCESS + } else { + Status::DEVICE_ERROR + } } From 1ae9bab3a6b0b786560fb187e84e79d7d837d238 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:47:44 +0200 Subject: [PATCH 09/22] real-hw-test: add interactive serial checks Automatic checks cannot prove that a real cable to a remote terminal works. On operator request each passing UART offers register dumps, a transmit line to the remote side, connection-signal inspection, another loopback, and receive-with-echo of typed characters. Escape, locally or as serial 0x1b, skips a UART that has no remote connected; the skip is recorded as a warning instead of a failure. --- real-hw-test/src/driver_test.rs | 26 +++++++++ real-hw-test/src/interactive.rs | 94 +++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 14 ++++- 3 files changed, 132 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 25c8d5d..51ef698 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -26,9 +26,23 @@ pub enum Driver { pub struct Result { pub passed: bool, pub connection_warning: bool, + pub interactive_skipped: bool, pub driver: Option, } +impl Result { + /// Summarizes one UART's outcome for the final on-screen list. + pub fn status(&self) -> &'static str { + if !self.passed { + "FAIL" + } else if self.connection_warning || self.interactive_skipped { + "WARN" + } else { + "PASS" + } + } +} + impl Driver { /// Constructs the public backend matching the candidate's address form. fn new(address: Address) -> core::result::Result { @@ -123,6 +137,15 @@ 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 { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::Port(uart) => uart.try_receive_byte(), + Self::Mmio(uart) => uart.try_receive_byte(), + } + } } /// Exercises every candidate through the public driver API. @@ -188,6 +211,7 @@ fn run_one(candidate: &Candidate) -> Result { Result { passed: true, connection_warning, + interactive_skipped: false, driver: Some(driver), } } @@ -246,6 +270,7 @@ fn fail(stage: &str, error: &str) -> Result { Result { passed: false, connection_warning: false, + interactive_skipped: false, driver: None, } } @@ -255,6 +280,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..96b4754 --- /dev/null +++ b/real-hw-test/src/interactive.rs @@ -0,0 +1,94 @@ +//! 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}; +use crate::uefi; + +/// 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 7c3ab5b..7c51425 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -22,6 +22,7 @@ mod device; mod discovery; mod driver_test; mod firmware; +mod interactive; use uefi::prelude::*; @@ -56,7 +57,8 @@ fn main() -> Status { uefi::println!(" found by: {}", candidate.found_by()); } - let drivers = driver_test::run(inventory.candidates()); + let mut drivers = driver_test::run(inventory.candidates()); + interactive::run(inventory.candidates(), &mut drivers); let passed = drivers.iter().filter(|result| result.passed).count(); let warnings = drivers .iter() @@ -66,10 +68,18 @@ 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() ); + let statuses = drivers.iter().map(driver_test::Result::status); + for (index, (candidate, status)) in inventory.candidates().iter().zip(statuses).enumerate() { + uefi::println!(" [{index}] {status}: {}", candidate.address); + } uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); if passed == drivers.len() { From 9a1a2cbfac7b394e9a084b0c1fcf16cf8d67a428 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 14:12:59 +0200 Subject: [PATCH 10/22] 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 b999c16..3030172 100644 --- a/real-hw-test/src/firmware.rs +++ b/real-hw-test/src/firmware.rs @@ -13,6 +13,15 @@ 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. + 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 7c51425..a738282 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -39,6 +39,7 @@ compile_error!("unsupported architecture; supported: x86_64, aarch64"); fn main() -> Status { uefi::helpers::init().expect("UEFI helpers should initialize"); uefi::println!("uart_16550 real-hardware test ({ARCH_NAME})"); + firmware::disable_watchdog(); if !firmware::disconnect_serial_controllers() { uefi::println!("FAIL: firmware serial ownership was not released"); From d620b28c8f367629aa23829b7e5e8db58f9f9337 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:34:38 +0200 Subject: [PATCH 11/22] 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. The file name carries the architecture so one stick can hold runs from several machines. --- real-hw-test/Cargo.lock | 113 +++++++++++++++++++++++++++++++++- real-hw-test/Cargo.toml | 3 +- real-hw-test/src/logging.rs | 117 ++++++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 19 +++++- 4 files changed, 246 insertions(+), 6 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 2ddace2..ca6b22d 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.1" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -116,8 +222,9 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2422138a1c4483f5d04081281ec841fb17f88cf1e2263b3b860fd455ab3a8d52" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", + "jiff", "log", "ptr_meta", "ucs2", @@ -143,7 +250,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b86018f5a44661f30db808298ccfcbe326279239c4d17a347c5476065ff72c0" dependencies = [ - "bitflags", + "bitflags 2.13.1", "uguid", ] diff --git a/real-hw-test/Cargo.toml b/real-hw-test/Cargo.toml index f0fb522..1c9424e 100644 --- a/real-hw-test/Cargo.toml +++ b/real-hw-test/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" publish = false [dependencies] +jiff = { version = "0.2", default-features = false } uart_16550 = { path = ".." } -uefi = { version = "0.40.0", features = ["alloc", "global_allocator", "panic_handler"] } +uefi = { version = "0.40.0", features = ["alloc", "global_allocator", "jiff02", "panic_handler"] } diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs new file mode 100644 index 0000000..27cdad8 --- /dev/null +++ b/real-hw-test/src/logging.rs @@ -0,0 +1,117 @@ +//! Screen and file diagnostics for one test run. +//! +//! Persisting the screen transcript makes a hardware failure inspectable after +//! reboot. A log write failure aborts the test rather than continuing without +//! the evidence needed to interpret the 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", + crate::ARCH_NAME, + 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 a738282..5bd5c6a 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -12,17 +12,28 @@ extern crate uefi as uefi_rs; /// The `uefi` crate is bound as `uefi_rs` so that this crate-local `uefi` /// module can take its name: every file reaches the library through -/// `crate::uefi`, which re-exports the crate and can override single items, -/// such as `println!`, for the whole test without touching call sites. +/// `crate::uefi`, which re-exports the crate but overrides `println!` with +/// `test_println!`, so each diagnostic also lands in the log file. The +/// library's own internal output is unaffected. 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; use uefi::prelude::*; @@ -38,6 +49,10 @@ compile_error!("unsupported architecture; supported: x86_64, aarch64"); #[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 ({ARCH_NAME})"); firmware::disable_watchdog(); From 1923ef5dbf322c745a53289878a85ff3038d8c7d Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:42:33 +0200 Subject: [PATCH 12/22] 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 | 13 +++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index 27cdad8..90aa005 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. @@ -44,6 +45,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()) @@ -76,14 +78,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. @@ -115,3 +117,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 5bd5c6a..03999bc 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -58,6 +58,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; } @@ -89,13 +90,21 @@ 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() + "\nFinal summary: {}/{} passed, {} connection \ + warning(s), {} interactive skip(s), {} \ + initialized.", + passed, + drivers.len(), + warnings, + skipped, + initialized, ); let statuses = drivers.iter().map(driver_test::Result::status); for (index, (candidate, status)) in inventory.candidates().iter().zip(statuses).enumerate() { 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 9b468d0784b3e0843f7db6f7d35b269e251989fa Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:41:38 +0200 Subject: [PATCH 13/22] 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 | 59 +++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 51ef698..16bb9d4 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; #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] @@ -14,6 +15,9 @@ use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; use crate::device::{Address, Candidate}; 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 { @@ -167,7 +171,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"); @@ -175,20 +179,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() { @@ -204,7 +208,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"); @@ -223,16 +228,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() @@ -276,10 +307,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 eea82400c538b59539cd49c0a0aeb9c24b7286c7 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:43:06 +0200 Subject: [PATCH 14/22] real-hw-test: document the hardware test Describe what the application discovers and checks, the recommended hardware and cable setup, how to build it and run it under QEMU, how to read its output, and the current state of architecture support. --- real-hw-test/README.md | 214 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 real-hw-test/README.md diff --git a/real-hw-test/README.md b/real-hw-test/README.md new file mode 100644 index 0000000..965ca24 --- /dev/null +++ b/real-hw-test/README.md @@ -0,0 +1,214 @@ +# uart_16550 UEFI real-hardware test + +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. + +Diagnostics go to the UEFI console and are persisted line by line on the boot +volume as `/uart_16550_test_logs/uart_16550__.txt`; a log +write failure aborts the test. Bytes written to a UART are short, recognizable +test payloads. + +## TL;DR + +1. Run `make artifact` (or `make artifacts` for every architecture), then + deploy the built images 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 drives UARTs synchronously by polling: interrupts stay +disabled, no handler is installed, and interrupt delivery is **not** tested. +It also disables the UEFI image watchdog, since the interactive phase may take +longer than the firmware's five-minute limit; a failure to do so is a warning. + +It discovers: + +- COM1 at `0x3f8` unconditionally (x86_64 only); +- conventional legacy ports at `0x2f8`, `0x3e8`, and `0x2e8` when the crate's + `check_present()` finds a device (x86_64 only); +- compatible byte-access UARTs advertised by ACPI SPCR; +- 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. Without x86 port instructions, an I/O BAR is reached +through the PCI I/O window that the platform's ACPI DSDT declares; without an +unambiguous window the device is skipped. + +## Recommended real-hardware setup + +Boot the application on an x86_64 machine (see "Architecture support" for +other architectures) 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: + +```console +# If your user is part of the "dialout" group there is no need for sudo +$ sudo minicom -D /dev/ttyUSB0 -b 9600 +``` + +Use 9600 8N1 without 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; its 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 the `[uart_16550]` payload appears remotely. +5. Use the interactive commands below. + +### Connect or reconnect during the test + +Starting without the remote cable is valid too. 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, so a +connection warning is 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 targets once if necessary, then build: + +```console +rustup target add x86_64-unknown-uefi aarch64-unknown-uefi +make artifact +``` + +The result is `build/BOOTX64.EFI`; `make artifact ARCH=aarch64` produces +`build/BOOTAA64.EFI` instead, and every `make` target accepts `ARCH`. +`make artifacts` cross-compiles every supported architecture in one step. +`make check` runs all static build checks. + +## Run under QEMU + +The Nix development shell supplies QEMU and the firmware: + +```console +nix develop +make qemu +``` + +The QEMU window is the UEFI monitor and keyboard; COM1 is connected to the +launching terminal. A `pci-serial` device is present as well, and QEMU prints +its `/dev/pts/...` path at startup; open it in a second terminal to exercise +PCI discovery and BAR-backed UART access: + +```console +minicom -D /dev/pts/NUMBER -b 9600 +``` + +Outside Nix, pass the firmware explicitly (`OVMF=/path/to/OVMF.fd make qemu`). +KVM is the default; `make qemu-tcg` (or `QEMU_ACCEL=tcg`) selects software +emulation. `QEMU`, `QEMU_ARGS`, and `ESP_DIR` override the executable, add +arguments, or relocate the directory-backed EFI system partition; QEMU data +stays below the 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 (`AAVMF_CODE`/`AAVMF_VARS` override it), +using TCG by default. The terminal shows the PL011 firmware console, which the +application correctly rejects; the only 16550 is the `pci-serial` device, +reached through the memory-mapped PCI I/O window. + +## 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 not supported because Rust has no riscv64 UEFI target; it would +need a custom target JSON on nightly with `-Zbuild-std`. QEMU's riscv64 `virt` +machine would otherwise fit well: 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` +confirms firmware still owns the serial output; after the controllers are +disconnected, remote output may stop, so keep reading the monitor. + +Each usable candidate is listed with its register access (PIO or MMIO), its +location, and the discovery paths that found it. Locations are `built-in +legacy port`, `built-in platform UART` (memory-mapped, described by firmware +without a PCI identity), or a PCI function with vendor/device IDs, `on the +root bus` (typically integrated) or `behind a bridge` (typically an add-in +card); known QEMU devices are named. Discovery paths are `required COM1`, +`presence check at a conventional port`, `ACPI SPCR`, and `PCI enumeration`. + +Good signs are: + +- `PASS` for crate `init`, initialized register values, crate loopback, and + send APIs; +- `[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 is expected with a three-wire +or USB serial cable that provides no modem-control lines; an Escape skip is a +warning as well. Investigate `FAIL`, `SKIP`, transmit or receive timeouts, a +failed `disconnect_controller`, or a register/loopback mismatch, starting with +the candidate address, its reported clock, the 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` +(they succeeded, but connection signals were absent or the interactive phase +was skipped), or `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`. From 4b7e622ac74398d6007f44e87d148bbdea84120d Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:53:32 +0200 Subject: [PATCH 15/22] real-hw-test: "make install" to install on a USB stick The test is meant to travel on a USB stick: build with 'make artifacts', plug in a stick that carries a FAT32 EFI partition, run 'make install', pick the stick from the list, and boot the test machine from it. The script copies every built architecture to its removable-media path after verifying that the target is the exact mount point of a FAT32 partition on a GPT disk. It never partitions, formats, or mounts anything itself, so a wrong pick or path stops with a diagnostic instead of writing into another filesystem. Built-in disks are not offered at all, because they carry the host's own EFI system partition; USB_MOUNT names the target explicitly for scripted use. --- real-hw-test/.gitignore | 2 +- real-hw-test/Makefile | 8 +- real-hw-test/README.md | 31 ++++- real-hw-test/flake.nix | 3 - real-hw-test/scripts/install-usb.sh | 174 ++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 6 deletions(-) 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 f59f8c2..53a2d54 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -25,7 +25,7 @@ ARTIFACT := build/$(BOOT_NAME) BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy -.PHONY: all artifact artifacts check qemu qemu-tcg clean +.PHONY: all artifact artifacts check qemu qemu-tcg install clean all: artifact @@ -43,6 +43,7 @@ check: $(CARGO) fmt --check $(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 @@ -52,6 +53,11 @@ qemu: artifact qemu-tcg: $(MAKE) qemu QEMU_ACCEL=tcg +# Deploys every built architecture; 'make artifacts' first covers them all. +# Without USB_MOUNT, the media is picked interactively. +install: artifact + USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh + clean: $(CARGO) clean --target-dir $(TARGET_DIR) rm -rf build diff --git a/real-hw-test/README.md b/real-hw-test/README.md index 965ca24..b9d99d1 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -13,7 +13,8 @@ test payloads. ## TL;DR 1. Run `make artifact` (or `make artifacts` for every architecture), then - deploy the built images to a mounted GPT/FAT32 EFI partition. + deploy the built images with `make install`, which lets you pick the mounted + GPT/FAT32 EFI partition interactively. 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 @@ -102,6 +103,34 @@ The result is `build/BOOTX64.EFI`; `make artifact ARCH=aarch64` produces `make artifacts` cross-compiles every supported architecture in one step. `make check` runs all static build checks. +## Install on USB media + +Prepare and mount an EFI partition yourself: the install target never +partitions, formats, mounts, or unmounts anything. With the media mounted, run: + +```console +make install +``` + +This opens an interactive picker listing every mounted FAT32 partition on a GPT +disk that sits on removable or USB-attached media, with size, label, bus, and +model. Built-in disks are never listed: they carry the host's own ESP, where +overwriting `EFI/BOOT` breaks the host's boot path. + +Pass the mount point directly to skip the picker, to reach media the picker +does not list, or for scripted runs without a terminal: + +```console +lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,PTTYPE,MOUNTPOINTS +make install USB_MOUNT=/run/media///EFI +``` + +Every artifact in `build/` is copied to its removable-media path +(`EFI/BOOT/BOOTX64.EFI`, `EFI/BOOT/BOOTAA64.EFI`), so one stick boots every +architecture built beforehand. A disk that is not GPT, a filesystem that is not +FAT32, a path that is not an exact mount point, or a read-only mount stops the +installation with a diagnostic. Unmount cleanly before removing the stick. + ## Run under QEMU The Nix development shell supplies QEMU and the firmware: diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index b12b081..ad84406 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -18,14 +18,11 @@ { default = pkgs.mkShell { packages = with pkgs; [ - dosfstools fzf - mtools # 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 = { diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh new file mode 100755 index 0000000..d2fa073 --- /dev/null +++ b/real-hw-test/scripts/install-usb.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifacts=("$@") +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 + +# 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 + +[[ ${#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 + +# lsblk raw output escapes spaces as '\x20'; values are read one at a time, so +# each query returns exactly one field. Media unplugged mid-scan yields an empty +# value, which the callers already treat as unusable. +blk_field() { + local value + value=$(lsblk -dnro "$1" -- "$2" 2>/dev/null) || value= + printf '%b' "$value" +} + +# The model is last: it is the least important column and the only one that can +# be truncated without losing information the operator needs to confirm. +format_row() { + printf '%-14s %6s %-12s %-24s %-26s %s\n' "$@" +} + +# Emits one tab-separated record per usable partition: +# partition, mount point, parent disk, then the display columns. +collect_candidates() { + local partition type fsver mount parent size label model transport bus + while read -r partition type; do + [[ "$type" == part ]] || continue + [[ "$(blk_field FSTYPE "$partition")" == vfat ]] || continue + fsver=$(blk_field FSVER "$partition") + [[ "$fsver" == FAT32 ]] || continue + # The script never mounts anything, so unmounted media is not offered. + mount=$(blk_field MOUNTPOINT "$partition") + [[ -n "$mount" && "$mount" != \[*\] ]] || continue + + parent=$(blk_field PKNAME "$partition") + [[ -n "$parent" ]] || continue + parent=/dev/$parent + [[ "$(blk_field PTTYPE "$parent")" == gpt ]] || continue + + # Built-in disks carry the host's own ESP, and overwriting EFI/BOOT + # there breaks the host's boot path. Only removable media is listed; + # USB_MOUNT remains the way to reach anything else. + transport=$(blk_field TRAN "$parent") + [[ "$transport" == usb || "$(blk_field RM "$partition")" == 1 || + "$(blk_field HOTPLUG "$partition")" == 1 ]] || continue + + size=$(blk_field SIZE "$partition") + label=$(blk_field LABEL "$partition") + model=$(blk_field MODEL "$parent") + bus=${transport:-unknown} + [[ -w "$mount" ]] || bus="$bus, read-only" + printf '%s\t%s\t%s\t' "$partition" "$mount" "$parent" + format_row \ + "$partition" "$size" "${label:--}" "$bus" "$mount" "${model:--}" + done < <(lsblk -nrpo NAME,TYPE) +} + +# Picks a mount point interactively and echoes it. The picker draws on the +# terminal and diagnostics go to stderr, so only the result reaches the caller. +# It runs in a command substitution: 'exit' ends that subshell, and errexit in +# the caller turns the failed assignment into the script's own exit. +select_usb_mount() { + local candidates=() selection mount header + mapfile -t candidates < <(collect_candidates) + if [[ ${#candidates[@]} -eq 0 ]]; then + echo "error: no removable FAT32 partition on a GPT disk is mounted" >&2 + echo " mount the EFI partition first, or name it with USB_MOUNT" >&2 + exit 2 + fi + + header=$'Enter installs, Esc aborts\n' + header+=$(format_row DEVICE SIZE LABEL BUS MOUNT MODEL) + + selection=$(printf '%s\n' "${candidates[@]}" | fzf \ + --delimiter=$'\t' \ + --with-nth=4.. \ + --height=~60% \ + --border \ + --border-label=' Select the EFI system partition ' \ + --header="$header" \ + --header-first \ + --preview='lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,LABEL,MOUNTPOINTS -- {3}' \ + --preview-window='down,8,border-top') || selection= + + [[ -n "$selection" ]] || fail "no media selected" + IFS=$'\t' read -r _ mount _ <<<"$selection" + printf '%s\n' "$mount" +} + +if [[ -z "$mount_input" ]]; then + # The device node can exist without a controlling terminal, so the guard + # opens it instead of only testing its permissions. + if { : /dev/null && command -v fzf >/dev/null 2>&1; then + mount_input=$(select_usb_mount) + else + fail "USB_MOUNT is unset; use 'make install USB_MOUNT=/path/to/mounted/efi-partition' + (interactive selection needs a terminal and fzf)" + fi +fi + +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" + +echo "Installing to validated media:" +echo " disk: $parent_device (GPT)" +echo " partition: $source_device (FAT32)" +echo " mount: $mount_path" +# 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." From 372928bc018dc957f4fd727aa9ab0eac7ee01821 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 16:03:29 +0200 Subject: [PATCH 16/22] 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. The machine and firmware selection is shared with run-qemu.sh through scripts/qemu-machine.sh; the harness only adds its headless front end. --- .github/workflows/real-hw-test.yml | 45 +++++++ real-hw-test/Makefile | 5 +- real-hw-test/README.md | 9 ++ real-hw-test/flake.nix | 3 + real-hw-test/scripts/run-qemu-ci.sh | 180 ++++++++++++++++++++++++++++ 5 files changed, 241 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 53a2d54..ba1f633 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -25,7 +25,7 @@ ARTIFACT := build/$(BOOT_NAME) BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy -.PHONY: all artifact artifacts check qemu qemu-tcg install clean +.PHONY: all artifact artifacts check qemu qemu-tcg ci-qemu install clean all: artifact @@ -53,6 +53,9 @@ qemu: artifact qemu-tcg: $(MAKE) qemu QEMU_ACCEL=tcg +ci-qemu: artifact + QEMU="$(QEMU)" OVMF="$(OVMF)" ./scripts/run-qemu-ci.sh $(ARTIFACT) + # Deploys every built architecture; 'make artifacts' first covers them all. # Without USB_MOUNT, the media is picked interactively. install: artifact diff --git a/real-hw-test/README.md b/real-hw-test/README.md index b9d99d1..ae77a17 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -161,6 +161,15 @@ using TCG by default. The terminal shows the PL011 firmware console, which the application correctly rejects; the only 16550 is the `pci-serial` device, reached through the memory-mapped PCI I/O window. +### Headless CI smoke test + +`make ci-qemu` boots the unmodified artifact headlessly under QEMU TCG. A +host-side script answers the operator prompts through the QEMU monitor and +judges the run by the persisted log and the serial captures: legacy COM1 and +the PCI UART must be found and pass. It guards the automatic paths against +regressions; it does not replace a run on real hardware with a real cable. +The Nix shell provides the required `socat`, `mtools`, and `dosfstools`. + ## Architecture support x86_64 is the primary target and the only one exercised on physical hardware so diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index ad84406..b12b081 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -18,11 +18,14 @@ { default = pkgs.mkShell { packages = with pkgs; [ + dosfstools fzf + mtools # 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 = { 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..0822582 --- /dev/null +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} +artifact=${1:-build/BOOTX64.EFI} +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 +console_log=$run_dir/console.log +pci_log=$run_dir/pci-serial.log +persisted_log=$run_dir/persisted.log + +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" + +# TCG keeps the headless run identical on every host, accelerated or not. +# shellcheck source=scripts/qemu-machine.sh +. "$(dirname "$0")/qemu-machine.sh" +qemu_machine_setup "$arch" "$qemu" tcg "$run_dir/vars.fd" + +# 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/$boot_name" + +"$qemu" \ + "${machine_args[@]}" \ + -drive "format=raw,file=$disk" \ + -nic none \ + -display none \ + -monitor "unix:$monitor,server,nowait" \ + -serial "file:$console_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 "$console_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 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 + 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 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 'location: built-in legacy port' + assert_log "$persisted_log" -F 'found by: required COM1' + assert_log "$persisted_log" -F 'QEMU pci-serial), on the root bus' + assert_log "$persisted_log" -F 'found by: PCI enumeration' + assert_log "$persisted_log" -F 'Final summary: 2/2 passed' + assert_log "$persisted_log" -F '2 interactive skip(s)' + assert_log "$console_log" -F '[uart_16550] uart transmit test' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' + ;; +*) + echo "error: no CI assertions for ARCH '$arch'" >&2 + exit 2 + ;; +esac + +if ((failures > 0)); then + dump_logs + exit 1 +fi + +echo "PASS: headless $arch TCG run drove the UART checks to completion" From 8e3f2bf3c3c0f0d3266dccb9d1b7357eff170e94 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:30 +0200 Subject: [PATCH 17/22] ci: smoke-test aarch64 UEFI UART paths in QEMU The x86_64 smoke test only covers port I/O and the legacy COM path. The aarch64 run is the counterpart for the MMIO backend, for the firmware-described (SPCR) console, and for the PCI I/O-window translation, so both halves of the driver are exercised on every push without any real hardware. 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/Makefile | 3 +- real-hw-test/README.md | 14 ++++---- real-hw-test/scripts/run-qemu-ci.sh | 13 +++++-- 4 files changed, 55 insertions(+), 31 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/Makefile b/real-hw-test/Makefile index ba1f633..702ab24 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -54,7 +54,8 @@ 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) # Deploys every built architecture; 'make artifacts' first covers them all. # Without USB_MOUNT, the media is picked interactively. diff --git a/real-hw-test/README.md b/real-hw-test/README.md index ae77a17..8464926 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -163,12 +163,14 @@ reached through the memory-mapped PCI I/O window. ### Headless CI smoke test -`make ci-qemu` boots the unmodified artifact headlessly under QEMU TCG. A -host-side script answers the operator prompts through the QEMU monitor and -judges the run by the persisted log and the serial captures: legacy COM1 and -the PCI UART must be found and pass. It guards the automatic paths against -regressions; it does not replace a run on real hardware with a real cable. -The Nix shell provides the required `socat`, `mtools`, and `dosfstools`. +`make ci-qemu` (and `make ci-qemu ARCH=aarch64`) boots the unmodified artifact +headlessly under QEMU TCG. A host-side script answers the operator prompts +through the QEMU monitor and judges the run by the persisted log and the serial +captures: on x86_64 legacy COM1 and the PCI UART must be found and pass, on +aarch64 the PL011 console must be rejected and the PCI UART driven through the +translated I/O window. It guards the automatic paths against regressions; it +does not replace a run on real hardware with a real cable. The Nix shell +provides the required `socat`, `mtools`, and `dosfstools`. ## Architecture support diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh index 0822582..3164fe7 100755 --- a/real-hw-test/scripts/run-qemu-ci.sh +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -166,9 +166,16 @@ x86_64) assert_log "$console_log" -F '[uart_16550] uart transmit test' assert_log "$pci_log" -F '[uart_16550] uart transmit test' ;; -*) - echo "error: no CI assertions for ARCH '$arch'" >&2 - exit 2 +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" -F 'QEMU pci-serial), on the root bus' + assert_log "$persisted_log" -F 'found by: PCI enumeration' + assert_log "$persisted_log" -F 'Final summary: 1/1 passed' + assert_log "$persisted_log" -F '1 interactive skip(s)' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' ;; esac From 5f81deeceb04bc54f1e329cfa8ec19c3443781df Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:46:00 +0200 Subject: [PATCH 18/22] doc: link the test suites from the README Point readers to the UEFI application for real hardware and to the VM-based integration test. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 5b07568..b084aa4 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ fn main() { } ``` +## Testing + +- There is a manual integration test packaged as UEFI application that you can + boot on real hardware: [./real-hw-test](./real-hw-test/README.md) +- There is an integration test that can run as a VM in + [./integration-test](./integration-test) + ## License This project is licensed under either of From 4aab76af683febd3651a8aa0954546c0fd3f826b Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 20 Sep 2026 12:16:49 +0200 Subject: [PATCH 19/22] ci: update dependabot --- .github/dependabot.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9ab178d..a09bf96 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,10 @@ version: 2 updates: - package-ecosystem: cargo - directory: "/" + directories: + - "/" + - "/real-hw-test" + - "/test" schedule: interval: monthly open-pull-requests-limit: 10 From dbc44c0c2fa44ea2095853e1fe5d3a78baf1e7f7 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 20 Sep 2026 12:19:42 +0200 Subject: [PATCH 20/22] clippy: clippy::absolute_paths --- real-hw-test/src/driver_test.rs | 26 +++++++++++--------------- real-hw-test/src/logging.rs | 2 +- real-hw-test/src/main.rs | 1 + 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 16bb9d4..8c3076f 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::result; use core::time::Duration; use uart_16550::backend::MmioBackend; @@ -49,7 +50,7 @@ impl Result { impl Driver { /// Constructs the public backend matching the candidate's address form. - fn new(address: Address) -> core::result::Result { + fn new(address: Address) -> result::Result { match address { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Address::Port(port) => { @@ -69,7 +70,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> { + fn init(&mut self, config: Config) -> result::Result<(), uart_16550::InitError> { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.init(config), @@ -87,7 +88,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> { + pub fn test_loopback(&mut self) -> result::Result<(), uart_16550::LoopbackError> { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.test_loopback(), @@ -96,9 +97,7 @@ impl Driver { } /// Samples modem-control inputs to diagnose remote cable wiring. - pub fn check_connected( - &mut self, - ) -> core::result::Result<(), uart_16550::RemoteReadyToReceiveError> { + pub fn check_connected(&mut self) -> result::Result<(), uart_16550::RemoteReadyToReceiveError> { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.check_connected(), @@ -107,7 +106,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> { + fn ready_to_send(&mut self) -> result::Result<(), uart_16550::ByteSendError> { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.ready_to_send(), @@ -116,7 +115,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> { + fn try_send_byte(&mut self, byte: u8) -> result::Result<(), uart_16550::ByteSendError> { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.try_send_byte(byte), @@ -143,7 +142,7 @@ impl Driver { } /// Polls one received byte so interactive checks never block keyboard input. - pub fn try_receive_byte(&mut self) -> core::result::Result { + pub fn try_receive_byte(&mut self) -> result::Result { match self { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] Self::Port(uart) => uart.try_receive_byte(), @@ -222,7 +221,7 @@ fn run_one(candidate: &Candidate) -> Result { } /// Uses every send API in one recognizable payload for remote verification. -fn exercise_send_apis(driver: &mut Driver) -> core::result::Result<(), &'static str> { +fn exercise_send_apis(driver: &mut Driver) -> result::Result<(), &'static str> { driver.ready_to_send().map_err(|_| "not ready to send")?; driver .try_send_byte(b'[') @@ -237,10 +236,7 @@ fn exercise_send_apis(driver: &mut Driver) -> core::result::Result<(), &'static } /// 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> { +fn send_all_with_timeout(driver: &mut Driver, bytes: &[u8]) -> result::Result<(), &'static str> { let mut remaining = bytes; for _ in 0..SEND_TIMEOUT_MS { let written = driver.send_bytes(remaining); @@ -254,7 +250,7 @@ fn send_all_with_timeout( } /// 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> { +fn wait_until_ready_to_send(driver: &mut Driver) -> result::Result<(), &'static str> { for _ in 0..SEND_TIMEOUT_MS { if driver.ready_to_send().is_ok() { return Ok(()); diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index 90aa005..c6eb730 100644 --- a/real-hw-test/src/logging.rs +++ b/real-hw-test/src/logging.rs @@ -127,7 +127,7 @@ pub fn report_location() { uefi_rs::println!("CRITICAL: test logger was not initialized"); panic!("test logger was not initialized"); }); - println(core::format_args!( + println(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 03999bc..ccc6224 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -1,6 +1,7 @@ #![no_main] #![no_std] #![deny(clippy::undocumented_unsafe_blocks)] +#![deny(clippy::absolute_paths)] //! Manual UEFI integration test for this repository's `uart_16550` driver. //! From a48a671b8bc74fe8f1f653d2ec3ea2b3ccad2289 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 20 Sep 2026 13:30:10 +0200 Subject: [PATCH 21/22] real-hw-test: record the start time in the log --- real-hw-test/src/logging.rs | 48 +++++++++++++++++++++++++++---------- real-hw-test/src/main.rs | 1 + 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index c6eb730..4a94a50 100644 --- a/real-hw-test/src/logging.rs +++ b/real-hw-test/src/logging.rs @@ -19,6 +19,7 @@ use uefi::runtime; struct Logger { file: RegularFile, path: String, + started: String, } /// Holds the single logger used by this synchronous, interrupt-free test. @@ -46,6 +47,15 @@ pub fn init() -> Result<(), &'static str> { time.second(), ); let path = format!("/uart_16550_test_logs/{file_name}"); + let started = format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + 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()) @@ -78,14 +88,18 @@ 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, path)) }; + unsafe { *LOGGER.0.get() = Some(Logger::new(file, path, started)) }; Ok(()) } impl Logger { /// Retains one file handle so each write extends the same run transcript. - fn new(file: RegularFile, path: String) -> Self { - Self { file, path } + fn new(file: RegularFile, path: String, started: String) -> Self { + Self { + file, + path, + started, + } } /// Appends one formatted line and flushes it to FAT before console output. @@ -118,16 +132,24 @@ pub fn println(args: Arguments<'_>) { uefi_rs::println!("{}", args); } +/// Reads one value from the installed logger; the test cannot run without it. +fn logger_field(field: impl FnOnce(&Logger) -> T) -> T { + // SAFETY: The test runs synchronously and `init` installs the sole logger. + let logger = unsafe { (&*LOGGER.0.get()).as_ref() }; + logger.map(field).unwrap_or_else(|| { + uefi_rs::println!("CRITICAL: test logger was not initialized"); + panic!("test logger was not initialized"); + }) +} + +/// Reports when the run started according to the firmware clock. +pub fn report_start_time() { + let started = logger_field(|logger| logger.started.clone()); + println(format_args!("Started at {started} (UEFI clock)")); +} + /// 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(format_args!( - "Logs were written to the USB drive: {path}" - )); + let path = logger_field(|logger| logger.path.clone()); + println(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 ccc6224..faa1786 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -55,6 +55,7 @@ fn main() -> Status { return Status::DEVICE_ERROR; } uefi::println!("uart_16550 real-hardware test ({ARCH_NAME})"); + logging::report_start_time(); firmware::disable_watchdog(); if !firmware::disconnect_serial_controllers() { From a83c4f572d52100fe7c26221a04efb2c34078d9b Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 20 Sep 2026 13:39:30 +0200 Subject: [PATCH 22/22] real-hw-test: build into the crate's own target directory --- real-hw-test/Makefile | 6 +++--- real-hw-test/README.md | 2 +- real-hw-test/scripts/run-qemu-ci.sh | 2 +- real-hw-test/scripts/run-qemu.sh | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 702ab24..9ae8589 100644 --- a/real-hw-test/Makefile +++ b/real-hw-test/Makefile @@ -18,12 +18,12 @@ else $(error unsupported ARCH '$(ARCH)'; supported: x86_64, aarch64) endif -TARGET_DIR := ../target/real-hw-test +TARGET_DIR := target PROFILE := release BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi ARTIFACT := build/$(BOOT_NAME) -BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build -CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy +BUILD := $(CARGO) build +CLIPPY := $(CARGO) clippy .PHONY: all artifact artifacts check qemu qemu-tcg ci-qemu install clean diff --git a/real-hw-test/README.md b/real-hw-test/README.md index 8464926..f4ac98b 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -153,7 +153,7 @@ Outside Nix, pass the firmware explicitly (`OVMF=/path/to/OVMF.fd make qemu`). KVM is the default; `make qemu-tcg` (or `QEMU_ACCEL=tcg`) selects software emulation. `QEMU`, `QEMU_ARGS`, and `ESP_DIR` override the executable, add arguments, or relocate the directory-backed EFI system partition; QEMU data -stays below the ignored `target/real-hw-test/` tree. +stays below the crate's `target/` tree. `make qemu ARCH=aarch64` runs the aarch64 build on QEMU's `virt` machine with the EDK2 firmware bundled with QEMU (`AAVMF_CODE`/`AAVMF_VARS` override it), diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh index 3164fe7..6d75e9e 100755 --- a/real-hw-test/scripts/run-qemu-ci.sh +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -4,7 +4,7 @@ set -euo pipefail arch=${ARCH:-x86_64} qemu=${QEMU:-qemu-system-$arch} artifact=${1:-build/BOOTX64.EFI} -run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci-$arch} +run_dir=${CI_RUN_DIR:-target/qemu-ci-$arch} timeout_s=${CI_TIMEOUT:-300} boot_name=$(basename "$artifact") diff --git a/real-hw-test/scripts/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh index 138edc8..5e4a515 100755 --- a/real-hw-test/scripts/run-qemu.sh +++ b/real-hw-test/scripts/run-qemu.sh @@ -4,7 +4,7 @@ set -euo pipefail arch=${ARCH:-x86_64} qemu=${QEMU:-qemu-system-$arch} accel=${QEMU_ACCEL:-kvm} -esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp-$arch} +esp_dir=${ESP_DIR:-target/qemu-esp-$arch} artifact=${ARTIFACT:-} if ! command -v "$qemu" >/dev/null 2>&1; then