DevCore is a portable C++ embedded framework. It gives you an event-driven task framework, a display subsystem with touchscreen support and an extensible visual-object model (plus a small experimental widget set), drivers for common external chips, a hardware-abstraction layer, and a set of math/filter utilities.
Portability is the central design goal. DevCore is not tied to any specific MCU or RTOS:
- Architecture independence comes from a hardware-abstraction layer. Today the repository ships STM32 HAL drivers, but the framework depends only on the abstract interfaces (
IGpio,IIic,ISpi, …). To target a different architecture, you replace the drivers — nothing above that layer changes. - RTOS independence comes from a thin wrapper. DevCore currently runs on FreeRTOS, but every RTOS call goes through the wrapper in
FreeRtosWrapper/. To run on a different RTOS, you re-implement that wrapper for it; the framework and your application are unaffected.
You normally work at the top of the stack — you subclass AppTask, build a screen out of visual objects, and let the framework run. You only reach down into the lower layers when a specific need forces you to, or when you are porting DevCore to new hardware or a new RTOS. A concrete porting checklist is at the end of the RTOS Wrapper section.
Written by Nicolai Shlapunov (Devtronic). Licensed under the BSD-3-Clause license — see License & Supporting DevCore at the end.
- Architecture at a Glance
- Getting Started
- The Application Framework —
AppTask,Result - Building Interactive Applications
- Talking to Hardware
- The RTOS Wrapper — raw primitives, and the porting layer for other RTOSes
- Utilities
- Appendix: Configuration Reference
- Appendix: Repository Layout
- License & Supporting DevCore
DevCore is layered. You spend almost all of your time in the top two layers; the lower layers exist so that the upper ones stay portable across architectures and RTOSes.
┌─────────────────────────────────────────────────────────┐
│ YOUR APP: AppTask subclasses, screens, widgets │ ← your app lives here
├─────────────────────────────────────────────────────────┤
│ High-level subsystems │
│ DisplayDrv · UI Engine · ButtonDrv / SoundDrv │
├─────────────────────────────────────────────────────────┤
│ Hardware: I* interfaces → StHal* drivers, chip libs │ ← swap drivers for
│ │ a new architecture
├─────────────────────────────────────────────────────────┤
│ RTOS Wrapper (Rtos, RtosQueue, RtosMutex, …) │ ← raw RTOS access, and
│ │ the RTOS port layer
├─────────────────────────────────────────────────────────┤
│ RTOS + MCU HAL (currently FreeRTOS + STM32 HAL) │ ← replaceable foundation
└─────────────────────────────────────────────────────────┘
The design rests on two ideas:
- Event-driven tasks. You subclass
AppTaskand override hook methods (TimerExpired,ProcessMessage,ProcessCallback) or a singleLoop. The framework owns the RTOS plumbing. - Interface-based hardware decoupling. Drivers implement pure-virtual interfaces (
IGpio,IIic,ISpi,IUart,IDisplay,ITouchscreen). Sensors, display controllers, and the framework depend only on the interface, so supporting a different MCU means re-implementing one thin layer.
The repository's current driver set targets STM32, so the typical starting point is a project generated by STM32CubeMX, which is where FreeRTOS and the HAL are configured. With CubeMX you get:
- FreeRTOS enabled and configured (via the CubeMX middleware settings).
- STM32 HAL generated for your MCU, with the peripherals you need turned on. Enabling a peripheral in CubeMX defines its module macro (
HAL_I2C_MODULE_ENABLED,HAL_SPI_MODULE_ENABLED, …) and generates its header (i2c.h,spi.h, …), whichDevCfg.hincludes; for any module left disabled,DevCfg.hgenerates a dummy handle type instead, so DevCore still compiles. - A generated
main.h, whichDevCfg.hincludes.
(Targeting a non-STM32 architecture or a different RTOS means providing drivers / an RTOS wrapper instead of relying on CubeMX — see Porting.)
Two FreeRTOS configuration values matter to DevCore. Check them before anything else — the symptoms of getting them wrong are confusing:
configUSE_APPLICATION_TASK_TAG = 1— required.Rtos::TaskCreatestores theAppTaskpointer in the task's application tag, andAppTask::GetCurrent()reads it back.AppTask::Callback()relies on this to detect same-task calls. This is not enabled by default in CubeMX; set it in the FreeRTOS advanced settings or in the user section ofFreeRTOSConfig.h.configTIMER_TASK_PRIORITY = configMAX_PRIORITIES - 1— strongly recommended.DevCfg.hemits a#warningotherwise. The reason is concrete: an event-drivenAppTaskwith a periodic timer blocks on its control queue with a timeout of twice the period, and its timer messages are delivered by the FreeRTOS timer-service task. If a higher-priority application task starves the timer task for more than two periods, the control-queue read times out and the task errors out. Running the timer task at the highest priority makes that impossible.
DevCfg.cpp replaces the global operator new/new[]/delete/delete[] with versions that call pvPortMalloc/vPortFree. Two consequences:
- Every C++ heap allocation anywhere in your firmware comes out of the FreeRTOS heap, so
configTOTAL_HEAP_SIZEmust be sized for all of it (task stacks, queues, and anything younew). The newlib heap is bypassed. - Allocation is therefore as thread-safe as the FreeRTOS heap implementation you selected.
Placement new/delete forms are also defined (standard no-ops), so you don't need <new>. Dynamic allocation works, but the usual embedded advice stands: create objects once at start-up and keep them. Note that a few DevCore classes allocate small internal buffers in their constructors (Eeprom24 and FramMB85 allocate their page buffer), so even an "all-static" design draws a little from the heap.
Copy DevCfgUsrExample.h into your project, rename it to DevCfgUsr.h on your include path, and set at least the RTOS-wrapper selection, the display buffer length, the Break() macro, and (optionally) which optional subsystems to compile in:
// DevCfgUsr.h
#include "stm32f4xx.h" // your MCU's HAL header
#define FREERTOS_WRAPPER // selects the RTOS wrapper (required)
#define DISPLAY_MAX_BUF_LEN 320 // see the Display System section for sizing
// Break() — what DevCore does on a fatal error. ARM breakpoint shown; you can
// make it an assert, a logging-reset, or leave it undefined (becomes a no-op).
#define Break() asm volatile("bkpt #0")
// Optional subsystems — define what you need:
// #define SOUNDDRV_ENABLED
// #define DWT_ENABLED
// #define UPDATE_AREA_ENABLEDFREERTOS_WRAPPER selects which RTOS wrapper DevCore compiles against; it's read by DevCfgRtos.h, which #errors if no wrapper is specified. This is the single switch a port to a different RTOS would extend (see Porting). Break() is the fatal-error hook — it's defined in your config (not framework source), so a non-ARM target supplies its own form; if you leave it undefined, DevCfg.h makes it a no-op.
DevCfgUsrExample.h also defines APPLICATION_TASK_STACK_SIZE and APPLICATION_TASK_PRIORITY. These are conventions for your own tasks — nothing inside DevCore reads them. They exist so your AppTask constructors have one obvious place to pull stack/priority numbers from. The full list of defines DevCore itself consumes is in the Configuration Reference.
Includes.
#include <DevCore.h>pulls in the whole framework (the RTOS wrapper, interfaces, display stack, drivers, libraries, math utilities, and the ready-to-use tasks/widgets) in one line — it's the simplest way to start. The examples below include individual headers to show where each piece lives, but a single<DevCore.h>works just as well.
#include "AppTask.h"
class AppMain : public AppTask
{
public:
static AppMain& GetInstance() { static AppMain inst; return inst; }
Result Setup() override
{
// one-time init; runs before any task's main loop starts
return Result::RESULT_OK;
}
Result TimerExpired(uint32_t missed) override
{
// runs every 50 ms
return Result::RESULT_OK;
}
private:
// stack 1024 words, priority, name, no queue, 50 ms timer
AppMain() : AppTask(1024u, tskIDLE_PRIORITY + 2u, "AppMain", 0u, 0u, nullptr, 50u) {}
};
// in main(), before the scheduler starts:
AppMain::GetInstance().InitTask();Because the constructor gives this task a timer period, it runs in event-driven mode: the framework blocks for you and calls TimerExpired every 50 ms. The other mode, and what each constructor argument means, is covered in The Application Framework.
Two things in this step are easy to get wrong, so they are stated as rules first:
Rule 1 — construct
DisplayDrvbefore any visual object. EveryVisObjectcaptures a pointer to the default display list in its constructor, and that default is set byDisplayDrv's constructor (the source says so explicitly: "DisplayDrv::GetInstance() should be called before creation any objects that contains VisObjects"). ABoxorStringcreated at global scope is constructed beforemain()runs — before anyGetInstance()call — so it capturesnullptrand the firstShow()dereferences it. Either create visual objects after the firstDisplayDrv::GetInstance()call, or attach them explicitly withSetList().Rule 2 — don't put visual objects on
main()'s stack. On Cortex-M, FreeRTOS reclaims the main stack for interrupt use when the scheduler starts, so plain locals inmain()are destroyed-in-effect the moment the kernel runs. Usestaticlocals insidemain()(static storage, but constructed when execution reaches them — afterGetInstance()), or members of your task objects.
Peripheral and LCD driver objects are unaffected by Rule 1 — their constructors only store references — so they can stay global. Note the LCD constructor argument order: width, height, SPI, CS, DC, then an optional reset pointer. Fonts are singletons accessed through GetInstance().
#include "StHalSpi.h"
#include "StHalGpio.h"
#include "ILI9341.h"
#include "DisplayDrv.h"
#include "Primitives.h"
#include "Strng.h"
#include "Fonts/Font_8x12.h"
// Peripherals and LCD driver: plain globals are fine (constructors are passive)
StHalSpi spi(hspi1); // HAL handle, by reference
StHalGpio cs (GPIOC, GPIO_PIN_3, IGpio::OUTPUT);
StHalGpio dc (GPIOC, GPIO_PIN_2, IGpio::OUTPUT);
StHalGpio rst(GPIOC, GPIO_PIN_4, IGpio::OUTPUT);
ILI9341 lcd(240, 320, spi, cs, dc, &rst); // width, height, spi, cs, dc, rst*
int main(void)
{
// ... HAL init, clock, peripheral init ...
// First GetInstance() constructs DisplayDrv and sets the default list (Rule 1)
DisplayDrv& display = DisplayDrv::GetInstance();
display.InitTask(lcd); // touchscreen optional, omitted here
// Visual objects: static locals, created only now (Rules 1 & 2)
static Box background(0, 0, 240, 320, COLOR_DARKBLUE);
static String label("Hello DevCore!", 20, 150, COLOR_WHITE, COLOR_DARKBLUE,
Font_8x12::GetInstance());
background.Show(1); // z-order 1 (back)
label.Show(2); // z-order 2 (front)
AppMain::GetInstance().InitTask();
// ... start the FreeRTOS scheduler ...
}(The colour constants are COLOR_BLACK, COLOR_WHITE, greys, and five brightness steps each of red, green, blue, yellow, cyan and magenta — e.g. COLOR_DARKBLUE, COLOR_RED. There are no named mixed colours beyond those.)
For touchscreen support, pass a touch driver as the second argument to InitTask — see Display System.
This is the conceptual core of DevCore. Everything that runs as a task — DisplayDrv, SoundDrv, your application logic — is an AppTask. Every function returns a Result.
A lightweight value type returned by almost every DevCore function. It wraps a ResultCode enum and composes with |=.
Result r = SomeFunction();
if (r.IsGood()) { /* RESULT_OK */ }
if (r.IsBad()) { /* any error */ }
if (r == Result::ERR_NULL_PTR) { /* specific code */ }
// Accumulate: r keeps the FIRST non-OK code, ignores later ones
Result acc;
acc |= StepA();
acc |= StepB(); // if StepA failed, its code is preservedError codes are grouped by area: generic (ERR_NULL_PTR, ERR_BAD_PARAMETER, ERR_BUSY, ERR_OVERFLOW, ERR_BAD_CRC, …), RTOS (ERR_TASK_CREATE, ERR_QUEUE_WRITE, ERR_TIMER_START, ERR_MUTEX_LOCK, …), one framework-specific code (ERR_CTRL_QUEUE_WRITE — see Sending work to a task), and per-bus codes (ERR_UART_TIMEOUT, ERR_I2C_BUSY, ERR_SPI_TIMEOUT, …).
AppTask is an abstract base class for tasks. Depending on how you call its constructor, a task runs in one of two mutually exclusive modes.
AppTask(uint16_t stk_size, // stack, in words
uint8_t task_prio,
const char name[],
uint16_t queue_len = 0, // task-queue length, messages
uint16_t queue_msg_size = 0, // size of one message
void* task_msg_p = nullptr, // receive buffer for ProcessMessage()
uint32_t task_interval_ms = 0, // periodic timer (0 = none)
bool tmr_priority = false); // deliver timer msgs as priorityEvery task starts the same way: InitTask() → CreateTask() → Setup(). The framework synchronises all tasks at Setup() — no task enters its main loop until every task has finished Setup(). Two consequences worth knowing: if any task's Setup() returns an error, the whole system stays halted at the barrier (the failed task never releases its slot, so nothing proceeds past start-up — deliberate fail-stop behaviour, and under a debugger you'll land on a bkpt); and Setup() runs in the task's own context with the scheduler running, so blocking calls are allowed there.
What happens after the barrier depends entirely on whether you gave the task a timer period and/or a message queue:
InitTask() → CreateTask() → Setup() (all tasks barrier here)
│
┌───────────────────────────┴────────────────────────────┐
│ timer period == 0 AND queue length == 0 │
│ → while(Loop() == RESULT_OK) { } │
│ Loop() IS the task body. It blocks however it │
│ wants (e.g. on a semaphore) inside Loop(). │
│ TimerExpired / ProcessMessage / ProcessCallback │
│ are NEVER called. │
├────────────────────────────────────────────────────────┤
│ timer period > 0 OR queue length > 0 │
│ → IntLoop() blocks on an internal control queue │
│ and dispatches one hook per wake-up: │
│ • TimerExpired(missed) ← periodic timer │
│ • ProcessMessage() ← task-queue message │
│ • ProcessCallback(ptr) ← posted callback │
│ Loop() is NEVER called. │
└────────────────────────────────────────────────────────┘
How the framework waits depends on whether the task has a timer. With a timer, the control-queue read times out at 2 × timer period — a built-in watchdog: timer messages should arrive every period, so two silent periods mean timer delivery is broken and the task errors out. (This is the concrete reason the timer-service task must not be starved — see Prerequisites.) Without a timer, a queue- or callback-driven task simply blocks until something arrives.
Loop mode is for tasks that own their own blocking. DisplayDrv is the textbook example: it has no timer and no queue, so the framework calls its Loop() repeatedly; inside, Loop() blocks on a semaphore and wakes when another task calls UpdateDisplay() (or on a 50 ms timeout, to poll the touchscreen). Returning anything other than RESULT_OK from Loop() ends the task.
Event-driven mode delegates blocking to the framework. IntLoop() waits on the control queue and calls exactly one hook per wake-up. The missed_cnt passed to TimerExpired() is how many timer ticks were skipped because the control queue was full when the timer fired; it resets to zero after each successful delivery.
class SensorTask : public AppTask
{
public:
static SensorTask& GetInstance() { static SensorTask t; return t; }
Result Setup() override { /* init sensor */ return Result::RESULT_OK; }
Result TimerExpired(uint32_t missed) override
{
ReadSensor();
return Result::RESULT_OK;
}
Result ProcessMessage() override
{
// the received message is already in the buffer you passed as task_msg_p
Command cmd = rx_cmd;
// ... handle cmd ...
return Result::RESULT_OK;
}
private:
Command rx_cmd; // receive buffer for ProcessMessage()
SensorTask() : AppTask(
512, // stack size (words)
2, // priority
"Sensor", // task name
4, // task queue length (messages)
sizeof(Command), // message size
&rx_cmd, // pointer to receive buffer
100, // timer interval ms (0 = no timer)
false // timer message normal priority
) {}
};class WorkerTask : public AppTask
{
public:
static WorkerTask& GetInstance() { static WorkerTask t; return t; }
Result Loop() override
{
job_ready.Take(); // block until signalled — do NOT busy-spin
DoWork();
return Result::RESULT_OK; // returning non-OK ends the task
}
private:
RtosSemaphore job_ready;
// no queue, no timer → loop mode
WorkerTask() : AppTask(512, 2, "Worker") {}
};SendTaskMessage() is protected. A task only ever messages itself; it must not be called from outside. To let other tasks request work, expose a typed public method that wraps it. This keeps the message format private and gives callers a meaningful API.
class SensorTask : public AppTask
{
public:
// public interface other tasks call
Result RequestCalibration()
{
Command c = { CMD_CALIBRATE };
return SendTaskMessage(&c);
}
Result RequestCalibrationUrgent()
{
Command c = { CMD_CALIBRATE };
return SendTaskMessage(&c, true, true); // priority in both queues
}
// ...
};
// elsewhere:
SensorTask::GetInstance().RequestCalibration();Check the returned Result — the two failure codes mean different things:
ERR_QUEUE_WRITE: the task queue was full; the message you just sent was dropped.ERR_CTRL_QUEUE_WRITE: the message entered the task queue, but the internal control queue was full. To keep the queues consistent the framework discards one message from the front of the task queue — so a message was lost, but it is the oldest pending one, not necessarily yours.
(Internally the control queue is sized queue_len + 2, leaving room for a timer and a callback message alongside a full task queue, so ERR_CTRL_QUEUE_WRITE is rare in practice.)
The message buffer is consumed by the call.
SendTaskMessage(void* task_msg, …)takes a non-const pointer, and the buffer's contents are unspecified after the call returns — on theERR_CTRL_QUEUE_WRITErecovery path the framework drains a message back into it. Extract anything you need (e.g. an id) from the message before sending; never read the buffer afterward. The buffer must be at leastqueue_msg_sizebytes (true by construction when it's the message type the queue was sized for).
Callback() hands a function to another task:
Result OnDone(void* obj_ptr, void* ptr) { /* see context rules below */ return Result::RESULT_OK; }
otherTask.Callback(OnDone, this, dataPtr);Where the callback executes depends on the target. There are three cases, and only the first is the "deferred" one:
- Target is an event-driven task (has a timer) and is not the caller → the callback is queued and runs later inside the target task's dispatch loop. This is the normal, clean hand-off between execution contexts.
- Target is a loop-mode task (no timer, no queue — e.g.
DisplayDrv) → there is no control queue to post to, so the callback executes immediately, in the caller's context. If the callback touches the target's data, protect that data with a mutex. - Caller is the target → executes immediately, directly.
Passing nullptr as the function pointer invokes the target's virtual ProcessCallback(ptr) hook instead of a free function (same three context rules apply).
For event-driven tasks, the periodic timer can be paused and resumed at runtime — but only by the task itself. StartTimer()/StopTimer() check the calling context and return ERR_CANNOT_EXECUTE if another task tries to control the timer; a task may only start/stop its own. StopTimer() halts just the periodic TimerExpired calls — the task stays alive and keeps servicing queue messages and callbacks, blocking indefinitely until one arrives — and StartTimer() resumes the ticks. This is the intended pattern for pausing periodic work during a long operation (e.g. an SD-card transfer that would otherwise outlast the timer watchdog): the task stops its own timer, does the work, restarts it.
// Called from within the task's own context (Setup, a handler, or Loop):
StopTimer(); // pause periodic TimerExpired; task keeps running
StartTimer(); // resumeAppTask::GetCurrent() returns the AppTask* of the calling context (it reads the FreeRTOS application task tag — the reason configUSE_APPLICATION_TASK_TAG = 1 is required). Useful in shared code that behaves differently per task.
This is where most application code lives: a display, things drawn on it, widgets the user touches, and the input/sound tasks that feed them.
The display subsystem has three layers stacked on top of each other:
- LCD controller drivers (
ILI9341,ILI9488,GC9A01,ST7789) — implementIDisplay, push pixels over SPI. DisplayDrv— a singletonAppTaskthat runs the render loop and owns the list of visible objects.- Visual objects (
Box,String,Image, …) — what you actually place on screen.
All take width, height first, then the SPI interface and control GPIOs.
| Class | Panel | Bytes/px | Reset argument |
|---|---|---|---|
ILI9341 |
240×320 TFT | 2 | optional pointer (IGpio*, default nullptr) |
ILI9488 |
320×480 TFT | 3 | optional pointer — see colour note below |
GC9A01 |
240×240 round | 2 | optional pointer |
ST7789 |
240×240 / 240×320 IPS | 2 | reference, or omit entirely — there is no pointer form |
ILI9341 lcd(240, 320, spi, cs, dc, &rst); // width, height, spi, cs, dc, rst* (optional)
GC9A01 lcd(240, 240, spi, cs, dc, &rst);
ST7789 lcd(240, 240, spi, cs, dc, rst); // reset by reference …
ST7789 lcd(240, 240, spi, cs, dc); // … or no reset pin at allThe ILI9488 colour caveat: this controller does not work in 16-bit SPI colour mode — the source marks it broken (0x55 - 16 bit(DOESN'T WORK!)). It always runs in 18-bit mode (3 bytes/pixel). Because of that, DisplayDrv calls the driver's PrepareData() on every line before sending it, converting from your framework color_t to the wire format in place, inside the line buffer:
- with
COLOR_16BIT(2-bytecolor_t) the data expands to 3 bytes/px (1.5×), so the line buffer must be 1.5× larger than the pixel count it holds; - with
COLOR_24BIT(4-bytecolor_t) it shrinks to 3 bytes/px — no extra space needed; - with
COLOR_3BITtwo pixels are packed per byte.
How big the buffer must be is driven by the longest scan line, and that depends on the update mode (see SetUpdateMode): in UPDATE_TOP_BOTTOM a line spans the display width, but UPDATE_LEFT_RIGHT rotates the panel 90°, so a line spans the height. Because the mode is switchable at runtime, DISPLAY_MAX_BUF_LEN must satisfy both axes — for ILI9488 with COLOR_16BIT, at least max(width, height) × 3 / 2. DisplayDrv::InitTask validates this against the display's reported per-line byte count for both dimensions and traps (a fatal Break()) at start-up if the buffer is too small, so an undersized buffer fails loudly at init rather than corrupting memory mid-render.
(color_t and the COLOR_* defines are explained under Writing a Custom Visual Object and in the Configuration Reference.)
| Class | Sensor | Bus | Type | Constructor |
|---|---|---|---|---|
FT6236 |
FocalTech FT6236 | I2C | Capacitive | (iic, [rotation], [w=320], [h=480]) |
XPT2046 |
XPT2046 | SPI | Resistive | (spi, touch_cs, touch_irq, [rotation], [w=320], [h=240]) |
Both implement ITouchscreen (IsTouched(), GetXY(), GetRawXY(), SetRotation(), and SetCalibrationConsts() for resistive panels). DisplayDrv polls the touchscreen and routes touch events to visual objects.
DisplayDrv is a singleton. Construct it before any visual object (Rule 1 in Getting Started), initialise it once with a display (and optionally a touchscreen), then drive it from your tasks.
DisplayDrv& disp = DisplayDrv::GetInstance();
disp.InitTask(lcd, touch); // touch optional
disp.SetRotation(IDisplay::ROTATION_LEFT); // TOP / LEFT / BOTTOM / RIGHT
disp.SetBackgroundColor(COLOR_BLACK);
disp.SetUpdateMode(DisplayDrv::UPDATE_TOP_BOTTOM); // or UPDATE_LEFT_RIGHT
// Safe multi-object changes from an application task:
disp.LockDisplay();
// ... modify several visual objects ...
disp.UnlockDisplay();
disp.UpdateDisplay(); // signal the render loop to redraw
// Partial redraw (effective only with UPDATE_AREA_ENABLED;
// without the define it compiles but returns ERR_BAD_PARAMETER):
disp.InvalidateArea(x0, y0, x1, y1);
disp.InvalidateDisplay(); // = InvalidateArea(whole screen)
// Screen size and touch:
int32_t w = disp.GetScreenW();
int32_t h = disp.GetScreenH();
int32_t tx, ty;
if (disp.GetTouchXY(tx, ty)) { /* currently touched at tx,ty */ }
disp.TouchCalibrate(); // interactive resistive-touch calibrationDisplayDrv renders into a double line-buffer one scan line at a time, streaming each finished line over DMA while composing the next — which is exactly why custom visual objects must follow the drawing contract below. Its loop wakes either when UpdateDisplay() signals it or on a 50 ms timeout, so the touchscreen is polled about 20 times a second even when nothing is being redrawn. LockDisplay() takes a recursive mutex, so nested lock/unlock pairs are safe.
SetUpdateMode chooses the scan direction. UPDATE_TOP_BOTTOM draws horizontal lines top to bottom; UPDATE_LEFT_RIGHT draws vertical columns left to right, which it implements by rotating the panel 90° (it applies rotation - 1 to the controller). The visible effect is the same image — the difference is the order pixels reach the panel, which matters for tearing on some displays and for the line-buffer sizing noted above (in UPDATE_LEFT_RIGHT a "line" is as long as the display is tall). Switching modes invalidates the whole screen. Custom objects support the column case via DrawInBufH (see below).
Every drawable inherits VisObject. Common operations (from the base class): Show(z), Hide(), Move(x, y, is_delta), SetActive(bool) (enables touch routing), GetWidth()/GetHeight(), and LockVisObject()/UnlockVisObject() for safe updates.
Two details of Show(z) are worth knowing. Show(0) means "keep the current z", not "set z to 0" — a fresh object's z already defaults to 0, so Show(0) on it works, but you cannot use Show(0) to move an object back to layer 0 later. And objects with equal z stack in insertion order — the most recently shown draws on top.
Every drawable also has a default constructor plus a SetParams(...) method mirroring its value constructor, so you can declare objects first and configure them later (handy for arrays and members).
Primitives (Primitives.h):
Box bg(0, 0, 240, 320, COLOR_DARKBLUE); // x,y,w,h,color,[fill=true]
Box frame(10, 10, 100, 40, COLOR_WHITE, false); // outline only
ShadowBox shadow(20, 60, 80, 30); // darkens pixels beneath it
Line ln(0, 0, 100, 50, COLOR_RED); // x1,y1,x2,y2,color
Circle dot(120, 160, 8, COLOR_GREEN, true); // x,y,r,color,[fill=false],[even=false]
Triangle tri(0,0, 50,0, 25,40, COLOR_YELLOW, true); // 3 points, color, [fill=false]
bg.Show(1);
frame.Show(2);Text — fonts are singletons; constructors take a Font&:
#include "Fonts/Font_8x12.h"
// transparent background:
String a("Temp:", 10, 10, COLOR_WHITE, Font_8x12::GetInstance());
// opaque background:
String b("23.5 C", 70, 10, COLOR_YELLOW, COLOR_BLACK, Font_8x12::GetInstance());
b.SetString("24.1 C"); // update text later
// printf-style update into a caller-owned buffer (buffer must outlive the object's use of it):
char buf[16];
b.SetString(buf, sizeof(buf), "%d.%d C", t / 10, t % 10);
// aligned in a fixed-width field (LEFT / CENTER / RIGHT):
StringAligned val("100%", StringAligned::RIGHT, 0, 30, 240,
COLOR_WHITE, Font_8x12::GetInstance());
// multi-line block — splits at '\n' characters (it does NOT word-wrap to a width):
MultiLineString note("Line one\nLine two\nLine three", 10, 60,
COLOR_WHITE, Font_8x12::GetInstance());Built-in font sizes: Font_4x6, Font_6x8, Font_8x8, Font_8x12, Font_10x18, Font_12x16. Each is a Font singleton; pass Font_NxM::GetInstance().
Note: the single-line string class is named
Stringbut lives inStrng.h/Strng.cpp.
Images and tiled maps. Image.h actually provides four drawables, all built around an ImageDesc (width, height, bits-per-pixel, pointer to pixel data, optional palette, optional transparent colour):
Image img(x, y, image_desc); // picks the right rendering from the ImageDesc
img.SetHorizontalFlip(true);ImagePalette, ImageBitmap, and ImageBinary are the specialised forms (palettised 8-bit, raw color_t bitmap, and 1-bit) if you want to construct one directly. TiledMap renders a grid of tiles from a tile index map plus a tileset:
TiledMap map(x, y, w, h,
map_data, map_w, map_h, bitmask, // tile index array + its dimensions
tiles, tiles_cnt, default_color); // ImageDesc tilesetThis is the single most important section for extending the display system. A VisObject subclass must implement two pure-virtual methods; everything else (list membership, z-order, show/hide, touch routing, locking) is handled for you.
DisplayDrv::Loop() renders the screen one scan line at a time into a double line buffer. For each line it:
- fills the buffer with the background colour;
- calls
list.DrawInBufW(buf, n, line, start_x)(orDrawInBufHinUPDATE_LEFT_RIGHTmode); - the list iterates every object in z-order (lowest first) and calls the same method on each, so higher-z objects paint over lower-z ones;
- runs
PrepareData()if the driver needs it, then DMA-streams the line while composing the next in the other buffer half.
Throughout the framework a pixel is a color_t, whose type is fixed at compile time by exactly one of COLOR_24BIT (uint32_t), COLOR_16BIT (uint16_t RGB565, the default), or COLOR_3BIT (uint8_t). Named constants (COLOR_BLACK, COLOR_RED, …) are provided for each depth. This is a single global choice — every object and driver uses the same color_t.
virtual void DrawInBufW(color_t* buf, int32_t n, int32_t line, int32_t start_x) override;
virtual void DrawInBufH(color_t* buf, int32_t n, int32_t row, int32_t start_y) override;DrawInBufW — one horizontal scan line (the default update mode):
| Param | Meaning |
|---|---|
buf |
line buffer, n pixels wide |
n |
pixels in the buffer (may be a partial-update sub-width) |
line |
the Y coordinate of the line being drawn, in the parent list's coordinate space |
start_x |
the X coordinate that maps to buf[0], in the parent list's space |
void MyObject::DrawInBufW(color_t* buf, int32_t n, int32_t line, int32_t start_x)
{
// 1. skip lines that don't intersect this object
if((line < y_start) || (line > y_end)) return;
// 2. map the object's X span into buffer indices
int32_t start = x_start - start_x;
int32_t end = x_end - start_x;
// 3. clip to the buffer
if(end < 0 || start >= n) return;
if(start < 0) start = 0;
if(end >= n) end = n - 1;
// 4. write pixels
for(int32_t i = start; i <= end; i++) buf[i] = color;
}DrawInBufH — the transposed case, one vertical column. Here row is the X coordinate of the column and start_y maps to buf[0]; check against x_start/x_end, map y_start/y_end into buffer indices.
- Reading the buffer before writing is valid. It already holds everything drawn by lower-z objects.
ShadowBoxexploits this — it halves each channel of the existing pixels instead of writing an opaque colour. - Coordinates are relative to the parent list, not the screen.
VisList::DrawInBufWsubtracts its ownx_start/y_startbefore forwardingline/start_xto its children, so the child's storedx_start/y_startand the incoming values share one coordinate space. For objects in the root list this happens to equal screen coordinates (root origin is 0,0); inside a nestedVisListit does not. DrawInBufHmay be left empty. It exists for cases where horizontal scanning is pathologically inefficient — an oscilloscope trace, for instance, whereDrawInBufWwould scan the whole buffer every line, butDrawInBufHcan use the column index directly to fetch the single Y value for that X. If your object has no such need, giveDrawInBufHan empty body and rely onDrawInBufW.- Never block or call RTOS primitives inside these methods — they run inside
DisplayDrv::Loop()while the line mutex is held.
UiEngine.h pulls in all the UI classes. Set expectations first: this part of DevCore is exploratory. The widgets grew out of experiments (largely for the DevBoy handheld project), and UiButton is the only one that is more or less ready to use as-is. Treat the others as working starting points to read, adapt, or replace rather than finished components — the display system underneath them is solid, and writing your own widget on top of it is covered in Writing a Custom Visual Object.
The classes come in two distinct kinds, and the distinction matters:
- Widgets —
UiButton,UiCheckbox,UiScroll— areVisObjects. YouShow()them, they live on screen,DisplayDrvroutes touch to them, and they report back through callbacks. - Modal controls —
UiMenu,UiMsgBox— are notVisObjects. Each is a composite that owns a set of visual objects and (forUiMenu) a blockingRun()loop that executes in your calling task until the user makes a choice.
Maturity varies — set expectations accordingly. The widget set grew out of UI exploration (largely on the author's DevBoy project) rather than as a finished toolkit. UiButton is the one component that is more or less ready for real use. The others — UiCheckbox, UiScroll, and especially the modal UiMenu/UiMsgBox — are functional exploration pieces: usable as-is in simple cases, but mainly valuable as reference code. The supported extension path for anything they don't cover is writing your own VisObject (previous section). Expect rough edges and API churn outside UiButton — the signature wrinkle below is one example.
UiButton::SetCallbacktakes the task by pointer (AppTask*), whileUiCheckbox::SetCallbacktakes it by reference (AppTask&).
UiButton btn("OK", x, y, w, h, /*is_active=*/true); // active = touchable
btn.SetCallback(&MyTask::GetInstance(), MyCallback, param);
btn.SetFont(Font_8x12::GetInstance());
btn.Show(10);
// btn.Enable(); btn.Disable(); btn.GetPressed();When the button is pressed, the callback is posted from the DisplayDrv task via AppTask::Callback, with your registered param as the first argument and a pointer to the button itself as the second:
Result MyCallback(void* param, void* button_ptr) // runs per the Callback context rules
{
UiButton& which = *static_cast<UiButton*>(button_ptr); // lets one handler serve many buttons
// ...
return Result::RESULT_OK;
}Make the receiving task event-driven (give it a timer): then the callback is queued and runs in that task. If the target is a loop-mode task, the callback executes inside DisplayDrv's render task instead — see Posting a callback.
The constructor takes only a position (the box is a fixed glyph) plus initial/active flags — no label or size:
UiCheckbox cb(x, y, /*is_checked=*/false, /*is_active=*/true);
cb.SetCallback(MyTask::GetInstance(), MyCallback, param); // task by reference
cb.SetChecked(true);
bool on = cb.GetChecked();
cb.Show(10);UiScroll scroll(x, y, w, h, /*n=*/total_items, /*bar=*/visible_items);
// optional trailing flags: is_vertical = true, is_has_buttons = false, is_active = true
scroll.SetTotal(total_items);
scroll.SetBar(visible_items);
scroll.SetScrollPos(offset);
int32_t pos = scroll.GetScrollPos();
scroll.Show(5);UiMenu is built from an array of MenuItem structs and driven by Run(), which blocks the calling task until the user enters an item or backs out. The item callbacks are plain function pointers (not the AppTask callback machinery) and execute inside Run() — i.e. in your task.
// MenuItem fields: { caption, enter-callback, value-string callback, ptr, add_param }
// void EnterCb(void* ptr, uint32_t add_param);
// char* GetStrCb(void* ptr, char* buf, uint32_t n, uint32_t add_param);
UiMenu::MenuItem items[] =
{
{ "Brightness", OnBrightness, GetBrightnessStr, this, 0 },
{ "Volume", OnVolume, GetVolumeStr, this, 0 },
{ "About", OnAbout, nullptr, this, 0 },
};
UiMenu menu("Settings", items, 3 /*items_cnt*/,
0 /*current_pos*/,
&Font_8x12::GetInstance() /*header font*/,
&Font_8x12::GetInstance() /*items font*/,
0, 0, 240, 320 /*x,y,w,h*/);
menu.Run(); // blocks; redraws ~10×/s; returns when the user exitsShow() and Hide() (both take no arguments — z-order is managed internally) exist for manual control, but Run() calls Show() itself; normal usage is just Run().
Selection moves via the embedded touch scrollbar and, when INPUTDRV_ENABLED is defined, the up/down inputs; enter and back come from the InputDrv right/left inputs. Without INPUTDRV_ENABLED there is currently no enter/back source, so Run() cannot return — in practice UiMenu is operable only on DevBoy-style hardware today. On anything else, treat it as reference code for building your own menu (a VisList of Strings plus a UiScroll covers most of it).
A modal dialog; most layout parameters default sensibly (centred on screen):
UiMsgBox box("Delete all data?", "Confirm",
&Font_8x12::GetInstance(), // message font
&Font_8x12::GetInstance()); // header font
box.Show(); // z defaults to 0xFFFFFFF0 — effectively always on top
// ... later ...
box.Hide();
box.Run(1500); // convenience: Show → UpdateDisplay → wait 1500 ms → Hide
// (blocks the calling task for the duration)These are AppTask singletons that feed the UI layer. ButtonDrv is always compiled and general-purpose; SoundDrv and InputDrv sit behind config defines — and InputDrv is specific to one board, covered last.
Manages an array of GPIO buttons with debouncing, hold, and double-click detection. Initialise it with an array of IGpio*:
IGpio* buttons[] = { &up_gpio, &down_gpio, &enter_gpio };
ButtonDrv& btn = ButtonDrv::GetInstance();
btn.InitTask(buttons, 3);
// poll:
ButtonDrv::ButtonState s = btn.GetButtonState(0); // RELEASED / PRESSED / HOLD / DOUBLE
// or register a callback handler for a set of buttons (by bit mask):
static ButtonDrv::CallbackListEntry entry; // linked into an intrusive list —
// MUST outlive the registration
btn.AddButtonsCallbackHandler(&MyTask::GetInstance(), MyCallback, this,
/*mask=*/0b111, entry);
// btn.DeleteButtonsCallbackHandler(entry); // unlink when done
// tunables:
btn.SetButtonHoldDelay(500); // ms
btn.SetButtonDoubleDelay(250); // msDrives a buzzer through a PWM timer channel.
SoundDrv& snd = SoundDrv::GetInstance();
snd.InitTask(htim3, /*channel=*/TIM_CHANNEL_1, buzzer_gpio); // timer by reference
snd.Click(); // short UI click
snd.Beep(1000, 100); // 1000 Hz for 100 ms — BLOCKS the callerBeep() plays synchronously in the calling task (an optional third argument adds an equal pause afterwards). For asynchronous playback, PlaySound() hands a melody to the SoundDrv task and returns immediately — but note the packed note format, documented in the source as 0x***#:
- the upper 12 bits are the frequency in Hz (values of 18 or below play as a rest);
- the lower 4 bits are the note duration, in multiples of
temp_ms(use 1–15).
// C5–D5–E5–F5, each lasting 2 × 150 ms:
const uint16_t melody[] =
{
(523u << 4) | 2u,
(587u << 4) | 2u,
(659u << 4) | 2u,
(698u << 4) | 2u,
};
snd.PlaySound(melody, 4, /*temp_ms=*/150); // optional 4th arg: repeat = false
// snd.IsSoundPlayed(); snd.StopSound();
snd.Mute(true);InputDrv was written for the author's DevBoy project and is shaped entirely around that board: two expansion ports (EXT_LEFT/EXT_RIGHT), each carrying buttons (BTN_UP/LEFT/DOWN/RIGHT) and a rotary encoder with two buttons, read through a timer and an ADC. It ships in the repository because DevBoy builds (and UiMenu) use it — it is not a general-purpose input layer, and on other hardware you almost certainly want ButtonDrv, or a small AppTask of your own, instead.
InputDrv& in = InputDrv::GetInstance();
in.InitTask(&htim2, &hadc1);
bool pressed = in.GetButtonState(InputDrv::EXT_LEFT, InputDrv::BTN_UP);
int32_t enc = in.GetEncoderState(InputDrv::EXT_RIGHT);You reach this layer when an application needs a specific peripheral or external chip. The pattern is always the same: high-level code depends on an interface (I*), and a concrete driver implements it. To port DevCore to a different MCU, you re-implement the drivers, not the layers above.
Pure-virtual base classes in Interfaces/, with no hardware dependency. The interfaces declare a broad operation set and provide ERR_NOT_IMPLEMENTED defaults for everything optional, so a driver implements what its hardware supports and unsupported calls fail cleanly at runtime rather than at link time.
IGpio — digital I/O with optional inverted polarity, so logical and electrical levels stay separate (SetHigh() on an INVERTED pin drives it electrically low):
gpio.Init();
gpio.SetHigh(); gpio.SetLow();
bool hi = gpio.IsHigh();
IGpio::State s = gpio.Read(); // LOW / HIGH (electrical)IIic — I2C master (blocking core, with WriteAsync/ReadAsync/IsBusy and TX/RX timeout setters for drivers that support them):
iic.Init();
iic.Write(addr, tx, tx_len);
iic.Read(addr, rx, rx_len);
iic.Transfer(addr, tx, tx_len, rx, rx_len); // write-then-read, one transaction
iic.IsDeviceReady(addr, retries);ISpi — SPI master, blocking and async (plus Read, TransferAsync, Abort, and SetMode/GetMode for clock polarity/phase):
spi.Init();
spi.Write(tx, len);
spi.Transfer(tx, rx, len);
spi.WriteAsync(tx, len); // DMA; poll IsTransferComplete()
while(!spi.IsTransferComplete()) { /* yield */ }
spi.SetSpeed(clock_hz);IUart — byte or buffer stream:
uart.Init();
uart.Write(byte); uart.Write(buf, len);
uart.Read(byte); uart.Read(buf, len /*in/out*/);
bool done = uart.IsTxComplete();ITouchscreen — IsTouched(), GetXY(), GetRawXY(), SetRotation(), SetCalibrationConsts(); shares the Rotation enum with IDisplay.
IDisplay — the low-level pixel interface implemented by the LCD drivers and consumed by DisplayDrv.
ICallback — a one-method interface (virtual void Callback(void* ptr) = 0) for objects that accept typed callbacks without a function pointer.
STM32 HAL implementations in Drivers/. Each bus driver takes its HAL handle by reference.
StHalGpio led(GPIOA, GPIO_PIN_5, IGpio::OUTPUT); // port*, pin, type, [polarity = NORMAL]
StHalIic iic(hi2c1); // I2C_HandleTypeDef&
StHalSpi spi(hspi1); // SPI_HandleTypeDef&
StHalUart uart(huart2); // UART_HandleTypeDef&StHalIic— blocking I2C overHAL_I2C_*, fullIIicimplementation (including combinedTransferand async variants).StHalIicThreadSafe— a separate I2C driver (also constructed from anI2C_HandleTypeDef&) that guards every bus operation with a mutex, for buses shared by multiple tasks. It is not a wrapper aroundStHalIic— use one or the other.StHalSpi— blocking + DMA SPI; 8/16-bit transfers, TX/RX-only modes, manual CS for display streaming.StHalUart— blocking UART with configurable timeouts.
DwtCycleCounter — cycle-accurate profiling via the Cortex-M DWT unit (compiled only with DWT_ENABLED):
DwtCycleCounter::Init();
uint32_t t0 = DwtCycleCounter::GetClockCounter();
// ... timed code ...
uint32_t cycles = DwtCycleCounter::GetClockCounter() - t0;
DwtCycleCounter::DelayUs(50);Drivers for off-chip parts in Libraries/. Every one is constructed from an IIic&, so it works over any I2C implementation (including the thread-safe one).
BoschBME280 — temperature / pressure / humidity. Call TakeMeasurement() first; read either the float getters or the integer fixed-point ones:
BoschBME280 bme(iic);
bme.Initialize();
bme.SetSampling(BoschBME280::MODE_NORMAL,
BoschBME280::SAMPLING_X16, // temperature
BoschBME280::SAMPLING_X16, // pressure
BoschBME280::SAMPLING_X16, // humidity
BoschBME280::FILTER_OFF,
BoschBME280::STANDBY_MS_0_5);
bme.TakeMeasurement();
float t = bme.GetTemperature(); // °C
float p = bme.GetPressure(); // Pa — divide by 100 for hPa/mbar
float h = bme.GetHumidity(); // %RH
// integer forms, no float math:
// GetTemperature_x100() → °C×100, GetPressure_x256() → Pa×256, GetHumidity_x1024() → %RH×1024Mlx90614 — non-contact IR thermometer. Returns ×100 integers via out-parameters:
Mlx90614 mlx(iic);
mlx.Initialize();
mlx.TakeMeasurement();
int32_t ambient_x100, object_x100;
mlx.GetAmbientTemperature_x100(ambient_x100);
mlx.GetObjectTemperature_x100(object_x100); // optional: , Mlx90614::OBJECT2Vl53l0x — time-of-flight distance:
Vl53l0x tof(iic);
tof.Initialize();
uint16_t mm;
tof.GetDistanceMm(mm);Tcs34725 — RGB colour sensor:
Tcs34725 col(iic);
col.Initialize();
uint16_t r, g, b, c;
col.GetRawData(r, g, b, c);
uint16_t lux, kelvin;
col.GetLux(lux);
col.GetColorTemperature(kelvin);Eeprom24 — I2C EEPROM (24Cxxx). Constructor: (iic, wp_gpio_ptr = nullptr, size_bytes = 0x2000, page_size = 32); the init method is Init() (not Initialize). The write-protect GPIO is an optional pointer. The constructor heap-allocates its page buffer (page_size + 2 bytes):
Eeprom24 eeprom(iic, &wp_gpio, /*size=*/0x2000, /*page=*/32);
eeprom.Init();
eeprom.Write(offset, data, len);
eeprom.Read(offset, buf, len);FramMB85 — I2C FRAM (Fujitsu MB85). Same shape as Eeprom24 — (iic, wp_gpio_ptr, size_bytes, buffer_size), Init(), Read/Write — but with no write-cycle delay and effectively unlimited endurance:
FramMB85 fram(iic, &wp_gpio, /*size=*/0x2000, /*buffer=*/32);
fram.Init();
fram.Write(offset, data, len);
fram.Read(offset, buf, len);This layer has two audiences. In day-to-day application work you rarely touch it directly — AppTask covers most needs — but it is available when you want raw primitives (queues, mutexes, timers). It is also the layer you re-implement to run DevCore on a different RTOS: keep these class interfaces identical and back them with another RTOS's calls, and everything above continues to work unchanged.
The wrapper lives in FreeRtosWrapper/. Each class wraps one RTOS object and returns Result codes.
Mind the timeout units — they differ by class. Queue and timer calls take milliseconds; mutex and semaphore waits take ticks (
TickType_t, defaulting toportMAX_DELAY), so usepdMS_TO_TICKS()there. For queues, a timeout ofUINT32_MAXms means block indefinitely.
Rtos — static task/scheduler helpers:
Rtos::TaskCreate(func, "Name", stack_depth, param, priority);
Rtos::IsSchedulerRunning();
Rtos::IsInHandlerMode(); // true inside an ISR
Rtos::EnterCriticalSection(); Rtos::ExitCriticalSection();
Rtos::SuspendScheduler(); Rtos::ResumeScheduler();RtosQueue — typed queue (items copied by value). The primitives detect ISR context internally and switch to the FromISR variants:
RtosQueue q(10, sizeof(Msg));
q.Create();
q.SendToBack(&msg); q.SendToFront(&msg); // optional timeout_ms (default 0)
q.Receive(&out, /*timeout_ms=*/100); // UINT32_MAX → block until a message arrives
q.Peek(&out, 0);
bool empty = q.IsEmpty();RtosMutex — non-recursive mutex with priority inheritance (created in its constructor):
RtosMutex m;
m.Lock(); m.Lock(pdMS_TO_TICKS(50)); // ticks!
m.Release();
m.IsTakenByCurrentTask();RtosRecursiveMutex — re-entrant mutex; lock count must be matched by release count. DisplayDrv uses two of them (frame and line) for nested locking around the render loop.
RtosSemaphore — binary semaphore for signalling:
RtosSemaphore s;
s.Give(); // also ISR-safe internally
s.Take(); s.Take(pdMS_TO_TICKS(200)); // ticks!RtosTimer — software timer, repeating or one-shot. The callback parameter is a function reference of type RtosTimer::Callback (void (void* ptr)):
void OnTimer(void* param) { /* runs in the FreeRTOS timer task */ }
RtosTimer t(500, RtosTimer::REPEATING, OnTimer, param);
t.Create();
t.Start();
t.UpdatePeriod(1000);
t.StartWithNewPeriod(250);
t.Reset();
t.Stop();
bool active = t.IsActive();RtosTick — tick/time helpers:
uint32_t t0 = RtosTick::GetTimeMs();
if (RtosTick::CheckTimeDifferenceMs(t0, 500)) { /* 500 ms elapsed */ }
RtosTick::DelayMs(10);
uint32_t wake = RtosTick::GetTickCount();
RtosTick::DelayUntilMs(wake, 100); // fixed-rate loops without drift
RtosTick::Yield();The portable design promised in the introduction cashes out as two checklists.
For a new RTOS, add a wrapper alongside FreeRtosWrapper/ exposing the same class interfaces, then wire it into DevCfgRtos.h — that header selects the wrapper by config define (#if defined(FREERTOS_WRAPPER) … #elif defined(YOUR_WRAPPER) …), so adding a branch there is the whole integration. Verified specifics to carry over:
Rtos::TaskCreatemust stash the per-taskparam_ptrsomewhereRtos::GetCurrentTaskParam()can retrieve it from the running task —AppTask::GetCurrent()(and through it,Callback()'s same-task detection) depends on that round trip. The FreeRTOS port uses the application task tag.- The primitives detect ISR context (
Rtos::IsInHandlerMode, via the Cortex-M IPSR in this port) and switch to ISR-safe calls internally; preserve that behaviour. RtosTick::MsToTicks/TicksToMsmust be exact for your port's tick rate, andRtosQueue::Receivemust preserve theUINT32_MAX-means-block-indefinitely convention —AppTaskrelies on it for event-driven tasks that have no timer.Rtos::Alloc/Rtos::Freeare the heap seam (the globaloperator new/deleteinDevCfg.cpproute through them); point them at your RTOS's allocator (or a mutex-guarded one).TickType_t/portMAX_DELAYappear in the mutex/semaphore signatures — supply equivalents.
For a new MCU architecture, implement the Interfaces/ contracts (IGpio, IIic, ISpi, IUart, and IDisplay/ITouchscreen if you bring new panels) and replace the #include plumbing at the top of DevCfg.h that pulls in the STM32 HAL headers. Define Break() for your architecture in your config. Everything from AppTask up is architecture-blind.
Header-only templates in Math/, no dependencies.
CircularBuffer<T, N, ST = T> — ring buffer with a running sum (ST should be wider than T). Note the API is Add / IsFilled — there is no Push/Pop:
CircularBuffer<int16_t, 32, int32_t> buf;
buf.Add(sample);
int32_t sum = buf.GetSum();
int16_t avg = buf.GetAverage();
bool full = buf.IsFilled();FIFO<T, N> — a real FIFO with Push/Pop:
FIFO<Event, 16> fifo;
fifo.Push(ev);
Event e;
if (fifo.Pop(e)) { /* got one */ }
fifo.PushUnique(ev); // push only if not already present
bool empty = fifo.IsEmpty();RollingAverage<T, N, ST = T> — moving average (built on CircularBuffer):
RollingAverage<float, 8, double> avg;
avg.Add(sample);
float mean = avg.GetAverage();MedianSortFilter<T, N, ST = T> — sliding-window median via insertion sort; Add returns the new median:
MedianSortFilter<int16_t, 5> med;
int16_t m = med.Add(raw); // or med.GetMedian() afterwardsMedianListFilter<T, N, IT = uint16_t> — sliding-window median via a sorted linked list (cheaper inserts for larger windows); Add returns the new median.
Hysteresis<T> — Schmitt-trigger comparator. Process() returns true once the input exceeds max, and stays true until the input falls to min or below:
Hysteresis<float> overheat(70.0f, 80.0f); // min, max
bool fan_on = overheat.Process(temp); // on above 80 °C, off again at ≤ 70 °C(For an inverted condition — e.g. a heater that should run when it's cold — negate the output or the input.)
Crc32 — CRC-32 as a free function (Crc32(buf, len)), declared in Crc32.h with the implementation and lookup table in Crc32.cpp:
uint32_t crc = Crc32(data_ptr, len);All settings live in your DevCfgUsr.h. Defaults shown are what DevCfg.h applies if you leave a value undefined.
| Define | Default | Purpose |
|---|---|---|
FREERTOS_WRAPPER |
— (required) | Selects the RTOS wrapper. Read by DevCfgRtos.h, which #errors if no wrapper is defined |
Break() |
no-op if undefined | Fatal-error hook DevCore calls on an unrecoverable condition. Define in your config (e.g. asm volatile("bkpt #0") on ARM, or an assert/reset). DevCfg.h makes it a no-op if you leave it undefined |
DISPLAY_MAX_BUF_LEN |
320 | Pixels in each of the two display line buffers. Set to the longest scan line: normally the screen width, but because UPDATE_LEFT_RIGHT rotates the panel it must cover max(width, height). For ILI9488 with COLOR_16BIT, multiply by 3/2 (the in-place 18-bit expansion). InitTask traps at start-up if it's too small |
COLOR_24BIT / COLOR_16BIT / COLOR_3BIT |
COLOR_16BIT |
Compile-time color_t type used by the whole framework |
UPDATE_AREA_ENABLED |
off | Redraw only invalidated regions instead of the full screen. Without it, InvalidateArea returns ERR_BAD_PARAMETER |
MULTIPLE_UPDATE_AREAS N |
off | Track up to N independent dirty rectangles (defining it implies UPDATE_AREA_ENABLED; the example in DevCfg.h uses 32) |
DISPLAY_DEBUG_INFO |
off | Overlay an FPS counter |
DISPLAY_DEBUG_AREA |
off | Tint updated regions to visualise redraws |
DISPLAY_DEBUG_TOUCH |
off | Draw a marker at the touch point |
INPUTDRV_ENABLED |
off | Compile in the InputDrv task — DevBoy-specific hardware; also the only source of UiMenu's enter/back inputs |
SOUNDDRV_ENABLED |
off | Compile in the SoundDrv task |
DWT_ENABLED |
off | Enable the DWT cycle counter |
DISPLAY_DRV_TASK_STACK_SIZE |
1024 | DisplayDrv stack (words) |
DISPLAY_DRV_TASK_PRIORITY |
idle+1 | DisplayDrv priority |
INPUT_DRV_TASK_STACK_SIZE / INPUT_DRV_TASK_PRIORITY |
min / idle+2 | InputDrv |
SOUND_DRV_TASK_STACK_SIZE / SOUND_DRV_TASK_PRIORITY |
min / idle+3 | SoundDrv |
Two macros in DevCfgUsrExample.h — APPLICATION_TASK_STACK_SIZE and APPLICATION_TASK_PRIORITY — are conventions for your own tasks, not framework inputs; DevCore never reads them.
FreeRTOS-side requirements (configUSE_APPLICATION_TASK_TAG = 1, timer-task priority, 1 kHz tick) are covered in Prerequisites; DevCfg.h emits a #warning when the timer-task priority isn't configMAX_PRIORITIES - 1.
DevCore/
├── DevCore.h Umbrella include — pulls in the whole framework
├── DevCfgRtos.h RTOS-wrapper selector (FREERTOS_WRAPPER → FreeRtosWrapper/…)
├── DevCfgUsrExample.h Example user config → copy to your project as DevCfgUsr.h
├── DevCfg.h / .cpp Config hub: includes HAL, user config, color_t, global new/delete
│
├── Framework/ AppTask (task base class) · Result (error type)
├── FreeRtosWrapper/ Rtos · RtosQueue · RtosMutex · RtosRecursiveMutex ·
│ RtosSemaphore · RtosTimer · RtosTick
│
├── Interfaces/ IGpio · IIic · ISpi · IUart · IDisplay · ITouchscreen · ICallback
├── Drivers/ StHalGpio · StHalIic · StHalIicThreadSafe · StHalSpi ·
│ StHalUart · DwtCycleCounter (STM32 HAL implementations)
├── Libraries/ BoschBME280 · Mlx90614 · Vl53l0x · Tcs34725 · Eeprom24 · FramMB85
│
├── Display/ DisplayDrv (render task)
│ ├── ILI9341 · ILI9488 · GC9A01 · ST7789 (LCD controllers)
│ ├── FT6236 · XPT2046 (touchscreens)
│ ├── VisObject · VisList (visual-object model)
│ ├── Primitives · Strng · StringAligned ·
│ │ MultiLineString · Image (+ ImagePalette ·
│ │ ImageBitmap · ImageBinary) · TiledMap (drawables)
│ ├── UpdateAreaProcessor (dirty-region tracking)
│ └── Font.h + Fonts/ (Font_4x6 … Font_12x16) (bitmap fonts, singletons)
│
├── UiEngine/ UiButton · UiCheckbox · UiScroll (VisObject widgets;
│ UiButton the most mature)
│ UiMenu · UiMsgBox (modal controls — exploration-grade)
├── Tasks/ ButtonDrv · InputDrv (DevBoy-specific) · SoundDrv
└── Math/ CircularBuffer · FIFO · RollingAverage · MedianListFilter ·
MedianSortFilter · Hysteresis · Crc32
DevCore is free software under the BSD-3-Clause license (below). You can use it in personal, open-source, or commercial products with no obligation beyond keeping the copyright notice. The two notes below are requests, not requirements — nothing in them is a condition of the license.
Please consider contributing back. DevCore is hosted on GitHub, so contributing is as simple as opening a pull request. New hardware drivers (for other MCUs/architectures) and RTOS wrappers (for RTOSes other than FreeRTOS) are especially valuable — they extend DevCore to platforms others are trying to reach, and they're exactly the kind of work the framework's portable design is meant to invite. Bug fixes and improvements are welcome too.
Please consider supporting development. If DevCore helps power a product that does well commercially, supporting its upkeep helps keep the project healthy. You can sponsor the author through GitHub Sponsors: https://github.com/sponsors/nickshl
Neither is required. Both are appreciated.
BSD 3-Clause License
Copyright (c) 2016-2026, Devtronic & Nicolai Shlapunov
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of Devtronic nor the names of its contributors may be
used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.