Gestion dynamique de la mémoire embarquée 2ème partie

Pour faire suite à l'article précédent, voici quelques astuces pour déterminer l'utilisation du tas par votre programme.

En C

En C, il est assez difficile d'estimer l'utilisation du tas car les fonctions d'allocation sont dites type-erased, c'est à dire que le type de retour de l'allocateur étant void*, il est implicitement converti vers le type final. Vous ne pouvez donc pas facilement caractériser les allocations effectuées par type. Au mieux, vous pourrez faire un histogramme en fonction de la taille d'allocation, mais guère plus.

Si vous devez absolument qualifier les allocations en fonction du type, il est recommandé de remplacer (avec le préprocesseur) malloc par une autre fonction qui ne retourne pas un void* comme ceci:

// In break_malloc.c
struct NotConstructible {};
NotConstructible * breakMalloc(const size_t size) { return ::malloc(size); }

// In break_malloc.h
NotConstructible * breakMalloc(const size_t size);
#define malloc breakMalloc

When you'll build your firmware, force the inclusion of the break_malloc.h file (with -include flag), the compiler will error out on each allocation and you'll be able to modify the call to something more verbose like:

void * malloc_type(const size_t size, const char * type); 
// With type extracted from the error line:
// replace: MyStruct * ptr = malloc(sizeof(MyStruct) * 4); 
// by:  MyStruct * ptr = malloc_type(sizeof(MyStruct) * 4, "MyStruct");

Après, il faut stocker les allocations en fonction du type donné dans une table de hash par exemple. Voir plus bas pour un code possible.

En C++

En C++, il n'y a pas de conversion implicite de void* en un autre type. Il est plus facile de capturer le type lors de l'appel à l'allocateur en utilisant une fonction template. Cependant, l'allocation arrive systématiquement via la fonction void * operator new(size_t) qui, également, a perdu son type initial. Ce n'est donc pas ici que l'on peut capturer le type alloué (ou alors, difficilement, en générant une backtrace et en la parsant, ce que l'on ne peut pas se permettre dans le monde de l'embarqué).

Il est possible de faire une astuce identique au mode C avec le préprocesseur:

template <typename T>
struct tag {
    const std::size_t size = { sizeof(T) };
    static const char * name() { return typeid(T).name(); }
};

template<typename T>
void * operator new(size_t s, const tag<T> t) {
    Registry::credit(t::name(), t::size, s); 
    return ::malloc(s);
}

#define new   bad_new

Et remplacer tous les appels à new par un placement new new(tag<MyStruct>{}). Normalement, un code C++ ne devrait pas avoir de trop nombreux appels à new.

Si vous utiliser les STL, il est possible d'agir à un niveau au dessus, via les allocateurs. Dans ce cas, on écrira un allocateur qui remplacera l'allocateur par défaut et capturera les allocations réalisées par votre programme. Le code est un peu plus complexe, donc le voici bout par bout:

#include <unordered_map>
#include <typeinfo>
#include <limits>
#include <cstdio>
#include <cstring>

// Containers we inject in the allocator
#include <string>

struct Registry
{
    struct AllocTrack
    {
        size_t count;
        size_t max;
        size_t current;

        AllocTrack() : count(0), max(0), current(0) {}
    };

    std::unordered_map<const char *, AllocTrack> registry;
    static Registry & get_instance() { static Registry a; return a; }
    void credit(const char * key, std::size_t count, std::size_t size) {
        AllocTrack & track = registry[key];
        track.count += count;
        track.max += size;
        track.current += size;
    }
    void debit(const char * key, std::size_t count, std::size_t size) {
        AllocTrack & track = registry[key];
        track.count -= count;
        track.current -= size;
    }
    void dump() {
        // Not using C++ iostream here to avoid triggering the allocator itself
        printf("Allocator registry:\n");
        for (auto it : registry) {
            char buffer[256];
            strncpy(buffer, it.first, 256);
            char * t = strstr(buffer, ";");
            if (!t) t = strstr(buffer, "]");
            if (t) *t = 0;
            printf("%s: %lu instances %lu bytes, max usage %lu\n", buffer, it.second.count, it.second.current, it.second.max);
        }
    }

    template<typename T>
    static const char * getTypeName() {
        const char * templateType = __PRETTY_FUNCTION__;
        const char * ptr = strstr(templateType, "=") + 2;
        return ptr;
    }

    ~Registry() {
        dump();
    }
};

La structure AllocTrack capture les statistiques des allocations, c'est à dire le nombre d'allocations en cours, la taille maximale allouée (total), la taille d'une unité d'allocation. La structure Registry est une base de registre qui associe un type (donné par son nom) à ses statistiques. On peut réaliser 2 opérations sur cette base, créditer ou débiter une allocation. La fonction getTypeName() est une astuce pour éviter de compiler votre programme avec les exceptions (-frtti), elle utilise le préprocesseur pour extraire le type du paramètre template donné.

Ensuite, c'est assez simple, on remplace l'allocateur par le nôtre:

template <class T>
class accounting_allocator {
    public:
    // type definitions
    typedef T        value_type;
    typedef T*       pointer;
    typedef const T* const_pointer;
    typedef T&       reference;
    typedef const T& const_reference;
    typedef std::size_t    size_type;
    typedef std::ptrdiff_t difference_type;

    // rebind allocator to type U
    template <class U>
    struct rebind {
        typedef accounting_allocator<U> other;
    };

    // return address of values
    pointer address (reference value) const {
        return &value;
    }
    const_pointer address (const_reference value) const {
        return &value;
    }

    /* constructors and destructor
    * - nothing to do because the allocator has no state
    */
    accounting_allocator() throw() {
    }
    accounting_allocator(const accounting_allocator&) throw() {
    }
    template <class U>
        accounting_allocator (const accounting_allocator<U>&) throw() {
    }
    ~accounting_allocator() throw() {
    }

    // return maximum number of elements that can be allocated
    size_type max_size () const throw() {
    //  std::cout << "max_size()" << std::endl;
        return std::numeric_limits<std::size_t>::max() / sizeof(T);
    }

    // allocate but don't initialize num elements of type T
    pointer allocate (size_type num, const void* = 0) {
        pointer ret = (pointer)(::operator new(num*sizeof(T)));
        Registry::get_instance().credit(Registry::getTypeName<T>(), num, num * sizeof(T));
        return ret;
    }

    // initialize elements of allocated storage p with value value
    void construct (pointer p, const T& value) {
        // initialize memory with placement new
        new((void*)p) T(value);
    }

    // destroy elements of initialized storage p
    void destroy (pointer p) {
        // destroy objects by calling their destructor
        p->~T();
    }

    // deallocate storage p of deleted elements
    void deallocate (pointer p, size_type num) {
        // print message and deallocate memory with global delete
        ::operator delete((void*)p);
        Registry::get_instance().debit(Registry::getTypeName<T>(), num, num * sizeof(T));
    }
};
template<>
class accounting_allocator<void>
{
public:
    typedef std::size_t      size_type;
    typedef std::ptrdiff_t   difference_type;
    typedef void*       pointer;
    typedef const void* const_pointer;
    typedef void        value_type;

    template<typename _Tp1>
    struct rebind
    { typedef std::allocator<_Tp1> other; };
};

// return that all specializations of this allocator are interchangeable
template <class T1, class T2>
bool operator== (const accounting_allocator<T1>&, const accounting_allocator<T2>&) throw() { return true; }
template <class T1, class T2>
bool operator!= (const accounting_allocator<T1>&, const accounting_allocator<T2>&) throw() { return false; }

#ifdef DEBUG_ALLOCATOR
    typedef std::basic_string<char, std::char_traits<char>, accounting_allocator<char> > VString;

    namespace std
    {
        template<> 
        struct hash<VString>
        {
            typedef VString argument_type;
            typedef std::size_t result_type;
            result_type operator()(argument_type const& in) const
            {
                return std::_Hash_impl::hash(in.data(), in.length());
            }
        };
    }

    typedef std::basic_stringstream<char, std::char_traits<char>, accounting_allocator<char> > VStringStream;
    typedef std::basic_ostringstream<char, std::char_traits<char>, accounting_allocator<char> > VOStringStream;

    inline bool operator== (const VString & a, const std::basic_string<char, std::char_traits<char>, std::allocator<char>> & b) throw() { return a.size() == b.size() && memcmp(a.data(), b.data(), a.size()) == 0; }
#else 
    typedef std::string VString;
    typedef std::stringstream VStringStream;
    typedef std::ostringstream VOStringStream;
#endif

Il y a un exemple ici pour le type std::string mais il est possible de faire de même avec tous les containers de la STL. Il faut remplacer dans votre code std::string par VString, puis compiler le lancer votre programme. Lorsque vous voulez savoir la consommation d'un container dans votre projet, vous appelez Registry::dump() et vous obtenez:

Allocator registry:
char: 512 instances 512 bytes, max usage 1785

Article précédent

Articles en relation