Problem: Writing I2C Drivers Takes Hours of Datasheet Parsing
You need to interface with an I2C temperature sensor (like the MCP9808) but manually translating register maps from datasheets into safe Rust code is tedious and error-prone.
You'll learn:
- How AI assistants speed up register mapping and bitfield generation
- Structuring drivers using embedded-hal traits for portability
- Testing embedded code without hardware using mocks
- Common pitfalls when AI generates embedded code
Time: 25 min | Level: Intermediate
Why This Happens
Embedded drivers require three tedious tasks that AI excels at:
- Register mapping: Converting datasheet tables to typed constants
- Bitfield manipulation: Safe bit shifting and masking
- Boilerplate: Trait implementations and error handling
Common pain points:
- Datasheets have 50+ registers with cryptic names
- Manual bit masking is error-prone (
(val >> 4) & 0x0F) - Testing requires physical hardware
What AI helps with: Generating type-safe register definitions, bitfield structs, and mock-friendly abstractions.
Solution
Step 1: Set Up Your Embedded Rust Project
# Create new embedded project
cargo new --lib mcp9808-driver
cd mcp9808-driver
# Add dependencies
cat >> Cargo.toml << 'EOF'
[dependencies]
embedded-hal = "1.0"
bitfield = "0.14"
[dev-dependencies]
embedded-hal-mock = "0.11"
EOF
Expected: Cargo.toml with embedded-hal 1.0 (the 2024+ stable API).
Why embedded-hal: Abstracts I2C/SPI/GPIO so your driver works on any platform (STM32, ESP32, Raspberry Pi).
Step 2: Use AI to Generate Register Map
Prompt to AI assistant:
"Given this MCP9808 datasheet excerpt, generate Rust register constants and a bitfield struct for the configuration register:
- Register 0x01: Configuration (16-bit)
- Bits 10-9: Alert polarity (0=active-low, 1=active-high)
- Bit 8: Alert mode (0=comparator, 1=interrupt)
- Bit 0: Shutdown mode (1=shutdown)
Use bitfield crate and const generics."
AI generates:
use bitfield::bitfield;
// Register addresses
const REG_CONFIG: u8 = 0x01;
const REG_TEMP: u8 = 0x05;
const REG_RESOLUTION: u8 = 0x08;
// Configuration register bitfield
bitfield! {
pub struct ConfigReg(u16);
impl Debug;
pub alert_polarity, set_alert_polarity: 10, 9;
pub alert_mode, set_alert_mode: 8;
pub shutdown, set_shutdown: 0;
}
impl From<[u8; 2]> for ConfigReg {
fn from(bytes: [u8; 2]) -> Self {
// I2C returns big-endian
ConfigReg(u16::from_be_bytes(bytes))
}
}
Review checklist:
- Register addresses match datasheet (check hex values)
- Bit positions are correct (AI sometimes inverts MSB/LSB)
- Endianness handled (I2C is typically big-endian)
If it fails:
- Compile error on bitfield syntax: Update bitfield crate to 0.14+
- Wrong bit order: Verify datasheet bit numbering (some use MSB-first)
Step 3: Implement Driver with embedded-hal
Prompt to AI:
"Create a driver struct that takes a generic I2C implementation using embedded-hal 1.0. Include methods to read temperature and configure shutdown mode."
AI generates (with your edits):
use embedded_hal::i2c::I2c;
pub struct Mcp9808<I2C> {
i2c: I2C,
address: u8,
}
#[derive(Debug)]
pub enum Error<E> {
I2c(E),
InvalidData,
}
impl<I2C, E> Mcp9808<I2C>
where
I2C: I2c<Error = E>,
{
pub fn new(i2c: I2C, address: u8) -> Self {
Self { i2c, address }
}
pub fn read_temperature(&mut self) -> Result<f32, Error<E>> {
let mut buf = [0u8; 2];
self.i2c
.write_read(self.address, &[REG_TEMP], &mut buf)
.map_err(Error::I2c)?;
// Parse 13-bit two's complement temperature
let raw = u16::from_be_bytes(buf);
let temp = if raw & 0x1000 != 0 {
// Negative temperature
((raw & 0x0FFF) as f32) / 16.0 - 256.0
} else {
((raw & 0x0FFF) as f32) / 16.0
};
Ok(temp)
}
pub fn set_shutdown(&mut self, enable: bool) -> Result<(), Error<E>> {
// Read current config
let mut buf = [0u8; 2];
self.i2c
.write_read(self.address, &[REG_CONFIG], &mut buf)
.map_err(Error::I2c)?;
let mut config = ConfigReg::from(buf);
config.set_shutdown(enable as u16);
// Write back
let bytes = config.0.to_be_bytes();
self.i2c
.write(self.address, &[REG_CONFIG, bytes[0], bytes[1]])
.map_err(Error::I2c)?;
Ok(())
}
}
Why this works: Generic over I2C trait means this compiles for STM32, ESP32, or Linux without changes.
AI mistakes to catch:
- Using blocking I2C instead of embedded-hal traits
- Forgetting error type propagation in generics
- Incorrect temperature formula (verify against datasheet)
Step 4: Test Without Hardware
#[cfg(test)]
mod tests {
use super::*;
use embedded_hal_mock::eh1::i2c::{Mock as I2cMock, Transaction};
#[test]
fn test_read_temperature() {
let expectations = [
// Expect write of temp register address, return 25.0°C
Transaction::write_read(0x18, vec![REG_TEMP], vec![0x01, 0x90]),
];
let i2c = I2cMock::new(&expectations);
let mut sensor = Mcp9808::new(i2c, 0x18);
let temp = sensor.read_temperature().unwrap();
assert!((temp - 25.0).abs() < 0.1);
sensor.i2c.done(); // Verify all transactions happened
}
#[test]
fn test_shutdown() {
let expectations = [
// Read config
Transaction::write_read(0x18, vec![REG_CONFIG], vec![0x00, 0x00]),
// Write config with shutdown bit set
Transaction::write(0x18, vec![REG_CONFIG, 0x00, 0x01]),
];
let i2c = I2cMock::new(&expectations);
let mut sensor = Mcp9808::new(i2c, 0x18);
sensor.set_shutdown(true).unwrap();
sensor.i2c.done();
}
}
Run tests:
cargo test
You should see: All tests pass without hardware connected.
Step 5: Use on Real Hardware
// Example for STM32F4 (works similarly on any platform)
use stm32f4xx_hal::{i2c::I2c, pac, prelude::*};
#[entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.freeze();
let gpiob = dp.GPIOB.split();
let scl = gpiob.pb8.into_alternate_open_drain();
let sda = gpiob.pb9.into_alternate_open_drain();
let i2c = I2c::new(dp.I2C1, (scl, sda), 100.kHz(), &clocks);
let mut sensor = Mcp9808::new(i2c, 0x18);
loop {
match sensor.read_temperature() {
Ok(temp) => {
// Log or display temperature
defmt::info!("Temperature: {:.2}°C", temp);
}
Err(e) => {
defmt::error!("Sensor error: {:?}", e);
}
}
cortex_m::asm::delay(1_000_000);
}
}
Verification
On hardware:
- Flash to your board:
cargo embed --release - Check serial output:
defmt-printor RTT viewer - Expected output:
Temperature: 23.45°Cupdating every second
Without hardware:
cargo test --lib
You should see: test tests::test_read_temperature ... ok
What You Learned
- AI accelerates register mapping but requires datasheet verification
- embedded-hal traits make drivers portable across all Rust platforms
- Mock testing validates logic before flashing hardware
- Bitfield crate prevents bit manipulation bugs
Limitations:
- AI may generate outdated embedded-hal 0.2 code (check imports)
- Complex state machines still need manual design
- Timing-critical code (SPI bit-banging) needs human review
When NOT to use AI:
- Safety-critical systems (medical, automotive) - require formal verification
- Real-time guarantees - AI doesn't understand interrupt priorities
- Custom protocols - datasheets aren't in training data
AI Assistant Tips for Embedded Code
Effective Prompts
✅ Good:
"Generate register definitions for MCP9808 with bitfield crate. Register 0x05 is temperature (13-bit two's complement, 0.0625°C resolution). Show conversion to f32."
❌ Bad:
"Write temperature sensor code"
Common AI Mistakes
| Issue | Fix |
|---|---|
Uses std library | Specify #![no_std] in prompt |
| Blocking delays | Ask for embedded-hal-async version |
| Wrong endianness | Always verify byte order in datasheet |
| Missing error handling | Request Result<T, Error<E>> pattern |
Review Checklist
- Uses
#![no_std]andcore::imports - Generic over HAL traits, not concrete types
- Error types propagate through generics
- Bitfield directions match datasheet (MSB/LSB)
- Timing calculations use datasheet formulas
Tested on Rust 1.75+, embedded-hal 1.0, STM32F401/ESP32-C3/Raspberry Pi Pico MCP9808 datasheet version: DS25095D