Better Communication Protocols with Ada's Record Representation Clauses
Efficient data transmission to microcontrollers often requires making a choice between simple code and extra overhead. Common serialization libraries such as Nanopb keep code simple but impose processing and memory costs to deserialise data in to language-native types, while manual binary packing introduces extra complexity and risks of errors.
Ada provides a mechanism called representation clauses which allow developers to define the exact bit-level layouts of data structures and work with them directly without any need for external tools, manual packing, or extra memory for deserialisation. Developers do not even need to worry about designing these layouts from scratch as the compilers can take a record definition and give the developer a tightly packed representation clause to use as a starting point.
In this blog post we will take an example of a motor controller and go through the steps we might take to design a communication protocol for it. For the sake of simplicity we will exclude scalar bit ordering, however Ada does allow for this to be specified.
Requirements #
For our protocol we will define the following requirements:
- We need to be able to receive a stream of configuration and motion commands and differentiate between the two.
- Motion commands can either contain a velocity or torque setting with a value between -128 and 128 and a resolution of 2^(-6).
- Configuration commands are a set of 3 PID gain values between 10 and 41.
- Messages must use the minimum number of bytes possible to avoid saturating the connection.
Defining the Data Model #
With our requirements defined we can write a data model in Ada that represents them in a way that precisely mirrors the requirements without the need for comments to denote fixed point types or any sort of external data definition language:
package Motor_Protocol is
type Motion_Parameter is delta 2.0**(-6) range -128.0 .. 128.0
with Size => 14; -- Optional
type Command_Kind is (Motion, Config) with Size => 1;
for Command_Kind use (Motion => 0, Config => 1); -- Optional
type Motion_Kind is (Velocity, Torque);
type Gain_Value is range 10 .. 41
with Size => 5; -- Not optional as we want to force a biased representation.
type Motion_Command (Kind : Motion_Kind := Velocity) is record
-- Including a default value for Kind turns this in to a definite type
-- which always has the same size. Ada also allows for indefinite types
-- where the size will change based on the record variants.
case Kind is
when Velocity =>
Target_Vel : Motion_Parameter;
when Torque =>
Target_Torque : Motion_Parameter;
end case;
end record;
type Command_Packet (Kind : Command_Kind := Motion) is record
-- We could include some common data here.
case Kind is
when Motion =>
M_Data : Motion_Command;
when Config =>
PID_P : Gain_Value;
PID_I : Gain_Value;
PID_D : Gain_Value;
end case;
end record;
pragma Pack (Motion_Command);
pragma Pack (Command_Packet);
end Motor_Protocol;
Defining the Representation #
You may notice that we have not defined the exact representation of the command packet but have instead just included two pragma Pack lines at the end of the package.
This is because we are now going to have the compiler generate representation information for us instead of going through the error-prone process of figuring out all the bit positions ourselves.
We do this by running gcc -gnatR which gives us the following output:
for Motion_Command'Object_Size use 16;
for Motion_Command'Value_Size use 15;
for Motion_Command'Alignment use 1;
for Motion_Command use record
Kind at 0 range 0 .. 0;
Target_Vel at 0 range 1 .. 14;
Target_Torque at 0 range 1 .. 14;
end record;
for Command_Packet'Size use 16;
for Command_Packet'Alignment use 1;
for Command_Packet use record
Kind at 0 range 0 .. 0;
M_Data at 0 range 1 .. 15;
Pid_P at 0 range 1 .. 5;
Pid_I at 0 range 6 .. 10;
Pid_D at 1 range 3 .. 7;
end record;
As you can see, GCC has nicely packed all our values together, including overlapping variant options.
To document this and confirm that this representation never changes across different compilers or architectures we can copy and paste these values as a replacement for the pragma Pack lines above.
We could also make any tweaks to this if we wanted to.
GCC also warns us about the problem you may have already noticed here with there being one bit too few to store values all the way from -32 to 32.
For our purposes this is fine, but if we wanted to we could use gcc -gnatR2 to see the correct upper bound of and adjust our file to match.
motor_protocol.ads:3:09: warning: declared high bound of type "Motion_Parameter" is outside type range [enabled by default]
motor_protocol.ads:3:09: warning: high bound adjusted down by delta (RM 3.5.9(13)) [enabled by default]
Usage #
If you have defined a data structure like this in a language such as protobuff before you might be used to needing to generate a collection of functions to deserialise this data, but as these are all native Ada data types we can work with them directly as you would with an object in any other language. Typical usage of a record as defined above may look something like this:
procedure Receive_Packet is
Input_Command : aliased Command_Packet;
begin
DMA_Receive (Input_Command'Address);
-- Receive data directly from our UART.
if Input_Command.Kind = Motion then
-- Use the data with zero copies and no need for manual conversion from
-- a fixed point type. For the PID values there is also no requirement
-- to manually apply a bias value.
if Input_Command.M_Data.Kind = Velocity then
Update_Motor_Vel (Float (Input_Command.M_Data.Target_Vel));
elsif Input_Command.M_Data.Kind = Torque then
Update_Motor_Torque (Float (Input_Command.M_Data.Target_Torque));
end if;
end if;
end Receive_Packet;
Comparison with Traditional Approaches #
To appreciate the efficiency and safety of the Ada approach, it is useful to compare it with how this same protocol would be implemented using standard serialization libraries or manual packing.
Versus Protocol Buffers #
Protobuf is often the default choice for structured data, but it incurs significant overhead for small microcontroller payloads. Protobuf has extra size overhead for field tags and varint encoding, the Ada packet is always exactly 2 bytes but the Protobuf equivalent would likely be 5 to 7 bytes depending on the values. On top of the additional size, Protobuf requires a parser that consumes processing time and memory.
Versus Manual Packing in C #
Another common alternative in embedded systems is manual bit manipulation in C/C++. To replicate the 5-bit biased PID values packed across byte boundaries, the C code would look similar to this:
typedef struct {
uint8_t raw_bytes[2];
} packet_t;
void process_packet(packet_t *p) {
// Extract Command Kind (Bit 0)
uint8_t kind = p->raw_bytes[0] & 0x01;
if (kind == 1) { // Config
// Extract PID_P (Bits 1-5)
uint8_t raw_p = (p->raw_bytes[0] >> 1) & 0x1F;
// Extract PID_I (Bits 6-10)
// Take top 2 bits of byte 0 and bottom 3 bits of byte 1
uint8_t raw_i = ((p->raw_bytes[0] >> 6) & 0x03) |
((p->raw_bytes[1] << 2) & 0x1C);
// Extract PID_D (Bits 11-15)
uint8_t raw_d = (p->raw_bytes[1] >> 3) & 0x1F;
// Manually apply bias
int pid_p = raw_p + 10;
int pid_i = raw_i + 10;
int pid_d = raw_d + 10;
}
}
This approach is time-consuming, difficult to read, and error-prone to implement.
Conclusion #
Ada’s representation clauses allow for the optimal use of highly constrained hardware without the significant complexity of manual packing or the costs of serialisation libraries. Being a built-in language feature removes almost all complexity that would otherwise be present.