cc_tutorial/tutorials/tutorial12 at master · commschamp/cc_tutorial

Tutorial 12

Avoiding dynamic memory allocation.

Many embedded bare-metal systems don't use any heap and cannot use dynamic memory allocation. The COMMS Library has several places where dynamic memory allocation is used:

The dynamic allocation inside comms::frame::MsgIdLayer and comms::MsgFactory can be resolved by using comms::option::app::InPlaceAllocation option. If forces usage of an uninitialized storage area (as private data member), big enough to hold any (but one at a time) message provided in the input messages tuple. When new message type is recognized, the message object is created using placement allocation and a pointer to the used array is returned. The message object returned by the frame (Frame::MsgPtr) is still held by std::unique_ptr, but with a custom deleter, which will invoke the proper message class destructor.

The problematic storage types that use dynamic memory allocation (std::string and std::vector) can also be replaced using some options. The COMMS Library provides comms::util::StaticString and comms::util::StaticVector which expose similar public interface as std::string and std::vector respectively, but receive additional template parameter which specifies their maximal capacity and use std::array of appropriate std::aligned_storage as their private data member. In order to replace usage of problematic std::string and/or std::vector, the comms::option::app::FixedSizeStorage needs to be passed to the field definition.

The generated code contains include/tutorial12/options/BareMetalDefaultOptions.h options configuration. It completely disables dynamic memory allocation in all possible places. Let's take a look inside:

#ifndef DEFAULT_SEQ_FIXED_STORAGE_SIZE
/// @brief Define default fixed size for various sequence fields
/// @details May be defined during compile time to change the default value.
#define DEFAULT_SEQ_FIXED_STORAGE_SIZE 32
#endif

#include "tutorial12/options/DefaultOptions.h"

namespace tutorial12
{

namespace options
{

template <typename TBase = tutorial12::options::DefaultOptions>
struct BareMetalDefaultOptionsT : public TBase
{
    struct message : public TBase::message
    {
        struct Msg1Fields : public TBase::message::Msg1Fields
        {
            using F1 =
                std::tuple<
                    comms::option::app::FixedSizeStorage<DEFAULT_SEQ_FIXED_STORAGE_SIZE>,
                    typename TBase::message::Msg1Fields::F1
                >;
            using F2 =
                std::tuple<
                    comms::option::app::SequenceFixedSizeUseFixedSizeStorage,
                    typename TBase::message::Msg1Fields::F2
                >;
        }; // struct Msg1Fields

        ...

    }; // struct message

    struct frame : public TBase::frame
    {
        struct FrameLayers : public TBase::frame::FrameLayers
        {
            ...

            using Id = std::tuple<
                comms::option::app::InPlaceAllocation,
                typename TBase::frame::FrameLayers::Id
            >;

        }; // struct FrameLayers

    }; // struct frame

};

using BareMetalDefaultOptions = BareMetalDefaultOptionsT<>;

} // namespace options

} // namespace tutorial12

As was explained earlier, the comms::option::app::InPlaceAllocation option passed to the Id framing layer results in placement rather than dynamic memory allocation.

All the problematic fields receive comms::option::app::FixedSizeStorage option. The template parameter specifies maximal length. The generated code forces the same maximal length for all such fields to be DEFAULT_SEQ_FIXED_STORAGE_SIZE which is defined at the beginning of the file. The generated code allows compiled application to set a different default value if needed.

Please also note that all the fixed length / count fields (the ones that use length or count property) already specify the maximal length / count of the storage and it doesn't need to be repeated. In this case the passed option is comms::option::app::SequenceFixedSizeUseFixedSizeStorage.

The used options will force usage of comms::util::StaticString instead of std::string and comms::util::StaticVector instead of std::vector.


SIDE NOTE: The Data layer of the protocol framing receives an option which is passed to the payload field. The latter is used only when framing fields are cached in some external structure (see documentation of comms::frame::ProtocolLayerBase::readFieldsCached()) which is irrelevant for this tutorial and should be ignored.

template <typename TBase = tutorial12::options::DefaultOptions>
struct BareMetalDefaultOptionsT : public TBase
{
    ...

    struct frame : public TBase::frame
    {
        struct FrameLayers : public TBase::frame::FrameLayers
        {
            using Data = std::tuple<
                comms::option::app::FixedSizeStorage<DEFAULT_SEQ_FIXED_STORAGE_SIZE * 8>,
                typename TBase::frame::FrameLayers::Data
            >;

            ...

        }; // struct FrameLayers

    }; // struct frame
};

In normal operation the payload is not copied anywhere and message's read() function operates on the input buffer itself.


This tutorial reused the generated include/tutorial12/options/BareMetalDefaultOptions.h to define its own protocol options inside src/BareMetalProtocolOptions.h

// Expects to wrap a variant of tutorial12::options::BareMetalDefaultOptionsT
template <typename TBase = tutorial12::options::BareMetalDefaultOptions>
struct BareMetalProtocolOptionsT : public TBase
{
    struct frame : public TBase::frame
    {
        struct Msg1Fields : public TBase::message::Msg1Fields
        {
            using F1 =
                std::tuple<
                    comms::option::app::FixedSizeStorage<8>,
                    typename TBase::message::Msg1Fields::F1
                >;
        }; // struct Msg1Fields
    }; // struct frame
};

using BareMetalProtocolOptions = BareMetalProtocolOptionsT<>;

The definition above assumes that the template parameter is going to be a variant of tutorial12::options::BareMetalDefaultOptionsT and overrides the default storage size of Msg1Fields::F1.


SIDE NOTE: The COMMS Library was implemented in a way that processes the options bottom-up. As the result, the options that appear above may override the configuration enforced by the options listed below.

In the example above the comms::option::app::FixedSizeStorage<8> overrides the configuration enforced by the tutorial12::options::BareMetalDefaultOptions (which is comms::option::app::FixedSizeStorage<DEFAULT_SEQ_FIXED_STORAGE_SIZE>).


The protocol options used by the ServerSession of this tutorial are:

using ServerProtocolOptions =
    BareMetalProtocolOptionsT<
        tutorial12::options::BareMetalDefaultOptionsT<
            tutorial12::options::ServerDefaultOptions
        >
    >;

The server doesn't have any other special aspects and everything operates normally, but without any dynamic memory allocation.

One of the important aspects to understand is that for sequence fields like <string>, or <data> the input data is constantly copied from the input buffer to the internal storage of these fields, whether it is std::string, std::vector, comms::util::StaticString, or comms::util::StaticVector. If we think about it a bit deeper, in most of the cases (all the previous tutorials so far) the message object doesn't outlive the input buffer . It would be beneficial if the storage type of the <string> and <data> fields is some kind of "view" on input buffer. The COMMS Library provides such an ability with comms::option::app::OrigDataView option. If the option is passed to the definition of comms::field::String then the storage type will be std::string_view if C++17 is been used to compile the source and the compiler actually supports it. Otherwise comms::util::StringView is chosen. Similar for the definition of the comms::field::ArrayList with std::uint8_t as its element type (used to define <data> field). If C++20 is used to compile the source and the compiler supports it the std::span is used as the storage type. Otherwise the comms::util::ArrayView is chosen.

NOTE that the data view cannot be used for the <list> field, because its element is a field, not raw data, which might use specific endian for its deserialization or any other special decoding operation.

To help with passing such "data view" options to the used protocol definition, the generated code contains include/<namespace>/options/DataViewDefaultOptions.h.

The client side of this tutorial tries to use such options in addition to avoiding dynamic memory allocation. To make the example even more interesting the virtual functions are also avoided.

To support such configuration this tutorial defines a separate options structure (src/DataViewBareMetalProtocolOptions.h) which combines the "data view" and "bare metal" (lack of dynamic memory allocation) configurations.

template <typename TBase = tutorial12::options::DataViewDefaultOptions>
struct DataViewBareMetalProtocolOptionsT : public TBase
{
    struct frame : public TBase::frame
    {
        struct Msg3Fields : public TBase::message::Msg3Fields
        {
            using F1 =
                std::tuple<
                    comms::option::app::FixedSizeStorage<16>,
                    typename TBase::message::Msg3Fields::F1
                >;

            using F2 =
                std::tuple<
                    comms::option::app::SequenceFixedSizeUseFixedSizeStorage,
                    typename TBase::message::Msg3Fields::F2
                >;
        }; // struct Msg3Fields

        struct FrameLayers : public TBase::frame::FrameLayers
        {
            using Id = std::tuple<
                comms::option::app::InPlaceAllocation,
                typename TBase::frame::FrameLayers::Id
            >;

        }; // struct FrameLayers

    }; // struct frame
};

using DataViewBareMetalProtocolOptions = DataViewBareMetalProtocolOptionsT<>;

Note that it expects a variant of tutorial12::options::DataViewDefaultOptionsT to be passed as a template parameter. The fields of the Msg3 are a variants of <list> and cannot use a view on input buffer. In order to prevent the storage type from been std::vector the comms::option::app::FixedSizeStorage or comms::option::app::SequenceFixedSizeUseFixedSizeStorage option needs to be used. To prevent dynamic memory allocation, when message itself is created, the comms::option::app::InPlaceAllocation option needs to be passed to the Id framing layer.

The important part of the client definition looks like this:

class ClientSession : public Session
{
    using Base = Session;
public:
    using Base::Base; // Inherit constructors

    // Common interface class for all the messages
    using Message = tutorial12::Message<>;

    // Protocol options for client
    using ClientProtocolOptions =
        DataViewBareMetalProtocolOptionsT<
            tutorial12::options::DataViewDefaultOptionsT<
                tutorial12::options::ClientDefaultOptions
            >
        >;

    // Definition of all the used message classes
    using Msg1 = tutorial12::message::Msg1<Message, ClientProtocolOptions>;
    using Msg2 = tutorial12::message::Msg2<Message, ClientProtocolOptions>;
    using Msg3 = tutorial12::message::Msg3<Message, ClientProtocolOptions>;

private:
    template <typename TMsg>
    void sendMessage(const TMsg& msg)
    {
        ...
    }

    // Client specific frame
    using Frame =
        tutorial12::frame::Frame<
            Message,
            tutorial12::input::ClientInputMessages<Message, ClientProtocolOptions>,
            ClientProtocolOptions
        >;
};

The important thing to realize is that the message object is held by the Frame::MsgPtr which is a variant of std::unique_ptr with a custom deleter. The latter contains a special logic to determine the right type of destructor to call when message object is destructed.

Another interesting aspect worth mentioning is demonstrated by the void ClientSession::sendMsg2() function:

void ClientSession::sendMsg2()
{
    static const std::uint8_t Data1[] = {0xaa, 0xbb, 0xcc, 0xdd};
    static const std::uint8_t Data2[] = {0x12, 0x34, 0x58, 0x78};
    Msg2 msg;
    comms::util::assign(msg.field_f1().value(), std::begin(Data1), std::end(Data1));
    comms::util::assign(msg.field_f2().value(), std::begin(Data2), std::end(Data2));
    sendMessage(msg);
}

The public interface of std::string and std::string_view differ. The latter doesn't have assign() member function for example. The similar situation can be observed with std::vector and std::span. It is difficult to write assignment code which is underlying storage type agnostic. Once the underlying storage type is assumed to be something and its known API function is used, it becomes a boilerplate code which may fail the compilation and/or work incorrectly when the assumption is broken. The COMMS Library introduces comms::util::assign() stand alone function (requires include of comms/util/assign.h). It is a helper function which allows writing storage type agnostic code to assign a range of values. It can be used when the two iterators (begin and end) are known and works well for any type, whether it is std::string, std::string_view, comms::util::StaticString, comms::util::StringView, std::vector, std::span, comms::util::StaticVector, or comms::util::ArrayView.


SIDE NOTE: Most bare metal applications avoid usage of dynamic memory allocation, some also avoid virtual functions (due to code size limitations). Many also exclude usage of standard C library altogether.

Note that the COMMS Library uses various debug code inner correctness checks (compiled in when standard NDEBUG is not defined). Such checks are implemented using COMMS_ASSERT() macro, which by default invokes standard assert() defined by the standard library, which may cause a problem if the latter is not used. To avoid usage of the standard assert() there is a need to define COMMS_NOSTDLIB during compilation. It will cause the default failure functionality of the COMMS_ASSERT() macro to be an infinite loop.

The COMMS Library allows run-time override of the default assertion failure functionality. Please read Custom Assertion Failure Behaviour page from the documentation for more details. The Error Handling section also contains useful information about the available error handling.


Summary

Read Previous Tutorial <-----------------------> Read Next Tutorial