Tour of pw_kernel#
pw_kernel: A Rust-based kernel for embedded systems
This tour provides a conceptual overview of the software architecture of
embedded systems built on top of pw_kernel. It walks through the
adventure example to keep the discussion
focused and concrete.
Setup#
See Quickstart if you’d like to run the adventure example yourself. This tour starts exactly where the quickstart left off.
Overview#
The adventure example is a multi-process system composed of three userspace applications:
Supervisor: watches other processes and restarts them when they fail.
UART driver: userspace UART driver implementing the server-side of the streaming protocol and handles UART interrupts in userspace.
Game: runs game logic and communicates with the UART driver for input/output.
System generation and startup#
When creating a system on top of pw_kernel you define the entire system
declaratively in a file like system.json5.
This includes not only the physical layout:
kernel: {
flash_start_address: 0x80000000,
flash_size_bytes: 261120,
ram_start_address: 0x81000000,
ram_size_bytes: 65536,
},
But also logical definitions, such as how processes are allowed to communicate with each other over IPC:
objects: [
{
name: "uart_driver_process",
type: "process",
linked_process: "uart_driver_16550"
},
{
name: "adventure_game_process",
type: "process",
linked_process: "adventure_game"
},
{
name: "main_loop_wait_group",
type: "wait_group"
},
{
name: "main_thread",
type: "thread",
kernel_stack_size_bytes: 2048,
},
],
The end-to-end system is built, linked, and optimized into a single image with a Bazel
macro such as system_image.
system_image(
name = "adventure",
apps = [
"//apps/supervisor:supervisor",
"//apps/uart_driver_mock:uart_driver_mock",
"//apps/adventure_game:adventure_game",
],
kernel = ":target",
platform = "//targets/riscv_qemu:qemu_virt_riscv32",
system_config = ":system_config",
tags = ["kernel"],
)
You start up the system by invoking a codegen’d start() function. See
target.rs
for example. At this point, pw_kernel constructs and starts the system as
defined in system.json5: allocating kernel-side representations of processes,
setting up IPC channels, defining memory access permissions, etc.
Supervisor#
The supervisor process acts as the system’s orchestrator, running in its own
unprivileged process but with special permissions to monitor and control the
lifecycles of the UART driver and game processes. The supervisor uses wait groups
to wait on signals from the other processes.
/// Encapsulates the supervisor setup and event loop execution,
/// returning Ok(()) on successful game exit or a pw_status::Error on failures.
fn run_supervisor() -> Result<()> {
// Add both child processes to the WaitGroup, passing their handles as user_data
syscall::wait_group_add(
handle::MAIN_LOOP_WAIT_GROUP,
handle::UART_DRIVER_PROCESS,
Signals::JOINABLE,
handle::UART_DRIVER_PROCESS as usize,
)?;
syscall::wait_group_add(
handle::MAIN_LOOP_WAIT_GROUP,
handle::ADVENTURE_GAME_PROCESS,
Signals::JOINABLE,
handle::ADVENTURE_GAME_PROCESS as usize,
)?;
loop {
// Wait for a process to become joinable, retrying on timeout. This
// timeout could be used to pet a watchdog or the like.
let deadline = SystemClock::now() + SUPERVISOR_TIMEOUT;
let wait_result =
match syscall::object_wait(handle::MAIN_LOOP_WAIT_GROUP, Signals::READABLE, deadline) {
Err(Error::DeadlineExceeded) => continue,
res => res?,
};
let triggered_handle = wait_result.user_data as u32;
// Join the process and get it's `ExitStatus`.
let status = syscall::task_join(triggered_handle)?;
log_exit_status(triggered_handle, status);
// If the game process completed successfully, trigger clean system shutdown.
if triggered_handle == handle::ADVENTURE_GAME_PROCESS
&& matches!(status, ExitStatus::Success(_))
{
pw_log::info!("Supervisor shutting down system due to successful game exit.");
return Ok(());
}
// Always restart any exited process
pw_log::warn!(
"Process '{}' exited, restarting...",
process_name(triggered_handle) as &str
);
syscall::process_start(triggered_handle).inspect_err(|err| {
pw_log::error!(
"Failed to restart process '{}': {}",
process_name(triggered_handle) as &str,
*err as u32
);
})?;
}
}
Streaming IPC#
The game process communicates with the UART driver process via an IPC channel.
In pw_kernel IPC channels are synchronous and asymmetric. Only one side
of the channel (the initiator) can start transactions. The other side
(the handler) listens and acts upon read/write requests from the initiator.
In the adventure example the game process functions as the client using the
initiator side of the IPC channel, and the UART driver is the server using
the handler side of the channel. When the driver receives new data from the
UART hardware, it sets the USER signal to inform the game process that new
data is ready and therefore it should start a new IPC transaction.
/// Toggles the peer user signal (Signals::USER) on the client.
fn set_data_available(&self, available: bool) -> Result<()> {
syscall::object_set_peer_user_signal(self.ipc_handle, available)?;
Ok(())
}