STM32 MIDI Controller Part 2: FIFO Buffered I2C EEPROM

Early on in the conceptualization phase of this project, I wanted something that had good customisability on board. That meant that I would need some sort of non-volatile memory to store all user changeable parameters, so I wouldn’t have to set everything up all over again every time I unplugged the thing. Out of all possible solutions, EEPROM would probably be the easiest to implement.

The STM32F103 does not have internal EEPROM (unlike some AVR micros) however, and I really didn’t want to go to all the trouble to decipher ST’s emulated EEPROM library. So, I settled on using an I2C EEPROM, which is actually a pretty cheap and easy-to-use way to add non-volatile memory to your projects. In my case, I chose the 24LC128, since I could get the DIP version of it for cheap on RS. Almost any I2C EEPROM would be fine, and the stuff in this article would apply to them as well.

Once again, there are many-many tutorials out there on how to use I2C EEPROMs; and these IC’s are really quite simple in terms of their communication protocol. However, with both I2C peripherals occupied for tasks with pretty critical timing requirements: one for updating the and LCD, the other driving an LED matrix, squeezing in yet another I2C device was not a trivial task. The LED matrix was continuously driven by DMA, and any pauses will cause the LEDs to flicker, which I really did not want. That left me with no choice but to tie the EEPROM to the same I2C peripheral driving the LCD (see Part 1 for more details).

Coming up with a solution

At the most fundamental level, I needed the EEPROM to do a couple of things:

  • Update the values of any parameters changed by the user
  • Recall all the parameters every time the MIDI controller is plugged in

Recalling the parameters is easy, and can be implemented in a naive way during initialization, ie before the main program loop starts. However, once the program enters the loop, there is a so much stuff going on that deciding when the I2C transaction the update parameter values becomes a challenge.

I considered, among other things, adding copious amounts of capacitance to the power rails so the microcontroller can detect itself being unplugged and still have enough power to flush all the modified parameter values to the EEPROM; or maybe I could add in an option to the menu to save all parameters and exit, kinda like how BIOS’es work, during which the device simply pauses its operation while it flushes the data to the EEPROM.

Since neither was particularly nice, I settled on implementing a FIFO buffer, which is essentially a queue. Then, every time the LCD gets updated, it checks to see if the FIFO buffer is empty, and writes the new stuff to the EEPROM in case its not.

FIFO buffers: a handy way to implement a queue for data flushing.

Once again, there are countless people out there on the internet that could explain how a FIFO buffer works much better than I can, so I’ll only give a quick overview here.

To visualise how a FIFO buffer works, picture a clock. One of those old school analog ones.

For those who need help with what an analog clock looks like.
Photo by Artem Riasnianskyi on Unsplash

Now, say for a second that the numbers 1 to 12 are positions in a 12 element array, the minutes hand is what we’ll call the input pointer, and the hours hand is the output pointer. Upon initialization, we set both pointers to point at the same number, which number exactly doesn’t matter, but for simplicity’s sake we’ll set them to point at 12.

The positions the pointers can point at are all modulo 12, meaning to say that they take values of 0 to 11 (12 on the clock face is equivalent to 0). If a pointer is currently pointing at 11, and you advance it 1 position, it’ll wrap around and point to position 0 again. The same thing also applies if you reverse the pointer any number of positions.

The first thing to wrap our heads around is: is both pointers are pointing at the same number, the FIFO is said to be empty. In other words, when the FIFO is full, the pointers should be next to one another, with the output pointer 1 position ahead of the input pointer (I promise this will become clear in just a moment).

Now, say you have something you want write to the EEPROM, and so you’d like to add it to the queue. To achieve that, we write that data value into the spot the input pointer is currently pointing at. Then, we advance the input pointer by 1 position.

When the time comes to write queued data out to the EEPROM, the code reads what is currently stored at the spot the output pointer is pointed at, then it advances the output pointer by 1. It does so over and over and over again until the output pointer is pointing at the same spot as the input pointer, meaning to say that it has flushed out all the queued data, and the FIFO is now empty.

Note: the FIFO isn’t really empty empty; the data hasn’t been cleared out of the array. It’s just that we have processed every single data currently saved in the array, and so don’t have anything left to process anymore.

Now, just extend this analogy to a clock with as many numbers as there are elements in your particular FIFO implementation.

FIFO buffer implementation

//implement a FIFO uint8_t eepromDataQueue[64]; volatile int8_t inputEEPROMBufferPosition = 0; volatile int8_t outputEEPROMBufferPosition = 0; //this will be incremented in an interrupt void EEPROMWriteParameter(uint16_t parameterAddress, uint8_t value){ eepromDataQueue[inputEEPROMBufferPosition] = (uint8_t)(parameterAddress >> 8); //load the address high byte into the queue inputEEPROMBufferPosition = ((inputEEPROMBufferPosition + 1) % (sizeof(eepromDataQueue)/sizeof(eepromDataQueue[0]))); //sizeof is compile time eepromDataQueue[inputEEPROMBufferPosition] = (uint8_t)(parameterAddress & 0xff); //load the address low byte into the queue inputEEPROMBufferPosition = ((inputEEPROMBufferPosition + 1) % (sizeof(eepromDataQueue)/sizeof(eepromDataQueue[0]))); //sizeof is compile time eepromDataQueue[inputEEPROMBufferPosition] = value; //load the data into the queue inputEEPROMBufferPosition = ((inputEEPROMBufferPosition + 1) % (sizeof(eepromDataQueue)/sizeof(eepromDataQueue[0]))); //sizeof is compile time }
Code language: C++ (cpp)

Also note that its really important to add the volatile keyword to anything that will be accessed/modified in interrupt service routines. The reasons are a huge can of worms beyond the scope of this article, but do look it up!

Since I’m using the 24LC128, the data addresses go from 0 to 16383, ie the address is 13 bits wide. Hence, it requires a 2 byte long address. Thus for every byte we want to write to the EEPROM, we actually need to send 3 bytes over the I2C bus: the address high byte, followed by the address low byte, followed by the actual data itself. Lines 8-9 show the adding of a byte to the FIFO buffer: the data is first written to the current position pointed to by the input pointer, then the input pointer is incremented by 1. Don’t get intimidated by all the stuff in line 9, its just there to ensure the pointer rolls over when it’s supposed to. It’ll make sense if you think about it, I promise.

This function is intended to be called in the button press handler function in the code driving the menu system every time the user changes a parameter.

Flushing the stuff in the queue into the actual EEPROM

If the queue has stuff in it, it will be flushed in the I2C 2 Event Interrupt, after the LCD is done updating.

Here is the code I wrote for the I2C 2 Event Interrupt:

void I2C2_EV_IRQHandler(void) { /* USER CODE BEGIN I2C2_EV_IRQn 0 */ if(I2C2->SR1 & (1<<2)){ //BTF is set if(EEPROMWriting){ //we are in the middle of an EEPROMWrite if(outputEEPROMBufferPosition != inputEEPROMBufferPosition){ //we still have data queued I2C2->DR = eepromDataQueue[outputEEPROMBufferPosition]; //load the current byte into the eeprom outputEEPROMBufferPosition = ((outputEEPROMBufferPosition + 1) % (sizeof(eepromDataQueue)/sizeof(eepromDataQueue[0]))); //advance the output pointer } else{ //we are done with the EEPROM, clear everything I2C2->CR1 |= (1<<9); //send stop condition isLCDPrinting = 0; //mark the I2C Bus as free for the next LCD Request EEPROMWriting = 0; //mark the EEPROM Writing process as done I2C2->CR2 &= ~(1<<9); //disable I2C2 Event Interrupt } } if(cycleEN){ GPIOA->BRR = 1<<8; //wait for the MCP23017 to have valid data GPIOA->BRR = 1<<8; GPIOA->BRR = 1<<8; GPIOA->BRR = 1<<8; GPIOA->BRR = 1<<8; GPIOA->BRR = 1<<8; GPIOA->BRR = 1<<8; GPIOA->BSRR = 1<<8; //this pulse is 100ns, aka too short, datasheet specifies min of 230 ns GPIOA->BSRR = 1<<8; GPIOA->BSRR = 1<<8; GPIOA->BSRR = 1<<8; GPIOA->BSRR = 1<<8; GPIOA->BRR = 1<<8; } if(currentLCDByte == 0 && EEPROMWriting == 0){ // we're done with the command byte, set RS GPIOB->BSRR = (1<<1); currentLCDByte++; I2C2->DR = LCDBuffer[currentLCDByte-1]; } else if(currentLCDByte == 17 && EEPROMWriting == 0){ //if we are done with the LCD, but the EEPROM is not in the middle of a write to prevent restarting //we're done with all characters, disable cycleEN cycleEN = 0; I2C2->CR1 |= (1<<9); //send stop condition I2C2->CR2 &= ~(1<<9); //disable I2C2 Event Interrupt if(outputEEPROMBufferPosition != inputEEPROMBufferPosition){ //we have data queued in the eeprom fifo, initiate a write EEPROMWriting = 1; //mark that we are now flushing data out to the EEPROM I2C2->CR1 &= ~(1<<8); I2C2->CR1 |= 1<<8; //send start condition while ((I2C2->SR1 & 1) == 0); //clear SB I2C2->DR = 0xA0; //address the EEPROM while ((I2C2->SR1 & (1<<1)) == 0); //wait for ADDR flag while ((I2C2->SR2 & (1<<2)) == 0); //read I2C SR2 while ((I2C2->SR1 & (1<<7)) == 0); //make sure TxE is 1 I2C2->DR = eepromDataQueue[outputEEPROMBufferPosition]; //load the current byte into the eeprom I2C2->CR2 |= (1<<9); //enable I2C2 Event Interrupt outputEEPROMBufferPosition = ((outputEEPROMBufferPosition + 1) % (sizeof(eepromDataQueue)/sizeof(eepromDataQueue[0]))); //advance the output pointer } else{ isLCDPrinting = 0; //mark the I2C Bus as free for the next LCD Request } } else if(EEPROMWriting == 0){ //only load in LCD Data if we are not in the middle of an EEPROM write currentLCDByte++; //load in next byte into DR here I2C2->DR = LCDBuffer[currentLCDByte-1]; } } /* USER CODE END I2C2_EV_IRQn 0 */ HAL_I2C_EV_IRQHandler(&hi2c2); /* USER CODE BEGIN I2C2_EV_IRQn 1 */ /* USER CODE END I2C2_EV_IRQn 1 */ }
Code language: C++ (cpp)

The LCD part of this ISR was explained in Part 1B, do read that article first if you want to get a better feel of what we’re dealing with here.

In line 63, we check to see if the FIFO buffer is empty, if it isn’t we proceed to set EEPROMWriting to 1, which sets the state of the “state machine” to, well, writing to EEPROM. We then go ahead and initiate an I2C transfer to the EEPROM, loading the byte pointed to by the output pointer into the data register, and advancing the output pointer by 1. Notice that the I2C event interrupt generation was disabled at line 61 to avoid a whole bunch of interrupts from being queued while we are doing all the starting and stopping and addressing stuff. It is only re-enabled once all that I2C setup is done, much like in the LCD setup function.

The ISR then proceeds to fire after every byte transferred, as usual. Then, when the FIFO is finally empty, it sends an I2C stop condition, resets the state of the ISR to print to the LCD, clears isLCDPrinting to let the main loop know that it can now request an LCD update, and finally disables the I2C 2 Event Interrupt because it is no longer needed until the next LCD update is requested.

At this point, I have to stress that when I wrote this code, I made a bold assumption that every time the ISR runs, only 1 byte is queued to be written to the EEPROM, which means that there are only 3 bytes queued in the FIFO buffer. This worked alright because the flushing of the queued data takes place every single time a line on the LCD is updated, which would occur anyway if the user were setting a parameter. Moreover, the function call to queue data into the FIFO buffer occurs on the pressing of the menu encoder, which avoids multiple calls between potential display updates, as might be the case if it were called every single time the menu encoder was rotated, as the LCD might not be done updating yet between encoder ticks.

All in all, this imperfect code ended up working for me, but you could implement a byte counter (like for the LCD parts of the code) to start and stop the I2C transaction every 3 bytes, because if more than 3 bytes were in the FIFO, the EEPROM will just write everything starting from the the first data byte sequentially into memory, which is obviously not what we want.

But, you would need to ensure the EEPROM is done flushing internally first before requesting another write, so you need to send over a stop condition, wait for the EEPROM to write, then send a start condition again and repeat the whole thing. Restarts won’t work since the EEPROM only starts its internal flushing on receiving a stop condition. Everything gets quite a bit more complicated.

Write cycle time trap for young players

This initiates the internal write cycle and during this time, the 24XX128 will not generate Acknowledge signals (Figure 6-1)

Section 6.1 from the 24LC128 Datasheet

As it turns out, the EEPROM needs quite some time to process write requests, a maximum of 5ms according to the datasheet, during which it simply won’t respond to any requests on the I2C bus, including acknowledging when it sees its address on the bus. This 5ms figure is the same regardless of how many bytes you send over (with a maximum of 64 bytes per I2C transaction), the EERPOM essentially does a full 64 byte page refresh every time, so its not like it takes longer if you send more stuff over.

When doing a write of less than 64 bytes the data in the rest of the page is refreshed along with the data bytes being written. This will force the entire page to endure a write cycle, for this reason endurance is specified per page

Section 6.1 from the 24LC128 Datasheet

This would be a huge problem for the while loop based I2C implementation I’ve used thus far, since the ADDR bit will never be set and the entire program will just halt. But, through a combination if using a slow 100kHz I2C speed, and only performing writes after sending a full 16 bytes over the slow I2C bus, the EEPROM has no issues clearing everything in time.

Page wraparound trap for young players

Page write operations are limited to writing bytes within a single physical page, regardless of the number of bytes actually being written. Physical page boundaries start at addresses that are integer multiples of the page buffer size (or ‘page size’) and end at addresses that are integer multiples of [page size – 1]. If a Page Write command attempts to write across a physical page boundary, the result is that the data wraps around to the beginning of the current page (over-writing data previously stored there), instead of being written to the next page, as might be expected. It is, therefore, necessary for the application software to prevent page write operations that would attempt to cross a page boundary

Section 6.3 from the 24LC128 Datasheet

This one isn’t really a problem here, since we are only writing at most 1 byte each time. But if you want to write multiple bytes (sequentially) at once, then it might be a real Murphy.

Inside the EEPROM, the cells are organized into pages: bytes 0-63 are page 1 (or 0, doesn’t matter), 64-127 are page 2, etc.

The gotcha arises if you try to write past these page boundaries. For example, say you tried to write from bytes 63 to 65. Instead of writing bytes 63, 64, and 65, the EEPROM wraps around, and writes bytes 63, 0 and 1! That is a surefire way to ruin your day if you weren’t aware of it!

Leave a Comment

Your email address will not be published.