OLED Prototype Board

Microcontrollers are not the friendliest computers in the world. Let’s face it, if you want to know what’s going on inside them you have little choice but to solder LED’s before you need to resort to cumbersome solutions like a UART connection to a remote terminal, or a JTAG debugger.

Luckily, there’s now a variety of small, inexpensive OLED graphical display modules on the market. Here are a few examples straight out of my desk drawers :

Let me lower your expectations right away, when I say “OLED” I’m not talking about the glorious 4K HDR display on your smartphone. Primo, those are definitely not inexpensive. Segundo, an STM32F103 has nowhere near the horsepower it takes to drive such a display. The modules on this photo are 128 x 64 pixels monochrome displays. The one on the left has a 0.96” diagonal and costs as little as 2 Euros, the one on the right has a 2.42” diagonal and costs around 13 Euros. You’re not exactly going to need a loan from your banker.

That’s enough pixels for 8 lines of 20 characters, give or take. And that has the potential to be much more informative than simple LED’s on GPIO pins.

There are other types of display similar to those two, some are even smaller (down to 0.5”) and some are so big and expensive you’d need a really good reason to ever consider them. They are all based on the same type of display controller so it’s very easy to adapt your code to drive any of them. In fact, the two displays I’ve shown you are completely interchangeable. They even have the same header pinout.

One big advantage of OLED is readability. The tiny 0.96” display is perfectly readable even in sunlight. That’s because each pixel is a small organic LED. The larger display can even be described as high-visibility, in fact it’s a good replacement for the old, expensive and fragile vacuum fluorescent display (VFD) modules.

Now, let’s interface the 2.42” display to the Maple Mini board we’ve build earlier.

1.1.        Display Interface

OLED modules are available with either an SPI bus or I²C bus interface. I²C uses only two pins on your microcontroller but has limited bandwidth, which will impact refresh rate while hogging the bus. If you’d rather be able to display information as fast as possible the SPI interface is the better choice, as it’s roughly a hundred times faster.

The SPI bus is a synchronous serial interface capable of full-duplex communications at virtually any speed your electronics can manage. In the case of the STM32F103, which has two SPI bus controllers, the maximum speed on SPI is 18 MHz, which translates to 18 megabits per second.

While the term “bus” implies that multiple devices can be connected to the same controller, I tend to reserve a controller just for the display. High-frequency buses sometimes have signal integrity issues on prototyping boards. Besides, there are two SPI controllers on the Maple Mini so I’m not really losing options.

SPI OLED modules are write-only devices : there’s not much point reading data from a display. The signals we need to interface to the STM32 are :

  •         MOSI : SPI Master Output, Slave Input. That’s the data line.
  •         SCK : SPI Serial Clock. That’s the data clock line.
  •         SS : SPI Slave Select. This signals the start and end of SPI frames to the OLED.
  •         RESET : Display controller reset (not the same as the STM32 reset).
  •         DC : Data / Command selection signal. Identifies the data send over SPI.

RESET and DC will need to be driven by GPIO outputs. Depending on your coding and the exact display controller in your module, SS can be generated by the STM32’s SPI controller or it may be driven by a GPIO as well.

(Note : with some microcontrollers and some displays, it’s not really necessary to drive SS : it can be tied to GND permanently… but it’s best not to rely on that)

Finally, the display requires 3.3 V for power.

Here’s the pin map of the Maple Mini :

There are SPI-capable pins on either side of the Maple Mini, but I will be using SPI2 because that allows me to install the display module like so :

Right over the Maple Mini while keeping its user button accessible. Sure, this hides the LED, but we won’t be needing it anymore once we’re done.

I’m using female pin headers to create a socket for the display. This also lets me raise the display so that it doesn’t touch the Maple Mini :

My first two steps are to solder male and female pin headers to the display and to the board so that I can line the module up with the board, and then drill holes for M3 risers. This display isn’t heavy but it’s definitely not going to hang firm with just seven pins on one side.

For reasons that may or may not become clear in the future, I’ve decided to use the Maple Mini’s SPI2 to drive the display (spoiler alert : I have plans for the timers which share the SPI1 pins). Since this is a write-only situation, the MISO pin (PB14) will be configured as a regular GPIO to drive the DC pin of the display. The display’s RESET pin will be driven by PB2, which has no special purpose I’d be losing.

1.2.        Laying Out Some Wiring

The first step is to bring the 3.3 V and GND rails from the Maple Mini out and around the board. Keep in mind that every peripheral we may add to this board will need electrical power to work. It’s a good idea to create a “power grid” on the board before adding anything else.

With the Maple Mini this is actually fairly simple : the module has 3.3V and GND pins on each edge. It’s only a matter of bending and connecting some L-shaped wires to those pins, like so :

Tack those wires at every end and bend to keep them in place, but leave most of the holes clear : we’ll be using them to tap into the grid.

From there, connecting the OLED header to the correct pins on the Maple Mini is mostly a matter of reading the silkscreen on both the OLED module and the Maple Mini. The Chinese being ever economical may label MISO “SDA” and SCK “SCL” because they use the same boards for the SPI and I²C versions of those displays, but hey at least the purpose of the pins matches the labels.

I’m not sure you need more photos of my sexy soldering, instead let me tease you with what the board finally look like with the SPI interface soldered, the display installed, and some demo code flashed into the STM32 :

Now that is a lot better than a mere LED… and the photo doesn’t even do justice to the display’s brightness. This is the 2.42” unit. The 0.96” is both pin-compatible and software-compatible :

1.3.        The Boring Part Code

I’m joking; it’s far from boring, actually. No, seriously, I swear !

The main reason I’m confident of that… is I’m not going to detail every line of code it takes to make a display work. Even simple displays are complicated under the hood, so that would take way too long. Instead, I’ll offer you a teeny tiny library I’ve put together, some explanation as to its anatomy and a quick guide for integrating it into your own project. Let’s face it, that’s pretty much the one thing you really care about.

Let’s start with getting you the code. It consists of two files, which you can download right here :

Copy the C file into your project’s Core\Src folder and the header into your project’s Core\Inc folder.

Just one thing : do not send me messages regarding this code and anything you believe I did wrong. This is a piece of code I’ve slapped together out of recycled parts from other display libraries just to have something that works and isn’t a resource hog… like, say, an Adafruit library. Seriously, do those guys actually know that an MCU’s RAM is limited ? I swear, it’s like they’re coding Chrome’s memory leaks or something.

Anyway, now that you’ve got a display library in your project, it’s time to talk about what it does and how it does it. This is mandatory reading if you want to understand the steps necessary to use it.

1.4.        Anatomy of a Display Driver

The display driver only has one purpose : to translate high-levels commands such as “printing a string of characters” into SPI commands that will make the OLED module do exactly that.

While there are legions of display controllers out there, they all operate more or less along the same logic; which in turn means that most display drivers look the same :

  •         There’s always one or several functions for initializing the display interface (here, the STM32’s SPI bus controller) and the display itself.
  •         There’s a frame buffer, located in the microcontroller’s RAM, where the driver will draw the pixels that will be displayed.
  •         In the background, there’s a mechanism to periodically send that frame buffer to the display at a set frame rate.
  •         And finally, there’s a bunch of user functions for drawing and printing. Those modify the frame buffer; they don’t talk to the display directly.

This actually works the same even on your computer, and that’s one of the reasons your graphics card has RAM on it.

My SSD1309 OLED driver is no exception : it will reserve 1 KB of your STM32’s RAM to store a frame buffer (128 x 64 pixels, monochrome, equals 1024 bytes). There’s 20 KB of RAM in the STM32F103 so yeah, you’re going to “lose” 5% of your RAM. Keep that in mind if your application needs a lot of it.

I’ve also baked in a generic 5 x 7 pixels ASCII font : the SSD1309 only knows pixels, it doesn’t contain a font ROM. The font is declared as a constant, so it doesn’t use any RAM. Instead it uses around 650 bytes of Flash… but your STM32 has a 128 KB of it, so you shouldn’t feel it unless you’re into bloatware like a Microsoft programmer with no budget oversight or UX training.

Finally, my driver uses DMA transfers triggered by the STM32’s system tick as the background mechanism to send the frame buffer to the display. What this means for you is low CPU overhead : once the driver has been initialized, it’ll only wake up once per millisecond to check if it’s time to send the next frame to the display. That only takes long enough to request a DMA transfer. Chances are, even in a real-time application, you won’t notice it’s there.

1.5.        Integrating the Driver

The source files contain enough comments to get you sorted out, but more documentation never hurts.

The STM32 needs to be configured properly : remember, STM32CubeIDE has a graphic tool to let you enter your device configuration and then generate the initialization code for you.

If it isn’t already opened, double-click on your project’s .ioc file to launch the device configurator. Go to the pinout tab, expand the Connectivity category and click on SPI2 (or whichever SPI controller your particular board uses). Cube’s peripheral block configuration UI is always split in two sub-panes : a Mode pane at the top and a Configuration pane at the bottom. By default, SPI2 is disabled, and so its Configuration pane is empty. Change its mode to Transmit Only Master.

Two things will happen :

  •         Two pins on the microcontroller diagram will turn green : one for MOSI, one for SCK. Make sure they match your board. In the case of our prototype, PB15 and PB13 respectively. If they don’t match, find the correct pins on the diagram and reassign them manually.
  •         The Configuration pane now looks like this :

This is a screenshot from my own demo project, with every parameter set to the values they should have in order to make the OLED display work. Those might not be the default values you will get : find the differences and correct them.

Also, keep an eye on the baud rate : the SSD1309 will not work at anything above 10 Mbit/s. The SSD1306 found on smaller displays works at up to 30 Mbit/s. If you exceed your display’s top speed, tweak the prescaler until you get a baud rate that will work. If you don’t, you’ll just end-up with a black screen. Try to get as close as possible to the maximum speed your display will work at, just in case you also need the SPI bus for something else.

Notice that the Configuration pane has tabs. We’ve been looking at the Parameter Settings tabs.

To minimize CPU overhead, my driver relies on DMA transfers. If you don’t know what that is, a DMA controller is a fairly simple piece of hardware inside a processor that is designed to do just one thing : transfer blocks of data from one place to another while the CPU does something else. When you’re looking at sending an entire display’s worth of pixel data several times per second, well you’re looking at the exact type of task DMA controllers were invented for.

Click on the DMA Settings tab. This is where you’ll configure the DMA controller to enable transfers from RAM to SPI. To do so, click on the Add button. A new line will appear in the tab. Select the DMA request SPI2_TX, which should be the only choice since we’ve setup SPI2 as a transmit-only master. Now all you have to do is fill in the blanks :

Here’s the logic behind those settings : we want a Normal transfer instead of Circular so we can send one frame at a time. This lets us control the display’s frame rate. We’ve set the SPI interface to 8-bit in order to match the SSD1309’s register width, so we’ll be transferring bytes. We’ll be transferring 1024 of them at once, so the DMA controller will need to increment the RAM address each time it sends a byte, but the SPI controller only has one transmission register, so we’ll always be sending the data to the same address. Couldn’t be clearer.

And with this, the SPI and DMA configuration are complete. Now you need to configure three GPIO output pins. Left-click each one to set that function and then right-click to rename :

  •         PB14 shall be named OLED_DC
  •         PB12 shall be named OLED_NSS
  •         PB2 shall be named OLED_RESET

Those names are important : when you tell Cube to generate the STM32’s initialization code, it will create macros using those names. My display driver uses those macros to drive the pins… which won’t work if they have different names.

When you’re done with that, the microcontroller diagram should look something like this :

Almost there… you still need to setup the GPIO’s initial state. Expand the System Core category and click on GPIO. You’ll see a list of your discrete I/O pins. Select any pin to access all its settings :

OLED_RESET must have an output level of Low. This is the initial level if will be set to by the code generated by Cube. The idea is to keep the OLED display under reset until the driver initializes it.

OLED_NSS must have an output level of High. SPI slave select signals are active-low : the idea is to keep the display’s SPI interface on standby until the driver sends commands and data to it.

OLED_DC must have an output level of Low. This is a data / command selection signal, where data is “high” and command is “low”. The first thing the driver will do is send commands to setup the display, so I think you get the picture. And yes, this was a display driver pun.

And that’s all for device configuration. Now generate the initialization code (from the Project menu) and let’s move on to, well, the code.

First, edit your project’s “main.c” file :

You obviously need to include the driver’s header file. Remember to do so in an area of code delimited by “USER CODE” comments. There happens to be one specifically for #include directives :

/* USER CODE BEGIN Includes */
#include 
#include "SSD1309.h"
/* USER CODE END Includes */

It’s a good idea to also include the standard library header “stdio.h”, because it contains string functions such as sprintf that you’re going to need if you want to display the value of variables.

Next, initialize the display with a call to oled_init. The logical place to do that is in the main function. Again, you’ll find there’s a dedicated “USER CODE” area for initialization code, just below Cube-generated calls to the Cube-generated initialization functions :

/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_SPI2_Init();
/* USER CODE BEGIN 2 */
oled_init (&hspi2, 30);
/* USER CODE END 2 */

The oled_init function must be called after the SPI controller’s initialization, for obvious reasons. The first parameter is the address of the SPI controller’s “handle”, a data structure that lets the STM32 HAL functions access that peripheral. Any time you setup a peripheral and generate code, Cube will create one of those, with a name that matches the peripheral. So if you’re using SPI1 instead of SPI2, you should pass “&hspi1” instead.

The second parameter is the frame rate. Long story short : OLED displays can easily show several thousand frames per second, which is useless in almost any scenario I can think of. This parameter lets you set the number of frames per second that will actually be sent to the display module. 30 FPS is more than adequate for regular human eyes.

Speaking of frame rate, it’s now time to implement the background mechanism that will keep sending frames to the display once it’s initialized.

To that end, edit your project’s “stm32f1xx_it.c” source file, which is under Core\Src. This file contains all the interrupt handlers that Cube generates for you. If you look around, you’ll actually find a DMA interrupt handler for the SPI2 transfer request you configured earlier… and lo and behold, it calls the HAL’s SPI DMA interrupt handler, as you’d expect.

But I digress. First, include the driver’s header file again. You know the drill :

/* USER CODE BEGIN Includes */
#include "SSD1309.h"
/* USER CODE END Includes */

Now look for a function called SysTick_Handler. That function is the interrupt handler for a system timer that is part of the ARM Cortex-M core. It generates an interruption every millisecond and is intended for things like rough time-keeping or to program delays. My driver uses it to refresh the OLED display. To do that, you need but add a call to the driver’s oled_refresh function :

void SysTick_Handler(void)
{
  /* USER CODE BEGIN SysTick_IRQn 0 */

  /* USER CODE END SysTick_IRQn 0 */
  HAL_IncTick();
  /* USER CODE BEGIN SysTick_IRQn 1 */
  oled_refresh ();
  /* USER CODE END SysTick_IRQn 1 */
}

You could put that call first or last in the handler. I’d put it last because refreshing a display is probably less urgent than anything else the HAL might do with it.

And you’re done ! If you’ve managed to do everything so far without missing anything, you’re ready to display stuff !

1.6.        Using the Driver

You’ve been very patient, and now your efforts are paying off. Let’s write a simple bit of code to see what the OLED display is capable of. And also to blow your socks off.

I’ll keep it simple for once : we’re just going to count how many times the main function’s infinite loop goes around, and display that number each time.

First, declare a variable to use as a counter, in the main function, just before the “while(1)” statement. Oh, and declare a string to print that number into. Then in the loop body you just need to increment the counter, convert it into a string and print it to the frame buffer. Or you can just copy and paste this :

/* Infinite loop */
/* USER CODE BEGIN WHILE */
int count = 0;
char str[20];
while (1)
{
  /* USER CODE END WHILE */

  /* USER CODE BEGIN 3 */
  	sprintf (str, "Loop %i", count++);
print (str, 0, 0);
}
/* USER CODE END 3 */

Run it and bask in the glory of organic LED light ! You’ve earned it !

And we’re done… next, we’ll look to add connectors to the prototype board so we can bring out I²C, SPI and UART.