Adding a display
The lm3s811evb
QEMU machine comes with an emulated OLED display,
which would be a nice thing to get going. Having a screen immediately
allows us to show and interact in more ways than just through the serial port.
Code now also available in a Gitlab repo.
The documentation tells us that it is a 96x16 OLED display using an SSD0303 controller connected to the I2C bus. The documentation regarding this setup is generally scarce, the best source of information is actually the QEMU source code. The source is quite easy to read though, so don’t be scared to use it as a source of information.
Reference information
- Stellaris LM3S811 datasheet
- I2C slave is not implemented
- SSD1306 datasheet
- Didn’t find the datasheet for SSD0303, but this appears to function the same
- QEMU stellaris source code
- QEMU SSD1306 source code
Some reference information regarding the QEMU setup with the SSD0303
- SSD0303 I2C address: 0x3D (Source)
- Reading from the SSD0303 is not implemented
Embedded drivers
Most of the embedded code I have both read and written do a really poor job of seperating drivers. It is not uncommon to have a driver be a driver for both the I2C communication itself, which is very MCU-specific, and for the integrated circuit you communicate with.
static unsigned char command_buffer[32];
void dev_init(void) {
// Initialize MCU for I2C communication
...
// Do some target device initialization
...
}
void dev_do_something(void) {
// Use MCU registers to send some commands over the I2C bus
}
Or in some other way having a very tight coupling between the IC and the I2C driver.
static unsigned char command_buffer[32];
void dev_init(void) {
// Do some target device initialization
i2c_write_data(buffer, length);
}
void dev_do_something(void) {
i2c_write_data(buffer, length);
i2c_read_data(read_buffer, read_length);
}
The second code example is slightly better than the first example, but only just. It is not uncommon for an MCU to have multiple I2C buses and reusing the driver for the chip device on a different hardware platform would still require porting the driver to each new platform. Code reuse does not scale, bugs needs to be fixed in all variations of the driver etc.
Reusable driver in C
One quite common technique to allow drivers to talk to eachother in C is to create a
struct
of function pointers. These structs can be passed to other drivers and they can use
the function pointers to talk with the correct driver.
// i2c_dev_info tells which hardware I2C to initialize
i2c_dev i2c = i2c_create(i2c_dev_info);
// Allows the SSD0303 device to use I2C functionality
display_dev disp = ssd0303_create(&i2c, /* Other options */);
disp.set_pixel(x, y, pixel_on); // Change some pixel value
disp.update(); // Show the change
This is pretty much only a re-implementation of virtual member-functions in C++ though, so why not use that?
Easier registry access
To allow for easier registry accress I created a class template that
both performs the necessary magin and also gives access to some helper
functions, such as a reg.set_bit
class.
template<std::uintptr_t Add, class T Access = read_write>
struct peripheral_reg {
// ... some details.
}
The nice thing about the access level is that I can disable write functionality for read only parts and let the compiler catch such errors.
First I2C driver
The first I2C driver will be very simple. It will be blocking and only
support master write operation, since that is what SSD0303 supports in QEMU.
I created a virtual base class driver::i2c::master
.
struct master
{
enum class operation: std::uint8_t
{
write,
read
};
virtual void reset() = 0;
virtual void set_slave_address(std::uint8_t slave, operation op) = 0;
virtual void start(std::uint8_t data) = 0;
virtual void start_stop(std::uint8_t data) = 0;
virtual bool is_busy() const = 0;
virtual bool is_error() const = 0;
virtual bool is_arbitrition_lost() const = 0;
virtual void repeat_start(std::uint8_t data) = 0;
virtual void stop() = 0;
virtual void stop(std::uint8_t data) = 0;
virtual ~master() = default;
virtual bool write(gsl::span<const std::uint8_t> data);
};
It basicly contains a bunch of methods meant to be implemented by hardware-specific drivers. It also contains a basic write function that uses other defined functions to implement I2C write functionality. It is marked virtual in case the target platform has built-in support for writing I2C data, via DMA for example. The stellaris MCU does not have any special support for this though, so it uses the default algorithm.
Now a single I2C chip can be described as a chip with a bus-unique address connected to a specific I2C bus. So the master operation above can be wrapped in a chip struct of its own.
struct chip
{
std::uint8_t slave_address;
master *master_impl;
chip(std::uint8_t slave_address, master *master_impl):
slave_address(slave_address), master_impl(master_impl)
{
}
bool write(gsl::span<const std::uint8_t> data)
{
master_impl->set_slave_address(slave_address, master::operation::write);
return master_impl->write(data);
}
};
and all of a sudden we have (in my opinion) a very clean way of writing to an I2C chip.
The above I2C combination can be used to create the basis of an ssd0303
struct. Note that I have so far not created an interface for a display class.
struct ssd0303
{
driver::i2c::master::chip chip;
std::array<std::uint8_t, 132 + 1> pixel_command_buffer[2];
ssd0303(std::uint8_t i2c_address, driver::i2c::master *i2c_bus);
void setup();
void turn_off();
void turn_on();
void send_command(std::uint8_t command);
void set_pixel(unsigned int x, unsigned int y, bool on);
void update();
};
Using this is very straight-forward:
#include <driver/i2c/stellaris.hpp>
#include <driver/display/ssd0303.hpp>
[[noreturn]] int main()
{
driver::i2c::stellaris::i2c1_master i2c1;
driver::display::ssd0303 ssd0303(0x7a, &i2c1);
i2c1.setup();
ssd0303.setup();
// Set some pixel data
for(int y=0; y<16; y++) {
for(int x=0; x<132; x++) {
ssd0303.set_pixel(x, y, x % 2 == 0 && y % 2 == 0);
}
}
// Update the display with new content.
ssd0303.update();
while(true);
}
Setup methods
A careful reader can see that I have setup
methods and don’t really
use the constructor to setup a driver or peripheral. This isn’t idiomatic
C++. In the embedded world it isn’t uncommon to setup the peripheral,
briefly do some work and then shut it down to disable clocks and go to sleep.
Data stored in internal driver buffers should probably be conserved though so
a construct-work-descruct
sequence is not ideal either.
I certianly haven’t settled on one approach, but this felt for the moment closer to how peripherals often are used.