Module 8 Lessons — Talking to Hardware

Back to the Course 2 syllabus · Practice: Module 8 exercises

This page is the module’s teaching text: how a program reaches a peripheral, from a raw address to a driver that runs unchanged on two boards. It covers the same ground twice — the C idioms (volatile, register structs, mask macros, the linker script, libgpiod and i2c-dev on Linux) and the Rust ones (volatile accesses, the PAC, typestate HALs, embedded-hal traits, the gpiod and linux-embedded-hal crates) — and it treats the microcontroller and the Linux board as two different problems that happen to share an I²C bus. Modules 6 and 7 supplied the layout and undefined-behavior vocabulary this page assumes; Module 9 takes over where an interrupt enters the picture. As with every lessons page in this course, it is AI-drafted teaching text reviewed by me; see the syllabus’s note on AI use. MC 39–44, the Embedded Rust Book’s Peripherals and Static Guarantees chapters, and the Embedonomicon remain available as optional deep-dives; nothing below requires them.


1 · From an address to a driver

On a Cortex-M there is no operating system between the program and the silicon: a peripheral is a block of registers at a fixed address in the same 4 GB space as flash and RAM, and “configure the UART” means “store these words at these addresses in this order.” On the Jetson the same statement is still true — the UART is still registers at a physical address — but a kernel owns them, and user programs reach hardware through the driver the kernel exposes. That difference decides the shape of every layer above it.

Layer Bare-metal C Bare-metal Rust Embedded Linux (either language)
Registers volatile struct overlay at a #defined base (CMSIS device header) Peripheral Access Crate (PAC, generated by svd2rust from the vendor’s SVD file: stm32l4) Owned by the kernel driver; not touched from user space (§9)
Hardware abstraction Vendor HAL (STM32Cube HAL/LL) HAL crate implementing embedded-hal traits — for the L476 this course uses embassy-stm32 Character devices and ioctls: /dev/gpiochipN, /dev/i2c-N, /dev/spidevB.C
Portable driver A .c file over a hand-written “transport” struct of function pointers A crate generic over embedded-hal traits (I2c, SpiDevice, OutputPin) libgpiod / gpiod; i2c-dev / linux-embedded-hal — the same Rust driver crate as the row above
Application main + ISRs #[entry] + #[interrupt], or an RTIC/Embassy app A process, threads, an event loop (Module 11)

The Rust column’s promise is the module’s destination: a driver written once against embedded_hal::i2c::I2c compiles for the STM32 (where the trait is implemented by embassy-stm32’s I²C peripheral) and for the Jetson (where linux-embedded-hal implements it over /dev/i2c-7). The C column can reach the same portability only by hand — a transport struct the driver calls through — and Exercise 8.5 builds both so the cost of each is visible.

2 · The as-if rule and volatile

The compiler does not promise to execute statements as written. It promises the program’s observable behavior matches the abstract machine’s, and may cache loads in registers, merge stores, hoist loop-invariant reads, and delete code whose effect “cannot matter.” That model is right for computation and wrong for hardware, where a load can clear a flag and a store can start a conversion. volatile is the smallest tool that reconciles the two: it declares an access to be observable behavior.

2.1 In C: the qualifier is on the object

A volatile-qualified access must be performed, as written, in source order relative to other volatile accesses. That is the entire contract:

Property volatile provides it?
Load/store actually emitted, not cached or folded Yes — the one job
Ordering vs. other volatile accesses Yes (compiler ordering, same core)
Ordering vs. surrounding non-volatile accesses No — they can move around it
Atomicity (flag++, a 64-bit read) No — still a read-modify-write, still two words
Hardware memory barrier (DMB/DSB) No
Event counting (no lost events) No — a level, not a queue
#define GPIOA ((GPIO_TypeDef *)GPIOA_BASE)

uint32_t input = GPIOA->IDR;   // volatile member read — always emitted
GPIOA->BSRR = GPIO_PIN_5;      // volatile member write — always emitted

Without the qualifier, a flag-polling loop at -O2 becomes a single test or an infinite loop: nothing in the loop body changes the flag, so the load is hoisted out of it. Exercise 8.1 makes you watch that happen in the Thumb-2 disassembly.

Direction is part of the interface, and the type can carry it. Hardware registers are read-only, write-only, or read-write from the CPU’s side:

typedef struct {
    volatile const uint32_t IDR;   // hardware writes it; software only reads
    volatile       uint32_t ODR;   // read-write
    volatile       uint32_t BSRR;  // write semantics defined by the hardware
} GpioView;

CMSIS spells the same three cases __I, __O, __IO. const does not enforce hardware permissions — some write-only registers read back garbage rather than faulting — but it documents the contract and turns a store into the input register into a compile error.

2.2 In Rust: the qualifier is on the access

Rust has no volatile type qualifier. Volatility is a property of a single read or write, expressed with two functions on raw pointers:

use core::ptr::{read_volatile, write_volatile};

const GPIOA_IDR: *const u32 = 0x4800_0010 as *const u32;
const GPIOA_BSRR: *mut u32  = 0x4800_0018 as *mut u32;

let input = unsafe { read_volatile(GPIOA_IDR) };
unsafe { write_volatile(GPIOA_BSRR, 1 << 5) };

Both are unsafe: the compiler cannot know the address is valid, aligned, and mapped, so the caller asserts it (Module 7’s contract). The guarantees table is identical to C’s — a volatile access is emitted and ordered only against other volatile accesses; it is not atomic and not a barrier. What Rust adds is that a plain dereference of the same pointer is also possible and also wrong, and nothing but discipline distinguishes *GPIOA_IDR from read_volatile(GPIOA_IDR) in a raw-pointer world. That is why nobody writes drivers at this level: the PAC (§5) makes every register access volatile by construction, and the raw functions survive only inside it.

The C idea of a register struct still exists, with #[repr(C)] doing the job C’s layout rules do implicitly:

#[repr(C)]
struct GpioRegs {
    moder: u32, otyper: u32, ospeedr: u32, pupdr: u32,
    idr: u32, odr: u32, bsrr: u32,
}
const GPIOA: *mut GpioRegs = 0x4800_0000 as *mut GpioRegs;

let idr = unsafe { read_volatile(&raw const (*GPIOA).idr) };

&raw const place (edition 2024 syntax) takes the field’s address without creating a reference — a reference to a register would let the compiler assume the value is stable, which is exactly the assumption volatile exists to deny. The volatile_register crate packages this as RO<u32>/WO<u32>/RW<u32> field types whose only methods are volatile; that is the Embedded Rust Book’s “first attempt,” and the PAC is its industrial-strength version.

TipFirmware rule

volatile (C) and read_volatile/write_volatile (Rust) are for memory-mapped I/O and nothing else. Every other “shared with an interrupt” case is a Module 9 problem, and putting volatile on it hides the design flaw without fixing it.

3 · MMIO mechanics: width, side effects, read-modify-write

A register is not RAM. Three physical facts drive every idiom in this section.

Access width is part of the interface. The pointer’s type selects the instruction — uint8_t *LDRB/STRB, uint16_t *LDRH/STRH, uint32_t *LDR/STR — and some peripherals require a full-word access or a specific half. The reference manual, not the type system, is the authority. In Rust the same choice is the pointee type of the raw pointer: read_volatile(p as *const u16) emits LDRH. Exercise 8.2 confirms the selection by type in both toolchains.

Reads and writes have side effects. Reading a status register may clear flags; writing a 1 to a bit may clear it — the write-1-to-clear convention for interrupt and status flags:

USART2->ICR = USART_ICR_ORECF;    // clear ONE flag: write its bit, alone

The trap is USART2->ICR |= USART_ICR_ORECF; — a read, an OR, and a write back of every bit that read as 1, which clears every pending flag at once. A compound assignment is always a read and a write, however atomic the source looks.

Read-modify-write races against everything else that touches the register. GPIOA->ODR |= GPIO_PIN_5; is load–OR–store. If an interrupt flips another pin between the load and the store, that pin’s update is silently undone:

sequenceDiagram
    participant M as main: ODR |= PIN_5
    participant I as ISR: ODR |= PIN_7
    participant R as ODR register
    M->>R: load ODR        (reads 0x0000)
    Note over I,R: interrupt fires
    I->>R: load ODR        (reads 0x0000)
    I->>R: store 0x0080    (PIN_7 set)
    Note over I,R: ISR returns
    M->>R: store 0x0020    (PIN_5 set — PIN_7 LOST)

sequenceDiagram
    participant M as main: ODR |= PIN_5
    participant I as ISR: ODR |= PIN_7
    participant R as ODR register
    M->>R: load ODR        (reads 0x0000)
    Note over I,R: interrupt fires
    I->>R: load ODR        (reads 0x0000)
    I->>R: store 0x0080    (PIN_7 set)
    Note over I,R: ISR returns
    M->>R: store 0x0020    (PIN_5 set — PIN_7 LOST)

Hardware’s answer is a dedicated set/reset register: GPIOA->BSRR = GPIO_PIN_5; is one store, no read, no window. When the peripheral offers one it is always the right choice; when it does not, the update needs a critical section (Module 9).

3.1 The same three facts in PAC vocabulary

svd2rust generates three operations per register, and their names encode exactly this section:

Operation What it emits When to use it
reg().read() one volatile load; returns a reader with typed field accessors inspect
reg().write(\|w\| …) one volatile store; every field you do not set takes its reset value one-shot registers (BSRR, ICR), full re-initialization
reg().modify(\|r, w\| …) load, then store — an explicit read-modify-write change some fields, keep the rest
// Set PA5 without touching other pins: a one-shot write to the set/reset register.
gpioa.bsrr().write(|w| w.bs5().set_bit());

// Change PA5's mode, keep the other 15 pins' modes: a deliberate RMW.
gpioa.moder().modify(|_, w| unsafe { w.moder5().bits(0b01) });

The write-resets-unset-fields rule is the write-1-to-clear discipline built into the API: icr().write(|w| w.orecf().clear_bit_by_one()) cannot accidentally clear other flags, because the other fields are written as their reset value, zero. And because modify is spelled differently from write, a read-modify-write on a status register is visible in a code review in a way that C’s |= is not.

4 · Bit-fields vs. masks vs. typed fields

C bit-fields look like the datasheet diagram, which is why they are tempting for registers — and the standard leaves too much of their layout implementation-defined to trust for MMIO: allocation order within the unit, straddling, padding, and the width of the generated access all vary by compiler and ABI. Worse, a bit-field store may compile to a read-modify-write of the whole containing word, which is wrong for a side-effecting register.

The robust C idiom is the one CMSIS publishes — position and mask macros:

uint32_t reg = peripheral->CTRL;
reg &= ~CTRL_MODE_Msk;
reg |= (mode << CTRL_MODE_Pos) & CTRL_MODE_Msk;
peripheral->CTRL = reg;              // one explicit, full-width RMW

Bit-fields remain acceptable for software-internal packed state on one known ABI, never for MMIO and never for wire formats.

The PAC is the third option: the SVD file’s field definitions become typed accessor methods, so w.moder5().bits(0b01) performs the mask-and-shift for you, at the width the SVD declares, with the value range checked at compile time where the SVD enumerates the legal values (w.moder5().output() when the vendor’s patches provide the variant) and behind unsafe where it does not (bits() on an unconstrained field, as in the example above). The generated code is the C mask idiom; the type system is what stops a 3-bit value from being written into a 2-bit field.

C bit-field C mask/position macros Rust PAC field accessor
Layout defined by the compiler (implementation-defined) you the SVD file
Access width visible? no yes yes (per register)
Illegal value caught never never at compile time for enumerated fields
RMW visible in source no yes yes (modify vs write)
Suitable for MMIO no yes yes

5 · The PAC and the singleton

A Peripheral Access Crate is a mechanical translation of the vendor’s SVD file into Rust: one module per peripheral, one struct per register block, one type per register, one method per field. What it adds beyond the CMSIS header is ownership.

use stm32l4::stm32l4x6 as pac;

let dp = pac::Peripherals::take().unwrap();   // Some(...) the first time, None ever after
let cp = cortex_m::Peripherals::take().unwrap();

dp.RCC.ahb2enr().modify(|_, w| w.gpioaen().set_bit());
dp.GPIOA.moder().modify(|_, w| unsafe { w.moder5().bits(0b01) });
dp.GPIOA.bsrr().write(|w| w.bs5().set_bit());

Peripherals::take() returns Option<Peripherals> and returns Some exactly once per program run. After that, dp.GPIOA is an ordinary owned value: it can be moved into a driver struct, lent as &GPIOA for reads, lent as &mut GPIOA for configuration, and the borrow checker applies its usual rules — one mutable reference or any number of shared ones. The Embedded Rust Book’s framing is the one to keep: hardware is global mutable state, the three rules for touching it (always volatile; any number of readers; a writer holds the only reference) are the borrow checker’s rules, and the singleton is what lets the borrow checker see the hardware at all. In C, nothing prevents two modules from configuring USART2 with contradictory settings; in Rust, the second module cannot name the peripheral without being handed it.

The unwrap() in the example is the one place the course tolerates it: take() failing means the program’s own structure is wrong, which is a bug to find at the first run, not an error to handle. (RTIC and Embassy remove even that — embassy_stm32::init returns the singletons already taken, and RTIC’s init::Context hands them to you as fields.)

Interrupt handlers come from the same crate, because the vector table’s layout is device-specific:

use pac::interrupt;

#[interrupt]
fn EXTI15_10() {
    // runs in handler mode; shares state with main only through Module 9's tools
}

#[interrupt] (from the PAC) and #[exception] (from cortex-m-rt) both check the handler’s name against the device’s vector list at compile time — a misspelled EXTI15_10 is a build error, where a misspelled EXTI15_10_IRQHandler in C compiles clean and loops forever in the weak default handler (§8).

6 · HALs and typestate: the peripheral as a state machine

A GPIO pin is a small state machine — disabled; enabled as input (floating, pull-up, pull-down); enabled as output (push-pull, open-drain; high, low) — and only some transitions are meaningful. “Set the output level of a pin configured as an input” is one the hardware may ignore, may honor in a surprising way, or may not define. A C HAL enforces the state machine at runtime, if at all:

HAL_StatusTypeDef led_set(Led *led, bool on) {
    if (led->mode != LED_MODE_OUTPUT) return HAL_ERROR;   // checked every call, at runtime
    HAL_GPIO_WritePin(led->port, led->pin, on ? GPIO_PIN_SET : GPIO_PIN_RESET);
    return HAL_OK;
}

Typestate moves the state into the type, so the check happens once, at compile time, and costs nothing at runtime:

pub struct Pin<MODE> { port: &'static GpioRegs, n: u8, _mode: PhantomData<MODE> }
pub struct Input; pub struct Output;

impl Pin<Input> {
    pub fn into_output(self) -> Pin<Output> { /* write MODER; */ Pin { port: self.port, n: self.n, _mode: PhantomData } }
    pub fn is_high(&self) -> bool { /* read IDR */ }
}
impl Pin<Output> {
    pub fn set_high(&mut self) { /* write BSRR */ }
}

into_output consumes self and returns a differently typed value, so the old Pin<Input> no longer exists and set_high on it is not a runtime error but a type error. PhantomData<MODE> is zero-sized: Pin<Output> is the same bytes as Pin<Input>, and the disassembly of set_high is one store to BSRR. Exercise 8.3 builds this driver in both languages and tabulates which misuses each catches, and when.

6.1 embassy-stm32 as the L476’s HAL

The stm32l4xx-hal crate that older tutorials use has not been updated since 2022; this course’s HAL for the NUCLEO-L476RG is embassy-stm32, which is maintained, covers the whole STM32 family from one codebase, and — despite the name — has a complete blocking API usable without the async executor. Module 10 adds the executor; here the crate is just a HAL.

[dependencies]
embassy-stm32 = { version = "0.6", features = ["stm32l476rg", "memory-x", "exti"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
panic-probe = "1"
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use embassy_stm32::gpio::{Level, Output, Speed};
use panic_probe as _;

#[entry]
fn main() -> ! {
    let p = embassy_stm32::init(Default::default());   // clocks, singletons, memory.x-defined layout
    let mut led = Output::new(p.PA5, Level::Low, Speed::Low);   // LD2 on the NUCLEO-L476RG
    loop {
        led.set_high();
        cortex_m::asm::delay(8_000_000);
        led.set_low();
        cortex_m::asm::delay(8_000_000);
    }
}

Three things the C blink does implicitly are explicit here. init configures the clock tree from a Config (the default is the internal oscillator; the 80 MHz PLL setup Course 3 uses is a few fields in that struct) and enables peripheral clocks lazily as pins are claimed — the RCC->AHB2ENR write that every C GPIO example forgets is inside Output::new. p.PA5 is a singleton pin, moved into the Output, so no other code can reconfigure PA5 while the LED owns it. And the memory-x feature writes memory.x for the named chip so the crate targets the real flash and RAM without a hand-maintained file. Course 3’s STM32 workflow sets up the C side of this same board; probe-rs run --chip STM32L476RGTx is the Rust side’s flash-and-run.

6.2 What the layers cost

Register level (§5) HAL (embassy-stm32) embedded-hal trait (§7)
Portability one chip one vendor family any implementor
Wrong-pin or wrong-mode bugs runtime, if noticed compile time (typestate, singleton pins) compile time (trait bounds)
Code size minimal small — generics monomorphize away none beyond the HAL
Where the unsafe lives your code inside the crate nowhere in the driver

The C column of the same table reads “one chip / runtime / minimal / everywhere” at every layer, because C HALs are libraries of functions, not of types.

7 · embedded-hal: the portable driver

embedded-hal 1.0 is a set of traits — no code, only contracts — that every Rust HAL implements and every portable driver consumes. The ones this course uses:

Trait Method shape Implemented by
digital::OutputPin set_high(), set_low()Result<(), Self::Error> embassy_stm32::gpio::Output; gpiod-backed pins on Linux (via linux-embedded-hal)
digital::InputPin is_high(), is_low() likewise
i2c::I2c write, read, write_read, transaction with a 7-bit u8 address embassy_stm32::i2c::I2c; linux_embedded_hal::I2cdev
spi::SpiDevice / spi::SpiBus a device with its own chip select vs. the shared bus embassy_stm32::spi::Spi + embedded-hal-bus; linux_embedded_hal::SpidevDevice
delay::DelayNs delay_ns/us/ms embassy_time::Delay; linux_embedded_hal::Delay

A driver is a struct generic over the trait, with the bus owned by the driver and every method returning the bus’s own error type:

use embedded_hal::i2c::I2c;

pub struct Ads1115<I2C> { i2c: I2C, addr: u8 }

impl<I2C: I2c> Ads1115<I2C> {
    pub const ADDR_GND: u8 = 0x48;
    pub fn new(i2c: I2C, addr: u8) -> Self { Self { i2c, addr } }

    pub fn config(&mut self) -> Result<u16, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.addr, &[0x01], &mut buf)?;   // pointer register, then 2 bytes
        Ok(u16::from_be_bytes(buf))
    }
}

The same source compiles for the STM32 with Ads1115::new(embassy_i2c, Ads1115::ADDR_GND) and for the Jetson with Ads1115::new(I2cdev::new("/dev/i2c-7")?, Ads1115::ADDR_GND). It also compiles on the Mac with a fake I2c that records transactions, which is how a driver’s register protocol gets unit-tested without a bus (Module 12). The ADS1115 (address 0x48, Course 3 Lab 3.4) and the MCP4725 DAC (0x60/0x62, Lab 3.2) are the module’s running examples because both already sit on the Course 3 bench and both boards can reach them.

The C version of portability is a transport struct — a vtable by hand:

typedef struct {
    int (*write_read)(void *ctx, uint8_t addr, const uint8_t *w, size_t wn, uint8_t *r, size_t rn);
    void *ctx;
} i2c_transport;

typedef struct { i2c_transport bus; uint8_t addr; } ads1115;

int ads1115_config(ads1115 *dev, uint16_t *out);   // calls dev->bus.write_read(...)

It works, it is how every serious C sensor library is written, and it costs an indirect call per transaction plus a void * that the compiler cannot check. Rust’s generic monomorphizes to a direct call and the compiler checks that the bus actually implements I2c. Exercise 8.5 builds both drivers and compiles each for three targets.

8 · Startup, sections, and the map file

The ISO C object model ends at “objects have addresses.” Firmware needs more: the vector table at the start of flash, .data initializers in flash but the objects in RAM, a DMA buffer in the right bank, a default handler for every unimplemented interrupt. That is the linker’s world, and both languages reach it through the same GNU-flavored mechanisms.

flowchart LR
    subgraph FLASH["Flash (0x0800 0000)"]
        V[".isr_vector / .vector_table<br/>(KEEP — vector table)"]
        T[".text — code"]
        R[".rodata — const tables"]
        LI[".data load image"]
    end
    subgraph RAM["SRAM (0x2000 0000)"]
        D[".data — initialized statics"]
        B[".bss — zeroed statics"]
        S["stack ↓ (from top)"]
    end
    LI -- "startup: copy" --> D
    B -. "startup: zero" .- B

flowchart LR
    subgraph FLASH["Flash (0x0800 0000)"]
        V[".isr_vector / .vector_table<br/>(KEEP — vector table)"]
        T[".text — code"]
        R[".rodata — const tables"]
        LI[".data load image"]
    end
    subgraph RAM["SRAM (0x2000 0000)"]
        D[".data — initialized statics"]
        B[".bss — zeroed statics"]
        S["stack ↓ (from top)"]
    end
    LI -- "startup: copy" --> D
    B -. "startup: zero" .- B

8.1 In C

__attribute__((section(".dma_buffer"), aligned(32)))
static uint8_t rx_buffer[1024];

__attribute__((weak)) void board_fault_hook(uint32_t code) { (void)code; }
  • section("…") places the object in a named section; the linker script must place the section, and the map file is the proof that it did.
  • weak is a default definition that a strong one elsewhere silently replaces — the STM32 startup file gives every vector a weak alias to Default_Handler, and your ADC1_IRQHandler wins by name. A typo compiles clean.
  • used and the script’s KEEP() resist --gc-sections; the vector table survives only because the script says KEEP(*(.isr_vector)).
  • The map file (-Wl,-Map=firmware.map) records every section’s address and size, every symbol’s placement, and which weak symbol lost.

8.2 In Rust

cortex-m-rt owns the linker script (link.x, generated from your memory.x), the vector table, and the reset handler; the attributes are the same ideas with edition-2024 spelling — attributes that can break linking are marked unsafe:

#[unsafe(link_section = ".dma_buffer")]
#[used]
static mut RX_BUFFER: [u8; 1024] = [0; 1024];   // placement; Module 9 governs access

#[unsafe(no_mangle)]
pub extern "C" fn board_fault_hook(code: u32) {}  // a C-callable symbol, unmangled

Exceptions and interrupts are functions with checked names rather than weak symbols:

use cortex_m_rt::{exception, ExceptionFrame};

#[exception]
unsafe fn HardFault(ef: &ExceptionFrame) -> ! {
    // ef.pc(), ef.lr(), ef.r0()… — the frame the core stacked on entry
    loop {}
}

#[exception]
unsafe fn DefaultHandler(irqn: i16) {   // catches every vector without its own handler
    loop {}
}

The C equivalent of the HardFault handler — a naked HardFault_Handler that selects MSP or PSP from EXC_RETURN and passes the stacked frame to a C function — is what Course 3 Lab 7.1 writes by hand; cortex-m-rt generates that trampoline and hands you the frame as a typed reference. Exercise 8.7 triggers a fault in QEMU and reads the frame both ways.

The inspection tools map one to one:

Question C (arm-none-eabi-*) Rust (cargo-binutils)
Section sizes and addresses objdump -h firmware.elf, size -A cargo objdump --bin x -- -h, cargo size --bin x -- -A
Where did this symbol go, which weak lost the map file; nm cargo nm --bin x; -C link-arg=-Map=x.map in rustflags
Did the section attribute take effect objdump -t cargo objdump -- -t
What does the handler compile to objdump -d cargo objdump -- -d --no-show-raw-insn
TipFirmware rule

The map file is the ground truth for “where did it actually go.” In both languages, a placement attribute that the linker script does not honor is silently ignored; read the map before trusting the attribute.

9 · Embedded Linux: the device model

On the Jetson and the Pi the kernel owns every peripheral, and the question is not “which address” but “which device node and which ioctl.” The rules of the tier:

  • Never touch registers from user space in production. /dev/mem plus mmap reaches physical addresses, needs root, bypasses the driver’s locking and power management, and on the Jetson has to contend with an IOMMU; it is a bring-up and forensics tool only.
  • The sysfs GPIO interface (/sys/class/gpio) is deprecated — global GPIO numbers, no ownership, no event timestamps — and the replacement since kernel 4.8 is the GPIO character device, /dev/gpiochipN, reached through libgpiod.
  • Buses are character devices with ioctls: /dev/i2c-N (i2c-dev), /dev/spidevB.C (spidev), /dev/ttyTHS1 / /dev/ttyAMA0 (the UART, as a tty). Group membership (gpio, i2c, dialout) replaces root; Course 3’s Jetson setup essentials lists the groups and the header-pin conventions this module uses: header pins 3/5 are I²C bus 7 on the Jetson and bus 1 on the Pi; the course’s marker GPIO is header pin 7.

9.1 GPIO through the character device

libgpiod’s C API on JetPack 6 is the v1 series (1.6); the v2 API (2.x, on newer distributions) renamed nearly everything around request objects. Check which one the board has with gpiodetect --version before writing a line, and find a header pin’s chip and line offset with gpioinfo rather than hard-coding a number.

#include <gpiod.h>
struct gpiod_chip *chip = gpiod_chip_open_by_name("gpiochip0");
struct gpiod_line *line = gpiod_chip_get_line(chip, line_offset);   // from gpioinfo
if (gpiod_line_request_output(line, "ex-8-8", 0) < 0) { /* errno */ }
gpiod_line_set_value(line, 1);
gpiod_line_release(line);
gpiod_chip_close(chip);

The Rust gpiod crate speaks the kernel’s uAPI directly — no libgpiod dependency, either uAPI version — and the request is a typed builder:

use gpiod::{Chip, Options};
let chip = Chip::new("gpiochip0")?;
let opts = Options::output([line_offset]).values([false]).consumer("ex-8-8");
let mut lines = chip.request_lines(opts)?;
lines.set_values([true])?;

Both versions express what sysfs could not: a consumer name the kernel shows in gpioinfo, ownership that ends when the request is released (or, in Rust, dropped), and edge events with kernel timestamps (Module 11 reads those through epoll). Confirm the gpiod crate’s exact builder names against its docs.rs page for the pinned 0.3 release.

9.2 I²C: raw ioctls vs. the trait

The i2c-dev interface is a file descriptor and two ioctls:

#include <linux/i2c-dev.h>
#include <linux/i2c.h>
int fd = open("/dev/i2c-7", O_RDWR);
ioctl(fd, I2C_SLAVE, 0x48);                 // address for plain read()/write()
uint8_t reg = 0x01; write(fd, &reg, 1);     // two transactions: STOP between them
uint8_t buf[2]; read(fd, buf, 2);

A write followed by a read is two bus transactions with a STOP between them; a device that requires a repeated start (many do, for register reads) needs the I2C_RDWR ioctl with a two-element struct i2c_msg array. That distinction is exactly what embedded-hal’s write_read encodes, and linux_embedded_hal::I2cdev implements it with I2C_RDWR underneath — so §7’s driver runs on the Jetson through:

use linux_embedded_hal::I2cdev;
let i2c = I2cdev::new("/dev/i2c-7")?;
let mut adc = Ads1115::new(i2c, Ads1115::ADDR_GND);
let cfg = adc.config()?;

i2cdetect -y 7 (Course 3 Lab 3.1’s tool) is the first check on either board; the Pi’s bus is 1. SPI follows the same shape — /dev/spidev0.0, SPI_IOC_MESSAGE for full-duplex transfers, linux_embedded_hal::SpidevDevice implementing SpiDevice.

9.3 What the tier changes

Bare metal Embedded Linux
Who owns the register your program the kernel driver
Concurrency with the hardware interrupts (Module 9) the kernel; your process sees blocking or pollable fds
Timing of an access one bus cycle a syscall, a driver, a scheduler — microseconds to milliseconds, with jitter (Module 11)
Failure mode a bus fault, a wrong bit errno (EACCES, EBUSY, EREMOTEIO) — an io::Error in Rust
Portability of a driver via embedded-hal traits the same traits, via linux-embedded-hal

The last row is why the ADS1115 driver is written once. The rows above it are why “runs on the Jetson” does not imply “meets the deadline on the Jetson” — Module 11’s subject.

10 · Lesson → exercise map

Section Exercise it feeds
§2 volatile in both languages 8.1 (proven in disassembly)
§3–4 MMIO width, RMW, write-1-to-clear, fields 8.2 (fake register block, C and Rust)
§5 PAC and the singleton 8.2, 8.4
§6 typestate, embassy-stm32 8.3 (typestate LED driver), 8.4 (blink, three ways)
§7 embedded-hal driver 8.5 (one I²C driver, two boards)
§8 sections, weak symbols, exceptions 8.6 (map file, both toolchains), 8.7 (HardFault frame)
§9 Linux device model 8.8 (gpiod and I²C on the Jetson)