This guide explains how to wire up cross-process communication between two pw_kernel userspace processes using channel objects.
A worked example lives at target/veer/ipc/ (system image) and upstream at @pigweed//pw_kernel/tests/ipc/user/ (the two processes themselves) — refer to those files alongside this document.
A channel in pw_kernel is a kernel object with two endpoints owned by distinct processes:
channel_transact call that sends a request and blocks until the handler responds.The kernel handles the rendezvous, copies the request and response between process address spaces, and wakes the appropriate thread on each side.
system.json5Channel endpoints are declared as objects inside the processes list of the system.json5 file driving the system image. Each side names its local object; the initiator names the handler process and its handler-side object name to wire the two together.
{ apps: [ { name: "ipc", flash_size_bytes: 32768, ram_size_bytes: 8192, processes: [ { name: "initiator", objects: [ { name: "IPC", type: "channel_initiator", handler_process: "handler", handler_object_name: "IPC", }, ], threads: [ { name: "initiator thread", stack_size_bytes: 1024 }, ], }, { name: "handler", objects: [ { name: "IPC", type: "channel_handler" }, ], threads: [ { name: "handler thread", stack_size_bytes: 1024 }, ], }, ], }, ], }
The system_generator codegen step turns the name field into a handle::IPC constant inside each process's generated *_codegen crate. The two name strings do not need to match across processes; only the handler_object_name linkage on the initiator does.
The initiator performs a synchronous channel_transact: it provides a send buffer, a receive buffer, and a deadline, and the syscall returns the number of bytes the handler wrote.
use initiator_codegen::handle; use pw_status::Result; use userspace::time::Instant; use userspace::{process_entry, syscall}; fn send_one(c: char) -> Result<()> { let mut send_buf = [0u8; size_of::<char>()]; let mut recv_buf = [0u8; size_of::<char>() * 2]; c.encode_utf8(&mut send_buf); let _len: usize = syscall::channel_transact( handle::IPC, &send_buf, &mut recv_buf, Instant::MAX, )?; Ok(()) } #[process_entry("initiator")] fn entry() -> ! { let _ = send_one('a'); loop {} }
Instant::MAX waits indefinitely. Pass a finite Instant to bound the transaction — the kernel returns a deadline-exceeded error rather than blocking forever.
The handler waits for the channel to become readable, reads the request, and writes a response. The pattern is object_wait → channel_read → channel_respond, looped.
use handler_codegen::handle; use pw_status::{Error, Result}; use userspace::process_entry; use userspace::syscall::{self, Signals}; use userspace::time::Instant; fn handle_messages() -> Result<()> { loop { let wait = syscall::object_wait(handle::IPC, Signals::READABLE, Instant::MAX) .map_err(|_| Error::Internal)?; if !wait.pending_signals.contains(Signals::READABLE) { return Err(Error::Internal); } let mut request = [0u8; size_of::<char>()]; let _len = syscall::channel_read(handle::IPC, 0, &mut request)?; let c = char::from_u32(u32::from_ne_bytes(request)) .ok_or(Error::InvalidArgument)?; let upper = c.to_ascii_uppercase(); let mut response = [0u8; size_of::<char>() * 2]; upper.encode_utf8(&mut response[0..size_of::<char>()]); c.encode_utf8(&mut response[size_of::<char>()..]); syscall::channel_respond(handle::IPC, &response)?; } } #[process_entry("handler")] fn entry() -> ! { if let Err(e) = handle_messages() { let _ = syscall::debug_shutdown(Err(e)); } loop {} }
channel_respond must be called exactly once per request; the next object_wait then unblocks for the following request.
On a target like veer, the system_image macro consumes both the system.json5 and the upstream multi_process_app target that bundles the two process binaries. See target/veer/ipc/user/BUILD.bazel:
system_image( name = "ipc", apps = ["@pigweed//pw_kernel/tests/ipc/user:ipc"], kernel = ":target", platform = "//target/veer", system_config = ":system_config", tags = ["kernel"], ) target_codegen( name = "codegen", arch = "@pigweed//pw_kernel/arch/riscv:arch_riscv", system_config = ":system_config", )
Each process's own BUILD.bazel (upstream, in @pigweed//pw_kernel/tests/ipc/user/BUILD.bazel) uses the rust_process macro and names its codegen crate via codegen_crate_name, which is what allows use initiator_codegen::handle and use handler_codegen::handle to resolve from within the source files.
The kernel does not impose a wire format on the request or response — both are byte buffers. A few rules of thumb that follow from that:
no_std userspace.unwrap / panic paths. The handler is the service for every other process holding an initiator endpoint, so a panic-on-malformed-input becomes a denial-of-service.zerocopy) — there is no built-in IDL.object_wait on each in turn.channel_respond exactly once per channel_read before reading again. Skipping the response leaves the initiator blocked.Instant; the kernel does not impose a default timeout.