Baremetal Arduino: Oled Display SH1106 SPI

0 - Introduction

In some previous articles, we touched on the subject of OLED screens and, with the help of U8g2 lib, we even got SH1106 and SSD1302 screens working. In today’s article though, we won’t get any help from U8g2 because we will make our own SH1106 driver from scratch.

Before starting, you will need the SH1106 SPI screen (buy here) and an Arduino Nano (buy here).

1 - Pins Code

To simplify the usage of pins, we can create a small struct that stores the port, data direction register and bit for the pin we will want to use. Taking inspiration from Arduino’s libraries, we can create a function named ‘set mode’ to set a pin as input or output, and one named ‘set value’ to set the state as either high or low. 

We can, lastly, check the Arduino Nano’s schematic (which is the one i am using) and define the pins:

#ifndef _pins_h_
#define  _pins_h_

#include <avr/io.h>

#define PM_HIGH 1
#define PM_LOW  0
#define PM_OUTPUT 1
#define PM_INPUT  1

typedef struct 
{
    volatile uint8_t* port;
    volatile uint8_t* ddr;
    uint8_t pin;
} pin_t;

void pin_setmode(pin_t pin, uint8_t mode);
void pin_setval(pin_t pin, uint8_t val);


#ifdef __AVR_ATmega328P__ // Arduino Nano

    #define D0 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD0}
    #define D1 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD1}
    #define D2 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD2}
    #define D3 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD3}
    #define D4 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD4}
    #define D5 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD5}
    #define D6 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD6}
    #define D7 (pin_t){.ddr = &DDRD, .port = &PORTD, .pin = PD7}

    #define D8 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB0}
    #define D9 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB1}
    #define D10 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB2}
    #define D11 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB3}
    #define D12 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB4}
    #define D13 (pin_t){.ddr = &DDRB, .port = &PORTB, .pin = PB5}

    #define A0 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC0}
    #define A1 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC1}
    #define A2 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC2}
    #define A3 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC3}
    #define A4 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC3}
    #define A5 (pin_t){.ddr = &DDRC, .port = &PORTC, .pin = PC5}


#elif __AVR_ATmega2560__ // Arduino Mega

#endif

#endif

In the cpp file we need to implement both ‘set mode’ and ‘set value’.

For ‘set mode’ we need to set the bit in the data direction register if ‘mode’ is non-zero and clear it, if it is zero, ‘set value’ will be pretty similar, as we only change the ‘pin.ddr’ to ‘pin.port’ (and the variable name ‘mode’ to ‘val’):

#include "pins.h"

void pin_setmode(pin_t pin, uint8_t mode)
{
    if (mode)
        *pin.ddr |= _BV(pin.pin);
    else 
        *pin.ddr &= ~_BV(pin.pin);
}
void pin_setval(pin_t pin, uint8_t val)
{
    if (val)
        *pin.port |= _BV(pin.pin);
    else 
        *pin.port &= ~_BV(pin.pin);
}

2 - SPI Code

One of the requirements for a SH1106 display is to either have SPI or I2C. The display i used uses SPI, so that’s what we will implement.

Start by including the ‘pins’ header file created before, then define the spi pins (check the schematic again) and create 4 functions, ‘init’, ‘begin’, ‘end’ and ‘send’:

#ifndef _spi_sync_h_
#define _spi_sync_h_

#include "pins.h"

#ifdef __AVR_ATmega328P__

    #define SPI_SS D10
    #define SPI_SCK D13
    #define SPI_MOSI D11
    #define SPI_MISO D12

#elif __AVR_ATmega2560__
    
    // Add pins for Arduino Mega
    
#endif

void spi_init(pin_t cs);

void spi_begin(pin_t cs);
void spi_end(pin_t cs);

void spi_send(uint8_t data);

#endif

On the cpp file, create the ‘init’ function. In it, set all SPI pins to output except ‘MISO’, which is set to input and also  set CS to output and HIGH. The ‘begin’ and ‘end’ functions simply set the CS pin to low and high respectively and, finally, on the ‘send’ function, write the data to ‘SPDR’ and wait until it is sent:

#include "spi_sync.h"

void spi_init(pin_t cs)
{
    pin_setmode(SPI_MOSI, PM_OUTPUT);
    pin_setmode(SPI_SCK, PM_OUTPUT);
    pin_setmode(SPI_MISO, PM_INPUT);
    pin_setmode(cs, PM_OUTPUT);
    pin_setval(cs, PM_HIGH);

    SPCR = _BV(SPE) | _BV(MSTR) | _BV(SPR1) | _BV(SPR0);
}

void spi_begin(pin_t cs) { pin_setval(cs, PM_LOW); }
void spi_end(pin_t cs) { pin_setval(cs, PM_HIGH); }

void spi_send(uint8_t data)
{
    SPDR = data;
    while (!(SPSR & (1 << SPIF)));
}

3 - Display Code

On the display driver’s header file, we will use the spi file we created just now, along with ‘avr/io’. For defines, we need the resolution of the display, page ammount, buffersize and colors (black and white).

In this driver we will use a simple struct to house the display buffer along with the 3 pins required by the display, those will be the chip select (CS), data command (DC) and reset (RES).

As for the functions we will create, we will need ‘init’, ‘update’, one to ‘draw_fill’ the screen and one to ‘draw_pixel’ (‘draw_box is optional’ and was just used for testing):

#ifndef _sh1106_sync_h_
#define _sh1106_sync_h_

#include <avr/io.h>
#include "spi_sync.h"

#define SH1106_WIDTH 128
#define SH1106_HEIGHT 64
#define SH1106_PAGES (SH1106_HEIGHT / 8)
#define SH1106_BUFSIZE (SH1106_WIDTH * SH1106_PAGES)
#define SH1106_WHITE 1
#define SH1106_BLACK 0

typedef struct
{
    uint8_t buf[SH1106_BUFSIZE];
    pin_t cs;
    pin_t dc;
    pin_t res;
} sh1106_t;

sh1106_t sh1106_init(pin_t cs, pin_t dc, pin_t res);
void sh1106_update(sh1106_t* oled);

void sh1106_draw_fill(sh1106_t* oled, uint8_t color);
void sh1106_draw_pixel(sh1106_t* oled, uint8_t x, uint8_t y, uint8_t color);
void sh1106_draw_box(sh1106_t* oled, uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint8_t color);

#endif

As for the cpp file, we will need ‘util/delay’ from the avr libs, and all the commands copied from the SH1106’s datasheet:

#include "sh1106_sync.h"
#include <util/delay.h>

// Define SH1106 commands
#define SH1106_DISPLAY_OFF              0xAE
#define SH1106_DISPLAY_ON               0xAF
#define SH1106_SET_DISPLAY_CLOCK        0xD5
#define SH1106_SET_MULTIPLEX            0xA8
#define SH1106_SET_DISPLAY_OFFSET       0xD3
#define SH1106_SET_START_LINE           0x40
#define SH1106_SET_CHARGE_PUMP          0xAD
#define SH1106_SET_MEMORY_ADR           0x20
#define SH1106_SET_SEGMENT_REMAP        0xA1
#define SH1106_SET_COM_SCAN_DEC         0xC8
#define SH1106_SET_COM_PINS             0xDA
#define SH1106_SET_CONTRAST             0x81
#define SH1106_SET_PRECHARGE            0xD9
#define SH1106_SET_VCOM_DESELECT        0xDB
#define SH1106_DISPLAY_ALL_ON_RESUME    0xA4
#define SH1106_NORMAL_DISPLAY           0xA6

For sending data to the display we will need two functions. The first one will be named ‘send command’ and it will set the dc pin to low before sending data to the display, and after the transfer is complete, it will set it to high again. The second function is also optional as it is just calling the spi send function:

void sh1106_sendCmd(pin_t dc, uint8_t val)
{
    pin_setval(dc, PM_LOW);

    spi_send(val);

    pin_setval(dc, PM_HIGH);
}
void sh1106_sendData(uint8_t val) 
{ 
    spi_send(val); 
}

We will now create the initialization sequence for the display, keep in mind that every display model is a little different and your brand might require a different sequence than mine does. The following sequence worked on 3 different displays i tested, so it probably will work on your display as well.

We start setting up the pins by setting both dc and reset as output, and setting them high by default, then initialize the spi by passing in the cs pin.

The next step is to reset the display, we do that by setting ‘res’ as low for a little while and then setting back to high.

Now we are ready to send the intialization sequence begin a spi transmission and start sending the commands, once again, keep in mind that this sequence has a chance to not work perfectly on your display.

And, finally, we end the transmission and return the struct with the display buffer and pins:

sh1106_t sh1106_init(pin_t cs, pin_t dc, pin_t res)
{
    pin_setmode(dc, PM_OUTPUT); pin_setmode(res, PM_OUTPUT);
    pin_setval(dc, PM_HIGH);    pin_setval(res, PM_HIGH);
    spi_init(cs);

    pin_setval(res, PM_LOW);
    _delay_ms(10);
    pin_setval(res, PM_HIGH);
    _delay_ms(10);

    spi_begin(cs);

    sh1106_sendCmd(dc, SH1106_DISPLAY_OFF);
    _delay_ms(50);

    sh1106_sendCmd(dc, SH1106_SET_DISPLAY_CLOCK);
    sh1106_sendCmd(dc, 0x80);

    sh1106_sendCmd(dc, SH1106_SET_MULTIPLEX);
    sh1106_sendCmd(dc, 0x3F);

    sh1106_sendCmd(dc, SH1106_SET_DISPLAY_OFFSET);
    sh1106_sendCmd(dc, 0x00);
    
    sh1106_sendCmd(dc, SH1106_SET_START_LINE);

    sh1106_sendCmd(dc, SH1106_SET_CHARGE_PUMP);
    sh1106_sendCmd(dc, 0x8B);

    sh1106_sendCmd(dc, SH1106_SET_MEMORY_ADR);
    sh1106_sendCmd(dc, 0x00);

    sh1106_sendCmd(dc, SH1106_SET_SEGMENT_REMAP);
    sh1106_sendCmd(dc, SH1106_SET_COM_SCAN_DEC);

    sh1106_sendCmd(dc, SH1106_SET_COM_PINS);
    sh1106_sendCmd(dc, 0x12);

    sh1106_sendCmd(dc, SH1106_SET_CONTRAST);
    sh1106_sendCmd(dc, 0x7F);

    sh1106_sendCmd(dc, SH1106_SET_PRECHARGE);
    sh1106_sendCmd(dc, 0xF1);

    sh1106_sendCmd(dc, SH1106_SET_VCOM_DESELECT);
    sh1106_sendCmd(dc, 0x30);

    sh1106_sendCmd(dc, SH1106_DISPLAY_ALL_ON_RESUME);
    sh1106_sendCmd(dc, SH1106_NORMAL_DISPLAY);

    sh1106_sendCmd(dc, SH1106_DISPLAY_ON);
    _delay_ms(150);

    spi_end(cs);

    return (sh1106_t)
    {
        .dc = dc,
        .cs = cs,
        .res = res,
        .buf = {0}
    };
}

The update function requires us to divide the buffer in pages, and sending them one by one so, we begin the spi transmission, send the commands 0x00 and 0x10, then we send 0xB0 plus the page number we are going to send, and then the 128 bytes of the page and, lastly, end the transmission:

void sh1106_update(sh1106_t* oled)
{
    spi_begin(oled->cs);

    for (uint8_t page = 0; page < SH1106_PAGES; page++)
    {
        sh1106_sendCmd(oled->dc, 0x00);
        sh1106_sendCmd(oled->dc, 0x10);
        sh1106_sendCmd(oled->dc, 0xB0 + page);

        for (uint8_t c = 0; c < SH1106_WIDTH; c++)
            sh1106_sendData(oled->buf[page * SH1106_WIDTH + c]);
    }

    spi_end(oled->cs);
}

The ‘fill’ function will either set all pixels to white or to black by filling the buffer with 255s or zeros:

void sh1106_draw_fill(sh1106_t* oled, uint8_t color)
{
    color = (color) ? 0xFF : 0x00;
    for (uint16_t i = 0; i < SH1106_BUFSIZE; i++)
        oled->buf[i] = color;
}

Lastly on the driver, we create the draw pixel and draw box functions:

void sh1106_draw_pixel(sh1106_t* oled, uint8_t x, uint8_t y, uint8_t color)
{
    /*// wrap
    x = x % SH1106_WIDTH;
    y = y % SH1106_HEIGHT;*/
    if (x >= SH1106_WIDTH || y >= SH1106_HEIGHT)
        return;

    uint16_t i = (y / 8) * SH1106_WIDTH + x;
    if (color)
        oled->buf[i] |= (1 << (y % 8));
    else 
        oled->buf[i] &= ~(1 << (y % 8));
}

void sh1106_draw_box(sh1106_t* oled, uint8_t x, uint8_t y, uint8_t w, uint8_t h, uint8_t color)
{
    for (uint8_t xx = 0; xx < w; xx++)
        for (uint8_t yy = 0; yy < h; yy++)
            sh1106_draw_pixel(oled, xx + x, yy + y, color);
}

3 - main.cpp

On main, we initalize the display, create a variable to keep track of the pixel we will paint and, in an infinite loop, update the pixel position, fill the screen with black, draw the new pixel and update the screen:

#include <avr/io.h>
#include "sh1106_sync.h"

int main()
{
    sh1106_t oled = sh1106_init(D10, A2, A1);
    
    uint8_t x = 0;
    while (1) 
    {
        x = (x + 1) % SH1106_WIDTH;
        sh1106_draw_fill(&oled, SH1106_BLACK);
        sh1106_draw_pixel(&oled, x, 10, SH1106_WHITE);
        sh1106_update(&oled);
    }
}

And that’s all. Thanks for reading and stay tuned for more tech insights and tutorials. Until next time, and keep exploring the world of tech!