Using a Task Based Dispatcher

Ron Kreymborg

This series of articles deals with task scheduling systems for small embedded processor equipment. The examples use the Atmel AVR range of processors but the code is written in gcc C and could easily be ported to other hardware. The schedulers presented here are variations of the complete TaskR2 package shown elsewhere. Note that task scheduling does not imply a pre-emptive operating system, but a system for managing multiple tasks in a controlled way.

The version shown here is for an event based system. A similar system is Tiny. Event based means a new task is started or restarted by an event, where an event occurrence is a processor interrupt. Thus an event such as a clock tick or level change or a/d conversion complete, etc will require some post processing such as updating a counter or changing some output state, or something more complex such as converting a number of timer readings to kilometers per hour or perhaps a quite complex set of calculations that define a specific response to the event.

A task dispatcher is basically a first in first out queue, with functions to put tasks on the queue and other functions to take tasks off the queue and start them running. The mechanism is arranged so that the main program, executing in a loop, will continuely call a despatch function whose only job is to examine the queue for ready tasks and when they exist, start them running. Interrupt routines add tasks to the tail of the queue, a simple function that takes a minimum of time.

Thus when an interrupt occurs, the relevant interrupt executes, calls the queue-task function to add its associated task to the tail of the ready queue, and then it exits. The next cycle through the main program loop that calls the despatch function will now find a new task on the queue. This task is taken off the head of the queue, made the current task, and started running. In a significant difference from a pre-emptive system, this task will run to conclusion. Other interrupts may occur while this task is running, but their only effect is to put their associated tasks on the ready queue. Of course, where speed is of the essence, simple events that require little more processing than to change an output state etc can be included within the interrupt routine. But in general tasks are run via the ready queue, and responses that embody significant processing are always run as queued tasks. The main plus of this system is that a running task has complete ownership of the cpu, and need not worry about contention issues with other tasks such as occur in pre-emptive systems.

In this task based paradigm, interrupt events are the only instigators of processing, and the cpu time typically involved in most tasks is minimal. Even when a task does require considerable processing that could take many milliseconds to complete, as long as this does not mean other tasks could be compromised, the system will execute as expected. Many embedded systems fit this model perfectly. We will see later how adding a delay queue allows events to run at specific times or after specific delays.

The examples that follow all do the same thing but highlight the different ways tasks can be run. The demonstration examples show the two general task types, a state change task that is over very quickly, and a compute bound task that takes up considerable processing time. Here both example events are instigated by clock interrupts, but any interrupt would do. Both tasks indicate their state using leds on the STK200/300 development boards as well as state changes on the AVR micro port A that can be monitored using a 'scope. The important thing to note is how tasks are scheduled from within an interrupt routine, and the possible ramifications of tasks interfering with each other.

The first type or simple state change task is:

static void Task_ManageEvent(void)
{
   if (LED_State)
   {
      cbi(PORTA, 5);          // for scoping
      sbi(PORTB, 6);          // the state change
   }
   else
   {
      sbi(PORTA, 5);          // for scoping
      cbi(PORTB, 6);          // the state change
   }
   
   LED_State = ~LED_State;    // flip state
} 

The LED_State variable is a static local global BYTE (unsigned char) that will consequently be initialized to zero during startup. When the task executes it examines this variable and flips both the led and variable to the opposite state then exits. It therefore runs for a very short time and drives the led with a 50% duty cycle. Bit 5 of port A can be used for scoping and led 6 will be on for one half of the cycle. An interrupt routine that could schedule the above event in response to a Timer0 overflow interrupt would be:

SIGNAL(SIG_OVERFLOW0)
{
   outp(CLK0_COUNT, TCNT0);      // reset counter
   sbi(PORTA, 1);                // for scoping
   QueTask(Task_ManageEvent);    // schedule the task
   cbi(PORTA, 1);                // for scoping
} 

Bit 1 of port A can be used for scoping to see how long the QueTask function takes and as a trigger for scoping subsequent task execution times.

The second type or compute bound task is:

void Task_ComputeBoundTask(void)
{
   int i, j;
   
   sbi(PORTA, 3);                // for scoping
   cbi(PORTB, 7);                // the event start
   for (i=0; i<5000; i++)
      j = 1;
   sbi(PORTB, 7);                // the event stop
   cbi(PORTA, 3);                // for scoping
} 

As you can see, the "computing" is simply a loop. This makes it easy to lengthen or shorten the time the event takes to watch various effects. Bit 3 of port A can be used for scoping the extent of the event, and led 7 will light while the task is running.

For the most part you can add tasks as required. Simply add the interrupt handler that schedules the task and write the task execution code. You will need to ensure that space reserved for the ready queue is adequate and how you do this will be explained later.


On to Example 1