blob: 2e276ce05ac8b46a92a62f241f9701ebb0be4c49 [file] [view]
# 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.
```json5
{
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.
```rust
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.
```rust
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`:
```python
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.