ISR Contexts in Embedded C and C++ — Selecting the correct function at compile time

Interrupt handling is an important part of embedded systems development: it allows separating application logic from peripheral interfacing, while removing the need for polling and allowing real-time operations of peripherals.

With interrupt handling in mind, the library may add functionality that requires different implementation while inside an interrupt context. Alternatively, a library function may require checks and calls when running outside an interrupt.

The rule of thumb for interrupt handling is the faster, the better. This usually rules out automatic context detection at runtime. So, library writers generate two versions of the same function, optimizing for the context where they’re executed.

One example of an API that extensively uses of this technique is FreeRTOS’s API. We can take its Timer API as an example, which provides many pairs of these functions:

  • xTimerStart / xTimerStartFromISR
  • xTimerStop / xTimerStopFromISR
  • xTimerChangePeriod / xTimerChangePeriodFromISR
  • xTimerReset / xTimerResetFromISR
  • xTimerPendFunctionCall / xTimerPendFunctionCallFromISR

But this approach has some issues:

  • A user depends on an IDE or documentation to know if a FromISR function even exists
  • If a free function does not have the FromISR suffix, does it mean it’s interrupt-safe or not?
    • Do we really expect a programmer to access documentation to verify if a call is valid in a specific context?

It’s 2021, we can do better than that. In this article, we’ll explore 4 ways of improving an interface like that with C++.

But wait, there’s more: for those who won’t/can’t use C++, we’ll also see 2 ways of doing this with C!

🔗Our example: Wrapping a queue interface written in C

Instead of creating a new API for every technique, we’ll wrap this fictional C API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Our handle type.
typedef struct queue_handle_t /* {...} */ queue_handle_t;

// Creation and Deletion
// DO NOT USE INSIDE INTERRUPT!
queue_handle_t Queue_create();
void Queue_delete(queue_handle_t* handle);

// Fine to use anywhere
bool Queue_empty(const queue_handle_t* handle);
bool Queue_full(const queue_handle_t* handle);

// Regular context
bool Queue_push(queue_handle_t* handle, int data);
bool Queue_pop(queue_handle_t* handle, int* data);

// ISR Context
bool Queue_push_ISR(queue_handle_t* handle, int data);
bool Queue_pop_ISR(queue_handle_t* handle, int* data);

It has:

  • 2 functions that can be used anywhere
  • 2 functions that need the user to specify context
  • 2 functions that should not be called inside an ISR

We have here all the issues described in the introduction. So let’s also have a toy example, to try and replicate for each technique:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
queue_handle_t* global_queue;

// In C, we need to initialize our handle somewhere.
int main(){
queue_handle_t handle = Queue_create();
global_queue = &handle;
// Do stuff
}

// An interrupt function
// (assume ISR as a macro for the attribute that makes this
// an interrupt function in your platform)
ISR void isr() {
if(Queue_empty(global_queue))
Queue_push(global_queue, 1);
}

// A function to be called during regular context
void not_isr() {
if(!Queue_full(global_queue))
Queue_push(global_queue, 0);
}

A reader with a keen eye may have noticed we already forgot to call the correct push function inside an ISR. Other readers may have missed it, like I did, and like a code reviewer can. And that’s the problem with this type of interface.

“Make interfaces easy to use correctly and hard to use incorrectly”

— Scott Meyers

The master has spoken, so let’s see what we can do!

🔗C++: RAII guards

The idea for this entire article started from this technique, which I was experimenting earlier, and had promising results. Even though it ended up not being great, it’s still cool to see what a compiler can do for us. So it’s a perfect first solution to build upon.

Let’s start with how it looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// A shared instance of the queue
MyQueue global_queue;

// Our interrupt function
ISR void isr() noexcept {
context::isr isr_context;
if(global_queue.empty())
global_queue.push(1);
}

// Our function to be called during regular context
void not_isr() noexcept {
context::regular regular_context;
if(!global_queue.full())
global_queue.push(0);
}

// Our push method
bool MyQueue::push(int data){
// Check if it's an ISR context, then call the correct function
if(context::is_isr()) {
return Queue_push_ISR(&handle, data);
} else {
return Queue_push(&handle, data);
}
}

We presented a few constructs here:

  • context::is_isr() defines whether we’re inside an ISR
  • context::isr and context::regular RAII guards, that are not referenced anywhere else

We can conclude there must be something hidden. And there is: a global boolean variable, which stores the magic behind the context detection logic.

The context logic is fairly simple, and this would be the entire library component we’d need:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace context {

namespace detail {
// The backbone of the entire operation: a global variable!
// Intentionally not volatile
inline bool is_isr = false;
}

// A function to "hide" our is_isr
bool is_isr() {
return detail::is_isr;
}

// A context RAII guard that sets is_isr to true, then returns to previous value
struct isr {
bool old_isr;
isr() : old_isr{detail::is_isr} { detail::is_isr = true; }
~isr() { detail::is_isr = old_isr; }
};

// A context RAII guard that sets is_isr to false, then returns to previous value
struct regular {
bool old_isr;
regular() : old_isr{detail::is_isr} { detail::is_isr = false; }
~regular() { detail::is_isr = old_isr; }
};

}

“But Joel, your titled said compile time, that’s runtime!”

Indeed, it is. But if your compiler can guarantee all functions called within that context do not change is_isr, it can infer that constructing and destroying both isr_context and regular_context have no side-effects. And then, they’re optimized away¹. For instance, this simplified example does everything we expected from it, outputting an assembly with zero overhead.

¹ That’s the same reason why we need volatile or atomic for variables being used in both contexts. But here, we’re using it to our advantage!

Defining the rest of the Queue wrapper class

Our wrapper class’ interface is transparent to context:

1
2
3
4
5
6
7
8
9
10
11
class MyQueue {
queue_handle_t handle;
public:
MyQueue(); // We call create here
~MyQueue(); // and destroy here

bool push(int data);
std::optional<int> pop();
bool empty() const;
bool full() const;
};

Implementation of all other functions is left as an exercise to the reader.

Pros

  • Easy to use
  • Only define the context logic once, use everywhere
  • Only instantiate once on the top-level function of the context, automatically detect inside the functions
  • Zero overhead is achievable

Cons

  • Works without specifying any context (easy to use incorrectly!)
  • No way to block, at compile time, calls to Queue_create and Queue_delete in the wrong context
    • At runtime, it would need exception throwing or std::optional factories. Not great, either way.
  • Very hard to reach zero overhead, needing LTO or unity builds (ew), or jumping through many hoops to make the compiler understand there are no side-effects.

Verdict: It’s a fun showing of the power of the compiler, but it’s too error-prone, and depends on very strong optimization techniques. We can do better, and we will.

🔗C++: Context wrapper classes

This strategy is based on getting a handler from a specific context type. Our sample code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// A shared instance of the queue
MyQueue global_queue;

// Our interrupt function
ISR void isr() noexcept {
auto queue = global_queue.isr_context();
if(queue.empty())
queue.push(1);
}

// Our function to be called during regular context
void not_isr() noexcept {
auto queue = global_queue.regular_context();
if(!queue.full())
queue.push(0);
}

What determines our context are the member functions isr_context and regular_context. We cannot call a function on global_queue without a context wrapper/handler, forcing the user to, at least, think of which context they want to use.

The interface of this implementation is given by:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct MyQueue {
private:
queue_handle_t handle;

// We define the common interface, for ISR and regular contexts
// The other interfaces will inherit from this,
struct queue_common {
protected:
// We hold a non-owning reference back to the queue
MyQueue& queue;
// We need to construct this, as it's not an aggregate type
queue_common(MyQueue& q) : queue{q} {}
// We guarantee nobody ever holds an instance of this, except derived classes
~queue_common() = default;
public:
bool empty();
bool full();
};

// We implement the ISR interface
struct queue_isr : queue_common {
queue_isr(MyQueue& q) : queue_common{q} {}

bool push(int data);
std::optional<int> pop();
};

// The Regular interface is the same, but can add stuff that ISR can't invoke
struct queue_regular : queue_common {
queue_regular(MyQueue& q) : queue_common{q} {}

bool push(int data);
std::optional<int> pop();

// This can allow calling Queue_delete outside the destructor
void release();
};
public:

MyQueue();
~MyQueue();

// Where we make our interface public
queue_isr isr_context(){
return {*this};
}

queue_regular regular_context(){
return {*this};
}

};

Pros

  • Easy to use correctly
  • Impossible to use without choosing a context, i.e. harder to use incorrectly
  • Zero overhead (when inlined)
  • We can define functions that only work in certain contexts
  • We can share implementation between contexts

Cons

  • Verbose declaration
  • We can’t invalidate the constructor’s call to Queue_create inside an ISR
  • Strategy needs an object to operate over, i.e. does not allow declaring static functionality.

Verdict: It’s a viable way of implementing interfaces, but it needs some boilerplate for the definition. Declaration is not very clean, though we can improve readability by separating definition from declaration.

🔗C++: Tagged functions

For this technique, we need to call our functions with objects of different types, similar to how we’d use them for tag dispatching. We then have two different approaches:

1. Overload Set, which uses empty classes and passes an instance of them as arguments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
namespace context{
struct any {};
struct isr : any {};
struct regular : any {};
}

class MyQueue {
queue_handle_t handle;
public:
// Used only on regular context
// We make it explicit so people don't call MyQueue({})
explicit MyQueue(context::regular);

// We can't limit this one
~MyQueue();

// Our context-dependent overload sets
bool push(context::isr, int data);
bool push(context::regular, int data);

std::optional<int> pop(context::isr);
std::optional<int> pop(context::regular);

// context::any utilized for consistent
// May be declared without
bool empty(context::any = {}) const;
bool full(context::any = {}) const;
};

// And our usage

MyQueue global_queue(context::regular{});

ISR void isr() noexcept {
context::isr context;
if(global_queue.empty(context))
global_queue.push(context, 1);
}

void not_isr() noexcept {
context::regular context;
if(!global_queue.full(context))
global_queue.push(context, 0);
}

Notice we pass the tags as value, not as references. This slightly helps code generation in the case where the function is not inlined, as we don’t pass an address.

2. Template specializations, using a template parameter as the tag. The parameter can be a value, e.g. an enum:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
enum class context {
any,
isr,
regular
};

class MyQueue {
queue_handle_t handle;

// Our constructor is now private
MyQueue();
public:
// We cannot provide a constructor, but we can provide a tagged function
// ... which calls the constructor
template<context>
static MyQueue create() = delete;

// We still can't limit this one
~MyQueue();

// We declare our functions here
template<context>
bool push(int data) = delete;

template<context>
std::optional<int> pop() = delete;

// context::any utilized for consistency
// Using a default argument means it can be called without one.
// This kind of interface is optional.
template<context = context::any>
bool empty() const;

template<context = context::any>
bool full() const;
};

// Our template specializations are required to be declared outside the class

template<>
MyQueue MyQueue::create<context::regular>();

template<>
bool MyQueue::push<context::isr>(int data);

template<>
bool MyQueue::push<context::regular>(int data);

template<>
std::optional<int> MyQueue::pop<context::isr>();

template<>
std::optional<int> MyQueue::pop<context::regular>();

// And our usage

MyQueue global_queue = MyQueue::create<context::regular>();

ISR void isr() noexcept {
constexpr auto context = context::isr;
if(global_queue.empty<context>())
global_queue.push<context>(1);
}

void not_isr() noexcept {
constexpr auto context = context::regular;
if(!global_queue.full<context>())
global_queue.push<context>(0);
}

Although more restrictive and verbose, this technique does not use a register for the tag argument, which may be relevant when not inlining these functions. In all other aspects, it’s either the same or worse than the previous solution.

Pros

  • A context-specific handle is always required to call functions
  • We can constrain the construction to certain contexts
  • Zero overhead (when the overload set is inlined, or when using the template alternative)
  • No need for a release member function, as the object can only be constructed in a regular context

Cons

  • All calls must be individually tagged
    • Including the functions that do not depend on context, for interface consistency
  • Template version is verbose and does not allow calling the constructor with tags

Verdict: Both are practical ways of solving the problem, though the overload set solution is definitely cleaner. Having to tag each call makes the interface less clean than it needs to be, which may or may not be a good thing.

(Thanks to Austin Morton for introducing this technique to the discussion on the CppLang Slack)

🔗C++: Namespaces

This technique is fairly straight-forward, as it’s very similar to our C code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
MyQueue global_queue;

ISR void isr() noexcept {
// We import the functions from our interrupt context namespace
using namespace context::isr;

// We then use the correctly imported functions
if(empty(global_queue))
push(global_queue, 1);
}

void not_isr() noexcept {
// We import the functions from our regular context namespace
using namespace context::regular;

// Then use the correctly imported ones
if(!full(global_queue))
push(global_queue, 0);
}

int main(){
using namespace context::regular;

// We need to manually initialize our queue
create(global_queue);
// Do stuff, then destroy, manually
destroy(global_queue);
}

And the declaration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// A dummy wrapper, for this application
struct MyQueue {
queue_handle_t handle;
};

// Common functions to all contexts, to be imported with the other contexts
namespace context::detail::common {

bool empty(MyQueue& queue);
bool full(MyQueue& queue);

}

// The ISR context

namespace context::isr {

// We import our global functions
using namespace context::detail::common;

// Our specific implementations
bool push(MyQueue& queue, int data);
std::optional<int> pop(MyQueue& queue);

}

// The Regular context

namespace context::regular {

// We import our global functions
using namespace context::detail::common;

// Our specific implementations
bool push(MyQueue& queue, int data);
std::optional<int> pop(MyQueue& queue);

// Our functions only available in this context
void create(MyQueue& queue);
void destroy(MyQueue& queue);

}

As you may see, we’re basically doing what the original C interface does, but the name of the functions are defined by their namespaces instead of suffixes. The advantage this presents over the original C implementation is that we can just define using namespace context::<context>; inside our function, and never worry about which function is interrupt-safe or not, or which one requires a FromISR suffix.

Pros

  • Easy to use: define the context once per function, every call is done correctly
  • Zero overhead (requires inlining only if a wrapper)

Cons

  • Can only use free functions (we have no UFCS, or extension methods)
  • Our class is either an aggregate, or we require to jump through hoops to make our class constructible in an specific context. If we make the class an aggregate, we have the same complications for destruction.
    • These solutions are not idiomatic C++, and still resemble C APIs

Verdict: Usable technique, though the complications of making it look like a C interface may be a turn-off for C++ programmers that prefer encapsulated interfaces.

🔗C: Tagged Macros

Well, I promised we can also do this in C. So, let’s see how! Starting from the usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct isr_context{} isr_context;
typedef struct regular_context{} regular_context;

int main(){
regular_context context;
queue_handle_t handle = Queue_create(context);
global_queue = &handle;
// And stuff...
}

ISR void isr() {
isr_context context;
if(Queue_empty(context, global_queue))
Queue_push(context, global_queue, 1);
}

void not_isr() {
regular_context context;
if(!Queue_full(context, global_queue))
Queue_push(context, global_queue, 0);
}

Pretty neat for C, huh? An overload set with the same name as the original function, just like the C++ solution!

How to implement this solution

The first thing we need to know to implement this is the _Generic selector from C11. It allows implementing overload sets using macros, e.g. the math functions in <tgmath.h>:

1
2
3
4
5
#define cbrt(X) _Generic((X),           \
long double: cbrtl, \
default: cbrt, \
float: cbrtf \
)(X)

In this example, we dispatch, at compile time, the function that will be called, given the argument for our cbrt macro. (Example from the Generic Selection page on cppreference.com)

One thing that we don’t usually consider with generic selection, though, is that argument passing is part of the macro, not the _Generic syntax! So we can just implement our functions like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Context-dependent dispatching
#define Queue_push(tag, handle, data) \
_Generic((tag), \
isr_context: Queue_push_ISR, \
regular_context: Queue_push \
)(handle, data)

// Single-context function: fails compilation if in wrong context
#define Queue_create(tag) \
_Generic((tag), \
regular_context: Queue_create \
)()

// Context-independent function: just an alias to the function
// Or, to force correctness, may use a redundant _Generic implementation
#define Queue_empty(tag, handle) Queue_empty(handle)

(As before, implementing the remaining functions should be trivial, and is left as an exercise)

Pros

  • Single function name, so it’s easy to use correctly and hard to use incorrectly
  • Macros hide the original interface (Note: they need to be declared after the functions, and cannot exist before the definition)
    • Trying to call without a tag yields a compilation error, which is what we wanted!
  • Zero overhead, even without optimizations!

Cons

  • Available only on C11 or later
  • Errors being behind preprocessor magic can make it hard to understand/debug for some users
  • All calls must be individually tagged

Verdict: This technique is very similar to C++’s tagged functions, and has similar pros and cons. But, since it’s in C, it’s one of the better interface improvements we can get, with a superior interface, when compared to the initial one. If C11 is available in your compiler, it may be a good choice to try it out.

🔗C: Tagged macros with wrappers

The previous solution was a vast improvement on the interface for C. The question is: can we do better? I can’t assure we can, but we definitely can do differently:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct isr_context{ queue_handle_t* handle; } isr_context;
typedef struct regular_context{ queue_handle_t* handle; } regular_context;

queue_handle_t* global_queue;

int main(){
regular_context context;
queue_handle_t handle = Queue_create(context);
global_queue = &handle;
// Do stuff
}

ISR void isr() {
isr_context queue = {global_queue};
if(Queue_empty(queue))
Queue_push(queue, 1);
}

void not_isr() {
regular_context queue = {global_queue};
if(!Queue_full(queue))
Queue_push(queue, 0);
}

We now removed the need for a separate tag! We only need to specify it once, and use it normally. The definition is basically the same as the previous one, just changing the parameter passing:

1
2
3
4
5
6
7
8
9
10
11
12
#define Queue_push(tag, data)               \
_Generic((tag), \
isr_context: Queue_push_ISR, \
regular_context: Queue_push \
)(tag.handle, data)

#define Queue_create(tag) \
_Generic((tag), \
regular_context: Queue_create \
)()

#define Queue_empty(tag) Queue_empty(tag.handle)

Pros

  • Easy to use: instantiate the wrapper once, use it in every function call
  • Cannot use without a wrapper, i.e. hard to use incorrectly
  • Zero overhead, in optimized builds. On unoptimized builds, it constructs a small struct holding a pointer.

Cons

  • The same macro magic and C11 availability issues from the previous solution
  • Utilizing the tag type for both wrapping a handle or passing an empty context tag may be confusing
    • e.g. A user may ask “Is Queue_create supposed to return a handle, or to put the handle inside my context variable?”

Verdict: This technique seems very similar to the C++ Namespaces solution, with a touch of the wrapper class technique. This technique is fairly clean, and the cons sound like nitpicking, when compared to the original interface. If I still wrote pure C interfaces, I’d choose this implementation.

🔗Conclusion

We saw a few techniques for solving an embedded systems development problem: how to make a context-dependent API more fool-proof?

We cannot make any of these interfaces impossible to use incorrectly, but instantiating the wrong context makes it much easier to detect a mistake.

About which solution is the best, there is none. Most of the techniques presented are viable choices for their respective languages. (RAII guards can burn in hell)

At the end, it’s a tradeoff, mostly based on complexity of implementation and how clean is the interface. Sometimes, it’s better to just follow the KISS principle and choose the simplest one. Some other times, it’s better to try a more robust approach to give a more readable interface to the user. As I’ve said, it’s a tradeoff.

And, if there is a tradeoff, there is space for a comparison table!

Feature RAII guard Wrapper class Tagged overload set Tagged template Namespaces _Generic macro _Generic wrapper
Forces a choice in each context ✔️ ✔️* ✔️* ✔️ ✔️* ✔️
Prevents circumvention ✔️ ✔️* ✔️* ✔️* ✔️* ✔️
Usage with free functions ✔️ ✔️¹ ✔️ ✔️ ✔️ ✔️ ✔️
Usage with member functions ✔️ ✔️ ✔️ ✔️ DNA DNA
Removes repetition from calls ✔️ ✔️ ✔️ ✔️
No additional symbols generated ➖/✔️² ✔️
Can limit construction scope ✔️³ ✔️ DNA DNA
Runtime overhead removal complex trivial trivial trivial trivial/nonexistent** nonexistent trivial

Legend:

  • ✔️ means the feature is present
  • ❌ means the feature is not present
  • ➖ means the compiler needs to completely inline the functions to remove the overhead
    • Which shouldn’t be a problem in most cases. Always compile with optimizations on, even on debug mode! (GCC’s -Og is a thing)
  • * means that handles need to be encapsulated, in order to prevent the user from calling a function directly
  • ❔ means… it’s complicated. We can force the use of our syntax, but it’s too error-prone
  • Runtime overhead removal, i.e. how much does the compiler need to know to
    • complex: the compiler needs a lot of information to entirely remove overhead, and may require link-time optimization (LTO)
    • trivial: simply inlining the function removes all runtime overhead that would occur
    • nonexistent: when the decision is made by a macro, the compiled code is no different than manually putting the correct code
  • ¹ With hidden friends
  • ² There’s only overhead (without optimizations) if we’re wrapping other functions.
  • ³ At runtime, with exceptions (not recommended)
  • ⁉ means we need to use non-idiomatic ways of constructing (and/or destructing) the classes
  • DNA (Does Not Apply): C does not have member functions, constructors and destructors

In order to not overcomplicate this article, I’ve intentionally left out some other issues, like qualifiers and noexcept correctness, combination of some of these techniques, or problems we’d get by taking function addresses or interfacing with a function-like macro.

If you reached this far, thank you for reading! If I’m wrong, I’ll be glad to learn something new from it! You can find me on twitter, on the CppLang Slack, and you can also open an issue on this blog’s repository!