TDD und Mikrocontroller: ein praxistauglicher Test-First-Ansatz​

Seite 2: Dual-Target-Testing

Inhaltsverzeichnis

Beim Dual Targeting strukturiert man den Code so, dass einige Teile sich nicht nur auf der Zielhardware, sondern auch auf dem Entwicklungsrechner kompilieren lassen. Das ermöglicht normale Unit-Tests, um die Teile off-target auf dem PC zu testen. James Grenning hat diesen Prozess erstmals in seinem empfehlenswerten Buch "Test Driven Development for Embedded" [2] detailliert dargestellt.

Off-Target-Testing ist ideal, um einzelne Softwarekomponenten effizient zu prüfen. Da der Test auf dem PC läuft, gibt es keine Einschränkungen bei den Ressourcen, und man kann leistungsstarke Testframeworks wie googletest oder Catch2 direkt verwenden.

Allerdings muss der Code für Unit-Testing von seinen Abhängigkeiten isoliert sein. Bei Mikrocontroller-Code beziehen sich diese Abhängigkeiten beispielsweise auf die CAN- und UART-Peripherie (Universal Asynchronous Receiver Transmitter) sowie im Allgemeinen alle Special Function Registers (SFR) des Mikrocontrollers. Das bedeutet, dass es nicht möglich ist, Code für einen Off-Target-Test zu kompilieren, der in irgendeiner Form ein SFR verwendet oder im Header einbindet. Das hat zwei Konsequenzen:

  1. Im Vergleich zu Desktopanwendungen ist es deutlich schwieriger, Abhängigkeiten in der Embedded-Software zu isolieren.
  2. Hardwarenaher Embedded-Code wie Treiber lässt sich nicht off-target testen.

Eine klare Isolierung ist eine besondere Herausforderung in der Softwarearchitektur, die man mit Erfahrung und Ausdauer in der Regel gut meistern kann. Häufig offenbaren sich Schwachstellen und unüberlegte Abhängigkeiten in der Architektur erst beim Versuch, die Anwendung off-target zu kompilieren. Konsequentes Off-Target-Testen erfordert eine gewisse Disziplin. Obwohl das anfangs aufwendig ist, wirkt es sich schnell positiv auf die Wiederverwendbarkeit und Qualität der Software aus.

Unit-Tests direkt auf dem PC im Rahmen von Off-Target-Tests auszuführen, ist wegen der besseren Performance und der Unabhängigkeit von realer Hardware komfortabel. Dabei gilt es jedoch, stets das Risiko und die Aussagekraft der Tests im Auge zu behalten. Das folgende Beispiel zeigt unterschiedliche Ergebnisse bei der Ausführung.

#include <iostream>

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

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

int main() { foo(fun1(), fun2()); }
Zielsystem Compiler Ergebnis
Desktop x86-64 gcc 9.2 fun2() fun1()  foo()
Arm-Cortex M4 arm-gcc-none-eabi 8-2018-q4 fun1() fun2() foo()

Die Risiken, die sich aus einer ausschließlichen Off-Target-Ausführung ergeben, beschreibt ein Artikel auf heise Developer. In jedem sicherheitskritischen Projekt sollten die Unit-Tests daher zusätzlich on-target, also direkt auf dem Zielsystem, ausgeführt werden. Diese strategisch sinnvolle Kombination von On- und Off-Target-Ausführung heißt Dual-Targeting. Zum Ausführen der Tests auf dem Mikrocontroller sind zwei Aspekte wichtig:

  1. Ressourcenverbrauch: Die Testframeworks sind oft umfangreich, was den Bedarf an Flash-Speicher erhöht. Das kann die Kapazitäten des Mikrocontrollers überschreiten. Geeignete Compiler-Flags und eine durchdachte Partitionierung der Tests auf mehrere Binaries können Abhilfe schaffen.
  2. Bereitstellung von Hardware: Ein Mikrocontroller mit Flasher/Debugger muss zur Verfügung stehen, um die Ausführung zu ermöglichen. Für einfache Setups ist ein passendes Evaluation-Board ausreichend. Zudem muss man den Output der Unit-Tests erfassen und analysieren – in der Regel über eine serielle Schnittstelle (oft UART). Die Software erfordert Anpassungen, damit sie ihren Output über UART ausgibt.

Für anspruchsvollere Anforderungen existieren kommerzielle Angebote für On-Target-Tests wie die ExecutionPlatform von embeff, die Skalierbarkeit und Netzwerkfähigkeit bieten und zum Beispiel angepasste Test-Frameworks mitliefern. User erkennen damit kaum Unterschiede zwischen Off- und On-Target-Ausführung, da sie beide Varianten direkt aus der IDE starten können.

TDD funktioniert bei einer Off-Target-Ausführung ohne Einschränkungen, aber hardwarenaher Code lässt sich damit nicht testen. Die Essenz von TDD liegt in einer schnellen Feedbackschleife. Der automatisierte Test gibt in Sekunden Rückmeldung darüber, ob die Implementierung den Anforderungen entspricht. Auf den ersten Blick scheint dieser Ablauf mit dem Entwickeln hardwarenahen Codes inkompatibel zu sein.

Wer eine Funktion entwickelt, die Mikrocontrollerfunktionen wie einen Treiber verwendet, geht normalerweise folgendermaßen vor, um den Code auszuprobieren und zu testen:

  1. Er oder sie verschafft sich Zugang zu den relevanten Pins, eventuell durch Löten,
  2. verbindet das passende Gerät mit den Pins (Logic Analyzer, Oszilloskop, Signalgenerator, CAN-Dongle, ...)
  3. kompiliert eine spezielle Firmware, die den jeweiligen Code leicht zugänglich macht oder direkt ausführt, und
  4. flasht die spezielle Firmware, führt sie aus und überprüft, ob das erwartete Ergebnis eingetreten ist.

Dieser Ablauf ist spezifisch für einen einzelnen Test, schwer automatisierbar und zeitaufwendig. Für ein TDD-artiges Vorgehen ist er ungeeignet.

In der Praxis versucht man sich daher mit einem registerbasierten Testen zu behelfen. Dabei wird für einen Treiber nicht geschaut, ob er das gewünschte Verhalten am Pin erreicht, sondern ob der Treibercode einen bestimmten Wert in ein oder mehrere Register schreibt. In der Theorie ist das eine ausgezeichnete Idee: Das Mikrocontroller Reference Manual spezifiziert, welche Werte die Register enthalten müssen, um das gewünschte Verhalten am Pin zu erreichen.

Praktisch zeigen sich allerdings zwei Schwächen beim registerbasierten Testen:

  1. Heutige Mikrocontroller sind so komplex, dass der Zusammenhang zwischen Register und Pin-Verhalten schwer überschaubar ist. Die Beschreibung der Interaktionen für einen typischen STM32 umfasst mehrere Tausend Seiten, wobei häufig eine Peripherie wie UART viele Nebenwirkungen mit anderen Peripheriegeräten wie DMA (Direct Memory Access) hat. Es ist daher aufwendig und fehleranfällig, die erwarteten Werte der Register zu definieren.
  2. Ein registerbasierter Test fungiert zwangsläufig als Whitebox-Test gegen die Implementierung. Beispielsweise lässt sich prüfen, ob ein Byte seriell per UART mit der gewünschten Baudrate gesendet wird. Der Mikrocontroller stellt in der UART-Peripherie ein Register zur Einstellung der Baudrate bereit, das der Treiber setzt. Der Test prüft, ob der Inhalt des Registers mit einem manuell berechneten Wert übereinstimmt. Die tatsächliche Baudrate hängt jedoch vom Wert anderer System-Clocks und deren Konfiguration ab. Wenn eine Anwendung zu einem späteren Zeitpunkt den Wert im Baudraten-Register halbiert und die Frequenz der System-Clock verdoppelt, scheitert der registerbasierte Test, obwohl die tatsächliche Baudrate am Pin korrekt bleibt. Solche Modifikationen sind gerade bei TDD häufig.

Registerbasiertes Testen ist daher schlecht mit TDD vereinbar. Für zuverlässige Ergebnisse muss der tatsächliche Mikrocontroller in den Testprozess integriert sein.

Um belastbare Aussagen über hardwarenahen Code treffen zu können, ist eine Überprüfung des Verhaltens an den Pins notwendig. Ein praktikabler Ansatz hierfür ist das Open-Loop-Testing. Dabei ersetzen aus dem Unit-Test bekannte Mocks und Stubs die externen Mikrocontroller-Schnittstellen wie GPIO (General Purpose Input/Output), CAN und UART. Das ermöglicht das Testen von Vorgängen auf Pin-Ebene, ohne dabei eine vollständige Simulation der Umgebung zu benötigen.

Im Test legt man ein bestimmtes Pin-Verhalten fest oder ruft Funktionen im Code auf, und als Ergebnis bewertet man Rückgabewerte von Funktionen im Code oder die Aktivitäten an den Pins.

Beispielsweise würde ein Test zum Überprüfen der UART-Baudrate folgendermaßen aussehen:

  1. Der Test ruft die Treiberfunktion im Mikrocontroller auf, die ein Byte auf dem UART-Pin sendet.
  2. Der Test überprüft, ob das Byte mit der gewünschten Geschwindigkeit auf dem TX-Pin erscheint.

(Bild: embeff)

Mit dieser Methode lassen sich alle Treiberfunktionen systematisch testen, sofern es wirtschaftlich machbar ist, die benötigten Peripherien in einem automatisierten Setup zu überprüfen. Je nach spezifischen Anforderungen kann man ein solches Testsetup mit einem skriptfähigen Logic-Analyzer, Signalgenerator und speziellen Protokoll-Probes für Peripherien wie einem CAN-Dongle aufbauen.