From 8af0e08b83f0bfe7f782908b2cc50c9dfb4ecfea Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Mon, 10 Aug 2026 14:19:52 -0700 Subject: [PATCH 01/10] target/veer: add Caliptra-SS register crate and third-party BUILD wiring --- target/veer/registers/BUILD.bazel | 17 +++++++++++ target/veer/registers/README.md | 28 +++++++++++++++++++ target/veer/registers/registers.rs | 24 ++++++++++++++++ .../caliptra/caliptra-mcu-sw/BUILD.bazel | 13 +++++++++ .../caliptra/caliptra-mcu-sw/overlay.BUILD | 1 + 5 files changed, 83 insertions(+) create mode 100644 target/veer/registers/BUILD.bazel create mode 100644 target/veer/registers/README.md create mode 100644 target/veer/registers/registers.rs diff --git a/target/veer/registers/BUILD.bazel b/target/veer/registers/BUILD.bazel new file mode 100644 index 000000000..86500bad4 --- /dev/null +++ b/target/veer/registers/BUILD.bazel @@ -0,0 +1,17 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library") + +package(default_visibility = ["//visibility:public"]) + +# Umbrella crate re-exporting all Caliptra-SS peripheral register modules. +rust_library( + name = "registers", + srcs = ["registers.rs"], + crate_name = "caliptra_ss_registers", + edition = "2024", + deps = [ + "//third_party/caliptra/caliptra-mcu-sw:firmware_registers_generated", + ], +) diff --git a/target/veer/registers/README.md b/target/veer/registers/README.md new file mode 100644 index 000000000..a17295c51 --- /dev/null +++ b/target/veer/registers/README.md @@ -0,0 +1,28 @@ +# target/veer/registers + +Umbrella crate (`caliptra_ss_registers`) that re-exports the generated +register definitions for every Caliptra Subsystem peripheral. + +## Source + +Registers are generated from the Caliptra-SS SystemRDL sources by +`caliptra_mcu_registers_generator` and live in the pinned +`caliptra-mcu-sw` third-party dependency at +`registers/generated-firmware/src/`. Each peripheral module exposes a +`bits` sub-module of `tock_registers::register_bitfields!` types plus a +base address constant (e.g. `I3C_CSR_ADDR = 0x2000_4000`). + +## Usage + +Add `//target/veer/registers` to your `deps` and import the peripheral +module you need: + +```rust +use caliptra_ss_registers::i3c; +// Bitfield types for register reads/writes +use i3c::bits::Control; + +// Base address for MMIO pointer construction +const BASE: u32 = i3c::I3C_CSR_ADDR; + +``` diff --git a/target/veer/registers/registers.rs b/target/veer/registers/registers.rs new file mode 100644 index 000000000..7dda9e40d --- /dev/null +++ b/target/veer/registers/registers.rs @@ -0,0 +1,24 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Caliptra Subsystem register definitions. +//! +//! Re-exports the generated firmware register modules from caliptra-mcu-sw so +//! peripheral drivers in `target/veer/peripherals/` have a single import path. + +#![no_std] + +pub use caliptra_mcu_registers_generated::axicdma; +pub use caliptra_mcu_registers_generated::defines; +pub use caliptra_mcu_registers_generated::doe_mbox; +pub use caliptra_mcu_registers_generated::el2_pic_ctrl; +pub use caliptra_mcu_registers_generated::fuses; +pub use caliptra_mcu_registers_generated::i3c; +pub use caliptra_mcu_registers_generated::lc_ctrl; +pub use caliptra_mcu_registers_generated::mbox; +pub use caliptra_mcu_registers_generated::mci; +pub use caliptra_mcu_registers_generated::otp_ctrl; +pub use caliptra_mcu_registers_generated::primary_flash_ctrl; +pub use caliptra_mcu_registers_generated::secondary_flash_ctrl; +pub use caliptra_mcu_registers_generated::sha512_acc; +pub use caliptra_mcu_registers_generated::soc; diff --git a/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel b/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel index f40764767..70a6eb468 100644 --- a/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel +++ b/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel @@ -23,6 +23,19 @@ rust_library( ], ) +# no_std register definitions for Caliptra-SS peripherals (I3C, DMA, fuses, …). +rust_library( + name = "firmware_registers_generated", + srcs = ["@caliptra_mcu_sw//:all_files"], + crate_name = "caliptra_mcu_registers_generated", + crate_root = "@caliptra_mcu_sw//:registers/generated-firmware/src/lib.rs", + edition = "2021", + deps = [ + "//third_party/caliptra:crate_tock-registers", + "//third_party/caliptra:crate_zeroize", + ], +) + rust_library( name = "emulator_bmc", srcs = ["@caliptra_mcu_sw//:emulator_bmc_srcs"], diff --git a/third_party/caliptra/caliptra-mcu-sw/overlay.BUILD b/third_party/caliptra/caliptra-mcu-sw/overlay.BUILD index 832935550..7bd429ae2 100644 --- a/third_party/caliptra/caliptra-mcu-sw/overlay.BUILD +++ b/third_party/caliptra/caliptra-mcu-sw/overlay.BUILD @@ -17,6 +17,7 @@ exports_files( "rom/src/lib.rs", "common/testing/src/lib.rs", "registers/generated-emulator/src/lib.rs", + "registers/generated-firmware/src/lib.rs", "emulator/consts/src/lib.rs", "caliptra-util-host/apps/mailbox/server/src/lib.rs", "builder/src/lib.rs", From d721a0c60d0dd8519cef70d6fa92fe97d90d87df Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Wed, 12 Aug 2026 17:22:11 -0700 Subject: [PATCH 02/10] Add VeeR I3C smoke test and host harness --- target/veer/peripherals/i3c/BUILD.bazel | 19 ++ target/veer/peripherals/i3c/lib.rs | 172 ++++++++++ target/veer/tests/i3c_smoke/BUILD.bazel | 79 +++++ .../tests/i3c_smoke/i3c_smoke_host_test.rs | 308 ++++++++++++++++++ target/veer/tests/i3c_smoke/system.json5 | 16 + target/veer/tests/i3c_smoke/target.rs | 83 +++++ 6 files changed, 677 insertions(+) create mode 100644 target/veer/peripherals/i3c/BUILD.bazel create mode 100644 target/veer/peripherals/i3c/lib.rs create mode 100644 target/veer/tests/i3c_smoke/BUILD.bazel create mode 100644 target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs create mode 100644 target/veer/tests/i3c_smoke/system.json5 create mode 100644 target/veer/tests/i3c_smoke/target.rs diff --git a/target/veer/peripherals/i3c/BUILD.bazel b/target/veer/peripherals/i3c/BUILD.bazel new file mode 100644 index 000000000..ce2db063b --- /dev/null +++ b/target/veer/peripherals/i3c/BUILD.bazel @@ -0,0 +1,19 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library") +load("//target/veer:defs.bzl", "TARGET_COMPATIBLE_WITH") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "i3c", + srcs = ["lib.rs"], + crate_name = "caliptra_i3c_target", + edition = "2024", + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + "//target/veer/registers", + "//third_party/caliptra:crate_tock-registers", + ], +) diff --git a/target/veer/peripherals/i3c/lib.rs b/target/veer/peripherals/i3c/lib.rs new file mode 100644 index 000000000..12e43cba9 --- /dev/null +++ b/target/veer/peripherals/i3c/lib.rs @@ -0,0 +1,172 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Caliptra-SS I3C target peripheral driver. +//! +//! Drives the i3c-core HCI TTI (Target Transaction Interface) on behalf of +//! firmware running on the VeeR RISC-V core. The i3c-core is always a +//! *target* (secondary) from the VeeR perspective — a BMC controller on the +//! I3C bus initiates all transfers. +//! +//! The Caliptra ROM initializes the I3C core before handing off to application +//! firmware. This driver assumes the ROM has already run and only manages the +//! data path (interrupt enable/disable, RX drain, TX queue, IBI). +//! +//! # TTI data path +//! +//! - **Incoming write** (controller → target): hardware pushes a descriptor +//! into `tti_rx_desc_queue_port` then `data_length` words into +//! `tti_rx_data_port`. Poll `rx_pending()` or enable the RX interrupt. +//! - **Outgoing read** (target → controller): firmware writes data words to +//! `tti_tx_data_port` then a descriptor to `tti_tx_desc_queue_port`. +//! - **IBI**: write MDB + optional payload words to `tti_tti_ibi_port` then +//! the IBI descriptor; hardware raises the IBI on the bus. + +#![no_std] + +use caliptra_ss_registers::i3c; + +use core::marker::PhantomData; +use tock_registers::interfaces::{ReadWriteable, Readable, Writeable}; + +/// MIPI DCR value for an MCTP endpoint. +pub const MCTP_DCR: u8 = 0xCC; + +/// Driver for the Caliptra-SS i3c-core in target (secondary) mode. +pub struct CaliptraI3cTarget { + regs: *const i3c::regs::I3c, + // !Send + !Sync: exclusive ownership of one physical peripheral + _not_send_sync: PhantomData<*mut ()>, +} + +impl CaliptraI3cTarget { + /// Construct the driver over the Caliptra-SS I3C peripheral. + /// + /// # Safety + /// + /// Caller must ensure exclusive access to the I3C peripheral for the + /// lifetime of this value. + pub const unsafe fn new() -> Self { + Self { + regs: i3c::I3C_CSR_ADDR as *const i3c::regs::I3c, + _not_send_sync: PhantomData, + } + } + + #[inline] + fn regs(&self) -> &i3c::regs::I3c { + // SAFETY: `new` guarantees a valid address and exclusive access. + unsafe { &*self.regs } + } + + // ------------------------------------------------------------------------- + // Interrupts + // ------------------------------------------------------------------------- + + /// Enable the RX descriptor threshold interrupt. + pub fn enable_rx_interrupt(&mut self) { + self.regs() + .tti_interrupt_enable + .modify(i3c::bits::InterruptEnable::RxDescStatEn::SET); + } + + /// Disable the RX descriptor threshold interrupt. + pub fn disable_rx_interrupt(&mut self) { + self.regs() + .tti_interrupt_enable + .modify(i3c::bits::InterruptEnable::RxDescStatEn::CLEAR); + } + + // ------------------------------------------------------------------------- + // TTI receive (controller → target private write) + // ------------------------------------------------------------------------- + + /// Return `true` if an RX descriptor is waiting in the TTI queue. + pub fn rx_pending(&self) -> bool { + self.regs() + .tti_interrupt_status + .is_set(i3c::bits::InterruptStatus::RxDescStat) + } + + /// Drain one incoming write into `buf`. Returns the number of bytes read, + /// or `None` if no descriptor was present. + pub fn rx_read(&mut self, buf: &mut [u8]) -> Option { + let regs = self.regs(); + if !regs + .tti_interrupt_status + .is_set(i3c::bits::InterruptStatus::RxDescStat) + { + return None; + } + + let desc = regs.tti_rx_desc_queue_port.get(); + // Lower 16 bits of descriptor carry the data length in bytes. + let len = (desc & 0xffff) as usize; + let nwords = (len + 3) / 4; + + let mut i = 0usize; + for _ in 0..nwords { + let word = regs.tti_rx_data_port.get(); + for &b in &word.to_le_bytes() { + if let Some(slot) = buf.get_mut(i) { + *slot = b; + } + i += 1; + } + } + + // W1C — clear the status bit. + regs.tti_interrupt_status + .write(i3c::bits::InterruptStatus::RxDescStat::SET); + + Some(len.min(buf.len())) + } + + // ------------------------------------------------------------------------- + // TTI transmit (target → controller private read response) + // ------------------------------------------------------------------------- + + /// Queue `data` as the response to the next private-read from the controller. + pub fn tx_write(&mut self, data: &[u8]) { + let regs = self.regs(); + let mut chunks = data.chunks_exact(4); + for chunk in &mut chunks { + let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + regs.tti_tx_data_port.set(word); + } + let rem = chunks.remainder(); + if !rem.is_empty() { + let mut tmp = [0u8; 4]; + tmp[..rem.len()].copy_from_slice(rem); + regs.tti_tx_data_port.set(u32::from_le_bytes(tmp)); + } + // Descriptor: data_length in lower 16 bits; saturate rather than truncate. + regs.tti_tx_desc_queue_port + .set(u32::try_from(data.len()).unwrap_or(u16::MAX as u32)); + } + + // ------------------------------------------------------------------------- + // IBI (In-Band Interrupt — target → controller unsolicited notification) + // ------------------------------------------------------------------------- + + /// Raise an IBI with the given Mandatory Data Byte and optional payload. + /// Payload must be ≤255 bytes; excess bytes are silently dropped. + pub fn ibi_raise(&mut self, mdb: u8, payload: &[u8]) { + let payload = &payload[..payload.len().min(255)]; + let regs = self.regs(); + let mut chunks = payload.chunks_exact(4); + for chunk in &mut chunks { + let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + regs.tti_tti_ibi_port.set(word); + } + let rem = chunks.remainder(); + if !rem.is_empty() { + let mut tmp = [0u8; 4]; + tmp[..rem.len()].copy_from_slice(rem); + regs.tti_tti_ibi_port.set(u32::from_le_bytes(tmp)); + } + // IBI descriptor: MDB in bits [31:24], payload length in bits [7:0]. + let desc = ((mdb as u32) << 24) | (payload.len() as u32 & 0xff); + regs.tti_tti_ibi_port.set(desc); + } +} diff --git a/target/veer/tests/i3c_smoke/BUILD.bazel b/target/veer/tests/i3c_smoke/BUILD.bazel new file mode 100644 index 000000000..53744e229 --- /dev/null +++ b/target/veer/tests/i3c_smoke/BUILD.bazel @@ -0,0 +1,79 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image") +load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen") +load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script") +load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("//target/veer:defs.bzl", "TARGET_COMPATIBLE_WITH") +load("//target/veer/tooling:caliptra_runner.bzl", "caliptra_runner") + +package(default_visibility = ["//visibility:public"]) + +system_image( + name = "i3c_smoke", + kernel = ":target", + platform = "//target/veer", +) + +target_linker_script( + name = "linker_script", + system_config = ":system_config", + tags = ["kernel"], + template = "//target/veer:linker_script_template", +) + +rust_binary_no_panics_test( + name = "no_panics_test", + binary = ":i3c_smoke", +) + +filegroup( + name = "system_config", + srcs = ["system.json5"], +) + +target_codegen( + name = "codegen", + arch = "@pigweed//pw_kernel/arch/riscv:arch_riscv", + system_config = ":system_config", +) + +rust_binary( + name = "target", + srcs = ["target.rs"], + edition = "2024", + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + ":codegen", + ":linker_script", + "//target/veer:entry", + "//target/veer/peripherals/i3c", + "@pigweed//pw_kernel/arch/riscv:arch_riscv", + "@pigweed//pw_kernel/kernel", + "@pigweed//pw_kernel/subsys/console:console_backend", + "@pigweed//pw_kernel/target:target_common", + "@pigweed//pw_log/rust:pw_log", + ], +) + +caliptra_runner( + name = "i3c_smoke_runner", + interface = "emulator", + tags = ["manual"], + target = ":i3c_smoke", +) + +rust_test( + name = "i3c_smoke_test", + srcs = ["i3c_smoke_host_test.rs"], + crate_root = "i3c_smoke_host_test.rs", + edition = "2024", + data = [":i3c_smoke_runner"], + # caliptra_runner.py hardcodes --i3c-port=65534; must not run in parallel. + tags = [ + "emulator", + "exclusive", + ], +) diff --git a/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs new file mode 100644 index 000000000..fb9055505 --- /dev/null +++ b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs @@ -0,0 +1,308 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side harness for the VeeR I3C smoke test. +//! +//! Launches the emulator runner, waits for the firmware to report it is waiting +//! for a private write, then injects a valid private-write frame over the I3C +//! TCP socket (127.0.0.1:65534). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::string::{String, ToString}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use std::vec::Vec; + +const I3C_HOST: &str = "127.0.0.1"; +const I3C_PORT: u16 = 65534; +const DEFAULT_TARGET_ADDR: u8 = 0x08; +const PAYLOAD: [u8; 4] = [0x01, 0x02, 0x03, 0x04]; + +fn crc8_smbus(data: &[u8]) -> u8 { + let mut crc: u8 = 0; + for &value in data { + crc ^= value; + for _ in 0..8 { + if (crc & 0x80) != 0 { + crc = (crc << 1) ^ 0x07; + } else { + crc <<= 1; + } + } + } + crc +} + +fn connect_i3c_socket(timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut last_err: Option = None; + while Instant::now() < deadline { + match TcpStream::connect((I3C_HOST, I3C_PORT)) { + Ok(s) => return Ok(s), + Err(e) => { + last_err = Some(e.to_string()); + thread::sleep(Duration::from_millis(50)); + } + } + } + Err(format!( + "timed out connecting to {}:{} ({})", + I3C_HOST, + I3C_PORT, + last_err.unwrap_or_else(|| "unknown error".to_string()) + )) +} + +fn make_private_write_header(target_addr: u8, data_len: u16) -> [u8; 9] { + // IncomingHeader: to_addr:u8 + command:[u32;2] LE. + // Private write command (rnw=0): word0 = 0, data_length in word1[23:16]. + let cmd_word0: u32 = 0; + let cmd_word1: u32 = ((data_len as u32) & 0xFF) << 16; + + let mut out = [0u8; 9]; + out[0] = target_addr; + out[1..5].copy_from_slice(&cmd_word0.to_le_bytes()); + out[5..9].copy_from_slice(&cmd_word1.to_le_bytes()); + out +} + +fn send_private_write_on_stream( + stream: &mut TcpStream, + target_addr: u8, + payload: &[u8], +) -> Result<(), String> { + let mut pec_input = Vec::with_capacity(1 + payload.len()); + pec_input.push(target_addr << 1); + pec_input.extend_from_slice(payload); + let pec = crc8_smbus(&pec_input); + + let mut body = Vec::with_capacity(payload.len() + 1); + body.extend_from_slice(payload); + body.push(pec); + + let header = make_private_write_header(target_addr, body.len() as u16); + stream + .set_nonblocking(false) + .map_err(|e| format!("failed to set blocking mode for header write: {}", e))?; + stream + .write_all(&header) + .map_err(|e| format!("failed writing I3C header: {}", e))?; + stream + .set_nonblocking(true) + .map_err(|e| format!("failed to set nonblocking mode for payload write: {}", e))?; + stream + .write_all(&body) + .map_err(|e| format!("failed writing I3C body: {}", e))?; + + println!( + "I3C HOST TRACE: wrote frame to addr=0x{target_addr:02x} header={:02x?} body={:02x?}", + header, + body + ); + + Ok(()) +} + +fn extract_target_addr(line: &str) -> Option { + let marker = "target DynamicI3cAddress("; + let start = line.find(marker)? + marker.len(); + let rest = &line[start..]; + let end = rest.find(')')?; + let parsed = rest[..end].parse::().ok()?; + u8::try_from(parsed).ok() +} + +fn resolve_runner_path() -> PathBuf { + let srcdir = std::env::var("TEST_SRCDIR") + .expect("missing TEST_SRCDIR environment variable"); + let workspace = std::env::var("TEST_WORKSPACE") + .expect("missing TEST_WORKSPACE environment variable"); + let workspace_root = Path::new(&srcdir).join(&workspace); + + let candidate = workspace_root.join( + "target/veer/tests/i3c_smoke/i3c_smoke_runner.sh", + ); + if candidate.exists() { + return candidate; + } + + panic!( + "unable to locate i3c smoke runner at {:?}; TEST_SRCDIR={:?}, TEST_WORKSPACE={:?}", + candidate, + std::env::var("TEST_SRCDIR").ok(), + std::env::var("TEST_WORKSPACE").ok(), + ); +} + +fn resolve_runner_cwd(runner: &Path) -> PathBuf { + if let (Ok(srcdir), Ok(workspace)) = ( + std::env::var("TEST_SRCDIR"), + std::env::var("TEST_WORKSPACE"), + ) { + let root = Path::new(&srcdir).join(&workspace); + if root.exists() { + return root; + } + } + runner + .parent() + .expect("runner path has no parent directory") + .to_path_buf() +} + +#[test] +fn i3c_smoke_host_test() { + let runner = resolve_runner_path(); + let runner_cwd = resolve_runner_cwd(&runner); + let ready_marker = "waiting for private write"; + let socket_marker = "Starting I3C Socket"; + + let mut child = Command::new(&runner) + .current_dir(runner_cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn i3c_smoke_runner"); + + let stdout = child + .stdout + .take() + .expect("failed to capture runner stdout"); + let stderr = child + .stderr + .take() + .expect("failed to capture runner stderr"); + + let mut stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + + let sent = Arc::new(AtomicBool::new(false)); + let ready = Arc::new(AtomicBool::new(false)); + let done = Arc::new(AtomicBool::new(false)); + let target_addr = Arc::new(AtomicU8::new(DEFAULT_TARGET_ADDR)); + let connect_attempts = Arc::new(AtomicU32::new(0)); + let write_successes = Arc::new(AtomicU32::new(0)); + let write_failures = Arc::new(AtomicU32::new(0)); + + let sent_sender = Arc::clone(&sent); + let ready_sender = Arc::clone(&ready); + let done_sender = Arc::clone(&done); + let target_sender = Arc::clone(&target_addr); + let connect_attempts_sender = Arc::clone(&connect_attempts); + let write_successes_sender = Arc::clone(&write_successes); + let write_failures_sender = Arc::clone(&write_failures); + let sender_thread = thread::spawn(move || { + let mut keepalive: Option = None; + + while !done_sender.load(Ordering::Relaxed) { + if ready_sender.load(Ordering::Relaxed) { + let addr = target_sender.load(Ordering::Relaxed); + println!("I3C HOST TRACE: readiness observed, connecting to {}:{} with addr=0x{addr:02x}", I3C_HOST, I3C_PORT); + connect_attempts_sender.fetch_add(1, Ordering::Relaxed); + if let Ok(mut stream) = connect_i3c_socket(Duration::from_secs(5)) { + let mut writes_sent = 0u8; + for attempt in 0..15 { + match send_private_write_on_stream(&mut stream, addr, &PAYLOAD) { + Ok(()) => { + println!("I3C HOST TRACE: send attempt {} succeeded", attempt + 1); + writes_sent += 1; + write_successes_sender.fetch_add(1, Ordering::Relaxed); + } + Err(e) => { + println!("I3C HOST TRACE: send attempt {} failed: {}", attempt + 1, e); + write_failures_sender.fetch_add(1, Ordering::Relaxed); + } + } + thread::sleep(Duration::from_millis(25)); + } + if writes_sent > 0 { + sent_sender.store(true, Ordering::Relaxed); + keepalive = Some(stream); + break; + } + } + thread::sleep(Duration::from_millis(25)); + continue; + } + thread::sleep(Duration::from_millis(5)); + } + + while !done_sender.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(10)); + } + drop(keepalive); + }); + + let target_stderr = Arc::clone(&target_addr); + let ready_stderr = Arc::clone(&ready); + let stderr_thread = thread::spawn(move || { + let mut reader = stderr_reader; + let mut line = String::new(); + loop { + line.clear(); + let n = reader + .read_line(&mut line) + .expect("failed to read runner stderr"); + if n == 0 { + break; + } + eprint!("{}", line); + + if let Some(addr) = extract_target_addr(&line) { + target_stderr.store(addr, Ordering::Relaxed); + } + if line.contains(ready_marker) || line.contains(socket_marker) { + ready_stderr.store(true, Ordering::Relaxed); + } + } + }); + + let mut line = String::new(); + + loop { + line.clear(); + let n = stdout_reader + .read_line(&mut line) + .expect("failed to read runner stdout"); + if n == 0 { + break; + } + + print!("{}", line); + + if let Some(addr) = extract_target_addr(&line) { + target_addr.store(addr, Ordering::Relaxed); + } + + if line.contains(ready_marker) || line.contains(socket_marker) { + ready.store(true, Ordering::Relaxed); + } + } + + done.store(true, Ordering::Relaxed); + sender_thread + .join() + .expect("failed to join sender thread"); + + stderr_thread + .join() + .expect("failed to join stderr reader thread"); + + let status = child.wait().expect("failed to wait for runner"); + + assert!( + sent.load(Ordering::Relaxed), + "did not send I3C payload; ready={}, target_addr=0x{:02x}, connect_attempts={}, write_successes={}, write_failures={}", + ready.load(Ordering::Relaxed), + target_addr.load(Ordering::Relaxed), + connect_attempts.load(Ordering::Relaxed), + write_successes.load(Ordering::Relaxed), + write_failures.load(Ordering::Relaxed) + ); + assert!(status.success(), "runner exited with status: {}", status); +} diff --git a/target/veer/tests/i3c_smoke/system.json5 b/target/veer/tests/i3c_smoke/system.json5 new file mode 100644 index 000000000..cc890eadf --- /dev/null +++ b/target/veer/tests/i3c_smoke/system.json5 @@ -0,0 +1,16 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 +{ + arch: { + type: "riscv", + }, + kernel: { + flash_start_address: 0xA0010000, + flash_size_bytes: 65536, + ram_start_address: 0x10000000, + ram_size_bytes: 32768, + interrupt_table: { + table: {} + }, + }, +} diff --git a/target/veer/tests/i3c_smoke/target.rs b/target/veer/tests/i3c_smoke/target.rs new file mode 100644 index 000000000..bfe1457b3 --- /dev/null +++ b/target/veer/tests/i3c_smoke/target.rs @@ -0,0 +1,83 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! I3C smoke test. +//! +//! Verifies that: +//! 1. `enable_rx_interrupt()` runs without trapping. +//! 2. A private write sent by the test harness over TCP port 65534 is +//! received correctly with the expected payload [0x01, 0x02, 0x03, 0x04]. + +#![no_std] +#![no_main] + +use caliptra_i3c_target::CaliptraI3cTarget; +use entry::exit; +use target_common::{declare_target, TargetInterface}; +use {console_backend as _, kernel as _}; + +pub struct Target {} + +// Emits PW_KERNEL_INTERRUPT_TABLE from the interrupt_table in system.json5. +codegen::declare_kernel_interrupt_handlers!(); + +impl TargetInterface for Target { + const NAME: &'static str = "Caliptra I3C Smoke Test"; + + fn main() -> ! { + // SAFETY: single call at boot; Caliptra ROM has already initialized + // the I3C core and we are the only owner of the peripheral. + let mut i3c = unsafe { CaliptraI3cTarget::new() }; + i3c.enable_rx_interrupt(); + pw_log::info!("I3C smoke test: waiting for private write"); + + let mut buf = [0u8; 64]; + // Poll until we receive a write or exhaust the iteration budget. + const MAX_POLLS: u32 = 10_000_000; + let mut polls = 0u32; + loop { + if polls % 1_000_000 == 0 { + let pending = i3c.rx_pending(); + pw_log::info!( + "I3C smoke trace: poll={} rx_pending={}", + polls, + if pending { 1u32 } else { 0u32 } + ); + } + + if let Some(len) = i3c.rx_read(&mut buf) { + pw_log::info!( + "I3C smoke trace: rx_read len={} first4={:02x} {:02x} {:02x} {:02x}", + len as u32, + buf[0], + buf[1], + buf[2], + buf[3] + ); + let expected = [0x01u8, 0x02, 0x03, 0x04]; + if len >= 4 && buf[..4] == expected { + pw_log::info!("I3C smoke test: received expected payload OK"); + exit(0); + } else { + pw_log::info!("I3C smoke test: unexpected payload len={}", len as u32); + exit(1); + } + } + polls += 1; + if polls >= MAX_POLLS { + pw_log::info!("I3C smoke test: timed out waiting for write"); + exit(2); + } + } + } + + fn shutdown(code: u32) -> ! { + match code { + 0 => pw_log::info!("PASS"), + _ => pw_log::info!("FAIL: {}", code), + } + exit(code); + } +} + +declare_target!(Target); From 4481ed78812314113e86a96745352e1cf70066e5 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Wed, 12 Aug 2026 20:01:38 -0700 Subject: [PATCH 03/10] Fix I3C smoke test: start the emulator's I3C controller pump The stock caliptra-mcu-sw emulator main only drains the I3C socket's command channel into the emulated target when built with one of its test-* cargo features; in a featureless build, frames written to --i3c-port accumulate in an mpsc channel nobody reads, so the firmware's TTI RX queue never fills and the smoke test could not pass. Replace the emulator's entry point with a local main (linking the unmodified upstream emulator_lib) that unconditionally calls the public start_i3c_controller() before the run loop, following the same local-main pattern as the signer target. Also harden the test itself: - target.rs: drop enable_rx_interrupt() and pure-poll instead; the system image has an empty interrupt table, and a delivered frame would vector the CPU to address zero (observed as a terminal mcause=1 exception at epc=0). - host harness: treat only the firmware's "waiting for private write" log line as readiness (the emulator's socket banner prints minutes before firmware boots), and keep sending frames until the runner exits instead of 15 attempts fired during boot. i3c_smoke_test now passes in ~16s (previously failed after 161s). --- .../tests/i3c_smoke/i3c_smoke_host_test.rs | 57 +++++++++---------- target/veer/tests/i3c_smoke/target.rs | 9 ++- .../caliptra/caliptra-mcu-sw/BUILD.bazel | 10 ++-- .../caliptra-mcu-sw/src/emulator_main.rs | 33 +++++++++++ 4 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 third_party/caliptra/caliptra-mcu-sw/src/emulator_main.rs diff --git a/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs index fb9055505..106e779a5 100644 --- a/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs +++ b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs @@ -159,8 +159,10 @@ fn resolve_runner_cwd(runner: &Path) -> PathBuf { fn i3c_smoke_host_test() { let runner = resolve_runner_path(); let runner_cwd = resolve_runner_cwd(&runner); + // Only the firmware's own log line counts as readiness: the emulator's + // I3C socket opens minutes earlier (before the MCU ROM and recovery flow + // finish), and frames sent that early race firmware boot. let ready_marker = "waiting for private write"; - let socket_marker = "Starting I3C Socket"; let mut child = Command::new(&runner) .current_dir(runner_cwd) @@ -197,45 +199,42 @@ fn i3c_smoke_host_test() { let write_successes_sender = Arc::clone(&write_successes); let write_failures_sender = Arc::clone(&write_failures); let sender_thread = thread::spawn(move || { - let mut keepalive: Option = None; - while !done_sender.load(Ordering::Relaxed) { - if ready_sender.load(Ordering::Relaxed) { - let addr = target_sender.load(Ordering::Relaxed); - println!("I3C HOST TRACE: readiness observed, connecting to {}:{} with addr=0x{addr:02x}", I3C_HOST, I3C_PORT); - connect_attempts_sender.fetch_add(1, Ordering::Relaxed); - if let Ok(mut stream) = connect_i3c_socket(Duration::from_secs(5)) { - let mut writes_sent = 0u8; - for attempt in 0..15 { + if !ready_sender.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(5)); + continue; + } + + let addr = target_sender.load(Ordering::Relaxed); + println!("I3C HOST TRACE: readiness observed, connecting to {}:{} with addr=0x{addr:02x}", I3C_HOST, I3C_PORT); + connect_attempts_sender.fetch_add(1, Ordering::Relaxed); + match connect_i3c_socket(Duration::from_secs(5)) { + Ok(mut stream) => { + // Keep sending until the runner exits: the firmware + // terminates the emulator as soon as one frame arrives, + // so exit is the natural stop condition. + while !done_sender.load(Ordering::Relaxed) { match send_private_write_on_stream(&mut stream, addr, &PAYLOAD) { Ok(()) => { - println!("I3C HOST TRACE: send attempt {} succeeded", attempt + 1); - writes_sent += 1; write_successes_sender.fetch_add(1, Ordering::Relaxed); + sent_sender.store(true, Ordering::Relaxed); } Err(e) => { - println!("I3C HOST TRACE: send attempt {} failed: {}", attempt + 1, e); + println!("I3C HOST TRACE: send failed: {}", e); write_failures_sender.fetch_add(1, Ordering::Relaxed); + // Reconnect via the outer loop. + break; } } - thread::sleep(Duration::from_millis(25)); - } - if writes_sent > 0 { - sent_sender.store(true, Ordering::Relaxed); - keepalive = Some(stream); - break; + thread::sleep(Duration::from_millis(100)); } } - thread::sleep(Duration::from_millis(25)); - continue; + Err(e) => { + println!("I3C HOST TRACE: connect failed: {}", e); + thread::sleep(Duration::from_millis(100)); + } } - thread::sleep(Duration::from_millis(5)); - } - - while !done_sender.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(10)); } - drop(keepalive); }); let target_stderr = Arc::clone(&target_addr); @@ -256,7 +255,7 @@ fn i3c_smoke_host_test() { if let Some(addr) = extract_target_addr(&line) { target_stderr.store(addr, Ordering::Relaxed); } - if line.contains(ready_marker) || line.contains(socket_marker) { + if line.contains(ready_marker) { ready_stderr.store(true, Ordering::Relaxed); } } @@ -279,7 +278,7 @@ fn i3c_smoke_host_test() { target_addr.store(addr, Ordering::Relaxed); } - if line.contains(ready_marker) || line.contains(socket_marker) { + if line.contains(ready_marker) { ready.store(true, Ordering::Relaxed); } } diff --git a/target/veer/tests/i3c_smoke/target.rs b/target/veer/tests/i3c_smoke/target.rs index bfe1457b3..dd3329f7f 100644 --- a/target/veer/tests/i3c_smoke/target.rs +++ b/target/veer/tests/i3c_smoke/target.rs @@ -3,10 +3,10 @@ //! I3C smoke test. //! -//! Verifies that: -//! 1. `enable_rx_interrupt()` runs without trapping. -//! 2. A private write sent by the test harness over TCP port 65534 is -//! received correctly with the expected payload [0x01, 0x02, 0x03, 0x04]. +//! Verifies that a private write sent by the test harness over TCP port +//! 65534 is received correctly with the expected payload +//! [0x01, 0x02, 0x03, 0x04]. Reception is pure-polling: this system image +//! has an empty interrupt table, so no peripheral interrupt is enabled. #![no_std] #![no_main] @@ -28,7 +28,6 @@ impl TargetInterface for Target { // SAFETY: single call at boot; Caliptra ROM has already initialized // the I3C core and we are the only owner of the peripheral. let mut i3c = unsafe { CaliptraI3cTarget::new() }; - i3c.enable_rx_interrupt(); pw_log::info!("I3C smoke test: waiting for private write"); let mut buf = [0u8; 64]; diff --git a/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel b/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel index 70a6eb468..282d9a80a 100644 --- a/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel +++ b/third_party/caliptra/caliptra-mcu-sw/BUILD.bazel @@ -196,23 +196,21 @@ rust_library( ], ) -# Emulator host binary +# Emulator host binary. Uses a local entry point instead of the upstream +# main.rs so the I3C controller socket pump is always started; see +# src/emulator_main.rs. rust_binary( name = "emulator", - srcs = ["@caliptra_mcu_sw//:emulator_srcs"], + srcs = ["src/emulator_main.rs"], aliases = { ":emulator_lib": "emulator", }, - crate_root = "@caliptra_mcu_sw//:emulator/app/src/main.rs", edition = "2021", deps = [ ":emulator_lib", ":mcu_testing_common", - "//third_party/caliptra:crate_log", "//third_party/caliptra/caliptra-sw:caliptra_emu_cpu", "@rust_caliptra_crates_host//:clap", - "@rust_caliptra_crates_host//:ctrlc", - "@rust_caliptra_crates_host//:simple_logger", ], ) diff --git a/third_party/caliptra/caliptra-mcu-sw/src/emulator_main.rs b/third_party/caliptra/caliptra-mcu-sw/src/emulator_main.rs new file mode 100644 index 000000000..b9aec8e96 --- /dev/null +++ b/third_party/caliptra/caliptra-mcu-sw/src/emulator_main.rs @@ -0,0 +1,33 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Entry point for the Caliptra MCU emulator. +//! +//! Replaces the upstream `emulator/app/src/main.rs`, which only pumps I3C +//! socket traffic into the emulated target when built with one of its +//! `test-*` cargo features. Host-side tests drive firmware over the I3C TCP +//! socket (`--i3c-port`), so this entry point unconditionally starts the I3C +//! controller before entering the run loop. +//! +//! Firmware-requested exits terminate the process from within `step()` (the +//! emulator's exit-control peripheral calls `std::process::exit`), so the +//! loop only ends on fatal errors or breakpoints. + +use caliptra_emu_cpu::StepAction; +use clap::Parser; +use emulator::{Emulator, EmulatorArgs}; +use mcu_testing_common::MCU_RUNNING; +use std::io; + +fn main() -> io::Result<()> { + let cli = EmulatorArgs::parse(); + let mut emulator = Emulator::from_args(cli, false)?; + emulator.start_i3c_controller(); + while MCU_RUNNING.load(std::sync::atomic::Ordering::Relaxed) { + match emulator.step() { + StepAction::Break | StepAction::Fatal => break, + _ => {} + } + } + Ok(()) +} From 130ca28767cf702347cd754247fb09b583447f47 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 06:39:03 -0700 Subject: [PATCH 04/10] target/veer: add shared i3c_host emulator-test harness library --- target/veer/tests/i3c_host/BUILD.bazel | 18 ++ target/veer/tests/i3c_host/lib.rs | 377 +++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 target/veer/tests/i3c_host/BUILD.bazel create mode 100644 target/veer/tests/i3c_host/lib.rs diff --git a/target/veer/tests/i3c_host/BUILD.bazel b/target/veer/tests/i3c_host/BUILD.bazel new file mode 100644 index 000000000..1ca2bd422 --- /dev/null +++ b/target/veer/tests/i3c_host/BUILD.bazel @@ -0,0 +1,18 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "i3c_host", + srcs = ["lib.rs"], + edition = "2024", +) + +rust_test( + name = "i3c_host_unit_test", + crate = ":i3c_host", + edition = "2024", +) diff --git a/target/veer/tests/i3c_host/lib.rs b/target/veer/tests/i3c_host/lib.rs new file mode 100644 index 000000000..886121a7f --- /dev/null +++ b/target/veer/tests/i3c_host/lib.rs @@ -0,0 +1,377 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Shared host-side harness for VeeR emulator I3C tests. +//! +//! Wire protocol (see caliptra-mcu-sw common/testing/src/i3c_socket_server.rs): +//! - Host -> emulator: `to_addr: u8` + two LE u32 command words + data bytes. +//! `rnw` is bit 29 of the 64-bit command; `data_length` is bits 63:48. +//! - Emulator -> host: `ibi: u8`, `from_addr: u8`, LE u32 response +//! descriptor (`data_length` in bits 15:0), then data bytes. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub const I3C_HOST: &str = "127.0.0.1"; +pub const I3C_PORT: u16 = 65534; +pub const DEFAULT_TARGET_ADDR: u8 = 0x08; + +pub fn crc8_smbus(data: &[u8]) -> u8 { + let mut crc: u8 = 0; + for &value in data { + crc ^= value; + for _ in 0..8 { + if (crc & 0x80) != 0 { + crc = (crc << 1) ^ 0x07; + } else { + crc <<= 1; + } + } + } + crc +} + +/// Payload followed by the SMBus PEC over (write-address byte + payload). +pub fn body_with_pec(target_addr: u8, payload: &[u8]) -> Vec { + let mut pec_input = Vec::with_capacity(1 + payload.len()); + pec_input.push(target_addr << 1); + pec_input.extend_from_slice(payload); + let pec = crc8_smbus(&pec_input); + let mut body = Vec::with_capacity(payload.len() + 1); + body.extend_from_slice(payload); + body.push(pec); + body +} + +pub fn make_private_write_header(target_addr: u8, data_len: u16) -> [u8; 9] { + // rnw (bit 29) = 0; data_length in bits 63:48 (word1 bits 31:16). + let cmd_word0: u32 = 0; + let cmd_word1: u32 = (data_len as u32) << 16; + let mut out = [0u8; 9]; + out[0] = target_addr; + out[1..5].copy_from_slice(&cmd_word0.to_le_bytes()); + out[5..9].copy_from_slice(&cmd_word1.to_le_bytes()); + out +} + +pub fn make_private_read_header(target_addr: u8) -> [u8; 9] { + // rnw (bit 29) = 1; data_length = 0 (the target reports its own length). + let cmd_word0: u32 = 1 << 29; + let cmd_word1: u32 = 0; + let mut out = [0u8; 9]; + out[0] = target_addr; + out[1..5].copy_from_slice(&cmd_word0.to_le_bytes()); + out[5..9].copy_from_slice(&cmd_word1.to_le_bytes()); + out +} + +pub fn connect_i3c_socket(timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut last_err: Option = None; + while Instant::now() < deadline { + match TcpStream::connect((I3C_HOST, I3C_PORT)) { + Ok(s) => return Ok(s), + Err(e) => { + last_err = Some(e.to_string()); + thread::sleep(Duration::from_millis(50)); + } + } + } + Err(format!( + "timed out connecting to {}:{} ({})", + I3C_HOST, + I3C_PORT, + last_err.unwrap_or_else(|| "unknown error".to_string()) + )) +} + +pub fn send_private_write_on_stream( + stream: &mut TcpStream, + target_addr: u8, + payload: &[u8], +) -> Result<(), String> { + let body = body_with_pec(target_addr, payload); + let header = make_private_write_header(target_addr, body.len() as u16); + let mut frame = Vec::with_capacity(header.len() + body.len()); + frame.extend_from_slice(&header); + frame.extend_from_slice(&body); + stream + .write_all(&frame) + .map_err(|e| format!("failed writing I3C private-write frame: {}", e))?; + println!( + "I3C HOST TRACE: wrote frame to addr=0x{target_addr:02x} header={:02x?} body={:02x?}", + header, body + ); + Ok(()) +} + +pub fn send_private_read_on_stream( + stream: &mut TcpStream, + target_addr: u8, +) -> Result<(), String> { + let header = make_private_read_header(target_addr); + stream + .write_all(&header) + .map_err(|e| format!("failed writing I3C private-read command: {}", e))?; + println!("I3C HOST TRACE: wrote read command to addr=0x{target_addr:02x}"); + Ok(()) +} + +pub struct OutgoingPacket { + pub ibi: u8, + pub from_addr: u8, + pub data: Vec, +} + +pub fn read_outgoing_packet(reader: &mut R) -> Result { + let mut header = [0u8; 6]; + reader + .read_exact(&mut header) + .map_err(|e| format!("failed reading outgoing packet header: {}", e))?; + let descriptor = u32::from_le_bytes([header[2], header[3], header[4], header[5]]); + let len = (descriptor & 0xffff) as usize; + let mut data = vec![0u8; len]; + reader + .read_exact(&mut data) + .map_err(|e| format!("failed reading outgoing packet data ({} bytes): {}", len, e))?; + Ok(OutgoingPacket { + ibi: header[0], + from_addr: header[1], + data, + }) +} + +pub fn extract_target_addr(line: &str) -> Option { + let marker = "target DynamicI3cAddress("; + let start = line.find(marker)? + marker.len(); + let rest = &line[start..]; + let end = rest.find(')')?; + let parsed = rest[..end].parse::().ok()?; + u8::try_from(parsed).ok() +} + +fn resolve_runner_path(runner_rel_path: &str) -> PathBuf { + let srcdir = + std::env::var("TEST_SRCDIR").expect("missing TEST_SRCDIR environment variable"); + let workspace = + std::env::var("TEST_WORKSPACE").expect("missing TEST_WORKSPACE environment variable"); + let candidate = Path::new(&srcdir).join(&workspace).join(runner_rel_path); + if candidate.exists() { + return candidate; + } + panic!( + "unable to locate emulator runner at {:?}; TEST_SRCDIR={:?}, TEST_WORKSPACE={:?}", + candidate, + std::env::var("TEST_SRCDIR").ok(), + std::env::var("TEST_WORKSPACE").ok(), + ); +} + +fn resolve_runner_cwd(runner: &Path) -> PathBuf { + if let (Ok(srcdir), Ok(workspace)) = + (std::env::var("TEST_SRCDIR"), std::env::var("TEST_WORKSPACE")) + { + let root = Path::new(&srcdir).join(&workspace); + if root.exists() { + return root; + } + } + runner + .parent() + .expect("runner path has no parent directory") + .to_path_buf() +} + +/// A spawned emulator runner with background stdout/stderr watchers. +pub struct Runner { + child: Child, + ready: Arc, + exited: Arc, + target_addr: Arc, + stdout_thread: Option>, + stderr_thread: Option>, +} + +impl Runner { + /// Spawn `runner_rel_path` (workspace-relative runfiles path). `ready` + /// flips when `ready_marker` appears on either stream; `exited` flips + /// when stdout reaches EOF. + pub fn spawn(runner_rel_path: &str, ready_marker: &'static str) -> Self { + let runner = resolve_runner_path(runner_rel_path); + let cwd = resolve_runner_cwd(&runner); + let mut child = Command::new(&runner) + .current_dir(cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn emulator runner"); + + let ready = Arc::new(AtomicBool::new(false)); + let exited = Arc::new(AtomicBool::new(false)); + let target_addr = Arc::new(AtomicU8::new(DEFAULT_TARGET_ADDR)); + + let stdout = child.stdout.take().expect("failed to capture runner stdout"); + let stderr = child.stderr.take().expect("failed to capture runner stderr"); + + let watch = |reader: Box, + to_stderr: bool, + ready: Arc, + exited: Option>, + target_addr: Arc| { + move || { + let mut reader = BufReader::new(reader); + let mut line = String::new(); + loop { + line.clear(); + let n = reader + .read_line(&mut line) + .expect("failed to read runner output"); + if n == 0 { + break; + } + if to_stderr { + eprint!("{}", line); + } else { + print!("{}", line); + } + if let Some(addr) = extract_target_addr(&line) { + target_addr.store(addr, Ordering::Relaxed); + } + if line.contains(ready_marker) { + ready.store(true, Ordering::Relaxed); + } + } + if let Some(exited) = exited { + exited.store(true, Ordering::Relaxed); + } + } + }; + + let stdout_thread = thread::spawn(watch( + Box::new(stdout), + false, + Arc::clone(&ready), + Some(Arc::clone(&exited)), + Arc::clone(&target_addr), + )); + let stderr_thread = thread::spawn(watch( + Box::new(stderr), + true, + Arc::clone(&ready), + None, + Arc::clone(&target_addr), + )); + + Runner { + child, + ready, + exited, + target_addr, + stdout_thread: Some(stdout_thread), + stderr_thread: Some(stderr_thread), + } + } + + pub fn ready(&self) -> bool { + self.ready.load(Ordering::Relaxed) + } + + pub fn exited(&self) -> bool { + self.exited.load(Ordering::Relaxed) + } + + pub fn target_addr(&self) -> u8 { + self.target_addr.load(Ordering::Relaxed) + } + + /// Block until the ready marker is seen. Returns false if the runner + /// exits first or `timeout` elapses. + pub fn wait_ready(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if self.ready() { + return true; + } + if self.exited() { + return false; + } + thread::sleep(Duration::from_millis(10)); + } + false + } + + /// Join the watcher threads and reap the child. + pub fn wait(mut self) -> ExitStatus { + if let Some(t) = self.stdout_thread.take() { + t.join().expect("failed to join stdout watcher"); + } + if let Some(t) = self.stderr_thread.take() { + t.join().expect("failed to join stderr watcher"); + } + self.child.wait().expect("failed to wait for runner") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn crc8_matches_known_value() { + // Address 0x08 (write byte 0x10) + payload [1,2,3,4] -> PEC 0xd1, + // as observed on the wire in the i3c_smoke test. + assert_eq!(crc8_smbus(&[0x10, 0x01, 0x02, 0x03, 0x04]), 0xd1); + } + + #[test] + fn body_appends_pec() { + assert_eq!( + body_with_pec(0x08, &[0x01, 0x02, 0x03, 0x04]), + vec![0x01, 0x02, 0x03, 0x04, 0xd1] + ); + } + + #[test] + fn write_header_layout() { + // data_length lands in word1 bits 31:16. + assert_eq!( + make_private_write_header(0x08, 5), + [0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00] + ); + } + + #[test] + fn read_header_sets_rnw_bit_29() { + assert_eq!( + make_private_read_header(0x08), + [0x08, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00] + ); + } + + #[test] + fn parses_outgoing_packet() { + // ibi=0, from_addr=8, descriptor len=3, data [aa,bb,cc]. + let bytes = [0x00, 0x08, 0x03, 0x00, 0x00, 0x00, 0xaa, 0xbb, 0xcc]; + let mut cursor = Cursor::new(&bytes[..]); + let pkt = read_outgoing_packet(&mut cursor).unwrap(); + assert_eq!(pkt.ibi, 0); + assert_eq!(pkt.from_addr, 8); + assert_eq!(pkt.data, vec![0xaa, 0xbb, 0xcc]); + } + + #[test] + fn extracts_dynamic_address() { + assert_eq!( + extract_target_addr("i3c target DynamicI3cAddress(9) attached"), + Some(9) + ); + assert_eq!(extract_target_addr("no address here"), None); + } +} From 64cbd5ab7638c3fd1d640af69495586c96157c8e Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 06:40:07 -0700 Subject: [PATCH 05/10] target/veer: refactor i3c_smoke host test onto i3c_host harness --- target/veer/tests/i3c_smoke/BUILD.bazel | 1 + .../tests/i3c_smoke/i3c_smoke_host_test.rs | 314 ++---------------- 2 files changed, 28 insertions(+), 287 deletions(-) diff --git a/target/veer/tests/i3c_smoke/BUILD.bazel b/target/veer/tests/i3c_smoke/BUILD.bazel index 53744e229..60158e3e9 100644 --- a/target/veer/tests/i3c_smoke/BUILD.bazel +++ b/target/veer/tests/i3c_smoke/BUILD.bazel @@ -76,4 +76,5 @@ rust_test( "emulator", "exclusive", ], + deps = ["//target/veer/tests/i3c_host"], ) diff --git a/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs index 106e779a5..c2d12bad6 100644 --- a/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs +++ b/target/veer/tests/i3c_smoke/i3c_smoke_host_test.rs @@ -3,305 +3,45 @@ //! Host-side harness for the VeeR I3C smoke test. //! -//! Launches the emulator runner, waits for the firmware to report it is waiting -//! for a private write, then injects a valid private-write frame over the I3C -//! TCP socket (127.0.0.1:65534). +//! Launches the emulator runner, waits for the firmware to report it is +//! waiting for a private write, then injects private-write frames over the +//! I3C TCP socket until the firmware receives one and exits. -use std::io::{BufRead, BufReader, Write}; -use std::net::TcpStream; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::string::{String, ToString}; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering}; -use std::sync::Arc; +use i3c_host::{connect_i3c_socket, send_private_write_on_stream, Runner}; use std::thread; -use std::time::{Duration, Instant}; -use std::vec::Vec; +use std::time::Duration; -const I3C_HOST: &str = "127.0.0.1"; -const I3C_PORT: u16 = 65534; -const DEFAULT_TARGET_ADDR: u8 = 0x08; const PAYLOAD: [u8; 4] = [0x01, 0x02, 0x03, 0x04]; -fn crc8_smbus(data: &[u8]) -> u8 { - let mut crc: u8 = 0; - for &value in data { - crc ^= value; - for _ in 0..8 { - if (crc & 0x80) != 0 { - crc = (crc << 1) ^ 0x07; - } else { - crc <<= 1; - } - } - } - crc -} - -fn connect_i3c_socket(timeout: Duration) -> Result { - let deadline = Instant::now() + timeout; - let mut last_err: Option = None; - while Instant::now() < deadline { - match TcpStream::connect((I3C_HOST, I3C_PORT)) { - Ok(s) => return Ok(s), - Err(e) => { - last_err = Some(e.to_string()); - thread::sleep(Duration::from_millis(50)); - } - } - } - Err(format!( - "timed out connecting to {}:{} ({})", - I3C_HOST, - I3C_PORT, - last_err.unwrap_or_else(|| "unknown error".to_string()) - )) -} - -fn make_private_write_header(target_addr: u8, data_len: u16) -> [u8; 9] { - // IncomingHeader: to_addr:u8 + command:[u32;2] LE. - // Private write command (rnw=0): word0 = 0, data_length in word1[23:16]. - let cmd_word0: u32 = 0; - let cmd_word1: u32 = ((data_len as u32) & 0xFF) << 16; - - let mut out = [0u8; 9]; - out[0] = target_addr; - out[1..5].copy_from_slice(&cmd_word0.to_le_bytes()); - out[5..9].copy_from_slice(&cmd_word1.to_le_bytes()); - out -} - -fn send_private_write_on_stream( - stream: &mut TcpStream, - target_addr: u8, - payload: &[u8], -) -> Result<(), String> { - let mut pec_input = Vec::with_capacity(1 + payload.len()); - pec_input.push(target_addr << 1); - pec_input.extend_from_slice(payload); - let pec = crc8_smbus(&pec_input); - - let mut body = Vec::with_capacity(payload.len() + 1); - body.extend_from_slice(payload); - body.push(pec); - - let header = make_private_write_header(target_addr, body.len() as u16); - stream - .set_nonblocking(false) - .map_err(|e| format!("failed to set blocking mode for header write: {}", e))?; - stream - .write_all(&header) - .map_err(|e| format!("failed writing I3C header: {}", e))?; - stream - .set_nonblocking(true) - .map_err(|e| format!("failed to set nonblocking mode for payload write: {}", e))?; - stream - .write_all(&body) - .map_err(|e| format!("failed writing I3C body: {}", e))?; - - println!( - "I3C HOST TRACE: wrote frame to addr=0x{target_addr:02x} header={:02x?} body={:02x?}", - header, - body - ); - - Ok(()) -} - -fn extract_target_addr(line: &str) -> Option { - let marker = "target DynamicI3cAddress("; - let start = line.find(marker)? + marker.len(); - let rest = &line[start..]; - let end = rest.find(')')?; - let parsed = rest[..end].parse::().ok()?; - u8::try_from(parsed).ok() -} - -fn resolve_runner_path() -> PathBuf { - let srcdir = std::env::var("TEST_SRCDIR") - .expect("missing TEST_SRCDIR environment variable"); - let workspace = std::env::var("TEST_WORKSPACE") - .expect("missing TEST_WORKSPACE environment variable"); - let workspace_root = Path::new(&srcdir).join(&workspace); - - let candidate = workspace_root.join( +#[test] +fn i3c_smoke_host_test() { + let runner = Runner::spawn( "target/veer/tests/i3c_smoke/i3c_smoke_runner.sh", + // Only the firmware's own log line counts as readiness: the + // emulator's I3C socket opens minutes earlier, and frames sent that + // early race firmware boot. + "waiting for private write", ); - if candidate.exists() { - return candidate; - } - - panic!( - "unable to locate i3c smoke runner at {:?}; TEST_SRCDIR={:?}, TEST_WORKSPACE={:?}", - candidate, - std::env::var("TEST_SRCDIR").ok(), - std::env::var("TEST_WORKSPACE").ok(), + assert!( + runner.wait_ready(Duration::from_secs(600)), + "runner exited or timed out before firmware readiness" ); -} -fn resolve_runner_cwd(runner: &Path) -> PathBuf { - if let (Ok(srcdir), Ok(workspace)) = ( - std::env::var("TEST_SRCDIR"), - std::env::var("TEST_WORKSPACE"), - ) { - let root = Path::new(&srcdir).join(&workspace); - if root.exists() { - return root; - } - } - runner - .parent() - .expect("runner path has no parent directory") - .to_path_buf() -} - -#[test] -fn i3c_smoke_host_test() { - let runner = resolve_runner_path(); - let runner_cwd = resolve_runner_cwd(&runner); - // Only the firmware's own log line counts as readiness: the emulator's - // I3C socket opens minutes earlier (before the MCU ROM and recovery flow - // finish), and frames sent that early race firmware boot. - let ready_marker = "waiting for private write"; - - let mut child = Command::new(&runner) - .current_dir(runner_cwd) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("failed to spawn i3c_smoke_runner"); - - let stdout = child - .stdout - .take() - .expect("failed to capture runner stdout"); - let stderr = child - .stderr - .take() - .expect("failed to capture runner stderr"); - - let mut stdout_reader = BufReader::new(stdout); - let stderr_reader = BufReader::new(stderr); - - let sent = Arc::new(AtomicBool::new(false)); - let ready = Arc::new(AtomicBool::new(false)); - let done = Arc::new(AtomicBool::new(false)); - let target_addr = Arc::new(AtomicU8::new(DEFAULT_TARGET_ADDR)); - let connect_attempts = Arc::new(AtomicU32::new(0)); - let write_successes = Arc::new(AtomicU32::new(0)); - let write_failures = Arc::new(AtomicU32::new(0)); - - let sent_sender = Arc::clone(&sent); - let ready_sender = Arc::clone(&ready); - let done_sender = Arc::clone(&done); - let target_sender = Arc::clone(&target_addr); - let connect_attempts_sender = Arc::clone(&connect_attempts); - let write_successes_sender = Arc::clone(&write_successes); - let write_failures_sender = Arc::clone(&write_failures); - let sender_thread = thread::spawn(move || { - while !done_sender.load(Ordering::Relaxed) { - if !ready_sender.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(5)); - continue; - } - - let addr = target_sender.load(Ordering::Relaxed); - println!("I3C HOST TRACE: readiness observed, connecting to {}:{} with addr=0x{addr:02x}", I3C_HOST, I3C_PORT); - connect_attempts_sender.fetch_add(1, Ordering::Relaxed); - match connect_i3c_socket(Duration::from_secs(5)) { - Ok(mut stream) => { - // Keep sending until the runner exits: the firmware - // terminates the emulator as soon as one frame arrives, - // so exit is the natural stop condition. - while !done_sender.load(Ordering::Relaxed) { - match send_private_write_on_stream(&mut stream, addr, &PAYLOAD) { - Ok(()) => { - write_successes_sender.fetch_add(1, Ordering::Relaxed); - sent_sender.store(true, Ordering::Relaxed); - } - Err(e) => { - println!("I3C HOST TRACE: send failed: {}", e); - write_failures_sender.fetch_add(1, Ordering::Relaxed); - // Reconnect via the outer loop. - break; - } - } - thread::sleep(Duration::from_millis(100)); - } - } - Err(e) => { - println!("I3C HOST TRACE: connect failed: {}", e); - thread::sleep(Duration::from_millis(100)); - } - } - } - }); - - let target_stderr = Arc::clone(&target_addr); - let ready_stderr = Arc::clone(&ready); - let stderr_thread = thread::spawn(move || { - let mut reader = stderr_reader; - let mut line = String::new(); - loop { - line.clear(); - let n = reader - .read_line(&mut line) - .expect("failed to read runner stderr"); - if n == 0 { - break; - } - eprint!("{}", line); + let addr = runner.target_addr(); + let mut stream = + connect_i3c_socket(Duration::from_secs(5)).expect("failed to connect to I3C socket"); - if let Some(addr) = extract_target_addr(&line) { - target_stderr.store(addr, Ordering::Relaxed); - } - if line.contains(ready_marker) { - ready_stderr.store(true, Ordering::Relaxed); - } - } - }); - - let mut line = String::new(); - - loop { - line.clear(); - let n = stdout_reader - .read_line(&mut line) - .expect("failed to read runner stdout"); - if n == 0 { - break; - } - - print!("{}", line); - - if let Some(addr) = extract_target_addr(&line) { - target_addr.store(addr, Ordering::Relaxed); - } - - if line.contains(ready_marker) { - ready.store(true, Ordering::Relaxed); + // Keep sending until the runner exits: the firmware terminates the + // emulator as soon as one frame arrives. + let mut sends = 0u32; + while !runner.exited() { + if send_private_write_on_stream(&mut stream, addr, &PAYLOAD).is_ok() { + sends += 1; } + thread::sleep(Duration::from_millis(100)); } - done.store(true, Ordering::Relaxed); - sender_thread - .join() - .expect("failed to join sender thread"); - - stderr_thread - .join() - .expect("failed to join stderr reader thread"); - - let status = child.wait().expect("failed to wait for runner"); - - assert!( - sent.load(Ordering::Relaxed), - "did not send I3C payload; ready={}, target_addr=0x{:02x}, connect_attempts={}, write_successes={}, write_failures={}", - ready.load(Ordering::Relaxed), - target_addr.load(Ordering::Relaxed), - connect_attempts.load(Ordering::Relaxed), - write_successes.load(Ordering::Relaxed), - write_failures.load(Ordering::Relaxed) - ); + let status = runner.wait(); + assert!(sends > 0, "no I3C private-write frame was ever sent"); assert!(status.success(), "runner exited with status: {}", status); } From b6aed28a32ff414c9181d4f91ab51246b923a956 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 06:55:50 -0700 Subject: [PATCH 06/10] target/veer: fix TTI TX and IBI write ordering in I3C driver The i3c-core TTI expects the descriptor before the data: the TX descriptor write opens the buffer that subsequent tx_data_port writes fill, and the first word written to the IBI port is parsed as the IBI descriptor. The driver had both orders inverted (data first), which panicked the emulator's I3C model on the first tx_write and would have corrupted any IBI with a payload. Matches the upstream caliptra-mcu-sw runtime driver (runtime/kernel/drivers/i3c/src/core.rs). Found by the new i3c_echo test; the smoke test never transmits, so it could not catch this. --- target/veer/peripherals/i3c/lib.rs | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/target/veer/peripherals/i3c/lib.rs b/target/veer/peripherals/i3c/lib.rs index 12e43cba9..41d2b5d75 100644 --- a/target/veer/peripherals/i3c/lib.rs +++ b/target/veer/peripherals/i3c/lib.rs @@ -17,10 +17,12 @@ //! - **Incoming write** (controller → target): hardware pushes a descriptor //! into `tti_rx_desc_queue_port` then `data_length` words into //! `tti_rx_data_port`. Poll `rx_pending()` or enable the RX interrupt. -//! - **Outgoing read** (target → controller): firmware writes data words to -//! `tti_tx_data_port` then a descriptor to `tti_tx_desc_queue_port`. -//! - **IBI**: write MDB + optional payload words to `tti_tti_ibi_port` then -//! the IBI descriptor; hardware raises the IBI on the bus. +//! - **Outgoing read** (target → controller): firmware writes a descriptor +//! to `tti_tx_desc_queue_port` then `data_length` words to +//! `tti_tx_data_port`. +//! - **IBI**: write the IBI descriptor (MDB + payload length) to +//! `tti_tti_ibi_port` then the payload words; hardware raises the IBI on +//! the bus. #![no_std] @@ -127,8 +129,16 @@ impl CaliptraI3cTarget { // ------------------------------------------------------------------------- /// Queue `data` as the response to the next private-read from the controller. + /// + /// The descriptor must be written before the data words: the hardware + /// (and the emulator model) opens a new TX buffer on the descriptor + /// write and appends subsequent data-port writes to it, matching the + /// upstream caliptra-mcu-sw runtime driver. pub fn tx_write(&mut self, data: &[u8]) { let regs = self.regs(); + // Descriptor: data_length in lower 16 bits; saturate rather than truncate. + regs.tti_tx_desc_queue_port + .set(u32::try_from(data.len()).unwrap_or(u16::MAX as u32)); let mut chunks = data.chunks_exact(4); for chunk in &mut chunks { let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); @@ -140,9 +150,6 @@ impl CaliptraI3cTarget { tmp[..rem.len()].copy_from_slice(rem); regs.tti_tx_data_port.set(u32::from_le_bytes(tmp)); } - // Descriptor: data_length in lower 16 bits; saturate rather than truncate. - regs.tti_tx_desc_queue_port - .set(u32::try_from(data.len()).unwrap_or(u16::MAX as u32)); } // ------------------------------------------------------------------------- @@ -151,9 +158,16 @@ impl CaliptraI3cTarget { /// Raise an IBI with the given Mandatory Data Byte and optional payload. /// Payload must be ≤255 bytes; excess bytes are silently dropped. + /// + /// The descriptor word must be written before the payload words: the + /// hardware (and the emulator model) parses the first word written to + /// the IBI port as the descriptor and takes the payload length from it. pub fn ibi_raise(&mut self, mdb: u8, payload: &[u8]) { let payload = &payload[..payload.len().min(255)]; let regs = self.regs(); + // IBI descriptor: MDB in bits [31:24], payload length in bits [7:0]. + let desc = ((mdb as u32) << 24) | (payload.len() as u32 & 0xff); + regs.tti_tti_ibi_port.set(desc); let mut chunks = payload.chunks_exact(4); for chunk in &mut chunks { let word = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); @@ -165,8 +179,5 @@ impl CaliptraI3cTarget { tmp[..rem.len()].copy_from_slice(rem); regs.tti_tti_ibi_port.set(u32::from_le_bytes(tmp)); } - // IBI descriptor: MDB in bits [31:24], payload length in bits [7:0]. - let desc = ((mdb as u32) << 24) | (payload.len() as u32 & 0xff); - regs.tti_tti_ibi_port.set(desc); } } From a4830fe4497ff4b2c6d98c1f3329e5b89e6f1670 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 06:55:50 -0700 Subject: [PATCH 07/10] target/veer: add I3C echo round-trip emulator test --- target/veer/tests/i3c_echo/BUILD.bazel | 80 ++++++++++++++++++ .../veer/tests/i3c_echo/i3c_echo_host_test.rs | 83 +++++++++++++++++++ target/veer/tests/i3c_echo/system.json5 | 16 ++++ target/veer/tests/i3c_echo/target.rs | 64 ++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 target/veer/tests/i3c_echo/BUILD.bazel create mode 100644 target/veer/tests/i3c_echo/i3c_echo_host_test.rs create mode 100644 target/veer/tests/i3c_echo/system.json5 create mode 100644 target/veer/tests/i3c_echo/target.rs diff --git a/target/veer/tests/i3c_echo/BUILD.bazel b/target/veer/tests/i3c_echo/BUILD.bazel new file mode 100644 index 000000000..132a3a929 --- /dev/null +++ b/target/veer/tests/i3c_echo/BUILD.bazel @@ -0,0 +1,80 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image") +load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen") +load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script") +load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("//target/veer:defs.bzl", "TARGET_COMPATIBLE_WITH") +load("//target/veer/tooling:caliptra_runner.bzl", "caliptra_runner") + +package(default_visibility = ["//visibility:public"]) + +system_image( + name = "i3c_echo", + kernel = ":target", + platform = "//target/veer", +) + +target_linker_script( + name = "linker_script", + system_config = ":system_config", + tags = ["kernel"], + template = "//target/veer:linker_script_template", +) + +rust_binary_no_panics_test( + name = "no_panics_test", + binary = ":i3c_echo", +) + +filegroup( + name = "system_config", + srcs = ["system.json5"], +) + +target_codegen( + name = "codegen", + arch = "@pigweed//pw_kernel/arch/riscv:arch_riscv", + system_config = ":system_config", +) + +rust_binary( + name = "target", + srcs = ["target.rs"], + edition = "2024", + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + ":codegen", + ":linker_script", + "//target/veer:entry", + "//target/veer/peripherals/i3c", + "@pigweed//pw_kernel/arch/riscv:arch_riscv", + "@pigweed//pw_kernel/kernel", + "@pigweed//pw_kernel/subsys/console:console_backend", + "@pigweed//pw_kernel/target:target_common", + "@pigweed//pw_log/rust:pw_log", + ], +) + +caliptra_runner( + name = "i3c_echo_runner", + interface = "emulator", + tags = ["manual"], + target = ":i3c_echo", +) + +rust_test( + name = "i3c_echo_test", + srcs = ["i3c_echo_host_test.rs"], + crate_root = "i3c_echo_host_test.rs", + edition = "2024", + data = [":i3c_echo_runner"], + # caliptra_runner.py hardcodes --i3c-port=65534; must not run in parallel. + tags = [ + "emulator", + "exclusive", + ], + deps = ["//target/veer/tests/i3c_host"], +) diff --git a/target/veer/tests/i3c_echo/i3c_echo_host_test.rs b/target/veer/tests/i3c_echo/i3c_echo_host_test.rs new file mode 100644 index 000000000..8e82fad86 --- /dev/null +++ b/target/veer/tests/i3c_echo/i3c_echo_host_test.rs @@ -0,0 +1,83 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side harness for the VeeR I3C echo test. +//! +//! Sends a private write, reads the firmware's echo back with a private +//! read, then sends a "DONE" write so the firmware exits 0. + +use i3c_host::{ + body_with_pec, connect_i3c_socket, read_outgoing_packet, send_private_read_on_stream, + send_private_write_on_stream, Runner, +}; +use std::thread; +use std::time::Duration; + +const PAYLOAD: [u8; 16] = [ + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, + 0xaf, +]; +const DONE: &[u8] = b"DONE"; + +#[test] +fn i3c_echo_host_test() { + let runner = Runner::spawn( + "target/veer/tests/i3c_echo/i3c_echo_runner.sh", + "waiting for private write", + ); + assert!( + runner.wait_ready(Duration::from_secs(600)), + "runner exited or timed out before firmware readiness" + ); + + let addr = runner.target_addr(); + let mut stream = + connect_i3c_socket(Duration::from_secs(5)).expect("failed to connect to I3C socket"); + // The firmware echoes the full body (payload + PEC) back verbatim. + let expected = body_with_pec(addr, &PAYLOAD); + + let mut echoed = false; + let mut attempts = 0u32; + 'outer: for _ in 0..20 { + attempts += 1; + if send_private_write_on_stream(&mut stream, addr, &PAYLOAD).is_err() { + thread::sleep(Duration::from_millis(250)); + continue; + } + thread::sleep(Duration::from_millis(100)); + if send_private_read_on_stream(&mut stream, addr).is_err() { + thread::sleep(Duration::from_millis(250)); + continue; + } + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("failed to set read timeout"); + // Drain packets until the echo shows up or the read times out; + // skip IBIs and stale responses from earlier attempts. + loop { + match read_outgoing_packet(&mut stream) { + Ok(pkt) => { + println!( + "I3C HOST TRACE: packet ibi=0x{:02x} from=0x{:02x} data={:02x?}", + pkt.ibi, pkt.from_addr, pkt.data + ); + if pkt.ibi == 0 && pkt.data == expected { + echoed = true; + break 'outer; + } + } + Err(_) => break, + } + } + } + assert!(echoed, "echo never received after {} attempts", attempts); + + // Host-driven teardown: keep sending DONE until the firmware exits. + while !runner.exited() { + let _ = send_private_write_on_stream(&mut stream, addr, DONE); + thread::sleep(Duration::from_millis(100)); + } + + let status = runner.wait(); + assert!(status.success(), "runner exited with status: {}", status); +} diff --git a/target/veer/tests/i3c_echo/system.json5 b/target/veer/tests/i3c_echo/system.json5 new file mode 100644 index 000000000..cc890eadf --- /dev/null +++ b/target/veer/tests/i3c_echo/system.json5 @@ -0,0 +1,16 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 +{ + arch: { + type: "riscv", + }, + kernel: { + flash_start_address: 0xA0010000, + flash_size_bytes: 65536, + ram_start_address: 0x10000000, + ram_size_bytes: 32768, + interrupt_table: { + table: {} + }, + }, +} diff --git a/target/veer/tests/i3c_echo/target.rs b/target/veer/tests/i3c_echo/target.rs new file mode 100644 index 000000000..92cde4783 --- /dev/null +++ b/target/veer/tests/i3c_echo/target.rs @@ -0,0 +1,64 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! I3C echo test. +//! +//! Echoes every received private write back verbatim via the TTI TX queue +//! (PEC byte included), so the host can read it back with a private read. +//! A write whose payload starts with ASCII "DONE" ends the test with +//! exit(0). Reception is pure-polling: this system image has an empty +//! interrupt table. + +#![no_std] +#![no_main] + +use caliptra_i3c_target::CaliptraI3cTarget; +use entry::exit; +use target_common::{declare_target, TargetInterface}; +use {console_backend as _, kernel as _}; + +pub struct Target {} + +// Emits PW_KERNEL_INTERRUPT_TABLE from the interrupt_table in system.json5. +codegen::declare_kernel_interrupt_handlers!(); + +impl TargetInterface for Target { + const NAME: &'static str = "Caliptra I3C Echo Test"; + + fn main() -> ! { + // SAFETY: single call at boot; Caliptra ROM has already initialized + // the I3C core and we are the only owner of the peripheral. + let mut i3c = unsafe { CaliptraI3cTarget::new() }; + pw_log::info!("I3C echo test: waiting for private write"); + + let mut buf = [0u8; 64]; + const MAX_POLLS: u32 = 10_000_000; + let mut polls = 0u32; + loop { + if let Some(len) = i3c.rx_read(&mut buf) { + if len >= 4 && buf[..4] == *b"DONE" { + pw_log::info!("I3C echo test: received DONE"); + exit(0); + } + let len = len.min(buf.len()); + i3c.tx_write(&buf[..len]); + pw_log::info!("I3C echo trace: echoed {} bytes", len as u32); + } + polls += 1; + if polls >= MAX_POLLS { + pw_log::info!("I3C echo test: timed out waiting for write"); + exit(2); + } + } + } + + fn shutdown(code: u32) -> ! { + match code { + 0 => pw_log::info!("PASS"), + _ => pw_log::info!("FAIL: {}", code), + } + exit(code); + } +} + +declare_target!(Target); From 96eedc6b781857c59788f43c1783465d46cfe5b6 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 06:59:17 -0700 Subject: [PATCH 08/10] target/veer: add I3C IBI emulator test Firmware raises an IBI (MDB 0xA5 with a 4-byte payload) on each received private write; the host asserts the MDB arrives over the I3C socket. Gives the descriptor-first ibi_raise() ordering fix behavioral coverage. Only the MDB is asserted: the emulator's IBI model does not yet forward the payload to the controller side (upstream TODO in check_ibi_buffer). --- target/veer/tests/i3c_ibi/BUILD.bazel | 80 +++++++++++++++++++ .../veer/tests/i3c_ibi/i3c_ibi_host_test.rs | 80 +++++++++++++++++++ target/veer/tests/i3c_ibi/system.json5 | 16 ++++ target/veer/tests/i3c_ibi/target.rs | 66 +++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 target/veer/tests/i3c_ibi/BUILD.bazel create mode 100644 target/veer/tests/i3c_ibi/i3c_ibi_host_test.rs create mode 100644 target/veer/tests/i3c_ibi/system.json5 create mode 100644 target/veer/tests/i3c_ibi/target.rs diff --git a/target/veer/tests/i3c_ibi/BUILD.bazel b/target/veer/tests/i3c_ibi/BUILD.bazel new file mode 100644 index 000000000..7d16c25ea --- /dev/null +++ b/target/veer/tests/i3c_ibi/BUILD.bazel @@ -0,0 +1,80 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image") +load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen") +load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script") +load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("//target/veer:defs.bzl", "TARGET_COMPATIBLE_WITH") +load("//target/veer/tooling:caliptra_runner.bzl", "caliptra_runner") + +package(default_visibility = ["//visibility:public"]) + +system_image( + name = "i3c_ibi", + kernel = ":target", + platform = "//target/veer", +) + +target_linker_script( + name = "linker_script", + system_config = ":system_config", + tags = ["kernel"], + template = "//target/veer:linker_script_template", +) + +rust_binary_no_panics_test( + name = "no_panics_test", + binary = ":i3c_ibi", +) + +filegroup( + name = "system_config", + srcs = ["system.json5"], +) + +target_codegen( + name = "codegen", + arch = "@pigweed//pw_kernel/arch/riscv:arch_riscv", + system_config = ":system_config", +) + +rust_binary( + name = "target", + srcs = ["target.rs"], + edition = "2024", + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + ":codegen", + ":linker_script", + "//target/veer:entry", + "//target/veer/peripherals/i3c", + "@pigweed//pw_kernel/arch/riscv:arch_riscv", + "@pigweed//pw_kernel/kernel", + "@pigweed//pw_kernel/subsys/console:console_backend", + "@pigweed//pw_kernel/target:target_common", + "@pigweed//pw_log/rust:pw_log", + ], +) + +caliptra_runner( + name = "i3c_ibi_runner", + interface = "emulator", + tags = ["manual"], + target = ":i3c_ibi", +) + +rust_test( + name = "i3c_ibi_test", + srcs = ["i3c_ibi_host_test.rs"], + crate_root = "i3c_ibi_host_test.rs", + edition = "2024", + data = [":i3c_ibi_runner"], + # caliptra_runner.py hardcodes --i3c-port=65534; must not run in parallel. + tags = [ + "emulator", + "exclusive", + ], + deps = ["//target/veer/tests/i3c_host"], +) diff --git a/target/veer/tests/i3c_ibi/i3c_ibi_host_test.rs b/target/veer/tests/i3c_ibi/i3c_ibi_host_test.rs new file mode 100644 index 000000000..83284b4bf --- /dev/null +++ b/target/veer/tests/i3c_ibi/i3c_ibi_host_test.rs @@ -0,0 +1,80 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side harness for the VeeR I3C IBI test. +//! +//! Sends a private write to trigger the firmware's IBI, asserts the IBI's +//! MDB arrives on the socket, then sends a "DONE" write so the firmware +//! exits 0. +//! +//! The emulator's IBI model forwards only the MDB to the controller side +//! (payload forwarding is an upstream TODO in emulator/periph/src/i3c.rs +//! check_ibi_buffer), so only the MDB is asserted here even though the +//! firmware raises the IBI with a payload. + +use i3c_host::{ + connect_i3c_socket, read_outgoing_packet, send_private_write_on_stream, Runner, +}; +use std::thread; +use std::time::Duration; + +const TRIGGER: [u8; 4] = [0xb0, 0xb1, 0xb2, 0xb3]; +const IBI_MDB: u8 = 0xA5; +const DONE: &[u8] = b"DONE"; + +#[test] +fn i3c_ibi_host_test() { + let runner = Runner::spawn( + "target/veer/tests/i3c_ibi/i3c_ibi_runner.sh", + "waiting for private write", + ); + assert!( + runner.wait_ready(Duration::from_secs(600)), + "runner exited or timed out before firmware readiness" + ); + + let addr = runner.target_addr(); + let mut stream = + connect_i3c_socket(Duration::from_secs(5)).expect("failed to connect to I3C socket"); + + let mut ibi_seen = false; + let mut attempts = 0u32; + 'outer: for _ in 0..20 { + attempts += 1; + if send_private_write_on_stream(&mut stream, addr, &TRIGGER).is_err() { + thread::sleep(Duration::from_millis(250)); + continue; + } + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("failed to set read timeout"); + // The IBI is forwarded spontaneously by the emulator's controller + // pump; no read command is needed. Drain packets until it shows up + // or the read times out. + loop { + match read_outgoing_packet(&mut stream) { + Ok(pkt) => { + println!( + "I3C HOST TRACE: packet ibi=0x{:02x} from=0x{:02x} data={:02x?}", + pkt.ibi, pkt.from_addr, pkt.data + ); + if pkt.ibi == IBI_MDB { + ibi_seen = true; + break 'outer; + } + } + Err(_) => break, + } + } + } + assert!(ibi_seen, "IBI never received after {} attempts", attempts); + + // Host-driven teardown: keep sending DONE until the firmware exits. + while !runner.exited() { + let _ = send_private_write_on_stream(&mut stream, addr, DONE); + thread::sleep(Duration::from_millis(100)); + } + + let status = runner.wait(); + assert!(status.success(), "runner exited with status: {}", status); +} diff --git a/target/veer/tests/i3c_ibi/system.json5 b/target/veer/tests/i3c_ibi/system.json5 new file mode 100644 index 000000000..cc890eadf --- /dev/null +++ b/target/veer/tests/i3c_ibi/system.json5 @@ -0,0 +1,16 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 +{ + arch: { + type: "riscv", + }, + kernel: { + flash_start_address: 0xA0010000, + flash_size_bytes: 65536, + ram_start_address: 0x10000000, + ram_size_bytes: 32768, + interrupt_table: { + table: {} + }, + }, +} diff --git a/target/veer/tests/i3c_ibi/target.rs b/target/veer/tests/i3c_ibi/target.rs new file mode 100644 index 000000000..c2071a8d5 --- /dev/null +++ b/target/veer/tests/i3c_ibi/target.rs @@ -0,0 +1,66 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! I3C IBI test. +//! +//! Raises an IBI (MDB 0xA5 with a 4-byte payload) each time a private write +//! arrives, so the host can assert the IBI reaches the controller side. A +//! write whose payload starts with ASCII "DONE" ends the test with exit(0). +//! Reception is pure-polling: this system image has an empty interrupt +//! table. + +#![no_std] +#![no_main] + +use caliptra_i3c_target::CaliptraI3cTarget; +use entry::exit; +use target_common::{declare_target, TargetInterface}; +use {console_backend as _, kernel as _}; + +pub struct Target {} + +// Emits PW_KERNEL_INTERRUPT_TABLE from the interrupt_table in system.json5. +codegen::declare_kernel_interrupt_handlers!(); + +const IBI_MDB: u8 = 0xA5; +const IBI_PAYLOAD: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + +impl TargetInterface for Target { + const NAME: &'static str = "Caliptra I3C IBI Test"; + + fn main() -> ! { + // SAFETY: single call at boot; Caliptra ROM has already initialized + // the I3C core and we are the only owner of the peripheral. + let mut i3c = unsafe { CaliptraI3cTarget::new() }; + pw_log::info!("I3C IBI test: waiting for private write"); + + let mut buf = [0u8; 64]; + const MAX_POLLS: u32 = 10_000_000; + let mut polls = 0u32; + loop { + if let Some(len) = i3c.rx_read(&mut buf) { + if len >= 4 && buf[..4] == *b"DONE" { + pw_log::info!("I3C IBI test: received DONE"); + exit(0); + } + i3c.ibi_raise(IBI_MDB, &IBI_PAYLOAD); + pw_log::info!("I3C IBI trace: raised IBI"); + } + polls += 1; + if polls >= MAX_POLLS { + pw_log::info!("I3C IBI test: timed out waiting for write"); + exit(2); + } + } + } + + fn shutdown(code: u32) -> ! { + match code { + 0 => pw_log::info!("PASS"), + _ => pw_log::info!("FAIL: {}", code), + } + exit(code); + } +} + +declare_target!(Target); From 3212a40d9689f7894efe23f6e75b83c9170c3a73 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 07:16:08 -0700 Subject: [PATCH 09/10] target/veer: add interrupt-driven I3C RX emulator test Unlike the polling smoke test, the firmware discovers the frame solely via the TTI RX-descriptor interrupt: IRQ 2 on the VeeR PIC, wired through the pw_kernel interrupt table to a handler that masks the level-triggered enable and flags the main thread. Getting this to work surfaced a real platform gap: the emulated VeeR core delivers external interrupts by jumping through the MEIVT redirect table (which must live in DCCM), but pw_kernel never programs MEIVT. The first external interrupt therefore escalated to a "table not in DCCM" NMI with an unprogrammed vector, sending the CPU to address 0 -- the terminal mcause=1/epc=0 exception originally seen when the smoke test enabled the RX interrupt. The firmware works around it by filling a redirect table in DCCM with the mtvec trap vector before enabling the interrupt; the proper fix belongs in pigweed's veer_pic early_init. --- target/veer/tests/i3c_irq/BUILD.bazel | 80 ++++++++++ .../veer/tests/i3c_irq/i3c_irq_host_test.rs | 42 ++++++ target/veer/tests/i3c_irq/system.json5 | 19 +++ target/veer/tests/i3c_irq/target.rs | 137 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 target/veer/tests/i3c_irq/BUILD.bazel create mode 100644 target/veer/tests/i3c_irq/i3c_irq_host_test.rs create mode 100644 target/veer/tests/i3c_irq/system.json5 create mode 100644 target/veer/tests/i3c_irq/target.rs diff --git a/target/veer/tests/i3c_irq/BUILD.bazel b/target/veer/tests/i3c_irq/BUILD.bazel new file mode 100644 index 000000000..3c4feb8ee --- /dev/null +++ b/target/veer/tests/i3c_irq/BUILD.bazel @@ -0,0 +1,80 @@ +# Licensed under the Apache-2.0 license +# SPDX-License-Identifier: Apache-2.0 + +load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image") +load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen") +load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script") +load("@pigweed//pw_kernel/tooling/panic_detector:rust_binary_no_panics_test.bzl", "rust_binary_no_panics_test") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") +load("//target/veer:defs.bzl", "TARGET_COMPATIBLE_WITH") +load("//target/veer/tooling:caliptra_runner.bzl", "caliptra_runner") + +package(default_visibility = ["//visibility:public"]) + +system_image( + name = "i3c_irq", + kernel = ":target", + platform = "//target/veer", +) + +target_linker_script( + name = "linker_script", + system_config = ":system_config", + tags = ["kernel"], + template = "//target/veer:linker_script_template", +) + +rust_binary_no_panics_test( + name = "no_panics_test", + binary = ":i3c_irq", +) + +filegroup( + name = "system_config", + srcs = ["system.json5"], +) + +target_codegen( + name = "codegen", + arch = "@pigweed//pw_kernel/arch/riscv:arch_riscv", + system_config = ":system_config", +) + +rust_binary( + name = "target", + srcs = ["target.rs"], + edition = "2024", + target_compatible_with = TARGET_COMPATIBLE_WITH, + deps = [ + ":codegen", + ":linker_script", + "//target/veer:entry", + "//target/veer/peripherals/i3c", + "@pigweed//pw_kernel/arch/riscv:arch_riscv", + "@pigweed//pw_kernel/kernel", + "@pigweed//pw_kernel/subsys/console:console_backend", + "@pigweed//pw_kernel/target:target_common", + "@pigweed//pw_log/rust:pw_log", + ], +) + +caliptra_runner( + name = "i3c_irq_runner", + interface = "emulator", + tags = ["manual"], + target = ":i3c_irq", +) + +rust_test( + name = "i3c_irq_test", + srcs = ["i3c_irq_host_test.rs"], + crate_root = "i3c_irq_host_test.rs", + edition = "2024", + data = [":i3c_irq_runner"], + # caliptra_runner.py hardcodes --i3c-port=65534; must not run in parallel. + tags = [ + "emulator", + "exclusive", + ], + deps = ["//target/veer/tests/i3c_host"], +) diff --git a/target/veer/tests/i3c_irq/i3c_irq_host_test.rs b/target/veer/tests/i3c_irq/i3c_irq_host_test.rs new file mode 100644 index 000000000..f0fb4596a --- /dev/null +++ b/target/veer/tests/i3c_irq/i3c_irq_host_test.rs @@ -0,0 +1,42 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Host-side harness for the VeeR interrupt-driven I3C RX test. +//! +//! Identical to the smoke harness: inject private-write frames until the +//! firmware (which discovers the frame via the I3C interrupt rather than +//! polling) receives one and exits. + +use i3c_host::{connect_i3c_socket, send_private_write_on_stream, Runner}; +use std::thread; +use std::time::Duration; + +const PAYLOAD: [u8; 4] = [0x01, 0x02, 0x03, 0x04]; + +#[test] +fn i3c_irq_host_test() { + let runner = Runner::spawn( + "target/veer/tests/i3c_irq/i3c_irq_runner.sh", + "waiting for private write", + ); + assert!( + runner.wait_ready(Duration::from_secs(600)), + "runner exited or timed out before firmware readiness" + ); + + let addr = runner.target_addr(); + let mut stream = + connect_i3c_socket(Duration::from_secs(5)).expect("failed to connect to I3C socket"); + + let mut sends = 0u32; + while !runner.exited() { + if send_private_write_on_stream(&mut stream, addr, &PAYLOAD).is_ok() { + sends += 1; + } + thread::sleep(Duration::from_millis(100)); + } + + let status = runner.wait(); + assert!(sends > 0, "no I3C private-write frame was ever sent"); + assert!(status.success(), "runner exited with status: {}", status); +} diff --git a/target/veer/tests/i3c_irq/system.json5 b/target/veer/tests/i3c_irq/system.json5 new file mode 100644 index 000000000..85daa5f0d --- /dev/null +++ b/target/veer/tests/i3c_irq/system.json5 @@ -0,0 +1,19 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 +{ + arch: { + type: "riscv", + }, + kernel: { + flash_start_address: 0xA0010000, + flash_size_bytes: 65536, + ram_start_address: 0x10000000, + ram_size_bytes: 32768, + interrupt_table: { + table: { + // IRQ 2 = I3C on the emulated VeeR PIC (McuRootBus::I3C_IRQ). + "2": "i3c_interrupt_handler", + } + }, + }, +} diff --git a/target/veer/tests/i3c_irq/target.rs b/target/veer/tests/i3c_irq/target.rs new file mode 100644 index 000000000..0e31a62a6 --- /dev/null +++ b/target/veer/tests/i3c_irq/target.rs @@ -0,0 +1,137 @@ +// Licensed under the Apache-2.0 license +// SPDX-License-Identifier: Apache-2.0 + +//! Interrupt-driven I3C RX test. +//! +//! Unlike the polling smoke test, main never touches the I3C status +//! registers to discover a frame: the TTI RX-descriptor interrupt (IRQ 2 on +//! the VeeR PIC, wired to `i3c_interrupt_handler` via system.json5) is the +//! only path from frame arrival to reception. The handler masks the RX +//! interrupt enable (the IRQ is level-triggered) and sets a flag; main +//! drains the frame and exits 0 on the expected payload, 1 on a mismatch, +//! and 2 on timeout. + +#![no_std] +#![no_main] + +use caliptra_i3c_target::CaliptraI3cTarget; +use core::sync::atomic::{AtomicBool, Ordering}; +use entry::exit; +use target_common::{declare_target, TargetInterface}; +use console_backend as _; + +// The declare_kernel_interrupt_handlers! expansion below imports +// kernel::interrupt_controller::InterruptController at module scope, which +// is what makes the enable_interrupt call in main resolve. + +pub struct Target {} + +// Emits PW_KERNEL_INTERRUPT_TABLE from the interrupt_table in system.json5. +codegen::declare_kernel_interrupt_handlers!(); + +/// I3C IRQ number on the emulated VeeR PIC; must match system.json5. +const I3C_IRQ: u32 = 2; + +// AtomicBool with store/load only: riscv32imc has no atomic RMW instructions. +static RX_EVENT: AtomicBool = AtomicBool::new(false); + +/// Program the VeeR external-interrupt redirect table (MEIVT). +/// +/// The emulated VeeR core delivers an external interrupt by jumping to the +/// address stored at `MEIVT[irq]` (fast redirect), and requires the table to +/// live in DCCM. pw_kernel never programs MEIVT, so the first external +/// interrupt otherwise escalates to a "table not in DCCM" NMI whose vector +/// is also unprogrammed, and the CPU jumps to address 0 (the terminal +/// mcause=1/epc=0 exception previously seen with enable_rx_interrupt). +/// +/// Pointing every entry at the kernel's standard trap vector (mtvec base) +/// makes the redirect behave exactly like an mtvec-vectored trap; the +/// kernel's PIC dispatch then reads the claim id from MEIHAP as usual. +fn init_meivt() { + const DCCM_BASE: u32 = 0x5000_0000; + const MAX_IRQ: u32 = 32; + let mtvec: u32; + // SAFETY: reading mtvec has no side effects. + unsafe { + core::arch::asm!("csrr {}, mtvec", out(reg) mtvec); + } + let trap_vector = mtvec & !0x3; + for irq in 0..MAX_IRQ { + // SAFETY: DCCM is dedicated data RAM, unused by this system image. + unsafe { + core::ptr::write_volatile((DCCM_BASE + irq * 4) as *mut u32, trap_vector); + } + } + // SAFETY: MEIVT (VeeR-specific CSR 0xBC8) points the redirect table at + // the block initialized above. + unsafe { + core::arch::asm!("csrw 0xbc8, {}", in(reg) DCCM_BASE); + } +} + +/// Referenced by name from system.json5; the generated wrapper calls this +/// with the concrete arch inside an interrupt guard. +pub fn i3c_interrupt_handler(_kernel: K) { + // The RxDescStat IRQ is level-triggered (asserted while enable & status + // are both set), so mask the enable here to deassert it; main drains + // the descriptor afterwards. + // + // SAFETY: main only spins on RX_EVENT while the interrupt is enabled, + // so this ephemeral handle cannot race main's register accesses. + let mut i3c = unsafe { CaliptraI3cTarget::new() }; + i3c.disable_rx_interrupt(); + RX_EVENT.store(true, Ordering::SeqCst); +} + +impl TargetInterface for Target { + const NAME: &'static str = "Caliptra I3C IRQ Test"; + + fn main() -> ! { + // SAFETY: single call at boot; Caliptra ROM has already initialized + // the I3C core and we are the only owner of the peripheral (the + // interrupt handler's handle is sequenced by RX_EVENTS, see above). + init_meivt(); + let mut i3c = unsafe { CaliptraI3cTarget::new() }; + i3c.enable_rx_interrupt(); + ::InterruptController::enable_interrupt(I3C_IRQ); + pw_log::info!("I3C IRQ test: waiting for private write"); + + const MAX_POLLS: u32 = 10_000_000; + let mut polls = 0u32; + while !RX_EVENT.load(Ordering::SeqCst) { + polls += 1; + if polls >= MAX_POLLS { + pw_log::info!("I3C IRQ test: timed out waiting for interrupt"); + exit(2); + } + core::hint::spin_loop(); + } + pw_log::info!("I3C IRQ trace: interrupt observed"); + + let mut buf = [0u8; 64]; + match i3c.rx_read(&mut buf) { + Some(len) if len >= 4 && buf[..4] == [0x01, 0x02, 0x03, 0x04] => { + pw_log::info!("I3C IRQ test: received expected payload OK"); + exit(0); + } + Some(len) => { + pw_log::info!("I3C IRQ test: unexpected payload len={}", len as u32); + exit(1); + } + None => { + pw_log::info!("I3C IRQ test: interrupt fired but no descriptor"); + exit(1); + } + } + } + + fn shutdown(code: u32) -> ! { + match code { + 0 => pw_log::info!("PASS"), + _ => pw_log::info!("FAIL: {}", code), + } + exit(code); + } +} + +declare_target!(Target); From 048c1b86ebc4e96ad7cdca69d002455cb9eef350 Mon Sep 17 00:00:00 2001 From: Anthony Rocha Date: Thu, 13 Aug 2026 07:32:12 -0700 Subject: [PATCH 10/10] third_party/pigweed: patch veer_pic to program MEIVT Move the external-interrupt redirect table setup from the i3c_irq test firmware into the kernel where it belongs: veer_pic early_init now fills a target-provided MEIVT table (every entry pointing at the mtvec trap vector, so redirected interrupts take the standard trap path and the claim id is read from MEIHAP) and writes the MEIVT CSR. The table location comes from a new optional MEIVT_BASE_ADDRESS on VeerPicConfigInterface, defaulting to None so other pigweed users are unaffected; target/veer places it at DCCM base 0x50000000 as the VeeR core requires. All veer system images now get working external interrupts, not just the one test that carried the workaround. Candidate for upstreaming to pigweed. Note: syscall_latency still fails with a similar epc=0 signature, but after its measurement completes, in the userspace process exit path -- a separate pw_kernel bug, unrelated to MEIVT. --- MODULE.bazel | 4 ++ target/veer/config.rs | 4 ++ target/veer/tests/i3c_irq/target.rs | 35 --------------- third_party/pigweed/veer_pic_meivt.patch | 56 ++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 third_party/pigweed/veer_pic_meivt.patch diff --git a/MODULE.bazel b/MODULE.bazel index 12cf77598..8a60dd105 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -33,6 +33,10 @@ git_override( # Fix syscall_defs/syscall_user being gated on userspace_build_enabled, # which breaks userspace=False kernel builds (pigweed regression). "//third_party/pigweed:syscall_no_userspace_constraint.patch", + # Program the VeeR external-interrupt redirect table (MEIVT) in + # veer_pic early_init; without it the first external interrupt + # vectors through an unprogrammed table to address 0. + "//third_party/pigweed:veer_pic_meivt.patch", ], remote = "https://pigweed.googlesource.com/pigweed/pigweed", ) diff --git a/target/veer/config.rs b/target/veer/config.rs index b9268f7c6..52e099457 100644 --- a/target/veer/config.rs +++ b/target/veer/config.rs @@ -55,6 +55,10 @@ pub struct VeerPicConfig; impl VeerPicConfigInterface for VeerPicConfig { const PIC_BASE_ADDRESS: usize = PIC_BASE; const MAX_IRQS: u32 = 256; + // The VeeR core requires the external-interrupt redirect table to live + // in DCCM; this image does not otherwise use DCCM. 256 IRQs * 4 bytes + // fits well within the 16KiB DCCM. + const MEIVT_BASE_ADDRESS: Option = Some(0x5000_0000); } pub struct TimerConfig; diff --git a/target/veer/tests/i3c_irq/target.rs b/target/veer/tests/i3c_irq/target.rs index 0e31a62a6..05c476ebb 100644 --- a/target/veer/tests/i3c_irq/target.rs +++ b/target/veer/tests/i3c_irq/target.rs @@ -35,40 +35,6 @@ const I3C_IRQ: u32 = 2; // AtomicBool with store/load only: riscv32imc has no atomic RMW instructions. static RX_EVENT: AtomicBool = AtomicBool::new(false); -/// Program the VeeR external-interrupt redirect table (MEIVT). -/// -/// The emulated VeeR core delivers an external interrupt by jumping to the -/// address stored at `MEIVT[irq]` (fast redirect), and requires the table to -/// live in DCCM. pw_kernel never programs MEIVT, so the first external -/// interrupt otherwise escalates to a "table not in DCCM" NMI whose vector -/// is also unprogrammed, and the CPU jumps to address 0 (the terminal -/// mcause=1/epc=0 exception previously seen with enable_rx_interrupt). -/// -/// Pointing every entry at the kernel's standard trap vector (mtvec base) -/// makes the redirect behave exactly like an mtvec-vectored trap; the -/// kernel's PIC dispatch then reads the claim id from MEIHAP as usual. -fn init_meivt() { - const DCCM_BASE: u32 = 0x5000_0000; - const MAX_IRQ: u32 = 32; - let mtvec: u32; - // SAFETY: reading mtvec has no side effects. - unsafe { - core::arch::asm!("csrr {}, mtvec", out(reg) mtvec); - } - let trap_vector = mtvec & !0x3; - for irq in 0..MAX_IRQ { - // SAFETY: DCCM is dedicated data RAM, unused by this system image. - unsafe { - core::ptr::write_volatile((DCCM_BASE + irq * 4) as *mut u32, trap_vector); - } - } - // SAFETY: MEIVT (VeeR-specific CSR 0xBC8) points the redirect table at - // the block initialized above. - unsafe { - core::arch::asm!("csrw 0xbc8, {}", in(reg) DCCM_BASE); - } -} - /// Referenced by name from system.json5; the generated wrapper calls this /// with the concrete arch inside an interrupt guard. pub fn i3c_interrupt_handler(_kernel: K) { @@ -90,7 +56,6 @@ impl TargetInterface for Target { // SAFETY: single call at boot; Caliptra ROM has already initialized // the I3C core and we are the only owner of the peripheral (the // interrupt handler's handle is sequenced by RX_EVENTS, see above). - init_meivt(); let mut i3c = unsafe { CaliptraI3cTarget::new() }; i3c.enable_rx_interrupt(); ::InterruptController::enable_interrupt(I3C_IRQ); diff --git a/third_party/pigweed/veer_pic_meivt.patch b/third_party/pigweed/veer_pic_meivt.patch new file mode 100644 index 000000000..76c3a2175 --- /dev/null +++ b/third_party/pigweed/veer_pic_meivt.patch @@ -0,0 +1,56 @@ +diff -u -r a/pw_kernel/arch/riscv/veer_pic.rs b/pw_kernel/arch/riscv/veer_pic.rs +--- a/pw_kernel/arch/riscv/veer_pic.rs 2026-08-13 07:18:55.632731645 -0700 ++++ b/pw_kernel/arch/riscv/veer_pic.rs 2026-08-13 07:22:15.448047131 -0700 +@@ -280,6 +280,34 @@ + ); + set_global_priority(GLOBAL_PRIORITY); + ++ // Program the external-interrupt redirect table (MEIVT) if the ++ // target provides a location for it. VeeR delivers external ++ // interrupts by jumping to the address stored at MEIVT[irq], so ++ // point every entry at the mtvec trap vector: redirected ++ // interrupts then take the standard trap path and the claim id is ++ // read from MEIHAP as usual. Without this, the first external ++ // interrupt vectors through an unprogrammed table. ++ if let Some(base) = VeerPicConfig::MEIVT_BASE_ADDRESS { ++ // MEIVT must be 1KiB-aligned. ++ pw_assert::assert!(base & 0x3ff == 0); ++ let trap_vector = riscv::register::mtvec::read().bits() & !0b11; ++ for irq in 0..VeerPicConfig::MAX_IRQS as usize { ++ // SAFETY: the target guarantees the table region at `base` ++ // is reserved for the redirect table. ++ unsafe { ++ core::ptr::write_volatile( ++ (base + irq * core::mem::size_of::()) as *mut u32, ++ trap_vector as u32, ++ ); ++ } ++ } ++ // SAFETY: MEIVT (VeeR-specific CSR 0xBC8) selects the table ++ // initialized above. ++ unsafe { ++ core::arch::asm!("csrw 0xBC8, {}", in(reg) base); ++ } ++ } ++ + unsafe { + riscv::register::mie::set_mext(); + } +diff -u -r a/pw_kernel/config/lib.rs b/pw_kernel/config/lib.rs +--- a/pw_kernel/config/lib.rs 2026-08-13 07:18:55.628770650 -0700 ++++ b/pw_kernel/config/lib.rs 2026-08-13 07:21:21.367503980 -0700 +@@ -100,6 +100,14 @@ + /// The maximum number of interrupts the + /// PIC supports per context. + const MAX_IRQS: u32 = 256; ++ ++ /// Base address for the external-interrupt redirect table (MEIVT). ++ /// ++ /// Must be 1KiB-aligned. On cores that require the redirect table to ++ /// reside in DCCM (e.g. the Caliptra MCU integration), the address must ++ /// fall within DCCM. `None` leaves MEIVT unprogrammed, in which case ++ /// external interrupts must not be enabled. ++ const MEIVT_BASE_ADDRESS: Option = None; + } + + /// CLINT timer config.