« Posts list

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]
#![no_main]

extern crate cortex_m;
extern crate cortex_m_rt as rt;
extern crate panic_semihosting;
extern crate stm32f1xx_hal as hal;

use cortex_m_rt::{ExceptionFrame, entry, exception};
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, Line, Rect};
use hal::i2c::{BlockingI2c, DutyCycle, Mode};
use hal::prelude::*;
use hal::stm32;
use ssd1306::prelude::*;
use ssd1306::Builder;

#[entry]
fn main() -> ! {
    let dp = stm32::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 = BlockingI2c::i2c1(
        dp.I2C1,
        (scl, sda),
        &mut afio.mapr,
        Mode::Fast {
            frequency: 400_000,
            duty_cycle: DutyCycle::Ratio2to1,
        },
        clocks,
        &mut rcc.apb1,
        1000,
        10,
        1000,
        1000,
    );

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

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

    disp.draw(
        Line::new(Coord::new(8, 16 + 16), Coord::new(8 + 16, 16 + 16))
            .with_stroke(Some(1u8.into()))
            .into_iter(),
    );
    disp.draw(
        Line::new(Coord::new(8, 16 + 16), Coord::new(8 + 8, 16))
            .with_stroke(Some(1u8.into()))
            .into_iter(),
    );
    disp.draw(
        Line::new(Coord::new(8 + 16, 16 + 16), Coord::new(8 + 8, 16))
            .with_stroke(Some(1u8.into()))
            .into_iter(),
    );

    disp.draw(
        Rect::new(Coord::new(48, 16), Coord::new(48 + 16, 16 + 16))
            .with_stroke(Some(1u8.into()))
            .into_iter(),
    );

    disp.draw(
        Circle::new(Coord::new(96, 16 + 8), 8)
            .with_stroke(Some(1u8.into()))
            .into_iter(),
    );

    disp.flush().unwrap();

    loop {}
}

#[exception]
fn HardFault(ef: &ExceptionFrame) -> ! {
    panic!("{:#?}", ef);
}

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

use cortex_m_rt::{ExceptionFrame, entry, exception};
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, Line, Rect};
use hal::i2c::{BlockingI2c, DutyCycle, Mode};
use hal::prelude::*;
use hal::stm32;
use ssd1306::prelude::*;
use ssd1306::Builder;

// ...

let dp = stm32::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 = BlockingI2c::i2c1(
    dp.I2C1,
    (scl, sda),
    &mut afio.mapr,
    Mode::Fast {
        frequency: 400_000,
        duty_cycle: DutyCycle::Ratio2to1,
    },
    clocks,
    &mut rcc.apb1,
    1000,
    10,
    1000,
    1000,
);

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 unless you just want to draw raw pixels. 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(Coord::new(8, 16 + 16), Coord::new(8 + 16, 16 + 16))
        .with_stroke(Some(1u8.into()))
        .into_iter(),
);
disp.draw(
    Line::new(Coord::new(8, 16 + 16), Coord::new(8 + 8, 16))
        .with_stroke(Some(1u8.into()))
        .into_iter(),
);
disp.draw(
    Line::new(Coord::new(8 + 16, 16 + 16), Coord::new(8 + 8, 16))
        .with_stroke(Some(1u8.into()))
        .into_iter(),
);

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

// Circle
disp.draw(
    Circle::new(Coord::new(96, 16 + 8), 8)
        .with_stroke(Some(1u8.into()))
        .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 the STM32F103, using the SSD1306 via I2C:

#![no_std]
#![no_main]

extern crate cortex_m;
extern crate cortex_m_rt as rt;
extern crate panic_semihosting;
extern crate stm32f1xx_hal as hal;

use core::fmt::Write;
use cortex_m_rt::ExceptionFrame;
use cortex_m_rt::{entry, exception};
use hal::i2c::{BlockingI2c, DutyCycle, Mode};
use hal::prelude::*;
use hal::stm32;
use ssd1306::prelude::*;
use ssd1306::Builder;

#[entry]
fn main() -> ! {
    let dp = stm32::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 = BlockingI2c::i2c1(
        dp.I2C1,
        (scl, sda),
        &mut afio.mapr,
        Mode::Fast {
            frequency: 400_000,
            duty_cycle: DutyCycle::Ratio2to1,
        },
        clocks,
        &mut rcc.apb1,
        1000,
        10,
        1000,
        1000,
    );

    let mut disp: TerminalMode<_> = Builder::new().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();
}

#[exception]
fn HardFault(ef: &ExceptionFrame) -> ! {
    panic!("{:#?}", ef);
}

You can find the full 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!