Running complex C ++ applications on microcontrollers

imageToday nobody will be surprised by the opportunity to develop in C ++ for microcontrollers. The mbed project is fully focused on this language. A number of other RTOSs provide C ++ development capabilities. This is convenient, because the programmer has access to object-oriented programming tools. However, many RTOSs impose various restrictions on the use of C ++. In this article we will look at the internals of C ++ and find out the reasons for these limitations.



I want to note right away that most of the examples will be considered on RTOS Embox . Indeed, such complex C ++ projects as Qt and OpenCV work in it on microcontrollers . OpenCV requires full C ++ support, which is usually not found on microcontrollers.



Basic syntax



The syntax of the C ++ language is implemented by the compiler. But at runtime, it is necessary to implement several basic entities. In the compiler, they are included in the libsupc ++ language support library. A. The most basic is the support for constructors and destructors. There are two types of objects: global and new ones.



Global constructors and destructors



Let's take a look at how any C ++ application works. Before entering main (), all global C ++ objects are created, if they are present in the code. For this, a special section .init_array is used. There can also be sections .init, .preinit_array, .ctors. For modern ARM compilers, the most common use of sections is .preinit_array, .init, and .init_array. From the point of view of LIBC, this is an ordinary array of pointers to functions, which must be passed from beginning to end by calling the corresponding element of the array. After this procedure, control is transferred to main ().



The code for calling constructors for global objects from Embox:



void cxx_invoke_constructors(void) {
    extern const char _ctors_start, _ctors_end;
    typedef void (*ctor_func_t)(void);
    ctor_func_t *func = (ctor_func_t *) &_ctors_start;

    ....

    for ( ; func != (ctor_func_t *) &_ctors_end; func++) {
        (*func)();
    }
}
      
      





Let's now see how the termination of a C ++ application works, namely, calling the destructors of global objects. There are two ways.



I'll start with the one most commonly used in compilers - via __cxa_atexit () (from the C ++ ABI). This is an analogue of the POSIX atexit function, that is, you can register special handlers that will be called when the program ends. When the global constructors are called at the start of the application, as described above, there is also compiler-generated code that registers handlers via the __cxa_atexit call. LIBC's job here is to store the required handlers and their arguments and call them when the application ends.



Another way is to store pointers to destructors in special sections .fini_array and .fini. In the GCC compiler, this can be achieved with the -fno-use-cxa-atexit flag. In this case, the destructors must be called in reverse order (from high address to low address) during application termination. This method is less common, but can be useful in microcontrollers. Indeed, in this case, at the time of building the application, you can find out how many handlers are required.



The code for calling destructors for global objects from Embox:



int __cxa_atexit(void (*f)(void *), void *objptr, void *dso) {
    if (atexit_func_count >= TABLE_SIZE) {
        printf("__cxa_atexit: static destruction table overflow.\n");
        return -1;
    }

    atexit_funcs[atexit_func_count].destructor_func = f;
    atexit_funcs[atexit_func_count].obj_ptr = objptr;
    atexit_funcs[atexit_func_count].dso_handle = dso;
    atexit_func_count++;

    return 0;
};

void __cxa_finalize(void *f) {
    int i = atexit_func_count;

    if (!f) {
        while (i--) {
            if (atexit_funcs[i].destructor_func) {
                (*atexit_funcs[i].destructor_func)(atexit_funcs[i].obj_ptr);
                atexit_funcs[i].destructor_func = 0;
            }
        }
        atexit_func_count = 0;
    } else {
        for ( ; i >= 0; --i) {
            if (atexit_funcs[i].destructor_func == f) {
                (*atexit_funcs[i].destructor_func)(atexit_funcs[i].obj_ptr);
                atexit_funcs[i].destructor_func = 0;
            }
        }
    }
}

void cxx_invoke_destructors(void) {
    extern const char _dtors_start, _dtors_end;
    typedef void (*dtor_func_t)(void);
    dtor_func_t *func = ((dtor_func_t *) &_dtors_end) - 1;

    /* There are two possible ways for destructors to be calls:
     * 1. Through callbacks registered with __cxa_atexit.
     * 2. From .fini_array section.  */

    /* Handle callbacks registered with __cxa_atexit first, if any.*/
    __cxa_finalize(0);

    /* Handle .fini_array, if any. Functions are executed in teh reverse order. */
    for ( ; func >= (dtor_func_t *) &_dtors_start; func--) {
        (*func)();
    }
}
      
      





Global destructors are required to be able to restart C ++ applications. Most RTOS for microcontrollers run a single application that does not reboot. The start begins with a custom function main, the only one in the system. Therefore, in small RTOSs, global destructors are often empty, because they are not intended to be used.



Global destructors code from Zephyr RTOS:



/**
 * @brief Register destructor for a global object
 *
 * @param destructor the global object destructor function
 * @param objptr global object pointer
 * @param dso Dynamic Shared Object handle for shared libraries
 *
 * Function does nothing at the moment, assuming the global objects
 * do not need to be deleted
 *
 * @return N/A
 */
int __cxa_atexit(void (*destructor)(void *), void *objptr, void *dso)
{
    ARG_UNUSED(destructor);
    ARG_UNUSED(objptr);
    ARG_UNUSED(dso);
    return 0;
}

      
      





New / delete operators



In the GCC compiler, the implementation of the new / delete operators is in the libsupc ++ library, and their declarations are in the header file.



You can use the new / delete implementations from libsupc ++. A, but they are quite simple and can be implemented, for example, through standard malloc / free or analogs.



New / delete implementation code for simple Embox objects:




void* operator new(std::size_t size)  throw() {
    void *ptr = NULL;

    if ((ptr = std::malloc(size)) == 0) {
        if (alloc_failure_handler) {
            alloc_failure_handler();
        }
    }

    return ptr;
}
void operator delete(void* ptr) throw() {
    std::free(ptr);
}
      
      





RTTI & exceptions



If your application is simple, you may not need exception support and dynamic data type identification (RTTI). In this case, they can be disabled using the compiler flags -no-exception -no-rtti.



But if this C ++ functionality is required, it needs to be implemented. This is much more difficult to do than new / delete.



The good news is that these things are OS independent and are already cross-compiled in the libsupc ++ library. A. Accordingly, the easiest way to add support is to use the libsupc ++. A library from the cross compiler. The prototypes themselves are in the header files and.



To use cross-compiler exceptions, there are small requirements that must be met when adding your own C ++ runtime load method. The linker script must have a special .eh_frame section. And before using runtime, this section must be initialized with the address of the beginning of the section. Embox uses the following code:



void register_eh_frame(void) {
    extern const char _eh_frame_begin;
    __register_frame((void *)&_eh_frame_begin);
}
      
      





For ARM architecture, other sections with their own information structure are used - .ARM.exidx and .ARM.extab. The format of these sections is defined in the “Exception Handling ABI for the ARM Architecture” - EHABI standard. .ARM.exidx is the index table, and .ARM.extab is the table of the actual items required to handle the exception. To use these sections for handling exceptions, you need to include them in the linker script:



    .ARM.exidx : {
        __exidx_start = .;
        KEEP(*(.ARM.exidx*));
        __exidx_end = .;
    } SECTION_REGION(text)

    .ARM.extab : {
        KEEP(*(.ARM.extab*));
    } SECTION_REGION(text)
      
      





To enable GCC to use these sections to handle exceptions, the start and end of the .ARM.exidx section are specified - __exidx_start and __exidx_end. These symbols are imported into libgcc in the libgcc / unwind-arm-common.inc file:

extern __EIT_entry __exidx_start;
extern __EIT_entry __exidx_end;
      
      





For more information about stack unwind on ARM, see the article .



Language Standard Library (libstdc ++)



Native implementation of the standard library



C ++ support includes not only the basic syntax, but also the libstdc ++ standard library. Its functionality, as well as for syntax, can be divided into different levels. There are basic things like working with strings or C ++ setjmp wrapper. They are easily implemented through the standard C library. And there are more advanced things, for example, the Standard Template Library (STL).



Standard library from cross compiler



Basic things are implemented in Embox. If these things are enough, then you can not include the external C ++ standard library. But if you need, for example, support for containers, then the easiest way is to use the library and header files from the cross-compiler.



There is a quirk when using the C ++ Standard Library from a cross compiler. Let's take a look at the standard arm-none-eabi-gcc:



$ arm-none-eabi-gcc -v
Using built-in specs.
COLLECT_GCC=arm-none-eabi-gcc
COLLECT_LTO_WRAPPER=/home/alexander/apt/gcc-arm-none-eabi-9-2020-q2-update/bin/../lib/gcc/arm-none-eabi/9.3.1/lto-wrapper
Target: arm-none-eabi
Configured with: ***     --with-gnu-as --with-gnu-ld --with-newlib   ***
Thread model: single
gcc version 9.3.1 20200408 (release) (GNU Arm Embedded Toolchain 9-2020-q2-update)
      
      





It is built with support for the --with-newlib.Newlib implementation of the C standard library. Embox uses its own implementation of the standard library. There is a reason for this, minimizing overhead. And therefore, the required parameters can be set for the standard C library, as well as for other parts of the system.



Since the standard C libraries are different, a compatibility layer must be implemented to maintain runtime. I will give an example of implementation from Embox of one of the necessary but not obvious things to support the standard library from a cross-compiler



struct _reent {
    int _errno;           /* local copy of errno */

  /* FILE is a big struct and may change over time.  To try to achieve binary
     compatibility with future versions, put stdin,stdout,stderr here.
     These are pointers into member __sf defined below.  */
    FILE *_stdin, *_stdout, *_stderr;
};

struct _reent global_newlib_reent;

void *_impure_ptr = &global_newlib_reent;

static int reent_init(void) {
    global_newlib_reent._stdin = stdin;
    global_newlib_reent._stdout = stdout;
    global_newlib_reent._stderr = stderr;

    return 0;
}
      
      





All parts and their implementations necessary for using the libstdc ++ cross-compiler can be viewed in Embox in the 'third-party / lib / toolchain / newlib_compat /' folder



Extended support for the standard library std :: thread and std :: mutex



The C ++ Standard Library in the compiler can have different levels of support. Let's take another look at the output:



$ arm-none-eabi-gcc -v
***
Thread model: single
gcc version 9.3.1 20200408 (release) (GNU Arm Embedded Toolchain 9-2020-q2-update)
      
      





Thread model: single. When GCC is built with this option, all thread support from the STL is removed (for example, std :: thread and std :: mutex ). And, for example, there will be problems with the assembly of such a complex C ++ application as OpenCV. In other words, this version of the library is not enough to build applications that require this functionality.



The solution we use at Embox is to build our own compiler for the sake of the standard library with a multithreaded model. In the case of Embox, the posix “Thread model: posix” is used. In this case, std :: thread and std :: mutex are implemented via the standard pthread_ * and pthread_mutex_ *. This also removes the need to include the newlib compatibility layer.



Embox configuration



Although rebuilding the compiler is the most reliable and provides the most complete and compatible solution, but at the same time it takes a lot of time and may require additional resources, which are not so many in the microcontroller. Therefore, this method is not advisable to use everywhere.



In order to optimize support costs, Embox has introduced several abstract classes (interfaces) of which different implementations can be specified.



  • embox.lib.libsupcxx - defines which method to use to support the basic syntax of the language.
  • embox.lib.libstdcxx - defines which implementation of the standard library to use


There are three options for libsupcxx:



  • embox.lib.cxx.libsupcxx_standalone - basic implementation included in Embox.
  • third_party.lib.libsupcxx_toolchain - use the language support library from the cross compiler
  • third_party.gcc.tlibsupcxx - complete assembly of the library from sources


The minimal option can work even without the C ++ standard library. Embox has an implementation based on the simplest functions from the standard C library. If this functionality is not enough, you can set three libstdcxx options.



  • third_party.STLport.libstlportg is an STL standard library based on the STLport project. Doesn't require rebuilding gcc. But the project has not been supported for a long time
  • third_party.lib.libstdcxx_toolchain - standard library from the cross compiler
  • third_party.gcc.libstdcxx - complete assembly of the library from sources


If you wish, our wiki describes how you can build and run Qt or OpenCV on STM32F7. All code is naturally free.



All Articles