Leveraging Our Build Systems to Support Portability

We’ve shared practical decoupling techniques and discussed lightweight abstractions that help us create portable firmware modules. These techniques help us create portable software, but they don’t prevent anyone directly using vendor SDKs and native OS function calls in portable modules.

In this article we will share techniques that leverage build systems and repository structures to enforce loose coupling between firmware and the underlying hardware. Our goal is stress-free enforcement of portability rules. Our builds should fail if we’re doing the wrong things rather than allowing us to pollute our code with platform-specific functionality.

Changing our approach to organizing and building our projects makes it easier to:

  1. Migrate from one platform to another
  2. Test modules in a unit test harness
  3. Run and debug code directly on our development machines, giving us access to fuzzers, sanitizers, and other useful non-embedded debugging tools
Note

 The techniques described in this article require that you have control over the project’s build system. If you’re using an IDE, you may not have sufficient control to implement these techniques.

Table of Contents:

  1. Avoid `#ifdef` Statements
  2. Platform-Specific Include Paths
  3. Isolate the Platform-Specific Pieces
  4. Putting it All Together
  5. Further Reading

Avoid #ifdef Statements

We commonly encounter code which uses #ifdef statements to enable portability. Whenever there is a header, API call, or implementation detail that changes from one platform to another, developers leverage the preprocessor to supply the correct implementation during compilation. Here’s an example from libcpp’s locale.cpp:

wchar_t ctype<wchar_t>::do_toupper(char_type c) const
{
#ifdef _LIBCPP_HAS_DEFAULTRUNELOCALE
    return isascii(c) ? _DefaultRuneLocale.__mapupper[c] : c;
#elif defined(__GLIBC__) || defined(__EMSCRIPTEN__) || \
      defined(__NetBSD__)
    return isascii(c) ? ctype<char>::__classic_upper_table()[c] : c;
#else
    return (isascii(c) && iswlower_l(c, _LIBCPP_GET_C_LOCALE)) ? c-L'a'+L'A' : c;
#endif
}

This approach works, but we recommend avoiding #ifdef statements for portability. Sometimes we can justify the use of the preprocessor. In these cases, we should keep the statements hidden behind an abstraction layer. We do not want preprocessor statements polluting our entire code base.

One problem with a preprocessor approach is that it is error prone. It is easy to add platform-specific function calls and forget to wrap them in an #ifdef. Every time we need to support a new platform, we will need to update every relevant #ifdef block. Each preprocessor block may use different logic, meaning that some relevant preprocessor blocks don’t come up in a simple “find all” search.

Consider the example above alongside the code below (from the libcpp __locale header). Depending on the search term you are using during your #ifdef update hunt, you may find one of these code snippets but not the other. If you look through the logic in both source files, you’ll notice that there’s a lot of nuanced details on how and where the various platforms differ. The source requires a complete read-through to make sure you update every spot relevant to your platform.

#if defined(_LIBCPP_MSVCRT_LIKE)
# include <support/win32/locale_win32.h>
#elif defined(_AIX)
# include <support/ibm/xlocale.h>
#elif defined(__ANDROID__)
# include <support/android/locale_bionic.h>
#elif defined(__sun__)
# include <xlocale.h>
# include <support/solaris/xlocale.h>
#elif defined(_NEWLIB_VERSION)
# include <support/newlib/xlocale.h>
#elif (defined(__APPLE__)      || defined(__FreeBSD__) \
    || defined(__EMSCRIPTEN__) || defined(__IBMCPP__))
# include <xlocale.h>
#elif defined(__Fuchsia__)
# include <support/fuchsia/xlocale.h>
#elif defined(_LIBCPP_HAS_MUSL_LIBC)
# include <support/musl/xlocale.h>
#endif

Another problem with relying on the preprocessor is that our code becomes increasingly difficult to parse. When there are 1–2 different cases to support, an #ifdef seems like a simple solution. However, as the program grows, we become unable to read through a function or file and gain a solid understanding of what it does.

Consider this example from libcpp’s locale.cpp:

#ifdef _LIBCPP_PROVIDES_DEFAULT_RUNE_TABLE
const ctype<char>::mask*
ctype<char>::classic_table()  _NOEXCEPT
{
    static _LIBCPP_CONSTEXPR const ctype<char>::mask builtin_table[table_size] = {
        /* Hiding Table Details for example brevity... */
    };
    return builtin_table;
}
#else
const ctype<char>::mask*
ctype<char>::classic_table()  _NOEXCEPT
{
#if defined(__APPLE__) || defined(__FreeBSD__)
    return _DefaultRuneLocale.__runetype;
#elif defined(__NetBSD__)
    return _C_ctype_tab_ + 1;
#elif defined(__GLIBC__)
    return _LIBCPP_GET_C_LOCALE->__ctype_b;
#elif __sun__
    return __ctype_mask;
#elif defined(_LIBCPP_MSVCRT) || defined(__MINGW32__)
    return __pctype_func();
#elif defined(__EMSCRIPTEN__)
    return *__ctype_b_loc();
#elif defined(_NEWLIB_VERSION)
    // Newlib has a 257-entry table in ctype_.c, where (char)0 starts at [1].
    return _ctype_ + 1;
#elif defined(_AIX)
    return (const unsigned int *)__lc_ctype_ptr->obj->mask;
#else
    // Platform not supported: abort so the person doing the port knows what to
    // fix
# warning  ctype<char>::classic_table() is not implemented
    printf("ctype<char>::classic_table() is not implemented\n");
    abort();
    return NULL;
#endif
}
#endif

That is a simple “one-line” function built for cross-platform portability. While it achieves portability, it’s unreadable and makes my heart race a little faster when I see so many #if/#elif statements.

Even in simpler scenarios with fewer cases, #ifdef statements can create confusion. A common question I ask is “Which variant is actually used during my target compilation?” This happens even in familiar code bases. The answer is often difficult to find by anyone except the core developers, and even they may struggle.

The preprocessor is a powerful tool, but we tend to have more reliable methods for achieving portability.

Proper Constraints

I used libcpp to show the problems that arise with #ifdef statements because it is a multi-platform project. However, libcpp’s use of #ifdef statements for portability meets the criteria we previously mentioned: if we need to use the preprocessor for portability, we should constrain its use behind an abstraction layer.

While libcpp serves as an example of how #ifdef statements can make code difficult to understand, they also show how an abstraction layer shields us from these details. The preprocessor statements are contained and do not pollute your program. You need not worry about the underlying platform differences, you just use standard C++ function calls.

The libcpp model is the exactly what we want to enforce within our own projects: the messy platform-specific logic is isolated in a layer that most developers never interact with.

Platform-Specific Include Paths

We often have different headers, type definitions, and function prototypes for different platforms. The first step to cleaning up our code is often to put the platform-specific details in a generic header such as platform.h, which internally has the necessary #ifdef statements to provide the correct details.

If we’ve created abstractions and type definitions for our platform, we need not use the preprocessor for selecting the right headers and types. We can create a distinct set of headers for each platform. Each platform uses the same header naming scheme, but we store the headers in a different directory per platform. We then rely on our build system and our repository’s directory structure to supply the correct include path when we compile the program.

Example Scenario: libc Fixed-Width Integer Types

To show how this works, we’ll look at Embedded Artistry’s libc. Like libcpp, libc provides a common set of interfaces that support multiple platforms. Our job is to make sure that the types and interfaces are valid no matter the underlying platform.

To constrain the problem space, we will focus on the stdint.h fixed-width integer types (e.g., int8_tuint32_t). These types provide a signed or unsigned integer type alias with the specified number of bits.

The problem we face when implementing this support in libc is that the native type (intunsigned long) may differ in size (i.e., number of bytes) across processor architectures. Some compilers may also have built-in expectations about the definition of a fixed-width integer type that conflict with our definition. As a result, we need to supply a definition of the fixed-width types for each architecture.

Let’s solve this problem without the preprocessor.

Creating Header Abstractions

We have standard fixed-width integer type names, so building a new abstraction layer is unnecessary. Only a subset of the types require an architecture-specific definition:

  • int8_t
  • int16_t
  • int32_t
  • int64_t
  • uint8_t
  • uint16_t
  • uint32_t
  • uint64_t
  • intmax_t
  • uintmax_t
  • intptr_t
  • uintptr_t

We derive the other types and definitions provided by stdint.h (e.g. int8_least_t) from those platform-specific definitions.

Rather than handle this with the preprocessor, we will rely on our build system. For this solution, we defined an internal platform-specific header for each type. Every platform must use the same header organization scheme to supply the type definitions. These headers are meant for internal use only. We prefix our internal headers with an underscore (_) so they will stand out if we see them used in another module during a code review.

In our stdint.h header, we include the platform-specific headers that we need:

/* 7.18.1.1 Exact-width integer types */
#include <_types/_int8_t.h>
#include <_types/_int16_t.h>
#include <_types/_int32_t.h>
#include <_types/_int64_t.h>
#include <_types/_uint8_t.h>
#include <_types/_uint16_t.h>
#include <_types/_uint32_t.h>
#include <_types/_uint64_t.h>

Each header file defines the associated type for the target platform:

#ifndef UINT64_T_H_
#define UINT64_T_H_

// Definition for aarch64
typedef unsigned long uint64_t;

#endif //__UINT64_T_H_

A single header would also have been appropriate for this use case, since these types are consumed in a single location (stdint.h). Multiple headers are nice if different modules need access to only a subset of platform-specific definitions. We structure the modules to include only the details they need to know about without accidentally coupling them to unrelated details.

Organizing Our Repository

We will need to organize our repository to support this technique. We need to create a directory tree that contains the platform-specific details. Each platform will use a different folder. The directory structure and file list must be identical for each platform.

For the libc project, we place the platform-specific details in the arch/ folder, which represents different processor architectures.

You can see that the _types/ directory contains definitions for all platform-specific types, some of which are used by other headers and not stdint.h

Leverage the Build System

Once we organize our platform-specific code in this manner, we need to update our build system so it supplies the correct include paths. We use Meson in the examples below, but the techniques can be used with any build system.

The steps we will take are:

  1. Detect the Platform
  2. Include the Correct Directory
  3. Use the Include Path for Relevant Modules
  4. Install Headers to a Standard Location

Detect the Platform

The first thing we need to do is determine the target platform. This can take many forms:

  • Rely on the compiler to tell you what you need
  • Rely on the build system to tell you what you need
  • Check the operating system
  • Use a manual option

For our purposes, we rely on Meson to tell us the underlying processor architecture. Meson will provide information about the build machine, and we supply information for cross-compilation in a “cross file”.

if meson.is_cross_build()
    target_architecture = host_machine.cpu_family()
    native_architecture = build_machine.cpu_family()
else
    target_architecture = build_machine.cpu_family()
    native_architecture = build_machine.cpu_family()
endif

After we’ve determined the target architecture, we tell Meson to pick up the meson.build file for that architecture.

subdir('arch/' + target_architecture)

if target_architecture != native_architecture
    subdir('arch/' + native_architecture)
endif

Include the Correct Directory

The build file we included above contains the details we need for the target architecture. Here, we create dependencies and include directory definitions that we use elsewhere in the project. You might also supply custom source files, definitions, or linker flags specific to the target architecture.

# x86_64 architecture meson build

x86_64_arch_dep = declare_dependency(
    include_directories: include_directories('include')
)

if target_architecture == 'x86_64'
    target_arch_deps += [x86_64_arch_dep]
    target_arch_include += include_directories('include')
endif

if native_architecture == 'x86_64'
    native_arch_deps += [x86_64_arch_dep]
    native_arch_include += include_directories('include')
endif

Notice that we use the same abstraction principle for our build system: we have generic variables (e.g., target_arch_deps) which we tie to our actual platform-specific information. The rest of the build system only references the generic names, allowing us to use the same build rules for multiple architecture targets.

Use the Include Path for Relevant Modules

Once we’ve picked up the right include paths, we need to use them when defining build targets. For this libc library target, we reference target_arch_deps:

libc = static_library(
    'c',
    libc_standalone_files + libc_common_files,
    c_args: [stdlib_compiler_flags, gdtoa_compiler_flags, libc_host_compile_args, '-nostdinc'],
    include_directories: [libc_includes, libc_system_includes],
    dependencies: target_arch_deps, # <--- PLATFORM SPECIFIC DEP
    pic: true,
)

We also add it to the dependency variable that is used when linking against libc, ensuring the build system picks up the correct headers.

libc_dep = declare_dependency(
    link_with: [
        libc,
        libprintf
    ],
    include_directories: [libc_system_includes, target_arch_includes],
    compile_args: stdlib_compiler_flags,
)

Install Headers to a Standard Location

While our libc implementation does not install headers, we could change the build dependency configuration easily.

Consumers of libc might expect the headers to be installed to a specific location. Instead of installing all of the architecture-specific headers as a copy of the source tree, we could instead install only the relevant architecture-specific headers to a common location. We wouldn’t need to keep track of the target architecture anymore, so we would just install them to a top-level arch/ directory.

Under this scenario, we would change libc_dep to specify the top level arch/ folder as an include directory, breaking the dependency on the target_arch_deps.

libc_dep = declare_dependency(
    link_with: [
        libc,
        libprintf
    ],
    include_directories: [
        libc_system_includes,
        include_directories('include/path/to/arch/', is_system: true),
     ],
    compile_args: stdlib_compiler_flags,
)

Summary

What I described above is a generic and portable approach to organizing software projects. While we provided examples for the Meson build system, we have used the same approach with Make, CMake, and others. The full libc library build file is available within the GitHub repository.

The strategy to follow, regardless of the underlying build system:

  1. Organize platform-specific information inside a directory structure with a standard set of files, where each platform has its own folder
  2. Detect the target platform that the software is being compiled for
  3. Supply the proper include path to the build for the detected platform
  4. Optionally, install headers to a standard location

By relying on our build system, we can avoid the need for fancy preprocessor directives and can more easily write portable and reusable code.

Isolate the Platform-Specific Pieces

The previous solution works well for many cases. One critical detail is that any file which uses our libc headers also needs the include path for the target architecture. We don’t expect anyone to include the private libc headers directly.

Where private header inclusion becomes problematic is in situations where we are trying to constrain components that can tightly couple us to the underlying system, such as OS headers, processor headers, and vendor SDKs. We are building abstractions to make sure these details don’t accidentally leak into the rest of our code, so we need to fail the build if someone tries.

To prevent this, I isolate all platform-specific code into standalone libraries. The libraries provide abstractions and generic interfaces. The rest of the program works with these abstractions rather than working directly with platform-specific code. Under this model, we can ensure much of our firmware remains portable.

We’ll tackle this problem by:

  1. Stripping unwanted includes and definitions from headers
  2. Avoiding global include settings
  3. Building our abstraction modules as standalone libraries, which we link into the final executable

The following code is taken from the Embedded Virtual Machine project. The examples below are excerpts from a particular moment in time. We link to the files, but note that they may have evolved from the implementations shown below.

Strip Unwanted Includes and Definitions from Your Headers

The first step in addressing this problem is to remove unwanted includes and platform-specific definitions from our header files.

Our goal is to define an interface that shields the rest of the system from the underlying platform details, such as the OS or processor. If we include these platform-specific files in our abstraction headers, we are still allowing other modules in our system to access those details. We need to remove these unwanted includes.

The first rule: if you need to access platform-specific information, you should only do that in source files (.c.cpp). You can still use internal headers which aren’t exposed to the rest of the system. However, you must not use these internal headers in public header files.

Example Processor Abstraction

Consider a barebones Nordic nRF52840 processor interface. Rather than directly manipulating nRF52 processor registers, code which needs to interact with the processor must use generic processor interfaces (or generic driver interfaces). If we need to switch processors, we can re-implement these interfaces for the new processor without needing to modify higher-level code that interacts with the processor through our abstracted interface.

Notice that there are no includes for CMSIS headers or Nordic SDK headers. We only depend on a C++ standard library header and a generic abstraction that defines the required processor interfaces.

#ifndef nRF52840_HPP_
#define nRF52840_HPP_

#include <cstdint>
#include <processor/virtual_processor.hpp>

class nRF52840 : public embvm::VirtualProcessorBase<nRF52840>
{
    using ProcessorBase = embvm::VirtualProcessorBase<nRF52840>;

  public:
    nRF52840() noexcept : ProcessorBase("nrF52840") {}
    ~nRF52840();

#pragma mark - Inherited Functions -

    static void earlyInitHook_() noexcept {}

    void init_() noexcept;

    void reset_() noexcept;

#pragma mark - Custom Functions -

    void spin(unsigned int usecs) noexcept;
    
    /// Returns true if high voltage mode is enabled
    bool highVoltageMode() noexcept;

    /**
     * Function for configuring UICR_REGOUT0 register
     * to set GPIO output voltage to 3.0V.
     *
     * This function will reset the microcontroller if the requested setting was applied.
     * The UICR registers require this.
     */
    bool highVoltageMode(bool en) noexcept;
};

#endif // nRF52840_HPP_

Within our nrf52840.cpp file we can safely use the Nordic SDK headers to access the processor definitions:

#include"nrf52840.hpp"
#include <nrf52840.h>
#include <nrf52840_bitfields.h>

bool nRF52840::highVoltageMode() noexcept
{
    return (NRF_POWER->MAINREGSTATUS &
            (POWER_MAINREGSTATUS_MAINREGSTATUS_High << 
                POWER_MAINREGSTATUS_MAINREGSTATUS_Pos));
}

bool nRF52840::highVoltageMode(bool en) noexcept
{
    // Configure UICR_REGOUT0 register only if it is set to default value.
    if(en && checkVOUT(UICR_REGOUT0_VOUT_DEFAULT))
    {
        setVOUT(UICR_REGOUT0_VOUT_3V0);
    }
    // else if disabling high voltage mode and we _aren't_ default, 
    // we need to restore it
    else if(!checkVOUT(UICR_REGOUT0_VOUT_DEFAULT))
    {
        setVOUT(UICR_REGOUT0_VOUT_DEFAULT);
    }

    return en;
}

/*
* Full implementation is truncated for brevity
*/

Handling Platform-Specific Types

Types that depend on platform-specific information are often the trickiest detail to nail. You should start by using forward declarations, leaving the details implemented in a source file or internal header. In scenarios where forward declarations don’t suffice, I recommend that you create your own abstract type and convert to the platform-specific type under the hood.

For example, a generic mutex abstraction might define a type to differentiate between “normal” and “recursive” mutexes:

enum class mutex_type_t : std::uint8_t
{
    /// A normal mutex can only be locked once, even by the same thread.
    normal = 0,
    /// A recursive mutex can be locked multiple times by the same thread,
    /// but it must be unlocked the same number of times.
    recursive,
    /// The default operational type for the Virtual RTOS mutexes is recursive.
    defaultType = recursive,
};

We can translate the abstract type into different code paths within the implementation details:

FreeRTOSMutex:: FreeRTOSMutex(mutex_type_t type) noexcept : type_(type)
{
    switch(type)
    {
        case embvm::mutex::type::recursive:
            handle_ = createRecursiveMutex();
            break;
        case embvm::mutex::type::normal:
            handle_ =  createMutex();
            break;
    }
}

If you’re using a typedef or using to convert one type into a generic type alias, you can instead define a common abstraction based on void * or uintptr_t.

Consider a generic mutex once more. Most mutex APIs expect to receive a handle representing the instance of the mutex. The exact definition of that handle changes from implementation to implementation. To deal with this, we can create a generic definition of a handle which is an alias for a uintptr_t.

/// Generic type for mutex handles
using mutex_handle_t = uintptr_t;

The implementation handles the conversion between the generic type and the specific types:

// Convert from FreeRTOS to generic handle
mutex_handle_t createRecursiveMutex() noexcept
{
    return reinterpret_cast<mutex_handle_t>(xSemaphoreCreateRecursiveMutex());
}

// Convert from generic handle to Free RTOS
void FreeRTOSMutex::unlock() noexcept
{
    if(type_ == embvm::mutex::type::recursive)
    {
        xSemaphoreGiveRecursive(reinterpret_cast<SemaphoreHandle_t>(handle_));
    }
    else
    {
        xSemaphoreGive(reinterpret_cast<SemaphoreHandle_t>(handle_));
    }
}

Because we wrote our application code against generic types and interfaces, and because we kept the implementation details isolated, we can freely switch between platforms without changing our application-level code.

Avoid Global Include Settings

Once we’ve cleaned up our headers and moved platform-specific details to our source files, we need to enforce a separation between platform-specific include paths and portions of the system that shouldn’t have access to them.

Many projects are built as a single executable build target and linked against vendor-supplied libraries. In these setups, there is often a global include directory setting that automatically supplies the include arguments to all build targets. This behavior is convenient, but it also prevents us from enforcing separation: any file can include any header within the global include search path.

To solve this problem, we need to think carefully about what headers our entire program needs access to, and what we can limit to a subset of files. Create different include flag variables to track these subsets explicitly.

Let’s revisit our libc library build target. Note that we track three different include directory lists: libc_includeslibc_system_includes, and target_arch_deps/target_arch_includes.

libc = static_library(
    'c',
    libc_standalone_files + libc_common_files,
    c_args: [stdlib_compiler_flags, gdtoa_compiler_flags, libc_host_compile_args, '-nostdinc'],
    include_directories: [libc_includes, libc_system_includes],
    dependencies: target_arch_deps, # <--- PLATFORM SPECIFIC DEP
    pic: true,
)

Now, when others are consuming the library, notice that we don’t include libc_includes in the list of directories they can access. This set of headers is only needed when building the library. We don’t give external modules the ability to access those headers.

libc_dep = declare_dependency(
    link_with: [
        libc,
        libprintf
    ],
    include_directories: [libc_system_includes, target_arch_includes],
    compile_args: stdlib_compiler_flags,
)

Build Platform-Specific Abstractions as Standalone Libraries

We’ve removed platform-specific details from our headers and we’ve split up our include directories to remove platform-specific includes from the global header include list. The next containment step is to build the platform-specific modules and abstractions as standalone libraries.

We do this because we can easily enforce a different set of include settings for each library and executable target. We are isolating dependencies and constraining them within specific modules.

Let’s return to our nRF52840 processor example. We want to build all the processor-related code as a static library. We will link this library into the final application, and we want to make sure that the rest of the application can’t access the processor-specific headers.

First, we must define a static library build target. The library build will have access to the Nordic SDK headers and the CMSIS headers (see include_directories), since the processor code and the common driver implementations require them.

nordic_include_dirs = [
    include_directories('include'),
    include_directories('mdk', is_system: true)
]

nrf52840 = static_library('nrf52840',
    [
        'nrf52840.cpp',
        ‘mdk/gcc_startup_nrf52840.S',
        ‘mdk/system_nrf52840.c',
    ],
    include_directories: [
        nordic_include_dirs,
    cmsis_corem_include,
        libc_header_include,
    ],
    c_args: [
        '-DCONFIG_GPIO_AS_PINRESET',
        '-DNRF52840_XXAA',
    ],
    cpp_args: [
        '-DNRF52840_XXAA',
    ],
    dependencies: [
        nrf_common_drivers_dep,
        libcxx_header_include_dep,
        arm_dep,
    ],
    native: false
)

We’ll declare a dependency for other modules in the build that need to use this nRF52840 library. Note that the dependency only includes the local directory (so we can pick up nrf52840.hpp) and a set of common Arm abstractions. We only use the Nordic and CMSIS headers when compiling the library, not when using the generic interfaces in the application.

nrf52840_processor_dep = declare_dependency(
    include_directories: [
        include_directories('.'),
            arm_common_includes
    ],
    compile_args: ['-DNRF52840_XXAA'], # For the linker script
    link_args: [
        # So the application linker script can pick up gcc_arm_common.ld
        '-L' + arm_directory_root,
    ],
    link_with: nrf52840,
)

Summary

Changing the way we structure our builds yields dramatic improvements in designing portable software. We can eliminate the possibility of platform-specific code infecting our portable modules.

We use this technique for processor-specific libraries and drivers, board support packages (BSPs), hardware abstraction layers (HALs), drivers, and OS-specific code.

The technique is portable across build systems. This is the fundamental strategy to follow:

  1. Strip unnecessary include statements and definitions from “public” headers
  2. Stop using global include settings in your build system (i.e., include settings that apply to every build target automatically)
  3. Compile and link platform-specific abstractions as standalone libraries so you can hide the implementation details from external users

The most painful part about this technique is converting an existing project to use this build strategy. But once you complete the work, you will continue to reap the benefits.

Putting it All Together

We use these two techniques on all of our internal projects and most client projects. We can enable abstractions for platform-specific code and ensure that we keep platform-specific code out of the portable portions of our projects.

We recommend taking a day or two to familiarize yourself with your build system and build organization. Once you’ve applied these techniques to one project, you’ll easily be able to deploy them to other projects. Even better, set up a reusable project template so that your use of these techniques becomes automatic.

Further Reading

2 Replies to “Leveraging Our Build Systems to Support Portability”

  1. Great entry, as always. Something is not clear to me yet (probably because I haven’t seen the rest of the code):

    In the examples above you are using two classes named nRF52840 and FreeRTOSMutex, which I guess they inherit from a virtual class like Processor and Mutex. My question is, how do you use them in the application code? I guess at some point you have to initialize a Mutex object and inject the FreeRTOSMutex or the POSIXMutex depending on what the target platform is. Where and how do you do this? Can you give an example?

  2. Hi Xabier,

    I’ll write two articles to show both the Processor use case (along with device drivers and board configuration) + the OS case in detail. Here’s a summary for now.

    The primary layers of abstraction in my framework are “hardware platform”, essentially a BSP, which encompasses the processor, peripherals on the board, initialization, etc. The hardware platform provide its own generic interface to interact with the board, which usually hides the processor completely within it. A specific processor instance can be safely declared in the hardware platform module without any tricks. Processor-specific calls (reset(), for example) can be passed along by the hardware platform interface whenever necessary.

    Now, a processor and a hardware platform contain drivers. There are two ways to pass these up the chain while only providing generic access. All drivers self-register with a “driver registry” in the base Driver class, and the registry can be used to access the generic instances of drivers by type, name, etc.

    auto i2c = platform.findDriver<embvm::i2c::master>();

    The second is that the hardware platform or processor can return generic interfaces to peripherals that are available.

    embvm::tof::sensor& tof0_inst() noexcept { return tof0; }

    For the OS, I use a factory interface. This approach can work with both dynamic memory allocation and static memory allocation (through the use of object pools. I use the ETL for this, particularly the etl::pool class).

    I have a common factory interface (the proof of concept this is from uses CRTP), which takes in generic types and returns a pointer to the generic type:

    static embvm::VirtualThread* createThread_impl(std::string_view name, embvm::thread::func_t f, embvm::thread::input_t input, embvm::thread::priority p, size_t stack_size, void* stack_ptr) noexcept;

    I have a destroy interface as well, since we’re dealing with generic types and cannot destruct them ourselves:

    static void destroy_impl(embvm::VirtualThread* item) noexcept;

    Inside the factory .cpp file under static memory configurations, I have a pool for each of the OS types:

    etl::pool<Thread, OS_THREAD_POOL_SIZE> thread_factory_;

    I create the thread from the pool:

    embvm::VirtualThread* freertosOSFactory_impl::createThread_impl( std::string_view name, embvm::thread::func_t f, embvm::thread::input_t input, embvm::thread::priority p, size_t stack_size, void* stack_ptr) noexcept { return thread_factory_.create<os::freertos::Thread>(name, f, input, p, stack_size, stack_ptr); }

    And to destroy it, I convert back from the generic type to the particular type I know about:

    void freertosOSFactory_impl::destroy_impl(embvm::VirtualThread* item) noexcept { assert(item); thread_factory_.destroy<Thread>(reinterpret_cast<Thread*>(item)); }

    To select the implementation, I rely on my build system, which will link the library and provide the correct include path to the rest of the program. I use a generic factory header name (os.hpp) and factory type name (“Factory”) with a standard factory interface so that the code can stay consistent even if I switch between the FreeRTOS or posix implementations.

    Since I use CRTP, this is how the using declaration looks:

    /// Convenience alias for the FreeRTOS OS Factory. /// Use this type instead of the verbose embvm::VirtualOSFactory definition. using Factory = embvm::VirtualOSFactory<os::freertos::freertosOSFactory_impl>;

Share Your Thoughts

This site uses Akismet to reduce spam. Learn how your comment data is processed.