-
Notifications
You must be signed in to change notification settings - Fork 5
Using the new callback interface
- Introduction
- Why bother?
-
FAQ and Examples
- I don't need that stuff, I want to do it as I'm used to
- Do I really need to write a callback function to do something simple as toggling a pin?
- I need to write a lot of identical callback functions. Can this be done smarter?
- How can I use the New Interface to Embedd an IntervalTimer in my Class
- What else can I attach as callback?
Teensyduino, starting with version 1.59, might replace the traditional function pointer interface for attaching callbacks with a more modern interface using the inplace_function
facility.
inplace_function
is a version of the std::function type designed to work without dynamic memory allocation. It allows the user to create a function object that can store pretty much any callable object in a statically allocated buffer, making it particularly suitable for embedded systems.
In the Arduino ecosystem, callbacks are typically attached to providers (e.g. IntervalTimer) by passing the address of the callback using simple void(*)(void) pointers. However, this can be limiting when the callback requires state or when the providing class needs to be embedded into another class. To address this issue, a common approach is to pass an additional pointer to the callback. This parameter points to the required additional information. While this method can solve the problems, it can be challenging to understand for users who are not familiar with low-level programming.
Modern c++ approaches this well known issue by not requiring a simple pointer to the callback, but to accept a much broader range of callable objects. This includes
- Traditional callbacks, i.e. pointers to void functions
- Functors as callback objects
- Static and non static member functions
- Lambda expressions
You find more basic information here Fun with modern cpp - Callbacks and here TeensyTimerTool-Callbacks.
The following chapters show some use cases and worked out examples.
Of course, the new interface accepts exactly the same pointers to callback functions as the traditional interface did. I.e., the following code will work as usual:
IntervalTimer t;
void onTimer() // typical callback
{
digitalToggleFast(LED_BUILTIN);
}
void setup()
{
pinMode(LED_BUILTIN, OUTPUT);
t.begin(onTimer, 500'000); // pass the address of the callback to the provider, invoke callback every 500ms
}
void loop()
{
}
No, these days compilers are smart enough to do the work for you. Here an example
IntervalTimer t;
void setup()
{
pinMode(LED_BUILTIN, OUTPUT);
t.begin([] { digitalToggle(LED_BUILTIN); }, 500'000);
}
void loop()
{
}
The expression [] { digitalToggle(LED_BUILTIN); }
is a so called lambda expression. It tells the compiler: "Please generate a function with the body given between the braces. The function shall not take parameters and shall return void. I don't need a name for this function, but please return a pointer to it."
Thus, the shown lambda will generate the same function as we wrote manually in the example above. Since the lambda returns a pointer to the generated function, it can be directly placed in the begin function of the IntervalTimer.
Lets assume you need to handle pin-interrupts for a lot of pins in a similar way. Instead of writing a dedicated callback for each and every pin, you might prefer to have one callback handling all of them.
void onPinChange(int pinNr)
{
Serial.printf("Pin %d changed\n", pinNr);
}
void setup()
{
attachInterrupt(0, [] { onPinChange(0); }, FALLING);
attachInterrupt(1, [] { onPinChange(1); }, FALLING);
attachInterrupt(2, [] { onPinChange(2); }, FALLING);
attachInterrupt(3, [] { onPinChange(3); }, FALLING);
attachInterrupt(4, [] { onPinChange(4); }, FALLING);
attachInterrupt(5, [] { onPinChange(5); }, FALLING);
attachInterrupt(6, [] { onPinChange(6); }, FALLING);
attachInterrupt(7, [] { onPinChange(7); }, FALLING);
}
void loop()
{
}
Here we use lambda expressions to have the compilier generating small relay functions which will be attached to the pin interrupt. Those functions will then invoke onPinChange(int pinNr)
and pass the pin number to it. Opposed to function pointers the content of a lambda expression is fully visible for the compiler which allows for better optimizations.
Note that the following code needs attachInterrupt to have the new interface, which it currently (2023-04-07) does not.
Looking at the code above naturally leads to the question "couldn't we do all these attachInterrupt calls in a loop?" Sure we can:
void onPinChange(int pinNr)
{
Serial.printf("Pin %d changed\n", pinNr);
}
void setup()
{
for (int pin = 0; pin < 8; pin++)
{
attachInterruptEx(pin,[pin]{onPinChange(pin);},FALLING);
}
}
void loop()
{
}
Please note that the lambda expression now has the variable pin
between the square brackets. The variables listed between those square brackets are captured. I.e., the compiler generated function will contain a variable pin
, which is preset to the value it had when the compiler evaluated the lambda expression. E.g., for the third iteration, the compiler will translate the lambda expression into something equivalent to
void someUnknownName()
{
int pin = 3;
onPinChange(pin);
}
Using the traditional method of attaching void(*)(void) function pointers, this task quickly gets messy and ugly. With the new interface, however, embedding is easy to accomplish. Let's say we want to make a frequency generator that generates a simple square wave on a pin. For easy use, the generating IntervalTimer should be embedded in the class.
class FrequencyGenerator
{
public:
void begin(uint8_t _pin, float frequency)
{
unsigned period = 1E6f / frequency / 2.0f;
pin = _pin;
pinMode(pin, OUTPUT);
t.begin([this] {this->onTimer(); }, period);
}
protected:
void onTimer() // non static callback, has access to class members
{
digitalToggleFast(pin);
}
IntervalTimer t;
uint8_t pin;
};
//--------------------------------------------------------
// User code:
FrequencyGenerator fgA, fgB;
void setup()
{
fgA.begin(0, 10'000);
fgB.begin(1, 50'000);
}
void loop()
{
}
The example generates a 10kHz signal on pin0 and a 50kHz signal on pin1;
Again, we use a lambda expression to generate the actual callback. This time we capture the this
pointer, i.e., the address of the actual object on which we called begin. Therefore, the compiler generates somthing equivalent to
void someFunction()
{
FrequencyGenerator* f = ... //address of the object on which begin was called
f->onTimer(); // call the non static onTimer function on this object.
}
and attaches it to the IntervalTimer. Whenever the callback is invoked it calls the onTimer()
function on the correct object.
Functors are classes with an overridden function call operator. Before lambda expressions where available, functors where often used to generate functions with state. Let's have a look at an example on how to use functors in the context of the new callback system.
// Functor, i.e. class with overridden operator()
// Generates pulses with configurable output pin and duration
class PulseGenerator
{
public:
PulseGenerator(uint8_t _pin, unsigned _duration)
{
pin = _pin;
duration = _duration;
pinMode(pin, OUTPUT);
}
void operator()(void) // this will be called by our timer
{
digitalWriteFast(pin, HIGH);
delayMicroseconds(duration); // for the sake of simplicity, avoid in real code
digitalWriteFast(pin, LOW);
}
protected:
uint8_t pin;
unsigned duration;
};
//--------------------------------------------------------------
// User code
PulseGenerator g1(0, 100); // generates 100µs pulses on pin 0;
IntervalTimer timer;
void setup()
{
timer.begin(generator, 10'000); //we can directly attach the functor, timer will call its operator()
}
void loop()
{
}
The IntervalTimer directly accepts functors and uses its operator() as callback. The example generates a 100µs pulse on pin 0 every 10ms.
The interface also accepts static and nonstatic member functions. Static member functions can be used in the same way as free functions as shown in the first example.
The syntax for non static member functions is a bit arkward but it works.
class TestClass
{
public:
TestClass(const char* _id)
{
strlcpy(id, _id, 8);
}
void doSomething()
{
Serial.print(id);
Serial.println(" called");
}
protected:
char id[8];
};
//--------------------------------------------------------------
// User code
TestClass m1("first"), m2("second");
IntervalTimer t1, t2;
void setup()
{
t1.begin(std::bind(&TestClass::doSomething, m1), 100'000); // calls the doSomething member of m1 every 100ms
t2.begin(std::bind(&TestClass::doSomething, m2), 250'000); // calls the doSomething member of m1 every 250ms
}
void loop()
{
}
However, since using a lambda expression is much simpler one doesn't see this syntax very often. Here an example using lambdas for the same thing:
// same test class as in the last example....
//--------------------------------------------------------------
// User code
TestClass m1("first"), m2("second");
IntervalTimer t1, t2;
void setup()
{
t1.begin([] { m1.doSomething(); }, 100'000); // calls the doSomething member of m1 every 100ms
t2.begin([] { m2.doSomething(); }, 250'000); // calls the doSomething member of m1 every 250ms
}
void loop()
{
}
To be continued ....
Teensy is a PJRC trademark. Notes here are for reference and will typically refer to the ARM variants unless noted.