-
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, will 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 which points to 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 which 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.
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.
So, it 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()
{
}
We use lambda expressions to have the compilier generating small functions which will be attached to the pin interrupt. Those functions will then invoke onPinChange(int pinNr)
and pass the pin number to it.
Looking at the code above naturally leads to question "couldn't we do all these attachInterrupt calls in a loop?" Sure we can:
Note that the following code needs attachInterrupt to have the new interface, which it currently (2023-04-07) does not.
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);
}
and returns the address of it.
Using the traditional method of attaching callbacks this task used to be involved and ugly. With the new interface it gets very simple. Assume we want to do a frequency generator which generates a simple square signal on a pin.
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. The compiler generates somthing equivalent to
void someFunction()
{
FrequencyGenerator* f = ... //address of the object on which begin was called
f->onTimer(); // calls
}
and attaches it to the IntervalTimer. Whenever the callback is invoked it calls the onTimer()
function of the captured address.
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 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 often.
// 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.