Book Image

Embedded Programming with Modern C++ Cookbook

By : Igor Viarheichyk
Book Image

Embedded Programming with Modern C++ Cookbook

By: Igor Viarheichyk

Overview of this book

Developing applications for embedded systems may seem like a daunting task as developers face challenges related to limited memory, high power consumption, and maintaining real-time responses. This book is a collection of practical examples to explain how to develop applications for embedded boards and overcome the challenges that you may encounter while developing. The book will start with an introduction to embedded systems and how to set up the development environment. By teaching you to build your first embedded application, the book will help you progress from the basics to more complex concepts, such as debugging, logging, and profiling. Moving ahead, you will learn how to use specialized memory and custom allocators. From here, you will delve into recipes that will teach you how to work with the C++ memory model, atomic variables, and synchronization. The book will then take you through recipes on inter-process communication, data serialization, and timers. Finally, you will cover topics such as error handling and guidelines for real-time systems and safety-critical systems. By the end of this book, you will have become proficient in building robust and secure embedded applications with C++.
Table of Contents (17 chapters)

Using C++ for embedded development

For many years, the vast majority of an embedded project was developed using the C programming language. This language perfectly fits the needs of embedded software developers. It provides feature-rich and convenient syntax but at the same time, it is relatively low-level and does not hide platform specifics from developers. 

Due to its versatility, compactness, and the high performance of the compiled code, it became a de facto standard development language in the embedded world. Compilers for the C language exist for most, if not all, architectures; they are optimized to generate machine code that is more efficient than those that are written manually.

Over time, the complexity of embedded systems increased and developers faced the limitations of C, the most notable being error-prone resource management and a lack of high-level abstractions. The development of complex applications in C requires a lot of effort and time. 

At the same time, C++ was evolving, gaining new features and adopting programming techniques that make it the best choice for developers of modern embedded systems. These new features and techniques are as follows:

  • You don't pay for what you don't use.
  • Object-oriented programming to time the code complexity.
  • Resource acquisition is initialization (RAII).
  • Exceptions.
  • A powerful standard library.
  • Threads and memory model as part of the language specification.

You don't pay for what you don't use

One of the mottos of C++ is You don't pay for what you don't use. This language is packed with many more features than C, yet it promises zero overhead for those that are not used. 

Take, for example, virtual functions:

#include <iostream>

class A {

public:

void print() {

std::cout << "A" << std::endl;

}

};

class B: public A {

public:

void print() {

std::cout << "B" << std::endl;

}

};

int main() {

A* obj = new B;

obj->print();

}

The preceding code will output A, despite obj pointing to the object of the B class. To make it work as expected, the developer adds a keywordvirtual:

#include <iostream>

class A {

public:

virtual void print() {

std::cout << "A" << std::endl;

}

};

class B: public A {

public:

void print() {

std::cout << "B" << std::endl;

}

};

int main() {

A* obj = new B;

obj->print();

}

After this change, the code outputs B, which is what most developers expect to get as a result. You may ask why C++ does not enforce every method to be virtual by default. This approach is adopted by Java and doesn't seem to have any downsides.

The reason is that virtual functions are not free. Function resolution is performed at runtime via the virtual table—an array of function pointers. It adds a slight overhead to the function invocation time. If you do not need dynamic polymorphism, you do not pay for it. That is why C++ developers add the virtual keyboard, to explicitly agree with functionality that adds performance overhead.

Object-oriented programming to time the code complexity

As the complexity of embedded programs grows over time, it becomes more and more difficult to manage them using the traditional procedural approach provided by the C language. If you take a look at a large C project, such as the Linux kernel, you will see that it adopts many aspects of object-oriented programming.

The Linux kernel extensively uses encapsulation, hiding implementation details and providing object interfaces using C structures.

Though it is possible to write object-oriented code in C, it is much easier and convenient to do it in C++, where a compiler does all the heavy lifting for the developers.

Resource acquisition is initialization

Embedded developers work a lot with the resources provided by the operating system: memory, files, and network sockets. C developers use pairs of API functions to acquire and free resources; for example, malloc to claim a block of memory and free to return it to the system. If for some reason the developer forgets to invoke free, this block of memory leaks. Memory leaking, or resource leaking, is generally a common problem in applications written in C:

#include <stdio.h>

#include <unistd.h>

#include <fcntl.h>

#include <string.h>

int AppendString(const char* str) {

int fd = open("test.txt", O_CREAT|O_RDWR|O_APPEND);

if (fd < 0) {

printf("Can't open file\n");

return -1;

}

size_t len = strlen(str);

if (write(fd, str, len) < len) {

printf("Can't append a string to a file\n");

return -1;

}

close(fd);

return 0;

}

This preceding code looks correct, but it contains several serious issues. If the write function returns an error or writes less data than requested (and this is correct behavior), the AppendString function logs an error and returns. However, if it forgets to close the file descriptor, it leaks. Over time, more and more file descriptors leak and at some point, the program reaches the limit of open file descriptors, making all calls to the open function fail.

C++ provides a powerful programming idiom that prevents resource leakage: RAII. A resource is allocated in an object constructor and deallocated in the object destructor. This means that the resource is only held while the object is alive. It is automatically freed when the object is destroyed:

#include <fstream>

void AppendString(const std::string& str) {

std::ofstream output("test.txt", std::ofstream::app);

if (!output.is_open()){

throw std::runtime_error("Can't open file");

}

output << str;

}

Note that this function does not call close explicitly. The file is closed in the destructor of the output object, which is automatically invoked when the AppendString function returns.

Exceptions

Traditionally, C developers handled errors using error codes. This approach requires lots of attention from the coders and is a constant source of hard-to-find bugs in C programs. It is too easy to omit or overlook missing check-for-a-return code, masking the error:

#include <stdio.h>

#include <unistd.h>

#include <fcntl.h>

#include <iostream>

#include <fstream>

char read_last_byte(const char* filename) {

char result = 0;

int fd = open(filename, O_RDONLY);

if (fd < 0) {

printf("Can't open file\n");

return -1;

}

lseek(fd, -1, SEEK_END);

size_t s = read(fd, &result, sizeof(result));

if (s != sizeof(result)) {

printf("Can't read from file: %lu\n", s);

close(fd);

return -1;

}

close(fd);

return result;

}

The preceding code has at least two issues related to error handling. First, the result of the lseek function call is not checked. If lseek returns an error, the function will work incorrectly. The second issue is more subtle, yet more important and harder to fix. The read_last_byte function returns -1 to indicate an error, but it is also a valid value of a byte. It is not possible to distinguish whether the last byte of a file is 0xFF or whether the function encountered an error. To correctly handle this case, the function interface should be redefined as follows:

int read_last_byte(const char* filename, char* result);

The function returns -1 in the case of an error and 0 otherwise. The result is stored in a char variable passed by reference. Although this interface is correct, it is not as convenient for developers as the original one.

A program that eventually crashes randomly may be considered the best outcome for these kinds of errors. It would be worse if it keeps working, silently corrupting data or generating incorrect results. 

Besides that, the code that implements the logic and the code responsible for error checks are intertwined. The code becomes hard to read and hard to understand and, as a result, even more error-prone.

Although developers can still keep using return codes, the recommended way of error handling in modern C++ is exceptions. Correctly designed and correctly used exceptions significantly reduce the complexity of error handling, making code readable and robust. 

The same function written in C++ using exceptions looks much cleaner: 

char read_last_byte2(const char* filename) {

char result = 0;

std::fstream file;

file.exceptions (

std::ifstream::failbit | std::ifstream::badbit );

file.open(filename);

file.seekg(-1, file.end);

file.read(&result, sizeof(result));

return result;

}

 

The powerful standard library

C++ comes with a feature-rich and powerful standard library. Many functions that required C developers to use third-party libraries are now part of the standard C++ library. This means less external dependencies, more stable and predictable behavior, and improved portability between hardware architectures.

The C++ standard library comes with containers built on top of the most commonly used data structures, such as arrays, binary trees, and hash tables. These containers are generic and efficiently cover most of the developer's everyday needs. Developers do not need to spend time and effort creating their own, often error-prone, implementations of the essential data structures.

The containers are carefully designed in a way that minimizes the need for explicit resources, allocation, or deallocation, leading to significantly lower chances of memory or other system resources leaking.

The standard library also provides many standard algorithms, such as find, sort, replace, binary search, operations with sets, and permutations. The algorithms can be applied to any containers that expose integrator interfaces. Combined with standard containers, they help developers focus on high-level abstractions and build them on top of well-tested functionality with a minimal amount of additional code.

Threads and a memory model as part of the language specification

The C++11 standard introduced a memory model that clearly defines the behavior of a C++ program in a multithreaded environment. 

For the C language specifications, the memory model was out of scope. The language itself was not aware of threads or parallel execution semantics. It was up to the third-party libraries, such as pthreads, to provide all the necessary support for multithread applications.

Earlier versions of C++ followed the same principle. Multithreading was out of the scope of the language specification. However, modern CPUs with multiple pipelines supporting instruction reordering demanded more deterministic behavior of compilers.

As a result, modern specifications of C++ explicitly define classes for threads, various types of locks and mutexes, condition variables, and atomic variables. This gives embedded developers a powerful tool kit to design and implement applications capable of utilizing all the power of modern multicore CPUs. Since the tool kit is part of the language specification, these applications have deterministic behavior and are portable to all supported architectures.