Building a header-only C++20 coroutine runtime for bare-metal RISC-V

Designing a lightweight coroutine runtime for real-time tasks without an OS, featuring awaitable timers and static memory allocation

Phil Mulholland
5 min readNov 24, 2024

Creating the coroutine runtime infrastructure

A simple coroutine example was presented in “C++20 coroutines, header only, without an OS”. This post describes the runtime used for that example in detail.

Summary of the runtime files

The runtime for this example is a set of include files in include/coro. These files are used:

  • nop_task.hpp : Task structure including promise_type to conform the C++ coroutines task concept.
  • scheduler.hpp : Generic scheduler class that can manage a set of std::coroutine_handle to determine when they should resume and implement the resumption.
  • awaitable_timer.hpp : An "awaitable" class that can be used with co_await to schedule a coroutines to wake up after a given std::chono delay.
  • static_list.hpp: An alternative to std::list that uses custom memory allocation from a static region to avoid heap usage.
  • awaitable_priority.hpp: An alternative "awaitable" class for tasks to be scheduled to wake according to priority.

NOTE: All classes here are designed to not use the heap for allocation. They will allocate all memory from statically declared buffers.

The coroutine task concept

The nop_task class in nop_task.hpp file implements the coroutine task concept.

A coroutine task includes a promise concept with no return values. The important structures in this file are struct nop_task / struct nop_task::promise_type. This is implemented as described in CPP Reference.

This task structure will be allocated each time a coroutine is called. To avoid heap allocation static memory allocation is used (to be described below). When using a memory constrained platform it is important to understand that the number of coroutines that can be called is restricted by the memory allocated for nop_task::task_heap_.

The relationships between the task classes are shown in the following class diagram:

The awaitable concept

The classes in awaitable_timer.hpp and awaitable_priority.hpp represent asynchronous events that pause the coroutine task until an event occurs.

These classes are designed to be returned from a co_await, this ensures a task can be scheduled to be resumed on a later event.

The awaitable_timer class implements the awaitable concept described in CPP Reference, and also the co_await operator that is overloaded to take the scheduler_delay struct and return awaitable_timer. An additional concept of the scheduler class is being used to manage the coroutine handle and wake up conditions that are used to implement coroutine task pause.

The relationships between the awaitable classes are shown in the following class diagram:

The scheduler class

The classes in scheduler.hpp are designed to manage the coroutines that are paused. It is a template class, parameterized according to the type of event that should be scheduled, and the maximum number of concurrent active tasks.

The scheduler does the work that would be done by an RTOS or General Purpose OS. It manages a task list of waiting tasks with wake conditions and resumes them on the wake event.

The awaitable classes, introduced above, will insert paused tasks via insert(). The active execution context must call resume() to resume the paused tasks. Each entry in the task list is a schedule_entry structure. The classe are templates specialized by the wake up condition.

This scheduler class is not a concept required by C++ coroutines, but in this example it is needed as there is no operating system scheduler.

The relationships between scheduler classes are shown in the following class diagram:

Using the awaitable and scheduler classes to create a software timer

The awaitable class and scheduler are combined to implement the software timer feature. The following diagram shows how the classes relate.

Walk through of the detailed sequence of suspend and resume

Now the concrete classes have been defined, the sequence to suspend and resume a coroutine class can be shown.

It is shown below in 3 stages in relation to the simple timer example.

1. Setup a coroutine, and suspend on the first wait.

2. Resume and suspend, iterate over several time delays.

3. Complete iterating and exit coroutines.

Testing

The runtime has some basic unit testing implemented in test using the unity test framework. The tests are not comprehensive, but run independent of hardware as the runtime is host & OS independent. The tests are compiled for the host OS and run locally.

Summary

The runtime presented in this article is not meant for production usage and has the bare minimal functionality to implement a re-entrant function using a software timer.

However, it does show the potential of C++ coroutines to be applied to real time applications that are portable across different OS and target architectures.

Appendix

References

I won’t explain the details of C++ coroutines, there are much better resources. I used the following to understand coroutines:

A Few implementation details

  • Tasks are created at the first call to an asynchronous routine. The allocated task data structure is minimal.
  • Tasks do not have a dedicated stack, they will be restored to the stack where they are woken.
  • Context switching stack management is implemented by the compiler, no custom assembler routines are needed (such as portASM.s in FreeRTOS). This is possible as there is no pre-emptive context switching.
  • The scheduler can be made a C++ object, and context switching can be controlled programmatically...

Originally published at http://five-embeddev.com on November 24, 2024.

--

--

Phil Mulholland
Phil Mulholland

Written by Phil Mulholland

Experienced in Distributed Systems, Event-Driven Systems, Firmware for SoC/MCU, Systems Simulation, Network Monitoring and Analysis, Automated Testing and RTL.