This project was started as an experiment. The main goal is to create hi-level, memory-safe zero-cost abstractions using modern C++23. This repository is not intended to transform into a library but rather be a collection of experiments that will eventually become a solid baggage of experience (which will hopefully lead me to a point where I can start designing a modern C++ framework for microcontrollers).
This project is at it's very early stage. Things may be brutally rewritten, reorganized without any remorse.
I have decided to use RP2040
from the Raspberry Pi as a main
microcontroller for this experiment. It was a rather difficult (but necessary)
choice because the RP2040
is not easy to use when it comes to real bare metal
programming. Mainly because it has a complicated boot method due to the lack of
built-in flash memory. This significantly raised the entry barrier.
Everything you see in this repository is a result of real bare metal programming in C++23. Everything is written from scratch without any external dependencies. I mean everything... Including linker scripts, memory map and even bootloader.
Things that are up and running as for today:
- booting from the external flash memory using hand-written bootloader (stage #2),
- running the system at 125MHz:
- configuring a crystal oscillator (XOSC),
- configuring PLLs,
- configuring all system clocks (
clk_gpout{0..3}
,clk_ref
,clk_sys
,clk_peri
,clk_usb
,clk_adc
,clk_rtc
),
- configuring a watchdog timer,
- configuring timers,
- sending/receiving data with UART,
- configuring GPIOs,
- configuring PWMs.
Take a look at examples/ for a list of working examples.
Teaser:
// (cut)
int main()
{
gpio::pin<platform::pins::gpio25> led0;
led0.function_select(gpio::functions::sio);
led0.set_as_output();
// Prepere descriptor for the selected interface
constexpr auto descriptor =
hd44780::interfaces::gpio4_bit{.register_select = platform::pins::gpio16,
.enable = platform::pins::gpio17,
.data4 = platform::pins::gpio18,
.data5 = platform::pins::gpio19,
.data6 = platform::pins::gpio20,
.data7 = platform::pins::gpio21};
// Define your LCD hardware layout
constexpr auto configuration = hd44780::configuration{
.columns = 16, .lines = 2, .font_size = hd44780::font::font_5x8};
// Get type of the driver based on your descriptor and configuration
using lcd =
hd44780::hd44780<hd44780::interface_for<descriptor>, configuration>;
timer::delay(500ms);
// Initialize both MCU interface (in this case - the GPIOs) and the LCD
// itself
lcd::init();
lcd::soft_puts("Hello world");
lcd::cursor_goto(0, 1);
lcd::soft_puts("blog.regalis.tech");
while (true) {
led0.toggle();
timer::delay(250ms);
}
// (cut)
The final library/framework should comply with the following assumptions:
It must be hard (or impossible) to use the framework wrong. This can be achieved by using strong types and concepts extensively.
In the C
programming world - It is very common to fall into a trap and mix
bits or registers. For example, given the following C
code for the AVR
microcontroller:
void adc_start_conversion() {
ADCSRB |= (1 << ADSC);
}
The above function was supposed to start the analog-to-digital conversion, but
instead will force the ADC multiplexer to select the negative input to the
analog comparator. All because of the fact that the programmer mixed the
ADCSRA
and ADCSRB
registers.
We can easily avoid this kind of errors, C++23 comes with a handy tools which
may help: std::is_scoped_enum
and std::to_underlying
.
As it comes to correctness - we already have plenty of great tools built right
into the C++. By using constexpr
, consteval
and static_assert
- we can
provide bit-level correctness for all abstractions.
I think we came into the point where we do not need any preprocessor
directives - we can replace them all with constexpr
, consteval
and a bit of
metaprogramming.
Note: To be fair, the only one preprocessor directive you may see in the
codebase is #include
- this is due to lack of full support for modules in
both Meson build system and
GCC.
The final library/framework should not use any dependencies at all, not even headers provided by the CPU vendors.
This is to enforce maximum security and ensure that the library/framework will be fully controlled by the end users.
This is the absolute opposite of what Rust community is doing all over the place (using a library/framework will almost always lead to downloading gazillion of dependencies).
The library must use a true, instruction-level test system to ensure maximum performance.
This can be achieved by compiling dedicated tests with -ffunction-sections
and -fdata-sections
and comparing each section of the resulting binaries with
a predefined set of expected instructions.
The library must provide a universal way to work with registers. Both for MCU's registers and registers of external devices.
I already provided a working proof-of-concept which looks like this:
using gpio_oe = rw_reg<addrs::sio_base, 0x020>;
using gpio_oe_set = rw_reg<addrs::sio_base, 0x024>;
using gpio_oe_clr = rw_reg<addrs::sio_base, 0x028>;
using gpio_oe_xor = rw_reg<addrs::sio_base, 0x02c>;
The user can access these registers with static functions:
registers::gpio_oe_set::set_value(1 << 5);
Or create a higher level abstractions around it:
gpio::pin<pins::gpio25> led0;
led0.set_as_output();
led0.toggle();
The following C++23 features may be helpful:
Multidimensional subscript operator (P2128R6)
The
P2128R6
and
2589R0
papers may allow us to define a multidimensional subscript operator
(operator[](auto... bits)
). As a result, we may use the following syntax to
access registers:
registers::gpio_oe[bits::bit1, bits::bit2, bits::bit5] = 1;
registers::gpio_oe[bits::bit1, bits::bit2, bits::bit5] = state::high;
// or simpler:
registers::gpio_oe[1, 2, 5] = 1;
Device drivers must by provided in a form which will be reusable across many different CPU architectures and many different communication methods, thus they need to be written as "what to do" rather than "how to do it".
For example, the common HD44780
LCD controller may be connected to
software-controlled GPIOs or via an I2C converter or even to a dedicated MCU's
peripheral designed to interface with parallel memory modules.
The "ideal" situation will be to provide a driver with the following interface:
constexpr auto interface_descriptor = interface::gpio::for<drivers::lcd::hd44780>{
.mode = drivers::lcd::hd44780::four_bits,
.register_select = platform::pins::gpio10,
.read_write = platform::pins::gpio11,
.enable = platform::pins::gpio12,
.data0 = platform::pins::gpio13,
.data1 = platform::pins::gpio14,
.data2 = platform::pins::gpio15,
.data3 = platform::pins::gpio16,
};
drivers::lcd::hd44780 main_lcd{interface::gpio, interface_descriptor};
drivers::lcd::hd44780 second_lcd{interface::i2c,
interface::i2c::for<drivers::lcd::hd44780>{
// i2c parameters
}};
std::print(main_lcd, "Hello world...");
std::print(second_lcd, "Hello world again...");
The library must provide a seamless integration with the great std::print
(see
P2093R14),
for example:
std::print(uart0, "Hello world from the microcontroller! My CPUID is: {}", platform::cpuid);
The library must be developer-friendly, thus all types, globals, function names
must use suitable prefixes to allow developer to use the library without
digging into the documentation. For example use m_member
prefix instead of
member_
postfix while working with member variables.
The library must use modern, bloat-free build system. My favourite one is
Meson
.
The project must be built using the Mesons's cross build environment.
To build the project, use the following command:
$ meson setup --cross-file cross/armv6-m/rp2040.txt build/
$ meson compile -C build/
The above commands will build all the examples by default.
Examples are ready to be flashed to the Raspberry Pi Pico board. In order to
generate firmware images (UF2) for the Pico - you will need to convert the
elf
files into the uf2
files. I have implemented the tool specificly for
this job. You can download it here.
I have prepared the build system in such a way that UF2 files will be
automatically generated as soon as the regalis-pico-bin2uf2
program is in
your PATH.
The only thing to is to reconfigure your build directory with meson setup --reconfigure build/
and to run meson compile -C build
one again.
Happy hacking!