About Task Management

The design process of a real-time application generally involves splitting the work to be completed into tasks, each responsible for a portion of the problem. µC/OS-III makes it easy for an application programmer to adopt this paradigm. A task (also called a thread) is a simple program that thinks it has the Central Processing Unit (CPU) all to itself. On a single CPU, only one task can execute at any given time. 

µC/OS-III supports multitasking and allows the application to have any number of tasks. The maximum number of task is actually only limited by the amount of memory (both code and data space) available to the processor. Multitasking is the process of scheduling and switching the CPU between several tasks (this will be expanded upon later). The CPU switches its attention between several sequential tasks. Multitasking provides the illusion of having multiple CPUs and, actually maximizes the use of the CPU. Multitasking also helps in the creation of modular applications. One of the most important aspects of multitasking is that it allows the application programmer to manage the complexity inherent in real-time applications. Application programs are typically easier to design and maintain when multitasking is used.

Tasks are used for such chores as monitoring inputs, updating outputs, performing computations, control loops, update one or more displays, reading buttons and keyboards, communicating with other systems, and more. One application may contain a handful of tasks while another application may require hundreds. The number of tasks does not establish how good or effective a design may be, it really depends on what the application (or product) needs to do. The amount of work a task performs also depends on the application. One task may have a few microseconds worth of work to perform while another task may require tens of milliseconds.

Tasks look like just any other C function, with a few small differences. There are two types of tasks: run-to-completion (the first listing below) and infinite loop (the second listing below). In most embedded systems, tasks typically take the form of an infinite loop. Also, no task is allowed to return as other C functions can. Given that a task is a regular C function, it can declare local variables.

When a µC/OS-III task begins executing, it is passed an argument, p_arg. This argument is a pointer to a void. The pointer is a universal vehicle used to pass your task the address of a variable, a structure, or even the address of a function, if necessary. With this pointer, it is possible to create many identical tasks, that all use the same code (or task body), but, with different run-time characteristics. For example, you may have four asynchronous serial ports that are each managed by their own task. However, the task code is actually identical. Instead of copying the code four times, you can create the code for a “generic” task that receives a pointer to a data structure, which contains the serial port’s parameters (baud rate, I/O port addresses, interrupt vector number, etc.) as an argument. In other words, you can instantiate the same task code four times and pass it different data for each serial port that each instance will manage.

A run-to-completion task must delete itself by calling OSTaskDel(). The task starts, performs its function, and terminates. There would typically not be too many such tasks in the embedded system because of the overhead associated with “creating” and “deleting” tasks at run-time. In the task body, you can call most of µC/OS-III’s functions to help perform the desired operation of the task.

Listing - Run-To-Completion task
void MyTask (void *p_arg) 
{
    OS_ERR err;
    /* Local variables                    */


 
    /* Do something with 'p_arg'          */ 
    /* Task initialization                */ 
    /* Task body ... do work!             */ 
    OSTaskDel((OS_TCB *)0, &err);
}


With µC/OS-III, you either can call a C or assembly language functions from a task. In fact, it is possible to call the same C function from different tasks as long as the functions are reentrant. A reentrant function is a function that does not use static or otherwise global variables unless they are protected (µC/OS-III provides mechanisms for this) from multiple access. If shared C functions only use local variables, they are generally reentrant (assuming that the compiler generates reentrant code). An example of a non-reentrant function is the famous strtok() provided by most C compilers as part of the standard library. This function is used to parse an ASCII string for “tokens.” The first time you call this function, you specify the ASCII string to parse and a list of token delimiters. As soon as the function finds the first token, it returns. The function “remembers” where it was last so when called again, it can extract additional tokens, which is clearly non-reentrant.

The use of an infinite loop is more common in embedded systems because of the repetitive work needed in such systems (reading inputs, updating displays, performing control operations, etc.). This is one aspect that makes a task different than a regular C function. Note that one could use a “while (1)” or “for (;;)” to implement the infinite loop, since both behave the same. The one used is simply a matter of personal preference. At Micrium, we like to use “while (DEF_ON)”. The infinite loop must call a µC/OS-III service (i.e., function) that will cause the task to wait for an event to occur. It is important that each task wait for an event to occur, otherwise the task would be a true infinite loop and there would be no easy way for other lower priority tasks to execute. This concept will become clear as more is understood regarding µC/OS-III.

Listing - Infinite Loop task
void MyTask (void *p_arg)
{
    /* Local variables                                                 */

    /* Do something with "p_arg"                                       */
    /* Task initialization                                             */
    while (DEF_ON) {      /* Task body, as an infinite loop.           */
        :
        /* Task body ... do work!                                      */
        :
        /* Must call one of the following services:                    */
        /*    OSFlagPend()                                             */
        /*    OSMutexPend()                                            */
        /*    OSQPend()                                                */
        /*    OSSemPend()                                              */
        /*    OSTimeDly()                                              */
        /*    OSTimeDlyHMSM()                                          */
        /*    OSTaskQPend()                                            */
        /*    OSTaskSemPend()                                          */
        /*    OSTaskSuspend()     (Suspend self)                       */
        /*    OSTaskDel()         (Delete  self)                       */
        :
        /* Task body ... do work!                                      */
        :
    }
}


The event the task is waiting for may simply be the passage of time (when OSTimeDly() or OSTimeDlyHMSM() is called). For example, a design may need to scan a keyboard every 100 milliseconds. In this case, you would simply delay the task for 100 milliseconds then see if a key was pressed on the keyboard and, possibly perform some action based on which key was pressed. Typically, however, a keyboard scanning task should just buffer an “identifier” unique to the key pressed and use another task to decide what to do with the key(s) pressed.

Similarly, the event the task is waiting for could be the arrival of a packet from an Ethernet controller. In this case, the task would call one of the OS???Pend() calls (pend is synonymous with wait). The task will have nothing to do until the packet is received. Once the packet is received, the task processes the contents of the packet, and possibly moves the packet along a network stack.

It’s important to note that when a task waits for an event, it does not consume CPU time.

Tasks must be created in order for µC/OS-III to know about tasks. You create a task by simply calling OSTaskCreate() as we’ve seen in the Getting Started section. The function prototype for OSTaskCreate() is shown below:

Listing - OSTaskCreate()
void OSTaskCreate  (OS_TCB         *p_tcb,
                    OS_CHAR        *p_name,
                    OS_TASK_PTR     p_task,
                    void           *p_arg,
                    OS_PRIO         prio,
                    CPU_STK        *p_stk_base,
                    CPU_STK_SIZE    stk_limit,
                    CPU_STK_SIZE    stk_size,
                    OS_MSG_QTY      q_size,
                    OS_TICK         time_slice,
                    void           *p_ext,
                    OS_OPT          opt,
                    OS_ERR         *p_err)


A complete description of OSTaskCreate() and its arguments is provided in uC-OS-III API Reference. However, it is important to understand that a task needs to be assigned a Task Control Block (i.e., TCB), a stack, a priority and a few other parameters which are initialized by OSTaskCreate(), as shown in the figure below.


Figure - OSTaskCreate() initializes the task’s TCB and stack

(1) When calling OSTaskCreate(), you pass the base address of the stack (p_stk_base) that will be used by the task, the watermark limit for stack growth (stk_limit) which is expressed in number of CPU_STK entries before the stack is empty, and the size of that stack (stk_size), also in number of CPU_STK elements.

(2) When specifying OS_OPT_TASK_STK_CHK + OS_OPT_TASK_STK_CLR in the opt argument of OSTaskCreate(), µC/OS-III initializes the task’s stack with all zeros.

(3) µC/OS-III then initializes the top of the task’s stack with a copy of the CPU registers in the same stacking order as if they were all saved at the beginning of an ISR. This makes it easy to perform context switches as we will see when discussing the context switching process. For illustration purposes, the assumption is that the stack grows from high memory to low memory, but the same concept applies for CPUs that use the stack in the reverse order.

(4) The new value of the stack pointer (SP) is saved in the TCB. Note that this is also called the top-of-stack.

(5) The remaining fields of the TCB are initialized: task priority, task name, task state, internal message queue, internal semaphore, and many others.


Next, a call is made to a function that is defined in the CPU port, OSTaskCreateHook() (see os_cpu_c.c). OSTaskCreateHook() is passed the pointer to the new TCB and this function allows you (or the port designer) to extend the functionality of OSTaskCreate(). For example, one could printout the contents of the fields of the newly created TCB onto a terminal for debugging purposes.

The task is then placed in the ready-list (see The Ready List) and finally, if multitasking has started, µC/OS-III will invoke the scheduler to see if the created task is now the highest priority task and, if so, will context switch to this new task.

The body of the task can invoke other services provided by µC/OS-III. Specifically, a task can create another task (i.e., call OSTaskCreate()), suspend and resume other tasks (i.e., call OSTaskSuspend() and OSTaskResume() respectively), post signals or messages to other tasks (i.e., call OS??Post()), share resources with other tasks, and more. In other words, tasks are not limited to only make “wait for an event” function calls.

The figure below shows the resources with which a task typically interacts.

Figure - Tasks interact with resources

(1) An important aspect of a task is its code. As previously mentioned, the code looks like any other C function, except that it is typically implemented as an infinite loop and, a task is not allowed to return.

(2) Each task is assigned a priority based on its importance in the application. µC/OS-III’s job is to decide which task will run on the CPU. The general rule is that µC/OS-III will run the most important ready-to-run task (highest priority).

With µC/OS-III, a low priority number indicates a high priority. In other words, a task at priority 1 is more important than a task at priority 10.

µC/OS-III supports a compile-time user configurable number of different priorities (see OS_PRIO_MAX in os_cfg.h). Thus, µC/OS-III allows the user to determine the number of different priority levels the application is allowed to use. Also, µC/OS-III supports an unlimited number of tasks at the same priority. For example, µC/OS-III can be configured to have 64 different priority levels and one can assign dozens of tasks at each priority level.

See Assigning Task Priorities.

(3) A task has its own set of CPU registers. As far as a task is concerned, the task thinks it actually has the CPU all to itself.

(4) Because µC/OS-III is a preemptive kernel, each task must have its own stack area. The stack always resides in RAM and is used to keep track of local variables, function calls, and possibly ISR (Interrupt Service Routine) nesting.

Stack space can be allocated either statically (at compile-time) or dynamically (at run-time). A static stack declaration is shown below. This declaration is made outside of a function.

static CPU_STK MyTaskStk[???];

or,

CPU_STK MyTaskStk[???];

Note that “???” indicates that the size of the stack (and thus the array) depends on the task stack requirements. Stack space may be allocated dynamically by using the C compiler’s heap management function (i.e., malloc()) as shown below. However, care must be taken with fragmentation. If creating and deleting tasks, the process of allocating memory might not be able to provide a stack for the task(s) because the heap will eventually become fragmented. For this reason, allocating stack space dynamically in an embedded system is typically allowed but, once allocated, stacks should not be deallocated. Said another way, it’s fine to create a task’s stack from the heap as long as you don’t free the stack space back to the heap.

Listing - Stack size
void SomeCode (void) 
{
    CPU_STK *p_stk;
    :
    :
    p_stk = (CPU_STK *)malloc(stk_size); if (p_stk != (CPU_STK *)0) {
        Create the task and pass it "p_stk" as the base address of the stack; }
    :
    : 
} 

See Determining the Size of a Stack.

(5) A task can also have access to global variables. However, because µC/OS-III is a preemptive kernel care must be taken with code when accessing such variables as they may be shared between multiple tasks. Fortunately, µC/OS-III provides mechanisms to help with the management of such shared resources (semaphores, mutexes and more).

(6) A task may also have access to one or more Input/Output (I/O) devices (also known as peripherals). In fact, it is common practice to assign tasks to manage I/O devices.