Das Echtzeit-Betriebssystem Metronom - Ein RTOS für AVR-Prozessoren
Für vielerlei Aufgaben — wie zum Beispiel die Verarbeitung kontinuierlicher Signale — müssen Mikrocontroller Aufgaben in exakten Zeitabständen erledigen. Das hier vorgestellte Echtzeitbetriebssystem eignet sich (auch) für AVR-Controller mit wenig Speicher. Dafür muss man mit Einschränkungen wie einer reinen Assemblerprogrammierung leben, was bei Projekten, bei denen es hauptsächlich auf Geschwindigkeit und Echtzeitfähigkeit ankommt, aber immer noch einen guten Kompromiss darstellt.
Warum noch ein Betriebssystem mehr?
Mit dem Erscheinen von kleinen und kleinsten Prozessoren oder Controllern wurden Abläufe automatisierbar, die früher den Einsatz eines „richtigen” Computers niemals gerechtfertigt hätten. Bei diesen Kleinstcomputern sind keinerlei Peripheriegeräte (Tastatur, Maus, Bildschirm, Disk oder Ähnliches) zu steuern, die Betriebssysteme können damit auf das Allernotwendigste reduziert werden, nämlich das Organisieren der Verarbeitung von Nutzer-Programmen.
Die meisten Betriebssysteme sind darauf getrimmt, möglichst viele Programme möglichst effizient und (von außen gesehen) gleichzeitig auszuführen. Anders sieht die Sache jedoch aus, wenn mit den Prozessoren kontinuierliche, zeitgebundene Signale verarbeitet werden sollen: Hier braucht es Prozesse, welche in exakten Intervallen ablaufen. Die Funktion delay() in Arduino zum Beispiel ist dann nicht mehr genügend genau, da sie nur Wartezeiten erzeugt, nicht aber die für die Verarbeitung benötigten Laufzeiten mitberücksichtigt. Und diese fallen bei Abtastzeiten von 1 ms oder noch kürzer deutlich ins Gewicht.
Es gilt also folgende zwei Probleme zu lösen:
- Gewisse Tasks sollen exakt zu vorgegebenen Zeiten ablaufen, andere dagegen nur, wenn gerade übrig bleibende Zeit dafür eingesetzt werden kann.
- Jeder unterbrechbare Task benötigt einen eigenen Stack zum Zwischenspeichern der Registerinhalte. Bei kleinen Controllern ist jedoch der Speicherplatz recht beschränkt: zum Beispiel 750 Bytes beim ATtiny25 oder 1 k beim ATmega8.
Das hier vorgestellte Betriebssystem Metronom kann als Open-Source-Software unter der BSD-2-Lizenz von der Elektor-Website heruntergeladen werden; vorgesehen ist in den kommenden Monaten auch eine Veröffentlichung via GitHub.
Zyklische Tasks
Metronom ist genau für die Aufgabenstellung konzipiert, sogenannte zyklische Tasks zu exakt vorgegebenen zeitlichen Abständen auszuführen (mit bis zu acht unterschiedlichen Zykluszeiten). Pro Zykluszeit gibt es genau einen Task; sind mehrere, inhaltlich voneinander unabhängige Aktivitäten im gleichen Zyklus auszuführen, so sind diese im selben Task zusammenzufassen.
Die Zykluszeiten werden folgendermaßen erzeugt:
- Die Basis-Zykluszeit (beispielsweise 1 ms) wird durch die Hardwaremittel des Prozessors (Quarz- oder interner RC-Oszillator, hardware- und interruptgesteuerter Softwarezähler) hergestellt, und
- die weiteren Zykluszeiten werden durch eine Kette von Zählern erzeugt, sodass jede Zykluszeit ein Vielfaches der vorhergehenden ist (die Standard-Einstellung ist beispielsweise 1 ms → 10 ms → 100 ms → 1 s).
Eine wichtige Eigenschaft des hier vorgestellten Betriebssystems ist, dass zyklische Tasks sich gegenseitig nicht unterbrechen können (non preemptive). Damit laufen einerseits diese Tasks möglichst zeitgenau ab, anderseits verliert der Prozessor keine „unproduktiven Zeiten“ für Taskwechsel. Und da jeder zyklische Task – einmal gestartet – ohne Unterbrechung (ausgenommen durch Interrupts) zu Ende läuft, bevor der nächste zyklische Task gestartet wird, können alle zyklischen Tasks gemeinsam denselben Stack benützen.
Was geschieht jedoch, wenn ein Task länger als die Basis-Zykluszeit läuft (was eigentlich durch den Programmierer vermieden werden sollte) oder aber, wenn im selben Basiszyklus mehrere zyklische Tasks gestartet wurden, wobei die Summe der Laufzeiten die Basis-Zykluszeit übersteigt (was durchaus legitim ist)? Hier tritt eine weitere Eigenschaft von Metronom in Kraft: Die zyklischen Tasks besitzen Prioritäten: Der Task mit der kürzesten Zykluszeit hat höchste Priorität, der „zweitschnellste“ Task die nächstniedrigere und so weiter. Können nicht alle gleichzeitig gestarteten zyklischen Tasks innerhalb der Basis-Zykluszeit abgearbeitet werden, so wird der gerade laufende Task nach Ablauf der Basis-Zykluszeit bis zu seinem Ende fortgesetzt – danach aber wird zuerst der höchstpriorisierte „schnellste“ Task wieder ausgeführt und erst danach weitere, noch von vorher gestartete zyklische Tasks.
Ein Beispiel: Die Implementation der Zykluszeiten führt dazu, dass jede Sekunde einmal alle zyklischen Tasks gleichzeitig gestartet werden. Was dann geschieht, zeigt Bild 1.
Daraus ist abzuleiten, dass als absolut oberste Grenze jeder zyklische Task nicht länger als die kürzeste Zykluszeit dauern darf - also in unserem Beispiel 1 ms. Das entspricht bei einem ATtiny mit 8 MHz ungefähr 6000, bei einem ATmega mit 16 MHz ungefähr 14000 Instruktionen (der Rest der Instruktionen wird im Mittel durch das Betriebssystem selbst sowie die Behandlung von Interrupts gebraucht).
Hintergrund-Tasks
Nun gibt es jedoch gewisse Operationen, welche von Natur aus länger dauern:
- Ein Schreibzugriff auf das EEPROM dauert beispielsweise einige Millisekunden (typisch ungefähr 3,3 ms), also untragbar lange bei einem Basiszyklus von 1 ms.
- Das Übertragen eines Texts mit 9600 Bd ist mit einem Basiszyklus von 1 ms nicht machbar, da bereits die Übertragungszeit eines einzelnen Zeichens länger als 1 ms dauert.
- Sollen längere Berechnungen (zum Beispiel mit emulierten Arithmetik-Operationen!) durchgeführt oder Zeichenketten verarbeitet werden, so dauern diese innerhalb eines zyklischen Tasks oft zu lange und behindern damit die zeitgebundenen Abläufe.
Die Folgerung heißt demnach, dass trotzdem eine Möglichkeit erforderlich ist, solche Aufgaben an irgendeine unterbrechbare Art von Tasks zu delegieren.Dafür werden zwei Methoden in Kombination verwendet:
- Verwendung von Interrupts statt aktives Warten: Damit kann man das Warten auf das Ende eines Vorgangs (zum Beispiel das Übertragen eines Zeichens) an die Hardware „delegieren”; dieser Weg wird für interruptgesteuerte Vorgänge benutzt. Dies löst die Probleme für eine einzelne Zeichen-Übertragung oder für das Schreiben eines einzelnen Werts ins EEPROM, nicht aber das Warten auf das Ende der Gesamt-Operation, etwa die Übertragung eines ganzen Texts.
- Einrichten von Hintergrund-Tasks: Ein Hintergrund-Task läuft nur in denjenigen Zeiten, welche nicht durch zyklische Tasks beansprucht werden. Zudem ist er gegenüber den zyklischen Tasks jederzeit unterbrechbar, behindert also deren zeitgerechtes Ablaufen nicht.
Durch andere Hintergrund-Tasks jedoch kann ein einmal laufender Hintergrund-Task nicht unterbrochen werden. Es wird somit immer nur ein Hintergrund-Task abgearbeitet, und wenn dieser warten muss, wartet die ganze Verarbeitung von Hintergrund-Tasks. Die Abarbeitung der Hintergrund-Tasks wird damit zwar langsamer; dafür wird hiermit ermöglicht, dass für die Gesamtheit aller Hintergrund-Tasks ebenfalls nur ein einziger Stack-Bereich freigehalten werden muss.
Hintergrund-Tasks zeichnen sich durch folgende Eigenschaften aus:
- ein Hintergrund-Task ist jederzeit unterbrechbar zugunsten von zyklischen Tasks, nicht aber zugunsten eines anderen Hintergrund-Tasks.
- Das Ablaufen eines Hintergrund-Tasks wird durch einen Start-Aufruf an den Dispatcher ausgelöst.
- Hintergrund-Tasks werden in der Reihenfolge ihres Starts nacheinander ausgeführt.
- Hintergrund-Tasks können auf Ereignisse (WAIT_EVENT) warten, welche zum Beispiel durch interruptgesteuerte Abläufe ausgelöst werden.
- Hintergrund-Tasks können auch vorgebbare Zeiten abwarten (DELAY).
- Jedem Hintergrund-Task kann eine Start-Nachricht von drei 16-bit-Wörtern mitgegeben werden, mit welchen sein spezifischer Auftrag definiert werden kann (ein viertes Wort ist für die Startadresse des Task reserviert).
- Innerhalb des Benutzerprogramms kann eine beliebige Anzahl von Hintergrund-Task-Programmen vorhanden sein; es können jedoch maximal acht gleichzeitig gestartet sein.
Die Koordination der Tasks untereinander („wann darf welcher Task ablaufen”) ist Aufgabe des sogenannten Dispatchers. Dieser führt alle „Verwaltungs-Abläufe” aus, vom Start von Tasks über die Sicherung/Wiederherstellung der Prozessorregister bis hin zur Sperrung/Freigabe von Interrupts.
Exceptions
Da Kleinstprozessoren meist keine textorientierten Peripheriegeräte besitzen, gestaltet sich die Fehlersuche insbesonders bei zeitabhängigen Funktionen sehr schwierig, da Haltepunkte oder ähnliches das Zeitverhalten völlig außer Takt bringen. Deshalb stellt der Betriebssystem-Kern einen vereinfachten Mechanismus zur Exception-Behandlung zur Verfügung, welcher in zwei Stufen aufgeteilt ist:
- Ein globaler try-catch-Bereich fängt alle im Kernel und in den Arithmetik-Emulationen auftretenden Exceptions (Ausnahmen/Fehler) ab. Die exception-spezifischen Daten können im EEPROM gespeichert und/oder via USART ausgegeben werden; danach führt das Betriebssystem einen Gesamtsystem-RESET (einschliesslich user-reset) aus. Dieser Exception-Bereich ist immer aktiv.
- Zusätzlich kann ein anwendungsorientierter try-catch-Bereich benutzt werden, der nur das eigentliche Anwender-Programm abdeckt. Die Behandlung solcher Exceptions geschieht anfänglich gleich wie oben: Die Exception-Daten werden gespeichert und/oder via USART ausgegeben; danach wird ein durch den Anwender zu definierender „Anwendungs-Restart” durchgeführt (Subroutine user_restart).
Interruptbehandlung
Interrupts werden auf vier unterschiedliche Arten behandelt:
- Der Reset-Interrupt wird durch das Betriebssystem genutzt und ist dem Benutzer nicht direkt zugänglich. Da jedoch der User diesen Interrupt auch zur Initialisierung der eigenen Abläufe benötigt, ruft das Betriebssystem nach der eigenen Initialisierung die Subroutine user_init auf, welche der Anwender mit seinem anwendungsspezifischen Initialisierungs-Code füllen kann.
- Der Timer/Counter0 wird für die Erzeugung des Basistakts für alle zyklischen Prozesse benutzt; er ist dem Anwender deshalb nicht zugänglich.
- Für den Einsatz des EEPROMs und des USARTs bietet das Betriebssystem fertige Treiber-Bausteine an, welche anlässlich der Betriebssystem-Generierung eingebunden werden können (siehe unten). Der Anwender kann aber auch eigene Service-Routinen an diese Interrupts koppeln oder sie bei Nichtgebrauch einfach offen lassen.
- Alle übrigen Interrupts stehen dem User direkt offen. Pro Interrupt ist jeweils eine sogenannte Interrupt-Service-Routine sowie eine Interrupt-Initialisierungs-Routine zu definieren. Gehören zu einem Device mehr als nur ein Interrupt (wie zum Beispiel bei Timern oder USART), so genügt eine gemeinsame Initialisierungs-Routine. Der Anwender aktiviert hierzu die entsprechenden Parameter in der Generierungs-Datei und fügt die Inhalte der betreffenden Initialisierungs- und Serviceroutinen in seinem Anwenderprogramm ein.
- Nicht genutzte Interrupts werden durch das Betriebssystem automatisch „abgefangen“.
Programmierumgebung
Metronom ist aus Effizienzgründen in AVR-Assembler (Atmel/Microchip) geschrieben und setzt somit voraus, dass die Benutzerprogramme ebenfalls in Assembler geschrieben sind; eine Schnittstelle zu C wurde nicht implementiert. Stattdessen gibt es jedoch eine Library mit vielen Subroutinen für beispielsweise 8-Bit-Arithmetik sowie 16-Bit-Arithmetik (vier Grundrechenarten); eine 16-Bit-Fractional-Bibliothek ist in Vorbereitung.
Zur Erleichterung der Programmierarbeit liegen alle Betriebssystem-Aufrufe als Makros vor. Zur Vermeidung von Namenskollisionen gilt folgende Namenskonvention: Alle Variablen und Sprungziele innerhalb des Betriebssystems und der Bibliotheken beginnen mit Unterstrich („_”) – daher sollten alle Namen im Benutzerprogramm ausschließlich mit Buchstaben beginnen. Andere Zeichen als Buchstaben und Zahlen und der Unterstrich sind nicht zulässig.
Die Gesamtstruktur von Metronom und dem jeweiligen Benutzerprogramm sieht man in Bild 2.
Betriebssystem-Aufrufe
Für die vollständige Liste der Betriebssystem-Aufrufe wird auf die Referenzen am Schluss des Artikels verwiesen; hier nur eine grobe Übersicht:
Makros für Exception-Handling:
- KKTHROW wirft eine systemweite Exception, was bedeutet, dass nach Speichern/Ausgeben der Exception-Information das gesamte System neu gestartet wird.
- KTHROW wirft eine auf das Benutzerprogramm begrenzte Exception, was bedeutet, dass nach Speichern/Ausgeben der Exception-Information nur die Benutzer-Subroutine user_restart ausgeführt wird; danach werden die zyklischen Tasks neu gestartet.
Macros for using background tasks
- _KSTART_BTASK starts a background task.
- _KDELAY puts the calling background task to sleep for n (0 to 65535) ms.
- _KWAIT puts the calling background task to sleep, from which it can be resumed by means of …
- _KCONTINUE.
Makros für 8- und 16-Bit-Arithmetik
Generell werden für Arithmetik-Operationen aller Art die Register r25:r24 als Akkumulator und r23:r22 als Speicher für den Zweit-Operanden (falls benötigt) verwendet.
Hierfür gibt es über 20 unterschiedliche Funktionen wie _mul8u8 für eine 8x8bit-Multiplikation oder _abs16 für einen 16Bit-Absolutwert.
Ferner gibt es viele Lade- und Speicher-Pseudocodes wie etwa _ld16 (Lade 16-Bit-Zahl in den Akkumulator).
Macros for EEPROM use
- _KWRITE_TO_EEPROM zum Schreiben ins EEPROM,
- _KREAD_FROM_EEPROM zum Lesen vom EEPROM.
Makros für USART-Nutzung
- _KWRITE_TO_LCD ist ein spezifischer USART-Treiber, welcher dem darzustellenden Text gleich auch die notwendigen Steuerzeichen für ein 2x16 LCD-Display beifügt
- _KREAD_FROM_USART (bisher nicht implementiert).
Systemgenerator SysGen
Für das Generieren eines Systems (also des vollständigen Codes) wird der eigene Systemgenerator SysGen verwendet, der ebenfalls Bestandteil des Gesamtpakets ist. SysGen ist nicht auf Metronom beschränkt, sondern kann auch für allgemeine Generieraufgaben verwendet werden.
Manche Leser mögen sich fragen, warum ein eigener System-Generator entwickelt wurde, nachdem es ja eine Vielzahl von Preprozessoren und Makrogeneratoren gibt. Doch für das Generieren des Betriebssystems Metronom genügen die Funktionsumfänge der Preprozessoren im Atmel-Studio sowie von Standard-C nicht. Insbesondere da der Preprozessor keine String-„Arithmetik“ erlaubt, kann man kein „Standard-Directory“ oder „Library-Directory“ vorgeben und von diesem aus darin enthaltene Dateien anwählen. Eine Recherche bei stackoverflow hat gezeigt, dass andere Leute dasselbe Problem haben wie ich, aber keiner der heutigen Preprozessoren damit umgehen kann.
Der Preprozessor von Atmel-Studio (und auch der GNU-Preprozessor) bieten folgende Funktionen für das Zusammensetzen der beteiligten Dateien:
- define / set <name> = <numerischer_ausdruck>
- if … elif … else … endif, auch geschachtelt
- ifdef, ifndef
- include <pfad> | exit
Es fehlen folgende Funktionalitäten:
- <pfad> kann nur als fixer String vorgegeben werden, nötig wäre aber ein String-Ausdruck von (beliebig vielen) Teil-Strings, sowohl Stringvariablen als auch String-Konstanten.
- define und set können nur numerische Werte zuweisen, keine Strings, kein Aneinanderhängen von Strings, und auch keine logischen Ausdrücke.
- Für das (einmalige) Einbinden von Library-Programmen genügen die in AVRASM oder C-Preprozessor gegebenen Makro-Möglichkeiten nicht; da Makros in AVRASM keine Include-Anweisungen enthalten dürfen, ist zum Beispiel das automatische Einbinden von Emulations-Routinen nicht möglich.
Das führt zu folgendem Funktionsumfang:
- define / set <name> = <numerischer_ausdruck> | <string ausdruck> | <boole'scher ausdruck>
- if … elif … else … endif, auch geschachtelt
- ifdef, ifndef wird umgewandelt in if isdef(..) bzw. ! isdef(..) und ist damit auch innerhalb von Boole-Ausdrücken verwendbar
- include <string ausdruck> | exit
- message | error
- code (zum Erzeugen von Code-Zeilen mit generierbarem Inhalt)
- macro/endmacro mit passender Parameter-Kennzeichnung
- Eine zusätzliche Bedingung ist, dass Anweisungen bestehender Preprozessoren mit denjenigen von SysGen mischbar sind, ohne dass sie sich gegenseitig stören.
Das Programm SysGen kann ebenfalls von der angegebenen Website heruntergeladen werden. SysGen ist in Java (Version 12) geschrieben und benötigt für seinen Ablauf eine entsprechende Java-Installation.
Programmieren mit Metronom
Um dem Anwender das mühsame Durcharbeiten des Betriebssystem-Quellcodes zu ersparen, ist das gesamte Betriebssystem in generierbarer Form aufgebaut. Das bedeutet, dass der Anwender nur die Definitions-Datei sowie – falls erforderlich – die selber programmierten Interrupt-Routinen ausfüllen muss. Wo diese hingehören und wie sie verbunden werden, wird durch den Generiervorgang automatisch erledigt.
In seiner Grundform setzt sich ein Anwendersystem aus den Teilen wie in Bild 3 zusammen.
Die Interrupt-Tabelle und der Kernel werden stets als Ganzes in das entstehende Gesamt-Programm eingebaut; von den Device-Handlern und den Bibliotheken dagegen werden nur die wirklich benötigten Teile übernommen.
Um den Ablauf einer Generierung zu veranschaulichen, wird hier das Generier-Skript eines meiner eigenen Projekte in Listing 1 wiedergegeben.
; *********************************************************
; Master Definition
; *********************************************************
; Stand: 03.05.2022
;
; This file contains all informations required to generate your user system for
; AVR processors.
; It consists of three parts:
;
; 1. Definitions
; A bunch of variable definitions defining which functionalities to include.
; This part must be edited by the user.
;
; 2. An $include statement for the actual generation of the operating system.
; DO NOT MODIFY THIS STATEMENT!
;
; 3. The $include statement(s) adding the user program(s).
; This part must be edited by the user.
;
; *********************************************************
; PART 1: DEFINITIONS
;
; This script is valid for ATmega8, ATmega328/P and ATtiny25/45/85 processors.
; If you want to use it for any other processors feel free to adapt it accordingly.
$define processor = "ATmega8"
; Remove the ; in front of the $set directive if you want to use the EEPROM
; $set _GEEPROM=1
; if you want to write your own routines to write to the EEPROM use the following
; definition:
; $set _GEEPROM=2
; Enabling this definition will insert an appropriate JMP instruction to your
; interrupt service routine e_rdy_isr in the InterruptHandlers.asm file
; Remove the ; in front of the $set directive if
; ... you want to output serial data via the USART, or
; ... you want exception messages to be sent outside via the USART
; $set _GUSART=1
; if you want to write your own routines to use the USART
; use the following definition instead
; $set _GUSART=2
; Enabling this definition will enable the interrupt service routines usart_udre_isr,
; usart_rxc_isr and usart_txc_isr in the InterruptHandlers.asm file.
; ---------------------------------------------------------
; Define the division ratios of the time intervals for cyclic tasks
; The definition shown here is the standard preset for 1 : 10 : 100 : 1000 ms
; The first ratio being 0 ends the divider chain.
.equ _KRATIO1 = 10 ; 1 -> 10ms
.equ _KRATIO2 = 10 ; 10 -> 100ms
.equ _KRATIO3 = 10 ; 100ms -> 1s
.equ _KRATIO4 = 0 ; end of divider chain
.equ _KRATIO5 = 0
.equ _KRATIO6 = 0
.equ _KRATIO7 = 0
; NOTE: Do not remove "superfluous" .EQU statements but set them to 0 if not used!
; ---------------------------------------------------------
; Define the constants used for generation of the 1ms timer interrupt
; IMPORTANT: The following definitions depend on the processor being used
; and the frequency of the master clock
$if (processor == "ATmega8")
; The definitions below are based on a system frequency of 12.288 MHz (crystal)
; This frequency has been chosen in order to use the crystal also for USART@9600 Bd
;
; set prescaler for counter0 to divide by 256, yields 48kHz counting freq for Counter0
.equ _KTCCR0B_SETUP = 4
; Counter0 should divide by 48 in order to produce interrupts every 1ms;
; since counter0 produces an interrupt only at overflow we must preset
; with (256-48) - 1 = 207.
$code ".equ _KTCNT0_SETUP = " + (256 - 48) - 1
$elif ... similar for other processors
;
$endif
;
; ---------------------------------------------------------
; Define the characteristics of USART transmission
; (if you don't use the USART just neglect these definitions):
$set fOSC = 12288000
$set baud_rate = 9600
$code ".equ _KUBRR_SETUP = " + (fOSC / (16 *baudrate) – 1)
; parity: 0 = Disabled,
; (1 = Reserved), 2 = Enable Even, 3 = Enable Odd
.equ _KPARITY = 0
; stop bits: 0 = 1 stop bit, 1 = 2 stop bits
.equ _KSTOP_BITS = 1
; data bits transferred: 0 = 5-bits, 1 = 6-bits, 2 = 7-bits, 3 = 8-bits, 7 = 9-bits
.equ _KDATA_BITS = 3
;
; ---------------------------------------------------------
; Connect a user defined interrupt handler (except RESET and Timer0)
; by removing the ; in front of the appropriate $set directive;
; don't change any names but just let the $set statement as is
; Interrupts for ATmega8
; $set _kext_int0 = 1 ; IRQ0 handler
$set _kext_int1 = 1 ; IRQ1 handler/initializer is supplied by user
; $set _ktim2_cmp = 1 ; Timer 2 Compare Handler
; $set _ktim2_ovf = 1 ; Timer 2 Overflow Handler
; $set _ktim1_capt = 1 ; Timer 1 Capture Handler
;
; etc. etc. etc.
;
; *********************************************************
; PART 2: GENERATING THE OPERATING SYSTEM
;
.LISTMAC
;
$include lib_path + "\GenerateOS.asm"
;
;
; *********************************************************
; PART 3: ADD THE USER PROGRAM
;
$include user_path + "\MyApplication.asm"
;
$exit
(210719-02) Übersetzung: Rolf Gerstendorf
Elektor Mag 03/04 2023
Sie haben Fragen oder Kommentare?
Haben Sie technische Fragen oder Kommentare zu diesem Artikel? Schicken Sie eine E-Mail an den Autor unter profos@rspd.ch oder kontaktieren Sie Elektor unter redaktion@elektor.de.