Embedded-Entwickler schätzen es, wenn ihr Code beim ersten Versuch fehlerfrei auf der Hardware läuft. In der Praxis treten jedoch häufig Fehler und Probleme auf, die dieses Ziel erschweren. Doch was wäre, wenn sich viele dieser Probleme bereits im Vorfeld erkennen ließen? In diesem Artikel zur testgetriebenen Entwicklung stellen wir eine Methode vor, mit der sich Firmware von Anfang an robuster und zuverlässiger schreiben lässt – und Fehler sowie unerwünschte Nebenwirkungen deutlich reduziert werden können.

Da die Firmware-Entwicklung ein Teilbereich des Software-Engineerings ist, hat sie den Ansatz des Test-Driven Developments (TDD) aus dem Software-Engineering übernommen, der betont, dass Tests vor dem eigentlichen Code geschrieben werden. Einige Firmware-Entwickler verfolgen jedoch den Ansatz des Test-Later Developments (TLD), bei dem das Testen —insbesondere Unit-Tests— aufgeschoben wird, bis der Code als funktionsfähig angesehen wird. Obwohl diese Denkweise effizient und natürlich erscheinen mag, führt sie oft zu einer schlechten Testabdeckung. Dadurch wird der Code anfälliger für Integrationsfehler, wenn neue Funktionen hinzugefügt werden.

Das Großartige an TDD ist, dass bei strikter Befolgung eine vollständige Testabdeckung gewährleistet ist. Entwickler nehmen eine testorientierte Denkweise an und schreiben Tests vor dem eigentlichen Code. Zunächst schlägt der Test fehl, da keine Implementierung existiert (rot), siehe Bild 1. Anschließend wird der Code geschrieben, um die Einheit zu implementieren und den Test zu bestehen (grün). Schließlich wird der Code verfeinert und optimiert, wobei der Test weiterhin erfolgreich bleibt (Refactor). Dieser Zyklus —rot, grün, Refactor— ist das Kernstück von TDD.

Illustration of TDD iteration steps.
Bild 1. Eine Darstellung der TDD-Iterationsschritte.

TDD kehrt den traditionellen Firmware-Entwicklungsprozess um und erscheint anfangs ungewohnt und herausfordernd. Ein häufiger Kritikpunkt ist, wie Unit-Tests auf Firmware angewendet werden können, die auf einem Mikrocontroller läuft, da diese stark von Hersteller-SDKs und Toolchains abhängt. Dieser Artikel beleuchtet diese Herausforderungen und zeigt, wie TDD effektiv in die Embedded-Entwicklung integriert werden kann.

Egal, ob Sie zuvor von TDD aus einem Buch, von einem Kollegen oder einem Vortrag gehört haben —oder ob Sie gar völlig neu in diesem Konzept sind— dieser Artikel ist für Sie.

Abonnieren
Tag-Benachrichtigung zu Embedded & AI jetzt abonnieren!

Verständnis von Unit-Tests in eingebetteten Systemen

TDD kann nicht eingeführt werden, ohne Unit-Tests klar zu verstehen. Auch wenn dieser Artikel nicht lehren soll, wie man Unit-Tests schreibt, ist es wichtig, einige Schlüsselbegriffe zu klären.

Der Begriff Test Double ist im Zusammenhang mit Unit-Testing weit verbreitet. Er bezieht sich auf Objekte, die reale Systemkomponenten ersetzen, um das zu testende System (SUT) von Abhängigkeiten zu isolieren. In eingebetteten Systemen ist die Verwendung von Test-Doubles essenziell, da Abhängigkeiten von SDKs und Drittanbieter-Bibliotheken restriktiver sein können als in der allgemeinen Softwareentwicklung. Test-Doubles sind besonders wichtig beim Off-Target-Testing, bei dem der Host-Compiler (meist auf einem PC) zur Generierung von Binärdateien verwendet wird und weder das SDK noch echte MCU-Peripherie involviert sind.

Test-Doubles gibt es in verschiedenen Typen, wobei die gebräuchlichsten Fakes, Stubs und Mocks genannt werden:

 

  • Fakes bieten eine vereinfachte Implementierung einer Abhängigkeit, um das Testen zu erleichtern. Zum Beispiel wird ein NOR-Flash-Key/Value-Store während Off-Target-Tests durch einen In-Memory-Key/Value-Store auf dem Host ersetzt.
  • Stubs geben vordefinierte, hartcodierte Antworten mit minimaler Logik zurück. Zum Beispiel wird eine ADC-Lesefunktion durch eine ersetzt, die immer einen festen Wert zurückgibt.
  • Mocks überprüfen Interaktionen und stellen sicher, dass Funktionen mit den erwarteten Argumenten aufgerufen werden. Im Gegensatz zu Stubs fungieren Mocks als „Spione“ für Funktionsaufrufe. Beim Testen eines I2C-Sensor-Treibers kann ein Mock zum Beispiel überprüfen, ob die richtige Registeradresse gesendet wurde, während ein Stub passiv bleibt.

 

Es ist wichtig zu beachten, dass die Unterschiede zwischen Test-Doubles tiefer gehen, aber falls Sie mit diesen Konzepten nicht vertraut sind, sollte diese Erklärung ein guter Einstieg sein. Außerdem kann das Ersetzen von Abhängigkeiten in C/C++ mit unterschiedlichen Test-Double-Techniken erreicht werden, etwa Interface-Based Replacement, Inheritance-Based Replacement, Composition-Based Replacement, Link-Time Replacement, Macro-Preprocessed Replacement und Vtable-Based Replacement. Mehr dazu in[1].

Unit-Tests erfordern ein Framework, das aus einer Bibliothek und einem Test-Runner besteht. Die Bibliothek stellt Assertions und Unterstützung für Test-Doubles wie Mocks bereit, während der Test-Runner die Tests ausführt. Es ist erwähnenswert, dass nicht alle Frameworks Mocks von Haus aus unterstützen. Das Framework verwaltet auch Test-Fixtures, die in der Regel Setup und Teardown heißen und vor und nach jedem Test ausgeführt werden, um eine kontrollierte Umgebung sicherzustellen.

Abonnieren
Tag-Benachrichtigung zu Embedded Programming jetzt abonnieren!

TDD in der Praxis

TDD folgt einer iterativen Schleife:

 

1. Schreiben Sie einen Test, bevor Sie den Code implementieren, damit die Funktionsdefinition existiert, aber noch keinen Inhalt hat. Die Test-Suite, einschließlich bereits bestandener Tests, sollte ausgeführt werden, wobei der neue Test zunächst fehlschlägt.

2. Implementieren Sie den Code minimal, damit er den Test besteht. Die erste Version ist möglicherweise nicht optimal, aber das Ausführen der gesamten Test-Suite verhindert Regressionen.

3. Refaktorieren Sie den Code für Produktionsqualität. Ein erneutes Ausführen der Tests stellt sicher, dass keine neuen Fehler entstanden sind. Dieser Ansatz spart Zeit im Vergleich zur späteren Fehlersuche.

 

Wir verwenden ein Demo-Projekt, um die TDD-Schleife in der Praxis zu demonstrieren. Sie können beliebige Unit-Testing-Frameworks wie Unity oder GoogleTest verwenden; wir nehmen diesmal Ceedling. Ceedling ist benutzerfreundlich und enthält Mocks über CMock.

In diesem Demo-Projekt bauen wir einen steuerbaren Spannungsteiler mit einem digitalen Potentiometer wie dem AD5160, der über SPI verbunden ist (Bild 2). Als ersten Schritt teilen wir das System in zwei Komponenten: eine für die Berechnung des erforderlichen Widerstands zur Erreichung der gewünschten Ausgangsspannung und eine andere, die diesen Wert über SPI an den Chip sendet. Um uns auf das TDD-Beispiel zu konzentrieren, werden einige Vereinfachungen vorgenommen.

Firmware f2
Bild 2. Softwaregesteuerter Spannungsteiler.

Nach der Installation von Ceedling [3] erstellen wir das Projekt mit folgendem Befehl:

 

ceedling new sw_voltage_divider

 

Dann erstellen wir ein Modul mit dem Befehl:

ceedling module:create[voltageDiv]

Dieses Modul enthält den Anwendungscode, der die Berechnung des benötigten Widerstandswerts übernimmt. Dadurch ergibt sich folgende Verzeichnisstruktur.

├── project.yml

├── src

│ ├── voltageDiv.c

│ └── voltageDiv.h

└── test

└── test_voltageDiv.c

 

Grafikteam, bitte stellt sicher, dass die Sonderzeichen im Layout erhalten bleiben

 

Berechnung des Widerstands

Unser erster Schritt ist die Implementierung des Codes zur Berechnung des erforderlichen Widerstands. Das ist eine einfache Funktion, die zwei Parameter übernimmt —Eingangsspannung und gewünschte Ausgangsspannung— und den berechneten Widerstandswert zurückgibt. Wir beginnen mit dem ersten Unit-Test, um die Ausgabe dieser Funktion zu überprüfen. Da dieser Test zunächst fehlschlägt, markiert er den Start unseres TDD-Zyklus.

 

// in src/voltageDiv.h

int32_t get_Resistance(uint32_t Vin, uint32_t Vout );

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

return -1;

}

 

// in test/test_voltageDiv.c

void test_whenValidVoutAndVinProvided_

thenReturnCorrectResistance(void)

{

TEST_ASSERT_EQUAL_INT32(get_Resistance(5000,2500),10000);

// Angenommen, R1 muss 10k sein, wenn Vin = 5V und Vout = 2,5V

}

 

Die Funktion TEST_ASSERT... prüft, dass 10000 im int32-Format zurückgegeben wird, wenn die get_Resistance-Funktion mit den angegebenen Parametern aufgerufen wird.

Die Anfangsimplementierung wird den Widerstand nach der Spannungsteilerregel berechnen. Das reicht aus, um den ersten Test zu bestehen.

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

return 10000*Vout / (Vin - Vout);

}

 

Als Nächstes refaktorieren wir den Code, indem wir R1 als Makro in der Header-Datei definieren und eine Prüfung hinzufügen, um sicherzustellen, dass Vin und Vout nicht gleich sind, um eine Division durch0 zu vermeiden. Außerdem fügen wir Bedingungen hinzu, damit weder Vin noch Vout0 sind und Vin immer größer als Vout ist. Durch das Ausführen der Unit-Tests wird bestätigt, dass unsere Refaktorierung keine Fehler eingeführt hat.

 

// in src/voltageDiv.c

int32_t get_Resistance(uint32_t Vin, uint32_t Vout )

{

if(Vin == 0 || Vout == 0) return -1;

 

if(Vin == Vout) return -1;

if(Vout > Vin) return -1;

 

uint32_t R2 = R1*Vout / (Vin - Vout);

 

return R2;

}

 

Der Potentiometertreiber

Nun bauen wir den Potentiometertreiber. Wir erstellen eine Funktion, die das Potentiometer auf den gewünschten Widerstandswert setzt, indem sie diesen über SPI an den Chip sendet. Der zugehörige Unit-Test überprüft dieses Verhalten und schlägt beim ersten Durchlauf wie erwartet fehl. Erstellen wir dafür ein Modul mit ceedling module:create[AD5160].

 

// in src/AD5160.h

uint8_t pot_set(uint32_t res_value);

 

// in src/AD5160.c

uint8_t pot_set(uint32_t res_value)

{

return 0;

}

 

// in test/test_AD5160.c

void test_AD5160_SetResistanceViaSpi(void)

{

TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);

}

 

Während des normalen Betriebs gibt die Funktion pot_set den Wert1 zurück, um zu bestätigen, dass das Potentiometer erfolgreich gesetzt wurde. Dies wird mit der TEST_ASSERT...-Funktion überprüft. Wie Sie sehen, schlägt der erste Test fehl.

Die Basisimplementierung der pot_set-Funktion besteht darin, die entsprechende Anzahl an Schritten für das digitale Potentiometer zu berechnen, um einen möglichst passenden Widerstandswert zu erreichen. Da dafür eine SPI-Übertragung mit echter Hardware nötig wäre, verwenden wir beim Testen einen Mock, um die reale SPI-Übertragung zu ersetzen. In Ceedling geschieht dies, indem vor der Assertion, die pot_set testet, spi_transfer_ExpectAndReturn aufgerufen wird. Dafür muss lediglich die spi_transfer-Deklaration in SPI.h eingebunden werden.

 

// in test/test_AD5160.c

void test_AD5160_SetResistanceValue(void)

{

spi_transfer_ExpectAndReturn(128,1);

// SPI-Mock wird innerhalb von pot_set verwendet

TEST_ASSERT_EQUAL_INT8(pot_set(5000),1);

}

 

// in src/AD5160.c

uint8_t pot_set(uint32_t res_value)

{

uint8_t step = 10000/256;

uint8_t D = res_value / step;

return spi_transfer(D);

}

 

Abschließend können wir den Code noch durch Makros und eine Prüfung erweitern, damit der benötigte Widerstand den Maximalwert des Potentiometers nicht überschreitet.

uint8_t pot_set(uint32_t res_value)

{

if(res_value > R2_MAX) return 0;

uint8_t D = res_value / POT_RESOLUTION ;

return spi_transfer(D);

}

Den vollständigen Quellcode des Demo-Projekts finden Sie im GitHub-Repository[4].

Abonnieren
Tag-Benachrichtigung zu programmieren jetzt abonnieren!

Bewertung der Effizienz von TDD

Wie jeder ingenieurmäßige Ansatz ist TDD nicht immer die optimale Lösung für jedes Szenario. Selbst wenn es nicht vollständig angewendet wird, können seine Prinzipien die Herangehensweise an Softwareentwicklung deutlich beeinflussen. TDD bietet zahlreiche Vorteile, darunter:

  • TDD fördert Modularität und Isolierung von Abhängigkeiten, was für effektive Unit-Tests essenziell ist. Ihr Code wird dadurch gut strukturiert und portabel. Es wird zum Beispiel davon abgeraten, Anwendungslogik direkt mit Hardwarezugriffen zu vermischen, selbst im Namen der Effizienz.
  • Die TDD-Iteration hält Entwickler auf ein Problem zur gleichen Zeit fokussiert und betont externes Verhalten und Schnittstellendesign, um effektive Unit-Tests zu erstellen.
  • Das Ausführen von Unit-Tests in jedem Schritt hilft, Fehler schnell zu lokalisieren und bietet unmittelbares Feedback.
  • TDD ermöglicht Entwicklung bereits vor Verfügbarkeit der Zielhardware durch Verwendung von Mocks, wodurch Hardwarekomponenten simuliert und Tests sowie Validierung früh im Prozess durchgeführt werden können.
  • TDD ermöglicht unabhängige Entwicklung, selbst wenn Kollegen ihre Teile noch nicht abgeschlossen haben. Ist etwa ein Treiber noch in Entwicklung, kann dessen Verhalten durch Mocks simuliert werden.
  • Die TDD-Iteration bietet Entwicklern eine kontinuierliche Entwicklung. Sobald eine oder mehrere Unit-Tests ein Feature abdecken, ist dessen erfolgreiche Implementierung bestätigt.

Eine der wenigen wissenschaftlichen Studien zur Anwendung von TDD in der Embedded-Software-Entwicklung, „Test-Driven Development and Embedded Systems: An Exploratory Investigation“[5], untersuchte deren Einfluss auf Softwarequalität und Entwicklerproduktivität. In der Studie implementierten neun Masterstudenten Testprojekte mit und ohne TDD. Die Ergebnisse zeigten eine höhere externe Qualität, aber keinen signifikanten Unterschied in der Produktivität. Auch wenn diese Studie keine endgültige Antwort liefert, sind die Ergebnisse dennoch bemerkenswert.

Die offensichtlichen Herausforderungen von TDD, wie der zusätzliche Code für Unit-Tests und der Aufwand zum Ersetzen von Abhängigkeiten durch Test-Doubles wie Mocks, müssen nicht extra betont werden. Die Entscheidung für TDD sollte immer Aufwand und potenziellen Nutzen abwägen.

Abschließend sei erwähnt, dass TDD eine Entwicklungsmethodik und keine Implementierungsstrategie ist —sie zeigt Entwicklern nicht, wie sie ihren Code strukturieren oder architektonisch aufbauen sollen. TDD passt zur agilen Entwicklung, insbesondere zu Extreme Programming (XP), das häufige Releases in kurzen Entwicklungszyklen und den Test-First-Ansatz befürwortet. Allerdings ist TDD nicht ausschließlich auf XP beschränkt. Außerdem sollten während TDD entstandene Unit-Tests durch System- und Integrationstests ergänzt werden.

On-Target- vs. Off-Target-Testing

Off-Target- und On-Target-Testing bezeichnen den Ort, an dem Tests ausgeführt werden (siehe Bild3). Off-Target-Testing läuft auf dem Host-Rechner, während On-Target-Testing direkt auf der Zielhardware ausgeführt wird, die später die Firmware betreibt. Dabei werden Unit-Tests auf der Zielhardware ausgeführt und Testergebnisse typischerweise über UART-Logs oder ein ähnliches Protokoll an den Host übermittelt.

Manche argumentieren, dass Off-Target-TDD ineffizient für das Testen realer Hardware-Funktionalität sei —das ist zum Teil richtig und zeigt, dass in manchen Fällen On-Target- oder Dual-Target-Testing erforderlich ist. Allerdings ist Off-Target-TDD entscheidend, wenn Hardwarefehler schwer auszulösen oder zu reproduzieren sind. Beispielsweise ist es beim Entwickeln eines Flash-Speicher-Treibers schwierig, Fehler wie SPI-Busprobleme oder Flash-Ausfälle zu testen, da die Hardware unter normalen Bedingungen meist korrekt arbeitet.
 

TDD On-Target, Off-Target, and Dual-target strategies.
Bild 3. On-Target-, Off-Target- und Dual-Target-Strategien des TDD.

On-Target-Testing erscheint oft bevorzugt und realistischer, aber es gibt Fälle, in denen Off-Target-Testing praktischer ist:

 

  • Die Zielhardware befindet sich noch in Entwicklung und ist noch nicht einsatzbereit.
  • Begrenzter Speicher auf der Zielhardware verhindert das Ausführen aller Unit-Tests.
  • Keine Debug-Ports oder geeignete Schnittstelle, um Testergebnisse anzuzeigen.
  • Begrenzte Hardware-Verfügbarkeit erschwert das Testen für alle Teammitglieder.
  • Bestimmte Fehler (beispielsweise Busfehler) lassen sich auf realer Hardware nicht zuverlässig auslösen.
  • Die Test-Suite dauert zu lange, weil das Flashen von Binärdateien und das Abrufen der Testergebnisse Zeit in Anspruch nimmt.

 

In solchen Fällen sorgt Off-Target-TDD für einen reibungslosen Entwicklungsprozess, während bei Bedarf periodisches oder partielles On-Target-Testing —das sogenannte Dual-Target-Testing— eingesetzt werden kann.

Einige Entwickler denken, Unit-Test-Frameworks wie Unity funktionieren nur auf dem Host, was aber nicht stimmt. Unity kann direkt auf der Zielhardware ausgeführt werden, wobei Testergebnisse beispielsweise über UART ausgegeben werden. On-Target-Testing ist jedoch schwieriger zu automatisieren.

Off-Target-Testing birgt allerdings auch Risiken, wie etwa Unterschiede zwischen Host- und Ziel-Compiler-Toolchains. Ein int beispielsweise ist auf den meisten ARM Cortex-M-Zielen in der Regel 4Byte groß, kann aber auf x86-64 4◦Byte oder 8Byte umfassen. Ein guter Kompromiss ist es, TDD anzuwenden und den Code mit der Toolchain des Ziels schnell und nahtlos beispielsweise mit Emulatoren wie QEMU auszuführen.


TDD aus Sicht der Firmware-Entwickler-Community
 

Rückmeldungen aus der Entwickler-Community liefern wertvolle Einblicke. Online-Plattformen wie Reddit ermöglichen es, Meinungen und Erfahrungen aus der Branche einzuholen – auch von Mitarbeitenden solcher Unternehmen, die ihre TDD-Nutzung normalerweise nicht öffentlich machen. Da anonyme Beiträge reale Praxiserfahrungen widerspiegeln, habe ich einige bemerkenswerte Stimmen ausgewählt, ohne sie weiter zu bewerten.

Ein Entwickler ist überzeugt, dass sich TDD grundsätzlich lohnt, auch wenn der Einstieg oft ungewohnt ist und der Ansatz nicht die schnellste Methode zur Softwareentwicklung darstellt. Ein anderer hebt hervor, dass mit gezieltem Mocking und Fakes Unit-Tests vollständig ohne Hardwarebezug möglich sind.

Kritisch äußerte sich ein Entwickler insbesondere bei Projekten mit vielen Unbekannten, die erst im Laufe der Entwicklung sichtbar werden. Hier entstehe unnötiger Mehraufwand, weil Tests wieder gelöscht oder angepasst werden müssen. Oft wird daher zunächst ein Prototyp so weit entwickelt, bis dieser durch funktionale oder Integrationstests auf höherer Ebene stabil läuft. Erst dann wird gezielt refaktoriert und in den stabilen Bereichen mit Unit-Tests abgesichert.

Ein weiterer interessanter Anwendungsfall für TDD wird bei mehrstufigen Signalverarbeitungsalgorithmen gesehen, da sich jede Verarbeitungsstufe separat testen lässt. Darüber hinaus helfen die bei TDD entstehenden Unit-Tests, seltene Fehler auf dem Host zu erkennen, etwa Hardware- oder Timing-Probleme, die auf realer Hardware schwer nachzustellen sind.


Fazit

Manche befürworten TDD, ohne auf Vor- und Nachteile einzugehen, andere lehnen es komplett ab. Das Erlernen und Ausprobieren von TDD kann die Entwicklung auch dann verbessern, wenn man es nicht strikt befolgt. Besonders bei großen Projekten mit mehreren Teammitgliedern und komplexen Funktionalitäten ist TDD nützlich, aber auch kleinere Teams und Projekte profitieren davon.

Entwickler, die TDD ablehnen, haben oft Schwierigkeiten mit Firmware-Architektur oder dem Schreiben effektiver Testfälle. Tatsächlich stammen viele Missverständnisse zu TDD von mangelnder Erfahrung beim Verfassen guter Unit-Tests. Ansätze wie „Test the interface, not the implementation“ und Behavior-Driven-Development (BDD) helfen, redundantes Umschreiben von Tests zu vermeiden. Zusätzlich bewertet Mutation Testing die Testqualität, indem kleine Codeänderungen eingeführt werden und überprüft wird, ob die Tests diese erkennen.

Zum Schluss noch die übliche Literaturempfehlung: Das grundlegende Buch von James Grenning Test-Driven Development for Embedded C[6] ist zum Zeitpunkt der Artikelerstellung das einzige Werk, das sich explizit mit TDD in eingebetteten Systemen beschäftigt. Ebenfalls empfehlenswert ist das Buchkapitel „Test-Driven Development as a Reliable Embedded Software Engineering Practice“ aus Embedded and Real-Time System Development: A Software Engineering Perspective, das eine ausführliche Erklärung von TDD in eingebetteten Systemen bietet und wertvolle Erweiterungen vorschlägt. Eine Vielzahl von Ressourcen, die zur Erstellung dieses Artikels genutzt wurden, sind platzsparend in einer GitHub-README-Datei gelistet.


Anmerkung der Redaktion: Dieser Artikel (250092-01) erschien in Elektor September/October 2025.


Fragen zu Firmare oder TDD?

Haben Sie technische Fragen zur Firmare-Entwicklung oder zu diesem Artikel? Schicken Sie eine E-Mail an den Autor unter yt@atadiat.com oder an Elektor unter editor@elektor.com.

Abonnieren
Tag-Benachrichtigung zu Firmware jetzt abonnieren!