Testen von Embedded-Software
Im Buch "The Art of Software Testing" von Glenford J. Myers bittet der Autor den Leser zunächst, darüber nachzudenken, wie man eine Software testen kann. Wenn Sie noch nie einen Softwaretest geschrieben haben, stellen Sie plötzlich fest, wie schwierig das Testen von Software ist. Wo soll man anfangen? Was soll abgedeckt werden? Wann ist man fertig? Aber die wichtigste Frage bei allem ist: Was ist Software-Testing überhaupt?
Myers liefert uns eine ausgezeichnete Definition für die Antwort der letzte Frage:
"Testen ist der Prozess der Ausführung eines Programms mit der Absicht Fehler zu finden."
Software hat oft einen schlechten Ruf. Sie kann im Gegensatz zur Hardware, wie z. B. einem Leiterplatten- oder Chipdesign, das über die gesamte Lebensdauer des Produkts stabil bleibt, im Feld geändert werden. Also, so der Gedanke, müssen die Qualitätsanforderungen an seine Entwicklung nicht so hoch sein, da Fehler später relativ einfach behoben werden können. Dies gilt zwar für Geräte im Büro oder Rechenzentrum, die mit dem Internet verbunden sind, jedoch nicht, wenn Sie Satelliten mit einer Missionslebensdauer von 20 Jahren bauen.
Embedded-Software-Entwicklung passt nicht in diese "Wir können es später beheben"-Mentalität, da die Software untrennbar mit der Hardware verbunden ist, insbesondere gilt dies für Code der Peripherie. Darüber hinaus sind die meisten Mikrocontroller nicht mit dem Internet verbunden. Ist dies doch der Fall, z. B. ein IoT-Sensor, dann reicht die verfügbare Bandbreite wahrscheinlich nicht aus, um das Updaten eines neuen Firmware-Images zu unterstützen.
Darüber hinaus gibt es noch eine weitere Herausforderung. Wenn Sie Code auf einem PC entwickeln, können Sie Tests relativ schnell ausführen und die Ergebnisse am Bildschirm anzeigen lassen. Auf einem Mikrocontroller haben Sie wahrscheinlich keine Anzeigemöglichkeiten. Oder eine Tastatur. Wie testen Entwickler also Embedded-Software?
Testen von Embedded-Software gegen die Anforderungen
Der erste Schritt zum erfolgreichen Testen von Embedded-Software ist eine gute Definition dessen, was die Software tun soll. Es sollte ein Anforderungsdokument verfasst werden, in dem die erwartete Funktionalität und etwaige Einschränkungen erläutert werden. Wenn Sie beispielsweise eine Funktion haben, die Temperaturmessungen von Celsius in Fahrenheit umwandelt, wäre eine Anforderung, dass die Umrechnung über alle Werte hinweg genau ist. Die mathematische Umrechnung lautet:
TempF = TempC × (9/5) + 32
Die Entwicklung von Code für eingebettete Systeme erfordert jedoch oft, dass man unter den Einschränkungen der begrenzten Ressourcen kleiner Mikrocontroller arbeiten muss. Ohne Gleitkommaunterstützung könnte dies bedeuten, dass eine Funktion nur mit ganzzahliger Mathematik entwickelt wird. Dies führt unweigerlich zu größeren Fehlern, als man es von einer Gleitkommaimplementierung erwarten würde. Man müsste dies also in den Anforderungen extra ausweisen, wahrscheinlich als das Maß an Genauigkeit das man erwarten kann.
Integer-Mathematik kann auch den Bereich der Werte, die konvertiert werden können, einschränken. Zum Beispiel begrenzt ein vorzeichenbehaftetes Acht-Bit-Zeichen den Eingabebereich auf -128 bis +127. Dies hat zur Folge, dass aber auch das Ergebnis auf denselben Bereich reduziert wird. Bei unserem Beispiel wird die Temperaturmessung also auf einen Eingangsbereich von -88°C bis +52°C eingeschränkt, der dann auch nur in -127°F bis +127°F umgewandelt werden kann.
Dies klingt alles sehr limitierend, ist aber ziemlich realistisch für eine Acht-Bit-Mikrocontroller Temperaturmessungen für den Haushalt. Ein Vergleich von Code mit Acht-Bit-char- und float-Implementierungen wird am Ende dieses Artikels gemacht.
Diese Grenzen der Funktionalität und im Bereich von Ein- und Ausgabe helfen uns auch bei der Definition unserer Tests.
Black- und White-Box Testen
Mit einer Spezifikation gibt es prinzipiell zwei grundlegende Ansätze für die Entwicklung von Tests: Black- und White-Box-Tests. Black Box Testen geht davon aus, dass man den Code gemäß der Spezifikation testet, ohne die Implementierung zu kennen. White-Box Testen hingegen berücksichtigt die Spezifikation während der Testentwicklung, aber mit einem Verständnis dafür, wie der Code funktioniert.
Im Falle unserer Funktion für die Temperaturumwandlung könnten Black-Box-Tests zu einer umfassenden Sammlung von Tests führen. Alle möglichen gültigen Eingangswerte könnten wie in der Spezifikation definiert getestet werden (-88 bis +52°C). Das Ergebnis in Fahrenheit würde gemäß der angegebenen Genauigkeit (+/- 1°F) überprüft werden. In diesem Beispiel ist die Anzahl der resultierenden Tests groß, aber überschaubar. Wenn wir jedoch 16-Bit-Werte unterstützen oder mehrere Eingabewerte haben, wird die Anzahl der resultierenden Testfälle schnell unüberschaubar, sowohl aus Sicht der Menge als auch der Testausführungszeit.
Black-Box Testen
Um das Testen überschaubarer zu machen, können einige Annahmen über den zu testenden Code getroffen werden, um die Anzahl der Tests zu reduzieren. Wenn die Funktion beispielsweise 25°C korrekt umwandelt, funktioniert sie wahrscheinlich auch für 26 und 24 korrekt. Dies wird als Äquivalenzklasse bezeichnet und wird verwendet, um die Anzahl der Testfälle formal zu reduzieren, ohne die Testqualität zu beeinträchtigen.
Es gibt zusätzlich auch andere Strategien, um die Anzahl der Testfälle zu reduzieren. Die Randwertanalyse untersucht die Randbedingungen von Äquivalenzklassen. In unserem Beispiel würden wir uns die Grenzen der Eingabewerte ansehen, wie sie in der Spezifikation definiert sind (z.B. -88 bis -86°C und +50 bis +52°C). Als Programmierer wissen wir auch, dass Probleme auftreten können, wenn Variablen falsch definiert sind, z. B. char ohne Vorzeichen (unsigned char) anstelle von char. Daher sind Tests für Eingänge von -1°C, 0°C und 1°C sinnvoll, ebenso wie Tests, die Ergebnisse von -1°F, 0°F und 1°F erwarten.
Die gesamte Palette der Black-Box-Testansätze, wie von Myers aufgelistet, umfasst:
- Äquivalenzpartitionierung.
- Randwertanalyse.
- Ursache-Wirkungs-Grafik: eine formale Methode zur Testentwicklung, geeignet für komplexe Systeme.
- Fehlerraten: eine informelle Methode der Testentwicklung basierend auf Intuition und Erfahrung.
White-Box-Testen
White-Box-Tests verfolgen einen anderen Ansatz. Der Testentwickler kennt die Implementierung des Codes. In unserem Beispiel enthält die Funktion nur eine Zeile C-Quellcode: die mathematische Gleichung zur Umwandlung von Celsius in Fahrenheit. Daher würden sich die Tests, die sich ergeben würden, nicht wesentlich von denen unterscheiden, die mit einem Black-Box-Ansatz erstellt wurden.
Sollte der Quellcode jedoch Entscheidungen enthalten, z. B. if- und switch-Anweisungen, sieht die Sache anders aus. Mit Kenntnis der Logik des Programms kann ein Tester Tests erstellen, die sicherstellen, dass alle möglichen Pfade durch den Code ausgeführt werden. So werden manche Testfälle einige scheinbar seltsame Wertekombinationen passieren, so dass Codezeilen tief in der Software erreicht werden. Auch hier ermöglichen unterschiedliche Ansätze, unterschiedliche Testtiefen der Testabdeckung zu erreichen.
Myers listet Folgendes auf:
- Anweisungsabdeckung: Stellt sicher, dass alle Anweisungen ausgeführt werden.
- Entscheidungsabdeckung: Stellt sicher, dass alle Entscheidungsanweisungen mindestens einmal wahr oder falsch liefern.
- Bedingungsabdeckung: Stellt sicher, dass die Bedingungen von Entscheidungsanweisungen getestet werden, um wahr und falsch zu liefern (z. B. if(A && B)).
- Entscheidungs-/Bedingungsabdeckung: Beide Ansätze werden vor allem in Code mit einem komplexeren Ablauf benötigt, um mehrere mögliche Pfade zu durchlaufen.
- Mehrfachentscheidungsabdeckung: Dieser Ansatz, der in der Regel als modifizierte Bedingung/Entscheidungsabdeckung (Modified condition/decision coverage - MC/DC) bezeichnet wird, deckt auch Pfade ab, die die oben genannten Alternativen verfehlen können. Sie wird zum Testen sicherheitskritischer Software verwendet, die in einigen Automobil-, Medizin-, Luft- und Raumfahrtanwendungen eingesetzt wird.
Wann sollte Embedded-Software getestet werden?
Für entsprechende Softwaretests ist es nie zu früh. Und obwohl es verlockend sein kann, Tests für Ihren eigenen Code zu schreiben, sollte dies die Aufgabe von jemandem sein, der mit ihrer Implementierung nicht vertraut ist. Die oben genannten Testansätze führen zu vielen Tests, deren Ausführung Stunden dauern kann. Deshalb sollten Sie die Testumgebung ausreichend auf Funktion überprüfen, bevor Sie einen Testlauf über Nacht starten. Daher lohnt es sich dafür einen eigenen Test mit nur einer Handvoll von Prüfungen als sogenannten Eingangstest zu entwickeln. Dies wird auch als Smoke-Test (Rauchtest) bezeichnet.
Die Benamung von Tests bezieht sich grob auf den Entwicklungsstand Ihres Projekts. Tests für unsere Temperaturumwandlungsfunktion werden als Unit-Tests bezeichnet. Sie testen dabei eine Funktion oder ein Modul isoliert betrachtet. Ein solcher Code könnte unabhängig auf einem PC getestet werden, da er universell ist und nicht von den Fähigkeiten des Zielmikrocontrollers abhängt. Software zur Unterstützung von Unit-Tests ist z.B. Unity, das für Embedded-Entwickler entwickelt wurde oder CppUnit für C++.
Tests werden in der Regel mithilfe einer Behauptung erstellt, einer Aussage des erwarteten korrekten Ergebnisses. Wenn das Ergebnis falsch ist, wird der Testfehler nach Abschluss aller Tests notiert und gemeldet. Im Folgenden finden Sie ein Beispiel für die Verwendung von Unity:
// Variable value test
int a = 1;
TEST_ASSERT( a == 1 ); //this one will pass
TEST_ASSERT( a == 2 ); //this one will fail
// Example output for failed test:
TestMyModule.c:15:test_One:FAIL
// Function test; function is expected to return five
TEST_ASSERT_EQUAL_INT( 5, FunctionUnderTest() );
(Source: Unity)
Das Testen von Protokollen ist schwieriger. Man erwartet, dass die Protokolle Daten mit Schichten darüber und darunter teilen. Dazu simulieren Softwareimplementierungen, die als Stub bezeichnet werden, das erwartete Verhalten.
Code, der direkt auf den Peripheriegeräten eines Mikrocontrollers arbeitet ist noch schwieriger zu testen. Ein möglicher Ansatz ist die Entwicklung eines „Hardware-in-the-Loop“ (HIL)-Setups. Beim Testen von Code, der den UART initialisiert, könnte beispielsweise ein zweiter Mikrocontroller verwendet werden, um den korrekten Vorgang für jeden Test zu bestätigen. Alternativ könnte ein Logikanalysator mit einer Programmierschnittstelle die Ausgabe erfassen, die Baudrate überprüfen und die Paritäts- und Stoppbitkonfiguration korrigieren.
Die verschiedenen Softwaremodule (Units) werden später im Entwicklungsprozess kombiniert. Beispielsweise können wir unser Fahrenheit-Ergebnis mit einem Ringpuffer ausgeben, der mit der UART-Schnittstelle verknüpft ist. In dieser Phase sind Integrationstests erforderlich, um festzustellen, ob die einzelnen Softwaremodule in Verbindung miteinander noch korrekt funktionieren. Für eingebettete Systeme erfordert dies zusätzlich einen HIL-Ansatz.
Für ein fertiges Produkt sind Systemtests erforderlich. In diesem Stadium ist es nicht unbedingt erforderlich die Funktionalität des Codes zu untersuchen. Stattdessen konzentriert sich ein Testteam auf die Gesamtfunktionalität. Dadurch wird untersucht, ob ein Tastendruck zu der richtigen Reaktion führt, das Display die richtigen Nachrichten ausgibt oder die resultierende Funktionalität wie erwartet ist.
Embedded-Software-Tests - nicht so einfach, wie es klingt
Das Testen ist eine komplexe Angelegenheit mit Herausforderungen, die von der Implementierung bis hin zur Ausführung der Tests reichen. Glücklicherweise stehen viele gute Beispiele zur Verfügung, die diese formalen Testansätze erklären. Selbst Myers' Buch, geschrieben in den 1970er Jahren, hat heute noch Relevanz. Die Testentwicklung wird auch dank einer Reihe von Open-Source-Frameworks erleichtert. Dies hilft besonders den Embedded-Entwicklern bei ihren ersten Schritten zur Verbesserung ihrer Testansätze.
Beispielcode zum Konvertieren von Celsius in Fahrenheit
Acht-Bit-Mikrocontroller arbeiten effizienter mit Acht-Bit-Werten. Darüber hinaus ist es unwahrscheinlich, dass sie Hardware zur Beschleunigung von Gleitkommamathematik enthalten. Daher ist es sinnvoll, möglichst prozessoroptimierte Funktionen zu schreiben, die schnell und effizient sind. Durch die Begrenzung des Bereichs der unterstützten Celsius-Werte ist die folgende char-basierte Temperaturumwandlungsfunktion fünfmal schneller als die gleiche Funktion mit Gleitkommavariablen. Die Ausführungszeiten basieren auf einem Arduino MEGA (ATMEGA2560), der mit den Standard-Arduino-IDE-Einstellungen kompiliert wurde.
// Required 5.5µs to execute
// Limited to Celsius range of -88°C to +52°C
char convertCtoF(char celsius) {
int fahrenheit = 0;
digitalWrite(7, HIGH);
// F = (C * (9/5)) + 32 - convert celsius to fahrenheit
// (9/5) = 1.8
fahrenheit = ((18 * ((int) celsius)) + 320) / 10;
digitalWrite(7, LOW);
return (char) fahrenheit;
}
// Floating-point Celsius to Fahrenheit conversion function.
// Required 24.3µs to execute
// Limited to range of 'float'
float fpConvertCtoF(float celsius) {
float fahrenheit = 0.0;
digitalWrite(7, HIGH);
fahrenheit = (celsius * (9.0 / 5.0)) + 32.0;
digitalWrite(7, LOW);
return fahrenheit;
}