To read original PDF of the print article, click here.
Get by Without an RTOS
Too many simple systems use a commercial RTOS. All that's sometimes needed is a way to implement even-driven and periodic functions. Here's an easy way to do just that.
It's a fact of life that many embedded systems survive perfectly well without a multitasking real-time operating system (RTOS). I have always wondered if I could find a single criterion that would tell me if it would be an advantage or a liability to include an RTOS for a particular project.
How hard is it to guess if an RTOS is hidden inside an embedded box simply by looking at it, using it, and reading the owner's manuals? Do you think your VCR has an RTOS hidden inside? What about your car? And your mobile phone? (Maybe, just as some car models proudly state their engine size and the number of valves on the back, an embedded system should clearly state “DRIVEN BY XOS. Number of tasks: 24. RAM size: 2MB.”)
Chances are your guess will be wrong. A really complicated, large system may use an infinite loop plus a couple of hard working interrupts. On the other hand, a smaller system with less than 20,000 lines of C code may have a full-blown commercial RTOS. Let's consider two extreme cases. First, consider a primitive software-driven refrigerator. Our refrigerator software simply monitors and controls the temperature and handles the door-open switch and the lamps. Even a hard-core advocate of operating systems would hardly insist on procuring a suitable RTOS.
At the other end of the scale, consider something much more complex (and not normally viewed as an embedded system), something probably sitting in a close proximity to you right now. A personal computer.
I guess only a few really brave individuals would proclaim that a proper multitasking operating system on the desktop is an unnecessary luxury. But wait! You only need to look back a decade or so. Back then, the world's PCs weren't driven by multitasking operating systems. In fact, DOS simply provided some software interfaces to the hardware, some memory management functions, and a few other bits and pieces.
Somewhere in the middle, between a fridge and a desktop PC is a fine line between a “yes we need one, let's get the sales rep” and “no, let's just get a few guys and start coding.”
Our fridge can become more complex because marketing now wants a keypad so the user may manually enter defrost schedules. Suddenly, we need to provide a real-time clock, a display, and a keypad. Since we now have a display, it might be wise to provide some cool graphics to entertain the users as they fetch beers from the fridge. Marketing also wants to make the software compatible with the next generation of voice-activated fridges.
For small to medium embedded products, there is no simple answer to the “yes” or “no” question. The following drawbacks should be considered if you choose to include an RTOS:
On the other hand, if you decide to take a short cut and not use an RTOS in a system where you really need one, things will be much worse. You will find that your software is getting more and more clumsy, the system keeps falling over and hangs in the most unexpected places, until finally you call it a day and start again from the scratch, using a few pieces of code you can salvage from the mess.
To find the right balance, it's important to realize that a system without an RTOS can exhibit multitasking behavior. It seems to me that often the main reason behind a decision to port an RTOS where it is unnecessary is the lack of understanding that by using some simple means and some not-so-complex code, an efficient, fast, and reasonably balanced system can be built. Moreover, should an RTOS be required some time in the future, this is no longer a painful exercise, as the system can be engineered as self-contained tasks, even in the absence of a “true” RTOS. In the following sections, I will describe such a system.
Main control loop
In addition, as our system is not pre-emptive (tasks cannot be interrupted by another task ) we have the luxury of not worrying about protecting our data with semaphores/mutexes. All of our tasks relinquish control only when the entry function of the task returns.As an example, let's consider an embedded system with a keypad, an LCD, and an RS-232 port that runs some comms. The system also has some I/O and a parallel printer. Each change of state of an input or output results in an RS-232 message sent out, a printout, and an LCD update. Received RS-232 messages can result in printouts, LCD updates, and output status updates. We may have to start a flash pattern on a particular lamp as a result of:
Let's have a quick look at our main control loop in Listing 1. Nothing new or original here. Three things are immediately obvious:
So what happens inside the functions called from our infinite loop? The majority of tasks in our system are event-driven tasks. They do not execute until a suitable message is received. Each of these tasks has a dedicated input event queue. For example, IO_ProcessOutputs is an event-driven task. It handles the state of outputs and does nothing if there are no state changes to be performed on the outputs. However, if an output needs to be turned on, an event message is sent to this task. In our system, three tasks will send event messages to the IO_ProcessOutputs:
The handling of the events inside IO_ProcessOutputs is exactly the same, no matter which of the three tasks has sent the event. (The event-driven task structure is described in the next section.)Other tasks in our system are periodic. They run without a trigger event. Some need to run faster, others slower. For example, we may need to scan the inputs at a much faster rate than the LCD update. How do we achieve different execution speeds if the functions are called from the same control loop?
I have also promised to provide some simple means of inter-task communications. For example, we may want to stop scanning the inputs after a particular keypad entry and restart the scanning after another entry. This would require a call from a keypad scanner to stop the I/O scanner task. We may also want to slow down the execution of some tasks depending on the circumstances. Let's say we detect an avalanche of input state changes, and our RS-232 link can no longer cope with sending all these messages. As a solution, we would like to slow down the I/O scanner task from the RS-232 sending task. All this is achieved using the proven and reliable, if extremely unoriginal, technique of execution counters, and is described in the Periodic Tasks section.
In addition to all these features, we need to perform a variety of small but important duties. For example, we want to dim the LCD exactly one minute after the very last key was pressed. We also want to flash a cursor on the LCD at a periodic, fixed and exact frequency. Since dedicating a separate task to each of these functions is definitely overkill, we handle them through software timers. Rather than being explicitly called from the main control loop, the function to turn the cursor on/off is called indirectly from TMR_Process task, which is the only non-user-defined task in the main control loop.
Figure 1: Event-driven task: data/event flow diagram
Please note that the EVENT_TYPE structure is unique for each task. In other words, the task itself determines the format of events it expects to receive. For example, in the IO_ProcessRequests task, we would want to include the number and the new state of the output. In the printer task we could simply construct the PRN_EVENT_TYPE as a buffer large enough to contain a single null-terminated string. Since EVENT_TYPE is likely to be different for each task, the user will have to define a unique structure, based on INPUT_EVENT_QUEUE_TYPE, for each event-driven task. Moreover, each task will have it's own GetEvent, PutEvent, and an initialization function.
A simple ring buffer structure allows us to achieve asynchronous reads and writes to the buffer, and to store up to BUFFER_SIZE entries. Any other task, or the task itself, can inject events of EVENT_TYPE into the input ring buffer. The events will be inserted at the position of OutPtr, which will grow until the “owner” task executes and reads events from the buffer. When the task extracts events, the position of InPtr is adjusted. When OutPtr and InPtr are equal, the buffer contains no unprocessed events. The “Count” member contains the number of unprocessed events in the buffer.Listing 3 shows the implementation of InitEventBuffer, GetEvent, and PutEvent. It's quite simple to imagine how other tasks would activate the outputs. All they need to do is to construct an event of OUTPUT_EVENT_TYPE and call OUTPUT_PutEvent, as shown in Listing 4.
With all other tasks happily using the OUTPUT_PutEvent function, the only thing we need to do is implement our output controller task. This is straightforward, as shown in Listing 5.
Note that by simply changing “if” to “while” in the function above we can significantly change the behavior of the task. Instead of processing one event per iteration of the task, it will process all the events in the buffer. Another valid idea would be to implement a mixture of two methods, where a task extracts a maximum of X events at a time. Even better if X can be changed by other tasks. Changes to Listing 5 to implement these ideas are straightforward, and I leave them to the reader.
In regards to the first problem, a mechanism is available to help slow down the tasks. This can be done in terms relative to the main control loop, or in absolute terms (we will call it exact timing). In both cases, we need two variables per task. One is called an execution counter and the other reload value. The execution counter counts down from reload value. When it reaches zero, the task is called, otherwise the entry function to the task exits without further processing. The reason for using a variable rather than a constant for reload value is that this helps us to dynamically manipulate the execution speed of the task. See Listing 6.Some points about the code:
The periodic tasks must be written in a manner that will guarantee that they return in a reasonable time. This is not always easy. For example, consider a background memory checking task. If the amount of memory to check is large, we will not want to do the whole check in one iteration, as it may take too long. A useful approach to such tasks is to build them as finite state machines, with each iteration taking it to the next state. The processing in each state is limited to the longest permissible time-slice. Sample code for memory checking function is shown in Listing 7.
Figure 2: State-driven LCD update task-the state transition diagram
This piece of code will execute 100 times to check 10,000 bytes of RAM. We control how often the memory check is done using the usual execution counter and reload value technique. Another variation of the same idea is shown in Listing 8. The task is doing nothing until somebody sets the WinTaskState to any non-idle state. Up to four following iterations will then update relevant portions of information in the window. For example, to re-paint the entire window, we would set WinTaskState to WIN_TASK_CONST, which will update all the constant information, all variable information, and all graphics and animations in the window. After this update the window update task will become idle again until the next trigger via a WinTaskState. Conveniently, if we only want to update the animations, we can set the WinTaskState to WIN_TASK_ANIMATIONS (in this case, we will not re-paint the constants, variables, and static graphics on the screen). Figure 2 shows the LCD update task's state transition diagram.
Do we really want to dedicate periodic tasks to these little, yet important, functions? Most of these will also require exact timing, making our 10ms interrupt work very hard. Our main control loop will become long and messy. So we must find a neater solution.You may have noticed a call to TMR_Process in the main control loop. This is the only non-user-defined task in our system. In our implementation, the TMR_Process task itself is an event-driven task, and works exactly as the user-defined event-driven tasks described in this article.
Internally, the timer task could use something like the structures in Listing 9 to define the timer task input event queue. We use the Parameter field to add some flexibility to our module. The application may choose to use it for a variety of purposes, or simply ignore it.