Rust on Pi4
for embedded and systems work
This guide starts from fundamentals and moves into your real stack: GPIO, I2C, MQTT, and service deployment. It is designed for someone excited to build, not just read syntax examples.
Every lesson includes why-first explanation and then concrete code so you can internalize Rust and ship working systems.
Create Workspace, Files, and First Test Run
Objective: build a clean Rust workspace from nothing and prove you can run and test code end-to-end.
Learning Focus: This lesson removes all setup assumptions. You will create directories, create files, run code, and run tests using a repeatable workflow you can reuse for every later lesson.
Before learning ownership or async, you need operational confidence in the toolchain loop itself. That loop is simple and strict: create project, edit files, run locally, test behavior, then iterate. Once this loop is stable, the rest of Rust learning becomes a sequence of controlled experiments instead of random trial and error.
Create your working folders
Use a dedicated top-level directory so all course projects stay organized and easy to back up.
mkdir -p $HOME/codecore/rust-course cd $HOME/codecore/rust-course pwd
Create your first Rust project
This command creates the project folder plus starter files so you are not manually scaffolding everything.
source $HOME/.cargo/env cargo new --bin rust-from-zero cd rust-from-zero ls -la
Understand the file layout
These are the minimum files you should always recognize in a Rust binary project.
rust-from-zero/
Cargo.toml
src/
main.rsEdit code and run
Replace your program with a tiny function you can test. This is your first full create-edit-run loop.
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let result = add(2, 3);
println!("sum={}", result);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}Run and test
Always run this pair together while learning: execute behavior, then validate expectations with tests.
cargo run cargo test
Checkpoint: you can create a Rust project from scratch, edit src/main.rs, run it, and pass at least one test.
Rust Basics Without Handwaving
Objective: understand variables, mutability, functions, and control flow in Rust syntax.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This block gives a compact baseline that mirrors patterns you will reuse in service code.
The reason this first block matters is that Rust syntax encodes intent explicitly: immutable by default values communicate stability, and mutable bindings communicate controlled change. That clarity is foundational when you later model hardware state transitions and messaging loops.
fn main() {
let host = "pi-dns-core";
let mut sample_count = 0;
while sample_count < 3 {
println!("host={} sample={}", host, sample_count);
sample_count += 1;
}
let status = if sample_count == 3 { "ok" } else { "pending" };
println!("status={}", status);
}Checkpoint: you can explain why host is immutable and sample_count is mutable.
Ownership, Borrowing, and Lifetimes in Practice
Objective: remove the biggest mental blockers by seeing ownership with real examples.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Interpret then run
The first function takes ownership, the second borrows. This distinction controls memory safety without garbage collection.
Ownership rules look strict at first, but they remove whole classes of runtime bugs that are painful in long-running edge services. This lesson trains you to reason about value lifetime at compile time instead of debugging use-after-free or race-driven corruption in production.
fn takes_ownership(s: String) {
println!("owned={}", s);
}
fn borrows(s: &str) {
println!("borrowed={}", s);
}
fn main() {
let payload = String::from("mqtt/pi4/health/json");
borrows(&payload);
takes_ownership(payload);
// payload is moved here and cannot be used again
}Checkpoint: you can predict where moved values become invalid.
Cargo Projects, Crates, and Build Profiles
Objective: create and manage Rust projects the same way you will for embedded services.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Lesson 0 taught the basic loop. This lesson extends that loop into a multi-file project shape that resembles real services: source code separated from test code, deterministic dependencies in Cargo.toml, and repeatable commands for debug versus release lifecycle.
Run
This gives repeatable project setup and clarifies debug versus release behavior.
Reproducible project structure is part of engineering quality. Cargo does dependency resolution, build graph management, and profile control in one workflow, so your Pi builds and CI builds stay aligned instead of diverging by environment.
source $HOME/.cargo/env cargo new --bin rust-lab cd rust-lab cargo run cargo build --release
Create a reusable module file
Create a second source file so your project grows beyond one giant main file.
pub fn health_label(cpu_pct: f32) -> &'static str {
if cpu_pct > 85.0 {
"hot"
} else {
"ok"
}
}Now wire the module into your program so file boundaries and imports are explicit.
mod health;
fn main() {
let cpu_pct = 42.5;
println!("cpu={} status={}", cpu_pct, health::health_label(cpu_pct));
}Create a dedicated test file
Use the tests directory so behavior checks stay visible as the codebase grows.
#[path = "../src/health.rs"]
mod health;
#[test]
fn health_label_hot_when_cpu_high() {
assert_eq!(health::health_label(90.0), "hot");
}
#[test]
fn health_label_ok_when_cpu_normal() {
assert_eq!(health::health_label(42.5), "ok");
}Run development checks
Run all three so syntax, behavior, and release compilation stay healthy at each step.
cargo fmt cargo test cargo build --release
Checkpoint: you understand why release binaries are used for long-running services.
Error Handling with Result and Anyhow
Objective: handle real failures cleanly instead of panicking in production.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This style is what you should use in hardware and networked components that can fail at runtime.
Error context is not optional in distributed edge systems. When a sensor read, broker connect, or file load fails, contextual errors give you immediate fault locality and reduce mean time to recovery during incidents.
use anyhow::{Context, Result};
use std::fs;
fn read_config(path: &str) -> Result {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read config: {}", path))?;
Ok(content)
}
fn main() -> Result<()> {
let cfg = read_config("./app.conf")?;
println!("{}", cfg);
Ok(())
} Checkpoint: errors include actionable context instead of vague panic traces.
Model Device State with Structs and Enums
Objective: represent telemetry and control states in typed models.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
Typed state models reduce invalid transitions and payload bugs before runtime.
The practical benefit is state integrity. Enums make invalid states explicit and enforce handling paths, which is critical when hardware outputs and safety behavior must remain deterministic under all control outcomes.
#[derive(Debug)]
enum RelayState {
Off,
On,
Fault(String),
}
#[derive(Debug)]
struct HealthSample {
cpu_pct: f32,
mem_pct: f32,
relay: RelayState,
}
fn main() {
let sample = HealthSample {
cpu_pct: 19.3,
mem_pct: 42.1,
relay: RelayState::Off,
};
println!("{:?}", sample);
}Checkpoint: you can represent invalid or fault states explicitly in type-safe form.
Traits and Generics for Reusable Hardware Interfaces
Objective: design reusable abstractions so control logic is not hardcoded to one sensor.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This pattern lets you swap real hardware and mocks for testing without rewriting business logic.
Traits are your contract layer between policy and device implementation. By coding against behavior instead of concrete types, you can test control logic on a laptop and deploy the same logic on Pi-backed hardware interfaces.
trait TelemetrySource {
fn read_value(&self) -> f32;
}
struct CpuSource;
impl TelemetrySource for CpuSource {
fn read_value(&self) -> f32 { 17.0 }
}
fn emit(source: T) {
println!("value={}", source.read_value());
}
fn main() {
emit(CpuSource);
} Checkpoint: you understand how traits help testability and long-term maintainability.
Async Runtime with Tokio
Objective: run concurrent tasks like telemetry loops and message consumers without blocking.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This pattern schedules periodic publishers and background tasks cleanly.
Async is about structured concurrency, not speed hype. Tokio lets you run multiple I/O-bound responsibilities in one service without blocking critical loops, which is essential for telemetry, command handling, and watchdog behavior coexisting safely.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task = tokio::spawn(async {
loop {
println!("tick");
sleep(Duration::from_secs(2)).await;
}
});
tokio::time::sleep(Duration::from_secs(6)).await;
task.abort();
}Checkpoint: periodic loops run without freezing the main runtime.
Pi4 GPIO and I2C with rppal
Objective: bridge language fundamentals to real Pi hardware control.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This reads an I2C register block and toggles an output pin to prove end-to-end hardware access from Rust.
This is the inflection point from language learning to embedded application. You are proving that typed Rust code can safely drive actuators and read bus data on real hardware with explicit error handling and clear control flow.
use anyhow::Result;
use rppal::gpio::Gpio;
use rppal::i2c::I2c;
fn main() -> Result<()> {
let gpio = Gpio::new()?;
let mut relay = gpio.get(22)?.into_output_low();
relay.set_high();
let mut i2c = I2c::new()?;
i2c.set_slave_address(0x76)?;
let mut buf = [0u8; 2];
i2c.block_read(0xF7, &mut buf)?;
println!("i2c_bytes={:?}", buf);
relay.set_low();
Ok(())
}Checkpoint: output pin toggles and I2C bytes are read without runtime errors.
MQTT Publishing in Rust
Objective: publish structured health telemetry using a production MQTT crate.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This is the minimal publisher skeleton aligned to your MQTT lessons and topic contracts.
The important concept is contract continuity. The language can change, but topic names and payload shape stay stable so existing consumers and SQL pipelines remain valid while you improve publisher reliability and maintainability.
use rumqttc::{Client, MqttOptions, QoS};
use std::{thread, time::Duration};
fn main() {
let mut options = MqttOptions::new("rust-pub", "127.0.0.1", 1883);
options.set_credentials("pi4_publisher", "ChangePublisherPassword!");
let (mut client, mut connection) = Client::new(options, 10);
thread::spawn(move || for _ in connection.iter() {});
loop {
let msg = r#"{"host":"pi-dns-core","cpu_pct":10.2}"#;
let _ = client.publish("mqtt/pi4/health/json", QoS::AtLeastOnce, false, msg);
thread::sleep(Duration::from_secs(10));
}
}Checkpoint: broker subscribers receive Rust-published JSON on the expected topic.
Deploy Rust App as a Real Service
Objective: run Rust applications under systemd with restart, logging, and secure defaults.
Learning Focus: This lesson builds practical engineering judgment, not just task completion. As you run each step, connect the action to runtime behavior, failure signals, and design trade-offs so you can adapt the pattern in real systems.
This objective matters because it strengthens your engineering judgment, not just your syntax memory. As you move through the lesson, connect each code decision to runtime behavior on real hardware and real services.
Run
This final step turns your project into a production service aligned with your home infrastructure.
Service wrapping is where code becomes operations. Restart policy, privilege boundaries, and status inspection paths are part of the application design, because runtime behavior under failure matters as much as correctness under ideal conditions.
cargo build --release sudo install -m 0755 target/release/rust-lab /usr/local/bin/rust-lab sudo tee /etc/systemd/system/rust-lab.service >/dev/null <<'EOF' [Unit] Description=Rust Lab Service After=network-online.target [Service] ExecStart=/usr/local/bin/rust-lab Restart=always RestartSec=2 User=pi Group=pi NoNewPrivileges=true [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now rust-lab sudo systemctl status rust-lab --no-pager
You are complete when you can design, build, debug, and deploy Rust services on Pi4 that interact with GPIO, MQTT, and your existing data plane.