Background
I recently designed and fabricated a printed circuit board (PCB) business card. I used a STM8S003F3 microcontroller along with a SSD1306 display. This business card has my name, contact info, and other information on a silkscreen layer. The microcontroller runs a "space-invaders-esque" game, communicating with the display over I2C.
You can view the code and board files here.
The Problem
The most natural way to control a display is to create what's called a frame buffer. We would allocate enough space in the microcontrollers memory so that every pixel is 1 bit. Every time we want to change the display, we can just set or reset bits in the frame buffer, and then transmit the entire frame buffer.
However, eight bit microcontrollers are very resource limited. My display's dimensions are 128x32, or 4096 pixels. If I used 1 bit to represent each pixel, I will use 512 bytes of RAM. The microcontroller I'm using has 1024 bytes.
This doesn't sound so bad, right? I have over twice the memory needed for my frame buffer! Well, there's a slight issue.
Of those 1024 bytes, 512 are reserved for the stack. The stack keeps track of where we are in the program, local variables that are in use, and similar. As you might expect, that means the stack is absolutely essential. We obviously can't chop out the entire stack for our frame buffer. It's possible to only store part of our frame buffer where the stack is, but it's challenging to determine how much of the stack is in use at any time. The stack could overwrite part of our frame buffer as we jump between functions, which would corrupt our display into a mess of pixels.
Okay, so we went from having 1024 bytes of memory in the microcontroller to 512.
That's still enough for our frame buffer, so what's the big deal?
Well, I need to store more stuff than just the frame buffer outside of
the stack. The space invaders code (space_invaders.c
) has several
statically allocated variables. That means these variables are not
stored in the stack, but in that 512 bytes area. (It works out to
about 176 bytes.)
Now we're down to 336 bytes, which somehow needs to represent 512 bytes of data. Sounds like a problem, doesn't it?
Making due with less, drawing a display without a complete frame buffer
So creating a 512 byte frame buffer is a bust. Fortunately though,
there are some ways I could work around this! One option, and the one
I went with, was to only draw half the display at a time! I
created a function in ssd1306.h
with the following signature.
/* I dunno if an enum is the best way to do this, but I'm doing it! */
typedef enum { RIGHT, LEFT } ssd1306_side_t;
/* ... */
/* Draw the frame buffer to the selected side */
signed char draw_half(ssd1306_side_t side);
Let's take a look at how ~draw_half()~ works, shall we?
signed char draw_half(ssd1306_side_t side) {
const uint8_t change_start_right[7] = {CONTROL_BYTE(CO_DATA, DC_COMMAND),
CMD_ADDR_COL, SSD1306_WIDTH / 2, SSD1306_WIDTH - 1,
CMD_ADDR_PAGE, 0, 3};
const uint8_t change_start_left[7] = {CONTROL_BYTE(CO_DATA, DC_COMMAND),
CMD_ADDR_COL, 0, SSD1306_WIDTH / 2 - 1,
CMD_ADDR_PAGE, 0, 3};
int err;
if (side == RIGHT) {
err = send_data(change_start_right, sizeof(change_start_right) / sizeof(change_start_right[0]));
} else {
err = send_data(change_start_left, sizeof(change_start_left) / sizeof(change_start_left[0]));
}
if (err != 0) return err;
draw_frame_buffer();
return 0;
}
I have two arrays with 7 bytes of data in them called
change_start_right
and change_start_left
. When draw_half(LEFT)
is called, I send change_start_left
to the display, and vice versa.
[fn:4:That sizeof
nonsense lets me add and remove elements to
change_start_*
without needing to change the second argument to
send_data()
.] When change_start_left
is sent, the pixel
addressing looks like
and when change_start_right
is sent
Obviously this is a little scaled down compared to a 128x32 pixel display, but it illustrates the point. The first bit of my frame buffer is sent to the top left of the half we are on, then we move across horizontally until reaching the halfway point. We then jump down a row and repeat!
For completeness, draw_frame_buffer()
looks like
struct S {
uint8_t control_byte;
uint8_t frame_buffer[BUF_SIZE];
} SSD1306_Data = {.control_byte = CONTROL_BYTE(CO_DATA, DC_DATA)};
/* ... */
signed char draw_frame_buffer() {
return send_data(&SSD1306_Data.control_byte, sizeof(struct S));
}
Every transmission needs to start with a control byte. I can't start a
transmission, send a control byte, stop it, start a new one, and send
the frame buffer. It needs to be one continuous message.
send_bytes()
(which is just a wrapper for i2c_send_bytes()
) takes
an array of data and a size, and will just send that array over. In
other words, I can't tell i2c_send_bytes()
"Hey, send this byte
first, then send these 256 bytes".
I could make frame_buffer
a 257 byte array, and remember that the
first byte represents the control byte, not pixels. This is an ugly
solution in my eyes and will complicate later code. Instead,
remembering that an array is just a block of continuous memory, I
found a second solution. I created a structure with the control byte
first, followed by the frame buffer. The elements in a structure are
continuous [fn:5:Sorta. There is something called structure "padding".
However, since I'm dealing with 8 bit data in an 8 bit
microcontroller, it's not an issue. Even if that wasn't enough, the
fact that everything is 8 bits or an array of 8 bit elements, padding
won't be added. Technically though I don't know if the C standard
guarantees this. Some compilers support structure "packing" with a
compile-time flag, which is guaranteed to prevent this issue. I am
using SDCC, which does not.], so to send_bytes()
, this is just a 257
byte array.
Manipulating the frame buffer: drawing images
We're almost done here. There's just one element missing. If I had a 512 byte frame buffer, I could easily draw an image (e.g. a spaceship) by going to the x and y coordinates in the frame buffer, then drawing the pixels.
Representing images with structs
I chose to use two different structs to represent images.
#define MAX_FRAMES (4) /* up to 4 frames of animation */
/* ... */
struct Image {
const char width;
const char height;
const uint8_t *pixels;
};
struct DrawableImage {
signed char x;
signed char y;
unsigned char state;
const struct Image *images[MAX_FRAMES];
};
Image
is a struct that contains the raw data of the image, along
with the dimensions so it is drawn properly. For instance, if I want
to represent
as a struct, I would write
const uint8_t spaceship_pixels[24] = {
0x21, 0x00, 0x00,
0x41, 0x80, 0x00,
0x21, 0xC1, 0xC0,
0x8F, 0xFF, 0xFF,
0x0F, 0xFF, 0xFF,
0x81, 0xC1, 0xC0,
0x41, 0x80, 0x00,
0x61, 0x00, 0x00
};
const struct Image spaceship_image = {
.width = 24, .height = 8, .pixels = spaceship_pixels
};
This pixels array goes from the top left pixel of the image (red
square), left to right, marking which pixels need to be on. 0x21
means the 3rd pixel 0b0010
and the 8th pixel 0b0001
are both lit,
while the rest of that row is off. Because I include the width and
height, I know when I need to wrap around to the next row, as well as
how many rows there are in total. (In theory I could remove height
as the pixel array length + width can be used to calculate the height.
height = sizeof(spaceship_pixels) / (width/8)
)
This spaceship_image
variable contains everything I need to know
about the image itself. Because I separated Image
and
DrawableImage
, I can declare my Image
structs as const
. This
means the array of pixels, width, and height are all stored in program
(flash) memory, not RAM. Otherwise, I'd be very limited after using
257 bytes for my frame buffer and 512 bytes for the stack, with only
255 bytes left for everything else.
By doing so, the DrawableImage
struct has to contain a pointer to
the Image
struct, as they are in different locations in memory. I
also want my images to be animated, so I have an array of pointers to
Image
structs. That's what const struct Image *images[MAX_FRAMES];
means. images
is an array of pointers to const Image structures.
The x
and y
fields of the DrawableImage
structure tell us the
coordinates of the top left pixel of the display. state
is the frame
of animation we're on. Every time we want to move the image to a new
frame, all we have to do is increment state
, while ensuring it's a
valid number.
Using these structs to draw on the display
So, hopefully now we understand how I represent images using two
separate structures. By doing so, I can combine related data together,
hopefully making it easier to actually draw them on the display. I
want to have a method called draw_image()
in ssd1306.c/h
that
takes a DrawableImage
structure, then modifies the frame buffer so
that the correct pixels are lit up. The next time the frame buffer is
transmitted, the display should update accordingly.
Because my frame buffer is split in half, there's one other thing that
I need to pass draw_image()
. The DrawableImage
can be located
anywhere on the display, on either half. If I am going to use the
frame buffer to update the left half of the display, and I send a
DrawableImage
that's located on the right half, nothing should
update. If the DrawableImage
is in between the two halves, only part
of it should be drawn. Therefore I have a second argument called side
that
tells draw_image()
how the DrawableImage
should be drawn depending
on which half we're on. (e.g. If the DrawableImage
is on the left half
but side
says RIGHT
, don't change any pixels in the frame buffer!)
Breaking apart draw_image()
Here's the first few lines of code of draw_image()
.
/* in ssd1306.h */
#define REDRAW_OTHER_HALF (1)
/* in ssd1306.c */
signed char draw_image(struct DrawableImage *image, ssd1306_side_t side) {
if (image == NULL) return -1;
char width = image->images[image->state]->width;
char need_redraw = 0; /* flag for if any pixels outside bounds of buffer */
/* ... */
First, we perform a simple check to help verify that the DrawableImage
is
valid. If we did not perform this check, we'd be drawing garbage on
the screen! This actually was a big issue for several days that wasn't
immediately obvious. I changed an unrelated part of my code to return
NULL
under certain conditions, but wasn't checking if the image was
NULL
when calling draw_image()
. This is what that looked like.
I declare two variables, width
and need_redraw
. width
is just a
shorthand so I don't need to write
image->images[image->state]->width
every time. (This monstrosity
means "go to the DrawableImage
struct pointed to by the image
pointer, get the Image
structure for the current frame of animation
we're on, then get the width
of that Image
structure).
need_redraw
is a flag variable. If the image we're drawing is
partially or fully on the other half of the screen, it will be set to
REDRAW_OTHER_HALF
, telling whatever function called this one "Hey,
this DrawableImage
needs to be drawn again later".
/* don't waste time drawing images that don't appear */
if (side == LEFT && image->x > BUF_WIDTH) return need_redraw;
if (side == RIGHT && image->x + width < BUF_WIDTH) return need_redraw;
If we're on the left half of the screen and the x coordinate of the
top left of our image is greater than the halfway coordinate of our display,
then we need to draw the image again! Remember, the coordinates tell
us the location of the top left pixel of the DrawableImage
.
There's actually a bug here at the time of this writing. I need to
return REDRAW_OTHER_HALF
(if I want the code to exit immediately).
As need_redraw
was not set yet, I am returning 0
, meaning
the image does not need to be redrawn!
The really ugly part of draw_image()
Heads up. This next part is a doozy comparatively.
for (int i = 0; i < image->images[image->state]->height; i++) {
for (int j = 0; j < width; j++) {
/* ... */
We have two nested for loops that will iterate through the pixels of
the Image
. Unfortunately, because this is C, there's no concept of a
Pixel
type. I need to use as little storage as possible, so I have
my pixels represented as an array of 8-bit characters.
unsigned char subscript = (i * width + j) / 8;
unsigned char bit_num = 7 - (i * width + j) % 8;
/* ... */
subscript
tells me which 8-bit sequence will contain the pixel I
need. Similarly, bit_num
is the number of the specific bit within
the bit. For example, subscript = 2
and bit_num = 3
means I will
be looking at the 4th bit within the 3rd byte. By looking at this bit,
I'll know if the pixel should be on or off.
if ((image->images[image->state]->pixels[subscript] & (1 << (bit_num))) != 0) {
/* ... */
And that's exactly what I do! I access the pixel data through a fairly
obtuse chain of arrow (->
) operators, testing if the specific bit is
high or low. If it is high, I continue.
Technically, I could clear
the pixel if it is zero. This would prevent
images from overlapping, which I don't want.
signed char xcord = image->x + j, ycord = image->y + i;
/* ... */
After that, I need to generate the x
and y
coordinates to place
the pixel. Because I already have the coordinate for the top left of
my image, I just need to add my i
and j
values to find the
coordinates of the pixel!
if (side == RIGHT) xcord -= SSD1306_WIDTH / 2;
There's one last small adjustment I need to make. My frame buffer is
half the width of the display. (64x32 instead of 128x32). If I want to
draw on the right half of the display and try using the x coordinate
without modifying it, I'll be attempting to draw a pixel outside of my
frame buffer! So, I subtract 64 from the x coordinate if side
is
RIGHT
, protecting against this.
if (draw_pixel(xcord, ycord) == INVALID) need_redraw = REDRAW_OTHER_HALF;
Before returning need_redraw
, I have one final thing to do. Actually
updating the pixel in my frame buffer! In the interest of space, I
won't actually go into the details of how draw_pixel()
works.
Suffice to say that it takes a coordinate between (0, 0)
and (63, 31)
, toggling the correct bit in the frame buffer. If that coordinate
is invalid, it returns INVALID
. By checking the return value, I will
know if any coordinates of my image fall outside of that area,
suggesting it is either corrupt or—more likely—on the other half of
the screen, meaning the image needs to be redrawn to fully appear on
the display.
Using draw_image()
and pals to draw on a display
By this point, you are probably confused as to how this code actually
works. I talked a lot about drawing and redrawing the display.
Hopefullly a brief example of using these methods will help. Here is a
modified snippet of code in my main()
method that is run in an
infinite loop.
for (int i = 0; i < 3; i++) {
lasers[i] = debug_drawableimage_playerlaser(i);
invader_lasers[i] = debug_drawableimage_invaderlaser(i);
invaders[i] = debug_drawableimage_invader(i);
draw_image(lasers[i], LEFT);
draw_image(invader_lasers[i], LEFT);
draw_image(invaders[i], LEFT);
}
draw_half(LEFT);
clear_buffer();
for (int i = 0; i < 3; i++) {
draw_image(lasers[i], RIGHT);
draw_image(invader_lasers[i], RIGHT);
draw_image(invaders[i], RIGHT);
}
draw_half(RIGHT);
clear_buffer();
lasers[]
, invader_lasers[]
, and invaders[]
are all arrays of 3 DrawableImages
. First, I update the
DrawableImage
to the most recent ones reported by my Space Invaders
game. How Space Invaders creates these DrawableImages
is not really
relevent right now.
I then attempt to draw the images on the left half of the screen. Some
of these DrawableImages
will not be drawn because they are on the
right. This is totally fine! draw_image()
does nothing in that case.
draw_half()
will take the contents of the frame buffer, and send
that information to the display. This is what physically causes the
display to update, showing the new pixels on the screen. I then clear
the buffer to all zeroes so that the left half will not overlap with
the right half.
This entire process is repeated on the right half.
Here's the final result!