There are many monochrome OLED display modules available for really reasonable prices out there. They can be really useful for a small and simple display in a project. However, many libraries available for driving them are rather big and slow. In this article I want to show how easy it is to drive those displays directly, without a dedicated library, and how this can improve speed and save memory.
As an example I will be using a Wemos D1 Mini (now Lolin D1 Mini) board with an OLED shield. To focus on the data we need to send, and not on details of environments and libraries, I will be using MicroPython for all of the code. I hope that once understood, the examples can easily be translated for any other board, display and language.
Hardware and Setup
The Wemos D1 Mini contains an ESP8266 chip, programmable over a serial connection, together with a USB-to-serial converter, which lets us connect it easily to our computers with an USB cable. It will just work on Linux, but on Windows and Mac you will need to install drivers for it. You will also need to flash a MicroPython firmware on the board, and to install Adafruit Ampy tool for running code on it.
Finally, you will need to plug the OLED shield into your board. It contains a 64x48 monochrome OLED display with an SSD1306 (or similar) chip placed directly on the glass of the display (COG — Chip On Glass), and with some required passive components.
We will put all our code in a single file, called "oled.py", which we will run with the command "ampy -p PORT run oled.py", where PORT is the serial port as which your board appears after connecting. On Linux it's "/dev/ttyUSB0", but on other platforms it will be called differently.
That's all we need.
The particular display we are using is configured to use I2C protocol for communication -- it only uses two pins, SDA and SCL, which on our board are connected to pins 4 (D2) and 5 (D1) respectively. More I2C devices can be connected to the same pins, and to select which one we want to talk to, we need to specify an address. Our shield uses address 0x3C (60 decimal).
The display can receive either commands that change its internal settings, or data of the image to be displayed on it. The distinction is made using the very first byte of each transmission. If it's 0x00, then it's commands, and if it's 0x40, then it's data. This is all we need to know to send some data to our display:
import machine i2c = machine.I2C(-1, machine.Pin(5), machine.Pin(4)) i2c.writeto_mem(0x3C, 0x40, # send data b'\xFF\xFF\xFF' )
This code creates an I2C bus, and writes to the address 0x3C byte 0x40 followed by three bytes 0xFF. In theory, it should make our display show something, but it doesn't. The display remains dark. Why?
Switching the Display On
By default the display is in a sleep mode, and needs to be switched on to start displaying anything. We will do that with the following code:
import machine i2c = machine.I2C(-1, machine.Pin(5), machine.Pin(4)) i2c.writeto_mem(0x3C, 0x00, # send commands b'\xAE' # display enable = off b'\x8D\x14' # charge pump = enable b'\xAF' # display enable = on )
There are three commands in there. First, we make sure that the display is really off, because the next command we are going to use can only be used when it's off. Then we send a command that enables the internal charge pump.
What is a charge pump? You see, and OLED display like the one we are using contains a lot of LED diodes in it, that need to be powered with a certain voltage to light up -- even as much as 15V. We can provide that voltage from an external source, or we can use a circuit that is built into the display to generate that voltage from the 3.3V it is powered with. The default setting is to use an external voltage source, so we need to change the configuration to use the internal charge pump. The last command switched the display back on.
After running this, you should see some random pixels on the display.
Our display has internal memory, which it uses to remember what it is displaying while it refreshes the display over and over again for us. That memory is organized in a peculiar way. It is divided into eight horizontal strips called "pages", each 8 pixels high, which in turn are split into 128 vertical strips 1 pixel wide. Each such strip of 8 pixels corresponds to one byte of the memory.
If you do the math, you will notice that something is wrong here: the memory stores 128x64 pixels, not 64x48 like our display! Why? Well, the same chip is used with many different OLED displays, so it has more memory available. The remaining pins of chip are simply left unconnected in our case, and so the remaining memory is unused. But we can actually make use of it with some tricks later on.
The way our display is connected is a bit convoluted as well. The first page from the top is actually page 7, and then we have 0, 1, 2, 3. Pages 4-6 are unused. Also not all columns are connected — our display only uses columns 32-95, so the first and last 32 columns are unused. We will need to take that into account when calculating coordinates.
Note that other displays may be connected to the chip differently, and even use interlacing. We will discuss how to deal with that later on.
Page Addressing Mode
The SSD1306 chip actually has three different addressing modes. We will use the page addressing, because that is compatible with all other displays, such as the SH1106. We will discuss other modes briefly later on. Our display starts in the page addressing mode by default, but we can make sure it's in that mode by sending bytes 0x20 and 0x02 as commands.
Afterwards, we need to position the cursor that we will use to write to the memory. To select the page to which we want to write, we send one of the page selection commands, from 0xB0 to 0xB7, which select pages from 0 to 7.
Then we need to select the column. For that we will need two commands, one from 0x00 to 0x0F to select the lower 4 bits, and from 0x10 to 0x1F to select the upper 4 bits. You can think about this as the second command selecting which group of 16 columns you are using, and the first one which column in that group.
Finally, we can write some data. So let's try this:
import machine i2c = machine.I2C(-1, machine.Pin(5), machine.Pin(4)) i2c.writeto_mem(0x3C, 0x00, # send commands b'\xAE' # display enable = off b'\x8D\x14' # charge pump = enable b'\xAF' # display enable = on b'\x20\x02' # address mode = page b'\xB7' # page = 7 b'\x00x\x12' # column = 32 ) i2c.writeto_mem(0x3C, 0x40, # send data b'\xFF\xFF\xFF\xFF' )
This should make a rectangle of 4x8 pixels appear in one of the corners of the display. We are making progress!
Note that every time you write a byte of data, it's written to the memory, and the column register is increased by one. However, in the page addressing mode, it doesn't wrap around! If you need to write data to several different pages,
you have to re-set the cursor position for each page. We will later see that other addressing modes don't have that limitation.
Bits and Pixels
It's nice that we have a rectangle, but how do we draw individual pixels? Well, the short answer is: we don't. As we said before, individual pixels correspond to bits in our bytes, and we can only send a whole byte (8 pixels) at a time at the minimum. To light up a single pixel you would need to either read the byte, or remember what it was, and then modify it to add one pixel, and write it back. That is exactly what all those libraries do, and that is the reason why they are either slow or take up a lot of memory (or both).
But in practice, most of the time you are not really drawing individual pixels. Sure, if it's some kind of a graph you are plotting, then you would do best by allocating a buffer of memory, filling it with zeroes, adding all the pixels you need, and then sending that to the display. But if it's images, elements of the user interface, text or even graphics of a game, you are going to be much better off by making sure things align with the 8-pixel pages, and write whole bytes.
To see how that works, let's try to draw a letter "X" on the display. We will start by drawing it with the bits. We will need four columns, like this:
0b00110110 0b00001000 0b00110110 0b00000000
That gives us four numbers: 0x36, 0x08, 0x36, 0x00. Let's try writing that:
import machine i2c = machine.I2C(-1, machine.Pin(5), machine.Pin(4)) i2c.writeto_mem(0x3C, 0x00, b'\xAE\x8D\x14\xAF' # init b'\xB7\x00x\x12' # page=7, column = 32 ) i2c.writeto_mem(0x3C, 0x40, # send data b'\x36\x08\x36\x00' )
If we defined such four numbers for every character to be displayed, we could
easily make a function that displays text on our display! Let's try it:
import machine FONT = (b'\x00\x00\x00\x00\x00^\x00\x00\x06\x00\x06\x00~$~\x00,~4\x00b\x18F' b'\x004Jt\x00\x00\x06\x00\x00<BB\x00BB<\x00*\x1c*\x00\x08\x1c\x08\x00' b'@0\x00\x00\x08\x08\x08\x00\x00@\x00\x00`\x18\x06\x00<"\x1e\x00\x04>' b'\x00\x00:*.\x00**>\x00\x0e\x08>\x00.*:\x00>*:\x00\x02\x02>\x00>*>' b'\x00.*>\x00\x00\x14\x00\x00@4\x00\x00\x08\x14"\x00\x14\x14\x14\x00"' b'\x14\x08\x00\x02Z\x0e\x00~Z^\x00>\n>\x00>*4\x00>"6\x00>"\x1c\x00>*"' b'\x00>\n\x02\x00>":\x00>\x08>\x00">"\x000 >\x00>\x086\x00> \x00>\x04' b'>\x00>\x02>\x00>">\x00>\n\x0e\x00>"~\x00>\x1a.\x00.*:\x00\x02>\x02' b'\x00> >\x00\x1e0\x1e\x00>\x10>\x006\x086\x00\x0e8\x0e\x002*&\x00~BB' b'\x00\x06\x18`\x00BB~\x00\x0c\x06\x0c\x00@@@@\x00\x02\x04\x004,<\x00>' b'$<\x00<$$\x00<$>\x00<4,\x00\x08~\n\x00\\T|\x00>\x04<\x00\x08: \x00`H' b'z\x00>\x084\x00\x02> \x00<\x1c<\x00<\x04<\x00<$<\x00|$<\x00<$|\x00<' b'\x04\x0c\x00,,4\x00\x04>$\x00< <\x00\x1c \x1c\x00<0<\x004\x084\x00\\' b'P|\x004,$\x00\x08vB\x00\x00~\x00\x00Bv\x08\x00\x10*\x04\x00') def move(x, y): x += 32 buffer = bytearray(3) buffer = 0xB0 | y buffer = x & 0x0f buffer = 0x10 | (x 4) & 0x0f i2c.writeto_mem(0x3C, 0x00, buffer) def letter(c): index = min(95, max(0, ord(c) - 32)) * 4 buffer = FONT[index:index + 4] i2c.writeto_mem(0x3C, 0x40, buffer) i2c = machine.I2C(-1, machine.Pin(5), machine.Pin(4)) i2c.writeto_mem(0x3C, 0x00, b'\xAE\x8D\x14\xAF') row, col = 0, 0 move(col, row) for c in "Hello world! This is a test! Testing 1, 2, 3.": letter(c) col += 1 if col >= 16: col = 0 row += 1 if row >= 5: row = 0 move(col, row)
We have defined two helper functions. One for moving the cursor to the specified place, and another for sending the data of a letter. We have a big string of bytes containing the definitions of the shapes of our letters.
Using the same technique we can also place other graphical elements, not just letters.
We mentioned earlier that the extra memory can be utilized somehow. Turns out that this display has a built-in scrolling mechanism, which you can use to display the parts of the memory that are further down. It even wraps around, so you can use the invisible parts of the memory as a buffer. The commands that set the scrolling are 0x40 to 0x7F — each sets the top of the display to start at a corresponding line (0x40 at line 0, 0x41 at line 1, ..., 0x7F at line 63). We can use it like this:
def scroll(dy): _byte = bytearray(1) _byte = 0x40 | dy & 0x3F i2c.writeto_mem(0x3C, 0x00, _byte)
Since our particular display has twice as much memory as the display size, we could even use it for double-buffering! Draw our stuff on the invisible part, and then switch to display it, and draw the next frame on the other part.
Scrolling can also be used for one more thing: if you want to have a frame around your text, but smaller than 8 pixels, then you can scroll the displays by 4 pixels and draw your frame in there.
While everything works with just the default settings of the chip, it also has a number of registers that we can use to fine tune it for a particular display we have, and improve its efficiency a little. Consider this example:
i2c.writeto_mem(0x3C, 0x00, b'\xA1' b'\xC8' b'\xD3\x00' b'\x81\xFF' b'\xA8\x30' b'\xA4' b'\xA6' b'\xda\x12' b'\xd5\x80' b'\xd9\xf1' b'\xdb\x30' )
Some of those are specific to the SSD1306 (the clock settings or the pre-charge period), others are common to all displays. Refer to the datasheet of your particular display to find the corresponding commands for any other module.
Other Addressing Modes
The SSD1306 display has two more addressing modes that are a little bit more convenient. The main difference is that we specify a rectangle to me updated on the screen, and when the cursor reaches the end of that rectangle, it wraps around to the next page. This is nice, because it lets us display a multi-page image in one transfer — in particular, we can send the contents of the whole screen that way, and that's usually how all the libraries out there work. This addressing mode uses commands 0x21 and 0x22 for setting the cursor position. Refer to the datasheet for details.
It's worth reading the datasheet, because different displays have various additional commands available, that are sometimes really convenient. For instance, the SSD1306 has additional commands for setting up and performing automatic scrolling without you having to update the display at all — the chip takes care of handling the animations! Other chips will have other custom stuff available, so always make sure you know exactly what chip your display uses and that you have an up-to-date datasheet for it, and go digging!
The same chip can also be setup to communicate using a 4-wire SPI protocol. There is not much difference in practice from your point of view, except that you use SPI, you need to keep the CS pin low when talking to the display, and instead of sending that first byte with value 0x00 or 0x40, you hold the DC pin high or low. Oh, and it's much faster.
The chip can also use two parallel protocols for communication, where you send a whole byte at a time using 8 pins. I haven't seen any ready to use modules setup in those modes, so I will leave them out of this. It's a nice thing to keep in mind if you plan a custom build, have a lot of free pins and want really large speeds.