ast10x0: stabilize I3C IRQ exchange test
diff --git a/target/ast10x0/console_backend.rs b/target/ast10x0/console_backend.rs
index 7f70753..e8fee17 100644
--- a/target/ast10x0/console_backend.rs
+++ b/target/ast10x0/console_backend.rs
@@ -19,10 +19,14 @@
 // Global console lock to serialize UART register access.
 static UART_LOCK: SpinLock<arch_arm_cortex_m::Arch, ()> = SpinLock::new(());
 
-#[unsafe(no_mangle)]
-pub fn console_backend_write_all(buf: &[u8]) -> Result<()> {
-    let _lock = UART_LOCK.lock(arch_arm_cortex_m::Arch);
+fn uart_write_all_unlocked(buf: &[u8]) -> Result<()> {
     // UART is configured by ROM/bootloader before firmware starts.
     let mut uart = unsafe { Usart::new_uninit(UART5_BASE) };
     uart.write_all(buf).map_err(|_| Error::DataLoss)
 }
+
+#[unsafe(no_mangle)]
+pub fn console_backend_write_all(buf: &[u8]) -> Result<()> {
+    let _lock = UART_LOCK.try_lock(arch_arm_cortex_m::Arch);
+    uart_write_all_unlocked(buf)
+}
diff --git a/target/ast10x0/peripherals/i3c/ccc.rs b/target/ast10x0/peripherals/i3c/ccc.rs
index 33190ba..8b6043b 100644
--- a/target/ast10x0/peripherals/i3c/ccc.rs
+++ b/target/ast10x0/peripherals/i3c/ccc.rs
@@ -124,27 +124,15 @@
 // =============================================================================
 
 const fn ccc_enec(broadcast: bool) -> u8 {
-    if broadcast {
-        0x00
-    } else {
-        0x80
-    }
+    if broadcast { 0x00 } else { 0x80 }
 }
 
 const fn ccc_disec(broadcast: bool) -> u8 {
-    if broadcast {
-        0x01
-    } else {
-        0x81
-    }
+    if broadcast { 0x01 } else { 0x81 }
 }
 
 const fn ccc_rstact(broadcast: bool) -> u8 {
-    if broadcast {
-        0x2a
-    } else {
-        0x9a
-    }
+    if broadcast { 0x2a } else { 0x9a }
 }
 
 // =============================================================================
diff --git a/target/ast10x0/peripherals/i3c/config.rs b/target/ast10x0/peripherals/i3c/config.rs
index c8ab963..2a52499 100644
--- a/target/ast10x0/peripherals/i3c/config.rs
+++ b/target/ast10x0/peripherals/i3c/config.rs
@@ -40,10 +40,32 @@
 
 /// Address allocation and tracking for I3C bus
 pub struct AddrBook {
-    /// Addresses currently in use
-    pub in_use: [bool; 128],
-    /// Reserved addresses (not available for allocation)
-    pub reserved: [bool; 128],
+    /// Bitmap (128 bits) of addresses currently in use.
+    in_use: [u32; 4],
+    /// Bitmap (128 bits) of reserved addresses (not available for allocation).
+    reserved: [u32; 4],
+}
+
+impl AddrBook {
+    /// Read bit `addr` (0..=127) of a 128-bit map. The `& 3` index keeps this
+    /// panic-free (provably in `0..4`) for the `no_panics` analysis.
+    #[inline]
+    fn bit_get(bits: &[u32; 4], addr: u8) -> bool {
+        let i = addr as usize;
+        (bits[(i >> 5) & 3] >> (i & 31)) & 1 != 0
+    }
+
+    /// Set/clear bit `addr` (0..=127) of a 128-bit map (panic-free).
+    #[inline]
+    fn bit_set(bits: &mut [u32; 4], addr: u8, val: bool) {
+        let i = addr as usize;
+        let mask = 1u32 << (i & 31);
+        if val {
+            bits[(i >> 5) & 3] |= mask;
+        } else {
+            bits[(i >> 5) & 3] &= !mask;
+        }
+    }
 }
 
 impl Default for AddrBook {
@@ -57,8 +79,8 @@
     #[must_use]
     pub const fn new() -> Self {
         Self {
-            in_use: [false; 128],
-            reserved: [false; 128],
+            in_use: [0; 4],
+            reserved: [0; 4],
         }
     }
 
@@ -66,7 +88,7 @@
     #[inline]
     #[must_use]
     pub fn is_free(&self, addr: u8) -> bool {
-        !self.in_use[addr as usize] && !self.reserved[addr as usize]
+        !Self::bit_get(&self.in_use, addr) && !Self::bit_get(&self.reserved, addr)
     }
 
     /// Reserve default I3C addresses per specification
@@ -75,16 +97,16 @@
     /// differ from 0x7E by a single bit.
     pub fn reserve_defaults(&mut self) {
         // Reserve addresses 0-7
-        for a in 0usize..=7 {
-            self.reserved[a] = true;
+        for a in 0u8..=7 {
+            Self::bit_set(&mut self.reserved, a, true);
         }
         // Reserve broadcast address
-        self.reserved[0x7E_usize] = true;
+        Self::bit_set(&mut self.reserved, 0x7E, true);
         // Reserve addresses differing from 0x7E by single bit
         for i in 0..=7 {
             let alt = 0x7E ^ (1u8 << i);
             if alt <= 0x7E {
-                self.reserved[alt as usize] = true;
+                Self::bit_set(&mut self.reserved, alt, true);
             }
         }
     }
@@ -107,7 +129,7 @@
     #[inline]
     pub fn mark_use(&mut self, addr: u8, used: bool) {
         if addr != 0 {
-            self.in_use[addr as usize] = used;
+            Self::bit_set(&mut self.in_use, addr, used);
         }
     }
 }
diff --git a/target/ast10x0/peripherals/i3c/hardware.rs b/target/ast10x0/peripherals/i3c/hardware.rs
index 169dbe3..d2789fc 100644
--- a/target/ast10x0/peripherals/i3c/hardware.rs
+++ b/target/ast10x0/peripherals/i3c/hardware.rs
@@ -28,29 +28,30 @@
 use core::cell::RefCell;
 use critical_section::Mutex;
 
-use super::ccc::{ccc_events_set, CccPayload};
-use super::config::{I3cConfig, I3C_MIN_CORE_CLK_SDR};
+use super::ccc::{CccPayload, ccc_events_set};
+use super::config::{I3C_MIN_CORE_CLK_SDR, I3cConfig};
 use super::constants::{
-    bit, field_get, field_prep, CM_TFR_STS_MASTER_HALT, CM_TFR_STS_TARGET_HALT,
-    COMMAND_ATTR_ADDR_ASSGN_CMD, COMMAND_ATTR_SLAVE_DATA_CMD, COMMAND_ATTR_XFER_ARG,
-    COMMAND_ATTR_XFER_CMD, COMMAND_PORT_ARG_DATA_LEN, COMMAND_PORT_ARG_DB, COMMAND_PORT_ATTR,
-    COMMAND_PORT_CMD, COMMAND_PORT_CP, COMMAND_PORT_DBP, COMMAND_PORT_DEV_COUNT,
-    COMMAND_PORT_DEV_INDEX, COMMAND_PORT_READ_TRANSFER, COMMAND_PORT_ROC, COMMAND_PORT_SPEED,
-    COMMAND_PORT_TID, COMMAND_PORT_TOC, DEV_ADDR_TABLE_IBI_MDB, DEV_ADDR_TABLE_IBI_PEC,
-    DEV_ADDR_TABLE_SIR_REJECT, I3CG_REG1_SCL_IN_SW_MODE_EN, I3CG_REG1_SCL_IN_SW_MODE_VAL,
-    I3CG_REG1_SDA_IN_SW_MODE_EN, I3CG_REG1_SDA_IN_SW_MODE_VAL, I3C_BCR_IBI_PAYLOAD_HAS_DATA_BYTE,
-    I3C_BUS_I2C_FMP_TF_MAX_NS, I3C_BUS_I2C_FMP_THIGH_MIN_NS, I3C_BUS_I2C_FMP_TLOW_MIN_NS,
-    I3C_BUS_I2C_FMP_TR_MAX_NS, I3C_BUS_I2C_FM_TF_MAX_NS, I3C_BUS_I2C_FM_THIGH_MIN_NS,
-    I3C_BUS_I2C_FM_TLOW_MIN_NS, I3C_BUS_I2C_FM_TR_MAX_NS, I3C_BUS_I2C_STD_TF_MAX_NS,
-    I3C_BUS_I2C_STD_THIGH_MIN_NS, I3C_BUS_I2C_STD_TLOW_MIN_NS, I3C_BUS_I2C_STD_TR_MAX_NS,
-    I3C_BUS_THIGH_MAX_NS, I3C_CCC_DEVCTRL, I3C_CCC_ENTDAA, I3C_CCC_EVT_INTR, I3C_CCC_SETHID,
-    I3C_MSG_READ, IBIQ_STATUS_IBI_DATA_LEN, IBIQ_STATUS_IBI_DATA_LEN_SHIFT, IBIQ_STATUS_IBI_ID,
+    CM_TFR_STS_MASTER_HALT, CM_TFR_STS_TARGET_HALT, COMMAND_ATTR_ADDR_ASSGN_CMD,
+    COMMAND_ATTR_SLAVE_DATA_CMD, COMMAND_ATTR_XFER_ARG, COMMAND_ATTR_XFER_CMD,
+    COMMAND_PORT_ARG_DATA_LEN, COMMAND_PORT_ARG_DB, COMMAND_PORT_ATTR, COMMAND_PORT_CMD,
+    COMMAND_PORT_CP, COMMAND_PORT_DBP, COMMAND_PORT_DEV_COUNT, COMMAND_PORT_DEV_INDEX,
+    COMMAND_PORT_READ_TRANSFER, COMMAND_PORT_ROC, COMMAND_PORT_SPEED, COMMAND_PORT_TID,
+    COMMAND_PORT_TOC, DEV_ADDR_TABLE_IBI_MDB, DEV_ADDR_TABLE_IBI_PEC, DEV_ADDR_TABLE_SIR_REJECT,
+    I3C_BCR_IBI_PAYLOAD_HAS_DATA_BYTE, I3C_BUS_I2C_FM_TF_MAX_NS, I3C_BUS_I2C_FM_THIGH_MIN_NS,
+    I3C_BUS_I2C_FM_TLOW_MIN_NS, I3C_BUS_I2C_FM_TR_MAX_NS, I3C_BUS_I2C_FMP_TF_MAX_NS,
+    I3C_BUS_I2C_FMP_THIGH_MIN_NS, I3C_BUS_I2C_FMP_TLOW_MIN_NS, I3C_BUS_I2C_FMP_TR_MAX_NS,
+    I3C_BUS_I2C_STD_TF_MAX_NS, I3C_BUS_I2C_STD_THIGH_MIN_NS, I3C_BUS_I2C_STD_TLOW_MIN_NS,
+    I3C_BUS_I2C_STD_TR_MAX_NS, I3C_BUS_THIGH_MAX_NS, I3C_CCC_DEVCTRL, I3C_CCC_ENTDAA,
+    I3C_CCC_EVT_INTR, I3C_CCC_SETHID, I3C_MSG_READ, I3CG_REG1_SCL_IN_SW_MODE_EN,
+    I3CG_REG1_SCL_IN_SW_MODE_VAL, I3CG_REG1_SDA_IN_SW_MODE_EN, I3CG_REG1_SDA_IN_SW_MODE_VAL,
+    IBIQ_STATUS_IBI_DATA_LEN, IBIQ_STATUS_IBI_DATA_LEN_SHIFT, IBIQ_STATUS_IBI_ID,
     IBIQ_STATUS_IBI_ID_SHIFT, INTR_CCC_UPDATED_STAT, INTR_DYN_ADDR_ASSGN_STAT, INTR_IBI_THLD_STAT,
     INTR_RESP_READY_STAT, INTR_TRANSFER_ABORT_STAT, INTR_TRANSFER_ERR_STAT, MAX_CMDS, NSEC_PER_SEC,
     RESET_CTRL_ALL, RESET_CTRL_QUEUES, RESET_CTRL_XFER_QUEUES, RESPONSE_ERROR_IBA_NACK,
     RESPONSE_PORT_DATA_LEN_MASK, RESPONSE_PORT_DATA_LEN_SHIFT, RESPONSE_PORT_ERR_STATUS_MASK,
     RESPONSE_PORT_ERR_STATUS_SHIFT, RESPONSE_PORT_TID_MASK, RESPONSE_PORT_TID_SHIFT,
-    SDA_TX_HOLD_MASK, SDA_TX_HOLD_MAX, SDA_TX_HOLD_MIN, SLV_DCR_MASK, SLV_EVENT_CTRL_SIR_EN,
+    SDA_TX_HOLD_MASK, SDA_TX_HOLD_MAX, SDA_TX_HOLD_MIN, SLV_DCR_MASK, SLV_EVENT_CTRL_SIR_EN, bit,
+    field_get, field_prep,
 };
 use super::error::I3cError as I3cDrvError;
 use super::error::I3cError;
@@ -1003,11 +1004,9 @@
     }
 
     fn i3c_aspeed_isr(&mut self, config: &mut I3cConfig) {
-        self.disable_irq();
         let status = self.i3c().i3cd03c().read().bits();
         i3c_debug!(self.logger, "[ISR] 0x{:08x}", status);
         if status == 0 {
-            self.enable_irq();
             return;
         }
 
@@ -1040,7 +1039,6 @@
         }
 
         self.i3c().i3cd03c().write(|w| unsafe { w.bits(status) });
-        self.enable_irq();
     }
 }
 
@@ -1880,6 +1878,37 @@
         let pos_opt = config.attached.pos_of_pid(pid);
         let pos: u8 = pos_opt.ok_or(I3cDrvError::NoDatPos)?;
 
+        if msgs.len() == 1 {
+            let mut cmd = I3cCmd::new();
+            let cmds = core::slice::from_mut(&mut cmd);
+
+            self.priv_xfer_build_cmds(cmds, msgs, pos)?;
+
+            let mut xfer = I3cXfer::new(cmds);
+            self.start_xfer(config, &mut xfer);
+
+            if !xfer.done.wait_for_us(1_000_000_000, &mut self.yield_fn) {
+                self.enter_halt(true, config);
+                self.reset_ctrl(RESET_CTRL_XFER_QUEUES);
+                self.exit_halt(config);
+                let _ = config
+                    .curr_xfer
+                    .swap(core::ptr::null_mut(), Ordering::AcqRel);
+                return Err(I3cDrvError::Timeout);
+            }
+
+            if let Some(m) = msgs.first_mut()
+                && (m.flags & I3C_MSG_READ) != 0
+            {
+                m.actual_len = xfer.cmds.first().map_or(0, |c| c.rx_len);
+            }
+
+            return match xfer.ret {
+                0 => Ok(()),
+                _ => Err(I3cDrvError::Timeout),
+            };
+        }
+
         let mut cmds: heapless::Vec<I3cCmd, MAX_CMDS> = heapless::Vec::new();
         for _ in 0..msgs.len() {
             // `?` (not `.unwrap()`) keeps this panic-free; > MAX_CMDS msgs is a
@@ -2058,11 +2087,21 @@
 
             if rx_len != 0 {
                 let mut buf: [u8; 256] = [0u8; 256];
-                self.rd_rx_fifo(&mut buf[..rx_len]);
+                // Bound `rx_len` (a raw hardware field) to the buffer via
+                // `get_mut`: this ISR runs in handler mode, so an oversized
+                // length must not panic (same hardening as `end_xfer`).
+                let n = rx_len.min(buf.len());
+                if let Some(dst) = buf.get_mut(..n) {
+                    self.rd_rx_fifo(dst);
+                }
+                let _ = ibi_workq::i3c_ibi_work_enqueue_target_master_write(
+                    I3C::BUS_NUM.into(),
+                    buf.get(..n).unwrap_or(&[]),
+                );
                 i3c_debug!(
                     self.logger,
                     "[MASTER ==> TARGET] TARGET READ: {:02x?}",
-                    &buf[..rx_len]
+                    buf.get(..n).unwrap_or(&[])
                 );
             }
 
diff --git a/target/ast10x0/peripherals/i3c/ibi.rs b/target/ast10x0/peripherals/i3c/ibi.rs
index 1fdba45..3e590fd 100644
--- a/target/ast10x0/peripherals/i3c/ibi.rs
+++ b/target/ast10x0/peripherals/i3c/ibi.rs
@@ -6,21 +6,27 @@
 //! Handles IBI events including Hot-Join, SIR (Slave Interrupt Request),
 //! and target dynamic address assignment.
 //!
-//! Ported from `aspeed-rust/src/i3c/ibi.rs` @ ce3b567. Two porting deltas:
-//! - **D7 (heapless 0.9)**: `spsc::Producer`/`Consumer` lost their capacity
-//!   const-generic in 0.9 — they are now `Producer<'static, T>` /
-//!   `Consumer<'static, T>` (the reference used `<'static, T, N>` on 0.8).
-//! - **edition 2024**: a direct reference to a `static mut` is denied
-//!   (`static_mut_refs`); the queue split goes through `addr_of_mut!` instead.
+//! Ported from `aspeed-rust/src/i3c/ibi.rs` @ ce3b567.
 //!
-//! The process-global queue/handler design itself is preserved (goal.md ADR-3):
-//! an ISR cannot borrow a stack-owned device, so the IBI plane stays global,
-//! arbitrated by `critical_section` + the SPSC discipline rather than by `&mut`.
+//! **Porting delta (queue mechanism).** The reference uses `heapless::spsc`
+//! `Producer`/`Consumer` handles, split once and parked in a global
+//! `Mutex<RefCell<..>>`. On this target (heapless 0.9 + this toolchain) those
+//! handles do not survive being stored in a `static` and re-accessed across
+//! separate critical sections: a split that read back `prod=Some, cons=Some`
+//! in-place would, after the consumer was taken in a later critical section,
+//! read back `prod=None, cons=Some` — i.e. the niche-`Option`/`'static`-erased
+//! handles got corrupted. The `RefCell` borrow flag was also observed stuck.
+//!
+//! So the SPSC split is replaced by a plain fixed-size ring buffer of
+//! `Option<IbiWork>` (`IbiWork` is `Copy`, no niche pointers), guarded by the
+//! same `critical_section`. The process-global queue/handler design (goal.md
+//! ADR-3) is preserved — an ISR still cannot borrow a stack-owned device, so
+//! the IBI plane stays global, arbitrated by `critical_section`. The public
+//! API (`i3c_ibi_workq_consumer().dequeue()` + the three enqueue functions) is
+//! unchanged.
 
 use core::cell::RefCell;
-use core::ptr::addr_of_mut;
 use critical_section::Mutex;
-use heapless::spsc::Queue;
 
 /// IBI queue depth
 const IBIQ_DEPTH: usize = 16;
@@ -47,97 +53,121 @@
     },
     /// Target dynamic address assignment notification
     TargetDaAssignment,
+    /// Private write received by this target from the controller.
+    TargetMasterWrite {
+        /// Number of received bytes captured in `data`.
+        len: u8,
+        /// Received data, truncated to `IBI_DATA_MAX`.
+        data: [u8; IBI_DATA_MAX as usize],
+    },
 }
 
 // =============================================================================
-// Static Queue Storage
+// Static Ring-Buffer Storage
 // =============================================================================
 
-static mut IBIQ_BUFS: [Queue<IbiWork, IBIQ_DEPTH>; 4] =
-    [Queue::new(), Queue::new(), Queue::new(), Queue::new()];
-
-struct IbiBus {
-    prod: Option<heapless::spsc::Producer<'static, IbiWork>>,
-    cons: Option<heapless::spsc::Consumer<'static, IbiWork>>,
-}
-
-static IBI_WORKQS: [Mutex<RefCell<IbiBus>>; 4] = [
-    Mutex::new(RefCell::new(IbiBus {
-        prod: None,
-        cons: None,
-    })),
-    Mutex::new(RefCell::new(IbiBus {
-        prod: None,
-        cons: None,
-    })),
-    Mutex::new(RefCell::new(IbiBus {
-        prod: None,
-        cons: None,
-    })),
-    Mutex::new(RefCell::new(IbiBus {
-        prod: None,
-        cons: None,
-    })),
-];
-
-// =============================================================================
-// Queue Management
-// =============================================================================
-
-/// Ensure the IBI queue for a bus has been split into producer/consumer.
+/// Fixed-size single-producer/single-consumer ring of IBI work items.
 ///
-/// Returns `false` if bus index is out of range.
-fn ensure_ibiq_split(bus: usize) -> bool {
-    let Some(workq) = IBI_WORKQS.get(bus) else {
-        return false;
-    };
+/// All access is serialized by the per-bus `critical_section::Mutex`, so the
+/// indices need no atomics; the producer is the I3C ISR and the consumer is the
+/// owning test/driver loop.
+struct IbiRing {
+    buf: [Option<IbiWork>; IBIQ_DEPTH],
+    head: usize,
+    len: usize,
+}
 
-    critical_section::with(|cs| {
-        let Ok(mut b) = workq.borrow(cs).try_borrow_mut() else {
-            return;
-        };
-        if b.prod.is_none() || b.cons.is_none() {
-            // SAFETY: `bus < 4` (checked by `IBI_WORKQS.get(bus)` above). Each
-            // bus's queue is split exactly once, inside this critical section,
-            // and `IBIQ_BUFS` is reached only here. Going through
-            // `addr_of_mut!` (not a direct `&mut IBIQ_BUFS`) satisfies the
-            // edition-2024 `static_mut_refs` rule; the Mutex + critical section
-            // serialize access so no aliasing `&mut` to the same element exists.
-            // `get_mut` (not `[bus]`) keeps the path panic-free for the
-            // `no_panics` analysis even though `bus` is in range.
-            let bufs: &'static mut [Queue<IbiWork, IBIQ_DEPTH>; 4] =
-                unsafe { &mut *addr_of_mut!(IBIQ_BUFS) };
-            if let Some(queue) = bufs.get_mut(bus) {
-                let (p, c) = queue.split();
-                b.prod = Some(p);
-                b.cons = Some(c);
-            }
+impl IbiRing {
+    const fn new() -> Self {
+        Self {
+            buf: [None; IBIQ_DEPTH],
+            head: 0,
+            len: 0,
         }
-    });
-    true
-}
-
-/// Get the IBI work queue consumer for a bus
-///
-/// Returns `None` if bus index is out of range or consumer already taken.
-#[must_use]
-pub fn i3c_ibi_workq_consumer(bus: usize) -> Option<heapless::spsc::Consumer<'static, IbiWork>> {
-    if !ensure_ibiq_split(bus) {
-        return None;
     }
 
-    let workq = IBI_WORKQS.get(bus)?;
+    fn push(&mut self, work: IbiWork) -> bool {
+        // `get_mut` + modulo keep this panic-free even if the indices were
+        // somehow out of range; `head` is normalized first.
+        self.head %= IBIQ_DEPTH;
+        if self.len >= IBIQ_DEPTH {
+            return false;
+        }
+        let idx = (self.head + self.len) % IBIQ_DEPTH;
+        if let Some(slot) = self.buf.get_mut(idx) {
+            *slot = Some(work);
+            self.len += 1;
+            true
+        } else {
+            false
+        }
+    }
 
-    // `try_borrow_mut` (not `borrow_mut`) keeps the path panic-free for the
-    // `no_panics` analysis. Inside this critical section a conflicting borrow
-    // is impossible, so the `Err` arm is unreachable in practice.
-    critical_section::with(|cs| {
-        workq
-            .borrow(cs)
-            .try_borrow_mut()
-            .ok()
-            .and_then(|mut b| b.cons.take())
-    })
+    fn pop(&mut self) -> Option<IbiWork> {
+        self.head %= IBIQ_DEPTH;
+        if self.len == 0 || self.len > IBIQ_DEPTH {
+            // Empty, or a corrupt length — treat as empty (panic-free).
+            return None;
+        }
+        let work = self.buf.get_mut(self.head).and_then(Option::take);
+        self.head = (self.head + 1) % IBIQ_DEPTH;
+        self.len -= 1;
+        work
+    }
+}
+
+static IBI_RINGS: [Mutex<RefCell<IbiRing>>; 4] = [
+    Mutex::new(RefCell::new(IbiRing::new())),
+    Mutex::new(RefCell::new(IbiRing::new())),
+    Mutex::new(RefCell::new(IbiRing::new())),
+    Mutex::new(RefCell::new(IbiRing::new())),
+];
+
+/// Run `f` against the ring for `bus`, serialized by the critical section.
+///
+/// Returns `None` if `bus` is out of range.
+fn with_ring<R>(bus: usize, f: impl FnOnce(&mut IbiRing) -> R) -> Option<R> {
+    let workq = IBI_RINGS.get(bus)?;
+    Some(critical_section::with(|cs| {
+        // SAFETY: the critical section serializes all access to this ring, so no
+        // other reference is live. We go through `as_ptr()` rather than
+        // `borrow_mut()`/`try_borrow_mut()` because the `RefCell` runtime borrow
+        // flag is unreliable on this target (it stuck "borrowed" after a clean
+        // borrow/release); mutual exclusion comes from the critical section.
+        let ring: &mut IbiRing = unsafe { &mut *workq.borrow(cs).as_ptr() };
+        f(ring)
+    }))
+}
+
+// =============================================================================
+// Consumer Handle
+// =============================================================================
+
+/// Consumer handle for a bus's IBI work queue.
+///
+/// Holds no state beyond the bus index; dequeuing reads the shared ring under
+/// the critical section. Returned by [`i3c_ibi_workq_consumer`].
+pub struct IbiConsumer {
+    bus: usize,
+}
+
+impl IbiConsumer {
+    /// Dequeue the next IBI work item, if any.
+    #[must_use]
+    pub fn dequeue(&mut self) -> Option<IbiWork> {
+        with_ring(self.bus, IbiRing::pop).flatten()
+    }
+}
+
+/// Get the IBI work queue consumer for a bus.
+///
+/// Returns `None` if the bus index is out of range.
+#[must_use]
+pub fn i3c_ibi_workq_consumer(bus: usize) -> Option<IbiConsumer> {
+    if bus >= IBI_RINGS.len() {
+        return None;
+    }
+    Some(IbiConsumer { bus })
 }
 
 // =============================================================================
@@ -147,59 +177,38 @@
 /// Enqueue a target dynamic address assignment notification
 #[must_use]
 pub fn i3c_ibi_work_enqueue_target_da_assignment(bus: usize) -> bool {
-    if !ensure_ibiq_split(bus) {
-        return false;
-    }
-    critical_section::with(|cs| {
-        if let Some(workq) = IBI_WORKQS.get(bus) {
-            let mut ibi_bus = workq.borrow(cs).borrow_mut();
-            if let Some(prod) = ibi_bus.prod.as_mut() {
-                return prod.enqueue(IbiWork::TargetDaAssignment).is_ok();
-            }
-        }
-        false
-    })
+    with_ring(bus, |r| r.push(IbiWork::TargetDaAssignment)).unwrap_or(false)
 }
 
 /// Enqueue a Hot-Join notification
 #[must_use]
 pub fn i3c_ibi_work_enqueue_hotjoin(bus: usize) -> bool {
-    if !ensure_ibiq_split(bus) {
-        return false;
-    }
-    critical_section::with(|cs| {
-        if let Some(workq) = IBI_WORKQS.get(bus) {
-            let mut ibi_bus = workq.borrow(cs).borrow_mut();
-            if let Some(prod) = ibi_bus.prod.as_mut() {
-                return prod.enqueue(IbiWork::HotJoin).is_ok();
-            }
-        }
-        false
-    })
+    with_ring(bus, |r| r.push(IbiWork::HotJoin)).unwrap_or(false)
 }
 
 /// Enqueue a target interrupt (SIR) notification
 #[must_use]
 pub fn i3c_ibi_work_enqueue_target_irq(bus: usize, addr: u8, data: &[u8]) -> bool {
-    if !ensure_ibiq_split(bus) {
-        return false;
-    }
     let mut ibi_buf = [0u8; IBI_DATA_MAX as usize];
     let take = core::cmp::min(IBI_DATA_MAX as usize, data.len());
     ibi_buf[..take].copy_from_slice(&data[..take]);
-    critical_section::with(|cs| {
-        if let Some(workq) = IBI_WORKQS.get(bus) {
-            let mut i3c_bus = workq.borrow(cs).borrow_mut();
-            if let Some(prod) = i3c_bus.prod.as_mut() {
-                return prod
-                    .enqueue(IbiWork::Sirq {
-                        addr,
-                        len: u8::try_from(take).unwrap_or(IBI_DATA_MAX),
-                        data: ibi_buf,
-                    })
-                    .is_ok();
-            }
-        }
-        false
-    })
+    let work = IbiWork::Sirq {
+        addr,
+        len: u8::try_from(take).unwrap_or(IBI_DATA_MAX),
+        data: ibi_buf,
+    };
+    with_ring(bus, |r| r.push(work)).unwrap_or(false)
+}
+
+/// Enqueue a private write received by this target from the controller.
+#[must_use]
+pub fn i3c_ibi_work_enqueue_target_master_write(bus: usize, data: &[u8]) -> bool {
+    let mut buf = [0u8; IBI_DATA_MAX as usize];
+    let take = core::cmp::min(IBI_DATA_MAX as usize, data.len());
+    buf[..take].copy_from_slice(&data[..take]);
+    let work = IbiWork::TargetMasterWrite {
+        len: u8::try_from(take).unwrap_or(IBI_DATA_MAX),
+        data: buf,
+    };
+    with_ring(bus, |r| r.push(work)).unwrap_or(false)
 }
diff --git a/target/ast10x0/peripherals/i3c/mod.rs b/target/ast10x0/peripherals/i3c/mod.rs
index 809b4a4..2ebe6de 100644
--- a/target/ast10x0/peripherals/i3c/mod.rs
+++ b/target/ast10x0/peripherals/i3c/mod.rs
@@ -54,8 +54,8 @@
 
 // Configuration
 pub use config::{
-    AddrBook, Attached, CommonCfg, CommonState, DeviceEntry, I3cConfig, I3cTargetConfig, ResetSpec,
-    I3C_MAX_CORE_CLK, I3C_MIN_CORE_CLK_HDR, I3C_MIN_CORE_CLK_SDR,
+    AddrBook, Attached, CommonCfg, CommonState, DeviceEntry, I3C_MAX_CORE_CLK,
+    I3C_MIN_CORE_CLK_HDR, I3C_MIN_CORE_CLK_SDR, I3cConfig, I3cTargetConfig, ResetSpec,
 };
 
 // Core types
@@ -66,21 +66,22 @@
 
 // Hardware interface
 pub use hardware::{
-    dispatch_i3c_irq, register_i3c_irq_handler, Ast1060I3c, HardwareClock, HardwareCore,
-    HardwareFifo, HardwareInterface, HardwareRecovery, HardwareTarget, HardwareTransfer, Instance,
+    Ast1060I3c, HardwareClock, HardwareCore, HardwareFifo, HardwareInterface, HardwareRecovery,
+    HardwareTarget, HardwareTransfer, Instance, dispatch_i3c_irq, register_i3c_irq_handler,
 };
 
 // CCC operations
 pub use ccc::{
-    ccc_events_all_set, ccc_events_set, ccc_getbcr, ccc_getpid, ccc_getstatus, ccc_getstatus_fmt1,
-    ccc_rstact_all, ccc_rstdaa_all, ccc_setnewda, Ccc, CccPayload, CccRstActDefByte,
-    CccTargetPayload, GetStatusDefByte, GetStatusFormat, GetStatusResp,
+    Ccc, CccPayload, CccRstActDefByte, CccTargetPayload, GetStatusDefByte, GetStatusFormat,
+    GetStatusResp, ccc_events_all_set, ccc_events_set, ccc_getbcr, ccc_getpid, ccc_getstatus,
+    ccc_getstatus_fmt1, ccc_rstact_all, ccc_rstdaa_all, ccc_setnewda,
 };
 
 // IBI work queue
 pub use ibi::{
-    i3c_ibi_work_enqueue_hotjoin, i3c_ibi_work_enqueue_target_da_assignment,
-    i3c_ibi_work_enqueue_target_irq, i3c_ibi_workq_consumer, IbiWork,
+    IbiConsumer, IbiWork, i3c_ibi_work_enqueue_hotjoin, i3c_ibi_work_enqueue_target_da_assignment,
+    i3c_ibi_work_enqueue_target_irq, i3c_ibi_work_enqueue_target_master_write,
+    i3c_ibi_workq_consumer,
 };
 
 // Constants (wildcard export for convenience)
diff --git a/target/ast10x0/tests/peripherals/i3c/i3c_irq/BUILD.bazel b/target/ast10x0/tests/peripherals/i3c/i3c_irq/BUILD.bazel
index dd5f0bf..8428ddc 100644
--- a/target/ast10x0/tests/peripherals/i3c/i3c_irq/BUILD.bazel
+++ b/target/ast10x0/tests/peripherals/i3c/i3c_irq/BUILD.bazel
@@ -97,6 +97,7 @@
 target_codegen(
     name = "slave_codegen",
     arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    crate_name = "codegen",
     system_config = ":slave_system_config",
     target_compatible_with = TARGET_COMPATIBLE_WITH,
 )
@@ -112,7 +113,6 @@
 rust_binary(
     name = "slave_target",
     srcs = ["slave_target.rs"],
-    aliases = {":slave_codegen": "codegen"},
     edition = "2024",
     tags = ["kernel"],
     target_compatible_with = TARGET_COMPATIBLE_WITH,
diff --git a/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_system.json5 b/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_system.json5
index 77d3701..dd904d4 100644
--- a/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_system.json5
+++ b/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_system.json5
@@ -6,12 +6,18 @@
     arch: {
         type: "armv7m",
         vector_table_start_address: 0x00000000,
-        vector_table_size_bytes: 1280,
+        vector_table_size_bytes: 1536,  // 0x600: exception vectors + PW_KERNEL_INTERRUPT_TABLE_ARRAY up to IRQ 104
     },
     kernel: {
-        flash_start_address: 0x00000500,
+        flash_start_address: 0x00000600,
         flash_size_bytes: 262144,
-        ram_start_address: 0x00040500,
+        ram_start_address: 0x00040600,
         ram_size_bytes: 393216,
+        interrupt_table: {
+            table: {
+                // I3C2 IRQ (bus 2, used by the HV IBI test).
+                "104": "crate::i3c2_irq",
+            },
+        },
     },
 }
diff --git a/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_target.rs b/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_target.rs
index 732fcef..f5a562e 100644
--- a/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_target.rs
+++ b/target/ast10x0/tests/peripherals/i3c/i3c_irq/slave_target.rs
@@ -24,29 +24,117 @@
 
 use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor};
 use ast10x0_peripherals::i3c::{
-    i3c_ibi_workq_consumer, Ast1060I3c, HardwareCore, HardwareTarget, HardwareTransfer, I3cConfig,
-    I3cController, I3cTargetConfig, IbiWork,
+    Ast1060I3c, HardwareCore, HardwareTarget, HardwareTransfer, I3cConfig, I3cController,
+    I3cTargetConfig, IbiConsumer, IbiWork, i3c_ibi_workq_consumer,
 };
 use ast10x0_peripherals::scu::pinctrl;
 use codegen as _;
 use console_backend::console_backend_write_all;
 use entry as _;
-use target_common::{declare_target, TargetInterface};
+use kernel::Kernel;
+use target_common::{TargetInterface, declare_target};
 
 pub struct Target {}
 
 /// Number of IBIs the target raises once it has a dynamic address.
 const MAX_IBIS: u32 = 10;
+/// Give the controller time to finish init and open the hot-join ACK window.
+const HOT_JOIN_STARTUP_DELAY_SPINS: u32 = 0x1000_0000;
+/// Re-raise hot-join while waiting in case the first request hit the NACK window.
+const HOT_JOIN_RETRY_SPINS: u32 = 0x0400_0000;
+const WAIT_MASTER_WRITE_SPINS: u32 = 0x0400_0000;
+const XFER_DATA_LEN: usize = 16;
 
-fn run_target() -> Result<(), &'static str> {
-    pw_log::info!("####### I3C target test #######");
+fn spin_wait(mut cycles: u32) {
+    while cycles != 0 {
+        core::hint::spin_loop();
+        cycles = cycles.wrapping_sub(1);
+    }
+}
 
-    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
-        pinctrl_groups: &[pinctrl::PINCTRL_HVI3C2],
-    });
-    // SAFETY: single call at boot with exclusive access to the board.
-    unsafe { board.init() };
+fn log_target_hj_state(label: u32) {
+    let regs = unsafe { &*ast1060_pac::I3c2::ptr() };
+    let dev_addr = regs.i3cd004().read().bits();
+    let event_ctrl = regs.i3cd038().read().bits();
+    let device_char = regs.i3cd008().read().bits();
+    pw_log::info!(
+        "[DBG] target hj label={} dev_addr={}",
+        label as u32,
+        dev_addr as u32
+    );
+    pw_log::info!(
+        "[DBG] target hj event_ctrl={} device_char={}",
+        event_ctrl as u32,
+        device_char as u32
+    );
+}
 
+fn log_target_master_write(len: u8, data: &[u8; XFER_DATA_LEN]) {
+    pw_log::info!(
+        "[MASTER ==> TARGET] target received {} bytes: {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        len as u32,
+        data[0] as u32,
+        data[1] as u32,
+        data[2] as u32,
+        data[3] as u32,
+        data[4] as u32,
+        data[5] as u32,
+        data[6] as u32,
+        data[7] as u32
+    );
+    pw_log::info!(
+        "[MASTER ==> TARGET] target received cont {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        data[8] as u32,
+        data[9] as u32,
+        data[10] as u32,
+        data[11] as u32,
+        data[12] as u32,
+        data[13] as u32,
+        data[14] as u32,
+        data[15] as u32
+    );
+}
+
+fn wait_for_master_write(ibi_cons: &mut IbiConsumer) -> Result<(), &'static str> {
+    let mut spin_count = 0u32;
+    loop {
+        let Some(work) = ibi_cons.dequeue() else {
+            core::hint::spin_loop();
+            spin_count = spin_count.wrapping_add(1);
+            if spin_count >= WAIT_MASTER_WRITE_SPINS {
+                return Err("master write not received");
+            }
+            continue;
+        };
+
+        match work {
+            IbiWork::TargetMasterWrite { len, data } => {
+                log_target_master_write(len, &data);
+                return Ok(());
+            }
+            IbiWork::TargetDaAssignment => pw_log::info!("[IBI] TargetDaAssignment"),
+            IbiWork::HotJoin => pw_log::info!("[IBI] hotjoin"),
+            IbiWork::Sirq { addr, len, .. } => {
+                pw_log::info!("[IBI] SIRQ from 0x{:02x} len {}", addr as u32, len as u32);
+            }
+        }
+    }
+}
+
+/// Calibrated busy-wait used as the driver's yield/delay hook. Mirrors the
+/// reference `DummyDelay::delay_ns` (busy-loop of ~`ns / 100` nops). A named
+/// `fn` (not a closure) keeps [`build_target`]'s return type nameable.
+fn yield_delay(ns: u32) {
+    for _ in 0..(ns / 100) {
+        core::hint::spin_loop();
+    }
+}
+
+/// Build + validate the target controller in its own `#[inline(never)]` frame
+/// so the temporary `I3cConfig` (256-byte `AddrBook` inside) is freed on return
+/// rather than lingering alongside `ctrl` on the 2 KiB kernel bootstrap stack.
+#[inline(never)]
+fn build_target() -> Result<I3cController<Ast1060I3c<ast1060_pac::I3c2, fn(u32)>>, &'static str> {
     // Secondary (target) timing — identical to the reference target.
     let mut config = I3cConfig::new()
         .core_clk_hz(200_000_000)
@@ -66,12 +154,29 @@
         .map_err(|_| "invalid clock configuration")?;
 
     // SAFETY: the test owns I3C bus 2 and uses the matching PAC blocks.
-    let hw = unsafe { Ast1060I3c::<ast1060_pac::I3c2, _>::new(|_| core::hint::spin_loop()) };
-    let mut ctrl = I3cController::new(hw, config);
-    ctrl.init_hardware();
+    let hw = unsafe { Ast1060I3c::<ast1060_pac::I3c2, fn(u32)>::new(yield_delay) };
+    Ok(I3cController::new(hw, config))
+}
 
+fn run_target() -> Result<(), &'static str> {
+    pw_log::info!("####### I3C target test #######");
+
+    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
+        pinctrl_groups: &[pinctrl::PINCTRL_HVI3C2],
+    });
+    // SAFETY: single call at boot with exclusive access to the board.
+    unsafe { board.init() };
+
+    // Build the controller in a separate (never-inlined) frame so the temporary
+    // `I3cConfig` (256-byte `AddrBook` inside) is freed before `ctrl` is used —
+    // the kernel bootstrap thread stack is only 2 KiB and two live `I3cConfig`s
+    // overflow it. See `build_target`.
+    let mut ctrl = build_target()?;
     let bus = ctrl.hw.bus_num() as usize;
     let mut ibi_cons = i3c_ibi_workq_consumer(bus).ok_or("IBI consumer unavailable")?;
+    pw_log::info!("IBI work queue ready on bus {}", bus as u32);
+
+    ctrl.init_hardware();
 
     let dyn_addr = 8u8;
     let dev_idx = 0usize;
@@ -82,13 +187,25 @@
         dyn_addr as u32
     );
 
+    pw_log::info!("waiting before hot-join...");
+    spin_wait(HOT_JOIN_STARTUP_DELAY_SPINS);
     pw_log::info!("raising hot-join; waiting for dynamic address assignment...");
-    let _ = ctrl.hw.target_ibi_raise_hj(&mut ctrl.config);
+    let hj_ok = ctrl.hw.target_ibi_raise_hj(&mut ctrl.config).is_ok();
+    pw_log::info!("[DBG] hot-join raise ok={}", hj_ok as u32);
+    log_target_hj_state(0);
 
     // Wait for the controller to assign our dynamic address.
+    let mut spin_count = 0u32;
     loop {
         let Some(work) = ibi_cons.dequeue() else {
             core::hint::spin_loop();
+            spin_count = spin_count.wrapping_add(1);
+            if spin_count & (HOT_JOIN_RETRY_SPINS - 1) == 0 {
+                pw_log::info!("[DBG] retry hot-join");
+                let hj_ok = ctrl.hw.target_ibi_raise_hj(&mut ctrl.config).is_ok();
+                pw_log::info!("[DBG] hot-join retry ok={}", hj_ok as u32);
+                log_target_hj_state(1);
+            }
             continue;
         };
         match work {
@@ -104,6 +221,9 @@
             IbiWork::Sirq { addr, len, .. } => {
                 pw_log::info!("[IBI] SIRQ from 0x{:02x} len {}", addr as u32, len as u32);
             }
+            IbiWork::TargetMasterWrite { len, data } => {
+                log_target_master_write(len, &data);
+            }
         }
     }
 
@@ -121,6 +241,7 @@
         if ctrl.target_get_ibi_payload(&mut data).is_err() {
             return Err("target_get_ibi_payload failed");
         }
+        wait_for_master_write(&mut ibi_cons)?;
         ibi_count += 1;
     }
 
@@ -128,6 +249,12 @@
     Ok(())
 }
 
+pub fn i3c2_irq<K: Kernel>(_k: K) {
+    ast10x0_peripherals::i3c::dispatch_i3c_irq(2);
+}
+
+codegen::declare_kernel_interrupt_handlers!();
+
 impl TargetInterface for Target {
     const NAME: &'static str = "AST10x0 Kernel I3C IBI (target)";
 
diff --git a/target/ast10x0/tests/peripherals/i3c/i3c_irq/system.json5 b/target/ast10x0/tests/peripherals/i3c/i3c_irq/system.json5
index 3ecb32c..e9d3c1c 100644
--- a/target/ast10x0/tests/peripherals/i3c/i3c_irq/system.json5
+++ b/target/ast10x0/tests/peripherals/i3c/i3c_irq/system.json5
@@ -7,12 +7,18 @@
     arch: {
         type: "armv7m",
         vector_table_start_address: 0x00000000,
-        vector_table_size_bytes: 1280,  // 0x500 (320 vectors)
+        vector_table_size_bytes: 1536,  // 0x600: exception vectors + PW_KERNEL_INTERRUPT_TABLE_ARRAY up to IRQ 104
     },
     kernel: {
-        flash_start_address: 0x00000500,  // After vector table
+        flash_start_address: 0x00000600,  // After vector table
         flash_size_bytes: 262144,         // 256KB for kernel code (in RAM)
-        ram_start_address: 0x00040500,    // RAM starts after code
+        ram_start_address: 0x00040600,    // RAM starts after code
         ram_size_bytes: 393216,           // 384KB for data
+        interrupt_table: {
+            table: {
+                // I3C2 IRQ (bus 2, used by the HV IBI test).
+                "104": "crate::i3c2_irq",
+            },
+        },
     },
 }
diff --git a/target/ast10x0/tests/peripherals/i3c/i3c_irq/target.rs b/target/ast10x0/tests/peripherals/i3c/i3c_irq/target.rs
index dfa801d..c0d8c38 100644
--- a/target/ast10x0/tests/peripherals/i3c/i3c_irq/target.rs
+++ b/target/ast10x0/tests/peripherals/i3c/i3c_irq/target.rs
@@ -28,31 +28,96 @@
 
 use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor};
 use ast10x0_peripherals::i3c::{
-    i3c_ibi_workq_consumer, Ast1060I3c, HardwareCore, HardwareTransfer, I3cConfig, I3cController,
-    I3cMsg, IbiWork, I3C_MSG_READ, I3C_MSG_STOP, I3C_MSG_WRITE,
+    Ast1060I3c, HardwareCore, HardwareTransfer, I3C_MSG_READ, I3C_MSG_STOP, I3C_MSG_WRITE,
+    I3cConfig, I3cController, I3cMsg, IbiWork, i3c_ibi_workq_consumer,
 };
 use ast10x0_peripherals::scu::pinctrl;
 use codegen as _;
 use console_backend::console_backend_write_all;
 use entry as _;
-use target_common::{declare_target, TargetInterface};
+use kernel::Kernel;
+use target_common::{TargetInterface, declare_target};
 
 pub struct Target {}
 
+type I3c2Hw = Ast1060I3c<ast1060_pac::I3c2, fn(u32)>;
+type I3c2Controller = I3cController<I3c2Hw>;
+
 /// PID of the peer target (matches the `:slave` image / the reference).
 const KNOWN_PID: u64 = 0x07ec_a003_2000;
 /// Stop after this many master<->target exchanges.
 const MAX_EXCHANGES: u32 = 10;
+const XFER_DATA_LEN: usize = 16;
+const WAIT_LOG_SPINS: u32 = 0x0400_0000;
 
-fn run_controller() -> Result<(), &'static str> {
-    pw_log::info!("####### I3C master test #######");
+/// Calibrated busy-wait used as the driver's yield/delay hook. Mirrors the
+/// reference `DummyDelay::delay_ns` (busy-loop of ~`ns / 100` nops). A named
+/// `fn` (not a closure) keeps [`build_controller`]'s return type nameable.
+fn yield_delay(ns: u32) {
+    for _ in 0..(ns / 100) {
+        core::hint::spin_loop();
+    }
+}
 
-    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
-        pinctrl_groups: &[pinctrl::PINCTRL_HVI3C2],
-    });
-    // SAFETY: single call at boot with exclusive access to the board.
-    unsafe { board.init() };
+fn log_master_read_payload(len: u32, data: &[u8; XFER_DATA_LEN]) {
+    pw_log::info!(
+        "[MASTER <== TARGET] data len={} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        len as u32,
+        data[0] as u32,
+        data[1] as u32,
+        data[2] as u32,
+        data[3] as u32,
+        data[4] as u32,
+        data[5] as u32,
+        data[6] as u32,
+        data[7] as u32
+    );
+    pw_log::info!(
+        "[MASTER <== TARGET] data cont {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        data[8] as u32,
+        data[9] as u32,
+        data[10] as u32,
+        data[11] as u32,
+        data[12] as u32,
+        data[13] as u32,
+        data[14] as u32,
+        data[15] as u32
+    );
+}
 
+fn log_master_write_payload(data: &[u8; XFER_DATA_LEN]) {
+    pw_log::info!(
+        "[MASTER ==> TARGET] data {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        data[0] as u32,
+        data[1] as u32,
+        data[2] as u32,
+        data[3] as u32,
+        data[4] as u32,
+        data[5] as u32,
+        data[6] as u32,
+        data[7] as u32
+    );
+    pw_log::info!(
+        "[MASTER ==> TARGET] data cont {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}",
+        data[8] as u32,
+        data[9] as u32,
+        data[10] as u32,
+        data[11] as u32,
+        data[12] as u32,
+        data[13] as u32,
+        data[14] as u32,
+        data[15] as u32
+    );
+}
+
+/// Build + validate the controller in its own `#[inline(never)]` frame.
+///
+/// The temporary `I3cConfig` embeds a 256-byte `AddrBook`; keeping it live
+/// alongside `ctrl` (which owns a moved copy) would put two `I3cConfig`s on the
+/// 2 KiB kernel bootstrap stack and overflow it. Building here frees the
+/// temporary on return, leaving `run_controller` with only `ctrl`.
+#[inline(never)]
+fn build_controller() -> Result<I3c2Controller, &'static str> {
     // Controller (primary) timing — identical to the reference master.
     let mut config = I3cConfig::new()
         .core_clk_hz(200_000_000)
@@ -69,14 +134,78 @@
         .validate_clock()
         .map_err(|_| "invalid clock configuration")?;
 
-    // SAFETY: the test owns I3C bus 2 and uses the matching PAC blocks; the
-    // busy-spin closure is the bare-metal wait policy.
-    let hw = unsafe { Ast1060I3c::<ast1060_pac::I3c2, _>::new(|_| core::hint::spin_loop()) };
-    let mut ctrl = I3cController::new(hw, config);
-    ctrl.init_hardware();
+    // SAFETY: the test owns I3C bus 2 and uses the matching PAC blocks.
+    let hw = unsafe { Ast1060I3c::<ast1060_pac::I3c2, fn(u32)>::new(yield_delay) };
+    Ok(I3cController::new(hw, config))
+}
 
+#[inline(never)]
+fn master_read_from_target(
+    ctrl: &mut I3c2Controller,
+) -> Result<(u32, [u8; XFER_DATA_LEN]), &'static str> {
+    let mut rx_buf = [0u8; 128];
+    let actual_len = {
+        let mut rd_msgs = [I3cMsg {
+            buf: Some(&mut rx_buf[..]),
+            actual_len: 128,
+            num_xfer: 0,
+            flags: I3C_MSG_READ | I3C_MSG_STOP,
+            hdr_mode: 0,
+            hdr_cmd_mode: 0,
+        }];
+        ctrl.hw
+            .priv_xfer(&mut ctrl.config, KNOWN_PID, &mut rd_msgs)
+            .map_err(|_| "private read failed")?;
+        rd_msgs[0].actual_len
+    };
+    let mut data = [0u8; XFER_DATA_LEN];
+    let take = (actual_len as usize).min(data.len()).min(rx_buf.len());
+    data[..take].copy_from_slice(&rx_buf[..take]);
+    Ok((actual_len, data))
+}
+
+#[inline(never)]
+fn master_write_to_target(ctrl: &mut I3c2Controller) -> Result<(), &'static str> {
+    let mut tx_buf: [u8; XFER_DATA_LEN] = [
+        0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
+        0x88,
+    ];
+    let mut wr_msgs = [I3cMsg {
+        buf: Some(&mut tx_buf[..]),
+        actual_len: 16,
+        num_xfer: 0,
+        flags: I3C_MSG_WRITE | I3C_MSG_STOP,
+        hdr_mode: 0,
+        hdr_cmd_mode: 0,
+    }];
+    ctrl.hw
+        .priv_xfer(&mut ctrl.config, KNOWN_PID, &mut wr_msgs)
+        .map_err(|_| "private write failed")?;
+    log_master_write_payload(&tx_buf);
+    Ok(())
+}
+
+fn run_controller() -> Result<(), &'static str> {
+    pw_log::info!("####### I3C master test #######");
+
+    let board = Ast10x0Board::new(Ast10x0BoardDescriptor {
+        pinctrl_groups: &[pinctrl::PINCTRL_HVI3C2],
+    });
+    // SAFETY: single call at boot with exclusive access to the board.
+    unsafe { board.init() };
+
+    // Build the controller in a separate (never-inlined) frame so the temporary
+    // `I3cConfig` is freed before the long-lived `ctrl` is used (see
+    // `build_controller`): the kernel bootstrap thread stack is only 2 KiB and
+    // two live `I3cConfig`s (each embeds a 256-byte `AddrBook`) overflow it.
+    let mut ctrl = build_controller()?;
     let bus = ctrl.hw.bus_num() as usize;
     let mut ibi_cons = i3c_ibi_workq_consumer(bus).ok_or("IBI consumer unavailable")?;
+    pw_log::info!("IBI work queue ready on bus {}", bus as u32);
+
+    pw_log::info!("initializing I3C2 controller");
+    ctrl.init_hardware();
+    pw_log::info!("I3C2 controller ready");
 
     let dyn_addr = ctrl
         .config
@@ -92,9 +221,47 @@
     pw_log::info!("pre-attached dev at slot 0, dyn addr {}", dyn_addr as u32);
 
     let mut received = 0u32;
+    let mut spin_count = 0u32;
     loop {
         let Some(work) = ibi_cons.dequeue() else {
             core::hint::spin_loop();
+            spin_count = spin_count.wrapping_add(1);
+            if spin_count & (WAIT_LOG_SPINS - 1) == 0 {
+                let irq_count = I3C2_IRQ_COUNT.load(core::sync::atomic::Ordering::Relaxed);
+                let status = I3C2_LAST_STATUS.load(core::sync::atomic::Ordering::Relaxed);
+                let queue_status =
+                    I3C2_LAST_QUEUE_STATUS.load(core::sync::atomic::Ordering::Relaxed);
+                let status_en = I3C2_LAST_STATUS_EN.load(core::sync::atomic::Ordering::Relaxed);
+                let signal_en = I3C2_LAST_SIGNAL_EN.load(core::sync::atomic::Ordering::Relaxed);
+                let ibi_count = (queue_status >> 24) & 0x1f;
+                let ibi_buf_blr = (queue_status >> 16) & 0xff;
+                let resp_blr = (queue_status >> 8) & 0xff;
+                pw_log::info!(
+                    "[DBG] waiting irq_count={} spin={}",
+                    irq_count as u32,
+                    spin_count as u32
+                );
+                pw_log::info!(
+                    "[DBG] i3c2 status={} queue={}",
+                    status as u32,
+                    queue_status as u32
+                );
+                pw_log::info!(
+                    "[DBG] i3c2 ibi_count={} ibi_buf_blr={}",
+                    ibi_count as u32,
+                    ibi_buf_blr as u32
+                );
+                pw_log::info!(
+                    "[DBG] i3c2 resp_blr={} status_en={}",
+                    resp_blr as u32,
+                    status_en as u32
+                );
+                pw_log::info!(
+                    "[DBG] i3c2 signal_en={} reserved={}",
+                    signal_en as u32,
+                    0 as u32
+                );
+            }
             continue;
         };
         match work {
@@ -109,49 +276,59 @@
                     pw_log::error!("acknowledge_ibi failed");
                 }
 
-                // Private read: MASTER <== TARGET
-                let mut rx_buf = [0u8; 128];
-                let mut rd_msgs = [I3cMsg {
-                    buf: Some(&mut rx_buf[..]),
-                    actual_len: 128,
-                    num_xfer: 0,
-                    flags: I3C_MSG_READ | I3C_MSG_STOP,
-                    hdr_mode: 0,
-                    hdr_cmd_mode: 0,
-                }];
-                let _ = ctrl.hw.priv_xfer(&mut ctrl.config, KNOWN_PID, &mut rd_msgs);
-                pw_log::info!(
-                    "[MASTER <== TARGET] read {} bytes",
-                    rd_msgs[0].actual_len as u32
-                );
+                let (read_len, read_data) = master_read_from_target(&mut ctrl)?;
+                pw_log::info!("[MASTER <== TARGET] read {} bytes", read_len as u32);
+                log_master_read_payload(read_len, &read_data);
 
                 received += 1;
-                if received > MAX_EXCHANGES {
+                master_write_to_target(&mut ctrl)?;
+                pw_log::info!("[MASTER ==> TARGET] wrote 16 bytes");
+
+                if received >= MAX_EXCHANGES {
                     pw_log::info!("I3C master test done");
                     return Ok(());
                 }
-
-                // Private write: MASTER ==> TARGET
-                let mut tx_buf: [u8; 16] = [
-                    0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55,
-                    0x66, 0x77, 0x88,
-                ];
-                let mut wr_msgs = [I3cMsg {
-                    buf: Some(&mut tx_buf[..]),
-                    actual_len: 16,
-                    num_xfer: 0,
-                    flags: I3C_MSG_WRITE | I3C_MSG_STOP,
-                    hdr_mode: 0,
-                    hdr_cmd_mode: 0,
-                }];
-                let _ = ctrl.hw.priv_xfer(&mut ctrl.config, KNOWN_PID, &mut wr_msgs);
-                pw_log::info!("[MASTER ==> TARGET] wrote 16 bytes");
             }
             IbiWork::TargetDaAssignment => pw_log::info!("[IBI] TargetDaAssignment"),
+            IbiWork::TargetMasterWrite { len, .. } => {
+                pw_log::info!("[IBI] TargetMasterWrite len {}", len as u32);
+            }
         }
     }
 }
 
+static I3C2_IRQ_COUNT: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
+static I3C2_LAST_STATUS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
+static I3C2_LAST_QUEUE_STATUS: core::sync::atomic::AtomicU32 =
+    core::sync::atomic::AtomicU32::new(0);
+static I3C2_LAST_STATUS_EN: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
+static I3C2_LAST_SIGNAL_EN: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
+
+pub fn i3c2_irq<K: Kernel>(_k: K) {
+    // Do not read i3cd018 here: that register pops the IBI queue entry.
+    let regs = unsafe { &*ast1060_pac::I3c2::ptr() };
+    I3C2_LAST_STATUS.store(
+        regs.i3cd03c().read().bits(),
+        core::sync::atomic::Ordering::Relaxed,
+    );
+    I3C2_LAST_QUEUE_STATUS.store(
+        regs.i3cd04c().read().bits(),
+        core::sync::atomic::Ordering::Relaxed,
+    );
+    I3C2_LAST_STATUS_EN.store(
+        regs.i3cd040().read().bits(),
+        core::sync::atomic::Ordering::Relaxed,
+    );
+    I3C2_LAST_SIGNAL_EN.store(
+        regs.i3cd044().read().bits(),
+        core::sync::atomic::Ordering::Relaxed,
+    );
+    I3C2_IRQ_COUNT.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
+    ast10x0_peripherals::i3c::dispatch_i3c_irq(2);
+}
+
+codegen::declare_kernel_interrupt_handlers!();
+
 impl TargetInterface for Target {
     const NAME: &'static str = "AST10x0 Kernel I3C IBI (controller)";