ecor
Embedded Coroutines & Execution
Single-header C++20 coroutine and async execution library for embedded systems
C++ has std::execution (P2300) for async operations, contains part of the implementation to provide additional embedded friendly abstractions. We need something lightweight, zero-allocation capable, and usable in resource-constrained systems.
ecor is a single-header implementation of P2300 sender/receiver model with coroutine support, designed specifically for embedded systems. It provides zero-allocation async primitives, custom memory management, and additional abstractions missing from the standard.
Quick Start
{
co_return 42;
}
struct my_receiver {
int& result_ref;
void set_value(int value) noexcept {
result_ref = value;
}
template<typename E>
void set_error(E&&) noexcept {
}
void set_stopped() noexcept {
}
auto get_env() const noexcept {
}
};
int my_main()
{
uint8_t buffer[4096];
auto task_sender = fetch_data(ctx);
int result = 0;
auto op = std::move(task_sender).connect(my_receiver{result});
op.start();
while (ctx.core.run_once()) { }
return 0;
}
Circular buffer memory resource, manages a provided memory block as a circular buffer for dynamic all...
Definition: ecor.hpp:398
Standard empty environment.
Definition: ecor.hpp:143
Receiver concept tag, used for marking types as receivers.
Definition: ecor.hpp:93
Basic task context that provides the task with necessary resources: task_core and task_memory_resourc...
Definition: ecor.hpp:2301
Task type that represents an asynchronous operation that is implemented as a coroutine and is a sende...
Definition: ecor.hpp:2726
Tasks - Coroutine-based Async
Tasks are the primary way to write async code in ecor. They're coroutines that can co_await other async operations.
Basic Task
#include <ecor/ecor.hpp>
{
co_return;
}
{
int result = 42;
co_return result;
}
Awaiting Other Tasks
Tasks can await other tasks, creating async chains:
{
co_return 100;
}
{
int value = co_await get_sensor_value(ctx);
if (value > 50) {
}
co_return;
}
Awaiting Senders
Tasks can await any sender (P2300 concept):
{
int value = co_await events.schedule();
co_return;
}
Broadcast source that implements a scheduler that allows multiple receivers to be scheduled with the ...
Definition: ecor.hpp:1711
Type tag for set_value completion signal.
Definition: ecor.hpp:103
Cooperative Scheduling — <tt>ecor::suspend</tt>
Use co_await ecor::suspend; to yield from the current task, placing it at the back of the ready queue so other tasks get a chance to run first.
{
while (true) {
toggle_led();
co_await ecor::suspend;
}
}
If a stop has already been requested at the point of suspension, the task completes with set_stopped() instead of resuming — no callbacks or extra allocations involved. Code that never uses ecor::suspend incurs zero overhead.
Broadcast Source - One-to-Many Events
broadcast_source allows one producer to send events to multiple subscribers. After event is emitted, all waiting tasks receive the event. If no tasks are waiting, the event is dropped. After task receives the event, it must re-register to receive future events.
The template parameter defines the event signature (e.g., set_value_t(Args...)), see rest of the documentation for details.
#include <ecor/ecor.hpp>
{
while (true) {
int button_id = co_await btn.schedule();
}
}
{
while (true) {
int button_id = co_await btn.schedule();
}
}
{
btn.set_value(1);
co_return;
}
Multiple Value Types
Broadcast sources can handle multiple value completion signatures:
>;
{
auto sender = events.schedule();
auto result = co_await (sender | ecor::as_variant);
std::visit([](auto&& val) {
if constexpr (std::is_same_v<std::decay_t<decltype(val)>, int>) {
} else {
}
}, result);
co_return;
}
Custom error types
Broadcast sources can also have custom error signatures:
>;
Type tag for set_error completion signal.
Definition: ecor.hpp:108
This allows you to send error events to all waiting tasks, which can be useful for broadcasting system-wide errors or status updates. However, this is not compatible with standard configuration of ecor::task which expects only set_error_t(ecor::task_error) - broadcast source with any extra error can't be directly awaited. You have to use one of the sender combinators to handle this, e.g., sink_err to convert errors into optional.
Stoppable signature
You can also add a stoppable signature to allow waiting tasks to be cancelled:
>;
Type tag for set_stopped completion signal.
Definition: ecor.hpp:113
FIFO Source - Point-to-Point Events
While broadcast_source delivers events to all waiting tasks, fifo_source delivers each event to exactly one waiting task (the one that has been waiting the longest). If no tasks are waiting when an event is emitted, the event is immediately dropped.
#include <ecor/ecor.hpp>
{
while (true) {
int job_id = co_await q.schedule();
}
}
{
q.set_value(101);
q.set_value(102);
co_return;
}
FIFO source that implements a scheduler that allows multiple receivers to be scheduled with the same ...
Definition: ecor.hpp:1794
Sequential Source - Ordered Event Processing
seq_source provides ordered, keyed event processing. Events with keys are processed in order of the keys.
uint64_t,
>;
Keyed source that implements a scheduler that allows multiple receivers to be scheduled with the same...
Definition: ecor.hpp:1873
Timer Implementation Example
#include <ecor/ecor.hpp>
uint64_t,
>;
class timer_manager {
public:
timer_manager() = default;
auto sleep_until(uint64_t wake_time) {
return source.schedule(wake_time);
}
void tick(uint64_t current_time) {
while (!source.empty() && source.front().key <= current_time) {
uint64_t event_time = source.
front().key;
source.set_value(current_time);
}
}
private:
timer_source source;
};
while (true) {
uint64_t now = get_current_time();
co_await timer.sleep_until(now + 1000);
toggle_led();
}
}
while (true) {
uint64_t now = get_current_time();
co_await timer.sleep_until(now + 5000);
read_temperature_sensor();
}
}
_sh_entry< K, S... > const & front() const
Get a reference to the scheduled receiver with the smallest key.
Definition: ecor.hpp:1947
Priority Queue Pattern
seq_source can implement priority-based message delivery where messages go to the highest priority (lowest key) waiter:
uint32_t,
>;
priority_queue& queue)
{
while (true) {
std::string msg = co_await queue.schedule(0);
process_message(msg);
}
}
priority_queue& queue)
{
while (true) {
std::string msg = co_await queue.schedule(10);
process_message(msg);
}
}
queue.set_value("urgent message");
co_return;
}
Stop Tokens - Cancellation
ecor implements inplace_stop_token and inplace_stop_source from P2300, providing cooperative cancellation without allocation.
Basic Cancellation
#include <ecor/ecor.hpp>
for (int i = 0; i < 1000000; ++i) {
if (token.stop_requested()) {
co_return;
}
}
co_return;
}
void cancel_operation() {
}
In-place stop source, which is a simple implementation of a stoppable source that is designed to be u...
Definition: ecor.hpp:1332
bool request_stop()
Request a stop.
Definition: ecor.hpp:1360
inplace_stop_token get_token() const noexcept
Get the stop token associated with this stop source.
Definition: ecor.hpp:1438
Stop Callbacks
Register callbacks to be invoked when stop is requested:
void example() {
cleanup_resources();
}};
}
In-place stop callback, which is a callback that can be registered with an inplace_stop_token to be i...
Definition: ecor.hpp:1455
Multiple Callbacks
Callback During Stop
If you register a callback after stop has been requested, it executes immediately:
Memory Management
ecor provides deterministic memory management for embedded systems through custom allocators.
Circular Buffer Memory
Pre-allocate a buffer for all async operations:
#include <ecor/ecor.hpp>
uint8_t task_buffer[8192];
auto t1 = my_task(ctx);
auto t2 = another_task(ctx);
Custom Memory Resources
Implement your own memory resource:
struct my_memory_resource {
void* allocate(std::size_t bytes, std::size_t align) {
return custom_alloc(bytes, align);
}
void deallocate(void* p, std::size_t bytes, std::size_t align) {
custom_free(p, bytes, align);
}
};
my_memory_resource my_mem;
Task memory resource that provides allocation and deallocation functions for tasks.
Definition: ecor.hpp:2244
Checking Memory Usage
void check_memory() {
uint8_t buffer[4096];
std::size_t used = mem.used_bytes();
std::size_t available = capacity - used;
if (used > capacity * 0.9) {
}
}
std::size_t capacity() const noexcept
Get total capacity of the buffer in bytes.
Definition: ecor.hpp:514
Sender Combinators
ecor provides P2300-style sender combinators for composing async operations.
or - Race Multiple Senders
Passess calls to the first sender that completes (value, error, or stopped), other is ignored.
auto winner = co_await (sender1 || sender2);
co_return;
}
as_variant - Handle Multiple Completions
If a sender has multiple set_value signatures, as_variant converts them into a single std::variant - combining multiple value types into one.
> source;
auto result =
co_await (source.
schedule() | ecor::as_variant);
std::visit([](auto val) {
if constexpr (std::is_same_v<decltype(val), int>) {
} else {
}
}, result);
co_return;
}
_ll_sender< S... > schedule() noexcept
Schedule a new receiver with this source.
Definition: ecor.hpp:1715
sink_err - Convert Errors to Optional
For void-returning senders that might error, sink_err converts errors into an optional:
auto result = co_await (my_task(ctx) | ecor::sink_err);
if (result) {
std::visit([](auto&& error) {
}, *result);
} else {
}
co_return;
}
then - Transform Values
then applies a callable to each set_value completion, passing errors and stop signals through unchanged:
int result =
co_await (source.
schedule() | ecor::then([](
int v) {
return v * 2; }));
co_return;
}
If the callable returns void, the output sender emits set_value_t().
Task Holder - Automatic Restart
task_holder owns a task<void, CFG> and restarts it automatically every time it completes, providing a simple "never-stop service" abstraction with cooperative cancellation.
{
co_return;
}
void run()
{
static uint8_t buffer[4096];
return my_service(c);
}};
holder.start();
}
bool run_once()
Run a single ready task if there is one.
Definition: ecor.hpp:2036
Owns a task<void, CFG> that restarts automatically, it is provided with a factory to create new task ...
Definition: ecor.hpp:3442
Restart policy
- Restarts on any completion (
set_value, set_error, or set_stopped from the inner task).
- Exits the loop only when
stop() has been called and the next completion arrives — regardless of which signal that is.
- Exceptions thrown inside the task are converted to
set_error(task_error::task_unhandled_exception) and also trigger a restart.
Stopping
stop() signals the holder to exit after the current task completes. It returns a sender that fires once the loop has exited — safe to co_await from another task:
{
co_await holder.stop();
}
Custom configuration
To use a custom task configuration (extra error signatures) or a custom context type, spell out the template parameters explicitly:
struct my_error {};
struct my_cfg {
};
void run_with_custom_cfg(my_ctx_type& ctx) {
}
Type container for completion signatures, used to specify the set of possible completion signals.
Definition: ecor.hpp:125
Async Arena - Managed Async Lifetimes
async_arena provides reference-counted smart pointers (async_ptr) with asynchronous destruction. When the last pointer to an object is dropped, the arena runs a user-defined async destroy protocol before calling ~T() and freeing memory — all driven through the same task_core scheduler.
Basic Usage
struct my_device {
int id;
};
return dev.shutdown_source.schedule();
}
struct my_mem {
void* allocate(std::size_t bytes, std::size_t align) {
return ::operator new(bytes, std::align_val_t(align));
}
void deallocate(void* p, std::size_t, std::size_t align) {
::operator delete(p, std::align_val_t(align));
}
};
void arena_example() {
my_mem mem;
auto ptr = arena.make<my_device>(42, shutdown_src);
auto copy = ptr;
ptr.reset();
copy.reset();
}
Async arena.
Definition: ecor.hpp:3971
When the last async_ptr is dropped, the arena:
- Enqueues the object for destruction,
- Calls
ecor::async_destroy(ctx, obj) which must return a sender,
- Connects and starts that sender,
- On completion, calls
~T() and frees the control block memory.
The <tt>async_destroy</tt> CPO
Provide either a member function or an ADL free function:
struct widget {
};
struct driver {};
The destroy sender can be a task<void> coroutine, a source's schedule(), or any other sender.
Graceful Shutdown
async_destroy() on the arena signals that no new objects will be created and returns a sender that completes when all pending destructions have finished:
struct arena_obj {
};
struct arena_mem {
void* allocate(std::size_t bytes, std::size_t align) {
return ::operator new(bytes, std::align_val_t(align));
}
void deallocate(void* p, std::size_t, std::size_t align) {
::operator delete(p, std::align_val_t(align));
}
};
{
}
_ll_sender< set_value_t() > async_destroy() noexcept
Signal that no new objects will be created and return a sender that completes with set_value_t() when...
Definition: ecor.hpp:4000
Precondition: The async_destroy() sender must complete before the arena object is destroyed.
Key Properties
- Single-threaded, not interrupt-safe — all pointer operations and
run_once() must happen on the same thread.
- Single allocation per object — control block and
T are allocated together (make_shared style).
- Two-phase cleanup — the destroy sender's operation state is freed on a separate
run_once() tick after completion, preventing use-after-free of the op_state during stack unwinding.
Transaction Controller - Request-Reply Protocols
trnx_controller_source supports ordered request-reply protocols (UART, SPI, I²C, etc.) where multiple transactions can be in flight simultaneously.
Basic Usage
Define a transaction data type with completion signatures, then schedule transactions through the source:
struct my_trnx {
int request_id;
};
void trnx_example() {
auto sender = source.
schedule(my_trnx{.request_id = 1});
int reply = 0;
struct recv {
int* out;
void set_value(int v) noexcept { *out = v; }
void set_error(uint8_t) noexcept {}
void set_stopped() noexcept {}
};
auto op = std::move(sender).connect(recv{&reply});
op.start();
entry->set_value(42);
}
Source for scheduling transactions in a request-reply protocol (e.g.
Definition: ecor.hpp:4266
sender_type schedule(T val)
Schedule a new transaction with the given user data.
Definition: ecor.hpp:4272
trnx_entry< T > * query_next_trnx()
Retrieve the next pending transaction entry for processing by the driver.
Definition: ecor.hpp:4281
Circular Buffer for In-Flight Transactions
trnx_circular_buffer is a 4-cursor ring buffer for managing transactions that cross ISR boundaries. The cursors form a pipeline: enqueue → tx → rx → deliver.
struct buf_trnx {
int request_id;
};
void buffer_example() {
while (!buffer.full()) {
if (!entry) break;
buffer.push(entry);
}
while (buffer.has_tx()) {
buffer.tx_done();
}
while (buffer.has_deliver()) {
auto* entry = buffer.deliver_front();
entry->set_value(0);
buffer.pop();
}
}
ISR-safe 4-cursor circular buffer for managing in-flight transactions.
Definition: ecor.hpp:4158
Stop Semantics
Stop tokens are checked only for pending transactions (still in the source's linked list). query_next_trnx() automatically removes cancelled entries and calls set_stopped() on them. Once a transaction is taken from the list and moved into a circular buffer or handed to hardware, the driver is fully responsible for completing it — stop is no longer checked by the source.
ISR safety: trnx_circular_buffer uses std::atomic cursors. On single-word architectures (e.g. ARM Cortex-M), these compile to plain loads/stores with no overhead.
Assert customization
By default, ecor uses assert for internal checks. You can customize this by defining ECOR_ASSERT before including the header:
#define ECOR_ASSERT(expr) my_custom_assert(expr)
#include <ecor/ecor.hpp>
Or use default assert by defining ECOR_USE_DEFAULT_ASSERT:
#define ECOR_USE_DEFAULT_ASSERT
#include <ecor/ecor.hpp>
Credits
Created by veverak (koniarik). Questions? Find me on #include discord.
License
MIT License - see LICENSE file for details.