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 usesuint16_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 anFBConsole
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
functionwrite_data
functionswrite_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.