zurück zum Artikel

Versteckte Risiken beim Kompilieren von Embedded Software

Daniel Penning
Verstecke Risiken beim Kompilieren von Embedded Software

Um sicherzustellen, das Embedded Software korrekt läuft, sind der passende Compiler und Tests auf der Zielplattform unerlässlich.

Embedded Firmware entsteht üblicherweise auf einem Desktop-PC. Code ohne direkte Abhängigkeit zur Hardware lässt sich mit einem herkömmlichen Compiler an der Workstation kompilieren und bereits auf dem Entwicklungsrechner (Off-Target) ausführen. Viele Teams nutzen diesen Ansatz, um lange Kompilier-Flash-Debug-Schleifen auf dem Mikrocontroller zu umgehen.

Dabei gilt es jedoch zu beachten, dass der auf die Weise getestete Code beim Ausführen auf dem echten Zielsystem (On-Target) unter Umständen ein anderes Verhalten zeigt. Im besten Fall kommt es bereits beim Kompilieren mit dem Cross-Compiler zu einem Fehler. Mit weniger Glück verhält sich der kompilierte Code in Details anders. Im Off-Target-Verfahren verifizierter Code ist plötzlich nicht mehr korrekt.

Vielfach kommt der Off-Target-Ansatz für Unit-Tests zum Einsatz, was im Hinblick auf ein effizientes Entwickeln sinnvoll ist. Bleiben aber zusätzliche On-Target-Tests – mit potenziell unterschiedlichen Ergebnissen – aus, ergibt sich schnell eine rein gefühlte Sicherheit.

Die Einflüsse beim Erzeugen eines ausführbaren Binary (Abb. 1)

Die Einflüsse beim Erzeugen eines ausführbaren Binary (Abb. 1)

Der Code wird erst durch eine Toolchain mit Compiler und Linker zu einem ausführbaren Binary. Abbildung 1 zeigt die Einflussfaktoren auf diesen Prozess. Einen wichtigen, wenn auch subtilen Einluss, hat der Compiler. Das wirft die Frage auf, wie zwischen zwei standardkonformen Compilern ein Unterschied im Ergebnis entstehen kann. Ist die eindeutige Auslegung von Code nicht gerade der Hauptgrund für einen Sprachstandard?

Der Begriff "undefined behaviour" ist vielen C- und C++-Entwicklern bekannt. Dabei lassen die Standards den Compilern mit Absicht Freiraum in der Interpretation. Weniger bewusst ist die Tatsache, dass es dadurch zu subtilen Abweichungen im Off-Target-Verhalten kommen kann.

Insgesamt gibt es drei Kategorien von Freiräumen, die der C++-Standard konformen Compilern einräumt. Der folgende Überblick ist der Definition des noch aktuellen C++17-Standard entnommen, und der C18-Standard definiert die Begriffe sinngemäß gleich.

  1. unspecified-behaviour [1]: Verhalten für ein wohlgeformtes Programmkonstrukt und korrekte Daten, das von der Implementierung abhängt ("behavior, for a well-formed program construct and correct data, that depends on the implementation")
  2. implementation-defined behaviour [2]: Verhalten für ein wohlgeformtes Programmkonstrukt und korrekte Daten, das von der Implementierung abhängt und das jede Implementierung dokumentiert. ("behavior, for a well-formed program construct and correct data, that depends on the implementation and that each implementation documents")
  3. undefined behaviour [3]: Verhalten, für das dieser internationale Standard keine Anforderungen vorgibt.

Unspezifiziertes Verhalten bezieht sich auf korrekte Programme, die Sprachmittel in einer nicht näher spezifizierten Weise verwenden. In dem Fall kann der Compiler entscheiden, welches Verhalten er einsetzt. Im Gegensatz zu "undefined behaviour" sind die möglichen Verhaltensweisen jedoch überschaubar und nachvollziehbar.

Überraschenderweise ist einer der Kernaspekte der Sprache undefiniert: Die Ausführungsreihenfolge bei Ausdrücken.

foo(fun1(), fun2());

Die Zeile ruft eine Funktion foo mit zwei Parametern auf, die jeweils Rückgabewerte von zwei weiteren Funktionen sind.

Der Standard definiert für solche Ausdrücke nicht, ob zunächst fun1 oder fun2 ausgeführt wird. Und tatsächlich nutzen unterschiedliche Compiler beide möglichen Varianten. Das folgende Kurzbeispiel demonstriert die Reihenfolge der Auswertung:

#include <iostream>

int fun1() { printf("fun1() \n"); return 0; }
int fun2() { printf("fun2() \n"); return 0; }

void foo(int x, int y) { printf("foo() \n"); }

int main() {
    foo(fun1(), fun2());
}

Die Ausgabe auf einem typischen Desktop-Compiler (x86-64 mit gcc 9.2) liefert folgendes Ergebnis [4]:

fun2() 
fun1() 
foo() 

Ein typischer Embedded Compiler (arm-gcc-none-eabi 8-2018-q4) auf einem ARM Cortex-M4 erzeugt aus derselben Zeile dagegen folgenden Code:

fun1() 
fun2() 
foo() 

Der Embedded hat gegenüber dem Desktop Compiler somit eine verdrehte Reihenfolge in der Ausführung.

Dieses Beispiel führt lediglich zu unterschiedlichen Ausgaben. Beide Funktionen geben immer 0 zurück. Man kann sich jedoch leicht ausmalen, welche Effekte entstehen, wenn fun1 und fun2 auf gemeinsame Daten zugreifen und diese auswerten und verändern.

Nach Wissen des Autors existiert leider keine kompakte Liste für unspezifiziertes Verhalten. Das ist für eine weitere Kategorie von Compiler-abhängigem Verhalten glücklicherweise anders.

Implementierungs-abhängiges Verhalten unterscheidet sich in einem wichtigen Punkt von unspezifiziertem Verhalten: Der Compiler-Hersteller muss dokumentieren, welches Verhalten der Compiler verwendet.

Der C++-Standard selbst enthält eine Liste [5] mit allen Stellen, die einer solchen Dokumentationspflicht unterworfen sind. Mit 235 Punkten ist die Liste allerdings relativ umfangreich.

Einige Punkte erscheinen auf Anhieb plausibel: Das Alignment von Datentypen [6] muss von der Zielarchitektur abhängig sein. Der Compiler muss beispielsweise berücksichtigen, wenn die Hardware den Zugriff auf 4-byte-Integer nur an 4-byte-Adressen (0x00, 0x04, 0x08, ...) unterstützt. Das einheitliche Festlegen solcher Charakteristiken über viele Hardwarearchitekturen hinweg in einer aussagekräftigen Weise ist schlicht nicht möglich. Die Plattformunabhängigkeit gehört zu den wichtigen Eigenschaften von C und C++. Es ist somit die beste Wahl, diese Punkte als implementierungsabhängig zu definieren.

Andere Punkte der Liste bringen keine dermaßen deutliche Begründung mit. Unter anderem ist nicht direkt ersichtlich, warum der Wert von NULL als implementierungs-abhängig markiert ist.

Am Beispiel von Aufzählungen mit enum lässt sich zeigen, wie aus der Freiheit des Compilers funktional unterschiedlicher Code entsteht. Der Standard definiert dazu Folgendes [7]: Die Implementierung legt fest, welcher ganzzahlige Typ als zugrundeliegender Typ genutzt ist. Allerdings soll der zugrundeliegende Type nicht größer als int sein, es sei denn der Wert eines Enumerator passt nicht in int oder unsigned int. (It is implementation-defined which integral type is used as the underlying type except that the underlying type shall not be larger than int unless the value of an enumerator cannot fit in an int or unsigned int.)

Je nach Compiler ist der zugrundeliegende Typ der Aufzählung als unsigned char oder unsigend int definiert (Abb. 2).

Je nach Compiler ist der zugrundeliegende Typ der Aufzählung als unsigned char oder unsigend int definiert (Abb. 2).

(Bild: embeff [8])

In Abbildung 2 ist erkennbar, dass der arm-gcc den Typ der Aufzählung NWRegisteringMode auf unsigned char festlegt. Der x86-gcc weist dem gleichen Enum den Typen unsigned int zu. Das Template auf der linken Seite dient lediglich als Hilfsmittel, damit der Compiler den gewählten Typ des enums leserlich ausgibt.

Obwohl sich beide Compiler standardkonform verhalten, kann die Umsetzung Unterschiede zur Laufzeit bewirken. Insbesondere Bit-Operationen und Casts verursachen in der Praxis Bugs in Embedded-Bibliotheken [9].

Eine prägnante Zusammenfassung für undefiniertes Verhalten liefert die cppreference [10]: Durch das Verletzen bestimmter Regeln der Programmiersprache wird das gesamte Programm sinnlos. (Renders the entire program meaningless if certain rules of the language are violated.)

Wenn Code also (unbewusst) die Regeln der Sprache verletzt, darf der Compiler das gesamte Programm als unsinnig interpretieren. Das folgende Beispiel zeigt auf, welche Gefahr in derartigen Situationen lauert:

int f(bool parameter) {
  // uninitialisierte lokale Variable:
  int a;    
  if(parameter) { a = 42; }
  // Möglicher Zugriff auf uninitialisierte Variable:
  return a; 
}

Der Zugriff auf uninitialisierte Variablen verletzt die Regeln der Sprache und ist damit undefiniert. Um zu verstehen, wie der Compiler damit umgeht, ist ein Blick in das generierte Assembly aufschlussreich, das unterschiedliches Verhalten je nach Optimierung aufweist (s. Abb. 3):

embeff

Das generierte Assembly der f-Funktion verhält sich funktional unterschiedlich für unoptimiertem (oben rechts) und optimiertem Code (unten rechts) (Abb. 3).

(Bild: embeff [11])

Je nach Optimierungsstufe ist somit ein komplett unterschiedliches Verhalten zu beobachten. Da es sich bei dem Variablenzugriff um undefiniertes Verhalten handelt, ist die Interpretation zulässig. Laut Standard darf der Compiler an der Stelle das gesamte Programm als unsinnig betrachten. Der Standard fordert hingegen nicht, dass eine Meldung (Warnung/Fehler) darüber erfolgt.

Als weiteren wichtigen Faktor gilt es zu berücksichtigen, dass Compiler und Laufzeitbibliotheken Bugs haben. Das ergibt sich naturgemäß aus der mittlerweile enormen Komplexität solcher Tools. Das tatsächliche Ausmaß dieser Fehlerquelle ist dramatisch. Der verbreitete gcc-Compiler weist Stand Juli 2020 in seinem Bug-Tracker [12] 14.383 offene Bugs aus. Während dieser Artikel entstand, konnten 49 davon behoben werden – gleichzeitig kamen jedoch 75 neu gemeldete Bugs hinzu.

Ein konkreter Bug in der GNU Embedded Toolchain von ARM verdeutlicht die Auswirkungen: #1527413 4.9 series reproducibly corrupts register R7 [13] – unter äußerst speziellen Bedingungen verändert der Compiler ein Register des Prozessors ungewollt. Der Beispielcode zur Reproduktion würde unverändert mit einem anderen Compiler fehlerlos laufen. Das zeigt, dass nur eine On-Target-Ausführung sicherstellen kann, dass der eigene Code nicht von Compiler-Bugs betroffen ist.

Entwickler führen Code häufig auf dem Entwicklungsrechner aus, um die mit der On-Target-Ausführung verbundenen langen Feedbackschleifen abzukürzen. Allerdings ist diese Abkürzung nur mit Bedacht zu wählen.

Der Compiler ist einer der wichtigsten Faktoren für die strukturellen Unterschiede zwischen On- und Off-Target-Ausführung. Unspezifiziertes, implementierungsabhängiges und undefiniertes Verhalten sind wichtige Aspekte von C und C++. Compiler bekommen durch den jeweiligen Standard Freiheitsgrade und Interpretationsspielraum.

Zusammen mit anderen Einflussfaktoren arbeitet Code auf dem Zielsystem womöglich nicht korrekt, obwohl die Entwickler ihn im Off-Target-Verfahren verifiziert haben. Teams sollten Code daher immer mit dem für das Endprodukt eingesetzten Compiler testen. Ohne eine On-Target-Ausführung auf dem realen Zielsystem drohen versteckte Risiken und eine Sicherheit, die sich als nur gefühlt entpuppt.

Daniel Penning
ist Gründer und Geschäftsführer der embeff GmbH in Lübeck. Sein Ziel ist robuste Embedded Software. Er arbeitet an einem Test-Tool für Mikrocontroller, mit dem Kunden diesem Ziel ein Stück näherkommen.

(rme [14])


URL dieses Artikels:
https://www.heise.de/-4922167

Links in diesem Artikel:
[1] https://timsong-cpp.github.io/cppwp/n4659/defns.unspecified
[2] https://timsong-cpp.github.io/cppwp/n4659/defns.impl.defined
[3] https://timsong-cpp.github.io/cppwp/n4659/defns.undefined
[4] https://ce.embeff.com/z/_8pz3i
[5] https://timsong-cpp.github.io/cppwp/n4659/impldefindex
[6] https://timsong-cpp.github.io/cppwp/n4659/basic.align
[7] https://timsong-cpp.github.io/cppwp/n4659/dcl.enum#7
[8] https://ce.embeff.com/z/eCXBvH
[9] https://embeff.com/mbed-unit-tests-investigation/
[10] https://en.cppreference.com/w/cpp/language/ub
[11] https://ce.embeff.com/z/9fTy6B
[12] https://gcc.gnu.org/bugzilla/page.cgi?id=gcc/weekly-bug-summary.html
[13] https://bugs.launchpad.net/gcc-arm-embedded/+bug/1527413
[14] mailto:rme@ix.de