Skip to content
Select theme

Talking to an I2C sensor

Step 7 of 10, and the step that gives the firmware an input. Step 6 left it dimming LD2 on a triangle curve the button pauses; nothing the firmware did depended on the world outside the chip. This step puts a sensor on the bus. A BMP280 reports temperature and pressure over I2C, the firmware reads it once every half second, and the temperature in that reading sets how fast the breath runs. It starts from tag stm32-ch06 and ends at stm32-ch07.

Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against, with stm32f4xx-hal 0.23.0, embedded-hal 1.0.0 and bmpe280 1.0.5.

I2C is two wires and a rule. SCL carries the clock and SDA the data, both open-drain with a pull-up, so any device on the bus can hold a line low and no two devices can ever fight over it. Every transfer is started by the master — here the F401 — and every device answers to a seven-bit address of its own. One wire pair, any number of devices, one address each: at this level of detail, that is the whole protocol.

On the F401, I2C1’s SCL and SDA are muxed onto PB8 and PB9, which the board’s user guide lists as the Arduino header’s D15 and D14 — the two pins a sensor module plugs straight into. A hundred kilohertz is I2C’s standard mode, and the rate this firmware picks.

The crate literally named bmp280 is the one a search turns up first, and it cannot build for this chip. Its manifest depends on i2cdev, a crate for Linux’s /dev/i2c-* devices, and its source reaches for std::path — a no_std firmware has neither. bmpe280 1.0.5 is the one that fits: no_std, a single dependency, and that dependency is embedded-hal 1.0, the trait family the firmware already speaks.

The driver’s shape is small. BMP280::new_with_address(i2c, address) takes the bus, asks the chip who it is, and — if the answer is the BMP280’s chip id — reads the calibration block and writes the configuration registers. measure() then reads six bytes and returns degrees Celsius and pascals as f64. There is no Result in sight, which is the first thing to be careful about, and the pitfalls at the end come back to it.

The bus joins the firmware in main(), after the timer and before the delay:

src/main.rs, in main()
// I2C1's SCL and SDA are muxed onto PB8 and PB9 — the Nucleo's
// Arduino header pins D15 and D14, where a sensor module plugs
// straight in. The pins are passed in their plain split state: the
// bus constructor moves them into alternate mode itself. One
// hundred kilohertz is the bus's standard mode.
let i2c = dp.I2C1.i2c((gpiob.pb8, gpiob.pb9), 100.kHz(), &mut rcc);
let mut sensor = BMP280::new_with_address(i2c, SENSOR_ADDRESS);
// The driver cannot fail, so it cannot report one either: a chip
// that does not answer leaves it unconfigured, and every reading it
// would return after that is a fiction. Ask the chip who it is
// before trusting it.
if sensor.id() != BMP280_ID {
stall_lit();
}

The pins are handed over in their plain split state — the bus constructor moves them into alternate mode itself, open-drain, which is the mode I2C’s wired-AND lines need. The address is a constant rather than the driver’s new, because the driver defaults to 0x77 and the module this article wires up answers at 0x76; the next section is about that choice.

Every bus method is a trait method, and the trait’s methods return a Result — the third trait family in this track to hand one back where a bare value would do (step 5 hit it on is_low, step 6 on set_duty_cycle_percent). The block needs embedded-hal, the crate the firmware already links against, and bmpe280, the driver:

[dependencies]
bmpe280 = "1.0.5"
embedded-hal = "1.0.0"
use embedded_hal::i2c::{ErrorType, I2c, Operation, SevenBitAddress};
struct MockI2c;
impl ErrorType for MockI2c {
type Error = std::convert::Infallible;
}
impl I2c<SevenBitAddress> for MockI2c {
fn transaction(
&mut self,
_address: u8,
_operations: &mut [Operation<'_>],
) -> Result<(), Self::Error> {
Ok(())
}
}
/// A read of the chip's id register, minus the error handling: the
/// bus method returns a `Result`, and a `()` return type does not
/// accept one.
fn chip_id(bus: &mut MockI2c) {
bus.write_read(0x76, &[0xD0], &mut [0u8; 1])
}
fn main() {}
error[E0308]: mismatched types
--> src/main.rs:23:5
|
23 | bus.write_read(0x76, &[0xD0], &mut [0u8; 1])
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `()`, found `Result<(), Infallible>`
|
= note: expected unit type `()`
found enum `Result<(), Infallible>`
help: consider using a semicolon here
|
23 | bus.write_read(0x76, &[0xD0], &mut [0u8; 1]);
| +
help: try adding a return type
|
22 | fn chip_id(bus: &mut MockI2c) -> Result<(), Infallible> {
| +++++++++++++++++++++++++
help: consider using `Result::expect` to unwrap the `Result<(), Infallible>` value, panicking if the value is a `Result<Err`
|
23 | bus.write_read(0x76, &[0xD0], &mut [0u8; 1]).expect("REASON")
| +++++++++++++++++
For more information about this error, try `rustc --explain E0308`.
error: could not compile `verify` (bin "verify") due to 1 previous error

On a real bus the error is not academic: a read from an address nothing answers at is a bus error, and the Result is where it surfaces. The driver throws those Results away, which is why the firmware checks the chip id instead of trusting a reading.

The best way to see what new_with_address and measure actually do is to put a mock on the bus that records every transfer. The mock implements one method — transaction — because read, write and write_read are default methods the trait builds on top of it, and the driver only ever needs those three. This is the complete program:

use bmpe280::BMP280;
use embedded_hal::i2c::{ErrorType, I2c, Operation, SevenBitAddress};
use std::cell::RefCell;
use std::rc::Rc;
/// One transfer the mock recorded, in the order the driver made it.
struct Transfer {
/// The address the transfer was aimed at.
address: u8,
op: Op,
}
enum Op {
/// A register write: the register, then the byte written to it.
Write(u8, u8),
/// A register read: the register it started from, then the bytes.
Read(u8, Vec<u8>),
}
/// What the mock's bus remembers: a register file — what each register
/// reads back — and a transcript of every transfer.
struct Bus {
/// The only address a chip answers at. A transfer aimed anywhere
/// else leaves its read buffer untouched, the way a bus with no
/// slave acknowledging leaves the master holding zeros.
address: u8,
registers: [u8; 256],
/// The register the next read starts from: the chip's register
/// pointer, which advances as bytes are read.
pointer: u8,
transcript: Vec<Transfer>,
}
/// A stand-in for the board's I2C bus, holding the sensor end of it.
/// The shared cell is what lets the demo keep reading the transcript
/// after the driver has taken ownership of the bus itself.
struct MockI2c {
shared: Rc<RefCell<Bus>>,
}
impl ErrorType for MockI2c {
type Error = std::convert::Infallible;
}
// Only `transaction` is implemented: `read`, `write` and `write_read`
// are default methods the trait builds on top of it, and the driver
// only ever needs those three.
impl I2c<SevenBitAddress> for MockI2c {
fn transaction(
&mut self,
address: u8,
operations: &mut [Operation<'_>],
) -> Result<(), Self::Error> {
let mut bus = self.shared.borrow_mut();
for op in operations {
match op {
// A one-byte write sets the register pointer — the
// address phase of a read. Two bytes are a register
// write.
Operation::Write(bytes) => {
if bytes.len() >= 2 {
bus.registers[bytes[0] as usize] = bytes[1];
bus.pointer = bytes[0];
bus.transcript.push(Transfer {
address,
op: Op::Write(bytes[0], bytes[1]),
});
} else {
bus.pointer = bytes[0];
}
}
Operation::Read(buffer) => {
let start = bus.pointer;
if address == bus.address {
for slot in buffer.iter_mut() {
*slot = bus.registers[bus.pointer as usize];
bus.pointer = bus.pointer.wrapping_add(1);
}
}
bus.transcript.push(Transfer {
address,
op: Op::Read(start, buffer.to_vec()),
});
}
}
}
Ok(())
}
}
/// The calibration block as a chip with these coefficients stores it
/// at 0x88, little-endian pairs. The values are a plausible set — the
/// point of the demo is what the driver does with them, not where
/// they came from.
const CALIBRATION: [u8; 24] = [
0x60, 0x6B, // dig_t1 = 27504
0x43, 0x67, // dig_t2 = 26435
0x18, 0xFC, // dig_t3 = -1000
0x7D, 0x8E, // dig_p1 = 36477
0xA3, 0xD6, // dig_p2 = -10685
0xD0, 0x0B, // dig_p3 = 3024
0x27, 0x0B, // dig_p4 = 2855
0x8C, 0x00, // dig_p5 = 140
0xF9, 0xFF, // dig_p6 = -7
0x8C, 0x3C, // dig_p7 = 15500
0x18, 0xC7, // dig_p8 = -14600
0x70, 0x17, // dig_p9 = 6000
];
/// One measurement as the chip stores it at 0xF7: pressure then
/// temperature, each twenty bits across three bytes.
fn measurement(pressure: u32, temperature: u32) -> [u8; 6] {
let split = |raw: u32| {
[
(raw >> 12) as u8,
(raw >> 4) as u8,
// Only the top four bits of the third byte carry data.
((raw & 0x0F) << 4) as u8,
]
};
let [p0, p1, p2] = split(pressure);
let [t0, t1, t2] = split(temperature);
[p0, p1, p2, t0, t1, t2]
}
/// Print the transfers made since the last call.
fn print_new(shared: &Rc<RefCell<Bus>>, shown: &mut usize) {
let bus = shared.borrow();
for t in &bus.transcript[*shown..] {
match &t.op {
Op::Write(reg, val) => println!("0x{:02X} W 0x{reg:02X} 0x{val:02X}", t.address),
Op::Read(reg, bytes) => {
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02X}")).collect();
println!(
"0x{:02X} R 0x{reg:02X} {:>2} -> [{}]",
t.address,
bytes.len(),
hex.join(" ")
);
}
}
}
*shown = bus.transcript.len();
}
fn main() {
let mut registers = [0u8; 256];
registers[0xD0] = 0x58; // chip id
registers[0x88..0x88 + CALIBRATION.len()].copy_from_slice(&CALIBRATION);
// A reading near room temperature and standard pressure.
registers[0xF7..0xF7 + 6].copy_from_slice(&measurement(411446, 519888));
let shared = Rc::new(RefCell::new(Bus {
address: 0x76,
registers,
pointer: 0,
transcript: Vec::new(),
}));
let mut shown = 0;
// The address the driver is given is the one the firmware passes:
// 0x76, the module's SDO-pin-low address.
let mut sensor = BMP280::new_with_address(
MockI2c {
shared: Rc::clone(&shared),
},
0x76,
);
println!("--- set-up ---");
print_new(&shared, &mut shown);
println!("--- chip id ---");
println!("chip id: 0x{:02X}", sensor.id());
print_new(&shared, &mut shown);
// The first reading. Its pressure is computed from the t_fine the
// previous reading left behind — and at start-up there is no
// previous reading, so t_fine is still zero.
println!("--- first reading ---");
let first = sensor.measure();
print_new(&shared, &mut shown);
println!(
"temperature {:.2} C, pressure {:.2} Pa",
first.temperature, first.pressure
);
// The second reading is the first good one: t_fine now comes from
// the sample the first call took.
println!("--- second reading ---");
let second = sensor.measure();
print_new(&shared, &mut shown);
println!(
"temperature {:.2} C, pressure {:.2} Pa",
second.temperature, second.pressure
);
// A warmer room: the same coefficients, five degrees more.
println!("--- a warmer room ---");
shared.borrow_mut().registers[0xFA..0xFD].copy_from_slice(&measurement(411446, 535848)[3..]);
let warm = sensor.measure();
print_new(&shared, &mut shown);
println!(
"temperature {:.2} C, pressure {:.2} Pa",
warm.temperature, warm.pressure
);
// What a missing sensor looks like: the same bus, an address no
// chip answers at. The driver cannot report the silence — it was
// never configured, and it says so by returning nothing at all.
println!("--- no chip at 0x77 ---");
let mut ghost = BMP280::new_with_address(
MockI2c {
shared: Rc::clone(&shared),
},
0x77,
);
println!("chip id: 0x{:02X}", ghost.id());
print_new(&shared, &mut shown);
let nothing = ghost.measure();
print_new(&shared, &mut shown);
println!(
"temperature {:.2} C, pressure {:.2} Pa",
nothing.temperature, nothing.pressure
);
}
--- set-up ---
0x76 R 0xD0 1 -> [58]
0x76 R 0x88 24 -> [60 6B 43 67 18 FC 7D 8E A3 D6 D0 0B 27 0B 8C 00 F9 FF 8C 3C 18 C7 70 17]
0x76 W 0xE0 0x3F
0x76 R 0xE0 1 -> [3F]
0x76 W 0xF5 0x90
0x76 R 0xF5 1 -> [90]
0x76 W 0xF4 0xAB
0x76 R 0xF4 1 -> [AB]
--- chip id ---
chip id: 0x58
0x76 R 0xD0 1 -> [58]
--- first reading ---
0x76 R 0xF7 6 -> [64 73 60 7E ED 00]
temperature 25.16 C, pressure 97473.67 Pa
--- second reading ---
0x76 R 0xF7 6 -> [64 73 60 7E ED 00]
temperature 25.16 C, pressure 101310.73 Pa
--- a warmer room ---
0x76 R 0xF7 6 -> [64 73 60 82 D2 80]
temperature 30.16 C, pressure 101310.73 Pa
--- no chip at 0x77 ---
chip id: 0x00
0x77 R 0xD0 1 -> [00]
0x77 R 0xD0 1 -> [00]
0x77 R 0xF7 6 -> [00 00 00 00 00 00]
temperature 0.00 C, pressure 1048576.00 Pa

Line by line, the set-up is the whole driver: read register 0xD0 for the chip id, and if it comes back 0x58 read the twenty-four calibration bytes at 0x88, write 0xE0 (the reset register) with 0x3F — not the 0xB6 the datasheet makes the reset value and the driver’s own reset method calls its magic number, so whatever this write does, it is not the documented reset — then write the configuration and control registers. 0xF5 gets 0x90: a 500 ms standby time and the IIR filter at its longest time constant, c16. 0xF4 gets 0xAB: temperature oversampled sixteen times, pressure twice, normal mode. The stray one-byte read after every write is the driver’s write helper reading back a byte it throws away — harmless, but it doubles the bus traffic for writes and looks alarming in a logic-analyser capture.

Each reading is one transfer of six bytes from 0xF7: pressure first, then temperature, each twenty bits spread across three bytes. What the driver makes of those bytes is the compensation formula from the datasheet, run in f64 against the calibration block it read at start-up — 25.16 °C and about 1013 hPa from the bytes above.

The first pressure is the odd one out, and it is worth understanding why. The compensation needs t_fine, a value the temperature computation produces and the pressure computation consumes — and the driver computes pressure before it computes the new temperature, so the first reading’s pressure is built from the t_fine of the reading before it. At start-up there is no reading before it, t_fine is still zero, and the first pressure comes out 97,473.67 Pa instead of 101,310.73: a few percent low. The temperature is unaffected — t_fine is its output, not its input — and the firmware only ever uses the temperature, so the quirk costs this firmware nothing. A reader who prints pressure should read it twice.

The last section is the one that matters on a real bench. No chip answers at 0x77, so the driver’s id read comes back zero, the calibration and configuration are skipped, and measure returns 0.00 °C and 1048576.00 Pa. That pressure is the compensation’s starting offset, 2^20, with nothing subtracted: with an all-zero calibration block the formula’s scale factor comes out zero, and the driver leaves the raw count alone rather than divide by it. A missing sensor does not read as an error. It reads as a cold, high-pressure day. That is what the firmware’s start-up check turns into a lit LED.

The reading arrives as an f64 in degrees Celsius, and the firmware maps it to the breath’s half-period: each degree shaves ten ticks off, clamped so a silly reading cannot stop the breath or turn it into a strobe. The clamp is not decoration — the ghost reading of 0.00 °C would otherwise hand back the slowest breath in the table, and a sub-zero reading an even slower one, which looks exactly like a working sensor in a freezer.

temperature | half | breath
0.0 C | 500 | 1.00 s
10.0 C | 400 | 0.80 s
20.0 C | 300 | 0.60 s
25.0 C | 250 | 0.50 s
30.0 C | 200 | 0.40 s
45.0 C | 50 | 0.10 s
60.0 C | 50 | 0.10 s
tick | duty
1 | 0%
2 | 10%
3 | 20%
4 | 30%
5 | 40%
6 | 50%
7 | 60%
8 | 70%
9 | 80%
10 | 90%
11 | 100%
12 | 90%
13 | 80%
14 | 70%
15 | 0%
16 | 20%
17 | 40%
18 | 60%
19 | 80%
20 | 100%
21 | 80%
22 | 60%
23 | 40%
24 | 20%
25 | 0%

The table is the mapping the firmware runs: a 20 °C room breathes every 0.6 s, a 30 °C one every 0.4 s, and past 45 °C the clamp holds the breath at a tenth of a second. The trace under it is set_half doing its job. At tick 15 — the last duty printed was 70%, and the next would have been 60% — the reading arrives and halves the half-period; the phase is now past the new turnaround, so the ramp restarts from dark, which is the visible 70% to 0% step, and climbs at the new, steeper slope.

src/main.rs in full — this is the tag’s file, byte for byte:

src/main.rs
#![no_std]
#![no_main]
use bmpe280::BMP280;
use cortex_m::delay::Delay;
use cortex_m_rt::entry;
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::InputPin;
use embedded_hal::i2c::I2c;
use embedded_hal::pwm::SetDutyCycle;
use stm32f4xx_hal::pac;
use stm32f4xx_hal::prelude::*;
use stm32f4xx_hal::rcc::Config;
/// The BMP280's chip ID, read from register 0xD0 at start-up. A chip
/// that answers with anything else was not configured by the driver.
const BMP280_ID: u8 = 0x58;
/// The sensor's I2C address. The module this article wires up has its
/// SDO pin pulled low, which selects 0x76; with SDO left high the same
/// chip answers at 0x77 instead — and 0x77 is what the driver's own
/// `new` defaults to, which is why the address is passed explicitly.
const SENSOR_ADDRESS: u8 = 0x76;
/// The half-period the breath starts with, before the first reading
/// has arrived.
const DEFAULT_HALF: u16 = 300;
/// Ticks between sensor readings: half a second.
const READING_TICKS: u16 = 500;
/// The fastest half-period the mapping allows, in ticks. Below this a
/// breath stops being a breath and becomes a strobe.
const MIN_HALF: u16 = 50;
/// The slowest half-period the mapping allows, in ticks.
const MAX_HALF: u16 = 500;
/// Ticks shaved off the half-period per degree Celsius.
const TEMP_STEP: f64 = 10.0;
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let cp = cortex_m::peripheral::Peripherals::take().unwrap();
// Constrain the RCC and apply the default clock configuration: the
// chip's 16 MHz internal oscillator, no PLL.
let mut rcc = dp.RCC.constrain().freeze(Config::default());
let clocks = rcc.clocks;
let gpioa = dp.GPIOA.split(&mut rcc);
let gpiob = dp.GPIOB.split(&mut rcc);
let gpioc = dp.GPIOC.split(&mut rcc);
// PC13 is the board's user button, wired as step 5 left it.
let button_pin = gpioc.pc13.into_pull_up_input();
let mut button = Button::new(button_pin);
// TIM2's channel 1 is muxed onto PA5, the pin LD2 is wired to. The
// pin changes jobs here: no longer an output the firmware writes
// outright, but a timer output the firmware only loads. One
// kilohertz makes a period of 1 ms — the same length as the loop's
// tick, so the duty is written once per period and never lands
// mid-period.
let (_, channels) = dp.TIM2.pwm_hz(1.kHz(), &mut rcc);
let mut led = channels.0.with(gpioa.pa5.into_alternate::<1>());
// The channel starts with its output disabled: the counter runs and
// the waveform is generated, but nothing reaches the pin until this
// call sets the channel's output-enable bit.
led.enable();
// I2C1's SCL and SDA are muxed onto PB8 and PB9 — the Nucleo's
// Arduino header pins D15 and D14, where a sensor module plugs
// straight in. The pins are passed in their plain split state: the
// bus constructor moves them into alternate mode itself. One
// hundred kilohertz is the bus's standard mode.
let i2c = dp.I2C1.i2c((gpiob.pb8, gpiob.pb9), 100.kHz(), &mut rcc);
let mut sensor = BMP280::new_with_address(i2c, SENSOR_ADDRESS);
// The driver cannot fail, so it cannot report one either: a chip
// that does not answer leaves it unconfigured, and every reading it
// would return after that is a fiction. Ask the chip who it is
// before trusting it.
if sensor.id() != BMP280_ID {
stall_lit();
}
// The delay counts SysTick ticks against the AHB frequency.
let mut delay = Delay::new(cp.SYST, clocks.hclk().to_Hz());
sense(&mut sensor, &mut led, &mut button, &mut delay)
}
/// A button that reports one event per accepted press.
///
/// A mechanical contact does not close once: it bounces, and a raw read
/// of the pin flickers between levels around each transition. This
/// struct swallows that by only accepting a level that has held still
/// for `DEBOUNCE_TICKS` consecutive ticks. The pin type is generic, so
/// the same struct runs against a recorded trace on the host (that is
/// how it was tested) and against PC13 on the board.
struct Button<I: InputPin> {
pin: I,
/// The level the debouncer currently believes the line is at:
/// `true` while the button is held down.
pressed: bool,
/// How many consecutive ticks have disagreed with `pressed`.
held: u8,
}
/// A level change has to hold for this many consecutive 1 ms ticks before
/// it counts. Ten milliseconds is a starting point: long enough that
/// chatter cannot reach it, short enough that a deliberate press
/// registers on the first attempt.
const DEBOUNCE_TICKS: u8 = 10;
impl<I: InputPin> Button<I> {
fn new(pin: I) -> Self {
// A board starts with the button released; if it is held at
// power-on, the first accepted level corrects this on release.
Button {
pin,
pressed: false,
held: 0,
}
}
/// One tick of the poll loop. Returns `true` on the tick a press is
/// accepted — not on every tick the button is held.
fn tick(&mut self) -> bool {
// Active low: the pin reads low while the button is pressed. An
// error can't happen on this chip (`Infallible`), so a read that
// did fail would keep the last accepted level.
let last = self.pressed;
let raw = self.pin.is_low().unwrap_or(last);
if raw == self.pressed {
self.held = 0;
false
} else {
self.held += 1;
if self.held >= DEBOUNCE_TICKS {
self.pressed = raw;
self.held = 0;
// `raw` is `true` exactly when the new level is pressed;
// a release is accepted the same way but not reported.
raw
} else {
false
}
}
}
}
/// The breathing curve, kept free of chip types so the same struct runs
/// against a mock PWM channel on a host (that is how it was tested) and
/// against TIM2's channel 1 on the board.
struct Breath {
/// Ticks since the ramp started, 0..=2*half.
phase: u16,
/// Ticks in half a cycle: the ramp up, or the ramp down. The duty
/// climbs from 0 to full and back inside 2*half ticks.
half: u16,
/// Whether the breath runs at all; the button flips this.
running: bool,
}
impl Breath {
fn new(half: u16) -> Self {
Breath {
phase: 0,
half,
running: true,
}
}
/// A press switches the breathing off or on, and either way the next
/// breath starts from dark.
fn toggle(&mut self) {
self.running = !self.running;
self.phase = 0;
}
/// A new half-period restarts the ramp. A shorter half-period can
/// leave `phase` past the new turnaround, where `duty` would count
/// backwards from an underflow; restarting from dark costs at most
/// one ramp.
fn set_half(&mut self, half: u16) {
self.half = half;
if self.phase > half * 2 {
self.phase = 0;
}
}
/// The duty the LED should hold right now, in percent. The curve is
/// a triangle: straight up to full brightness, straight back down,
/// one tick wide at each turnaround.
fn duty(&self) -> u8 {
if !self.running {
return 0;
}
// Integer arithmetic only — no float in sight, and none needed
// for a straight line. The percent is the numerator over `half`.
let percent = if self.phase < self.half {
self.phase as u32 * 100
} else {
(2 * self.half as u32 - self.phase as u32) * 100
} / self.half as u32;
percent as u8
}
/// One tick of the loop. A paused breath does not advance.
fn advance(&mut self) {
if !self.running {
return;
}
self.phase += 1;
if self.phase > 2 * self.half {
self.phase = 0;
}
}
}
/// The mapping from a reading to a breath: each degree Celsius shaves
/// `TEMP_STEP` ticks off the half-period, so a warmer room breathes
/// faster. The clamp is what keeps a silly reading — a dead bus reads
/// 0 °C, a hot day 45 — from stopping the breath or turning it into a
/// strobe.
fn half_for(temperature: f64) -> u16 {
(MAX_HALF as f64 - TEMP_STEP * temperature).clamp(MIN_HALF as f64, MAX_HALF as f64) as u16
}
/// The loop, written against the traits instead of the chip's channel,
/// pin and bus types, as `breathe` was: `S` carries the bus the sensor
/// sits on, `P` the duty, `I` the button, `D` the time.
///
/// The button still pauses and resumes the breath. What the sensor adds
/// is the speed: one reading every `READING_TICKS`, and the temperature
/// in it sets the half-period. Until step 8 gives readings a channel to
/// be printed on, the LED is the only output the board has.
fn sense<S: I2c, P: SetDutyCycle, I: InputPin, D: DelayNs>(
sensor: &mut BMP280<S>,
led: &mut P,
button: &mut Button<I>,
delay: &mut D,
) -> ! {
let mut breath = Breath::new(DEFAULT_HALF);
let mut since_reading: u16 = 0;
loop {
if button.tick() {
breath.toggle();
}
// One write per tick, running or paused. On this chip the error
// type is `Infallible`, so the unwrap cannot fire.
led.set_duty_cycle_percent(breath.duty()).unwrap();
delay.delay_ms(1);
breath.advance();
since_reading += 1;
if since_reading >= READING_TICKS {
since_reading = 0;
// The driver returns degrees Celsius as an `f64`; on this
// chip that is a software double, and one reading every
// half second is not where the cycles go.
let reading = sensor.measure();
breath.set_half(half_for(reading.temperature));
}
}
}
/// The board sits with LD2 lit: the look a panic gives, reused for a
/// sensor that does not answer. Nothing else is running, so nothing
/// else can take the pin back — the mode bits are ours to rewrite.
fn stall_lit() -> ! {
// Safety: `ptr()` returns the register block's address without
// checking ownership. GPIOA exists at that address from reset
// onward, and nothing else runs once this has been called.
unsafe {
let gpioa = pac::GPIOA::ptr();
(*gpioa).moder().modify(|_, w| w.moder5().output());
(*gpioa).bsrr().write(|w| w.bs5().set_());
}
loop {}
}
/// What runs when the firmware panics. Step 2 filled this slot with the
/// `panic-halt` crate; step 4 replaced that with a handler of its own.
///
/// Step 6 gave PA5 to the timer, and a pin in alternate mode is driven
/// by the peripheral: a BSRR write alone would no longer reach it. The
/// mode bits are the handler's to rewrite, so the pin goes back to a
/// plain output first and the LED is the BSRR's again — a panic still
/// freezes the board with LD2 lit, which tells it apart from a breath
/// that merely stopped.
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
stall_lit()
}

What changed since step 6 is small and concentrated. The imports gain BMP280 and the I2c trait; main gains the bus, the sensor and the id check; breathe becomes sense, which is the same loop with a reading every 500 ticks that calls set_half. Breath gains set_half and nothing else. The panic handler loses its body to stall_lit, which the missing-sensor path shares — a panic and a silent sensor now freeze the board the same way, with LD2 lit.

One detail in sense is worth a second look. The driver hands back an f64, and on this chip that is a software double: the F401’s floating-point unit is single-precision, so the built firmware carries twelve __aeabi_d* routines — __aeabi_dadd, __aeabi_d2iz for the cast back to ticks — and not one __aeabi_f*. One reading per half second is not where the cycles go, but the compensation formula is dozens of double operations, and a firmware reading in a tight loop would feel it.

Terminal window
cargo build
Compiling document-features v0.2.12
Compiling time v0.3.55
Compiling syn v2.0.119
Compiling syn v3.0.9
Compiling enumflags2_derive v0.7.12
Compiling cortex-m-rt-macros v0.7.7
Compiling enumflags2 v0.7.12
Compiling bmpe280 v1.0.5
Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.25s

That is a cold build’s tail: the dependency closure, then the firmware itself. Cargo compiles dependencies in parallel, so the order of the middle lines varies between runs. bmpe280 is the only new name in the closure — the driver pulls in nothing the firmware did not already link. No --target — .cargo/config.toml pins it, as it has since step 2. The path after the package name is this machine’s project directory, and the build time is this machine’s too.

With the board plugged in:

Terminal window
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`
Error: No connected probes were found.

The last line is this machine again: no board attached, the same error a loose cable gives. Plug the Nucleo in and the same command writes the image, resets the chip and starts it. What the board does then: LD2 breathes, and the breath’s speed follows the temperature — once a second in a cold room, past twice a second as it warms. B1 pauses and resumes as before. And if the sensor is not answering at 0x76 — a module wired to the other address, a loose SDA — the id check fails and the LED goes solid, which is the one signal the board has until step 8.

The address is a choice, and the driver’s default may not be yours

Section titled “The address is a choice, and the driver’s default may not be yours”

The BMP280 answers at 0x76 or 0x77 depending on its SDO pin: pulled low, 0x76; left high, 0x77. The common modules pull it low on the board; some breakouts leave it high. The driver’s new defaults to 0x77, so passing the address explicitly — as this firmware does — is what keeps the two from quietly disagreeing. Two related traps: the address is right-aligned, so it is 0x76 and not 0xEC (the shifted form with the read/write bit that some other platforms want), and a wrong address does not fail — it reads as the cold, high-pressure ghost from the transcript above. If the LED goes solid at start-up, the address is the first thing to swap.

The driver cannot fail, so it cannot tell you it failed

Section titled “The driver cannot fail, so it cannot tell you it failed”

Every bus call inside bmpe280 discards its Result. A missing chip, a disconnected SDA, a module with no power: all of them come back as plausible numbers. The chip id is the only honest signal the driver offers, and it is worth exactly one read at start-up — which is what the firmware spends it on.

The first pressure reading is a few percent off

Section titled “The first pressure reading is a few percent off”

Covered in the transcript section, and repeated here because it is the kind of thing that sends someone hunting a hardware fault: the driver computes pressure from the previous sample’s t_fine, and at start-up that value is zero. Read the pressure twice and trust the second. The temperature has no such lag.

The driver implements the compensation in f64. On the F401 that is soft-float: twelve double-precision routines in the binary, none of them hardware. If a later step needs a reading per millisecond rather than per half second, an integer version of the same formula would need no float at all — the coefficients are already in registers.

The project lives in embedded-stm32/ in the examples repository, one commit and one tag per article. This article’s end state is stm32-ch07:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/embedded-stm32
git checkout stm32-ch07
cargo build

If a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.

Step 8, Serial output and logging on a microcontroller, gives readings somewhere to go: a UART connection and defmt, so the numbers stop being a brightness and start being output. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.