From Zero Assumptions

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.

LanguageRust stable
BuildCargo
RuntimeTokio
Pi4 I/Orppal
Messagingrumqttc
Path

Every lesson includes why-first explanation and then concrete code so you can internalize Rust and ship working systems.

Lesson 0

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.

bash
folder setup
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.

bash
project scaffold
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.

text
generated structure
rust-from-zero/
  Cargo.toml
  src/
    main.rs

Edit code and run

Replace your program with a tiny function you can test. This is your first full create-edit-run loop.

rust
src/main.rs
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.

bash
development loop
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.

Lesson 1

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.

rust
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.

Lesson 2

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.

rust
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.

Lesson 3

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.

bash
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.

rust
src/health.rs
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.

rust
src/main.rs
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.

rust
tests/health_tests.rs
#[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.

bash
build and test loop
cargo fmt
  cargo test
  cargo build --release

Checkpoint: you understand why release binaries are used for long-running services.

Lesson 4

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.

rust
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.

Lesson 5

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.

rust
#[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.

Lesson 6

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.

rust
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.

Lesson 7

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.

rust
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.

Lesson 8

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.

rust
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.

Lesson 9

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.

rust
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.

Lesson 10

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.

bash + systemd
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
Completion Standard

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.