Working with OLEDs: SSD1353 & SSD1333
A quick intro to interfacing common OLED displays to bare-metal microcontrollers.
A pet peeve of mine is that when it comes to microcontroller projects, good displays seem taboo. Consider Microsoft’s MakeCode Arcade: a laudable corporate effort to get kids interested in game design by letting them build software in a Scratch-like environment and then deploy apps to handheld consoles.
An example of this hardware is Adafruit PyGamer. The specs are impressive for the price: it sports a 32-bit Cortex-M4F chip with a floating point unit, more than 8 MB of onboard storage, and a card reader so that you never run out of space. Except… all these megabytes of game assets must be rendered on a crummy LCD display with a resolution of 160×128:
Don’t get me wrong: I get the realities of designing disposable toys. You’re competing with Chinese sellers for the attention of apathetic customers who think that advanced electronics shouldn’t cost more than $24.95. But Nintendo Game Boy Color had more pixels than this! And it came out in 1998 with an 8-bit CPU on board.
I covered the 32-bit use case in an earlier article, highlighting the ease of hooking up a bare-metal MCU to Newhaven NHD-2.8-240320AF-CSXP-F — a much nicer 320×240 IPS module that is well-suited for handheld games and can easily deliver 30+ fps:
If you have less ram or less CPU power at your disposal, lower-resolution displays still make sense — but in that category, RGB OLED modules are a wiser pick. Not only do they perform and look better, but they’re surprisingly easy to use: no need for an OS, drivers, or any third-party code. To many, display hardware feels like black magic, but most of it is essentially plug-and-play.
To illustrate, let’s have a look at Solomon Systech SSD1353 and SSD1333: a pair of controllers embedded in a number of small OLED displays, including Newhaven NHD-1.91-176176UBC3 (176×176, 1.9”) and NHD-1.8-160128UBC3 (160×128, 1.8”). The hook-up is trivial: the modules have integrated DC-DC voltage converters, so all we need to do is supply around 3.3 V and then connect the module to an MCU using a 8080-style parallel bus (8 lines + WR and D/C). The module will read one input byte on every rising edge of the WR signal. If D/C is zero, the byte is interpreted as a command; otherwise, it’s data.
On the software side, we need to wait about 20 ms, pull the RESET- line low for ~100 µs, and then wait around 10 ms for the controller to start up. At that point, the chip should be in a predictable state, but the panel itself will be turned off. Using the 1.8” module as an example, the next step is to send command 0xA8 (“set multiplex ratio”), followed by a data byte set to 127 decimal. The controller can handle up to 132 rows, but the panel has just 128, so we need to tell the chip where to stop.
After that, we send command 0xA0 (“set re-map & data format”), followed by 0b10110000. This a combination of several bit fields to set 18-bpp color mode, disable interlacing, and reverse row order to match the orientation of this particular panel on its PCB. It is also possible to configure 16-bpp color (aka RGB565); this is more memory-efficient, but less intuitive to work with.
The final configuration step is to send the following commands specific to the actual glass panel used by Newhaven in the 1.8” module:
0xB1 0b00100010 → precharge time 5 cycles, discharge time 2 cycles 0xB3 0b01000000 → fosc = 440 kHz, for ~30 fps refresh 0xBB 8 → precharge voltage 0.16×Vcc 0x83 106 → red current: 42% 0x82 96 → green current: 38% 0x81 117 → blue current: 46%
The values are taken from the Newhaven spec; the panel will work if you omit the sequence, but it will be running less efficiently and the colors might be slightly off.
With the configuration out of the way, we can send the 0xAF command (“display on”) and wait about 1 ms. After that, you select a frame buffer region to write to by using the following pair of three-byte instructions:
0x15 x_start x_end → sets horizontal range ("set column address") 0x75 y_start y_end → sets vertical range ("set row address")
Once the region is selected, we send 0x5C (“write RAM”) and start streaming pixel data. Each pixel consists of three bytes — blue, green, red, in this order — with permissible color component values ranging from 0 to 63.
An example implementation of an SSD1353 graphics stack for an 8-bit microcontroller with 16 kB of RAM can be found in my earlier gaming project, Sir Box-a-Lot. I originally designed the project for another now-obsolete display module, but ported it to SSD1353 this week:
So you've described how to initialize and use this one make/model, but how does one learn to figure this out? Is it reverse engineering the sample code? reading the spec sheet and applying the commands one at a time until you've figured it out?
If Your project includes a graphic display, there is one problematic issue worth addressing. The graphic displays usually come without their RAM memories, so the actual frame buffer is being held by MCU in its RAM and is being written to the display either by using I2C or SPI interface.
In other words, it requires the developer of preserving bulk region of memory for a frame buffer, the higher he resolution, the higher of RAM is needed.
While designing a circuit for fun it doesn't make a difference whether You're going to use an MCU based on ARM core with plenty of RAM/Flash, IOs and packed with features or not. On contrary, working on a commercial projects requires selecting the MCU that is going to be cost effective at first, allow to fit the code for Your design and have a little space left for a few extra features You want to add into the coding process. If Your design is going to be sold in numbers, the $10 difference in between Atmega328 and Atmega SAM4 goes in numbers as well. And that is highly related to the competition with Asian market You have already mentioned. And the companies asking for the electronic design will evaluate the project based on target price, not the MCU features and computing power.
A while ago I've been asked for the fertilizer mixer controller, that I've initially decided to fit into Atmega328 (cost effective) that will be interfaced to SSD1306 (I love OLEDs for their crystal clear and wide angle view) and started coding: startup (integrity check on RTC, recipes, timetable - yes, EEPROMs sometimes loose their data), main window, several setup windows: date & time, recipes, timetable, diagnostics, PID params, probe calibration, alarms and factory reset. It barely fit into the MCU (I had to quit the idea of adding MODBUS). But in this case the display is just a terminal: set&forget.
When elegance of a design comes to play, it is reasonable to explore more advanced (and pricey) MCUs, that allow using a dedicated graphic libraries, like LVGL - they can provide a lightweight graphic windows with buttons and such, which is more than drawing lines, circles and rectangles in a frame buffer.
My mentioned project is somewhere in between those two. I've developed a dynamically allocated windows system, but in a text mode. And yes, on that occasion I have used malloc() condemned by many. And I love it!