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_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.

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_waitchannel_readchannel_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_std userspace.
  • 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_wait on each in turn.
  • The handler must call channel_respond exactly once per channel_read before 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.