Reihe: Embedded World – Die unsichtbaren Gehirne verstehen (Teil 6)
Register und Interrupts – Die direkte Kommunikation mit der Hardware
Von DerSchneider
Einleitung: Die Sprache der Maschine
Bisher haben wir die Architektur eines Embedded Systems kennengelernt – die CPU, den Speicher, die Peripherie. Wir wissen, wie der Taktgeber den Rhythmus vorgibt und was Echtzeit bedeutet. Aber wie spricht ein Programm eigentlich mit dieser Hardware? Wie teilt man der Peripherie mit, dass sie einen Timer starten soll? Wie erfährt die CPU, dass ein Sensor einen neuen Wert geliefert hat?
Die Antwort führt uns auf die unterste Ebene der Programmierung – dorthin, wo die Grenze zwischen Software und Hardware verschwimmt. Hier gibt es keine Betriebssystem-API, keine Treiberbibliothek, keine Abstraktion. Hier spricht der Code direkt mit der Maschine, in ihrer eigenen Sprache: über Register und Interrupts.
Dieser Artikel taucht ein in diese fundamentale Schicht der Embedded-Entwicklung. Wir lernen, was Hardware-Register sind, wie man sie programmiert und warum Interrupts das Rückgrat jedes reaktiven Systems bilden.
Hauptteil
1. Die Schaltzentrale: Was sind Hardware-Register?
Stellen Sie sich vor, Sie sitzen in einem Kontrollraum mit unzähligen Schaltern, Hebeln und Anzeigen. Jeder Schalter steuert eine bestimmte Funktion der Maschine. Jede Anzeige zeigt einen bestimmten Zustand an. Genau so funktioniert die Kommunikation mit der Peripherie eines Mikrocontrollers – über Hardware-Register.
Ein Hardware-Register ist ein spezieller Speicherplatz, der direkt mit einer Peripherie-Einheit verbunden ist. Im Gegensatz zu normalen RAM-Speicherzellen, die nur Daten aufbewahren, sind Register mit der Logik der Peripherie verknüpft:
- Schreibt man ein bestimmtes Bit in ein Register, kann das einen Timer starten, einen Pin auf HIGH setzen oder eine Kommunikationsschnittstelle aktivieren.
- Liest man ein Register, erhält man den aktuellen Zustand eines Sensors, den Zählerstand eines Timers oder das empfangene Byte einer Schnittstelle.
Register sind die Schalthebel und Anzeigen des Mikrocontrollers. Jede Peripherie-Einheit hat einen Satz von Registern, die ihre Funktion steuern. Und jedes dieser Register hat eine feste Adresse im Speicherbereich des Mikrocontrollers.
2. Ein Beispiel: Die GPIO-Register
Machen wir es konkret am Beispiel der GPIO-Pins (General Purpose Input/Output). In einem typischen Mikrocontroller gibt es für die GPIO-Steuerung mehrere Register:
- DDR (Data Direction Register): Hier wird festgelegt, ob ein Pin als Eingang oder Ausgang arbeitet. Schreiben Sie eine 1 in das entsprechende Bit, wird der Pin zum Ausgang. Eine 0 macht ihn zum Eingang.
- PORT (Port Output Register): Bei Pins, die als Ausgang konfiguriert sind, bestimmt dieses Register, ob der Pin HIGH (1) oder LOW (0) ist.
- PIN (Port Input Register): Hier können Sie lesen, welcher Zustand an einem als Eingang konfigurierten Pin anliegt.
In der Arduino-Welt sind diese Register hinter Funktionen wie pinMode() und digitalWrite() versteckt. Wer aber direkt in C programmiert, schreibt oft so:
c
DDRB |= (1 << PB0); // Setze Pin PB0 als Ausgang PORTB |= (1 << PB0); // Setze Pin PB0 auf HIGH
Das ist keine Zauberei, sondern der direkte Zugriff auf die Hardware-Register. Man schreibt eine 1 an die entsprechende Stelle im Datenrichtungsregister (DDR) bzw. im Ausgangsregister (PORT).
3. Die Kunst der Bitmanipulation
Wer mit Registern arbeitet, muss Bits setzen, löschen und prüfen können. Das ist eine Kunst für sich, denn Register sind oft nur 8 oder 16 Bit breit, und jedes Bit hat eine eigene Bedeutung.
Die wichtigsten Operationen:
- Bit setzen:
register |= (1 << bitnummer);– Setzt das angegebene Bit auf 1, ohne die anderen zu verändern. - Bit löschen:
register &= ~(1 << bitnummer);– Setzt das Bit auf 0. - Bit toggeln:
register ^= (1 << bitnummer);– Dreht das Bit um (aus 1 wird 0, aus 0 wird 1). - Bit prüfen:
if (register & (1 << bitnummer))– Testet, ob das Bit gesetzt ist.
Diese scheinbar kryptischen Ausdrücke sind die Grundwerkzeuge jedes Embedded-Entwicklers. Sie sind schnell, effizient und sprechen direkt die Hardware an.
4. Das Problem: Polling und seine Grenzen
Nun können wir also die Hardware steuern und ihren Zustand lesen. Aber wie erfahren wir, dass ein Ereignis eingetreten ist? Dass ein Timer abgelaufen ist? Dass ein neues Byte über die serielle Schnittstelle angekommen ist?
Die naive Methode heißt Polling: Die CPU fragt in einer Schleife immer wieder ein bestimmtes Register ab, ob das Ereignis eingetreten ist.
c
while (!(UCSRA & (1 << RXC))) {
// Warte, bis ein Zeichen empfangen wurde
}
char empfangenesZeichen = UDR;
Das Problem: Solange die CPU in dieser Schleife wartet, kann sie nichts anderes tun. Sie verschwendet kostbare Rechenzeit mit sinnlosem Warten. Bei einem einfachen Programm mag das angehen, aber sobald mehrere Ereignisse gleichzeitig überwacht werden müssen oder das System auf andere Aufgaben reagieren soll, ist Polling keine Lösung.
5. Die Lösung: Interrupts – Wenn die Hardware ruft
Hier kommen Interrupts ins Spiel. Ein Interrupt ist ein Hardware-Signal, das die CPU zu jedem beliebigen Zeitpunkt unterbrechen kann. Die Peripherie ruft sozusagen an, statt dass die CPU dauernd nachfragen muss.
Der Ablauf:
- Interrupt-Konfiguration: Im Programm wird festgelegt, auf welche Ereignisse die CPU reagieren soll (z.B. „Timer abgelaufen“, „Zeichen empfangen“, „Pin-Zustand geändert“).
- Interrupt-Serviceroutine (ISR): Für jedes Ereignis wird eine spezielle Funktion geschrieben – die ISR. Sie enthält den Code, der ausgeführt werden soll, wenn das Ereignis eintritt.
- Normales Programm läuft: Die CPU arbeitet ihr Hauptprogramm ab, kümmert sich um andere Aufgaben.
- Ereignis tritt ein: Die Peripherie setzt ein Interrupt-Flag und sendet ein Signal an die CPU.
- Unterbrechung: Die CPU beendet nach dem aktuellen Befehl ihre Arbeit, sichert den aktuellen Zustand (Programmzähler, Register) auf dem Stack und springt zur ISR.
- Bearbeitung: Die ISR wird ausgeführt. Sie sollte so kurz wie möglich sein, um andere Interrupts nicht zu blockieren.
- Rückkehr: Die ISR endet mit einem speziellen Befehl, der die gesicherten Werte wiederherstellt und die CPU zurück zum unterbrochenen Programm bringt.
Das Schöne daran: Die CPU kann während des Wartens auf Ereignisse produktiv arbeiten. Sie wird nur unterbrochen, wenn wirklich etwas zu tun ist.
6. Prioritäten und Verschachtelung
Was passiert, wenn während einer ISR ein weiterer Interrupt eintritt? Das hängt vom System ab:
- Nicht verschachtelte Interrupts: Während eine ISR läuft, werden alle anderen Interrupts ignoriert. Einfach, aber hochpriore Ereignisse müssen warten.
- Verschachtelte Interrupts: Interrupts können Prioritäten haben. Ein niederpriorer Interrupt kann durch einen hochprioren unterbrochen werden. Das ist komplexer, erlaubt aber, dass wichtige Ereignisse sofort behandelt werden.
In der Praxis haben die meisten Mikrocontroller eine feste Prioritätsordnung. Manche erlauben es, die Prioritäten zu programmieren. Die Kunst besteht darin, die ISRs so kurz zu halten, dass auch bei vielen Interrupts alle rechtzeitig bedient werden können.
7. Fallstricke und Gefahren
Interrupts sind mächtig, aber auch gefährlich. Einige klassische Probleme:
Gemeinsame Daten: Wenn eine ISR und das Hauptprogramm auf dieselben Daten zugreifen, kann es zu Konsistenzproblemen kommen. Die ISR ändert eine Variable, während das Hauptprogramm sie gerade liest – das Ergebnis ist undefiniert. Abhilfe schaffen Sperrmechanismen (z.B. Interrupts kurzzeitig deaktivieren) oder atomare Operationen.
Zu lange ISRs: Wenn eine ISR zu lange läuft, werden andere Interrupts blockiert. Das kann Echtzeitanforderungen verletzen. ISRs sollten daher nur das Nötigste tun – z.B. ein Flag setzen oder ein Byte in einen Puffer legen – und die aufwendige Verarbeitung dem Hauptprogramm überlassen.
Rekursive Interrupts: Manche Systeme erlauben es, dass ein Interrupt sich selbst unterbricht – meist keine gute Idee.
Stack-Overflow: Jeder Interrupt braucht Stack-Speicher. Bei vielen verschachtelten Interrupts kann der Stack überlaufen.
8. Ein Praxisbeispiel: Externer Interrupt durch Taster
Stellen wir uns vor, wir wollen einen Taster überwachen, aber nicht durch Polling. Stattdessen soll ein Interrupt ausgelöst werden, sobald der Taster gedrückt wird.
Die Konfiguration (vereinfacht für einen AVR-Mikrocontroller):
c
// Konfiguriere Pin als Eingang mit Pull-up DDRD &= ~(1 << PD2); // PD2 als Eingang PORTD |= (1 << PD2); // Pull-up aktivieren // Konfiguriere externen Interrupt für INT0 (PD2) EICRA |= (1 << ISC01); // Interrupt bei fallender Flanke EIMSK |= (1 << INT0); // Interrupt INT0 aktivieren // Global Interrupts aktivieren sei();
Die Interrupt-Serviceroutine:
c
ISR(INT0_vect) {
// Wird aufgerufen, wenn der Taster gedrückt wird
tasterGedrueckt = true; // Nur Flag setzen
}
Im Hauptprogramm wird dann regelmäßig geprüft, ob das Flag gesetzt ist, und entsprechend reagiert. Die ISR war nur wenige Taktzyklen lang, hat kein anderes Ereignis blockiert und das Hauptprogramm kann in Ruhe weiterarbeiten.
9. Warum das alles wichtig ist
Register und Interrupts sind die unterste Ebene der Embedded-Programmierung. Sie zu verstehen bedeutet:
- Kontrolle: Man ist nicht mehr auf Bibliotheken angewiesen, die vielleicht nicht optimal sind. Man kann die Hardware genau so nutzen, wie man sie braucht.
- Effizienz: Direkter Registerzugriff ist oft schneller als Funktionsaufrufe. Interrupts sparen kostbare CPU-Zeit.
- Echtzeitfähigkeit: Nur mit Interrupts lassen sich harte Echtzeitanforderungen erfüllen.
- Fehlerverständnis: Viele merkwürdige Fehler in Embedded Systems haben mit falscher Interrupt-Behandlung oder Register-Konfiguration zu tun.
Natürlich muss nicht jeder Entwickler ständig auf dieser Ebene programmieren. Aber zu wissen, was unter der Haube passiert, macht den Unterschied zwischen einem Anwender, der Code kopiert, und einem Entwickler, der wirklich versteht, was sein System tut.
Fazit und Ausblick
Wir sind angekommen im Maschinenraum. Register sind die Schalter und Anzeigen, mit denen die Software direkt in die Hardware eingreift. Interrupts sind die Rufanlagen, mit denen die Hardware der Software signalisiert, dass etwas passiert ist. Zusammen bilden sie die Grundlage jeder effizienten, reaktiven Embedded-Programmierung.
Doch die Kommunikation mit der Hardware ist nur die eine Seite. Embedded Systems müssen auch untereinander kommunizieren – mit Sensoren, mit anderen Controllern, mit der Außenwelt. Dafür brauchen sie standardisierte Schnittstellen und Protokolle.
Mit diesen Schnittstellen beschäftigen wir uns im nächsten Artikel.
Kommentar abschicken