zuendmasse

adventures of a random geek

Let’s write an embedded-hal-driver

Mid January japaric started “The weekly driver initiative” with the goal to kick-start releases of platform agnostic embedded-hal based driver crates. In this post we’ll build an embedded-hal-driver for the AT24C32-EEPROM chip.

What’s an EEPROM?

EEPROM stands for electrically erasable read only memory. It is quite slow and has a low memory density compared to flash memory, but allows single byte operations (read/write). Modern EEPROMs also offer multi byte page reads and writes.

What is it good for?

While you shouldn’t use EEPROMs to store huge or often changing data, they are useful to hold serial numbers, telephone numbers, configuration data, calibration data… basically everything which is seldom changed. EEPROM cells can typically be rewritten about a million times, so think about it before you dump your logs or sensor data into it.

AT24C32

I happen to own an DS3231 real time clock (RTC) breakout board which also contains an Atmel AT24C32 EEPROM and is accessible through i2c-bus. We ignore the RTC for now and focus on the EEPROM.

A good start is to take a look in the AT24C32 datasheet to get an overview of the chips opcodes and features. The device supports up to eight different i2c addresses depending on the state of the A0, A1 and A2 pin. This means we can connect up to eight of these EEPROMs to a single i2c-bus (without address modifications at runtime). And there are some commands we’ll have to implement to actually write or read bytes:

  • single byte write
  • page write
  • current address read
  • random read
  • sequential read

Electrical connection

The device communicates over the i2c-bus to the outside world. Mine is part of a RTC breakout board which looks like this:

ds3231_at24c32.png

The orange rectangle marks the AT24C32 chip. The green one shows 3 solder bridges you can short to change the i2c address. The red rectangle marks the 4 pins we need to connect to our test machine. I’m using a raspberry pi 2 and the following connection:

RPi pin AT24C32 pin J1 pin
1 3.3V 8 VCC 5 VCC
9 GND 4 GND 6 GND
3 I2C1_SDA 5 SDA 4 SDA
5 I2C1_SCL 6 SCL 3 SCL

Testing the setup

We can now test the connection by scanning the bus for devices. To do this we’ll first need to enable i2c on the RPi:

  • sudo raspi-config
  • 5 Interfacing Options
  • P5 I2C
  • <Yes>
$ ls /dev/i2c*
/dev/i2c-1

Let’s install some helpers:

$ sudo apt-get install -y i2c-tools

And finally check if our device is sitting in the i2c-bus.

$ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:          -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- 57 -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- 68 -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

There they are. Address 0x68 is the RTC and 0x57 is our AT24C32 because all solder bridges are open and the A* pins are pulled up. The base address of the device is 0b1010000 (0x50) and the last 3 LSB are determined by the A* pins.

The driver crate

The hardware works, now let’s talk to it. We let cargo create a new crate and setup a new target to cross compile to the raspberry pi, so we can quickly build an example and test it on our target.

$ cargo new at24cx
    Created library `at24cx` project
$ cd at24cx
$ mkdir .cargo
$ editor .cargo/config && cat $_
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc-6"
$ cargo build --target=armv7-unknown-linux-gnueabihf
   Compiling at24cx v0.1.0 (file:///home/wose/projects/rust-embedded/at24cx)
    Finished dev [unoptimized + debuginfo] target(s) in 0.34 secs

Dependencies

embedded-hal provides i2c traits we’ll use to talk to the i2c-bus in a platform agnostic way. To test it we’ll need an implementation of the embedded-hal traits. linux-embedded-hal provides this implementation for linux and thus for the raspberry pi.

$ cargo add embedded-hal
$ cargo add --dev linux-embedded-hal

Write/Read a single byte

Let’s try to create a minimal driver to write a single byte to the EEPROM and read it back. Another look in the datasheet reveals what we need to send to write a single byte:

byte-write.png

  • the device address (0x57) with the R/W bit 0 (write to the slave)
  • MSBs of the 16 bit address (the memory address is actually just 12 bit for the AT24C32)
  • LSBs of the 16 bit address
  • the data byte

What about reading a random memory address?

random-read.png

Similar to writing a single byte, we first need to write the device and memory address to the i2c-bus and then start a read by sending the device address with the R/W bit 1 (read from the slave). The EEPROM will then send the data at that memory address.

#![no_std]

extern crate embedded_hal as hal;

use hal::blocking::i2c::{Write, WriteRead};

// we'll add support for the other 7 addresses later
pub const ADDRESS: u8 = 0x57;

/// AT24Cx Driver
pub struct AT24Cx;

impl AT24Cx
{
    pub fn new() -> Self {
        AT24Cx {}
    }

    pub fn write<I2C, E>(&self, i2c: &mut I2C, address: u16, byte: u8) -> Result<(), E>
    where
        I2C: Write<Error = E> + WriteRead<Error = E>,
    {
        let msb = (address >> 8) as u8;
        let lsb = (address & 0xFF) as u8;
        i2c.write(ADDRESS, &[msb, lsb, byte])
    }

    pub fn read<I2C, E>(&self, i2c: &mut I2C, address: u16) -> Result<u8, E>
    where
        I2C: Write<Error = E> + WriteRead<Error = E>,
    {
        let msb = (address >> 8) as u8;
        let lsb = (address & 0xFF) as u8;
        let mut buffer = [0];
        i2c.write_read(ADDRESS, &[msb, lsb], &mut buffer)?;
        Ok(buffer[0])
    }
}

Now we add an example to actually test our driver.

extern crate at24cx;
extern crate linux_embedded_hal as hal;

use at24cx::AT24Cx;
use hal::I2cdev;
use std::thread;
use std::time::Duration;

fn main() {
    let mut dev = I2cdev::new("/dev/i2c-1").unwrap();
    let eeprom = AT24Cx::new();

    eeprom.write(&mut dev, 0x0042, 42).unwrap();

    // wait 10ms for the write to finish or the eeprom will NAK the next write or read request
    thread::sleep(Duration::from_millis(10));

    println!(
        "The answer to the ultimate question of life, the universe and everything is {}.",
        eeprom.read(&mut dev, 0x0042).unwrap()
    );
}

Build and run it on the RPi:

$ cargo build --target=armv7-unknown-linux-gnueabihf --example rpi
$ # copy the example to your RPi
$ ssh pi@pi
$ ./rpi
The answer to the ultimate question of life, the universe and everything is 42.

Yay! This driver will now work on any platform which has an embedded-hal i2c trait implementation. But there is more. We can get rid of the delay in our example by polling the EEPROM for the finished write operation and also write and read multiple bytes in one go.

Memory pages

The memory inside the EEPROM can be visualized as a table. The rows represent pages and the columns the data words inside a page. The size of a page and data word is device specific. The AT24C32 has a word size of 8 bit (or 1 byte) ,a page size of 32 words and has 128 pages (128 * 32 * 8 = 32768 bits).

Why is this important? Every time we write or read a word the internal address pointer of the EEPROM is incremented, so the next read or write operation will use the next byte. But if we hit a page boundary we won’t move to the next page but instead start at the beginning of the current page (only the lower 5 bits of the memory address are incremented). Sending more bytes than the page size (32) will overwrite data we already sent.

page-write.png

A page write is very similar to single byte write, just send more data bytes instead of the STOP.

...
    pub fn write_page<I2C, E>(&self, i2c: &mut I2C, address: u16, data: &[u8]) -> Result<(), E>
    where
        I2C: Write<Error = E> + WriteRead<Error = E>,
    {
        // limit is the page size or we would overwrite data we jyst sent
        let len = min(data.len(), 32);

        // 2 address bytes + page size
        let mut  buffer = [0; 34];
        {
            let (addr, dst) = buffer.split_at_mut(2);
            BE::write_u16(addr, address);
            dst[..len].clone_from_slice(&data[..len]);
        }

        i2c.write(ADDRESS, &buffer[..data.len()+2])
    }
...

Note that we now use the byteorder crate to format the address instead of doing so by hand. The following example will test this by filling page 1 with 0xEE.

extern crate at24cx;
extern crate linux_embedded_hal as hal;

use at24cx::AT24Cx;
use hal::I2cdev;

fn main() {
    let mut dev = I2cdev::new("/dev/i2c-1").unwrap();
    let eeprom = AT24Cx::new();
    eeprom.write_page(&mut dev, 32, &[0xEE; 32]).unwrap();
}

To read more than one byte in one go we’ll modify the current read method to read an arbitrary amount of bytes. Sequential read operations are not limited to a single page. If the end of the memory is reached the internal address pointer will roll over and continue at the beginning of the memory. So in theory we should be able to read the entire EEPROM with one transaction.

sequential-read.png

...
    pub fn read<B, I2C, E>(&self, i2c: &mut I2C, address: u16) -> Result<B, E>
    where
        B: Unsize<[u8]>,
        I2C: Write<Error = E> + WriteRead<Error = E>,
    {
        let mut addr = [0; 2];
        BE::write_u16(&mut addr, address);

        let mut buffer: B = unsafe { mem::uninitialized() };
        {
            let slice: &mut [u8] = &mut buffer;
            i2c.write_read(ADDRESS, &addr, slice)?;
        }

        Ok(buffer)
    }
...

The following example will dump the complete EEPROM memory and we should see our answer from the first example somewhere near the beginning of the memory and page 1 should contain 0xEE for every byte.

extern crate at24cx;
extern crate linux_embedded_hal as hal;

use at24cx::AT24Cx;
use hal::I2cdev;

fn main() {
    let mut dev = I2cdev::new("/dev/i2c-1").unwrap();
    let eeprom = AT24Cx::new();

    let mem: [u8;4096] = eeprom.read(&mut dev, 0x0000).unwrap();
    for page in mem.chunks(32) {
        for byte in page {
            print!("{:X} ", byte);
        }
        println!();
    }
}

And run it:

$ ./rpi
41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F 50 51 52 53 54 55 56 57 58 59 5A FF FF FF FF FF FF
EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE EE
FF FF 2A FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
...

We can spot the previously set value 42 (0x2A) at row 3 column 3 with the memory address 2 * 32 + 2 = 66 (0x0042), which was the address we used for the write. Yay!

We can also notice that the start of page 0 is filled with the letters A-Z. This may be some remains from factory tests, they weren’t written by me.

Conclusion and TODOs

We now have a platform agnostic driver for the AT24C32 EEPROM. Actually, we can also use it with the AT24C64 EEPROM, because they have the same page and word size. Many EEPROMs have the same or a very similar interface and they differ only in address and page size. Adding other chips should be easy. I’ll do some refactoring to make this straightforward and add some of the AT24CXXX chips myself. Pull requests are always welcome.

The current WIP driver is on github. And will be released to crates.io after the ACK polling has been added. The README.md contains a list of implemented and planned features. Feel free to open an issue if something is missing or could be improved.

I’ll try to cover the DS3231 RTC in a later post (WIP driver).

published: 2018-02-23 Fri 00:00 updated: 2022-05-11 Wed 10:20 wose CC-0