Running complex C++ applications on microcontrollers – InformTFB

Running complex C++ applications on microcontrollers

Running complex C++ applications on microcontrollers

imageToday, no one is surprised by the opportunity to develop in C++ for microcontrollers. The mbed project is completely focused on this language. A number of other RTOS provide the possibility of developing in C++. This is convenient, because the programmer has access to object-oriented programming tools. However, many RTOS impose various restrictions on the use of C++. In this article, we will look at the internal organization of C++ and find out the reasons for these limitations.

Just want to note that most of the examples will be considered on RTOS Embox. After all, it runs such complex C++ projects as Qt and OpenCV on microcontrollers. OpenCV requires full C++ support, which is usually not available on microcontrollers.

Basic syntax

The C++ syntax is implemented by the compiler. But in runtime, you need to implement several basic entities. In the compiler, they are included in the libsupc++language support library. a. the most basic is support for constructors and destructors. There are two types of objects: global and allocated using new statements.

Global constructors and destructors

Let’s take a look at how any C++ application works. Before getting to main(), all global C++ objects are created, if they are present in the code. To do this, use the special section .init_array. There may also be sections .init,. preinit_array, and .ctors. For modern ARM compilers, sections are most often used in the following order .preinit_array, .init, and .init_array. From the point of view of LIBC, this is an ordinary array of function pointers, which you need to go from the beginning to the end, calling the corresponding array element. After this procedure, control is passed to main().

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)();
    }
}

Now let’s see how the completion of a C++ application works, namely, calling global object destructors. There are two ways.

I’ll start with the most commonly used one in compilers-using __cxa_atexit() (from the C++ ABI). This is the POSIX equivalent of the atexit function, meaning you can register special handlers that will be called when the program terminates. When global constructors are called at application startup, as described above, there is also compiler-generated code that registers handlers through the __cxa_atexit call. The LIBC task here is to save the required handlers and their arguments and call them at the moment of application termination.

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

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 involve running a single application that doesn’t restart. Start starts with the user-defined main function, which is the only one in the system. Therefore, in small RTOS, global destructors are often empty, because they are not supposed to be used.

Code for global destructors 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 statements

In the GCC compiler, the implementation of new/delete statements is located in the libsupc++ library, And their declarations are located 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, you can disable them by using the compiler flags-no-exception -no-rtti.

But if this C++ functionality is required, you need to implement it. This is much more difficult to do than new/delete.

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

To use cross-compiler exceptions, there are small requirements that need to be implemented when adding your own C++ runtime loading method. In the linker script must be provided in a special section .eh_frame. And before using the 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 the ARM architecture, other sections with their own information structure are used — .ARM.exidx and .ARM.extab. The format of these files is defined in the “Exception Handling ABI for the ARM Architecture” — EHABI standard. .ARM. exidx is a table of indexes, and. ARM. extab is a table of the elements themselves required for exception handling. To use these sections for exception handling, you must 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 allow GCC to use these sections for exception handling, specify the start and end of the section .ARM.exidx — __exidx_start and __exidx_end. These symbols are imported into libgcc in the file libgcc/unwind-arm-common. inc:

extern __EIT_entry __exidx_start;
extern __EIT_entry __exidx_end;

The standard language library (libstdc++)

Native implementation of the standard library

C++ language 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 the C++ setjmp wrapper . They are easily implemented through the C standard library. And there are more advanced things, for example, the Standard Template Library (STL).

Standard library from a cross-compiler

Basic things are implemented in Embox. If these things are enough, then you don’t need to connect 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.

When using the C++ standard library from a cross-compiler, there is a feature. 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 –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. Therefore, you can set the required parameters for the standard C library, just like for other parts of the system.

Since the standard C libraries are different, you need to implement a compatibility layer to support runtime. I will give an example of an 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 required for using the libstdc++ cross-compiler can be viewed in Embox in the folder ‘ third-party/lib/toolchain/newlib_compat/’

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

The C++ standard library in the compiler may 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)

The “Thread model: single ” thread model. 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, with the Assembly of such a complex C++ application as OpenCV, problems will arise. In other words, this version of the library is not enough to build applications that require this functionality.

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

Embox Configuration

Although rebuilding the compiler is the most reliable and provides the most complete and compatible solution, it takes quite a long time and may require additional resources that are not so much in the microcontroller. Therefore, it is not advisable to use this method everywhere.

To optimize support costs, Embox introduces several abstract classes( interfaces) with various implementations that can be specified.

  • embox.lib.libsupcxx — determines which method to support the basic syntax of the language you want to use.
  • embox.lib.libstdcxx — determines which implementation of the standard library you need to use

There are three options for libsupcxx:

  • embox.lib.cxx.libsupcxx_standalone — base implementation in the composition of the Embox.
  • third_party. lib.libsupcxx_toolchain — use the language support library from the cross-compiler
  • third_party.gcc.tlibsupcxx — full Assembly library from source

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

  • third_party. STLport. libstlportg — a standard library that includes STL based on the STLport project. It 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 — full Assembly library from source

end.

Anderson
Anderson
Web site editor and tester.

Leave a Reply

Your email address will not be published. Required fields are marked *