AVR-GCC-Codeoptimierung
Entstanden aus diesem Thread sollen hier ein paar Hinweise/Erfahrungen gegeben werden, um den Quellcode in Punkto Größe und Geschwindigkeit zu optimieren. En detail ist das Thema komplex, da es stark von der Codeoptimierung des Compilers abhängt. Es ist im Einzelfall ratsam zu prüfen, ob die eigenen Maßnahmen auch erfolgreich waren. Die Diskussionen [1] bzw. [2] können als Anhaltspunkte dienen, wie eine solche Prüfung ablaufen kann.
Prinzipien der Optimierung
Wie so oft sollte man nicht einfach wild drauf los optimieren und sich zunächst ein paar Dinge klar machen.
- Warum will ich optimieren?
- Was kann man sinnvoll optimieren?
- Wie viel Rechenzeit oder Speicher soll dabei gespart werden?
- Wie kann optimiert werden?
- "Verfrühte Optimierung ist die Wurzel allen Übels!"
Viele Optimierungen sind "Angst-Optimierungen", die nicht wirklich nötig sind. Die Gefahr mit Optimierungen ist, den Code tot zu optimieren, sprich Lesbarkeit, Portierbarkeit und ggf. Fehlerfreiheit sinken maßgeblich. Kurz und knapp in diesem BLOG formuliert.
Warum
Optimieren sollte man nur, wenn
- der Speicher nicht mehr ausreicht (RAM, Flash)
- Die Laufzeit für bestimmte Programmteile zu groß wird und somit bestimmte (Echtzeit-)Ausgaben nicht im erforderlichen Zeitrahmen erledigt werden
Weiter sollte man folgende Punkte gegeneinander abwägen:
- Codeverbrauch
- Datenverbrauch. Statisch/Stack/Heap
- Forumsbeitrag: RAM-Verbrauch auch von lokalen Variablen ermitteln
- Mittlere Laufzeit/maximale Laufzeit
- Entwicklungszeit
- Portabilität (Compiler, Hardware, ...)
- Verständlichkeit der Quelle, siehe Strukturierte Programmierung auf Mikrocontrollern
- ABI-Konformität
Was
Die goldene Regel lautet:
90% der Rechenleistung werden in 10% des Codes verbraucht.
Diese 10% muss man finden und zum richtigen Zeitpunkt optimieren. Der Rest muss nur sauber und lesbar geschrieben sein. Was jedoch nichts bringt, ist eine Funktion, die von 1 Minute Programmlaufzeit lediglich 1 Sekunde verbraucht, um den Faktor 10 schneller zu machen. Die Programmlaufzeit sinkt dann von 60 Sekunden auf 59.1 Sekunden. Der Aufwand, die Funktion um einen Faktor 10 schneller zu machen ist aber meistens beträchtlich! Kann ich aber den Code, der für die 59 Sekunden verantwortlich ist um lediglich einen Faktor 2 schneller machen (was immer noch oft schwer genug ist, aber auf jeden Fall leichter als ein Faktor 10), dann sinkt die Gesamtlaufzeit von 60 Sekunden auf 30.5 Sekunden. Dort bringt Optimieren augenscheinlich viel mehr!
Um die optimierungswürdigen Stellen zu finden, muss man sein Programm analysieren. Dazu gibt es verschiedene Möglichkeiten.
Speicherverbrauch nach Funktionen aufschlüsseln
- map-File
- dort sind alle globalen und statischen Variablen enthalten. Eine Map-Datei kann mit den GNU-Tools während des Linkens angelegt werden:
> avr-gcc ... -Wl,-Map,foo.map
- Die Option -Wl bewirkt, daß avr-gcc die angehängen Optionen unverändert an den Linker weiterreicht. Dieser erzeugt dann das Mapfile "foo.map", eine Textdatei.
- avr-size
- Mit Tools wie avr-size kann die Platzbelegung einzelner Module ermittelt werden:
> avr-size -x foo1.o foo2.o ...
- bzw. die Platzbelegung der elf-Datei:
> avr-size foo.elf
- avr-nm
-
> avr-nm --size-sort -S foo.elf
- ergibt eine Liste mit der Größe aller Objekte: die erste Spalte enthält die Adresse, die zweite Spalte die Größe, die dritte den Typ und die vierte Spalte den zugehörigen Symbolnamen. Der Typ ergibt sich aus der folgenden Zuordnung, wobei Großbuchstaben globale Symbole kennzeichnen und Kleinbuchstaben Symbole, die Modul-lokal sind:
- T/t
- Objekte in der text-Section: Funktionen, Daten im Flash
- D/d
- Objekte im data-Segment (initialisierte Daten)
- B/b
- Objekte im bss-Segment (Null-initialisierte Daten)
- avr-gcc
- Der Compiler hat bereits Informationen über die übersetzten Funktionen, die man direkt zur Analyse verwenden kann. Dazu lässt man avr-gcc die Assembler-Ausgabe, die ohne weiteres Zutun nur als temporäre Datei angelegt wird, abspeichern. Etwa für die Quelldatei foo.c:
> avr-gcc -save-temps foo.c -c ...
- Die Assembler-Datei wird damit als foo.s angelegt und nicht gelöscht. (Das ebenfalls angelegte Präcompilat foo.i wird nicht benötigt). Für jede Funktion gibt avr-gcc 3.4.x im Prolog einen Kommentar der Form[1]
/* prologue: frame size=0 */
- aus, was die Größe des aktuellen Frames angibt. Dies ist der Platz auf dem Stack, der für lokale Variablen benötigt wird. Am besten ist es, wenn die Frame-Size wie im Beispiel gleich 0 ist. Ansonsten sollte man versuchen, diese Größe auf Null zu bringen. Für Variablen, die nicht in Registern gehalten werden können, müssen Speicherzugriffe in den Stack erzeugt werden. Diese machen das Programm sowohl größer aus auch langsamer. Zudem reserviert avr-gcc bei solche Funktionen das Y-Register als Frame-Pointer; das Y-Register steht damit nicht mehr für lokale Variablen zur Verfügung was sich ebenfalls ungünstig auf die Codegüte auswirkt. Ein Grund für das Anlegen eines Frames können zu viele lokale Variablen sein (zB lokale Puffer/Arrays) oder lokale Variablen/Strukturen/Parameter mit ungünstigen Größen, etwa eine 3-Byte große Struktur.
- Neben dieser Information gibt avr-gcc Kommentare der Gestalt
/* prologue end (size=2) */
- aus die darüber informieren, wie viele Register auf dem Stack gesichert wurden.
- Zusammen mit Werkzeugen wie grep, die in jedem Linux und jeder WinAVR-Distribution enthalten sind, findet man schnell Übeltäter wie Funktionen mit Frame.
- Assembler-Code sichten
- Ein kurzer Blick auf den erzeugten Assembler-Code zeigt oft, wie gut der Compiler den Code umgesetzt hat. Den erzeugten Assembler-Code zu überfliegen ist wesentlich zeitsparender als selbst in Assembler zu programmieren. Je nach Gusto verwendet man zur Einsicht den Assembler-Code, den avr-gcc ausgibt (s.o.), Assembler-Dumps des Assemblers, List-Files oder HEX-Dumps. Siehe auch[2]
- Hilfsmittel
- einkaufen oder selber bauen. Es gilt herauszufinden, welche Funktion massig Stack durch lokale Variablen verbraucht. Stacktracer können das. Wenn man keinen hat, dann muss man sich eben selber einen bauen, indem man den Stackpointer mitloggt. Zur Not einen Code-Review machen: Alle Funktionen optisch durchgehen und die identifizieren, die viele Variablen anlegen. Dann die Aufrufhierarchie der Funktion feststellen: Wirken sich die vielen Variablen überhaupt aus oder entsteht mein Problem durch eine tiefe Funktionsaufrufhierarchie, bei der zwar wenige Variablen pro Funktion im Spiel sind, aber die Menge der ineinander geschachtelten Aufrufe 'das Kraut fett macht'
- Profitools
- können das alles fast auf Knopfdruck, kosten aber viel Geld
Laufzeit messen
- Simulator
- In Echtzeit mittels Testpin, welche an Anfang einer Funktion/Blocks gesetzt wird und am Ende wieder gelöscht wird. Mit einem Oszilloskop kann man so sehr einfach die Laufzeit messen.
- Anmerkung
- Solche Messverfahren liefern immer nur eine untere Schranke für die Laufzeit, niemals eine obere Schranke. Eine obere Schranke, wie man sie etwa in sicherheitskritischen Systemen benötigt, liefert eine statische Codeanalyse.
Wie viel
Der Aufwand von Optimierungen wächst exponentiell. Die letzten paar Prozent brauchen überproportional viel Aufwand.
Wie
Meist muss man die Wahl treffen ob man Speicher oder Rechenzeit sparen will, beides gleichzeitig geht meist nicht. Das Konzept heißt 'Space for Time' und kann in beide Richtungen verwendet werden. Als Beispiel soll eine komplizierte Berechnung dienen. Diese kann man relativ kompakt in eine Funktion packen, welche dann aber eher langsam ist. Oder man benutzt eine sehr große Tabelle, in welcher die Ergebnisse schon für jeden Eingangswert vorausberechnet wurden. Diese Lösung ist sehr schnell, verbraucht aber sehr viel Speicher.
- Inlining von Funktionen erhöht den Speicherverbrauch, senkt aber die Laufzeit. Beispiel: Funktion A ist 50 Byte groß und wird 10 mal im Programm aufgerufen. Ein Aufruf kostet 10 Byte:
- Ohne Inline: 10 * 10Byte + 50 Byte = 150 Byte Platzverbrauch
- Mit Inline: 10 * 50 Byte = 500 Byte
- Optimierer einschalten
- möglichst keine Floating Point Operationen, besser ist meist Festkommaarithmetik
- Formeln umstellen und zusammenfassen
- Variablen so klein wie möglich, uint8_t wo's nur geht.
- Wirklich zeitkritische Funktionen und Interrupts als Assemblercode in separater Datei
GCC-Optionen
Optimierungs-Level
avr-gcc kennt mehrere Optimierungsstufen:
- -O0
- Keine Optimierung des erzeugten Codes. Diese Optimierungsstufe optimiert den Resourcenverbrauch des Hostrechners und die Nachvollziehbarkeit der erzeugten Codes anhand von Debug-Information. Alle lokalen Variablen werden auf dem Stack angelegt und nicht in Registern gehalten. Es werden keine komplexen Optimierungsalgorithmen angewandt; lediglich Konstanten wie 1+2 werden zu 3 gefaltet.
- -O1
- Je höher die Optimierungsstufe, desto schwieriger ist der erzeugte Code nachvollziehbar — auch mit Debugger. Diese O-Stufe ist ein Kompromiss zwischen aggressiver Optimierung und Nachvollziehbarkeit des erzeugten Codes. Ein ehernes Gesetz in GCC ist, dass er den gleichen Code erzeugen muss unabhängig davon, ob Debug-Information erzeugt wird oder nicht. Im Umkehrschluss erlaubt volle Debug-Unterstützung nicht alle Optimierungen, wozu diese Optimierungsstufe dient.
- -O2
- Optimierung auf Geschwindigkeit. Für AVR nur mässig sinnvoll, da sich der Codezuwachs nicht in einen entsprechenden Geschwindigkeitszuwachs transformiert. Dies liegt vor allem daran, daß Sprünge und Funktionsaufrufe auf AVR im Vergleich zu anderen Architekturen sehr billig sind. Es bringt also kaum einen Geschwindigkeitszuwachs, einen Block zu kopieren um einen Sprung zu sparen. Hingegen vergrößert dies den Code deutlich.
- -O3
- Dito. Auf Teufel-komm-raus Funktionen zu inlinen, Schleifen aufzurollen oder gar Funktionen mehrfach für unterschiedliche Aufruf-Szenarien zu implementieren, ist auf einem kleinen µC wie AVR der Overkill.
- -Os
- Optimierung auf Codegröße. Die bevorzugte Optimierungsstufe für AVR und viele andere µC.
Jede O-Option ist ein Sammlung von verschiedenen Schaltern, welche bestimmte Optimierungsstrategien aktivieren. Um zu sehen, welche Schalter dies genau sind, erzeugt man wie oben beschrieben mit den Schalten
-save-temps -fverbose-asm
die Assembler-Ausgabe von gcc und schaut die Optionen im s-File nach. Einzelne Optionen lassen sich gezielt aktivieren bzw. deaktivieren und damit zum Beispiel zum -Os-Paket hinzufügen.
Eine Ausnahme bildet -O0: Hier ist Code-Optimierung generell deaktiviert, und Optimierungsschalter bleiben ohne Wirkung.
Feinabstimmung der Optimizer
Kandidaten für Optimierungsoptionen sind folgende Schalter. -m kennzeichnet maschinenspezifische Schalter, die nur für AVR gültig sind. -f bzw. -fno- sind maschinenunabhängige Schalter, die auch für andere Architekturen verfügbar sind.
- -fno-split-wide-types
- Je nach Quelle kann die Deaktivierung von -fsplit-wide-types besseren Code ergeben.
- -fno-inline-small-functions
- Relativ kleine Funktionen /immer/ zu inlinen kann den Code unnötig vergrößern, dieser Schalter unterbindet das automatische Inlinen kleiner Funktionen.
- -finline-limit=wert
- Maximalwert für automatisch geinlinte Funktionen. In einschlägigen Foren werden kleine Werte für wert vorgeschlagen, z.B. 1…3
- -mcall-prologues
- Die für aufwändige Funktionen mitunter recht langen push/pop-Sequenzen werden durch Hilfsfunktionen ersetzt. Das kann vor allem bei grossen Programmen Platz sparen. Die Ausführungszeit steigt an.
- -fno-jump-tables
- Switch-Statements werden hierdurch mitunter deutlich kürzer.
- -fno-move-loop-invariants
-fno-tree-loop-optimize - Einige Schleifenoptimierungen, welche die Registerlast erhöhen und für AVR kaum zu einem Geschwindigkeitszuwachs führen, werden deaktiviert.
- -fno-tree-switch-conversion
- Neue GCC-Versionen können switch/case Anweisungen u.U. in Lookup-Tabellen umwandeln, die im RAM abgelegt werden, siehe auch PR49857. Dieser Optimierung ist bei RAM-Knappheit in Betracht zu ziehen, bring aber natürlich nur dann etwas, wenn diese Optimierung auch ausgeführt wurde.
- -fno-optimize-sibling-calls
- Ab 4.7 wirksam: Tailcall-Optimierung kann den Code vergrößern, wenn Epiloge mehrfach erzeugt werden. In diesem Fall deaktiviert man die Tailcall-Optimierung.
- -maccumulate-args
- Ab 4.7: Funktionen, die mehrere printf-artige Aufrufe enthalten und viele Artumente per Stack an diese übergeben, werden u.U kleiner.
- -mstrict-X
- Ab 4.7: Beeinflusst die Art und Weise, wie das X-Register zur Adressierung verwendet wird.
- -mbranch-cost=wert
- Ab 4.7: Setzt die Kosten, mit der der Compiler einen bedingten Sprunge veranschlagt. Default-Wert ist wert=0.
- -fno-caller-saves
- Kann zu effizienteren Pro-/Epilogen beitragen.
- -fno-tree-ter
- Es gibt Fälle, in denen der Compiler die Berechnung temporärer Variablen über volatile-Zugriffe und Memory-Barriers zieht, siehe PR53033. Von der C-Spezifikation her ist dies zulässig, kann aber zu unerwünschter Umsortierung der volatile-Operation führe, z.B. wenn es sich dabei um eine SEI-Instruktion handelt. Ohne diese Optimierung wird der Code evtl. etwas größer, aber Probleme wie im PR beschrieben können vermieden werden.
- --param case-values-threshold=wert
- Ab 4.7: Schwellwert an Einträgen in einem switch/case, ab dem anstatt eines binären if/else-Entscheidungsbaums eine Sprungtabelle zu den case-Labels erzeugt wird. Voreinstellung ab 4.7 ist wert=7. Ältere Compilerversionen verwenden andere Werte. Siehe auch -f[no-]jump-tables.
Generell gilt für all diese Optionen, daß sie abhängig vom Projekt zu einer Codeverbesserung oder -verschlechterung führen können — dies ist i.d.R. vom Projektcode abhängig.
Linker-Optionen
- -ffunction-sections
-Wl,--gc-sections - Der Linker wirft nicht referenzierte Sections weg, was die Codegröße günstig beeinflussen kann. Diese Optimierung verkleinert den Code nur dann, wenn nicht verwendete Funktionen in der Anwendung rumgammeln. Weil der Linker nur auf Section-Ebene optimieren kann, muss zusätzlich der Compiler mit -ffunction-sections aufgerufen werden, um die Anwendung auf möglichst viele Sections zu verteilen.
- -Wl,--relax
-mrelax - Der Linker fasst Tail-Calls wie
- CALL some_function
RET
- CALL some_function
- zusammen als
- JMP some_function
- Siehe auch die Compiler-Option -f[no-]optimize-sibling-calls von oben. Zudem wird CALL umgewandelt zur kürzeren RCALL-Instruktion falls das Sprungziel im ±4 KiB-Zielbereich von RCALL liegt. Analog für JMP zu RJMP.
- Die beiden Optionen sind gleichwertig; die erste Variante veranlasst den Compiler, den Linker mit --relax aufzurufen. Die zweite Variante verwendet den allgemeinen -Wl-Mechanismus, um eine Option von der Compiler-Kommandozeile an den Linker durchzureichen.
Weitere Optionen
- -mtiny-stack
- Der Compiler ändert nur das Low-Byte des Stackpointers (SP), was auf Controllern mit 16-Bit SP zu kleinerem Code führen kann. Benötigt der Code Platz auf dem Stack, so erzeugt der Compiler u.U. Code, der den SP liest, einen Offset aufaddiert/abzieht und den SP dann zurückschreibt. Dies ist aufwändig. Für den Fall, daß sich dabei das High-Byte SP nicht ändert, kann Code eingespart werden.
- Beispiel: ATtiny44 hat RAM von 0x60 bis 0x15f. Braucht die Anwendung nicht mehr als 0x60 = 96 Bytes an Stack, dann kann mit -mtiny-stack compiliert werden.
Änderung des Binärinterfaces per Option
- Warnung
- Im Gegensatz zu den Optionen der vorherigen Abschnitte, bei denen es sich um reine Optimierungsoptionen handelt, ändern die folgenden Optionen das Binärinterface (ABI) des vom Compiler erzeugten Codes und sind daher nur nach eingehender Prüfung anzuwenden! Wird eine Anwendung mit diesen Schaltern übersetzt, dann ist sicher zu stellen, daß alle Module inclusive Libraries damit derzeugt werden oder die ABI-Änderung sich nicht auf Code in Bibliotheken auswirkt!
Vorsicht in auch deshalb geboten, weil manche Entwicklungsumgebungen wie "Atmel Studio" das ABI per Default verändern und ohne dass der Anwender es extra anfordert.
- -funsigned-char
- Anders als im avr-gcc ABI ist der Typ char unsigned anstatt signed.
- Besser: Verwende die C99-Typen wie uint8_t aus dem C99-Header stdint.h.
- -funsigned-bitfields
- Bitfelder mit Basetype char, short, int long und long long werden als unsigned implementiert anstatt als signed wie in der avr-gcc ABI.
- Besser: Wenn ein Bitfeld unsigned sein soll, dann mach es unsigned!
- -fpack-struct
- Im Gegensatz zur avr-gcc ABI werden Strukturen und Unions per default gepackt.
- Besser: Wenn ein zusammengesetzter Typ gepackt werden soll, mache ein Typedef mit explizitem __attribute__((packed))!
- Noch besser: Sorge dafür, dass die Struktur von sich aus gepackt und aligned ist, auch bei Bitfeldern! Dazu etwa die Reihenfolge char int char ändern in int char char und Lücken (v.a. bei Bitfeldern) auffüllen. Der Compiler ändert niemals die Reihenfolge der Datenelemente, auch nicht in Klassen.
- -fshort-enums
- Enum-Typen werden so kurz wie möglich implementiert anstatt als int gemäß avr-gcc ABI.
- -mint8
- Ein int ist nur nocht 8 Bits breit. Dies entspricht nicht mehr dem C-Standard und wird nicht durch die C-Bibliotheken wie AVR-Libc oder newlib unterstützt! Die Codegröße von 8-Bit Operationen kann sich verkleinern, weil andere Integer-Promotion Regeln angewandt werden. Literals müssen ggf. angepasst bzw gecastet werden, siehe auch C99-Makros wie UINT16_C aus stdint.h.
Anpassungen der Quelle
Attribute noreturn, OS_main und OS_task
Mikrocontroller-Programme laufen normalerweise in einer Endlosschleife, so dass die main-Routine nie verlassen wird. Teilt man dies dem Compiler mit, kann er bestimmte Optimierungen durchführen. So ist es zum Beispiel unnötig, Code zum Sichern und Zurücklesen von Registern zu erzeugen.
Das Mitteilen funktioniert beim gcc über Attribute, die man der Deklaration oder bei der Implementierung einer Funktion anhängt:
static void main_loop (void) __attribute__((noreturn));
void main_loop (void)
{
while (1)
{
// Hauptschleife
}
}
oder
static void __attribute__((noreturn))
main_loop (void)
{
while (1)
{
// Hauptschleife
}
}
Die Funktion main_loop kann dann in main aufgerufen werden:
int main (void)
{
main_loop();
return 0;
}
Das abschließende return wird vom Compiler wegoptimiert und belegt keinen Speicher.
Statt __attribute__((noreturn))
kann auch (ab C++11, portabel)
[[noreturn]]
geschrieben werden.
avr-gcc kennt weiterhin die Attribute OS_main und OS_task. Die Verwendung von OS_main kann etwa aussehen wie folgt. Natürlich kann auch wie oben die Hauptschleife in einer eigenen Funktion implementiert werden, und das return verursacht keinen zusätzlichen Code:
int __attribute__((OS_main))
main (void)
{
while (1)
{
// Hauptschleife
}
return 0;
}
Statische Variablen in einer Struktur sammeln
Gibt es in einem Programm mehrere inhaltlich zusammengehörende Variablen, dann ist es sinnvoll diese in einer Struktur zu vereinigen. Neben einer klareren Programm- bzw. Datenstruktur kann dies auch zu kleinerem Code führen.
Beispiel ist die folgende kleine Routine, welche die Zeit in der globalen time-Strukture um eine Sekunde erhöht:
#include <stdint.h>
typedef struct
{
uint8_t second;
uint8_t minute;
uint8_t hour;
} time_t;
// Globale time-Struktur enthält die Zeit
time_t time;
void next_second (void)
{
// Zeiger auf die globale time-Struktur
time_t *ptime = &time;
// time um 1 Sekunde erhöhen
if (++ptime->second == 60)
{
ptime->second = 0;
if (++ptime->minute == 60)
{
ptime->minute = 0;
if (++ptime->hour == 24)
ptime->hour = 0;
}
}
}
Die Funktion enthält mehrere indirekte Zugriffe auf die time-Struktur, und es wäre günstig, wenn avr-gcc indirekte Adressierung für die Zugriffe verwendet: Alle Zugriffe geschehen über den Struktur-Zeiger ptime, und ein indirekter Zugriff per LD, LDD, ST oder STT kostet 2 Bytes, während die direkten Spreicherzugriffe LDS und STS jeweils 4 Bytes verbrauchen.
avr-gcc erzeugt mit Optimierung auf Größe jedoch folgenden Code:
next_second:
// if (++ptime->second == 60)
lds r24, time
subi r24, -1
sts time, r24
cpi r24, 60
brne .L1
// ptime->second = 0;
sts time, __zero_reg__
// if (++ptime->minute == 60)
lds r24, time+1
subi r24, -1
sts time+1, r24
cpi r24, 60
brne .L1
// ptime->minute = 0;
sts time+1, __zero_reg__
// if (++ptime->hour == 24)
lds r24,time+2
subi r24, -1
sts time+2, r24
cpi r24, 24
brne .L1
// ptime->hour = 0;
sts time+2, __zero_reg__
.L1:
ret
D.h. obwohl der C-Code indirekt zugreift, enthält das Compilat direkte Zugriffe und der Code belegt 56 Bytes an Flash. Grund ist, daß der Compiler den Inhalt der Variablen ptime kennt und daher die indirekten Struktur-Zugriffe in direkte umwandelt.
Um indirekte Adressierung im erzeugten Code zu erzwingen, kann man die Struktur-Adresse als Parameter übergeben:
void next_second (time_t *ptime)
oder – als hässliche Lösung – dem Compiler des Wissen um den Inhalt von ptime per Inline-Assembler nehmen:
asm ("" : "+r" (ptime));
Dies führt denn zu folgendem Code, der um 25% kleiner ist und nur noch 42 Bytes Flash belegt:
next_second:
// time_t *ptime = &time;
// asm ("" : "+r" (ptime));
ldi r30, lo8(time)
ldi r31, hi8(time)
// if (++ptime->second == 60)
ld r24, Z
subi r24, -1
st Z, r24
cpi r24, 60
brne .L1
// ptime->second = 0;
st Z, __zero_reg__
// if (++ptime->minute == 60)
ldd r24, Z+1
subi r24, -1
std Z+1, r24
cpi r24, 60
brne .L1
// ptime->minute = 0;
std Z+1, __zero_reg__
// if (++ptime->hour == 24)
ldd r24, Z+2
subi r24, -1
std Z+2, r24
cpi r24, 24
brne .L1
// ptime->hour = 0;
std Z+2, __zero_reg__
.L1:
ret
Weil AVRs nur zwei Zeiger-Register haben, die über diese Addressierungsart verfügen (Y und Z), ist diese Optimierung nur eingeschränkt anwendbar.
Werden etwa Z oder Y für andere Zwecke benötigt – etwa für Flash-Adressierung per LPM oder Y als Frame-Pointer gebraucht – verkleinert sich das Anwendungsfeld noch weiter. Zudem müssen genügend Struktur-Zugriffe nacheinander erfolgen, damit ein positiven Effekt auf die Codegröße zustande kommt. Immerhin muss die Adresse geladen werden, das Zeiger-Register wird belegt und steht nicht für andere Variablen zur Verfügung, und im Falle von Y kommen PUSH/POP im Prolog/Epilog hinzu. Werden viele unterschiedliche Struktur-Pointer verwendet ("viele" relativ zur Anzahl der verfügbaren Pointer-Registern), kann die Codegröße auch ansteigen.
Direkte Zugriffe sind in jedem Falle schneller, denn direkte und indirekte Zugriffe kosten die gleiche Zeit. Die Indirekten Zugriffe erfordern jedoch die Initialisierung des Zeiger-Registers und evl PUSH/POP.
Multiplikationen mit Konstanten
Der Compiler instanziiert sofort eine teure allgemeine Bibliotheksfunktion, auch wenn es anders ginge. Ich hatte eine einzige 32-bit Multiplikation mit 10 drin, die mir ein mulsi3 beschert hat. Mit a = (b<<3) + (b<<1) geht es in dem Fall kürzer. Wie gesagt, map-File beobachten.
- Anmerkung
- Variablen als unsigned definieren, dann sollte der Compiler das selbst machen.
- Anmerkung
- Auch Schieben ist teuer auf AVR. Schauen wir uns also mal an, was aus folgendem Code wird:
uint32_t foo (uint32_t i)
{
return i*10;
}
uint32_t bar (uint32_t i)
{
return (i << 1) + (i << 3);
}
00000032 <foo>: 32: 2a e0 ldi r18, 0x0A ; 10 34: 30 e0 ldi r19, 0x00 ; 0 36: 40 e0 ldi r20, 0x00 ; 0 38: 50 e0 ldi r21, 0x00 ; 0 3a: 19 d0 rcall .+50 ; 0x6e <__mulsi3> 3c: 08 95 ret 0000003e <bar>: 3e: 26 2f mov r18, r22 40: 37 2f mov r19, r23 42: 48 2f mov r20, r24 44: 59 2f mov r21, r25 46: 22 0f add r18, r18 48: 33 1f adc r19, r19 4a: 44 1f adc r20, r20 4c: 55 1f adc r21, r21 4e: e3 e0 ldi r30, 0x03 ; 3 50: 66 0f add r22, r22 52: 77 1f adc r23, r23 54: 88 1f adc r24, r24 56: 99 1f adc r25, r25 58: ea 95 dec r30 5a: d1 f7 brne .-12 ; 0x50 <__SREG__+0x11> 5c: 26 0f add r18, r22 5e: 37 1f adc r19, r23 60: 48 1f adc r20, r24 62: 59 1f adc r21, r25 64: 95 2f mov r25, r21 66: 84 2f mov r24, r20 68: 73 2f mov r23, r19 6a: 62 2f mov r22, r18 6c: 08 95 ret 0000006e <__mulsi3>: 6e: ff 27 eor r31, r31 70: ee 27 eor r30, r30 72: bb 27 eor r27, r27 74: aa 27 eor r26, r26 00000076 <__mulsi3_loop>: 76: 60 ff sbrs r22, 0 78: 04 c0 rjmp .+8 ; 0x82 <__mulsi3_skip1> 7a: a2 0f add r26, r18 7c: b3 1f adc r27, r19 7e: e4 1f adc r30, r20 80: f5 1f adc r31, r21 00000082 <__mulsi3_skip1>: 82: 22 0f add r18, r18 84: 33 1f adc r19, r19 86: 44 1f adc r20, r20 88: 55 1f adc r21, r21 8a: 96 95 lsr r25 8c: 87 95 ror r24 8e: 77 95 ror r23 90: 67 95 ror r22 92: 89 f7 brne .-30 ; 0x76 <__mulsi3_loop> 94: 00 97 sbiw r24, 0x00 ; 0 96: 76 07 cpc r23, r22 98: 71 f7 brne .-36 ; 0x76 <__mulsi3_loop> 0000009a <__mulsi3_exit>: 9a: 9f 2f mov r25, r31 9c: 8e 2f mov r24, r30 9e: 7b 2f mov r23, r27 a0: 6a 2f mov r22, r26 a2: 08 95 ret
- Der Funktionsaufruf samt Lib-Funktion ist garnicht sooo teuer. Bereits mit zwei Multiplikationen im Programm — auch einer Multiplikation mit einer anderen Konstanten oder einer Variablen — gewinnt die lib-Version, da der Code wiederverwendet wird. Übrigens sind diese Multiplikationsroutinen und auch die in die libgcc enthaltenen Divisionen keine "normalen" Funktionen wie sie von C erzeugt werden. avr-gcc weiß genau, welche Register diese Routinen belegen und welche nicht. Damit ist der Aufruf einer solchen Funktion billiger als ein herkömmlicher Funktionsaufruf, bei dem die Funktion als Blackbox behandelt werden muss, die alle call-clobbered Register zerstört.
Alle Variablen nur so breit wie nötig
Hatte ich eigentlich schon, nur an einigen wenigen Stellen war ich da etwas nachlässig. Mitunter reicht ein kleinerer Typ doch, wenn man z. B. vorher geeignet skaliert. Am besten nur die skalaren Typen aus <stdint.h> verwenden, das erleichtert auch das Folgende. Bei RAM Knappheit: kann ich Strings sinnvollerweise aus dem RAM ins Flash verbannen? Kann ich es mir leisten mehrere Flag-Variablen in ein Byte zusammenzufassen, auch wenn dann die Zugriffe möglicherweise etwas langsamer werden.
Logische Operatoren werden auf int-Größe erweitert
Obwohl der AVR ein 8-Bit Controller ist, verlangt der C-Standard, daß ein int mindestens 16 Bits groß ist. Wegen den Promotion-Regeln von C werden 8-Bit Operanden in Operationen auf 16 Bits aufgeweitet. Beispiel:
#include <stdint.h>
uint8_t c;
void foo (uint8_t a, uint8_t b)
{
if (a == ~b)
c = 0;
}
Den zweiten Operanden mit dem Komplement weitet der Compiler auf 16 Bit auf, wodurch alle high-Bits von ~b gesetzt werden. Der Compiler erkennt, daß der Vergleich niemals wahr ist und optimiert ihn weg:
foo:
ret
Ein Cast verhindert dieses:
void foo (uint8_t a, uint8_t b)
{
if (a == (uint8_t) ~b)
c = 0;
}
was übersetzt wird zu:
foo:
// if (a == (uint8_t) ~b)
com r22
cpse r24, r22
rjmp .L1
// c = 0
sts c, __zero_reg__
.L1:
ret
- Achtung
- Tatsächlich handelt es sich dabei nicht um ein Optimierungsproblem, sondern einen typischen Programmierfehler. Die beiden Varianten sind keineswegs identisch! Bei Variablen vom Typ uint8_t ist der Ausdruck (a == ~b) immer falsch! Dies unterstreicht mehr als eindringlich, daß eine gute Kenntnis der Programmiersprache das A und O jedes erfolgreichen Programmierers ist. Kenne zuallerst mal deine Programmiersprache - und zwar auch in den intimen Details!
Speichern von globalen Flags
Oft werden in den Programmen Flags verwendet, um beispielsweise eingetroffene Interrupts in der main-Routine auszuwerten. Hierzu wird üblicherweise eine globale Variable verwendet.
Um den Wert dieser Variable abzufragen, muss sie jedoch erst aus dem SRAM in ein Register geladen werden, und kann dann erst auf ihren Status hin überprüft werden. Eine Möglichkeit ist, der globalen Variablen ein einziges Register fest zuzuordnen:
register uint8_t counter8_1 asm("r2");
register uint8_t counter8_2 asm("r3");
register uint16_t counter16_1 asm("r4"); // r4:r5
register uint16_t counter16_2 asm("r6"); // r6:r7
siehe auch: http://www.nongnu.org/avr-libc/user-manual/FAQ.html#faq_regbind
- Warnung
- Dieses Vorgehen verändert das ABI! Um dieses Feature fehlerfrei anzuwenden, ist einiges an Wissen über die Interna vom GCC notwendig[3]. Auch ein korrekt funktionierendes Programm ist keine Garantie dafür, daß die globalen Register fehlerfrei implementiert wurden. Unter Umständen bringen erst spätere Codeänderungen/-erweiterung den Fehler zum Vorschein, und weil der Fehler vorher nicht akut war, sucht man sich den Wolf an der falschen Stelle im Code anstatt bei der globalen Registern. Siehe auch Globale Register.
Als Alternative kann man ein nicht verwendetes Register des I/O-Bereichs verwenden. Dabei würde sich z. B. das Register eines zweiten UARTs oder das EEPROM-Register anbieten, falls diese nicht benötigt werden. Neuere AVR-Modelle besitzen für diesen Zweck 3 frei verwendbare Register (GPIOR0-2), eines davon im bitadressierbaren I/O-Bereich (GPIOR0), die neueren ATXmegas haben sogar 16 Stück! Damit kann man sehr viele Flags sehr einfach, schnell und atomar setzen, löschen und abfragen, denn der Zugiff wird in einzelne Assemblerbefehle sbi/cbi bzw. sbis/sbic umgesetzt. Die Register GPIOR1 und GPIOR2 sind bei ATmegas nicht bitadressierbar (bei vielen ATtiny dagegen schon), aber immerhin ist der Zugiff über in/out (1 Takt) schneller als auf den RAM (2 Takte). Diese Methode greift nicht in die interne Verarbeitung des Compilers ein und ist damit einfach und sicher anwendbar.
#define FLAG_UART 3
GPIOR0 |= (1<<FLAG_UART);
...
if ( GPIOR0 & (1<<FLAG_UART) ) { ... }
Puffern von volatile-Variablen
Der Compiler behandelt volatile-Variablen bei mehreren Manipulationen wie heiße Kartoffeln. Für jeden einzelnen Vorgang wiederholt sich das Spiel:
- aus dem Speicher holen
- bearbeiten
- zurückspeichern
Unter Umständen ist dieses Verhalten unsinnig. Ein Minimalbeispiel:
volatile char var;
ISR()
{
var++;
if (var > 100)
var = 0;
}
void main (void)
{
while (1)
printf (var);
}
Hier wird var pro ISR-Ausführung zwei mal aus dem RAM geholt und zurückgeschrieben. Das ist überflüssig, weil die Interruptroutine nicht unterbrochen werden kann. Aus Sicht der ISR bräuchte man eigentlich kein volatile, kann es aber wegen des Zugriffs aus main heraus nicht weglassen. Eine Lösung findet sich im folgenden Schnipsel:
volatile char var;
ISR()
{
char temp = var;
if (++temp > 100)
temp=0;
var = temp;
}
void main (void)
{
while (1)
printf (var);
}
Hier wird die globale Variable var in der lokalen Variable temp gepuffert. Ein Nachteil durch das Anlegen von temp ergibt sich nicht, da das dafür verwendete Register für die Manipulation sowieso benötigt wird.
Wie alle Optimierungen kann dieses Vorgehen auch nach hinten losgehen: Wenn Laden und Zurückspeichern von var weit auseinanderliegen (extrem lange ISR), müllt man sich die Register zu. Im schlimmsten Fall wird temp sogar zwischenzeitlich auf dem Stack ausgelagert.
Schleifen
Bei Schleifen, die eine bestimmte Anzahl an Durchläufen ausgeführt werden sollen, ist es besser den Schleifenzähler vorher auf einen Wert zu setzen, und am Ende einer Do-While Schleife diesen zu dekrementieren. So beschränkt sich die Sprungbedingung auf ein brne (branch if not equal).
Beispiel:
uint8_t counter;
counter = 100;
do
{
// mach irgendetwas
} while (--counter);
Achtung: Derartige Optimierungen sind meistens zweifelhaft und sehr Compilerabhängig. Compiler betreiben aufwendige Code- und Datenflussanalysen, in denen sie viele Dinge ins Kalkül ziehen, die ein menschlicher Programmierer im Regelfall nicht mehr überblicken kann. Von einem ordentlichen Compiler kann erwartet werden, dass er derartige Umstellungen (sofern sie möglich sind) in Eigenregie erledigt. Auch wenn es einzelne Compiler bzw. Compilerversionen gibt, in denen ein manueller Umbau einer derartigen Schleife tatsächlich eine Verbesserung bringt, sollte man immer im Hinterkopf behalten, dass sich in der nächsten Compilerversion alles umdrehen kann. Im Zweifelsfall lieber die Schleifenvariante benutzen, die der Situation angemessen ist und die den gewünschten Vorgang am klarsten beschreibt. Wenn dieser Vorgang im weitesten Sinne einen Countdown darstellt, dann ist natürlich nichts gegen eine derartige Schleifenkonstruktion einzuwenden.
Schiebeoperationen
Oft benötigt man Schiebeoperationen, um Daten Bit für Bit zu verarbeiten, z.B. für Soft-SPI. Hier empfiehlt es sich DRINGEND, möglichst nur Schiebeoperationen mit konstanten Verschiebungszähler durchzuführen, diese sind auf dem AVR deutlich schneller als Schiebeoperationen mit Variablen, welche meist durch mehrfache Funktionsaufrufe mit Scheifen etc. gelöst werden. Dabei verwendet man oft konstante Bitmasken, um die einzelnen Bits nacheinander zu maskieren.
Noch schneller geht das Schieben von Konstanten um einen konstanten Wert, denn das macht der Compiler bei eingeschalteter Optimierung noch während des Compilierens. Gerade für schnelle IO-Operationen kommt dann meist nur ein einziger ASM-Befehl heraus (sbi, cbi).
Ebenso ist es wenig empfehlenswert, 32 oder gar 64 Bit Datentypen zu schieben, das dauert sehr lange. Besser ist es, die Daten als Array von 8 Bit Werten zu handhaben und diese zu schieben, das ist meist deutlich schneller. Gute Beispiele findet man hier und hier.
#define DATAPORT PORTD
#define IO_BIT PD4
uint8_t a, b, i;
i=5
a = b << i; // variabler Verschiebewert, langsam
a = b << 5; // konstanter Verschiebewert, schnell
DATAPORT |= (1<<IO_BIT); // alles Konstanten, sehr schnell
Optimierung der Ausführungsgeschwindigkeit
Hierzu gibt es schon eine Application-Note von Atmel. Diese AppNote bezieht sich auf den IAR-Compiler. Die darin genannten "Optimierungen" sind für avr-gcc größtenteils obsolet oder bleiben bestenfalls ohne Effekt.
Weblinks:
- AVR035: Efficient C Coding for AVR
- Program optimization auf Wikipedia, engl.
- Sensor smoothing and optimised maths on the Arduino - Multiplikation vs. Division
Fußnoten
- ↑ Für avr-gcc 4.x sehen die Kommentare anders aus oder fehlen je nach Compilerversion ganz
- ↑ roboternetz.de: Assembler-Dump erstellen mit avr-gcc
Links
Atmel AVR4027: Tips and Tricks to Optimize Your C Code for 8-bit AVR Microcontrollers (Beispiel-Code)