MODERN C++ IN EMBEDDED DEVELOPMENT: STRONG TYPES AND USER-DEFINED LITERALS
I started using Modern C++ in embedded projects two years ago. Even though C is a defacto standard language in embedded development, I see more and more people switching to C++. Embedded developers are often biased and still think you can’t use C++ without dynamic memory allocation. Yes, that’s true for some parts of the C++ standard library, but there are plenty of other features that will make your life easier.
My mission is to show the benefits of modern C++ to the embedded community. I started doing that through a series of blog posts:
- Modern C++ in embedded development – a quick overview of some C++11 to C++17 features I use daily,
- Modern C++ in embedded development: constexpr – a few examples of usage of constexpr – one of my favorite features, and
- Modern C++ in embedded development: using C libraries – here, I explain my preferred way of using vendor-provided C libraries in a C++ project.
This is my fourth blog post, where I discuss so-called strong types and user-defined literals. Both C and C++ are statically typed languages where the data type is known at the compile time. Data types are context-dependent, and many interfaces are designed with context descriptions in the documentation. This makes interfaces hard to use and requires additional mental efforts to reason about. Using strong types helps solve some common bugs and makes code easier to read.
Strong types make interfaces easy to read
Many embedded libraries I worked with suffer from hard-to-read interfaces. They usually come with massive documentation, and reading and understanding it takes considerable time. Here’s an example of a function from a BLE stack used to create a connection with a slave device.
/**
* @brief Start the direct connection establishment procedure.
A LE_Create_Connection call will be made to the controller by GAP with the initiator filter policy set to "ignore whitelist and
process connectable advertising packets only for the specified
device".
* @param LE_Scan_Interval This is defined as the time interval from when the Controller started its last LE scan until it begins the subsequent LE scan.
Time = N * 0.625 msec.
* Values:
- 0x0004 (2.500 ms) ... 0x4000 (10240.000 ms)
* @param LE_Scan_Window Amount of time for the duration of the LE scan. LE_Scan_Window
shall be less than or equal to LE_Scan_Interval.
Time = N * 0.625 msec.
* Values:
- 0x0004 (2.500 ms) ... 0x4000 (10240.000 ms)
* @param Peer_Address_Type The address type of the peer device.
* Values:
- 0x00: Public Device Address
- 0x01: Random Device Address
* @param Peer_Address Public Device Address or Random Device Address of the device
to be connected.
* @param Own_Address_Type Own address type:
- 0x00: Public Device Address (it is allowed only if privacy is disabled)
- 0x01: Random Device Address (it is allowed only if privacy is disabled)
- 0x02: Resolvable Private Address (it is allowed only if privacy is enabled)
- 0x03: Non Resolvable Private Address (it is allowed only if privacy is enabled)
* Values:
- 0x00: Public Device Address
- 0x01: Random Device Address
- 0x02: Resolvable Private Address
- 0x03: Non Resolvable Private Address
* @param Conn_Interval_Min Minimum value for the connection event interval. This shall be less than or equal to Conn_Interval_Max.
Time = N * 1.25 msec.
* Values:
- 0x0006 (7.50 ms) ... 0x0C80 (4000.00 ms)
* @param Conn_Interval_Max Maximum value for the connection event interval. This shall be
greater than or equal to Conn_Interval_Min.
Time = N * 1.25 msec.
* Values:
- 0x0006 (7.50 ms) ... 0x0C80 (4000.00 ms)
* @param Conn_Latency Slave latency for the connection in number of connection events.
* Values:
- 0x0000 ... 0x01F3
* @param Supervision_Timeout Supervision timeout for the LE Link.
It shall be a multiple of 10 ms and larger than (1 + connSlaveLatency) * connInterval * 2.
Time = N * 10 msec.
* Values:
- 0x000A (100 ms) ... 0x0C80 (32000 ms)
* @param Minimum_CE_Length Information parameter about the minimum length of connection needed for this LE connection.
Time = N * 0.625 msec.
* Values:
- 0x0000 (0.000 ms) ... 0xFFFF (40959.375 ms)
* @param Maximum_CE_Length Information parameter about the maximum length of connection needed
for this LE connection.
Time = N * 0.625 msec.
* Values:
- 0x0000 (0.000 ms) ... 0xFFFF (40959.375 ms)
* @retval Value indicating success or error code.
*/
tBleStatus aci_gap_create_connection(uint16_t LE_Scan_Interval,
uint16_t LE_Scan_Window,
uint8_t Peer_Address_Type,
uint8_t Peer_Address[6],
uint8_t Own_Address_Type,
uint16_t Conn_Interval_Min,
uint16_t Conn_Interval_Max,
uint16_t Conn_Latency,
uint16_t Supervision_Timeout,
uint16_t Minimum_CE_Length,
uint16_t Maximum_CE_Length);
There are a couple of problems with this function:
- Most parameters are time-related, but the units they represent are unclear.
- Even though they represent time, units are not consistent.
- LE_Scan_Interval, LE_Scan_Window, Conn_Interval_Min, Conn_Interval_Max, Supervision_Timeout, Minimum_CE_Length, and Maximum_CE_Length are all time-related parameters, but they all come in strange and different units. They are either multiples of 0.625, 1.25, or 10 ms.
Now, you can introduce a couple of macros that will take care of the conversion, and the problem is solved:
#define CONN_L(x) ((int)((x) / 0.625f))
#define CONN_P(x) ((int)((x) / 1.25f))
Here is an example of aci_gap_create_connection function call using the above macros:
tBleStatus status = aci_gap_create_connection(CONN_L(80), CONN_L(120), PUBLIC_ADDR, mac_addr,
CONN_P(50), CONN_P(60), 0, SUPERV_TIMEOUT, CONN_L(10), CONN_L(15));
But both LE_Scan_Interval and Conn_Interval_Min are uint16_t, and no one can stop me from using non-matching macros or passing any uint16_t value I can think of. The code would compile, and we are unsure if it would raise an assert in run time. These types of bugs are the hardest to find and debug.
To avoid these situations and possible confusion, we can introduce strong types. For the sake of simplicity, let’s call them ConnL and ConnP:
class ConnL {
private:
uint16_t time_;
public:
explicit ConnL(float time_ms) : time_(time/0.625f){}
uint16_t & get() {return time_;}
};
class ConnP {
private:
uint16_t time_;
public:
explicit ConnL(float time_ms) : time_(time/1.25f){}
uint16_t & get() {return time_;}
};
The function declaration would change to:
tBleStatus aci_gap_create_connection(ConnL LE_Scan_Interval,
ConnL LE_Scan_Window,
uint8_t Peer_Address_Type,
uint8_t Peer_Address[6],
uint8_t Own_Address_Type,
ConnP Conn_Interval_Min,
ConnP Conn_Interval_Max,
uint16_t Conn_Latency,
uint16_t Supervision_Timeout,
ConnL Minimum_CE_Length,
ConnL Maximum_CE_Length);
And here’s the new function call:
tBleStatus status = aci_gap_create_connection(ConnL(80), ConnL(120), PUBLIC_ADDR, mac_addr,
ConnP(50), ConnP(60), 0, SUPERV_TIMEOUT, ConnL(10), ConnL(15));
Now, if we used ConnP instead of ConnL, or vice versa, we’d get a compile-time error! As we are dealing with a vendor-provided library, I’d recommend creating a simple wrapper for this interface. Here, you can read about the best practices for integrating C libraries in your C++ code.
To make this even more verbose, we could introduce another type that would represent time:
class Time {
private:
float time_in_ms_;
public:
explicit Time(float time_in_ms) : time_in_ms_(time_in_ms){}
uint16_t & get() {return time_in_ms_;}
};
ConnP and ConnL would change to:
class ConnL {
private:
Time time_;
public:
explicit ConnL(Time time) : time_(time.get()/0.625f){}
Time & get() {return time_;}
};
class ConnP {
private:
Time time_;
public:
explicit ConnP(Time time) : time_(time.get()/0.625f){}
Time & get() {return time_;}
};
The function call would look like this:
tBleStatus status = aci_gap_create_connection(ConnL(Time(80)), ConnL(Time(120)), PUBLIC_ADDR, mac_addr,
ConnP(Time(50)), ConnP(Time(60)), 0, SUPERV_TIMEOUT, ConnL(Time(10)), ConnL(Time(15)));
It is way more verbose and obvious what’s happening from the call site, but it’s not that easy to read.
User-defined literals
C++11 introduced user-defined literals, a feature that allows integer, floating-point, and string literals to produce objects of user-defined type by defining a user-defined suffix. For our newly defined type, Time, we can construct the following user-defined literal:
Time operator"" _ms(float time) {
return Time(time);
}
Now our function call looks way more readable:
tBleStatus status = aci_gap_create_connection(ConnL(80_ms), ConnL(120_ms), PUBLIC_ADDR, mac_addr,
ConnP(50_ms), ConnP(60_ms), 0, SUPERV_TIMEOUT, ConnL(10_ms), ConnL(15_ms));
The best thing is our interface is still verbose enough. It provides you with all the relevant information you need at the call site.
Expressiveness
Being in the industry for some time makes you appreciate readable and expressive code. We can define multiple user-defined literals that will create objects of the same type but using different units such as seconds, minutes, or hours. And combining them by overloading the operator plus we can get some quite expressive and powerful lines of code. We could use our type Time for timers or alarms. Let’s define a function:
void setAlarmIn(Time time);
By defining the following user-defined literals:
Time operator"" _s(float time) {
return Time(time*1000);
}
Time operator"" _min(float time) {
return Time(time*1000*60);
}
And overloading operator+
Time operator+(const Time& a, const Time& b) {
return Time(a.get()+b.get());
}
We can have the following function call:
setAlarmIn(5min+30s);
Please note that C++ standard template library (STL) includes chrono library (std::chrono), which includes helper types for a time duration in different time units (nanoseconds, microseconds, milliseconds, seconds, minutes, …). The usage of STL-provided types is preferred in this and almost any other scenario.
Further reading
Creating a new type for every variable and writing a whole class doesn’t make much sense. There is a way to make strong types generic, and you can read about it at Jonathan Boccara’s blog fluentcpp. Even better, the library NamedType is available on GitHub, and it allows you to create a new strong type using just a single line of code.
Amar Mahmutbegović
Head of Engineering
Embedded developer with a proven history in the development of BLE embedded firmware in C and C++. Co-founder of Semblie.
S