AVR-GCC-Codeoptimierung

Wechseln zu: Navigation, Suche

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
  • 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
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 daß 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))!
-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.

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:

Fußnoten

  1. Für avr-gcc 4.x sehen die Kommentare anders aus oder fehlen je nach Compilerversion ganz
  2. 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)