Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Custom Board Package

A board crate is the top-level, batteries-included package that application code depends on. It provides three things:

  1. Config struct – network settings (IP, MAC, gateway), transport selection (Ethernet, serial, or WiFi via Cargo features), and a zenoh locator string.
  2. run() entry point – initializes hardware, starts the network stack, and calls a user-provided closure with &Config.
  3. Hardware initialization – PHY/MAC driver setup, interrupt configuration, clock init, and force-linking of platform shim crates.

Users never interact with platform crates or driver crates directly. They depend on a single board crate, call run(), and get a working environment for creating an Executor.

use nros_my_board::{Config, run};
use nros::prelude::*;

run(Config::default(), |config| {
    let exec_config = ExecutorConfig::new(config.zenoh_locator)
        .domain_id(config.domain_id);
    let mut executor = Executor::open(&exec_config)?;
    let mut node = executor.create_node("my_node")?;
    // publishers, subscriptions, services, actions, timers...
    Ok(())
})

Board = platform + hardware

A board crate combines two layers:

  • Platform crate (nros-platform-<os>) – generic RTOS or bare-metal primitives: clock, sleep, threading, memory, random number generation. Implements the traits defined in nros-platform. See Custom Platform for how to write one.
  • Hardware drivers – board-specific peripherals: Ethernet controller, WiFi radio, serial UART. Driver crates in packages/drivers/ (e.g., lan9118-smoltcp, openeth-smoltcp) implement the smoltcp Device trait for a specific Ethernet MAC/PHY.

The board crate glues these together: it depends on the platform crate for OS primitives, on driver crates for peripheral access, and on transport crates (nros-smoltcp, zpico-serial) for bridging the network stack to zenoh-pico. The result is a single dependency that gives application code everything it needs.

Application
    |
    v
Board crate (nros-my-board)
    |
    +-- Platform crate (nros-platform-<name>)
    +-- Driver crate (lan9118-smoltcp)
    +-- Transport bridge (nros-smoltcp or zpico-serial)
    +-- zpico-sys (its default-on alias TU supplies zenoh-pico z_* symbols)

Crate structure

A minimal board crate looks like this:

nros-my-board/
  Cargo.toml
  .gitignore        # /target/
  src/
    lib.rs          # extern crate force-links, pub use Config, pub fn run()
    config.rs       # Config struct with network fields
    node.rs         # Hardware init sequence + run() implementation

Cargo.toml

[package]
name = "nros-my-board"
version = "0.1.0"
edition = "2024"

[lib]
name = "nros_my_board"

[dependencies]
# Platform primitives (pick one)
nros-platform = { path = "../../core/nros-platform", features = ["platform-freertos"] }
nros-platform-freertos = { path = "../../core/nros-platform-freertos" }

# zenoh-pico FFI. Its default-on `platform-aliases` C TU emits every
# `z_*` / `_z_*` symbol zenoh-pico needs, forwarding to `nros_platform_*`.
# (Phase 129 retired the separate `zpico-platform-shim` crate.)
zpico-sys = { path = "../../zpico/zpico-sys", default-features = false }

# Ethernet transport (optional, gated by feature)
nros-smoltcp = { path = "../../drivers/nros-smoltcp", optional = true }
lan9118-smoltcp = { path = "../../drivers/lan9118-smoltcp", optional = true }
smoltcp = { version = "0.12", default-features = false, optional = true, features = [
    "medium-ethernet", "proto-ipv4", "socket-tcp", "socket-udp",
] }

# Serial transport (optional, gated by feature)
zpico-serial = { path = "../../zpico/zpico-serial", optional = true }
my-uart-driver = { path = "../../drivers/my-uart", optional = true }

# Board-specific dependencies
cortex-m = "0.7"
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
panic-semihosting = "0.6"

[features]
default = ["ethernet"]
ethernet = ["dep:nros-smoltcp", "dep:lan9118-smoltcp", "dep:smoltcp"]
serial = ["dep:zpico-serial", "dep:my-uart-driver"]

lib.rs

The lib module force-links shim crates and re-exports the public API.

#![no_std]

// Force-link: ensures `zpico-sys` (and its default-on `platform-aliases`
// C alias TU, which emits zenoh-pico's `z_*` FFI symbols) is pulled into
// the final binary even though Rust code never calls it directly. Without
// this, the linker drops the rlib and zenoh-pico fails to resolve. Phase
// 129 retired the separate `zpico-platform-shim` keep-alive.
extern crate zpico_sys;

mod config;
mod node;

pub use config::Config;
pub use node::{init_hardware, run};

// Re-export entry macro so examples can use #[entry]
pub use cortex_m_rt::entry;

// Convenience println! that routes to semihosting (QEMU) or UART
pub use cortex_m_semihosting;
#[macro_export]
macro_rules! println {
    ($($arg:tt)*) => { $crate::cortex_m_semihosting::hprintln!($($arg)*) };
}

pub fn exit_success() -> ! {
    cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_SUCCESS);
    loop {}
}

pub fn exit_failure() -> ! {
    cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_FAILURE);
    loop {}
}

config.rs

The Config struct holds all network and transport settings. Fields are gated by Cargo features so only relevant fields exist for a given build.

#[derive(Clone)]
pub struct Config {
    #[cfg(feature = "ethernet")]
    pub mac: [u8; 6],
    #[cfg(feature = "ethernet")]
    pub ip: [u8; 4],
    #[cfg(feature = "ethernet")]
    pub prefix: u8,
    #[cfg(feature = "ethernet")]
    pub gateway: [u8; 4],

    #[cfg(feature = "serial")]
    pub uart_base: usize,
    #[cfg(feature = "serial")]
    pub baudrate: u32,

    pub zenoh_locator: &'static str,
    pub domain_id: u32,
}

#[cfg(feature = "ethernet")]
impl Default for Config {
    fn default() -> Self {
        Self {
            mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x00],
            ip: [192, 0, 3, 10],
            prefix: 24,
            gateway: [192, 0, 3, 1],
            #[cfg(feature = "serial")]
            uart_base: 0x4000_4000,
            #[cfg(feature = "serial")]
            baudrate: 115200,
            zenoh_locator: "tcp/192.0.3.1:7447",
            domain_id: 0,
        }
    }
}

Provide builder methods (with_ip, with_mac, with_gateway, etc.) and factory presets (listener(), talker()) for common test topologies. See nros-board-mps2-an385/src/config.rs for a complete example including from_toml() parsing.

node.rs

The run() function is the entry point. For bare-metal targets it calls the user closure directly. For RTOS targets it creates a task, starts the scheduler, and runs the closure inside that task.

Bare-metal pattern:

pub fn run<F, E: core::fmt::Debug>(config: Config, f: F) -> !
where
    F: FnOnce(&Config) -> Result<(), E>,
{
    init_hardware(&config);
    match f(&config) {
        Ok(()) => exit_success(),
        Err(e) => {
            hprintln!("Error: {:?}", e);
            exit_failure()
        }
    }
}

RTOS pattern (FreeRTOS example):

pub fn run<F, E: core::fmt::Debug>(config: Config, f: F) -> !
where
    F: FnOnce(&Config) -> Result<(), E>,
{
    init_hardware(&config);
    // Create FreeRTOS task with the user closure
    create_app_task(f, config);
    // Start the scheduler -- never returns
    start_scheduler()
}

The init_hardware() function must follow this order:

  1. Clock – initialize the hardware timer (must happen before any clock reads)
  2. Cycle counter – enable DWT or equivalent for timing measurements
  3. RNG seed – seed PRNG with entropy (IP-based hash, semihosting time, etc.)
  4. Transport – Ethernet driver + smoltcp, or UART + zpico-serial
  5. Application – call the user closure

Ethernet peripherals and smoltcp state must live in static mut storage (they are referenced by FFI poll callbacks):

static mut ETH_DEVICE: MaybeUninit<Lan9118> = MaybeUninit::uninit();
static mut NET_IFACE: MaybeUninit<Interface> = MaybeUninit::uninit();
static mut NET_SOCKETS: MaybeUninit<SocketSet<'static>> = MaybeUninit::uninit();

Transport features

Board crates use Cargo features to select the communication transport:

  • ethernet (typically the default) – TCP/UDP via smoltcp or lwIP. Requires an Ethernet driver crate and nros-smoltcp.
  • serial – UART link via zpico-serial. Requires a UART driver crate.
  • wifi – WiFi via the platform’s native stack (ESP32). The zenoh-pico layer uses OS sockets directly.

At least one transport must be enabled. Enforce this at compile time:

#[cfg(not(any(feature = "ethernet", feature = "serial")))]
compile_error!("Enable at least one transport: `ethernet` or `serial`");

Config fields are #[cfg(feature = "...")]-gated per transport, so the struct only contains fields relevant to the enabled transport. Both ethernet and serial can be enabled simultaneously – the zenoh locator string determines which transport is used at runtime (tcp/... for Ethernet, serial/... for UART).

Reference implementations

Start from the crate closest to your target:

Board crateTransportPlatformLocation
nros-board-mps2-an385Ethernet + SerialBare-metalpackages/boards/nros-board-mps2-an385/
nros-board-mps2-an385-freertosEthernet (lwIP)FreeRTOSpackages/boards/nros-board-mps2-an385-freertos/
nros-board-esp32-qemuEthernet (OpenETH)Bare-metal (esp-hal)packages/boards/nros-board-esp32-qemu/
nros-board-stm32f4Ethernet + SerialBare-metalpackages/boards/nros-board-stm32f4/
nros-board-nuttx-qemu-armBSD socketsNuttXpackages/boards/nros-board-nuttx-qemu-arm/
nros-board-threadx-qemu-riscv64NetX DuoThreadXpackages/boards/nros-board-threadx-qemu-riscv64/

The bare-metal nros-board-mps2-an385 is the simplest starting point. The FreeRTOS variant shows how to add RTOS task creation and lwIP networking.

See also

  • Custom Platform – writing the platform crate that sits underneath a board crate
  • Porting Overview – the three customization axes (RMW, platform, board)
  • Creating Examples – detailed example layout patterns across native, BSP, and Zephyr targets