From 99769402f09eaa1a42248a072fc3ff279e82cb3b Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:32:38 +0200 Subject: [PATCH 01/20] real-hw-test: add UEFI application scaffold 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. --- 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 | 33 ++++++++ 5 files changed, 249 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..28d1904 --- /dev/null +++ b/real-hw-test/Cargo.lock @@ -0,0 +1,160 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "uart-16550-real-hw-test" +version = "0.1.0" +dependencies = [ + "uart_16550", + "uefi", +] + +[[package]] +name = "uart_16550" +version = "0.8.0" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.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..5df1a8d --- /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 --locked --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 --locked --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..3e97347 --- /dev/null +++ b/real-hw-test/src/main.rs @@ -0,0 +1,33 @@ +#![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; + +/// Routes every UEFI diagnostic through one crate-local indirection point. +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 3f430b67ab0de9f23b52b54d61790e42009c769f Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 02/20] real-hw-test: add QEMU development environment 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. --- real-hw-test/Makefile | 22 +++++++-- real-hw-test/scripts/run-qemu.sh | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100755 real-hw-test/scripts/run-qemu.sh diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index 5df1a8d..e1053de 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 --locked +CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.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 --locked --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 --locked --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/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh new file mode 100755 index 0000000..2d375cd --- /dev/null +++ b/real-hw-test/scripts/run-qemu.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} +accel=${QEMU_ACCEL:-kvm} +ovmf=${OVMF:-} +esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp-$arch} +artifact=${ARTIFACT:-} + +if ! command -v "$qemu" >/dev/null 2>&1; then + echo "error: QEMU executable not found: $qemu" >&2 + exit 2 +fi + +# Per-architecture machine, firmware, display, and input configuration. +case "$arch" in +x86_64) + artifact=${artifact:-build/BOOTX64.EFI} + if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ + >&2 + exit 2 + fi + if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 + fi + machine_args=(-machine "q35,accel=$accel" -m 256M -bios "$ovmf") + ;; +aarch64) + artifact=${artifact:-build/BOOTAA64.EFI} + # QEMU ships pflash-style EDK2 images next to its own installation. + share_dir=$(dirname "$(readlink -f "$(command -v "$qemu")")")/../share/qemu + aavmf_code=${AAVMF_CODE:-$share_dir/edk2-aarch64-code.fd} + aavmf_vars=${AAVMF_VARS:-$share_dir/edk2-arm-vars.fd} + for firmware in "$aavmf_code" "$aavmf_vars"; do + if [[ ! -r "$firmware" ]]; then + echo "error: aarch64 firmware is not readable: $firmware" >&2 + echo " set AAVMF_CODE and AAVMF_VARS" >&2 + exit 2 + fi + done + # virt has no built-in display or keyboard; ramfb and a USB keyboard give + # the operator the UEFI monitor and Enter/Escape navigation. + machine_args=(-machine "virt,accel=$accel" -cpu max -m 512M + -drive "if=pflash,format=raw,file=$aavmf_code,readonly=on" + -drive "if=pflash,format=raw,file=$esp_dir-vars.fd" + -device ramfb -device qemu-xhci -device usb-kbd) + ;; +*) + echo "error: unsupported ARCH '$arch'; supported: x86_64, aarch64" >&2 + exit 2 + ;; +esac + +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi + +# Recreate the virtual ESP so QEMU never boots a stale application. +rm -rf "$esp_dir" +mkdir -p "$esp_dir/EFI/BOOT" +cp "$artifact" "$esp_dir/EFI/BOOT/$(basename "$artifact")" +if [[ "$arch" == aarch64 ]]; then + # Writable per-run variable store; the template may be read-only on disk. + cp "$aavmf_vars" "$esp_dir-vars.fd" + chmod u+w "$esp_dir-vars.fd" +fi + +echo "QEMU 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 41d08b465377e0faf84e19119e076680e03b01a4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:38:48 +0200 Subject: [PATCH 03/20] nix: add flake development environment The dev shell supplies QEMU, OVMF, and rustup. The .envrc enables direnv integration. 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 | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 65 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..779ea69 --- /dev/null +++ b/real-hw-test/flake.nix @@ -0,0 +1,37 @@ +{ + 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; [ + # The full QEMU: qemu_kvm carries only the host architecture's + # system emulator, but the aarch64 test needs qemu-system-aarch64. + qemu + rustup + ]; + env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; + env.AAVMF_CODE = "${pkgs.qemu}/share/qemu/edk2-aarch64-code.fd"; + env.AAVMF_VARS = "${pkgs.qemu}/share/qemu/edk2-arm-vars.fd"; + }; + } + ); + + formatter = forAllSystems ( + system: nixpkgs.legacyPackages.${system}.nixfmt-tree + ); + }; +} From a2d3a4d5385c45734a32884483e214a003102cf7 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 04/20] real-hw-test: 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. --- real-hw-test/Cargo.toml | 2 +- real-hw-test/src/device.rs | 72 +++++++++++++++++++++++++++ real-hw-test/src/discovery.rs | 14 ++++++ real-hw-test/src/firmware.rs | 94 +++++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 23 +++++++++ 5 files changed, 204 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..5c367a9 --- /dev/null +++ b/real-hw-test/src/device.rs @@ -0,0 +1,72 @@ +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)] +/// Records how discovery found a candidate so duplicate descriptions remain useful. +pub enum Source { + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + RequiredCom1, +} + +#[derive(Debug)] +/// A deduplicated UART address, clock, and all firmware or bus provenance. +pub struct Candidate { + pub address: Address, + pub clock_hz: u32, + pub sources: Vec, +} + +#[derive(Debug, Default)] +/// The candidate list shared by the driver and interactive test phases. +pub struct Inventory { + candidates: Vec, +} + +impl Inventory { + /// Adds a source to an address, merging descriptions to avoid duplicate tests. + pub fn add(&mut self, address: Address, clock_hz: Option, source: Source) { + if let Some(candidate) = self + .candidates + .iter_mut() + .find(|candidate| candidate.address == address) + { + if !candidate.sources.contains(&source) { + candidate.sources.push(source); + } + if let Some(clock_hz) = clock_hz.filter(|clock| *clock != 0) { + candidate.clock_hz = clock_hz; + } + return; + } + + self.candidates.push(Candidate { + address, + clock_hz: clock_hz.unwrap_or(CLK_FREQUENCY_HZ), + sources: alloc::vec![source], + }); + } + + /// Returns candidates in discovery order for stable on-screen summaries. + pub fn candidates(&self) -> &[Candidate] { + &self.candidates + } +} diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs new file mode 100644 index 0000000..84cbf85 --- /dev/null +++ b/real-hw-test/src/discovery.rs @@ -0,0 +1,14 @@ +//! 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, Inventory, Source}; + +/// 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, Source::RequiredCom1); + inventory +} diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs new file mode 100644 index 0000000..ac8f0ac --- /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 program a UART concurrently. + +use alloc::vec::Vec; +use core::time::Duration; + +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams, SearchType}; +use uefi::proto::console::serial::Serial; +use uefi::proto::console::text::Key; +use uefi::{Handle, Status, system}; + +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 3e97347..0016164 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; /// Routes every UEFI diagnostic through one crate-local indirection point. @@ -14,6 +15,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. @@ -29,5 +34,23 @@ 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 sources={:?}", + candidate.address, + candidate.clock_hz, + candidate.sources + ); + } + uefi::println!("\nDiscovery complete. Press Enter to return to firmware."); + firmware::wait_for_enter(); Status::SUCCESS } From 5dbfa2a2c055fe8d35285240c5d42482e9539966 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 05/20] 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 | 2 ++ real-hw-test/src/discovery.rs | 49 ++++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index 5c367a9..13e0bc2 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 Source { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] RequiredCom1, + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + LegacyProbe, } #[derive(Debug)] diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index 84cbf85..b2412f0 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -1,14 +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, Inventory, Source}; +#[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, Source}; +#[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, Source::RequiredCom1); + #[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, Source::RequiredCom1); + } else if present { + inventory.add(address, None, Source::LegacyProbe); + } + } +} + +/// 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 28af9e79934d42fa7108ac64ef737f180342e8f4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 06/20] 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. --- real-hw-test/src/device.rs | 10 +- real-hw-test/src/discovery.rs | 7 +- real-hw-test/src/discovery/acpi.rs | 213 +++++++++++++++++++++++++++++ 3 files changed, 227 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 13e0bc2..dd50a97 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 Source { RequiredCom1, #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, + AcpiSpcr, } #[derive(Debug)] diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index b2412f0..5615a53 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, Source}; #[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..8e5a562 --- /dev/null +++ b/real-hw-test/src/discovery/acpi.rs @@ -0,0 +1,213 @@ +//! Conservative ACPI SPCR discovery for firmware-described serial consoles. +//! +//! SPCR matters where a debug UART is not at a conventional COM address. Strict +//! validation prevents treating an incompatible layout as a 16550 device. + +use core::slice; + +use uefi::system; +use uefi::table::cfg::ConfigTableEntry; + +use crate::device::{Address, Inventory, Source}; +use crate::uefi; + +const SDT_HEADER_LEN: usize = 36; +const MAX_TABLE_LEN: usize = 1024 * 1024; + +/// Locates SPCR from UEFI configuration tables and safely skips invalid data. +pub fn discover(inventory: &mut Inventory) { + uefi::println!("\nACPI SPCR discovery:"); + let rsdp = system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) + .or_else(|| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) + }) + .map(|entry| entry.address as usize) + }); + + let Some(rsdp) = rsdp else { + uefi::println!(" SKIP: no ACPI RSDP in the UEFI configuration table"); + return; + }; + + match find_spcr(rsdp) { + Ok(Some(spcr)) => add_spcr(inventory, spcr), + Ok(None) => uefi::println!(" SKIP: no SPCR table"), + Err(reason) => uefi::println!(" SKIP: invalid ACPI data: {reason}"), + } +} + +/// The SPCR subset needed to validate and add a byte-access UART candidate. +#[derive(Clone, Copy)] +struct SpcrInfo { + interface: u8, + address_space: u8, + bit_width: u8, + bit_offset: u8, + access_size: u8, + base: u64, + clock_hz: Option, +} + +/// Accepts only SPCR layouts that the byte-oriented driver can safely access. +fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { + uefi::println!( + concat!( + " interface=0x{:02x} space={} base=0x{:x} width={} ", + "offset={} access={} clock={:?}", + ), + spcr.interface, + spcr.address_space, + spcr.base, + spcr.bit_width, + spcr.bit_offset, + spcr.access_size, + spcr.clock_hz + ); + + if !matches!(spcr.interface, 0x00 | 0x01 | 0x12) { + uefi::println!(" SKIP: SPCR interface is not 16450/16550-compatible"); + return; + } + if spcr.bit_offset != 0 || !matches!(spcr.bit_width, 0 | 8) { + uefi::println!(" SKIP: UART registers are not byte-aligned byte fields"); + return; + } + if !matches!(spcr.access_size, 0 | 1) { + uefi::println!(" SKIP: uart_16550 requires byte register accesses"); + return; + } + + let address = match spcr.address_space { + 0 if spcr.base <= (usize::MAX - 7) as u64 => Address::Mmio { + base: spcr.base as usize, + stride: 1, + }, + 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; + } + }; + + uefi::println!(" candidate: {address}"); + inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); +} + +/// Validates RSDP and XSDT/RSDT data before finding and decoding an SPCR table. +fn find_spcr(rsdp_address: usize) -> Result, &'static str> { + let rsdp = acpi_bytes(rsdp_address, 36)?; + if &rsdp[..8] != b"RSD PTR " || !checksum_ok(&rsdp[..20]) { + return Err("bad RSDP signature or checksum"); + } + + let revision = rsdp[15]; + let (root_address, entry_size) = if revision >= 2 { + let length = read_u32(rsdp, 20) as usize; + if !(36..=4096).contains(&length) { + return Err("invalid RSDP length"); + } + let full = acpi_bytes(rsdp_address, length)?; + if !checksum_ok(full) { + return Err("bad extended RSDP checksum"); + } + (read_u64(full, 24) as usize, 8) + } else { + (read_u32(rsdp, 16) as usize, 4) + }; + + let root = sdt(root_address)?; + let expected = if entry_size == 8 { b"XSDT" } else { b"RSDT" }; + if &root[..4] != expected { + return Err("root table has the wrong signature"); + } + + for entry in root[SDT_HEADER_LEN..].chunks_exact(entry_size) { + let address = if entry_size == 8 { + read_u64(entry, 0) as usize + } else { + read_u32(entry, 0) as usize + }; + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + if &header[..4] != b"SPCR" { + continue; + } + let table = sdt(address)?; + if table.len() < 80 { + return Err("SPCR is too short"); + } + let clock = read_u32(table, 76); + return Ok(Some(SpcrInfo { + interface: table[36], + address_space: table[40], + bit_width: table[41], + bit_offset: table[42], + access_size: table[43], + base: read_u64(table, 44), + clock_hz: (clock != 0).then_some(clock), + })); + } + Ok(None) +} + +/// Borrows mapped firmware ACPI memory after rejecting a null physical address. +fn acpi_bytes(address: usize, length: usize) -> Result<&'static [u8], &'static str> { + if address == 0 { + return Err("null ACPI table address"); + } + // SAFETY: UEFI keeps firmware ACPI memory mapped while boot services run. + Ok(unsafe { slice::from_raw_parts(address as *const u8, length) }) +} + +/// Validates an SDT's declared bounded length and complete ACPI checksum. +fn sdt(address: usize) -> Result<&'static [u8], &'static str> { + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + let length = read_u32(header, 4) as usize; + if !(SDT_HEADER_LEN..=MAX_TABLE_LEN).contains(&length) { + return Err("invalid SDT length"); + } + let table = acpi_bytes(address, length)?; + checksum_ok(table) + .then_some(table) + .ok_or("bad SDT checksum") +} + +/// Applies ACPI's wrapping-byte checksum rule to one complete table region. +fn checksum_ok(bytes: &[u8]) -> bool { + bytes.iter().fold(0_u8, |sum, byte| sum.wrapping_add(*byte)) == 0 +} + +/// Decodes a bounds-checked little-endian 32-bit ACPI field without raw offsets. +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + let value = bytes[offset..offset + 4] + .try_into() + .expect("caller validated ACPI field bounds"); + u32::from_le_bytes(value) +} + +/// Decodes a bounds-checked little-endian 64-bit ACPI field without raw offsets. +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + let value = bytes[offset..offset + 8] + .try_into() + .expect("caller validated ACPI field bounds"); + u64::from_le_bytes(value) +} From 4eb0fab4d72686a9d0df35dac1edc38b3573a021 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:43:18 +0200 Subject: [PATCH 07/20] 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. --- real-hw-test/src/device.rs | 6 ++ real-hw-test/src/discovery.rs | 8 +- real-hw-test/src/discovery/pci.rs | 169 ++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 real-hw-test/src/discovery/pci.rs diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index dd50a97..a67cc27 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -35,6 +35,12 @@ pub enum Source { #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, AcpiSpcr, + Pci { + segment: u32, + bus: u8, + device: u8, + function: u8, + }, } #[derive(Debug)] diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index 5615a53..a35f554 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -1,7 +1,7 @@ -//! 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 and dynamically described +//! UARTs, including QEMU's independent PCI serial controller. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use uart_16550::Uart16550; @@ -13,6 +13,7 @@ use crate::device::{Address, Source}; use crate::uefi; mod acpi; +mod pci; /// Combines every discovery source into a deduplicated test inventory. pub fn discover() -> Inventory { @@ -20,6 +21,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/pci.rs b/real-hw-test/src/discovery/pci.rs new file mode 100644 index 0000000..3f467ef --- /dev/null +++ b/real-hw-test/src/discovery/pci.rs @@ -0,0 +1,169 @@ +//! Conservative PCI serial-controller discovery through UEFI root bridges. +//! +//! It verifies BAR-backed PIO/MMIO paths and gives QEMU a device independent of +//! legacy COM1. + +use alloc::vec::Vec; + +use uefi::Status; +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams}; +use uefi::proto::pci::PciIoAddress; +use uefi::proto::pci::root_bridge::PciRootBridgeIo; + +use crate::device::{Address, Inventory, Source}; +use crate::uefi; + +/// Opens each root bridge read-only and searches it for serial-class endpoints. +pub fn discover(inventory: &mut Inventory) { + uefi::println!("\nPCI serial-controller discovery:"); + let handles = match boot::find_handles::() { + Ok(handles) => handles, + Err(error) if error.status() == Status::NOT_FOUND => { + uefi::println!(" SKIP: no PCI root bridge protocol"); + return; + } + Err(error) => { + uefi::println!(" SKIP: PCI root bridge lookup failed: {error:?}"); + return; + } + }; + + for handle in handles { + let params = OpenProtocolParams { + handle, + agent: boot::image_handle(), + controller: None, + }; + let root = { + // SAFETY: GetProtocol is read-only and firmware retains the interface. + unsafe { + boot::open_protocol::(params, OpenProtocolAttributes::GetProtocol) + } + }; + match root { + Ok(mut root) => discover_root(&mut root, inventory), + Err(error) => uefi::println!(" root bridge open failed: {error:?}"), + } + } +} + +/// Enumerates one segment and forwards serial-class functions for BAR inspection. +fn discover_root(root: &mut PciRootBridgeIo, inventory: &mut Inventory) { + let segment = root.segment_nr(); + let tree = match root.enumerate() { + Ok(tree) => tree, + Err(error) => { + uefi::println!(" segment {segment}: enumeration failed: {error:?}"); + return; + } + }; + let addresses: Vec<_> = tree.iter().copied().collect(); + + for address in addresses { + let Ok(class_register) = config_u32(root, address, 0x08) else { + continue; + }; + let class = (class_register >> 24) as u8; + let subclass = (class_register >> 16) as u8; + if class != 0x07 || subclass != 0x00 { + continue; + } + + inspect_serial_controller(root, segment, address, class_register, inventory); + } +} + +/// Validates one endpoint's interface, decoding state, and BAR0 before using it. +fn inspect_serial_controller( + root: &mut PciRootBridgeIo, + segment: u32, + address: PciIoAddress, + class_register: u32, + inventory: &mut Inventory, +) { + let identity = config_u32(root, address, 0x00).unwrap_or(u32::MAX); + let command = config_u16(root, address, 0x04).unwrap_or(0); + let header_type = config_u8(root, address, 0x0e).unwrap_or(0xff) & 0x7f; + let prog_if = (class_register >> 8) as u8; + let bar0 = config_u32(root, address, 0x10).unwrap_or(0); + let bar1 = config_u32(root, address, 0x14).unwrap_or(0); + let vendor = identity as u16; + let device_id = (identity >> 16) as u16; + let (bus, device, function) = (address.bus, address.dev, address.fun); + + uefi::println!( + concat!( + " {:04x}:{:02x}:{:02x}.{}: ", + "{:04x}:{:04x} prog-if=0x{:02x} ", + "command=0x{:04x} BAR0=0x{:08x}", + ), + segment, + bus, + device, + function, + vendor, + device_id, + prog_if, + command, + bar0, + ); + if header_type != 0 || !(0x02..=0x06).contains(&prog_if) { + uefi::println!(" SKIP: not an unambiguous 16550-compatible endpoint"); + return; + } + + let candidate = if bar0 & 1 != 0 { + let base = bar0 & !0x3; + if command & 1 == 0 || base > u32::from(u16::MAX - 7) { + None + } else { + Some(Address::Port(base as u16)) + } + } else { + let memory_type = (bar0 >> 1) & 0x3; + let base = match memory_type { + 0 => u64::from(bar0 & !0xf), + 2 => (u64::from(bar1) << 32) | u64::from(bar0 & !0xf), + _ => 0, + }; + if command & 2 == 0 || base == 0 || base > usize::MAX as u64 { + None + } else { + Some(Address::Mmio { + base: base as usize, + stride: 1, + }) + } + }; + + let Some(candidate) = candidate else { + uefi::println!(" SKIP: BAR0 is disabled, invalid, or unsupported"); + return; + }; + uefi::println!(" candidate: {candidate}"); + inventory.add( + candidate, + None, + Source::Pci { + segment, + bus, + device, + function, + }, + ); +} + +/// Reads one byte from PCI configuration space through the root bridge. +fn config_u8(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} + +/// Reads one 16-bit PCI configuration value through the root bridge. +fn config_u16(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} + +/// Reads one 32-bit PCI configuration value through the root bridge. +fn config_u32(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { + root.pci().read_one(address.with_register(offset)) +} From d759a2a6e18af146a91a4aa0fb6f7d0a5a04bee8 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:10:58 +0200 Subject: [PATCH 08/20] 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 | 262 ++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 24 ++- 2 files changed, 284 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..38f25c2 --- /dev/null +++ b/real-hw-test/src/driver_test.rs @@ -0,0 +1,262 @@ +//! 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!( + concat!( + " {}: IER={:?} ISR={:?} LCR={:?} MCR={:?} ", + "LSR={:?} MSR={:?} SPR={:02x} DLL={:02x} DLM={:02x}", + ), + label, + dump.ier, + dump.isr, + dump.lcr, + dump.mcr, + dump.lsr, + dump.msr, + dump.spr, + dump.dll, + dump.dlm + ); +} + +/// Reports failures that occur before a driver can be retained for diagnostics. +fn fail(stage: &str, error: &str) -> Result { + uefi::println!(" FAIL: {stage}: {error}"); + Result { + passed: false, + connection_warning: false, + driver: None, + } +} + +/// Retains a constructed driver after failure without allowing interactive use. +fn failed_driver(driver: Driver) -> Result { + Result { + passed: false, + connection_warning: false, + driver: Some(driver), + } +} diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index 0016164..4bdffed 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -17,6 +17,7 @@ mod uefi { mod device; mod discovery; +mod driver_test; mod firmware; use uefi::prelude::*; @@ -50,7 +51,26 @@ fn main() -> Status { candidate.sources ); } - 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 c9a0a8f95bf1494d82143b26496e9c98185d4383 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:47:44 +0200 Subject: [PATCH 09/20] 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 | 12 +++++ real-hw-test/src/interactive.rs | 94 +++++++++++++++++++++++++++++++++ real-hw-test/src/main.rs | 20 ++++++- 3 files changed, 124 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 38f25c2..8d6f6fc 100644 --- a/real-hw-test/src/driver_test.rs +++ b/real-hw-test/src/driver_test.rs @@ -26,6 +26,7 @@ pub enum Driver { pub struct Result { pub passed: bool, pub connection_warning: bool, + pub interactive_skipped: bool, pub driver: Option, } @@ -123,6 +124,14 @@ 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 +197,7 @@ fn run_one(candidate: &Candidate) -> Result { Result { passed: true, connection_warning, + interactive_skipped: false, driver: Some(driver), } } @@ -248,6 +258,7 @@ fn fail(stage: &str, error: &str) -> Result { Result { passed: false, connection_warning: false, + interactive_skipped: false, driver: None, } } @@ -257,6 +268,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 4bdffed..d180399 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -19,6 +19,7 @@ mod device; mod discovery; mod driver_test; mod firmware; +mod interactive; use uefi::prelude::*; @@ -52,7 +53,8 @@ fn main() -> Status { ); } - 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() @@ -62,10 +64,24 @@ fn main() -> Status { .iter() .filter(|result| result.driver.is_some()) .count(); + let skipped = drivers + .iter() + .filter(|result| result.interactive_skipped) + .count(); uefi::println!( - "\nAutomatic summary: {passed}/{} passed, {warnings} connection warning(s), {initialized} initialized.", + "\nFinal summary: {passed}/{} passed, {warnings} connection warning(s), {skipped} interactive skip(s), {initialized} initialized.", drivers.len() ); + for (index, (candidate, result)) in inventory.candidates().iter().zip(&drivers).enumerate() { + let status = if !result.passed { + "FAIL" + } else if result.connection_warning || result.interactive_skipped { + "WARN" + } else { + "PASS" + }; + uefi::println!(" [{index}] {status}: {}", candidate.address); + } uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); if passed == drivers.len() { From 3b850b97d640c33ee58b5bcd5e886a7333fd1271 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 14:12:59 +0200 Subject: [PATCH 10/20] real-hw-test: disable firmware watchdog Manual hardware checks can wait forever for an operator. Disable the UEFI image watchdog so a long session does not reset the machine. Report firmware failures on screen. --- real-hw-test/src/firmware.rs | 9 +++++++++ real-hw-test/src/main.rs | 1 + 2 files changed, 10 insertions(+) diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs index ac8f0ac..933231c 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 d180399..f75c9ab 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -36,6 +36,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 07a1f71cf7ad512c6ff33ce9d187d4d1d2691bad Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:34:38 +0200 Subject: [PATCH 11/20] real-hw-test: persist diagnostics on the boot volume Mirror every test diagnostic to a dated file below /uart_16550_test_logs while retaining UEFI console output. Keep one flushed FAT file handle so a failed write is reported as critical and aborts the test instead of losing failure evidence. 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 | 16 ++++- 4 files changed, 244 insertions(+), 5 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 28d1904..6b5deb8 100644 --- a/real-hw-test/Cargo.lock +++ b/real-hw-test/Cargo.lock @@ -8,6 +8,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -20,12 +26,91 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "jiff-core", + "jiff-static", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -86,10 +171,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "uart-16550-real-hw-test" version = "0.1.0" dependencies = [ + "jiff", "uart_16550", "uefi", ] @@ -98,7 +204,7 @@ dependencies = [ name = "uart_16550" version = "0.8.0" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -116,8 +222,9 @@ version = "0.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..06cbc9c --- /dev/null +++ b/real-hw-test/src/logging.rs @@ -0,0 +1,117 @@ +//! Fail-closed screen and file diagnostics for one integration-test run. +//! +//! Persisting the screen transcript makes a physical-hardware failure +//! inspectable after reboot. A write failure aborts instead of silently losing +//! diagnostics that are needed to interpret the hardware result. + +use alloc::format; +use alloc::string::String; +use core::cell::UnsafeCell; +use core::fmt::{Arguments, Write}; + +use jiff::civil::DateTime; +use uefi::boot; +use uefi::fs::PathBuf; +use uefi::proto::media::file::{File, FileAttribute, FileMode, RegularFile}; +use uefi::runtime; + +/// Owns the opened log file and flushes each diagnostic before displaying it. +struct Logger { + file: RegularFile, +} + +/// Holds the single logger used by this synchronous, interrupt-free test. +struct LoggerSlot(UnsafeCell>); + +// SAFETY: The test is synchronous and deliberately does not enable interrupts, +// so no concurrent caller can access the logger. +unsafe impl Sync for LoggerSlot {} + +/// Stores the logger after initialization and before the first test diagnostic. +static LOGGER: LoggerSlot = LoggerSlot(UnsafeCell::new(None)); + +/// Creates the dated log file on the volume that contains this UEFI image. +pub fn init() -> Result<(), &'static str> { + let time = runtime::get_time().map_err(|_| "could not read UEFI time")?; + let time = DateTime::try_from(time).map_err(|_| "UEFI time is invalid")?; + let file_name = format!( + "uart_16550_{}_{:04}-{:02}-{:02}_{:02}-{:02}-{:02}.txt", + 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 f75c9ab..ce8c634 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -10,16 +10,26 @@ extern crate alloc; extern crate uefi as uefi_rs; -/// Routes every UEFI diagnostic through one crate-local indirection point. +/// Routes existing UEFI diagnostics through the fail-closed test logger. mod uefi { + pub use crate::test_println as println; pub use uefi_rs::*; } +/// Mirrors UEFI diagnostics to the screen and the test-run log file. +#[macro_export] +macro_rules! test_println { + ($($arg:tt)*) => { + $crate::logging::println(core::format_args!($($arg)*)) + }; +} + mod device; mod discovery; mod driver_test; mod firmware; mod interactive; +mod logging; use uefi::prelude::*; @@ -35,6 +45,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 1f5bb3c23c1e53f0f53cbe67c069a1ad3d7e2cbe Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:41:38 +0200 Subject: [PATCH 12/20] real-hw-test: bound nonblocking transmit checks A real UART can still be draining the byte accepted by try_send_byte when send_bytes is called. Retry the nonblocking API for one second instead of treating temporary backpressure as a driver failure. Preserve an earlier modem-signal warning if a later driver check fails. --- real-hw-test/src/driver_test.rs | 60 +++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs index 8d6f6fc..03a2feb 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 { @@ -124,6 +128,7 @@ impl Driver { Self::Mmio(uart) => uart.send_bytes_exact(bytes), } } + /// Polls one received byte so interactive checks never block keyboard input. pub fn try_receive_byte(&mut self) -> core::result::Result { match self { @@ -153,7 +158,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"); @@ -161,20 +166,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() { @@ -190,7 +195,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"); @@ -209,16 +215,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() @@ -264,10 +296,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 ca3ac571e1bcb3c0872089fbc1a55a1fe9801006 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 17:42:33 +0200 Subject: [PATCH 13/20] real-hw-test: report persisted log location Report the dated USB-drive log path before each normal test exit. This keeps the result on screen when the automated checks fail as well as when they complete successfully. --- real-hw-test/src/logging.rs | 22 +++++++++++++++++++--- real-hw-test/src/main.rs | 15 +++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/real-hw-test/src/logging.rs b/real-hw-test/src/logging.rs index 06cbc9c..fd840c1 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 ce8c634..363ca8c 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -54,6 +54,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; } @@ -84,8 +85,16 @@ fn main() -> Status { .filter(|result| result.interactive_skipped) .count(); uefi::println!( - "\nFinal summary: {passed}/{} passed, {warnings} connection warning(s), {skipped} interactive skip(s), {initialized} initialized.", - drivers.len() + concat!( + "\nFinal summary: {}/{} passed, {} connection ", + "warning(s), {} interactive skip(s), {} ", + "initialized.", + ), + passed, + drivers.len(), + warnings, + skipped, + initialized, ); for (index, (candidate, result)) in inventory.candidates().iter().zip(&drivers).enumerate() { let status = if !result.passed { @@ -97,6 +106,8 @@ fn main() -> Status { }; uefi::println!(" [{index}] {status}: {}", candidate.address); } + + logging::report_location(); uefi::println!("Press Enter to return to firmware."); firmware::wait_for_enter(); if passed == drivers.len() { From ea7d5eff8b8c9d16bf3c41c106a19e94ed42b416 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:20 +0200 Subject: [PATCH 14/20] real-hw-test: reach PCI I/O BARs through the ACPI I/O window Without x86 port instructions, PCI I/O space is a memory-mapped window behind the root bridge. Firmware hides the CPU-side base inside its protocol implementation, but AML resource templates embed plain address space descriptors, so a strictly validated DSDT byte scan recovers the translated window without an AML interpreter. Firmware also leaves the decoding of endpoints it never binds disabled; an assigned BAR of an unambiguous UART is therefore enabled explicitly. With the I/O window in place the aarch64 target builds, so 'make check' lints both targets. --- real-hw-test/Makefile | 3 +- real-hw-test/src/discovery/acpi.rs | 159 +++++++++++++++++++++++------ real-hw-test/src/discovery/pci.rs | 61 ++++++++++- 3 files changed, 191 insertions(+), 32 deletions(-) diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile index e1053de..62a6f4e 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/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index 8e5a562..bad6745 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -17,19 +17,7 @@ const MAX_TABLE_LEN: usize = 1024 * 1024; /// Locates SPCR from UEFI configuration tables and safely skips invalid data. pub fn discover(inventory: &mut Inventory) { uefi::println!("\nACPI SPCR discovery:"); - let rsdp = system::with_config_table(|tables| { - tables - .iter() - .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) - .or_else(|| { - tables - .iter() - .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) - }) - .map(|entry| entry.address as usize) - }); - - let Some(rsdp) = rsdp else { + let Some(rsdp) = rsdp() else { uefi::println!(" SKIP: no ACPI RSDP in the UEFI configuration table"); return; }; @@ -41,6 +29,21 @@ pub fn discover(inventory: &mut Inventory) { } } +/// Returns the RSDP address from the UEFI configuration table, preferring ACPI 2. +fn rsdp() -> Option { + system::with_config_table(|tables| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI2_GUID) + .or_else(|| { + tables + .iter() + .find(|entry| entry.guid == ConfigTableEntry::ACPI_GUID) + }) + .map(|entry| entry.address as usize) + }) +} + /// The SPCR subset needed to validate and add a byte-access UART candidate. #[derive(Clone, Copy)] struct SpcrInfo { @@ -113,8 +116,31 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); } -/// Validates RSDP and XSDT/RSDT data before finding and decoding an SPCR table. +/// Finds and decodes an SPCR table, requiring the fields this test consumes. fn find_spcr(rsdp_address: usize) -> Result, &'static str> { + let Some(table) = find_table(rsdp_address, b"SPCR")? else { + return Ok(None); + }; + if table.len() < 80 { + return Err("SPCR is too short"); + } + let clock = read_u32(table, 76); + Ok(Some(SpcrInfo { + interface: table[36], + address_space: table[40], + bit_width: table[41], + bit_offset: table[42], + access_size: table[43], + base: read_u64(table, 44), + clock_hz: (clock != 0).then_some(clock), + })) +} + +/// Validates RSDP and XSDT/RSDT data before returning one table by signature. +fn find_table( + rsdp_address: usize, + signature: &[u8; 4], +) -> Result, &'static str> { let rsdp = acpi_bytes(rsdp_address, 36)?; if &rsdp[..8] != b"RSD PTR " || !checksum_ok(&rsdp[..20]) { return Err("bad RSDP signature or checksum"); @@ -148,27 +174,102 @@ fn find_spcr(rsdp_address: usize) -> Result, &'static str> { read_u32(entry, 0) as usize }; let header = acpi_bytes(address, SDT_HEADER_LEN)?; - if &header[..4] != b"SPCR" { + if &header[..4] != signature { continue; } - let table = sdt(address)?; - if table.len() < 80 { - return Err("SPCR is too short"); - } - let clock = read_u32(table, 76); - return Ok(Some(SpcrInfo { - interface: table[36], - address_space: table[40], - bit_width: table[41], - bit_offset: table[42], - access_size: table[43], - base: read_u64(table, 44), - clock_hz: (clock != 0).then_some(clock), - })); + return sdt(address).map(Some); } Ok(None) } +/// The CPU-visible MMIO window ACPI declares for the PCI I/O address space. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IoWindow { + pub pci_min: u64, + pub pci_max: u64, + pub cpu_base: u64, +} + +/// Recovers the PCI I/O window translation from the DSDT's resource bytes. +/// +/// AML resource templates embed plain ACPI address-space descriptors, so a +/// strictly validated byte scan finds the root bridge's translated I/O range +/// without an AML interpreter. Ambiguous DSDTs yield no window. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub fn pci_io_window() -> Option { + let fadt = find_table(rsdp()?, b"FACP").ok().flatten()?; + let dsdt_address = if fadt.len() >= 148 && read_u64(fadt, 140) != 0 { + read_u64(fadt, 140) as usize + } else if fadt.len() >= 44 { + read_u32(fadt, 40) as usize + } else { + return None; + }; + let dsdt = sdt(dsdt_address).ok()?; + + let mut found: Option = None; + let mut offset = 0; + while offset < dsdt.len() { + let (window, size) = match parse_io_descriptor(&dsdt[offset..]) { + Some(parsed) => parsed, + None => { + offset += 1; + continue; + } + }; + offset += size; + match found { + None => found = Some(window), + Some(previous) if previous == window => {} + Some(_) => return None, + } + } + found +} + +/// Decodes one translated DWord/QWord I/O descriptor at the slice's start. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn parse_io_descriptor(bytes: &[u8]) -> Option<(IoWindow, usize)> { + const DWORD_IO: (u8, u16, usize) = (0x87, 23, 4); + const QWORD_IO: (u8, u16, usize) = (0x8a, 43, 8); + const TYPE_IO: u8 = 1; + + let (_, body_len, field_size) = [DWORD_IO, QWORD_IO] + .into_iter() + .find(|(tag, _, _)| bytes.first() == Some(tag))?; + let size = 3 + usize::from(body_len); + if bytes.len() < size + || u16::from_le_bytes([bytes[1], bytes[2]]) != body_len + || bytes[3] != TYPE_IO + { + return None; + } + let field = |index: usize| { + let offset = 6 + index * field_size; + if field_size == 8 { + read_u64(bytes, offset) + } else { + u64::from(read_u32(bytes, offset)) + } + }; + let (pci_min, pci_max, translation, length) = (field(1), field(2), field(3), field(4)); + + // Only an arithmetically consistent, actually translated window is usable. + let consistent = pci_min <= pci_max + && length == pci_max - pci_min + 1 + && translation != 0 + && translation.checked_add(pci_max).is_some(); + consistent.then_some(( + IoWindow { + pci_min, + pci_max, + cpu_base: pci_min + translation, + }, + size, + )) +} + /// Borrows mapped firmware ACPI memory after rejecting a null physical address. fn acpi_bytes(address: usize, length: usize) -> Result<&'static [u8], &'static str> { if address == 0 { diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs index 3f467ef..5f38ed0 100644 --- a/real-hw-test/src/discovery/pci.rs +++ b/real-hw-test/src/discovery/pci.rs @@ -112,12 +112,27 @@ fn inspect_serial_controller( return; } + // Firmware enables decoding only for endpoints it binds a driver to; an + // otherwise valid UART may therefore arrive with its assigned BAR disabled. + let needed_enable: u16 = if bar0 & 1 != 0 { 0x0001 } else { 0x0002 }; + let command = if command & needed_enable == 0 { + match enable_decoding(root, address, command | needed_enable) { + Some(command) => command, + None => { + uefi::println!(" SKIP: could not enable BAR0 decoding"); + return; + } + } + } else { + command + }; + let candidate = if bar0 & 1 != 0 { let base = bar0 & !0x3; - if command & 1 == 0 || base > u32::from(u16::MAX - 7) { + if command & 1 == 0 { None } else { - Some(Address::Port(base as u16)) + io_bar_address(root, base) } } else { let memory_type = (bar0 >> 1) & 0x3; @@ -153,6 +168,48 @@ fn inspect_serial_controller( ); } +/// Sets a missing decode-enable bit and returns the verified command register. +fn enable_decoding(root: &mut PciRootBridgeIo, address: PciIoAddress, command: u16) -> Option { + root.pci() + .write_one(address.with_register(0x04), command) + .ok()?; + let command = config_u16(root, address, 0x04).ok()?; + uefi::println!(" enabled BAR0 decoding: command=0x{command:04x}"); + Some(command) +} + +/// Uses an I/O BAR directly: x86 port instructions reach PCI I/O space as-is. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +fn io_bar_address(_root: &mut PciRootBridgeIo, base: u32) -> Option
{ + (base <= u32::from(u16::MAX - 7)).then_some(Address::Port(base as u16)) +} + +/// Translates an I/O BAR into the platform's memory-mapped I/O window. +/// +/// Without port instructions, PCI I/O space is reached through an MMIO +/// aperture. Firmware hides its CPU-side base inside the root bridge protocol, +/// so the window is taken from the platform's ACPI description instead. +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn io_bar_address(_root: &mut PciRootBridgeIo, base: u32) -> Option
{ + let Some(window) = super::acpi::pci_io_window() else { + uefi::println!(" SKIP: no unambiguous ACPI PCI I/O window"); + return None; + }; + let base = u64::from(base); + if base < window.pci_min || base + 7 > window.pci_max { + uefi::println!(" SKIP: I/O BAR lies outside the ACPI I/O window"); + return None; + } + let translated = base + .checked_sub(window.pci_min)? + .checked_add(window.cpu_base)?; + uefi::println!(" I/O window translation: 0x{base:x} -> 0x{translated:x}"); + Some(Address::Mmio { + base: usize::try_from(translated).ok()?, + stride: 1, + }) +} + /// Reads one byte from PCI configuration space through the root bridge. fn config_u8(root: &mut PciRootBridgeIo, address: PciIoAddress, offset: u8) -> uefi::Result { root.pci().read_one(address.with_register(offset)) From a9ea5a4df06ba03b369c0293709874414500bb58 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:43:06 +0200 Subject: [PATCH 15/20] 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 | 242 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 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..2c691cf --- /dev/null +++ b/real-hw-test/README.md @@ -0,0 +1,242 @@ +# 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. + +All diagnostics use UEFI Simple Text Output and are intended to stay visible on +the test machine's monitor. They are also persisted, line by line, on the boot +volume as `/uart_16550_test_logs/uart_16550__YYYY-MM-DD_HH-MM-SS.txt`. A log +creation, write, or flush failure is critical and aborts the test. Bytes +written directly to a UART are deliberately short, recognizable test payloads. + +## TL;DR + +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 runs UARTs synchronously using polling. It disables UART +interrupts, installs no interrupt handler, and does **not** validate interrupt +delivery or interrupt-driven transmit/receive behavior. + +It also disables the UEFI image watchdog because an interactive serial phase +may legitimately take longer than the firmware's normal five-minute limit. A +failure to disable it is reported as a warning on screen. + +It discovers: + +- COM1 at `0x3f8` unconditionally (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 memory-mapped PCI I/O window that the platform's ACPI DSDT +declares; a missing or ambiguous window skips the device. + +## Recommended real-hardware setup + +Boot the application on an x86_64 machine (see "Architecture support" for the +state of other architectures) with: + +- UEFI firmware and Secure Boot disabled, unless you sign the application; +- a monitor connected to the machine; +- a USB keyboard for Enter/Escape navigation; +- a physical serial port connected to another machine using the required + RS-232/null-modem wiring or an appropriate USB serial cable; +- Linux and Minicom on the remote machine. + +For example, adjust the remote device name as needed: + +```console +minicom -D /dev/ttyUSB0 -b 9600 +``` + +Use 9600 baud, 8 data bits, no parity, one stop bit, and no hardware flow +control. Firmware may use a different rate before takeover. The application +prints the UEFI `SerialIo` mode and waits for Enter before switching the UARTs +to 9600 8N1. + +### Remote already connected + +1. Boot the USB media and watch the monitor. +2. Confirm the `UEFI SERIAL BASELINE` line also appears remotely when firmware + serial redirection is active. Absence is valid when firmware exposes no + serial console. +3. Set Minicom to 9600 8N1 and press Enter on the test machine's USB keyboard. +4. Confirm the `[uart_16550]` payload appears remotely. +5. Use the interactive commands below. + +### Connect or reconnect during the test + +It is also valid to start without the remote cable connected. Let the automatic +tests finish, connect the cable during the interactive phase, and then: + +- type `c` to inspect DSR/CTS and modem-status changes; +- type `r` before and after reconnecting to compare registers; +- type `t` to send a known line to Minicom; +- type another printable ASCII character to test receive and echo. + +Some USB serial and null-modem wiring does not expose DSR or CTS. A connection +warning is therefore diagnostic and does not fail otherwise working traffic. +Press Escape on the local USB keyboard or send byte `0x1b` from the remote +terminal to skip a UART that has no connected remote. + +## Build + +Install the Rust UEFI targets once if necessary: + +```console +rustup target add x86_64-unknown-uefi aarch64-unknown-uefi +``` + +Then build and stage the removable-media filename: + +```console +make artifact +file build/BOOTX64.EFI +``` + +The resulting file is `build/BOOTX64.EFI`. `make artifact ARCH=aarch64` +produces `build/BOOTAA64.EFI` instead; every `make` target accepts the same +`ARCH` variable. `make artifacts` cross-compiles every supported architecture +in one step. + +Run all static build checks with: + +```console +make check +``` + +## Run under QEMU + +The included Nix development shell supplies QEMU and OVMF: + +```console +nix develop +make qemu +``` + +The graphical QEMU window is the UEFI monitor and keyboard. COM1 is connected +to the terminal that launched QEMU. A `pci-serial` device is also present; QEMU +prints its `/dev/pts/...` path during startup. Open that PTY in a second terminal +to exercise PCI discovery and BAR-backed UART access: + +```console +minicom -D /dev/pts/NUMBER -b 9600 +``` + +Outside Nix, provide the combined OVMF image explicitly: + +```console +OVMF=/path/to/OVMF.fd make qemu +``` + +KVM is used by default. Use software emulation when KVM is unavailable: + +```console +make qemu-tcg +# equivalent: QEMU_ACCEL=tcg make qemu +``` + +`QEMU`, `QEMU_ARGS`, and `ESP_DIR` can override the executable, add QEMU +arguments, or relocate the temporary directory-backed EFI system partition. +QEMU data stays below the repository's ignored `target/real-hw-test/` tree. + +`make qemu ARCH=aarch64` runs the aarch64 build on QEMU's `virt` machine with +the EDK2 firmware bundled with QEMU (override via `AAVMF_CODE`/`AAVMF_VARS`). +The terminal shows the PL011 firmware console, which the application correctly +rejects as a UART candidate; the only 16550 is the `pci-serial` device, reached +through the memory-mapped PCI I/O window. TCG is the default accelerator for +the aarch64 guest. + +## Architecture support + +x86_64 is the primary target and the only one exercised on physical hardware so +far. aarch64 is fully validated under QEMU; on real aarch64 machines the test +is expected to find little today: + +- Server-class Arm platforms describe a PL011 or SBSA Generic UART in SPCR, + which is not 16550-compatible and is deliberately rejected. +- Boards whose EDK2 ports do describe a 16550 (for example RK3588) declare + 32-bit registers at stride 4; the driver only performs byte accesses, so + such SPCR layouts are rejected as well. +- Boards booting through U-Boot's EFI implementation publish a device tree + instead of ACPI; the test has no device-tree discovery. + +riscv64 is currently not supported because Rust has no riscv64 UEFI target; +building would require a custom target JSON on nightly with `-Zbuild-std`. +QEMU's riscv64 `virt` machine would otherwise be a good fit: its ns16550a is +MMIO-mapped and described by an SPCR with the 16550 interface type. + +## Reading the test output + +The UEFI monitor is authoritative. Before takeover, `UEFI SERIAL BASELINE` +confirms firmware still owns the serial output. After controllers are +disconnected, remote output may stop; continue reading diagnostics on the +monitor. + +Good signs are: + +- `PASS` for 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 can be expected with a +three-wire or USB serial cable that does not provide modem-control lines. A +local or serial Escape skip is also a warning, not an automatic test failure. + +Investigate `FAIL`, `SKIP`, transmit or receive timeouts, a failed +`disconnect_controller`, an initialization/register/loopback mismatch, or a +final summary containing `FAIL`. Start with the candidate address, its reported +clock, 9600 8N1 settings, cable crossover and ground, and the remote terminal. + +## Interactive commands + +Commands are read from the UART currently named on the monitor: + +| Input | Expected result | +| --- | --- | +| `r` | Register snapshot appears on the UEFI screen. | +| `t` | `[interactive]` test line appears on the remote terminal. | +| `c` | Screen shows DSR/CTS status and a fresh register dump. | +| `l` | Screen reports `PASS: interactive loopback` or a failure. | +| `q` | This UART completes and the next candidate begins. | +| Printable ASCII | Screen shows the byte and the remote receives its echo. | +| Local Escape or serial `0x1b` | Skip this UART with a `WARN` diagnostic. | + +The final screen reports: + +- `PASS`: required automatic checks succeeded; +- `WARN`: automatic checks succeeded but connection signals were absent or the + interactive phase was skipped; +- `FAIL`: presence, initialization, register, loopback, or transmit readiness + failed. + +## Troubleshooting + +- No firmware baseline remotely: firmware may not expose or use `SerialIo`. + COM1 is still probed and tested after takeover. +- Garbled characters: confirm both ends use 9600 8N1 after the Enter prompt. +- No traffic: verify TX/RX crossover, common ground, RS-232 voltage conversion, + and whether a null-modem adapter is required. +- DSR/CTS warning with working bytes: the cable likely omits modem-control + lines; leave hardware flow control disabled. +- PCI controller is skipped: its programming interface, BAR, decoding state, + or layout was not safe to treat as a standard 16550 endpoint. +- QEMU does not start with KVM: use `make qemu-tcg`. From 8d135a391b32fd6bd6af3044d65d8bf547460be5 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 13:53:32 +0200 Subject: [PATCH 16/20] real-hw-test: "make install" to install on a USB stick --- real-hw-test/.gitignore | 2 +- real-hw-test/Makefile | 8 +- real-hw-test/README.md | 38 +++++- real-hw-test/flake.nix | 2 + real-hw-test/scripts/install-usb.sh | 174 ++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 3 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 62a6f4e..edf0c16 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 --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.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 2c691cf..69908b0 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -14,7 +14,8 @@ written directly to a UART are deliberately 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. + 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 @@ -120,6 +121,41 @@ Run all static build checks with: make check ``` +## Install on USB media + +Prepare and mount an EFI partition yourself. The install target intentionally +does not partition, format, mount, or unmount devices. It verifies that the +mount is backed by a partition on a GPT disk and that `lsblk` identifies the +filesystem as FAT32 before copying anything. + +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. That is also the way to reach +media the picker does not list, and scripted runs without a terminal require +it: + +```console +lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,PTTYPE,MOUNTPOINTS +make install USB_MOUNT=/run/media///EFI +``` + +Every artifact present in `build/` is copied to its removable-media path, so +one stick can boot every architecture built beforehand (for example with +`make artifacts`): `EFI/BOOT/BOOTX64.EFI`, `EFI/BOOT/BOOTAA64.EFI`, and so on. +If the disk is not GPT, the +filesystem is not FAT32, the path is not an exact mount point, or the mount is +not writable, installation stops with a diagnostic. Unmount the partition +cleanly before removing it. + ## Run under QEMU The included Nix development shell supplies QEMU and OVMF: diff --git a/real-hw-test/flake.nix b/real-hw-test/flake.nix index 779ea69..bbc108f 100644 --- a/real-hw-test/flake.nix +++ b/real-hw-test/flake.nix @@ -18,10 +18,12 @@ { default = pkgs.mkShell { packages = with pkgs; [ + fzf # The full QEMU: qemu_kvm carries only the host architecture's # system emulator, but the aarch64 test needs qemu-system-aarch64. qemu rustup + util-linux ]; env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; env.AAVMF_CODE = "${pkgs.qemu}/share/qemu/edk2-aarch64-code.fd"; diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh new file mode 100755 index 0000000..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 a48ac15b90acc31ac610fb9a5f79165b8db50732 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 16 Aug 2026 16:03:29 +0200 Subject: [PATCH 17/20] ci: smoke-test UEFI UART paths in QEMU Run the unmodified interactive image headlessly under TCG. The harness answers the operator prompts through QEMU-monitor sendkey and judges the run by the log persisted on the boot volume plus both serial captures. Require automatic checks for legacy COM1 and a PCI UART. --- .github/workflows/real-hw-test.yml | 45 +++++++ real-hw-test/Makefile | 5 +- real-hw-test/README.md | 22 ++++ real-hw-test/flake.nix | 3 + real-hw-test/scripts/run-qemu-ci.sh | 175 ++++++++++++++++++++++++++++ 5 files changed, 249 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 edf0c16..3392a74 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 --locked CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked -.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 69908b0..8150bf6 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -198,6 +198,28 @@ rejects as a UART candidate; the only 16550 is the `pci-serial` device, reached through the memory-mapped PCI I/O window. TCG is the default accelerator for the aarch64 guest. +### Headless CI smoke test + +`make ci-qemu` boots the same artifact as `make artifact` headlessly with QEMU +TCG; the application contains no CI-specific code. A host-side script answers +the operator prompts and skips the interactive phase through QEMU-monitor +`sendkey`, then judges the run by the log the application persists on its boot +volume and by the serial captures. It requires both legacy COM1 and the QEMU +PCI serial controller to be discovered and every deterministic +`uart_16550` check to pass. + +The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and OVMF; +the Nix development shell provides all of them. + +```console +make ci-qemu +``` + +This smoke test is useful for debugging the test application and preventing its +automatic QEMU paths from regressing. It does not replace the manual test of a +real cable, reconnect behavior, firmware-specific ownership handoff, or +physical hardware. + ## 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 bbc108f..12c0575 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.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh new file mode 100755 index 0000000..5bbfb1c --- /dev/null +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +qemu=${QEMU:-qemu-system-x86_64} +ovmf=${OVMF:-} +artifact=${1:-build/BOOTX64.EFI} +run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci} +timeout_s=${CI_TIMEOUT:-300} + +disk=$run_dir/disk.img +monitor=$run_dir/monitor.sock +com1_log=$run_dir/com1.log +pci_log=$run_dir/pci-serial.log +persisted_log=$run_dir/persisted.log + +if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 + exit 2 +fi +if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 +fi +for tool in "$qemu" socat truncate mkfs.vfat mmd mcopy; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: required tool not found: $tool" >&2 + exit 2 + fi +done +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi +if [[ -z "$run_dir" || "$run_dir" == "/" ]]; then + echo "error: refusing unsafe CI_RUN_DIR: $run_dir" >&2 + exit 2 +fi + +# A fresh boot image and logs ensure the result cannot come from a previous run. +rm -rf "$run_dir" +mkdir -p "$run_dir" + +# A real FAT image instead of QEMU's experimental fat:rw: directory makes the +# log that the application persists on its boot volume readable on the host. +truncate -s 64M "$disk" +mkfs.vfat "$disk" >/dev/null +mmd -i "$disk" ::/EFI ::/EFI/BOOT +mcopy -i "$disk" "$artifact" ::/EFI/BOOT/BOOTX64.EFI + +"$qemu" \ + -machine q35,accel=tcg \ + -m 256M \ + -bios "$ovmf" \ + -drive "format=raw,file=$disk" \ + -nic none \ + -display none \ + -monitor "unix:$monitor,server,nowait" \ + -serial "file:$com1_log" \ + -chardev "file,id=pci_serial,path=$pci_log" \ + -device pci-serial,chardev=pci_serial \ + -no-reboot & +qemu_pid=$! +trap 'kill -9 "$qemu_pid" 2>/dev/null || true' EXIT +deadline=$(($(date +%s) + timeout_s)) + +monitor_cmd() { + printf '%s\n' "$1" | socat -t 1 - "UNIX-CONNECT:$monitor" >/dev/null 2>&1 \ + || true +} + +qemu_alive() { + kill -0 "$qemu_pid" 2>/dev/null +} + +# Mid-run extraction from the live FAT image is best-effort progress polling; +# only the extraction after QEMU quit is authoritative. +extract_persisted_log() { + rm -rf "$run_dir/logs" + mkdir -p "$run_dir/logs" + MTOOLS_SKIP_CHECK=1 mcopy -n -s -i "$disk" ::/uart_16550_test_logs \ + "$run_dir/logs/" >/dev/null 2>&1 || true + cat "$run_dir/logs/uart_16550_test_logs"/*.txt 2>/dev/null || true +} + +dump_logs() { + if [[ -s "$persisted_log" ]]; then + echo "--- $persisted_log ---" >&2 + cat "$persisted_log" >&2 + fi + for log in "$com1_log" "$pci_log"; do + if [[ -s "$log" ]]; then + echo "--- $log ---" >&2 + sed -n '1,200p' "$log" >&2 + fi + done +} + +fail_run() { + extract_persisted_log > "$persisted_log" + dump_logs + echo "FAIL: $1" >&2 + exit 1 +} + +# The application persists every line before displaying it, so the extracted +# log doubles as the progress signal. No key is sent before the first operator +# prompt: an Escape while OVMF still owns the keyboard would enter firmware +# setup instead of the boot target. +until extract_persisted_log | grep -qF 'then press Enter.'; do + qemu_alive || fail_run "QEMU exited before the first operator prompt" + if (($(date +%s) >= deadline)); then + fail_run "timeout waiting for the first operator prompt" + fi + sleep 2 +done + +# Enter satisfies both operator confirmations, which discard every other key, +# and Escape skips each per-UART interactive phase, which ignores Enter. +# Blindly alternating both keys drives the application to its final prompt. +while (($(date +%s) < deadline)); do + if extract_persisted_log | grep -qF 'Press Enter to return to firmware.'; then + break + fi + # A queued Enter can finish the final prompt early; the persisted log then + # already contains every line the assertions below need. + qemu_alive || break + monitor_cmd 'sendkey ret' + sleep 1 + monitor_cmd 'sendkey esc' + sleep 1 +done + +# A monitor quit lets QEMU commit the final FAT state before extraction. +monitor_cmd quit +for _ in $(seq 15); do + qemu_alive || break + sleep 1 +done +kill -9 "$qemu_pid" 2>/dev/null || true +wait "$qemu_pid" 2>/dev/null || true + +extract_persisted_log > "$persisted_log" +if ! grep -qF 'Press Enter to return to firmware.' "$persisted_log"; then + dump_logs + echo "FAIL: the application did not reach its final prompt in ${timeout_s}s" >&2 + exit 1 +fi + +failures=0 +assert_log() { + local file=$1 + shift + if ! grep -q "$@" "$file"; then + echo "FAIL: expected $file to match: $*" >&2 + failures=$((failures + 1)) + fi +} + +# The same requirements the guest previously verified itself: COM1 and the PCI +# UART are both discovered and every automatic check passed. +assert_log "$persisted_log" -F 'PIO 0x03f8' +assert_log "$persisted_log" -F 'RequiredCom1' +assert_log "$persisted_log" -E 'sources=\[.*Pci' +assert_log "$persisted_log" -F 'Final summary: 2/2 passed' +assert_log "$persisted_log" -F '2 interactive skip(s)' +# The transmit payloads prove that bytes really left through both UARTs. +assert_log "$com1_log" -F '[uart_16550] uart transmit test' +assert_log "$pci_log" -F '[uart_16550] uart transmit test' + +if ((failures > 0)); then + dump_logs + exit 1 +fi + +echo "PASS: headless TCG run drove COM1 and PCI UART checks to completion" From 248e5ce9ecf8dea8f4024dbf90f374da102949d4 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Wed, 2 Sep 2026 10:09:30 +0200 Subject: [PATCH 18/20] ci: smoke-test aarch64 UEFI UART paths in QEMU The aarch64 virt machine has no 16550 except the PCI serial device, so the run must reject the PL011 console via SPCR and drive the PCI UART through the translated I/O window with the MMIO backend. --- .github/workflows/real-hw-test.yml | 56 +++++++++------ real-hw-test/Makefile | 3 +- real-hw-test/README.md | 13 ++-- real-hw-test/scripts/run-qemu-ci.sh | 108 ++++++++++++++++++++-------- 4 files changed, 123 insertions(+), 57 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 3392a74..efa5109 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 8150bf6..feef7e7 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -204,15 +204,18 @@ the aarch64 guest. TCG; the application contains no CI-specific code. A host-side script answers the operator prompts and skips the interactive phase through QEMU-monitor `sendkey`, then judges the run by the log the application persists on its boot -volume and by the serial captures. It requires both legacy COM1 and the QEMU -PCI serial controller to be discovered and every deterministic -`uart_16550` check to pass. +volume and by the serial captures. On x86_64 it requires both legacy COM1 and +the QEMU PCI serial controller to be discovered; on aarch64 it requires the +PL011 console to be rejected and the PCI UART to be driven through the +translated I/O window. Every deterministic `uart_16550` check must +pass. -The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and OVMF; -the Nix development shell provides all of them. +The harness needs `socat`, `mtools`, and `dosfstools` next to QEMU and the +firmware; the Nix development shell provides all of them. ```console make ci-qemu +make ci-qemu ARCH=aarch64 ``` This smoke test is useful for debugging the test application and preventing its diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh index 5bbfb1c..a5ddc32 100755 --- a/real-hw-test/scripts/run-qemu-ci.sh +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -1,26 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -qemu=${QEMU:-qemu-system-x86_64} +arch=${ARCH:-x86_64} +qemu=${QEMU:-qemu-system-$arch} ovmf=${OVMF:-} artifact=${1:-build/BOOTX64.EFI} -run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci} +run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci-$arch} timeout_s=${CI_TIMEOUT:-300} +boot_name=$(basename "$artifact") disk=$run_dir/disk.img monitor=$run_dir/monitor.sock -com1_log=$run_dir/com1.log +console_log=$run_dir/console.log pci_log=$run_dir/pci-serial.log persisted_log=$run_dir/persisted.log -if [[ -z "$ovmf" ]]; then - echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 - exit 2 -fi -if [[ ! -r "$ovmf" ]]; then - echo "error: OVMF firmware is not readable: $ovmf" >&2 - exit 2 -fi for tool in "$qemu" socat truncate mkfs.vfat mmd mcopy; do if ! command -v "$tool" >/dev/null 2>&1; then echo "error: required tool not found: $tool" >&2 @@ -36,26 +30,67 @@ if [[ -z "$run_dir" || "$run_dir" == "/" ]]; then exit 2 fi +# Per-architecture machine, firmware, and input configuration. Both guests get +# a PCI 16550; only q35 additionally has legacy COM1 and a built-in keyboard. +case "$arch" in +x86_64) + if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; set OVMF=/path/to/OVMF.fd" >&2 + exit 2 + fi + if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 + fi + machine_args=(-machine q35,accel=tcg -m 256M -bios "$ovmf") + ;; +aarch64) + # QEMU ships pflash-style EDK2 images next to its own installation. + share_dir=$(dirname "$(readlink -f "$(command -v "$qemu")")")/../share/qemu + aavmf_code=${AAVMF_CODE:-$share_dir/edk2-aarch64-code.fd} + aavmf_vars=${AAVMF_VARS:-$share_dir/edk2-arm-vars.fd} + for firmware in "$aavmf_code" "$aavmf_vars"; do + if [[ ! -r "$firmware" ]]; then + echo "error: aarch64 firmware is not readable: $firmware" >&2 + echo " set AAVMF_CODE and AAVMF_VARS" >&2 + exit 2 + fi + done + # The UEFI keyboard prompts need an input device; virt has none built in. + machine_args=(-machine virt,accel=tcg -cpu max -m 512M + -drive "if=pflash,format=raw,file=$aavmf_code,readonly=on" + -drive "if=pflash,format=raw,file=$run_dir/vars.fd" + -device qemu-xhci -device usb-kbd) + ;; +*) + echo "error: unsupported ARCH '$arch'; supported: x86_64, aarch64" >&2 + exit 2 + ;; +esac + # A fresh boot image and logs ensure the result cannot come from a previous run. rm -rf "$run_dir" mkdir -p "$run_dir" +if [[ "$arch" == aarch64 ]]; then + # Writable per-run variable store; the template may be read-only on disk. + cp "$aavmf_vars" "$run_dir/vars.fd" + chmod u+w "$run_dir/vars.fd" +fi # A real FAT image instead of QEMU's experimental fat:rw: directory makes the # log that the application persists on its boot volume readable on the host. truncate -s 64M "$disk" mkfs.vfat "$disk" >/dev/null mmd -i "$disk" ::/EFI ::/EFI/BOOT -mcopy -i "$disk" "$artifact" ::/EFI/BOOT/BOOTX64.EFI +mcopy -i "$disk" "$artifact" "::/EFI/BOOT/$boot_name" "$qemu" \ - -machine q35,accel=tcg \ - -m 256M \ - -bios "$ovmf" \ + "${machine_args[@]}" \ -drive "format=raw,file=$disk" \ -nic none \ -display none \ -monitor "unix:$monitor,server,nowait" \ - -serial "file:$com1_log" \ + -serial "file:$console_log" \ -chardev "file,id=pci_serial,path=$pci_log" \ -device pci-serial,chardev=pci_serial \ -no-reboot & @@ -87,7 +122,7 @@ dump_logs() { echo "--- $persisted_log ---" >&2 cat "$persisted_log" >&2 fi - for log in "$com1_log" "$pci_log"; do + for log in "$console_log" "$pci_log"; do if [[ -s "$log" ]]; then echo "--- $log ---" >&2 sed -n '1,200p' "$log" >&2 @@ -104,8 +139,8 @@ fail_run() { # The application persists every line before displaying it, so the extracted # log doubles as the progress signal. No key is sent before the first operator -# prompt: an Escape while OVMF still owns the keyboard would enter firmware -# setup instead of the boot target. +# prompt: an Escape while firmware still owns the keyboard would enter the +# firmware setup menu instead of the boot target. until extract_persisted_log | grep -qF 'then press Enter.'; do qemu_alive || fail_run "QEMU exited before the first operator prompt" if (($(date +%s) >= deadline)); then @@ -156,20 +191,33 @@ assert_log() { fi } -# The same requirements the guest previously verified itself: COM1 and the PCI -# UART are both discovered and every automatic check passed. -assert_log "$persisted_log" -F 'PIO 0x03f8' -assert_log "$persisted_log" -F 'RequiredCom1' -assert_log "$persisted_log" -E 'sources=\[.*Pci' -assert_log "$persisted_log" -F 'Final summary: 2/2 passed' -assert_log "$persisted_log" -F '2 interactive skip(s)' -# The transmit payloads prove that bytes really left through both UARTs. -assert_log "$com1_log" -F '[uart_16550] uart transmit test' -assert_log "$pci_log" -F '[uart_16550] uart transmit test' +# The per-architecture UART topology every run must fully discover and pass. +case "$arch" in +x86_64) + # Legacy COM1 plus the PCI UART, both driven through their captures. + assert_log "$persisted_log" -F 'PIO 0x03f8' + assert_log "$persisted_log" -F 'RequiredCom1' + assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F 'Final summary: 2/2 passed' + assert_log "$persisted_log" -F '2 interactive skip(s)' + assert_log "$console_log" -F '[uart_16550] uart transmit test' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' + ;; +aarch64) + # The PL011 console must be rejected; the PCI UART is reached through the + # ACPI-described I/O window and driven via the MMIO backend. + assert_log "$persisted_log" -F 'SKIP: SPCR interface is not 16450/16550-compatible' + assert_log "$persisted_log" -F 'I/O window translation:' + assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F 'Final summary: 1/1 passed' + assert_log "$persisted_log" -F '1 interactive skip(s)' + assert_log "$pci_log" -F '[uart_16550] uart transmit test' + ;; +esac if ((failures > 0)); then dump_logs exit 1 fi -echo "PASS: headless TCG run drove COM1 and PCI UART checks to completion" +echo "PASS: headless $arch TCG run drove the UART checks to completion" From 905650c7671959531cead8a6ae538f29bffbe040 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 17:46:00 +0200 Subject: [PATCH 19/20] 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 094020debc2614563ad74b76d113d64abece26a6 Mon Sep 17 00:00:00 2001 From: Philipp Schuster Date: Sun, 13 Sep 2026 19:19:28 +0200 Subject: [PATCH 20/20] real-hw-test: report where each UART lives and how it was found The candidate list only dumped the discovery enum, so an operator could not tell an on-board UART from an add-in card, or see that SPCR and PCI enumeration described the same function. Classify every candidate by location - built-in legacy port, built-in platform UART, or a PCI function with vendor/device IDs and whether it sits on the root bus or behind a bridge - separately from the paths that found it. SPCR's PCI identity fields (revision 2 and later) are parsed so a firmware console that is a PCI device is classified as one. PCI evidence wins when several paths describe the same address, and only enumeration knows the attachment. Known QEMU serial devices are named. --- real-hw-test/README.md | 10 ++ real-hw-test/scripts/run-qemu-ci.sh | 9 +- real-hw-test/src/device.rs | 161 +++++++++++++++++++++++++--- real-hw-test/src/discovery.rs | 6 +- real-hw-test/src/discovery/acpi.rs | 65 ++++++++++- real-hw-test/src/discovery/pci.rs | 43 +++++--- real-hw-test/src/main.rs | 7 +- 7 files changed, 261 insertions(+), 40 deletions(-) diff --git a/real-hw-test/README.md b/real-hw-test/README.md index feef7e7..7511e85 100644 --- a/real-hw-test/README.md +++ b/real-hw-test/README.md @@ -249,6 +249,16 @@ confirms firmware still owns the serial output. After controllers are disconnected, remote output may stop; continue reading diagnostics on the monitor. +Every usable candidate is listed with how its registers are reached (PIO or +MMIO), where it lives, and which discovery paths found it. Locations are +`built-in legacy port` (a conventional COM address), `built-in platform UART` +(a memory-mapped UART that firmware describes without a PCI identity), or a +PCI function with its address and vendor/device IDs, noted as +`on the root bus` (typically integrated on the board) 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`; one UART described by several paths appears once. + Good signs are: - `PASS` for crate `init`, initialized register values, crate loopback, and diff --git a/real-hw-test/scripts/run-qemu-ci.sh b/real-hw-test/scripts/run-qemu-ci.sh index a5ddc32..b5044b6 100755 --- a/real-hw-test/scripts/run-qemu-ci.sh +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -196,8 +196,10 @@ case "$arch" in x86_64) # Legacy COM1 plus the PCI UART, both driven through their captures. assert_log "$persisted_log" -F 'PIO 0x03f8' - assert_log "$persisted_log" -F 'RequiredCom1' - assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F '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' @@ -208,7 +210,8 @@ aarch64) # ACPI-described I/O window and driven via the MMIO backend. assert_log "$persisted_log" -F 'SKIP: SPCR interface is not 16450/16550-compatible' assert_log "$persisted_log" -F 'I/O window translation:' - assert_log "$persisted_log" -E 'sources=\[.*Pci' + assert_log "$persisted_log" -F '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' diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs index a67cc27..f5d3169 100644 --- a/real-hw-test/src/device.rs +++ b/real-hw-test/src/device.rs @@ -28,27 +28,149 @@ impl Display for Address { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -/// Records how discovery found a candidate so duplicate descriptions remain useful. -pub enum Source { +/// 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, #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] LegacyProbe, AcpiSpcr, - Pci { - segment: u32, - bus: u8, - device: u8, - function: u8, - }, + PciEnumeration, +} + +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", + #[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", + }) + } +} + +/// 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 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 { + pub segment: u32, + pub bus: u8, + pub device: u8, + 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 { + 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 + )?; + 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(()), + } + } +} + +#[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 and enumeration can describe one function, + /// and only enumeration knows how it is attached. + fn merge(&mut self, incoming: Location) { + match (self, incoming) { + (Self::Pci(current), Self::Pci(incoming)) => { + if current.attachment.is_none() { + current.attachment = incoming.attachment; + } + } + (current, Self::Pci(_)) => *current = incoming, + _ => {} + } + } +} + +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"), + Self::Platform => f.write_str("built-in platform UART"), + Self::Pci(function) => function.fmt(f), + } + } } #[derive(Debug)] -/// A deduplicated UART address, clock, and all firmware or bus provenance. +/// A deduplicated UART address, clock, location, and every path that found it. pub struct Candidate { pub address: Address, pub clock_hz: u32, - pub sources: Vec, + 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)] @@ -58,16 +180,24 @@ pub struct Inventory { } impl Inventory { - /// Adds a source to an address, merging descriptions to avoid duplicate tests. - pub fn add(&mut self, address: Address, clock_hz: Option, source: Source) { + /// 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.sources.contains(&source) { - candidate.sources.push(source); + 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; } @@ -77,7 +207,8 @@ impl Inventory { self.candidates.push(Candidate { address, clock_hz: clock_hz.unwrap_or(CLK_FREQUENCY_HZ), - sources: alloc::vec![source], + location, + discoveries: alloc::vec![discovery], }); } diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs index a35f554..e5c037d 100644 --- a/real-hw-test/src/discovery.rs +++ b/real-hw-test/src/discovery.rs @@ -8,7 +8,7 @@ use uart_16550::Uart16550; use crate::device::Inventory; #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] -use crate::device::{Address, Source}; +use crate::device::{Address, Discovery, Location}; #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use crate::uefi; @@ -40,9 +40,9 @@ fn discover_legacy(inventory: &mut Inventory) { ); if index == 0 { - inventory.add(address, None, Source::RequiredCom1); + inventory.add(address, None, Discovery::RequiredCom1, Location::LegacyPort); } else if present { - inventory.add(address, None, Source::LegacyProbe); + inventory.add(address, None, Discovery::LegacyProbe, Location::LegacyPort); } } } diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs index bad6745..d51863e 100644 --- a/real-hw-test/src/discovery/acpi.rs +++ b/real-hw-test/src/discovery/acpi.rs @@ -8,7 +8,7 @@ use core::slice; use uefi::system; use uefi::table::cfg::ConfigTableEntry; -use crate::device::{Address, Inventory, Source}; +use crate::device::{Address, Discovery, Inventory, Location, PciFunction}; use crate::uefi; const SDT_HEADER_LEN: usize = 36; @@ -54,6 +54,18 @@ struct SpcrInfo { 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. @@ -71,6 +83,17 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { 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"); @@ -112,8 +135,24 @@ fn add_spcr(inventory: &mut Inventory, spcr: SpcrInfo) { } }; - uefi::println!(" candidate: {address}"); - inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); + 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, + attachment: None, + }), + 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. @@ -125,6 +164,17 @@ fn find_spcr(rsdp_address: usize) -> Result, &'static str> { return Err("SPCR is too short"); } let clock = read_u32(table, 76); + let (device_id, vendor_id) = (read_u16(table, 64), read_u16(table, 66)); + // Revision 2 added the PCI identity; 0xffff marks a console that is not + // a PCI function. + let pci = (table[8] >= 2 && device_id != 0xffff && vendor_id != 0xffff).then(|| SpcrPci { + segment: table[75], + bus: table[68], + device: table[69], + function: table[70], + vendor_id, + device_id, + }); Ok(Some(SpcrInfo { interface: table[36], address_space: table[40], @@ -133,6 +183,7 @@ fn find_spcr(rsdp_address: usize) -> Result, &'static str> { access_size: table[43], base: read_u64(table, 44), clock_hz: (clock != 0).then_some(clock), + pci, })) } @@ -297,6 +348,14 @@ 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] diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs index 5f38ed0..fa3da26 100644 --- a/real-hw-test/src/discovery/pci.rs +++ b/real-hw-test/src/discovery/pci.rs @@ -8,9 +8,10 @@ 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, Inventory, Source}; +use crate::device::{Address, Attachment, Discovery, Inventory, Location, PciFunction}; use crate::uefi; /// Opens each root bridge read-only and searches it for serial-class endpoints. @@ -58,6 +59,14 @@ fn discover_root(root: &mut PciRootBridgeIo, inventory: &mut Inventory) { } }; 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, 0x08) else { @@ -69,7 +78,7 @@ fn discover_root(root: &mut PciRootBridgeIo, inventory: &mut Inventory) { continue; } - inspect_serial_controller(root, segment, address, class_register, inventory); + inspect_serial_controller(root, segment, root_bus, address, class_register, inventory); } } @@ -77,6 +86,7 @@ fn discover_root(root: &mut PciRootBridgeIo, inventory: &mut Inventory) { fn inspect_serial_controller( root: &mut PciRootBridgeIo, segment: u32, + root_bus: Option, address: PciIoAddress, class_register: u32, inventory: &mut Inventory, @@ -155,17 +165,24 @@ fn inspect_serial_controller( uefi::println!(" SKIP: BAR0 is disabled, invalid, or unsupported"); return; }; - uefi::println!(" candidate: {candidate}"); - inventory.add( - candidate, - None, - Source::Pci { - segment, - bus, - device, - function, - }, - ); + 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); } /// Sets a missing decode-enable bit and returns the verified command register. diff --git a/real-hw-test/src/main.rs b/real-hw-test/src/main.rs index 363ca8c..63aa19e 100644 --- a/real-hw-test/src/main.rs +++ b/real-hw-test/src/main.rs @@ -62,11 +62,12 @@ fn main() -> Status { uefi::println!("\nUsable UART candidates: {}", inventory.candidates().len()); for (index, candidate) in inventory.candidates().iter().enumerate() { uefi::println!( - " [{index}] {} clock={} Hz sources={:?}", + " [{index}] {} clock={} Hz", candidate.address, - candidate.clock_hz, - candidate.sources + candidate.clock_hz ); + uefi::println!(" location: {}", candidate.location); + uefi::println!(" found by: {}", candidate.found_by()); } let mut drivers = driver_test::run(inventory.candidates());