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

Test-Driven Development funktioniert auch im Embedded-Bereich, benötigt aber für hardwarenahen Code spezielle Methoden als Ergänzung.​

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen

(Bild: Connect world/Shutterstock.com)

Lesezeit: 12 Min.
Von
  • Daniel Penning
Inhaltsverzeichnis

Bei dem von Kent Beck eingeführten [1] Test-Driven Development (TDD) schreiben Entwicklerinnen oder Entwickler erst einen Test und beginnen anschließend mit der Implementierung. Der Test ermöglicht es, die Codequalität der Anwendung zu optimieren. TDD bietet in der Softwareentwicklung für Mikrocontroller Potential. Wer darauf setzen möchte, sollte die Idee zunächst verstehen und sie anschließend an die spezifischen Bedingungen von Embedded-Systemen anpassen.

Die Kernidee von TDD liegt darin, Tests zu nutzen, damit sie während der Entwicklung die Feedbackschleife erheblich verkürzen, also die Zeit, um eine Aufgabe auszuführen und zu überprüfen, ob sie erfolgreich war. Dadurch werden Tests zu einem wesentlichen Entwicklungswerkzeug und nicht nur zu einem Tool für die Qualitätssicherung.

(Bild: Marsner Technologies)

TDD ist als Methode weder an eine Programmiersprache noch an eine Umgebung gebunden. Allerdings sollte es vorab möglich sein, Tests einfach zu erstellen und zügig durchzuführen. In der Praxis sollten alle relevanten Tests in etwa zehn Sekunden erledigt sein.

Bei der Entwicklung von Desktopsoftware können Developer Code testen, indem sie entweder das gesamte Programm oder einzelne Funktionen mithilfe von Unit-Tests ausführen. Das komplette Programm zu testen, ist unkompliziert und benötigt keine spezielle Vorbereitung.

Je umfangreicher das Programm ist, desto mehr Zeit ist erforderlich, um den zu testenden Codeabschnitt zu erreichen. Daher verlängert sich die Feedbackschleife, wenn das Projekt wächst. Bei nichttrivialen Projekten dauert es mindestens einige Minuten, um zu kompilieren, die Anwendung zu starten, die erforderlichen Einstellungen oder Eingabewerte vorzunehmen und die Ergebnisse zu überprüfen. Zwischen der Implementierung einer Codeänderung und der Überprüfung, ob sie wie beabsichtigt funktioniert, vergehen jedes Mal wertvolle Minuten in der Feedbackschleife. Diese Zeit steht nicht mehr für andere produktive Aufgaben zur Verfügung.

Die iX-Konferenz zur Entwicklung für das IoT

(Bild: iX)

Am 21. und 22. Februar findet in München die building IoT 2024 statt. Die von iX und dpunkt.verlag ausgerichtete Konferenz richtet sich an diejenigen, die Anwendungen und Produkte für das Internet der Dinge erstellen. Dieses Jahr stehen die Themen IIoT und KI besonders im Fokus.

Das Programm bietet an zwei Tagen gut 30 Vorträge in drei Tracks unter anderem zu Unified Namespaces, KI auf MCUs, Cyber Resilience, niedriger Stromverbrauch von Embedded-Geräten und Data Science in der IoT-Entwicklung.

Unit-Tests sind darauf ausgelegt, isolierte Teile des Codes zu prüfen. Einen Unit-Test durchzuführen, nimmt nur wenige Sekunden in Anspruch und verkürzt die Feedbackschleife deutlich. Die Software muss jedoch passend eingerichtet sein. Der zu testende Code sollte beispielsweise nicht von externen Ressourcen wie einer Datenbank oder einer Benutzeroberfläche abhängig sein. Das Entkoppeln verbessert meist die Codequalität, ist aber zeitaufwendig. Für Teams, die zum ersten Mal Unit-Tests verwenden, kann die Umstellung eine Herausforderung sein. Glücklicherweise ist es heutzutage gängige Praxis, bei allen nichttrivialen Projekten ausgiebig Unit-Tests einzusetzen.

Embedded-Entwicklung kann hinsichtlich des Testens überwältigend und frustrierend sein. Embedded-Programme laufen nicht einfach auf dem Entwicklungs-PC, sondern das Programm liegt als Binärdatei vor und erfordert die Zielhardware. Um die Anwendung zu flashen, ist eine separate Programmierer/Debugger-Hardware erforderlich. Um den relevanten Code auszuführen, muss man potenziell einige Werte eingeben, etwa durch das Senden von CAN-Frames (Controller Area Network), die Kommunikation über ein serielles Terminal oder das Anschließen eines spezifischen Sensors. Die Feedbackschleife für ein eingebettetes Projekt ist daher von Haus aus deutlich länger und führt zusätzlich zu einer Abhängigkeit von spezifischer Hardware. Dual-Targeting ermöglicht jedoch die sinnvolle Verwendung von Unit-Tests in der Embedded-Entwicklung.

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.

Für TDD ist eine schnelle Feedbackschleife erforderlich. Off-Target-Tests ermöglichen es, hardwareunabhängigen Code isoliert zu testen und schnelles Feedback auch beim Testen von hardwarenahem Code zu erhalten.

TDD wirkt sich direkt positiv auf die Entwicklung aus, insbesondere in Bezug auf Softwaredesign, Implementierungsgeschwindigkeit und Qualität. Zusätzlich ergibt sich aus dem TDD-Ansatz kontinuierlich eine wachsende Test-Suite. Je umfassender diese den Code abdeckt, desto effektiver kann man Regressionen im Verlauf des Projekts identifizieren. Damit lassen sich später neue Funktionen und Fehlerbehebungen schnell umsetzen.

Bei der Entscheidung über den Einsatz von TDD sollte man die Projektdauer berücksichtigen. Bei Projekten mit einer mehrjährigen Entwicklungs- und Wartungsdauer können sich die anfänglich höheren Kosten für den TDD-Ansatz wie eventuelle Schulung der Mitarbeiter und die Anschaffung oder der Bau eines geeigneten Testsystems amortisieren.

  1. Kent Beck; Test-driven Development by Example; Addison-Wesley Professional, 2002
  2. James W. Grenning; Test Driven Development for Embedded; O’Reilly, 2011
  3. TDD für Mikrocontroller im Browser ausprobieren

Daniel Penning
studierte Elektrotechnik und arbeitete in Embedded-Projekten in der Industrie- und Sicherheitstechnik. Seine Begeisterung für qualitativ hochwertige Embedded-Entwicklung versucht er regelmäßig auf Konferenzen weiterzugeben. Als Geschäftsführer bei embeff bietet er Kunden innovative Lösungen für die testbegleitende Automatisierung während der Entwicklung an.

(rme)