«

Announcing the SSD1306 OLED display driver

As part of the weekly driver initiative, myself (@jamwaffles), @therealprof and @scowcron have been working on a Rust driver for the common as mud SSD1306-based OLED display modules. This little chip is found in the majority of inexpensive OLED display modules found on Ebay and AliExpress. It supports either an SPI or I2C interface, both of which the driver supports.

The driver currently supports two modes:

The easiest way to get started with either mode is to use the Builder. Here’s an example that connects over I2C and draws some shapes in GraphicsMode for the STM32F103:

#![no_std]

extern crate cortex_m;
extern crate embedded_graphics;
extern crate embedded_hal as hal;
extern crate ssd1306;
extern crate stm32f103xx_hal as blue_pill;

use blue_pill::i2c::{DutyCycle, I2c, Mode};
use blue_pill::prelude::*;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, Line, Rect};
use ssd1306::prelude::*;
use ssd1306::Builder;

fn main() {
    let dp = blue_pill::stm32f103xx::Peripherals::take().unwrap();

    let mut flash = dp.FLASH.constrain();
    let mut rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze(&mut flash.acr);
    let mut afio = dp.AFIO.constrain(&mut rcc.apb2);
    let mut gpiob = dp.GPIOB.split(&mut rcc.apb2);
    let scl = gpiob.pb8.into_alternate_open_drain(&mut gpiob.crh);
    let sda = gpiob.pb9.into_alternate_open_drain(&mut gpiob.crh);

    let i2c = I2c::i2c1(
        dp.I2C1,
        (scl, sda),
        &mut afio.mapr,
        Mode::Fast {
            frequency: 400_000,
            duty_cycle: DutyCycle::Ratio1to1,
        },
        clocks,
        &mut rcc.apb1,
    );

    let mut disp: GraphicsMode<_> = Builder::new()
        .connect_i2c(i2c)
        .into();

    disp.init().unwrap();
    disp.flush().unwrap();

    // Triangle
    disp.draw(Line::new((8, 16 + 16), (8 + 16, 16 + 16), 1).into_iter());
    disp.draw(Line::new((8, 16 + 16), (8 + 8, 16), 1).into_iter());
    disp.draw(Line::new((8 + 16, 16 + 16), (8 + 8, 16), 1).into_iter());

    // Square
    disp.draw(Rect::new((48, 16), (48 + 16, 16 + 16), 1u8).into_iter());

    // Circle
    disp.draw(Circle::new((96, 16 + 8), 8, 1u8).into_iter());

    disp.flush().unwrap();
}

First, we need to set up the I2C interface. This is pretty standard HAL boilerplate:

use blue_pill::i2c::{DutyCycle, I2c, Mode};
use blue_pill::prelude::*;

// ...

let dp = blue_pill::stm32f103xx::Peripherals::take().unwrap();

let mut flash = dp.FLASH.constrain();
let mut rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.freeze(&mut flash.acr);
let mut afio = dp.AFIO.constrain(&mut rcc.apb2);
let mut gpiob = dp.GPIOB.split(&mut rcc.apb2);
let scl = gpiob.pb8.into_alternate_open_drain(&mut gpiob.crh);
let sda = gpiob.pb9.into_alternate_open_drain(&mut gpiob.crh);

let i2c = I2c::i2c1(
    dp.I2C1,
    (scl, sda),
    &mut afio.mapr,
    Mode::Fast {
        frequency: 400_000,
        duty_cycle: DutyCycle::Ratio1to1,
    },
    clocks,
    &mut rcc.apb1,
);

You’ll need to change this code to work with the device you’re using. I’m running the I2C1 interface at 400KHz in the example above.

Next, let’s create a display instance, initialise it and clear the display:

use ssd1306::prelude::*;
use ssd1306::Builder;

// ...

let mut disp: GraphicsMode<_> = Builder::new().connect_i2c(i2c).into();

disp.init().unwrap();
disp.flush().unwrap();

This is where we use the Builder pattern to construct a driver that will talk to the display over I2C. By default, the builder returns a RawMode driver which isn’t very useful on it’s own. To be able to do more useful things, we’ll call .into() which will convert the driver into a richer mode defined by the type of disp. In this case, we want to use GraphicsMode<_> to be able to use all the goodness from the embedded_graphics crate.

The last step is to initialise and clear the display with disp.init() and disp.flush() (graphics mode has an empty display buffer by default).

Now we can draw some stuff to the display:

// Triangle
disp.draw(Line::new((8, 16 + 16), (8 + 16, 16 + 16), 1).into_iter());
disp.draw(Line::new((8, 16 + 16), (8 + 8, 16), 1).into_iter());
disp.draw(Line::new((8 + 16, 16 + 16), (8 + 8, 16), 1).into_iter());

// Square
disp.draw(Rect::new((48, 16), (48 + 16, 16 + 16), 1u8).into_iter());

// Circle
disp.draw(Circle::new((96, 16 + 8), 8, 1u8).into_iter());

disp.flush().unwrap();

This will draw a triangle, square and circle in roughly the middle of the display.

A triangle, square and circle

Bufferless

Because GraphicsMode is buffered, you need to call disp.flush() to write the buffer to the display. It also consumes 1KiB of RAM to hold the buffer which is quite a lot of memory for a µC!

Another supported mode is TerminalMode, implemented by @therealprof. TerminalMode is an unbuffered character output mode that renders only text. It draws from left to right and top to bottom, restarting in the top left corner. It uses a built-in 7x7 font on a fixed 8x8 pixel grid.

Aside from writing raw strings to the display, this mode also supports the core::fmt::Write trait so you can call any of the usual Rust output and formatting methods/macros on it. While useful, be aware that doing so will add a lot of bloat to your binary.

Here’s a small “Hello World!” example for a STM32F042, using the SSD1306 via I2C:

#![no_std]

extern crate cortex_m;
extern crate stm32f042_hal as hal;
extern crate embedded_hal;
extern crate ssd1306;

use hal::prelude::*;
use hal::i2c::*;
use hal::stm32f042;
use ssd1306::Builder;
use ssd1306::prelude::*;
use core::fmt::Write;

fn main() {
    if let Some(p) = stm32f042::Peripherals::take() {
        let gpiof = p.GPIOF.split();
        let mut rcc = p.RCC.constrain();
        let _ = rcc.cfgr.freeze();
        let scl = gpiof.pf1.into_alternate_af1().internal_pull_up(true).set_open_drain();
        let sda = gpiof.pf0.into_alternate_af1().internal_pull_up(true).set_open_drain();

        /* Setup I2C1 */
        let mut i2c = I2c::i2c1(p.I2C1, (scl, sda), 400.khz());

        let mut disp: TerminalMode<_> = Builder::new()
            .with_i2c_addr(0x3c)
            .connect_i2c(i2c).into();

        disp.set_rotation(DisplayRotation::Rotate180).unwrap();

        disp.init().unwrap();
        disp.clear();

        // Write a string to the display
        disp.write_str("Hello world!").unwrap();

        // Write a string using the `write!()` macro
        write!(disp, "Hello world!").unwrap();
    }
}

You can find another example here.

There’s currently no positioning or scrolling support beyond calling clear(), but this mode provides a lighter alternative to a full, buffered GraphicsMode.

Onwards

Please give the driver a try! There are a bunch of examples in the repo which should be a good starting point. They contain device-specific initialisation code, but the driver code itself is agnostic, so they should provide a good starting point. The driver should be pretty usable on most systems, but there’s a plethora of hardware out there, some combinations of which might not work. Please open an issue if you find a bug or something missing from the crate. The crate is written in a way that makes it relatively easy to add new modes, so if you’ve got a great idea for one, please submit a PR!

Share Comment on Twitter