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:
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:
typedefstruct{volatileconstuint32_t IDR;// hardware writes it; software only readsvolatileuint32_t ODR;// read-writevolatileuint32_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:
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:
&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:
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.
usestm32l4::stm32l4x6 as pac;let dp =pac::Peripherals::take().unwrap();// Some(...) the first time, None ever afterlet 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:
usepac::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:
into_outputconsumesself 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]usecortex_m_rt::entry;useembassy_stm32::gpio::{Level, Output, Speed};use panic_probe as _;#[entry]fn main() ->!{let p =embassy_stm32::init(Default::default());// clocks, singletons, memory.x-defined layoutletmut led =Output::new(p.PA5,Level::Low,Speed::Low);// LD2 on the NUCLEO-L476RGloop{ 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
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 fakeI2c 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:
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
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:
Exceptions and interrupts are functions with checked names rather than weak symbols:
usecortex_m_rt::{exception, ExceptionFrame};#[exception]unsafefn HardFault(ef:&ExceptionFrame) ->!{// ef.pc(), ef.lr(), ef.r0()… — the frame the core stacked on entryloop{}}#[exception]unsafefn DefaultHandler(irqn:i16) {// catches every vector without its own handlerloop{}}
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.
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,®,1);// two transactions: STOP between themuint8_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:
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)