Electronic Business Card Part 2: Display Drivers

Referenced in this Blog:

What is I2C?

If you’ve ever looked into hobbyist electronics, then you’ve probably heard of I2C. It’s an extremely popular communication protocol for connecting all sorts of devices: sensors, displays, motor drivers, entire other systems, and just about anything else you can think of. I2C’s real name is IIC (ew), which stands for ‘Inter-Integrated Circuit’. The beauty of I2C is that it uses extremely simple physical circuits and rules for communication. If you’ve never bothered to learn about it, what better time than now?

I2C Physical Circuit

Let’s look at the (extremely simplified) physics of how I2C works. Every device that wants to talk using I2C connects to the same two physical wires: Serial Clock (SCL) and Serial Data (SDA); together these two wires make up the I2C Bus. This means that I2C can be used to talk to as many devices as you want with only 2 wires and might look something like this:

Each device that connects to the I2C bus uses a special type of physical connection called Open-Drain; this means that instead of writing normal digital logic voltages of 3.3V for 1 and 0V for 0, the connection functions like a short circuit to ground that is either open for 1 or closed for 0. Each wire of the I2C bus contains a special purpose pull-up resistor that keeps the voltage on the bus at 3.3V unless one of the devices shorts to ground. This means that the physical interface at each device looks something like this:

With Open-Drain connections every device on the I2C bus can theoretically talk at the same time by writing to the bus and watching the bus at the same time. If a device on the bus wants to write a 1, they leave their switch open and the bus voltage will stay at 3.3V. However, if just one device wants to write a 0, they close their switch and the bus voltage for everyone will be pulled down to 0V. This means that logical 0 on the bus has higher precedence than logical 1 on the bus. This is how multiple devices can all be connected to the same two wires: they can tell when someone else is talking and wait for their turn!

Phew, thanks for indulging the Electrical Engineer in me. I just can’t pass up a chance to talk about circuits!

I2C Communication Rules

When using I2C, each device on the bus must either be a Master or a Slave device; these roles dictate who can talk on the bus and when. A Master device initiates communications on the bus by calling out a Slave device’s address and whether or not the Master device wants to write to or read from the Slave device. In every I2C bus communication, the Master device controls the SCL line; this means that the Master is in charge of how fast communications happen and when they take place. These roles line up well with just about any system you imagine: a single Master device (like a microcontroller) can write to and read from many Slave devices (screens, sensors, etc.).

The way a Master device can write to a Slave device goes like this. First, the Master starts the I2C communication by sending a Start signal, the Slave Address, and a Write signal. The Slave device will then acknowledge that it heard and is ready to listen. The Master will then send as much data as it wants one byte at a time, and the Slave device will acknowledge each byte as it receives it. When the Master is finished, it will send a Stop signal to let everyone on the I2C bus know that it is done. This whole process can look something like this for transmitting N bytes of data:

The process for a Master reading data from a Slave device is almost the same. The Master device still starts the I2C communication by sending a Start signal and the Slave Address, but now it sends a Read signal instead of a Write signal. The Slave device will acknowledge the request and then take control of the SDA line. This time the Slave device will transmit data one byte at a time, and the Master will acknowledge each byte as it receives it. When the Master device is ready to stop the communication, it will simply not acknowledge the last byte and send the Stop signal. A Master Device receiving N bytes of data from a Slave device might look something like this:

Luckily, all of these Starts, Stops, Acknowledgments, Reads, and Writes are handled by very well-designed I2C modules. Because I2C is such a widely used and well-defined standard, you will almost never have to worry about the nitty-gritty details of implementing these signals. Thank goodness for standardization!

Simple I2C Drivers for the MSP430FR2433 Microcontroller

The MSP430FR2433 microcontroller that I’m using comes with a built-in Enhanced Universal Serial Communication Interface (eUSCI) module that includes I2C drivers. If you want to read up on how exactly it works, you can read the whole chapter on it in the MSP430FR2xx Family User Guide, which I highly recommend doing! But if that sounds too much like homework, don’t worry: I did all of the heavy lifting for you.

It turns out that the SSD1306 OLED driver chip I chose only supports one-way communications through its I2C interface. This means that I only need to write code to transmit on the I2C bus because the SSD1306 is incapable of talking back. While that sounds like an oversight, remember that the SSD1306 is really just an output device: it doesn’t gather any data or input that we’d want to read since it’s just a screen. Plus, this means we only need to write functions to do two things:

Set up the I2C interface in Master mode Transmit data through the I2C interface

I2C Module Initialization Function: i2c_init()

The first task is easy because we can just follow the instructions in the MSP430FR2xxx Family User Guide and MSP430FR2433 Datasheet to configure the I2C interface how we want. Hopefully you read the whole chapter on the eUSCI module, but you only need to follow the procedure on p. 629 to configure the hardware. If you’re using a different Texas Instruments microcontroller, be sure to double check against the Family User Guide for your board and check the specific pins on the Datasheet. If you do that, almost everything should end up being the same.

/* * ! Initialize I2C interface * ! * ! Follows procedure outlined on p. 629 of the MSP430FR2xx * ! Family User Guide */ void i2c_init(void) { // 1. set UCSWRST to disable the eUSCI_B module UCB0CTLW0 = UCSWRST; // enable SW reset // 2. Initialize all eUSCI_B registers with UCSWRST = 1 UCB0CTLW0 |= UCMODE_3 | // I2C mode UCMST | // Master mode UCSSEL__SMCLK | // CLK source: SMCLK (1 MHz default) UCSYNC; // Synchronous mode enable UCB0BRW = 3; // SCL = SMCLK / 3 ~= 350 kHz // 3. Configure ports: // MSP430FR2433 datasheet p. 55 // P1.2 = SDA (DIR=X, SEL=01) // P1.3 = SCL (DIR=X, SEL=01) P1SEL0 |= (BIT2 | BIT3); P1SEL1 &= ~(BIT2 | BIT3); // 4. Clear UCSWRST to release the eUSCI_B for operation UCB0CTLW0 &= ~(UCSWRST); // 5. Enable interrupts UCB0IE |= UCNACKIE; // enable NACK interrupt }

I2C Transmission Function: i2c_tx()

Now I need a function to transmit data through our I2C interface. There are two ways I could go about writing this function: blocking or non-blocking.

A blocking function would do exactly what you might guess: block! The function won’t return until the I2C transaction is completed, which allows us to return some value to indicate if the transmission succeeded or failed at the cost of processor time.

function would do exactly what you might guess: block! The function won’t return until the I2C transaction is completed, which allows us to return some value to indicate if the transmission succeeded or failed at the cost of processor time. A non-blocking implementation would do the opposite: set up the transmission and return so that the processor can continue to work on other things while the transmission is handled by interrupts. This is great if you want to do a lot of processor intensive operations and need to squeeze every bit of performance out of your chip, but it also complicates handling any errors that occur during the transmission.

I’m going to implement a blocking I2C transmission function. I probably won’t come near the limits of the microcontroller’s hardware, so I won’t worry about wasted processor time. Plus, returning error codes will be very useful for debugging, and hobby projects always require a lot of debugging.

Here’s one way of writing a simple blocking I2C transmission function:

/************************************************************** * Global Variables ***************************************** **************************************************************/ static uint8_t i2cTransmitBuffer[I2C_TX_BUFFER_SIZE] = {0}; static volatile uint16_t i2cTransmitCounter = 0; static uint16_t i2cTransmitIndex = 0; /* * ! Transmit data through I2C interface * ! * ! \param deviceAddress: 7-bit I2C slave device address * ! \param *data: pointer to the byte array of data to write * ! \param count: number of bytes to transmit * ! * ! \return number of bytes left in I2C transmit buffer; 0 indicates * ! success, any other value indicates failure * ! * ! Copies count bytes from *data to a transmission buffer and begins * ! interrupt based transmission through the I2C interface */ uint16_t i2c_tx(uint8_t deviceAddress, const uint8_t *data, uint16_t count) { // prepare data for transmission i2cTransmitCounter = count; // there are count bytes to transmit i2cTransmitIndex = 0; // start from byte 0 do { // copy data into transmit buffer count--; i2cTransmitBuffer[count] = data[count]; } while (count); // configure and begin I2C transmission UCB0I2CSA = deviceAddress; // set slave address UCB0IFG &= ~UCTXIFG; // clear any pending interrupts UCB0IE |= UCTXIE; // enable TX interrupt UCB0CTLW0 |= UCTR | // I2C TX mode UCTXSTT; // send START condition // enter LPM0 until transmission is complete __bis_SR_register(GIE + LPM0_bits); // return the number of bytes left in the transmit buffer: // 0 if transmission succeeded, // >0 if transmission failed return i2cTransmitCounter; } /************************************************************** * I2C Interrupt Vector *************************************** **************************************************************/ #pragma vector = USCI_B0_VECTOR __interrupt void USCI_B0_ISR(void) { switch(__even_in_range(UCB0IV, USCI_I2C_UCBIT9IFG)) { case USCI_NONE: break; // Vector 0: No interrupts case USCI_I2C_UCALIFG: break; // Vector 2: ALIFG case USCI_I2C_UCNACKIFG: // Vector 4: NACKIFG __bic_SR_register_on_exit(CPUOFF); //exit LPM0 break; case USCI_I2C_UCSTTIFG: break; // Vector 6: STTIFG case USCI_I2C_UCSTPIFG: break; // Vector 8: STPIFG case USCI_I2C_UCRXIFG3: break; // Vector 10: RXIFG3 case USCI_I2C_UCTXIFG3: break; // Vector 12: TXIFG3 case USCI_I2C_UCRXIFG2: break; // Vector 14: RXIFG2 case USCI_I2C_UCTXIFG2: break; // Vector 16: TXIFG2 case USCI_I2C_UCRXIFG1: break; // Vector 18: RXIFG1 case USCI_I2C_UCTXIFG1: break; // Vector 20: TXIFG1 case USCI_I2C_UCRXIFG0: break; // Vector 22: RXIFG0 case USCI_I2C_UCTXIFG0: // Vector 24: TXIFG0 if (i2cTransmitCounter) { //if more to transmit: UCB0TXBUF = i2cTransmitBuffer[i2cTransmitIndex++]; //transmit byte and increment index i2cTransmitCounter--; //decrement counter } else { //if done with transmission UCB0CTLW0 |= UCTXSTP; // Send stop condition UCB0IE &= ~UCTXIE; // disable TX interrupt __bic_SR_register_on_exit(CPUOFF); // Exit LPM0 } break; default: break; } }

Any time an interrupt is used, code can become hard to follow. So, allow me to explain the basic premise here:

An array of bytes to transmit is provided. The message is copied into a static global array so that it can be accessed within the interrupt vector. Two static global counters are used to track the number of bytes left to transmit and the current index location. The I2C slave address is loaded into the eUSCI_B module, the I2C transmit interrupt is enabled, and the transaction is begun. The microcontroller is put into a low power mode that only interrupt routines can wake it from. Whenever the I2C module is ready to transmit another byte, the interrupt is activated and the processor is awoken. If there are bytes left to transmit, the next one is loaded into the transmit buffer and the processor is put back to sleep. If there are no bytes left to transmit, the low-power mode is exited and the function is allowed to return. If at any point during the transaction the slave device doesn’t respond, the NACK (Not ACKnowledged) interrupt will be triggered and the transaction will be aborted. This is done by exiting the low-power mode and allowing the function to return the number of bytes that were left to transmit when the error occurred.

So you can see that it’s a pretty simple implementation, even though the staggering number of hardware constants makes it look intimidating. If you need to adapt this code to a different Texas Instruments microcontroller, I’d recommend leaning into the Family User Manual and Datasheet for your board. The implementation shouldn’t be very different.

Lastly, I wanted to point out why the global variables are all static and why only one is volatile . The static keyword on a global variable in C makes it accessible only from the current file, which would prevent any code in other files from messing with these I2C-only global variables. The volatile keyword tells the compiler to not optimize this variable, which forces it to access memory for this variable every time it is referenced. This is important because i2cTransmitCounter is accessed and modified by both the i2c_tx() function and the interrupt service routine. Without the volatile keyword, the compiler will cache the value of i2cTransmitCounter when it is written at the beginning of i2c_tx() and reuse this same value when i2cTransmitCounter is returned from the function. This happens because the compiler sees that no modifications are made to i2cTransmitCounter between these two points in i2c_tx() and it wants to optimize the code for us. This would result in every call to i2c_tx() returning the count input regardless of whether or not the transmission succeeded, making it seem like the function never works! Thankfully, the volatile keyword tells the compiler to not do this and forces it to access memory to read the true value of i2cTransmitCounter when the function returns.

Setting Up the SSD1306 OLED Screen

The instruction manual for the SSD1306 chip can be found here and is well worth a read if you intend to use it. The basic operating principle is that there are single bit wide columns and 8-bit wide pages that will be displayed on the OLED screen by the SSD1306. The columns are easy to understand (a 32×128 OLED will have 128 columns), but the pages need to be thought of differently (a 32×128 OLED will only have 4 pages).

Before I start worrying about drawing pictures though, I need to set up the SSD1306 itself. Within the header file for my SSD1306 library, I’ve added constants for all of the different hardware commands that the manual provides. This makes it easy to configure the SSD1306 chip however I like:

/* * Initialize the SSD1306 display module * * \return 0 if I2C communication was successful, otherwise the number * of bytes left in the I2C transmission buffer when the * exchange failed will be returned */ uint16_t ssd1306_init(void){ const uint8_t instructions[] = { SSD1306_CMD_START, // start commands SSD1306_SETDISPLAY_OFF, // turn off display SSD1306_SETDISPLAYCLOCKDIV, // set clock: 0x80, // Fosc = 8, divide ratio = 0+1 SSD1306_SETMULTIPLEX, // display multiplexer: (SSD1306_ROWS - 1), // number of display rows SSD1306_VERTICALOFFSET, // display vertical offset: 0, // no offset SSD1306_SETSTARTLINE | 0x00, // RAM start line 0 SSD1306_SETCHARGEPUMP, // charge pump: 0x14, // charge pump ON (0x10 for OFF) SSD1306_SETADDRESSMODE, // addressing mode: 0x00, // horizontal mode SSD1306_COLSCAN_DESCENDING, // flip columns SSD1306_COMSCAN_ASCENDING, // don't flip rows (pages) SSD1306_SETCOMPINS, // set COM pins 0x02, // sequential pin mode SSD1306_SETCONTRAST, // set contrast 0x00, // minimal contrast SSD1306_SETPRECHARGE, // set precharge period 0xF1, // phase1 = 15, phase2 = 1 SSD1306_SETVCOMLEVEL, // set VCOMH deselect level 0x40, // ????? (0,2,3) SSD1306_ENTIREDISPLAY_OFF, // use RAM contents for display SSD1306_SETINVERT_OFF, // no inversion SSD1306_SCROLL_DEACTIVATE, // no scrolling SSD1306_SETDISPLAY_ON, // turn on display (normal mode) }; // send list of commands return i2c_tx(SSD1306_I2C_ADDRESS, instructions, sizeof instructions); }

Drawing to the SSD1306 OLED Screen

With the chip set up, it’s time to try drawing a pixel on the screen! As I mentioned earlier, the SSD1306 OLED works on columns and pages. Columns correspond well to the X position on the OLED, but pages don’t have a 1:1 relationship with the Y position of a pixel. Rather, I’ll need to use some integer math to select the right page and position within that page for a specific pixel.

Page is the integer division of Y / 8 because there are 8 pixels per page. To speed this up, I can use a bitwise shift to achieve division by 8 as Y >> 3 .

because there are 8 pixels per page. To speed this up, I can use a bitwise shift to achieve division by 8 as . Position is the (Y % 8) th bit of the (Y/8) th page; to speed this up, I can use a bitmask to perform the modulo 8 operation as Y & 0x07 .

Here’s a way to visualize the SSD1306 display and the integer math to draw to any pixel on the screen:

Now I can write a simple function to draw to a pixel on the screen:

/* * ! Draw a single pixel to the display * ! * ! \param x: x coordinate of pixel to write to [0:SSD1306_COLUMNS-1] * ! \param y: y coordinate of pixel to write to [0:SSD1306_ROWS-1] * ! \param value: value to write to the pixel [0:1] * ! * ! \return 0 if successful, error code if failed: * ! 1: x value out of range * ! 2: y value out of range * ! 3: I2C error during configuration * ! 4: I2C error during data transmission */ uint16_t ssd1306_drawPixel(uint16_t x, uint16_t y, uint8_t value){ // ensure pixel location is valid if (x >= SSD1306_COLUMNS) return 1; if (y >= SSD1306_ROWS) return 2; // send configuration message uint8_t configMsg[] = { SSD1306_CMD_START, // start commands SSD1306_SETPAGERANGE, // set page range: y >> 3, // y / 8 y >> 3, // y / 8 SSD1306_SETCOLRANGE, // set column range: x, // x x // x }; if (i2c_tx(SSD1306_I2C_ADDRESS, configMsg, sizeof configMsg)) return 3; // send pixel to be drawn uint8_t dataMsg[] = { SSD1306_DATA_START, // start data value << (y & 0x07) // y % 8 }; if (i2c_tx(SSD1306_I2C_ADDRESS, dataMsg, sizeof dataMsg)) return 4; return 0; }

And I can test this function by filling the entire screen:

uint16_t x,y; // iterate over every pixel for(x=0; x < SSD1306_COLUMNS; x++){ for(y=0; y <SSD1306_ROWS; y++) { ret = ssd1306_drawPixel(x, y, 1); // fill every pixel __delay_cycles(10000); // slow down so we can see the drawing } }

Wait a minute, that’s not filling the entire screen!

Implementing a Simple VRAM

Let’s think for just a moment on what’s happening with the SSD1306 driver. When I iterate over the entire screen I can see that every pixel in the display is being written to, but only the top pixel in every page is staying on. This is happening because I’m overwriting the SSD1306’s display RAM every time I draw a new pixel and erasing whatever else is on that page.

In order to fix this, I’ll need to implement a simple Video RAM (VRAM) on the microcontroller that stores the current state of the OLED display. Once again, I’ll use a static global variable for this so that any other SSD1306 drawing functions will be able to access the same VRAM. I’ll simply modify the ssd1306_drawPixel() function to draw to the microcontroller’s copy of VRAM first, then draw the updated page from VRAM to the SSD1306.

/************************************************************** * Global Variables ***************************************** **************************************************************/ static uint8_t ssd1306_vram[SSD1306_ROWS / 8][SSD1306_COLUMNS] = {0}; /* * ! Draw a single pixel to the display * ! * ! \param x: x coordinate of pixel to write to [0:SSD1306_COLUMNS-1] * ! \param y: y coordinate of pixel to write to [0:SSD1306_ROWS-1] * ! \param value: value to write to the pixel [0:1] * ! * ! \return 0 if successful, error code if failed: * ! 1: x value out of range * ! 2: y value out of range * ! 3: I2C error during configuration * ! 4: I2C error during data transmission */ uint16_t ssd1306_drawPixel(uint16_t x, uint16_t y, uint8_t value){ // ensure pixel location is valid if (x >= SSD1306_COLUMNS) return 1; if (y >= SSD1306_ROWS) return 2; // send configuration message const uint8_t page = y >> 3; const uint8_t configMsg[] = { SSD1306_CMD_START, // start commands SSD1306_SETPAGERANGE, // set page range: page, // y / 8 page, // y / 8 SSD1306_SETCOLRANGE, // set column range: x, // x x // x }; if (i2c_tx(SSD1306_I2C_ADDRESS, configMsg, sizeof configMsg)) return 3; // draw pixel to VRAM if(value) ssd1306_vram[page][x] |= 0x01 << (y & 0x07); else ssd1306_vram[page][x] &= ~(0x01 << (y & 0x07)); // draw updated VRAM page to screen const uint8_t dataMsg[] = { SSD1306_DATA_START, // start data ssd1306_vram[page][x] // VRAM page }; if (i2c_tx(SSD1306_I2C_ADDRESS, dataMsg, sizeof dataMsg)) return 4; // return successful return 0; }

Now I can fill in the entire screen, draw patterns, and even shapes!

Next Time: Sprite Based Animations

I now have a way to draw to the SSD1306 OLED display using I2C! But drawing individual pixels is probably the least efficient way to draw ever. Next time, I’ll look at implementing sprite based graphics and animations on the SSD1306 OLED along with some custom tools to make generating animated sprite artwork easy!

If you want a copy of the final I2C and SSD1306 libraries for yourself, head over to this project’s GitHub Repository and download them!