In previous articles, we have looked at the multi-tasking model and we have seen that each task is a quasi-independent program. Although tasks in an embedded application have a degree of independence, it does not mean that they have no “awareness” of one another. Although some tasks will be truly isolated from others, the requirement for communication and synchronization between tasks is very common. This represents a key part of the functionality provided by an RTOS. The actual range of options offered by a different RTOSes may vary quite widely – as will some of the terminology – so the best we can do in this article is review the commonly available facilities.
A Range of Options
There are three broad paradigms for inter-task communications and synchronization:
Task-owned facilities – attributes that an RTOS imparts to tasks that provide communication (input) facilities. The example we will look at some more is signals.
Kernel objects – facilities provided by the RTOS which represent stand-alone communication or synchronization facilities. Examples include: event flags, mailboxes, queues/pipes, semaphores and mutexes.
Message passing – a rationalized scheme where an RTOS allows the creation of message objects, which may be sent from one to task to another or to several others. This is fundamental to the kernel design and leads to the description of such a product as being a “message passing RTOS”.
The facilities that are ideal for each application will vary. There is also some overlap in their capabilities and some thought about scalability is worthwhile. For example, if an application needs several queues, but just a single mailbox, it may be more efficient to realize the mailbox with a single-entry queue. This object will be slightly non-optimal, but all the mailbox handling code will not be included in the application and, hence, scalability will reduce the RTOS memory footprint.
Shared Variables or Memory Areas
A simplistic approach to inter-task communication is to just have variables or memory areas which are accessible to all the tasks concerned. Whilst it is very primitive, this approach may be applicable to some applications. There is a need to control access. If the variable is simply a byte, then a write or a read to it will probably be an “atomic” (i.e. uninterruptible) operation, but care is needed if the processor allows other operations on bytes of memory, as they may be interruptible and a timing problem could result. One way to effect a lock/unlock is simply to disable interrupts for a short time.
If you are using a memory area, of course you still need locking. Using the first byte as a locking flag is a possibility, assuming that the memory architecture facilitates atomic access to this byte. One task loads data into the memory area, sets the flag and then waits for it to clear. The other task waits for the flag to be set, reads the data and clears the flag. Using interrupt disable as a lock is less wise, as moving the whole buffer of data may take time.
This type of shared memory usage is similar in style to the way many inter-processor communication facilities are implemented in multicore systems. In some cases, a hardware lock and/or an interrupt are incorporated into the inter-processor shared memory interface.
Signals are probably the simplest inter-task communication facility offered in conventional RTOSes. They consist of a set of bit flags – there may be 8, 16 or 32, depending on the specific implementation – which is associated with a specific task.
A signal flag (or several flags) may be set by any task using an OR type of operation. Only the task that owns the signals can read them. The reading process is generally destructive – i.e. the flags are also cleared.
In some systems, signals are implemented in a more sophisticated way such that a special function – nominated by the signal owning task – is automatically executed when any signal flags are set. This removes the necessity for the task to monitor the flags itself. This is somewhat analogous to an interrupt service routine.
There will be more information about signals in a future article, which describes their implementation in Nucleus SE.
Event Flag Groups
Event flag groups are like signals in that they are a bit-oriented inter-task communication facility. They may similarly be implemented in groups of 8, 16 or 32 bits. They differ from signals in being independent kernel objects; they do not “belong” to any specific task.
Any task may set and clear event flags using OR and AND operations. Likewise, any task may interrogate event flags using the same kind of operation. In many RTOSes, it is possible to make a blocking API call on an event flag combination; this means that a task may be suspended until a specific combination of event flags has been set. There may also be a “consume” option available, when interrogating event flags, such that all read flags are cleared.
There is more information about event flag groups in a future article, which describes their implementation in Nucleus SE.
Semaphores are independent kernel objects, which provide a flagging mechanism that is generally used to control access to a resource. There are broadly two types: binary semaphores (that just have two states) and counting semaphores (that have an arbitrary number of states). Some processors support (atomic) instructions that facilitate the easy implementation of binary semaphores. Binary semaphores may also be viewed as counting semaphores with a count limit of 1.
Any task may attempt to obtain a semaphore in order to gain access to a resource. If the current semaphore value is greater than 0, the obtain will be successful, which decrements the semaphore value. In many OSes, it is possible to make a blocking call to obtain a semaphore; this means that a task may be suspended until the semaphore is released by another task. Any task may release a semaphore, which increments its value.
There is more information about semaphores in a future article, which describes their implementation in Nucleus SE.
Mailboxes are independent kernel objects, which provide a means for tasks to transfer messages. The message size depends on the implementation, but will normally be fixed. One to four pointer-sized items are typical message sizes. Commonly, a pointer to some more complex data is sent via a mailbox. Some kernels implement mailboxes so that the data is just stored in a regular variable and the kernel manages access to it. Mailboxes may also be called “exchanges”, though this name is now uncommon.
Any task may send to a mailbox, which is then full. If a task then tries to send to send to a full mailbox, it will receive an error response. In many RTOSes, it is possible to make a blocking call to send to a mailbox; this means that a task may be suspended until the mailbox is read by another task. Any task may read from a mailbox, which renders it empty again. If a task tries read from an empty mailbox, it will receive an error response. In many RTOSes, it is possible to make a blocking call to read from a mailbox; this means that a task may be suspended until the mailbox is filled by another task.
Some RTOSes support a “broadcast” feature. This enables a message to be sent to all the tasks that are currently suspended on reading a specific mailbox.
Certain RTOSes do not support mailboxes at all. The recommendation is to use a single-entry queue (see below) instead. This is functionally equivalent, but carries additional memory and runtime overhead.
There is more information about mailboxes in a future article, which describes their implementation in Nucleus SE.
Queues are independent kernel objects, that provide a means for tasks to transfer messages. They are a little more flexible and complex than mailboxes. The message size depends on the implementation, but will normally be a fixed size and word/pointer oriented.
Any task may send to a queue and this may occur repeatedly until the queue is full, after which time any attempts to send will result in an error. The depth of the queue is generally user specified when it is created or the system is configured. In many RTOSes, it is possible to make a blocking call to send to a queue; this means that, if the queue is full, a task may be suspended until the queue is read by another task. Any task may read from a queue. Messages are read in the same order as they were sent – first in, first out (FIFO). If a task tries to read from an empty queue, it will receive an error response. In many RTOSes, it is possible to make a blocking call to read from a queue; this means that, if the queue is empty, a task may be suspended until a message is sent to the queue by another task.
An RTOS will probably support the facility to send a message to the front of the queue – this is also termed “jamming”. Some RTOSes also support a “broadcast” feature. This enables a message to be sent to all the tasks that are suspended on reading a queue. Additionally, an RTOS may support the sending and reading of messages of variable length; this gives greater flexibility, but carries some extra overhead.
Many RTOSes support another kernel object type called “pipes”. A pipe is essentially identical to a queue, but processes byte-oriented data.
The internal operation of queues is not of interest here, but it should be understood that they have more overheads in memory and runtime than mailboxes. This is primarily because two pointers – to the head and tail of the queue – need to be maintained.
There is more information about queues and pipes in future articles, which describe their implementation in Nucleus SE.
Mutual exclusion semaphores – mutexes – are independent kernel objects, which behave in a very similar way to normal binary semaphores. They are slightly more complex and incorporate the concept of temporary ownership (of the resource, access to which is being controlled). If a task obtains a mutex, only that same task can release it again – the mutex (and, hence, the resource) is temporarily owned by the task.
Mutexes are not provided by all RTOSes, but it is quite straightforward to adapt a regular binary semaphore. It would be necessary to write a “mutex obtain” function, which obtains the semaphore and notes the task identifier. Then a complementary “mutex release” function would check the calling task’s identifier and release the semaphore only if it matches the stored value, otherwise it would return an error.
Colin Walls has over thirty years experience in the electronics industry, largely dedicated to embedded software. A frequent presenter at conferences and seminars and author of numerous technical articles and two books on embedded software, Colin is an embedded software technologist with Mentor Embedded [the Mentor Graphics Embedded Software Division], and is based in the UK. His regular blog is located at: http://blogs.mentor.com/colinwalls. He may be reached by email at firstname.lastname@example.org