Write an I2C Driver in Rust with AI in 25 Minutes

Build a production-ready embedded Rust driver for I2C sensors using AI assistance. Learn HAL abstractions, register mapping, and testing strategies.

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:

  1. Register mapping: Converting datasheet tables to typed constants
  2. Bitfield manipulation: Safe bit shifting and masking
  3. 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:

  1. Flash to your board: cargo embed --release
  2. Check serial output: defmt-print or RTT viewer
  3. Expected output: Temperature: 23.45°C updating 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

IssueFix
Uses std librarySpecify #![no_std] in prompt
Blocking delaysAsk for embedded-hal-async version
Wrong endiannessAlways verify byte order in datasheet
Missing error handlingRequest Result<T, Error<E>> pattern

Review Checklist

  • Uses #![no_std] and core:: 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