Sollte ich meinen C-Code umgestalten, um ihn für einen eingebetteten Mikrocontroller zu optimieren?

Unten ist ein Code, der auf einem 8-Bit-Mikrocontroller implementiert ist.

Der folgende Kommentar wurde zu einer anderen Frage gepostet :

Da Ihr Code die Variable nicht verwendet i, warum nicht einfach while(length--)anstelle der ersten forSchleife?

Es schlägt vor, die for(..)Schleife für a zu ändern while(..). Wird dies einen praktischen Unterschied machen, wie optimiert der Code für einen eingebetteten Mikrocontroller ist?

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    for (uint16_t i=0; i < length; i++) { 
        crc = crc ^ *buffer++; 

        for (uint16_t j=0; j < 8; j++) { 
           if (crc & 1) 
              crc = (crc >> 1) ^ 0xEDB88320; 
           else 
              crc = crc >> 1; 
        } 
    } 

   return crc; 
}

Hinweis: Ich habe diese Frage geschrieben und sie selbst beantwortet, nachdem ich Anfang dieser Woche einen Kommentar gepostet hatte. Dieser Kommentar wurde positiv bewertet, aber ich hatte das Gefühl, ich sollte ihn mit einigen tatsächlichen Tests untermauern. Andere Antworten wären nützlich, aber dies war eher als allgemeiner Beweis für einen Punkt als als eine spezifische Frage zum Algorithmus gedacht.

Wenn dies auf einer 8-Bit-Maschine ist, warum sollte J absichtlich eine 16-Bit-Ganzzahl sein, wenn es nur von 0 bis 7 zählt?
Haben Sie ein Profil erstellt, um zu sehen, ob Sie es tatsächlich optimieren müssen?
Mikrocontroller enthalten normalerweise eine Anweisung, die dekrementiert und springt, wenn sie gleich/anders als Null ist. Das Erhöhen und Vergleichen mit einer Konstanten oder Variablen erfordert mindestens eine weitere Anweisung pro Schleife. Als allgemeine gute Praxis in Bezug auf das Timing sollten Sie also Schleifen erstellen, die einen Zähler verwenden, der am Ende auf Null verringert wird.
Der einzige Grund, warum ich dies geschrieben habe, ist, dass ich in einem anderen Thread Ratschläge gegeben habe, von denen ich beweisen wollte, dass sie sich unter bestimmten Umständen lohnen.
@OlinLathrop interessanter Punkt, ich frage mich, ob entweder XC8 oder GCC das entdeckt und optimiert haben. Das sollte für den Compiler ein einfacher Fang sein.
@OlinLathrop getestet und das spart noch mehr Platz, meine Antwort unten aktualisiert. Danke für den Hinweis.

Antworten (3)

Die klassischen drei Regeln der Optimierung:

  1. nicht.
  2. noch nicht.
  3. Profil vor der Optimierung.

Es schadet nicht, von vornherein sauberen, ordentlichen Code zu schreiben, aber vorzeitige Optimierung ist eine Verschwendung von Zeit und Mühe, die an anderer Stelle sinnvoller eingesetzt werden könnte.

Puffer-CRC-Routinen können oft ein Engpass auf 8-Bit-Mikros sein, selbst wenn sie optimal geschrieben sind . Außerdem sind viele 8-Bit-Mikros wie der zitierte Code wahrscheinlich viel langsamer als optimaler Code. Für viele Zwecke kann C-Code nahe genug an die Assemblierung kommen, um eine weitere Optimierung nicht wert zu sein, aber bestimmte Arten von Schleifenkonstrukten erfordern Optimierungen, die kein mir bekannter Compiler möglicherweise vornehmen kann.
Zu Bricks Punkt. „[...] vorzeitige Optimierung ist die Wurzel allen Übels“, so Donald Knuth.
Ich hasse es wirklich, wie weit die Leute diesen Knuth-Kommentar gehen. Sie sollten zumindest beim Schreiben von Code darauf achten, dass er von Anfang an einigermaßen effizient ist. Die Leute zitieren Knuth als Entschuldigung für ihren schlampigen Code und werden auch rechtschaffen darüber. Sie sollten gute Programmierpraktiken entwickeln, die Ihnen helfen, guten Code richtig zu machen. Ich sage nicht, dass Sie alles zerlegen und in der Pipeline analysieren sollen ... aber wie @supercat sagt, wenn Sie überhaupt auf Ihr Problem achten, werden Sie wahrscheinlich erkennen, wie viel Arbeit Ihr CRC-Algorithmus leisten wird. In vielen Situationen wird es absolut wichtig sein.
Eine CRC-Routine auf einem 8-Bit-Prozessor muss möglicherweise überhaupt nicht effizient sein, oder sie könnte absolut kritisch sein. Ohne weitere Einzelheiten ist dies KEIN gutes Beispiel für den "allgemeinen Beweis" des OP für oder gegen die Optimierung.
@darron: Das Schlüsselwort ist vorzeitige Optimierung, insbesondere in Fällen, in denen zu viel Aufmerksamkeit auf die Optimierung eines Teils eines Programms gelegt wird, was dazu führen kann, dass ein wichtigerer Teil vernachlässigt wird, der einen großen Engpass darstellt (z. B. ich erinnere mich, dass ich ein Programm übernommen habe, für das jemand geschrieben hat ein Festkomma-DSP. Alles wurde in Assembler-Code geschrieben, mit Ausnahme der Mathematik, die in C mit Gleitkomma geschrieben wurde, und IIRC benötigte etwa 60 ms, um alle 25 ms Audio zu verarbeiten). Ich glaube, mein Umschreiben endete mit ein paar hundert Zeilen Assembler-Code für Interrupt-Handler, ein paar Dutzend für Mathematik und ...
... fast alles andere in C. Ich habe nicht einmal versucht, die mathematischen Routinen in C zu schreiben. Der Assembler-Code-Teil der mathematischen Verarbeitung beanspruchte wahrscheinlich etwa 5 % der gesamten CPU-Zeit, und C hätte es mindestens getan 3-5x so langsam. Die CPU-Auslastung mit Festkomma-Mathematik in C lag möglicherweise unter 100 %, aber es ist viel einfacher, über Timings nachzudenken, wenn die Last niedrig ist, als wenn sie hoch ist.
@supercat Beachten Sie, dass die Regelaussage von RedGrittyBrick das Wort "vorzeitig" nicht erwähnt. Zumindest erscheint es hinterher ... aber VIELE Leute lassen dieses Wort einfach ganz weg. Ich schätze, mein Hauptärgernis hier ist, dass ich glaube, dass diese Logik "nur optimieren, wenn Sie müssen" allgemeiner zu einer beschissenen Architektur führt ... weil viele Leistungsprobleme architektonisch sind und es wirklich schmerzhaft ist, das später zu ändern. Ich denke, Knuth ging von einer Grundkompetenz aus, die sich heutzutage leider nicht mehr in der Realität widerspiegelt.
@darron: Ich interpretiere Knuths Aussage als „Optimieren, bevor man weiß, wo es gebraucht wird, wird oft dazu führen, dass Zeit verschwendet wird, um Dinge zu optimieren, die nicht wichtig sind, anstatt Dinge [nicht unbedingt nur die Leistung], die es tun . Ich glaube nicht, dass Profilerstellung immer erforderlich ist ; Wenn Sie beispielsweise zwischen einem einfachen O(N^3)-Algorithmus oder einem komplexen O(NlgN)-Algorithmus wählen, wäre das Wissen, dass N 1000 erreichen könnte, auch ohne Profilerstellung ein ziemlich starkes Argument gegen den ersteren, und zu wissen, dass N dies selten tun würde 3 überschreiten und 5 niemals überschreiten, wäre ein starkes Argument gegen letzteres.
@supercat Nun, ich wünschte, er hätte es auf deine Art gesagt. Ihr ganzer Absatz, wirklich. Du sagst es besser als ich. :)
Ein Ingenieur, der ständig die Spezifikationen übertrifft, verschwendet Zeit und Geld

Zunächst ist zu überlegen, was optimiert werden muss. Benötigt der Code weniger Programmspeicher oder soll er schneller laufen? Manchmal können diese Dinge beide erreicht werden, indem die Anzahl der Befehle reduziert wird, dies hängt jedoch vom zugrunde liegenden Mikrocontroller und der Anzahl der Zyklen ab, die jeder Befehl benötigt.

Es ist durchaus möglich, kleineren Code zu generieren, der mehr Zyklen zum Ausführen benötigt, insbesondere wenn Verzweigungen beteiligt sind.

Um mit dem Experimentieren zu beginnen, ist es wichtig, den kleinstmöglichen Codeabschnitt zu isolieren. Ich habe den folgenden einfachen Testfall erstellt, der die Funktion aus der Frage enthält und zwischen verschiedenen Mikrocontroller-Familien portierbar ist:

#include <stdint.h>

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    uint16_t i;
    uint16_t j;

    for (i = 0; i < length; i++) {
        crc = crc ^ *buffer++;

        for (j = 0; j < 8; j++) {
           if (crc & 1) 
              crc = (crc >> 1) ^ 0xEDB88320; 
           else 
              crc = crc >> 1;
        }
    }

   return crc;
}

int main(int argc, char** argv) {
    uint8_t data[] = "ABCDEF";
    uint32_t ret = 0;
    ret = MyFunction(ret, data, 6);
    while(1);
}

Wie in den Kommentaren zur anderen Frage erwähnt, wird der Wert der Variablen iniemals direkt verwendet, sondern immer nur mit verglichen length. Daher könnten wir es wie folgt umschreiben:

uint32_t MyFunction(uint32_t crc, uint8_t *buffer, uint16_t length) {
    uint16_t j;

    while (length--) {
      // .. do work ..
    }
    return crc;
}

Zu Vergleichszwecken habe ich avr-gcc Version 4.7.2 (für atmega8) und XC8 1.21 von Microchip (für PIC 18F) verwendet. Für XC8 habe ich PRO-Optimierungen aktiviert, für avr-gcc waren die angegebenen Argumente: avr-gcc -g -c -Os -Wall -mmcu=atmega8 test.c -o test-Os.

Beachten Sie, dass es wichtig ist zu überprüfen, dass retes nicht wegoptimiert ist, da der Compiler denkt, dass es nicht verwendet wird. Beide Varianten des C-Codes generieren dieselbe Assembly aus avr-gcc. Allerdings bemerkt XC8 die ungenutzte Variable nicht, daher kompiliert der Code mit einer while(..)Schleife auf 4 Bytes kleiner mit 2 RAM-Bytes gespart.

Wie in einem Kommentar zu dieser Frage erwähnt, wäre es auch effizienter, jeine zu erstellen uint8_t, da die Eingabe nie breiter als 8 Bit sein wird. Tests auf XC8 zeigen, dass durch diese Änderung weitere 8 Byte Programmplatz und 1 Byte RAM eingespart werden. Es reduziert auch die mit avr-gcc erzeugte Ausgabe um 4 Bytes und ein Registerbyte.

Zusammenfassend lohnt es sich immer, dem Compiler die beste Chance zu geben, guten Code zu generieren , in diesem Fall, indem keine zusätzlichen unnötigen Variablen verwendet werden und die kleinstmögliche Speicherklasse verwendet wird. Einige optimierende Compiler kommen besser zurecht als andere, aber beide funktionieren gut, wenn der eingegebene C-Code gut durchdacht ist.

Sie können für andere Dinge als nur Geschwindigkeit oder Größe optimieren. Die Optimierung der Energieeffizienz ist eine weitere beliebte Option, aber auch die Reaktionszeit oder die Reaktionszeitkonsistenz sind andere.

So etwas kann auf vielen Prozessoren in eine kleine Routine in Assemblersprache umgeschrieben werden, die viel schneller läuft als jede mögliche C-Implementierung. Auf einem PIC könnte die CRC-Berechnung beispielsweise mit 72 Anweisungen geschrieben werden, wenn sich _crc in der aktuell ausgewählten Bank befindet (Code für PICs der 16F-Serie).

    movf  _crc+1,w
    btfss _crc,0
     xorlw   0xNN  ; Would need to figure out proper value
    btfss _crc,1
     xorlw   0xNN  ; Would need to figure out proper value
    .. six more such instruction pairs
    movwf btemp+0  ; LSB of return
    movf  _crc+2,w
    btfss _crc,0
     xorlw   0xNN  ; Would need to figure out proper value
    btfss _crc,1
     xorlw   0xNN  ; Would need to figure out proper value
    .. six more such instruction pairs
    movwf btemp+1 ; Next byte of return
    movf  _crc+3,w
    .. eight more instruction pairs
    movwf btemp+2,w
    movlw 0
    .. eight more instruction pairs
    movwf btemp+3

Nicht das kompakteste Ding der Welt, aber es würde den CRC32 für ein eingehendes Byte in 72 Zyklen aktualisieren. Wenn man alternativ ein Teil der 18F-Serie verwendet und den Coderaum sparen könnte, könnte man Tabellen im Wert von 1 KByte verwenden (organisiert als vier seitenausgerichtete 256-Byte-Stücke). Code wäre dann so etwas wie:

    movf  _crc,w,b
    movwf _TBLPTRL,c
    movlw TabUpperAddress
    movwf _TBLPTRU,c
    movlw TabHighAddress
    movwf _TBLPTRH,c
    tblrd *
    movff _TABLAT,btemp+3
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+1,w,b
    movff _TABLAT,btemp+0
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+2,w,b
    movff _TABLAT,btemp+1
    incf  _TBLPTRH,c
    tblrd *
    xorwf _crc+3,w,b
    movff _TABLAT,btemp+2

Etwas schneller, aber es würde zusätzlich zum Code selbst Datentabellen im Wert von 1 KByte erfordern.