Schlanke Embeded-Entwicklung mit Small C++

Für kleine Systeme mit ein paar Kilobyte Speicher für Programm und Daten glauben viele Programmierer immer noch, dass sie C oder gar Assembler einsetzen müssen. Aber auch dort kann C++ seine Stärken ausspielen, und mit den richtigen Programmiertechniken lässt sich der Overhead (gegenüber C) auf null reduzieren.

In Pocket speichern vorlesen Druckansicht 15 Kommentare lesen
Schlanke Embeded-Entwicklung mit Small C++
Lesezeit: 23 Min.
Von
  • Detlef Vollmann
Inhaltsverzeichnis

Bjarne Stroustrup hat Ende 2013 in einem Interview behauptet: "Ich habe noch kein Programm gesehen, das besser in C als in C++ geschrieben werden kann. Ich glaube nicht, dass ein solches Programm existieren kann. Mit 'besser' meine ich kleiner, effizienter und besser wartbar." Da (fast) alle C-Programme auch C++-Programme sind, klingt das nach einer trivialen Aussage. Aber für ganz kleine Systeme, bei denen jedes Byte zählt, wird die Aussage trotzdem manchmal bezweifelt.

Das soll an einem einfachen Beispiel untersucht werden. Das klassische "Hello, World"-Programm eignet sich nicht für Embedded-Systeme, da ein Ausgabegerät fehlt. Stattdessen kommt in der Regel "Blinking Lights" zum Einsatz, das eine LED blinken lässt. Für den Arduino Uno sieht das zum Beispiel wie folgt aus:

#include <avr/io.h> 
#include <util/delay.h>

void init()
{
PORTB &= ~_BV(PB5);
DDRB |= _BV(PB5);
}

inline void ledOn()
{
PORTB |= _BV(PB5);
}

inline void ledOff()
{
PORTB &= ~_BV(PB5);
}

inline void ledCycle(int onMs, int offMs)
{
ledOn();
_delay_ms(onMs);
ledOff();
_delay_ms(offMs);
}

int main()
{
init();
while (1)
{
ledCycle(200, 200);
ledCycle(200, 200);
ledCycle(200, 200);
ledCycle(200, 500);
}
}

Mit avr-gcc kompiliert gibt das eine Programmgröße von 1176 Byte, und avr-g++ generiert ein 1162 Byte großes Binärprogramm. Das heißt, C++ erzeugt in diesem Fall sogar ein kleineres Programm als C.

Um dem Unterschied auf die Spur zu kommen, reicht ein Blick auf das Mapfile (siehe den Exkurs "Dem Compiler auf die Finger schauen"). Im Mapfile der C-Variante findet man die beiden Funktionen ledOn und ledOff mit jeweils 4 Byte. Und ein Blick auf das Assembler-Listing zeigt, dass der C-Compiler tatsächlich Aufrufe für die beiden Funktionen erzeugt, was in diesem Fall mehr Programmtext benötigt als ein Inlining. Daher braucht ledCycle mit dem C-Compiler 0x3e Byte und mit dem C++-Compiler nur 0x38 Byte. Vermutlich ignoriert der C-Compiler inline, wenn er auf Größe optimieren soll, der C++-Compiler jedoch nicht.

Mehr Infos

Exkurs: Dem Compiler auf die Finger schauen

Häufig ist man als Programmierer überrascht, wenn eine kleine Änderung im Quellcode plötzlich ein deutlich größeres Binärprogramm zur Folge hat. Dann heißt es, dem Unterschied auf die Spur zu kommen. Als Beispiel sollen die blinkenden LEDs in den beiden Varianten delay_ms als Inline-Funktion versus delay_ms als getrennt kompilierte Library-Funktion analysiert werden.

Der erste Anlaufpunkt ist das Mapfile, das der Linker auf Wunsch mit -Map=filename erzeugt. Diese Linkermap enthält viele interessante Informationen, die im Augenblick aber nicht interessieren. Relevant wird es an der ersten Stelle, die mit .text anfängt:

.text           0x0000000000000000       0xe4

Hier erfährt man, dass der Programmbereich an der Adresse 0v anfängt und insgesamt 0xe4 (=v228 dezimal) Byte groß ist. Die nächste relevante Zeile ist

.vectors       0x0000000000000000       0x68 .../crtm328p.o

Das sind die Interrupt-Vektoren, die für den ATMEGA328P 0x68 (104) Bytes groß sind (und für diesen Prozessor immer gleich groß sind).

Danach kommt ein Bereich, der mit __trampolines_start anfängt und mit __dtors_end aufhört. Im Beispiel ist der Bereich leer, aber je nach Programm können hier einige "Platzfresser" versteckt sein: Im trampolines-Bereich werden Platzhalter für indirekte und berechnete Sprünge abgelegt, im ctors-Bereich die Initialisierung der globalen Variablen und im dtor-Bereich allfällige Destruktoren globaler Objekte.

Anschließend kommt der Startcode, der main() aufruft: Initialisierung des CPU-Statusregisters und des Stackpointers. Dieser Code ist hier minimal, kann aber deutlich anwachsen, wenn globale Variablen (und ihre Initialisierung) ins Spiel kommen. Danach kommt noch mal das Einsprungziel für Interrupts, und dann fängt der eigene Code an:

.text          0x0000000000000080        0x6 /tmp/cc7AXrVl.o
0x0000000000000080 _Z4initv

Da init() nicht als inline markiert ist, wird die Funktion als solche angelegt (hier gäbe es noch einmal ein paar Byte Einsparungspotenzial). Die Funktion ist 6 Byte groß.

Bis hierher sind die zwei Mapfiles (für diese Analyse) identisch. Danach kommen die relevanten Unterschiede. Das erste Mapfile zeigt drei Funktionen:

.text._Z12ledHalfCycleILi200EEvb
0x0000000000000086 0x20 /tmp/cc7AXrVl.o
0x0000000000000086 _Z12ledHalfCycleILi200EEvb
.text._Z8ledCycleILi200ELi200EEvv
0x00000000000000a6 0xc /tmp/cc7AXrVl.o
0x00000000000000a6 _Z8ledCycleILi200ELi200EEvv
.text.startup 0x00000000000000b2 0x2e /tmp/cc7AXrVl.o
0x00000000000000b2 main

Das heiß, dass nur für void ledHalfCycle<200>(bool on) und void ledCycle<200, 200>() (und natürlich main()) eigene Funktionen generiert werden, alle anderen Funktionen werden inline expandiert. Dabei sind void ledHalfCycle<200>(bool on) 0x20 (32), void ledCycle<200, 200>() 0xc (12) und main() 0x2e (46) Bytes groß. Der entsprechende Teil im zweiten Mapfile zeigt auch diese drei Funktionen und zusätzlich noch delay_ms():

.text          0x0000000000000086    0x18 ../lib/libc++helpers.a(delay1.o)
0x0000000000000086 _Z8delay_ms15_delay_ms_ticks
.text._Z12ledHalfCycleILi200EEvb
0x000000000000009e 0x18 /tmp/ccZ6aoTp.o
0x000000000000009e _Z12ledHalfCycleILi200EEvb
.text._Z8ledCycleILi200ELi200EEvv
0x00000000000000b6 0xc /tmp/ccZ6aoTp.o
0x00000000000000b6 _Z8ledCycleILi200ELi200EEvv
.text.startup 0x00000000000000c2 0x32 /tmp/ccZ6aoTp.o
0x00000000000000c2 main

Hier ist void ledHalfCycle<200>(bool on) mit 0x18 (24) Bytes etwas kleiner, void ledCycle<200, 200>() mit 0xc (12) gleich groß und main() mit 0x32 (50) Bytes etwas größer als in der ersten Variante, dazu kommt die Library-Funktion delay_ms mit 0x18 (24) Bytes.

Zum Schluss des .text-Bereichs kommt noch Abschlusscode, der hier wieder für beide Varianten identisch ist. Jetzt ist klar, welche Funktionen nicht inline und wie groß sie sind, aber um herauszubekommen, warum sie so groß sind, muss man sich den generierten Code ansehen. Dazu gibt es das Programm objdump aus dem binutils-Paket, mit dem viele unterschiedliche Informationen aus dem ELF-Binärfile lesbar extrahiert werden. Hier ist vor allem der Assemblercode inter ssant, der durch die entsprechenden Zeilen aus dem C++-Quellcode angereichert wird. Der Befehl avr-objdump -S blinken-lights5.elf gibt das Assembler-Listing auf Standard-Output aus. Leider schießt das Einflechten des C++-Quellcodes meist über das Ziel hinaus und zeigt irreführende Kontextzeilen an.

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.

Ein Versuch, weitere Byte dadurch einzusparen, dass delay_ms nicht inline definiert wird, sondern extern aus einer Library gelinkt wird, ist dagegen kontraproduktiv. Warum eigentlich? Die Antwort darauf liefert ein Vergleich der Assembler-Listings der beiden Varianten (siehe Exkurs "Dem Compiler auf die Finger schauen"). Dabei interessiert zuerst die Funktion ledHalfCycle.

Das folgende Listing zeigt den ersten Teil der Funktion mit dem (inline) Ein- und Ausschalten der LED.

00000086 <_Z12ledHalfCycleILi200EEvb>:
template <int ms>
void ledHalfCycle(bool on)
{
if (on) {
86: 88 23 and r24, r24
88: 11 f0 breq .+4 ; 0x8e

inline void ledOn()
{
PORTB |= _BV(PB5); // der then-Zweig schaltet ein
8a: 2d 9a sbi 0x05, 5 ; 5
8c: 01 c0 rjmp .+2 ; 0x90

inline void ledOff()
{
PORTB &= ~_BV(PB5); // der else-Zweig schaltet aus
8e: 2d 98 cbi 0x05, 5 ; 5

Bis hier sind beide Varianten identisch. Danach kommt in der zweiten Variante mit der Library-Funktion der Aufruf von delay_ms() mit der Übergabe _delay_ms_ticks mit _loops (0x07D0) und _ticks (0x0190):

    delay_ms(ms);
a8: 60 ed ldi r22, 0xD0 ; 208
aa: 77 e0 ldi r23, 0x07 ; 7
ac: 80 e9 ldi r24, 0x90 ; 144
ae: 91 e0 ldi r25, 0x01 ; 1
b0: 0e 94 43 00 call 0x86 ; 0x86 <_Z8delay_ms15_delay_ms_ticks>

In der ersten Variante wird delay_ms inline expandiert:

  90   80 ed           ldi     r24, 0xD0       ; 208
92: 97 e0 ldi r25, 0x07 ; 7
__asm__ volatile (
"1: sbiw %0,1" "\n\t"
"brne 1b"
: "=w" (__count)
: "0" (__count)
);
94: 20 e9 ldi r18, 0x90 ; 144
96: 31 e0 ldi r19, 0x01 ; 1
98: f9 01 movw r30, r18
9a: 31 97 sbiw r30, 0x01 ; 1
9c: f1 f7 brne .-4 ; 0x9a
<_Z12ledHalfCycleILi200EEvb+0x14>
9e: 01 97 sbiw r24, 0x01 ; 1
};

inline void
delay_ms(_delay_ms_ticks _d)
{
while (_d._loops)
a0: 00 97 sbiw r24, 0x00 ; 0
a2: d1 f7 brne .-12 ; 0x98
<_Z12ledHalfCycleILi200EEvb+0x12>
ledOn();
} else {
ledOff();
}
delay_ms(ms);
}

Dabei wird erst mit zwei 8-Bit-Loads _loops für die while-Schleife geladen. Dann kommt die Funktion _delay_loop_2, die im Headerfile als inline-Assembler definiert ist und eine einfache Zählschleife implementiert (bis brne .-4) und darauf das Herunterzählen der äußeren Schleife mit zwei Subtraktionen und dem bedingten Sprung.

Die zweite Subtraktion (sbiw r24, 0x00) ist vermutlich ein Artefakt des Compilers und müsste eigentlich wegoptimiert werden. Danach folgt noch das Return, das in beiden Varianten identisch ist. Wenn man das Laden von _delay_ms_ticks, das in beiden Varianten anfällt, nicht berücksichtigt, kommt man bei der Variante mit der externen Funktion auf 4 Byte für den Funktionsaufruf und bei der Inline-Variante auf 12 Byte für die expandierte Funktion (und damit auf genau die 8 Byte Differenz, die das Mapfile schon gezeigt hat).

Interessant ist hier vor allem, dass die Inline-Expansion von delay_ms nur 12 Byte benötigt, die externe Funktion (wie im Mapfile zu sehen) jedoch 24 Byte. Zur Erklärung des Unterschieds wird delay_ms in der externen Variante genauer analysiert:

00000086 <_Z8delay_ms15_delay_ms_ticks>:
void delay_ms(_delay_ms_ticks d)
{
86: 9b 01 movw r18, r22
88: ac 01 movw r20, r24
while (d._loops)
8a: 21 15 cp r18, r1
8c: 31 05 cpc r19, r1
8e: 31 f0 breq .+12 ; 0x9c
<_Z8delay_ms15_delay_ms_ticks+0x16>
_delay_loop_2(d._ticks);

__asm__ volatile (
"1: sbiw %0,1" "\n\t"
"brne 1b"
: "=w" (__count)
: "0" (__count)
);
90: ca 01 movw r24, r20
92: 01 97 sbiw r24, 0x01 ; 1
94: f1 f7 brne .-4 ; 0x92
<_Z8delay_ms15_delay_ms_ticks+0xc>
--d._loops;
96: 21 50 subi r18, 0x01 ; 1
98: 31 09 sbc r19, r1
9a: f7 cf rjmp .-18 ; 0x8a
<_Z8delay_ms15_delay_ms_ticks+0x4>
}
9c: 08 95 ret

In der Funktion werden zuerst mit zwei 16-Bit-Moves die Parameter gerettet und anschließend mit zwei 8-Bit-Vergleichen die While-Bedingung am Anfang getestet. Eine 16-Bit-Addition adiw r18, 0 wäre für die Codegröße allerdings günstiger – für die Laufzeit ergeben die beiden Varianten dagegen keinen Unterschied. Die Expansion von _delay_loop_2 (Adressen 0x90 bis 0x95) ist im Prinzip identisch. Danach wird die äußere Schleife heruntergezählt (Adressen 0x96 bis 0x99).

Warum hier zwei 8-Bit-Subtraktionen statt einer 16-Bit-Subtraktion verwendet werden, ist unklar. Aber da der Compiler bei der Inline-Variante ebenfalls zwei Byte zu viel Code generiert, gibt das hier keinen Unterschied. Und weil der While-Test am Anfang stattfindet, wird zum Schluss ein unbedingter Sprung benötigt. Von den 12 Byte Unterschied zwischen der Inline- und der externen Variante gehen also 6 Byte zulasten des Funktionsaufrufs (2 Byte für das Return und 4 Byte für die Parameterkopie, wovon 2 Byte eigentlich unnötig sind) und 6 Byte zulasten des Vergleichs am Anfang der While-Schleife (wovon wieder zwei Byte ungünstiger Codegenerierung zuzuschreiben sind). Die Inline-Variante kann sich den Test am Anfang der While-Schleife sparen, weil zur Compile-Zeit bekannt ist, dass der Wert von _loops nicht 0 ist. Selbst bei günstiger Codegenerierung ist die Inline-Variante also kleiner, da sie Informationen aus der Aufrufumgebung mit berücksichtigen kann.

Noch unklar ist der Unterschied bei main(). Eigentlich sollte main() bei der Inline-Variante genauso 8 Byte größer sein wie ledHalfCycle<200>(bool on), stattdessen ist das main() der externen Variante um 4 Byte größer. Bei der Analyse der beiden main() in den Assembler-Listings stellt man fest, dass der Compiler in der Inline-Variante schlicht besseren Code erzeugt, während in der zweiten Variante die Parameter für den externen Funktionsaufruf unnötig durch verschiedene Register kopiert werden.

Es kostet schon etwas Aufwand, dem Compiler genau auf die Finger zu sehen und zu kontrollieren, was für ein Code erzeugt wird. Als Lohn versteht man besser, wie die verschiedenen C- und C++-Konstrukte in Assembler umgesetzt werden.

Im Beispiel des Artikels zeigt sich, dass einerseits der (GCC-)Compiler bei externen Funktionsaufrufen Mühe hat, größenoptimierten Code zu erzeugen, und andererseits die Kenntnis der Parameterwerte bei inline-Aufrufen bessere Optimierung ermöglicht. Mit dieser (inline-)Version ist das Programm zwar schön klein, aber nach C++ sieht es noch nicht wirklich aus (trotz template und constexpr). Der wesentliche Vorteil von C++ gegenüber C ist die Möglichkeit, einfacher Abstraktionen zu entwickeln, die sich dann immer wieder verwenden lassen. In diesem Einfachst-Programm bietet sich die LED-Ansteuerung an. Da sie sich (programmtechnisch) nicht von anderen allgemeinen digitalen Ausgängen unterscheidet, kann man hierfür eine Klasse GpioOut definieren.

Bei den AVRs ist ein GPIO durch den Port definiert (je nach konkretem Modell PORTA bis PORTL) und das Bit innerhalb des Ports. Im Beispiel wird PORTB, GPIO 5 verwendet. Zu jedem Port gibt es (im Wesentlichen) drei Register: das PORT-Register, in das der jeweilige Output-Wert (0 oder 1) geschrieben wird, das PIN-Register, aus dem der Input-Wert gelesen wird, und das DDR-Register, das festlegt, ob der entsprechende Pin ein Input oder ein Output ist. Im normalen Betrieb werden für GpioOut nur das PORT-Register und das entsprechende Pin-Bit benötigt, also sind als (private) Daten der Klasse die Adresse des Port-Registers und das Bit definiert. Damit sieht die Klasse mit einer Member-Funktion zum Setzen des Outputs wie folgt aus:

class GpioOut
{
public:
void set(bool state)
{
if (state)
{
*port |= bitVal;
} else {
*port &= ~bitVal;
}
}

private:
volatile uint8_t *port;
uint8_t bitVal;
};

Bleibt noch der Konstruktor. Die AVR Libc liefert für die verschiedenen AVR-Modelle Makrodefinitionen für die Register, also zum Beispiel PORTB, DDRB und PINB (die so ja auch schon im bisherigen Programm verwendet wurden). Diese Definitionen sollen weiterverwendet werden. Dazu bietet es sich an, eine entsprechende Hilfsstruktur zu definieren und ein Makro, das diese Hilfsstruktur füllt:

struct GpioDef
{
volatile uint8_t *portAddr;
volatile uint8_t *dirAddr;
volatile uint8_t *pin_addr;
uint8_t bit;
};
#define GPIO(port, bit) GpioDef({&PORT##port, &DDR##port, &PIN##port, bit})

Damit lässt sich der Pin mit GPIO(B, 5) definieren. Der Konstruktor bekommt jetzt also als Parameter eine GpioDef und den Initialwert:

GPIO::GpioOut(GpioDef def, bool initState)
: port(def.portAddr)
, bitVal(_BV(def.bit))
{
set(initState);
*def.dirAddr |= bitVal;
}

Im Programm lassen sich die Funktionen init(), ledOn() und ledOff() streichen und dafür ein entsprechendes globales Objekt definieren:

GpioOut led(GPIO(B, 5), true);

Und ledHalfCycle() wird einfacher:

template <int ms>
void ledHalfCycle(bool on)
{
led.set(on);
delay_ms(ms);
}

Damit sieht das Programm schon etwas mehr nach C++ aus, aber nach dem Kompilieren kommt das böse Erwachen: Das Binärprogramm ist auf 460 Byte gewachsen – und das ohne bessere Funktionalität, nur durch etwas Modularisierung. Ist C++ für so kleine Prozessoren vielleicht doch keine so gute Idee?

Eine erneute Analyse des generierten Codes zeigt, dass der Zuwachs an Code zu einem wesentlichen Teil auf Kosten des Konstruktors für das globale Objekt geht. Der GCC produziert relativ viel Infrastruktur für die dynamische Initialisierung (lies: Konstruktoren) globaler Variablen. Wer entsprechende globale Objekte haben will, muss einmal für die Infrastruktur zahlen.

Um nur die LED blinken zu lassen, kann man aber auf das globale Objekt verzichten und die LED stattdessen in main() definieren und sie den Funktionen als Argument via Referenz übergeben. Globale Variablen sind ja eigentlich sowieso keine gute Idee. (Auf so kleinen Systemen wie dem AVR sind in der Regel globale Variablen zwar besser als solche auf dem Stack, aber das ist ein anderes Thema …)

Damit schrumpft das Binärprogramm wieder auf 394 Byte, aber das ist immer noch massiv mehr als die Variante ohne GPIO-Klasse. Diese verursacht allein durch den Zugriff via Pointer einigen Overhead, aber irgendwie muss man sich das Register ja schließlich merken, um dann das richtige Bit zu setzen oder zu löschen. Aber statt das konkrete Register im Objekt zu speichern, geht das in C++ auch im Typ: Aus GpioOut wird einfach ein entsprechendes Template, das die Pin-Informationen als Template-Parameter enthält.

Allerdings kann ein Pointer nicht direkt als Template-Parameter dienen, daher ist ein wenig Trickserei (also Typen-Casting) angesagt. Der Port und das Pin-Bit sind nun nicht mehr als Daten im Objekt, sondern Template-Parameter:

template <uintptr_t port, uint8_t bit>
class GpioOut

Und eine kleine (private) Hilfsfunktion macht aus dem uintptr_t jeweils wieder einen Pointer:

constexpr uint8_t volatile *portReg() {
return reinterpret_cast<uint8_t volatile *>(port);
}

Für den Konstruktor wird die Tatsache genutzt, dass das DDR-Register in der Regel genau ein Byte vor dem PORT-Register kommt, wieder via eine Hilfsfunktion. Und die set()-Funktion sieht fast aus wie vorher:

template <uintptr_t port, uint8_t bit>
class GpioOut
{
public:
GpioOut(bool initState)
{
set(initState);
*ddrReg() |= bitVal(bit);
}

void set(bool state)
{
if (state)
{
*portReg() |= bitVal(bit);
} else {
*portReg() &= ~_BV(bit);
}
}
private:
constexpr uint8_t volatile *portReg() {
return reinterpret_cast<uint8_t volatile *>(port);
}
constexpr uint8_t volatile *ddrReg() {
return portReg() - 1;
}
};

Dabei ist bitVal() eine kleine Hilfsfunktion, die das Bit an der entsprechenden Stelle setzt:

constexpr uint8_t bitVal(uint8_t bit)
{
return 1 << bit;
}

_BV() ist ein Makro aus der AVR Libc, die genau dasselbe übernimmt. Interessanterweise erkennt der Compiler, dass *port Reg() &= ~_BV(bit) ein Bit in einem Register löscht, und macht aus dem ganzen Ausdruck einen einzigen Clear-Bit-Befehl (cbi). Kommt aber statt dem Makro _BV() die constexpr-Funktion bitVal() zum Einsatz, erkennt der Compiler das Muster nicht mehr. Das entsprechende Set-Bit zwei Zeilen vorher erkennt der Compiler dagegen auch mit bitVal().

Bleibt noch, das Programm auf das neue GpioOut umzuschreiben. Für den Typcast von der Portadresse auf uintptr_t wird wieder eine Hilfsfunktion verwendet:

constexpr uint16_t port2addr(uint8_t volatile &p)
{
return reinterpret_cast<uintptr_t>(&p);
}

Der Datentyp für das LED-Gpio sieht dann so aus:

typedef GpioOut<port2addr(PORTB), 5> MyLed;

Die Funktionen erhalten statt einem GpioOut einfach ein MyLed als Parameter, und in main() definiert man das entsprechende Objekt (das man jetzt ohne schlechtes Gewissen auf den Stack nehmen kann, da das Objekt die Größe null hat):

MyLed led(true); 

Und prompt ist das Binärprogramm wieder schön klein, nämlich nur noch 222 Byte. Die 6 Byte gegenüber dem bisher kleinsten Programm werden dadurch gespart, dass init() jetzt inline ist (als
Konstruktor von GpioOut).

Die Definition des Typs MyLed sieht etwas unschön aus, aber das ist rein kosmetisch und lässt sich gegebenfalls mit einem Makro aufbessern. Problematischer ist, dass die Funktionen ein MyLed als Parameter haben und dadurch nur für genau diese LED funktionieren. Aber auch das kann man ändern, indem der LED-Typ in die Template-Parameterliste der Funktionen mit aufgenommen wird. Das bleibt als Übung dem Leser überlassen (ohne Größenänderung des Binärprogramms).

Bjarne Stroustrups Aussage vom Anfang des Artikels gilt also auch für ganz kleine Systeme. In der Regel sind mit C++-Mechanismen Programme möglich, die besser wartbar sind, ohne größere oder weniger effiziente Programme zu produzieren als entsprechende C-Programme. Das gilt allerdings nur für den Vergleich von C++ zu C. Wie die Analyse des generierten Codes zeigt, produziert der GCC in einigen Fällen suboptimalen Code.

Die 90 Byte, die das eigentliche Programm (ohne Initialisierungscode) hier braucht, ließe sich mit handoptimiertem Assembler vermutlich auf weniger als 60 Byte drücken. Aber das Programm wäre dann deutlich schlechter wartbar. Und das etwas speziellere C++-Know-how, das zur Optimierung nötig war, steckt in den Klassen und Hilfsfunktionen und lässt sich in einer (Header-)Library verstecken. Das eigentliche Programm ist dagegen "ganz normales" C++.

Detlef Vollmann
ist ein aktives Mitglied des C++-Standardisierungskomitees (hauptsächlich in der Unterkommission zur Concurrency). Er ist einer der (vielen) Autoren des C++ Performance Report und führte die "Futures" in C++11 ein. Er liefert Support und Schulung zu Embedded-Systemen und Concurrency in C++.
(ane)