entwickler.de

Ada macht den Unterschied

Nicht zuletzt vor dem Hintergrund des enormen Aufschwungs des Internets der Dinge (IoT) gewinnen die eingebetteten Systeme – Embedded Systems – mehr und mehr an Bedeutung. Die dabei gestellten Anforderungen sind meist hoch: Einerseits sind die verfügbaren Ressourcen eingeschränkt und der Einsatz erfolgt häufig auch unter erschwerten Bedingungen, andererseits sind durchaus hochkritische Aufgaben zu bewältigen, beispielsweise in der Steuerung von Anlagen, Maschinen oder Fahrzeugen.

Die Entwicklung von Software für eingebettete Systeme unterscheidet sich daher auch erheblich von der, die in anderen Bereichen der IT üblich ist, beispielsweise bei den Intel-PCs. So sind bei eingebetteten Systemen viele Funktionen stark von der jeweiligen Hardware abhängig, es sind speziell angepasste Betriebssysteme zu berücksichtigen, das Debugging stellt besondere Anforderungen und die beschränkten Ressourcen müssen auch in der Programmierung berücksichtigt werden.

In der Softwareentwicklung für eingebettete Systeme werden natürlich auch Programmiersprachen eingesetzt, die sich für diese Aufgabe bewährt haben. Dazu zählt neben C++ heute vor allem die Programmiersprache Ada, die Ende der 1970er-Jahre fast zeitgleich mit C++ entwickelt und erstmals 1983 standardisiert wurde.

Im Unterschied zu anderen Programmiersprachen wurde Ada von Anfang an in Hinblick auf eingebettete und Echtzeitsysteme konzipiert. Die Sprache verfügt über zahlreiche Features, die gerade die Entwicklung dieser Systeme vereinfacht. Es lassen sich sowohl hardwarenahe, effiziente als auch problemnahe, elegante Lösungen erstellen. Für eine bessere Wiederverwendung von Code enthält Ada objektorientierte Programmierung sowie Unterstützung für Multitasking für mehr Struktur und Effizienz in nebenläufigen Anwendungen. Außerdem ermöglicht die vertragsbasierte Programmierung ein größeres Vertrauen in die Korrektheit des Programms. Hinsichtlich Effizienz steht Ada den Sprachen C und C++ in nichts nach.

Ein striktes Typsystem

Syntaktisch geht Ada andere Wege als C und C++, um die Lesbarkeit von Ada-Programmen zu erhöhen. Ada enthält ein striktes Typsystem und erzwingt eine strikte Trennung zwischen Typen: Das Typsystem wird dynamisch zur Laufzeit überprüft, wobei diese zusätzlichen Checks auch abgeschaltet werden können.

Während in anderen Programmiersprachen meist nur die vordefinierten Integer der Größen 8 Bit, 16 Bit, 32 Bit und 64 Bit sowie 32-Bit- und 64-Bit-Floating-Point-Typen verfügbar sind, können in Ada Integer- oder Floating-Point-Typen mit einem beliebigen Intervall – also zum Beispiel von 10 bis 20 – definiert werden. Echte Array-Typen und nicht nur Syntax für Zeiger gibt es ebenfalls, außerdem Typen wie Strukturen oder Aufzählungen.

Eine überaus nützliche Eigenschaft des Ada-Typsystems ist, dass es eine Reihe von statischen und dynamischen Überprüfungen ermöglicht. So kann man in Ada nicht einfach eine Floating-Point-Zahl mit einem Integer addieren, sondern muss durch explizite Konversionen deutlich machen, ob es sich bei dieser Addition um eine Ganzzahl- oder Fließkommaoperation handelt. Eine derartige Konversion ist auch nötig, wenn man beispielsweise einen 64-Bit-Integer-Wert in einer 16-Bit-Variable speichern möchte. Aufzählungen und Zeiger können nicht als numerische Werte verwendet werden, Zeigerarithmetik ist also nicht möglich – aber auch nicht nötig.

Einschränkungen dieser Art mögen dem Entwickler anfangs mühevoll erscheinen, er wird jedoch bald feststellen, dass sich auf diese Weise einfache Programmierfehler vermieden lassen. Die Überprüfungen können auch dazu verwendet werden, Änderungen am Code – das Refactoring – schnell und sicher durchzuführen. Wurde beispielsweise die Größe einer Variable von 16 auf 32 Bit verändert, reicht eine Neukompilierung, um die zu ändernden Stellen zu identifizieren.

Wichtig ist in diesem Zusammenhang die Unterscheidung zwischen statischen und dynamischen Überprüfungen: Konversionen prüft der Compiler statisch, also vor der Übersetzung des Programms in den Maschinencode. Andere Aspekte, zum Beispiel die Intervalle der vom Benutzer definierten numerischen Typen, werden dynamisch überprüft – also während der Ausführung des Programms. Diese Überprüfungen sind in der Regel sehr effizient, können aber für besonders performancekritischen Code auch abgeschaltet werden.

Representation Clauses

Dem erfahrenen Embedded-Programmierer ist vielleicht bei der Lektüre des Abschnitts über das Typsystem von Ada der Gedanke gekommen, dass er all diese High-Level-Features sowieso nicht nutzen kann. Denn wenn es darum geht, Details der Auslegung der Daten im Speicher zu bestimmen oder bestimmte Register zu manipulieren, muss man ja doch wieder zu Zeigern greifen und mit Bitmasken arbeiten.

An dieser Stelle kommt eines der vielleicht genialsten Features von Ada zum Zug: die so genannten Repräsentationsklauseln oder Representation Clauses. Sie erlauben es, die genaue Auslegung von so gut wie allen Ada-Datentypen im Speicher auf das Bit genau zu bestimmen, ohne auch nur einen einzigen Zeiger oder eine einzige Bit-Operation zu verwenden. Das erlaubt Sourcecode von hohem Abstraktionsniveau – zum Beispiel kann ein Ada-Programm so tun, als wäre ein Register eine Struktur mit mehreren Feldern, auf die man unabhängig zugreifen kann – während die Repräsentationsklausel erklärt, wie die einzelnen Felder auf die Bits des Registers abgebildet werden. Der Compiler erzeugt dann den Maschinencode mit Bitmasken und Bitverschiebungen, den ein C-Programmierer selbst schreiben müsste.

C kennt hier die so genannten Bit-Felder, die eine ähnliche Aufgabe haben. Jedoch haben Bit-Felder in C das Problem, dass sie wegen Endianness-Problemen nicht portabel sind, und dass die Größe von solchen Strukturen auch nicht portabel vorhergesagt werden kann. Deswegen sehen die meisten C-Programmierer von der Verwendung von Bitfeldern ab. Das zeigen die folgenden Beispiele. Dabei wird eine Reihe von Typen definiert, die helfen, auf ein General-Purpose-Input/Output-(GPIO-)Register zuzugreifen. Die erste Definition legt ein 4-Bit-Integer von 1 bis 16 fest:

type Field is range 1 ... 16 with Size => 4;

Hier ist zu beachten, dass diesem Typ nur 4 Bit zugestanden sind, also zum Beispiel die Zahl 16 gar nicht direkt dargestellt werden kann. Da aber der Datentyp 16 Werte darstellen kann und genau 16 Werte benötigt werden – allerdings von 1 bis 16 und nicht von 0 bis 15 – ist das kein Problem, und der Compiler kann eventuell nötige Anpassungen der Werte in Berechnungen selbst durchführen.

Im zweiten Beispiel wird eine Aufzählung definiert:

type Edge_Detection is (Rising_Edge, Falling_Edge) with Size => 1;

Da die Aufzählung nur zwei Werte hat, wird nur ein Bit benötigt. In anderen Sprachen würde hier vielleicht ein Boolean verwendet werden, in Ada kann man einen eigenen zweiwertigen Typ definieren. Im Speicher wird der Wert Rising_Edge mit 0 und Falling_Edge mit 1 dargestellt – doch das kann man auch ändern, wie das dritte Beispiel zeigt:

type Power_Value is (Min_VCC, Max_VCC) with Size => 2;
for Power_Value use (Min_VCC => 0, Max_VCC => 3);

Hier wird wieder eine zweiwertige Aufzählung definiert, doch diesmal werden zwei Bits beansprucht – das kann zum Beispiel vom Hersteller des Boards so definiert sein, um Platz für andere Werte zu lassen. Auch werden die numerischen Werte für die beiden Konstanten der Aufzählung auf 0 und 3 definiert.

--  Definition of the GPIO config field

type GPIO_Config is … with Size => 4;

type GPIO_Low_List is array (1 .. 8) of GPIO_Config
  with Pack, Element_Size => 4, Size => 32;

type GPIO_High_List is array (9 .. 16) of GPIO_Config
with Pack, Element_Size => 4, Size => 32;

Das vierte Beispiel zeigt Repräsentationsklauseln in Zusammenarbeit mit Arrays (Listing 1). Hier wurde erst ein Typ GPIO_Config – die genaue Definition wurde ausgelassen – definiert und deklariert, dass der Typ 4 Bit einnehmen soll. Dann werden zwei Array-Typen definiert, die je genau acht Elemente des Typs GPIO_Config halten sollen. Acht Objekte von je 4 Bit passen genau in ein 32-Bit-Word, und um sicher zu gehen, wird das dem Compiler auch so mitgeteilt: Die Größe der Array-Elemente wird mit 4 Bit deklariert (Element_Size), die Größe des gesamten Arrays wird auf 32 Bit festgesetzt (Size), und die Pack-Anweisung befiehlt dem Compiler, möglichst wenig Platz zu verwenden.

Mit einer derartigen Typdefinition kann man ganz bequem auf die einzelnen Komponenten mit Array-Syntax zugreifen, ohne Bitmasken, Shifts oder Ähnliches zu verwenden. Noch ein Detail: Der zweite Array-Typ enthält zwar auch acht Elemente, kann aber mit Werten zwischen 9 und 16 indiziert werden. Der Programmierer hat diese Werte so gewählt, weil sie dem Boardlayout entsprechen.

Ein weiteres Beispiel arbeitet mit Strukturen (Records in Ada). Es werden zuerst Datentypen für drei Felder eines solchen Records mit unterschiedlichen Größen definiert:

type Field1 is … with Size => 5;
type Field2 is … with Size => 15;
type Field3 is … with Size => 12;

Danach wird der Record-Typ selbst definiert (Listing 2).

type The_Register is record
  F1 : Field1;
  F2 : Field2;
  F3 : Field3;
end record
  with Size => 32,
    Volatile_Full_Access,
    Bit_Order => System.Low_Order_First;

for The_Register use record
  F1 at 0 range 0 .. 4;
  F2 at 0 range 5 .. 19;
  F3 at 0 range 20 .. 31;
end record;

Eine normale Definition eines Record-Typs in Ada würde nur den ersten Teil enthalten, in dem der Typ selbst und seine Felder definiert werden. Der zweite Teil ist die Repräsentationsklausel, die definiert, wo genau die Felder im Speicher liegen sollen. Die Instruktion Volatile_Full_Access ist noch nicht offizieller Teil der Ada-Sprache, sondern eine Erweiterung des verbreiteten GNAT-Compilers (Abb. 1). Sie befiehlt dem Compiler, das Register immer als Ganzes zu lesen und zu schreiben, da sonst merkwürdige Effekte auftreten können. Die Bit-Order-Instruktion ist interessant: Sie weist den Compiler an, die Daten immer im Little-Endian-Format darzustellen, auch wenn man sich auf einem Big-Endian-System befindet. Damit sind schwierig zu findende Fehler, die aus Endianness-Problemen resultieren, ausgeschlossen.

Abb. 1: GNAT Programming Studio, eine beliebte Entwicklungsumgebung für die Ada-Programmierung (Quelle: AdaCore)

Nach all diesen Typdefinitionen folgt nun auch ein bisschen Code. Der folgende Code definiert eine Registervariable und weist dem zweiten Feld den Wert 5 zu:

declare
  Register : The_Register with Address => 16#800012AF#;
begin
  Register.F2 := 5;
end;

Das Beispiel zeigt, dass man hier ohne Bitmanipulationen auskommt und einfach die Zuweisungssyntax für Record-Felder verwenden kann. Bei der Deklaration der Variable kann auch eine Adresse angegeben werden, um sie zum Beispiel auf das Register des Prozessors abzubilden. Man beachte dabei die im Vergleich zum Beispiel in C etwas andere Syntax für hexadezimale Zahlen.

Um es noch einmal zusammenzufassen: Repräsentationsklauseln sind der Klebstoff zwischen der Hardware und dem Quellcode, der es erst ermöglicht, auch in so genannten Low-Level-Szenarien wie der Embedded-Programmierung auf einem hohen Abstraktionsniveau zu programmieren.

Statt also von Hand fehleranfällige Bitmanipulationen zu programmieren, kann der Programmierer die gewünschte Abstraktion auswählen und dann mit Repräsentationsklauseln beschreiben, wie die Abstraktion im Speicher dargestellt wird. Der Compiler erledigt dann den Rest, in der Regel ohne Performanzunterschiede zu von Hand geschriebenem Code.

Neben dem strengen Typsystem verfügt Ada über weitere Features, die sich besonders für den Einsatz in eingebetteten Systemen eignen und auf die ich im Folgenden näher eingehen werde: Objektorientierte Programmierung, Multitasking sowie vertragsbasierte Programmierung.

Objektorientierte Programmierung

Während die Objektorientierung in der Softwareentwicklung schon seit Jahrzehnten üblich ist, wurde in der Embedded-Programmierung oft noch prozedural programmiert. Der Grund dafür ist, dass in vielen Programmiersprachen die Objektorientierung an einen Garbage Collector geknüpft ist und oft auch mit Performanceeinbußen oder wenig vorhersagbaren Ausführungszeiten verbunden ist.

Beides ist bei Ada nicht der Fall. Dafür sorgen zum einen statische Dispatching Tables und zum anderen die so genannten Storage Pools, die die Garbage Collection ersetzen können.

Multitasking

Die meisten Programmiersprachen stellen Nebenläufigkeit durch eine Threadbibliothek oder durch das Betriebssystem (Real-Time OS) bereit. Ada hingegen integriert die Nebenläufigkeit direkt in die Sprache. In Ada können direkt so genannte Tasks definiert werden, die dann nebenläufig ausgeführt werden.

Die Kommunikation erfolgt synchron durch so genannte Rendezvous-Aufrufe, oder asynchron durch so genannte Protected Objects. Dabei handelt es sich um ein Sprachkonstrukt, das ähnlich wie in der Objektorientierung Daten und Funktionen, die diese Daten manipulieren, in einer auch im parallelen Kontext sicheren Weise kombiniert. Das zeigt das Beispiel in Listing 3. Es definiert eine Prozedur, die bei ihrem Aufruf zwei Tasks startet, die dann nebenläufig ausgeführt werden. Nebenläufig heißt nicht unbedingt parallel – das hängt davon ab, ob der User überhaupt parallele Ausführung wünscht und die Hardware das auch unterstützt.

Wenn ein Betriebssystem vorhanden ist, werden Ada-Tasks auf die vom Betriebssystem bereitgestellten Threads abgebildet. Aber Ada-Tasks funktionieren auch im so genannten Bare-Metal-Konzept, also auch ohne RTOS, wenn die Ada-Runtime auf das Zielsystem angepasst wurde.

procedure Robot is

  task Environment_Scanning;
  task Motion_Control;

  task body Environment_Scanning is
    ...
  end Environment_Scanning;

  task body Motion_Control is
    ...
  end Motion_Control;
  -- the two tasks are automatically created and begin execution
begin -- Robot
  null;
  -- Robot waits here for them to terminate
end Robot;

Vertragsbasierte Programmierung

Die vertragsbasierte Programmierung wurde Anfang der 1990er-Jahre in der Programmiersprache Eiffel eingeführt und lange von anderen Sprachen ignoriert. Erst Mitte der Nuller-Jahre wurde dieses Konzept in Forschungsprojekten wie Spec# oder CodeContracts für C# – beide von Microsoft – ausprobiert. Seit der Version Ada 2012 ist Ada die einzige Programmiersprache, die fortgeschrittene Features der vertragsbasierten Programmierung auch in der Embedded-Programmierung ermöglicht.

Bei der vertragsbasierten Programmierung handelt es sich um Techniken, die die klassische Funktionsdeklaration bereichern und damit deutlicher machen, welche Aufgaben der aufrufenden und welche der aufgerufenen Funktion zugeordnet werden sollen.

Zum Beispiel herrscht oft Unklarheit darüber, ob eine C-Funktion, die einen Zeiger als Argument erwartet, auch funktionieren soll, wenn der Null-Zeiger übergeben wird, oder ob der Aufrufende diese Situation ausschließen soll. Ein anderes Beispiel sind erwartete Wertebereiche, zum Beispiel eines Winkels oder einer Temperatur, die oft in Kommentaren dokumentiert werden. Die einfachste Art und Weise, in Ada eine Funktionsdeklaration zu bereichern, ist, zu deklarieren, wie die Parameter einer Funktion verwendet werden. Ein Beispiel:

function F (A : in Integer; B : out Integer; C : in out Integer) return Integer;

Hier wurde deklariert, dass die Funktion F die Variable A nur liest, aber nicht schreibt (in), die Variable B nur schreibt, aber nicht liest (out), und schließlich die Variable C liest und schreibt (in out). Für solche Deklarationen muss man in C schon Zeiger verwenden; in Ada reichen diese einfachen Annotationen.

Eine weitere Art, eine Funktionsdeklaration anzureichern, ist, die Typen der Argumente und des Rückgabewerts genauer zu definieren. Sehen wir uns ein weiteres Beispiel an:

type Temperature is range -55 .. 125;
function Read_Temperature (This : Sensor) return Temperature;

Hier wurde – statt die Temperatur einfach als Integer darzustellen – ein deutlich präziserer Datentyp gewählt, der zum Beispiel den Wertebereich der Daten beschreibt, die laut Spezifikation vom Sensor tatsächlich ausgelesen werden können. Diese genaue Spezifikation befreit Benutzer dieser Funktion davon, die Rückgabe von ungültigen Werten zu überprüfen und zu reagieren. Falls der Sensor einen anderen Wert zurückgibt, wird das vom Programm per Check zur Laufzeit entdeckt und man kann auf einen Material- oder Spezifikationsfehler schließen.

Im nächsten Beispiel erwartet eine Funktion eine Liste als Argument, die als Zeiger implementiert ist. Die Liste soll schon angelegt und der Zeiger nicht null sein. In Ada lässt sich das folgendermaßen schreiben:

function Is_Empty (List : not null access My_List) return Boolean;

Das Schlüsselwort access ist Ada-Syntax für Zeiger, und not null deklariert, dass hier ein Null-Zeiger nicht erwünscht ist.

Vor- und Nachbedingungen – Pre- und Post Conditions – sind das mächtigste Sprachkonstrukt der vertragsbasierten Programmierung. Es handelt sich dabei um zwei boolesche Ausdrücke, die man einer Funktion hinzufügt; es kann auch nur einer von beiden angegeben werden.

Die Vorbedingung (pre) soll direkt vor dem Aufruf der Funktion wahr sein. Die Nachbedingung (post) soll bei der Rückgabe des Resultats gültig sein. Diese Ausdrücke können wie Assertionen zur Laufzeit ausgeführt werden. Ein Beispiel eines typischen API soll hier genügen. Der API-Nutzer soll den Sensor mit einem Aufruf der Funktion Initialize initialisieren, bevor er ausgelesen wird:

type Sensor is private;
function Initialized (This : Sensor) return Boolean;
procedure Initialize (This : in out Sensor)
  with Post => Initialized (This);
function Read_Temperature (This : Sensor) return Temperature
  with Pre => Initialized (This);

Zuerst wird eine Hilfsfunktion Initialized definiert, die dann später in den Pre- und Postausdrücken verwendet wird. Die Funktion Read_Temperature hat die Vorbedingung, dass der Sensor initialisiert sein soll, ausgedrückt durch die Hilfsfunktion. Diese Eigenschaft kann durch einen Aufruf der Initialize-Funktion erreicht werden.

In der Ada-Variante SPARK spielen Verträge eine noch eine größere Rolle: SPARK geht von Ada aus und schließt einige gefährliche Sprachfeatures wie Zeiger komplett aus. Verträge erlauben es, die völlige Abwesenheit von Laufzeitfehlern, beispielsweise Buffer Overflows oder Division durch null, mathematisch zu beweisen, und auch die Gültigkeit der Pre- und Post Conditions selbst restlos nachzuweisen.

Die vertragsbasierte Programmierung ist damit ein Werkzeug des API-Programmierers, um für die Benutzer des API präzisere Informationen bereitzustellen. Im Fehlerfall ist in der Regel schnell zu erkennen, ob das API falsch verwendet wurde oder der Fehler in der Bibliothek selbst steckt. Diese Eigenschaften sind nicht nur im Embedded-Bereich nützlich, aber besonders da ist die Korrektheit des Codes wichtig – und da können Verträge helfen.

In dieser kurzen Vorstellung der Programmiersprache Ada wurden vor allem diejenigen Features hervorgehoben, die in der eingebetteten Programmierung wichtig sind: das reiche Typsystem, das dank der Repräsentationsklauseln auch in der Low-Level-Programmierung verwendet werden kann, sowie moderne Features wie die objektorientierte und vertragsbasierte Programmierung. Diese Features können bei der Entwicklung von Software für eingebettete Systeme und bei IoT-Anwendungen den Unterschied ausmachen.

Entwickler Magazin

Dieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.