SSD1306 Out on a Cheap Date
We live in an era where huge display screens are commonplace. But embedded systems often demand small displays, hopefully with low-overhead control electronics. In this project article, Jeff endeavors to design and build a low-cost 128×64 OLED display subsystem using an 8-bit MCU and I2C interfacing.
My Uncle Bill loved to go to the drive-in. I’m not talking carhop drive-in eateries, like Sonic, but an outdoor movie theater—complete with a washboard parking lot filled with rows of poles holding speakers on tethers (Figure 1). You would park alongside a pole, crank down your window a bit (no buttons back then), and reach out, grab a speaker and hang it on the inside of the window. That took care of the sound. The movie was projected onto a giant screen, far larger than any of today’s Cineplex screens. The small projection block house doubled as the concession stand for popcorn and soda. Most nights were a twin bill. The first might be a Disney movie for the kids, and the second a western with cowboys and Indians, after the little guys passed out in the back seat.
I never said no to tagging along with my uncle’s family. However, as I grew, my tastes changed. Ray Harryhausen’s stop motion brought monsters alive in Jason and the Argonauts, Sinbad and Clash of the Titans. Then Sci-Fi took over, and my best bud Bill and I would hit the Orson Welles Cinema in Cambridge, Massachusetts for its 24-hour, 14-feature film marathon (Figure 2). The first year it featured This Island Earth, The Day the Earth Stood Still, The War of the Worlds, Things to Come, The Thing from Another World, Them! and Zardoz. Tickets were $4.75. The second year they showed a preview for a new space adventure called Star Wars.
Bill and I went to Springfield, Massachusetts for the opening of Star Wars. We had the theater to ourselves. And we stayed in that theater for every show that day. I’m not saying Star Wars changed my life, but I went back more times than I wish to admit. My kids grew up with it. Now, I take my grandkids with my light saber at my side. Space IS the final frontier. And NASA, along with SpaceX and Boeing are making it a real.
FROM HUGE TO TINY
That outdoor movie screen from the drive-in was gigantic. Today, the largest indoor screen is probably in the AT&T Stadium (formerly Dallas Cowboys Stadium)—72′ high by 160′ wide (Figure 3). But it’s digital HD, not projected like the drive-in theaters of old. Now we can watch movies on a tablet, or even a smart phone. You don’t have to drive to the theater, stand in line, purchase a ticket, find a seat and watch the coming attractions—you just stream (assuming you’ve paid for the service).
I use a 22″ monitor for engineering. My laptop has an 18” screen and is large enough for me to do some work if I’m away. When it comes down to projects, the screen size gets much smaller and more efficient. Probably the standard for most standalone projects is a 4-line by 20-character display. I’ve used 8-line and 2-line character displays, but, for me, the 4-line is the best compromise for the amount of information you can get on a screen at one time.
Not all projects need a display. However, I use one to debug almost all projects, as I’ve said many times before in this column. The USB/serial connection is the easiest to use, but this requires a serial device. Often this can be a PC running a terminal program. Some displays even support cursor addressing, which allows the application to assign messages to a specific location on the display, instead of rapidly scrolling all the messages. The latter can be hard to read when a lot of data is being presented.
Although support for other interfaces can be used, this often takes away precious I/O pins and programming space for the support of the library. With that in mind, the perfect display device will accept the single-pin UART output. That is the built-in interface for the Arduino’s print statement or any other microcontroller (MCU) with a UART. For years now, when I want a local display, I’ve used an inexpensive parallel-character display with a serial “backpack.” I first saw these presented by Scott Edwards about 25 years ago . These PCBs are designed to attach to the parallel header of a character LCD and provide a UART interface, so the standard parallel display becomes a serial device. These are available today from various manufacturers, and some even offer a pre-programmed IC so you can make your own backpack.
A very popular LCD these days is a 0.96″ 128 × 64-pixel OLED (organic LED) LCD. These are available from multiple sources, including Adafruit. The displays are based on the SSD1306, which is a single-chip display driver from Solomon Systech. The interface for this device is I2C. I’ve seen these for sale for under $4. This bright display will run from 3.3V or 5V. It has a few disadvantages, however—its interface and its size. I can’t do anything about its size, but I can simplify the interface. I use I2C all the time, and it only requires two pins. That said, there is a ton of code in the library that is required to use it, and that can really limit your available application space.
The project I describe here removes all that support from your application, and places it in a tiny backpack the same size as the display (Figure 4). The schematic is shown in Figure 5. I use an 8-pin Microchip Technology PIC16F18313 MCU to handle the display, and provide the user with a serial, 20-character by 8-line character display. I chose to emulate the cursor positioning protocol used by Paul Badger , but you could easily change this code using your own escape sequence.
A character is a picture of a letter, number or symbol. When a picture has low resolution—too few pixels—it can be misunderstood because of lack of detail. Therefore, it is important to pick a minimum resolution that will display all characters in a way that will not be misinterpreted as a different character. For instance, the letter S and the number 5 can easily be confused. While 5×5 pixels can be used in many cases, 5×7 is often used because it gives a bit more definition to the character, and a column of 7 pixels fits nicely into a byte, with the eighth bit used as a space between rows. So, the display space of 6×8 pixels, with its trailing blank column (between characters), holds one 5×7-pixel character.
To be consistent, the character will always be the placed in the top left corner of the display space. With a display width of 128 pixels and each character display space equal to 6 pixels, we have room for 128/6 = 21.3 characters. With a display height of 64 pixels and a character display height of 8 bits, we can have 64/8 = 8 character rows on the display.
The data for each character consists of 5 bytes of column data, with LSB (least significant bit) at the top and MSB (most significant bit)—the blank space between rows—at the bottom. A 5×7 character with three vertical stripes would consist of these five bytes: 0x7F, 0x00, 0x7F, 0x00, 0x7F (“0x” here designates hexadecimal numbers). A 5×7 character with four horizontal stripes consists of these five bytes: 0x55, 0x55, 0x55, 0x55, 0x55.
Since a byte has 256 possible combinations, we could set up a table with 256, 6-byte entries and be able to display 256 different characters. The 7-bit ASCII character set requires only 128 characters, and since we are assuming only characters, we can limit the table to the 5 data bytes for each character, instead of the 6 bytes just mentioned. The sixth data byte will always be 0x00, so there’s no need to include it in the table.
If you really want to cut down on the table size, you can leave out the first 32 unprintable characters (control characters). That will reduce the table size further, from 128 down to 96 entries. Remember that each entry requires 5 bytes here, so that’s 96 × 5 = 480 table bytes. If you wanted to support all 256 entries with a 6-byte table, that’s 256 × 6 = 1,536. I won’t be supporting all 256 here, because the memory space of PIC16F18313 MCU is only 2,048 bytes!
Cursor positioning allows the user to place text anywhere on the screen. This powerful tool cleans up the scrolling problem you get when just streaming data to a display. Because this is a graphic display, it does not even know what a character is—it just knows pixels—128 pixels horizontally by 64 pixels vertically. That means that it requires 128 × 64 = 8,196 bits or 8,196/8 = 1,024 bytes of memory for a screen full of information.
The graphics display RAM (GDRAM) in the SSD1306 display is, in fact, made up of 1,024 bytes of RAM memory. This is divided into 128-byte pages (rows) 0-7. Each page has segments (columns) 0-127. A row can be addressed by using the page start address command 0xB0-0xB7. A column can be addresses by using the lower (0x0-0xF) and upper (0x0-0x7) column start address commands 0x00-0x0F and 0x10-17. The default state for the display is used here: Page mode. In Page mode, each page wraps on itself.
Other modes are available. Vertical mode is where the row is incremented (with roll-overs incrementing the column) for each write. Horizontal mode is where the column is incremented (with roll-overs incrementing the row) for each write. Additionally, Scrolling modes up/down and left/right may be helpful, but I find that, since it is limited to one GDRAM space, you can’t scroll a message onto or off of the display area. All changes must be made through the GDRAM, and painting a totally new screen takes place live and will, therefore, not be instantaneous—something you may want for gaming to reduce flicker on rapid screen changes. With only 256 bytes of RAM on the PIC16F18313, there is no space to duplicate display memory or to write a function that quickly transfers memory to update the screen. My screen updates are actually character updates on a selected line basis.
Besides cursor addressing, there are other commands you may want to use. I included
CLS as a special function. These special functions must be added to any application to format the screen. I’ve implemented four “specialty” commands:
?f – Clear the screen
?Y# – Move the cursor to Row #0-7 (decimal character)
?X## – Move the cursor to Column #0-19 (2 decimal characters)
?? – Display “?”
You might like to implement other commands, such as controlling the character intensity, but I thought these would suffice.
One of the most frustrating parts of implementing any new idea is getting some amount of positive feedback. In this case, we want to see something on the display. The SSD1306 driver chip is not initialized when power is applied, so there is no immediate feedback that the device is operating at all. Since the device has been wired to use the I2C interface, we can get some glimmer of hope just from making contact with the device. I usually start out with any I2C device wired to an Arduino that is running a basic I2C detection application. It tries all the 7-bit I2C addresses, and reports any addresses that have ACK’d (acknowledged) back. An ACK means that at least one device recognized the current address.
At this point, you can at least confirm that the I2C address is correct and the device is responding. However, without some initialization, it doesn’t display anything. Much of the configuration is determined by the OLED display that is connected to the SSD1306, and has to do with the multiplexing of segments and commons. Some handle the orientation of the display, flipping or reversing the image.
Using the I2C interface, the command format is 3 or more bytes consisting of the I2C address, the mode byte and the command/data, which can be 1 or more bytes. The mode byte will be 0x00 for writing commands and 0x40 for writing data. Many command bytes have some amount of data within them, such as row start address, where the command is 0xB0, and the LSBs containing the row number 0-7. Writing data to the SSD1306 will be used to fill the GDRAM memory with display data.
You’ll find that once you have initialized the SSD1306 correctly and enabled the display, it will have random data in its GDRAM. This will be displayed as an illuminated pixel wherever the GDRAM has a logic high in its corresponding bit location. So, clearing the screen and writing zero data to all 1,024 memory locations is a good place to begin. The clear screen routine can be used to accomplish this. It contains two routines that will be useful later on: cursor positioning and displaying a character.
Cursor positioning uses two variables,
column, that must be set prior to calling the routine
PositionTheCursorAt. Displaying a character requires the variable
TempChar containing the ASCII character we wish to display. The
SendTheCharacter routine has two subroutines associated with it—
SendCharacter. The first uses
TempChar as an offset into the
CharacterDataTable. This will transfer the data to display that ASCII character into five variables,
Column5:0. The second routine writes these 5 column data bytes to the next GDRAM locations, plus an additional 0x00 to provide the vertical space between characters. The GDRAM location pointer has been previously established by the
PositionTheCursorAt routine. I implemented the
SendTheCharacter routine as a complete I2C data command by concatenating the 6 data bytes into a single data command. This limits data writes to 8 bytes, easily contained in the
The support for the UART is cut in half, because we won’t be sending any data back to whatever is supplying the text to display. I didn’t see a need to provide a TX (transmit) routine. The RX (receive) routine uses a 40 byte ring buffer that allows the RX input interrupt to handle receiving data whenever it comes in. The main loop handles emptying the buffer whenever any characters are available. The special functions are separated from the display text, in the ProcessRX routine, the only operation in the main loop after the device has been initialized and the screen has been cleared.
ESC_VAL —handle the special functions. ESC has been initialized to zero. While
ESC = 0, every character that comes from the RX ring buffer is compared to 0x37, the “?” character. If is not a “?”, it is simply passed on to the
SendTheCharacter routine and placed into the
I2CTXBuffer. If it was “?” then
ESC is incremented and the
RXProcess is exited.
The variable ESC is directly responsible for what happens with characters that have been received from the host. The flow chart in Figure 6 shows how these characters are handled by the application to institute the special functions for this OLED. Note while not an escaped “?” sequence, CR (carriage return) and LF (line feed) are intercepted to give additional control. You could print eight lines of text, clearing out previously printed text (even shorter lines) just using CR and LF with the text for each line.
I2C AND PPS
As previously mentioned, I2C commands and data are sent to the SSD1306 using the
I2CTXBuffer. This ring buffer holds each command or data bytes that will be sent to the display. An I2C transfer is initiated by calling
SendI2CRequest, which sets the start bit. From that point on, the I2C interrupt routine handles the transfer until the
I2CTXBuffer is empty. The combination of buffer and interrupt allows the transfer to take place as quickly as possible without affecting any other execution.
While you can read back data from the display, it was not necessary to implement any I2C reads. The maximum clock speed for the I2C bus is 400kHz. We can set the proper divisor for an I2C clock speed of 400kHz by setting the SSP1ADD register in the MCU.
where clock speed = 400kHz (maximum I2C bus speed)
where FOSC = 32MHz (internal oscillator of the MCU using the 2× Phase Lock Loop)
I find one of the most innovative additions to some MCUs today is the Peripheral Pin Select (PPS). Essentially the source/destination of all the I/O pins can be redefined. This allows the designer to choose which pins have which function. For instance, the UART pins TX and RX, are defined on pins 6 and 7. These also happen to be the pins that are defined for the in-circuit debugger. If you want to use the debugger and also have access to the UART, then there is a conflict. With PPS you can move TX and RX to two other pins to eliminate the conflict. You might even put both TX and RX on the same pin, and have a half-duplex master or slave (bidirectional bus).
I’ve redefined SCL, SDA and RX. Since we are not outputting any serial data, RA3 is left with no function. It could be redefined as TX or any other function, if we so desired. PPS selections for peripherals with bidirectional signals on a single pin must assign both PPS input and PPS output to the same pin. There is a special sequence of commands to set PPS redirection. This prevents a pin from getting inadvertently reassigned.
I wanted this project to be as inexpensive as possible. The MCU was $0.80. The display was $6.50 (seen elsewhere for less than $5). A small piece of prototyping material and the rest of the parts can put this whole project at under $10. Because the display board is 1.1”-square, the aim was to limit the interfacing PCB to that size. This was achieved even using a DIP MCU! I mounted my finished project in a really nice Hammond enclosure: the RZ0362C (Figure 7). It’s a 2″-square, water-tight ABS enclosure with a clear polycarbonate lid for $4.96.
You need to be aware that these 4-pin displays can be found with two different pinouts. Power (3.3V/5V) and ground can be found on either pin 1 or pin 2, with SCL on pin 3 and SDA on pin 4. I like the white displays; however, I’ve seen them in blue and yellow.
The standard operating voltage for many years was 5V. Today we see a lot more MCUs that run on 3.3V. It’s nice to have a display that can work with either. Every time I design with a PIC MCU, I add room for a 5-pin header for the in-circuit debugger. There is also a 6-pin header that mates with a USB-TTL serial dongle. I have made two cables, connecting this project’s three wires—power, ground and RX—to a 5-pin and 6-pin connector. This way it is always handy for popping onto a project and displaying a bunch of parameters that I want to watch. Screen size matters. But sometimes, you don’t need the big screen just to view information on demand.
JUST THE MINIMUM
This implementation uses 81% of the MCU’s code space, with about 400 bytes remaining—98% of the RAM is being used. To test the display, I use an external test program, which sends randomly requested position data along with appropriate text to the display. A CR clears the remaining row of characters and sets the column to zero. So, by using the row position command (?y#) followed by a CR, the new row gets cleared of previous text prior to setting the column (?x##), which positions the start of the text appropriately (Figure 8).
There are many other possibilities you could implement using this display. Because you are in command of the character set and how it is used, you can change how each character is displayed—doubling the size of each character, giving a display capacity of 4-lines by 10-characters. Graphical characters could be established, like those available on early PCs for displaying borders and boxes. Larger graphic icons— logos and such—can be displayed. This is a powerful display, and the serial interface presented here makes it accessible to many projects.
But don’t let its small size fool you. It can be great help when you need to monitor or debug a project. While many parts of the country have lost the large outdoor theater experience, new technology has given us the ability to stream feature films to smaller screens. I never imagined I would be able to view Star Wars or any other flick on demand in the comfort of my home. If you stream and have never seen The Day the Earth Stood Still, I suggest you plan an evening with this epic thriller directed by Robert Wise. I don’t think I’ll spoil anything by saying: “Gort, Klatu Barada Nicto.” Too much to watch, too little time.
 Scott Edwards’ parallel character displays. https://www.seetron.com/products.html
 Paul Badger’s cursor positioning protocol. https://moderndevice.com/product/lcd-character-displays
PUBLISHED IN CIRCUIT CELLAR MAGAZINE • JANUARY 2021 #366 – Get a PDF of the issue