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.

Sprachen  –  15 Kommentare
Schlanke Embeded-Entwicklung mit Small C++
Anzeige

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.

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.

Anzeige