The first thing that struck me when I started to learn about the Ada programing language was the tasking support. In Ada, creating tasks, synchronizing them, sharing access to resources, are part of the language
In this blog post I will focus on the embedded side of things. First because it's what I like, and also because it's much more simple :)
For real-time and embedded applications, Ada defines a profile called `Ravenscar`. It's a subset of the language designed to help schedulability analysis, it is also more compatible with platforms such as micro-controllers that have limited resources.
So this will not be a complete lecture on Ada tasking. I might do a follow-up with some more tasking features, if you ask for it in the comments ;)
So the first thing is to create tasks, right?
There are two ways to create tasks in Ada, first you can declare and implement a single task:
-- Task declaration task My_Task;
-- Task implementation task body My_Task is begin -- Do something cool here... end My_Task;
If you have multiple tasks doing the same job or if you are writing a library, you can define a task type:
-- Task type declaration task type My_Task_Type;
-- Task type implementation task body My_Task_Type is begin -- Do something really cool here... end My_Task_Type;
And then create as many tasks of this type as you want:
T1 : My_Task_Type; T2 : My_Task_Type;
One limitation of Ravenscar compared to full Ada, is that the number of tasks has to be known at compile time.
The timing features of Ravenscar are provided by the package (you guessed it) Ada.Real_Time.
In this package you will find:
- a definition of the Time type which represents the time elapsed since the start of the system
- a definition of the Time_Span type which represents a period between two Time values
- a function Clock that returns the current time (monotonic count since the start of the system)
- Various sub-programs to manipulate Time and Time_Span values
The Ada language also provides an instruction to suspend a task until a given point in time: delay until.
Here's an example of how to create a cyclic task using the timing features of Ada.
task body My_Task is Period : constant Time_Span := Milliseconds (100); Next_Release : Time; begin -- Set Initial release time Next_Release := Clock + Period; loop -- Suspend My_Task until the Clock is greater than Next_Release delay until Next_Release; -- Compute the next release time Next_Release := Next_Release + Period; -- Do something really cool at 10Hz... end loop; end My_Task;
Ravenscar has priority-based preemptive scheduling. A priority is assigned to each task and the scheduler will make sure that the highest priority task - among the ready tasks - is executing.
A task can be preempted if another task of higher priority is released, either by an external event (interrupt) or at the expiration of its delay until statement (as seen above).
If two tasks have the same priority, they will be executed in the order they were released (FIFO within priorities).
Task priorities are static, however we will see below that a task can have its priority temporary escalated.
The task priority is an integer value between 1 and 256, higher value means higher priority. It is specified with the Priority aspect:
Task My_Low_Priority_Task with Priority => 1; Task My_High_Priority_Task with Priority => 2;
Mutual exclusion and shared resources
In Ada, mutual exclusion is provided by the protected objects.
At run-time, the protected objects provide the following properties:
- There can be only one task executing a protected operation at a given time (mutual exclusion)
- There can be no deadlock
In the Ravenscar profile, this is achieved with Priority Ceiling Protocol.
A priority is assigned to each protected object, any tasks calling a protected sub-program must have a priority below or equal to the priority of the protected object.
When a task calls a protected sub-program, its priority will be temporarily raised to the priority of the protected object. As a result, this task cannot be preempted by any of the other tasks that potentially use this protected object, and therefore the mutual exclusion is ensured.
The Priority Ceiling Protocol also provides a solution to the classic scheduling problem of priority inversion.
Here is an example of protected object:
-- Specification protected My_Protected_Object with Priority => 3 is procedure Set_Data (Data : Integer); -- Protected procedues can read and/or modifiy the protected data function Data return Integer; -- Protected functions can only read the protected data private -- Protected data are declared in the private part PO_Data : Integer := 0; end;
-- Implementation protected body My_Protected_Object is procedure Set_Data (Data : Interger) is begin PO_Data := Data; end Set_Data; function Data return Integer is begin return PO_Data; end Data; end My_Protected_Object;
Another cool feature of protected objects is the synchronization between tasks.
It is done with a different kind of operation called an entry.
An entry has the same properties as a protected procedure except it will only be executed if a given condition is true. A task calling an entry will be suspended until the condition is true.
This feature can be used to synchronize tasks. Here's an example:
protected My_Protected_Object is procedure Send_Signal; entry Wait_For_Signal; private We_Have_A_Signal : Boolean := False; end My_Protected_Object;
protected body My_Protected_Object is procedure Send_Signal is begin We_Have_A_Signal := True; end Send_Signal; entry Wait_For_Signal when We_Have_A_Signal is begin We_Have_A_Signal := False; end Wait_For_Signal; end My_Protected_Object;
Protected objects are also used for interrupt handling. Private procedures of a protected object can be attached to an interrupt using the Attach_Handler aspect.
protected My_Protected_Object with Interrupt_Priority => 255 is private procedure UART_Interrupt_Handler with Attach_Handler => UART_Interrupt; end My_Protected_Object;
Combined with an entry it provides and elegant way to handle incoming data on a serial port for instance:
protected My_Protected_Object with Interrupt_Priority => 255 is entry Get_Next_Character (C : out Character); private procedure UART_Interrupt_Handler with Attach_Handler => UART_Interrupt; Received_Char : Character := ASCII.NUL; We_Have_A_Char : Boolean := False; end
protected body My_Protected_Object is entry Get_Next_Character (C : out Character) when We_Have_A_Char is begin C := Received_Char; We_Have_A_Char := False; end Get_Next_Character; procedure UART_Interrupt_Handler is begin Received_Char := A_Character_From_UART_Device; We_Have_A_Char := True; end UART_Interrupt_Handler; end
A task calling the entry Get_Next_Character will be suspended until an interrupt is triggered and the handler reads a character from the UART device. In the meantime, other tasks will be able to execute on the CPU.
Ada supports static and dynamic allocation of tasks to cores on multi processor architectures. The Ravenscar profile restricts this support to a fully partitioned approach were tasks are statically allocated to processors and there is no task migration among CPUs. These parallel tasks running on different CPUs can communicate and synchronize using protected objects.
The CPU aspect specifies the task affinity:
task Producer with CPU => 1; task Consumer with CPU => 2; -- Parallel tasks statically allocated to different cores
That's it for the quick overview of the basic Ada Ravenscar tasking features.
One of the advantages of having tasking as part of the language standard is the portability, you can run the same Ravenscar application on Windows, Linux, MacOs or an RTOS like VxWorks. GNAT also provides a small stand alone run-time that implements the Ravenscar tasking on bare metal. This run-time is available, for instance, on ARM Cortex-M micro-controllers.
It's like having an RTOS in your language.