Reihe: Embedded World – Die unsichtbaren Gehirne verstehen (Teil 8)
Nacktes Metall – Programmieren ohne Betriebssystem (Bare-Metal)
Von DerSchneider
Einleitung: Die Stille vor dem Betriebssystem
In der Welt der Desktop-Computer und Smartphones ist ein Leben ohne Betriebssystem unvorstellbar. Windows, macOS, Linux, Android – sie alle sind die unsichtbaren Vermittler zwischen Anwendung und Hardware, verwalten Speicher, starten Programme, treiben Geräte an. Ohne sie wäre ein moderner PC nur ein teures Briefbeschwerer.
In der Embedded World ist das oft anders. Millionen von Mikrocontrollern laufen ihr ganzes Leben lang ohne jedes Betriebssystem. Ihr Programm sitzt direkt auf der Hardware, spricht unvermittelt mit Registern und Interrupts, kennt keinen Scheduler und keinen Speicherschutz. Diese Art der Programmierung nennt man „Bare-Metal“ – nacktes Metall.
Dieser Artikel taucht ein in diese ursprüngliche Form der Embedded-Entwicklung. Wir lernen, wie ein Bare-Metal-Programm aufgebaut ist, warum dieser Ansatz trotz seiner Einfachheit oft die beste Wahl ist – und wann man besser zu einem Betriebssystem greift.
Hauptteil
1. Was bedeutet „Bare-Metal“?
Der Begriff „Bare-Metal“ bedeutet wörtlich „nacktes Metall“ und meint: Das Programm läuft direkt auf der Hardware, ohne Zwischenschicht. Es gibt kein Betriebssystem, das Ressourcen verwaltet, keine Treiber, die die Hardware abstrahieren, keinen Scheduler, der Tasks koordiniert.
Das Programm hat die vollständige Kontrolle über den Mikrocontroller – und die volle Verantwortung. Es muss selbst dafür sorgen, dass alle Peripherie richtig initialisiert wird, dass Interrupts korrekt behandelt werden, dass der Stack nicht überläuft und dass Zeitbedingungen eingehalten werden.
In einem Bare-Metal-System verschmilzt die Anwendungssoftware mit der Systemsoftware. Es gibt keine Trennung zwischen „User Space“ und „Kernel Space“. Alles läuft in einem einzigen Adressraum, mit den höchsten Privilegien.
2. Die Urform: Die Super-Loop
Das Herzstück jedes Bare-Metal-Programms ist die Super-Loop – eine endlose Schleife, die immer wieder durchlaufen wird. Das Grundgerüst sieht so aus:
c
void main(void) {
// Initialisierung: Hardware konfigurieren
init_hardware();
init_peripherie();
init_interrupts();
// Super-Loop
while(1) {
task1(); // Aufgabe 1 erledigen
task2(); // Aufgabe 2 erledigen
task3(); // Aufgabe 3 erledigen
// ...
}
}
Nach der einmaligen Initialisierung im main-Programm läuft der Mikrocontroller für den Rest seines Lebens in dieser Schleife. Jeder Durchlauf erledigt der Reihe nach alle anstehenden Aufgaben.
Dieses Muster ist einfach, überschaubar und deterministisch. Solange alle Aufgaben zusammen in jedem Schleifendurchlauf Platz finden, funktioniert es hervorragend.
3. Die Herausforderung: Unterschiedliche Zeitanforderungen
Die Super-Loop hat ein grundlegendes Problem: Alle Aufgaben werden in fester Reihenfolge abgearbeitet, und die Zeit für einen kompletten Durchlauf ist die Summe aller Aufgaben.
Wenn eine Aufgabe sehr lange dauert, verzögern sich alle anderen. Wenn manche Aufgaben schnell reagieren müssen (Echtzeit!), andere aber viel Zeit brauchen, gerät das System schnell an seine Grenzen.
Stellen Sie sich vor:
- Ein Taster muss alle 10 ms abgefragt werden, um Prellen zu erkennen.
- Ein Display muss alle 50 ms aktualisiert werden.
- Ein Sensor muss alle 100 ms ausgelesen werden.
- Eine komplexe Berechnung dauert 200 ms.
In einer einfachen Super-Loop würde die Berechnung alle anderen Aufgaben blockieren. Der Taster würde nur alle 200 ms abgefragt – viel zu langsam für eine zuverlässige Entprellung.
4. Die Lösung: Zeitgesteuerte Aufgaben
Die erste Verbesserung besteht darin, Aufgaben nicht einfach der Reihe nach abzuarbeiten, sondern zeitgesteuert. Mit einem Timer, der regelmäßig Interrupts auslöst, kann man einen „Takt“ erzeugen:
c
volatile int tick = 0;
// Timer-Interrupt, alle 1 ms
ISR(TIMER_ISR) {
tick++;
}
void main(void) {
init_timer(1); // Timer alle 1 ms
int letzteTaster = 0;
int letzteDisplay = 0;
while(1) {
if (tick - letzteTaster >= 10) { // alle 10 ms
taster_abfragen();
letzteTaster = tick;
}
if (tick - letzteDisplay >= 50) { // alle 50 ms
display_aktualisieren();
letzteDisplay = tick;
}
// Langsame Aufgabe, die immer läuft
langsame_berechnung();
}
}
Jetzt wird der Taster alle 10 ms abgefragt, auch wenn die Berechnung läuft. Die langsame_berechnung() muss nur darauf achten, regelmäßig zum Hauptprogramm zurückzukehren (kooperatives Multitasking), damit die anderen Aufgaben drankommen.
5. Kooperatives vs. Präemptives Multitasking
Die obige Lösung ist ein Beispiel für kooperatives Multitasking: Jede Aufgabe muss von sich aus die Kontrolle abgeben, damit andere drankommen. Das ist einfach und sicher (keine gleichzeitigen Zugriffe), aber eine Aufgabe, die sich nicht kooperativ verhält (z.B. eine Endlosschleife), blockiert das ganze System.
Die Alternative ist präemptives Multitasking, bei dem der Scheduler Aufgaben unterbrechen kann. Das erfordert aber ein Betriebssystem (RTOS) und ist deutlich komplexer.
In Bare-Metal-Systemen ist kooperatives Multitasking die Regel. Die Kunst besteht darin, alle Aufgaben so zu schreiben, dass sie regelmäßig zurückkehren – typischerweise in sogenannten „Zustandsmaschinen“, die nach jedem Schritt pausieren können.
6. Zustandsmaschinen als Retter
Komplexere Abläufe lassen sich oft elegant als Zustandsmaschine (State Machine) modellieren. Statt einer langen, blockierenden Funktion hat man eine Funktion, die bei jedem Aufruf einen Schritt weitergeht.
Beispiel: Ein Ampelsystem
c
typedef enum {ROT, ROT_GELB, GRUEN, GELB} ampel_zustand_t;
ampel_zustand_t zustand = ROT;
int letzterWechsel = 0;
void ampel_schritt(int zeit) {
switch(zustand) {
case ROT:
if (zeit - letzterWechsel >= 5000) { // 5 Sekunden
zustand = ROT_GELB;
letzterWechsel = zeit;
led_rot(ON);
led_gelb(ON);
led_gruen(OFF);
}
break;
case ROT_GELB:
if (zeit - letzterWechsel >= 1000) { // 1 Sekunde
zustand = GRUEN;
letzterWechsel = zeit;
led_rot(OFF);
led_gelb(OFF);
led_gruen(ON);
}
break;
// weitere Zustände...
}
}
Diese Funktion wird in der Super-Loop regelmäßig aufgerufen. Sie blockiert nie, macht immer nur einen Schritt und kehrt sofort zurück.
7. Die Vorteile von Bare-Metal
Warum programmiert man heute noch Bare-Metal, wo es doch RTOS-Lösungen für fast jeden Mikrocontroller gibt?
Einfachheit: Ein Bare-Metal-Programm ist oft überschaubarer als ein RTOS-System. Keine komplexen Scheduler-Konfigurationen, keine Synchronisationsprobleme, kein Speicherschutz.
Ressourceneffizienz: Ein RTOS braucht Speicher und Rechenzeit. Bei winzigen 8-Bit-Controllern mit 2 KB RAM ist das oft nicht vorhanden.
Determinismus: In einem gut geschriebenen Bare-Metal-System ist genau vorhersagbar, wann welche Aufgabe läuft. Bei RTOS kann es durch Prioritäten und Interrupts zu komplexen, schwer vorhersagbaren Abläufen kommen.
Kontrolle: Man hat die vollständige Kontrolle über alles. Keine Black Box, keine Überraschungen durch Betriebssystem-Verhalten.
8. Die Grenzen von Bare-Metal
Aber Bare-Metal hat auch klare Grenzen:
Komplexität: Sobald viele Aufgaben mit unterschiedlichen Prioritäten koordiniert werden müssen, wird der Code schnell unübersichtlich. Eine Zustandsmaschine mit Dutzenden Zuständen ist schwer zu warten.
Sicherheit: Es gibt keine Isolierung zwischen Aufgaben. Ein Fehler in einer Aufgabe kann das ganze System zum Absturz bringen.
Wiederverwendbarkeit: Code ist oft eng mit der spezifischen Hardware verzahnt und schwer auf andere Plattformen zu portieren.
Kommunikation: Wenn Aufgaben Daten austauschen müssen, muss man selbst Mechanismen dafür bauen (globale Variablen, Queues, etc.).
9. Ein Praxisbeispiel: Kleiner Datenlogger
Stellen wir uns einen einfachen Datenlogger vor: Ein Temperatursensor wird alle 10 Sekunden ausgelesen, der Wert auf einem Display angezeigt und auf einer SD-Karte gespeichert. Ein Taster erlaubt das Umschalten der Anzeige.
Ein Bare-Metal-Design könnte so aussehen:
c
volatile int sekunden = 0;
// Timer-Interrupt alle 1 Sekunde
ISR(TIMER_ISR) {
sekunden++;
}
void main(void) {
init_timer(1000); // 1 Sekunde
int letzteMessung = 0;
int letzteDisplay = 0;
int anzeigeModus = 0;
while(1) {
// Taster abfragen (entprellt)
if (taster_gedrueckt()) {
anzeigeModus = !anzeigeModus;
}
// Alle 10 Sekunden messen
if (sekunden - letzteMessung >= 10) {
int temp = sensor_lesen();
sd_karte_schreiben(temp);
letzteMessung = sekunden;
}
// Display alle 500 ms aktualisieren
if (sekunden - letzteDisplay >= 0.5) { // mit Timer-Auflösung
display_aktualisieren(anzeigeModus);
letzteDisplay = sekunden;
}
// Schlafen, wenn nichts zu tun
power_save();
}
}
Das System ist einfach, robust und stromsparend – perfekt für einen batteriebetriebenen Datenlogger.
10. Der Übergang: Wann wird ein RTOS nötig?
Irgendwann stößt Bare-Metal an seine Grenzen. Typische Anzeichen:
- Sie haben mehr als 5-10 Aufgaben mit unterschiedlichen Prioritäten.
- Aufgaben haben komplexe zeitliche Abhängigkeiten.
- Sie brauchen zuverlässige Kommunikation zwischen Aufgaben (Queues, Mailboxen).
- Sie portieren Code zwischen verschiedenen Plattformen.
- Die Zustandsmaschinen werden unübersichtlich und fehleranfällig.
Dann ist es Zeit, über ein Echtzeitbetriebssystem (RTOS) nachzudenken – das Thema unseres nächsten Artikels.
Fazit und Ausblick
Bare-Metal-Programmierung ist die Urform der Embedded-Entwicklung. Sie ist einfach, effizient und gibt maximale Kontrolle. Mit Techniken wie zeitgesteuerten Aufgaben und Zustandsmaschinen lassen sich auch komplexere Anwendungen realisieren. Für viele – vielleicht die meisten – Embedded-Projekte ist Bare-Metal völlig ausreichend.
Doch wenn die Komplexität wächst, wird ein Betriebssystem zur sinnvollen Abstraktion. Es übernimmt das Scheduling, die Synchronisation und die Kommunikation – und erlaubt dem Entwickler, sich auf die eigentliche Anwendung zu konzentrieren.
Mit diesen Echtzeitbetriebssystemen beschäftigen wir uns im nächsten Artikel.
Kommentar abschicken