跳转至

嵌入式现代C++教程——自定义分配器(Allocator)

在嵌入式世界里,内存不是“无限”的抽屉,而是那只随时会嫌你占空间的行李箱。默认的 new / malloc 对我们友好吗?有时候很友好(没错,方便);但更多时候,它们是潜在的性能炸弹、不可预测的延迟来源、以及碎片化萌生地。于是,写一个“自定义分配器”——你自己的内存管理策略,就变成了工程师的基本修行。


为什么要自定义分配器?

想象一下这些场景:实时任务不能被偶发的 malloc 阻塞;启动阶段需要一次性分配若干对象以避免运行时分配;小对象分配频繁但大小恒定;或者你想把一大块内存划分给特定模块,便于追踪与回收。默认分配器往往无法同时满足:确定性、低内存占用、低碎片和高性能。

自定义分配器修改了像内存申请的具体模式,我们可以接入自己的固定大小池、栈式分配、快速分配器。在之前的博客中,我们的这些实现可以有效的避免堆碎片、提高局部性。


分配器的基础概念

分配器,归根结底就是两件事:分配(给出一段未被使用的内存)和释放(把内存归回池子)。在 C++ 里还要注意对齐(alignment)和对象的构造/析构(placement new、显式 destroy)。

常见策略有:Bump(指针上移)分配器Free-list(空闲链表/内存池)Stack(栈)分配器、以及更复杂的 TLSF/分级位图 等。下面我们通过代码直观对比。


最简单:Bump(线性)分配器 — 启动与临时用例的好朋友

特点:实现极其简单,分配 O(1),不支持释放单个对象(可以一次性重置)。适合启动期分配或者短周期任务。

// bump_allocator.h - 非线程安全,简单演示
#include <cstddef>
#include <new>
#include <cassert>

class BumpAllocator {
    char* start_;
    char* ptr_;
    char* end_;
public:
    BumpAllocator(void* buffer, std::size_t size) 
      : start_(static_cast<char*>(buffer)), ptr_(start_), end_(start_ + size) {}

    void* allocate(std::size_t n, std::size_t align = alignof(std::max_align_t)) noexcept {
        std::size_t space = end_ - ptr_;
        std::uintptr_t p = reinterpret_cast<std::uintptr_t>(ptr_);
        std::size_t mis = p % align;
        std::size_t offset = mis ? (align - mis) : 0;
        if (n + offset > space) return nullptr;
        ptr_ += offset;
        void* res = ptr_;
        ptr_ += n;
        return res;
    }

    void reset() noexcept { ptr_ = start_; }
};

使用场景:启动时分配所有必要对象,后面不再释放;或临时缓冲池。记住:不能释放单个对象,除非你支持回滚到某个快照点(可以实现“标记/回滚”)。


固定大小内存池(Free-list)

当你有大量相同大小的小对象(例如消息节点、连接对象)时,固定大小内存池非常高效。每个槽(slot)大小固定,释放时把槽 push 回空闲链表。分配/释放都 O(1)。

// simple_pool.h - 单线程示例
#include <cstddef>
#include <cassert>
#include <cstdint>

class SimpleFixedPool {
    struct Node { Node* next; };
    void* buffer_;
    Node* free_head_;
    std::size_t slot_size_;
    std::size_t slot_count_;
public:
    SimpleFixedPool(void* buf, std::size_t slot_size, std::size_t count)
      : buffer_(buf), free_head_(nullptr), slot_size_((slot_size < sizeof(Node*))? sizeof(Node*): slot_size), slot_count_(count) {
        // 初始化空闲链表
        char* p = static_cast<char*>(buffer_);
        for (std::size_t i = 0; i < slot_count_; ++i) {
            Node* n = reinterpret_cast<Node*>(p + i * slot_size_);
            n->next = free_head_;
            free_head_ = n;
        }
    }
    void* allocate() noexcept {
        if (!free_head_) return nullptr;
        Node* n = free_head_;
        free_head_ = n->next;
        return n;
    }
    void deallocate(void* p) noexcept {
        Node* n = static_cast<Node*>(p);
        n->next = free_head_;
        free_head_ = n;
    }
};

要点提示:slot_size 应包含对齐与控制信息;线程安全时需要加锁或使用 lock-free 结构(复杂度上升)。内存利用率高,碎片少。


Stack(栈)分配器 — LIFO 场景的神器

当你分配/释放呈 LIFO(后进先出)模式时,栈分配器速度最快,可以释放一系列分配到某个“标记”为止。

// stack_allocator.h - 支持标记回滚
class StackAllocator {
    char* start_;
    char* top_;
    char* end_;
public:
    StackAllocator(void* buf, std::size_t size) : start_(static_cast<char*>(buf)), top_(start_), end_(start_+size) {}
    void* allocate(std::size_t n, std::size_t align = alignof(std::max_align_t)) noexcept {
        // 类似Bump的对齐处理
        // ...
    }
    // 标记与回滚API
    using Marker = char*;
    Marker mark() noexcept { return top_; }
    void rollback(Marker m) noexcept { top_ = m; }
};

适用:短生命周期链、任务栈式分配、帧分配(每帧分配,帧结束统一回收)。


用 C++ 风格包装(placement new 与析构)

分配器只提供原始内存;对象的构造/析构工作还是你的任务。示例如下:

#include <new> // placement new

// allocate memory for T and construct
template<typename T, typename Alloc, typename... Args>
T* construct_with(Alloc& a, Args&&... args) {
    void* mem = a.allocate(sizeof(T), alignof(T));
    if (!mem) return nullptr;
    return new (mem) T(std::forward<Args>(args)...);
}

// 销毁并归还内存(手动调用析构)
template<typename T, typename Alloc>
void destroy_with(Alloc& a, T* obj) noexcept {
    if (!obj) return;
    obj->~T();
    a.deallocate(static_cast<void*>(obj));
}

重要:在嵌入式中,禁用异常或在异常敏感代码中使用 noexcept 的 allocate 是常见实践;因此好多实现返回 nullptr 而不是抛异常。


如何把自定义分配器和 STL 一起用

标准库的 std::allocator 接口在老标准中较为笨重。C++17/20 引入了 std::pmr::memory_resource(更现代)用于替换默认分配策略。但在嵌入式里往往不启用完整的 <memory_resource>,于是你可以自己:

  • 为容器写一个简单的 wrapper,内部使用你的池分配节点。
  • 或实现兼容 std::allocator 接口的类(需要一堆 typedef 和 rebind),然后传给 std::vector<T, MyAlloc<T>>

如果构建环境允许,优先考虑 std::pmr —— 它语义更清晰,但开销与支持度要看你的平台。