Datalogger 2 – Contiguous EEPROM address space (digression)

TOC:

  1. Chapter 0 – Introduction
  2. Chapter 1 – Arduino I2C
  3. Chapter 2 – Continuous EEPROM Addressing (digression)
  4. Chapter 3 – Update and Sleep Design
  5. Chapter 4 – Clock Design and Sleep Investigation
  6. Chapter 5 – Gallery
  7. Chapter 6 – Results
  8. Chapter 7 – Final Report
  9. Chapter 8 – Followup 1

Introduction

I was reading the datasheet for the 24AA1025 EEPROM (1024Kbits, if you remember), and noticed some interesting features of its internal memory addressing scheme.  I thought they might be helpful if I wanted to increase the storage capacity of the embedded sensor device later on, and so I decided it would be worth building into the EEPROM interface abstraction layer that I’m putting together for the device.

There are really two things at work here, that must fit together properly for everything to work.  The first is the mechanism for dealing with large blocks of memory and chunks of data that crosses the boundary between these blocks.  The second is the mechanism for dealing with relatively small blocks of memory (i.e., pages) and chunks of data that cross the boundary between these pages.  I think it will be easier to start at the top, and explain what’s happening for the large blocks, and then dig down and explain how to handle pages at a lower level.  This mirrors how the code will handle it, in any case.

Device Addressing

According to the datasheet of the 24AA1025, the I2C address of each chip is configurable.  The top 4 bits are fixed to 1010, and of the following 3 bits (remember, I2C addresses are only 7 bits wide), the first is a “block select bit” (more on this later), and the final two bits are “chip select” bits.

The 24AA1025 is internally divided into two units, block 0, and block 1.  You may have noticed that when sending memory addresses over I2C that it accepts two bytes (MSB and LSB) for each address.  In other words, there are 16 bits of address available, which means the lowest address is 0x0000 and the highest is 0xFFFF.  This is enough to address 64KB  (65,536 bytes) of memory.  “But wait!”, you say, because you remember that this device has a purported capacity of 1024Kbits (128KB).  This amount of memory requires 17-bits to fully address it (from 0x00000 to 0x1FFFF).  That’s where the block select bit comes in — it acts as the 17th bit of the address space, but you have to use it in the I2C address of the device itself, not the memory address.    So, in essence, each 24AA1025 EEPROM chip acts like 2 separate devices on the I2C bus, each with 64KB of addressable memory.

To be able to treat the entire EEPROM device as a single, contiguous 128KB block of memory, we need to do some wrangling with the read and write functions.  Let’s deal with writing first, and reading later.

Writing to Memory

So, say we have a single 24AA1025 device, and an address space of 0x00000 – 0x1FFFF.  For a write of any given size, there are three possible cases.  Either the entirety of the block to be written will land in the first device (0x0000 – 0xFFFF) or the entirety will land in the second device (0x10000 – 0x1FFFF), or the block will start in the first device and cross the boundary into the second.  This is easy to determine, just look at the address to be written and the length of thewrite operation.

// prefix is binary 1010
#define DEVICE_PREFIX 0xA
#define DEVICE_SIZE   0x10000
#define MAX_ADDR      0x1FFFF

// since our address space is now greater than 16 bits, use a 32-bit value
void write_buffer(uint32_t addr, uint8_t* data, uint32_t length) {
  uint8_t dev_id = (DEVICE_PREFIX << 3);
  // first case, write entirely in 1st block
  if (addr < DEVICE_SIZE && addr + length < DEVICE_SIZE) {
    // set the block bit and chip select bits all to zero
    dev_id = dev_id | B000;
    write_device_buffer(dev_id, (uint16_t)addr, data, (uint16_t)length);

  // second case write entirely in 2nd block
  } else if (addr > DEVICE_SIZE) {
    // set the block bit, but leave the chip select bits at zero
    dev_id = dev_id | B100;
    write_device_buffer(dev_id, (uint16_t)addr, data, (uint16_t)length);

  // third case, write spans boundary between blocks
  } else {
    // break it into two writes
    uint8_t first_length = DEVICE_SIZE - addr;
    dev_id = dev_id | B000;
    write_device_buffer(dev_id, (uint16_t)addr, data, first_length); 

    // offset into data the number of bytes already written
    dev_id = dev_id | B100;
    write_device_buffer(dev_id, 0x0, (uint8_t*)&data[first_length], length - (first_length));
  }
}

write_device_buffer(uint8_t dev_id, uint16_t addr, uint8_t* data, uint16_t length) {
  // ... write length bytes of data to I2C device dev_id at location addr
}

Boy, this is getting long…

So, since we’re going to all the trouble of addressing two separate blocks on one device, why not add support for multiple devices?  Since the 24AA1025’s chip select bits essentially give us two additional bits of address space, we have a total of 19 bits available (16 per block + 1 for block select + 2 for chip select), for a maximum total addressable memory space of 0x00000 – 0x7FFFF, or 512 KB.  To support this, we’d update our code to something like this (which is more elegant anyway):

#define DEVICE_PREFIX B1010
#define DEVICE_SIZE   0x10000
#define MAX_ADDR      0x1FFFF

// change this to the number of chips in your circuit
// they must have sequential device addresses, according to the chip select pins
#define EEPROM_CHIPS   4
// two "devices" per chip
#define DEVICES        (EEPROM_CHIPS*2)

// reverse dev offset
// 000 - first 'device' on 1st chip
// 100 - second 'device' on 1st chip
// 001 - first 'device' on 2nd chip
// 101 - second 'device' on 2nd chip
// etc.
uint8_t DEV_REVERSE_LOOKUP[] = { B000, B100, B001, B101, B010, B110, B011, B111 };

// since our address space is now greater than 16 bits, use a 32-bit value
static bool write_buffer(uint32_t address, uint8_t* data, uint32_t length) {
 if (address > MAX_ADDR) {
   return false;
 }
 if (address + length > MAX_ADDR) {
   return false;
 }
 uint32_t start_byte        = address;
 uint32_t end_byte          = address + length;
 uint32_t curr_device_start = (address / DEVICE_SIZE) * DEVICE_SIZE;
 uint32_t next_device_start = curr_device_start + DEVICE_SIZE;

 bool success = true;
 while (end_byte > next_device_start && success) {
   uint8_t dev_offset = start_byte / DEVICE_SIZE;
   uint8_t dev_id     = (EEPROM_ID_PREFIX << 3) | DEV_REVERSE_LOOKUP[dev_offset];
   success = write_buffer(dev_id,
                          (uint16_t)(start_byte % DEVICE_SIZE),
                          (uint8_t*)&(data[start_byte - address]),
                          next_device_start - start_byte);

    curr_device_start = next_device_start;
    next_device_start = curr_device_start + DEVICE_SIZE;
    start_byte        = curr_device_start;
  }
  if (!success) {
    return false;
  }
  uint8_t dev_offset = start_byte / DEVICE_SIZE;
  uint8_t dev_id     = (EEPROM_ID_PREFIX << 3) | DEV_REVERSE_LOOKUP[dev_offset];
  return write_buffer(dev_id,
                      (uint16_t)(start_byte % DEVICE_SIZE),
                      (uint8_t*)&(data[start_byte - address]),
                      end_byte - start_byte);
}

write_device_buffer(uint8_t dev_id, uint16_t addr, uint8_t* data, uint16_t length) {
  // ... write length bytes of data to I2C device dev_id at location addr
}

Great!  Now we can write to our N<=4 EEPROM chips as if they were a single address space.  Except…

Page Addressing

…it’s a little more complicated than that.  If you recall, the datasheet specifies a maximum data size of 128 bytes for a single I2C write command.  This is because, it explains, there’s an internal write buffer that’s (you guessed it) 128 bytes long, aligned with 128-byte pages (which covers a range of 0x00 – 0x7F , if you’re keeping track).  This also means that a 16 byte write into, say, address 120, will wrap around the buffer and overwrite addresses 0 – 7, instead of crossing the page boundary.  So, just as we had to check if writes crossed device boundaries and break them up when necessary, so we have to also check if write cross page boundaries and break *them* up, likewise.  Fortunately, the algorithm closely follows the one we’ve already developed, and it goes something like this:

#define PAGE_SIZE 0x80

// since we're dealing within the 64 KB address space of a single device,
// addresses are limited to 16 bits...
static bool write_buffer(uint8_t dev_id, uint16_t address, uint8_t* data, uint16_t length) {
 uint16_t start_byte = address;
 uint16_t end_byte   = address + length;

 uint16_t curr_page_start = (address / PAGE_SIZE) * PAGE_SIZE;
 uint16_t next_page_start = curr_page_start + PAGE_SIZE;

 bool success = true;
 while (end_byte > next_page_start && success) {
   success = write_page(dev_id,
                        start_byte,
                        &(data[start_byte - address]),
                        next_page_start - start_byte);
   curr_page_start = next_page_start;
   next_page_start = curr_page_start + PAGE_SIZE;
   start_byte = curr_page_start;
 }
 if (!success) {
   return false;
 }

 return write_page(dev_id, start_byte, &(data[start_byte - address]), end_byte - start_byte);
}

void write_page(uint8_t dev_id, uint16_t addr, uint8_t* data, uint8_t length) {
  // your basic I2C start, write, stop sequence
  ,,,
}

Write Polling

Again, according to the datasheet, once the master has written to the page buffer and sent the “stop” condition, the 24AA1025 will write the page buffer to its actual location.  It will not ACK any I2C commands until this write cycle is complete.  We can use this to
determine when the write operation is complete and the next write can be initiated.  Since we prefer simplicity of code to extreme parallel performance, we don’t care that everything else is waiting for the EEPROM to complete its internal operation (separating it out is left as an exercise for the reader).  To find out when it’s done, we can keep checking to see when the device will ACK a start condition, before we return from the write operation.

// data can be maximum of 128 bytes, according to the data sheet
// we may need to do some internal smarts here to determine page boundaries and break up write operations
static bool write_page(uint8_t dev_id, uint16_t eeaddress, uint8_t* data, uint8_t length ) {
 if (twi.start(dev_id, I2C_WRITE)) {
   twi.write((uint8_t)((eeaddress >> 8) &0xFF));
   twi.write((uint8_t)(eeaddress & 0xFF));
   for (uint8_t c = 0; c < length; c++) {
   twi.write(data[c]);
 }
 twi.stop();
 // 24AA1025 will not acknowledge start conditions until the write cycle is complete
 while(!twi.start(dev_id, I2C_WRITE)) {};
 twi.stop();

 return true;
 } else {
   return false;
 }
}

I haven’t compiled all of this code, but it works on paper.  If you’re doing multi-byte writes to EEPROM (like I am) and don’t want to have to worry about page boundaries, or if you’re writing very large blocks of data to EEPROM (say, to dump or erase data) then this abstraction might be useful to you.

Reading from Memory

This part is quite simple.  Again, according to the datasheet, the 24AA1025 can read up to the entire 16-bit address space on a device in a single operation.  So, we only need to split up read operations that cross device boundaries, and we’ve already figured out how to do that in the write_buffer function we put together above.  So, we end up with something like this:

static bool read_buffer(uint32_t address, uint8_t* data, uint32_t length) {
 uint32_t start_byte        = address;
 uint32_t end_byte          = address + length;
 uint32_t curr_device_start = (address / DEVICE_SIZE) * DEVICE_SIZE;
 uint32_t next_device_start = curr_device_start + DEVICE_SIZE;

 bool success = true;
 while (end_byte > next_device_start && success) {
   uint8_t dev_offset = start_byte / DEVICE_SIZE;
   uint8_t dev_id     = (EEPROM_ID_PREFIX << 3) | DEV_REVERSE_LOOKUP[dev_offset];
   success = read_buffer(dev_id,
                         (uint16_t)(start_byte % DEVICE_SIZE),
                         (uint8_t*)&(data[start_byte - address]),
                         next_device_start - start_byte);

   curr_device_start = next_device_start;
   next_device_start = curr_device_start + DEVICE_SIZE;
   start_byte        = curr_device_start;
 }
 if (!success) {
   return false;
 }
 uint8_t dev_offset = start_byte / DEVICE_SIZE;
 uint8_t dev_id     = (EEPROM_ID_PREFIX << 3) | DEV_REVERSE_LOOKUP[dev_offset];
 return read_buffer(dev_id, (uint16_t)(start_byte % DEVICE_SIZE), (uint8_t*)&(data[start_byte - address]), (uint16_t)(end_byte - start_byte));
}

bool read_buffer(uint8_t dev_id, uint16_t address, uint8_t *buffer, uint16_t length ) {
 uint8_t i = 0;
 if (twi.start(dev_id, I2C_WRITE)) {
   twi.write((uint8_t)((address >> 8) &0xFF));
   twi.write((uint8_t)(address & 0xFF));
   twi.start(dev_id, I2C_READ);
   while (i < length-1) {
     buffer[i++] = twi.read(false);
   }
   buffer[i] = twi.read(true);
   twi.stop();
   return true;
 } else {
   Serial.println("nack for dev_id / write");
   return false;
 }
}

That’s it for this one.  That should be a nice, reuseable library for working with 24AA1025 EEPROM devices.  If I pull it out into a separate object, I’ll post the code.

Advertisements
This entry was posted in Datalogger, Make It Last, Projects. Bookmark the permalink.

13 Responses to Datalogger 2 – Contiguous EEPROM address space (digression)

  1. Pingback: Make It Last 0 – Introduction | Schazamp's Blog

  2. Pingback: Make It Last 3 – Status Update and Sleep Design | Schazamp's Blog

  3. Pingback: Make It Last 1 – Arduino I2C | Schazamp's Blog

  4. Pingback: Make It Last 4 – Clock Design and Sleep Investigation | Schazamp's Blog

  5. Pingback: Make It Last 5 – Gallery | Schazamp's Blog

  6. Pingback: Make It Last 6 – Results | Schazamp's Blog

  7. Pingback: Make It Last 7 – Final Report | Schazamp's Blog

  8. Pingback: Make It Last 8 – Followup 1 | Schazamp's Blog

  9. Pingback: Bus Pirate v3b FTDI | blogsville

  10. Aaron says:

    Your blog is helping me understand how to use the 24aa1025 in my own project. Also your programing examples and explanations are just great too. I have a couple of quick questions about one of your code segments:

    what does the & sign do when you pass the data record s to this function?
    why do you prefer to use the uint8_t format for declaring variables?

    void writeSample(const data_record_type& s) {
    // compute the offset into the EEPROM for the sample
    uint32_t addr = SAMPLE_DATA_ADDRESS + (sizeof(s) * s.sample);
    i2c_eeprom_write_buffer(addr, (uint8_t*)&s, sizeof(s));
    // save the total number of samples
    i2c_eeprom_write_buffer(TOTAL_SAMPLES_ADDRESS, (uint8_t*)&total_samples, sizeof(total_samples));
    }

    Thanks for sharing your work 🙂

    • schazamp says:

      The & in the parameter to the function indicates that it’s a “pass by reference” parameter, which instructs the compiler to treat that variable as if it had been passed to the function with a pointer, automatically dereferencing it anywhere it is used. I chose to use it because it saves the compiler creating a copy of the variable on the stack for the function call. You have to be careful using pass-by-reference, though, because normal pass-by-reference variables can be modified within the function, for example, if I assigned something to s or a field of s. However, since I specified the “const” modifier, the compiler knows that within the scope of this function s is to be considered a constant reference, and will produce a compile error if I try to assign anything to it.

      I prefer to use explicitly sized and signed types like uint8_t and uint32_t, because it’s helpful in certain situations to know that a variable uses exactly a given size, in bytes, whereas, generally, an “int” can vary in size from platform to platform. I prefer to indicated the signed / unsigned -ness of variables to remind me of the data range I am expecting the variable to contain. Any warnings that the compiler can give me about using signed / unsigned things improperly can help me identify bugs.

  11. Aaron says:

    Thanks for the explanation, I am studying up on pointers and I did not know about the variability of the int on different platforms.

    Hey I have one more for you…
    In the function: i2c_eeprom_write_buffer()
    – I can’t figure out when: end_byte > next_device_start
    – I have populated a spreadsheet with the EEPROM parameters to see when this condition might happen, but haven’t been able to figure it out… I think I might be missing something.

    Thanks!

    • schazamp says:

      The end_byte > next_device_start check is to see if the write or read spans multiple EEPROM chips, to determine whether it needs to break the operation up. It’s essentially the same check as the check to see if a write spans a page, but it only comes into play if you have several EEPROM chips configured at contiguous I2C addresses and are treating them as a single, unified address space.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s