Schlanke Embeded-Entwicklung mit Small C++

Seite 2: Templates

Inhaltsverzeichnis

Allerdings sind über ein Kilobyte, nur um eine LED regelmäßig blinken zu lassen, sowieso ziemlich viel. Ein zweiter Blick auf eines der Mapfiles verdeutlicht, dass eine ganze Reihe von Gleitkommafunktionen dazu gelinkt werden. Die Assembler-Listings zeigen, dass die Gleitkomma-Operationen benötigt werden, um Millisekunden in interne Ticks umzurechnen.

Ohne die Berechnungen wäre das Programm nur etwa 250 Byte groß, jedoch die Zeiten direkt in Ticks anzugeben, ist nicht nur unhandlich, sondern auch nicht portabel zwischen verschieden schnell getakteten AVRs. Aber da die Taktfrequenz des Zielprozessors beim Kompilieren bekannt ist, könnte der Compiler eigentlich die Umrechnungen selbst übernehmen. Und die hier verwendete Funktion _delay_ms der AVR Libc 1.8 weigert sich normalerweise auch zu kompilieren, wenn das übergebene Argument nicht zur Compile-Zeit bekannt ist, und muss erst mit -D__DELAY_BACKWARD_COMPATIBLE__ (oder dem Rückgriff auf eine ältere Version) überredet werden. Das Problem ist, dass in der Programmstruktur des Beispiels die Wartezeit zwar zur Compilezeit bekannt ist, aber an der Stelle, an der _delay_ms() aufgerufen wird, ist der Wert zur Übersetzungszeit nicht verfügbar.

Mit C++ lässt sich das leicht ändern: ledCycle wird einfach eine Template-Funktion, und die Zeiten übergibt man als Template-Argumente. Für die Definition von ledCycle ist nur der Funktionskopf zu ändern:

template <int onMs, int offMs>
void ledCycle()
{
...
}

Und beim Aufruf wird aus ledCycle(200, 200) einfach ledCycle<200, 200>(). Den Lohn der (nicht wirklich großen) Mühe sieht man nach dem Kompilieren: Das Binärprogramm ist nur noch 242 Byte groß. Natürlich lässt sich unter C mit Makros Ähnliches durchführen. ledCycle als Makro sieht dann so aus:

#define ledCycle(onMs, offMs)   \
(ledOn(), \
_delay_ms(onMs), \
ledOff(), \
_delay_ms(offMs))

Der Aufruf erfolgt wie vorher mit dem Ausdruck: ledCycle(200, 200). Allerdings wird das Programm damit größer als die Template-Version in C++: 316 Byte statt 242. Die Analyse zeigt, dass C++ nur die Version ledCycle<200, 500>() inline expandiert, während ledCycle<200, 200>() als externe Funktion instanziiert und dreimal aufgerufen wird. Das Makro in C wird dagegen jedes Mal inline expandiert, was den Code entsprechend aufbläht.

Die instanziierte Funktion ledCycle<200, 500>() braucht immer noch relativ viel Platz, obwohl 200 für onMs ja schon in ledCycle<200, 200>() instanziiert wurde. Das lässt sich leicht ändern: einfach eine Template-Funktion ledHalfCycle() definieren, der mitgegeben wird, ob ein- oder ausgeschaltet werden soll:

template <int ms>
void ledHalfCycle(bool on)
{
if (on) {
ledOn();
} else {
ledOff();
}
_delay_ms(ms);
}

ledCycle wird dadurch trivial:

template <int onMs, int offMs>
void ledCycle()
{
ledHalfCycle<onMs>(true);
ledHalfCycle<offMs>(false);
}

Damit schrumpft die Programmgröße auf akzeptable 228 Byte. Die Variante, ledHalfCycle das Ein/Aus als Template-Parameter zu übergeben, ist übrigens kontraproduktiv, da damit die Anzahl der unterschiedlichen Instanzen wieder steigt.

Doch wie lässt sich der Compiler dazu überreden, bestimmte Werte schon zur Compile-Zeit auszurechnen? Die Funktion _delay_ms aus der AVR Libc verwendet __builtin_avr_delay_cycles, das eine Konstante zur Compile-Zeit als Argument verlangt. Damit wird der Compiler gezwungen, das Argument selbst auszurechnen, statt Code zur Berechnung zu erzeugen. Das ist hier zwar praktisch, löst aber nicht das allgemeine Problem, den Compiler zum Berechnen bestimmter Werte zu überreden. Aber dafür gibt es in C++11 constexpr.

Die Originalimplementierung von _delay_ms ohne __builtin-Version sieht im Wesentlichen wie in folgt aus:

inline void _delay_ms(double __ms)
{
uint16_t __ticks;
double __tmp = ((F_CPU) / 4e3) * __ms;
if (__tmp < 1.0)
__ticks = 1;
else if (__tmp > 65535)
{
// __ticks = requested delay in 1/10 ms
__ticks = (uint16_t) (__ms * 10.0);
while(__ticks)
{
// wait 1/10 ms
_delay_loop_2(((F_CPU) / 4e3) / 10);
__ticks --;
}
return;
}

__ticks = (uint16_t)__tmp;
_delay_loop_2(__ticks);
}

Zuerst werden die Millisekunden in Delay-Ticks umgerechnet und dann je nach Anzahl der Ticks entweder _delay_loop_2 direkt aufgerufen oder, wenn es zu viele Ticks sind, in einer Schleife jeweils eine Zehntelmillisekunde gewartet.

Da die eigentliche Busy-Wait-Loop je Schleifendurchlauf vier CPU-Zyklen benötigt, ein Delay-Tick also vier CPU-Clock-Ticks sind, berechnen sich die Ticks für eine beziehungsweise 0,1 Millisekunden so:

constexpr double _delay_1ms = (F_CPU) / 4e3;
constexpr double _delay_100us = (F_CPU) / 4e4;

Die Exponenzialschreibweise für 4000 beziehungsweise 40.000 wird verwendet, damit die Berechnung im Typ double ausgeführt wird. constexpr sagt dem Compiler, dass die berechneten Werte zur Compile-Zeit verfügbar sein sollen. Um aus den Millisekunden sowohl die Delay-Ticks als auch die Anzahl der Schleifen in einem Rutsch (d. h. mit einer constexpr) zu berechnen, definiert man einfach ein struct, das beide Werte enthält und dessen Konstruktor als constexpr deklariert wird:

struct _delay_ms_ticks
{
constexpr _delay_ms_ticks(double _ms)
: _loops((_delay_1ms * _ms > 65535)
? _ms * 10
: 1)
, _ticks((_delay_1ms * _ms > 65535)
? _delay_100us
: (_delay_1ms * _ms < 1.0)
? 1
: _delay_1ms * _ms)
{}

uint16_t _loops;
uint16_t _ticks;
};

Die Initialisierung von _ticks sieht komplizierter aus, als sie ist. Es ist eine einfach verschachtelte Bedingung. Und da sie im Initialisierungskontext steht, kann man kein if-Statement verwenden, sondern muss auf den dreiwertigen Bedingungsoperator ?: zurückgreifen. Jetzt ist die Definition von delay_ms einfach (und streicht auch gleich den _ aus dem Namen):

inline void
delay_ms(_delay_ms_ticks d)
{
while (d._loops)
{
_delay_loop_2(d._ticks);
--d._loops;
}
}

Das Ergebnis ist wieder genau gleich groß wie die Variante mit der builtin-Funktion. Das heißt, man hat eine allgemeinere Lösung erreicht, ohne das Programm zu vergrößern. Die Funktion delay_ms lässt sich (im Gegensatz zur Library-Funktion _delay_ms) nämlich auch mit Werten aufrufen, die erst zur Laufzeit bekannt sind. Dann wird das Programm natürlich entsprechend größer, weil nun die Gleitkomma-Berechnungen wieder im Programm landen.