Когда мы разрабатываем под embedded, нам приходится сталкиваться с такими флагами компиляции как -nostdlib -fno-exceptions -fno-rtti
.
Во многих средах нет malloc/free (new/delete) и вообще нет встроенного выделения памяти.
Использование «больших» стандартных контейнеров C++ (например, std::vector) нередко исключено
В результате приходится решать задачу «ручного» управления памятью. Ниже рассмотрим два подхода:
Буфер + переопределение оператора new (placement new)
-
Собственная куча (Heap)
Почему динамической аллокации часто нет
-
Ограниченные ресурсы
Память (RAM/Flash) в контроллерах может исчисляться десятками или сотнями килобайт, поэтому каждый байт на счету.
-
Отсутствие стандартных библиотек
При флагах
-nostdlib
и-fno-exceptions
мы исключаем всю поддержку C/C++ runtime:Нет
new
/delete
(кроме placement-new, если его оставить).malloc
/free
нередко не реализованы или ведут к непредсказуемым последствиям (heap overflow, фрагментация).
Простота сертификации и детерминированность
Встраиваемые приложения часто требуют детерминированного поведения. Фрагментация кучи и непредсказуемые задержки неприемлемы.
Поэтому разработчики вынуждены либо заранее выделять «сырой» буфер и вызывать 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)
Идея
-
Создать класс
MyHeap
, который внутри держит:Статический массив-буфер (например, 2 МБ)
Текущий сдвиг (offset) — сколько байт уже занято.
-
Реализовать метод
allocate(size_t size, size_t alignment)
:Выравнивять текущий указатель вверх под
alignment
.Проверить, что
offset + size ≤ BUFFER_SIZE
.При успехе вернуть
void*
на выровненный адрес и обновитьoffset
.При нехватке памяти вернуть
nullptr
.
(Опционально) Переопределить глобальный
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 (своя куча) |
---|---|---|
Простота реализации |
Очень просто: для каждого объекта — свой буфер и |
Нужно написать класс-кучу, методы allocate/offset, переопределить |
Гибкость |
Подходит, когда заранее известен тип и кол-во объектов. |
Позволяет произвольно вызывать |
Утилизация памяти |
«Жестко» закреплён за конкретным объектом. |
Можно выделять разные размеры, кучу «жирнее» (несколько объектов в одном буфере, при одинаковом выравнивании). |
Разрез «тайминг» |
Инициализация только в том месте, где объявлен буфер. |
Выделение памяти в любом месте программы, пока осталось место. |
Кодовая избыточность |
При 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
. Если задача требует динамического (пусть и внутри жестко ограниченного) выделения памяти, у вас есть два основных пути:
Буфер+placement new — быстрый и надёжный способ, когда заранее известны объёмы и типы.
Собственная куча — гибкий и масштабируемый способ, когда объём и количество объектов заранее не фиксированы.
Комментарии (6)
a-tk
13.06.2025 11:34template<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, например, либо ещё как-то.
Uporoty
13.06.2025 11:34Стоит добавить три важные вещи, о которых все часто забывают:
Если у вас есть кастомный аллокатор, унаследовав его от std::pmr::memory_resource можно его использовать со стандартными контейнерами типа std::pmr::string, std::pmr::vector, и т.д. - и более того, можно с ними использовать разные типы аллокаторов (в одном случае свою кучу, а в другом случае просто локальный буфер), при этом они будут интероперабельны
Если нужно non-monotonic поведение аллокатора (возможность деаллокации), то просто pool allocator (он же arena allocator) на базе списков с фиксированным размером блока в реализации не сильно сложнее чем то, что у автора, зато гораздо более эффективен для многих применений
Существуют реализации
malloc()
иfree()
, которые выполняются за постоянное время и с предсказуемой фрагментацией кучи, что позволяет их использовать даже в системах жесткого реального времени
viordash
13.06.2025 11:34Когда мы разрабатываем под embedded, нам приходится сталкиваться с такими флагами компиляции как
-nostdlib -fno-exceptions -fno-rtti
избыточность -fno-exceptions -fno-rtti понятна. А чем stdlib мешает?
Cheater
Так у вас в способе #2 MyHeap каждый раз выделяет блок только с бОльшим адресом?? А что если память кончится? А как возвращать ему память (free()) и переиспользовать её?
В общем виде этот компонент называется "аллокатор памяти" и реализовать его правильно - это грёбаная ядерная физика. В 90% случаев лучше всего взять сторонний а не делать самому.
a-tk
Память должна распределяться при старте раз и навсегда. Динамическое выделение с потенциальной фрагментацией кучи может привести к отказу из-за неудачного распределения свободных блоков, которые когда-нибудь не соберутся воедино.
Jijiki
конкретно тут как я понял ембед, а в ОС ну типо десктоп, есть сискол(ну или не сискол а стандартизированный вызов на эту операцию точно есть!) стандартизированный, а в другой ОС свой интерфейс шлефанули поверх лоу-левела, тут смотря что обсуждаем я понял что на ембед нету ОС и там своё окружение, а в ос ябы тестил тот вызов и смотрел бы, ведь вроде память самому не взять должен быть интерфейс - он сопственно и предназначен для этого чтобы коммуницировать с чем там ядром наверно
прадедушка ОС это посикс стандарт наверно если по вики смотреть Version 1 Unix перед ним PDP если смотреть в вики на дерево соответственно стандарты там тоже ранжированы и прочее