Optimierfreundliche Software Tipps und Tricks für den optimalen Code

Code-Optimierer tragen viel dazu bei, effizienten und schnellen Assembler-Code zu erstellen. Aber die Art, wie der Quellcode geschrieben wurde, bestimmt die Möglichkeiten des Optimierers. Manchmal wirken sich kleine Änderungen des Quellcodes erheblich auf die Effizienz des vom Compiler generierten Codes aus.

Ein optimierender Compiler versucht Code zu erzeugen, der kompakt und schnell ist. Dies geschieht durch wiederholtes Umformen des Quellcodes. Die meisten Optimierungen folgen mathematischen oder logischen Regeln auf der Basis einer stichhaltigen theoretischen Grundlage. Andere Umformungen basieren auf Heuristik, also vor allem auf Erfahrungen, wonach einige Umwandlungen oft zu guten Code-Resultaten führen oder weitere Optimierungsmöglichkeiten eröffnen. Ob sich eine Programmoptimierung erfolgreich anwenden lässt oder nicht, bestimmt unter anderem auch wesentlich die Art, wie der Quellcode geschrieben wurde. Daher wirken sich manchmal kleine Änderungen am Quellcode ganz erheblich auf die Effizienz des vom Compiler generierten Codes aus.

Beim Schreiben des Codes sind verschiedene Aspekte zu beachten; einer davon ist aber entscheidend: Der Versuch, Code mit so wenig Zeilen wie möglich zu schreiben – mittels Postinkrementen, Ausdrücken, die »?:« verwenden und Komma-Ausdrücken, um viele Nebenerscheinungen in einen einzigen Ausdruck zu zwängen – bewirkt nicht, dass der Compiler effizienteren Code generiert. Der Quellcode wird nur verschachtelt und schwer wartbar. Ein Postinkrement oder eine Zuordnung lässt sich leicht übersehen, wenn sie sich innerhalb eines komplexen Ausdrucks befindet.

Code sollte besser in einer Art geschrieben werden, die einfach zu lesen ist. Als Beispiel diene eine einfache Schleife wie im Codebeispiel 1.

Verschiedene Faktoren können die Effizienz des vom Compiler erstellten Codes beeinflussen. Zuerst sollte die Art der Index-Variable dem Pointer entsprechen. Ein Array-Ausdruck wie a[i] ist nur die Abkürzung für *(&a[0]+i*size of(a[0])). Oder, anders ausgedrückt: »Addiere den Offset der Elementzahl i zum Pointer auf die Adresse des ersten Elements im Array a.«

Bei der Pointer-Arithmetik ist es hilfreich, wenn der Index-Ausdruck genauso groß ist wie der Pointer (außer für _far- Pointer, wo sich die Größe des Pointers und des Index-Ausdrucks unterscheiden). Ist der Typ des Index-Ausdrucks kleiner als der Pointer, muss der Compiler den Index-Ausdruck verlängern, bevor er ihn zum Pointer hinzufügt. Falls in einer konkreten Anwendung der Stack-Platz wertvoller ist als die Codegröße, kann es dennoch sinnvoll sein, einen kleineren Typ für die Index-Variable zu wählen. Dies geht aber meist zu Lasten der Codegröße und der Ausführungszeit. Eine Typumwandlung von Variablen kann zudem mehrere Schleifenoptimierungen verhindern

Auf die Schleifenbedingung kommt es an

Ebenfalls von großer Bedeutung ist die Schleifenbedingung. Viele Schleifenoptimierungen lassen sich nur durchführen, wenn sich die Anzahl der Iterationen berechnen lässt, bevor der Prozessor in die Schleife einsteigt. Leider ist es gar nicht immer so einfach, den Anfangswert vom Endwert abzuziehen und durch das Inkrement zu teilen. Was passiert zum Beispiel, wenn i ein »unsigned char«, n aber eine Integervariable ist und den Wert 1000 aufweist? Sehr wahrscheinlich hat der Programmierer nicht beabsichtigt, dass der Wert von n die Zahl 255 übersteigt. Ist dieser aber auf 1000 oder höher gesetzt, unterliegt die Variable i einem Überlauf, lange bevor sie 1000 erreicht. Es ist unwahrscheinlich, dass der Programmierer eine unendliche Schleife erzeugen wollte, die wiederholt 256 Elemente von b nach a kopiert.

Aber der Compiler kann die Absicht des Programmierers nicht erahnen. Er muss vom Schlimmsten ausgehen und kann keine der Optimierungen anwenden, die die Durchlaufzahl vor dem Eintreten der Schleife benötigen.

Die Bezugsoperatoren <= und >= sollten ebenfalls in Schleifen vermieden werden, bei denen der Endwert für die Abbruchbedingung eine Variable ist. Ist die Schleifenbedingung zum Beispiel i <= n, besteht die Möglichkeit, dass n den höchsten Wert hat, der sich innerhalb des Typs darstellen lässt. Der Compiler muss also annehmen, dass es sich möglicherweise um eine Endlosschleife handelt.

Nützliche C-Kurzbefehle, die in fast jeder Schleife vorkommen, sind die Operatoren »++« und »--«. Es gibt sie in zwei Arten: ++i inkrementiert i vor seinem Einsatz und i++ inkrementiert i nach seinem Einsatz. Die Befehle mögen ähnlich aussehen, die feinen Unterschiede können einem Optimierer aber sehr viel Mühe bereiten. Der C-Standard für die Semantik von Postinkrementen besagt: »Das Ergebnis des Postfix-++-Operators ist der Wert des Operanden. Nach dem Einholen des Ergebnisses wird der Wert des Operanden inkrementiert.«

Die meisten MCUs verfügen über eine Adressierungsart, bei der ein Pointer nach einer Lade- oder Speicheroperation inkrementiert wird, doch nur wenige MCUs können ein Postinkrement von Nicht-Pointer-Typen mit der gleichen Effizienz verarbeiten. Um den Standard einzuhalten, kann es also durchaus nötig sein, dass der Compiler den Operanden zu einer temporären Variable kopieren muss, bevor er das Inkrement durchführen kann.

Bei geradlinigem Code lässt sich das Inkrement aus dem Ausdruck entfernen und im Anschluss daran platzieren. Liegt zum Beispiel ein Ausdruck wie foo = a[i++] vor, kann dieser ausgeführt werden als: foo = a[i] gefolgt vom Ausdruck i = i + 1.

Was passiert aber, wenn das Postinkrement Teil einer Bedingung in einer While-Schleife ist? Es gibt nirgendwo eine Möglichkeit, das Inkrement nach der Bedingung einzufügen, es muss also vor der Abfrage ausgeführt werden. Eine einfache Schleife wie im Codebeispiel 2 muss der Compiler folglich mit Hilfe einer temporären Variable ausführen.

Ist der Wert von i nach Abschluss der Schleife unerheblich, ist es besser, das Inkrement innerhalb der Schleife zu platzieren. Der fast identische Quellcode für die Schleife im Codebeispiel 3 kommt durch diesen Kunstgriff ohne temporäre Variablen aus.

Entwickler optimierender Compiler sind sich der Komplexität bewusst, die mit Postinkrementen einhergehen. Obwohl jeder Versuch unternommen wird, diese Muster zu identifizieren und so viele temporäre Variablen wie möglich zu beseitigen, gibt es immer wieder Fälle, bei denen es schwierig ist, effizienten Code zu generieren – vor allem wenn die Schleifenbedingungen komplexer ausfallen als in den hier gezeigten Beispielen.

Meist ist es besser, einen komplexen Ausdruck in mehrere kleinere Ausdrücke zu zerlegen, genauso wie die Schleifenbedingung in Codebeispiel 3 in einen Test und in ein Inkrement aufgeteilt wurde.