pw_kernel IPC: Channel Objects
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.
Conceptual model
A channel in pw_kernel is a kernel object with two endpoints owned by
distinct processes:
- A handler endpoint — the server side. It waits for an incoming message, reads the request, and sends a single response.
- An initiator endpoint — the client side. It performs a single
channel_transactcall 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.
Declaring the channel in system.json5
Channel 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.
Initiator side
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.
#![allow(unused)] fn main() { 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.
Handler side
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.
#![allow(unused)] fn main() { 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.
Build wiring on the openprot side
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.
Designing your own channel protocol
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:
- Pick a fixed-size request/response layout (or a length-prefixed one) so
the handler can size its read buffer up front. Keep the buffers on the
stack — they live inside
no_stduserspace. - Validate everything coming off the wire: length, range, enum variants. Treat the request side as untrusted — it is in a different process and may be running independently maintained code.
- Keep the handler loop free of
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. - For typed payloads, define a small request/response enum in a shared
crate that both processes depend on, and use a byte-stable encoding
(e.g.
zerocopy) — there is no built-in IDL.
Limitations to know about
- A channel has exactly one initiator and one handler. To fan in multiple
clients, declare multiple channel pairs all pointing at the same handler
process and have the handler
object_waiton each in turn. - The handler must call
channel_respondexactly once perchannel_readbefore reading again. Skipping the response leaves the initiator blocked. - Bounded-deadline transactions require the caller to pass a finite
Instant; the kernel does not impose a default timeout.