Skip to content

nickshl/DevCore

Repository files navigation

DevCore

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.


Table of Contents

  1. Architecture at a Glance
  2. Getting Started
  3. The Application FrameworkAppTask, Result
  4. Building Interactive Applications
  5. Talking to Hardware
  6. The RTOS Wrapper — raw primitives, and the porting layer for other RTOSes
  7. Utilities
  8. Appendix: Configuration Reference
  9. Appendix: Repository Layout
  10. License & Supporting DevCore

Architecture at a Glance

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 AppTask and override hook methods (TimerExpired, ProcessMessage, ProcessCallback) or a single Loop. 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.

Getting Started

Prerequisites

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, …), which DevCfg.h includes; for any module left disabled, DevCfg.h generates a dummy handle type instead, so DevCore still compiles.
  • A generated main.h, which DevCfg.h includes.

(Targeting a non-STM32 architecture or a different RTOS means providing drivers / an RTOS wrapper instead of relying on CubeMX — see Porting.)

FreeRTOS settings DevCore depends on

Two FreeRTOS configuration values matter to DevCore. Check them before anything else — the symptoms of getting them wrong are confusing:

  1. configUSE_APPLICATION_TASK_TAG = 1 — required. Rtos::TaskCreate stores the AppTask pointer in the task's application tag, and AppTask::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 of FreeRTOSConfig.h.
  2. configTIMER_TASK_PRIORITY = configMAX_PRIORITIES - 1 — strongly recommended. DevCfg.h emits a #warning otherwise. The reason is concrete: an event-driven AppTask with 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.

A note on memory

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_SIZE must be sized for all of it (task stacks, queues, and anything you new). 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.

Step 1 — Create your user configuration

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_ENABLED

FREERTOS_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.

Step 2 — Write your first task

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.

Step 3 — Bring up a display and draw something

Two things in this step are easy to get wrong, so they are stated as rules first:

Rule 1 — construct DisplayDrv before any visual object. Every VisObject captures a pointer to the default display list in its constructor, and that default is set by DisplayDrv's constructor (the source says so explicitly: "DisplayDrv::GetInstance() should be called before creation any objects that contains VisObjects"). A Box or String created at global scope is constructed before main() runs — before any GetInstance() call — so it captures nullptr and the first Show() dereferences it. Either create visual objects after the first DisplayDrv::GetInstance() call, or attach them explicitly with SetList().

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 in main() are destroyed-in-effect the moment the kernel runs. Use static locals inside main() (static storage, but constructed when execution reaches them — after GetInstance()), 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.


The Application Framework

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.

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 preserved

Error 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

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 priority

The two execution modes

Every 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.

Defining an event-driven task

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
    ) {}
};

Defining a loop-mode task

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") {}
};

Sending work to a task — the encapsulation rule

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 the ERR_CTRL_QUEUE_WRITE recovery 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 least queue_msg_size bytes (true by construction when it's the message type the queue was sized for).

Posting a callback to another task

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:

  1. 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.
  2. 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.
  3. 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).

Timer control

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();   // resume

Identifying the running task

AppTask::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.


Building Interactive Applications

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.

Display System

The display subsystem has three layers stacked on top of each other:

  1. LCD controller drivers (ILI9341, ILI9488, GC9A01, ST7789) — implement IDisplay, push pixels over SPI.
  2. DisplayDrv — a singleton AppTask that runs the render loop and owns the list of visible objects.
  3. Visual objects (Box, String, Image, …) — what you actually place on screen.

LCD controller drivers

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 all

The 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-byte color_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-byte color_t) it shrinks to 3 bytes/px — no extra space needed;
  • with COLOR_3BIT two 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.)

Touchscreen drivers

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 — the render task

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 calibration

DisplayDrv 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).

Visual object catalogue

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 String but lives in Strng.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 tileset

Writing a Custom Visual Object

This 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.

How rendering works

DisplayDrv::Loop() renders the screen one scan line at a time into a double line buffer. For each line it:

  1. fills the buffer with the background colour;
  2. calls list.DrawInBufW(buf, n, line, start_x) (or DrawInBufH in UPDATE_LEFT_RIGHT mode);
  3. 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;
  4. runs PrepareData() if the driver needs it, then DMA-streams the line while composing the next in the other buffer half.

color_t and colour depth

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.

The two methods you must implement

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.

Key rules

  • Reading the buffer before writing is valid. It already holds everything drawn by lower-z objects. ShadowBox exploits 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::DrawInBufW subtracts its own x_start/y_start before forwarding line/start_x to its children, so the child's stored x_start/y_start and 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 nested VisList it does not.
  • DrawInBufH may be left empty. It exists for cases where horizontal scanning is pathologically inefficient — an oscilloscope trace, for instance, where DrawInBufW would scan the whole buffer every line, but DrawInBufH can use the column index directly to fetch the single Y value for that X. If your object has no such need, give DrawInBufH an empty body and rely on DrawInBufW.
  • Never block or call RTOS primitives inside these methods — they run inside DisplayDrv::Loop() while the line mutex is held.

UI Engine

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:

  • WidgetsUiButton, UiCheckbox, UiScroll — are VisObjects. You Show() them, they live on screen, DisplayDrv routes touch to them, and they report back through callbacks.
  • Modal controlsUiMenu, UiMsgBox — are not VisObjects. Each is a composite that owns a set of visual objects and (for UiMenu) a blocking Run() 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::SetCallback takes the task by pointer (AppTask*), while UiCheckbox::SetCallback takes it by reference (AppTask&).

UiButton

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.

UiCheckbox

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

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 — a modal, blocking menu

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 exits

Show() 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).

UiMsgBox

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)

Input & Sound

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.

ButtonDrv

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);   // ms

SoundDrv (requires SOUNDDRV_ENABLED)

Drives 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 caller

Beep() 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 (requires INPUTDRV_ENABLED) — DevBoy hardware only

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);

Talking to Hardware

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.

Hardware Abstraction Interfaces

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();

ITouchscreenIsTouched(), 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.

MCU Peripheral Drivers

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 over HAL_I2C_*, full IIic implementation (including combined Transfer and async variants).
  • StHalIicThreadSafe — a separate I2C driver (also constructed from an I2C_HandleTypeDef&) that guards every bus operation with a mutex, for buses shared by multiple tasks. It is not a wrapper around StHalIic — 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);

External Chip Libraries

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×1024

Mlx90614 — 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::OBJECT2

Vl53l0x — 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);

The RTOS Wrapper

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 to portMAX_DELAY), so use pdMS_TO_TICKS() there. For queues, a timeout of UINT32_MAX ms 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();

Porting to another RTOS or MCU

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:

  1. Rtos::TaskCreate must stash the per-task param_ptr somewhere Rtos::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.
  2. 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.
  3. RtosTick::MsToTicks/TicksToMs must be exact for your port's tick rate, and RtosQueue::Receive must preserve the UINT32_MAX-means-block-indefinitely convention — AppTask relies on it for event-driven tasks that have no timer.
  4. Rtos::Alloc/Rtos::Free are the heap seam (the global operator new/delete in DevCfg.cpp route through them); point them at your RTOS's allocator (or a mutex-guarded one).
  5. TickType_t/portMAX_DELAY appear 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.


Utilities

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() afterwards

MedianListFilter<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);

Appendix: Configuration Reference

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.hAPPLICATION_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.


Appendix: Repository Layout

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

License & Supporting DevCore

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.

License (BSD-3-Clause)

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.

About

DevCore - C++ FreeRTOS Wrapper, Drivers, Libraries and more

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors