Profitimage | Dreamstime.com
Ada Promo 60afc1aea20b0

Writing Ada on Embedded Systems

May 27, 2021
Writing low-level programming in Ada is easy. Here’s a primer on how it’s done.

This article is part of the Embedded Software series: Ada for the Embedded C Developer

We've seen in the previous articles how Ada can be used to describe high-level semantics and architecture. The beauty of the language, however, is that it can be used all the way down to the lowest levels of the development, including embedded assembly code or bit-level data management.

Representation Clauses

One very interesting feature of the language is that, unlike C, for example, there are no data representation constraints unless specified by the developer. This means that the compiler is free to choose the best tradeoff in terms of representation vs. performance. Let's start with the following Ada example:

type R is record
    V  : Integer range 0 .. 255;
    B1 : Boolean;
    B2 : Boolean;
end record
  with Pack;

and C example:

struct R {
    unsigned int V:8; 
    bool B1;
    bool B2;
};

The Ada and C++ code above both represent efforts to create an object that's as small as possible. Controlling data size isn’t possible in Java, but the language does specify the size of values for the primitive types.

Although the C++ and Ada code are equivalent in this example, there's an interesting semantic difference. In C++, the number of bits required by each field needs to be specified. Here, we're stating that V is only 8 bits, effectively representing values from 0 to 255.

In Ada, it's the other way around: The developer specifies the range of values required and the compiler decides how to represent things, optimizing for speed or size. The Pack aspect declared at the end of the record specifies that the compiler should optimize for size even at the expense of decreased speed in accessing record components. We'll see more details about the Pack aspect in the sections about bitwise operations and mapping structures to bit-fields in chapter 6 (to come).

Other representation clauses can be specified as well, along with compile-time consistency checks between requirements in terms of available values and specified sizes. This is particularly useful when a specific layout is necessary; for example, when interfacing with hardware, a driver, or a communication protocol. Here's how to specify a specific data layout based on the previous example:

type R is record
    V  : Integer range 0 .. 255;
    B1 : Boolean;
    B2 : Boolean;
end record;

for R use record
  -- Occupy the first bit of the first byte.
  B1 at 0 range 0 .. 0;
  -- Occupy the last 7 bits of the first byte. 
  --  as well as the first bit of the second byte.
  V at 0 range 1 .. 8;
  -- Occupy the second bit of the second byte.
  B2 at 1 range 1 .. 1;
end record;

We omit the “with Pack” directive and instead use a record representation clause following the record declaration. The compiler is directed to spread objects of type R across two bytes. The layout we're specifying here is fairly inefficient to work with on any machine. However, you can have the compiler construct the most efficient methods for access, rather than coding your own machine-dependent bit-level methods manually.

Embedded Assembly Code

When performing low-level development, such as at the kernel or hardware driver level, there can be times when it’s necessary to implement functionality with assembly code.

Every Ada compiler has its own conventions for embedding assembly code, based on the hardware platform and the supported assembler(s). Our examples here will work with GNAT and GCC on the x86 architecture.

All x86 processors since the Intel Pentium offer the rdtsc instruction, which tells us the number of cycles since the last processor reset. It takes no inputs and places an unsigned 64-bit value split between the edx and eax registers.

GNAT provides a subprogram called System.Machine_Code.Asm that can be used for assembly code insertion. You can specify a string to pass to the assembler as well as source-level variables that can be used for input and output:

-- get_processor_cycles.adb
with Ssytem.Machine_Code; use System.Machine_Code;
with Interfaces;          use Interfaces;

function Get_Processor_Cycles return Unsigned_64 is
  Low, High : Unsigned_32;
  Counter   : Unsigned_64;
begin
  Asm (“rdtsc”,
       Outputs => 
         (Unsigned_32’Asm_Output (“=a”, High),
          Unsigned_32’Asm_Output (“=d”, Low)),
       Volatile => True);

  Counter := 
    Unsigned_64 (High) * 2 ** 32 +
    Unsigned_64 (Low);
  return Counter;
end Get_Processor_Cycles;

The Unsigned_32'Asm_Output clauses above provide associations between machine registers and source-level variables to be updated. =a and =d refer to the eax and edx machine registers, respectively. The use of the Unsigned_32 and Unsigned_64 types from package Interfaces ensures correct representation of the data. We assemble the two 32-bit values to form a single 64-bit value.

We set the Volatile parameter to True to tell the compiler that invoking this instruction multiple times with the same inputs can result in different outputs. This eliminates the possibility that the compiler will optimize multiple invocations into a single call.

With optimization turned on, the GNAT compiler is smart enough to use the eax and edx registers to implement the High and Low variables. This results in zero overhead for the assembly interface.

The machine code insertion interface provides many features beyond what was shown here. More information can be found in the GNAT User's Guide, and the GNAT Reference manual.

Interrupt Handling

Handling interrupts is an important aspect when programming embedded devices. Interrupts are used, for example, to indicate that a hardware or software event has happened. Therefore, by handling interrupts, an application can react to external events.

Ada provides built-in support for handling interrupts. We can process interrupts by attaching a handler, which must be a protected procedure, to it. In the declaration of the protected procedure, we use the Attach_Handler aspect and indicate which interrupt we want to handle.

Let's look into a code example that traps the quit interrupt (SIGQUIT) on Linux:

-- signal_handlers.ads
with System.OS_Interface;
package Signal_Handlers is
  protected type Quit_Handler is
    function Requested return Boolean;
  private
    Quit_Request : Boolean := False;
    -- 
    -- Declaration of an interrupt handler for the “quit” interrupt
    --
  end Quit_Handler;
end Signal_Hander;
-- signal_handlers.adb
with Ada.Text_IO; use Ada.Text_IO;
package body Signal_Handler is
  protected body Quit_Handler is
    function Requested return Boolean is
      (Quit_Request);

    procedure Handle_Quit_Signal is
    begin
      Put_Line (“Quit request detected!”);
      Quit_Request := True;
    end Quit_Handler;
end Signal_Handler;
-- test_quite_handler.adb
with Ada.Text_IO; use Ada.Text_IO;
with Signal_Handlers;

procedure Test_Quit_Handler is
  Quit : Signal_Handlers.Quit_Handler;
begin
  while True loop
    delay 1.0;
    exit when Quit.Requested;
  end loop;

  Put_Line (“Exiting application...”);
end Test_Quit_Handler;

The specification of the Signal_Handlers package from this example contains the declaration of Quit_Handler, which is a protected type. In the private part of this protected type, we declare the Handle_Quit_Signal procedure. By using the Attach_Handler aspect in the declaration of Handle_Quit_Signal and indicating the quit interrupt (System.OS_Interface.SIGQUIT), we're instructing the operating system to call this procedure for any quit request. So, when the user presses CTRL+\ on their keyboard, for example, the application will behave as follows:

  • The operating system calls the Handle_Quit_Signal procedure, which displays a message to the user ("Quit request detected!") and sets a Boolean variable—Quit_Request, which is declared in the Quit_Handler type:

 --The main application checks the status of the quit handler by calling the Requested function as part of the while True   loop:

*This call is in the exit when Quit.Requested line.

*The Requested function returns True in this case because the Quit_Request flag was set by the Handle_Quit_Signal procedure.

  • The main applications exits the loop, displays a message, and finishes.

Note that the code example above isn't portable because it makes use of interrupts from the Linux operating system. When programming embedded devices, we would use instead the interrupts available on those specific devices.

Also note that, in the example above, we're declaring a static handler at compilation time. If you need to make use of dynamic handlers, which can be configured at runtime, you can utilize the subprograms from the Ada.Interrupts package. This package includes not only a version of Attach_Handler as a procedure, but also other procedures such as:

  • Exchange_Handler, which lets us exchange, at runtime, the current handler associated with a specific interrupt by a different handler.
  • Detach_Handler, which we can use to remove the handler currently associated with a given interrupt.

Details about the Ada.Interrupts package are out of scope for this course. We'll discuss them in a separate, more advanced course in the future. You can find some information about it in the Interrupts appendix of the Ada Reference Manual.

Read more from the Embedded Software series: Ada for the Embedded C Developer

About the Author

Fabien Chouteau

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

Sponsored Recommendations

Comments

To join the conversation, and become an exclusive member of Electronic Design, create an account today!