On the Micro:Gamer, the OLED screen is controlled via an I2C bus.
The screen resolution is 128x64, that is 8192 pixels. Since the screen is monochrome, there is only one bit per pixel so the frame buffer size 1024 bytes. We have to transfer all those 1024 bytes over I2C to refresh the screen. Lets calculate how much time it takes.
I2C transfer time
First we have to count the number of bits in an I2C transfer.
- 1 start bit
- 8 address bits
- 1 address ack bit
- 1 stop bit
- 8 bits + 1 ack bit for each byte of data we transfer
So the total number of bits for a transfer of N data bytes is:
bits (N) = 1 + 8 + 1 + N * (8 + 1) + 1
bits (N) = 11 + (9 * N)
Then we can calculate the speed of the transfer by dividing the number of bits by the speed of the bus in bits per second.
The micro:bit is fitted with an nRF51 microcontroller. On the nRF51 the maximum speed of the I2C controller is 400k bits per second, so the transfer time is:
transfer_time (N) = bits (N) / 400000
For examples, transferring 10 bytes will take
transfer_time (10) = (11 + (9 * 10)) / 400000 = 0.00025 seconds
There are more things to take into account for an accurate estimate of the transfer time, but this is a good approximation.
Arduino library I2C transfer
Now that we have the formula, let's see how much time it takes to transfer the 1024 bytes of our frame buffer.
The Arduino library limits the number of bytes per I2C transfers so the frame buffer has to be sent in multiple transfers. The driver I used, from Adafruit, sends 16 bytes of frame buffer per transfer with an extra byte to specify that we are sending data for the frame buffer, which means 64 transfers of 17 bytes.
So the total time it takes to send the full frame buffer is:
64 * transfer_time (16 + 1) = 26.2 milliseconds
The Arduino library driver is implemented using the polling technique. The CPU just continuously waits in a loop until it can send the next byte. So during the transfer time, the CPU is doing almost nothing.
If we want to run a game at 30 frames per seconds - one frame every 33 milliseconds - a transfer time of 26.2 milliseconds means that 80% of the CPU time will be wasted in I2C transfer. This is not ideal...
Asynchronous frame buffer transfer
The solution to this it to let the CPU do something else during the frame buffer transfer, and it can be done with interrupts. So I implemented an interrupt based I2C driver.
The CPU sends the first byte on the I2C bus and then continues its work, typically it will start to compute the new state of the game and render the next frame.
When the I2C controller is ready to send the next byte, an interrupt is triggered and the CPU will temporarily stop its normal operation to send the byte.
As a result, there's more CPU time available to execute the game code, allowing more complex games to run on the Micro:Gamer.
One potential problem with asynchronous frame buffer transfer is that, as the CPU continues to execute the game code, it can override the frame buffer in the middle of the transfer. This can cause glitches on the display, similar to the rolling shutter effect of digital cameras.
Again there is a solution for that, double buffering! Using two frame buffer instead of one, the CPU can edit frame buffer A while sending frame buffer B, and then it is the opposite, CPU edits frame buffer B while sending frame buffer A. Of course this means that we have to allocate one more frame buffer so it has a significant impact on memory usage.