From da9a474ddabc734651ccc7733ec6923d0997164a Mon Sep 17 00:00:00 2001 From: Chris Frantz Date: Sat, 20 Jun 2026 17:45:50 -0700 Subject: [PATCH 1/5] earlgrey: device_id helper function Signed-off-by: Chris Frantz --- target/earlgrey/util/BUILD.bazel | 1 + target/earlgrey/util/device_id.rs | 29 +++++++++++++++++++++++++++++ target/earlgrey/util/lib.rs | 1 + 3 files changed, 31 insertions(+) create mode 100644 target/earlgrey/util/device_id.rs diff --git a/target/earlgrey/util/BUILD.bazel b/target/earlgrey/util/BUILD.bazel index eff82b50..773892c7 100644 --- a/target/earlgrey/util/BUILD.bazel +++ b/target/earlgrey/util/BUILD.bazel @@ -10,6 +10,7 @@ rust_library( "boot_log.rs", "boot_svc.rs", "clock.rs", + "device_id.rs", "error.rs", "flash.rs", "lib.rs", diff --git a/target/earlgrey/util/device_id.rs b/target/earlgrey/util/device_id.rs new file mode 100644 index 00000000..4cc3fd71 --- /dev/null +++ b/target/earlgrey/util/device_id.rs @@ -0,0 +1,29 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Device ID formatting utilities. + +use zerocopy::IntoBytes; + +const HEX_CHARS: [u8; 16] = *b"0123456789abcdef"; + +/// Formats a 256-bit Device ID ([u32; 8]) into a hex string buffer. +/// +/// The formatting matches the byte order used when exposing the Device ID +/// via USB string descriptors (little-endian bytes of each word, from word 0 to 7). +/// +/// The buffer must be at least 64 bytes long. +pub fn format_device_id<'a>(device_id: &[u32; 8], buf: &'a mut [u8]) -> Result<&'a str, ()> { + if buf.len() < 64 { + return Err(()); + } + let device_id_bytes = device_id.as_bytes(); + + for i in 0..32 { + let byte = device_id_bytes[i]; + buf[i * 2] = HEX_CHARS[(byte >> 4) as usize]; + buf[i * 2 + 1] = HEX_CHARS[(byte & 0xf) as usize]; + } + // SAFETY: buf[..64] was populated entirely with valid ASCII hex characters from HEX_CHARS. + Ok(unsafe { core::str::from_utf8_unchecked(&buf[..64]) }) +} diff --git a/target/earlgrey/util/lib.rs b/target/earlgrey/util/lib.rs index 5ab9323c..178136f6 100644 --- a/target/earlgrey/util/lib.rs +++ b/target/earlgrey/util/lib.rs @@ -6,6 +6,7 @@ pub mod boot_log; pub mod boot_svc; pub mod clock; +pub mod device_id; pub mod error; pub mod flash; mod misc; From caef465594aef05a3edfb0c2491ea0bf88237366 Mon Sep 17 00:00:00 2001 From: Chris Frantz Date: Sat, 20 Jun 2026 14:26:23 -0700 Subject: [PATCH 2/5] earlgrey: usbdev with host-based test harness Signed-off-by: Chris Frantz --- MODULE.bazel | 8 +- target/earlgrey/tests/usbdev/BUILD.bazel | 29 +++ target/earlgrey/tests/usbdev/README.md | 34 +++ .../earlgrey/tests/usbdev/host_usb_check.rs | 213 ++++++++++++++++++ target/earlgrey/tests/usbdev/system.json5 | 6 + target/earlgrey/tests/usbdev/test_usb.rs | 23 +- third_party/lowrisc_opentitan/BUILD.bazel | 19 ++ 7 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 target/earlgrey/tests/usbdev/README.md create mode 100644 target/earlgrey/tests/usbdev/host_usb_check.rs diff --git a/MODULE.bazel b/MODULE.bazel index dcb0274e..0e954e48 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -108,7 +108,13 @@ crate.from_cargo( "thumbv7em-none-eabi", ], ) -use_repo(crate, "rust_caliptra_crates", "rust_caliptra_crates_host", "rust_crates") +use_repo( + crate, + "rust_caliptra_crates", + "rust_caliptra_crates_host", + "rust_crates", + ot_crate_index = "crate_index", +) pw_rust_crates_ext = use_extension("@pigweed//pw_build:pw_rust_crates_extension.bzl", "pw_rust_crates_extension") diff --git a/target/earlgrey/tests/usbdev/BUILD.bazel b/target/earlgrey/tests/usbdev/BUILD.bazel index c0f7ccaa..cd7324e3 100644 --- a/target/earlgrey/tests/usbdev/BUILD.bazel +++ b/target/earlgrey/tests/usbdev/BUILD.bazel @@ -25,14 +25,17 @@ rust_app( "//hal/blocking/usb:hal_usb", "//protocol/usb/stack", "//target/earlgrey/drivers:usb_driver", + "//target/earlgrey/registers:lc_ctrl", "//target/earlgrey/registers:pinmux", "//target/earlgrey/registers:top_earlgrey", "//target/earlgrey/registers:usbdev", + "//target/earlgrey/util", "//util/panic", "@pigweed//pw_kernel/userspace", "@pigweed//pw_log/rust:pw_log", "@pigweed//pw_status/rust:pw_status", "@rust_crates//:aligned", + "@rust_crates//:zerocopy", ], ) @@ -116,6 +119,8 @@ opentitan_test( "hyper310", ], target = ":usb", + test_cmd = "--logging=info --wait-for-firmware=false", + test_harness = ":host_usb_check", ) opentitan_test( @@ -128,6 +133,8 @@ opentitan_test( "hyper340", ], target = ":usb", + test_cmd = "--logging=info --wait-for-firmware=false", + test_harness = ":host_usb_check", ) opentitan_test( @@ -140,4 +147,26 @@ opentitan_test( "hardware", ], target = ":usb", + test_cmd = "--logging=info --wait-for-firmware=false", + test_harness = ":host_usb_check", +) + +rust_binary( + name = "host_usb_check", + srcs = [ + "host_usb_check.rs", + ], + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-Wl,--allow-shlib-undefined", + ], + deps = [ + "//third_party/lowrisc_opentitan:opentitanlib", + "//third_party/lowrisc_opentitan:usb_test_helper", + "@ot_crate_index//:anyhow", + "@ot_crate_index//:clap", + "@ot_crate_index//:humantime", + "@ot_crate_index//:log", + ], ) diff --git a/target/earlgrey/tests/usbdev/README.md b/target/earlgrey/tests/usbdev/README.md new file mode 100644 index 00000000..2514385d --- /dev/null +++ b/target/earlgrey/tests/usbdev/README.md @@ -0,0 +1,34 @@ +# USB Device Test (`usbdev`) + +This test verifies the USB device functionality on the OpenTitan Earlgrey target. It ensures that the USB device can be enumerated by the host and that it correctly exposes its Device ID as the USB serial number descriptor. + +## Test Components + +The test consists of two parts: + +1. **Firmware (`test_usb.rs`)**: Runs on the Ibex core of the OpenTitan. + * Maps and accesses the Lifecycle Controller (`lc_ctrl`) to read the 256-bit Device ID. + * Formats the Device ID as a 64-character hex string and prints it to the console (`Serial Number: `). + * Converts the Device ID bytes into a UTF-16 USB string descriptor. + * Initializes the USB device controller and exposes the Device ID as the USB Serial Number descriptor. + +2. **Host Harness (`host_usb_check.rs`)**: Runs on the host machine controlling the test. + * Resets the target board to ensure a clean boot and capture all logs. + * Monitors the UART console and waits for the `🔄 RUNNING` log. + * Uses regex to capture the printed serial number from the console logs: `Serial Number: ([0-9a-fA-F]{64})`. + * Enables VBUS on the target board (if supported by the transport). + * Polls the host USB bus for the OpenTitan USB device (matching VID/PID). + * Once detected, queries the USB device's Serial Number descriptor. + * Verifies that the USB Serial Number descriptor matches the serial number captured from the console logs. + +## Running the Test + +### On CW310 (FPGA) Hardware + +To run the test on a connected CW310 board: + +```bash +bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/tests/usbdev:usb_hyper310_test +``` + +The test harness will automatically handle bitstream loading, bootstrapping the firmware, resetting the target, and performing the verification. diff --git a/target/earlgrey/tests/usbdev/host_usb_check.rs b/target/earlgrey/tests/usbdev/host_usb_check.rs new file mode 100644 index 00000000..40401352 --- /dev/null +++ b/target/earlgrey/tests/usbdev/host_usb_check.rs @@ -0,0 +1,213 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, ensure, Context, Result}; +use clap::Parser; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use opentitanlib::io::console::ConsoleExt; +use opentitanlib::io::uart::Uart; +use opentitanlib::test_utils::init::InitializeTest; +use opentitanlib::transport::Capability; +use opentitanlib::uart::console::UartConsole; +use std::time::Instant; + +use usb::{port_path_string, UsbDeviceHandle, UsbOpts}; + +#[derive(Debug, Parser)] +struct Opts { + #[command(flatten)] + init: InitializeTest, + + /// Console/USB timeout. + #[arg(long, value_parser = humantime::parse_duration, default_value = "60s")] + timeout: Duration, + + /// USB options. + #[command(flatten)] + usb: UsbOpts, + + /// Wait for the USB device to appear before continuing with the test. + #[arg(long, action = clap::ArgAction::Set, default_value = "true")] + wait_for_usb_device: bool, + + /// Wait for the firmware to emit a PASS/FAIL result. + #[arg(long, action = clap::ArgAction::Set, default_value = "true")] + wait_for_firmware: bool, + + /// Executable to run after USB device connection. + /// This harness will spawn a process to execute and continue monitoring the UART + /// until the test passes (or fails). After that, the process will be killed. + /// If `wait_for_usb_device` is true, the harness will pass two extra arguments + /// to the executable to specify the bus and address of the USB device, as follows: + /// `--device :`. + #[arg(long)] + exec: Option, + + /// Arguments to pass to the executable. + #[arg(long)] + exec_arg: Vec, +} + +fn wait_for_device(opts: &Opts, uart: &dyn Uart) -> Result { + let stop = Instant::now() + opts.timeout; + log::info!("waiting for device (with UART log)..."); + while Instant::now() < stop { + let mut devices = opts.usb.wait_for_device(Duration::ZERO)?; + if !devices.is_empty() { + if devices.len() > 1 { + log::error!("several USB devices found:"); + for dev in &devices { + log::error!( + "- bus={} address={}", + dev.device().bus_number(), + dev.device().address() + ); + } + bail!("several USB devices found"); + } + let device = devices.remove(0); + log::info!( + "device found at bus={}, address={}, path={}", + device.device().bus_number(), + device.device().address(), + port_path_string(&device.device())? + ); + return Ok(device); + } + + let mut buf = [0u8; 256]; + match uart.read_timeout(&mut buf, Duration::from_millis(10)) { + Ok(n) if n > 0 => { + use std::io::Write; + std::io::stdout().write_all(&buf[..n])?; + std::io::stdout().flush()?; + } + _ => {} + } + } + bail!("no USB device found"); +} + +fn main() -> Result<()> { + let opts = Opts::parse(); + opts.init.init_logging(); + let transport = opts.init.init_target()?; + + transport + .capabilities()? + .request(Capability::USB) + .ok() + .context("This transport does not support USB")?; + + // Certain backends such as QEMU will not enumerate USB device until + // we request the USB context. + let _usb_context = transport.usb().context("Cannot get USB context")?; + + // Wait until test is running. + let uart = transport.uart("console")?; + log::info!("Resetting target..."); + transport.reset(opentitanlib::app::UartRx::Clear)?; + log::info!("waiting for RUNNING on console..."); + if let Err(e) = UartConsole::wait_for(&*uart, r"RUNNING", Duration::from_secs(5)) { + log::warn!("Failed waiting for RUNNING (non-fatal): {e}"); + } + + opts.usb.apply_strappings(&transport, true)?; + // Enable VBUS sense on the board if necessary. + if opts.usb.vbus_control_available() { + opts.usb.enable_vbus(&transport, true)?; + } + // Sense VBUS if available. + if opts.usb.vbus_sense_available() { + ensure!( + opts.usb.vbus_present(&transport)?, + "OT USB does not appear to be connected to a host (VBUS not detected)" + ); + } + + let mut captured_serial = None; + if opts.wait_for_usb_device { + log::info!("waiting for Serial Number on console..."); + let res = UartConsole::wait_for(&*uart, r"Serial Number: ([0-9a-fA-F]{64})", opts.timeout)?; + captured_serial = Some(res[1].clone()); + log::info!( + "Captured Serial Number: {}", + captured_serial.as_ref().unwrap() + ); + } + + // Wait for USB device to appear. + let device = if opts.wait_for_usb_device { + Some(wait_for_device(&opts, &*uart)?) + } else { + None + }; + + if let (Some(handle), Some(expected_serial)) = (&device, &captured_serial) { + let device_desc = handle.device().device_descriptor()?; + let usb_serial = handle.read_serial_number_string_ascii(&device_desc)?; + log::info!("USB Serial Number: {usb_serial}"); + if usb_serial.as_str() != expected_serial.as_str() { + bail!( + "Serial number mismatch: expected (UART) {}, got (USB) {}", + expected_serial, + usb_serial + ); + } + log::info!("Serial number match: {usb_serial}"); + } + + // Run executable if requested. + let child = match opts.exec { + Some(exec) => { + let mut cmd = Command::new(exec); + if let Some(device) = device { + cmd.arg("--device").arg(format!( + "{}:{}", + device.device().bus_number(), + device.device().address() + )); + } + cmd.args(opts.exec_arg); + log::info!( + "calling {:?} on {:?}", + cmd.get_program(), + cmd.get_args().collect::>() + ); + Some(cmd.spawn().context("could not start executable")?) + } + None => None, + }; + + // Wait for test to pass. + if opts.wait_for_firmware { + log::info!("wait for pass..."); + let res = UartConsole::wait_for(&*uart, r"PASS|FAIL", opts.timeout)?; + match res[0].as_str() { + "PASS" => (), + "FAIL" => bail!("device code reported a failure"), + _ => (), + }; + } + + // Kill executable (if running). + if let Some(mut child) = child { + match child.try_wait() { + Ok(Some(status)) => log::info!("executable exited with: {status}"), + Ok(None) => { + log::info!("executable did not finish and will be killed"); + let _ = child.kill(); + } + Err(e) => { + println!("error attempting to get executable status: {e}"); + log::info!("killing executable"); + let _ = child.kill(); + } + } + } + + Ok(()) +} diff --git a/target/earlgrey/tests/usbdev/system.json5 b/target/earlgrey/tests/usbdev/system.json5 index 87adb95f..7fc35ef3 100644 --- a/target/earlgrey/tests/usbdev/system.json5 +++ b/target/earlgrey/tests/usbdev/system.json5 @@ -67,6 +67,12 @@ type: "device", start_address: 0x40460000, size_bytes: 0x1000, + }, + { + name: "lc_ctrl", + type: "device", + start_address: 0x40140000, + size_bytes: 0x100, } ], diff --git a/target/earlgrey/tests/usbdev/test_usb.rs b/target/earlgrey/tests/usbdev/test_usb.rs index b5bd7207..955c7d39 100644 --- a/target/earlgrey/tests/usbdev/test_usb.rs +++ b/target/earlgrey/tests/usbdev/test_usb.rs @@ -3,7 +3,10 @@ #![no_std] #![no_main] +use earlgrey_util::device_id::format_device_id; +use lc_ctrl::LcCtrl; use pw_status::{Error, Result, StatusCode}; +use zerocopy::IntoBytes; use test_usb_codegen::{handle, signals}; use userspace::time::Instant; @@ -114,11 +117,23 @@ const CONTROL_EP_OUT_NUM: u8 = 0; fn handle_usb() -> Result<()> { let mut serial_num_buffer = Aligned::([0_u8; 130]); - // TODO: build proper descriptors. - //let mut product_desc_buffer = Aligned::([0_u8; 100]); + + let lc_ctrl = unsafe { LcCtrl::new() }; + let device_id: [u32; 8] = lc_ctrl.regs().device_id().read().into(); + + let mut serial_ascii = [0u8; 64]; + let serial_str = + format_device_id(&device_id, &mut serial_ascii).map_err(|_| Error::Internal)?; + pw_log::info!("Serial Number: {}", serial_str); + + let device_id_bytes = device_id.as_bytes(); + let descriptors = MyDescriptors { - serial_desc_bytes: hal_usb::hex_utf16_descriptor_aligned(&mut serial_num_buffer, b"12345") - .unwrap(), + serial_desc_bytes: hal_usb::hex_utf16_descriptor_aligned( + &mut serial_num_buffer, + device_id_bytes, + ) + .map_err(|_| Error::Internal)?, product_desc_bytes: PRODUCT_ID_DEFAULT, }; const USB_EP_IN: EpIn = EpIn { diff --git a/third_party/lowrisc_opentitan/BUILD.bazel b/third_party/lowrisc_opentitan/BUILD.bazel index 7652de75..caa517b0 100644 --- a/third_party/lowrisc_opentitan/BUILD.bazel +++ b/third_party/lowrisc_opentitan/BUILD.bazel @@ -1,6 +1,7 @@ # Licensed under the Apache-2.0 license # SPDX-License-Identifier: Apache-2.0 +load("@rules_rust//rust:defs.bzl", "rust_library") load(":defs.bzl", "opentitan_rust_binary", "opentitan_rust_library") package(default_visibility = ["//visibility:public"]) @@ -14,3 +15,21 @@ opentitan_rust_library( name = "opentitanlib", actual = "@lowrisc_opentitan//sw/host/opentitanlib", ) + +rust_library( + name = "usb_test_helper", + srcs = [ + "@lowrisc_opentitan//sw/host/tests/chip/usb:mod.rs", + "@lowrisc_opentitan//sw/host/tests/chip/usb:usb.rs", + ], + crate_name = "usb", + crate_root = "@lowrisc_opentitan//sw/host/tests/chip/usb:usb.rs", + deps = [ + "//third_party/lowrisc_opentitan:opentitanlib", + "@ot_crate_index//:anyhow", + "@ot_crate_index//:clap", + "@ot_crate_index//:humantime", + "@ot_crate_index//:log", + "@ot_crate_index//:rusb", + ], +) From a3b310716221836cb6d409a760ed1205f156f202 Mon Sep 17 00:00:00 2001 From: Chris Frantz Date: Sat, 20 Jun 2026 18:46:53 -0700 Subject: [PATCH 3/5] earlgrey: enhance usbserial test Signed-off-by: Chris Frantz --- target/earlgrey/tests/usbserial/BUILD.bazel | 41 ++++-- target/earlgrey/tests/usbserial/README.md | 47 +++++++ .../tests/usbserial/host_usb_serial_check.rs | 123 ++++++++++++++++++ target/earlgrey/tests/usbserial/system.json5 | 6 + target/earlgrey/tests/usbserial/test_usb.rs | 14 +- 5 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 target/earlgrey/tests/usbserial/README.md create mode 100644 target/earlgrey/tests/usbserial/host_usb_serial_check.rs diff --git a/target/earlgrey/tests/usbserial/BUILD.bazel b/target/earlgrey/tests/usbserial/BUILD.bazel index 44eebb52..2f6817fa 100644 --- a/target/earlgrey/tests/usbserial/BUILD.bazel +++ b/target/earlgrey/tests/usbserial/BUILD.bazel @@ -10,6 +10,7 @@ load("@rules_rust//rust:defs.bzl", "rust_binary") load("//target/earlgrey:defs.bzl", "TARGET_COMPATIBLE_WITH") load("//target/earlgrey/signing/keys:defs.bzl", "FPGA_ECDSA_KEY", "SILICON_ECDSA_KEY") load("//target/earlgrey/tooling:opentitan_runner.bzl", "opentitan_test") +load("//third_party/lowrisc_opentitan:defs.bzl", "opentitan_rust_binary") rust_app( name = "test_usb", @@ -26,14 +27,17 @@ rust_app( "//protocol/usb/cdc_acm", "//protocol/usb/stack", "//target/earlgrey/drivers:usb_driver", + "//target/earlgrey/registers:lc_ctrl", "//target/earlgrey/registers:pinmux", "//target/earlgrey/registers:top_earlgrey", "//target/earlgrey/registers:usbdev", + "//target/earlgrey/util", "//util/panic", "@pigweed//pw_kernel/userspace", "@pigweed//pw_log/rust:pw_log", "@pigweed//pw_status/rust:pw_status", "@rust_crates//:aligned", + "@rust_crates//:zerocopy", ], ) @@ -94,18 +98,6 @@ rust_binary( ], ) -opentitan_test( - name = "usb_verilator_test", - timeout = "eternal", - environment = "//target/earlgrey/env:verilator", - interface = "verilator", - tags = [ - "nightly_test", - "verilator", - ], - target = ":usb", -) - opentitan_test( name = "usb_hyper310_test", ecdsa_key = FPGA_ECDSA_KEY, @@ -116,6 +108,8 @@ opentitan_test( "hyper310", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_serial_check", ) opentitan_test( @@ -128,6 +122,8 @@ opentitan_test( "hyper340", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_serial_check", ) opentitan_test( @@ -140,4 +136,25 @@ opentitan_test( "hardware", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_serial_check", +) + +opentitan_rust_binary( + name = "host_usb_serial_check", + srcs = ["host_usb_serial_check.rs"], + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-Wl,--allow-shlib-undefined", + ], + deps = [ + "//third_party/lowrisc_opentitan:opentitanlib", + "//third_party/lowrisc_opentitan:usb_test_helper", + "@ot_crate_index//:anyhow", + "@ot_crate_index//:clap", + "@ot_crate_index//:humantime", + "@ot_crate_index//:log", + "@ot_crate_index//:serialport", + ], ) diff --git a/target/earlgrey/tests/usbserial/README.md b/target/earlgrey/tests/usbserial/README.md new file mode 100644 index 00000000..8e05716b --- /dev/null +++ b/target/earlgrey/tests/usbserial/README.md @@ -0,0 +1,47 @@ +# USB Serial Test (`usbserial`) + +This test verifies the USB CDC-ACM (Virtual Serial Port) functionality on the OpenTitan Earlgrey target. It ensures that the device can enumerate as a USB serial port, that the host can identify it by its unique Device ID (exposed as the USB serial number), and that data can be successfully transmitted bidirectionally. + +## Test Components + +The test consists of two parts: + +1. **Firmware (`test_usb.rs`)**: Runs on the Ibex core of the OpenTitan. + * Maps and accesses the Lifecycle Controller (`lc_ctrl`) to read the 256-bit Device ID. + * Formats the Device ID as a 64-character hex string and prints it to the UART console (`Serial Number: `). + * Initializes the USB CDC-ACM device stack, using the raw Device ID bytes to populate the USB Serial Number string descriptor (automatically converted to UTF-16 by the driver). + * Enters an echo loop: reads bytes from the USB serial interface and writes them back. + +2. **Host Harness (`host_usb_serial_check.rs`)**: Runs on the host machine controlling the test. + * Resets the target board to ensure a clean boot and capture all logs. + * Monitors the UART console and waits for the `🔄 RUNNING` log. + * Uses regex to capture the printed serial number from the console logs: `Serial Number: ([0-9a-fA-F]{64})`. + * Enables VBUS on the target board (if supported by the transport). + * Polls the host system's serial ports using the `serialport` crate, looking for a USB serial port (matching OpenTitan VID/PID) that reports the captured serial number. + * Once detected, opens the serial port device node (e.g., `/dev/ttyACM2`). + * Sends a test string (`"Hello, OpenPRoT USB Serial!"`) to the USB serial port. + * Reads back the response and verifies it matches the sent string (echo test). + +## Bazel Configuration and Transitions + +The host harness (`host_usb_serial_check`) depends on both `opentitanlib` (for target control and UART console monitoring) and the `serialport` crate (for host-side serial communication). + +Because `opentitanlib` is compiled under a specific Bazel configuration transition (which configures transport flags), the host harness binary must be built under the same transition to prevent Rust `StableCrateId` collisions between the direct and transitive dependencies on `serialport`. + +To hide this complexity, we use the `opentitan_rust_binary` macro in `BUILD.bazel` instead of `rust_binary`. This macro automatically creates the raw target and wraps it in a transition-applying rule, aligning all configurations. + +## Running the Test + +### On CW310 (FPGA) Hardware + +To run the test on a connected CW310 board: + +```bash +bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/tests/usbserial:usb_hyper310_test +``` + +To see verbose log output from the host harness during the test: + +```bash +bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/tests/usbserial:usb_hyper310_test --test_arg=--logging=info +``` diff --git a/target/earlgrey/tests/usbserial/host_usb_serial_check.rs b/target/earlgrey/tests/usbserial/host_usb_serial_check.rs new file mode 100644 index 00000000..d47ef4c4 --- /dev/null +++ b/target/earlgrey/tests/usbserial/host_usb_serial_check.rs @@ -0,0 +1,123 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +use opentitanlib::test_utils::init::InitializeTest; +use opentitanlib::uart::console::UartConsole; +use usb::UsbOpts; + +#[derive(Parser, Debug)] +struct Opts { + #[command(flatten)] + init: InitializeTest, + + #[command(flatten)] + usb: UsbOpts, + + #[arg(long, default_value = "Hello, OpenPRoT USB Serial!")] + echo_string: String, +} + +fn wait_for_usb_serial( + expected_serial: &str, + usb_vid: u16, + usb_pid: u16, + timeout: Duration, +) -> Result { + let start = Instant::now(); + while start.elapsed() < timeout { + let ports = serialport::available_ports().context("Failed to list serial ports")?; + for info in ports { + if let serialport::SerialPortType::UsbPort(usb_info) = &info.port_type { + if usb_info.vid == usb_vid && usb_info.pid == usb_pid { + if let Some(ref serial) = usb_info.serial_number { + if serial == expected_serial { + return Ok(info); + } + } + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + bail!("USB serial port not found within timeout"); +} + +fn run_echo_test(port_name: &str, test_data: &str) -> Result<()> { + let mut port = serialport::new(port_name, 115_200) + .timeout(Duration::from_secs(2)) + .open() + .context("Failed to open serial port")?; + + log::info!("Sending test data: {:?}", test_data); + port.write_all(test_data.as_bytes()) + .context("Failed to write to serial port")?; + + let mut buf = vec![0; test_data.len()]; + log::info!("Reading back data..."); + port.read_exact(&mut buf) + .context("Failed to read from serial port")?; + + let received = String::from_utf8_lossy(&buf); + log::info!("Received data: {:?}", received); + + if received != test_data { + bail!("Echo data mismatch"); + } + log::info!("Echo test passed!"); + Ok(()) +} + +fn main() -> Result<()> { + let opts = Opts::parse(); + opts.init.init_logging(); + + let transport = opts.init.init_target()?; + + log::info!("Resetting target..."); + transport.reset(opentitanlib::app::UartRx::Clear)?; + + // Wait until test is running. + let uart = transport.uart("console")?; + log::info!("waiting for RUNNING on console..."); + UartConsole::wait_for(&*uart, r"RUNNING", Duration::from_secs(30))?; + + opts.usb.apply_strappings(&transport, true)?; + // Enable VBUS sense on the board if necessary. + if opts.usb.vbus_control_available() { + opts.usb.enable_vbus(&transport, true)?; + } + // Sense VBUS if available. + if opts.usb.vbus_sense_available() { + if !opts.usb.vbus_present(&transport)? { + bail!("OT USB does not appear to be connected to a host (VBUS not detected)"); + } + } + + // Learn serial number + log::info!("waiting for Serial Number on console..."); + let res = UartConsole::wait_for( + &*uart, + r"Serial Number: ([0-9a-fA-F]{64})", + Duration::from_secs(10), + )?; + let serial_num = res[1].clone(); + log::info!("Captured Serial Number: {}", serial_num); + + log::info!("waiting for USB serial port with serial {}...", serial_num); + let port_info = wait_for_usb_serial( + &serial_num, + opts.usb.vid, + opts.usb.pid, + Duration::from_secs(10), + )?; + log::info!("Found USB serial port: {}", port_info.port_name); + + run_echo_test(&port_info.port_name, &opts.echo_string)?; + + Ok(()) +} diff --git a/target/earlgrey/tests/usbserial/system.json5 b/target/earlgrey/tests/usbserial/system.json5 index c9044e5f..6268e517 100644 --- a/target/earlgrey/tests/usbserial/system.json5 +++ b/target/earlgrey/tests/usbserial/system.json5 @@ -67,6 +67,12 @@ type: "device", start_address: 0x40460000, size_bytes: 0x1000, + }, + { + name: "lc_ctrl", + type: "device", + start_address: 0x40140000, + size_bytes: 0x100, } ], diff --git a/target/earlgrey/tests/usbserial/test_usb.rs b/target/earlgrey/tests/usbserial/test_usb.rs index bbba6f2c..5d13a397 100644 --- a/target/earlgrey/tests/usbserial/test_usb.rs +++ b/target/earlgrey/tests/usbserial/test_usb.rs @@ -5,11 +5,15 @@ #![no_main] #![allow(dead_code)] +use earlgrey_util::device_id::format_device_id; +use lc_ctrl::LcCtrl; use pw_status::{Error, Result, StatusCode}; use test_usb_codegen::{handle, signals}; use userspace::time::Instant; use userspace::{entry, syscall}; +use zerocopy::IntoBytes; + use aligned::{Aligned, A4}; use hal_usb::{ConfigDescriptor, DeviceDescriptor, StringDescriptorRef}; @@ -106,11 +110,19 @@ impl DescriptorSource for MyDescriptors<'_> { } fn handle_usb() -> Result<()> { + let lc_ctrl = unsafe { LcCtrl::new() }; + let device_id: [u32; 8] = lc_ctrl.regs().device_id().read().into(); + let mut dev_id_buf = [0_u8; 64]; + let serial_str = format_device_id(&device_id, &mut dev_id_buf).map_err(|_| Error::Internal)?; + pw_log::info!("Serial Number: {}", serial_str); + + let device_id_bytes = device_id.as_bytes(); + let mut serial_num_buffer = Aligned::([0_u8; 130]); let descriptors = MyDescriptors { serial_desc_bytes: hal_usb::hex_utf16_descriptor_aligned( &mut serial_num_buffer, - b"12345678", + device_id_bytes, ) .unwrap(), product_desc_bytes: PRODUCT_ID_DEFAULT, From ea7c954b3225ae1b26102a6e87552a808443d28b Mon Sep 17 00:00:00 2001 From: Chris Frantz Date: Sat, 20 Jun 2026 20:15:17 -0700 Subject: [PATCH 4/5] earlgrey: Add host-based usbdfu test harness Implement a functional host-based test harness for the `usbdfu` test. - Improve firmware to support DFU loopback (download/upload) on Alt 0. - firmware erases 64KB starting at 0xA0000 on block 0 of download, and programs subsequent blocks. - firmware supports upload on Alt 0 to read back programmed data. - Implement `host_usb_dfu_check.rs` host harness which reset target, captures serial number, claims DFU interface, and performs download/upload verification loop. - Add README.md describing the test. TAG=agy CONV=37898f8b-5d74-4dc6-95c2-98f7e13bd162 --- target/earlgrey/tests/usbdfu/BUILD.bazel | 30 +- target/earlgrey/tests/usbdfu/README.md | 51 +++ .../tests/usbdfu/host_usb_dfu_check.rs | 302 ++++++++++++++++++ target/earlgrey/tests/usbdfu/system.json5 | 6 + target/earlgrey/tests/usbdfu/test_usb.rs | 61 +++- 5 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 target/earlgrey/tests/usbdfu/README.md create mode 100644 target/earlgrey/tests/usbdfu/host_usb_dfu_check.rs diff --git a/target/earlgrey/tests/usbdfu/BUILD.bazel b/target/earlgrey/tests/usbdfu/BUILD.bazel index 650b55ec..1b686aa0 100644 --- a/target/earlgrey/tests/usbdfu/BUILD.bazel +++ b/target/earlgrey/tests/usbdfu/BUILD.bazel @@ -10,6 +10,7 @@ load("@rules_rust//rust:defs.bzl", "rust_binary") load("//target/earlgrey:defs.bzl", "TARGET_COMPATIBLE_WITH") load("//target/earlgrey/signing/keys:defs.bzl", "FPGA_ECDSA_KEY", "SILICON_ECDSA_KEY") load("//target/earlgrey/tooling:opentitan_runner.bzl", "opentitan_test") +load("//third_party/lowrisc_opentitan:defs.bzl", "opentitan_rust_binary") rust_app( name = "test_usb_dfu", @@ -28,6 +29,7 @@ rust_app( "//protocol/usb/stack", "//services/flash:client", "//target/earlgrey/drivers:usb_driver", + "//target/earlgrey/registers:lc_ctrl", "//target/earlgrey/registers:pinmux", "//target/earlgrey/registers:top_earlgrey", "//target/earlgrey/registers:usbdev", @@ -39,6 +41,7 @@ rust_app( "@pigweed//pw_log/rust:pw_log", "@pigweed//pw_status/rust:pw_status", "@rust_crates//:aligned", + "@rust_crates//:zerocopy", ], ) @@ -119,8 +122,24 @@ rust_binary( ], ) -# TODO: these tests aren't self-contained. For now, you need to -# interact with them via the `dfu-util` program. +opentitan_rust_binary( + name = "host_usb_dfu_check", + srcs = ["host_usb_dfu_check.rs"], + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-Wl,--allow-shlib-undefined", + ], + deps = [ + "//third_party/lowrisc_opentitan:opentitanlib", + "//third_party/lowrisc_opentitan:usb_test_helper", + "@ot_crate_index//:anyhow", + "@ot_crate_index//:clap", + "@ot_crate_index//:log", + "@ot_crate_index//:zerocopy", + ], +) + opentitan_test( name = "usb_verilator_test", timeout = "eternal", @@ -131,6 +150,8 @@ opentitan_test( "verilator", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_dfu_check", ) opentitan_test( @@ -143,6 +164,8 @@ opentitan_test( "hyper310", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_dfu_check", ) opentitan_test( @@ -155,6 +178,8 @@ opentitan_test( "hyper340", ], target = ":usb", + test_cmd = "--logging=info", + test_harness = ":host_usb_dfu_check", ) opentitan_test( @@ -167,6 +192,7 @@ opentitan_test( "hardware", ], target = ":usb", + test_harness = ":host_usb_dfu_check", ) rust_binary_no_panics_test( diff --git a/target/earlgrey/tests/usbdfu/README.md b/target/earlgrey/tests/usbdfu/README.md new file mode 100644 index 00000000..72fc4ce1 --- /dev/null +++ b/target/earlgrey/tests/usbdfu/README.md @@ -0,0 +1,51 @@ +# USB DFU Test (`usbdfu`) + +This test verifies the USB Device Firmware Upgrade (DFU) functionality on the OpenTitan Earlgrey target. It ensures that the device can enumerate as a DFU device, that the host can perform download (programming) and upload (readback) operations, and that device certificates can be read via alternate interface settings. + +## Test Components + +The test consists of three main components: + +1. **Firmware (`test_usb.rs`)**: Runs on the Ibex core of the OpenTitan. + * Reads the 256-bit Device ID from the Lifecycle Controller (`lc_ctrl`). + * Formats the Device ID as a 64-character hex string and prints it to the UART console (`Serial Number: `). + * Initializes the USB DFU device stack, exposing Alt 0 for flash programming, and Alt 1-3 for reading certificates. + * On Alt 0: + * Receives DFU `DNLOAD` blocks (2048 bytes). Block 0 triggers an erase of 64KB starting at `0xA0000` (Bank 1). + * Subsequent blocks are programmed to flash at `0xA0000` using the `flash_server` service. + * Supports `UPLOAD` to read back the programmed data. + * On Alt 1-3: + * Supports `UPLOAD` to read UDS, CDI_0, and CDI_1 certificates. If a certificate is blank (expected for UDS on FPGA), it stalls the control pipe, which is handled gracefully by the host. + +2. **Flash Server (`flash_server.rs`)**: A separate userspace process running on the target. + * Acts as a secure intermediary for flash operations. + * Receives IPC requests from the DFU process to erase and write flash. + * Interacts directly with the hardware flash controller. + +3. **Host Harness (`host_usb_dfu_check.rs`)**: Runs on the host machine. + * Resets the target board. + * Monitors the UART console to capture the unique Serial Number. + * Finds the DFU device on the USB bus matching the captured Serial Number. + * Claims the DFU interface and parses the DFU block size (transfer size) from the DFU functional descriptor. + * Generates 64KB of test data. + * Performs DFU download of the test data. + * Sends a Zero-Length Packet (ZLP) to signal the end of download. + * Waits for manifestation to complete and verifies the device returns to the `Idle` state. + * Performs DFU upload to read back the 64KB and verifies it matches the original data. + * Performs DFU upload on Alt 1-3 to read certificates, verifying they can be read (or stalled if blank) without crashing the target. + +## Running the Test + +### On CW310 (FPGA) Hardware + +To run the test on a connected CW310 board: + +```bash +bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/tests/usbdfu:usb_hyper310_test +``` + +To see verbose log output from the host harness and target console during the test: + +```bash +bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/tests/usbdfu:usb_hyper310_test --test_arg=--logging=info +``` diff --git a/target/earlgrey/tests/usbdfu/host_usb_dfu_check.rs b/target/earlgrey/tests/usbdfu/host_usb_dfu_check.rs new file mode 100644 index 00000000..9bb4cbf6 --- /dev/null +++ b/target/earlgrey/tests/usbdfu/host_usb_dfu_check.rs @@ -0,0 +1,302 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Parser; +use std::time::Duration; +use zerocopy::FromBytes; + +use opentitanlib::app::TransportWrapper; +use opentitanlib::io::console::ConsoleExt; +use opentitanlib::io::uart::Uart; +use opentitanlib::io::usb::UsbDevice; +use opentitanlib::rescue::dfu::{DfuRequest, DfuRequestType, DfuState, DfuStatus}; +use opentitanlib::test_utils::init::InitializeTest; +use opentitanlib::uart::console::UartConsole; +use usb::UsbOpts; + +#[derive(Parser, Debug)] +struct CmdArgs { + #[command(flatten)] + // Standard arguments for initializing the transport (Hyperdebug, etc.) + init: InitializeTest, + + #[command(flatten)] + usb: UsbOpts, +} + +const DFU_TIMEOUT: Duration = Duration::from_secs(10); + +struct DfuClient<'a> { + device: &'a dyn UsbDevice, + interface: u8, +} + +impl<'a> DfuClient<'a> { + fn new(device: &'a dyn UsbDevice, interface: u8) -> Self { + Self { device, interface } + } + + fn write_control(&self, request: DfuRequest, value: u16, data: &[u8]) -> Result { + self.device.write_control_timeout( + DfuRequestType::Out.into(), + request.into(), + value, + self.interface as u16, + data, + DFU_TIMEOUT, + ) + } + + fn read_control(&self, request: DfuRequest, value: u16, data: &mut [u8]) -> Result { + self.device.read_control_timeout( + DfuRequestType::In.into(), + request.into(), + value, + self.interface as u16, + data, + DFU_TIMEOUT, + ) + } + + fn download(&self, block_num: u16, data: &[u8]) -> Result { + self.write_control(DfuRequest::DnLoad, block_num, data) + } + + fn upload(&self, block_num: u16, data: &mut [u8]) -> Result { + self.read_control(DfuRequest::UpLoad, block_num, data) + } + + fn get_status(&self) -> Result { + let mut buf = [0u8; 6]; + self.read_control(DfuRequest::GetStatus, 0, &mut buf)?; + DfuStatus::read_from_bytes(&buf).map_err(|e| anyhow!("Failed to parse DfuStatus: {:?}", e)) + } + + fn clear_status(&self) -> Result<()> { + self.write_control(DfuRequest::ClrStatus, 0, &[])?; + Ok(()) + } + + #[allow(dead_code)] + fn abort(&self) -> Result<()> { + self.write_control(DfuRequest::Abort, 0, &[])?; + Ok(()) + } + + fn wait_state(&self, expected: DfuState, uart: &dyn Uart) -> Result { + loop { + print_uart(uart); + let status = self.get_status()?; + log::debug!( + "DFU State: {:?}, Status: {:?}", + status.state(), + status.status() + ); + if status.state() == expected { + return Ok(status); + } + if status.state() == DfuState::Error { + return Err(anyhow!("DFU entered Error state: {:?}", status.status())); + } + let delay = Duration::from_millis(status.poll_timeout() as u64); + std::thread::sleep(delay); + } + } +} + +fn print_uart(uart: &dyn Uart) { + if !log::log_enabled!(log::Level::Info) { + return; + } + let mut buf = [0u8; 256]; + while let Ok(n) = uart.read_timeout(&mut buf, Duration::ZERO) { + if n == 0 { + break; + } + use std::io::Write; + let _ = std::io::stdout().write_all(&buf[..n]); + let _ = std::io::stdout().flush(); + } +} + +fn get_dfu_transfer_size(device: &dyn UsbDevice, interface_num: u8) -> Result { + let config = device.active_configuration()?; + for intf in config.interface_alt_settings() { + let desc = intf.descriptor()?; + if desc.intf_num == interface_num { + for subdesc in intf.subdescriptors() { + if subdesc.len() >= 7 && subdesc[1] == 0x21 { + let transfer_size = u16::from_le_bytes([subdesc[5], subdesc[6]]); + return Ok(transfer_size); + } + } + } + } + bail!("DFU functional descriptor not found"); +} + +fn run_dfu_test(transport: &TransportWrapper, usb: &UsbOpts) -> Result<()> { + let uart = transport.uart("console")?; + + log::info!("Resetting target..."); + transport.reset(opentitanlib::app::UartRx::Clear)?; + + log::info!("waiting for RUNNING on console..."); + let _ = UartConsole::wait_for(&*uart, r"🔄 RUNNING", Duration::from_secs(10))?; + + usb.apply_strappings(transport, true)?; + // Enable VBUS sense on the board if necessary. + if usb.vbus_control_available() { + usb.enable_vbus(transport, true)?; + } + // Sense VBUS if available. + if usb.vbus_sense_available() { + if !usb.vbus_present(transport)? { + bail!("OT USB does not appear to be connected to a host (VBUS not detected)"); + } + } + + let usb_vid = usb.vid; + let usb_pid = usb.pid; + + log::info!("waiting for Serial Number on console..."); + let res = UartConsole::wait_for( + &*uart, + r"Serial Number: ([0-9a-fA-F]+)", + Duration::from_secs(5), + )?; + let serial_number = res[1].as_str(); + log::info!("Captured Serial Number: {}", serial_number); + + log::info!( + "waiting for DFU device (VID={:04x}, PID={:04x}, Serial={})...", + usb_vid, + usb_pid, + serial_number + ); + let device = transport + .usb()? + .device_by_id_with_timeout( + usb_vid, + usb_pid, + Some(serial_number), + Duration::from_secs(10), + ) + .context("DFU device not found")?; + + log::info!("Claiming DFU interface..."); + let interface_num = 0; + device.claim_interface(interface_num)?; + + let transfer_size = get_dfu_transfer_size(&*device, interface_num)?; + log::info!("DFU Transfer Size (Block Size): {} bytes", transfer_size); + + let dfu = DfuClient::new(&*device, interface_num); + + // Ensure we start from a clean state + let status = dfu.get_status()?; + if status.state() == DfuState::Error { + log::info!("Clearing DFU error status..."); + dfu.clear_status()?; + } + + // 1. Download Test + log::info!("Preparing 64KB test data..."); + let mut test_data = vec![0u8; 65536]; + for (i, byte) in test_data.iter_mut().enumerate() { + *byte = (i & 0xFF) as u8; + } + + log::info!("Starting DFU download..."); + let mut block_num = 0; + for chunk in test_data.chunks(transfer_size as usize) { + log::debug!("Sending block {}, size {}...", block_num, chunk.len()); + dfu.download(block_num, chunk)?; + dfu.wait_state(DfuState::DnLoadIdle, &*uart)?; + block_num += 1; + } + + log::info!("Signaling end of download (ZLP)..."); + dfu.download(block_num, &[])?; + + log::info!("Waiting for manifestation to complete..."); + // ManifestationTolerant = 1 in firmware, so it should transition back to Idle + dfu.wait_state(DfuState::Idle, &*uart)?; + log::info!("Download complete!"); + print_uart(&*uart); + + // 2. Upload Test (Verify Data) + log::info!("Starting DFU upload for verification..."); + let mut uploaded_data = Vec::new(); + let mut block_num = 0; + let mut buf = vec![0u8; transfer_size as usize]; + loop { + log::debug!("Reading block {}...", block_num); + let n = dfu.upload(block_num, &mut buf)?; + print_uart(&*uart); + if n == 0 { + break; + } + uploaded_data.extend_from_slice(&buf[..n]); + if n < transfer_size as usize { + break; // Short packet signals EOF + } + block_num += 1; + } + log::info!("Upload complete! Read {} bytes", uploaded_data.len()); + print_uart(&*uart); + + if uploaded_data != test_data { + bail!("Uploaded data does not match downloaded data!"); + } + log::info!("Data verification PASSED!"); + + // 3. Certificate Upload Test (Graceful handling of empty certs) + for alt in 1..=3 { + log::info!("Testing Certificate Alt Setting {}...", alt); + if let Err(e) = device.set_alternate_setting(interface_num, alt) { + log::warn!("Failed to set alternate setting {}: {:?}", alt, e); + continue; + } + + let mut cert_buf = vec![0u8; transfer_size as usize]; + // The firmware get_certificate might return ErrUnknown (Stall) if blank. + match dfu.upload(0, &mut cert_buf) { + Ok(n) => { + log::info!("Successfully read cert Alt {} ({} bytes)", alt, n); + // We can't verify content easily, but we log it. + if n > 0 { + log::info!( + "Cert Alt {} data (truncated): {:?}", + alt, + &cert_buf[..std::cmp::min(n, 16)] + ); + } else { + log::warn!("Cert Alt {} returned 0 bytes", alt); + } + } + Err(e) => { + // Expected on FPGA where certs are blank + log::info!("Cert Alt {} read failed (expected if blank): {:?}", alt, e); + // Try to clear status in case we stalled + let _ = dfu.clear_status(); + } + } + } + + // Switch back to Alt 0 + let _ = device.set_alternate_setting(interface_num, 0); + + device.release_interface(interface_num)?; + Ok(()) +} + +fn main() -> Result<()> { + let args = CmdArgs::parse(); + args.init.init_logging(); + + let transport = args.init.init_target()?; + run_dfu_test(&transport, &args.usb)?; + Ok(()) +} diff --git a/target/earlgrey/tests/usbdfu/system.json5 b/target/earlgrey/tests/usbdfu/system.json5 index a5b363e1..3c157335 100644 --- a/target/earlgrey/tests/usbdfu/system.json5 +++ b/target/earlgrey/tests/usbdfu/system.json5 @@ -115,6 +115,12 @@ type: "device", start_address: 0x40460000, size_bytes: 0x1000, + }, + { + name: "lc_ctrl", + type: "device", + start_address: 0x40140000, + size_bytes: 0x100, } ], diff --git a/target/earlgrey/tests/usbdfu/test_usb.rs b/target/earlgrey/tests/usbdfu/test_usb.rs index 963bb513..1f2ea4a3 100644 --- a/target/earlgrey/tests/usbdfu/test_usb.rs +++ b/target/earlgrey/tests/usbdfu/test_usb.rs @@ -17,11 +17,14 @@ use hal_usb::driver::UsbDriver; use usb_driver::UsbConfig; use usb_stack::{DescriptorSource, UsbAction, UsbClass}; +use earlgrey_util::device_id::format_device_id; use earlgrey_util::{EarlgreyFlashAddress, PersoCertificate}; use hal_flash::{Flash, FlashAddress}; +use lc_ctrl::LcCtrl; use services_flash_client::FlashIpcClient; use util_error::{self as error, ErrorCode}; use util_ipc::IpcHandle; +use zerocopy::IntoBytes; use protocol_usb_dfu::{DfuBuilder, DfuClass, DfuHandler, DfuStatus}; @@ -161,8 +164,12 @@ fn get_certificate(flash: &mut FlashIpcClient, n: u8, data: &mut [u8]) -> Result Err(DfuStatus::ErrUnknown) } +const TEST_FLASH_START: u32 = 0xA0000; +const TEST_FLASH_SIZE: u32 = 0x10000; // 64KB for test + struct MyDfuHandler { flash: FlashIpcClient, + written_bytes: usize, } impl DfuHandler for MyDfuHandler { @@ -173,6 +180,38 @@ impl DfuHandler for MyDfuHandler { block_num, data.len() ); + if alt != 0 { + return Err(DfuStatus::ErrFile); + } + + let block_size = 2048; // matching DFU_BUILDER transfer_size + let offset = block_num as u32 * block_size; + + if offset + data.len() as u32 > TEST_FLASH_SIZE { + return Err(DfuStatus::ErrAddress); + } + + if block_num == 0 { + pw_log::info!("Erasing test flash area at 0x{:08x}...", TEST_FLASH_START); + let mut addr = TEST_FLASH_START; + let end = TEST_FLASH_START + TEST_FLASH_SIZE; + let (_, pg_sz, _) = self.flash.geometry().map_err(|_| DfuStatus::ErrUnknown)?; + while addr < end { + self.flash + .erase(FlashAddress::data(addr), pg_sz) + .map_err(|_| DfuStatus::ErrErase)?; + addr += pg_sz.get() as u32; + } + self.written_bytes = 0; + } + + if !data.is_empty() { + self.flash + .program(FlashAddress::data(TEST_FLASH_START + offset), data) + .map_err(|_| DfuStatus::ErrProg)?; + self.written_bytes = offset as usize + data.len(); + } + Ok(()) } @@ -184,6 +223,19 @@ impl DfuHandler for MyDfuHandler { data.len() ); match alt { + 0 => { + let block_size = data.len(); + let offset = block_num as usize * block_size; + let len = core::cmp::min(self.written_bytes.saturating_sub(offset), block_size); + if len == 0 { + return Ok(0); + } + let chunk = data.get_mut(..len).ok_or(DfuStatus::ErrUnknown)?; + self.flash + .read(FlashAddress::data(TEST_FLASH_START + offset as u32), chunk) + .map_err(|_| DfuStatus::ErrUnknown)?; + Ok(len) + } 1 | 2 | 3 => get_certificate(&mut self.flash, alt - 1, data), _ => Err(DfuStatus::ErrFile), } @@ -200,11 +252,17 @@ impl DfuHandler for MyDfuHandler { } fn handle_usb() -> Result<(), ErrorCode> { + let lc_ctrl = unsafe { LcCtrl::new() }; + let device_id: [u32; 8] = lc_ctrl.regs().device_id().read().into(); + let mut hex_buf = [0u8; 64]; + let serial_num = format_device_id(&device_id, &mut hex_buf).unwrap_or("DFU-ERR"); + pw_log::info!("Serial Number: {}", serial_num); + let mut serial_num_buffer = Aligned::([0_u8; 130]); let descriptors = MyDescriptors { serial_desc_bytes: hal_usb::hex_utf16_descriptor_aligned( &mut serial_num_buffer, - b"DFU-12345", + device_id.as_bytes(), ) .unwrap_or(PRODUCT_ID_DEFAULT), product_desc_bytes: PRODUCT_ID_DEFAULT, @@ -218,6 +276,7 @@ fn handle_usb() -> Result<(), ErrorCode> { DFU_BUILDER, MyDfuHandler { flash: FlashIpcClient::new(IpcHandle::new(handle::FLASH_SERVICE))?, + written_bytes: 0, }, ); From 0f06d255f8d8262b17561fee9f6669de90109158 Mon Sep 17 00:00:00 2001 From: Chris Frantz Date: Sat, 20 Jun 2026 20:22:36 -0700 Subject: [PATCH 5/5] Add README.md to target/earlgrey/tests Document existing integration tests and provide a guide for writing new tests (both self-contained and host-harness-based). TAG=agy CONV=37898f8b-5d74-4dc6-95c2-98f7e13bd162 --- target/earlgrey/tests/README.md | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 target/earlgrey/tests/README.md diff --git a/target/earlgrey/tests/README.md b/target/earlgrey/tests/README.md new file mode 100644 index 00000000..3037d6db --- /dev/null +++ b/target/earlgrey/tests/README.md @@ -0,0 +1,62 @@ +# OpenTitan Earlgrey Integration Tests + +This directory contains integration tests for the OpenTitan Earlgrey target, running on the Pigweed Maize microkernel. + +## Test Directory Structure + +Each subdirectory here represents a test suite or a specific test target: + +* **[`drivers/gpio`](drivers/gpio)**: Tests the GPIO driver functionality, verifying that pins can be configured, read, written, and toggled. +* **[`eflash`](eflash)**: Tests the embedded flash driver operations (erase, program, read) using a secure `flash_server` userspace process. +* **[`ipc/user`](ipc/user)**: Tests userspace IPC channel communication between different processes on the microkernel. +* **[`logging`](logging)**: Tests the microkernel logging subsystems and `pw_log` routing. +* **[`threads/kernel`](threads/kernel)**: Tests kernel-level threading, context switching, and scheduling invariants. +* **[`uart`](uart)**: Tests UART driver initialization and loopback communication (using interrupt-driven RX/TX). +* **[`unittest_runner`](unittest_runner)**: Runs the upstream Pigweed kernel unit and integration test suite on the target hardware. +* **[`usbdev`](usbdev)**: Tests basic USB device controller initialization and host-side enumeration. +* **[`usbdfu`](usbdfu)**: Tests USB Device Firmware Upgrade (DFU) protocol, including loopback download/upload verification and certificate reading. +* **[`usbserial`](usbserial)**: Tests USB CDC-ACM (Virtual Serial Port) bidirectional data transmission using an echo loop. + +--- + +## Guide for Writing New Tests + +When writing new integration tests, you can choose between two main patterns depending on whether the test requires host-side interaction (like USB or JTAG) or is self-contained on the target. + +### 1. Self-Contained Tests + +Self-contained tests run entirely on the Ibex core and report their results via the UART console. The Bazel test runner (`opentitan_test` macro) monitors the console output to determine success or failure. + +* **Success Criteria**: By default, the test runner looks for the string `PASS\n` in the console output. Ensure your firmware prints this (e.g., `pw_log::info!("✅ TEST PASS")` or similar) when all assertions succeed. +* **Failure Criteria**: By default, the test runner matches the regex `FAIL: .+\n` for failures. If an assertion fails or a panic occurs, ensure the output matches this pattern. +* **Customization**: You can override these defaults in the `opentitan_test` rule using `exit_success` and `exit_failure` attributes. + +Example firmware structure: +```rust +#[entry] +fn entry() -> Result<(), Error> { + pw_log::info!("🔄 MY_TEST START"); + let result = run_my_test_logic(); + match result { + Ok(()) => { + pw_log::info!("PASS"); // Triggers Bazel test success + Ok(()) + } + Err(e) => { + pw_log::error!("FAIL: {:?}", e); // Triggers Bazel test failure + Err(Error::Unknown) + } + } +} +``` + +### 2. Tests with Host-Based Test Harnesses + +If your test involves external interfaces (e.g., verifying USB enumeration, DFU transfers, or JTAG operations), you must write a host-based test harness that runs on the host machine and coordinates with the target. + +* **Harness Definition**: Define the host harness as a Rust binary using `opentitan_rust_binary` (which applies the necessary transition for `opentitanlib` compatibility) and link it via the `test_harness` attribute of `opentitan_test`. +* **Target Reset**: The host harness should start by resetting the target (e.g., `transport.reset(UartRx::Clear)?`) to ensure a clean boot and capture early boot logs. +* **Device Correlation (Best Practice)**: On systems with multiple connected devices, it is critical to ensure the host harness talks to the correct target. + * **Firmware**: Read the unique 256-bit Device ID from the Lifecycle Controller (`lc_ctrl`) and print it to the console (e.g., `Serial Number: `) early in the boot sequence. If using USB, also use this Device ID to populate the USB Serial Number string descriptor. + * **Harness**: Monitor the UART console first, capture the printed serial number using regex, and then use that serial number to look up and open the correct USB/JTAG device (e.g., `device_by_id_with_timeout(vid, pid, Some(serial), timeout)`). +* **Console Monitoring**: Use `UartConsole::wait_for` to synchronize host actions with target states (e.g., waiting for `🔄 RUNNING` before starting USB transfers).