Ada For The Embedded C Developer
Ada For The Embedded C Developer
a
f
ort
heEmbeddedCDe
vel
oper
Quenti
nOchem
Rober
tTi
ce
Gus
tavoA.Hoffmann
Pa
tri
ckRoger
s
Ada for the Embedded C
Developer
Release 2024-02
Quentin Ochem
and Robert Tice
and Gustavo A. Hoffmann
and Patrick Rogers.
1 Introduction 3
1.1 So, what is this Ada thing anyway? . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.2 Ada — The Technical Details . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
i
4.2.1 Representation Clauses . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
4.2.2 Embedded Assembly Code . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
4.3 Interrupt Handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
4.4 Dealing with Absence of FPU with Fixed Point . . . . . . . . . . . . . . . . . . . . 88
4.5 Volatile and Atomic data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
4.5.1 Volatile . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
4.5.2 Atomic . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95
4.6 Interfacing with Devices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
4.6.1 Size aspect and attribute . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
4.6.2 Register overlays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
4.6.3 Data streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
4.7 ARM and svd2ada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
ii
7.3.4 Pointer to subprograms . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 199
7.4 Design by components using dynamic libraries . . . . . . . . . . . . . . . . . . . 205
10 Conclusion 227
Bibliography 259
iii
iv
Ada for the Embedded C Developer
This course introduces you to the Ada language by comparing it to C. It assumes that you
have good knowledge of the C language. It also assumes that the choice of learning Ada
is guided by considerations linked to reliability, safety or security. In that sense, it teaches
you Ada paradigms that should be applied in replacement of those usually applied in C.
This course also introduces you to the SPARK subset of the Ada programming language,
which removes a few features of the language with undefined behavior, so that the code is
fit for sound static analysis techniques.
This course was written by Quentin Ochem, Robert Tice, Gustavo A. Hoffmann, and Patrick
Rogers and reviewed by Patrick Rogers, Filip Gajowniczek, and Tucker Taft.
Note: The code examples in this course use an 80-column limit, which is a typical limit
for Ada code. Note that, on devices with a small screen size, some code examples might
be difficult to read.
Note: Each code example from this book has an associated "code block metadata", which
contains the name of the "project" and an MD5 hash value. This information is used to
identify a single code example.
You can find all code examples in a zip file, which you can download from the learn website2 .
The directory structure in the zip file is based on the code block metadata. For example, if
you're searching for a code example with this metadata:
• Project: Courses.Intro_To_Ada.Imperative_Language.Greet
• MD5: cba89a34b87c9dfa71533d982d05e6ab
you will find it in this directory:
projects/Courses/Intro_To_Ada/Imperative_Language/Greet/
cba89a34b87c9dfa71533d982d05e6ab/
In order to use this code example, just follow these steps:
1. Unpack the zip file;
2. Go to target directory;
3. Start GNAT Studio on this directory;
4. Build (or compile) the project;
1 http://creativecommons.org/licenses/by-sa/4.0
2 https://learn.adacore.com/zip/learning-ada_code.zip
CONTENTS 1
Ada for the Embedded C Developer
2 CONTENTS
CHAPTER
ONE
INTRODUCTION
To answer this question let's introduce Ada as it compares to C for an embedded application.
C developers are used to a certain coding semantic and style of programming. Especially
in the embedded domain, developers are used to working at a very low level near the
hardware to directly manipulate memory and registers. Normal operations involve math-
ematical operations on pointers, complex bit shifts, and logical bitwise operations. C is
well designed for such operations as it is a low level language that was designed to re-
place assembly language for faster, more efficient programming. Because of this minimal
abstraction, the programmer has to model the data that represents the problem they are
trying to solve using the language of the physical hardware.
Let's look at an example of this problem in action by comparing the same program in Ada
and C:
[C]
Listing 1: main.c
1 #include <stdio.h>
2 #include <stdlib.h>
3
16 return sum;
17 }
18
3
Ada for the Embedded C Developer
Project: Courses.Ada_For_Embedded_C_Dev.Introduction.Add_Angles_C
MD5: a6d184caaec372c538634c578b5e144b
Runtime output
Sum: 0
[Ada]
Listing 2: sum_angles.adb
1 with Ada.Command_Line; use Ada.Command_Line;
2 with Ada.Text_IO; use Ada.Text_IO;
3
4 procedure Sum_Angles is
5
19 return Sum;
20 end Add_Angles;
21
Project: Courses.Ada_For_Embedded_C_Dev.Introduction.Add_Angles_Ada
MD5: b5a446e5c27aa18c917ae8c2cc6c1605
Runtime output
Sum: 0
Here we have a piece of code in C and in Ada that takes some numbers from the command
line and stores them in an array. We then sum all of the values in the array and print
the result. The tricky part here is that we are working with values that model an angle
in degrees. We know that angles are modular types, meaning that angles greater than
360° can also be represented as Angle mod 360. So if we have an angle of 400°, this is
equivalent to 40°. In order to model this behavior in C we had to create the MOD_DEGREES
4 Chapter 1. Introduction
Ada for the Embedded C Developer
macro, which performs the modulus operation. As we read values from the command line,
we convert them to integers and perform the modulus before storing them into the array.
We then call add_angles which returns the sum of the values in the array. Can you spot the
problem with the C code?
Try running the Ada and C examples using the input sequence 340 2 50 70. What does
the C program output? What does the Ada program output? Why are they different?
The problem with the C code is that we forgot to call MOD_DEGREES in the for loop of
add_angles. This means that it is possible for add_angles to return values greater than
DEGREES_MAX. Let's look at the equivalent Ada code now to see how Ada handles the situa-
tion. The first thing we do in the Ada code is to create the type Degrees which is a modular
type. This means that the compiler is going to handle performing the modulus operation
for us. If we use the same for loop in the Add_Angles function, we can see that we aren't
doing anything special to make sure that our resulting value is within the 360° range we
need it to be in.
The takeaway from this example is that Ada tries to abstract some concepts from the de-
veloper so that the developer can focus on solving the problem at hand using a data model
that models the real world rather than using data types prescribed by the hardware. The
main benefit of this is that the compiler takes some responsibility from the developer for
generating correct code. In this example we forgot to put in a check in the C code. The
compiler inserted the check for us in the Ada code because we told the compiler what we
were trying to accomplish by defining strong types.
Ideally, we want all the power that the C programming language can give us to manipulate
the hardware we are working on while also allowing us the ability to more accurately model
data in a safe way. So, we have a dilemma; what can give us the power of operations
like the C language, but also provide us with features that can minimize the potential for
developer error? Since this course is about Ada, it's a good bet we're about to introduce
the Ada language as the answer to this question…
Unlike C, the Ada language was designed as a higher level language from its conception;
giving more responsibility to the compiler to generate correct code. As mentioned above,
with C, developers are constantly shifting, masking, and accessing bits directly on memory
pointers. In Ada, all of these operations are possible, but in most cases, there is a better way
to perform these operations using higher level constructs that are less prone to mistakes,
like off-by-one or unintentional buffer overflows. If we were to compare the same application
written using C and with Ada using high level constructs, we would see similar performance
in terms of speed and memory efficiency. If we compare the object code generated by both
compilers, it's possible that they even look identical!
Like C, Ada is a compiled language. This means that the compiler will parse the source
code and emit machine code native to the target hardware. The Ada compiler we will be
discussing in this course is the GNAT compiler. This compiler is based on the GCC technology
like many C and C++ compilers available. When the GNAT compiler is invoked on Ada code,
the GNAT front-end expands and translates the Ada code into an intermediate language
which is passed to GCC where the code is optimized and translated to machine code. A
C compiler based on GCC performs the same steps and uses the same intermediate GCC
representation. This means that the optimizations we are used to seeing with a GCC based
C compiler can also be applied to Ada code. The main difference between the two compilers
is that the Ada compiler is expanding high level constructs into intermediate code. After
expansion, the Ada code will be very similar to the equivalent C code.
It is possible to do a line-by-line translation of C code to Ada. This feels like a natural step for
a developer used to C paradigms. However, there may be very little benefit to doing so. For
the purpose of this course, we're going to assume that the choice of Ada over C is guided by
considerations linked to reliability, safety or security. In order to improve upon the reliabil-
ity, safety and security of our application, Ada paradigms should be applied in replacement
of those usually applied in C. Constructs such as pointers, preprocessor macros, bitwise
operations and defensive code typically get expressed in Ada in very different ways, im-
proving the overall reliability and readability of the applications. Learning these new ways
of coding, often, requires effort by the developer at first, but proves more efficient once the
paradigms are understood.
In this course we will also introduce the SPARK subset of the Ada programming language.
The SPARK subset removes a few features of the language, i.e., those that make proof
difficult, such as pointer aliasing. By removing these features we can write code that is fit
for sound static analysis techniques. This means that we can run mathematical provers on
the SPARK code to prove certain safety or security properties about the code.
6 Chapter 1. Introduction
CHAPTER
TWO
The Ada programming language is a general programming language, which means it can
be used for many different types of applications. One type of application where it partic-
ularly shines is reliable and safety-critical embedded software; meaning, a platform with
a microprocessor such as ARM, PowerPC, x86, or RISC-V. The application may be running
on top of an embedded operating system, such as an embedded Linux, or directly on bare
metal. And the application domain can range from small entities such as firmware or device
controllers to flight management systems, communication based train control systems, or
advanced driver assistance systems.
The toolchain used throughout this course is called GNAT, which is a suite of tools with a
compiler based on the GCC environment. It can be obtained from AdaCore, either as part of
a commercial contract with GNAT Pro3 or at no charge with the GNAT Community edition4 .
The information in this course will be relevant no matter which edition you're using. Most
examples will be runnable on the native Linux or Windows version for convenience. Some
will only be relevant in the context of a cross toolchain, in which case we'll be using the
embedded ARM bare metal toolchain.
As for any Ada compiler, GNAT takes advantage of implementation permissions and offers
a project management system. Because we're talking about embedded platforms, there
are a lot of topics that we'll go over which will be specific to GNAT, and sometimes to
specific platforms supported by GNAT. We'll try to make the distinction between what is
GNAT-specific and Ada generic as much as possible throughout this course.
For an introduction to the GNAT Toolchain for the GNAT Community edition, you may refer
to the Introduction to GNAT Toolchain course.
3 https://www.adacore.com/gnatpro
4 https://www.adacore.com/community
7
Ada for the Embedded C Developer
When we're discussing embedded programming, our target device is often different from
the host, which is the device we're using to actually write and build an application. In
this case, we're talking about cross compilation platforms (concisely referred to as cross
platforms).
The GNAT toolchain supports cross platform compilation for various target devices. This
section provides a short introduction to the topic. For more details, please refer to the
GNAT User’s Guide Supplement for Cross Platforms5
GNAT supports two types of cross platforms:
• cross targets, where the target device has an embedded operating system.
– ARM-Linux, which is commonly found in a Raspberry-Pi, is a prominent example.
• bareboard targets, where the run-times do not depend on an operating system.
– In this case, the application has direct access to the system hardware.
For each platform, a set of run-time libraries is available. Run-time libraries implement a
subset of the Ada language for different use cases, and they're different for each target
platform. They may be selected via an attribute in the project's GPR project file or as a
command-line switch to GPRbuild. Although the run-time libraries may vary from target to
target, the user interface stays the same, providing portability for the application.
Run-time libraries consists of:
1. Files that are dependent on the target board.
• These files are responsible for configuring and interacting with the hardware.
• They are known as a Board Support Package — commonly referred to by their
abbrevation BSP.
2. Code that is target-independent.
• This code implements language-defined functionality.
The bareboard run-time libraries are provided as customized run-times that are config-
ured to target a very specific micro-controller or processor. Therefore, for different micro-
controllers and processors, the run-time libraries need to be ported to the specific target.
These are some examples of what needs to be ported:
• startup code / scripts;
• clock frequency initializations;
• memory mapping / allocation;
• interrupts and interrupt priorities;
• register descriptions.
For more details on the topic, please refer to the following chapters of the GNAT User’s
Guide Supplement for Cross Platforms6 :
• Bareboard Topics7
• Customized Run-Time Libraries8
5 https://docs.adacore.com/gnat_ugx-docs/html/gnat_ugx/gnat_ugx.html
6 https://docs.adacore.com/gnat_ugx-docs/html/gnat_ugx/gnat_ugx.html
7 http://docs.adacore.com/live/wave/gnat_ugx/html/gnat_ugx/gnat_ugx/bareboard_topics.html
8 http://docs.adacore.com/live/wave/gnat_ugx/html/gnat_ugx/gnat_ugx/customized_run-time_libraries.html
The first piece of code to translate from C to Ada is the usual Hello World program:
[C]
Listing 1: main.c
1 #include <stdio.h>
2
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Hello_World_C
MD5: 59685c72296a032893cda71dade24196
Runtime output
Hello World
[Ada]
Listing 2: hello_world.adb
1 with Ada.Text_IO;
2
3 procedure Hello_World
4 is
5 begin
6 Ada.Text_IO.Put_Line ("Hello World");
7 end Hello_World;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Hello_World_Ada
MD5: f1a7c6a4fd679c4caea7ee31d14aab2e
Runtime output
Hello World
The resulting program will print Hello World on the screen. Let's now dissect the Ada
version to describe what is going on:
The first line of the Ada code is giving us access to the Ada.Text_IO library which contains
the Put_Line function we will use to print the text to the console. This is similar to C's
#include <stdio.h>. We then create a procedure which executes Put_Line which prints
to the console. This is similar to C's printf function. For now, we can assume these Ada
and C features have similar functionality. In reality, they are very different. We will explore
that more as we delve further into the Ada language.
You may have noticed that the Ada syntax is more verbose than C. Instead of using braces
{} to declare scope, Ada uses keywords. is opens a declarative scope — which is empty
here as there's no variable to declare. begin opens a sequence of statements. Within this
sequence, we're calling the function Put_Line, prefixing explicitly with the name of the
library unit where it's declared, Ada.Text_IO. The absence of the end of line \n can also be
noted, as Put_Line always terminates by an end of line.
Ada syntax might seem peculiar at first glance. Unlike many other languages, it's not
derived from the popular C style of notation with its ample use of brackets; rather, it uses a
more expository syntax coming from Pascal. In many ways, Ada is a more explicit language
— its syntax was designed to increase readability and maintainability, rather than making
it faster to write in a condensed manner. For example:
• full words like begin and end are used in place of curly braces.
• Conditions are written using if, then, elsif, else, and end if.
• Ada's assignment operator does not double as an expression, eliminating potential
mistakes that could be caused by = being used where == should be.
All languages provide one or more ways to express comments. In Ada, two consecutive
hyphens -- mark the start of a comment that continues to the end of the line. This is
exactly the same as using // for comments in C. Multi line comments like C's /* */ do not
exist in Ada.
Ada compilers are stricter with type and range checking than most C programmers are used
to. Most beginning Ada programmers encounter a variety of warnings and error messages
when coding, but this helps detect problems and vulnerabilities at compile time — early
on in the development cycle. In addition, checks (such as array bounds checks) provide
verification that could not be done at compile time but can be performed either at run-
time, or through formal proof (with the SPARK tooling).
Ada identifiers and reserved words are case insensitive. The identifiers VAR, var and VaR are
treated as the same identifier; likewise begin, BEGIN, Begin, etc. Identifiers may include
letters, digits, and underscores, but must always start with a letter. There are 73 reserved
keywords in Ada that may not be used as identifiers, and these are:
Both C and Ada were designed with the idea that the code specification and code imple-
mentation could be separated into two files. In C, the specification typically lives in the
.h, or header file, and the implementation lives in the .c file. Ada is superficially similar to
C. With the GNAT toolchain, compilation units are stored in files with an .ads extension for
specifications and with an .adb extension for implementations.
One main difference between the C and Ada compilation structure is that Ada compilation
units are structured into something called packages.
2.7 Packages
The package is the basic modularization unit of the Ada language, as is the class for Java
and the header and implementation pair for C. A specification defines a package and the
implementation implements the package. We saw this in an earlier example when we
included the Ada.Text_IO package into our application. The package specification has the
structure:
[Ada]
-- my_package.ads
package My_Package is
-- public declarations
private
-- private declarations
end My_Package;
-- my_package.adb
package body My_Package is
-- implementation
end My_Package;
An Ada package contains three parts that, for GNAT, are separated into two files: .ads files
contain public and private Ada specifications, and .adb files contain the implementation,
or Ada bodies.
[Ada]
package Package_Name is
-- public specifications
private
-- private specifications
end Package_Name;
Private types are useful for preventing the users of a package's types from depending on the
types' implementation details. Another use-case is the prevention of package users from
accessing package state/data arbitrarily. The private reserved word splits the package spec
into public and private parts. For example:
[Ada]
Listing 3: types.ads
1 package Types is
2 type Type_1 is private;
3 type Type_2 is private;
4 type Type_3 is private;
5 procedure P (X : Type_1);
6 -- ...
7 private
8 procedure Q (Y : Type_1);
9 type Type_1 is new Integer range 1 .. 1000;
10 type Type_2 is array (Integer range 1 .. 1000) of Integer;
11 type Type_3 is record
12 A, B : Integer;
13 end record;
14 end Types;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Private_Types
MD5: ae4a9e4d10b55e7efd92d7952ba22f4f
Subprograms declared above the private separator (such as P) will be visible to the pack-
age user, and the ones below (such as Q) will not. The body of the package, the imple-
mentation, has access to both parts. A package specification does not require a private
section.
Ada packages can be organized into hierarchies. A child unit can be declared in the following
way:
[Ada]
-- root-child.ads
package Root.Child is
-- package spec goes here
end Root.Child;
-- root-child.adb
Here, Root.Child is a child package of Root. The public part of Root.Child has access to
the public part of Root. The private part of Child has access to the private part of Root,
which is one of the main advantages of child packages. However, there is no visibility
relationship between the two bodies. One common way to use this capability is to define
subsystems around a hierarchical naming scheme.
Entities declared in the visible part of a package specification can be made accessible using
a with clause that references the package, which is similar to the C #include directive.
After a with clause makes a package available, references to the package contents require
the name of the package as a prefix, with a dot after the package name. This prefix can be
omitted if a use clause is employed.
[Ada]
Listing 4: pck.ads
1 -- pck.ads
2
3 package Pck is
4 My_Glob : Integer;
5 end Pck;
Listing 5: main.adb
1 -- main.adb
2
3 with Pck;
4
5 procedure Main is
6 begin
7 Pck.My_Glob := 0;
8 end Main;
In contrast to C, the Ada with clause is a semantic inclusion mechanism rather than a text
inclusion mechanism; for more information on this difference please refer to Packages.
The following code samples are all equivalent, and illustrate the use of comments and
working with integer variables:
[C]
Listing 6: main.c
1 #include <stdio.h>
2
11 // regular addition
12 d = a + b + c;
13
17 return 0;
18 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Var_Decl_C
MD5: ba258dac5c052a97da475239e2f2ce96
Runtime output
d = 101
[Ada]
Listing 7: main.adb
1 with Ada.Text_IO;
2
3 procedure Main
4 is
5 -- variable declaration
6 A, B : Integer := 0;
7 C : Integer := 100;
8 D : Integer;
9 begin
10 -- Ada does not have a shortcut format for increment like in C
11 A := A + 1;
12
13 -- regular addition
14 D := A + B + C;
15
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Var_Decl_Ada
MD5: eaff76f36d5f938bd806d29048df7865
Runtime output
D = 101
You'll notice that, in both languages, statements are terminated with a semicolon. This
means that you can have multi-line statements.
In the Ada example above, there are two distinct sections to the procedure Main. This first
section is delimited by the is keyword and the begin keyword. This section is called the
declarative block of the subprogram. The declarative block is where you will define all the
local variables which will be used in the subprogram. C89 had something similar, where
developers were required to declare their variables at the top of the scope block. Most C
developers may have run into this before when trying to write a for loop:
[C]
Listing 8: main.c
1 /* The C89 version */
2
3 #include <stdio.h>
4
22 return 0;
23 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Average_C89
MD5: 5c89aa28cba0bae4d963b235c53aedf2
Runtime output
Average: 3
[C]
Listing 9: main.c
1 // The modern C way
2
3 #include <stdio.h>
4
22 return 0;
23 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Average_C_Modern
MD5: 6354863137d78adb974743915d1d4530
Runtime output
Average: 3
For the fun of it, let's also see the Ada way to do this:
[Ada]
3 procedure Main is
4 type Int_Array is array (Natural range <>) of Integer;
5
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Average_Ada
MD5: 52abb574d7a8b3bdb56715735dcd1d54
Runtime output
Average: 3
We will explore more about the syntax of loops in Ada in a future section of this course; but
for now, notice that the I variable used as the loop index is not declared in the declarative
section!
backwards from the way C does declarations. The C language expects the type followed by
the variable name. Ada expects the variable name followed by a colon and then the type.
The next block in the Ada example is between the begin and end keywords. This is where
your statements will live. You can create new scopes by using the declare keyword:
[Ada]
3 procedure Main
4 is
5 -- variable declaration
6 A, B : Integer := 0;
7 C : Integer := 100;
8 D : Integer;
9 begin
10 -- Ada does not have a shortcut format for increment like in C
11 A := A + 1;
12
13 -- regular addition
14 D := A + B + C;
15
19 declare
20 E : constant Integer := D * 100;
21 begin
22 -- printing the result
23 Ada.Text_IO.Put_Line ("E =" & E'Img);
24 end;
25
26 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Var_Decl_Block_Ada
MD5: 9239b993a7eadb13a27bd3618a03431f
Runtime output
D = 101
E = 10100
Notice that we declared a new variable E whose scope only exists in our newly defined
block. The equivalent C code is:
[C]
11 // regular addition
12 d = a + b + c;
13
17 {
18 const int e = d * 100;
19 printf("e = %d\n", e);
20 }
21
22 return 0;
23 }
Runtime output
d = 101
e = 10100
Fun Fact about the C language assignment operator =: Did you know that an assignment
in C can be used in an expression? Let's look at an example:
[C]
7 if (a = 10)
8 printf("True\n");
9 else
10 printf("False\n");
11
12 return 0;
13 }
Runtime output
True
Run the above code example. What does it output? Is that what you were expecting?
The author of the above code example probably meant to test if a == 10 in the if statement
but accidentally typed = instead of ==. Because C treats assignment as an expression, it
was able to evaluate a = 10.
Let's look at the equivalent Ada code:
[Ada]
3 procedure Main
4 is
5 A : Integer := 0;
6 begin
7
8 if A := 10 then
9 Put_Line ("True");
10 else
11 Put_Line ("False");
12 end if;
13 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Equal_Ada
MD5: 1500b264531dfcc7a62eeed2f22f511b
The above code will not compile. This is because Ada does no allow assignment as an
expression.
2.9 Conditions
9 if (v > 0) {
10 printf("Positive\n");
11 }
12 else if (v < 0) {
13 printf("Negative\n");
14 }
(continues on next page)
2.9. Conditions 19
Ada for the Embedded C Developer
19 return 0;
20 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Condition_C
MD5: 69203e679085e73394d3620a5954262a
Runtime output
Zero
[Ada]
3 procedure Main
4 is
5 -- try changing the initial value to change the
6 -- output of the program
7 V : constant Integer := 0;
8 begin
9 if V > 0 then
10 Put_Line ("Positive");
11 elsif V < 0 then
12 Put_Line ("Negative");
13 else
14 Put_Line ("Zero");
15 end if;
16 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Condition_Ada
MD5: 417e557708472f9022db7d8c1ed6aa33
Runtime output
Zero
In Ada, everything that appears between the if and then keywords is the conditional ex-
pression, no parentheses are required. Comparison operators are the same except for:
Operator C Ada
Equality == =
Inequality != /=
Not ! not
And && and
Or || or
9 switch(v) {
10 case 0:
11 printf("Zero\n");
12 break;
13 case 1: case 2: case 3: case 4: case 5:
14 case 6: case 7: case 8: case 9:
15 printf("Positive\n");
16 break;
17 case 10: case 12: case 14: case 16: case 18:
18 printf("Even number between 10 and 18\n");
19 break;
20 default:
21 printf("Something else\n");
22 break;
23 }
24
25 return 0;
26 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Switch_Case_C
MD5: 1bdb3d0c151d71280ef9039841f7ee58
Runtime output
Zero
[Ada]
3 procedure Main
4 is
5 -- try changing the initial value to change the
6 -- output of the program
7 V : constant Integer := 0;
8 begin
9 case V is
10 when 0 =>
11 Put_Line ("Zero");
12 when 1 .. 9 =>
13 Put_Line ("Positive");
14 when 10 | 12 | 14 | 16 | 18 =>
15 Put_Line ("Even number between 10 and 18");
16 when others =>
17 Put_Line ("Something else");
18 end case;
19 end Main;
2.9. Conditions 21
Ada for the Embedded C Developer
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Switch_Case_Ada
MD5: 09e2318b56069281c95f23310dc121d1
Runtime output
Zero
Switch or Case?
A switch statement in C is the same as a case statement in Ada. This may be a little strange
because C uses both keywords in the statement syntax. Let's make an analogy between C
and Ada: C's switch is to Ada's case as C's case is to Ada's when.
Notice that in Ada, the case statement does not use the break keyword. In C, we use break
to stop the execution of a case branch from falling through to the next branch. Here is an
example:
[C]
7 switch(v) {
8 case 0:
9 printf("Zero\n");
10 case 1:
11 printf("One\n");
12 default:
13 printf("Other\n");
14 }
15
16 return 0;
17 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Switch_Case_Break_C
MD5: fd0389205476f161655caf32244d9054
Runtime output
Zero
One
Other
Run the above code with v = 0. What prints? What prints when we change the assignment
to v = 1?
When v = 0 the program outputs the strings Zero then One then Other. This is called
fall through. If you add the break statements back into the switch you can stop this fall
through behavior from happening. The reason why fall through is allowed in C is to allow
the behavior from the previous example where we want a specific branch to execute for
multiple inputs. Ada solves this a different way because it is possible, or even probable,
that the developer might forget a break statement accidentally. So Ada does not allow
fall through. Instead, you can use Ada's syntax to identify when a specific branch can be
executed by more than one input. If you want a range of values for a specific branch you
can use the First .. Last notation. If you want a few non-consecutive values you can
use the Value1 | Value2 | Value3 notation.
Instead of using the word default to denote the catch-all case, Ada uses the others key-
word.
2.10 Loops
2.10. Loops 23
Ada for the Embedded C Developer
50 return 0;
51 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Loops_C
MD5: bcd8963884e2b2a5e364219f9b6b8fbc
Runtime output
v = 128
v = 256
v = 30
v = 10
sum = 55
[Ada]
3 procedure Main is
4 V : Integer;
5 begin
6 -- this is a while loop
7 V := 1;
8 while V < 100 loop
9 V := V * 2;
10 end loop;
11 Ada.Text_IO.Put_Line ("V = " & Integer'Image (V));
12
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Loops_Ada
MD5: c09a092f8d2f682ce758d4bf059b954a
Runtime output
V = 128
V = 256
V = 30
V = 10
Sum = 55
The loop syntax in Ada is pretty straightforward. The loop and end loop keywords are
used to open and close the loop scope. Instead of using the break keyword to exit the loop,
Ada has the exit statement. The exit statement can be combined with a logic expression
using the exit when syntax.
The major deviation in loop syntax is regarding for loops. You'll notice, in C, that you some-
times declare, and at least initialize a loop counter variable, specify a loop predicate, or an
expression that indicates when the loop should continue executing or complete, and last
you specify an expression to update the loop counter.
[C]
In Ada, you don't declare or initialize a loop counter or specify an update expression. You
only name the loop counter and give it a range to loop over. The loop counter is read-only!
You cannot modify the loop counter inside the loop like you can in C. And the loop counter
will increment consecutively along the specified range. But what if you want to loop over
the range in reverse order?
[C]
2.10. Loops 25
Ada for the Embedded C Developer
12 return 0;
13 }
Runtime output
10
9
8
7
6
5
4
3
2
1
0
[Ada]
3 procedure Main
4 is
5 My_Range : constant := 10;
6 begin
7 for I in reverse 0 .. My_Range loop
8 Put_Line (I'Img);
9 end loop;
10 end Main;
Runtime output
10
9
8
7
6
5
4
3
2
1
0
Tick Image
Strangely enough, Ada people call the single apostrophe symbol, ', "tick". This "tick" says
the we are accessing an attribute of the variable. When we do 'Img on a variable of a
numerical type, we are going to return the string version of that numerical type. So in
the for loop above, I'Img, or "I tick image" will return the string representation of the
numerical value stored in I. We have to do this because Put_Line is expecting a string as an
input parameter.
We'll discuss attributes in more details later in this chapter (page 43).
In the above example, we are traversing over the range in reverse order. In Ada, we use
the reverse keyword to accomplish this.
In many cases, when we are writing a for loop, it has something to do with traversing an
array. In C, this is a classic location for off-by-one errors. Let's see an example in action:
[C]
17 if (i % 10 == 0) {
18 printf("\n");
19 }
20 }
21
22 return 0;
23 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Loop_Reverse_C
MD5: 710ce30066551d1aada8d4e98a6004b1
Runtime output
0
99 98 97 96 95 94 93 92 91 90
89 88 87 86 85 84 83 82 81 80
79 78 77 76 75 74 73 72 71 70
69 68 67 66 65 64 63 62 61 60
59 58 57 56 55 54 53 52 51 50
49 48 47 46 45 44 43 42 41 40
39 38 37 36 35 34 33 32 31 30
29 28 27 26 25 24 23 22 21 20
19 18 17 16 15 14 13 12 11 10
9 8 7 6 5 4 3 2 1
[Ada]
2.10. Loops 27
Ada for the Embedded C Developer
3 procedure Main
4 is
5 type Int_Array is array (Natural range 1 .. 100) of Integer;
6
7 List : Int_Array;
8 begin
9
17 if I mod 10 = 0 then
18 New_Line;
19 end if;
20 end loop;
21
22 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Loop_Reverse_Ada
MD5: 340b935d42a80671bb050bdad1b032f7
Runtime output
99 98 97 96 95 94 93 92 91 90
89 88 87 86 85 84 83 82 81 80
79 78 77 76 75 74 73 72 71 70
69 68 67 66 65 64 63 62 61 60
59 58 57 56 55 54 53 52 51 50
49 48 47 46 45 44 43 42 41 40
39 38 37 36 35 34 33 32 31 30
29 28 27 26 25 24 23 22 21 20
19 18 17 16 15 14 13 12 11 10
9 8 7 6 5 4 3 2 1 0
The above Ada and C code should initialize an array using a for loop. The initial values in
the array should be contiguously decreasing from 99 to 0 as we index from the first index
to the last index. In other words, the first index has a value of 99, the next has 98, the next
97 ... the last has a value of 0.
If you run both the C and Ada code above you'll notice that the outputs of the two programs
are different. Can you spot why?
In the C code there are two problems:
1. There's a buffer overflow in the first iteration of the loop. We would need to modify
the loop initialization to int i = LIST_LENGTH - 1;. The loop predicate should be
modified to i >= 0;
2. The C code also has another off-by-one problem in the math to compute the value
stored in list[i]. The expression should be changed to be list[i] = LIST_LENGTH
- i - 1;.
These are typical off-by-one problems that plagues C programs. You'll notice that we didn't
have this problem with the Ada code because we aren't defining the loop with arbitrary
numeric literals. Instead we are accessing attributes of the array we want to manipulate
and are using a keyword to determine the indexing direction.
We can actually simplify the Ada for loop a little further using iterators:
[Ada]
3 procedure Main
4 is
5 type Int_Array is array (Natural range 1 .. 100) of Integer;
6
7 List : Int_Array;
8 begin
9
17 if I mod 10 = 0 then
18 New_Line;
19 end if;
20 end loop;
21
22 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Loop_Reverse_Ada_Simplified
MD5: 612046826199b00ed61271d6215596fe
Runtime output
99 98 97 96 95 94 93 92 91 90
89 88 87 86 85 84 83 82 81 80
79 78 77 76 75 74 73 72 71 70
69 68 67 66 65 64 63 62 61 60
59 58 57 56 55 54 53 52 51 50
49 48 47 46 45 44 43 42 41 40
39 38 37 36 35 34 33 32 31 30
29 28 27 26 25 24 23 22 21 20
19 18 17 16 15 14 13 12 11 10
9 8 7 6 5 4 3 2 1 0
In the second for loop, we changed the syntax to for I of List. Instead of I being the
index counter, it is now an iterator that references the underlying element. This example
of Ada code is identical to the last bit of Ada code. We just used a different method to index
over the second for loop. There is no C equivalent to this Ada feature, but it is similar to
C++'s range based for loop.
2.10. Loops 29
Ada for the Embedded C Developer
Ada is considered a "strongly typed" language. This means that the language does not
define any implicit type conversions. C does define implicit type conversions, sometimes
referred to as integer promotion. The rules for promotion are fairly straightforward in simple
expressions but can get confusing very quickly. Let's look at a typical place of confusion
with implicit type conversion:
[C]
8 printf("Does a == b?\n");
9 if(a == b)
10 printf("Yes.\n");
11 else
12 printf("No.\n");
13
16 return 0;
17 }
Runtime output
Does a == b?
No.
a: 0x000000FF, b: 0xFFFFFFFF
Run the above code. You will notice that a != b! If we look at the output of the last printf
statement we will see the problem. a is an unsigned number where b is a signed number.
We stored a value of 0xFF in both variables, but a treated this as the decimal number 255
while b treated this as the decimal number -1. When we compare the two variables, of
course they aren't equal; but that's not very intuitive. Let's look at the equivalent Ada
example:
[Ada]
3 procedure Main
4 is
5 type Char is range 0 .. 255;
6 type Unsigned_Char is mod 256;
7
14 if A = B then
15 Put_Line ("Yes");
16 else
17 Put_Line ("No");
18 end if;
19
20 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Strong_Typing_Ada
MD5: d6ef2668809159e9fb0d42f91e893222
Build output
If you try to run this Ada example you will get a compilation error. This is because the
compiler is telling you that you cannot compare variables of two different types. We would
need to explicitly cast one side to make the comparison against two variables of the same
type. By enforcing the explicit cast we can't accidentally end up in a situation where we
assume something will happen implicitly when, in fact, our assumption is incorrect.
Another example: you can't divide an integer by a float. You need to perform the division
operation using values of the same type, so one value must be explicitly converted to
match the type of the other (in this case the more likely conversion is from integer to float).
Ada is designed to guarantee that what's done by the program is what's meant by the
programmer, leaving as little room for compiler interpretation as possible. Let's have a
look at the following example:
[Ada]
3 procedure Strong_Typing is
4 Alpha : constant Integer := 1;
5 Beta : constant Integer := 10;
6 Result : Float;
7 begin
8 Result := Float (Alpha) / Float (Beta);
9
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Strong_Typing_Ada_2
MD5: bf91f01b499bcd7da1df751a9f91a767
Runtime output
1.00000E-01
[C]
10 printf("%f\n", result);
11 }
12
17 return 0;
18 }
Runtime output
0.000000
Are the three programs above equivalent? It may seem like Ada is just adding extra com-
plexity by forcing you to make the conversion from Integer to Float explicit. In fact, it
significantly changes the behavior of the computation. While the Ada code performs a
floating point operation 1.0 / 10.0 and stores 0.1 in Result, the C version instead store 0.0
in result. This is because the C version perform an integer operation between two integer
variables: 1 / 10 is 0. The result of the integer division is then converted to a float and
stored. Errors of this sort can be very hard to locate in complex pieces of code, and sys-
tematic specification of how the operation should be interpreted helps to avoid this class
of errors. If an integer division was actually intended in the Ada case, it is still necessary to
explicitly convert the final result to Float:
[Ada]
-- Perform an Integer division then convert to Float
Result := Float (Alpha / Beta);
3 procedure Strong_Typing is
4 Alpha : constant Integer := 1;
5 Beta : constant Integer := 10;
6 Result : Float;
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Strong_Typing_Ada_2
MD5: 50d6a6a3270b51880c43c07f077760b6
Runtime output
0.00000E+00
The principal scalar types predefined by Ada are Integer, Float, Boolean, and Character.
These correspond to int, float, int (when used for Booleans), and char, respectively. The
names for these types are not reserved words; they are regular identifiers. There are other
language-defined integer and floating-point types as well. All have implementation-defined
ranges and precision.
Ada's type system encourages programmers to think about data at a high level of abstrac-
tion. The compiler will at times output a simple efficient machine instruction for a full line of
source code (and some instructions can be eliminated entirely). The careful programmer's
concern that the operation really makes sense in the real world would be satisfied, and so
would the programmer's concern about performance.
The next example below defines two different metrics: area and distance. Mixing these
two metrics must be done with great care, as certain operations do not make sense, like
adding an area to a distance. Others require knowledge of the expected semantics; for
example, multiplying two distances. To help avoid errors, Ada requires that each of the
binary operators +, -, *, and / for integer and floating-point types take operands of the
same type and return a value of that type.
[Ada]
5 D1 : Distance := 2.0;
6 D2 : Distance := 3.0;
7 A : Area;
8 begin
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Application_Defined_Types
MD5: 6a21d6281cc529bbf8ce2216d7e4a770
Build output
Even though the Distance and Area types above are just Float, the compiler does not
allow arbitrary mixing of values of these different types. An explicit conversion (which does
not necessarily mean any additional object code) is necessary.
The predefined Ada rules are not perfect; they admit some problematic cases (for example
multiplying two Distance yields a Distance) and prohibit some useful cases (for exam-
ple multiplying two Distances should deliver an Area). These situations can be handled
through other mechanisms. A predefined operation can be identified as abstract to make
it unavailable; overloading can be used to give new interpretations to existing operator
symbols, for example allowing an operator to return a value from a type different from its
operands; and more generally, GNAT has introduced a facility that helps perform dimen-
sionality checking.
Ada enumerations work similarly to C enum:
[Ada]
11 D : Day := Monday;
12 begin
13 null;
14 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enumeration_Ada
MD5: 51abd1863970e14ff86859c1aae11fe8
[C]
15 return 0;
16 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enumeration_C
MD5: d9f6724759375a126a6b5d8dceea3f24
But even though such enumerations may be implemented by the compiler as numeric val-
ues, at the language level Ada will not confuse the fact that Monday is a Day and is not
an Integer. You can compare a Day with another Day, though. To specify implementation
details like the numeric values that correspond with enumeration values in C you include
them in the original enum declaration:
[C]
3 enum Day {
4 Monday = 10,
5 Tuesday = 11,
6 Wednesday = 12,
7 Thursday = 13,
8 Friday = 14,
9 Saturday = 15,
10 Sunday = 16
11 };
12
19 return 0;
20 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enumeration_Values_C
MD5: 48ae1c84dafabde7a16de5305e106a80
Runtime output
d = 10
But in Ada you must use both a type definition for Day as well as a separate representation
clause for it like:
[Ada]
3 procedure Main is
4 type Day is
5 (Monday,
6 Tuesday,
7 Wednesday,
8 Thursday,
9 Friday,
10 Saturday,
11 Sunday);
12
23 D : Day := Monday;
24 V : Integer;
25 begin
26 V := Day'Enum_Rep (D);
27 Ada.Text_IO.Put_Line (Integer'Image (V));
28 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enumeration_Values
MD5: 9a4fa1a899cb8c240105bf8ad6dbfde3
Runtime output
10
Note that however, unlike C, values for enumerations in Ada have to be unique.
Contracts can be associated with types and variables, to refine values and define what are
considered valid values. The most common kind of contract is a range constraint introduced
with the range reserved word, for example:
[Ada]
4 G1, G2 : Grade;
5 N : Integer;
6 begin
7 -- ... -- Initialization of N
8 G1 := 80; -- OK
9 G1 := N; -- Illegal (type mismatch)
10 G1 := Grade (N); -- Legal, run-time range check
11 G2 := G1 + 10; -- Legal, run-time range check
12 G1 := (G1 + G2) / 2; -- Legal, run-time range check
13 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Range_Check
MD5: 0f249b06e373497ae94b6055a37187c8
Build output
In the above example, Grade is a new integer type associated with a range check. Range
checks are dynamic and are meant to enforce the property that no object of the given type
can have a value outside the specified range. In this example, the first assignment to G1
is correct and will not raise a run-time exception. Assigning N to G1 is illegal since Grade is
a different type than Integer. Converting N to Grade makes the assignment legal, and a
range check on the conversion confirms that the value is within 0 .. 100. Assigning G1 +
10 to G2 is legal since + for Grade returns a Grade (note that the literal 10 is interpreted as
a Grade value in this context), and again there is a range check.
The final assignment illustrates an interesting but subtle point. The subexpression G1 +
G2 may be outside the range of Grade, but the final result will be in range. Nevertheless,
depending on the representation chosen for Grade, the addition may overflow. If the com-
piler represents Grade values as signed 8-bit integers (i.e., machine numbers in the range
-128 .. 127) then the sum G1 + G2 may exceed 127, resulting in an integer overflow. To
prevent this, you can use explicit conversions and perform the computation in a sufficiently
large integer type, for example:
[Ada]
3 procedure Main is
4 type Grade is range 0 .. 100;
5
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Range_And_Explicit_Conversion
MD5: d317fd95099e49017c4a4c1c52b7f8be
Runtime output
99
Range checks are useful for detecting errors as early as possible. However, there may
be some impact on performance. Modern compilers do know how to remove redundant
checks, and you can deactivate these checks altogether if you have sufficient confidence
that your code will function correctly.
Types can be derived from the representation of any other type. The new derived type can
be associated with new constraints and operations. Going back to the Day example, one
can write:
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enum_Ranges_1
MD5: fd775ad4990d5636607d3a0d9b00044d
Since these are new types, implicit conversions are not allowed. In this case, it's more
natural to create a new set of constraints for the same type, instead of making completely
new ones. This is the idea behind subtypes in Ada. A subtype is a type with optional
additional constraints. For example:
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Enum_Ranges_2
MD5: 5bcbde5b9f1aea57ff172fcfc89e1c41
These declarations don't create new types, just new names for constrained ranges of their
base types.
The purpose of numeric ranges is to express some application-specific constraint that we
want the compiler to help us enforce. More importantly, we want the compiler to tell us
when that constraint cannot be met — when the underlying hardware cannot support the
range given. There are two things to consider:
• just a range constraint, such as A : Integer range 0 .. 10;, or
• a type declaration, such as type Result is range 0 .. 1_000_000_000;.
Both represent some sort of application-specific constraint, but in addition, the type declara-
tion promotes portability because it won't compile on targets that do not have a sufficiently
large hardware numeric type. That's a definition of portability that is preferable to having
something compile anywhere but not run correctly, as in C.
Unsigned integer numbers are quite common in embedded applications. In C, you can use
them by declaring unsigned int variables. In Ada, you have two options:
• declare custom unsigned range types;
– In addition, you can declare custom range subtypes or use existing subtypes such
as Natural.
• declare custom modular types.
The following table presents the main features of each type. We discuss these types right
after.
When declaring custom range types in Ada, you may use the full range in the same way
as in C. For example, this is the declaration of a 32-bit unsigned integer type and the X
variable in Ada:
[Ada]
3 procedure Main is
4 type Unsigned_Int_32 is range 0 .. 2 ** 32 - 1;
5
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Unsigned_32_Ada
MD5: 0a179ce327c022468f66b6814a981b62
Runtime output
X = 42
In C, when unsigned int has a size of 32 bits, this corresponds to the following declaration:
[C]
9 return 0;
10 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Unsigned_32_C
MD5: 546068de216de96282490e81a0f7df26
Runtime output
x = 42
Another strategy is to declare subtypes for existing signed types and specify just the range
that excludes negative numbers. For example, let's declare a custom 32-bit signed type
and its unsigned subtype:
[Ada]
3 procedure Main is
4 type Signed_Int_32 is range -2 ** 31 .. 2 ** 31 - 1;
5
10 X : Unsigned_Int_31 := 42;
11 begin
12 Put_Line ("X = " & Unsigned_Int_31'Image (X));
13 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Unsigned_31_Ada
MD5: 2ef2b5bfd54821ceb35faa222e649156
Runtime output
X = 42
In this case, we're just skipping the sign bit of the Signed_Int_32 type. In other words,
while Signed_Int_32 has a size of 32 bits, Unsigned_Int_31 has a range of 31 bits, even
if the base type has 32 bits.
Note that the declaration above is actually similar to the existing Natural subtype. Ada
provides the following standard subtypes:
Since they're standard subtypes, you can declare variables of those subtypes directly in
your implementation, in the same way as you can declare Integer variables.
As indicated in the table above, however, there is a difference in behavior for the variables
we just declared, which occurs in case of overflow. Let's consider this C example:
[C]
11 return 0;
12 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Overflow_Wraparound_C
MD5: 7d5dcf65471304ff8f303195359b4790
Runtime output
x = 0
3 procedure Main is
4 type Unsigned_Int_32 is range 0 .. 2 ** 32 - 1;
5
6 X : Unsigned_Int_32 := Unsigned_Int_32'Last + 1;
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Overflow_Wraparound_Ada
MD5: ee4c3e905c59f5c8d87311e13d079836
Build output
Runtime output
While the C uses modulo arithmetic for unsigned integer, Ada doesn't use it for the Un-
signed_Int_32 type. Ada does, however, support modular types via type definitions using
the mod keyword. In this example, we declare a 32-bit modular type:
[Ada]
3 procedure Main is
4 type Unsigned_32 is mod 2**32;
5
6 X : Unsigned_32 := Unsigned_32'Last + 1;
7 -- Now: X = 0
8 begin
9 Put_Line ("X = " & Unsigned_32'Image (X));
10 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Overflow_Wraparound_Ada
MD5: 4ed963ab372cafc8e7a19d9c3107276b
Runtime output
X = 0
2.11.6 Attributes
Attributes start with a single apostrophe ("tick"), and they allow you to query properties of,
and perform certain actions on, declared entities such as types, objects, and subprograms.
For example, you can determine the first and last bounds of scalar types, get the sizes
of objects and types, and convert values to and from strings. This section provides an
overview of how attributes work. For more information on the many attributes defined by
the language, you can refer directly to the Ada Language Reference Manual.
The 'Image and 'Value attributes allow you to transform a scalar value into a String and
vice-versa. For example:
[Ada]
3 procedure Main is
4 A : Integer := 10;
5 begin
6 Put_Line (Integer'Image (A));
7 A := Integer'Value ("99");
8 Put_Line (Integer'Image (A));
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Image_Attribute
MD5: 1fcfc79ec599a26e21aef7eacffcf96e
Runtime output
10
99
Important
Semantically, attributes are equivalent to subprograms. For example, Integer'Image is
defined as follows:
Certain attributes are provided only for certain kinds of types. For example, the 'Val and
'Pos attributes for an enumeration type associates a discrete value with its position among
its peers. One circuitous way of moving to the next character of the ASCII table is:
[Ada]
3 procedure Main is
4 C : Character := 'a';
5 begin
6 Put (C);
7 C := Character'Val (Character'Pos (C) + 1);
8 Put (C);
9 end Main;
Runtime output
ab
A more concise way to get the next value in Ada is to use the 'Succ attribute:
[Ada]
3 procedure Main is
4 C : Character := 'a';
5 begin
6 Put (C);
7 C := Character'Succ (C);
8 Put (C);
9 end Main;
Runtime output
ab
You can get the previous value using the 'Pred attribute. Here is the equivalent in C:
[C]
10 return 0;
11 }
Runtime output
ab
Other interesting examples are the 'First and 'Last attributes which, respectively, return
the first and last values of a scalar type. Using 32-bit integers, for instance, Integer'First
returns -231 and Integer'Last returns 231 - 1.
C arrays are pointers with offsets, but the same is not the case for Ada. Arrays in Ada are
not interchangeable with operations on pointers, and array types are considered first-class
citizens. They have dedicated semantics such as the availability of the array's boundaries
at run-time. Therefore, unhandled array overflows are impossible unless checks are sup-
pressed. Any discrete type can serve as an array index, and you can specify both the
starting and ending bounds — the lower bound doesn't necessarily have to be 0. Most of
the time, array types need to be explicitly declared prior to the declaration of an object of
that array type.
Here's an example of declaring an array of 26 characters, initializing the values from 'a'
to 'z':
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Character;
5 Arr : Arr_Type (1 .. 26);
6 C : Character := 'a';
7 begin
8 for I in Arr'Range loop
9 Arr (I) := C;
10 C := Character'Succ (C);
11
14 if I mod 7 = 0 then
15 New_Line;
16 end if;
17 end loop;
18 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Range_Ada
MD5: 8e0597f6c040c740b35c79bc4706829b
Runtime output
a b c d e f g
h i j k l m n
o p q r s t u
v w x y z
[C]
12 if ((I + 1) % 7 == 0) {
13 printf ("\n");
14 }
15 }
16
17 return 0;
18 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Range_C
MD5: 1182155f46a0b69f73cd5937c23ed67d
Runtime output
a b c d e f g
h i j k l m n
o p q r s t u
v w x y z
In C, only the size of the array is given during declaration. In Ada, array index ranges are
specified using two values of a discrete type. In this example, the array type declaration
specifies the use of Integer as the index type, but does not provide any constraints (use
<>, pronounced box, to specify "no constraints"). The constraints are defined in the object
declaration to be 1 to 26, inclusive. Arrays have an attribute called 'Range. In our example,
Arr'Range can also be expressed as Arr'First .. Arr'Last; both expressions will resolve
to 1 .. 26. So the 'Range attribute supplies the bounds for our for loop. There is no risk
of stating either of the bounds incorrectly, as one might do in C where I <= 26 may be
specified as the end-of-loop condition.
As in C, Ada String is an array of Character. Ada strings, importantly, are not delimited
with the special character '0' like they are in C. It is not necessary because Ada uses the
array's bounds to determine where the string starts and stops.
Ada's predefined String type is very straightforward to use:
[Ada]
3 procedure Main is
4 My_String : String (1 .. 19) := "This is an example!";
5 begin
6 Put_Line (My_String);
7 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Constrained_String
MD5: da2e88900c670f80b7380f87f2b89ec2
Runtime output
This is an example!
Unlike C, Ada does not offer escape sequences such as 'n'. Instead, explicit values from
the ASCII package must be concatenated (via the concatenation operator, &). Here for
example, is how to initialize a line of text ending with a new line:
[Ada]
3 procedure Main is
4 My_String : String := "This is a line" & ASCII.LF;
5 begin
6 Put (My_String);
7 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Constrained_String
MD5: 684bbbdf99d48ed6fd5c257183a6609f
Runtime output
This is a line
You see here that no constraints are necessary for this variable definition. The initial value
given allows the automatic determination of My_String's bounds.
Ada offers high-level operations for copying, slicing, and assigning values to arrays. We'll
start with assignment. In C, the assignment operator doesn't make a copy of the value of
an array, but only copies the address or reference to the target variable. In Ada, the actual
array contents are duplicated. To get the above behavior, actual pointer types would have
to be defined and used.
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Integer;
5 A1 : Arr_Type (1 .. 2);
6 A2 : Arr_Type (1 .. 2);
7 begin
8 A1 (1) := 0;
9 A1 (2) := 1;
10
11 A2 := A1;
12
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Copy_Ada
MD5: 4d4e9aa063c1f488e7cefa90083d06c2
Runtime output
0
1
[C]
9 A1 [0] = 0;
10 A1 [1] = 1;
11
18 return 0;
19 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Copy_C
MD5: 0dade800452673b7a82afe1c656f07e6
Runtime output
0
1
In all of the examples above, the source and destination arrays must have precisely the
same number of elements. Ada allows you to easily specify a portion, or slice, of an array.
So you can write the following:
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Integer;
5 A1 : Arr_Type (1 .. 10) := (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
6 A2 : Arr_Type (1 .. 5) := (1, 2, 3, 4, 5);
7 begin
8 A2 (1 .. 3) := A1 (4 .. 6);
9
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Slice
MD5: cb2a7de2cff8ea19025363886f8821e4
Runtime output
4
5
6
4
5
This assigns the 4th, 5th, and 6th elements of A1 into the 1st, 2nd, and 3rd elements of A2.
Note that only the length matters here: the values of the indexes don't have to be equal;
they slide automatically.
Ada also offers high level comparison operations which compare the contents of arrays as
opposed to their addresses:
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Integer;
5 A1 : Arr_Type (1 .. 2) := (10, 20);
6 A2 : Arr_Type (1 .. 2) := (10, 20);
7 begin
8 if A1 = A2 then
9 Put_Line ("A1 = A2");
10 else
11 Put_Line ("A1 /= A2");
12 end if;
13 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Equal_Ada
MD5: 650a734875a02b2fb3678bbc3f8dd82a
Runtime output
A1 = A2
[C]
8 int eq = 1;
9
17 if (eq) {
18 printf("A1 == A2\n");
(continues on next page)
24 return 0;
25 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Equal_C
MD5: efe8717d931324bcbe8b70b03693c92e
Runtime output
A1 == A2
You can assign to all the elements of an array in each language in different ways. In Ada, the
number of elements to assign can be determined by looking at the right-hand side, the left-
hand side, or both sides of the assignment. When bounds are known on the left-hand side,
it's possible to use the others expression to define a default value for all the unspecified
array elements. Therefore, you can write:
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Integer;
5 A1 : Arr_Type (-2 .. 42) := (others => 0);
6 begin
7 -- use a slice to assign A1 elements 11 .. 19 to 1
8 A1 (11 .. 19) := (others => 1);
9
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Assignment_Ada
MD5: 673d31f633a32b6bb1cce238150cfc80
Runtime output
---- A1 ----
-2 => 0
-1 => 0
0 => 0
1 => 0
2 => 0
3 => 0
4 => 0
5 => 0
6 => 0
7 => 0
(continues on next page)
In this example, we're specifying that A1 has a range between -2 and 42. We use (others
=> 0) to initialize all array elements with zero. In the next example, the number of elements
is determined by looking at the right-hand side:
[Ada]
3 procedure Main is
4 type Arr_Type is array (Integer range <>) of Integer;
5 A1 : Arr_Type := (1, 2, 3, 4, 5, 6, 7, 8, 9);
6 begin
7 A1 := (1, 2, 3, others => 10);
8
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Array_Assignment_Ada
MD5: 3e3d69815373d1c61208df265903e89d
Runtime output
---- A1 ----
-2147483648 => 1
-2147483647 => 2
-2147483646 => 3
-2147483645 => 10
-2147483644 => 10
-2147483643 => 10
-2147483642 => 10
-2147483641 => 10
-2147483640 => 10
The structure corresponding to a C struct is an Ada record. Here are some simple records:
[Ada]
3 procedure Main is
4 type R is record
5 A, B : Integer;
6 C : Float;
7 end record;
8
9 V : R;
10 begin
11 V.A := 0;
12 Put_Line ("V.A = " & Integer'Image (V.A));
13 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Struct_Ada
MD5: 013f27dfc827355f32bea37fb267df9b
Runtime output
V.A = 0
[C]
3 struct R {
4 int A, B;
5 float C;
6 };
7
14 return 0;
15 }
Runtime output
V.A = 0
Ada allows specification of default values for fields just like C. The values specified can take
the form of an ordered list of values, a named list of values, or an incomplete list followed
by others => <> to specify that fields not listed will take their default values. For example:
[Ada]
3 procedure Main is
4
5 type R is record
6 A, B : Integer := 0;
7 C : Float := 0.0;
8 end record;
9
23 begin
24 Put_R (V1, "V1");
25 Put_R (V2, "V2");
26 Put_R (V3, "V3");
27 Put_R (V4, "V4");
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Struct_Default_Ada
MD5: d0a9713e3bd9804c00ebf68cc7c196b7
Runtime output
V1 = ( 1, 2, 1.00000E+00)
V2 = ( 1, 2, 1.00000E+00)
V3 = ( 1, 2, 1.00000E+00)
V4 = ( 0, 0, 1.00000E+00)
2.11.9 Pointers
As a foreword to the topic of pointers, it's important to keep in mind the fact that most
situations that would require a pointer in C do not in Ada. In the vast majority of cases,
indirect memory management can be hidden from the developer and thus saves from many
potential errors. However, there are situation that do require the use of pointers, or said
differently that require to make memory indirection explicit. This section will present Ada
access types, the equivalent of C pointers. A further section will provide more details as to
how situations that require pointers in C can be done without access types in Ada.
We'll continue this section by explaining the difference between objects allocated on the
stack and objects allocated on the heap using the following example:
[Ada]
3 procedure Main is
4 type R is record
5 A, B : Integer;
6 end record;
7
15 V1, V2 : R;
16
17 begin
18 V1.A := 0;
19 V2 := V1;
20 V2.A := 1;
21
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Pointers_Ada
MD5: dd1367d57574a46df830884b2a7be930
Runtime output
V1 = ( 0, 0)
V2 = ( 1, 0)
[C]
3 struct R {
4 int A, B;
5 };
6
20 print_r(&V1, "V1");
21 print_r(&V2, "V2");
22
23 return 0;
24 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Pointers_C
MD5: 4b4b79789444339b504bddc01d2d43da
Runtime output
V1 = (0, 0)
V2 = (1, 0)
There are many commonalities between the Ada and C semantics above. In Ada and C,
objects are allocated on the stack and are directly accessed. V1 and V2 are two different
objects and the assignment statement copies the value of V1 into V2. V1 and V2 are two
distinct objects.
Here's now a similar example, but using heap allocation instead:
[Ada]
3 procedure Main is
4 type R is record
(continues on next page)
17 V1 : R_Access;
18 V2 : R_Access;
19 begin
20 V1 := new R;
21 V1.A := 0;
22 V2 := V1;
23 V2.A := 1;
24
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Heap_Alloc_Ada
MD5: 963b48bb0a8585a9941d8fb2d0eda390
Runtime output
V1 = ( 1, 0)
V2 = ( 1, 0)
[C]
4 struct R {
5 int A, B;
6 };
7
22 print_r(V1, "V1");
23 print_r(V2, "V2");
(continues on next page)
25 return 0;
26 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Heap_Alloc_C
MD5: 5c832377403dfa8f00d70ef92bfeff65
Runtime output
V1 = (1, 0)
V2 = (1, 0)
In this example, an object of type R is allocated on the heap. The same object is then
referred to through V1 and V2. As in C, there's no garbage collector in Ada, so objects
allocated by the new operator need to be expressly freed (which is not the case here).
Dereferencing is performed automatically in certain situations, for instance when it is clear
that the type required is the dereferenced object rather than the pointer itself, or when ac-
cessing record members via a pointer. To explicitly dereference an access variable, append
.all. The equivalent of V1->A in C can be written either as V1.A or V1.all.A.
Pointers to scalar objects in Ada and C look like:
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Access_To_Scalars
MD5: 2e2bf53a9b5dc1098921d811be73a7f0
[C]
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Pointers_To_Scalars
MD5: f22d7b6f8170587009b0f6bb1299c0a0
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Access_Initialization
MD5: 5789253068f77100eec34919b8de66ec
When using Ada pointers to reference objects on the stack, the referenced objects must
be declared as being aliased. This directs the compiler to implement the object using a
memory region, rather than using registers or eliminating it entirely via optimization. The
access type needs to be declared as either access all (if the referenced object needs to
be assigned to) or access constant (if the referenced object is a constant). The 'Access
attribute works like the C & operator to get a pointer to the object, but with a scope acces-
sibility check to prevent references to objects that have gone out of scope. For example:
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Access_All
MD5: 520df34083e3517876e10710530380be
[C]
6 return 0;
7 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Access_All_C
MD5: a592fcf09dabe15f2aaf12fba047d74f
To deallocate objects from the heap in Ada, it is necessary to use a deallocation subprogram
that accepts a specific access type. A generic procedure is provided that can be customized
to fit your needs, it's called Ada.Unchecked_Deallocation. To create your customized
deallocator (that is, to instantiate this generic), you must provide the object type as well as
the access type as follows:
[Ada]
3 procedure Main is
4 type Integer_Access is access all Integer;
5 procedure Free is new Ada.Unchecked_Deallocation (Integer, Integer_Access);
6 My_Pointer : Integer_Access := new Integer;
7 begin
8 Free (My_Pointer);
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Unchecked_Deallocation
MD5: ef6ee170fea1f6c6c01037a09809916f
[C]
8 return 0;
9 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Free
MD5: 066046816dd1c4f9106b5e822cfe5e44
Subroutines in C are always expressed as functions which may or may not return a value.
Ada explicitly differentiates between functions and procedures. Functions must return a
value and procedures must not. Ada uses the more general term subprogram to refer to
both functions and procedures.
Parameters can be passed in three distinct modes:
• in, which is the default, is for input parameters, whose value is provided by the caller
and cannot be changed by the subprogram.
• out is for output parameters, with no initial value, to be assigned by the subprogram
and returned to the caller.
• in out is a parameter with an initial value provided by the caller, which can be mod-
ified by the subprogram and returned to the caller (more or less the equivalent of a
non-constant pointer in C).
Ada also provides access and aliased parameters, which are in effect explicit pass-by-
reference indicators.
In Ada, the programmer specifies how the parameter will be used and in general the com-
piler decides how it will be passed (i.e., by copy or by reference). C has the programmer
specify how to pass the parameter.
Important
There are some exceptions to the "general" rule in Ada. For example, parameters of scalar
types are always passed by copy, for all three modes.
3 procedure Proc
4 (Var1 : Integer;
5 Var2 : out Integer;
6 Var3 : in out Integer)
7 is
8 begin
9 Var2 := Func (Var1);
10 Var3 := Var3 + 1;
11 end Proc;
4 procedure Main is
5 V1, V2 : Integer;
6 begin
7 V2 := 2;
8 Proc (5, V1, V2);
9
Runtime output
V1: 6
V2: 3
[C]
3 void Proc
4 (int Var1,
5 int * Var2,
6 int * Var3)
7 {
8 *Var2 = Func (Var1);
9 *Var3 += 1;
10 }
8 v2 = 2;
9 Proc (5, &v1, &v2);
10
14 return 0;
15 }
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Subroutines_C
MD5: dd5645c832ef00b94061f204852084a3
Runtime output
v1: 6
v2: 3
The first two declarations for Proc and Func are specifications of the subprograms which are
being provided later. Although optional here, it's still considered good practice to separately
define specifications and implementations in order to make it easier to read the program.
In Ada and C, a function that has not yet been seen cannot be used. Here, Proc can call
Func because its specification has been declared.
Parameters in Ada subprogram declarations are separated with semicolons, because com-
mas are reserved for listing multiple parameters of the same type. Parameter declaration
syntax is the same as variable declaration syntax (except for the modes), including de-
fault values for parameters. If there are no parameters, the parentheses must be omitted
entirely from both the declaration and invocation of the subprogram.
In Ada 202X
Ada 202X allows for using static expression functions, which are evaluated at compile time.
To achieve this, we can use an aspect — we'll discuss aspects later in this chapter (page 65).
An expression function is static when the Static aspect is specified. For example:
procedure Main is
begin
null;
end Main;
In this example, we declare X1 using an expression. In the declaration of X2, we call the
static expression function If_Then_Else. Both X1 and X2 have the same constant value.
2.12.2 Overloading
In C, function names must be unique. Ada allows overloading, in which multiple subpro-
grams can share the same name as long as the subprogram signatures (the parameter
types, and function return types) are different. The compiler will be able to resolve the
calls to the proper routines or it will reject the calls. For example:
[Ada]
9 end Machine;
19 end Machine;
4 procedure Main is
5 S : Status;
6 C : Code;
7 T : Threshold;
8 begin
9 S := On;
10 C := Get (S);
11 T := Get (S);
12
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Overloading_Ada
MD5: 909cdf00b629917f7131489702cc26f1
Runtime output
S: ON
C: 3
T: 1.00000E+01
The Ada compiler knows that an assignment to C requires a Code value. So, it chooses the
Get function that returns a Code to satisfy this requirement.
Operators in Ada are functions too. This allows you to define local operators that override
operators defined at an outer scope, and provide overloaded operators that operate on and
compare different types. To declare an operator as a function, enclose its "name" in quotes:
[Ada]
9 end Machine_2;
19 end Machine_2;
4 procedure Main is
5 I : Input;
6 begin
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Overloading_Eq
MD5: c5580f15c1b93f73fff3afc147cd15a1
Runtime output
2.12.3 Aspects
Aspect specifications allow you to define certain characteristics of a declaration using the
with keyword after the declaration:
For example, you can inline a subprogram by specifying the Inline aspect:
[Ada]
8 end Float_Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Inline_Aspect
MD5: 6e25e81e4015d907d50aa9cf4a0a3fab
9 end Float_Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Inline_Aspect
MD5: bd5df14dce9577a054f0ec612d5bbe40
Aspects and attributes might refer to the same kind of information. For example, we can
use the Size aspect to define the expected minimum size of objects of a certain type:
[Ada]
6 end My_Device_Types;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Size_Aspect
MD5: 049be992b876dba42cf091afc256db35
In the same way, we can use the size attribute to retrieve the size of a type or of an object:
[Ada]
5 procedure Show_Device_Types is
6 UInt10_Obj : constant UInt10 := 0;
7 begin
8 Put_Line ("Size of UInt10 type: " & Positive'Image (UInt10'Size));
9 Put_Line ("Size of UInt10 object: " & Positive'Image (UInt10_Obj'Size));
10 end Show_Device_Types;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Size_Aspect
MD5: 4e46ad9cf54276b381b960672daa03b9
Runtime output
We'll explain both Size aspect and Size attribute later in this course (page 97).
THREE
Concurrent and real-time programming are standard parts of the Ada language. As such,
they have the same semantics, whether executing on a native target with an OS such as
Linux, on a real-time operating system (RTOS) such as VxWorks, or on a bare metal target
with no OS or RTOS at all.
For resource-constrained systems, two subsets of the Ada concurrency facilities are defined,
known as the Ravenscar and Jorvik profiles. Though restricted, these subsets have highly
desirable properties, including: efficiency, predictability, analyzability, absence of dead-
lock, bounded blocking, absence of priority inversion, a real-time scheduler, and a small
memory footprint. On bare metal systems, this means in effect that Ada comes with its
own real-time kernel.
Enhanced portability and expressive power are the primary advantages of using the stan-
dard concurrency facilities, potentially resulting in considerable cost savings. For example,
with little effort, it is possible to migrate from Windows to Linux to a bare machine without
requiring any changes to the code. Thread management and synchronization is all done by
the implementation, transparently. However, in some situations, it’s critical to be able to
access directly the services provided by the platform. In this case, it’s always possible to
make direct system calls from Ada code. Several targets of the GNAT compiler provide this
sort of API by default, for example win32ada for Windows and Florist for POSIX systems.
On native and RTOS-based platforms GNAT typically provides the full concurrency facilities.
In contrast, on bare metal platforms GNAT typically provides the two standard subsets:
Ravenscar and Jorvik.
3.2 Tasks
Ada offers a high level construct called a task which is an independent thread of execution.
In GNAT, tasks are either mapped to the underlying OS threads, or use a dedicated kernel
when not available.
The following example will display the 26 letters of the alphabet twice, using two concurrent
tasks. Since there is no synchronization between the two threads of control in any of the
examples, the output may be interspersed.
[Ada]
69
Ada for the Embedded C Developer
Listing 1: main.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
6 task My_Task;
7
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.My_Task
MD5: 154702197f0c02f5750838e51a99f548
Runtime output
ABCDEFGHIJKLMNOPQRSTUVWXYZ
ABCDEFGHIJKLMNOPQRSTUVWXYZ
Any number of Ada tasks may be declared in any declarative region. A task declaration
is very similar to a procedure or package declaration. They all start automatically when
control reaches the begin. A block will not exit until all sequences of statements defined
within that scope, including those in tasks, have been completed.
A task type is a generalization of a task object; each object of a task type has the same
behavior. A declared object of a task type is started within the scope where it is declared,
and control does not leave that scope until the task has terminated.
Task types can be parameterized; the parameter serves the same purpose as an argument
to a constructor in Java. The following example creates 10 tasks, each of which displays a
subset of the alphabet contained between the parameter and the 'Z' Character. As with
the earlier example, since there is no synchronization among the tasks, the output may be
interspersed depending on the underlying implementation of the task scheduling algorithm.
[Ada]
Listing 2: my_tasks.ads
1 package My_Tasks is
2
5 end My_Tasks;
Listing 3: my_tasks.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
13 end My_Tasks;
Listing 4: main.adb
1 with My_Tasks; use My_Tasks;
2
3 procedure Main is
4 Dummy_Tab : array (0 .. 3) of My_Task ('W');
5 begin
6 null;
7 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.My_Task_Type
MD5: 81d88397b0548fdcc1ba31549a8de4fd
Runtime output
WXYZ
WXYZ
WXYZ
WXYZ
In Ada, a task may be dynamically allocated rather than declared statically. The task will
then start as soon as it has been allocated, and terminates when its work is completed.
[Ada]
Listing 5: main.adb
1 with My_Tasks; use My_Tasks;
2
3 procedure Main is
4 type Ptr_Task is access My_Task;
5
6 T : Ptr_Task;
7 begin
8 T := new My_Task ('W');
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.My_Task_Type
MD5: d88a96eecf50ebbcdfe9cb870f232a09
Runtime output
3.2. Tasks 71
Ada for the Embedded C Developer
WXYZ
3.3 Rendezvous
A rendezvous is a synchronization between two tasks, allowing them to exchange data and
coordinate execution. Let's consider the following example:
[Ada]
Listing 6: main.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Main is
4
5 task After is
6 entry Go;
7 end After;
8
15 begin
16 Put_Line ("Before");
17 After.Go;
18 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Rendezvous
MD5: b0a595b1eecac793e40b6d1d41171766
Runtime output
Before
After
The Go entry declared in After is the client interface to the task. In the task body, the
accept statement causes the task to wait for a call on the entry. This particular entry
and accept pair simply causes the task to wait until Main calls After.Go. So, even though
the two tasks start simultaneously and execute independently, they can coordinate via Go.
Then, they both continue execution independently after the rendezvous.
The entry/accept pair can take/pass parameters, and the accept statement can contain a
sequence of statements; while these statements are executed, the caller is blocked.
Let's look at a more ambitious example. The rendezvous below accepts parameters and
executes some code:
[Ada]
Listing 7: main.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Main is
(continues on next page)
5 task After is
6 entry Go (Text : String);
7 end After;
8
16 begin
17 Put_Line ("Before");
18 After.Go ("Main");
19 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Rendezvous_Params
MD5: 6430e88f5ae349128bb1f1d53f36251e
Runtime output
Before
After: Main
In the above example, the Put_Line is placed in the accept statement. Here's a possible
execution trace, assuming a uniprocessor:
1. At the begin of Main, task After is started and the main procedure is suspended.
2. After reaches the accept statement and is suspended, since there is no pending call
on the Go entry.
3. The main procedure is awakened and executes the Put_Line invocation, displaying
the string "Before".
4. The main procedure calls the Go entry. Since After is suspended on its accept state-
ment for this entry, the call succeeds.
5. The main procedure is suspended, and the task After is awakened to execute the
body of the accept statement. The actual parameter "Main" is passed to the accept
statement, and the Put_Line invocation is executed. As a result, the string "After:
Main" is displayed.
6. When the accept statement is completed, both the After task and the main proce-
dure are ready to run. Suppose that the Main procedure is given the processor. It
reaches its end, but the local task After has not yet terminated. The main procedure
is suspended.
7. The After task continues, and terminates since it is at its end. The main procedure is
resumed, and it too can terminate since its dependent task has terminated.
The above description is a conceptual model; in practice the implementation can perform
various optimizations to avoid unnecessary context switches.
3.3. Rendezvous 73
Ada for the Embedded C Developer
The accept statement by itself can only wait for a single event (call) at a time. The select
statement allows a task to listen for multiple events simultaneously, and then to deal with
the first event to occur. This feature is illustrated by the task below, which maintains an
integer value that is modified by other tasks that call Increment, Decrement, and Get:
[Ada]
Listing 8: counters.ads
1 package Counters is
2
3 task Counter is
4 entry Get (Result : out Integer);
5 entry Increment;
6 entry Decrement;
7 end Counter;
8
9 end Counters;
Listing 9: counters.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
29 end Counters;
4 procedure Main is
5 V : Integer;
6 begin
(continues on next page)
9 Counter.Get (V);
10 Put_Line ("Got value. Value = " & Integer'Image (V));
11
12 Counter.Increment;
13 Put_Line ("Incremented value.");
14
15 Counter.Increment;
16 Put_Line ("Incremented value.");
17
18 Counter.Get (V);
19 Put_Line ("Got value. Value = " & Integer'Image (V));
20
21 Counter.Decrement;
22 Put_Line ("Decremented value.");
23
24 Counter.Get (V);
25 Put_Line ("Got value. Value = " & Integer'Image (V));
26
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Selective_Rendezvous
MD5: 619d009bcfcd8053bc132b2e32a29249
Runtime output
Main started.
Got value. Value = 0
Incremented value.
Incremented value.
Got value. Value = 2
Decremented value.
Got value. Value = 1
Main finished.
Exiting Counter task...
When the task's statement flow reaches the select, it will wait for all four events — three
entries and a delay — in parallel. If the delay of five seconds is exceeded, the task will
execute the statements following the delay statement (and in this case will exit the loop,
in effect terminating the task). The accept bodies for the Increment, Decrement, or Get
entries will be otherwise executed as they're called. These four sections of the select state-
ment are mutually exclusive: at each iteration of the loop, only one will be invoked. This is
a critical point; if the task had been written as a package, with procedures for the various
operations, then a race condition could occur where multiple tasks simultaneously calling,
say, Increment, cause the value to only get incremented once. In the tasking version, if
multiple tasks simultaneously call Increment then only one at a time will be accepted, and
the value will be incremented by each of the tasks when it is accepted.
More specifically, each entry has an associated queue of pending callers. If a task calls one
of the entries and Counter is not ready to accept the call (i.e., if Counter is not suspended
at the select statement) then the calling task is suspended, and placed in the queue of
the entry that it is calling. From the perspective of the Counter task, at any iteration of the
loop there are several possibilities:
• There is no call pending on any of the entries. In this case Counter is suspended. It
will be awakened by the first of two events: a call on one of its entries (which will then
be immediately accepted), or the expiration of the five second delay (whose effect
Although the rendezvous may be used to implement mutually exclusive access to a shared
data object, an alternative (and generally preferable) style is through a protected object,
an efficiently implementable mechanism that makes the effect more explicit. A protected
object has a public interface (its protected operations) for accessing and manipulating the
object's components (its private part). Mutual exclusion is enforced through a conceptual
lock on the object, and encapsulation ensures that the only external access to the compo-
nents are through the protected operations.
Two kinds of operations can be performed on such objects: read-write operations by pro-
cedures or entries, and read-only operations by functions. The lock mechanism is imple-
mented so that it's possible to perform concurrent read operations but not concurrent write
or read/write operations.
Let's reimplement our earlier tasking example with a protected object called Counter:
[Ada]
3 protected Counter is
4 function Get return Integer;
5 procedure Increment;
6 procedure Decrement;
7 private
8 Value : Integer := 0;
9 end Counter;
10
11 end Counters;
9 procedure Increment is
10 begin
11 Value := Value + 1;
12 end Increment;
13
14 procedure Decrement is
(continues on next page)
20 end Counters;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Protected_Counter
MD5: f29f21621dfcf092580f6a130101788e
Having two completely different ways to implement the same paradigm might seem compli-
cated. However, in practice the actual problem to solve usually drives the choice between
an active structure (a task) or a passive structure (a protected object).
A protected object can be accessed through prefix notation:
[Ada]
4 procedure Main is
5 begin
6 Counter.Increment;
7 Counter.Decrement;
8 Put_Line (Integer'Image (Counter.Get));
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Protected_Counter
MD5: 704e3a382fe38caa11ecd3d46fcd2beb
Runtime output
A protected object may look like a package syntactically, since it contains declarations that
can be accessed externally using prefix notation. However, the declaration of a protected
object is extremely restricted; for example, no public data is allowed, no types can be
declared inside, etc. And besides the syntactic differences, there is a critical semantic
distinction: a protected object has a conceptual lock that guarantees mutual exclusion;
there is no such lock for a package.
Like tasks, it's possible to declare protected types that can be instantiated several times:
declare
protected type Counter is
-- as above
end Counter;
C1 : Counter;
C2 : Counter;
(continues on next page)
Protected objects and types can declare a procedure-like operation known as an entry. An
entry is somewhat similar to a procedure but includes a so-called barrier condition that
must be true in order for the entry invocation to succeed. Calling a protected entry is
thus a two step process: first, acquire the lock on the object, and then evaluate the barrier
condition. If the condition is true then the caller will execute the entry body. If the condition
is false, then the caller is placed in the queue for the entry, and relinquishes the lock.
Barrier conditions (for entries with non-empty queues) are reevaluated upon completion of
protected procedures and protected entries.
Here's an example illustrating protected entries: a protected type that models a binary
semaphore / persistent signal.
[Ada]
10 end Binary_Semaphores;
9 procedure Signal is
10 begin
11 Signaled := True;
12 end Signal;
13 end Binary_Semaphore;
14
15 end Binary_Semaphores;
4 procedure Main is
5 B : Binary_Semaphore;
6
7 task T1;
(continues on next page)
10 task body T1 is
11 begin
12 Put_Line ("Task T1 waiting...");
13 B.Wait;
14
24 task body T2 is
25 begin
26 Put_Line ("Task T2 waiting...");
27 B.Wait;
28
38 begin
39 Put_Line ("Main started.");
40 B.Signal;
41 Put_Line ("Main finished.");
42 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Protected_Binary_Semaphore
MD5: aa064a9ec056d44c4217e64cd05726a4
Runtime output
Task T1 waiting...
Task T2 waiting...
Main started.
Main finished.
Task T1.
Task T1 will signal...
Task T1 finished.
Task T2
Task T2 will signal...
Task T2 finished.
Ada concurrency features provide much further generality than what's been presented here.
For additional information please consult one of the works cited in the References section.
3.6 Ravenscar
The Ravenscar profile is a subset of the Ada concurrency facilities that supports determin-
ism, schedulability analysis, constrained memory utilization, and certification to the highest
integrity levels. Four distinct application domains are intended:
• hard real-time applications requiring predictability,
• safety-critical systems requiring formal, stringent certification,
• high-integrity applications requiring formal static analysis and verification,
• embedded applications requiring both a small memory footprint and low execution
overhead.
Tasking constructs that preclude analysis, either technically or economically, are disal-
lowed. You can use the pragma Profile (Ravenscar) to indicate that the Ravenscar
restrictions must be observed in your program.
Some of the examples we've seen above will be rejected by the compiler when using the
Ravenscar profile. For example:
[Ada]
5 end My_Tasks;
13 end My_Tasks;
5 procedure Main is
6 Tab : array (0 .. 3) of My_Task ('W');
7 begin
8 null;
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Ravenscar
MD5: b7518a039c2b4cecece1de63eeaa208f
Build output
This code violates the No_Task_Hierarchy restriction of the Ravenscar profile. This is due to
the declaration of Tab in the Main procedure. Ravenscar requires task declarations to be
done at the library level. Therefore, a simple solution is to create a separate package and
reference it in the main application:
[Ada]
3 package My_Task_Inst is
4
7 end My_Task_Inst;
3 with My_Task_Inst;
4
5 procedure Main is
6 begin
7 null;
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Concurrency.Ravenscar
MD5: b38943dc1c962b5e691f2b6d9933a3ec
Runtime output
WXYZ
WXYZ
WXYZ
WXYZ
Also, Ravenscar prohibits entries for tasks. For example, we're not allowed to write this
declaration:
You can use, however, one entry per protected object. As an example, the declaration of
the Binary_Semaphore type that we've discussed before compiles fine with Ravenscar:
3.6. Ravenscar 81
Ada for the Embedded C Developer
We could add more procedures and functions to the declaration of Binary_Semaphore, but
we wouldn't be able to add another entry when using Ravenscar.
Similar to the previous example with the task array declaration, objects of Bi-
nary_Semaphore cannot be declared in the main application:
procedure Main is
B : Binary_Semaphore;
begin
null;
end Main;
9 https://blog.adacore.com/theres-a-mini-rtos-in-my-language
FOUR
Ada supports a high level of abstractness and expressiveness. In some cases, the compiler
translates those constructs directly into machine code. However, there are many high-level
constructs for which a direct compilation would be difficult. In those cases, the compiler
links to a library containing an implementation of those high-level constructs: this is the
so-called run-time library.
One typical example of high-level constructs that can be cumbersome for direct machine
code generation is Ada source-code using tasking. In this case, linking to a low-level im-
plementation of multithreading support — for example, an implementation using POSIX
threads — is more straightforward than trying to make the compiler generate all the ma-
chine code.
In the case of GNAT, the run-time library is implemented using both C and Ada source-
code. Also, depending on the operating system, the library will interface with low-level
functionality from the target operating system.
There are basically two types of run-time libraries:
• the standard run-time library: in many cases, this is the run-time library available on
desktop operating systems or on some embedded platforms (such as ARM-Linux on a
Raspberry-Pi).
• the configurable run-time library: this is a capability that is used to create custom
run-time libraries for specific target devices.
Configurable run-time libraries are usually used for constrained target devices where sup-
port for the full library would be difficult or even impossible. In this case, configurable
run-time libraries may support just a subset of the full Ada language. There are many
reasons that speak for this approach:
• Some aspects of the Ada language may not translate well to limited operating systems.
• Memory constraints may require reducing the size of the run-time library, so that de-
velopers may need to replace or even remove parts of the library.
• When certification is required, those parts of the library that would require too much
certification effort can be removed.
When using a configurable run-time library, the compiler checks whether the library sup-
ports certain features of the language. If a feature isn't supported, the compiler will give
an error message.
You can find further information about the run-time library on this chapter of the GNAT
User's Guide Supplement for Cross Platforms10
10 https://docs.adacore.com/gnat_ugx-docs/html/gnat_ugx/gnat_ugx/the_gnat_configurable_run_time_facility.
html
83
Ada for the Embedded C Developer
We've seen in the previous chapters 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.
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 trade-off in terms of representation vs. performance. Let's start
with the following example:
[Ada]
type R is record
V : Integer range 0 .. 255;
B1 : Boolean;
B2 : Boolean;
end record
with Pack;
[C]
struct R {
unsigned int v:8;
bool b1;
bool b2;
};
The Ada and the C code above both represent efforts to create an object that's as small
as possible. Controlling data size is not 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 particular 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 (page 140) and mapping
structures to bit-fields (page 142) in chapter 6.
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 par-
ticularly useful when a specific layout is necessary; for example when interfacing with hard-
ware, a driver, or a communication protocol. Here's how to specify a specific data layout
based on the previous example:
[Ada]
type R is record
V : Integer range 0 .. 255;
B1 : Boolean;
B2 : Boolean;
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,
but you can have the compiler construct the most efficient methods for access, rather than
coding your own machine-dependent bit-level methods manually.
When performing low-level development, such as at the kernel or hardware driver level,
there can be times when it is 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 assem-
bly code insertion. You can specify a string to pass to the assembler as well as source-level
variables to be used for input and output:
[Ada]
Listing 1: get_processor_cycles.adb
1 with System.Machine_Code; use System.Machine_Code;
2 with Interfaces; use Interfaces;
3
14 Counter :=
15 Unsigned_64 (High) * 2 ** 32 +
16 Unsigned_64 (Low);
17
18 return Counter;
19 end Get_Processor_Cycles;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Assembly_Code
MD5: 092be19e223946ebb9fb9f4786003b94
Listing 2: signal_handlers.ads
1 with System.OS_Interface;
2
3 package Signal_Handlers is
4
10 --
11 -- Declaration of an interrupt handler for the "quit" interrupt:
12 --
13 procedure Handle_Quit_Signal
14 with Attach_Handler => System.OS_Interface.SIGQUIT;
15 end Quit_Handler;
16
17 end Signal_Handlers;
Listing 3: signal_handlers.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
10 procedure Handle_Quit_Signal is
11 begin
12 Put_Line ("Quit request detected!");
13 Quit_Request := True;
14 end Handle_Quit_Signal;
15
16 end Quit_Handler;
17
18 end Signal_Handlers;
Listing 4: test_quit_handler.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Signal_Handlers;
3
4 procedure Test_Quit_Handler is
5 Quit : Signal_Handlers.Quit_Handler;
6
7 begin
8 while True loop
9 delay 1.0;
10 exit when Quit.Requested;
11 end loop;
12
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Quit_Handler
MD5: d272c5bc59576444e09007a04a615ccf
The specification of the Signal_Handlers package from this example contains the decla-
ration 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 as-
pect 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
Many numerical applications typically use floating-point types to compute values. However,
in some platforms, a floating-point unit may not be available. Other platforms may have a
floating-point unit, but using it in certain numerical algorithms can be prohibitive in terms
of performance. For those cases, fixed-point arithmetic can be a good alternative.
The difference between fixed-point and floating-point types might not be so obvious when
looking at this code snippet:
[Ada]
Listing 5: fixed_definitions.ads
1 package Fixed_Definitions is
2
7 end Fixed_Definitions;
Listing 6: show_float_and_fixed_point.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
5 procedure Show_Float_And_Fixed_Point is
6 Float_Value : Float := 0.25;
7 Fixed_Value : Fixed := 0.25;
8 begin
9
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Fixed_Point
MD5: 881817bb310304bc285f01454ab446f7
Runtime output
Float_Value = 5.00000E-01
Fixed_Value = 0.5000000000
In this example, the application will show the value 0.5 for both Float_Value and
Fixed_Value.
The major difference between floating-point and fixed-point types is in the way the values
are stored. Values of ordinary fixed-point types are, in effect, scaled integers. The scaling
used for ordinary fixed-point types is defined by the type's small, which is derived from
the specified delta and, by default, is a power of two. Therefore, ordinary fixed-point types
are sometimes called binary fixed-point types. In that sense, ordinary fixed-point types can
be thought of being close to the actual representation on the machine. In fact, ordinary
fixed-point types make use of the available integer shift instructions, for example.
Another difference between floating-point and fixed-point types is that Ada doesn't provide
standard fixed-point types — except for the Duration type, which is used to represent an
interval of time in seconds. While the Ada standard specifies floating-point types such as
Float and Long_Float, we have to declare our own fixed-point types. Note that, in the
previous example, we have used a fixed-point type named Fixed: this type isn't part of the
standard, but must be declared somewhere in the source-code of our application.
The syntax for an ordinary fixed-point type is
By default, the compiler will choose a scale factor, or small, that is a power of 2 no greater
than <delta_value>.
For example, we may define a normalized range between -1.0 and 1.0 as following:
[Ada]
Listing 7: normalized_fixed_point_type.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Normalized_Fixed_Point_Type is
4 D : constant := 2.0 ** (-31);
5 type TQ31 is delta D range -1.0 .. 1.0 - D;
6 begin
7 Put_Line ("TQ31 requires " & Integer'Image (TQ31'Size) & " bits");
8 Put_Line ("The delta value of TQ31 is " & TQ31'Image (TQ31'Delta));
9 Put_Line ("The minimum value of TQ31 is " & TQ31'Image (TQ31'First));
10 Put_Line ("The maximum value of TQ31 is " & TQ31'Image (TQ31'Last));
11 end Normalized_Fixed_Point_Type;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Normalized_Fixed_Point_Type
MD5: 2fe6e9f9bd20d2cfab959d1c0273280b
Runtime output
In this example, we are defining a 32-bit fixed-point data type for our normalized range.
When running the application, we notice that the upper bound is close to one, but not
exactly one. This is a typical effect of fixed-point data types — you can find more details
in this discussion about the Q format12 . We may also rewrite this code with an exact type
definition:
[Ada]
Listing 8: normalized_adapted_fixed_point_type.ads
1 package Normalized_Adapted_Fixed_Point_Type is
2
3 type TQ31 is delta 2.0 ** (-31) range -1.0 .. 1.0 - 2.0 ** (-31);
4
5 end Normalized_Adapted_Fixed_Point_Type;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Normalized_Adapted_Fixed_Point_
↪Type
MD5: abe5f4e029c7c3c7a069890882b17f50
Listing 9: custom_fixed_point_range.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Ada.Numerics; use Ada.Numerics;
3
4 procedure Custom_Fixed_Point_Range is
5 type Inv_Trig is delta 2.0 ** (-15) * Pi range -Pi / 2.0 .. Pi / 2.0;
6 begin
7 Put_Line ("Inv_Trig requires " & Integer'Image (Inv_Trig'Size)
8 & " bits");
9 Put_Line ("The delta value of Inv_Trig is "
10 & Inv_Trig'Image (Inv_Trig'Delta));
11 Put_Line ("The minimum value of Inv_Trig is "
12 & Inv_Trig'Image (Inv_Trig'First));
13 Put_Line ("The maximum value of Inv_Trig is "
14 & Inv_Trig'Image (Inv_Trig'Last));
15 end Custom_Fixed_Point_Range;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Custom_Fixed_Point_Range
MD5: 0d9a4bc96191d1341bbb1c081555b613
Runtime output
In this example, we are defining a 16-bit type called Inv_Trig, which has a range from -π/2
to π/2.
All standard operations are available for fixed-point types. For example:
[Ada]
12 https://en.wikipedia.org/wiki/Q_(number_format)
3 procedure Fixed_Point_Op is
4 type TQ31 is delta 2.0 ** (-31) range -1.0 .. 1.0 - 2.0 ** (-31);
5
6 A, B, R : TQ31;
7 begin
8 A := 0.25;
9 B := 0.50;
10 R := A + B;
11 Put_Line ("R is " & TQ31'Image (R));
12 end Fixed_Point_Op;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Fixed_Point_Op
MD5: 78bafd93b25da898c00cc38c9d518e2a
Runtime output
R is 0.7500000000
4 #define SHIFT_FACTOR 32
5
31 printf("Original value\n");
32 display_fixed(fixed_value);
33
34 printf("... + 0.25\n");
35 fixed_value = add(fixed_value, TO_FIXED(0.25));
36 display_fixed(fixed_value);
37
38 printf("... * 0.5\n");
39 fixed_value = mult(fixed_value, TO_FIXED(0.5));
40 display_fixed(fixed_value);
41
42 return 0;
43 }
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Fixed_Point_C
MD5: 61016e8fc0dbc4d0eefd2c86915489e5
Runtime output
Original value
value (integer) = 536870912
value (float) = 0.25000
... + 0.25
value (integer) = 1073741824
value (float) = 0.50000
... * 0.5
value (integer) = 536870912
value (float) = 0.25000
Here, we declare the fixed-point type fixed based on int and two operations for it: addition
(via the add function) and multiplication (via the mult function). Note that, while fixed-
point addition is quite straightforward, multiplication requires right-shifting to match the
correct internal representation. In Ada, since fixed-point operations are part of the language
specification, they don't need to be emulated. Therefore, no extra effort is required from
the programmer.
Also note that the example above is very rudimentary, so it doesn't take some of the side-
effects of fixed-point arithmetic into account. In C, you have to manually take all side-effects
deriving from fixed-point arithmetic into account, while in Ada, the compiler takes care of
selecting the right operations for you.
Ada has built-in support for handling both volatile and atomic data. Let's start by discussing
volatile objects.
4.5.1 Volatile
A volatile13 object can be described as an object in memory whose value may change
between two consecutive memory accesses of a process A — even if process A itself hasn't
changed the value. This situation may arise when an object in memory is being shared by
multiple threads. For example, a thread B may modify the value of that object between two
read accesses of a thread A. Another typical example is the one of memory-mapped I/O14 ,
where the hardware might be constantly changing the value of an object in memory.
Because the value of a volatile object may be constantly changing, a compiler cannot gen-
erate code that stores the value of that object into a register and use the value from the
register in subsequent operations. Storing into a register is avoided because, if the value
is stored there, it would be outdated if another process had changed the volatile object in
the meantime. Instead, the compiler generates code in such a way that the process must
read the value of the volatile object from memory for each access.
Let's look at a simple example of a volatile variable in C:
[C]
14 return 0;
15 }
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Volatile_Object_C
MD5: 863c7dda4acb3286976a1edab29bab08
Runtime output
val: 999000.000
In this example, val has the modifier volatile, which indicates that the compiler must han-
dle val as a volatile object. Therefore, each read and write access in the loop is performed
by accessing the value of val in then memory.
This is the corresponding implementation in Ada:
13 https://en.wikipedia.org/wiki/Volatile_(computer_programming)
14 https://en.wikipedia.org/wiki/Memory-mapped_I/O
[Ada]
3 procedure Show_Volatile_Object is
4 Val : Long_Float with Volatile;
5 begin
6 Val := 0.0;
7 for I in 0 .. 999 loop
8 Val := Val + 2.0 * Long_Float (I);
9 end loop;
10
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Volatile_Object_Ada
MD5: aa1e276e64e69813bfc3e3ef39f3dd47
Runtime output
Val: 9.99000000000000E+05
In this example, Val has the Volatile aspect, which makes the object volatile. We can
also use the Volatile aspect in type declarations. For example:
[Ada]
3 procedure Show_Volatile_Type is
4 type Volatile_Long_Float is new Long_Float with Volatile;
5
6 Val : Volatile_Long_Float;
7 begin
8 Val := 0.0;
9 for I in 0 .. 999 loop
10 Val := Val + 2.0 * Volatile_Long_Float (I);
11 end loop;
12
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Volatile_Type
MD5: 41ecf028803a58ce244c421eaeb118e4
Runtime output
Val: 9.99000000000000E+05
Here, we're declaring a new type Volatile_Long_Float based on the Long_Float type
and using the Volatile aspect. Any object of this type is automatically volatile.
In addition to that, we can declare components of an array to be volatile. In this case, we
can use the Volatile_Components aspect in the array declaration. For example:
[Ada]
3 procedure Show_Volatile_Array_Components is
4 Arr : array (1 .. 2) of Long_Float with Volatile_Components;
5 begin
6 Arr := (others => 0.0);
7
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Volatile_Array_Components
MD5: 601d61dd01888c60ae1a51ec513138d5
Runtime output
Note that it's possible to use the Volatile aspect for the array declaration as well:
[Ada]
4.5.2 Atomic
An atomic object is an object that only accepts atomic reads and updates. The Ada standard
specifies that "for an atomic object (including an atomic component), all reads and updates
of the object as a whole are indivisible." In this case, the compiler must generate Assembly
code in such a way that reads and updates of an atomic object must be done in a single
instruction, so that no other instruction could execute on that same object before the read
or update completes.
In other contexts
Generally, we can say that operations are said to be atomic when they can be completed
without interruptions. This is an important requirement when we're performing operations
on objects in memory that are shared between multiple processes.
This definition of atomicity above is used, for example, when implementing databases.
However, for this section, we're using the term "atomic" differently. Here, it really means
that reads and updates must be performed with a single Assembly instruction.
For example, if we have a 32-bit object composed of four 8-bit bytes, the compiler cannot
generate code to read or update the object using four 8-bit store / load instructions, or even
two 16-bit store / load instructions. In this case, in order to maintain atomicity, the compiler
must generate code using one 32-bit store / load instruction.
Because of this strict definition, we might have objects for which the Atomic aspect cannot
be specified. Lots of machines support integer types that are larger than the native word-
sized integer. For example, a 16-bit machine probably supports both 16-bit and 32-bit
integers, but only 16-bit integer objects can be marked as atomic — or, more generally,
only objects that fit into at most 16 bits.
Atomicity may be important, for example, when dealing with shared hardware registers.
In fact, for certain architectures, the hardware may require that memory-mapped registers
are handled atomically. In Ada, we can use the Atomic aspect to indicate that an object is
atomic. This is how we can use the aspect to declare a shared hardware register:
[Ada]
3 procedure Show_Shared_HW_Register is
4 R : Integer
5 with Atomic, Address => System'To_Address (16#FFFF00A0#);
6 begin
7 null;
8 end Show_Shared_HW_Register;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Atomic_Object
MD5: 7ef148adf393819fc3fbc25eb45afe46
Note that the Address aspect allows for assigning a variable to a specific location in the
memory. In this example, we're using this aspect to specify the address of the memory-
mapped register. We'll discuss more about the Address aspect later in the section about
mapping structures to bit-fields (page 142) (in chapter 6).
In addition to atomic objects, we can declare atomic types and atomic array components
— similarly to what we've seen before for volatile objects. For example:
[Ada]
3 procedure Show_Shared_HW_Register is
4 type Atomic_Integer is new Integer with Atomic;
5
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Atomic_Types_Arrays
MD5: 11475b5152087eff7f36abfe2c5ae9a1
In this example, we're declaring the Atomic_Integer type, which is an atomic type. Objects
of this type — such as R in this example — are automatically atomic. This example also
includes the declaration of the Arr array, which has atomic components.
Previously, we've seen that we can use representation clauses (page 84) to specify a par-
ticular layout for a record type. As mentioned before, this is useful when interfacing with
hardware, drivers, or communication protocols. In this section, we'll extend this concept
for two specific use-cases: register overlays and data streams. Before we discuss those
use-cases, though, we'll first explain the Size aspect and the Size attribute.
The Size aspect indicates the minimum number of bits required to represent an object.
When applied to a type, the Size aspect is telling the compiler to not make record or array
components of a type T any smaller than X bits. Therefore, a common usage for this aspect
is to just confirm expectations: developers specify 'Size to tell the compiler that T should
fit X bits, and the compiler will tell them if they are right (or wrong).
When the specified size value is larger than necessary, it can cause objects to be bigger
in memory than they would be otherwise. For example, for some enumeration types, we
could say for type Enum'Size use 32; when the number of literals would otherwise have
required only a byte. That's useful for unchecked conversions because the sizes of the two
types need to be the same. Likewise, it's useful for interfacing with C, where enum types
are just mapped to the int type, and thus larger than Ada might otherwise require. We'll
discuss unchecked conversions later in the course (page 156).
Let's look at an example from an earlier chapter:
[Ada]
6 end My_Device_Types;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Size_Aspect
MD5: 049be992b876dba42cf091afc256db35
Here, we're saying that objects of type UInt10 must have at least 10 bits. In this case, if
the code compiles, it is a confirmation that such values can be represented in 10 bits when
packed into an enclosing record or array type.
If the size specified was larger than what the compiler would use by default, then it could
affect the size of objects. For example, for UInt10, anything up to and including 16 would
make no difference on a typical machine. However, anything over 16 would then push
the compiler to use a larger object representation. That would be important for unchecked
conversions, for example.
The Size attribute indicates the number of bits required to represent a type or an object.
We can use the size attribute to retrieve the size of a type or of an object:
[Ada]
5 procedure Show_Device_Types is
6 UInt10_Obj : constant UInt10 := 0;
7 begin
8 Put_Line ("Size of UInt10 type: " & Positive'Image (UInt10'Size));
9 Put_Line ("Size of UInt10 object: " & Positive'Image (UInt10_Obj'Size));
10 end Show_Device_Types;
Project: Courses.Ada_For_Embedded_C_Dev.Perspective.Size_Aspect
MD5: 4e46ad9cf54276b381b960672daa03b9
Runtime output
Here, we're retrieving the actual sizes of the UInt10 type and an object of that type. Note
that the sizes don't necessarily need to match. For example, although the size of UInt10
type is expected to be 10 bits, the size of UInt10_Obj may be 16 bits, depending on the
platform. Also, components of this type within composite types (arrays, records) will prob-
ably be 16 bits as well unless they are packed.
Register overlays make use of representation clauses to create a structure that facilitates
manipulating bits from registers. Let's look at a simplified example of a power manage-
ment controller containing registers such as a system clock enable register. Note that this
example is based on an actual architecture:
[Ada]
3 package Registers is
4
54 end Registers;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.PMC_Peripheral
MD5: d6f37976ca653d65d71ee5ea463df81c
First, we declare the system clock enable register — this is PMC_SCER_Register type in the
code example. Most of the bits in that register are reserved. However, we're interested
in bit #5, which is used to activate or deactivate the system clock. To achieve a correct
representation of this bit, we do the following:
• We declare the USBCLK component of this record using the USB_Clock_Enable type,
which has a size of one bit; and
• we use a representation clause to indicate that the USBCLK component is specifically
at bit #5 of byte #0.
After declaring the system clock enable register and specifying its individual bits as
components of a record type, we declare the power management controller type —
PMC_Peripheral record type in the code example. Here, we declare two 16-bit registers as
record components of PMC_Peripheral. These registers are used to enable or disable the
system clock. The strategy we use in the declaration is similar to the one we've just seen
above:
• We declare these registers as components of the PMC_Peripheral record type;
• we use a representation clause to specify that the PMC_SCER register is at byte #0 and
the PMC_SCDR register is at byte #2.
– Since these registers have 16 bits, we use a range of bits from 0 to 15.
The actual power management controller becomes accessible by the declaration of the
PMC_Periph object of PMC_Peripheral type. Here, we specify the actual address of the
memory-mapped registers (400E0600 in hexadecimal) using the Address aspect in the
declaration. When we use the Address aspect in an object declaration, we're indicating the
address in memory of that object.
Because we specify the address of the memory-mapped registers in the declaration of
PMC_Periph, this object is now an overlay for those registers. This also means that any
operation on this object corresponds to an actual operation on the registers of the power
management controller. We'll discuss more details about overlays in the section about
mapping structures to bit-fields (page 142) (in chapter 6).
Finally, in a test application, we can access any bit of any register of the power management
controller with simple record component selection. For example, we can set the USBCLK bit
of the PMC_SCER register by using PMC_Periph.PMC_SCER.USBCLK:
[Ada]
3 procedure Enable_USB_Clock is
4 begin
5 Registers.PMC_Periph.PMC_SCER.USBCLK := 1;
6 end Enable_USB_Clock;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.PMC_Peripheral
MD5: b8f35a80d5f04cd362e5309aef33a100
This code example makes use of many aspects and keywords of the Ada language. One
of them is the Volatile aspect, which we've discussed in the section about volatile and
atomic objects (page 93). Using the Volatile aspect for the PMC_SCER_Register type
ensures that objects of this type won't be stored in a register.
In the declaration of the PMC_SCER_Register record type of the example, we use the
Bit_Order aspect to specify the bit ordering of the record type. Here, we can select one of
these options:
• High_Order_First: first bit of the record is the most significant bit;
• Low_Order_First: first bit of the record is the least significant bit.
The declarations from the Registers package also makes use of the Import, which is some-
times necessary when creating overlays. When used in the context of object declarations,
it avoids default initialization (for data types that have it.). Aspect Import will be discussed
in the section that explains how to map structures to bit-fields (page 142) in chapter 6.
Please refer to that chapter for more details.
3 procedure Show_Bit_Declaration is
4
8 B : constant Bit := 0;
9 -- ^ Although Bit'Size is 1, B'Size is almost certainly 8
10 begin
11 Put_Line ("Bit'Size = " & Positive'Image (Bit'Size));
12 Put_Line ("B'Size = " & Positive'Image (B'Size));
13 end Show_Bit_Declaration;
Runtime output
Bit'Size = 1
B'Size = 8
In this case, B is almost certainly going to be 8-bits wide on a typical machine, even though
the language requires that Bit'Size is 1 by default.
In the declaration of the components of the PMC_Peripheral record type, we use the
aliased keyword to specify that those record components are accessible via other paths
besides the component name. Therefore, the compiler won't store them in registers. This
makes sense because we want to ensure that we're accessing specific memory-mapped
registers, and not registers assigned by the compiler. Note that, for the same reason, we
also use the aliased keyword in the declaration of the PMC_Periph object.
Creating data streams — in the context of interfacing with devices — means the serialization
of arbitrary information and its transmission over a communication channel. For example,
we might want to transmit the content of memory-mapped registers as byte streams using
a serial port. To do this, we first need to get a serialized representation of those registers
as an array of bytes, which we can then transmit over the serial port.
Serialization of arbitrary record types — including register overlays — can be achieved by
declaring an array of bytes as an overlay. By doing this, we're basically interpreting the
information from those record types as bytes while ignoring their actual structure — i.e.
their components and representation clause. We'll discuss details about overlays in the
section about mapping structures to bit-fields (page 142) (in chapter 6).
Let's look at a simple example of serialization of an arbitrary record type:
[Ada]
9 end Arbitrary_Types;
9 --
10 -- We can access the serialized data in Raw_TX, which is our overlay
11 --
12 Raw_TX : UByte_Array (1 .. Some_Object'Size / 8)
13 with Address => Some_Object'Address;
14 begin
15 null;
16 --
17 -- Now, we could stream the data from Some_Object.
18 --
19 -- For example, we could send the bytes (from Raw_TX) via the
20 -- serial port.
21 --
22 end Serialize_Data;
4 procedure Data_Stream_Declaration is
5 Dummy_Object : Arbitrary_Types.Arbitrary_Record;
6
7 begin
8 Serialize_Data (Dummy_Object);
9 end Data_Stream_Declaration;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Data_Stream_Declaration
MD5: 1de6f518520010c28fd8deb29a2bf209
The most important part of this example is the implementation of the Serial-
ize_Data procedure, where we declare Raw_TX as an overlay for our arbitrary object
(Some_Object of Arbitrary_Record type). In simple terms, by writing with Address
=> Some_Object'Address; in the declaration of Raw_TX, we're specifying that Raw_TX and
Some_Object have the same address in memory. Here, we are:
• taking the address of Some_Object — using the Address attribute —, and then
• using it as the address of Raw_TX — which is specified with the Address aspect.
By doing this, we're essentially saying that both Raw_TX and Some_Object are different
representations of the same object in memory.
Because the Raw_TX overlay is completely agnostic about the actual structure of the record
type, the Arbitrary_Record type could really be anything. By declaring Raw_TX, we create
an array of bytes that we can use to stream the information from Some_Object.
We can use this approach and create a data stream for the register overlay example that
we've seen before. This is the corresponding implementation:
[Ada]
3 package Registers is
4
54 end Registers;
16 end Serial_Ports;
30 end Serial_Ports;
4 package Data_Stream is
5
12 end Data_Stream;
23 end Data_Stream;
3 with Registers;
4 with Data_Stream;
5 with Serial_Ports;
6
7 procedure Test_Data_Stream is
8
9 procedure Display_Registers is
10 use Ada.Text_IO;
11 begin
12 Put_Line ("---- Registers ----");
13 Put_Line ("PMC_SCER.USBCLK: "
14 & Registers.PMC_Periph.PMC_SCER.USBCLK'Image);
15 Put_Line ("PMC_SCDR.USBCLK: "
16 & Registers.PMC_Periph.PMC_SCDR.USBCLK'Image);
17 Put_Line ("-------------- ----");
18 end Display_Registers;
(continues on next page)
20 Port : Serial_Ports.Serial_Port;
21 begin
22 Registers.PMC_Periph.PMC_SCER.USBCLK := 1;
23 Registers.PMC_Periph.PMC_SCDR.USBCLK := 1;
24
25 Display_Registers;
26
33 Display_Registers;
34 end Test_Data_Stream;
Project: Courses.Ada_For_Embedded_C_Dev.Embedded.Data_Stream
MD5: 3f4e1a184e52a83b1b9de9e3d5cb43bf
Runtime output
In this example, we can find the overlay in the implementation of the Send and Receive
procedures from the Data_Stream package. Because the overlay doesn't need to know the
internals of the PMC_Peripheral type, we're declaring it in the same way as in the previous
example (where we created an overlay for Some_Object). In this case, we're creating an
overlay for the PMC parameter.
Note that, for this section, we're not really interested in the details about the serial port.
Thus, package Serial_Ports in this example is just a stub. However, because the Se-
rial_Port type in that package only sees arrays of bytes, after implementing an actual
serial port interface for a specific device, we could create data streams for any type.
As we've seen in the previous section about interfacing with devices (page 97), Ada offers
powerful features to describe low-level details about the hardware architecture without
giving up its strong typing capabilities. However, it can be cumbersome to create a specifi-
cation for all those low-level details when you have a complex architecture. Fortunately, for
ARM Cortex-M devices, the GNAT toolchain offers an Ada binding generator called svd2ada,
which takes CMSIS-SVD descriptions for those devices and creates Ada specifications that
match the architecture. CMSIS-SVD description files are based on the Cortex Microcon-
troller Software Interface Standard (CMSIS), which is a hardware abstraction layer for ARM
Cortex microcontrollers.
Please refer to the svd2ada project page15 for details about this tool.
15 https://github.com/AdaCore/svd2ada
FIVE
In Ada, several common programming errors that are not already detected at compile-time
are detected instead at run-time, triggering "exceptions" that interrupt the normal flow of
execution. For example, an exception is raised by an attempt to access an array component
via an index that is out of bounds. This simple check precludes exploits based on buffer
overflow. Several other cases also raise language-defined exceptions, such as scalar range
constraint violations and null pointer dereferences. Developers may declare and raise their
own application-specific exceptions too. (Exceptions are software artifacts, although an
implementation may map hardware events to exceptions.)
Exceptions are raised during execution of what we will loosely define as a "frame." A frame
is a language construct that has a call stack entry when called, for example a procedure or
function body. There are a few other constructs that are also pertinent but this definition
will suffice for now.
Frames have a sequence of statements implementing their functionality. They can also
have optional "exception handlers" that specify the response when exceptions are "raised"
by those statements. These exceptions could be raised directly within the statements, or
indirectly via calls to other procedures and functions.
For example, the frame below is a procedure including three exceptions handlers:
Listing 1: p.adb
1 procedure P is
2 begin
3 Statements_That_Might_Raise_Exceptions;
4 exception
5 when A =>
6 Handle_A;
7 when B =>
8 Handle_B;
9 when C =>
10 Handle_C;
11 end P;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exceptions
MD5: bf7a8740dfca9f3da993f054e22ca97d
The three exception handlers each start with the word when (lines 5, 7, and 9). Next comes
one or more exception identifiers, followed by the so-called "arrow." In Ada, the arrow always
associates something on the left side with something on the right side. In this case, the left
side is the exception name and the right side is the handler's code for that exception.
109
Ada for the Embedded C Developer
Each handler's code consists of an arbitrary sequence of statements, in this case specific
procedures called in response to those specific exceptions. If exception A is raised we call
procedure Handle_A (line 6), dedicated to doing the actual work of handling that exception.
The other two exceptions are dealt with similarly, on lines 8 and 10.
Structurally, the exception handlers are grouped together and textually separated from
the rest of the code in a frame. As a result, the sequence of statements representing the
normal flow of execution is distinct from the section representing the error handling. The
reserved word exception separates these two sections (line 4 above). This separation
helps simplify the overall flow, increasing understandability. In particular, status result
codes are not required so there is no mixture of error checking and normal processing. If no
exception is raised the exception handler section is automatically skipped when the frame
exits.
Note how the syntactic structure of the exception handling section resembles that of an
Ada case statement. The resemblance is intentional, to suggest similar behavior. When
something in the statements of the normal execution raises an exception, the corresponding
exception handler for that specific exception is executed. After that, the routine completes.
The handlers do not "fall through" to the handlers below. For example, if exception B is
raised, procedure Handle_B is called but Handle_C is not called. There's no need for a
break statement, just as there is no need for it in a case statement. (There's no break
statement in Ada anyway.)
So far, we've seen a frame with three specific exceptions handled. What happens if a frame
has no handler for the actual exception raised? In that case the run-time library code goes
"looking" for one.
Specifically, the active exception is propagated up the dynamic call chain. At each point in
the chain, normal execution in that caller is abandoned and the handlers are examined. If
that caller has a handler for the exception, the handler is executed. That caller then returns
normally to its caller and execution continues from there. Otherwise, propagation goes up
one level in the call chain and the process repeats. The search continues until a matching
handler is found or no callers remain. If a handler is never found the application terminates
abnormally. If the search reaches the main procedure and it has a matching handler it will
execute the handler, but, as always, the routine completes so once again the application
terminates.
For a concrete example, consider the following:
Listing 2: arrays.ads
1 package Arrays is
2
7 end Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exceptions
MD5: a2dfa05b56144e21d5796d39c88ceac2
Listing 3: arrays.adb
1 package body Arrays is
2
8 end Arrays;
Listing 4: some_process.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Arrays; use Arrays;
3
4 procedure Some_Process is
5 L : constant List (1 .. 100) := (others => 42);
6 begin
7 Put_Line (Integer'Image (Value (L, 1, 10)));
8 exception
9 when Constraint_Error =>
10 Put_Line ("Constraint_Error caught in Some_Process");
11 Put_Line ("Some_Process completes normally");
12 end Some_Process;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exceptions
MD5: 7733854601db37eb53f4c4094fe5ca0d
Listing 5: main.adb
1 with Some_Process;
2 with Ada.Text_IO; use Ada.Text_IO;
3
4 procedure Main is
5 begin
6 Some_Process;
7 Put_Line ("Main completes normally");
8 end Main;
Procedure Main calls Some_Process, which in turn calls function Value (line 7).
Some_Process declares the array object L of type List on line 5, with bounds 1 through
100. The call to Value has arguments, including variable L, leading to an attempt to access
an array component via an out-of-bounds index (1 + 10 * 10 = 101, beyond the last index
of L). This attempt will trigger an exception in Value prior to actually accessing the array
object's memory. Function Value doesn't have any exception handlers so the exception
is propagated up to the caller Some_Process. Procedure Some_Process has an exception
handler for Constraint_Error and it so happens that Constraint_Error is the exception
raised in this case. As a result, the code for that handler will be executed, printing some
messages on the screen. Then procedure Some_Process will return to Main normally. Main
then continues to execute normally after the call to Some_Process and prints its completion
message.
If procedure Some_Process had also not had a handler for Constraint_Error, that proce-
dure call would also have returned abnormally and the exception would have been propa-
gated further up the call chain to procedure Main. Normal execution in Main would likewise
be abandoned in search of a handler. But Main does not have any handlers so Main would
have completed abnormally, immediately, without printing its closing message.
This semantic model is the same as with many other programming languages, in which
the execution of a frame's sequence of statements is unavoidably abandoned when an
exception becomes active. The model is a direct reaction to the use of status codes returned
from functions as in C, where it is all too easy to forget (intentionally or otherwise) to check
the status values returned. With the exception model errors cannot be ignored.
However, full exception propagation as described above is not the norm for embedded
applications when the highest levels of integrity are required. The run-time library code
implementing exception propagation can be rather complex and expensive to certify. Those
problems apply to the application code too, because exception propagation is a form of
control flow without any explicit construct in the source. Instead of the full exception model,
designers of high-integrity applications often take alternative approaches.
One alternative consists of deactivating exceptions altogether, or more precisely, deac-
tivating language-defined checks, which means that the compiler will not generate code
checking for conditions giving rise to exceptions. Of course, this makes the code vulnerable
to attacks, such as buffer overflow, unless otherwise verified (e.g. through static analysis).
Deactivation can be applied at the unit level, through the -gnatp compiler switch, or lo-
cally within a unit via the pragma Suppress. (Refer to the GNAT User’s Guide for Native
Platforms16 for more details about the switch.)
For example, we can write the following. Note the pragma on line 4 of arrays.adb within
function Value:
Listing 6: arrays.ads
1 package Arrays is
2
7 end Arrays;
Listing 7: arrays.adb
1 package body Arrays is
2
9 end Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exception_Suppress
MD5: 62c37774cbcd5f167858d3b5268006aa
Listing 8: some_process.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Arrays; use Arrays;
3
4 procedure Some_Process is
5 L : constant List (1 .. 100) := (others => 42);
6 begin
7 Put_Line (Integer'Image (Value (L, 1, 10)));
8 exception
9 when Constraint_Error =>
10 Put_Line ("FAILURE");
11 end Some_Process;
This placement of the pragma will only suppress checks in the function body. However,
16 https://docs.adacore.com/gnat_ugn-docs/html/gnat_ugn/gnat_ugn/building_executable_programs_with_
gnat.html
that is where the exception would otherwise have been raised, leading to incorrect and
unpredictable execution. (Run the program more than once. If it prints the right answer
(42), or even the same value each time, it's just a coincidence.) As you can see, suppressing
checks negates the guarantee of errors being detected and addressed at run-time.
Another alternative is to leave checks enabled but not retain the dynamic call-chain prop-
agation. There are a couple of approaches available in this alternative.
The first approach is for the run-time library to invoke a global "last chance handler" (LCH)
when any exception is raised. Instead of the sequence of statements of an ordinary ex-
ception handler, the LCH is actually a procedure intended to perform "last-wishes" before
the program terminates. No exception handlers are allowed. In this scheme "propagation"
is simply a direct call to the LCH procedure. The default LCH implementation provided
by GNAT does nothing other than loop infinitely. Users may define their own replacement
implementation.
The availability of this approach depends on the run-time library. Typically, Zero Footprint
and Ravenscar SFP run-times will provide this mechanism because they are intended for
certification.
A user-defined LCH handler can be provided either in C or in Ada, with the following profiles:
[Ada]
[C]
We'll go into the details of the pragma Export in a further section on language interfacing.
For now, just know that the symbol __gnat_last_chance_handler is what the run-time
uses to branch immediately to the last-chance handler. Pragma Export associates that
symbol with this replacement procedure so it will be invoked instead of the default routine.
As a consequence, the actual procedure name in Ada is immaterial.
Here is an example implementation that simply blinks an LED forever on the target:
loop
Toggle (LCH_LED);
Next_Release := Next_Release + Period;
delay until Next_Release;
end loop;
end Last_Chance_Handler;
The LCH_LED is a constant referencing the LED used by the last-chance handler, declared
elsewhere. The infinite loop is necessary because a last-chance handler must never return
to the caller (hence the term "last-chance"). The LED changes state every half-second.
Unlike the approach in which there is only the last-chance handler routine, the other ap-
proach allows exception handlers, but in a specific, restricted manner. Whenever an ex-
ception is raised, the only handler that can apply is a matching handler located in the same
frame in which the exception is raised. Propagation in this context is simply an imme-
diate branch instruction issued by the compiler, going directly to the matching handler's
sequence of statements. If there is no matching local handler the last chance handler is
invoked. For example consider the body of function Value in the body of package Arrays:
Listing 9: arrays.ads
1 package Arrays is
2
7 end Arrays;
11 end Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exception_Return
MD5: 1f63b92739deb03529884ab0d25dadb8
4 procedure Some_Process is
5 L : constant List (1 .. 100) := (others => 42);
6 begin
7 Put_Line (Integer'Image (Value (L, 1, 10)));
8 exception
9 when Constraint_Error =>
10 Put_Line ("FAILURE");
11 end Some_Process;
In both procedure Some_Process and function Value we have an exception handler for
Constraint_Error. In this example the exception is raised in Value because the index
check fails there. A local handler for that exception is present so the handler applies and
the function returns zero, normally. Because the call to the function returns normally, the
execution of Some_Process prints zero and then completes normally.
Let's imagine, however, that function Value did not have a handler for Constraint_Error.
In the context of full exception propagation, the function call would return to the caller, i.e.,
Some_Process, and would be handled in that procedure's handler. But only local handlers
are allowed under the second alternative so the lack of a local handler in Value would
result in the last-chance handler being invoked. The handler for Constraint_Error in
Some_Process under this alternative approach.
So far we've only illustrated handling the Constraint_Error exception. It's possible to
handle other language-defined and user-defined exceptions as well, of course. It is even
possible to define a single handler for all other exceptions that might be encountered in
the handled sequence of statements, beyond those explicitly named. The "name" for this
otherwise anonymous exception is the Ada reserved word others. As in case statements,
it covers all other choices not explicitly mentioned, and so must come last. For example:
7 end Arrays;
13 end Arrays;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Exception_Return_Others
MD5: 7c2ed7efa23242f502a6cf4767da0192
4 procedure Some_Process is
5 L : constant List (1 .. 100) := (others => 42);
6 begin
7 Put_Line (Integer'Image (Value (L, 1, 10)));
8 exception
9 when Constraint_Error =>
10 Put_Line ("FAILURE");
11 end Some_Process;
In the code above, the Value function has a handler specifically for Constraint_Error
as before, but also now has a handler for all other exceptions. For any exception other
than Constraint_Error, function Value returns -1. If you remove the function's handler
for Constraint_Error (lines 7 and 8) then the other "anonymous" handler will catch the
exception and -1 will be returned instead of zero.
There are additional capabilities for exceptions, but for now you have a good basic under-
standing of how exceptions work, especially their dynamic nature at run-time.
So far, we have discussed language-defined checks inserted by the compiler for verification
at run-time, leading to exceptions being raised. We saw that these dynamic checks verified
semantic conditions ensuring proper execution, such as preventing writing past the end of
a buffer, or exceeding an application-specific integer range constraint, and so on. These
checks are defined by the language because they apply generally and can be expressed in
language-defined terms.
Developers can also define dynamic checks. These checks specify component-specific or
application-specific conditions, expressed in terms defined by the component or applica-
tion. We will refer to these checks as "user-defined" for convenience. (Be sure you under-
stand that we are not talking about user-defined exceptions here.)
Like the language-defined checks, user-defined checks must be true at run-time. All checks
consist of Boolean conditions, which is why we can refer to them as assertions: their con-
ditions are asserted to be true by the compiler or developer.
Assertions come in several forms, some relatively low-level, such as a simple pragma As-
sert, and some high-level, such as type invariants and contracts. These forms will be
presented in detail in a later section, but we will illustrate some of them here.
User-defined checks can be enabled at run-time in GNAT with the -gnata switch, as well as
with pragma Assertion_Policy. The switch enables all forms of these assertions, whereas
the pragma can be used to control specific forms. The switch is typically used but there are
reasonable use-cases in which some user-defined checks are enabled, and others, although
defined, are disabled.
By default in GNAT, language-defined checks are enabled but user-defined checks are dis-
abled. Here's an example of a simple program employing a low-level assertion. We can use
it to show the effects of the switches, including the defaults:
3 procedure Main is
4 X : Positive := 10;
5 begin
6 X := X * 5;
7 pragma Assert (X > 99);
8 X := X - 99;
9 Put_Line (Integer'Image (X));
10 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Low_Level_Assertion
MD5: 2eb5e1879740cc3914acb8a362995b31
If we compiled this code we would get a warning about the assignment on line 8 after the
pragma Assert, but not one about the Assert itself on line 7.
gprbuild -q -P main.gpr
main.adb:8:11: warning: value not in range of type "Standard.Positive"
main.adb:8:11: warning: "Constraint_Error" will be raised at run time
No code is generated for the user-defined check expressed via pragma Assert but the
language-defined check is emitted. In this case the range constraint on X excludes zero
and negative numbers, but X * 5 = 50, X - 99 = -49. As a result, the check for the last
assignment would fail, raising Constraint_Error when the program runs. These results
are the expected behavior for the default switch settings.
But now let's enable user-defined checks and build it. Different compiler output will appear.
3 procedure Main is
4 X : Positive := 10;
5 begin
6 X := X * 5;
7 pragma Assert (X > 99);
8 X := X - 99;
9 Put_Line (Integer'Image (X));
10 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Assert
MD5: 2eb5e1879740cc3914acb8a362995b31
Build output
Runtime output
Now we also get the compiler warning about the pragma Assert condition. When
run, the failure of pragma Assert on line 7 raises the exception Ada.Assertions.
Assertion_Error. According to the expression in the assertion, X is expected (incor-
rectly) to be above 99 after the multiplication. (The exception name in the error mes-
sage, SYSTEM.ASSERTIONS.ASSERT_FAILURE, is a GNAT-specific alias for Ada.Assertions.
Assertion_Error.)
It's interesting to see in the output that the compiler can detect some violations at compile-
time:
Generally speaking, a complete analysis is beyond the scope of compilers and they may
not find all errors prior to execution, even those we might detect ourselves by inspection.
More errors can be found by tools dedicated to that purpose, known as static analyzers. But
even an automated static analysis tool cannot guarantee it will find all potential problems.
A much more powerful alternative is formal proof, a form of static analysis that can (when
possible) give strong guarantees about the checks, for all possible conditions and all pos-
sible inputs. Proof can be applied to both language-defined and user-defined checks.
Be sure you understand that formal proof, as a form of static analysis, verifies conditions
prior to execution, even prior to compilation. That earliness provides significant cost ben-
efits. Removing bugs earlier is far less expensive than doing so later because the cost to
fix bugs increases exponentially over the phases of the project life cycle, especially after
deployment. Preventing bug introduction into the deployed system is the least expensive
approach of all. Furthermore, cost savings during the initial development will be possible
as well, for reasons specific to proof. We will revisit this topic later in this section.
Formal analysis for proof can be achieved through the SPARK subset of the Ada language
combined with the gnatprove verification tool. SPARK is a subset encompassing most of
the Ada language, except for features that preclude proof. As a disclaimer, this course is
not aimed at providing a full introduction to proof and the SPARK language, but rather to
present in a few examples what it is about and what it can do for us.
As it turns out, our procedure Main is already SPARK compliant so we can start verifying it.
3 procedure Main is
4 X : Positive := 10;
5 begin
6 X := X * 5;
7 pragma Assert (X > 99);
8 X := X - 99;
9 Put_Line (Integer'Image (X));
10 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Assert
MD5: 98cad2c7e7b7a12740db013727f01d45
Build output
Prover output
Runtime output
The "Prove" button invokes gnatprove on main.adb. You can ignore the parameters to the
invocation. For the purpose of this demonstration, the interesting output is this message:
main.adb:7:19: medium: assertion might fail, cannot prove X > 99 (e.g. when X = 50)
gnatprove can tell that the assertion X > 99 may have a problem. There's indeed a bug
here, and gnatprove even gives us the counterexample (when X is 50). As a result the code
is not proven and we know we have an error to correct.
Notice that the message says the assertion "might fail" even though clearly gnatprove has
an example for when failure is certain. That wording is a reflection of the fact that SPARK
gives strong guarantees when the assertions are proven to hold, but does not guarantee
that flagged problems are indeed problems. In other words, gnatprove does not give false
positives but false negatives are possible. The result is that if gnatprove does not indicate
a problem for the code under analysis we can be sure there is no problem, but if gnatprove
does indicate a problem the tool may be wrong.
An immediate benefit from having our code compatible with the SPARK subset is that we
can ask gnatprove to verify initialization and correct data flow, as indicated by the absence
of messages during SPARK "flow analysis." Flow analysis detects programming errors such
as reading uninitialized data, problematic aliasing between formal parameters, and data
races between concurrent tasks.
In addition, gnatprove checks unit specifications for the actual data read or written, and the
flow of information from inputs to outputs. As you can imagine, this verification provides
significant benefits, and it can be reached with comparatively low cost.
For example, the following illustrates an initialization failure:
4 procedure Main is
5 B : Integer;
6 begin
7 Increment (B);
8 Put_Line (B'Image);
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Contracts_0
MD5: 06d432a84d94635bb7bddafd9574a748
Prover output
Granted, Increment is a silly procedure as-is, but imagine it did useful things, and, as part
of that, incremented the argument. gnatprove tells us that the caller has not assigned a
value to the argument passed to Increment.
Consider this next routine, which contains a serious coding error. Flow analysis will find it
for us.
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Contracts_1
MD5: af7f16a9c83359c49fde44ed4796c8ec
Prover output
So far, we've seen assertions in a routine's sequence of statements, either through implicit
language-defined checks (is the index in the right range?) or explicit user-defined checks.
These checks are already useful by themselves but they have an important limitation: the
assertions are in the implementation, hidden from the callers of the routine. For example,
a call's success or failure may depend upon certain input values but the caller doesn't have
that information.
Generally speaking, Ada and SPARK put a lot of emphasis on strong, complete specifications
for the sake of abstraction and analysis. Callers need not examine the implementations to
determine whether the arguments passed to it are changed, for example. It is possible to go
beyond that, however, to specify implementation constraints and functional requirements.
We use contracts to do so.
At the language level, contracts are higher-level forms of assertions associated with speci-
fications and declarations rather than sequences of statements. Like other assertions they
can be activated or deactivated at run-time, and can be statically proven. We'll concen-
trate here on two kinds of contracts, both associated especially (but not exclusively) with
procedures and functions:
• Preconditions, those Boolean conditions required to be true prior to a call of the cor-
responding subprogram
• Postconditions, those Boolean conditions required to be true after a call, as a result of
the corresponding subprogram's execution
In particular, preconditions specify the initial conditions, if any, required for the called rou-
tine to correctly execute. Postconditions, on the other hand, specify what the called rou-
tine's execution must have done, at least, on normal completion. Therefore, preconditions
are obligations on callers (referred to as "clients") and postconditions are obligations on
implementers. By the same token, preconditions are guarantees to the implementers, and
postconditions are guarantees to clients.
Contract-based programming, then, is the specification and rigorous enforcement of these
obligations and guarantees. Enforcement is rigorous because it is not manual, but tool-
based: dynamically at run-time with exceptions, or, with SPARK, statically, prior to build.
Preconditions are specified via the "Pre" aspect. Postconditions are specified via the "Post"
aspect. Usually subprograms have separate declarations and these aspects appear with
those declarations, even though they are about the bodies. Placement on the declarations
allows the obligations and guarantees to be visible to all parties. For example:
The precondition on line 2 specifies that, for any given call, the sum of the values passed
to parameters X and Y must not be zero. (Perhaps we're dividing by X + Y in the body.) The
declaration also provides a guarantee about the function call's result, via the postcondition
on line 3: for any given call, the value returned will be greater than the value passed to X.
Consider a client calling this function:
4 procedure Demo is
5 A, B, C : Integer;
6 begin
7 A := Mid (1, 2);
8 B := Mid (1, -1);
9 C := Mid (A, B);
10 Put_Line (C'Image);
11 end Demo;
Prover output
Phase 1 of 2: generation of Global contracts ...
Phase 2 of 2: flow analysis and proof ...
(continues on next page)
gnatprove indicates that the assignment to B (line 8) might fail because of the precondition,
i.e., the sum of the inputs shouldn't be 0, yet -1 + 1 = 0. (We will address the other output
message elsewhere.)
Let's change the argument passed to Y in the second call (line 8). Instead of -1 we will pass
-2:
4 procedure Demo is
5 A, B, C : Integer;
6 begin
7 A := Mid (1, 2);
8 B := Mid (1, -2);
9 C := Mid (A, B);
10 Put_Line (C'Image);
11 end Demo;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Contracts_3
MD5: 496937d76e16ba524f98f5a94398e929
Prover output
The second call will no longer be flagged for the precondition. In addition, gnatprove will
know from the postcondition that A has to be greater than 1, as does B, because in both
calls 1 was passed to X. Therefore, gnatprove can deduce that the precondition will hold
for the third call C := Mid (A, B); because the sum of two numbers greater than 1 will
never be zero.
Postconditions can also compare the state prior to a call with the state after a call, using
the 'Old attribute. For example:
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Contracts_4
MD5: b879dcff91cb4fbce5501474b7f2e732
Prover output
The postcondition specifies that, on return, the argument passed to the parameter Value
will be one greater than it was immediately prior to the call (Value'Old).
10 end P;
10 V := Data (Index);
11 Index := Index + 1;
12 end Read;
13 end P;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Defensive
MD5: 4b4767100079b228f4f3c630d267ec53
Prover output
In addition to procedure Read we would also have a way to load the array components in
the first place, but we can ignore that for the purpose of this discussion.
Procedure Read is responsible for reading an element of the array and then incrementing
the index. What should it do in case of an invalid index? In this implementation there is
defensive code that returns a value arbitrarily chosen. We could also redesign the code to
return a status in this case, or — better — raise an exception.
An even more robust approach would be instead to ensure that this subprogram is only
called when Index is within the indexing boundaries of Data. We can express that require-
ment with a precondition (line 9).
11 end P;
9 end P;
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Defensive
MD5: 9646614c34d191be51b4522c972538aa
Prover output
Now we don't need the defensive code in the procedure body. That's safe because SPARK
will attempt to prove statically that the check will not fail at the point of each call.
Assuming that procedure Read is intended to be the only way to get values from the array,
in a real application (where the principles of software engineering apply) we would take ad-
vantage of the compile-time visibility controls that packages offer. Specifically, we would
move all the variables' declarations to the private part of the package, or even the pack-
age body, so that client code could not possibly access the array directly. Only procedure
Read would remain visible to clients, thus remaining the only means of accessing the ar-
ray. However, that change would entail others, and in this chapter we are only concerned
with introducing the capabilities of SPARK. Therefore, we keep the examples as simple as
possible.
Earlier we said that gnatprove will verify both language-defined and user-defined checks.
Proving that the language-defined checks will not raise exceptions at run-time is known as
proving "Absence of Run-Time Errors" or AoRTE for short. Successful proof of these checks
is highly significant in itself.
One of the major resulting benefits is that we can deploy the final executable with checks
disabled. That has obvious performance benefits, but it is also a safety issue. If we disable
the checks we also disable the run-time library support for them, but in that case the lan-
guage does not define what happens if indeed an exception is raised. Formally speaking,
anything could happen. We must have good reason for thinking that exceptions cannot be
raised.
This is such an important issue that proof of AoRTE can be used to comply with the objec-
tives of certification standards in various high-integrity domains (for example, DO-178B/C in
avionics, EN 50128 in railway, IEC 61508 in many safety-related industries, ECSS-Q-ST-80C
in space, IEC 60880 in nuclear, IEC 62304 in medical, and ISO 26262 in automotive).
As a result, the quality of the program can be guaranteed to achieve higher levels of in-
tegrity than would be possible in other programming languages.
However, successful proof of AoRTE may require additional assertions, especially precon-
ditions. We can see that with procedure Increment, the procedure that takes an Integer
argument and increments it by one. But of course, if the incoming value of the argument
is the largest possible positive value, the attempt to increment it would overflow, raising
Constraint_Error. (As you have likely already concluded, Constraint_Error is the most
common exception you will have to deal with.) We added a precondition to allow only the
integer values up to, but not including, the largest positive value:
Project: Courses.Ada_For_Embedded_C_Dev.SPARK.Contracts_5
MD5: b879dcff91cb4fbce5501474b7f2e732
Prover output
Prove it, then comment-out the precondition and try proving it again. Not only will gnat-
prove tell us what is wrong, it will suggest a solution as well.
Without the precondition the check it provides would have to be implemented as defensive
code in the body. One or the other is critical here, but note that we should never need both.
The postcondition on Increment expresses what is, in fact, a unit-level requirement. Suc-
cessfully proving such requirements is another significant robustness and cost benefit. To-
gether with the proofs for initialization and AoRTE, these proofs ensure program integrity,
that is, the program executes within safe boundaries: the control flow of the program is cor-
rectly programmed and cannot be circumvented through run-time errors, and data cannot
be corrupted.
We can go even further. We can use contracts to express arbitrary abstract properties when
such exist. Safety and security properties, for instance, could be expressed as postcondi-
tions and then proven by gnatprove.
For example, imagine we have a procedure to move a train to a new position on the track,
and we want to do so safely, without leading to a collision with another train. Procedure
Move, therefore, takes two inputs: a train identifier specifying which train to move, and the
intended new position. The procedure's output is a value indicating a motion command to
be given to the train in order to go to that new position. If the train cannot go to that new
position safely the output command is to stop the train. Otherwise the command is for the
train to continue at an indicated speed:
procedure Move
(Train : in Train_Id;
New_Position : in Train_Position;
Result : out Move_Result)
with
Pre => Valid_Id (Train) and
(continues on next page)
The preconditions specify that, given a safe initial state and a valid move, the result of the
call will also be a safe state: there will be at most one train per track section and the track
signaling system will not allow any unsafe movements.
Make sure you understand that gnatprove does not attempt to prove the program correct
as a whole. It attempts to prove language-defined and user-defined assertions about parts
of the program, especially individual routines and calls to those routines. Furthermore,
gnatprove proves the routines correct only to the extent that the user-defined assertions
correctly and sufficiently describe and constrain the implementation of the corresponding
routines.
Although we are not proving whole program correctness, as you will have seen — and done
— we can prove properties than make our software far more robust and bug-free than is
possible otherwise. But in addition, consider what proving the unit-level requirements for
your procedures and functions would do for the cost of unit testing and system integration.
The tests would pass the first time.
However, within the scope of what SPARK can do, not everything can be proven. In some
cases that is because the software behavior is not amenable to expression as boolean
conditions (for example, a mouse driver). In other cases the source code is beyond the
capabilities of the analyzers that actually do the mathematical proof. In these cases the
combination of proof and actual test is appropriate, and still less expensive that testing
alone.
There is, of course, much more to be said about what can be done with SPARK and gnat-
prove. Those topics are reserved for the Introduction to SPARK course.
SIX
One question that may arise relatively soon when converting from C to Ada is the style of
source code presentation. The Ada language doesn't impose any particular style and for
many reasons, it may seem attractive to keep a C-like style — for example, camel casing
— to the Ada program.
However, the code in the Ada language standard, most third-party code, and the libraries
provided by GNAT follow a specific style for identifiers and reserved words. Using a different
style for the rest of the program leads to inconsistencies, thereby decreasing readability and
confusing automatic style checkers. For those reasons, it's usually advisable to adopt the
Ada style — in which each identifier starts with an upper case letter, followed by lower case
letters (or digits), with an underscore separating two "distinct" words within the identifier.
Acronyms within identifiers are in upper case. For example, there is a language-defined
package named Ada.Text_IO. Reserved words are all lower case.
Following this scheme doesn't preclude adding additional, project-specific rules.
Before even considering translating code from C to Ada, it's worthwhile to evaluate the
possibility of keeping a portion of the C code intact, and only translating selected modules
to Ada. This is a necessary evil when introducing Ada to an existing large C codebase,
where re-writing the entire code upfront is not practical nor cost-effective.
Fortunately, Ada has a dedicated set of features for interfacing with other languages. The
Interfaces package hierarchy and the pragmas Convention, Import, and Export allow
you to make inter-language calls while observing proper data representation for each lan-
guage.
Let's start with the following C code:
[C]
Listing 1: call.c
1 #include <stdio.h>
2
3 struct my_struct {
4 int A, B;
5 };
6
129
Ada for the Embedded C Developer
To call that function from Ada, the Ada compiler requires a description of the data struc-
ture to pass as well as a description of the function itself. To capture how the C struct
my_struct is represented, we can use the following record along with a pragma Conven-
tion. The pragma directs the compiler to lay out the data in memory the way a C compiler
would.
[Ada]
Listing 2: use_my_struct.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Interfaces.C;
3
4 procedure Use_My_Struct is
5
Runtime output
V = ( 1 2)
Describing a foreign subprogram call to Ada code is called binding and it is performed in
two stages. First, an Ada subprogram specification equivalent to the C function is coded.
A C function returning a value maps to an Ada function, and a void function maps to an
Ada procedure. Then, rather than implementing the subprogram using Ada code, we use a
pragma Import:
procedure Call (V : my_struct);
pragma Import (C, Call, "call"); -- Third argument optional
The Import pragma specifies that whenever Call is invoked by Ada code, it should invoke
the Call function with the C calling convention.
And that's all that's necessary. Here's an example of a call to Call:
[Ada]
Listing 3: use_my_struct.adb
1 with Interfaces.C;
2
Project: Courses.Ada_For_Embedded_C_Dev.Translation.My_Struct
MD5: 9b54edadd406c7f5a2b9f8b8f82a4a88
The easiest way to build an application using mixed C / Ada code is to create a simple
project file for gprbuild and specify C as an additional language. By default, when using
gprbuild we only compile Ada source files. To compile C code files as well, we use the
Languages attribute and specify c as an option, as in the following example of a project file
named default.gpr:
project Default is
end Default;
Then, we use this project file to build the application by simply calling gprbuild. Alterna-
tively, we can specify the project file on the command-line with the -P option — for example,
gprbuild -P default.gpr. In both cases, gprbuild compiles all C source-code file found
in the directory and links the corresponding object files to build the executable.
In order to include debug information, you can use gprbuild -cargs -g. This option adds
debug information based on both C and Ada code to the executable. Alternatively, you can
specify a Builder package in the project file and include global compilation switches for
each language using the Global_Compilation_Switches attribute. For example:
project Default is
package Builder is
for Global_Compilation_Switches ("Ada") use ("-g");
for Global_Compilation_Switches ("C") use ("-g");
end Builder;
end Default;
In this case, you can simply run gprbuild -P default.gpr to build the executable.
To debug the executable, you can use programs such as gdb or ddd, which are suitable for
debugging both C and Ada source-code. If you prefer a complete IDE, you may want to look
into GNAT Studio, which supports building and debugging an application within a single
environment, and remotely running applications loaded to various embedded devices. You
can find more information about gprbuild and GNAT Studio in the Introduction to GNAT
Toolchain course.
It may be useful to start interfacing Ada and C by using automatic binding generators. These
can be done either by invoking gcc -fdump-ada-spec option (to generate an Ada binding
to a C header file) or -gnatceg option (to generate a C binding to an Ada specification file).
For example:
The level of interfacing is very low level and typically requires either massaging (changing
the generated files) or wrapping (calling the generated files from a higher level interface).
For example, numbers bound from C to Ada are only standard numbers where user-defined
types may be desirable. C uses a lot of by-pointer parameters which may be better replaced
by other parameter modes, etc.
However, the automatic binding generator helps having a starting point which ensures
compatibility of the Ada and the C code.
It is relatively straightforward to pass an array from Ada to C. In particular, with the GNAT
compiler, passing an array is equivalent to passing a pointer to its first element. Of course,
as there's no notion of boundaries in C, the length of the array needs to be passed explicitly.
For example:
[C]
Listing 4: p.h
1 void p (int * a, int length);
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Arr_1
MD5: 123353e301a3d43016d2799855e6732a
[Ada]
Listing 5: main.adb
1 procedure Main is
2 type Arr is array (Integer range <>) of Integer;
3
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Arr_1
MD5: 9bfbc0f31da4554a1e1dea1ba2b1d305
The other way around — that is, retrieving an array that has been creating on the C side —
is more difficult. Because C doesn't explicitly carry boundaries, they need to be recreated
in some way.
The first option is to actually create an Ada array without boundaries. This is the most
flexible, but also the least safe option. It involves creating an array with indices over the
full range of Integer without ever creating it from Ada, but instead retrieving it as an access
from C. For example:
[C]
Listing 6: f.h
1 int * f ();
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Arr_2
MD5: 19e33efb6d7d46778b88baa2709111e5
[Ada]
Listing 7: main.adb
1 procedure Main is
2 type Arr is array (Integer) of Integer;
3 type Arr_A is access all Arr;
4
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Arr_2
MD5: b52213bcdd8db5e8abfcb8effabb84df
Note that Arr is a constrained type (it doesn't have the range <> notation for indices). For
that reason, as it would be for C, it's possible to iterate over the whole range of integer,
beyond the memory actually allocated for the array.
A somewhat safer way is to overlay an Ada array over the C one. This requires having
access to the length of the array. This time, let's consider two cases, one with an array
and its size accessible through functions, another one on global variables. This time, as
we're using an overlay, the function will be directly mapped to an Ada function returning
an address:
[C]
Listing 8: fg.h
1 int * f_arr (void);
2 int f_size (void);
3
4 int * g_arr;
5 int g_size;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Arr_3
MD5: b315ec2e5d9fdd297ba295ccbae910bc
[Ada]
Listing 9: fg.ads
1 with System;
2
3 package Fg is
4
15 G_Size : Integer;
16 pragma Import (C, G_Size, "g_size");
17
21 end Fg;
3 procedure Main is
4 begin
5 null;
6 end Main;
With all solutions though, importing an array from C is a relatively unsafe pattern, as there's
only so much information on the array as there would be on the C side in the first place.
These are good places for careful peer reviews.
When interfacing Ada and C, the rules of parameter passing are a bit different with regards
to what's a reference and what's a copy. Scalar types and pointers are passed by value,
whereas record and arrays are (almost) always passed by reference. However, there may
be cases where the C interface also passes values and not pointers to objects. Here's a
slightly modified version of a previous example to illustrate this point:
[C]
3 struct my_struct {
4 int A, B;
5 };
6
In Ada, a type can be modified so that parameters of this type can always be passed by
copy.
[Ada]
3 procedure Main is
4 type my_struct is record
5 A : Interfaces.C.int;
6 B : Interfaces.C.int;
7 end record
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Param_By_Value_Ada
MD5: 16e97033bdffb2bacc0cf3322c019a94
Note that this cannot be done at the subprogram declaration level, so if there is a mix of
by-copy and by-reference calls, two different types need to be used on the Ada side.
Because of the absence of namespaces, any global name in C tends to be very long. And
because of the absence of overloading, they can even encode type names in their type.
In Ada, the package is a namespace — two entities declared in two different packages are
clearly identified and can always be specifically designated. The C names are usually a
good indication of the names of the future packages and should be stripped — it is possible
to use the full name if useful. For example, here's how the following declaration and call
could be translated:
[C]
7 return 0;
8 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Namespaces
MD5: e8c25da648a2e8662d97a9a5b863a5bc
[Ada]
7 end Register_Interface;
3 procedure Main is
4 begin
5 Register_Interface.Initialize (15);
6 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Namespaces
MD5: 934edd7d3c74d058f862a786582a32c0
Note that in the above example, a use clause on Register_Interface could allow us to
omit the prefix.
6.8 Pointers
The first thing to ask when translating pointers from C to Ada is: are they needed in the
first place? In Ada, pointers (or access types) should only be used with complex structures
that cannot be allocated at run-time — think of a linked list or a graph for example. There
are many other situations that would need a pointer in C, but do not in Ada, in particular:
• Arrays, even when dynamically allocated
• Results of functions
• Passing large structures as parameters
• Access to registers
• ... others
This is not to say that pointers aren't used in these cases but, more often than not, the
pointer is hidden from the user and automatically handled by the code generated by the
compiler; thus avoiding possible mistakes from being made. Generally speaking, when
looking at C code, it's good practice to start by analyzing how many pointers are used and
to translate as many as possible into pointerless Ada structures.
Here are a few examples of such patterns — additional examples can be found throughout
this document.
Dynamically allocated arrays can be directly allocated on the stack:
[C]
3 int main() {
4 int *a = malloc(sizeof(int) * 10);
5
6 return 0;
7 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Array_Stack_Alloc_C
MD5: a922c3e163494339d6773c6ab1256549
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Array_Stack_Alloc_Ada
MD5: 2e4196c2a2016244a48de153beaa2b49
Build output
main.adb:3:04: warning: variable "A" is never read and never assigned [-gnatwv]
It's even possible to create a such an array within a structure, provided that the size of the
array is known when instantiating this object, using a type discriminant:
[C]
3 typedef struct {
4 int * a;
5 } S;
6
13 return 0;
14 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Struct_Array_Stack_Alloc_C
MD5: f8e5a877977387986b3e2353834a2989
[Ada]
8 V : S (9);
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Struct_Array_Stack_Alloc_Ada
MD5: 955c704bdbe4b2b788e4a790ade12df7
Build output
main.adb:8:04: warning: variable "V" is never read and never assigned [-gnatwv]
With regards to parameter passing, usage mode (input / output) should be preferred to
implementation mode (by copy or by reference). The Ada compiler will automatically pass
a reference when needed. This works also for smaller objects, so that the compiler will copy
in an out when needed. One of the advantages of this approach is that it clarifies the nature
of the object: in particular, it differentiates between arrays and scalars. For example:
[C]
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Array_In_Out_C
MD5: c2c936dd3afc4850c5869e4db73bb36b
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Array_In_Out_Ada
MD5: cf8e51391c9fd8608183c9dae2aa2802
Most of the time, access to registers end up in some specific structures being mapped onto
a specific location in memory. In Ada, this can be achieved through an Address clause
associated to a variable, for example:
[C]
5 return 0;
6 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Address_C
MD5: e810538d72d835a04736fcaf732f1930
[Ada]
3 procedure Test is
4 R : Integer with Address => System'To_Address (16#FFFF00A0#);
5 begin
6 null;
7 end Test;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Address_Ada
MD5: 1263f7289cec6673f19d88bffbeead48
These are some of the most common misuse of pointers in Ada. Previous sections of the
document deal with specifically using access types if absolutely necessary.
Bitwise operations such as masks and shifts in Ada should be relatively rarely needed, and,
when translating C code, it's good practice to consider alternatives. In a lot of cases, these
operations are used to insert several pieces of data into a larger structure. In Ada, this can
be done by describing the structure layout at the type level through representation clauses,
and then accessing this structure as any other.
Consider the case of using a C primitive type as a container for single bit boolean flags. In
C, this would be done through masks, e.g.:
[C]
12 return 0;
13 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Flags_C
MD5: cf903dee1fb1d78d74dc42b66adcdbd5
In Ada, the above can be represented through a Boolean array of enumerate values:
[Ada]
6 Value : Value_Array :=
7 (Flag_2 => True,
8 Flag_4 => True,
9 others => False);
10 begin
11 null;
12 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Flags_Ada
MD5: c92c8532763469f5e4d1027df2bd6a6b
Note the Pack directive for the array, which requests that the array takes as little space as
possible.
It is also possible to map records on memory when additional control over the representation
is needed or more complex data are used:
[C]
5 value = (2 << 1) | 1;
6
7 return 0;
8 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Rec_Map_C
MD5: 16606f11ab3e9c86d3e1d88ac9c3f37f
[Ada]
3 procedure Main is
4 type Value_Type is mod 2 ** 32;
5 pragma Provide_Shift_Operators (Value_Type);
6
7 Value : Value_Type;
8 begin
9 Value := Shift_Left (2, 1) or 1;
10 Put_Line ("Value = " & Value_Type'Image (Value));
11 end Main;
Runtime output
Value = 5
In the previous section, we've seen how to perform bitwise operations. In this section, we
look at how to interpret a data type as a bit-field and perform low-level operations on it.
In general, you can create a bit-field from any arbitrary data type. First, we declare a bit-
field type like this:
[Ada]
type Bit_Field is array (Natural range <>) of Boolean with Pack;
As we've seen previously, the Pack aspect declared at the end of the type declaration
indicates that the compiler should optimize for size. We must use this aspect to be able to
interpret data types as a bit-field.
Then, we can use the Size and the Address attributes of an object of any type to declare a
bit-field for this object. We've discussed the Size attribute earlier in this course (page 97).
The Address attribute indicates the address in memory of that object. For example, as-
suming we've declare a variable V, we can declare an actual bit-field object by referring to
the Address attribute of V and using it in the declaration of the bit-field, as shown here:
[Ada]
Note that, in this declaration, we're using the Address attribute of V for the Address aspect
of B.
This technique is called overlays for serialization. Now, any operation that we perform on
B will have a direct impact on V, since both are using the same memory location.
The approach that we use in this section relies on the Address aspect. Another approach
would be to use unchecked conversions, which we'll discuss in the next section (page 156).
We should add the Volatile aspect to the declaration to cover the case when both objects
can still be changed independently — they need to be volatile, otherwise one change might
be missed. This is the updated declaration:
[Ada]
Using the Volatile aspect is important at high level of optimizations. You can find further
details about this aspect in the section about the Volatile and Atomic aspects (page 93).
Another important aspect that should be added is Import. When used in the context of ob-
ject declarations, it'll avoid default initialization which could overwrite the existing content
while creating the overlay — see an example in the admonition below. The declaration now
becomes:
B : Bit_Field (0 .. V'Size - 1)
with
Address => V'Address, Import, Volatile;
3 procedure Simple_Bitfield is
4 type Bit_Field is array (Natural range <>) of Boolean with Pack;
5
6 V : Integer := 0;
7 B : Bit_Field (0 .. V'Size - 1)
8 with Address => V'Address, Import, Volatile;
9 begin
10 B (2) := True;
11 Put_Line ("V = " & Integer'Image (V));
12 end Simple_Bitfield;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_Ada
MD5: 193a2db91619426a145cd267f873145f
Runtime output
V = 4
In this example, we first initialize V with zero. Then, we use the bit-field B and set the third
element (B (2)) to True. This automatically sets bit #3 of V to 1. Therefore, as expected,
the application displays the message V = 4, which corresponds to 22 = 4.
Note that, in the declaration of the bit-field type above, we could also have used a positive
range. For example:
B : Bit_Field (1 .. V'Size)
with Address => V'Address, Import, Volatile;
The only difference in this case is that the first bit is B (1) instead of B (0).
In C, we would rely on bit-shifting and masking to set that specific bit:
[C]
7 v = v | (1 << 2);
8
11 return 0;
12 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_C
MD5: 98557f80ea3bc1b081ae2688f844cbe1
Runtime output
v = 4
Important
Ada has the concept of default initialization. For example, you may set the default value of
record components:
[Ada]
3 procedure Main is
4
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Default_Record_Type
MD5: 010877f4d20302a1abcb9562c9e36a38
Runtime output
R.X = 10
R.Y = 11
In the code above, we don't explicitly initialize the components of R, so they still have the
default values 10 and 11, which are displayed by the application.
Likewise, the Default_Value aspect can be used to specify the default value in other kinds
of type declarations. For example:
[Ada]
3 procedure Main is
4
8 P : Percentage;
9 begin
10 Put_Line ("P = " & Percentage'Image (P));
11 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Default_Value_Type
MD5: b3715f7cba0cbefa433bac529d95e395
Runtime output
P = 10
When declaring an object whose type has a default value, the object will automatically be
initialized with the default value. In the example above, P is automatically initialized with
10, which is the default value of the Percentage type.
Some types have an implicit default value. For example, access types have a default value
of null.
As we've just seen, when declaring objects for types with associated default values, auto-
matic initialization will happen. This can also happens when creating an overlay with the
Address aspect. The default value is then used to overwrite the content at the memory
location indicated by the address. However, in most situations, this isn't the behavior we
expect, since overlays are usually created to analyze and manipulate existing values. Let's
look at an example where this happens:
[Ada]
3 package body P is
4
16 end P;
3 with P; use P;
4
5 procedure Main is
6 V : Integer := 10;
7 begin
8 Put_Line ("V = " & Integer'Image (V));
9 Display_Bytes_Increment (V);
10 Put_Line ("V = " & Integer'Image (V));
11 end Main;
Build output
p.adb:7:14: warning: default initialization of "Bf" may modify "V" [enabled by␣
↪default]
p.adb:7:14: warning: use pragma Import for "Bf" to suppress initialization (RM B.
↪1(24)) [enabled by default]
Runtime output
V = 10
Byte = 0
Byte = 0
(continues on next page)
3 package body P is
4
16 end P;
3 with P; use P;
4
5 procedure Main is
6 V : Integer := 10;
7 begin
8 Put_Line ("V = " & Integer'Image (V));
9 Display_Bytes_Increment (V);
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Overlay_Default_Init_Import
MD5: e269d9d3c06c0f6c69ead16e7d2ba70b
Runtime output
V = 10
Byte = 10
Byte = 0
Byte = 0
Byte = 0
Now incrementing...
V = 11
This unwanted side-effect of the initialization by the Default_Value aspect that we've just
seen can also happen in these cases:
• when we set a default value for components of a record type declaration,
• when we use the Default_Component_Value aspect for array types, or
• when we set use the Initialize_Scalars pragma for a package.
Again, using the Import aspect when declaring the overlay eliminates this side-effect.
We can use this pattern for objects of more complex data types like arrays or records. For
example:
[Ada]
3 procedure Int_Array_Bitfield is
4 type Bit_Field is array (Natural range <>) of Boolean with Pack;
5
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_Int_Array_Ada
MD5: 478ba4ce4f5886566556bddb58245eb9
Runtime output
A ( 1)= 4
A ( 2)= 0
In the Ada example above, we're using the bit-field to set bit #3 of the first element of the
array (A (1)). We could set bit #4 of the second element by using the size of the data type
(in this case, Integer'Size):
[Ada]
B (Integer'Size + 3) := True;
In C, we would select the specific array position and, again, rely on bit-shifting and masking
to set that specific bit:
[C]
15 return 0;
16 }
Runtime output
a[0] = 4
a[1] = 0
Since we can use this pattern for any arbitrary data type, this allows us to easily create a
subprogram to serialize data types and, for example, transmit complex data structures as
a bitstream. For example:
[Ada]
7 end Serializer;
15 begin
16 Put ("Bits: ");
17 for E of B loop
18 Show_Bit (E);
19 end loop;
20 New_Line;
21 end Transmit;
22
23 end Serializer;
8 end My_Recs;
4 procedure Main is
5 R : Rec := (5, "abc");
6 B : Bit_Field (0 .. R'Size - 1)
7 with Address => R'Address, Import, Volatile;
8 begin
9 Transmit (B);
10 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_Serialization_ada
MD5: 5c9c2d18bab7c78456d1d795c6334cd9
Build output
main.adb:9:14: warning: volatile actual passed by copy (RM C.6(19)) [enabled by␣
↪default]
Runtime output
Bits: 1010000000000000000000000000000010000110010001101100011000000000
In this example, the Transmit procedure from Serializer package displays the individual
bits of a bit-field. We could have used this strategy to actually transmit the information as
a bitstream. In the main application, we call Transmit for the object R of record type Rec.
Since Transmit has the bit-field type as a parameter, we can use it for any type, as long
as we have a corresponding bit-field representation.
In C, we interpret the input pointer as an array of bytes, and then use shifting and masking
to access the bits of that byte. Here, we use the char type because it has a size of one byte
in most platforms.
[C]
3 #include <stdio.h>
4 #include <assert.h>
5
11 assert(sizeof(char) == 1);
12
13 printf("Bits: ");
14 for (i = 0; i < len / (sizeof(char) * 8); i++)
15 {
16 for (j = 0; j < sizeof(char) * 8; j++)
17 {
18 printf("%d", c[i] >> j & 1);
19 }
20 }
21 printf("\n");
22 }
3 #include "my_recs.h"
4 #include "serializer.h"
5
12 return 0;
13 }
Runtime output
Bits: 1010000000000000000000000000000010000110010001101100011000000000
Similarly, we can write a subprogram that converts a bit-field — which may have been
received as a bitstream — to a specific type. We can add a To_Rec subprogram to the
My_Recs package to convert a bit-field to the Rec type. This can be used to convert a
bitstream that we received into the actual data type representation.
As you know, we may write the To_Rec subprogram as a procedure or as a function. Since
we need to use slightly different strategies for the implementation, the following example
has both versions of To_Rec.
This is the updated code for the My_Recs package and the Main procedure:
[Ada]
7 end Serializer;
15 begin
16 Put ("Bits: ");
17 for E of B loop
18 Show_Bit (E);
19 end loop;
20 New_Line;
21 end Transmit;
22
23 end Serializer;
3 package My_Recs is
4
17 end My_Recs;
22 return R;
23 end To_Rec;
24
31 end My_Recs;
5 procedure Main is
6 R1 : Rec := (5, "abc");
7 R2 : Rec := (0, "zzz");
8
9 B1 : Bit_Field (0 .. R1'Size - 1)
10 with Address => R1'Address, Import, Volatile;
11 begin
12 Put ("R2 = ");
13 Display (R2);
14 New_Line;
15
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_Deserialization_Ada
MD5: bf5cb5ef048ed1f95dba8e85275f6e32
Build output
main.adb:18:12: warning: volatile actual passed by copy (RM C.6(19)) [enabled by␣
↪default]
Runtime output
R2 = ( 0, zzz)
New bitstream received!
R2 = ( 5, abc)
In both versions of To_Rec, we declare the record object B_R as an overlay of the input
bit-field. In the procedure version of To_Rec, we then simply copy the data from B_R to the
output parameter R. In the function version of To_Rec, however, we need to declare a local
record object R, which we return after the assignment.
In C, we can interpret the input pointer as an array of bytes, and copy the individual bytes.
For example:
[C]
3 #include <stdio.h>
4 #include <assert.h>
5
9 printf("r2 = ");
10 display_r (&r2);
11 printf("\n");
12
20 return 0;
21 }
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Bitfield_Deserialization_C
MD5: 1c0fda773b0b681d0a4e9a57cf67d997
Runtime output
r2 = {0, zzz}
New bitstream received!
r2 = {5, abc}
Here, to_r casts both pointer parameters to pointers to char to get a byte-aligned pointer.
Then, it simply copies the data byte-by-byte.
Unchecked conversions are another way of converting between unrelated data types. This
conversion is done by instantiating the generic Unchecked_Conversions function for the
types you want to convert. Let's look at a simple example:
[Ada]
4 procedure Simple_Unchecked_Conversion is
5 type State is (Off, State_1, State_2)
6 with Size => Integer'Size;
7
8 for State use (Off => 0, State_1 => 32, State_2 => 64);
9
13 I : Integer;
14 begin
15 I := As_Integer (State_2);
16 Put_Line ("I = " & Integer'Image (I));
17 end Simple_Unchecked_Conversion;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Simple_Unchecked_Conversion
MD5: 1b6058ef1919879a7d2d86be41f3b269
Runtime output
I = 64
3 procedure Simple_Overlay is
4 type State is (Off, State_1, State_2)
5 with Size => Integer'Size;
6
7 for State use (Off => 0, State_1 => 32, State_2 => 64);
8
9 S : State;
10 I : Integer
11 with Address => S'Address, Import, Volatile;
12 begin
13 S := State_2;
14 Put_Line ("I = " & Integer'Image (I));
15 end Simple_Overlay;
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Simple_Overlay
MD5: 932135a47c36c406e70b22e075afeaf2
Runtime output
I = 64
Let's look at another example of converting between different numeric formats. In this
case, we want to convert between a 16-bit fixed-point and a 16-bit integer data type. This
is how we can do it using Unchecked_Conversion:
[Ada]
4 procedure Fixed_Int_Unchecked_Conversion is
5 Delta_16 : constant := 1.0 / 2.0 ** (16 - 1);
6 Max_16 : constant := 2 ** 15;
7
18 I : Int_16 := 0;
19 F : Fixed_16 := 0.0;
20 begin
21 F := Fixed_16'Last;
22 I := As_Int_16 (F);
23
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Fixed_Int_Unchecked_Conversion
MD5: 53b59ca56a5c25408d8b6e5fcb06f37a
Runtime output
F = 0.99997
I = 32767
Here, we instantiate Unchecked_Conversion for the Int_16 and Fixed_16 types, and we
call the instantiated functions explicitly. In this case, we call As_Int_16 to get the integer
value corresponding to Fixed_16'Last.
This is how we can rewrite the implementation above using overlays:
[Ada]
3 procedure Fixed_Int_Overlay is
4 Delta_16 : constant := 1.0 / 2.0 ** (16 - 1);
5 Max_16 : constant := 2 ** 15;
6
12 I : Int_16 := 0;
13 F : Fixed_16
14 with Address => I'Address, Import, Volatile;
15 begin
16 F := Fixed_16'Last;
17
Project: Courses.Ada_For_Embedded_C_Dev.Translation.Fixed_Int_Overlay
MD5: ee86e3d10266f8c8c96311595b6624ec
Runtime output
F = 0.99997
I = 32767
Here, the conversion to the integer value is implicit, so we don't need to call a conversion
function.
Using Unchecked_Conversion has the advantage of making it clear that a conversion is
happening, since the conversion is written explicitly in the code. With overlays, that con-
version is automatic and therefore implicit. In that sense, using an unchecked conversion
is a cleaner and safer approach. On the other hand, an unchecked conversion requires
a copy, so it's less efficient than overlays, where no copy is performed — because one
change in the source object is automatically reflected in the target object (and vice-versa).
In the end, the choice between unchecked conversions and overlays depends on the level
of performance that you want to achieve.
Also note that an unchecked conversion only has defined behavior when instantiated for
constrained types. For example, we shouldn't use this kind of conversion:
Ada.Unchecked_Conversion (Source => String,
Target => Integer);
Although this compiles, the behavior will only be well-defined in those cases when
Source'Size = Target'Size. Therefore, instead of using an unconstrained type for
Source, we should use a subtype that matches this expectation:
subtype Integer_String is String (1 .. Integer'Size / Character'Size);
Similarly, in order to rewrite the examples using bit-fields that we've seen in the previous
section, we cannot simply instantiate Unchecked_Conversion with the Target indicating
the unconstrained bit-field, such as:
Ada.Unchecked_Conversion (Source => Integer,
Target => Bit_Field);
Instead, we have to declare a subtype for the specific range we're interested in. This is how
we can rewrite one of the previous examples:
[Ada]
4 procedure Simple_Bitfield_Conversion is
5 type Bit_Field is array (Natural range <>) of Boolean with Pack;
6
7 V : Integer := 4;
8
23 B : Integer_Bit_Field;
24 begin
25 B := As_Bit_Field (V);
26
Runtime output
V = 4
In this example, we first declare the subtype Integer_Bit_Field as a bit-field with a length
that fits the V variable we want to convert to. Then, we can use that subtype in the instan-
tiation of Unchecked_Conversion.
SEVEN
7.2.1 Genericity
One usage of C macros involves the creation of functions that works regardless of the type
they're being called upon. For example, a swap macro may look like:
[C]
Listing 1: main.c
1 #include <stdio.h>
2 #include <stdlib.h>
3
161
Ada for the Embedded C Developer
10 int main()
11 {
12 int a = 10;
13 int b = 42;
14
21 return 0;
22 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Swap_C
MD5: 96d0e8ce9ae985e4de9ed64a0f0961f5
Runtime output
a = 10, b = 42
a = 42, b = 10
Ada offers a way to declare this kind of functions as a generic, that is, a function that is
written after static arguments, such as a parameter:
[Ada]
Listing 2: main.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Main is
4
5 generic
6 type A_Type is private;
7 procedure Swap (Left, Right : in out A_Type);
8
18 A : Integer := 10;
19 B : Integer := 42;
20
21 begin
22 Put_Line ("A = "
23 & Integer'Image (A)
24 & ", B = "
25 & Integer'Image (B));
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Swap_Ada
MD5: 13f3527b4e3258ebd43be827ad0fcd14
Runtime output
A = 10, B = 42
A = 42, B = 10
There are a few key differences between the C and the Ada version here. In C, the macro
can be used directly and essentially get expanded by the preprocessor without any kind of
checks. In Ada, the generic will first be checked for internal consistency. It then needs to be
explicitly instantiated for a concrete type. From there, it's exactly as if there was an actual
version of this Swap function, which is going to be called as any other function. All rules for
parameter modes and control will apply to this instance.
In many respects, an Ada generic is a way to provide a safe specification and implementa-
tion of such macros, through both the validation of the generic itself and its usage.
Subprograms aren't the only entities that can me made generic. As a matter of fact, it's
much more common to render an entire package generic. In this case the instantiation
creates a new version of all the entities present in the generic, including global variables.
For example:
[Ada]
Listing 3: gen.ads
1 generic
2 type T is private;
3 package Gen is
4 type C is tagged record
5 V : T;
6 end record;
7
8 G : Integer;
9 end Gen;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Gen_Pkg_1
MD5: 721f9954561b7e0d2964ba0d226c748b
Listing 4: main.adb
1 with Gen;
2
3 procedure Main is
4 package I1 is new Gen (Integer);
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Gen_Pkg_1
MD5: ab0e99dedf40fff1bced048a96a0fbb6
Listing 5: sort.ads
1 generic
2 type Component is private;
3 type Index is (<>);
4 with function "<" (Left, Right : Component) return Boolean;
5 type Array_Type is array (Index range <>) of Component;
6 procedure Sort (A : in out Array_Type);
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Gen_Pkg_2
MD5: 5781f53f4fd4453ecc1313d05ab76f81
The declaration above states that we need a type (Component), a discrete type (Index),
a comparison subprogram ("<"), and an array definition (Array_Type). Given these, it's
possible to write an algorithm that can sort any Array_Type. Note the usage of the with
reserved word in front of the function name: it exists to differentiate between the generic
parameter and the beginning of the generic subprogram.
Here is a non-exhaustive overview of the kind of constraints that can be put on types:
For a more complete list please reference the Generic Formal Types in the Appendix of the
Introduction to Ada course.
Let's take a case where a codebase needs to handle small variations of a given device, or
maybe different generations of a device, depending on the platform it's running on. In this
example, we're assuming that each platform will lead to a different binary, so the code can
statically resolve which set of services are available. However, we want an easy way to
implement a new device based on a previous one, saying "this new device is the same as
this previous device, with these new services and these changes in existing services".
We can implement such patterns using Ada's simple derivation — as opposed to tagged
derivation, which is OOP-related and discussed in a later section.
Let's start from the following example:
[Ada]
Listing 6: drivers_1.ads
1 package Drivers_1 is
2
9 end Drivers_1;
Listing 7: drivers_1.adb
1 package body Drivers_1 is
2
17 end Drivers_1;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 4f9d7e29b64cda8664438a1d7eed9049
In the above example, Device_1 is an empty record type. It may also have some fields if
required, or be a different type such as a scalar. Then the four procedures Startup, Send,
Send_Fast and Receive are primitives of this type. A primitive is essentially a subprogram
that has a parameter or return type directly referencing this type and declared in the same
scope. At this stage, there's nothing special with this type: we're using it as we would use
any other type. For example:
[Ada]
Listing 8: main.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2 with Drivers_1; use Drivers_1;
3
4 procedure Main is
5 D : Device_1;
6 I : Integer;
7 begin
8 Startup (D);
9 Send_Fast (D, 999);
10 Receive (D, I);
11 Put_Line (Integer'Image (I));
12 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 1b28f2c8ca92498cbcda582f092b9912
Runtime output
42
Let's now assume that we need to implement a new generation of device, Device_2. This
new device works exactly like the first one, except for the startup code that has to be
done differently. We can create a new type that operates exactly like the previous one, but
modifies only the behavior of Startup:
[Ada]
Listing 9: drivers_2.ads
1 with Drivers_1; use Drivers_1;
2
3 package Drivers_2 is
4
7 overriding
8 procedure Startup (Device : Device_2);
9
10 end Drivers_2;
3 overriding
4 procedure Startup (Device : Device_2) is null;
5
6 end Drivers_2;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 276c9da0b7c9ad61d679531e16fdd9cb
Here, Device_2 is derived from Device_1. It contains all the exact same properties and
primitives, in particular, Startup, Send, Send_Fast and Receive. However, here, we de-
cided to change the Startup function and to provide a different implementation. We over-
ride this function. The main subprogram doesn't change much, except for the fact that it
now relies on a different type:
[Ada]
4 procedure Main is
5 D : Device_2;
6 I : Integer;
7 begin
8 Startup (D);
9 Send_Fast (D, 999);
10 Receive (D, I);
11 Put_Line (Integer'Image (I));
12 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 31e7105a99771ce6c1602af117e2e8a6
Runtime output
42
We can continue with this approach and introduce a new generation of devices. This new
device doesn't implement the Send_Fast service so we want to remove it from the list
of available services. Furthermore, for the purpose of our example, let's assume that the
hardware team went back to the Device_1 way of implementing Startup. We can write
this new device the following way:
[Ada]
3 package Drivers_3 is
4
7 overriding
8 procedure Startup (Device : Device_3);
9
13 end Drivers_3;
3 overriding
4 procedure Startup (Device : Device_3) is null;
5
6 end Drivers_3;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 779579532c81b672d8a641c0b8594ed5
The is abstract definition makes illegal any call to a function, so calls to Send_Fast on
Device_3 will be flagged as being illegal. To then implement Startup of Device_3 as being
the same as the Startup of Device_1, we can convert the type in the implementation:
[Ada]
3 overriding
4 procedure Startup (Device : Device_3) is
5 begin
6 Drivers_1.Startup (Device_1 (Device));
7 end Startup;
8
9 end Drivers_3;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 5db9596c276a7a4521914f4108f61d28
4 procedure Main is
5 D : Device_3;
6 I : Integer;
7 begin
8 Startup (D);
9 Send_Fast (D, 999);
10 Receive (D, I);
11 Put_Line (Integer'Image (I));
12 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: 8b6af16d21c2f8a1f0e4866e6ddffd1f
Build output
[Ada]
7 end Drivers_1;
11 end Drivers_1;
3 package Drivers_2 is
4
9 end Drivers_2;
11 end Drivers_2;
4 procedure Main is
5 D : Transceiver;
6 I : Integer;
7 begin
8 Send (D, 999);
9 Receive (D, I);
10 Put_Line (Integer'Image (I));
11 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Derived_Drivers
MD5: e92590e4b91fef33f4fec23362a52873
Runtime output
42
In the above example, the whole code can rely on drivers.ads, instead of relying on the
specific driver. Here, Drivers is another name for Driver_1. In order to switch to Driver_2,
the project only has to replace that one drivers.ads file.
In the following section, we'll go one step further and demonstrate that this selection can
be done through a configuration switch selected at build time instead of a manual code
modification.
Configuration pragmas are a set of pragmas that modify the compilation of source-code
files. You may use them to either relax or strengthen requirements. For example:
In this example, we're suppressing the overflow check, thereby relaxing a requirement.
Normally, the following program would raise a constraint error due to a failed overflow
check:
[Ada]
4 procedure Main is
5 I : Integer := Integer'Last;
6 begin
7 I := Add_Max (I);
8 Put_Line ("I = " & Integer'Image (I));
9 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Constraint_Error_Detection
MD5: d6960fe8ae2af1d66b617bb92d3d47b6
Runtime output
When suppressing the overflow check, however, the program doesn't raise an exception,
and the value that Add_Max returns is -2, which is a wraparound of the sum of the maximum
integer values (Integer'Last + Integer'Last).
We could also strengthen requirements, as in this example:
Here, the restriction forbids the use of floating-point types and objects. The following pro-
gram would violate this restriction, so the compiler isn't able to compile the program when
the restriction is used:
procedure Main is
F : Float := 0.0;
-- Declaration is not possible with No_Floating_Point restriction.
begin
null;
end Main;
Restrictions are especially useful for high-integrity applications. In fact, the Ada Reference
Manual has a separate section for them17 .
When creating a project, it is practical to list all configuration pragmas in a separate file.
This is called a configuration pragma file, and it usually has an .adc file extension. If you use
GPRbuild for building Ada applications, you can specify the configuration pragma file in the
corresponding project file. For example, here we indicate that gnat.adc is the configuration
pragma file for our project:
project Default is
package Compiler is
for Local_Configuration_Pragmas use "gnat.adc";
end Compiler;
(continues on next page)
17 http://www.ada-auth.org/standards/12rm/html/RM-H-4.html
end Default;
In C, preprocessing flags are used to create blocks of code that are only compiled under
certain circumstances. For example, we could have a block that is only used for debugging:
[C]
4 int func(int x)
5 {
6 return x % 4;
7 }
8
9 int main()
10 {
11 int a, b;
12
13 a = 10;
14 b = func(a);
15
16 #ifdef DEBUG
17 printf("func(%d) => %d\n", a, b);
18 #endif
19
20 return 0;
21 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Debug_Code_C
MD5: 4daa8123f7112e7487ab54f16f80d34b
Here, the block indicated by the DEBUG flag is only included in the build if we define this
preprocessing flag, which is what we expect for a debug version of the build. In the release
version, however, we want to keep debug information out of the build, so we don't use this
flag during the build process.
Ada doesn't define a preprocessor as part of the language. Some Ada toolchains — like the
GNAT toolchain — do have a preprocessor that could create code similar to the one we've
just seen. When programming in Ada, however, the recommendation is to use configuration
packages to select code blocks that are meant to be included in the application.
When using a configuration package, the example above can be written as:
[Ada]
5 end Config;
5 procedure Main is
6 A, B : Integer;
7 begin
8 A := 10;
9 B := Func (A);
10
11 if Config.Debug then
12 Put_Line ("Func(" & Integer'Image (A) & ") => "
13 & Integer'Image (B));
14 end if;
15 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Debug_Code_Ada
MD5: b643b683098fa7ad5568a69c9f2c000f
In this example, Config is a configuration package. The version of Config we're seeing
here is the release version. The debug version of the Config package looks like this:
package Config is
end Config;
The compiler makes sure to remove dead code. In the case of the release version, since
Config.Debug is constant and set to False, the compiler is smart enough to remove the
call to Put_Line from the build.
As you can see, both versions of Config are very similar to each other. The general idea is
to create packages that declare the same constants, but using different values.
In C, we differentiate between the debug and release versions by selecting the appropriate
preprocessing flags, but in Ada, we select the appropriate configuration package during the
build process. Since the file name is usually the same (config.ads for the example above),
we may want to store them in distinct directories. For the example above, we could have:
• src/debug/config.ads for the debug version, and
• src/release/config.ads for the release version.
Then, we simply select the appropriate configuration package for each version of the build
by indicating the correct path to it. When using GPRbuild, we can select the appropriate
directory where the config.ads file is located. We can use scenario variables in our project,
which allow for creating different versions of a build. For example:
project Default is
end Default;
In this example, we're defining a scenario type called Mode_Type. Then, we're declaring
the scenario variable Mode and using it in the Source_Dirs declaration to complete the
path to the subdirectory containing the config.ads file. The expression "src/" & Mode
concatenates the user-specified mode to select the appropriate subdirectory.
We can then set the mode on the command-line. For example:
In addition to selecting code blocks for the build, we could also specify values that depend
on the target build. For our example above, we may want to create two versions of the
application, each one having a different version of a MOD_VALUE that is used in the imple-
mentation of func(). In C, we can achieve this by using preprocessing flags and defining
the corresponding version in APP_VERSION. Then, depending on the value of APP_VERSION,
we define the corresponding value of MOD_VALUE.
[C]
5 #if APP_VERSION == 1
6 #define MOD_VALUE 4
7 #endif
8
9 #if APP_VERSION == 2
10 #define MOD_VALUE 5
11 #endif
4 #include "defs.h"
5
6 int func(int x)
7 {
8 return x % MOD_VALUE;
9 }
10
11 int main()
12 {
13 int a, b;
(continues on next page)
15 a = 10;
16 b = func(a);
17
18 return 0;
19 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.App_Version_C
MD5: 9f204dcc65b70618324c48be0dbdffbe
If not defined outside, the code above will compile version #1 of the application. We can
change this by specifying a value for APP_VERSION during the build (e.g. as a Makefile
switch).
For the Ada version of this code, we can create two configuration packages for each version
of the application. For example:
[Ada]
3 package App_Defs is
4
7 end App_Defs;
3 procedure Main is
4 A, B : Integer;
5 begin
6 A := 10;
7 B := Func (A);
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.App_Version_Ada
MD5: 7c8e4280e74c04ab51073b25e8f53995
The code above shows the version #1 of the configuration package. The corresponding
-- ./src/app_2/app_defs.ads
package App_Defs is
end App_Defs;
Again, we just need to select the appropriate configuration package for each version of the
build, which we can easily do when using GPRbuild.
In basic terms, records with discriminants are records that include "parameters" in their
type definitions. This allows for adding more flexibility to the type definition. In the section
about pointers (page 137), we've seen this example:
[Ada]
8 V : S (9);
9 begin
10 null;
11 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Rec_Disc_Ada
MD5: 02fa8fa7832a262b99aee139a1b5b7a6
Build output
main.adb:8:04: warning: variable "V" is never read and never assigned [-gnatwv]
Here, Last is the discriminant for type S. When declaring the variable V as S (9), we
specify the actual index of the last position of the array component A by setting the Last
discriminant to 9.
We can create an equivalent implementation in C by declaring a struct with a pointer to
an array:
[C]
4 typedef struct {
5 int * a;
6 const int last;
7 } S;
8
19 return 0;
20 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Rec_Disc_C
MD5: 8f8b53c38c2ef8c1624208a2d8fd13ef
Here, we need to explicitly allocate the a array of the S struct via a call to malloc(), which
allocates memory space on the heap. In the Ada version, in contrast, the array (V.A) is
allocated on the stack and we don't need to explicitly allocate it.
Note that the information that we provide as the discriminant to the record type (in the Ada
code) is constant, so we cannot assign a value to it. For example, we cannot write:
[Ada]
In the C version, we declare the last field constant to get the same behavior.
[C]
Note that the information provided as discriminants is visible. In the example above, we
could display Last by writing:
[Ada]
Also note that, even if a type is private, we can still access the information of the discrimi-
nants if they are visible in the public part of the type declaration. Let's rewrite the example
above:
[Ada]
6 private
(continues on next page)
11 end Array_Definition;
4 procedure Main is
5 V : S (9);
6 begin
7 Put_Line ("Last : " & Integer'Image (V.Last));
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Rec_Disc_Ada_Private
MD5: fa0158c3c61dd9ec7e4000416672f9e9
Build output
Runtime output
Last : 9
Even though the S type is now private, we can still display Last because this discriminant
is visible in the non-private part of package Array_Definition.
In simple terms, a variant record is a record with discriminants that allows for changing its
structure. Basically, it's a record containing a case. This is the general structure:
[Ada]
case V is
when Opt_1 => F1 : Type_1;
when Opt_2 => F2 : Type_2;
end case;
end record;
3 procedure Main is
4
24 begin
25 Display (F);
26 Display (I);
27 end Main;
Runtime output
Float value: 1.00000E+01
Integer value: 9
4 typedef struct {
5 int use_float;
6 union {
7 float f;
8 int i;
9 };
10 } float_int;
11
16 v.use_float = 1;
17 v.f = f;
18 return v;
(continues on next page)
25 v.use_float = 0;
26 v.i = i;
27 return v;
28 }
29
45 display (f);
46 display (i);
47
48 return 0;
49 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Var_Rec_C
MD5: ac0ad1e6ff7f2154e9dbb6838999a62e
Runtime output
Similar to the Ada code, we declare f containing a floating-point value, and i containing an
integer value. One difference is that we use the init_float() and init_int() functions
to initialize the float_int struct. These functions initialize the correct field of the union
and set the use_float field accordingly.
There is, however, a difference in accessibility between variant records in Ada and unions
in C. In C, we're allowed to access any field of the union regardless of the initialization:
[C]
This feature is useful to create overlays. In this specific example, however, the information
displayed to the user doesn't make sense, since the union was initialized with a floating-
point value (v.f) and, by accessing the integer field (v.i), we're displaying it as if it was
an integer value.
In Ada, accessing the wrong component would raise an exception at run-time ("discriminant
check failed"), since the component is checked before being accessed:
[Ada]
Using this method prevents wrong information being used in other parts of the program.
To get the same behavior in Ada as we do in C, we need to explicitly use the
Unchecked_Union aspect in the type declaration. This is the modified example:
[Ada]
3 procedure Main is
4
15 begin
16 Put_Line ("Integer value: " & Integer'Image (V.I));
17 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Unchecked_Union_Ada
MD5: f6c5eacbd96c23531d02bb47a9668ac5
Runtime output
Now, we can display the integer component (V.I) even though we initialized the floating-
point component (V.F). As expected, the information displayed by the test application in
this case doesn't make sense.
Note that, when using the Unchecked_Union aspect in the declaration of a variant record,
the reference discriminant is not available anymore, since it isn't stored as part of the
record. Therefore, we cannot access the Use_Float discriminant as in the following code:
[Ada]
Unchecked unions are particularly useful in Ada when creating bindings for C code.
We can also use variant records to specify optional components of a record. For example:
[Ada]
3 procedure Main is
4 type Arr is array (Integer range <>) of Integer;
5
11 case Has_Extra_Info is
12 when No => null;
13 when Yes => B : Arr (0 .. Last);
14 end case;
15 end record;
16
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Var_Rec_Null_Ada
MD5: 548235fa8458302ba025c8fa49e61777
Build output
Runtime output
Here, in the declaration of S_Var, we don't have any component in case Has_Extra_Info
is false. The component is simply set to null in this case.
When running the example above, we see that the size of V1 is greater than the size of V2
due to the extra B component — which is only included when Has_Extra_Info is true.
We can use optional components to prevent subprograms from generating invalid informa-
tion that could be misused by the caller. Consider the following example:
[C]
40 return 0;
41 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Non_Opt_C
MD5: 56f8a72782c4a54d8a6026aa39ce421a
Runtime output
Calculation error!
Value = 0.500000
In this code, we're using the output parameter success of the calculate() function to
indicate whether the calculation was successful or not. This approach has a major problem:
there's no way to prevent that the invalid value returned by calculate() in case of an error
is misused in another computation. For example:
[C]
return 0;
}
We cannot prevent access to the returned value or, at least, force the caller to evaluate
success before using the returned value.
This is the corresponding code in Ada:
[Ada]
3 procedure Main is
4
26 F : Float;
27 Success : Boolean;
28 begin
29 F := Calculate (1.0, 0.5, Success);
30 Display (F, Success);
31
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Non_Opt_Ada
MD5: bb27fd31660ad604487f908934a3d3cb
Runtime output
Calculation error!
Value = 5.00000E-01
The Ada code above suffers from the same drawbacks as the C code. Again, there's no way
to prevent misuse of the invalid value returned by Calculate in case of errors.
However, in Ada, we can use variant records to make the component unavailable and there-
fore prevent misuse of this information. Let's rewrite the original example and wrap the
returned value in a variant record:
[Ada]
3 procedure Main is
4
30 begin
31 Display (Calculate (1.0, 0.5));
32 Display (Calculate (0.5, 1.0));
33 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Opt_Ada
MD5: 8b70cd16d5ff13611567fa71059d6891
Runtime output
Calculation error!
Value = 5.00000E-01
In this example, we can determine whether the calculation was successful or not by eval-
uating the Success component of the Opt_Float. If the calculation wasn't successful, we
won't be able to access the F component of the Opt_Float. As mentioned before, trying to
access the component in this case would raise an exception. Therefore, in case of errors,
we can ensure that no information is misused after the call to Calculate.
In the previous section (page 176), we've seen that we can add variability to records by
using discriminants. Another approach is to use tagged records, which are the base for
object-oriented programming in Ada.
A tagged record type is declared by adding the tagged keyword. For example:
[Ada]
11 R1 : Rec;
12 R2 : Tagged_Rec;
13
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Tagged_Type_Decl
MD5: 53810d3bb5aa7e7b1483270d974eb025
In this simple example, there isn't much difference between the Rec and Tagged_Rec type.
However, tagged types can be derived and extended. For example:
[Ada]
23 R1 : Rec;
24 R2 : Tagged_Rec;
25 R3 : Ext_Tagged_Rec;
26
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Tagged_Type_Extension_Decl
MD5: 707a3e6b220357f50f6792190b000c91
As indicated in the example, a type derived from an untagged type cannot have an exten-
sion. The compiler indicates this error if you uncomment the declaration of the Ext_Rec
type above. In contrast, we can extend a tagged type, as we did in the declaration of
Ext_Tagged_Rec. In this case, Ext_Tagged_Rec has all the components of the Tagged_Rec
type (V, in this case) plus the additional components from its own type declaration (V2, in
this case).
Previously, we've seen that subprograms can be overriden. For example, if we had im-
plemented a Reset and a Display procedure for the Rec type that we declared above,
these procedures would be available for an Ext_Rec type derived from Rec. Also, we could
override these procedures for the Ext_Rec type. In Ada, we don't need object-oriented
programming features to do that: simple (untagged) records can be used to derive types,
inherit operations and override them. However, in applications where the actual subpro-
gram to be called is determined dynamically at run-time, we need dispatching calls. In this
case, we must use tagged types to implement this.
Let's discuss the similarities and differences between untagged and tagged types based on
this example:
[Ada]
30 end P;
3 package body P is
4
62 end P;
4 procedure Main is
5 X_Rec : Rec;
6 X_New_Rec : New_Rec;
7
22 --
23 -- Use new operations when available
24 --
25 New_Op (X_New_Rec);
26 X_Ext_Tagged_Rec.New_Op;
27
28 --
29 -- Display all objects
30 --
31 Display (X_Rec);
32 Display (X_New_Rec);
33 X_Tagged_Rec.Display; -- we could write "Display (X_Tagged_Rec)" as well
34 X_Ext_Tagged_Rec.Display;
35
36 --
37 -- Resetting and display objects of Tagged_Rec'Class
38 --
39 Put_Line ("Operations on Tagged_Rec'Class");
40 Put_Line ("------------------------------");
41 for E of X_Tagged_Rec_Array loop
42 E.Reset;
43 E.Display;
44 end loop;
45 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Tagged_Type_Extension_Decl
MD5: 29412b74db6680f0a0986b62e5284cf7
Runtime output
TYPE: REC
Rec.V = 0
TYPE: NEW_REC
New_Rec.V = 1
TYPE: P.TAGGED_REC
Tagged_Rec.V = 0
TYPE: P.EXT_TAGGED_REC
Ext_Tagged_Rec.V = 1
Ext_Tagged_Rec.V2 = 0
Operations on Tagged_Rec'Class
------------------------------
TYPE: P.TAGGED_REC
Tagged_Rec.V = 0
TYPE: P.EXT_TAGGED_REC
Ext_Tagged_Rec.V = 0
Ext_Tagged_Rec.V2 = 0
Let's look more closely at the dispatching calls implemented above. First, we declare the
X_Tagged_Rec_Array array and initialize it with the access to objects of both parent and
derived tagged types:
[Ada]
Here, we use the aliased keyword to be able to get access to the objects (via the 'Access
attribute).
Then, we loop over this array and call the Reset and Display procedures:
[Ada]
Since we're using dispatching calls, the actual procedure that is selected depends on the
type of the object. For the first element (X_Tagged_Rec_Array (1)), this is Tagged_Rec,
while for the second element (X_Tagged_Rec_Array (2)), this is Ext_Tagged_Rec.
Dispatching calls are only possible for a type class — for example, the Tagged_Rec'Class.
When the type of an object is known at compile time, the calls won't dispatch at
runtime. For example, the call to the Reset procedure of the X_Ext_Tagged_Rec
object (X_Ext_Tagged_Rec.Reset) will always take the overriden Reset procedure of
the Ext_Tagged_Rec type. Similarly, if we perform a view conversion by writing
Tagged_Rec (A_Ext_Tagged_Rec).Display, we're instructing the compiler to interpret
A_Ext_Tagged_Rec as an object of type Tagged_Rec, so that the compiler selects the Dis-
play procedure of the Tagged_Rec type.
7.3.3.5 Interfaces
Another useful feature of object-oriented programming is the use of interfaces. In this case,
we can define abstract operations, and implement them in the derived tagged types. We
declare an interface by simply writing type T is interface. For example:
[Ada]
All operations on an interface type are abstract, so we need to write is abstract in the
signature — as we did in the declaration of Op above. Also, since interfaces are abstract
types and don't have an actual implementation, we cannot declare objects for it.
We can derive tagged types from an interface and implement the actual operations of that
interface:
[Ada]
Note that we're not using the tagged keyword in the declaration because any type derived
from an interface is automatically tagged.
Let's look at an example with an interface and two derived tagged types:
[Ada]
12 end P;
3 package body P is
4
17 end P;
3 procedure Main is
4 D_Small : Small_Display_Type;
5 D_Big : Big_Display_Type;
6
12 begin
13 Dispatching_Display (D_Small);
14 Dispatching_Display (D_Big);
15 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Interfaces_1
MD5: 564eba158b2f8fc3efea9e892a21caa9
Runtime output
Using Small_Display_Type
Using Big_Display_Type
In this example, we have an interface type Display_Interface and two tagged types that
are derived from Display_Interface: Small_Display_Type and Big_Display_Type.
Both types (Small_Display_Type and Big_Display_Type) implement the interface by
overriding the Display procedure. Then, in the inner procedure Dispatching_Display
of the Main procedure, we perform a dispatching call depending on the actual type of D.
We may derive a type from multiple interfaces by simply writing type Derived_T is new
T1 and T2 with null record. For example:
[Ada]
17 end Transceivers;
17 end Transceivers;
3 procedure Main is
4 D : Transceiver;
5 begin
6 D.Send;
7 D.Receive;
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Multiple_Interfaces
MD5: c81813941bd3458eaf7b1fd39b010a03
Runtime output
Sending data...
Receiving data...
We may also declare abstract tagged types. Note that, because the type is abstract, we
cannot use it to declare objects for it — this is the same as for interfaces. We can only
use it to derive other types. Let's look at the abstract tagged type declared in the Ab-
stract_Transceivers package:
[Ada]
3 package Abstract_Transceivers is
4
11 end Abstract_Transceivers;
11 end Abstract_Transceivers;
3 procedure Main is
4 D : Abstract_Transceiver;
5 begin
6 D.Send;
7 D.Receive;
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Multiple_Interfaces
MD5: c2b0b3aab1ffc9c3b9a0749bf6721088
Build output
In this example, we declare the abstract tagged type Abstract_Transceiver. Here, we're
only partially implementing the interfaces from which this type is derived: we're imple-
menting Send, but we're skipping the implementation of Receive. Therefore, Receive is
an abstract operation of Abstract_Transceiver. Since any tagged type that has abstract
operations is abstract, we must indicate this by adding the abstract keyword in type dec-
laration.
Also, when compiling this example, we get an error because we're trying to declare an object
of Abstract_Transceiver (in the Main procedure), which is not possible. Naturally, if we
derive another type from Abstract_Transceiver and implement Receive as well, then we
can declare objects of this derived type. This is what we do in the Full_Transceivers
below:
[Ada]
3 package Full_Transceivers is
4
8 end Full_Transceivers;
11 end Full_Transceivers;
3 procedure Main is
4 D : Full_Transceiver;
5 begin
6 D.Send;
7 D.Receive;
8 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Multiple_Interfaces
MD5: 77a86a6d917547d306a89422e7522111
Runtime output
Sending data...
Receiving data...
Here, we implement the Receive procedure for the Full_Transceiver. Therefore, the type
doesn't have any abstract operation, so we can use it to declare objects.
In the section about simple derivation (page 165), we've seen an example where the actual
selection was done at implementation time by renaming one of the packages:
[Ada]
with Drivers_1;
Although this approach is useful in many cases, there might be situations where we need
to select the actual driver dynamically at runtime. Let's look at how we could rewrite that
example using interfaces, tagged types and dispatching calls:
[Ada]
9 end Drivers_Base;
3 package Drivers_1 is
4
11 end Drivers_1;
19 end Drivers_1;
3 package Drivers_2 is
4
11 end Drivers_2;
19 end Drivers_2;
3 with Drivers_Base;
4 with Drivers_1;
5 with Drivers_2;
6
7 procedure Main is
8 D1 : aliased Drivers_1.Transceiver;
9 D2 : aliased Drivers_2.Transceiver;
10 D : access Drivers_Base.Transceiver'Class;
11
12 I : Integer;
13
26 begin
27 Select_Driver (1);
28 D.Send (999);
29 D.Receive (I);
30 Put_Line (Integer'Image (I));
31
32 Select_Driver (2);
33 D.Send (999);
34 D.Receive (I);
35 Put_Line (Integer'Image (I));
36 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Tagged_Drivers
MD5: d823b7231f1adf003fb6f545cb482308
Runtime output
Using Drivers_1
42
Using Drivers_2
7
In this example, we declare the Transceiver interface in the Drivers_Base package. This
interface is then used to derive the tagged types Transceiver from both Drivers_1 and
Drivers_2 packages.
In the Main procedure, we use the access to Transceiver'Class — from the interface
declared in the Drivers_Base package — to declare D. This object D contains the access to
the actual driver loaded at any specific time. We select the driver at runtime in the inner
Select_Driver procedure, which initializes D (with the access to the selected driver). Then,
any operation on D triggers a dispatching call to the selected driver.
14 int main()
15 {
16 int selection = 1;
17 void (*current_show_msg) (char *);
18
19 switch (selection)
20 {
21 case 1: current_show_msg = &show_msg_v1; break;
22 case 2: current_show_msg = &show_msg_v2; break;
23 default: current_show_msg = NULL; break;
24 }
25
26 if (current_show_msg != NULL)
27 {
28 current_show_msg ("Hello there!");
29 }
30 else
31 {
32 printf("ERROR: no version of show_msg() selected!\n");
33 }
34
35 return 0;
36 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Selecting_Subprogram_C
MD5: 414c99fca2490611d20d031f8549ff59
Runtime output
The example above contains two versions of the show_msg() function: show_msg_v1()
and show_msg_v2(). The function is selected depending on the value of selection, which
initializes the function pointer current_show_msg. If there's no corresponding value, cur-
rent_show_msg is set to null — alternatively, we could have selected a default version of
show_msg() function. By calling current_show_msg ("Hello there!"), we're calling the
function that current_show_msg is pointing to.
This is the corresponding implementation in Ada:
[Ada]
3 procedure Show_Subprogram_Selection is
4
18 Current_Show_Msg : Show_Msg_Proc;
19 Selection : Natural;
20
21 begin
22 Selection := 1;
23
24 case Selection is
25 when 1 => Current_Show_Msg := Show_Msg_V1'Access;
26 when 2 => Current_Show_Msg := Show_Msg_V2'Access;
27 when others => Current_Show_Msg := null;
28 end case;
29
36 end Show_Subprogram_Selection;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Selecting_Subprogram_Ada
MD5: ee41e042e3b879b4a2671bfe6d8072aa
Runtime output
The structure of the code above is very similar to the one used in the C code. Again, we
have two version of Show_Msg: Show_Msg_V1 and Show_Msg_V2. We set Current_Show_Msg
according to the value of Selection. Here, we use 'Access to get access to the corre-
sponding procedure. If no version of Show_Msg is available, we set Current_Show_Msg to
null.
Pointers to subprograms are also typically used as callback functions. This approach is
extensively used in systems that process events, for example. Here, we could have a two-
layered system:
• A layer of the system (an event manager) triggers events depending on information
from sensors.
– For each event, callback functions can be registered.
– The event manager calls registered callback functions when an event is triggered.
• Another layer of the system registers callback functions for specific events and decides
what to do when those events are triggered.
This approach promotes information hiding and component decoupling because:
• the layer of the system responsible for managing events doesn't need to know what
the callback function actually does, while
• the layer of the system that implements callback functions remains agnostic to imple-
mentation details of the event manager — for example, how events are implemented
in the event manager.
Let's see an example in C where we have a process_values() function that calls a callback
function (process_one) to process a list of values:
[C]
3 #include <assert.h>
4 #include <stdio.h>
5
4 #include "process_values.h"
5
11 # define LEN_VALUES 5
12
13 int main()
(continues on next page)
16 int values[LEN_VALUES] = { 1, 2, 3, 4, 5 };
17 int i;
18
26 return 0;
27 }
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Callback_C
MD5: ff5c8611d0901f40b6c4a9effeb0a323
Runtime output
Value [0] = 11
Value [1] = 12
Value [2] = 13
Value [3] = 14
Value [4] = 15
As mentioned previously, process_values() doesn't have any knowledge about what pro-
cess_one() does with the integer value it receives as a parameter. Also, we could re-
place proc_10() by another function without having to change the implementation of pro-
cess_values().
Note that process_values() calls an assert() for the function pointer to compare it
against null. Here, instead of checking the validity of the function pointer, we're expecting
the caller of process_values() to provide a valid pointer.
This is the corresponding implementation in Ada:
[Ada]
11 end Values_Processing;
11 end Values_Processing;
6 procedure Show_Callback is
7 Values : Integer_Array := (1, 2, 3, 4, 5);
8 begin
9 Process_Values (Values, Proc_10'Access);
10
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.Callback_Ada
MD5: f49c54f0d14193d305c0e962a392ab67
Runtime output
Value [ 1] = 11
Value [ 2] = 12
Value [ 3] = 13
Value [ 4] = 14
Value [ 5] = 15
In the previous sections, we have shown how to use packages to create separate com-
ponents of a system. As we know, when designing a complex system, it is advisable to
separate concerns into distinct units, so we can use Ada packages to represent each unit
of a system. In this section, we go one step further and create separate dynamic libraries
for each component, which we'll then link to the main application.
Let's suppose we have a main system (Main_System) and a component A (Component_A)
that we want to use in the main system. For example:
[Ada]
10 end Component_A;
15 end Component_A;
8 procedure Main_System is
9 Values : constant Float_Array := (10.0, 11.0, 12.0, 13.0);
10 Average_Value : Float;
11 begin
12 Average_Value := Average (Values);
13 Put_Line ("Average = " & Float'Image (Average_Value));
14 end Main_System;
Project: Courses.Ada_For_Embedded_C_Dev.Reusability.System_For_Dyn_Lib
MD5: d759132b787e636d4bcd5f8cd6393f2a
Runtime output
Average = 1.15000E+01
Note that, in the source-code example above, we're indicating the name of each file. We'll
now see how to organize those files in a structure that is suitable for the GNAT build system
(GPRbuild).
In order to discuss how to create dynamic libraries, we need to dig into some details about
the build system. With GNAT, we can use project files for GPRbuild to easily design dynamic
libraries. Let's say we use the following directory structure for the code above:
|- component_a
| | component_a.gpr
| |- src
| | | component_a.adb
| | | component_a.ads
|- main_system
| | main_system.gpr
| |- src
| | | main_system.adb
Here, we have two directories: component_a and main_system. Each directory contains a
project file (with the .gpr file extension) and a source-code directory (src).
In the source-code example above, we've seen the content of files component_a.ads,
component_a.adb and main_system.adb. Now, let's discuss how to write the project file for
Component_A (component_a.gpr), which will build the dynamic library for this component:
end Component_A;
The project is defined as a library project instead of project. This tells GPRbuild to build
a library instead of an executable binary. We then specify the library name using the Li-
brary_Name attribute, which is required, so it must appear in a library project. The next
two library-related attributes are optional, but important for our use-case. We use:
• Library_Kind to specify that we want to create a dynamic library — by default, this
attribute is set to static;
• Library_Dir to specify the directory where the library is stored.
In the project file of our main system (main_system.gpr), we just need to reference the
project of Component_A using a with clause and indicating the correct path to that project
file:
with "../component_a/component_a.gpr";
project Main_System is
for Source_Dirs use ("src");
for Object_Dir use "obj";
(continues on next page)
GPRbuild takes care of selecting the correct settings to link the dynamic library created for
Component_A with the main application (Main_System) and build an executable.
We can use the same strategy to create a Component_B and dynamically link to it in the
Main_System. We just need to create the separate structure for this component — with the
appropriate Ada packages and project file — and include it in the project file of the main
system using a with clause:
with "../component_a/component_a.gpr";
with "../component_b/component_b.gpr";
...
Again, GPRbuild takes care of selecting the correct settings to link both dynamic libraries
together with the main application.
You can find more details and special setting for library projects in the GPRbuild documen-
tation18 .
18 https://docs.adacore.com/gprbuild-docs/html/gprbuild_ug/gnat_project_manager.html#library-projects
EIGHT
PERFORMANCE CONSIDERATIONS
All in all, there should not be significant performance differences between code written
in Ada and code written in C, provided that they are semantically equivalent. Taking the
current GNAT implementation and its GCC C counterpart for example, most of the code
generation and optimization phases are shared between C and Ada — so there's not one
compiler more efficient than the other. Furthermore, the two languages are fairly similar
in the way they implement imperative semantics, in particular with regards to memory
management or control flow. They should be equivalent on average.
When comparing the performance of C and Ada code, differences might be observed. This
usually comes from the fact that, while the two piece appear semantically equivalent, they
happen to be actually quite different; C code semantics do not implicitly apply the same
run-time checks that Ada does. This section will present common ways for improving Ada
code performance.
Clever use of compilation switches might optimize the performance of an application sig-
nificantly. In this section, we'll briefly look into some of the switches available in the GNAT
toolchain.
Optimization levels can be found in many compilers for multiple languages. On the low-
est level, the GNAT compiler doesn't optimize the code at all, while at the higher levels,
the compiler analyses the code and optimizes it by removing unnecessary operations and
making the most use of the target processor's capabilities.
By being part of GCC, GNAT offers the same -O_ switches as GCC:
SwitchDescription
-O0 No optimization: the generated code is completely unoptimized. This is the default
optimization level.
-O1 Moderate optimization.
-O2 Full optimization.
-O3 Same optimization level as for -O2. In addition, further optimization strategies,
such as aggressive automatic inlining and vectorization.
209
Ada for the Embedded C Developer
Note that the higher the level, the longer the compilation time. For fast compilation dur-
ing development phase, unless you're working on benchmarking algorithms, using -O0 is
probably a good idea.
In addition to the levels presented above, GNAT also has the -Os switch, which allows for
optimizing code and data usage.
8.2.2 Inlining
As we've seen in the previous section, automatic inlining depends on the optimization level.
The highest optimization level (-O3), for example, performs aggressive automatic inlining.
This could mean that this level inlines too much rather than not enough. As a result, the
cache may become an issue and the overall performance may be worse than the one we
would achieve by compiling the same code with optimization level 2 (-O2). Therefore, the
general recommendation is to not just select -O3 for the optimized version of an application,
but instead compare it the optimized version built with -O2.
In some cases, it's better to reduce the optimization level and perform manual inlining in-
stead of automatic inlining. We do that by using the Inline aspect. Let's reuse an example
from a previous chapter and inline the Average function:
[Ada]
Listing 1: float_arrays.ads
1 package Float_Arrays is
2
8 end Float_Arrays;
Listing 2: float_arrays.adb
1 package body Float_Arrays is
2
12 end Float_Arrays;
Listing 3: compute_average.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
5 procedure Compute_Average is
6 Values : constant Float_Array := (10.0, 11.0, 12.0, 13.0);
7 Average_Value : Float;
8 begin
9 Average_Value := Average (Values);
(continues on next page)
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Inlining
MD5: faf9d0d8cd5aefd7a48bcd950b1256fa
Runtime output
Average = 1.15000E+01
When compiling this example, GNAT will inline Average in the Compute_Average procedure.
In order to effectively use this aspect, however, we need to set the optimization level to
at least -O1 and use the -gnatn switch, which instructs the compiler to take the Inline
aspect into account.
Note, however, that the Inline aspect is just a recommendation to the compiler. Some-
times, the compiler might not be able to follow this recommendation, so it won't inline the
subprogram. In this case, we get a compilation warning from GNAT.
These are some examples of situations where the compiler might not be able to inline a
subprogram:
• when the code is too large,
• when it's too complicated — for example, when it involves exception handling —, or
• when it contains tasks, etc.
In addition to the Inline aspect, we also have the Inline_Always aspect. In contrast to the
former aspect, however, the Inline_Always aspect isn't primarily related to performance.
Instead, it should be used when the functionality would be incorrect if inlining was not per-
formed by the compiler. Examples of this are procedures that insert Assembly instructions
that only make sense when the procedure is inlined, such as memory barriers.
Similar to the Inline aspect, there might be situations where a subprogram has the In-
line_Always aspect, but the compiler is unable to inline it. In this case, we get a compila-
tion error from GNAT.
8.3.1 Checks
Ada provides many runtime checks to ensure that the implementation is working as ex-
pected. For example, when accessing an array, we would like to make sure that we're not
accessing a memory position that is not allocated for that array. This is achieved by an
index check.
Another example of runtime check is the verification of valid ranges. For example, when
adding two integer numbers, we would like to ensure that the result is still in the valid
range — that the value is neither too large nor too small. This is achieved by an range
check. Likewise, arithmetic operations shouldn't overflow or underflow. This is achieved by
an overflow check.
Although runtime checks are very useful and should be used as much as possible, they can
also increase the overhead of implementations at certain hot-spots. For example, checking
the index of an array in a sorting algorithm may significantly decrease its performance. In
those cases, suppressing the check may be an option. We can achieve this suppression by
using pragma Suppress (Index_Check). For example:
[Ada]
We can also deactivate overflow checks for integer types using the -gnato switch when
compiling a source-code file with GNAT. In this case, overflow checks in the whole file are
deactivated.
It is also possible to suppress all checks at once using pragma Suppress (All_Checks).
In addition, GNAT offers a compilation switch called -gnatp, which has the same effect on
the whole file.
Note, however, that this kind of suppression is just a recommendation to the compiler.
There's no guarantee that the compiler will actually suppress any of the checks because
the compiler may not be able to do so — typically because the hardware happens to do it.
For example, if the machine traps on any access via address zero, requesting the removal of
null access value checks in the generated code won't prevent the checks from happening.
It is important to differentiate between required and redundant checks. Let's consider the
following example in C:
[C]
Listing 4: main.c
1 #include <stdio.h>
2
7 res = a / b;
8
12 return 0;
13 }
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Division_By_Zero_C
MD5: c8d95cbdd76618108119886c27ce7eb6
Because C doesn't have language-defined checks, as soon as the application tries to divide
a value by zero in res = a / b, it'll break — on Linux, for example, you may get the
following error message by the operating system: Floating point exception (core
dumped). Therefore, we need to manually introduce a check for zero before this operation.
For example:
[C]
Listing 5: main.c
1 #include <stdio.h>
2
7 if (b != 0) {
8 res = a / b;
9
19 return 0;
20 }
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Division_By_Zero_Check_C
MD5: 67ea0140d8248674b4aac06825c7cdbe
Runtime output
Listing 6: show_division_by_zero.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Show_Division_By_Zero is
4 A : Integer := 8;
5 B : Integer := 0;
6 Res : Integer;
7 begin
8 Res := A / B;
9
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Division_By_Zero_Ada
MD5: 2af6690eb977203ef7ce2178d15255af
Build output
Runtime output
Similar to the first version of the C code, we're not explicitly checking for a potential division
by zero here. In Ada, however, this check is automatically inserted by the language itself.
When running the application above, an exception is raised when the application tries to
divide the value in A by zero. We could introduce exception handling in our example, so
that we get the same message as we did in the second version of the C code:
[Ada]
Listing 7: show_division_by_zero.adb
1 with Ada.Text_IO; use Ada.Text_IO;
2
3 procedure Show_Division_By_Zero is
4 A : Integer := 8;
5 B : Integer := 0;
6 Res : Integer;
7 begin
8 Res := A / B;
9
Build output
show_division_by_zero.adb:8:15: warning: division by zero [enabled by default]
show_division_by_zero.adb:8:15: warning: Constraint_Error will be raised at run␣
↪time [enabled by default]
Runtime output
Error: cannot calculate value (division by zero)
This example demonstrates that the division check for Res := A / B is required and
shouldn't be suppressed. In contrast, a check is redundant — and therefore not required —
when we know that the condition that leads to a failure can never happen. In many cases,
the compiler itself detects redundant checks and eliminates them (for higher optimization
levels). Therefore, when improving the performance of your application, you should:
1. keep all checks active for most parts of the application;
2. identify the hot-spots of your application;
3. identify which checks haven't been eliminated by the optimizer on these hot-spots;
4. identify which of those checks are redundant;
5. only suppress those checks that are redundant, and keep the required ones.
8.3.2 Assertions
We've already discussed assertions in this section of the SPARK chapter (page 116). Asser-
tions are user-defined checks that you can add to your code using the pragma Assert. For
example:
[Ada]
return Res;
end Sort;
Assertions that are specified with pragma Assert are not enabled by default. You can
enable them by setting the assertion policy to check — using pragma Assertion_Policy
(Check) — or by using the -gnata switch when compiling with GNAT.
Similar to the checks discussed previously, assertions can generate significant overhead
when used at hot-spots. Restricting those assertions to development (e.g. debug version)
and turning them off on the release version may be an option. In this case, formal proof
— as discussed in the SPARK chapter (page 109) — can help you. By formally proving that
assertions will never fail at run-time, you can safely deactivate them.
Ada generally speaking provides more ways than C or C++ to write simple dynamic struc-
tures, that is to say structures that have constraints computed after variables. For example,
it's quite typical to have initial values in record types:
[Ada]
type R is record
F : Some_Field := Call_To_Some_Function;
end record;
However, the consequences of the above is that any declaration of a instance of this type
without an explicit value for F will issue a call to Call_To_Some_Function. More subtle
issue may arise with elaboration. For example, it's possible to write:
Listing 8: some_functions.ads
1 package Some_Functions is
2
7 end Some_Functions;
Listing 9: values.ads
1 with Some_Functions; use Some_Functions;
2
3 package Values is
4 A_Start : Integer := Some_Function_Call;
5 A_End : Integer := Some_Other_Function_Call;
6 end Values;
3 package Arr_Def is
4 type Arr is array (Integer range A_Start .. A_End) of Integer;
5 end Arr_Def;
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Dynamic_Array
MD5: 0c97cecb64d27e935724c8b5f941fb4f
It may indeed be appealing to be able to change the values of A_Start and A_End at startup
so as to align a series of arrays dynamically. The consequence, however, is that these values
will not be known statically, so any code that needs to access to boundaries of the array
will need to read data from memory. While it's perfectly fine most of the time, there may be
situations where performances are so critical that static values for array boundaries must
be enforced.
Here's a last case which may also be surprising:
[Ada]
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Record_With_Arrays
MD5: e7b2656433279d36db87506276b68398
In the code above, R contains two arrays, F1 and F2, respectively constrained by the dis-
criminant D1 and D2. The consequence is, however, that to access F2, the run-time needs to
know how large F1 is, which is dynamically constrained when creating an instance. There-
fore, accessing to F2 requires a computation involving D1 which is slower than, let's say,
two pointers in an C array that would point to two different arrays.
Generally speaking, when values are used in data structures, it's useful to always consider
where they're coming from, and if their value is static (computed by the compiler) or dy-
namic (only known at run-time). There's nothing fundamentally wrong with dynamically
constrained types, unless they appear in performance-critical pieces of the application.
In the section about pointers (page 137), we mentioned that the Ada compiler will auto-
matically pass parameters by reference when needed. Let's look into what "when needed"
means. The fundamental point to understand is that the parameter types determine how
the parameters are passed in and/or out. The parameter modes do not control how param-
eters are passed.
Specifically, the language standards specifies that scalar types are always passed by value,
and that some other types are always passed by reference. It would not make sense to make
a copy of a task when passing it as a parameter, for example. So parameters that can be
passed reasonably by value will be, and those that must be passed by reference will be.
That's the safest approach.
But the language also specifies that when the parameter is an array type or a record type,
and the record/array components are all by-value types, then the compiler decides: it can
pass the parameter using either mechanism. The critical case is when such a parameter
is large, e.g., a large matrix. We don't want the compiler to pass it by value because that
would entail a large copy, and indeed the compiler will not do so. But if the array or record
parameter is small, say the same size as an address, then it doesn't matter how it is passed
and by copy is just as fast as by reference. That's why the language gives the choice to
the compiler. Although the language does not mandate that large parameters be passed
by reference, any reasonable compiler will do the right thing.
The modes do have an effect, but not in determining how the parameters are passed. Their
effect, for parameters passed by value, is to determine how many times the value is copied.
For mode in and mode out there is just one copy. For mode in out there will be two copies,
one in each direction.
Therefore, unlike C, you don't have to use access types in Ada to get better performance
when passing arrays or records to subprograms. The compiler will almost certainly do the
right thing for you.
Let's look at this example:
[C]
3 struct Data {
4 int prev, curr;
5 };
6
27 return 0;
28 }
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Passing_Rec_By_Reference_C
MD5: 9087e26168e49d095b5e0776d6330d69
Runtime output
Prev : 1
Curr : 3
In this C code example, we're using pointers to pass D1 as a reference to update and dis-
play. In contrast, the equivalent code in Ada simply uses the parameter modes to specify
the data flow directions. The mechanisms used to pass the values do not appear in the
source code.
[Ada]
3 procedure Update_Record is
4
25 begin
26 Update (D1, 3);
27 Display (D1);
28 end Update_Record;
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Passing_Rec_By_Reference_Ada
MD5: 6c64fb73e2cf490c0a129f0cd73c190b
Runtime output
Prev: 1
Curr: 3
In the calls to Update and Display, D1 is always be passed by reference. Because no extra
copy takes place, we get a performance that is equivalent to the C version. If we had used
arrays in the example above, D1 would have been passed by reference as well:
[Ada]
3 procedure Update_Array is
4
23 begin
24 Update (D1, 3);
25 Display (D1);
26 end Update_Array;
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Passing_Array_By_Reference_Ada
MD5: 5fb27811f34543fc4150eb4fddbe7034
Runtime output
Prev: 1
Curr: 3
Again, no extra copy is performed in the calls to Update and Display, which gives us optimal
performance when dealing with arrays and avoids the need to use access types to optimize
the code.
Previously, we've discussed the cost of passing complex records as arguments to subpro-
grams. We've seen that we don't have to use explicit access type parameters to get better
performance in Ada. In this section, we'll briefly discuss the cost of function returns.
In general, we can use either procedures or functions to initialize a data structure. Let's
look at this example in C:
[C]
3 struct Data {
4 int prev, curr;
5 };
6
17 return d;
18 }
19
24 D1 = get_init_data();
25
26 init_data(&D1);
27
28 return 0;
29 }
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Init_Rec_Proc_And_Func_C
MD5: 0586636d5e25c0d6bec2257af75ae998
This code example contains two subprograms that initialize the Data structure:
• init_data(), which receives the data structure as a reference (using a pointer) and
initializes it, and
• get_init_data(), which returns the initialized structure.
In C, we generally avoid implementing functions such as get_init_data() because of the
extra copy that is needed for the function return.
This is the corresponding implementation in Ada:
[Ada]
19 D1 : Data;
20
25 Init (D1);
26 end Init_Record;
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Init_Rec_Proc_And_Func_Ada
MD5: 0f930eea432a82d78840b72c0714b283
Build output
In this example, we have two versions of Init: one using a procedural form, and the other
one using a functional form. Note that, because of Ada's support for subprogram overload-
ing, we can use the same name for both subprograms.
The issue is that assignment of a function result entails a copy, just as if we assigned
one variable to another. For example, when assigning a function result to a constant, the
function result is copied into the memory for the constant. That's what is happening in the
above examples for the initialized variables.
Therefore, in terms of performance, the same recommendations apply: for large types we
should avoid writing functions like the Init function above. Instead, we should use the
procedural form of Init. The reason is that the compiler necessarily generates a copy for
the Init function, while the Init procedure uses a reference for the output parameter, so
that the actual record initialization is performed in place in the caller's argument.
An exception to this is when we use functions returning values of limited types, which by
definition do not allow assignment. Here, to avoid allowing something that would otherwise
look suspiciously like an assignment, the compiler generates the function body so that it
builds the result directly into the object being assigned. No copy takes place.
We could, for example, rewrite the example above using limited types:
[Ada]
16 D1 : Data := Init;
17
Project: Courses.Ada_For_Embedded_C_Dev.Performance.Init_Lim_Rec_Proc_And_Func_Ada
MD5: 57fc1b3f69b42dd4633b0c67e252c2d2
In this example, D1 : Data := Init; has the same cost as the call to the procedural form
— Init (D1); — that we've seen in the previous example. This is because the assignment
is done in place.
Note that limited types require the use of the extended return statements (return ... do
... end return) in function implementations. Also note that, because the Data type is
limited, we can only use the Init function in the declaration of D1; a statement in the code
such as D1 := Init; is therefore forbidden.
NINE
The technical benefits of a migration from C to Ada are usually relatively straightforward
to demonstrate. Hopefully, this course provides a good basis for it. However, when faced
with an actual business decision to make, additional considerations need to be taken into
account, such as return on investment, perennity of the solution, tool support, etc. This
section will cover a number of usual questions and provide elements of answers.
Switching from one technology to another is a cost, may that be in terms of training, tran-
sition of the existing environment or acquisition of new tools. This investment needs to be
matched with an expected return on investment, or ROI, to be consistent. Of course, it's
incredibly difficult to provide a firm answer to how much money can be saved by transi-
tioning, as this is highly dependent on specific project objectives and constraints. We're
going to provide qualitative and quantitative arguments here, from the perspective of a
project that has to reach a relatively high level of integrity, that is to say a system where
the occurrence of a software failure is a relatively costly event.
From a qualitative standpoint, there are various times in the software development life cycle
where defects can be found:
1. on the developer's desk
2. during component testing
3. during integration testing
4. after deployment
5. during maintenance
Numbers from studies vary greatly on the relative costs of defects found at each of these
phases, but there's a clear ordering between them. For example, a defect found while de-
veloping is orders of magnitude less expensive to fix than a defect found e.g. at integration
time, which may involve costly debugging sessions and slow down the entire system accep-
tance. The whole purpose of Ada and SPARK is to push defect detection to the developer's
desk as much as possible; at least for all of these defects that can be identified at that
level. While the strict act of writing software may be taking more effort because of all of
the additional safeguards, this should have a significant and positive impact down the line
and help to control costs overall. The exact value this may translate into is highly business
dependent.
From a quantitative standpoint, two studies have been done almost 25 years apart and
provide similar insights:
• Rational Software in 1995 found that the cost of developing software in Ada was overall
half as much as the cost of developing software in C.
223
Ada for the Embedded C Developer
• VDC ran a study in 2018, finding that the cost savings of developing with Ada over C
ranged from 6% to 38% in savings.
From a qualitative standpoint, in particular with regards to Ada and C from a formal proof
perspective, an interesting presentation was made in 2017 by two researchers. They tried
to apply formal proof on the same piece of code, developed in Ada/SPARK on one end and
C/Frama-C on the other. Their results indicate that the Ada/SPARK technology is indeed
more conducive to formal proof methodologies.
Although all of these studies have their own biases, they provide a good idea of what to
expect in terms of savings once the initial investment in switching to Ada is made. This is
assuming everything else is equal, in particular that the level of integrity is the same. In
many situations, the migration to Ada is justified by an increase in terms of integrity expec-
tations, in which case it's expected that development costs will rise (it's more expensive to
develop better software) and Ada is viewed as a means to mitigate this rise in development
costs.
That being said, the point of this argument is not to say that it's not possible to write very
safe and secure software with languages different than Ada. With the right expertise, the
right processes and the right tools, it's done every day. The point is that Ada overall reduces
the level of processes, expertise and tools necessary and will allow to reach the same target
at a lower cost.
Ada was initially born as a DoD project, and thus got its initial customer base in aerospace
and defence (A&D). At the time these lines are written and from the perspective of AdaCore,
A&D is still the largest consumer of Ada today and covers about 70% of the market. This
creates a consistent and long lasting set of established users as these project last often for
decades, using the same codebase migrating from platform to platform.
More recently however, there has been an emerging interest for Ada in new communities of
users such as automotive, medical device, industrial automation and overall cyber-security.
This can probably be explained by a rise of safety, reliability and cyber-security require-
ments. The market is moving relatively rapidly today and we're anticipating an increase
of the Ada footprint in these domains, while still remaining a technology of choice for the
development of mission critical software.
The first piece of the answer lies in the user base of the Ada language, as seen in the
previous question. Projects using Ada in the aerospace and defence domain maintain source
code over decades, providing healthy funding foundation for Ada-based technologies.
AdaCore being the author of this course, it's difficult for us to be fair in our description
of other Ada compilation technologies. We will leave to the readers the responsibility of
forging their own opinion. If they present a credible alternative to the GNAT compiler, then
this whole section can be considered as void.
Assuming GNAT is the only option available, and acknowledging that this is an argument
that we're hearing from a number of Ada adopters, let's discuss the "sole source" issue.
First of all, it's worth noting that industries are using a lot of software that is provided by
only one source, so while non-ideal, these situations are also quite common.
In the case of the GNAT compiler however, while AdaCore is the main maintainer, this main-
tenance is done as part of an open-source community. This means that nothing prevents
a third party to start selling a competing set of products based on the same compiler, pro-
vided that it too adopts the open-source approach. Our job is to be more cost-effective
than the alternative, and indeed for the vast part this has prevented a competing offering
to emerge. However, should AdaCore disappear or switch focus, Ada users would not be
prevented from carrying on using its software (there is no lock) and a third party could take
over maintenance. This is not a theoretical case, this has been done in the past either by
companies looking at supporting their own version of GNAT, vendors occupying a specific
niche that was left uncovered , or hobbyists developing their own builds.
With that in mind, it's clear that the "sole source" provider issue is a circumstantial —
nothing is preventing other vendors from emerging if the conditions are met.
A language by itself is of little use for the development of safety-critical software. Instead,
a complete toolset is needed to accompany the development process, in particular tools
for edition, testing, static analysis, etc.
AdaCore provides a number of these tools either in through its core or add-on package.
These include (as of 2019):
• An IDE (GNAT Studio)
• An Eclipse plug-in (GNATbench)
• A debugger (GDB)
• A testing tool (GNATtest)
• A structural code coverage tool (GNATcoverage)
• A metric computation tool (GNATmetric)
• A coding standard checker (GNATcheck)
• Static analysis tools (CodePeer, SPARK Pro)
• A Simulink code generator (QGen)
• An Ada parser to develop custom tools (libadalang)
Ada is, however, an internationally standardized language, and many companies are pro-
viding third party solutions to complete the toolset. Overall, the language can be and is
used with tools on par with their equivalent C counterparts.
A common question from teams on the verge of selecting Ada and SPARK is how to manage
the developer team growth and turnover. While Ada and SPARK are taught by a growing
number of universities worldwide, it may still be challenging to hire new staff with prior Ada
experience.
Fortunately, Ada's base semantics are very close to those of C/C++, so that a good embed-
ded software developer should be able to learn it relatively easily. This course is definitely
a resource available to get started. Online training material is also available, together with
on-site in person training.
In general, getting an engineer operational in Ada and SPARK shouldn't take more than a
few weeks worth of time.
The most common scenario when introducing Ada and SPARK to a project or a team is to do
it within a pre-existing C codebase, which can already spread over hundreds of thousands if
not millions lines of code. Re-writing this software to Ada or SPARK is of course not practical
and counterproductive.
Most teams select either a small piece of existing code which deserves particular attention,
or new modules to develop, and concentrate on this. Developing this module or part of
the application will also help in developing the coding patterns to be used for the particular
project and company. This typically concentrates an effort of a few people on a few thou-
sands lines of code. The resulting code can be linked to the rest of the C application. From
there, the newly established practices and their benefit can slowly spread through the rest
of the environment.
Establishing this initial core in Ada and SPARK is critical, and while learning the language
isn't a particularly difficult task, applying it to its full capacity may require some expertise.
One possibility to accelerate this initial process is to use AdaCore mentorship services.
TEN
CONCLUSION
Although Ada's syntax might seem peculiar to C developers at first glance, it was designed
to increase readability and maintainability, rather than making it faster to write in a con-
densed manner — as it is often the case in C.
Especially in the embedded domain, C developers are used to working at a very low level,
which includes mathematical operations on pointers, complex bit shifts, and logical bitwise
operations. C is well designed for such operations because it was designed to replace
Assembly language for faster, more efficient programming.
Ada can be used to describe high level semantics and architectures. 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. How-
ever, although Ada supports bitwise operations such as masks and shifts, they should be
relatively rarely needed. When translating C code to Ada, it's good practice to consider
alternatives. In a lot of cases, these operations are used to insert several pieces of data
into a larger structure. In Ada, this can be done by describing the structure layout at the
type level through representation clauses, and then accessing this structure as any other.
For example, we can interpret an arbitrary data type as a bit-field and perform low-level
operations on it.
Because Ada is a strongly typed language, it doesn't define any implicit type conversions
like C. If we try to compile Ada code that contains type mismatches, we'll get a compilation
error. Because the compiler prevents mixing variables of different types without explicit
type conversion, we can't accidentally end up in a situation where we assume something
will happen implicitly when, in fact, our assumption is incorrect. In this sense, Ada's type
system encourages programmers to think about data at a high level of abstraction. Ada
supports overlays and unchecked conversions as a way of converting between unrelated
data type, which are typically used for interfacing with low-level elements such as registers.
In Ada, arrays aren't interchangeable with operations on pointers like in C. Also, array types
are considered first-class citizens and have dedicated semantics such as the availability of
the array's boundaries at run-time. Therefore, unhandled array overflows are impossible
unless checks are suppressed. Any discrete type can serve as an array index, and we can
specify both the starting and ending bounds. In addition, Ada offers high-level operations
for copying, slicing, and assigning values to arrays.
Although Ada supports pointers, most situations that would require a pointer in C do not in
Ada. In the vast majority of the cases, indirect memory management can be hidden from
the developer and thus prevent many potential errors. In C, pointers are typically used to
pass references to subprograms, for example. In contrast, Ada parameter modes indicate
the flow of information to the reader, leaving the means of passing that information to the
compiler.
When translating pointers from C code to Ada, we need to assess whether they are needed
in the first place. Ada pointers (access types) should only be used with complex structures
that cannot be allocated at run-time. There are many situations that would require a pointer
in C, but do not in Ada. For example, arrays — even when dynamically allocated —, results
of functions, passing of large structures as parameters, access to registers, etc.
227
Ada for the Embedded C Developer
Because of the absence of namespaces, global names in C tend to be very long. Also,
because of the absence of overloading, they can even encode type names in their name.
In Ada, a package is a namespace. Also, we can use the private part of a package to declare
private types and private subprograms. In fact, private types are useful for preventing the
users of those types from depending on the implementation details. Another use-case is
the prevention of package users from accessing the package state/data arbitrarily.
Ada has a dedicated set of features for interfacing with other languages, so we can easily
interface with our existing C code before translating it to Ada. Also, GNAT includes auto-
matic binding generators. Therefore, instead of re-writing the entire C code upfront, which
isn't practical or cost-effective, we can selectively translate modules from C to Ada.
When it comes to implementing concurrency and real time, Ada offers several options. Ada
provides high level constructs such as tasks and protected objects to express concurrency
and synchronization, which can be used when running on top of an operating system such
as Linux. On more constrained systems, such as bare metal or some real-time operating
systems, a subset of the Ada tasking capabilities — known as the Ravenscar and Jorvik
profiles — is available. Though restricted, this subset also has nice properties, in particular
the absence of deadlock,the absence of priority inversion, schedulability and very small
footprint. On bare metal systems, this also essentially means that Ada comes with its own
real-time kernel. The advantage of using the full Ada tasking model or the restricted profiles
is to enhance portability.
Ada includes many features typically used for embedded programming:
• Built-in support for handling interrupts, so we can process interrupts by attaching a
handler — as a protected procedure — to it.
• Built-in support for handling both volatile and atomic data.
• Support for register overlays, which we can use to create a structure that facilitates
manipulating bits from registers.
• Support for creating data streams for serialization of arbitrary information and trans-
mission over a communication channel, such as a serial port.
• Built-in support for fixed-point arithmetic, which is an option when our target device
doesn't have a floating-point unit or the result of calculations needs to be bit-exact.
Also, Ada compilers such as GNAT have built-in support for directly mixing Ada and Assembly
code.
Ada also supports contracts, which can be associated with types and variables to refine
values and define valid and invalid values. The most common kind of contract is a range
constraint — using the range reserved word. Ada also supports contract-based program-
ming in the form of preconditions and postconditions. One typical benefit of contract-based
programming is the removal of defensive code in subprogram implementations.
It is common to see embedded software being used in a variety of configurations that re-
quire small changes to the code for each instance. In C, variability is usually achieved
through macros and function pointers, the former being tied to static variability and the
latter to dynamic variability. Ada offers many alternatives for both techniques, which aim
at structuring possible variations of the software. Examples of static variability in Ada are:
genericity, simple derivation, configuration pragma files, and configuration packages. Ex-
amples of dynamic variability in Ada are: records with discriminants, variant records —
which may include the use of unions —, object orientation, pointers to subprograms, and
design by components using dynamic libraries.
There shouldn't be significant performance differences between code written in Ada and
code written in C — provided that they are semantically equivalent. One reason is that
the two languages are fairly similar in the way they implement imperative semantics, in
particular with regards to memory management or control flow. Therefore, they should be
equivalent on average. However, when a piece of code in Ada is significantly slower than its
counterpart in C, this usually comes from the fact that, while the two pieces of code appear
229
Ada for the Embedded C Developer
ELEVEN
The goal of this appendix is to present a hands-on view on how to translate a system from
C to Ada and improve it with object-oriented programming.
Let's start with an overview of a simple system that we'll implement and use below. The
main system is called AB and it combines two systems A and B. System AB is not supposed
to do anything useful. However, it can serve as a good model for the hands-on we're about
to start.
This is a list of requirements for the individual systems A and B, and the combined system
AB:
• System A:
– The system can be activated and deactivated.
∗ During activation, the system's values are reset.
– Its current value (in floating-point) can be retrieved.
∗ This value is the average of the two internal floating-point values.
– Its current state (activated or deactivated) can be retrieved.
• System B:
– The system can be activated and deactivated.
∗ During activation, the system's value is reset.
– Its current value (in floating-point) can be retrieved.
– Its current state (activated or deactivated) can be retrieved.
• System AB
– The system contains an instance of system A and an instance of system B.
– The system can be activated and deactivated.
∗ System AB activates both systems A and B during its own activation.
∗ System AB deactivates both systems A and B during its own deactivation.
– Its current value (in floating-point) can be retrieved.
∗ This value is the average of the current values of systems A and B.
– Its current state (activated or deactivated) can be retrieved.
∗ AB is only considered activated when both systems A and B are activated.
231
Ada for the Embedded C Developer
In this section, we look into implementations (in both C and Ada) of system AB that don't
make use of object-oriented programming.
Listing 1: system_a.h
1 typedef struct {
2 float val[2];
3 int active;
4 } A;
5
Listing 2: system_a.c
1 #include "system_a.h"
2
Listing 3: system_b.h
1 typedef struct {
2 float val;
3 int active;
4 } B;
5
Listing 4: system_b.c
1 #include "system_b.h"
2
Listing 5: system_ab.h
1 #include "system_a.h"
2 #include "system_b.h"
3
4 typedef struct {
5 A a;
6 B b;
7 } AB;
8
Listing 6: system_ab.c
1 #include <math.h>
2 #include "system_ab.h"
3
Listing 7: main.c
1 #include <stdio.h>
2 #include "system_ab.h"
3
20 int main()
21 {
22 AB s;
23
27 display_active (&s);
28 display_check (&s);
29
33 display_active (&s);
34 }
Runtime output
Activating system AB...
System AB is active.
System AB check: PASSED.
Deactivating system AB...
System AB is not active.
Here, each system is implemented in a separate set of header and source-code files. For
example, the API of system AB is in system_ab.h and its implementation in system_ab.c.
In the main application, we instantiate system AB and activate it. Then, we proceed to dis-
play the activation state and the result of the system's health check. Finally, we deactivate
the system and display the activation state again.
Listing 8: system_a.ads
1 package System_A is
2
5 type A is record
6 Val : Val_Array (1 .. 2);
7 Active : Boolean;
8 end record;
9
18 end System_A;
Listing 9: system_a.adb
1 package body System_A is
2
24 end System_A;
3 type B is record
4 Val : Float;
5 Active : Boolean;
6 end record;
7
16 end System_B;
24 end System_B;
4 package System_AB is
5
6 type AB is record
7 SA : A;
8 SB : B;
9 end record;
10
21 end System_AB;
31 end System_AB;
5 procedure Main is
6
25 S : AB;
26 begin
27 Put_Line ("Activating system AB...");
28 AB_Activate (S);
29
30 Display_Active (S);
31 Display_Check (S);
32
36 Display_Active (S);
37 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.HandsOnOOP.System_AB_Ada
MD5: f2e3df0b3874e5edc5ea90c01961cf64
Runtime output
As you can see, this is a direct translation that doesn't change much of the structure of the
original C code. Here, the goal was to simply translate the system from one language to
another and make sure that the behavior remains the same.
3 type A is private;
4
13 private
14
17 type A is record
18 Val : Val_Array (1 .. 2);
19 Active : Boolean;
20 end record;
21
22 end Simple.System_A;
22 end Simple.System_A;
3 type B is private;
4
13 private
14
15 type B is record
16 Val : Float;
17 Active : Boolean;
18 end record;
19
20 end Simple.System_B;
22 end Simple.System_B;
4 package Simple.System_AB is
5
6 type AB is private;
7
18 private
19
20 type AB is record
21 SA : A;
22 SB : B;
23 end record;
24
25 end Simple.System_AB;
27 end Simple.System_AB;
5 procedure Main is
6
25 S : AB;
26 begin
27 Put_Line ("Activating system AB...");
28 Activate (S);
29
30 Display_Active (S);
31 Display_Check (S);
32
36 Display_Active (S);
37 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.HandsOnOOP.System_AB_Ada_Enhanced
MD5: 5019a7088ab4160f5e3b33c73db2b03b
Runtime output
Until now, we haven't used any of the object-oriented programming features of the Ada
language. So we can start by analyzing the API of systems A and B and deciding how to
best abstract some of its elements using object-oriented programming.
11.3.1 Interfaces
The first thing we may notice is that we actually have two distinct sets of APIs there:
• one API for activating and deactivating the system.
• one API for retrieving the value of the system.
We can use this distinction to declare two interface types:
• Activation_IF for the Activate and Deactivate procedures and the Is_Active func-
tion;
• Value_Retrieval_IF for the Value function.
This is how the declaration could look like:
Note that, because we are declaring interface types, all operations on those types must be
abstract or, in the case of procedures, they can also be declared null. For example, we
could change the declaration of the procedures above to this:
When an operation is declared abstract, we must override it for the type that derives from
the interface. When a procedure is declared null, it acts as a do-nothing default. In this
case, overriding the operation is optional for the type that derives from this interface.
Since the original system needs both interfaces we've just described, we have to declare
another type that combines those interfaces. We can do this by declaring the interface type
Sys_Base, which serves as the base type for systems A and B. This is the declaration:
Since the system activation functionality is common for both systems A and B, we could
implement it as part of Sys_Base. That would require changing the declaration from a
simple interface to an abstract record:
Now, we can add the Boolean component to the record (as a private component) and over-
ride the subprograms of the Activation_IF interface. This is the adapted declaration:
private
In the declaration of the Sys_Base type we've just seen, we're not overriding the Value
function — from the Value_Retrieval_IF interface — for the Sys_Base type, so it remains
an abstract function for Sys_Base. Therefore, the Sys_Base type itself remains abstract
and needs be explicitly declared as such.
We use this strategy to ensure that all types derived from Sys_Base need to implement
their own version of the Value function. For example:
Here, the A type is derived from the Sys_Base and it includes its own version of the Value
function by overriding it. Therefore, A is not an abstract type anymore and can be used to
declare objects:
procedure Main is
Obj : A;
V : Float;
begin
Obj.Activate;
V := Obj.Value;
end Main;
Important
Note that the use of the overriding keyword in the subprogram declaration is not strictly
necessary. In fact, we could leave this keyword out, and the code would still compile.
However, if provided, the compiler will check whether the information is correct.
Using the overriding keyword can help to avoid bad surprises — when you may think that
you're overriding a subprogram, but you're actually not. Similarly, you can also write not
overriding to be explicit about subprograms that are new primitives of a derived type. For
example:
We also need to declare the values that are used internally in systems A and B. For system
A, this is the declaration:
private
In the previous implementation, we've seen that the A_Activate and B_Activate proce-
dures perform the following steps:
• initialize internal values;
• indicate that the system is active (by setting the Active flag to True).
In the implementation of the Activate procedure for the Sys_Base type, however, we're
only dealing with the second step. Therefore, we need to override the Activate procedure
and make sure that we initialize internal values as well. First, we need to declare this
procedure for type A:
In the implementation of Activate, we should call the Activate procedure from the parent
(Sys_Base) to ensure that whatever was performed for the parent will be performed in the
derived type as well. For example:
Here, by writing Sys_Base (E), we're performing a view conversion. Basically, we're telling
the compiler to view E not as an object of type A, but of type Sys_Base. When we do this,
any operation performed on this object will be done as if it was an object of Sys_Base type,
which includes calling the Activate procedure of the Sys_Base type.
Important
If we write T (Obj).Proc, we're telling the compiler to call the Proc procedure of type T
and apply it on Obj.
If we write T'Class (Obj).Proc, however, we're telling the compiler to dispatch the call.
For example, if Obj is of derived type T2 and there's an overridden Proc procedure for type
T2, then this procedure will be called instead of the Proc procedure for type T.
11.3.5 Type AB
While the implementation of systems A and B is almost straightforward, it gets more in-
teresting in the case of system AB. Here, we have a similar API, but we don't need the
activation mechanism implemented in the abstract type Sys_Base. Therefore, deriving
from Sys_Base is not the best option. Instead, when declaring the AB type, we can simply
use the same interfaces as we did for Sys_Base, but keep it independent from Sys_Base.
For example:
private
Naturally, we still need to override all the subprograms that are part of the Activation_IF
and Value_Retrieval_IF interfaces. Also, we need to implement the additional Check
function that was originally only available on system AB. Therefore, we declare these sub-
programs:
20 private
21
27 end Simple;
16 end Simple;
9 private
10
17 end Simple.System_A;
15 end Simple.System_A;
9 private
10
15 end Simple.System_B;
12 end Simple.System_B;
4 package Simple.System_AB is
5
16 private
17
23 end Simple.System_AB;
27 end Simple.System_AB;
5 procedure Main is
6
25 S : AB;
26 begin
27 Put_Line ("Activating system AB...");
28 Activate (S);
29
30 Display_Active (S);
31 Display_Check (S);
32
36 Display_Active (S);
37 end Main;
Runtime output
Activating system AB...
System AB is active
System AB check: PASSED
Deactivating system AB...
System AB is not active
When analyzing the complete source-code, we see that there are at least two areas that
we could still improve.
The first issue concerns the implementation of the Activate procedure for types derived
from Sys_Base. For those derived types, we're expecting that the Activate procedure of
the parent must be called in the implementation of the overriding Activate procedure. For
example:
package body Simple.System_A is
If a developer forgets to call that specific Activate procedure, however, the system won't
work as expected. A better strategy could be the following:
• Declare a new Activation_Reset procedure for Sys_Base type.
• Make a dispatching call to the Activation_Reset procedure in the body of the Acti-
vate procedure (of the Sys_Base type).
• Let the derived types implement their own version of the Activation_Reset proce-
dure.
This is a simplified view of the implementation using the points described above:
package Simple is
end Simple;
E.Active := True;
end Activate;
end Simple;
package Simple.System_A is
private
end Simple.System_A;
end Simple.System_A;
The next area that we could improve is in the declaration of the system AB. In the previous
implementation, we were explicitly describing the two components of that system, namely
a component of type A and a component of type B:
Of course, this declaration matches the system requirements that we presented in the
beginning. However, we could use strategies that make it easier to incorporate requirement
changes later on. For example, we could hide this information about systems A and B by
simply declaring an array of components of type access Sys_Base'Class and allocate
them dynamically in the body of the package. Naturally, this approach might not be suitable
for certain platforms. However, the advantage would be that, if we wanted to replace the
component of type B by a new component of type C, for example, we wouldn't need to
change the interface. This is how the updated declaration could look like:
Important
Note that we're now using the limited keyword in the declaration of type AB. That is nec-
essary because we want to prevent objects of type AB being copied by assignment, which
would lead to two objects having the same (dynamically allocated) subsystems A and B
internally. This change requires that both Activation_IF and Value_Retrieval_IF are
declared limited as well.
Another approach that we could use to implement the dynamic allocation of systems A and
B is to declare AB as a limited controlled type — based on the Limited_Controlled type of
the Ada.Finalization package.
The Limited_Controlled type includes the following operations:
• Initialize, which is called when objects of a type derived from the Lim-
ited_Controlled type are being created — by declaring an object of the derived
type, for example —, and
• Finalize, which is called when objects are being destroyed — for example, when an
object gets out of scope at the end of a subprogram where it was created.
In this case, we must override those procedures, so we can use them for dynamic memory
allocation. This is a simplified view of the update implementation:
package Simple.System_AB is
end Simple.System_AB;
end Simple.System_AB;
22 private
23
29 end Simple;
9 E.Active := True;
10 end Activate;
11
20 end Simple;
7 private
8
17 end Simple.System_A;
14 end Simple.System_A;
7 private
8
15 end Simple.System_B;
11 end Simple.System_B;
3 package Simple.System_AB is
4
16 private
17
29 end Simple.System_AB;
48 end Simple.System_AB;
5 procedure Main is
6
25 S : AB;
26 begin
27 Put_Line ("Activating system AB...");
(continues on next page)
30 Display_Active (S);
31 Display_Check (S);
32
36 Display_Active (S);
37 end Main;
Project: Courses.Ada_For_Embedded_C_Dev.HandsOnOOP.System_AB_Ada_OOP_2
MD5: f8d0d4a07aaa045cb30bddc88db2215a
Runtime output
Naturally, this is by no means the best possible implementation of system AB. By applying
other software design strategies that we haven't covered here, we could most probably
think of different ways to use object-oriented programming to improve this implementa-
tion. Also, in comparison to the original implementation (page 235), we recognize that
the amount of source-code has grown. On the other hand, we now have a system that is
factored nicely, and also more extensible.
259