Testen von Embedded-Systemen mit ANSI-C

Eingebettet und getestet
Kommentare

In diesem Artikel werden Möglichkeiten vorgestellt, die unter Verwendung von Standardtechniken und Werkzeugen ein effizientes Testen von kleineren Embedded-Systemen ermöglichen. Dabei wird Wert auf das systematische Vorgehen und die Wiederverwendung der Testfälle und Testergebnisse gelegt.

Das Testen von kleinen Embedded-Systemen stellt aufgrund der geringen Ressourcen und der zum Teil eingeschränkten Kommunikationsmöglichkeiten eine besondere Herausforderung dar. In dem folgendem Artikel wird ein Konzept vorgestellt, wie trotz geringer Ressourcen und eingeschränkter Kommunikationsmöglichkeiten effizient auf Basis von Standardtechniken getestet werden kann.

Eine wichtige Frage bei der Entwicklung ist: „Verhält sich das entwickelte System gemäß den an ihn gestellten Anforderungen, und wie robust ist das System gegenüber Störungen und nicht spezifizierten Betriebsbedingungen?“

Es ist dabei zwischen dem Debuggen und dem Testen zu unterscheiden. Das Debuggen erfolgt während der Implementierung, um die Funktion einzelner Programmabschnitte zu überprüfen und nachzuvollziehen. In der Regel ist das Debuggen unsystematisch und nicht reproduzierbar. Im Gegensatz dazu findet das Testen mit geeigneten Testfällen geplant statt. Folgende Anforderungen werden u. a. an die Durchführung von Tests gestellt:

  1. Tests müssen reproduzierbar sein.
  2. Tests müssen gegen erreichbare Erwartungswerte durchgeführt werden.
  3. Testfälle müssen sinnvoll sein und zu neuen Erkenntnissen führen.

Die Forderung nach der Reproduzierbarkeit von Tests führt zwangsläufig dazu, dass die Durchführung der Tests, soweit dies sinnvoll möglich ist, automatisiert werden müssen. Sobald zeitliche Abläufe oder feste Reihenfolgen von Signalen eingehalten werden müssen, ist ein sinnvolles manuelles Testen nicht mehr möglich. Wenn nach einzelnen Entwicklungsschritten die Tests wiederholt werden sollen, um die Rückwirkungsfreiheit der durchgeführten Änderungen nachweisen zu können, spricht man von Regressionstests. Diese Tests sind ebenfalls nur automatisiert durchführbar, da durch das häufige Wiederholen der Tests der Testaufwand sehr hoch wird und nur noch schwer zu beherrschen ist.

Um Tests durchführen zu können, muss eine Testbarkeit mitimplementiert sein. Dazu sind notwendige Schnittstellen bereitzustellen und zu dokumentieren. Ebenfalls muss die Implementierung der Tests parallel während der Entwicklung des Systems erfolgen. Nur so kann sichergestellt werden, dass das System getestet werden kann, frühzeitig Erfahrungen über die Güte des Systems und zum Abschluss des Projekts eine hohe Reife und Anzahl von Testfällen vorliegen. Testen im Nachhinein ist häufig wesentlich aufwändiger und liefert häufig nicht die Güte der Testergebnisse. Auch werden Fehler erst spät erkannt und somit auch erst spät behoben. Das führt u. a. dazu, dass wenig Erfahrung über die Güte des erstellten Systems vorliegt.

Konzept der „Testgetriebenen Entwicklung“

Um einen projektbegleitenden Test durchzuführen, bietet sich das Konzept der „Testgetriebenen Entwicklung“ (testgesteuerte Programmierung, engl.: Test First Development oder Test-driven Development (TDD)) an. Hierzu werden vor der Implementierung der Funktionssoftware die notwendigen Testfälle spezifiziert und implementiert. Die Implementierung der Funktionssoftware erfolgt dann gegen die bereits erstellten Testfälle.

Beispiel: Temperatursensor

Es soll ein Modul implementiert werden, das die gemessene Temperatur einem Temperaturbereich zuordnet. Die Temperatur wird durch einen A/D Wandler digitalisiert, der über eine Auflösung von 10 Bit verfügt und somit einen Wertebereich von 0 … 1023 abbilden kann.

Alle Werte, die sich nicht in diesem Wertebereich befinden, sind ungültig und lassen auf defekte Hardware schließen. Der Sensor selbst liefert nur in einem bestimmten Wertebereich gültige Temperaturwerte. So können z. B. zu kleine Werte ein Indikator für einen Kurzschluss gegen GND und zu große Werte ein Indikator für einen Kurzschluss gegen U_Bat sein. Es wird somit der verwendbare Wertebereich weiter eingeschränkt. Der gültige Wertebereich wird in diesem Beispiel in die Bereiche KaltNormalWarm und Heiß unterteilt (Abb. 1).

Abb. 1: Unterteilung des Wertebereichs in Aquivalenzklassen

Diese Bereiche lassen sich durch eine Aufzählung (Enumeration) abbilden. Die Funktionssoftware wird mittels einer Funktion realisiert. Der Funktion wird der erfasste Wert übergeben und als Ergebnis ein Wert der Enumeration zurückgegeben. Die Schnittstelle kann wie in Listing 1 aussehen.

typedef enum {
  sensor_SHORT_GND,
  sensor_COLD,
  sensor_NORMAL,
  sensor_WARM,
  sensor_HOT,
  sensor_SHORT_UBAT,
  sensor_ADC,
  sensor_UNDEFINED
} sensor_RangeT;

extern sensor_RangeT sensor_getRange( uint16_t inValue );

Vor der Implementierung der Funktionssoftware werden die notwendigen Testfälle ermittelt und implementiert. Für die Ermittlung der notwendigen Testfälle kann die Grenzwertanalyse und das Bilden von Äquivalenzklassen verwendet werden. In dem Beispiel wurden durch das Bilden der Aufzählung (Enumeration) die Äquivalenzklassen gebildet. Tabelle 1 zeigt eine Übersicht.

Tabelle 1: Äquivalenzklassen

[ header = Seite 2: Testframeworks ]

Testframeworks

Die Tests werden auf der Modulebene durchgeführt. Dazu muss ein geeignetes Framework zur Verfügung stehen. Unter Java hat sich das JUnit-Framework etabliert und ist Bestandteil der Eclipse-Entwicklungsumgebung. Durch diese nahtlose Integration ist es relativ einfach, die notwendigen Testfälle zu implementieren und auszuführen.

Für C und C++ stehen auf JUnit basierende Frameworks wie CUnit, CppUnit oder Embedded Unit zur Verfügung. Diese Frameworks sind nicht Bestandteil von Standardframeworks. Um diese Testframeworks nutzen zu können, ist ein höherer Aufwand an Integration und Durchführung notwendig. Diese Frameworks basieren auf der grundsätzlichen Struktur in Abbildung 2.

Abb. 2: Struktur eines Testframeworks

Bei JUnit bildet Eclipse den Host, über den das Framework gesteuert wird. Das Framework ruft die Testfälle auf und sammelt die Ergebnisse der Tests. Die Darstellung der Testergebnisse erfolgt dabei durch den Host. Sollen die Tests bei kleinen Embedded-Systemen auf der Zielhardware durchgeführt werden, dann stehen auf der Zielhardware kein Host für die Darstellung der Ergebnisse und häufig nur wenig leistungsfähige Kommunikationsschnittstellen zur Verfügung. Die Softwarekomponenten/-module auf der Zielhardware bestehen somit aus:

  • dem zu testenden Modul
  • den auszuführenden Testfällen
  • dem Framework, das die Testfälle in einer vorher festgelegten Reihenfolge ausführt
  • der Kommunikationsschnittstelle zum Terminal
  • einem Runtime-Modul, das das Framework und die Kommunikation ausführt

Abbildung 3 zeigt die Struktur eines Embedded-Testframeworks.

Abb. 3: Struktur eines Embedded-Testframeworks

Das Framework stellt die notwendigen Funktionen und Makros zum Ausführen der Tests zur Verfügung und organisiert die Reihenfolge der Tests.

Die Testfälle werden in Testmodulen, den Suites, zusammengefasst. Die von dem Testmodul zur Verfügung gestellte Funktion testsensor_getSuite () liefert einen Zeiger auf die auszuführende Testsuite zurück. Listing 2 zeigt die Schnittstelle der Testsuite für den Sensor.

#ifndef TESTSENSOR_H_
#define TESTSENSOR_H_

#include "XCUnit.h"

extern xcuint_SuiteS * testsensor_getSuite();

#endif

Jeder Test wird durch eine eigene Funktion realisiert. Die auszuführenden Tests werden in der Datenstruktur xcunit_CaseS erfasst und die Reihenfolge ihrer Ausführung festgelegt. Die Datenstruktur verfügt über die vier Datenelemente in Tabelle 2.

Tabelle 2: Datenstruktur „xcunit_CaseS“

Die Datenstruktur xcuint_SuiteS liefert die notwendigen Informationen über die auszuführende Suite. Typischerweise realisiert ein C-Modul eine Suite. Die Datenstruktur verfügt über die Datenelemente in Tabelle 3.

Tabelle 3: Datenstruktur „xcunit_SuiteS“

Das Codesegment in Listing 3 zeigt die Implementierung der Funktion testsensor_getSuite ().

xcuint_SuiteS * testsensor_getSuite()
{
  static xcunit_CaseS theCaseList[] = {
    { "testLimitValue", testLimitValue, testBefore, testAfter },
    { "testEquivalenceClasses", testEquivalenceClasses, testBefore, testAfter },
  };

  static xcuint_SuiteS theSuite = { "testLevel0Queue",
    theCaseList,
    sizeof( theCaseList ) / sizeof( xcunit_CaseS ),
    0, 0
  };

  return &theSuite;
} // end testsensor_getSuite 

Die Funktionen für die Tests müssen der folgenden Funktionszeigerschnittstelle entsprechen, um zuweisungskompatibel zu dem Datenelement der Datenstruktur xcuint_SuiteS zu sein:

typedef void (*xcunit_FuncPtrT)(); 

Die Funktionen verfügen gemäß ihrer Schnittstelle weder über einen Rückgabewert noch über Parameter.

In dem Beispiel werden vier Funktionen in der Suite benötigt. Die beiden Funktionen testLimitValue() und testEquivalenceClasses() enthalten die einzelnen abzuarbeitenden Testschritte. Die Funktionen testBefore() und testAfter() werden vor bzw. nach den beiden Funktionen aufgerufen, um die Testumgebung ggf. in eine definierte Startbedingung zu setzen bzw. um nach dem Test evtl. noch belegte Ressourcen wieder freizugeben. Das Codefragment in Listing 4 zeigt die vier leeren Rümpfe der Funktionen.

static void testBefore()
{
}
static void testAfter()
{
}
static void testLimitValue() 
{
}
static void testEquivalenceClasses()
{
} 

Für das Testen und die Fehlersuche steht das Modul assert. zur Verfügung. Die Makros und Funktionen, die dort zur Verfügung gestellt werden, erlauben es, Ausdrücke aus einem Programm auf logische Fehler zu testen. Ergibt eine Prüfung den Wert 0, dann liegt ein Fehler vor und es wird eine Fehlermeldung auf stderr ausgegeben.

[ header = Seite 3: Kommunikation mit dem Host ]

Kommunikation mit dem Host

Typischerweise erfolgt die Ausgabe über stderr auf die Konsole. Es besteht jedoch die Möglichkeit, die Ausgabe umzulenken. Das Umlenken ist immer dann erforderlich, wenn Embedded-Systeme nicht über eine Konsole verfügen.

In der verwendeten Implementierung soll eine RS232-Schnittstelle verwendet werden. Dazu wird eine eigene putchar()-Funktion mit dem Namen usart0_putchar() implementiert. Die Aufgabe der Funktion besteht darin, ein einzelnes Zeichen über die RS232-Schnittstelle zu senden, wenn der Sendepuffer zur Verfügung steht:

int uart0_putchar(char c, FILE *stream)
{
  while( !(UCSR0A & (1<<UDRE0)) ) {}
  UDR0 = c;
  return 0;
} // end uart0_putchar
 

Als Nächstes muss eine Variable vom Type FILE angelegt und initialisiert werden. Beim Initialisieren wird die neue Funktion mit dem Namen usart0_putchar() angegeben:

static FILE mg_stdout = FDEV_SETUP_STREAM(uart0_putchar, NULL,FDEV_SETUP_WRITE);  

Damit die neue Funktion usart0_putchar() bei der Ausgabe genutzt wird, muss die neue Variable vom Type FILE dem stdout zugewiesen werden. In der Implementierung erfolgt die Zuweisung beim Aufruf der Funktion uart0_connectStdIOStream(). Diese Funktion muss einmalig vor der ersten Verwendung von Ausgaben aufgerufen werden:

void uart0_connectStdIOStream()
{
  stdout = &mg_stdout; 
} 
 

Testbedingungen abprüfen und Ergebnisse zur Verfügung stellen

Nachdem die Ausgabe der Testergebnisse mittels der seriellen Schnittstelle realisiert ist, kann mit der Implementierung der Tests begonnen werden. Für die Prüfung von Bedingungen wird hier exemplarisch das folgende Makro zur Verfügung gestellt:

ASSERT_EQUAL_UINT16 

Das Makro verfügt über drei Parameter (Tabelle 4).

Tabelle 4: Parameterliste des Makros

Das Makro ruft eine Funktion auf, die um die Parameter line und file erweitert ist. Als Wert wird dem Parameter line die Zeile im Programm übergeben, wo das ASSERT_EQUAL_UINT16-Makro aufgerufen wird. Der Parameter file erhält eine Referenz auf den aktuellen Namen der Datei.

Das folgende Programmsegment zeigt die Funktion, die durch das Makro ASSERT_EQUAL_UINT16 aufgerufen wird. Die Funktion hat zwei Aufgaben:

  • den logischen Vergleich der beiden Werte expected und actual.
  • abhängig vom Ergebnis des Vergleichs, die Ausgabe des Ergebnisses mittels der stdout über die serielle Schnittstelle (Listing 5).
void xcunit_assertImplementationUInt16( uint16_t inId, 
                                        uint16_t expected,
                                        uint16_t actual, 
                                        long   line, 
                                        const char *file)
{
  mg_NumOfAsserts++;
  if( expected == actual ) {
    strcpy_P( mg_Format, mg_TextAssertUInt16True );
    printf( mg_Format, inId, file, line, expected, actual );
  }
  else {
    mg_NumOfErrors++;
    strcpy_P( mg_Format, mg_TextAssertUInt16False );
    printf( mg_Format, inId, file, line, expected, actual );
  }
} 

[ header = Seite 4: Strukturierung von Tests ]

Strukturierung von Tests

Eines der Ziele bei der Erstellung von Tests ist deren Wiederverwendung. Um eine hohe Wiederverwendung zu erreichen, ist neben einer angemessenen Dokumentation eine Strukturierung der Tests notwendig. Es wird dabei unterschieden zwischen:

  • Test
  • Case
  • Suite
  • Szenario

Diese Strukturierung basiert auf den Möglichkeiten der Modularisierung von Software (Abb. 4).

Abb. 4: Modularisierung von Software

Jeder einzelne Test wird über eine ASSERT-Abfrage (z. B. ASSERT_EQUAL_UINT16) realisiert. Die Tests werden mittels Cases zusammengefasst und bilden die kleinste wiederverwendbare Einheit. Durch die Reihenfolge der Tests in einem Case wird die Abarbeitung der Tests festgelegt. Das Zusammenfassen der Tests in Cases wird in dem Beispiel durch die ANSI-C-Funktionen testLimitValue() und testEquivalenceClasses() realisiert.

Mehrere ANSI-C-Funktionen bzw. Cases werden ihrerseits in ANSI-C-Module, den Suites, zusammengefasst.

Listing 6 zeigt die Schnittstelle der Suite testSensor, die durch das ANSI-C-Modul testSensor.h und testSensor.h realisiert wird.

#ifndef TESTSENSOR_H_
#define TESTSENSOR_H_

#include "XCUnit.h"

extern xcuint_SuiteS * testsensor_getSuite();

#endif /* TESTSENSOR_H_ */ 

Listing 7 zeigt die Implementierung der beiden Cases testLimitValue() und testEquivalenceClasses() mit den dazugehörigen Tests.

Die Funktion testsensor_getSuite() ist die Schnittstelle der Suite. Sie liefert eine Referenz auf eine Datenstruktur zurück, die alle notwendigen Daten zum Ausführen der Suite enthält.

#include "testSensor.h"
#include "Sensor.h"

static void testBefore() {}

static void testAfter() {}

static void testLimitValue() 
{
  ASSERT_EQUAL_UINT16(  1, sensor_SHORT_GND, sensor_getRange( 0 ) );
  ASSERT_EQUAL_UINT16(  2, sensor_SHORT_GND, sensor_getRange( 100 ) );
  ASSERT_EQUAL_UINT16(  3, sensor_COLD, sensor_getRange( 101 ) );
  . . . 
  ASSERT_EQUAL_UINT16( 14, sensor_ADC, sensor_getRange( 65535 ) );
}

static void testEquivalenceClasses()
{
  ASSERT_EQUAL_UINT16(  1, sensor_SHORT_GND, sensor_getRange( 50 ) );;
  ASSERT_EQUAL_UINT16(  3, sensor_COLD, sensor_getRange( 150 ) );
  ASSERT_EQUAL_UINT16(  5, sensor_NORMAL, sensor_getRange( 250 ) );
  . . .
  ASSERT_EQUAL_UINT16( 13, sensor_ADC, sensor_getRange( 32000 ) );  
}

xcuint_SuiteS * testsensor_getSuite()
{
  static xcunit_CaseS theCaseList[] = {
    { "testLimitValue", testLimitValue, testBefore, testAfter },
    { "testEquivalenceClasses", testEquivalenceClasses, testBefore, testAfter },
  };
  
  static xcuint_SuiteS theSuite = { "testLevel0Queue",
    theCaseList,
    sizeof( theCaseList ) / sizeof( xcunit_CaseS ),
    0, 0
  };

  return &theSuite;

In dem Beispiel wird nur eine Suite verwendet. Listing 8 realisiert das Hauptprogramm mit der main()-Funktion, den benötigten Daten und Aufrufen.

#include <avr/io.h>
#include "uart0.h"
#include "XCUnit.h"
#include "testSensor.h"

static xcuint_ScenarioS mg_Scenario = { "Sensor Test", "1.0.0.1", 
                                        "2012-02-23", 0, 0 };
int main()
{
  uart0_init();
  uart0_connectStdIOStream();

  xcunit_start( &mg_Scenario );
  xcunit_run( testsensor_getSuite() );
  xcunit_end();

  while( 1 ) {
  } // end while ...

  return( 0 );
} // end main 

[ header = Seite 5: Ausgabe der Ergebnisse + Fazit und Ausblick ]

Ausgabe der Ergebnisse

In der Regel werden die Ergebnisse der Tests als lesbarer Text auf dem Bildschirm oder einem Terminal ausgegeben. Diese Art der Ausgabe hat den Vorteil der einfachen Lesbarkeit für die Person, die den Test durchführt. Allerdings wird eine Weiterverarbeitung und Archivierung der Ergebnisse erschwert.

Um eine Vereinfachung der weiteren Verarbeitung sicherstellen zu können, kann das XML-Format verwendet werden. Die Ergebnisse der einzelnen Tests werden im Framework in einem XML-Zeichenstrom verpackt und an die Kommunikation (z. B. RS232) weitergegeben. Der Empfänger speichert den XML-Zeichenstrom. Dieser kann dann mit standardisierten Werkzeugen weiterverarbeitet werden. Listing 9 zeigt die Struktur des Hauptprogramms.

<scenario name="Sensor Test" version="1.0.0.1" date="2012-02-23">
<suite name="testLevel0Queue">
<case name="testLimitValue">
<assert id="1" err="n" f=".././testSensor.c" l="24" t="uint16" e="0" a="0"></assert>
<assert id="2" err="n" f=".././testSensor.c" l="25" t="uint16" e="0" a="0"></assert>
<assert id="3" err="n" f=".././testSensor.c" l="26" t="uint16" e="1" a="1"></assert>
<assert id="4" err="n" f=".././testSensor.c" l="27" t="uint16" e="1" a="1"></assert>
<assert id="5" err="n" f=".././testSensor.c" l="28" t="uint16" e="2" a="2"></assert>
<assert id="6" err="n" f=".././testSensor.c" l="29" t="uint16" e="2" a="2"></assert>
<assert id="7" err="n" f=".././testSensor.c" l="30" t="uint16" e="3" a="3"></assert>
<assert id="8" err="n" f=".././testSensor.c" l="31" t="uint16" e="3" a="3"></assert>
<assert id="9" err="n" f=".././testSensor.c" l="32" t="uint16" e="4" a="4"></assert>
<assert id="10" err="n" f=".././testSensor.c" l="33" t="uint16" e="4" a="4"></assert>
<assert id="11" err="n" f=".././testSensor.c" l="34" t="uint16" e="5" a="5"></assert>
<assert id="12" err="n" f=".././testSensor.c" l="35" t="uint16" e="5" a="5"></assert>
<assert id="13" err="n" f=".././testSensor.c" l="36" t="uint16" e="6" a="6"></assert>
<assert id="14" err="n" f=".././testSensor.c" l="37" t="uint16" e="6" a="6"></assert>
</case>
<case name="testEquivalenceClasses">
<assert id="1" err="n" f=".././testSensor.c" l="46" t="uint16" e="0" a="0"></assert>
<assert id="3" err="n" f=".././testSensor.c" l="47" t="uint16" e="1" a="1"></assert>
<assert id="5" err="n" f=".././testSensor.c" l="48" t="uint16" e="2" a="2"></assert>
<assert id="7" err="n" f=".././testSensor.c" l="49" t="uint16" e="3" a="3"></assert>
<assert id="9" err="n" f=".././testSensor.c" l="50" t="uint16" e="4" a="4"></assert>
<assert id="11" err="n" f=".././testSensor.c" l="51" t="uint16" e="5" a="5"></assert>
<assert id="13" err="n" f=".././testSensor.c" l="52" t="uint16" e="6" a="6"></assert>
</case>
</suite>
<statistic suites="1" cases="2" asserts="21" errors="0"></statistic>
</scenario> 

Fazit und Ausblick

Es lassen sich mit einfachen Mitteln, die bereits durch die Programmiersprache bereitgestellt werden, gut strukturierte und wiederverwendbare Tests erstellen. Dieses Konzept erlaubt neben der Durchführung von Regressionstests, auch die Umsetzung der Methode der testgetriebenen Entwicklung. Bei der Ausgabe der Testergebnisse steht kleinen Embedded-Systemen meistens eine einfache serielle Schnittstelle zur Verfügung. Durch die Erzeugung von einfachen XML-Streams, die sich mittels eines Terminals einfach abspeichern lassen, steht einer automatischen Weiterverarbeitung der Daten nichts im Weg. Es lässt sich somit ein durchgängiger Workflow realisieren. Es sollen jedoch einige Nachteile nicht unerwähnt bleiben. Dazu gehört die Veränderung des Laufzeitverhaltens, durch die Erzeugung der Ausgaben und die nicht vorhandene Möglichkeit, die Testabdeckung automatisch zu ermitteln. Hier hilft nur der Einsatz von kommerziellen Werkzeugen oder die Erweiterung dieses einfachen Ansatzes.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -