Für sicherheitsrelevante Software fordern Normen die Durchführung von Unit-Tests bzw. Modultests. Die einschlägigen Normen definieren aber nicht, wie dabei vorzugehen ist. Das lässt den Entwicklungs- und Testteams Freiheiten bei der Auswahl/Definition des Verfahrens und der verwendeten Werkzeuge.
Es birgt aber die Gefahr, dass man zwar normenkonform arbeitet, aber dennoch schwere Verfahrensfehler macht.
Bei der Erstellung von Software fügen die Entwickler ab einem gewissen Projektfortschritt einzelne Module zu einem Softwaresystem zusammen. Die Unterteilung der Gesamtsoftware in einzelne Module ist Sache des Designs. Vor dem Zusammenfügen zu einem Ganzen werden die Module im Rahmen eines Modultests einzeln für sich getestet, weil es eine Reihe von Fehlern gibt, die in späteren Teststufen schwerer zu erkennen sind und weil das Debugging auf Modulebene viel effektiver ist als in späteren Teststufen. Die Referenz für diese Test ist das Design selbst, das den Zweck jeder Routine des Softwaremoduls beschreibt.
Begriffe und Granularität
Die erste dynamische Teststufe im Entwicklungsprozess von Software hat mehrere Namen. Meist verwendet die Literatur den Begriff Unit-Test, wenn der Testgegenstand eine einzelne Routine einer rein prozeduralen Programmiersprache ist, also eine Funktion in der Programmiersprache C. Unter Modultest verstehen die meisten Autoren den Test aller Routinen einer Quelldatei. Der Modultest wird manchmal auch Komponententest genannt; insbesondere dann, wenn statt einer einzigen Quelldatei mitunter auch mehrere der Gegenstand des Tests sind. Unit-Tests sind nur dann schlagkräftig, wenn sie auch die Interaktion der zusammengehörenden Routinen einer Quelldatei prüfen. Somit ergibt meist ein Set von guten Unit-Tests den Modultest. Die Begriffe sind daher in diesem Artikel auswechselbar verwendet.
Wie oben erwähnt, beschreibt die Designdokumentation (zumindest) das Verhalten eines Moduls im Gesamtkontext, also (zumindest) die öffentlich sichtbare Schnittstelle des Moduls. Der Unit-Test sollte im Idealfall nur unter Verwendung dieser öffentlich sichtbaren Schnittstelle arbeiten.
Andernfalls würde jedes schnittstellenneutrale Refactoring des zu testenden Moduls eine Überarbeitung der Unit-Tests notwendig machen. Die ausschließliche Orientierung an der öffentlich sichtbaren Schnittstelle erleichtert auch die Zusammenarbeit im Team, wenn man in Projekten arbeitet, in denen die Sicherheitsnorm fordert, dass der Unit-Test von einer anderen Person als dem Autor des Codes, durchgeführt wird.
Wozu überhaupt Unit Tests?
Werden viele Softwaremodule eines eingebetteten Systems zum voll integrierten System zusammengefügt, dann ist es manchmal schwer, Fehler des Systems den jeweiligen Ursachen zuzuordnen. Wenn dank eines Modultests sichergestellt ist, dass jedes Modul seine Aufgabe korrekt erfüllt, dann reduziert sich die Wahrscheinlichkeit für solch eine komplexe Fehlersuche. Ein Beispiel: ein Laserinterferometer misst die (relative) Länge eines Laserstrahls mit einer Genauigkeit im Bereich der Wellenlänge des Lichts. Die Messung wird unter anderem durch die Luftfeuchte beeinflusst und die Firmware des Interferometers verwendet unter anderem eine Fast-Fourier-Transformation (FFT) für die Längenmessung. Angenommen in der FFT liegt ein Fehler vor: Wie wahrscheinlich ist es, dass in einem Systemtest ein Szenario durchlaufen wird, das eine Abweichung um ein paar Dutzend Nanometer erkennt und diese Abweichung korrekt einem Fehler der FFT zugeordnet werden kann?
Viele Arten von Fehlern, die ein Modultest leicht findet, sind im Systemtest erst nach langer Testzeit oder nur unter außergewöhnlichen Umständen zu erkennen. Zum Beispiel dann, wenn der Fehlerzustand an der Blackbox nicht sichtbar ist, aber Folgen für spätere Berechnungen hat. Nicht zuletzt sind Unit-Tests eine einfache Möglichkeit 100% einer durch die Sicherheitsintegritätsstufe definierten Codeabdeckung zu demonstrieren – was in späteren Teststufen schwieriger wäre. Bei defensiver Programmierung ist ein Unit-Test auch oft die einzige Möglichkeit 100 % Abdeckung zu erzielen, wenn bestimmte Code-Pfade nur durch Fehlerinjektion erreichbar sind.
Im Moment verwendet die Mehrheit der Firmen Instrumentierung, um die Codeabdeckung zu messen. Der Instrumenter verändert den Quellcode, indem er zusätzliche Zeilen einfügt, die für die Überdeckungsanalyse benötigt werden. Listing 1 zeigt ein Beispiel. Das Problem: der Quellcode ist nicht mehr der, den man an den Kunden liefert. Alle Handbücher der den Autoren bekannten Unit-Test-Werkzeuge weisen auf diesen Umstand hin und empfehlen zwei Testdurchläufe. Der erste Testdurchlauf wird mit Instrumentierung gemacht. Er dient dazu, den Test zu debuggen und die Testfälle so nachzubessern, dass die geforderte strukturelle Testabdeckung erreicht wird. Der Zweck des ersten Durchlaufs ist also vor allem das Sammeln von Abdeckungsinformation. Der zweite Testdurchlauf findet ohne Instrumentierung am Zielsystem statt, siehe Bild 1 – ein Demo-Aufbau für das Werkzeug Cantata. Für diesen Testlauf wird unbedingt mit dem gleichen Compiler übersetzt, wie im Produktionsbetrieb. Dieser Testdurchlauf findet Fehler im Quellcode der getesteten Software, im Compiler und in der Laufzeitbibliothek des Compilers.
Fehlerquelle Instrumenter
Keine der einschlägigen Normen verlangt die Verwendung eines Unit-Test-Werkzeugs oder das Lesen der Handbücher dieser Werkzeuge. Und dies zu Recht, denn auch ohne Unit-Test-Werkzeug kann man Unit-Tests durchführen. Es gibt Projekte der höchsten Sicherheitsintegritätsstufe, die ohne so ein Werkzeug hervorragende Arbeit leisten. Es gibt aber auch Projekte, die jedes Audit bestanden haben und es trotzdem nicht gerade richtig gemacht haben. Folgendes Beispiel zeigt, welchen schweren Fehler eine Firma gemacht hat.
Firma A erzeugt Steuergeräte, die Notfallmaßnahmen im Verkehrswesen einleiten. Die Projekte bei A verwenden eine selbst entworfene Modultestumgebung und verwenden zur Ermittlung der Testabdeckung einen Instrumenter. Der Instrumenter ist gemäß IEC 61508-3 zertifiziert und erfüllt alle Anforderungen der für das Verkehrswesen relevanten Normen EN 50128 und ISO 26262. Der verwendete Compiler ist »qualifiziert«. Das bedeutet, dass man Vertrauen hat, dass er keine sicherheitsrelevanten Fehler verursacht. In Firma A war das Erreichen von 100 % MC/DC vorgeschrieben – das ist die Testabdeckung für die höchste Sicherheitsintegritätsstufe, siehe z. B. [1]. Alle Unit-Tests wurden instrumentiert am Target durchgeführt und damit das Erreichen von 100 % MC/DC gemessen und dokumentiert. Das Modultestteam übergab den getesteten Code an das Integrations-Team, das die Gesamtsoftware ohne Instrumentierung (neu) übersetzte und den Systemtests unterwarf.
Wo ist der Fehler? Es wurde nie ein Modultestlauf ohne Instrumentierung am Target gemacht. Der im Modultest getestete Quellcode hat also nie dem entsprochen, was tatsächlich für den Build des Produktivsystems verwendet wurde. Es kann also bei Firma A potenziell Code-Teile geben, die einen Fehler enthalten, der nur dann auftritt, wenn Code nicht instrumentiert ist und diese Code-Teile im Systemtest entweder nicht ausgeführt wurden oder die Fehlerwirkung in den Systemtests (bisher) nicht sichtbar war. Das mag unwahrscheinlich erscheinen. Die geringe Wahrscheinlichkeit schützt aber nicht vor einer Haftungsklage im Schadensfall, denn hier hat Firma A ihre Sorgfaltspflicht verletzt [1].
Firma A hat sich darauf verlassen, dass sie betriebsbewährte und vertrauenswürdige Werkzeuge im Einsatz hat und ihre eigenen Verfahrensanweisungen nicht genau hinterfragt. Die Zertifizierung des Instrumenters ist unerheblich für das beschriebene Szenario. Compiler sind zwar eine nicht zu vernachlässigende Fehlerquelle, aber hier gibt es auch bei einem fehlerfreien Compiler die Möglichkeit, dass Fehler durchrutschen. Denken wir an folgende Situation: Der Optimiervorgang des Compilers kann im instrumentierten Code eine Optimierungstechnik nicht anwenden, die im Originalcode anwendbar ist. Wenn bei einem C-Compiler der Code nun beispielsweise das Schlüsselwort volatile nicht bzw. falsch verwendet, dann rutscht dieser Bug durch. Mit Instrumentierung funktioniert der Code, weil der Code nicht ausreichend optimiert werden kann. Ohne Instrumentierung funktioniert der Code nicht, weil der Optimiervorgang etwas löscht, was er nicht sollte.
Fehlerquelle Compiler
Auch wenn mit und ohne Instrumentierung getestet und im Modultest daher exakt der gleiche Quellcode verwendet wird, wie bei der Produktion, kann das Probleme machen. Man kann den falschen Object Code testen, wenn man für die Tests einen anderen Compiler verwendet oder andere Compiler-Schalter als für den finalen Software-Build.
Firma B erzeugt Steuergeräte für Kfz. Der Gleichanteil von Code in den verschiedenen Steuergeräten, die auch mit verschiedenen Prozessoren ausgeliefert werden, wird in einer Plattformabteilung entwickelt und dort Unit-Tests unterzogen. Die Unit-Tests laufen mit und ohne Instrumentierung auf einem Mustersteuergerät. Der Plattform-Code wird nach dem Test für die Kundenprojekte freigegeben. Die Kundenprojekte übersetzen diesen Code dann mit dem jeweiligen Compiler für ihr Zielsystem und führen Integrationstests und Systemtests durch. Nachdem auf den verschiedenen Zielsystemen verschiedene Prozessoren zum Einsatz kommen, die verschiedene Instruktionssätze haben, kommen in den Projekten auch verschiedene Compiler zum Einsatz.
Hier ist der Fehler die Verwendung verschiedener Compiler, ohne dass die Modultests mit jedem verwendeten Compiler durchlaufen werden. Wenn der Unit-Test mit Compiler 1 keinen Fehler findet, dann bedeutet das nicht, dass bei Compiler 2 exakt das gleiche Ergebnis herauskommt. Neben Bugs in Compilern und Bugs in den Laufzeitbibliotheken der Compiler können auch Architektureigenschaften des Prozessors dafür verantwortlich sein, denn die Prozessoren sind nicht in allen Projekten bei Firma B die gleichen. Abhilfe bei Firma B ist nicht nur die Software, sondern auch die Unit-Testfälle den Projekten zur Verfügung zu stellen. Die Projekte müssen die Unit-Testfälle durchführen und dazu genau so übersetzen, wie sie die Software für das Produktivsystem übersetzen.
Aber ist Firma B denn dafür verantwortlich, wenn das Steuergerät durch einen Compiler-Fehler jemandem Schaden zufügt? Immerhin ist der Compiler nicht im eigenen Haus entwickelt worden, sondern ein gekauftes Entwicklungswerkzeug! Die kurze Antwort ist: ja. Der Compiler-Hersteller kann nicht für jede Verwendung haften. Er kann auch nicht wissen, ob die damit erzeugte Software in einem Getränkeautomaten oder in einem Kernkraftwerk läuft. Und es ist ein offenes Geheimnis, dass Compiler nicht zu 100 % getestet werden, weil die häufig nicht getesteten Grenzfälle sehr selten vorkommen und deren Test und Behebung teuer kommen. Das zuvor erwähnte Schlüsselwort volatile ist da ein schönes Beispiel. Eine Studie [2] hat ergeben, dass 11 von 12 getesteten C-Compilern Probleme mit perfider Verwendung dieses Schlüsselworts haben. Zum Beispiel, wenn ein Objekt »const volatile« deklariert wird.
Fehlerquelle Compilerschalter
Als letztes Beispiel sei das Projekt der Firma C vorgestellt. Auch sie verwendet einen qualifizierten/betriebsbewährten Compiler. Die Unit-Test-Läufe werden mit und ohne Instrumentierung am Target-Prozessor durchgeführt. Das verwendete Testwerkzeug verwendet einen separaten Übersetzungsvorgang. Als man für den Produktions-Build aus Performance-Gründen die Optimierungsstufe des Compilers ändern musste, hat niemand daran gedacht auch die Compiler-Settings im Unit-Test-Werkzeug zu adaptieren. Die Unit-Tests liefen daher mit einer anderen Optimierungsstufe, als der Produktions-Code.
Ist das ein Fehler? Ja, denn die Qualifizierung des Compilers garantiert nicht, dass er unter allen Umständen mit allen Compiler-Schaltern perfekt funktioniert [3], es sei denn der Compiler wurde durch einen formalen Beweis verifiziert. So ein formaler Beweis ist aber selten zu sehen und wird üblicherweise nur für eine einzige Optimierungsstufe geführt.
Die fehlende Garantie der Fehlerlosigkeit gilt im Besonderen für die Optimierungsstufen. Optimierung ist hochkomplex, ist daher fehleranfällig, und eine häufige Ursache für Compiler-Fehler. Alles, was vorher über die falsche Verwendung von volatile gesagt wurde, gilt sinngemäß auch für Fehler in den Optimierungsschritten des Compilers. In Abhängigkeit der verwendeten Compiler-Schalter kann die Software andere Ergebnisse liefern.
Abhilfe gegen das Durchrutschen von solchen Fehlern ist zum Beispiel eine strikte (und auf Einhaltung geprüfte) Verfahrensanweisung: es gibt keine separaten Übersetzungen für den Unit-Test; es wird stattdessen das Object File der zu testenden Datei aus dem Produktions-Build genommen. Eine alternative Möglichkeit ist zwar separat im Testwerkzeug zu übersetzen, aber die Compiler-Schalter automatisiert aus der Produktion zu übernehmen. Das gelingt dann gut, wenn das Unit-Test-Werkzeug nicht nur über die grafische Benutzerschnittstelle (Bild 2), sondern auch über Skripts gesteuert werden kann.
Tool-Qualifikation
In den Beispielen wurde bereits erwähnt, dass man einem qualifizierten Compiler nicht blind vertrauen darf. Warum gibt es dann überhaupt Tool-Qualifikationen und warum wird das in Normen gefordert? Um diese Frage zu beantworten, lohnt ein Blick auf den Ursprung diese Idee: In der Luftfahrt wird die Erzeugung von (Object) Code sehr genau durch Prozesse reglementiert. Es wird gefordert, dass alle beteiligten Werkzeuge an der Entwicklung von Code, mit der gleichen Sorgfalt erzeugt wurden, wie der Code selbst. Wer selbst Quellcode erzeugt, kann die geforderten Prozesse zwar lückenlos einhalten, aber hat keinen Einfluss, wie die Werkzeuge entwickelt wurden. Genau das ist die ursprüngliche Idee der Qualifikation von Werkzeugen – eine Bescheinigung der nötigen Sorgfalt.
Ein fehlerhafter Code-Generator oder Compiler kann ohne Zweifel Fehler einbringen. Nun gibt es aber noch Werkzeuge, die zwar keine Fehler in den Object Code einbringen können, aber Fehler unentdeckt lassen können. Wie der Instrumenter in Firma A zum Beispiel. Wenn dieses Werkzeug für die Testabeckung »100 %« meldet, obwohl sie nicht tatsächlich erreicht wurde, dann könnte ein Fehler durchrutschen. Schon die EN 61508 fordert daher qualifizierte/zertifizierte Tools. Dabei lässt eine der aus dieser Norm abgeleitete Norm, die ISO 26262, vier Varianten zu, wie so eine Qualifikation aussehen kann. Am häufigsten zum Einsatz kommt ein unabhängiges Prozessaudit in Kombination mit einer Inspektion der Testfälle für das Tool.
Bei der Qualifikation eines Entwicklungswerkzeugs entstehen eine Reihe von Artefakten. Unter anderem die Dokumentation von Anforderungen, Testfällen und ein Sicherheitshandbuch. Die im Sicherheitshandbuch genannten Randbedingungen für die Anwendung des Werkzeugs sollte man sich genau ansehen und nötigenfalls Hilfe durch einen Experten [4] hinzuziehen.
Zusammenfassung und Ausblick
Unit-Tests sind mehr als das Sammeln von Testabdeckung im Auftrag einer Norm. Sie können Fehler finden, die spätere Teststufen nur sehr schwer finden können. Dabei muss man aber in Projekten mit hohen Sicherheitsanforderungen darauf aufpassen, dass das Testobjekt exakt das gleiche ist, wie man es später dem Kunden ausliefert. Die erwähnte Testabdeckung messen die meisten Projekte zurzeit mithilfe von Instrumentierung des Codes.
Seit wenigen Jahren ist es auch möglich, die Testabdeckung ohne Instrumentierung am Target zu messen. Noch ist dies nicht für alle Prozessoren und unter allen Umständen möglich, doch die Hersteller dieser Werkzeuge nehmen eine technische Hürde nach der anderen. Das Thema Unit/Modul-Test für eingebettete Systeme befindet sich vermutlich bald im Wandel. Man darf gespannt sein, ob die technischen Normen eines Tages darauf reagieren.
Literatur
[1] Stephan Grünfelder: Software-Test für Embedded Systems. dPunkt-Verlag, Heidelberg, 2. Auflage 2017.
[2] https://compileroptimizations.com/category/volatile.htm
[3] https://www.elektroniknet.de/automotive/software-tools/compiler-qualifizierung-zertifizierung-iso26262.179100.html
[4] https://www.heicon-ulm.de