stm8-card: Frame Buffers

Tags:

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 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

left half

and when change_start_right is sent

right half

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

spaceship

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.

video

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!

final result business card