Когда мы разрабатываем под embedded, нам приходится сталкиваться с такими флагами компиляции как -nostdlib -fno-exceptions -fno-rtti.

Во многих средах нет malloc/free (new/delete) и вообще нет встроенного выделения памяти.

Использование «больших» стандартных контейнеров C++ (например, std::vector) нередко исключено

В результате приходится решать задачу «ручного» управления памятью. Ниже рассмотрим два подхода:

  • Буфер + переопределение оператора new (placement new)

  • Собственная куча (Heap)

    Почему динамической аллокации часто нет

    1. Ограниченные ресурсы

      Память (RAM/Flash) в контроллерах может исчисляться десятками или сотнями килобайт, поэтому каждый байт на счету.

    2. Отсутствие стандартных библиотек

      При флагах -nostdlib и -fno-exceptions мы исключаем всю поддержку C/C++ runtime:

      • Нет new/delete (кроме placement-new, если его оставить).

      • malloc/free нередко не реализованы или ведут к непредсказуемым последствиям (heap overflow, фрагментация).

    3. Простота сертификации и детерминированность
      Встраиваемые приложения часто требуют детерминированного поведения. Фрагментация кучи и непредсказуемые задержки неприемлемы.

    Поэтому разработчики вынуждены либо заранее выделять «сырой» буфер и вызывать placement new, либо писать собственный аллокатор (кучу) на фиксированном участке памяти.

    Способ 1. Буфер + placement new

    Идея

    • Заранее выделить в статической памяти фиксированный буфер (массив байт), подходящий по размеру и выравниванию под нужный объект.

    • При необходимости «конструировать» объект поверх этого буфера с помощью placement new.

    • При этом не использовать глобальный operator new(size_t), а локально в коде писать new (&buffer) Type(args...).

      Пример

      Допустим, у нас есть класс Ppo::timer, и мы хотим разместить ровно один его экземпляр в статическом участке памяти.

// cgf.h
#pragma once

namespace PpoDbTimer {
	enum PpoDbTimerEnum : int{
		timer1,
        TotalCount
    };
}
// ram.cpp
#include "cfg.h"

namespace PpoDbRam {
    int timers     [ PpoDbTimer::TotalCount     ] { 0 };
}
// rom.cpp
#include <cstddef>
#include "cfg.h"

namespace PpoDbRam {
    extern int timers[];
}

namespace Ppo {
    class timer{
        public:
            explicit timer(int *_timer) noexcept :time(_timer) {}

            void tick() noexcept {
                if (time) {
                    ++(*time);
                }
            }
                    
        private:
            int * const time;
            /// ...
    };
}

/*
    Переопределение оператора new
*/
inline void *operator new( size_t, void *ptr ) { return ptr; }

namespace {
    template <size_t Len, size_t Align>
    struct aligned_storage{
        struct type{
            alignas(Align) unsigned char data[Len];
        };
    };

    aligned_storage< sizeof( Ppo::timer ), alignof( Ppo::timer ) >::type timer1Buf;
}

void init(){
   auto & timer1 = *new (&timer1Buf) Ppo::timer(&PpoDbRam::timers[PpoDbTimer::timer1]);
   timer1.tick();
}


int main(){
    init();
    return 0;
}

Что важно

  • Статический буфер timer1Buf хранится в области .bss или .data (в зависимости от инициализации).

  • Выравнивание: используется std::aligned_storage_t<…>, чтобы гарантировать alignof(Ppo::timer).

  • Placement new: new (raw) Ppo::timer(timerAddr) не вызывает глобальный operator new(size_t), а просто конструирует объект в уже выделенной памяти.

  • Отсутствие delete: поскольку у нас нет поддержки освобождения, объект живёт «вечно» до конца программы.

  • UB при обращении до init(): если где-то код попытается прочитать timer1Ptr->… до вызова init(), будет UB.

Способ 2. Собственная куча (Heap)

Идея

  1. Создать класс MyHeap, который внутри держит:

    • Статический массив-буфер (например, 2 МБ)

    • Текущий сдвиг (offset) — сколько байт уже занято.

  2. Реализовать метод allocate(size_t size, size_t alignment):

    • Выравнивять текущий указатель вверх под alignment.

    • Проверить, что offset + size ≤ BUFFER_SIZE.

    • При успехе вернуть void* на выровненный адрес и обновить offset.

    • При нехватке памяти вернуть nullptr.

  3. (Опционально) Переопределить глобальный operator new(size_t) так, чтобы он делал MyHeap::allocate(...). И тогда в коде можно писать new T(args...), и вся память будет «брать» из нашего буфера. Но чаще держат create<T>(...) внутри MyHeap, чтобы не мешать стандартным new в других модулях.

Пример

//heap.h
#pragma once

#include <cstddef>

class MyHeap {
public:
    // Пытается выдать size байт с выравниванием alignment. Возвращает nullptr, если кончилась память.
    static void* allocate(size_t size, size_t alignment = alignof(size_t)) noexcept {
        size_t cur = reinterpret_cast<size_t>(buffer) + offset;
        size_t aligned = (cur + alignment - 1) & ~(alignment - 1);
        size_t newOffset = static_cast<size_t>(aligned - reinterpret_cast<size_t>(buffer)) + size;

        if (newOffset > BUFFER_SIZE)
            return nullptr;

        void* ptr = reinterpret_cast<void*>(aligned);
        offset = newOffset;
        return ptr;
    }

    template <typename T, typename... Args>
    static T* create(Args&&... args) noexcept {
        void* mem = allocate(sizeof(T), alignof(T));
        if (!mem) 
            return nullptr;
        return new (mem) T(static_cast<Args&&>(args)...);
    }
private:
    static constexpr size_t BUFFER_SIZE = 2 * 1024 * 1024;
    alignas(alignof(size_t)) static char buffer[];
    static size_t offset;
};
// rom.cpp 
#include "heap.h"
#include "cfg.h"
#include <cstddef>

namespace PpoDbRam {
    extern int timers[];
}

alignas(alignof(std::max_align_t)) char MyHeap::buffer[ MyHeap::BUFFER_SIZE ];
std::size_t MyHeap::offset = 0;

// Оставляем стандартный «placement new», чтобы new(mem) T() работал:
void* operator new(size_t, void* ptr) noexcept {
    return ptr;
}

// Глобальный operator new(size) будет выделять через MyHeap::allocate.
void* operator new(size_t size) noexcept {
    return MyHeap::allocate(size, alignof(max_align_t));
}

// Пустой operator delete (никакого освобождения)
void operator delete(void*) noexcept {
}

void operator delete(void*, size_t) noexcept {
}

namespace Ppo {
    class timer{
        public:
            explicit timer(int *_timer) noexcept :time(_timer) {}

            void tick() noexcept {
                if (time) {
                    ++(*time);
                }
            }
                    
        private:
            int * const time;
            /// ...
    };
}

void init(){
    auto & timer1 = *new Ppo::timer(&PpoDbRam::timers[ PpoDbTimer::timer1 ]);
    timer1.tick();
}

int main(){
    init();
    return 0;
}

Сравнение двух подходов

Критерий

Способ 1 (буфер + placement new)

Способ 2 (своя куча)

Простота реализации

Очень просто: для каждого объекта — свой буфер и placement new.

Нужно написать класс-кучу, методы allocate/offset, переопределить new/delete (если требуется).

Гибкость

Подходит, когда заранее известен тип и кол-во объектов.

Позволяет произвольно вызывать new T() десятки и сотни раз до исчерпания буфера.

Утилизация памяти

«Жестко» закреплён за конкретным объектом.

Можно выделять разные размеры, кучу «жирнее» (несколько объектов в одном буфере, при одинаковом выравнивании).

Разрез «тайминг»

Инициализация только в том месте, где объявлен буфер.

Выделение памяти в любом месте программы, пока осталось место.

Кодовая избыточность

При N разных типов/объектов — N буферов.

Один класс-куча для всех типов.

Риск UB и ошибок

Мало кода — меньше шансов допустить ошибку.

Больше кода/логики — может быть ошибка в вычислении выравнивания, offset и т. д.

Рекомендации

  • Если вам нужно одно-два статических объекта фиксированных типов, проще использовать Способ 1:

static std::aligned_storage_t<sizeof(MyType), alignof(MyType)> myObjBuf;
MyType& myObj = *new (&myObjBuf) MyType(arg…);

Это гарантирует, что объект хранится в статической памяти, а вы не задумываетесь о второй куче.

  • Если вы пишете более сложное приложение, где создаются десятки (или тысячи как в моем проекте) объектов разных типов, выбирайте Способ 2 и реализуйте свою кучу. Так вы сможете писать обычные new T(), а все они будут «упаковываться» в предопределённый буфер. Особенно если Вам надо саллоцировать что-то такое unordered_map<int,array<int>>.

Заключение

В embedded-мире почти всегда отсутствует стандартный allocator и вся стихийная флеш-рамка стандартного C++ отключена флагами -nostdlib -fno-exceptions -fno-rtti. Если задача требует динамического (пусть и внутри жестко ограниченного) выделения памяти, у вас есть два основных пути:

  1. Буфер+placement new — быстрый и надёжный способ, когда заранее известны объёмы и типы.

  2. Собственная куча — гибкий и масштабируемый способ, когда объём и количество объектов заранее не фиксированы.

Комментарии (6)


  1. Cheater
    13.06.2025 11:34

    Так у вас в способе #2 MyHeap каждый раз выделяет блок только с бОльшим адресом?? А что если память кончится? А как возвращать ему память (free()) и переиспользовать её?

    В общем виде этот компонент называется "аллокатор памяти" и реализовать его правильно - это грёбаная ядерная физика. В 90% случаев лучше всего взять сторонний а не делать самому.


    1. a-tk
      13.06.2025 11:34

      Память должна распределяться при старте раз и навсегда. Динамическое выделение с потенциальной фрагментацией кучи может привести к отказу из-за неудачного распределения свободных блоков, которые когда-нибудь не соберутся воедино.


    1. Jijiki
      13.06.2025 11:34

      конкретно тут как я понял ембед, а в ОС ну типо десктоп, есть сискол(ну или не сискол а стандартизированный вызов на эту операцию точно есть!) стандартизированный, а в другой ОС свой интерфейс шлефанули поверх лоу-левела, тут смотря что обсуждаем я понял что на ембед нету ОС и там своё окружение, а в ос ябы тестил тот вызов и смотрел бы, ведь вроде память самому не взять должен быть интерфейс - он сопственно и предназначен для этого чтобы коммуницировать с чем там ядром наверно

      прадедушка ОС это посикс стандарт наверно если по вики смотреть Version 1 Unix перед ним PDP если смотреть в вики на дерево соответственно стандарты там тоже ранжированы и прочее


  1. a-tk
    13.06.2025 11:34

    template<class T, int Id = 0>
    T& instance()
    {
      static char data[sizeof(T)];
      static bool initialized;
    
      if (!initialized)
      {
        initialized = true;       
        new(data)T();
      }
      return *reinterpret_cast<T*>(data);
    }
    
    instance<Timer>();
    instance<Channel, 0>();
    instance<Channel, 1>();

    И память статически размещена, и накладных расходов минимум (потому что блокировки на многопоточную инициализацию нет), и отложенная инициализация до первого вызова.

    С этой точки под необходимость можно протащить аргументы конструктора либо непосредственно через аргумент функции, либо через trait, например, либо ещё как-то.


  1. Uporoty
    13.06.2025 11:34

    Стоит добавить три важные вещи, о которых все часто забывают:

    1. Если у вас есть кастомный аллокатор, унаследовав его от std::pmr::memory_resource можно его использовать со стандартными контейнерами типа std::pmr::string, std::pmr::vector, и т.д. - и более того, можно с ними использовать разные типы аллокаторов (в одном случае свою кучу, а в другом случае просто локальный буфер), при этом они будут интероперабельны

    2. Если нужно non-monotonic поведение аллокатора (возможность деаллокации), то просто pool allocator (он же arena allocator) на базе списков с фиксированным размером блока в реализации не сильно сложнее чем то, что у автора, зато гораздо более эффективен для многих применений

    3.  Существуют реализацииmalloc() и free() , которые выполняются за постоянное время и с предсказуемой фрагментацией кучи, что позволяет их использовать даже в системах жесткого реального времени


  1. viordash
    13.06.2025 11:34

    Когда мы разрабатываем под embedded, нам приходится сталкиваться с такими флагами компиляции как -nostdlib -fno-exceptions -fno-rtti

    избыточность -fno-exceptions -fno-rtti понятна. А чем stdlib мешает?