Portabilität
In eingebetteten Systemen ist Portabilität ein sehr wichtiges Thema: Jeder Hersteller und sogar jede Produktfamilie eines einzelnen Herstellers bietet unterschiedliche Peripheriegeräte und Funktionen an, und dementsprechend variieren auch die Interaktionsmöglichkeiten mit den Peripheriegeräten.
Eine gängige Methode, diese Unterschiede auszugleichen, ist die sogenannte Hardware-Abstraktionsschicht (HAL).
Hardware-Abstraktionen sind Sammlungen von Software-Routinen, die bestimmte plattformspezifische Details emulieren und Programmen so direkten Zugriff auf die Hardwareressourcen ermöglichen.
Sie ermöglichen es Programmierern häufig, geräteunabhängige Hochleistungsanwendungen zu schreiben, indem sie standardisierte Betriebssystemaufrufe für den Hardwarezugriff bereitstellen.
Wikipedia: Hardware Abstraction Layer
Eingebettete Systeme nehmen hier eine Sonderstellung ein, da wir es typischerweise nicht mit Betriebssystemen und vom Benutzer installierbarer Software zu tun haben, sondern mit Firmware-Images, die als Ganzes kompiliert werden – und zudem mit einer Reihe weiterer Einschränkungen konfrontiert sind. Der klassische, auf Wikipedia beschriebene Ansatz könnte zwar theoretisch funktionieren, ist aber wahrscheinlich nicht der effizienteste Weg, um Portabilität zu gewährleisten.
Wie gehen wir dabei in Rust vor? Hier kommt embedded-hal ins Spiel…
Was ist embedded-hal?
Kurz gesagt handelt es sich um eine Reihe von Merkmalen, die Implementierungsverträge zwischen HAL-Implementierungen, Treibern und Anwendungen (oder Firmware) definieren. Diese Verträge umfassen sowohl Fähigkeiten (d. h., wenn ein Merkmal für einen bestimmten Typ implementiert ist, stellt die HAL-Implementierung eine bestimmte Fähigkeit bereit) als auch Methoden (d. h., wenn ein Typ, der ein Merkmal implementiert, erstellt werden kann, ist garantiert, dass die im Merkmal spezifizierten Methoden verfügbar sind).
Ein typischer Schichtaufbau könnte wie folgt aussehen:
Einige der in embedded-hal definierten Traits sind:
- GPIO (Eingangs- und Ausgangspins)
- Serielle Kommunikation
- I2C
- SPI
- Timers/Countdowns
- Analog-Digital-Umsetzung
Der Hauptgrund für die Existenz der embedded-hal-Traits und der sie implementierenden sowie nutzenden Crates liegt darin, die Komplexität beherrschbar zu halten. Bedenkt man, dass eine Anwendung sowohl die Nutzung der Hardware-Peripherie als auch die eigentliche Anwendungslogik sowie möglicherweise Treiber für zusätzliche Hardwarekomponenten implementieren muss, wird schnell klar, dass die Wiederverwendbarkeit stark eingeschränkt ist. Mathematisch ausgedrückt: Wenn M die Anzahl der HAL-Implementierungen für Peripheriegeräte und N die Anzahl der Treiber ist, würde man – müsste man für jede Anwendung das Rad neu erfinden – bei M × N Implementierungen landen; durch die Verwendung der von den embedded-hal-Traits bereitgestellten API nähert sich die Implementierungskomplexität hingegen M + N an. Natürlich ergeben sich daraus weitere Vorteile, wie etwa ein geringerer Aufwand durch „Trial-and-Error“ dank wohldefinierter und sofort einsatzbereiter APIs.
Nutzer von embedded-hal
Wie bereits erwähnt, gibt es drei Hauptnutzer einer HAL:
HAL-Implementierung
Eine HAL-Implementierung stellt die Schnittstelle zwischen der Hardware und den Nutzern der HAL-Traits bereit. Typische Implementierungen bestehen aus drei Teilen:
- Ein oder mehrere hardwarespezifische Typen
- Funktionen zum Erstellen und Initialisieren eines solchen Typs, die häufig verschiedene Konfigurationsoptionen (Geschwindigkeit, Betriebsmodus, verwendete Pins usw.) bereitstellen.
- eine oder mehrere
trait-Implementierungen (impl) von embedded-hal-Traits für diesen Typ
Eine solche HAL-Implementierung kann in verschiedenen Ausprägungen vorliegen:
- Über hardwarenahen Zugriff, z. B. über Register
- Über das Betriebssystem, z. B. durch Verwendung von
sysfsunter Linux - Über einen Adapter, z. B. ein Mock von Typen für Unit-Tests
- Via-Treiber für Hardware-Adapter, z. B. I2C-Multiplexer oder GPIO-Expander
Treiber
Ein Treiber implementiert eine Reihe benutzerdefinierter Funktionen für eine interne oder externe Komponente, die mit einem Peripheriegerät verbunden ist, das die embedded-hal-Traits implementiert. Typische Beispiele für solche Treiber sind verschiedene Sensoren (Temperatur-, Magnetometer-, Beschleunigungs- und Lichtsensoren), Anzeigegeräte (LED-Arrays, LCD-Displays) und Aktoren (Motoren, Sender).
Ein Treiber muss mit einer Instanz eines Typs initialisiert werden, der ein bestimmtes Trait von embedded-hal implementiert. Dies wird durch Trait-Bound sichergestellt. Der Treiber stellt eine eigene Typinstanz mit einem benutzerdefinierten Satz von Methoden bereit, die die Interaktion mit dem angesteuerten Gerät
Anwendung
Die Anwendung verknüpft die verschiedenen Komponenten miteinander und stellt sicher, dass die gewünschte Funktionalität erreicht wird. Bei der Portierung zwischen verschiedenen Systemen ist dies der Teil, der den größten Anpassungsaufwand erfordert, da die Anwendung die eigentliche Hardware über die HAL-Implementierung korrekt initialisieren muss – und die Initialisierung unterschiedlicher Hardware variiert, bisweilen sogar drastisch. Zudem spielen oft anwenderspezifische Entscheidungen eine wichtige Rolle: Komponenten können physisch an unterschiedliche Anschlüsse angebunden sein, Hardware-Busse erfordern mitunter externe Hardware zur Konfigurationsanpassung, oder es müssen Abwägungen bei der Nutzung interner Peripherie getroffen werden (etwa wenn mehrere Timer mit unterschiedlichen Fähigkeiten zur Verfügung stehen oder Konflikte zwischen verschiedenen Peripherieeinheiten auftreten).