The Metronom Real-Time Operating System: An RTOS for AVR Processors
For many tasks — such as processing continuous signals — microcontrollers have to perform tasks in exact time intervals. The real-time operating system presented here, the Metronom RTOS, is (also) suitable for AVR controllers with little memory. You have to accept some limitations, such as pure assembler programming, which is still a good compromise for projects where speed and real-time capability are important.
Why Yet Another Operating System?
With the appearance of small and very small processors or controllers, processes became automatable, for which the use of a “real” computer would never have been justified in the past. These microcontrollers do not need to control any peripherals (keyboard, mouse, screen, disk, etc.), so the operating systems can be reduced to the bare essentials of organizing the processing of user programs.
Most operating systems are designed to run as many programs as possible as efficiently as possible and (from the user’s point of view) simultaneously. The situation is different, however, where continuous, time-bound signals are to be processed: This requires processes that run at exact intervals. For example, the delay() function in Arduino is no longer sufficiently accurate in this case, since it only generates waiting times, but does not take into account the runtimes required for processing, which are clearly noticeable with sampling times of 1 ms or even shorter.
The following two problems must therefore be solved:
- Certain tasks should run exactly at predefined times, others only when there is time left over for them.
- Each interruptible task needs its own stack for buffering the register contents. However, with small controllers the memory space is quite limited: for example, 750 bytes with the ATtiny25 or 1 K with the ATmega8.
The Metronom operating system presented here can be downloaded from the Elektor website as open-source software under the BSD-2 license; a release via GitHub is also planned in the coming months.
Cyclic Tasks
Metronom is designed precisely for the execution of what is called cyclic tasks at precisely specified time intervals (with up to 8 different cycle times). There is exactly one task per cycle time; if several activities with independent content are to be executed in the same cycle, they are to be combined in the same task.
The cycle times are generated as follows:
- The base cycle period (e.g., 1 ms) is established using the processor’s hardware (crystal or internal RC oscillator, hardware- and interrupt-controlled software counter), and
- the other cycle times are generated by a chain of counters, so that each cycle time is a multiple of the previous one (e.g., the default setting is 1 ms 10 ms 100 ms 1 s).
An important feature of the operating system presented here is that cyclic tasks cannot interrupt each other (non-preemptive). On the one hand, this ensures that the timing of these tasks is as precise as possible, and on the other hand, the processor does not lose any “unproductive time” for task switching. And since each cyclic task — once started — runs to completion without interruption (except by interrupts) before the next cyclic task is started, all cyclic tasks can use the same stack together.
However, what happens if a task runs longer than the base cycle period (which actually should be avoided by the programmer) or if several cyclic tasks were started in the same base cycle, where the sum of the runtimes exceeds the base cycle time (which is quite legitimate)? Here another feature of Metronom comes into operation: The cyclic tasks have priorities: The task with the shortest cycle time has the highest priority, the “second fastest” task has the second-highest priority, and so on. If not all cyclic tasks started at the same time can be completed within the basic cycle time, the task currently running is continued to its end after the basic cycle time has elapsed — but then the highest-priority “fastest” task is executed again first, and only after that, other cyclic tasks started previously are executed again.
An example: The implementation of the cycle times causes all cyclic tasks to be started simultaneously every second. What happens then is shown in Figure 1.
This means that, as an absolute upper limit, each cyclic task must not take longer than the shortest cycle time — i.e., 1 ms in our example. This corresponds to about 6000 instructions for an ATtiny running at 8 MHz and about 14000 instructions for an ATmega at 16 MHz (the rest of the instructions are used — on average — by the operating system itself and for the handling of interrupts).
Background Tasks
However, there are certain operations which take longer due to their nature:
- Write access to the EEPROM, for example, takes a few milliseconds (typically about 3.3 ms), i.e., an intolerably long time for a basic cycle of 1 ms.
- Transmitting text at 9600 Bd is not feasible with a basic cycle of 1 ms because even the transmission of a single character already takes more than 1 ms.
- When longer calculations (for example, emulated arithmetic operations!) are to be carried out or character strings are to be processed, this often takes too long within a cyclic task and thus blocks the time-bound processes.
This means that there still needs to be a way to delegate such processes to some type of interruptible task. A combination of two methods is used for this:
- Using interrupts instead of active waiting: This allows the waiting for the end of an operation (e.g., the transmission of a character) to be “delegated” to the hardware; this method is used for interrupt-controlled operations. This solves the problems for a single character transmission or for writing a single value to the EEPROM, but not waiting for the end of the overall operation (e.g., transmitting an entire text).
- Implementation of background tasks: A background task runs only during those times that are not occupied by cyclic tasks. In addition, it can be interrupted at any time, so it does not interfere with the timely execution of the cyclic tasks.
However, once a background task is running, it cannot be interrupted by other background tasks. Thus, only one background task is processed at a time, and if it has to wait, the entire processing of background tasks waits. Although this slows down the processing of the background tasks, it means that only one stack area needs to be reserved for all background tasks.
Background tasks are characterized by the following properties:
- A background task can be interrupted at any time in favor of cyclic tasks, but not in favor of another background task.
- The execution of a background task is triggered by a start call to the dispatcher.
- Background tasks are executed one after the other in the order they were started.
- Background tasks can wait for events (WAIT_EVENT), which are triggered, for example, by interrupt-controlled processes.
- Background tasks can also wait for predefined times (DELAY).
- Each background task can be given a start message of 3 16-bit words, which can be used to specify its task (a 4th word is reserved for the task’s start address).
- Any number of background task routines can exist within the user program; however, a maximum of 8 can be started simultaneously.
The coordination of the tasks among each other (“When is which task allowed to run?”) is handled by what is called the dispatcher. It executes all “administrative processes”, such as starting tasks, backup/restore of the processor registers, or disabling/enabling interrupts.
Exceptions
Since microcontrollers usually do not have text-oriented peripherals, debugging is very difficult, especially for time-dependent functions, since breakpoints or the like completely disrupt the timing behavior. Therefore, the operating system kernel provides a simplified mechanism for exception handling, which is divided into two stages:
- A global try-catch area catches all exceptions (exceptions/errors) occurring in the kernel and in the arithmetic emulations. The exception-specific data can be stored in the EEPROM and/or output via USART; after that, the operating system performs a total system RESET (including user-reset). This exception area is always active.
- In addition, an application-oriented try-catch area can be used, which only covers the actual user program. The handling of such exceptions is initially the same as above: The exception data is stored and/or output via USART; then an “application restart” routine to be specified by the user is executed (subroutine user_restart).
Interrupt Handling
Interrupts are handled in four different ways:
- The reset interrupt is used by the operating system and is not directly accessible by the user. However, since the user also needs this interrupt to initialize their own processes, the operating system calls the subroutine user_init after its own initialization, which the user can fill with their application-specific initialization code.
- Timer/counter0 is used for the generation of the basic clock for all cyclic processes; it is therefore not accessible by the user.
- For the use of the EEPROM and the USART, the operating system offers ready-made driver blocks, which can be integrated during the generation of the operating system (see below). However, the user can also couple their own service routines to these interrupts or simply leave them open when not in use.
- All other interrupts are directly available to the user. For each interrupt, an interrupt service routine as well as an interrupt initialization routine must be specified; if more than one interrupt belongs to a device (e.g., timers or USART), a shared initialization routine is sufficient. For this purpose, the user activates the corresponding parameters in the generation file and inserts the contents of the relevant initialization and service routines in his user program.
- Unused interrupts are automatically “intercepted” by the operating system.
Programming Environment
For efficiency reasons, Metronom is written in AVR assembler (Atmel/Microchip) and thus assumes that user programs are also written in assembler; an interface to C was not implemented. Instead, however, there is a library with many subroutines, e.g., for 8-bit arithmetic as well as 16-bit arithmetic (4 basic arithmetic operations); a 16-bit fractional library is in preparation.
To facilitate programming work, all operating system calls are available as macros. To avoid naming collisions, the following naming convention applies: All variables and jump targets within the operating system and libraries start with an underscore (“_”) — therefore, all names in the user program should start with letters only. Characters other than letters, numbers and the underscore are not allowed.
The overall structure of Metronom and the corresponding user program can be seen in Figure 2.
Operating System Calls
For the complete list of operating system calls, please refer to the references at the end of the article; here is just a rough overview:
Macros for exception handling
- KKTHROW throws a system-wide exception, i.e., after saving/outputting the exception information, the whole system is restarted.
- KTHROW throws an exception limited to the user program, i.e., after saving/outputting the exception information, only the user subroutine user_restart is executed; afterwards the cyclic tasks are restarted.
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.
Macros for 8-bit and 16-bit arithmetic
Generally, for arithmetic operations of all kinds, the registers r25:r24 are used as accumulator and r23:r22 as memory for the second operand (if needed). For this purpose, there are more than 20 different functions, such as _mul8u8 for an 8×8-bit multiplication or _abs16 for a 16-bit absolute value. Furthermore, there are many load and save pseudo-codes such as _ld16 (load 16-bit number into accumulator).
Macros for EEPROM use
- _KWRITE_TO_EEPROM for writing to the EEPROM
- _KREAD_FROM_EEPROM for reading from the EEPROM
Macros for USART use
- _KWRITE_TO_LCD is a specific USART driver, which adds the necessary control characters for a 2×16 LCD display to the text to be displayed.
- _KREAD_FROM_USART (not implemented yet).
System Generator SysGen
For generating a system (i.e., the complete code), a dedicated system generator SysGen is used, which is also part of the overall package. SysGen is not limited to Metronom, but can also be used for general generation tasks.
Some readers may wonder why a separate system generator was developed, considering that there is a wide variety of preprocessors and macro generators. But for the generation of the Metronom operating system, the functionalities of the preprocessors in Atmel Studio as well as standard C are not sufficient. In particular, since the preprocessor does not support string “arithmetic”, it is not possible to specify a “default directory” or “library directory” and select files contained in it from there. A search on Stack Overflow showed that other people have the same problem as I, but none of today’s preprocessors can handle it.
The preprocessor of Atmel Studio (as well as the GNU preprocessor) offers the following functions for assembling the required files:
- define / set =
- if … elif … else … endif, also nested
- ifdef, ifndef
- include | exit
The following functionalities are missing:
- can only be passed as a fixed string, but a string expression of (any number of) partial strings, both string variables and string constants, would be necessary.
- define and set can only assign numeric values, no strings, no concatenation of strings, and also no logical expressions.
- For the (one-time) inclusion of library programs, the macro possibilities given in AVRASM or the C preprocessor are not sufficient; since macros in AVRASM cannot contain include statements, the automatic inclusion of emulation routines, for example, is not possible.
This leads to the following scope of functions:
- define / set = | |
- if … elif … else … endif, also nested
- ifdef, ifndef is converted into if isdef(..) or ! isdef(..) and can thus also be used within Boolean expressions.
- include | exit
- message | error
- code (to create lines of code)
- macro/endmacro with suitable parameter labeling
- An additional requirement is that instructions of existing preprocessors can be mixed with those of SysGen without interfering with each other.
The SysGen program can also be downloaded from the specified web page. SysGen is written in Java (version 12) and requires a corresponding Java installation to run.
Programming with Metronom
To save the user the tedious task of working through the operating system source code, the entire operating system is structured in a way to get automatically generated. This means that the user only has to fill in the definition file and — if necessary — the interrupt routines programmed by the user; where they belong and how they are connected is taken care of automatically by the generation process.
In its basic form, a user system is composed of the parts shown in Figure 3.
The interrupt table and the kernel are always incorporated as a whole into the resulting overall program. In case of the device handlers and the libraries, on the other hand, only the parts that are actually needed are incorporated. To illustrate the generation process, the generation script of one of my own projects can be seen in Listing 1.
Listing 1
; *********************************************************
; 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
This article appears in Elektor Mag 03/04 2023. Translated to English by Jörg Starkmuth
Questions or Comments About the Metronom?
Do you have technical questions or comments about this article or the Metronom? E-mail the author at profos@rspd.ch or contact Elektor at editor@elektor.com.