Home Raspberry Pi Pico - displays, fonts, portability?
Post
Cancel

Raspberry Pi Pico - displays, fonts, portability?

Preamble

The Pico is an excellent bit of kit, it’s no secret that I’m very much enjoying using it. I have many projects in mind for it, some of which include porting applications using the C/C++ SDK. The Raspberry Pi foundation have done an excellent job documenting the SDK, particularly in their code. There’s one feature I’d like to bring your attention to - the ability to direct standard output to one or more locations. The SDK has been built with expansion in mind. At the time of writing, it provides up to 3 interfaces natively - output to UART, output to USB serial, and output to a memory buffer. It allows any number of these interfaces to be output to at any time by providing a driver interface - which allows any programmer to write code for standard output and input.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct stdio_driver {

    void (*out_chars)(const char *buf, int len);
    void (*out_flush)();

    int (*in_chars)(char *buf, int len);

    // This is used internally by the Pico SDK, ensure you set it to NULL before
    // enabling the driver in code.
    stdio_driver_t *next;

#if PICO_STDIO_ENABLE_CRLF_SUPPORT
    bool last_ended_with_cr;
    bool crlf_enabled;
#endif

};

Each of the function definitions will be called in an enabled driver, if the function pointer is not equal to NULL, when the program makes the relevant high level calls. For example, if a program were to call printf, the Pico SDK will call the out_chars function for each driver, followed by the out_flush function. It is important to note, the in_chars function is expected to be non-blocking, so if your driver has no input to report - it must return 0 to allow the next driver to be tried.

The PICO_STDIO_ENABLE_CRLF_SUPPORT block is interesting - it indicates that the Pico SDK is capable of newline conversion. Looking through the Pico SDK source, it appears that the conversion happens at the point of outputting characters to stdout, rather than handling on the input. If enabled, the Pico SDK will convert instances where the line feed (\n) character appears by itself, to carriage return + line feed (\r\n). Individual drivers may disable this functionality, by setting crlf_enabled to false.

The flexibility of the SDK is brilliant. As programmers, we can look at implementing any number of communication methods. I’ve thought about custom UARTs, bluetooth serial, or even piping over a telnet-style interface by adding an appropriate network controller. The possibilities are endless, yet one comes to mind - outputting to a display. That should be easy, right? Implement a driver for whatever display you have in stock, include a font into your code, and write an out_chars function that draws characters to your display. Great, you’re done! But now let’s picture the scenario that someone else wants to use your code - but has a slightly different display. Now, if they want to use your code, they must go through the entire process again of setting up their display, implementing a font - did your driver implement a dumb serial terminal, or is it performing some terminal emulation like VT100? How much of that needs to be rewritten? Needless to say, there could be a lot of effort involved in porting code to a new display. This article talks about my solution to this problem - the I_Framebuffer interface, which looks to abstract all displays, and the FBConsole driver, which provides a console on top of a given I_Framebuffer implementation. All of the source code for this project can be found in this GitHub repository.

The I_Framebuffer interface

To abstract the specifics of a display from a console driver, we must define common functionality across all displays. To achieve this, I’ve written the I_Framebuffer virtual class. The interface itself isn’t specific to the Pico - it’s written in standard C++. However, the drivers that implement the interface will likely need to be written for each microcontroller that the author wishes to support. I’ll go into more depth on how this could be achieved later in the article.

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
class I_Framebuffer {
    public:
        virtual T get_color(uint8_t r, uint8_t g, uint8_t b);

        virtual void get_dimensions(uint16_t* width, uint16_t* height);

        virtual void plot_block(uint16_t x0, uint16_t y0,
                                uint16_t x1, uint16_t y1,
                                T* pixeldata, uint32_t len);
        
        virtual void scroll_vertical(uint16_t pixels);
};

To draw characters from a font, a console driver must be able to distinguish between a background colour and a foreground colour. However, the specifics of how this looks varies between displays - one may use an RGB565 pixel format, whereas another may only support monochrome colour. We wouldn’t want to limit a console driver to monochrome colour only, to support all displays, so instead we write the interface as a template - therefore the individual display implementation may define the storage class for pixels. To entirely abstract this from the console driver, an implementation must provide the get_color function - expecting RGB values in a range of 0-255, and returning an appropriately formatted pixel in the storage class specified by the implementation. This provides the console driver with the information it needs to create a buffer in which to draw a font character to.

Now the console driver has the ability to render characters to an internal buffer, having entirely abstracted the pixel format, it needs to be able to draw these characters to the display. The plot_block function must be implemented to achieve this. The x0,y0 and x1,y1 parameters specify the start and end coordinates to draw the block of pixels. For example, if an 8x8 character is being drawn in the top left of the display, the console driver would pass x0,y0 as (0,0) and x1,y1 as (7,7). The next parameter in the plot_block function is an array of pixels, in the pixel format & storage class provided in the template and given by get_color. Implementations may expect this array to be indexed with the formula index = (width * y) + x. The len parameter is the number of pixels in the passed array. Implementations should draw the number of pixels in the len parameter, which may be less than the area defined by x0,y0 and x1,y1.

The next thing a console driver must be able to accomplish, is line wrapping and display scrolling. The get_dimensions function takes two parameters, both of which are pointers to uint16_t variables, in which an implementation will store the width and height of the provided display in pixels. A console driver would use this information to determine how many characters may fit on the display.

Given the information on how many characters may fit on the display, a console driver is able to implement line wrapping by itself - it simply increments its y axis counter when x overflows. But what about when y overflows? In typical consoles, the display scrolls all characters up 1 line, and presents a new line. So, to accomplish the same behaviour, the console driver must be able to shift all the lines its drawn up a number of pixels, depending on the size of the font. This is where the scroll_vertical function must be implemented. When called, the implementation must shift the contents of the display up by the given number of pixels. Depending on the specific display, a number of methods may be available. In many instances, displays have hardware functionality to shift the contents of the display by a given number of pixels. An implementation may take advantage of this hardware functionality to scroll the display. However, if this option is chosen, the implementation must keep track of this such that calls to plot_block do not need to be augmented - i.e. a call to plot_block with a y0 value of 0 must always start drawing at the top of the display, regardless of whether it has been scrolled or not. An example of this can be seen in the ILI9341 driver I’ve implemented. However, in the instance that a display does not have scrolling functionality in hardware, an implementation may also choose to manually read & write from the video memory in order to shift the contents by the given number of pixels. This will almost always be slower, so an implementation should look to implement hardware methods wherever possible. Regardless of the method chosen, the state of the pixels at the bottom of the screen after calling the scroll_vertical function is undefined - the console driver is expected to clear this area itself.

The FBConsole driver

Having created an abstraction for displays, with rationale of each provided function in the context of a console driver, we can now turn our attention to the implementation of a console driver. I’ve written the FBConsole driver, which provides a set of functions and makes use of the Small Font Format specification I’ve written specifically for this function. Much like the I_Framebuffer interface, this driver isn’t specific to the Pico. Although all my testing has taken place on a Pico, as the display driver I wrote targets the Pico, the FBConsole driver is written in standard C++ and could be used on other microcontrollers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T>
class FBConsole {
    public:
        FBConsole(I_Framebuffer<T>* framebuffer, uint8_t* font, uint8_t scale = 1);

        void put_char(char c);
        void put_string(const char* str);
        void clear();

        void set_location(uint16_t x, uint16_t y);
        void set_background(T);
        void set_foreground(T);

        void get_dimensions(uint16_t* width, uint16_t* height);
};

Above is the public interface for the FBConsole driver. It’s written as a template, much like the I_Framebuffer interface, and must be defined with the same storage class as the display it will be used with. Immediately it can be seen in the constructor, that an I_Framebuffer interface must be passed to FBConsole. The constructor also requires an array of uint8_t to be passed as the font - this is expected to be an array using the Small Font Format specification, without the header information. The Small Font Format designer can output a suitable header to include in your application, to embed the font of your choice. At the time of writing both FBConsole and the Small Font Format support only 8x8 character sizes, therefore a font will require exactly 768 bytes of storage. Optionally, you may specify a scale - this scales the font by the specified factor. However, please consider that this exponentially increases the amount of memory that FBConsole will consume, as it holds a pixel buffer the size of 1 character. Setting the scale to 2 would increase the character size to 16x16, and consume 4 times more memory. On the Pico, with its 264KB RAM capacity, this isn’t a huge concern.

Upon instantiating an FBConsole instance, it will query the I_Framebuffer driver for its resolution, allocate the pixel buffer for drawing characters, set the console colours to white text on black background, and position the virtual cursor at (0,0). At the time of writing, the cursor has no on-screen presence.

To draw text, a program may call the put_char and put_string functions. Right now, these functions handle newlines (\n) and tabstops (\t). The published source code will define the tabstop at 8 characters, in line with the ANSI standard. However, it may be changed in code at compile-time. The set_location function will move the virtual cursor to a defined position, and the set_background & set_foreground functions will set the background and foreground colours for the terminal. Please note that this will affect only newly drawn characters, existing characters are never modified unless specifically drawn over by the programmer, or removed by scrolling the display. The clear function will set the entire display to the currently selected background colour, and set the cursor position to (0,0).

If the dimensions of the console are of relevance to the programmer, they may use the get_dimensions function. Much like the I_Framebuffer interface, get_dimensions takes two parameters, both of which are pointers to uint16_t variables, in which the driver will store the width and height of the console in characters.

Implementing as an stdio driver for Pico

Once the FBConsole driver is initialised with an appropriate I_Framebuffer implementation, there is relatively little work required to implement it as an stdio driver for the Pico SDK. I recommend for all projects, creating a separate file specifically to hold the framebuffer setup code - I like to call mine fb_setup.cpp. By defining a standardised file to hold the setup information, anyone wishing to re-use your code on a different display will immediately know where to look to make the appropriate changes. Here’s an example fb_setup.cpp file which I created to set up the console for an ILI9341 based display.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "FBConsole.hpp"
#include "pico/stdio/driver.h"
#include "pico/stdio.h"

#include "ili9341.hpp"
#include "gamefont.hpp"

FBConsole<uint16_t> *fb;
ILI9341* display;

// FBConsole specific
void fb_out_chars(const char *buf, int len)
{
    for (int i = 0; i < len; i++)
        fb->put_char(buf[i]);
}

stdio_driver_t stdio_fb = {
    .out_chars = fb_out_chars,
    .out_flush = 0,
    .in_chars = 0,
    .next = 0,

#if PICO_STDIO_ENABLE_CRLF_SUPPORT
    .crlf_enabled = false
#endif

};


// ILI9341 pin definitions:
// We are going to use SPI 0, and allocate it to the following GPIO pins.
// Pins can be changed, see the GPIO function select table in the datasheet
// for information on GPIO assignments.
#define SPI_PORT spi0
#define PIN_MISO 4
#define PIN_SCK  6
#define PIN_MOSI 7
#define PIN_CS   27
#define PIN_DC   26
#define PIN_RST  22

void fb_setup()
{
    display = ILI9341(SPI_PORT, PIN_MISO, PIN_MOSI, PIN_SCK,
                      PIN_CS, PIN_DC, PIN_RST);

    fb = new FBConsole<uint16_t>(display, (uint8_t*)&font);

    stdio_set_driver_enabled(&stdio_fb, true);
}

Much of this implementation could be reused in the majority of projects. Certainly the majority of the “FBConsole specific” section, which defines the fb_out_chars function and the stdio_fb structure, may be re-used across programs. A programmer would need to do the following, to change the display used in the program:

  • Set the correct data storage template in the FBConsole definition. The ILI9341 implementation uses uint16_t as a pixel storage class, other displays may differ.
  • Set the relevant pin definitions.
  • Change the first line in the fb_setup function to initialise the correct display, and to create an FBConsole instance with the correct data storage template.
  • Optionally, include a different font file - the example code imports the gamefont.hpp font.

With these steps completed, recompiling the source code should result in the program functioning on an entirely different display.

Testing

Having implemented an stdio driver for the Pico SDK, a console driver, and a framebuffer driver - it’s time to test!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <string.h>
#include "pico/stdlib.h"
#include "pico/stdio.h"

#include "fb_setup.hpp"

int main()
{
    // Initialise all SDK-provided stdio drivers
    stdio_init_all();

    // Set all pins high, to avoid chip selecting an incorrect device
    for (int pin = 0; pin < 29; pin++)
    {
        gpio_init(pin);
        gpio_set_dir(pin, GPIO_OUT);
        gpio_put(pin, 1);
    }

    // Set up a display, FBConsole, and an stdio driver
    fb_setup();    

    // Test printf
    printf("Hello world!\n\n%s\nint: %i\thex: %X\n\nThe framebuffer console driver supports wrapping. Terminal emulation to come.\n\n", "The meaning of life:", 42, 42);

    // Break, and halt execution
    __breakpoint();
    for(;;);
    return 0;
}

The above program sets up any compiled Pico SDK-provided stdio drivers, drives all GPIO pins high, sets up the display with fb_setup, and prints a long message with printf. If the program executes without raising an exception, the integration is successful.

Considerations for other microcontrollers

Writing easy-to-port display drivers

The vast majority of displays for microcontrollers operate over an SPI or I2C bus. At a fundamental level, the driver typically requires functions to reset the display, write a command to a display, and to write data to the display. By abstracting these into dedicated functions, porting a driver to a different microcontroller could be achieved in few steps. The ILI9341 driver I wrote for the Pico has microcontroller-specific code in the following places:

  • Constructor
  • reset function
  • write_data functions
  • write_cmd functions

A porting effort would need to re-implement the SPI & GPIO setup code in the constructor, and SPI transfer & GPIO setting code in the above functions. It’s also likely the constructor definition would need to change - the Pico expects an spi_inst_t* parameter which wouldn’t directly port to Arduino, for example. However, with the approach of abstracting the microcontroller-specific code, the porting effort is certainly made easier.

Memory considerations

FBConsole internally keeps a pixel buffer, large enough to hold one rendered character. In the put_char function, the character passed to the function is rendered to the internal pixel buffer, before calling plot_block in the underlying I_Framebuffer implementation. Typically, this is quite small - the currently supported 8*8 font size, combined with a 16-bit pixel format, would consume 128 bytes of RAM. On an Arduino Uno, this is less than 7% of the available RAM. However, where this can become large quickly is when FBConsole is passed a font scale other than 1. For example, passing a font scale of 2 means the pixel buffer becomes 16*16 pixels in size. Combined with the same 16-bit pixel format, this consumes 512 bytes of RAM - this is now 25% of the available RAM on an Arduino Uno! The pixel scale exponentially increases the amount of RAM consumed by FBConsole and, as such, should be carefully considered in situations where memory is sparse. The Pico suffers far less from this problem, with a massive 264KB total RAM capacity.

Arduino

AVR-based Arduino’s (Uno, Pro Mini, Nano, etc.) will place all variables in RAM by default, even those specified with a const identifier. Where values are known at compile-time, these values are compiled into the program and the setup code copies them from ROM to RAM. A Small Font Format font currently requires 768 bytes to hold a complete font for use in FBConsole - this is just less than 38% of the total RAM on an Uno. However, that’s not to say this is the only option. Arduino provides a functionality called PROGMEM, which allows the programmer to define various data structures that will live only in ROM - and not be copied to RAM at start-up. This is ideal for static data like the Small Font Format data required by FBConsole. However, the data cannot be accessed directly - any data in PROGMEM must be read with the pgm_read_word or pgm_read_byte functions. This means a small buffer must exist for accessing font data, not to mention a hit to performance where the data is read from ROM to RAM, but crucially also means significant changes must be made to the put_char function for FBConsole. This is far from impossible to accomplish, and it’s possible that the code could be included with a #define statement, or a specific template. At the time of writing, no effort has been made to achieve this - but I may do so in the future.

What’s next?

One of the things I’d like to implement soon is ANSI escape codes. I’ll likely write this as a layer atop the FBConsole driver, rather than baking it directly into FBConsole, as ANSI functionality isn’t required for 100% of projects which would want to output to a display. However, ANSI functionality such as obtaining display parameters, setting colours and cursor positions, could prove particularly useful in porting existing software to the Pico. I have a project in mind which may make use of this, which I’ll reveal in a future article.

Also expect to see some discussion soon surrounding reading/writing files on the Pico in C/C++. My last article talked about getting an SD card working under MicroPython, but doing the same in C/C++ with standard fopen, fread, fwrite and fclose commands looks to be a touch more difficult. It wouldn’t be impossible to see a littlefs2 filesystem in flash be exposed either, just like MicroPython.

Known Limitations

There is a potential limitation for monochrome displays, where likely the smallest storage class that can be used to implement the I_Framebuffer interface without modification will be uint8_t - whereas internally the display may use a more memory-efficient bit field. In the case of the Pico, with its massive 264KB RAM capacity, I’ve deemed this to be a minor issue - especially given that a typical framebuffer console will not be holding an entire framebuffer in memory, rather only an array large enough to accommodate an entire character. However, it could be sub-optimal for smaller microcontrollers where every byte of RAM counts. It’s possible this could be overcome by writing specific template definitions in FBConsole for a data storage class that would be more efficient for these displays, but I could see this involving rewriting the put_char function - which is rather large. Possibly a datatype of void could have a template in which the Small Font Format data is passed directly to the plot_block function, given it’s already a bitmap - though limits would have to be put in place for the font size. Let me know if you have any thoughts on this, in the comments below - I’m open to opinions.

This post is licensed under CC BY 4.0 by the author.
Contents