diff --git a/.github/workflows/real-hw-test.yml b/.github/workflows/real-hw-test.yml new file mode 100644 index 0000000..6700b90 --- /dev/null +++ b/.github/workflows/real-hw-test.yml @@ -0,0 +1,31 @@ +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 + steps: + - uses: actions/checkout@v7 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-uefi + - name: Install QEMU and OVMF + run: | + sudo apt-get update + sudo apt-get install -y ovmf qemu-system-x86 + - name: Run deterministic UEFI checks + working-directory: real-hw-test + run: | + OVMF=$(find /usr/share/OVMF -maxdepth 1 -type f -name OVMF_CODE.fd -print -quit) + if [[ -z "$OVMF" ]]; then + echo "error: the ovmf package did not install OVMF_CODE.fd" >&2 + exit 1 + fi + make ci-qemu OVMF="$OVMF" 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/.gitignore b/real-hw-test/.gitignore new file mode 100644 index 0000000..76daa49 --- /dev/null +++ b/real-hw-test/.gitignore @@ -0,0 +1,2 @@ +/build/ +/target/ diff --git a/real-hw-test/Cargo.lock b/real-hw-test/Cargo.lock new file mode 100644 index 0000000..25b62a1 --- /dev/null +++ b/real-hw-test/Cargo.lock @@ -0,0 +1,267 @@ +# 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 = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[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 = "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" +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 = "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", +] + +[[package]] +name = "uart_16550" +version = "0.8.0" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ucs2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79298e11f316400c57ec268f3c2c29ac3c4d4777687955cd3d4f3a35ce7eba" +dependencies = [ + "bit_field", +] + +[[package]] +name = "uefi" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f2e1b4a439d82899da4dc0ee8ac742db07a9ff493bc4f31d345c24e12e17e9" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "jiff", + "log", + "ptr_meta", + "ucs2", + "uefi-macros", + "uefi-raw", + "uguid", +] + +[[package]] +name = "uefi-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4687412b5ac74d245d5bfb1733ede50c31be19bf8a4b6a967a29b451bab49e67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uefi-raw" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a36f49db0b04a8dfd246a26ddf8d2526a2ffd8cf946ae7fe603fd0c5fbd2786" +dependencies = [ + "bitflags 2.13.1", + "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..2f0359d --- /dev/null +++ b/real-hw-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "uart-16550-real-hw-test" +version = "0.1.0" +edition = "2024" +publish = false + +[features] +ci = [] + +[dependencies] +jiff = { version = "0.2", default-features = false } +uart_16550 = { path = ".." } +uefi = { version = "0.38.0", features = ["alloc", "global_allocator", "jiff02", "panic_handler"] } diff --git a/real-hw-test/Makefile b/real-hw-test/Makefile new file mode 100644 index 0000000..bfae2de --- /dev/null +++ b/real-hw-test/Makefile @@ -0,0 +1,54 @@ +CARGO ?= cargo +QEMU ?= qemu-system-x86_64 +QEMU_ACCEL ?= kvm +TARGET := x86_64-unknown-uefi +TARGET_DIR := ../target/real-hw-test +PROFILE := release +BINARY := $(TARGET_DIR)/$(TARGET)/$(PROFILE)/uart-16550-real-hw-test.efi +ARTIFACT := build/BOOTX64.EFI +BUILD := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) build --locked +CLIPPY := CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO) clippy --locked +CI_ARTIFACT := build/BOOTX64-CI.EFI + +.PHONY: all artifact ci-artifact check qemu qemu-tcg ci-qemu install clean + +all: artifact + +artifact: $(ARTIFACT) + +$(ARTIFACT): FORCE + $(BUILD) --target $(TARGET) --release + mkdir -p $(dir $(ARTIFACT)) + cp $(BINARY) $(ARTIFACT) + +ci-artifact: FORCE + $(BUILD) --target $(TARGET) --release --features ci + mkdir -p $(dir $(CI_ARTIFACT)) + cp $(BINARY) $(CI_ARTIFACT) + +check: + $(CARGO) fmt --check + $(CLIPPY) --target $(TARGET) --release -- -D warnings + $(CLIPPY) --target $(TARGET) --release --features ci -- -D warnings + bash -n scripts/*.sh + $(MAKE) artifact + +qemu: artifact + QEMU="$(QEMU)" QEMU_ACCEL="$(QEMU_ACCEL)" OVMF="$(OVMF)" \ + ./scripts/run-qemu.sh $(QEMU_ARGS) + +qemu-tcg: + $(MAKE) qemu QEMU_ACCEL=tcg + +ci-qemu: ci-artifact + QEMU="$(QEMU)" OVMF="$(OVMF)" ./scripts/run-qemu-ci.sh $(CI_ARTIFACT) + +install: artifact + USB_MOUNT="$(USB_MOUNT)" ./scripts/install-usb.sh $(ARTIFACT) + +clean: + $(CARGO) clean --target-dir $(TARGET_DIR) + rm -rf build + +.PHONY: FORCE +FORCE: diff --git a/real-hw-test/README.md b/real-hw-test/README.md new file mode 100644 index 0000000..ffaec7a --- /dev/null +++ b/real-hw-test/README.md @@ -0,0 +1,246 @@ +# uart_16550 UEFI real-hardware test + +This subproject builds an x86_64 UEFI application that takes ownership of +16550-compatible UARTs and exercises this repository's driver. It is a manual +integration test: automatic register and loopback checks run first, followed by +an interactive serial menu. + +All diagnostics use UEFI Simple Text Output and are intended to stay visible on +the test machine's monitor. 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`, then deploy `build/BOOTX64.EFI` with `make install` to + a mounted GPT/FAT32 EFI partition. +2. Boot with a monitor and USB keyboard. Leave the monitor connected: it is the + authoritative diagnostic channel after firmware serial ownership is released. +3. Confirm the firmware baseline, configure the remote to 9600 8N1, and press + Enter on the local keyboard. +4. Look for automatic `PASS` lines and recognizable serial payloads, then use + the interactive commands to test the cable and reconnect behavior. + +## Test scope + +The application runs UARTs synchronously using polling. It disables UART +interrupts, installs no interrupt handler, and does **not** validate interrupt +delivery or interrupt-driven transmit/receive behavior. + +It also disables the UEFI image watchdog because an interactive serial phase +may legitimately take longer than the firmware's normal five-minute limit. A +failure to disable it is reported as a warning on screen. + +It discovers: + +- COM1 at `0x3f8` unconditionally; +- conventional legacy ports at `0x2f8`, `0x3e8`, and `0x2e8` when their scratch + registers behave like a UART; +- compatible byte-access UARTs advertised by ACPI SPCR; +- PCI serial-class controllers with an enabled, unambiguous, 16550-compatible + BAR0. + +Unsupported ACPI interfaces and ambiguous or vendor-specific PCI layouts are +reported but not accessed. + +## Recommended real-hardware setup + +Boot the application on an x86_64 machine with: + +- UEFI firmware and Secure Boot disabled, unless you sign the application; +- a monitor connected to the machine; +- a USB keyboard for Enter/Escape navigation; +- a physical serial port connected to another machine using the required + RS-232/null-modem wiring or an appropriate USB serial cable; +- Linux and Minicom on the remote machine. + +For example, adjust the remote device name as needed: + +```console +minicom -D /dev/ttyUSB0 -b 9600 +``` + +Use 9600 baud, 8 data bits, no parity, one stop bit, and no hardware flow +control. Firmware may use a different rate before takeover. The application +prints the UEFI `SerialIo` mode and waits for Enter before switching the UARTs +to 9600 8N1. + +### Remote already connected + +1. Boot the USB media and watch the monitor. +2. Confirm the `UEFI SERIAL BASELINE` line also appears remotely when firmware + serial redirection is active. Absence is valid when firmware exposes no + serial console. +3. Set Minicom to 9600 8N1 and press Enter on the test machine's USB keyboard. +4. Confirm `[barebones]` and `[uart_16550]` payloads appear remotely. +5. Use the interactive commands below. + +### Connect or reconnect during the test + +It is also valid to start without the remote cable connected. Let the automatic +tests finish, connect the cable during the interactive phase, and then: + +- type `c` to inspect DSR/CTS and modem-status changes; +- type `r` before and after reconnecting to compare registers; +- type `t` to send a known line to Minicom; +- type another printable ASCII character to test receive and echo. + +Some USB serial and null-modem wiring does not expose DSR or CTS. A connection +warning is therefore diagnostic and does not fail otherwise working traffic. +Press Escape on the local USB keyboard or send byte `0x1b` from the remote +terminal to skip a UART that has no connected remote. + +## Build + +Install the Rust UEFI target once if necessary: + +```console +rustup target add x86_64-unknown-uefi +``` + +Then build and stage the removable-media filename: + +```console +make artifact +file build/BOOTX64.EFI +``` + +The resulting file is `build/BOOTX64.EFI`. + +Run all static build checks with: + +```console +make check +``` + +## Install on USB media + +Prepare and mount an EFI partition yourself. The install target intentionally +does not partition, format, mount, or unmount devices. It verifies that the +mount is backed by a partition on a GPT disk and that `lsblk` identifies the +filesystem as FAT32 before copying anything. + +Inspect the target carefully: + +```console +lsblk -o NAME,SIZE,TYPE,FSTYPE,FSVER,PTTYPE,MOUNTPOINTS +make install USB_MOUNT=/run/media/$USER/EFI +``` + +The file is copied to `EFI/BOOT/BOOTX64.EFI`. If the disk is not GPT, the +filesystem is not FAT32, the path is not an exact mount point, or the mount is +not writable, installation stops with a diagnostic. Unmount the partition +cleanly before removing it. + +## Run under QEMU + +The included Nix development shell supplies QEMU and OVMF: + +```console +nix develop +make qemu +``` + +The graphical QEMU window is the UEFI monitor and keyboard. COM1 is connected +to the terminal that launched QEMU. A `pci-serial` device is also present; QEMU +prints its `/dev/pts/...` path during startup. Open that PTY in a second terminal +to exercise PCI discovery and BAR-backed UART access: + +```console +minicom -D /dev/pts/NUMBER -b 9600 +``` + +Outside Nix, provide the combined OVMF image explicitly: + +```console +OVMF=/path/to/OVMF.fd make qemu +``` + +KVM is used by default. Use software emulation when KVM is unavailable: + +```console +make qemu-tcg +# equivalent: QEMU_ACCEL=tcg make qemu +``` + +`QEMU`, `QEMU_ARGS`, and `ESP_DIR` can override the executable, add QEMU +arguments, or relocate the temporary directory-backed EFI system partition. +QEMU data stays below the repository's ignored `target/real-hw-test/` tree. + +### Headless CI smoke test + +`make ci-qemu` builds a feature-gated CI image and runs it headlessly with QEMU +TCG. It requires both legacy COM1 and the QEMU PCI serial controller to be +discovered, then runs the deterministic raw and `uart_16550` checks for both. +The CI image skips keyboard and remote-terminal interaction and exits through a +QEMU-only debug-exit device. + +```console +make ci-qemu +``` + +This smoke test is useful for debugging the test application and preventing its +automatic QEMU paths from regressing. It does not replace the manual test of a +real cable, reconnect behavior, firmware-specific ownership handoff, or +physical hardware. + +## Reading the test output + +The UEFI monitor is authoritative. Before takeover, `UEFI SERIAL BASELINE` +confirms firmware still owns the serial output. After controllers are +disconnected, remote output may stop; continue reading diagnostics on the +monitor. + +Good signs are: + +- `PASS` for raw initialization, loopback, register invariants, crate `init`, + crate loopback, and send APIs; +- `[barebones]` and `[uart_16550]` lines on the remote terminal; +- `PASS: interactive loopback`, echoed printable characters, and transmitted + `[interactive]` lines during manual testing; +- a final summary with each required UART marked `PASS`. + +`WARN: connection signals` or a DSR/CTS warning can be expected with a +three-wire or USB serial cable that does not provide modem-control lines. A +local or serial Escape skip is also a warning, not an automatic test failure. + +Investigate `FAIL`, `SKIP`, transmit or receive timeouts, a failed +`disconnect_controller`, an initialization/register/loopback mismatch, or a +final summary containing `FAIL`. Start with the candidate address, its reported +clock, 9600 8N1 settings, cable crossover and ground, and the remote terminal. + +## Interactive commands + +Commands are read from the UART currently named on the monitor: + +| Input | Expected result | +| --- | --- | +| `r` | Register snapshot appears on the UEFI screen. | +| `t` | `[interactive]` test line appears on the remote terminal. | +| `c` | Screen shows DSR/CTS status and a fresh register dump. | +| `l` | Screen reports `PASS: interactive loopback` or a failure. | +| `q` | This UART completes and the next candidate begins. | +| Printable ASCII | Screen shows the byte and the remote receives its echo. | +| Local Escape or serial `0x1b` | Skip this UART with a `WARN` diagnostic. | + +The final screen reports: + +- `PASS`: required automatic checks succeeded; +- `WARN`: automatic checks succeeded but connection signals were absent or the + interactive phase was skipped; +- `FAIL`: presence, initialization, register, loopback, or transmit readiness + failed. + +## Troubleshooting + +- No firmware baseline remotely: firmware may not expose or use `SerialIo`. + COM1 is still probed and tested after takeover. +- Garbled characters: confirm both ends use 9600 8N1 after the Enter prompt. +- No traffic: verify TX/RX crossover, common ground, RS-232 voltage conversion, + and whether a null-modem adapter is required. +- DSR/CTS warning with working bytes: the cable likely omits modem-control + lines; leave hardware flow control disabled. +- PCI controller is skipped: its programming interface, BAR, decoding state, + or layout was not safe to treat as a standard 16550 endpoint. +- QEMU does not start with KVM: use `make qemu-tcg`. diff --git a/real-hw-test/flake.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..fa615f0 --- /dev/null +++ b/real-hw-test/flake.nix @@ -0,0 +1,34 @@ +{ + description = "uart_16550 UEFI real-hardware test"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + + outputs = + { nixpkgs, ... }: + let + systems = [ "x86_64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + devShells = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + qemu_kvm + rustup + util-linux + ]; + env.OVMF = "${pkgs.OVMF.fd}/FV/OVMF.fd"; + }; + } + ); + + formatter = forAllSystems ( + system: nixpkgs.legacyPackages.${system}.nixfmt-tree + ); + }; +} diff --git a/real-hw-test/scripts/install-usb.sh b/real-hw-test/scripts/install-usb.sh new file mode 100755 index 0000000..4f1bb21 --- /dev/null +++ b/real-hw-test/scripts/install-usb.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact=${1:-build/BOOTX64.EFI} +mount_input=${USB_MOUNT:-} + +fail() { + echo "error: $*" >&2 + exit 2 +} + +for command in findmnt lsblk install readlink sync; do + command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command" +done + +[[ -n "$mount_input" ]] || fail \ + "USB_MOUNT is unset; use 'make install USB_MOUNT=/path/to/mounted/efi-partition'" +[[ -r "$artifact" ]] || fail "UEFI artifact is missing: $artifact (run 'make artifact')" + +mount_path=$(readlink -f -- "$mount_input") || fail "cannot resolve USB_MOUNT: $mount_input" +[[ "$mount_path" != / ]] || fail "refusing to install into the root filesystem" +[[ -d "$mount_path" ]] || fail "USB_MOUNT is not a directory: $mount_path" +[[ -w "$mount_path" ]] || fail "USB_MOUNT is not writable: $mount_path" + +mounted_target=$(findmnt -n -T "$mount_path" -o TARGET) || fail \ + "USB_MOUNT is not on a mounted filesystem: $mount_path" +mounted_target=$(readlink -f -- "$mounted_target") || fail \ + "cannot resolve the filesystem mount point: $mounted_target" +[[ "$mounted_target" == "$mount_path" ]] || fail \ + "USB_MOUNT must be the mount point itself; '$mount_path' is inside '$mounted_target'" + +source_name=$(findmnt -n -T "$mount_path" -o SOURCE) || fail \ + "cannot determine the mounted source device" +mount_fstype=$(findmnt -n -T "$mount_path" -o FSTYPE) || fail \ + "cannot determine the mounted filesystem type" +source_name=${source_name%%\[*\]} +source_device=$(readlink -f -- "$source_name") || fail \ + "cannot resolve mounted source device: $source_name" +[[ "$source_device" == /dev/* ]] || fail \ + "mounted source is not a block device: $source_name" + +device_type=$(lsblk -dnro TYPE "$source_device") +[[ "$device_type" == part ]] || fail \ + "EFI media must be a partition on a GPT disk; $source_device is type '$device_type'" + +parent_name=$(lsblk -dnro PKNAME "$source_device") +[[ -n "$parent_name" ]] || fail "cannot identify the parent disk of $source_device" +parent_device=/dev/$parent_name +partition_table=$(lsblk -dnro PTTYPE "$parent_device") +[[ "$partition_table" == gpt ]] || fail \ + "$parent_device uses '${partition_table:-no recognized partition table}', expected GPT" + +block_fstype=$(lsblk -dnro FSTYPE "$source_device") +fat_version=$(lsblk -dnro FSVER "$source_device") +[[ "$mount_fstype" == vfat && "$block_fstype" == vfat ]] || fail \ + "$source_device is '$mount_fstype'/'$block_fstype', expected a mounted FAT filesystem" +[[ "$fat_version" == FAT32 ]] || fail \ + "$source_device reports '${fat_version:-an unknown FAT version}', expected FAT32" + +target=$mount_path/EFI/BOOT/BOOTX64.EFI +echo "Installing to validated media:" +echo " disk: $parent_device (GPT)" +echo " partition: $source_device (FAT32)" +echo " mount: $mount_path" +echo " destination: $target" +install -D -m 0644 -- "$artifact" "$target" +sync "$target" +echo "Installation complete. Unmount the media cleanly before removing it." 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..3d519a8 --- /dev/null +++ b/real-hw-test/scripts/run-qemu-ci.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +qemu=${QEMU:-qemu-system-x86_64} +ovmf=${OVMF:-} +artifact=${1:-build/BOOTX64-CI.EFI} +run_dir=${CI_RUN_DIR:-../target/real-hw-test/qemu-ci} +esp_dir=$run_dir/esp +com1_log=$run_dir/com1.log +pci_log=$run_dir/pci-serial.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 +if ! command -v "$qemu" >/dev/null 2>&1; then + echo "error: QEMU executable not found: $qemu" >&2 + exit 2 +fi +if [[ ! -r "$artifact" ]]; then + echo "error: CI UEFI artifact is missing: $artifact" >&2 + echo " run 'make ci-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 ESP and logs ensure the result cannot come from a previous CI invocation. +rm -rf "$run_dir" +mkdir -p "$esp_dir/EFI/BOOT" +cp "$artifact" "$esp_dir/EFI/BOOT/BOOTX64.EFI" + +set +e +timeout --foreground 90 "$qemu" \ + -machine q35,accel=tcg \ + -m 256M \ + -bios "$ovmf" \ + -drive "format=raw,file=fat:rw:$esp_dir" \ + -nic none \ + -display none \ + -monitor none \ + -serial "file:$com1_log" \ + -chardev "file,id=pci_serial,path=$pci_log" \ + -device pci-serial,chardev=pci_serial \ + -device isa-debug-exit,iobase=0xf4,iosize=0x04 \ + -no-reboot +status=$? +set -e + +# isa-debug-exit maps guest value 0x10 to host status (0x10 << 1) | 1 = 33. +if [[ $status -eq 33 ]]; then + echo "PASS: headless TCG run completed COM1 and PCI UART checks" + exit 0 +fi + +echo "FAIL: headless TCG run exited with status $status (expected 33)" >&2 +for log in "$com1_log" "$pci_log"; do + if [[ -s "$log" ]]; then + echo "--- $log ---" >&2 + sed -n '1,200p' "$log" >&2 + fi +done +exit 1 diff --git a/real-hw-test/scripts/run-qemu.sh b/real-hw-test/scripts/run-qemu.sh new file mode 100755 index 0000000..49be207 --- /dev/null +++ b/real-hw-test/scripts/run-qemu.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +qemu=${QEMU:-qemu-system-x86_64} +accel=${QEMU_ACCEL:-kvm} +ovmf=${OVMF:-} +esp_dir=${ESP_DIR:-../target/real-hw-test/qemu-esp} +artifact=${ARTIFACT:-build/BOOTX64.EFI} + +if [[ -z "$ovmf" ]]; then + echo "error: OVMF is unset; run 'nix develop' or set OVMF=/path/to/OVMF.fd" \ + >&2 + exit 2 +fi +if [[ ! -r "$ovmf" ]]; then + echo "error: OVMF firmware is not readable: $ovmf" >&2 + exit 2 +fi +if ! command -v "$qemu" >/dev/null 2>&1; then + echo "error: QEMU executable not found: $qemu" >&2 + exit 2 +fi +if [[ ! -r "$artifact" ]]; then + echo "error: UEFI artifact is missing: $artifact (run 'make artifact')" >&2 + exit 2 +fi + +# Recreate the virtual ESP so QEMU never boots a stale application. +rm -rf "$esp_dir" +mkdir -p "$esp_dir/EFI/BOOT" +cp "$artifact" "$esp_dir/EFI/BOOT/BOOTX64.EFI" + +echo "QEMU COM1 is attached to this terminal." +echo "QEMU will print a /dev/pts/... path for the PCI serial device." + +exec "$qemu" \ + -machine "q35,accel=$accel" \ + -m 256M \ + -bios "$ovmf" \ + -drive "format=raw,file=fat:rw:$esp_dir" \ + -nic none \ + -monitor none \ + -serial stdio \ + -chardev pty,id=pci_serial \ + -device pci-serial,chardev=pci_serial \ + "$@" diff --git a/real-hw-test/src/ci.rs b/real-hw-test/src/ci.rs new file mode 100644 index 0000000..216d9dd --- /dev/null +++ b/real-hw-test/src/ci.rs @@ -0,0 +1,55 @@ +//! QEMU-only completion checks and exit signalling for continuous integration. +//! +//! CI cannot operate the cable or keyboard. It runs deterministic checks, needs +//! COM1 and a PCI UART, and reports through QEMU's `isa-debug-exit` device. + +use core::arch::asm; + +use crate::device::{Address, Candidate, Source}; +use crate::driver_test; +use crate::uefi; + +const QEMU_EXIT_PORT: u16 = 0xf4; +const QEMU_SUCCESS: u32 = 0x10; +const QEMU_FAILURE: u32 = 0x11; + +/// Verifies both QEMU discovery paths and automatic results before exiting. +pub fn finish(candidates: &[Candidate], results: &[driver_test::Result]) -> ! { + let has_com1 = candidates + .iter() + .any(|candidate| candidate.address == Address::Port(0x3f8)); + let has_pci = candidates.iter().any(|candidate| { + candidate + .sources + .iter() + .any(|source| matches!(source, Source::Pci { .. })) + }); + let all_passed = !results.is_empty() + && results.len() == candidates.len() + && results.iter().all(|result| result.passed); + + uefi::println!( + "CI requirements: COM1={} PCI={} automatic checks={}", + has_com1, + has_pci, + all_passed + ); + exit(has_com1 && has_pci && all_passed) +} + +/// Writes the CI status through QEMU's dedicated debug-exit I/O device. +pub fn exit(success: bool) -> ! { + let value = if success { QEMU_SUCCESS } else { QEMU_FAILURE }; + // SAFETY: the feature-gated CI launcher reserves this debug-exit port. + unsafe { + asm!( + "out dx, eax", + in("dx") QEMU_EXIT_PORT, + in("eax") value, + options(nomem, nostack, preserves_flags) + ); + } + loop { + core::hint::spin_loop(); + } +} diff --git a/real-hw-test/src/device.rs b/real-hw-test/src/device.rs new file mode 100644 index 0000000..ed68715 --- /dev/null +++ b/real-hw-test/src/device.rs @@ -0,0 +1,81 @@ +use alloc::vec::Vec; +use core::fmt::{self, Display, Formatter}; + +use uart_16550::spec::CLK_FREQUENCY_HZ; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// A byte-addressable 16550 register block reached through PIO or MMIO. +pub enum Address { + Port(u16), + Mmio { base: usize, stride: u8 }, +} + +impl Display for Address { + /// Formats an address in the form used by on-screen diagnostics. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Port(port) => write!(f, "PIO 0x{port:04x}"), + Self::Mmio { base, stride } => { + write!(f, "MMIO 0x{base:x}, stride {stride}") + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Records how discovery found a candidate so duplicate descriptions remain useful. +pub enum Source { + RequiredCom1, + LegacyProbe, + AcpiSpcr, + Pci { + segment: u32, + bus: u8, + device: u8, + function: u8, + }, +} + +#[derive(Debug)] +/// A deduplicated UART address, clock, and all firmware or bus provenance. +pub struct Candidate { + pub address: Address, + pub clock_hz: u32, + pub sources: Vec, +} + +#[derive(Debug, Default)] +/// The candidate list shared by raw, driver, and interactive test phases. +pub struct Inventory { + candidates: Vec, +} + +impl Inventory { + /// Adds a source to an address, merging descriptions to avoid duplicate tests. + pub fn add(&mut self, address: Address, clock_hz: Option, source: Source) { + if let Some(candidate) = self + .candidates + .iter_mut() + .find(|candidate| candidate.address == address) + { + if !candidate.sources.contains(&source) { + candidate.sources.push(source); + } + if let Some(clock_hz) = clock_hz.filter(|clock| *clock != 0) { + candidate.clock_hz = clock_hz; + } + return; + } + + self.candidates.push(Candidate { + address, + clock_hz: clock_hz.unwrap_or(CLK_FREQUENCY_HZ), + sources: alloc::vec![source], + }); + } + + /// Returns candidates in discovery order for stable on-screen summaries. + pub fn candidates(&self) -> &[Candidate] { + &self.candidates + } +} diff --git a/real-hw-test/src/discovery.rs b/real-hw-test/src/discovery.rs new file mode 100644 index 0000000..d0402f1 --- /dev/null +++ b/real-hw-test/src/discovery.rs @@ -0,0 +1,41 @@ +//! UART discovery through legacy probing, ACPI SPCR, and PCI enumeration. +//! +//! Multiple discovery paths cover fixed COM ports and dynamically described +//! UARTs, including QEMU's independent PCI serial controller. + +use crate::device::{Address, Inventory, Source}; +use crate::raw_uart::RawUart; +use crate::uefi; + +mod acpi; +mod pci; + +/// Combines every discovery source into a deduplicated test inventory. +pub fn discover() -> Inventory { + let mut inventory = Inventory::default(); + discover_legacy(&mut inventory); + acpi::discover(&mut inventory); + pci::discover(&mut inventory); + inventory +} + +/// Probes conventional COM addresses while always retaining COM1 as a baseline. +fn discover_legacy(inventory: &mut Inventory) { + const PORTS: [u16; 4] = [0x3f8, 0x2f8, 0x3e8, 0x2e8]; + + uefi::println!("\nLegacy UART probes:"); + for (index, port) in PORTS.into_iter().enumerate() { + let address = Address::Port(port); + let passed = RawUart::new(address).scratch_test(); + uefi::println!( + " {address}: scratch test {}", + if passed { "PASS" } else { "FAIL" } + ); + + if index == 0 { + inventory.add(address, None, Source::RequiredCom1); + } else if passed { + inventory.add(address, None, Source::LegacyProbe); + } + } +} diff --git a/real-hw-test/src/discovery/acpi.rs b/real-hw-test/src/discovery/acpi.rs new file mode 100644 index 0000000..89dd8a0 --- /dev/null +++ b/real-hw-test/src/discovery/acpi.rs @@ -0,0 +1,202 @@ +//! 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, + }, + 1 if spcr.base <= u64::from(u16::MAX - 7) => Address::Port(spcr.base as u16), + 0 | 1 => { + uefi::println!(" SKIP: SPCR base address is out of range"); + return; + } + _ => { + uefi::println!(" SKIP: unsupported ACPI address space"); + return; + } + }; + + uefi::println!(" candidate: {address}"); + inventory.add(address, spcr.clock_hz, Source::AcpiSpcr); +} + +/// Validates RSDP and XSDT/RSDT data before finding and decoding an SPCR table. +fn find_spcr(rsdp_address: usize) -> Result, &'static str> { + let rsdp = acpi_bytes(rsdp_address, 36)?; + if &rsdp[..8] != b"RSD PTR " || !checksum_ok(&rsdp[..20]) { + return Err("bad RSDP signature or checksum"); + } + + let revision = rsdp[15]; + let (root_address, entry_size) = if revision >= 2 { + let length = read_u32(rsdp, 20) as usize; + if !(36..=4096).contains(&length) { + return Err("invalid RSDP length"); + } + let full = acpi_bytes(rsdp_address, length)?; + if !checksum_ok(full) { + return Err("bad extended RSDP checksum"); + } + (read_u64(full, 24) as usize, 8) + } else { + (read_u32(rsdp, 16) as usize, 4) + }; + + let root = sdt(root_address)?; + let expected = if entry_size == 8 { b"XSDT" } else { b"RSDT" }; + if &root[..4] != expected { + return Err("root table has the wrong signature"); + } + + for entry in root[SDT_HEADER_LEN..].chunks_exact(entry_size) { + let address = if entry_size == 8 { + read_u64(entry, 0) as usize + } else { + read_u32(entry, 0) as usize + }; + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + if &header[..4] != b"SPCR" { + continue; + } + let table = sdt(address)?; + if table.len() < 80 { + return Err("SPCR is too short"); + } + let clock = read_u32(table, 76); + return Ok(Some(SpcrInfo { + interface: table[36], + address_space: table[40], + bit_width: table[41], + bit_offset: table[42], + access_size: table[43], + base: read_u64(table, 44), + clock_hz: (clock != 0).then_some(clock), + })); + } + Ok(None) +} + +/// Borrows mapped firmware ACPI memory after rejecting a null physical address. +fn acpi_bytes(address: usize, length: usize) -> Result<&'static [u8], &'static str> { + if address == 0 { + return Err("null ACPI table address"); + } + // SAFETY: UEFI keeps firmware ACPI memory mapped while boot services run. + Ok(unsafe { slice::from_raw_parts(address as *const u8, length) }) +} + +/// Validates an SDT's declared bounded length and complete ACPI checksum. +fn sdt(address: usize) -> Result<&'static [u8], &'static str> { + let header = acpi_bytes(address, SDT_HEADER_LEN)?; + let length = read_u32(header, 4) as usize; + if !(SDT_HEADER_LEN..=MAX_TABLE_LEN).contains(&length) { + return Err("invalid SDT length"); + } + let table = acpi_bytes(address, length)?; + checksum_ok(table) + .then_some(table) + .ok_or("bad SDT checksum") +} + +/// Applies ACPI's wrapping-byte checksum rule to one complete table region. +fn checksum_ok(bytes: &[u8]) -> bool { + bytes.iter().fold(0_u8, |sum, byte| sum.wrapping_add(*byte)) == 0 +} + +/// Decodes a bounds-checked little-endian 32-bit ACPI field without raw offsets. +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + let value = bytes[offset..offset + 4] + .try_into() + .expect("caller validated ACPI field bounds"); + u32::from_le_bytes(value) +} + +/// Decodes a bounds-checked little-endian 64-bit ACPI field without raw offsets. +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + let value = bytes[offset..offset + 8] + .try_into() + .expect("caller validated ACPI field bounds"); + u64::from_le_bytes(value) +} diff --git a/real-hw-test/src/discovery/pci.rs b/real-hw-test/src/discovery/pci.rs new file mode 100644 index 0000000..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)) +} diff --git a/real-hw-test/src/driver_test.rs b/real-hw-test/src/driver_test.rs new file mode 100644 index 0000000..b4bbb31 --- /dev/null +++ b/real-hw-test/src/driver_test.rs @@ -0,0 +1,314 @@ +//! Checks performed through the public `uart_16550` API. +//! +//! These run after raw preflight so a driver failure can be distinguished from +//! an absent or non-responsive UART. + +use alloc::vec::Vec; +use core::ptr::NonNull; +use core::time::Duration; + +use uart_16550::backend::{MmioBackend, PioBackend}; +use uart_16550::spec::registers::{LSR, MCR}; +use uart_16550::{BaudRate, Config, ConfigRegisterDump, Uart16550}; + +use crate::device::{Address, Candidate}; +use crate::preflight; +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 { + 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 interactive_skipped: bool, + pub driver: Option, +} + +impl Driver { + /// Constructs the public backend matching the candidate's address form. + fn new(address: Address) -> core::result::Result { + match address { + Address::Port(port) => { + // SAFETY: firmware serial consumers were disconnected before candidate discovery. + unsafe { Uart16550::new_port(port) } + .map(Self::Port) + .map_err(|_| "invalid PIO address") + } + Address::Mmio { base, stride } => { + let address = NonNull::new(base as *mut u8).ok_or("null MMIO address")?; + // SAFETY: ACPI/PCI supplied the active MMIO register range and stride. + unsafe { Uart16550::new_mmio(address, stride) } + .map(Self::Mmio) + .map_err(|_| "invalid MMIO address or stride") + } + } + } + + /// Initializes either backend with the same configuration for equal coverage. + fn init(&mut self, config: Config) -> core::result::Result<(), uart_16550::InitError> { + match self { + Self::Port(uart) => uart.init(config), + Self::Mmio(uart) => uart.init(config), + } + } + + /// Captures a typed register dump for diagnostics and invariant checks. + pub fn dump(&mut self) -> ConfigRegisterDump { + match self { + Self::Port(uart) => uart.config_register_dump(), + Self::Mmio(uart) => uart.config_register_dump(), + } + } + + /// Exercises the crate's loopback implementation through the chosen backend. + pub fn test_loopback(&mut self) -> core::result::Result<(), uart_16550::LoopbackError> { + match self { + Self::Port(uart) => uart.test_loopback(), + Self::Mmio(uart) => uart.test_loopback(), + } + } + + /// Samples modem-control inputs to diagnose remote cable wiring. + pub fn check_connected( + &mut self, + ) -> core::result::Result<(), uart_16550::RemoteReadyToReceiveError> { + match self { + Self::Port(uart) => uart.check_connected(), + Self::Mmio(uart) => uart.check_connected(), + } + } + + /// Delegates the crate's transmitter-readiness check to either backend. + fn ready_to_send(&mut self) -> core::result::Result<(), uart_16550::ByteSendError> { + match self { + Self::Port(uart) => uart.ready_to_send(), + Self::Mmio(uart) => uart.ready_to_send(), + } + } + + /// Sends one byte with the crate's fallible API for explicit coverage. + fn try_send_byte(&mut self, byte: u8) -> core::result::Result<(), uart_16550::ByteSendError> { + match self { + Self::Port(uart) => uart.try_send_byte(byte), + Self::Mmio(uart) => uart.try_send_byte(byte), + } + } + + /// Attempts a slice write and returns the crate's partial-write progress. + fn send_bytes(&mut self, bytes: &[u8]) -> usize { + match self { + Self::Port(uart) => uart.send_bytes(bytes), + Self::Mmio(uart) => uart.send_bytes(bytes), + } + } + + /// Completes a slice write through the crate's synchronous convenience API. + pub fn send_bytes_exact(&mut self, bytes: &[u8]) { + match self { + Self::Port(uart) => uart.send_bytes_exact(bytes), + Self::Mmio(uart) => uart.send_bytes_exact(bytes), + } + } + + /// Polls one received byte so interactive checks never block keyboard input. + #[cfg(not(feature = "ci"))] + pub fn try_receive_byte(&mut self) -> core::result::Result { + match self { + Self::Port(uart) => uart.try_receive_byte(), + Self::Mmio(uart) => uart.try_receive_byte(), + } + } +} + +/// Runs driver checks only after the independent preflight established hardware. +pub fn run(candidates: &[Candidate], preflight: &[preflight::Result]) -> Vec { + candidates + .iter() + .zip(preflight) + .map(|(candidate, preflight)| { + if preflight.passed { + run_one(candidate) + } else { + uefi::println!( + "\nSKIP uart_16550 checks for {}: barebones preflight failed", + candidate.address + ); + Result { + passed: false, + connection_warning: false, + interactive_skipped: false, + driver: None, + } + } + }) + .collect() +} + +/// Exercises init, registers, loopback, modem inputs, and transmit APIs once. +fn run_one(candidate: &Candidate) -> Result { + uefi::println!("\nuart_16550 checks: {}", candidate.address); + let mut driver = match Driver::new(candidate.address) { + Ok(driver) => driver, + Err(error) => return fail("construct driver", error), + }; + let config = Config { + frequency: candidate.clock_hz, + ..Config::default() + }; + + if let Err(error) = driver.init(config.clone()) { + uefi::println!(" FAIL: init: {error:?}"); + return failed_driver(driver, false); + } + 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, false); + } + uefi::println!(" PASS: initialized register values"); + + if let Err(error) = driver.test_loopback() { + uefi::println!(" FAIL: test_loopback: {error:?}"); + 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, false); + } + + 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}"); + 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"); + + Result { + passed: true, + connection_warning, + interactive_skipped: false, + 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")?; + + 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() + && 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, + interactive_skipped: false, + driver: None, + } +} + +/// Retains a constructed driver after failure without allowing interactive use. +fn failed_driver(driver: Driver, connection_warning: bool) -> Result { + Result { + passed: false, + connection_warning, + interactive_skipped: false, + driver: Some(driver), + } +} diff --git a/real-hw-test/src/firmware.rs b/real-hw-test/src/firmware.rs new file mode 100644 index 0000000..ed0108c --- /dev/null +++ b/real-hw-test/src/firmware.rs @@ -0,0 +1,113 @@ +//! 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; +#[cfg(not(feature = "ci"))] +use core::time::Duration; + +use uefi::boot::{self, OpenProtocolAttributes, OpenProtocolParams, SearchType}; +use uefi::proto::console::serial::Serial; +#[cfg(not(feature = "ci"))] +use uefi::proto::console::text::Key; +#[cfg(not(feature = "ci"))] +use uefi::system; +use uefi::{Handle, Status}; + +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::()) { + 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. +#[cfg(not(feature = "ci"))] +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"); + #[cfg(not(feature = "ci"))] + { + uefi::println!("Confirm the baseline, set the remote to 9600 8N1, then press Enter."); + wait_for_enter(); + } + #[cfg(feature = "ci")] + uefi::println!("CI mode: continuing without operator confirmation."); + + 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/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/logging.rs b/real-hw-test/src/logging.rs new file mode 100644 index 0000000..80c99e9 --- /dev/null +++ b/real-hw-test/src/logging.rs @@ -0,0 +1,132 @@ +//! 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, + path: String, +} + +/// Holds the single logger used by this synchronous, interrupt-free test. +struct LoggerSlot(UnsafeCell>); + +// SAFETY: The test is synchronous and deliberately does not enable interrupts, +// so no concurrent caller can access the logger. +unsafe impl Sync for LoggerSlot {} + +/// Stores the logger after initialization and before the first test diagnostic. +static LOGGER: LoggerSlot = LoggerSlot(UnsafeCell::new(None)); + +/// Creates the dated log file on the volume that contains this UEFI image. +pub fn init() -> Result<(), &'static str> { + let time = runtime::get_time().map_err(|_| "could not read UEFI time")?; + let time = DateTime::try_from(time).map_err(|_| "UEFI time is invalid")?; + let file_name = format!( + "uart_16550_{:04}-{:02}-{:02}_{:02}-{:02}-{:02}.txt", + time.year(), + time.month(), + time.day(), + time.hour(), + time.minute(), + time.second(), + ); + let 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()) + .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, path)) }; + Ok(()) +} + +impl Logger { + /// Retains one file handle so each write extends the same run transcript. + fn new(file: RegularFile, path: String) -> Self { + Self { file, path } + } + + /// 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); +} + +/// 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 new file mode 100644 index 0000000..f6546fb --- /dev/null +++ b/real-hw-test/src/main.rs @@ -0,0 +1,131 @@ +#![no_main] +#![no_std] +#![deny(clippy::undocumented_unsafe_blocks)] + +//! Manual UEFI integration test for this repository's `uart_16550` driver. +//! +//! The phases isolate firmware ownership, hardware discovery, direct register +//! access, and public driver APIs so the screen identifies the failing layer. + +extern crate alloc; +extern crate uefi as uefi_rs; + +/// Routes existing UEFI diagnostics through the fail-closed test logger. +mod uefi { + pub use crate::test_println as println; + pub use uefi_rs::*; +} + +/// Mirrors UEFI diagnostics to the screen and the test-run log file. +#[macro_export] +macro_rules! test_println { + ($($arg:tt)*) => { + $crate::logging::println(core::format_args!($($arg)*)) + }; +} + +#[cfg(feature = "ci")] +mod ci; +mod device; +mod discovery; +mod driver_test; +mod firmware; +#[cfg(not(feature = "ci"))] +mod interactive; +mod logging; +mod preflight; +mod raw_uart; + +use uefi::prelude::*; + +/// Starts the UEFI test and returns success while later commits add phases. +#[entry] +fn main() -> Status { + uefi::helpers::init().expect("UEFI helpers should initialize"); + if let Err(error) = logging::init() { + uefi_rs::println!("CRITICAL: cannot create test log: {error}"); + return Status::DEVICE_ERROR; + } + uefi::println!("uart_16550 real-hardware test"); + firmware::disable_watchdog(); + + if !firmware::disconnect_serial_controllers() { + uefi::println!("FAIL: firmware serial ownership was not released"); + logging::report_location(); + #[cfg(feature = "ci")] + ci::exit(false); + #[cfg(not(feature = "ci"))] + 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 + ); + } + + let preflight = preflight::run(inventory.candidates()); + let drivers = driver_test::run(inventory.candidates(), &preflight); + #[cfg(not(feature = "ci"))] + let mut drivers = drivers; + #[cfg(not(feature = "ci"))] + interactive::run(inventory.candidates(), &mut drivers); + 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(); + let skipped = drivers + .iter() + .filter(|result| result.interactive_skipped) + .count(); + uefi::println!( + 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 { + "FAIL" + } else if result.connection_warning || result.interactive_skipped { + "WARN" + } else { + "PASS" + }; + uefi::println!(" [{index}] {status}: {}", candidate.address); + } + + #[cfg(feature = "ci")] + { + logging::report_location(); + ci::finish(inventory.candidates(), &drivers); + } + + #[cfg(not(feature = "ci"))] + { + logging::report_location(); + uefi::println!("Press Enter to return to firmware."); + firmware::wait_for_enter(); + if passed == drivers.len() { + Status::SUCCESS + } else { + Status::DEVICE_ERROR + } + } +} diff --git a/real-hw-test/src/preflight.rs b/real-hw-test/src/preflight.rs new file mode 100644 index 0000000..757a8f3 --- /dev/null +++ b/real-hw-test/src/preflight.rs @@ -0,0 +1,60 @@ +//! Barebones register-level UART checks before the crate driver is constructed. +//! +//! This independent path validates addresses, clocks, FIFOs, and loopback so a +//! later public-API failure is easier to diagnose. + +use alloc::vec::Vec; + +use crate::device::Candidate; +use crate::raw_uart::RawUart; +use crate::uefi; + +#[derive(Clone, Copy, Debug)] +/// The automatic raw preflight outcome used to gate driver construction. +pub struct Result { + pub passed: bool, +} + +/// Runs the independent register-level preflight for every discovered UART. +pub fn run(candidates: &[Candidate]) -> Vec { + candidates.iter().map(run_one).collect() +} + +/// Initializes, snapshots, loopbacks, validates, and transmits on one UART. +fn run_one(candidate: &Candidate) -> Result { + uefi::println!("\nBarebones preflight: {}", candidate.address); + let mut uart = RawUart::new(candidate.address); + uart.snapshot().print("initial"); + + let divisor = match uart.initialize(candidate.clock_hz) { + Ok(divisor) => { + uefi::println!(" PASS: initialized at 9600 8N1 (divisor {divisor})"); + divisor + } + Err(error) => return fail("initialization", error), + }; + uart.snapshot().print("after raw init"); + + if let Err(error) = uart.test_loopback() { + return fail("single-byte/FIFO loopback", error); + } + uefi::println!(" PASS: single-byte and 16-byte loopback"); + uart.snapshot().print("after raw loopback"); + + if let Err(error) = uart.validate_configuration(divisor) { + return fail("register validation", error); + } + uefi::println!(" PASS: register invariants"); + + if let Err(error) = uart.send_bytes(b"[barebones] uart transmit test\r\n") { + return fail("transmit payload", error); + } + uefi::println!(" PASS: barebones transmit payload queued"); + Result { passed: true } +} + +/// Prints a consistently labelled raw-preflight failure result. +fn fail(stage: &str, error: crate::raw_uart::PreflightError) -> Result { + uefi::println!(" FAIL: {stage}: {error:?}"); + Result { passed: false } +} diff --git a/real-hw-test/src/raw_uart.rs b/real-hw-test/src/raw_uart.rs new file mode 100644 index 0000000..a38c378 --- /dev/null +++ b/real-hw-test/src/raw_uart.rs @@ -0,0 +1,316 @@ +//! Minimal synchronous 16550 access for the independent reference path. +//! +//! Direct PIO/MMIO operations validate a candidate before `Uart16550` exists, +//! avoiding a circular test that verifies the driver only with itself. + +use core::arch::asm; +use core::hint; + +use crate::device::Address; +use crate::uefi; + +const DATA: u8 = 0; +const IER: u8 = 1; +const ISR_FCR: u8 = 2; +const LCR: u8 = 3; +const MCR: u8 = 4; +const LSR: u8 = 5; +const MSR: u8 = 6; +const SPR: u8 = 7; + +const LCR_DLAB: u8 = 1 << 7; +const LSR_DATA_READY: u8 = 1 << 0; +const LSR_THR_EMPTY: u8 = 1 << 5; +const LSR_TRANSMITTER_EMPTY: u8 = 1 << 6; +const MCR_LOOP_BACK: u8 = 1 << 4; + +const POLL_LIMIT: usize = 2_000_000; + +/// An independently programmed UART used to establish a hardware baseline. +#[derive(Clone, Copy, Debug)] +pub struct RawUart { + address: Address, +} + +/// A diagnostic snapshot of normal and divisor-latch 16550 registers. +#[derive(Clone, Copy, Debug)] +pub struct RegisterSnapshot { + pub ier: u8, + pub isr: u8, + pub lcr: u8, + pub mcr: u8, + pub lsr: u8, + pub msr: u8, + pub spr: u8, + pub dll: u8, + pub dlm: u8, +} + +impl RegisterSnapshot { + /// Reassembles the divisor-latch bytes captured while DLAB was enabled. + pub const fn divisor(self) -> u16 { + (self.dlm as u16) << 8 | self.dll as u16 + } + + /// Prints byte values so hardware-specific deviations remain comparable. + pub fn print(self, label: &str) { + uefi::println!( + concat!( + " {}: IER={:02x} ISR={:02x} LCR={:02x} ", + "MCR={:02x} LSR={:02x} MSR={:02x} SPR={:02x} ", + "divisor={}", + ), + label, + self.ier, + self.isr, + self.lcr, + self.mcr, + self.lsr, + self.msr, + self.spr, + self.divisor() + ); + } +} + +/// Identifies the raw preflight stage that detected a non-working UART. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreflightError { + ScratchRegister, + InvalidClock, + TransmitterTimeout, + ReceiverTimeout, + UnexpectedByte { expected: u8, actual: u8 }, + UnexpectedMessage, + RegisterMismatch, +} + +impl RawUart { + /// Creates a reference accessor without touching the candidate yet. + pub const fn new(address: Address) -> Self { + Self { address } + } + + /// Reads one register through the candidate's PIO or MMIO mapping. + pub fn read(&mut self, offset: u8) -> u8 { + debug_assert!(offset < 8); + match self.address { + Address::Port(base) => { + let port = base + u16::from(offset); + let value: u8; + // SAFETY: discovery assigned an owned 16550-compatible PIO port. + unsafe { + asm!( + "in al, dx", + in("dx") port, + out("al") value, + options(nomem, nostack, preserves_flags) + ); + } + value + } + Address::Mmio { base, stride } => { + let address = base + usize::from(offset) * usize::from(stride); + // SAFETY: discovery validated the live firmware MMIO range. + unsafe { core::ptr::read_volatile(address as *const u8) } + } + } + } + + /// Writes one register through the candidate's PIO or MMIO mapping. + pub fn write(&mut self, offset: u8, value: u8) { + debug_assert!(offset < 8); + match self.address { + Address::Port(base) => { + let port = base + u16::from(offset); + // SAFETY: discovery assigned an owned 16550-compatible PIO port. + unsafe { + asm!( + "out dx, al", + in("dx") port, + in("al") value, + options(nomem, nostack, preserves_flags) + ); + } + } + Address::Mmio { base, stride } => { + let address = base + usize::from(offset) * usize::from(stride); + // SAFETY: discovery validated the live firmware MMIO range. + unsafe { core::ptr::write_volatile(address as *mut u8, value) }; + } + } + } + + /// Writes two patterns and restores the scratch register to detect UARTs. + pub fn scratch_test(&mut self) -> bool { + // Restore the old value because firmware may inspect it diagnostically. + let old = self.read(SPR); + let passed = [0x42, 0x73].into_iter().all(|pattern| { + self.write(SPR, pattern); + self.read(SPR) == pattern + }); + self.write(SPR, old); + passed + } + + /// Captures normal and banked registers while restoring the original LCR. + pub fn snapshot(&mut self) -> RegisterSnapshot { + let original_lcr = self.read(LCR); + self.write(LCR, original_lcr & !LCR_DLAB); + let ier = self.read(IER); + let snapshot = RegisterSnapshot { + ier, + isr: self.read(ISR_FCR), + lcr: original_lcr, + mcr: self.read(MCR), + lsr: self.read(LSR), + msr: self.read(MSR), + spr: self.read(SPR), + dll: 0, + dlm: 0, + }; + self.write(LCR, original_lcr | LCR_DLAB); + let snapshot = RegisterSnapshot { + dll: self.read(DATA), + dlm: self.read(IER), + ..snapshot + }; + self.write(LCR, original_lcr); + snapshot + } + + /// Programs polling-mode 9600 8N1 after validating the clock and scratch. + pub fn initialize(&mut self, clock_hz: u32) -> Result { + if !self.scratch_test() { + return Err(PreflightError::ScratchRegister); + } + let denominator = 16 * 9_600; + if clock_hz == 0 || !clock_hz.is_multiple_of(denominator) { + return Err(PreflightError::InvalidClock); + } + let divisor = u16::try_from(clock_hz / denominator) + .ok() + .filter(|divisor| *divisor != 0) + .ok_or(PreflightError::InvalidClock)?; + + self.write(LCR, 0); + self.write(IER, 0); + self.write(LCR, LCR_DLAB); + self.write(DATA, divisor as u8); + self.write(IER, (divisor >> 8) as u8); + self.write(LCR, 0x03); + self.write(ISR_FCR, 0xc7); + self.write(MCR, 0x0b); + self.wait_for_lsr(LSR_TRANSMITTER_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + + // Acknowledge stale line/modem deltas inherited from firmware. + let _ = self.read(LSR); + let _ = self.read(MSR); + Ok(divisor) + } + + /// Checks one-byte and FIFO-sized internal transfers, restoring MCR after. + pub fn test_loopback(&mut self) -> Result<(), PreflightError> { + const MESSAGE: [u8; 16] = *b"hello world!1337"; + let old_mcr = self.read(MCR); + self.write(MCR, MCR_LOOP_BACK); + self.write(ISR_FCR, 0xc7); + self.drain_receive_fifo(); + + let result = (|| { + self.send_byte(0x42)?; + let byte = self.receive_byte()?; + if byte != 0x42 { + return Err(PreflightError::UnexpectedByte { + expected: 0x42, + actual: byte, + }); + } + + self.wait_for_lsr(LSR_THR_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + for byte in MESSAGE { + self.write(DATA, byte); + } + let mut received = [0_u8; MESSAGE.len()]; + for byte in &mut received { + *byte = self.receive_byte()?; + } + (received == MESSAGE) + .then_some(()) + .ok_or(PreflightError::UnexpectedMessage) + })(); + + self.write(MCR, old_mcr); + self.write(ISR_FCR, 0xc7); + result + } + + /// Confirms raw initialization survived loopback and matches invariants. + pub fn validate_configuration(&mut self, divisor: u16) -> Result<(), PreflightError> { + self.wait_for_lsr(LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + let snapshot = self.snapshot(); + let matches = snapshot.ier == 0 + && snapshot.lcr == 0x03 + && snapshot.mcr & 0x1f == 0x0b + && snapshot.lsr & (LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY) + == LSR_THR_EMPTY | LSR_TRANSMITTER_EMPTY + && snapshot.isr & 0xc0 == 0xc0 + && snapshot.divisor() == divisor; + matches + .then_some(()) + .ok_or(PreflightError::RegisterMismatch) + } + + /// Sends an entire diagnostic payload with bounded polling per byte. + pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<(), PreflightError> { + for &byte in bytes { + self.send_byte(byte)?; + } + Ok(()) + } + + /// Waits for an empty transmit holding register before sending one byte. + fn send_byte(&mut self, byte: u8) -> Result<(), PreflightError> { + self.wait_for_lsr(LSR_THR_EMPTY, true) + .ok_or(PreflightError::TransmitterTimeout)?; + self.write(DATA, byte); + Ok(()) + } + + /// Waits for a received byte, bounding failures instead of hanging firmware. + fn receive_byte(&mut self) -> Result { + self.wait_for_lsr(LSR_DATA_READY, true) + .ok_or(PreflightError::ReceiverTimeout)?; + Ok(self.read(DATA)) + } + + /// Removes one FIFO of stale input that could otherwise falsify loopback. + fn drain_receive_fifo(&mut self) { + for _ in 0..16 { + if self.read(LSR) & LSR_DATA_READY == 0 { + break; + } + let _ = self.read(DATA); + } + } + + /// Polls line-status bits up to a fixed limit and returns the matching LSR. + fn wait_for_lsr(&mut self, mask: u8, all: bool) -> Option { + for _ in 0..POLL_LIMIT { + let lsr = self.read(LSR); + let ready = if all { + lsr & mask == mask + } else { + lsr & mask != 0 + }; + if ready { + return Some(lsr); + } + hint::spin_loop(); + } + None + } +} diff --git a/src/backend/mmio.rs b/src/backend/mmio.rs index 1c0f2b8..f328f45 100644 --- a/src/backend/mmio.rs +++ b/src/backend/mmio.rs @@ -24,9 +24,11 @@ unsafe impl Send for MmioAddress {} impl RegisterAddress for MmioAddress { #[inline(always)] fn add_offset(self, offset: u8) -> Self { - // SAFETY: We ensure on a higher level that the base address is valid - // and that this will not wrap. - let address = unsafe { self.0.add(offset as usize) }; + // MMIO is not a Rust allocation; the constructor already rejects + // register ranges whose address arithmetic would wrap. + let address = self.0.as_ptr().wrapping_add(offset as usize); + let message = "validated MMIO address offset cannot be null"; + let address = NonNull::new(address).expect(message); Self(address) } } @@ -120,9 +122,11 @@ impl Backend for MmioBackend { #[inline(always)] unsafe fn _read_register(&mut self, address: MmioAddress) -> u8 { debug_assert!(address >= self.base()); - let upper_bound_incl = (NUM_REGISTERS - 1) * usize::from(u8::from(self.stride)); + let register_count = NUM_REGISTERS - 1; + let upper_bound_incl = register_count * usize::from(u8::from(self.stride)); + let last_address = self.base().0.as_ptr().wrapping_add(upper_bound_incl); // Address is in the device's address range - debug_assert!(address.0.as_ptr() <= self.base().0.as_ptr().wrapping_add(upper_bound_incl)); + debug_assert!(address.0.as_ptr() <= last_address); // SAFETY: The caller ensured that the MMIO address is safe to use. unsafe { arch::mmio_read_register(address) } @@ -131,9 +135,11 @@ impl Backend for MmioBackend { #[inline(always)] unsafe fn _write_register(&mut self, address: MmioAddress, value: u8) { debug_assert!(address >= self.base()); - let upper_bound_incl = (NUM_REGISTERS - 1) * usize::from(u8::from(self.stride)); + let register_count = NUM_REGISTERS - 1; + let upper_bound_incl = register_count * usize::from(u8::from(self.stride)); + let last_address = self.base().0.as_ptr().wrapping_add(upper_bound_incl); // Address is in the device's address range - debug_assert!(address.0.as_ptr() <= self.base().0.as_ptr().wrapping_add(upper_bound_incl)); + debug_assert!(address.0.as_ptr() <= last_address); // SAFETY: The caller ensured that the MMIO address is safe to use. unsafe { arch::mmio_write_register(address, value) }