在嵌入式裸机(bare‑metal)开发中,往往没有操作系统提供的堆、文件系统或线程库,C++ 标准库的完整实现无法直接使用。ISO C++ 为此定义了 freestanding(独立运行)环境,只要求编译器提供一套最小化的语言与库子集。正确理解并实现这一子集,是把现代 C++ 引入微控制器、汽车 ECU、Bootloader 等场景的前提。

Freestanding 子集的边界

Freestanding 环境与 hosted(宿主)环境的最根本区别在于:是否依赖操作系统的运行时服务。C++ 标准在 P0829R3(Freestanding Proposal)中明确列出可强制要求的头文件及其特性。典型的可提供子集包括:

  • (仅限字符处理)
  • <type_traits>(全局 operator new/delete 的声明)
  • <initializer_list>(不涉及堆的算法)
  • (仅限不抛异常的成员)

这些头文件在编译时可以通过 -ffreestanding 编译器标志激活,工具链会据此定义宏 __cpp_freestanding_library,并对不支持的特性做出默认实现或直接报错。实际项目中,通常还会根据目标芯片的指令集特性,进一步裁剪或自行提供等价的轻量实现。

内存分配的实现路径

在裸机环境下,堆的实现必须自行完成。推荐的做法是提供 全局 operator newoperator delete 的自定义实现,并将它们声明为 noexcept,让分配失败时直接返回 nullptr 而不触发异常。以下是一段最小化的固定大小池(fixed‑size pool)实现框架:

// 预分配一块静态内存作为池
alignas(std::max_align_t) unsigned char heap_pool[4096];

// 简单的空闲块链表
struct Block { std::size_t size; Block* next; };
Block* free_list = reinterpret_cast<Block*>(heap_pool);

void* operator new(std::size_t size) noexcept {
    // 在 free_list 中寻找足够大的块
    Block* prev = nullptr;
    for (Block* cur = free_list; cur; prev = cur, cur = cur->next) {
        if (cur->size >= size) {
            // 找到合适的块,摘出链表
            if (prev) prev->next = cur->next;
            else free_list = cur->next;
            return reinterpret_cast<void*>(cur);
        }
    }
    // 找不到时返回 nullptr,表示分配失败
    return nullptr;
}

void operator delete(void* ptr) noexcept {
    if (!ptr) return;
    Block* cur = reinterpret_cast<Block*>(ptr);
    // 将块重新插入空闲链表(此处省略合并逻辑)
    cur->next = free_list;
    free_list = cur;
}

上述实现满足以下工程要点:

  1. 返回 nullptr 而非抛异常:在嵌入式场景下,通常没有 std::bad_alloc 的捕获机制,直接返回空指针更安全。
  2. 对齐保证:通过 alignas(std::max_align_t) 确保所有分配的内存符合平台最大对齐要求。
  3. 固定池大小:使用编译期已知的静态数组,避免运行时 brk/sbrk 系统调用。
  4. 可配置的分配策略:如果对实时性要求更高,可使用 bump‑pointer(仅在初始化阶段分配一次)或者 位图分配器;若需要更通用的碎片管理,则实现 free‑list 并在 delete 时进行相邻块合并。

如果项目需要支持 数组 newoperator new[])或 sized deleteoperator delete(p, size)),可以分别在对应位置补充实现,并在链接阶段使用 -fno-use-libcxx 等选项阻止标准库自带的实现被链接进来。

异常处理与 noexcept

在裸机环境里,打开异常(-fexceptions)往往会导致编译器生成额外的 unwind 信息以及 __cxa_throw 等运行时函数,这对 ROM/RAM 资源极度敏感的系统是不可接受的。更常见的做法是 全局禁用异常,即在编译选项中加入:

-fno-exceptions -fno-rtti

禁用异常后,所有标准库函数若出现错误只能通过 返回值错误码std::optional/std::expected 之类的类型来传递。为保持接口统一,建议将所有可能分配或可能失败的函数标记为 noexcept

class Buffer {
public:
    Buffer(std::size_t capacity) noexcept : data_(nullptr), cap_(capacity) {
        data_ = static_cast<char*>(::operator new(capacity, std::nothrow));
    }
    ~Buffer() noexcept { ::operator delete(data_); }
    // 省略其他成员
private:
    char* data_;
    std::size_t cap_;
};

operator new 返回 nullptr 时,构造函数亦不抛异常,而是将内部指针保持为 nullptr,后续成员函数通过检查 data_ != nullptr 来判断对象是否有效。

如果在代码中仍然需要捕获极端错误(例如在启动阶段分配关键数据结构失败),可以自行实现一个极简的 terminate handler

#include <cstdlib>

[[noreturn]] void __cxa_terminate_handler() {
    // 在此可选择点亮 LED、输出调试信息或触发硬件复位
    while (true) { /* 死循环或系统复位 */ }
}

通过在链接脚本中把标准库提供的 __cxa_terminate 替换为上述实现,可在真正不可恢复的错误发生时获得可控的行为,而不是触发未定义的 abort。

实践建议:编译链与链接脚本

  1. 编译器标志-ffreestanding -fno-exceptions -fno-rtti -fno-threadsafe-statics(如果不需要静态局部对象的线程安全初始化)。
  2. 标准库实现:如使用 Newlib‑Nanopicolibc 或自行裁剪的 libc++/libstdc++,需要在链接脚本中排除掉 heap 初始化的 _sbrk_malloc_r 等符号,防止意外的堆空间被加入。
  3. 内存映射:将堆池放置在特定链接段(如 .heap),并在 linker script 中把该段的 LOADADDR 设为 0,以确保它位于真实的 SRAM 区域。
  4. 调试与监控:在 operator new 中加入简单的计数或日志(通过 UART、SWO 等输出),记录分配次数、成功率以及当前池剩余大小。这对后期调优和异常排查非常重要。

Freestanding 提案 (P0829R3) 明确定义了哪些头文件可在无宿主环境中提供。BareMetalLib 项目展示了在裸机环境下的完整子集实现,可作为参考。