Mcucpp — Bare-Metal C++20 Framework for RISC-V MCUs
Modern C++20 framework and project template for constrained RISC-V microcontrollers. Primary target: K1921VG015 (NIIET v.2 board, RV32IMFC, 50 MHz, 1 MiB flash / 256 KiB RAM).
Status: experimental, under active development.
Features:
- Async/await coroutine framework for bare-metal I/O (timers, hardware flags, tasks)
- Zero dynamic allocation — stack-first, pre-allocated containers
- Hardware drivers: UART, ADC, I2C, DMA, timers, internal flash, GPIO, PLIC interrupts
- Device drivers: MPU-6050 (IMU), SSD1306 (OLED)
- Protocols: Modbus RTU slave, 1-Wire (DS18B20)
- printf-like formatting, CRC (16/32), binary streams
- No exceptions, no RTTI
Table of Contents
- Prerequisites
- Installation
- Quick Start
- Project Structure
- Tests
- Writing Your First Program
- Adding a New Test
- Adding a New Target Platform
- Key Framework Concepts
Prerequisites
| Dependency | Version | Notes |
|---|---|---|
| Python | 3.8+ | SCons runtime |
| SCons | 4.0+ | Build system |
| PyYAML | any | Target config parsing |
| pyserial | any | UART test output reading |
| riscv-none-elf-gcc | latest | RISC-V cross-compiler toolchain |
| OpenOCD | k1921vk branch | Flash programming and debugging |
| VSCode | optional | Recommended IDE |
Platform-Specific Notes
Windows:
- Install Python from python.org, ensure
pythonandpipare on PATH - Download RISC-V toolchain from riscv-collab/riscv-gnu-toolchain — add
bin/to PATH - Download OpenOCD for K1921 from DCVostok/openocd-k1921vk — add
bin/to PATH - Install Python packages:
pip install scons pyyaml pyserial
Linux:
sudo apt install python3 python3-pip scons python3-yaml python3-serial- Download and extract RISC-V toolchain, add to PATH or use full paths
- Download OpenOCD for K1921, add to PATH
Installation
# 1. Clone the repository
git clone <repo-url>
cd niiet_test
# 2. Install Python dependencies
pip install scons pyyaml pyserial
# 3. Verify toolchain is on PATH
riscv-none-elf-gcc --version
openocd --version
# 4. Connect hardware (one of):
# - NIIET dev board v.2 with built-in FTDI debugger
# - BlueBird VG015 board + BlueProg debugger (FTDI, VID:PID 0x16c0:05dc)
# UART console on the debugger's USB-serial port
Verifying the Setup
# Build and run a simple test (requires hardware connected)
scons blink UART_PORT=COM30 # Windows
scons blink UART_PORT=/dev/ttyUSB0 # Linux
Quick Start
scons # Build and flash all tests (flash ops serialized via file lock)
scons -j 4 # Parallel build
scons blink # Build, flash, and run a single test
scons ftoa # Float-to-string conversion tests
scons uart # UART driver tests (Poll / Dma / Irq / Async)
scons async_io # Coroutine / async I/O tests
scons CONFIGURATION=debug # Debug build (-Og, MY_DEBUG=1)
scons UART_PORT=COM30 # Override UART console port
scons -c # Clean
Each test is compiled, flashed to the board via OpenOCD, and its UART output is read back to report pass/fail/timeout.
Project Structure
niiet_test/
├── inc/ # Framework headers (namespace Mcucpp)
│ ├── async_io.h # io_task, co_await adapters
│ ├── queue_dispatcher.h # Coroutine queue with priority
│ ├── timer_dispatcher.h # Heap-based timer scheduler
│ ├── flag_dispatcher.h # Hardware flag/event dispatcher
│ ├── coro_concurrency.h # coro_mutex, manual_event, CO_LOCK
│ ├── vector_adapter.h # Stack-backed vector
│ ├── noalloc_function.h # Stack-based std::function replacement
│ ├── binary_stream.h # Byte-level I/O with endianness
│ ├── crc.h # Templated CRC (16/32)
│ ├── format/ # printf-like formatting engine
│ ├── protocols/ # Modbus RTU slave
│ ├── devices/ # I2C device drivers (MPU-6050, SSD1306)
│ └── K1921VG015/ # Target-specific headers (uart, adc, i2c, dma, tmr, ...)
├── src/
│ ├── main.cpp # Application entry point
│ └── platform/
│ ├── K1921VG015/ # Target implementations (startup, uart, adc, i2c, dma, tmr, ...)
│ └── ... # Shared framework implementations
├── tests/ # Hardware tests (flash + UART pass/fail)
├── scons/tools/ # SCons build tools (target YAML, PlatformTest, flash_and_run)
├── targets/ # Target configuration (YAML)
├── ldscripts/ # Linker scripts
└── openocd/ # OpenOCD interface configs
Tests
Tests run on real hardware: the build system compiles, flashes via OpenOCD, and parses the UART console output for All tests passed! / FAILED markers.
| Alias | Description |
|---|---|
blink |
GPIO blink |
adc |
ADC driver (Poll / Dma / Irq / Async) |
uart |
UART driver (Poll / Dma / Irq / Async) |
i2c |
I2C driver (Poll / Irq) |
timer |
Timers: period / PWM / capture |
flash |
Internal flash (erase + write) |
mti_irq |
Machine timer interrupt |
async_io |
Async I/O coroutines, dispatchers, concurrency primitives |
exception_handler |
Trap / exception handler |
i2c_mpu6050 |
MPU-6050 IMU sensor over I2C |
i2c_ssd1306 |
SSD1306 OLED over I2C |
ssd1306_checkerboard |
SSD1306 framebuffer visual test |
modbus |
Modbus RTU slave |
ftoa |
Float-to-string conversion |
format |
printf-like format engine |
crc |
CRC16 / CRC32 computation |
noalloc_function |
Stack-based function wrapper |
dyna_pin_list |
Dynamic pin list management |
allocators |
Bitmap / fixed-block / dispatch allocators + benchmarks |
Writing Your First Program
Step 1: Create the Test Source File
Create tests/myapp/test.cpp:
#include <clock.h>
#include <iopins.h>
#include <ports.h>
#include <delay.h>
#include <test_harness.h>
using namespace Mcucpp;
using namespace std::chrono_literals;
void run_tests()
{
test_cout << "My first program" << endl;
// Blink an LED on PC0
Pc0::Port().Enable();
Pc0::SetConfiguration(PortCfg::Out);
for (int i = 0; i < 5; i++)
{
Pc0::Set();
delay(100ms);
Pc0::Clear();
delay(100ms);
}
test_cout << "All tests passed!" << endl;
}
Step 2: Create the SConscript
Create tests/myapp/SConscript:
import os
from SCons.Script import ARGUMENTS, Environment
target = ARGUMENTS.get("target", "k1921vg015")
env = Environment(
toolpath=["../../scons/tools"],
tools=["mcucpp_tools"],
ENV=os.environ,
MCU_TARGET=target,
)
env.Append(CPPPATH=["#tests/common"])
env.PlatformTest(
test_name="myapp",
sources=[
"#tests/common/test_harness.cpp",
"#tests/myapp/test.cpp",
],
timeout=15,
)
Step 3: Register in SConstruct
SConscript("tests/myapp/SConscript")
And add "myapp" to the Alias("all_tests", ...) list.
Step 4: Build and Run
scons myapp
Using Coroutines (Async/Await)
#include <async_io.h>
#include <queue_dispatcher.h>
#include <timer_dispatcher.h>
#include <flag_dispatcher.h>
#include <test_harness.h>
using namespace Mcucpp;
using namespace std::chrono_literals;
io_task<void> BlinkLed()
{
while (true)
{
Pc0::Toggle();
co_await 500ms; // Async wait — yields control to the dispatcher
}
}
void run_tests()
{
auto blink = BlinkLed();
// Main loop — poll the dispatchers
for (int i = 0; i < 20; i++)
{
ATOMIC
{
GetTimerDispatcher().Poll();
GetFlagDispatcher().Poll();
GetQueueDispatcher().Poll();
}
}
test_cout << "All tests passed!" << endl;
}
Adding a New Test
- Create directory
tests/<name>/ - Write
test.cppwithvoid run_tests()entry point - Write
SConscript(see template above) - Register in root
SConstruct - Add to alias in
Alias("all_tests", ...)
Test output convention:
- Output
All tests passed!on success - The
CHECK,CHECK_EQUAL,CHECK_RANGEmacros (fromtest_harness.h) printFAILED: ...on a failed assertion - The test harness reads UART and reports pass/fail/timeout
Target-specific tests (e.g., tests/k1921vg015/<name>/) guard execution with if target == _target: and pass a short test_alias_name for the scons target.
Adding a New Target Platform
-
Create YAML config
targets/<target>.yaml:name: MyMCU manufacturer: Vendor memory: flash: 524288 ram: 65536 clock: f_osc: 16000000 f_cpu: 48000000 toolchain_tool: riscv_gcc toolchain: prefix: riscv-none-elf- arch: rv32imfc abi: ilp32 linker_script: mymcu_flash.ld include_paths: - inc defines: F_CPU: 48000000 DYNAMIC_MEMORY_SIZE: 8192 platform_sources: always: - src/platform/MyMCU/startup.c openocd: interface: openocd/mydebugger.cfg target_cfg: target/mymcu.cfg -
Create platform sources under
src/platform/MyMCU/ - Create target headers under
inc/MyMCU/ - Create linker script under
ldscripts/ - Create OpenOCD config under
openocd/
Key Framework Concepts
Async/Await Coroutines
io_task<void> MyTask()
{
co_await 100ms; // Wait 100ms
co_await io_flag<MyRegister>; // Wait for hardware flag
auto result = co_await OtherTask(); // Wait for another task
co_return;
}
Three dispatchers are polled in the main loop: TimerDispatcher, FlagDispatcher, CoroDispatcher.
Zero Dynamic Allocation
All containers use pre-allocated storage:
vector_adapter<T>— stack-backed vectornoalloc_function<R(Args...)>— stack-based function wrapper (SBO)BinaryStream<Source>— zero-copy byte I/O
Memory Management
No dynamic heap by default. When enabled (DYNAMIC_MEMORY_SIZE in target YAML), a tiered DispatchBlockAllocator backs the global operator new/delete over a pre-allocated pool. Coroutine frames are allocated on this heap.
Concurrency
coro_mutex+CO_LOCK(mtx)for coroutine-safe lockingmanual_eventfor signaling between coroutinesATOMICblocks for interrupt-safe dispatcher access
Hardware Drivers
Each peripheral is a single class covering all transfer mechanisms (Poll / Dma / Irq), selected by IOFlags, plus a coroutine API:
| Peripheral | Notes |
|---|---|
| UART | Poll / Dma / Irq / Async |
| ADC | Poll / Dma / Irq / Async |
| I2C | Poll / Irq / Async; platform-independent I2cDriver contract |
| DMA | Channel transfers for UART / ADC / I2C |
| Timers | TMR0–2 (16-bit) + TMR32 (32-bit); period / PWM / capture |
| Flash | 1 MiB internal flash, 256 × 4 KiB pages |
| GPIO | Per-pin API + dynamic pin lists |
| PLIC | External interrupts; core-local MTI/MSI bypass PLIC |
Device drivers build on top of the I2C driver: MPU-6050 (6-axis IMU) and SSD1306 (OLED display), each with Poll / Async / Irq mechanisms.
License
BSD License. See individual source files for full license text.