1991 veröffentlichte das Fachmagazin Developer‘s Insight einen unterhaltsamen Artikel mit dem Titel „How to Shoot Yourself In the Foot“, (Wie man sich selbst ins Bein schießt), der so begann:
Auf einen Blick
Abschließend nochmals die wichtigsten Punkte aus diesem Beitrag in einer kurzen Zusammenfassung. Ganz zu Anfang sollte man sich mit den Anforderungen der maßgeblichen Standards für die Softwareentwicklung vertraut machen. Als Basis-Coding-Standard sollte MISRA-C genutzt werden. Anschließend gilt es, die Nutzung des Schlüsselwortes volatile zu nutzen. Danach folgt die Implementierung einer Test- und Analyse-Strategie für die Stack-Allokierung. Werden diese Punkte beachtet, ist man mit C auf der sicheren Seite.
„Die Verbreitung moderner Programmiersprachen (die anscheinend zahllose Funktionen voneinander abgekupfert haben) macht es Programmierern manchmal schwer, sich zu erinnern, welche Sprache sie überhaupt gerade nutzen. Dieser Leitfaden soll Entwicklern, die dieses Dilemma kennen, weiterhelfen.“
Die Liste der betroffenen Sprachen beginnt mit C und stellt kurz und schmerzlos fest: „C – Schieß Dir selbst ins Bein.“ Was sich nach einem harten Urteil anhört, birgt tatsächlich ein Körnchen Wahrheit in sich. Trotzdem: Selbst wenn alternative Programmiersprachen mit viel weniger Problemen behaftet sein mögen, zum Beispiel hinsichtlich Typsicherheit und undefiniertem Verhalten, so lassen sie dafür aber oft Funktionen vermissen, die für die Hardware-nahe Programmierung ganz entscheidend sind. Wenn wir C nutzen wollen, müssen wir eine klare Bilanz ziehen, den offensichtlichen und weniger offensichtlichen Fallstricken ausweichen und dabei das Beste aus seinen Funktionen machen.
Wir können C für die Entwicklung von sicherheitsbezogenen Funktionalitäten aus zwei verschiedenen Perspektiven betrachten:
- Welche externen Anforderungen gibt es in einem sicherheitsbezogenen Projekt hinsichtlich der Wahl der Programmiersprache?
- Wie geht man mit eklatanteren Herausforderungen von C um, zum Beispiel bei der Arbeit mit älterem Code?
Eine Frage des Standards
Wenn die betreffenden Produkte aus den Bereichen Automotive, industrielle Steuerung, Medizintechnik oder der Schienenverkehrstechnik stammen, dann sind die Chancen groß, dass sie formale Anforderungen hinsichtlich ihrer funktionalen Sicherheit erfüllen müssen. Das kann auf eine ganz spezifische Anforderung hinaus laufen, wie zum Beispiel der maximal zulässigen Ausfallrate eines Produktes beziehungsweise der einer speziellen Funktion des Produktes. Es kann sich aber auch um eine generelle Produktanforderung handeln, etwa die Entwicklung nach einem speziellen Sicherheitsstandard wie IEC61508 (für elektrische, elektronische und programmierbar elektronische Systeme), ISO26262 (für sicherheitsbezogene elektrische/elektronische Systeme in Kraftfahrzeugen) oder EN 50126x (für Bahnanwendungen). Innerhalb der letzten zehn Jahre zeichnete sich ein klarer Trend ab: Die Implementierung von Sicherheits-Funktionalität verlagert sich zunehmend von der reinen Mechanik oder PLC-gesteuerten Automatisierung in die Mikroelektronik, wodurch sich die Anforderungen jetzt auch auf die Software-Ebene verschieben.
Weil die Software-Anforderungen der verschiedenen Standards im Grunde sehr ähnlich sind, werden wir hier die IEC61508 als Beispiel nehmen. Dieser Standard bildet die Grundlage für viele branchenspezifische Standards. Die Anforderungen der IEC61508 haben zum Großteil auch Gültigkeit für beispielsweise ISO26262.
Diese Standards haben einen großen Einfluss auf die Art zu arbeiten und zu dokumentieren: Angefangen bei der Zusammenstellung der Anforderungen bis hin zur Planung der Produktinbetriebnahme und -stilllegung beim Kunden. Es sind nicht mehr nur der Entwickler und die Projektbeteiligten, die entscheiden, ob die Vorgaben eines gewählten Standards erfüllt wurden oder nicht – vielmehr gilt es auch, einen dritten Gutachter einer akkreditierten Einrichtung oder einen entsprechenden Verantwortlichen im Unternehmen davon zu überzeugen.
Die meisten dieser Standards nutzen Variationen des Safety-Integrity-Level-Konzeptes (SIL). Abhängig von der Klassifizierung des Produktes gibt es daher auch Variationen, wie der entsprechende Standard angewandt wird.
C und die Standards: Eine Frage der Definition
Nun eröffnet sich eine interessante Frage: Was hat das eigentlich mit der Wahl der Programmiersprache zu tun? Sehr viel, wie sich herausstellen wird. Tabelle 1 ist der Norm IEC61508 (in Englisch) entnommen und gibt Entscheidungshilfen bei der Auswahl einer geeigneten Programmiersprache, abhängig vom Safety-Integrity-Level der geplanten Anwendung oder Sicherheitsfunktion. „HR“ steht für „highly recommended“ (sehr empfehlenswert), was schon per se ein unmissverständlicher Hinweis dafür ist, dass man dieser Empfehlung besser folgt oder zumindest einen äußerst guten Grund nachweisen kann, dies nicht getan zu haben.
Laut Tabelle ist es sehr empfehlenswert, eine geeignete Programmiersprache (suitable programming language) zu verwenden, was nicht wirklich eine Empfehlung darstellt. Aber der hier referenzierte Anhang C liefert (frei übersetzt) folgende Definition einer geeigneten Programmiersprache: „Die Sprache sollte vollständig und eindeutig definiert sein. Sie sollte Nutzer- oder Problem-orientiert und nicht Prozessor/Plattform-orientiert sein. Weit verbreitete Sprachen oder deren Untersprachen sind Spezialsprachen vorzuziehen. Die Sprache sollte die Nutzung kleiner und leicht zu handhabender Softwaremodule unterstützen, den Datenzugriff in speziellen Softwaremodulen beschränken; variable Teilbereiche sowie andere Arten von Fehler-limitierenden Konstrukten definieren.“
Wie passt C zu den verschiedenen Teilen der obigen Definition:
- Die Sprache sollte vollständig und eindeutig definiert sein: Abhängig von der Zählweise kann man argumentieren, dass C99 mindestens 190 undefinierte Verhaltensweisen beinhaltet.
- Die Sprache sollte Nutzer- oder Problem-orientiert und nicht Prozessor/Plattform-orientiert sein: Nachdem C ursprünglich als Systementwicklungssprache für die PDP-11-Architektur erfunden wurde und eine spezifische C-Implementierung für ein spezielles Ziel sich von der Implementierung für ein anderes Target unterscheiden muss beziehungsweise manchmal sogar anders sein muss als eine alternative Implementierung desselben Ziels – lässt sich schwerlich behaupten, C würde diesem Teil der Definition entsprechen.
- Weit verbreitete Sprachen oder deren Untersprachen sind Spezialsprachen vorzuziehen: Zumindest diese Definition trifft auf C zu. Es gibt enorm viele Entwickler und jeder von ihnen kennt C oder damit verwandte Sprachen wie C++.
- Softwaremodule unterstützen, den Datenzugriff in speziellen Softwaremodule beschränken; variable Teilbereiche sowie andere Arten von Fehler-limitierenden Konstrukten definieren: Obwohl C nicht ausdrücklich die Erstellung von Abstraktionen, die diese Konzepte unterstützen, verbietet, lässt sich sagen, dass die Sprache auch absolut keine Unterstützung hierfür bietet. Im Gegenteil, es kann sogar behauptet werden, das Gegenteil wäre zutreffend.
C entspricht insgesamt nicht wirklich den Erwartungen des Standards. Was kann man hier also tun? Die Antwort ist eigentlich recht einfach, denn liest man weiter, findet man eine Tabelle mit Rechtfertigungen für spezifische Sprachen (Tabelle 2).
Obwohl C keine empfohlene Sprache ist, ist sie mit einem passenden Subset in Verbindung mit einem Coding-Standard und statischem Analyse-Tool sogar eine höchst empfehlenswerte Sprache. Aber was bedeuten Subset und Coding-Standard in diesem Kontext?
Aufgabe und Ziel eines Sprachen-Subsets ist in diesem Zusammenhang die Wahrscheinlichkeit von Programmierfehlern zu reduzieren beziehungsweise die Wahrscheinlichkeit zu erhöhen, solche Fehler zu finden, die sich unweigerlich in die Codebasis einschleichen. Für C bedeutet dies, die Nutzung von undefinierten oder Implementierungs-definierten Verhaltensweisen weitestgehend zu eliminieren. Es gibt eine Vielzahl von solchen Sprachen-Subsets, die bekannteste dürfte aber MISRA-C sein. Das MISRA-C-Regelwerk war ursprünglich eine Initiative der britischen Motor Industry Software Reliability Association und zielte ausschließlich auf Automotive-Software ab. Über die Jahre haben sich die MISRA-C-Vorschriften weltweit und auch in andere Industriebereiche verbreitet, so dass das Regelwerk jetzt das am weitesten verbreitete C-Subset der Embedded-Branche ist.
Die Norm IEC61508 sagt ebenfalls viel über Coding-Standards aus. Hier einige Beispiele von Themen, die zusätzlich zu den MISRA-C-Vorschriften beachtet werden sollten:
- Wie schützt man den Zugriff auf geteilte Ressourcen, wie etwa globale Variablen?
- Die Nutzung von Stack- und Heap-Speicher für die Allokierung von Objekten.
- Rekursion – erlaubt oder nicht erlaubt?
- Komplexitätslimits, wie zum Beispiel Beschränkung der maximal zulässigen zyklomatischen Komplexität von Funktionen.
- Wie werden MISRA-C-Regeln ausgeschlossen, die in einem gewissen Kontext nicht anwendbar sind?
- Wie werden Compiler-spezifische Funktionen genutzt, zum Beispiel intrinsische Funktionen oder Spracherweiterungen?
- Wie werden Bereichsüberprüfungen, „asserts“ sowie Vor- und Nachbedingungen und ähnliche Konstrukte zur Fehlererkennung genutzt?
- Verwaltung von Schnittstellen und Zugriffe zwischen Modulen.
- Dokumentationsanforderungen.
Im Wesentlichen sollte ein Coding-Standard eine Hilfestellung für den Umgang mit Problemen sein, die die Code-Qualität und -Integrität betreffen, aber nicht ausdrücklich von der der Sprache oder dem Subset angegangen werden.
Verschiedene Ansätze, um MISRA-C zu nutzen
Wer MISRA-C nutzen möchte, wird verschiedene Ansätze verfolgen, je nachdem ob er bei Null anfängt oder einen älteren Code wiederverwendet. Wer einen vollkommen neuen Code entwickelt, sollte Folgendes beachten:
- Nicht vom Plan abweichen, nur weil man blind jeder Vorschrift folgt. So mancher Teil des Codes wird einer oder sogar mehreren Regeln nicht entsprechen. Das trifft vor allem auf Code zu, der mit der Hardware in Verbindung steht. Stattdessen gilt es, eine fundierte Entscheidung zu treffen, warum von der Regel abgewichen wird, und diese Entscheidung zu dokumentieren. Es sollte frühzeitig mit allen Projektbeteiligten und externen Prüfern diskutiert werden, ob grundsätzlich alle Regeln zu befolgen sind oder ob es Regeln gibt, die auf Projektebene ignoriert werden können.
- Immer versuchen, die Regeln zu Basistypen sowie Arithmetik und der Konvertierung dieser Typen einzuhalten. Speziell dieser Bereich steckt voller Fallen und während der Code auf der einen Plattform perfekt zu funktionieren scheint, versagt er auf einer anderen.
- Wird für ähnliche Code-Teile immer wieder das gleiche Ausweichmanöver für eine Regel angewandt dann ist dies ein Warnsignal, und man sollte man sich zum einen fragen ob die Regel richtig ausgelegt wird und zum anderen, ob das Code-Muster wirklich benötigt wird. Falls ja, sollte erwogen werden, den betreffenden Code als isolierte Funktion oder Funktionssatz auszulagern.
- Mit einer statischen Prüfung lässt sich die Übereinstimmung mit den Regeln interaktiv während des Entwicklungsprozesses abprüfen.
Wer MISRA-C-Regeln auf einen Legacy-Code anwendet, sollte Folgendes beachten:
- Eine Regel nach der anderen bearbeiten.
- Mit den einfachen Regeln anfangen.
- Genauer hinsehen bei Regeln, die festlegen, dass einfache Typen wie short, int und char nicht verwendet werden sollten und erwägen, ein Modul nach dem anderen zu ändern und dabei ausdrücklich größenbezogene Typen zu verwenden.
- Nach der Übung mit einigen einfachen Modulen mit den Modulen weitermachen, die als fehlerbehaftet oder aufwändig zu pflegen gelten.
Volatile und atomar
Ein oft missverstandener Bereich der C-Sprache ist volatile. Jeder Entwickler kann bestätigen, dass eine falsche Nutzung dieser Kennzeichnung zu den schlimmsten Verursachern von Abstürzen und dem Versagen von Embedded-Systemen zählt. Der Hauptgrund, ein Objekt mit volatile zu kennzeichnen, ist dem Compiler mitzuteilen, dass sich der Wert des Objektes auf für den Compiler unbekannte Weise verändern kann, weshalb alle Zugriffe auf das Objekt beibehalten werden müssen. Es gibt drei typische Szenarien, die volatile Objekte notwendig machen:
- Geteilte Zugriffe: Das Objekt wird von mehreren Tasks in einer Multitasking-Umgebung geteilt oder wird sowohl von einem einzelnen Thread für die Ausführung als auch einer oder mehreren Interrupt-Service-Routinen genutzt.
- Trigger-Zugriff, wie bei einer Memory-mapped-Hardware, bei der ein Zugriff einen Einfluss auf diese Hardware hat. Modifizierter Zugriff: Hier können sich die Inhalte des Objektes auf für den Compiler unbekannte Weise ändern.
Verwendet man bei der Deklaration eines Objektes das Schlüsselwort volatile, erhält man vom Compiler die Garantie, dass alle Lese- und Schreib-Zugriffe beibehalten werden.
Abhängig von der Ziel-Architektur kann es auch sein, dass alle Zugriffe vollständig sind, in der von einer abstrakten Architektur vorgegebenen Reihenfolge durchgeführt werden und, wenn verfügbar, atomar (atomic) sind, also ohne Unterbrechung durchgeführt werden.
Ist der Code aus Bild 1 Thread- und Interrupt-sicher, wenn man bedenkt, dass von anderen Prozessen auf volatile Objekte zugegriffen werden kann? Sowohl das Laden vom Speicher und das Speichern dort mit dem Wert vol sind atomar, weil diese auf eine 32-Bit-Lade/Speicher-Architektur abzielt. Aber die Quellanweisung ist nicht atomar. Es kann immer noch ein Kontext-Switch oder ein Interrupt zwischen den drei Instruktionen auftreten, die die vol++-Anweisung bilden.
Was ist zu tun:
- Niemals davon ausgehen, dass volatile gleich atomar ist, außer für bestimme Speicherzugriffe.
- Sicherstellen, dass Code, der mehr als nur einen atomaren Lese- oder Schreibzugriff durchführt, durch korrekte Serialisierungs-Anweisungen wie mutex oder Deaktivierung von Interrupts, geschützt wird, falls von mehreren Stellen auf das Objekt zugegriffen werden kann.
- Abdecken der korrekten Verwendung des Volatile-Schlüsselwortes sowie von Serialisierungs-Anweisungen im Codierungsstandard.
- Es sollte erwogen werden, alle verwendeten globalen Objekte in bestehendem Code zu überprüfen, inklusive der statischen Objekte innerhalb einer Datei.
Stack-Speicher korrekt dimensionieren
Ist der Stack unnötig groß, bedeutet das unter Umständen, dass auf dem Gerät mehr RAM-Speicher benötigt wird, was die Kosten in die Höhe treibt. Ist der Stack zu klein, funktioniert die Anwendung möglicherweise nicht anforderungsgerecht, was für ein Produkt mit sicherheitskritischen Anforderungen alles andere als gut ist. Folgende Checkliste könnte bei der Bestimmung der Stack-Größe helfen:
- Laufzeit-Stack-Check-Funktionalität des Debuggers nutzen, wenn vorhanden.
- Den Speicher über und/oder unter dem Stack mit einem besonderen Muster füllen, das mit einer dezidierten Check-Routine regelmäßig während der Laufzeit geprüft wird. Wegen der Anforderungen des IEC 60730-Standards für Haushaltsgeräte verfügen manche MCU-Anbieter über diese Funktionalitäten oder andere MCU-Self-Check-Funktionen in einer Spezial-Bibliothek.
- Eine Call-Tree-Analyse durchführen, um die Stack-Tiefe im Worst Case zu bestimmen, inklusive dem Stack, der von Interrupt-Handlern beansprucht wird. Bei der manuellen Überprüfung des Codes und der Linker-Map-Files gilt es zu beachten, dass Optimierungen die Stack-Nutzung beeinflussen. Bei der Call-Tree-Analyse und der Analyse der Stack-Tiefe helfen auch Tools, die vielleicht sogar in der aktuell genutzten Toolchain verfügbar sind.
Anders Holmberg
(ah)