A fast and lightweight event loop aimed at embedded platforms in C99.
- About
- Highlights
- API documentation
- Testing
- Core data structures
- Containers
- Core components
- Appendix A: Promises
- Appendix B: Modules
- Appendix C: Useful goodies
- Concurrency model
- Motivation
- Roadmap
µEvLoop is a microframework build around a lightweight event loop. It provides the programmer the building blocks to put together async, interrupt-based systems.
µEvLoop is loosely inspired on the Javascript event loop and aims to provide a similar programming model. Many similar concepts, such as events and closures are included. It is aimed at environments with very restricted resources, but can be run on all kinds of platforms.
µEvLoop is in its early days and the API may change at any moment for now. Although it's well tested, use it with caution. Anyway, feedback is most welcome.
- As minimalist and loose-coupled as possible.
- Does not allocate any dynamic memory on its own. All memory needed is statically allocated either explicitly by the user or implicitly by containers.
- Small memory footprint and runtime latency.
- Does not try to make assumptions about the underlying system.
- Extremely portable and conforming to ISO C99.
- Depends only on a very small subset of the standard libc, mostly for fixed size integers and booleans.
- Allows for excellent execution predictability and ease of debugging.
- Can be used baremetal or alongside RTOSes.
- Well tested and well documented.
The API documentation is automatically generated by Doxygen. Find it here.
Tests are written using a simple set of macros. To run them, execute make test
.
Please note that the makefile shipped is meant to be run in modern Linux systems. Right now, it makes use of bash commands and utilities as well as expects libSegFault.so
to be in a hardcoded path.
If this doesn't fit your needs, edit it as necessary.
To generate code coverage reports, run make coverage
. This requires gcov
, lcov
and genhtml
to be on your PATH
. After running, the results can be found on uevloop/coverage/index.html
.
These data structures are used across the whole framework. They can also be used by the programmer in userspace as required.
All core data structures are unsafe. Be sure to wrap access to them in critical sections if you mean to share them amongst contexts asynchronous to each other.
A closure is an object that binds a function to some context. When invoked with arbitrary parameters, the bound function is called with both context and parameters available. With closures, some very powerful programming patterns, as functional composition, become way easier to implement.
Closures are very light and it is often useful to pass them around by value.
#include <stdint.h>
#include <uevloop/utils/closure.h>
static void *add(void *context, void *params){
uintptr_t value1 = (uintptr_t)context;
uintptr_t value2 = (uintptr_t)params;
return (void *)(value1 + value2);
}
// ...
// Binds the function `add` to the context (5)
uel_closure_t add_five = uel_closure_create(&add, (void *)5);
// Invokes the closure with the parameters set to (2)
uintptr_t result = (uintptr_t)uel_closure_invoke(&add_five, (void *)2);
// Result is 7
Closures take the context and parameters as a void pointers and return the same. This is meant to make possible to pass and return complex objects from them.
At many times, however, the programmer may find the values passed/returned are small and simple (i.e.: smaller than a pointer). If so, it is absolutely valid to cast from/to a uintptr_t
or other data type known to be at most the size of a pointer. The above example does that to avoid creating unnecessary object pools or allocating dynamic memory.
Circular queues are fast FIFO (first-in-first-out) structures that rely on a pair of indices to maintain state. As the indices are moved forward on push/pop operations, the data itself is not moved at all.
The size of µEvLoop's circular queues are required to be powers of two, so it is possible to use fast modulo-2 arithmetic. As such, on queue creation, the size must be provided in its log2 form.
FORGETTING TO SUPPLY THE QUEUE'S SIZE IN LOG2 FORM MAY CAUSE THE STATIC ALLOCATION OF GIANT MEMORY POOLS
#include <stdint.h>
#include <uevloop/utils/circular-queue.h>
#define BUFFER_SIZE_LOG2N (5)
#define BUFFER_SIZE (1<<BUFFER_SIZE_LOG2N) // 1<<5 == 2**5 == 32
// ...
uel_cqueue_t queue;
void *buffer[BUFFER_SIZE];
// Creates a queue with 32 (2**5) slots
uel_cqueue_init(&queue, buffer, BUFFER_SIZE_LOG2N);
// Push items in the queue
uel_cqueue_push(&queue, (void *)3)
uel_cqueue_push(&queue, (void *)2)
uel_cqueue_push(&queue, (void *)1);
// Pop items from the queue
uintptr_t value1 = (uintptr_t) uel_cqueue_pop(&queue); // value1 is 3
uintptr_t value2 = (uintptr_t) uel_cqueue_pop(&queue); // value2 is 2
uintptr_t value3 = (uintptr_t) uel_cqueue_pop(&queue); // value3 is 1
Circular queues store void pointers. As it is the case with closures, this make possible to store complex objects within the queue, but often typecasting to an smaller value type is more useful.
On embedded systems, hardware resources such as processing power or RAM memory are often very limited. As a consequence, dynamic memory management can become very expensive in both aspects.
Object pools are statically allocated arrays of objects whose addresses are stored in a queue. Whenever the programmer needs a object in runtime, instead of dynamically allocating memory, it is possible to simply pop an object pointer from the pool and use it away.
Because object pools are statically allocated and backed by circular queues, they are very manageable and fast to operate.
#include <stdint.h>
#include <uevloop/utils/object-pool.h>
typedef struct obj obj_t;
struct obj {
uint32_t num;
char str[32];
// Whatever
};
// ...
// The log2 of our pool size.
#define POOL_SIZE_LOG2N (5)
UEL_DECLARE_OBJPOOL_BUFFERS(obj_t, POOL_SIZE_LOG2N, my_pool);
uel_objpool_t my_pool;
uel_objpool_init(&my_pool, POOL_SIZE_LOG2N, sizeof(obj_t), UEL_OBJPOOL_BUFFERS(my_pool));
// my_pool now is a pool with 32 (2**5) obj_t
// ...
// Whenever the programmer needs a fresh obj_t
obj_t *obj = (obj_t *)uel_objpool_acquire(&my_pool);
// When it is no longer needed, return it to the pool
uel_objpool_release(&my_pool, obj);
µEvLoop ships a simple linked list implementation that holds void pointers, as usual.
#include <stddef.h>
#include <stdint.h>
#include <uevloop/utils/linked-list.h>
// ...
uel_llist_t list;
uel_llist_init(&list);
uel_llist_node_t nodes[2] = {
{(void *)1, NULL},
{(void *)2, NULL}
};
// Push items into the list
uel_llist_push_head(&list, &nodes[0]);
uel_llist_push_head(&list, &nodes[1]);
// List now is TAIL-> [1]-> [2]-> NULL. HEAD-> [2]
uel_llist_node_t *node1 = (uel_llist_node_t *)llist_pop_tail(&list);
uel_llist_node_t *node2 = (uel_llist_node_t *)llist_pop_tail(&list);
//node1 == nodes[0] and node2 == nodes[1]
Containers are objects that encapsulate declaration, initialisation and manipulation of core data structures used by the framework.
They also encapsulates manipulation of these data structures inside critical sections, ensuring safe access to shared resources across the system.
The syspools
component is a container for the system internal object pools. It contains pools for events and linked list nodes used by the core components.
The system pools component is meant to be internally operated only. The only responsibility of the programmer is to allocate, initialise and provide it to other core components.
To configure the size of each pool created, edit include/uevloop/config.h
.
#include <uevloop/system/containers/system-pools.h>
// ...
uel_syspools_t pools;
uel_syspools_init(&pools);
// This allocates two pools:
// 1) pools.event_pool
// 2) pools.llist_node_pool
The sysqueues
component contains the necessary queues for sharing data amongst the core components. It holds queues for events in differing statuses.
As is the case with system pools, the sysqueues
component should not be directly operated by the programmer, except for declaration and initialisation.
Configure the size of each queue created in include/uevloop/config.h
.
#include <uevloop/system/containers/system-queues.h>
// ...
uel_sysqueues_t queues;
uel_sysqueues_init(&queues);
// This allocates two queues:
// 1) queues.event_queue (events ready to be processed are put here)
// 2) queues.schedule_queue (events ready to be scheduled are put here)
The application
component is a convenient top-level container for all the internals of an µEvLoop'd app. It is not necessary at all but contains much of the boilerplate in a typical application.
It also proxies functions to the event loop
and scheduler
components, serving as a single point entry for the system operation.
The following code is a realistic minimal setup of the framework.
#include <uevloop/system/containers/application.h>
#include <stdint.h>
static volatile uint32_t counter = 0;
static uel_application_t my_app;
// 1 kHz timer
void my_timer_isr(){
my_timer_isr_flag = 0;
uel_app_update_timer(&my_app, ++counter);
}
int main (int argc, char *argv[]){
uel_app_init(&my_app);
// From here, the programmer can:
// - Schedule timers with `uel_app_run_later` or `uel_app_run_at_intervals`
// - Enqueue closures with `uel_app_enqueue_closure`
// - Set up observers with `uel_app_observe`
// - Listen for signals set at other places
while(1){
uel_app_tick(&my_app);
}
return 0;
}
The application
component can also keep a registry of modules to manage. See Appendix A: Modules for more information.
The scheduler is a component that keeps track of current execution time and closures to be run in the future. It provides similar functionality to the setTimeout
and setInterval
Javascript functions.
Two queues lead in and out of it: the inbound schedule_queue is externally fed events that should be scheduled and then accounted for; the outbound event_queue hold events that are due to be collected and processed.
This component needs access to system's pools and queues.
#include <uevloop/system/containers/system-pools.h>
#include <uevloop/system/containers/system-queues.h>
#include <uevloop/system/scheduler.h>
// ...
// Create the system containers
uel_syspools_t pools;
uel_syspools_init(&pools);
uel_sysqueues_t queues;
uel_sysqueues_init(&queues);
// Create the scheduler
uel_scheduer_t scheduler;
uel_sch_init(&scheduler, &pools, &queues);
The scheduler
component accepts input of closures and scheduling info an then turns it into a timer event. This timer is then inserted in a timer list, which is sorted by each timer's due time.
#include <stdio.h>
#include <stdint.h>
#include <uevloop/utils/closure.h>
static void *print_coords(void *context, void *params){
uintptr_t x = (uintptr_t)context;
uintptr_t y = (uintptr_t)params;
printf("(x: %d, y: %d)\n", x, y);
return NULL;
}
// ...
uel_closure_t print_x_one = uel_closure_create(&print_coords, (void *)1);
uel_closure_t print_x_two = uel_closure_create(&print_coords, (void *)2);
uel_closure_t print_x_three = uel_closure_create(&print_coords, (void *)3);
// Schedules to run 1000ms in the future.
// Will print (x: 1, y: 4)
uel_sch_run_later(&scheduler, 1000, print_x_one, (void *)4);
// Schedules to run at intervals of 500ms, runs the first time after 500ms
// Will print (x: 2, y: 5)
uel_sch_run_at_intervals(&scheduler, 500, false, print_x_two, (void *)5);
// Schedules to run at intervals of 300ms, runs the first time the next runloop
// Will print (x: 3, y: 6)
uel_sch_run_at_intervals(&scheduler, 300, true, print_x_three, (void *)6);
The scheduler
must be fed regularly to work. It needs both an update on the running time as an stimulus to process enqueued timers. Ideally, a hardware timer will be consistently incrementing a counter and feeding it at an ISR while in the main loop the scheduler is oriented to process its queue.
// millisecond counter
volatile uint32_t counter = 0;
// 1kHz timer ISR
void my_timer_isr(){
my_timer_isr_flag = 0;
uel_sch_update_timer(&scheduler, ++counter);
}
// ...
// On the main loop
uel_sch_manage_timers(&scheduler);
When the function uel_sch_manage_timers
is called, two things happen:
- The
schedule_queue
is flushed and every timer in it is scheduled accordingly; - The scheduler iterates over the scheduled timer list from the beginning and breaks it when it finds a timer scheduled further in the future. It then proceeds to move each timer from the extracted list to the
event_queue
, where they will be further collected and processed.
Events are messages passed amongst the system internals that coordinate what tasks are to be run, when and in which order.
Usually, the programmer don't have to interact directly with events, being timer events and observers the only exceptions to this.
The functions uel_sch_run_later
and uel_sch_run_at_intervals
return a uel_event_t *
. With this handle, it is possible to pause and resume or even completely cancel a timer event.
#include <stddef.h>
uel_event_t *timer = uel_sch_run_at_intervals(&scheduler, 100, false, print_one, NULL);
// The event will be put on a hold queue in the scheduler
uel_event_timer_pause(timer);
// The event will be rescheduled on the scheduler
uel_event_timer_resume(timer);
// The event will be ignored by the scheduler and destroyed at the `event loop`
uel_event_timer_cancel(timer);
When pausing and resuming timer events, be aware of the internal's latencies: paused timers are only sent to the hold queue when their scheduled time is hit. Also, when resumed, they are scheduled based solely on their period setting, being the elapsed time when they were paused completely ignored. Should a timer both scheduled and paused be resumed before its elapsed time is hit, it behaves as it was never paused.
Regarding cancelled timer events, they are equally susceptible to internal latency as they will only be destroyed when processed by the event loop
. However, cancelled timers are not meant to be reused anyway. As a rule of thumb, never use a timer event after it was cancelled.
There are two distinct factors that will determine the actual time resolution of the scheduler:
- the frequency of the feed in timer ISR
- the frequency the function
uel_sch_manage_timers
is called.
The basic resolution variable is the feed-in timer frequency. Having this update too sporadically will cause events scheduled to differing moments to be indistinguishable regarding their schedule (e.g.: most of the time, having the timer increment every 100ms will make any events scheduled to moments with less than 100ms of difference to each other to be run in the same runloop).
A good value for the timer ISR frequency is usually between 1 kHz - 200 Hz, but depending on project requirements and available resources it can be less. Down to around 10 Hz is still valid, but precision will start to deteriorate quickly from here on.
There is little use having the feed-in timer ISR run at more than 1 kHz, as it is meant to measure milliseconds. Software timers are unlikely to be accurate enough for much greater frequencies anyway.
If the uel_sch_manage_timers
function is not called frequently enough, events will start enqueuing and won't be served in time. Just make sure it is called when the counter is updated or when there are events on the schedule queue.
The central piece of µEvLoop (even its name is a bloody reference to it) is the event loop, a queue of events to be processed sequentially. It is not aware of the execution time and simply process all enqueued events when run. Most heavy work in the system happens here.
The event loop requires access to system's internal pools and queues.
#include <uevloop/system/containers/system-pools.h>
#include <uevloop/system/containers/system-queues.h>
#include <uevloop/system/event-loop.h>
// Create system containers
uel_syspools_t pools;
uel_syspools_init(&pools);
uel_sysqueues_t queues;
uel_sysqueues_init(&queues);
// Create the event loop
uel_evloop_t loop;
uel_evloop_init(&loop, &pools, &queues);
The event loop is mean to behave as a run-to-completion task scheduler. Its uel_evloop_run
function should be called as often as possible as to minimise execution latency. Each execution of uel_evloop_run
is called a runloop .
The only way the programmer interacts with it, besides creation / initialisation, is by enqueuing hand-tailored closures directly, but other system components operate on the event loop behind the stage.
Any closure can be enqueued multiple times.
#include <uevloop/utils/closure.h>
static void *add(void *context, void *params){
uintptr_t *num = (uintptr_t *)context;
uintptr_t other = (uintptr_t)params;
*num += other;
return NULL;
}
// ...
uintptr_t value = 0;
uel_closure_t closure = uel_closure_create(&add, (void *)&value);
uel_evloop_enqueue_closure(&loop, &closure, (void *)1);
// value is 0
uel_evloop_run(&loop);
// value is 1
uel_evloop_enqueue_closure(&loop, &closure, (void *)2);
uel_evloop_enqueue_closure(&loop, &closure, (void *)3);
uel_evloop_run(&loop);
// value is 6
WARNING! uel_evloop_run
is the single most important function within µEvLoop. Almost every other core component depends on the event loop and if this function is not called, the loop won't work at all. Don't ever let it starve.
The event loop can be instructed to observe some arbitrary volatile value and react to changes in it.
Because observers are completely passive, they are ideal for triggering side-effects from ISRs without any latency. However, each observer set does incur extra latency during runloops, as the observed value must be continuously polled.
static volatile uintptr_t adc_reading = 0;
void my_adc_isr(){
adc_reading = my_adc_buffer;
my_adc_isr_flag = 0;
}
static void *process_adc_reading(void *context, void *params){
uintptr_t value = (uintptr_t)params;
// Do something with `value`
return NULL;
}
uel_closure_t processor =
uel_closure_create(process_adc_reading, NULL);
// This ensures each runloop the `adc_reading` variable is polled and, in case
// of changes to it, the `processor` closure is called with its new value as
// parameter.
uel_event_t *observer = uel_evloop_observe(&loop, &adc_reading, &processor);
// When an observer isn't needed anymore, it can be disposed of to release any
// used system resources.
// **DON'T** use an observer after it has been cancelled.
uel_event_observer_cancel(observer).
Signals are similar to events in Javascript. It allows the programmer to message distant parts of the system to communicate with each other in a pub/sub fashion.
At the centre of the signal system
is the Signal Relay, a structure that bind specific signals to its listeners. When a signal is emitted, the relay will asynchronously run each listener registered for that signal. If the listener was not recurring, it will be destroyed upon execution by the event loop.
To use signals, the programmer must first define what signals will be available in a particular relay, then create the relay bound to this signals.
To be initialised, the relay must have access to the system's internal pools and queues. The programmer will also need to supply it a buffer of linked lists, where listeners will be stored.
#include <uevloop/system/containers/system-pools.h>
#include <uevloop/system/containers/system-queues.h>
#include <uevloop/system/signal.h>
#include <uevloop/utils/linked-list.h>
// Create the system containers
uel_syspools_t pools;
uel_syspools_init(&pools);
uel_sysqueues_t queues;
uel_sysqueues_init(&queues);
// Define what signals will be available to this relay.
// Doing so in an enum makes it easy to add new signals in the future.
enum my_component_signals {
SIGNAL_1 = 0,
SIGNAL_2,
// New events would go here
SIGNAL_COUNT
};
// Declare the relay buffer. Note this array will be the number of signals large.
uel_llist_t buffer[SIGNAL_COUNT];
// Create the relay
uel_signal_relay_t relay;
uel_signal_relay_init(&relay, &pools, &queues, buffer, SIGNAL_COUNT);
// This is the listener function.
static void *respond_to_signal(void *context, void *params){
uintptr_t num = (uintptr_t)context;
// Signals can be emitted with parameters, just like events in JS
char c = (char)(uintptr_t)params;
printf("%d%c\n", num, c);
return NULL;
}
// Listeners can be persistent. They will fire once each time the signal is emitted
uel_closure_t respond_to_signal_1 = uel_closure_create(&respond_to_signal, (void *)1);
uel_signal_listener_t listener_1 = uel_signal_listen(SIGNAL_1, &relay, &respond_to_signal_1);
// Listeners can also be transient, so they fire ust on first emission
uel_closure_t respond_to_signal_2 = uel_closure_create(&respond_to_signal, (void *)2);
uel_signal_listener_t listener_2 = uel_signal_listen_once(SIGNAL_2, &relay, &respond_to_signal_2);
// ...
uel_signal_emit(SIGNAL_1, &relay, (void *)('a')); // prints 1a
uel_signal_emit(SIGNAL_2, &relay, (void *)('b')); // prints 2b
uel_signal_emit(SIGNAL_1, &relay, (void *)('c')); // prints 1c
uel_signal_emit(SIGNAL_2, &relay, (void *)('d')); // doesn't print anything
Please note the listener function will not be executed immediately, despite what this last snippet can lead to believe. Internally, each closure will be sent to the event loop and only when it runs will the closures be invoked.
You can also unlisten for events. This will prevent the listener returned by a uel_signal_listen()
or uel_signal_listen_once()
operation to have its closure invoked when the event loop performs the next runloop.
Additionally, said listener will be removed from the signal vector on such opportunity.
uel_signal_unlisten(listener_1, &relay);
uel_signal_unlisten(listener_2, &relay); // This has no effect because the listener
// for SIGNAL_2 has already been marked as unlistened
Promises are data structures that bind an asynchronous operation to the possible execution paths that derive from its result. They are heavily inspired by Javascript promises.
Promises allow for very clean asynchronous code and exceptional error handling.
All promises must be created at a store, to where they will come back once destroyed. A promise store encapsulates object pools for promises and segments, the two composing pieces for promise operation.
Promise store need access to two object pools, one for promises and one for segments.
#include <uevloop/utils/object-pools.h>
#include <uevloop/system/promise.h>
#define PROMISE_STORE_SIZE_LOG2N 4 // 16 promises
#define SEGMENT_STORE_SIZE_LOG2N 6 // 64 segments
uel_objpool_t promise_pool;
uel_objpool_t segment_pool;
UEL_DECLARE_OBJPOOL_BUFFERS(uel_promise_t, PROMISE_STORE_SIZE_LOG2N, promise);
UEL_DECLARE_OBJPOOL_BUFFERS(uel_promise_segment_t, SEGMENT_STORE_SIZE_LOG2N, segment);
uel_objpool_init(
&promise_pool,
PROMISE_STORE_SIZE_LOG2N,
sizeof(uel_promise_t),
UEL_OBJPOOL_BUFFERS(promise)
);
uel_objpool_init(
&segment_pool,
SEGMENT_STORE_SIZE_LOG2N,
sizeof(uel_promise_segment_t),
UEL_OBJPOOL_BUFFERS(segment)
);
uel_promise_store_t store = uel_promise_store_create(&promise_pool, &segment_pool);
As mentioned above, promises and segments are the two building blocks for composing asynchronous chains of events. Promises represent the asynchronous operation per se and segments are the synchronous processing that occurs when a promise settles.
Settling a promise means transitioning it into either resolved or rejected states which respectively indicate success or error of the asynchronous operation, optionally assigning a meaningful value to the promise.
There are two necessary pieces for creating a promise: a store and a constructor closure that starts the asynchronous operation.
void *start_some_async(void *context, void *params){
uel_promise_t *promise = (uel_promise_t *)params;
// ...
return NULL; // return value is ignored
}
// ...
uel_promise_t *promise =
uel_promise_create(&store, uel_closure_create(start_some_async, NULL));
When the promise is created, start_some_async
is invoked immediately, taking the promise pointer as parameter.
On creation, promises are in the pending state. This means its asynchronous operation has not been completed yet and the promise does not hold any meaningful value.
Once the operation is completed (and this can also be synchronously done from inside the constructor closure), there are two functions for signalling either success or failure of the asynchronous operation:
uel_promise_resolve(promise1, (void *)SOME_VALUE); // operation succeeded
uel_promise_reject(promise2, (void *)SOME_ERROR); // operation failed
Once a promise is settled, it holds a value that can be accessed via promise->value
.
Segments represent the synchronous phase that follows a promise settling. They contain two closures, one for handling resolved promises and one for handling rejected promises. Either one is chosen at runtime, depending on the settled state of the promise, and is invoked with the promise as parameter.
Depending on the promise state, attaching segments have different effects. When a promise is pending, attached segments just get enqueued for execution once the promise is settled. Should the promise be already settled, attached segments get processed immediately instead.
#include <stdio.h>
#include <stddef.h>
void *report_success(void *context, void *params) {
char *tag = (char *)context;
uel_promise_t *promise = (uel_promise_t *)params;
printf("promise %s resolved with %d\n", tag, promise->value);
return NULL;
}
void *report_error(void *context, void *params) {
char *tag = (char *)context;
uel_promise_t *promise = (uel_promise_t *)params;
printf("promise %s rejected with %d\n", tag, promise->value);
return NULL;
}
// ...
// Assume p1, p2 and p3 as pending, resolved and rejected promises, respectively
// p2 is resolved with (uintptr_t)10 and p3 is rejected with (uintptr_t)100
uel_promise_after(
p1,
uel_closure_create(report_success, (void *)"p1"),
uel_closure_create(report_error, (void *)"p1")
);
// Neither closure gets invoked. Instead, a new segment containing them is enqueued.
uel_promise_after(
p2,
uel_closure_create(report_success, (void *)"p2"),
uel_closure_create(report_error, (void *)"p2")
);
// As the promise is already resolved, instead of enqueueing a segment, the first
// closure is invoked.
// "p2 resolved with 10" is printed
uel_promise_after(
p3,
uel_closure_create(report_success, (void *)"p3"),
uel_closure_create(report_error, (void *)"p3")
);
// Similarly, as the promise is already rejected, the second closure gets invoked
// immediately.
// "p3 rejected with 100" is printed
uel_promise_resolve(p1, (uintptr_t)1);
// Upon settling, segments are removed from the queue and processed.
// "p1 resolved with 1" is printed
The uel_promise_after()
function takes two closures as parameters. This is useful for splitting the execution path in two mutually exclusive routes depending on the settled state of the promise.
There are three other functions for enqueuing segments. They can be used for attaching segments that only produce effects on specific settled states or attaching the same closure to both states:
uel_promise_then(my_promise, my_closure);
// `my_closure` is invoked only when `my_promise` is resolved.
// The added segment is ignored if the promise is rejected.
uel_promise_catch(my_promise, my_closure);
// `my_closure` is invoked only when `my_promise` is rejected.
// The added segment is ignored if the promise is resolved.
uel_promise_always(my_promise, my_closure);
// `my_closure` is invoked always upon settling, regardless of the settled state
Any number of segments can be attached to some promise. They will be either processed immediately, in case the promise is already settled, or enqueued for processing upon settling in the future. Regardless, attached segments are always processed in registration order.
Chaining segments is useful because segments have the ability to commute between execution paths through promise resettling. To resettle a promise means changing its state and value.
void *store_char(void *context, void *params) {
unsigned char *var = (unsigned char *)context;
uel_promise_t *promise = (uel_promise_t *)params;
if((uintptr_t)promise->value <= 255) {
*var = (unsigned char)(uintptr_t)promise->value;
} else {
uel_promise_resettle(promise, UEL_PROMISE_REJECTED, (void *)"Value too big");
}
return NULL;
}
void *report_error(void *context, void *params) {
uel_promise_t *promise = (uel_promise_t *)params;
printf("promise was rejected with error '%s'\n", (char *)promise->value);
return NULL;
}
void *done(void *context, void *params) {
static char *states[3] = { "pending", "resolved", "rejected" };
uel_promise_t *promise = (uel_promise_t *)params;
printf("operation done with state '%s'\n", states[promise->state]);
}
unsigned char c1 = 0;
uel_promise_t *p1 = uel_promise_create(&store, uel_nop()); // Creates a pending promise
uel_promise_then(p1, uel_closure_create(store_char, (void *)&c1));
uel_promise_catch(p1, uel_closure_create(report_error, NULL));
uel_promsie_always(p1, uel_closure_create(done, NULL));
This builds a segment chain attached to promise p1
. Each segment added describes one synchronous step to be executed for each of the two settled states.
Segments are processed sequentially, from first to last, starting with the closure relative to the state the promise was settled as. The following table illustrates this chain:
State | Segment 1 | Segment 2 | Segment 3 |
---|---|---|---|
resolved | store_char | nop | done |
rejected | nop | report_error | done |
The outcome of this chain is determined upon settling. For example, given the following resolution:
uel_promise_resolve(p1, (void *)10);
The first closure invoked is store_char
, in segment 1. In the closure function, the test condition promise->value <= 255
is true, so the closure proceeds to store its value in the c1
variable.
As the promise remains resolved, it advances to segment 2, where it finds a nop
(no-operation). This is due to the segment being attached via uel_promise_catch
.
The promise then advances to segment 3, where it finds the done
closure. The process then ends and the promise retains its state and value (UEL_PROMISE_RESOLVED
and (void *)10
in this case). By then, c1
holds (char)10
and operation done with state 'resolved'
is printed.
If instead the promise was resolved as:
uel_promise_resolve(p1, (void *)1000);
Then the test condition promise->value <= 255
would have failed. The store_char
would then skip storing the value and would rather resettle the promise as rejected, with some error message as value. This effectively commutes the execution path to the rejected branch.
Once the store_char
returns, as the promise is now rejected, the report_error
closure is invoked and promise was rejected with error 'Value too big'
is printed. The done
closure is then invoked and prints operation done with state 'rejected'
.
Similarly, if instead the promise had been rejected as:
uel_promise_reject(p1, (void *)"Asynchronous operation failed");
Segment 1 would be ignored, report_error
would be invoked and print promise was rejected with error 'Asynchronous operation failed'
and, at segment 3, done
would be invoked and print operation done with state 'rejected'
.
Resettling can also be used for recovering from errors if it commutes back to resolved
state. This constitutes an excellent exception system that allows errors raised in loose asynchronous operations to be rescued consistently. Even exclusively synchronous processes can benefit from this error rescuing system.
As a last note, segments can also resettle a promise as UEL_PROMISE_PENDING
. This halts the synchronous stage immediately, leaving any unprocessed segments in place. This phase can be restarted by either resolving or rejecting the promise again.
Promises can be nested into each other, allowing for complex asynchronous operations to compose a single segment chain. This provides superb flow control for related asynchronous operations that would otherwise produce a lot of spaghetti code.
Whenever a promise segment returns any non-null value, it is cast to a promise pointer. The original promise then transitions back to pending
state and awaits for the returned promise to settle. Once this new promise is settled, the original promise is resumed with whatever state and value the new promise was settled as.
For instance, suppose the programmer is programming an MCU that possesses an ADC with an arbitrarily long buffer and a DMA channel. The program must start the ADC, which will sample N
times and store it in its buffer. After N
samples have been taken, the DMA channel must be instructed to move it out of the ADC buffer into some memory-mapped buffer, where it will be processed.
This could be easily accomplished with signals or callbacks, but would eventually lead to confusing and discontinuous code. With nested promises, however, it is easy to describe the whole process into one single chain.
Suppose this is the implementation for the DMA and ADC modules:
// ADC implementation
static uel_promise_t *adc_promise;
// This ISR is called when the ADC finishes sampling
// the instructed number of samples
static void adc_isr() {
uel_promise_resolve(adc_promise, (void *)ADC_BUFFER);
adc_promise = NULL;
adc_isr_flag = 0;
}
// Launches the ADC and returns. This effectively starts an asynchronous operation.
static void *start_adc(void *context, void *params) {
uintptr_t count = (uintptr_t)context;
uel_promise_t *promise = (uel_promise_t *)params;
ADC_COUNT = count;
ADC_START = 1;
adc_promise = promise;
return NULL;
}
// Public interface function. Returns a promise that,
// when resolved, will contain the address of the buffer where
// data was sampled
uel_promise_t *adc_read(uintptr_t count) {
return uel_promise_create(&store, uel_closure_create(start_adc, (void *)count));
}
// DMA implementation
static uel_promise_ t *dma_promise;
// This ISR is called when the DMA channel has finished
// moving data
static void dma_isr() {
uel_promise_resolve(dma_promise, (void *)DMA_DESTINATION);
dma_promise = NULL;
dma_isr_flag = 0;
}
// Auxiliary structure to hold DMA mapping information
struct dma_mapping {
void *source;
void *destination;
uintptr_t count;
};
// Launches the DMA channel, starting an asynchronous operation
static void *start_dma(void *context, void *params) {
struct dma_mapping *mapping = (struct dma_mapping *)context;
uel_promise_t *promise = (uel_promise_t *)params;
DMA_SOURCE = mapping->source;
DMA_DESTINATION = mapping->destination;
DMA_COUNT = mapping->count;
DMA_START = 1;
adc_promise = promise;
return NULL;
}
// Public interface function.
// Returns a promise that, when resolved, will hold the address
// of the buffer to where data was moved
uel_promise_t *dma_move(void *destination, void *source, uintptr_t count) {
struct dma_mapping mapping = { source, destination, count };
return uel_promise_create(&store, uel_closure_create(start_adc, (void *)&mapping));
}
Implementing the project requirements is this simple:
#define N 10 // Will take 10 samples
static void *move_from_buffer(void *context, void *params) {
uel_promise_t *promise = (uel_promise_t *)params;
void *source = promise->value;
void *destination = context;
return (void *)dma_move(destination, source, N);
}
static void *data_moved(void *context, void *params) {
uel_promise_t *promise = (uel_promise_t *)params;
printf("Data moved to %p\n", promise->value);
return NULL;
}
// ...
uel_promise_t *promise = adc_read(N);
unsigned char destination[N];
uel_promise_then(promise, uel_closure_create(move_from_buffer, (void *)destination));
uel_promise_then(promise, uel_closure_create(data_moved, NULL));
Note that, in the above example, promises are resolved synchronously inside the ISR's. This may be not desirable due to performance reasons, but can be easily improved by enqueueing a closure that resolves nested promises into the event loop.
To destroy a promise, call uel_promise_destroy()
. This will release all segments and then the promise itself. Settling a promise after it has been destroyed is undefined behaviour.
Because settling and destroying promises are so frequent, there are helper functions that emit closures that automate this work:
// When invoked, destroys the promise
uel_closure_t destroyer = uel_promise_destroyer(promise);
// When invoked with some value, resolves the promise with that value
uel_closure_t resolver = uel_promise_resolver(promise);
// When invoked with some value, rejects the promise with that value
uel_closure_t rejecter = uel_promise_rejecter(promise);
Modules are independent units of behaviour, self-contained and self-allocated, with clear lifecycle hooks, interface and dependencies. They enforce separation of concerns and isolation by making clear how your code interacts with the rest of the application.
Modules are absolutely optional and very thin on the library side. They are basically a convention of how to write code in a fashion that works well with µEvLoop.
Modules can serve as a variety of purposes:
- They can act as bridges to static data, such as SFRs;
- They can be object factories, meant to distribute and recycle objects to other modules;
- They can act as services, background processes that interact with other parts of the application in a sattelite-fashion.
// File: my_module.h
#include <uevloop/utils/module.h>
#include <uevloop/system/containers/application.h>
typedef struct my_module my_module_t; // Private implementation
uel_module_t *my_module(uel_application_t *my_app);
// Any functions' signatures to operate the module go here
// File: my_module.c
#include "my_module.h"
struct my_module {
uel_module_t base; // Inherits base interface
// Other properties
};
// The config hook is used to prepare the module.
// When it is fired by the `application`, all modules are guaranteed to be
// registered, but may still be in an inconsistent state.
// Modules are loaded in the order they are supplied, so previous modules will
// already be configured.
static void config(uel_module_t *mod){
// ...
}
// The launch hook is where the module should effectively start its functions.
// All modules here are guaranteed to be registered and properly configurated.
static void launch(uel_module_t *mod){
// ...
}
// The constructor function is the only mandatory symbol to be exported.
// This function should initialise all independent or readly available data.
// It must not be called more than once per module.
uel_module_t *my_module(uel_application_t *my_app){
static my_module_t module;
uel_module_init(&module.base, config, launch, my_app);
// Initialise other module's properties
return &module.base;
}
Modules are operated by the application
component. It is responsible for loading, configuring and launching each module.
// File: my_app_modules.h
#include "my_module.h"
// Each module must be identified by a positive integer, zero-indexed.
// Storing module IDs in an enum makes it easy to add new modules in the future.
enum MY_APP_MODULES {
MY_MODULE,
// other modules...,
MY_APP_MODULE_COUNT
};
// File: main.c
#include <uevloop/system/containers/application.h>
#include "my_app_modules.h"
// Creates the controlling application
uel_application_t my_app;
uel_app_init(&my_app);
// Declares a list of module pointers to be supplied to the application.
uel_module_t *modules[MY_APP_MODULE_COUNT];
// Individually initialising pointers in the list ensures IDs always
// match their corresponding module, even if they change during development.
modules[MY_MODULE] = my_module(&my_app);
// This loads the modules into the application:
// - The module list is stored as the application registry;
// - Each module's configuration hook is sequentially invoked, according to
// their position in the registry;
// - Each module's launch hook is sequentially invoked, equally ordered by
// the registry.
uel_app_load(&my_app, modules, MY_APP_MODULE_COUNT);
There are two method for injecting a registered module: parametrised injection and ad-hoc injection. Each is adequate for a different situation:
Parametrised dependencies are dependencies that are supplied during module construction.
Given the following module header:
// File: my_greeter.h
#include "my_module.h"
#include <uevloop/utils/module.h>
#include <uevloop/system/containers/application.h>
typedef struct my_greeter my_greeter_t;
uel_module_t *my_greeter(
uel_application_t *my_app,
const char *name, // <-- Parametrised dependency
my_module_t *other_module // <-- Dependencies can be other modules
);
The application loading procedure would be:
// File: my_app_modules.h
enum MY_APP_MODULES {
MY_MODULE,
MY_GREETER,
MY_APP_MODULE_COUNT
};
// File: main.c
uel_module_t *modules[MY_APP_MODULE_COUNT];
modules[MY_MODULE] = my_module(&my_app);
// Injects parametrised dependencies `name` and `other_module`
modules[MY_GREETER] =
my_greeter(&my_app, "King Kong", (my_module_t *)modules[MY_MODULE]);
uel_app_load(&my_app, modules, MY_APP_MODULE_COUNT)
Parametrised injection facilitates reasoning about what each module depends on and in which order they are loaded. This is the preferable way to inject dependencies into modules.
Ad-hoc injections can occur anywhere, including places outside the scope of the application's managed modules.
// ANYWHERE with access to `my_app`:
#include <uevloop/system/containers/application.h>
#include "my_greeter.h"
#include "my_app_modules.h"
my_greeter_t *greeter = (my_greeter_t *)uel_app_require(&my_app, MY_GREETER);
While Ad-hoc injections seem easier, they make more difficult to know on which modules some particular piece of code depends on. Also, because they require the modules to already be loaded into the registry, they cannot be used during the configuration phase.
Iterators are abstractions on arbitrary collections of items. They provide a uniform interface for yielding each element in the iterated collection, disregarding implementation details of such collection.
There are two iterator specialisations shipped with µEvLoop:
#include <uevloop/utils/iterator.h>
#define NUMS_LENGTH 5
uintptr_t nums[NUMS_LENGTH] = { 1, 2, 3, 4, 5 };
uel_iterator_array_t array_iterator =
uel_iterator_array_create(nums, NUMS_LENGTH, sizeof(uintptr_t));
#include <uevloop/utils/iterator.h>
#include <uevloop/utils/linked-list.h>
uel_llist_node_t nodes[] = {
{ (void *)1, NULL },
{ (void *)2, NULL ),
{ (void *)3, NULL }
};
uel_llist_t list;
uel_llist_init(&list);
uel_llist_push_head(&list, &nodes[0]);
uel_llist_push_head(&list, &nodes[1]);
uel_iterator_llist_t llist_iterator = uel_iterator_llist_create(&list);
Iterators live entirely on an embedded function pointer, next
. It is responsible by yielding a pointer to each element in the collection.
// cast to generic iterator
uel_iterator_t *iterator = (uel_iterator_t *)&array_iterator;
// when supplied with `NULL` as parameter to `next`, yields
// the first element in the collection
int *current = NULL;
while(true){
current = (int *)iterator->next(iterator, (void *)current);
if(current != NULL){
// do something
}else break; // when there are no more elements , yields `NULL`
}
Besides manually operating an iterator, there are several iteration helpers that automatise work.
#include <uevloop/utils/closure.h>
void *accumulate(void *context, void *params){
uintptr_t *acc = (uintptr_t *)context;
uintptr_t num = *(uintptr_t *)params;
*acc += num;
return (void *)true; // required; returning false is equivalent to a `break`
}
uintptr_t acc = 0;
uel_closure_t acumulate_into_acc = uel_closure_create(accumulate, (void *)&acc);
uel_iterator_foreach(iterator, &accumulate_into_acc);
// if `iterator` is the same array iterator defined previously,
// acc == 15
There are many more iteration helpers, check the more details on the docs.
Iterators are meant to be expansible. If you need to enumerate your own type, write an iterator specialisation:
struct my_collection_type{
// whatever, could be a binary tree, a hash table etc
};
struct my_collection_iterator{
uel_iterator_t base; // inherits the base interface
// any other state necessary for iteration
};
void *my_collection_next(my_collection_iterator *iterator, void *last){
// implement your iteration logic here.
// remember:
// if last == NULL, yield the first element.
// if last == last element in collection, yield NULL.
}
struct my_collection_type collection; // initialised as needed
struct my_collection_iterator iterator = {
{ (uel_iterator_next_t)&my_collection_next, (void *)&collection },
// initialise any state as necessary
};
Note that base
is required to be the first member in your custom iterator structure. That way, a pointer to your iterator can be safely cast to uel_iterator_t *
forth and back.
Conditionals are functional switches in the form of a tuple of closures <test, if_true, if_false>
.
When applied to some input, this input is submitted to the test
closure. If it returns true
, if_true
closure is invoked, otherwise, if_false
is invoked. All closures take the same input as arguments.
#include <uevloop/utils/conditional.h>
#include <stdio.h>
void *is_divisible(void *context, void *params){
uintptr_t divisor = (uintptr_t)context;
uintptr_t dividend = (uintptr_t)params;
return (void *)(uintptr_t)(dividend % divisor != 0);
}
void *qualify_number(void *context, void *params){
char *parity = (char *)context;
uintptr_t num = (uintptr_t)params;
printf("%d is %s\n", num, parity);
return NULL;
}
uel_closure_t is_even =
uel_closure_create(is_divisible, (void *)2);
uel_closure_t print_even =
uel_closure_create(qualify_number, (void *)"even");
uel_closure_t print_odd =
uel_closure_create(qualify_number, (void *)"odd");
uel_conditional_t numer_parity;
uel_conditional_init(&number_parity, is_even, print_even, print_odd);
uel_conditional_apply(&number_parity, (void *)1);
// prints "1 is odd"
uel_conditional_apply(&number_parity, (void *)2);
// prints "2 is even"
Pipelines are sequences of closures whose outputs are connected to the next closure's input.
When applied to some input, this input is passed along each closure, being transformed along the way. Applying a pipeline returns the value returned by the last closure in it.
#include <uevloop/utils/pipeline.h>
void *exponentiate(void *context, void *params){
uintptr_t power = (uintptr_t)context;
uintptr_t base = (uintptr_t)params;
return (void *)(uintptr_t)pow(base, power);
}
void *add(void *context, void *params){
uintptr_t term1 = (uintptr_t)context;
uintptr_t term2 = (uintptr_t)params;
return (void *)(uintptr_t)(term1 + term2);
}
uel_closure_t square = uel_closure_create(exponentiate, (void *)2);
uel_closure_t increment = uel_closure_create(add, (void *)1);
// creates the math_pipeline, equivalent to the function f(x) = x^2 + 1
UEL_PIPELINE_DECLARE(math, square, increment);
uintptr_t res = (uintptr_t)uel_pipeline_apply(&math_pipeline, (void *)5);
// res == f(5) == (5^2 + 1) == 26
Iterators, conditionals and pipelines are objects associated with synchronous operations.
To make more suitable to asynchronous contexts, there are numerous helpers that can abstract some of their operational details and export them into portable closures.
Please read the docs to find out more about them.
Automatic pools are wrappers objects that enhance the abilities of object pools. They allow constructors and destructors to be attached and, instead of yielding bare pointers, yield uel_autoptr_t
automatic pointers.
Automatic pointers are objects that associate some object to the pool it is from, making it trivial to destroy the object regardless of access to its source. An automatic pointer issued to an object of type T
can be safely cast to T**
. Casting to any other pointer type is undefined behaviour.
Automatic pools are created and initialised in very similar ways to object pools:
#include <uevloop/utils/automatic-pool.h>
#define TUPLE_POOL_SIZE_LOG2N 5 // 32 tuples
struct tuple {
int a;
int b;
};
UEL_DECLARE_AUTOPOOL_BUFFERS(struct tuple, TUPLE_POOL_SIZE_LOG2N, tuple);
uel_autopool_t tuple_pool;
uel_autopool_init(
&tuple_pool,
TUPLE_POOL_SIZE_LOG2N,
sizeof(struct tuple),
UEL_AUTOPOOL_BUFFERS(tuple)
);
After an automatic pool has been created, it can allocate and deallocate objects, just like object pools.
struct tuple **t = (struct tuple **)uel_autopool_alloc(&tuple_pool);
if(t) {
(**t).a = 1;
(**t).b = 10;
uel_autoptr_dealloc((uel_autoptr_t)t);
}
It is possible to attach a constructor and a destructor to some automatic pool. This are closures that will be invoked upon object allocation and deallocation, taking a bare pointer to the object being operated on.
static void *default_tuple(void *context, void *params) {
struct tuple *value = (struct tuple *)context;
struct tuple *t = (struct tuple *)params;
*t = *value;
return NULL;
}
static void *zero_tuple(void *context, void *params) {
struct tuple *t = (struct tuple *)params;
t->a = 0;
t->b = 0;
return NULL;
}
// ...
static struct tuple default_value = { 10, 100 };
uel_autopool_set_contructor(
&tuple_pool,
uel_closure_create(default_tuple, (void *)&default_value)
);
uel_autopool_set_destructor(
&tuple_pool,
uel_closure_create(zero_tuple, NULL)
);
struct tuple **t1 = (struct tuple **)uel_autopool_alloc(&tuple_pool);
// t1.a == 10, t1.b == 100
uel_autoptr_dealloc((uel_autoptr_t)t1);
// before t1 is dealloc'ed t1.a == 0, t1.b == 0
µEvLoop is meant to run baremetal, primarily in simple single-core MCUs. That said, nothing stops it from being employed as a side library in RTOSes or in full-fledged x86_64 multi-threaded desktop applications.
Communication between asynchronous contexts, such as ISRs and side threads, is done through some shared data structures defined inside the library's core components. As whenever dealing with non-atomic shared memory, there must be synchronisation between accesses to these structures as to avoid memory corruption.
µEvLoop does not try to implement a universal locking scheme fit for any device. Instead, some generic critical section definition is provided.
By default, critical sections in µEvLoop are a no-op. They are provided as a set of macros that can be overriden by the programmer to implement platform specific behaviour.
For instance, while running baremetal it may be only necessary to disable interrupts to make sure accesses are synchronised. On a RTOS multi-threaded environment, on the other hand, it may be necessary to use a mutex.
There are three macros that define critical section implementation:
UEL_CRITICAL_SECTION_OBJ_TYPE
If needed, a global critical section object can be declared. If this macro is defined, this object will be available to any critical section under the symbol uel_critical_section
.
The UEL_CRITICAL_SECTION_OBJ_TYPE
macro defines the type of the object. It is the programmer's responsibility to declare, globally allocate and initialise the object.
UEL_CRITICAL_ENTER
Enters a new critical section. From this point until the critical section exits, no other thread or ISR may attempt to access the system's shared memory.
UEL_CRITICAL_EXIT
Exits the current critical section. After this is called, any shared memory is allowed to be claimed by some party.
I often work with small MCUs (8-16bits) that simply don't have the necessary power to run a RTOS or any fancy scheduling solution. Right now I am working on a new commercial project and felt the need to build something by my own. µEvLoop is my bet on how a modern, interrupt-driven and predictable embedded application should be. I am also looking for a new job and needed to improve my portfolio.
- Better error handling
- Logging helpers
- Macro shortcuts for frequently used functions
- More lifecycle hooks for modules
- Application teardown/exit
- Implement application signals