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(())
}