In the previous post, we got our discovery board lighting up on button presses using GPIO interrupts. Next I want to introduce our first peripheral device, our ePaper display.
I chose the 2.9 inch three color ePaper module from Waveshare for this project, mostly because a color ePaper display is pretty cool and I wanted to try it out. Be aware, they are very slow.
In order to communiate with the display, we’ll use SPI, a synchronous (synchronized to a clock signal), serial (one bit at a time) communication protocol running on four wires. SPI is extremely common protocol on embedded systems for communicating with peripherals. The radio transceiver on our discovery board is connected to our microcontroller via SPI as well.
SPI designates two lines, one for each device, for transmitting data (MOSI
and MISO
). It uses one line for clock synchronization (SCK
) and one for notifying the device we’re going to send data (CS
) to it. Because we have a display with one way communication, we only use one of the data lines (MOSI
) and simply set CS
to active when we want to transmit data.
Waveshare provides a wiki page for each display that gives a summary of the communication protocol specific to that display. In addition to the SPI lines, the display uses three more pins:
RST
- for resetting the deviceBUSY
(our only input pin) - indicates we need to wait because the device is busyDC
- indicates to the display whether we’re sending data or a command
Initialization
Let’s look at how we connect and initialize our device. The first thing to note is that we won’t be directly manipulating the SPI data lines ourselves. Our MCU datasheet tells us that it provides 2 SPI peripherals that handle the SPI communication for us. The gritty details are again in our reference manual, chapter 30.
Lucky for us, embedded-hal
and the HAL for our board are going to handle those details for us by initializating the peripherals (setting the clock, etc.) and giving us a convenient interface for sending and receiving data.
Let’s work on getting our SPI peripheral initialized. Spoiler alert for later: the datasheet for our module, page 4 tells us that the radio transceiver is connected to SPI1
. And so we’ll be using SPI2
for our display!
SPI pins
The SPI peripheral in our microcontroller is only capable of using specific pins for the SPI lines so let’s get those sorted.
If we look at our board manual, we find that chapter 9.2/table 7 tells us which pins are exposed on our board and which function they have. As part of the CN3
connector we find SPI2_SCK: PB13
, SPI2_MOSI: PB15
, and SPI2_NSS: PB12
(NSS is another name for the CS
line).
A more complete description of the pins and their functions can be found in table 16 of our MCU datasheet.
HAL
The HAL crate and clever types also gives us some hints as to which pins we need to provide. If we look at the signature of the Spi::spi2
function we see we need to pass an argument pins: PINS
with the bound PINS: Pins<SPI2>
. The docs for the Pins<SPI>
Trait
link to the PinSck
, PinMiso
, and PinMosi
traits. PinMosi<SPI2>
for example, is only implemented by PB15
. You may also have noticed the NoMiso
struct, which implements PinMiso<SPI2>
. We’ll pass that instead of passing in a real pin and leaving it unused.
The final arguments of our Spi::spi2
function are:
- the raw peripheral
SPI2
, which comes from ourPeripherals
struct (dp
from part 1) - the speed of the SPI peripheral, which we set equal to the system clock speed (in
main
we set it to the default) - the
mode: spi::Mode
SPI Mode
SPI requires setting the clock polarity and phase, which determine timing and polarity of the clock pulses with respect to the data signal. Reading through the display wiki or by interpreting the diagrams in the datasheet, chapter MCU Serial Interface (4-wire SPI), we see that we need SPI mode 0.
Finally we have something like:
let spi = Spi::spi2(
.SPI2,
dp.pb12, NoMiso, gpio.pb15),
(gpiob,
MODE_0.clocks.sys_clk(),
rcc&mut rcc
; )
Additional pins
We’ve also got to connect BUSY
, DC
and RST
. We can arbitrarily choose any GPIO pins for this. I chose PA2
, PA10
and PA8
.
Display driver crate
We now have our SPI peripheral connected and setup and can start up the display and send data. How exactly this is done is specified, albeit sometimes vaguely and confusingly, in the datasheet. It lists the commands we can send as well as how to send the pixel data.
We’ll spare ourselves translating those commands into Rust and use the amazing epd-waveshare library. Let’s start a new module for handling our display:
⮟ src/epaper.rs
use crate::hal::{
gpio::*,
,
pacprelude::*,
rcc::Rcc,
spi::{NoMiso, Spi, MODE_0},
};
pub type SPI = Spi<pac::SPI2, (gpiob::PB13<Analog>, NoMiso, gpiob::PB15<Analog>)>;
pub fn init(
: pac::SPI2,
spi2: gpiob::PB13<Analog>,
sck: gpiob::PB15<Analog>,
mosi: gpiob::PB12<Analog>,
cs: &mut Rcc,
rcc-> SPI {
) Spi::spi2(spi2, (sck, NoMiso, mosi), MODE_0, rcc.clocks.sys_clk(), rcc)
}
Most waveshare ePaper displays are supported by epd-waveshare
and in the docs we find EPD2in9bc::new
for our device (the protocol for the red and yellow versions of our display is identical).
We’ll need our initialized SPI struct as well as CS
, which we haven’t used yet. The SPI
struct doesn’t own the CS
pin like it does the other SPI pins. Retaining ownership of the pin gives us more flexibility, especially because if we have multiple SPI devices, we need one CS
pin per device. We also need the 3 other Waveshare specific pins, RST
, BUSY
and DC
.
The final piece of the puzzle here is delay: &mut DELAY where DELAY: DelayMs<u8>
, which we’re going to require as an argument of our init
function and cover in just a second:
⮟ src/epaper.rs
use crate::hal::{
gpio::*,
,
pacprelude::*,
rcc::Rcc,
spi::{NoMiso, Spi, MODE_0},
spi::{NoMiso, Spi},
};
use embedded_hal::blocking::delay::*;
use epd_waveshare::{epd2in9bc::EPD2in9bc, prelude::*, SPI_MODE};
pub type SPI = Spi<pac::SPI2, (gpiob::PB13<Analog>, NoMiso, gpiob::PB15<Analog>)>;
pub fn init(
pub fn init<DELAY, BUSY, DC, RST>(
: pac::SPI2,
spi2: gpiob::PB13<Analog>,
sck: gpiob::PB15<Analog>,
mosi: gpiob::PB12<Analog>,
cs: BUSY,
busy: DC,
dc: RST,
rst: &mut Rcc,
rcc-> SPI {
) Spi::spi2(spi2, (sck, NoMiso, mosi), MODE_0, rcc.clocks.sys_clk(), rcc)
mut delay: DELAY,
-> (
) ,
SPI<SPI, gpiob::PB12<Output<PushPull>>, BUSY, DC, RST>,
EPD2in9bc
)where
: DelayMs<u8>,
DELAY: InputPin,
BUSY: OutputPin,
DC: OutputPin,
RST{
// `epd-waveshare` conveniently exports the SPI mode for the waveshare devices
let mut spi = Spi::spi2(
,
spi2, NoMiso, mosi),
(sck,
SPI_MODE.clocks.sys_clk(),
rcc,
rcc;
)let epd = EPD2in9bc::new(
&mut spi,
.into_push_pull_output(),
cs,
busy,
dc,
rst&mut delay,
).unwrap();
, epd)
(spi}
Calling init
Let’s call our new init
function from main
.
⮟ src/main.rs
// Get access to the GPIO B port
// Get access to the GPIO A & B ports
let gpioa = dp.GPIOA.split(&mut rcc);
let gpiob = dp.GPIOB.split(&mut rcc);
⮟ src/main.rs
let (spi, epd) = epaper::init(
.SPI2,
dp.pb13,
gpiob.pb15,
gpiob.pb12,
gpiob.pa2.into_floating_input(),
gpioa.pa10.into_push_pull_output(),
gpioa.pa8.into_push_pull_output(),
gpioa&mut rcc,
,
delay; )
Delay
Sometimes with embedded software we need to wait. In our case epd-waveshare
waits some number of milliseconds after powering on our device.
One way to do this in general is to have the CPU loop:
for i in 1..10_000 {
cortex_m::asm::noop(); // prevent the compiler from optimizing our loop away
}
The disadvantage of this strategy is that it’s very difficult to wait a specific number of milliseconds. For example, the wall execution time of one noop
instruction varies based on the configured clock speed and interrupts can preempt our loop.
DelayMs<T>
is an embedded-hal
trait that provides a delay_ms
function for waiting some number of milliseconds. Most embedded-hal
-implementing HAL crates provide at least one Delay
-implementing struct, typically on top of timers.
Timers
Timers are configurable counters. In general, a timer is configured by selecting some input signal as a clock and then selecting a prescaler, which can scale the frequency down by some factor. We can then start our timer, which updates a register with the elapsed clock time.
Our timers, as well as all peripherals on our microcontroller, are connected to what’s called a bus. A bus is an electrical interconnect that allows components to communicate amongst each other. The microcontroller datasheet shows us in Figure 1 the specifics of which peripherals are connected to which buses. Every timer on our STM chip is connected to the bus’ internal clock. The general purpose timers can also use external signals as clocks.
The our SPI peripheral we set up earlier is connected to a bus, too. The implementation of spi2
sets the clock frequency by finding the relationship between the given frequency and the clock of its bus (APB1
, a peripheral bus) in order to set the prescaler.
Figure 2 of the MCU datasheet shows the relationships between all the clocks, prescalers and buses in the clock tree.
stm32l0xx_hal
provides the Delay
struct, which implements DelayMs
and DelayUs
with the SysTick
peripheral. SysTick
is provided by every Cortex-based microcontroller. Our MCU provides additional, ST-specific timers as well (TIM2
, TIM3
).
⮟ src/main.rs
use crate::hal::{
, delay
⮟ src/main.rs
// Get one-time access to our peripherals
let cp = cortex_m::Peripherals::take().unwrap();
let dp = pac::Peripherals::take().unwrap();
⮟ src/main.rs
// Because SysTick is universal to Cortex-M chips it's provided by the `cortex_m` crate
let syst_delay = delay::Delay::new(cp.SYST, rcc.clocks);
let (spi, epd) = epaper::init(
.SPI2,
dp.pb13,
gpiob.pb15,
gpiob.pb12,
gpiob.pa2.into_floating_input(),
gpioa.pa10.into_push_pull_output(),
gpioa.pa8.into_push_pull_output(),
gpioa&mut rcc,
,
delay, syst_delay
Drawing
epd-waveshare
uses embedded-graphics
, which provides primitives like shapes and text. Let’s explore these libraries by displaying a splash on device startup. The general idea is that we maintain an abstract display to which we draw our abstract objects and then render that display to a bitmap, either for black pixels or for red/yellow pixels.
⮟ src/epaper.rs
use embedded_graphics::{
fonts::{Font24x32, Text},
pixelcolor::BinaryColor::On as Black,
prelude::*,
,
style};
use embedded_hal::blocking::delay::*;
use epd_waveshare::{epd2in9bc::EPD2in9bc, prelude::*, SPI_MODE};
use epd_waveshare::{
epd2in9bc::{Display2in9bc, EPD2in9bc},
prelude::*,
,
SPI_MODE};
⮟ src/epaper.rs
, epd)
(spi}
pub type EPD = EPD2in9bc<
,
SPIgpiob::PB12<Output<PushPull>>,
gpioa::PA2<Input<Floating>>,
gpioa::PA10<Output<PushPull>>,
gpioa::PA8<Output<PushPull>>,
>;
pub fn display_startup(spi: &mut SPI, epd: &mut EPD) {
let mut display = Display2in9bc::default();
// the rotation is used when rendering our text
// and shapes into a bitmap
.set_rotation(DisplayRotation::Rotate90);
display// send a uniform chromatic and achromatic frame
.clear_frame(spi).expect("clear frame failed");
epdlet style = style::TextStyleBuilder::new(Font24x32)
.text_color(Black)
.build();
let _ = Text::new("Farsign", Point::new(50, 35))
.into_styled(style)
.draw(&mut display);
// render our display to a buffer and set it as
// our chromatic frame
.update_chromatic_frame(spi, display.buffer())
epd.expect("send text failed");
.display_frame(spi).expect("display startup failed");
epd}
That’s a wrap for our second post! Next I’m going to either explore translating our button presses to Morse code or using our radio.