AdaCore Blog

There's a mini-RTOS in my language

by Fabien Chouteau

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 ;)

Tasks

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.

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;

Scheduling

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;

Synchronization

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;

Interrupt Handling

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.

Multi-core support

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

Implementations

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.

Posted in #Ada    #Ravenscar    #embedded    #STM32   

About Fabien Chouteau

Fabien joined AdaCore in 2010 after his engineering degree at the EPITA (Paris). He is involved in real-time, embedded and hardware simulation technology. Maker/DIYer in his spare time, his projects include electronics, music and woodworking.