现代 C++ 跨平台开发-内存篇:函数调用、异常处理、异步等场景的内存管理

本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 5 篇,主要涉及:函数调用、异常处理、异步等场景的内存管理。

函数调用场景的内存管理

函数调用过程

  • 调用指令(如 call):将控制权转移到函数入口;

  • 返回值地址入栈;

  • 参数依次入栈;

  • 保存当前寄存器的值(保存现场);

  • 栈帧指针(即 RSP,用于返回上层调用方)和局部变量入栈(静态变量不入栈);

  • 执行函数体;

函数返回过程

  • 恢复寄存器的值(恢复现场);

  • 释放局部变量的栈空间,栈帧指针恢复到上一层函数;

  • 弹出参数和返回值地址;

调用约定

调用约定决定参数传递方式和堆栈清理方式,声明在返回值和函数名之间:
int __stdcall func(int a, int b);

__cdel

  • C/C++ 默认的调用约定,可不声明;

  • 参数从右到左入栈;

  • 调用方负责清理堆栈:

    • printf 等 C 风格可变参数函数必须用 __cdel,因为只有调用方知道参数个数;

    • 每处调用都要多出清理操作,输出的二进制较大;

__stdcall

  • 参数从右到左入栈;

  • 被调用方清理堆栈;

Win32 默认采用 __stdcall 调用约定(二进制更小)。

__fastcall

  • 前两个整型或指针参数存在寄存器(所以快),其余参数从右到左入栈;

  • 被调用方清理堆栈;

__thiscall

  • C++ 特有,用于类成员函数,将 this 指针作为隐式参数传递;

  • 若参数个数确定,类似 __stdcall

    • this 指针存寄存器;

    • 被调用方清理堆栈;

  • 若参数个数不确定,类似 __cdel

    • this 指针在其他参数入栈后再最后入栈;
  • 调用方清理堆栈;

RVO

C++17 起,强制编译器对函数返回“纯右值”(prvalue)的优化:
直接在调用者的栈帧上构造对象,消除了保存返回值而执行的临时对象构造和拷贝。

  • 如果用 -fno-elide-constructors 关闭 RVO,编译器发现局部变量即将离开作用域变成将亡值,会尝试调用移动构造函数;

  • 影响 RVO 的场景(不适用或被抑制):

    • 返回值用于赋值(会触发拷贝赋值运算符),而不是初始化构造;

    • 返回特殊变量:

      • 返回局部引用/指针,这属于 UB(悬垂),跟 RVO 无关;

      • 返回 static 变量、类成员变量;

      • 直接返回参数;

    • 画蛇添足手动移动 :

      • 函数内 return std::move(x)

      • 对函数返回值调用 std::move()

异常处理场景的内存管理

异常后,程序会从 throw 点开始向上回溯调用栈(Unwinding),直到匹配到 catch 块,这期间栈上构造的所有对象,都会自动析构,顺序与构造顺序相反。

二进制信息

在 ELF/Mach-O 等二进制的只读数据段,包含两块异常处理相关的信息,具体在文件中所处的位置,可参考:

现代 C++ 跨平台开发-二进制篇:编译器/运行时、二进制格式、静/动态库、符号分析

.eh_frame

由 DWARF 标准定义,用于支持栈展开(stack unwinding),包含每个函数的帧信息。
下面以 ELF 格式为例,展示 .eh_frame 的主要结构:

├── CIE (Common Information Entry)
│   ├── length                // CIE/FDE 总字节数(不包括 length 字段自身)
│   ├── CIE_id = 0            // 标识为 CIE(FDE 中为非零指针)
│   ├── version               // 通常为 1
│   ├── augmentation string   // 如 "zR"(返回地址寄存器)、"zL"(含 LSDA 指针)等
│   ├── code_alignment_factor
│   ├── data_alignment_factor
│   ├── return_address_register
│   ├── augmentation data     // 可选,如 FDE 编码方式、LSDA 地址偏移等
│   └── initial_instructions  // 初始 CFI 指令(用于定义寄存器恢复规则)
│
└── FDE (Frame Description Entry) × N(每个函数一个或多个)
    ├── length
    ├── CIE_pointer           // 相对偏移指向对应 CIE
    ├── initial_location      // 函数起始 PC(虚拟地址)
    ├── address_range         // 覆盖的指令长度
    ├── augmentation data     // 若 augmentation string 含 "zL",此处包含 LSDA 指针(即 .gcc_except_table 中某条目地址)
    └── instructions          // CFI 指令序列(如 DW_CFA_def_cfa, DW_CFA_offset 等)

.gcc_except_table

包含 LSDA(Language-Specific Data Area),描述:

  • 函数中哪些代码范围有 try/catch;

  • 哪些区域需要调用析构函数(cleanup);

  • 对于 noexcept 函数,是否应调用 std::terminate 而非继续传播;

下面仍以 ELF 格式为例,展示 .gcc_except_table 的主要结构:

├── LPStart encoding + value           // landing pad 起始地址编码及值(通常为 0,表示与 FDE pc 相同)
├── TType encoding                     // type table 编码方式(如 DW_EH_PE_uleb128 | DW_EH_PE_pcrel)
├── TType base offset                  // 指向 type table 的偏移(type_info* 数组)
├── Call Site Table
│   ├── call_site_start               // 相对于函数起始的 PC 偏移(try 或可能抛异常区域开始)
│   ├── call_site_len                 // 区域长度
│   ├── landing_pad_offset            // 相对于 LPStart 的 landing pad 偏移(0 表示无 handler,仅 cleanup)
│   └── action_offset                 // 指向 Action Table 的偏移(0 表示无 action)
│
├── Action Table(可选但关键)        // 描述异常处理动作链,每个 entry 为 1 字节或变长
│   └── Action Entry Chain(以 action_offset 为起点的单向链表)
│       ├── ttype_index (signed LEB128)
│       │   ├── > 0:表示 catch 的类型在 Type Table 中的索引(从 1 开始)
│       │   ├── = 0:保留(不应出现)
│       │   └── < 0:表示需执行 cleanup(析构),其绝对值无直接含义,仅标志 cleanup
│       │
│       └── next_action_offset (signed LEB128)
│           ├── = 0:链表结束
│           └── ≠ 0:相对于当前 entry 起始位置的偏移(通常为负,指向前一个 action)
│
└── Type Table(可选)                // type_info* 指针数组(按 ttype_index 索引),用于 catch 类型匹配
    └── entries[1], entries[2], ...   // 索引从 1 开始;entries[i] 是 type_info*(可能经 pcrel 编码)

运行时异常信息

运行时发生异常,会动态生成异常信息。以 LLVM 为例:

struct _LIBCXXABI_HIDDEN __cxa_exception {
   //...

   size_t referenceCount;

   std::type_info *exceptionType;

#ifdef __wasm__
   void *(_LIBCXXABI_DTOR_FUNC *exceptionDestructor)(void *);
#else
   void (_LIBCXXABI_DTOR_FUNC *__ptrauth_cxxabi_exception_destructor exceptionDestructor)(void *);
#endif

   std::unexpected_handler __ptrauth_cxxabi_unexpected_handler unexpectedHandler;
   std::terminate_handler __ptrauth_cxxabi_terminate_handler terminateHandler;

   __cxa_exception *nextException;

   //...

   _Unwind_Exception unwindHeader;
};

//  Allocate a __cxa_exception object, and zero-fill it.
//  Reserve "thrown_size" bytes on the end for the user's exception object.
//  Zero-fill the object. If memory can't be allocated, call std::terminate. 
//  Return a pointer to the memory to be used for the user's exception object.
void *__cxa_allocate_exception(size_t thrown_size) throw() {
    size_t actual_size = cxa_exception_size_from_exception_thrown_size(thrown_size);
    // Allocate extra space before the __cxa_exception header to ensure the start of the thrown object is sufficiently aligned.
    size_t header_offset = get_cxa_exception_offset();
    char *raw_buffer = (char *)__aligned_malloc_with_fallback(header_offset + actual_size);
    if (NULL == raw_buffer) std::terminate();
    __cxa_exception *exception_header = static_cast<__cxa_exception *>((void *)(raw_buffer + header_offset));
    // We warn on memset to a non-trivially castable type.
    // We might want to change that diagnostic to not fire on a trivially obvious zero fill.
    ::memset(static_cast<void*>(exception_header), 0, actual_size);
    return thrown_object_from_cxa_exception(exception_header);
}


//  Free a __cxa_exception object allocated with __cxa_allocate_exception.
void __cxa_free_exception(void *thrown_object) throw() {
    // Compute the size of the padding before the header.
    size_t header_offset = get_cxa_exception_offset();
    char *raw_buffer = ((char *)cxa_exception_from_thrown_object(thrown_object)) - header_offset;
    __aligned_free_with_fallback((void *)raw_buffer);
}

从上面可以看出,异常信息不仅包含析构等各种回调函数指针,还包含 type_info

具体的处理流程这里不展开,有兴趣的可参考下列文章:

或直接阅读 LLVM 相关源码:

性能影响

综合上面的二进制信息和运行时信息看,C++ 异常对性能的影响主要包含:

  • 二进制膨胀;

  • 运行时 RTTI 开销。

noexcept

  • 如果函数确认不会抛异常,可标注 noexcept

    • 二进制体积更小:编译器不会生成 .gcc_except_table

    • 运行时开销更小:若发生异常,不会为该函数生成额外的 __cxa_exception,而是直接 std::terminate

  • 移动构造/运算符必须声明 noexcept,否则不会被调用;

编译选项

  • -fno-exceptions -fno-asynchronous-unwind-tables:

    • 栈帧信息和异常表都不会生成;

    • 直接 std::terminate

  • -fno-exceptions -fasynchronous-unwind-tables:

    • 异步 unwinding:只生成栈帧信息而无异常表,仅用于跟踪调用栈;

    • 既不会调用析构函数,也不会 std::terminate

  • -fexceptions:

    • 全量生成栈信息和异常表(另一个编译参数被忽略);

    • 如果是 noexcept 函数,或者未捕获异常,会 std::terminate

最佳实践

  • 尽量多利用 RAII 管理资源,这样即使遇到异常一般也能正常释放;

  • 析构函数不要抛异常:否则会打断 unwinding,直接调用 std::terminate()

错误实践

不少 Java 开发习惯一上来先 try{} catch(Exception e){},C++ 中应杜绝。

  • 很多情况根本不会抛 C++ 异常,捕获没用:

    • C 函数一般直接触发 unix 信号(SIGSEGV, SIGABRT 等)直接终止进程;

    • C++ 标准库 IO 文件操作默认也关闭异常模式(可手动开启);

  • 就算会抛异常,也不应该捕获所有异常,会掩盖问题;

  • 异常处理有开销。

异步场景相关内存管理

函数式编程

  • 无捕获:

    • 会被优化为静态函数指;
  • 按值捕获:

    • 只读(const),最安全(避免悬垂引用);

    • 若需修改(包括移动),须加上 mutable 关键字。

  • 引用捕获:

    • 不能捕获右值引用;

    • 有悬垂引用风险;

    • 直接 [&] 会隐式捕获 this

  • 指针捕获:

    • 裸指针要按值捕获;

    • shared_ptr 可用于 lambda 等异步场景避免内存提前释放;

  • static 成员会被隐式捕获;

  • 尽量按需捕获,避免无脑 [&] 或直接捕获 this

  • std::function 底层是类型擦除,涉及虚函数开销,除非用于存储回调等场景,否则应考虑模板函数。

惰性初始化

前面提到,局部静态变量会在第一次调用时初始化,并且编译器保证其线程安全;将它跟 lambda 结合可实现惰性初始化:

std::string write(const Json::Value& value) {
    static thread_local const auto builder = [] {
        Json::StreamWriterBuilder b;
        b.settings_["indentation"] = "";
        return b;
    }();
    return Json::writeString(builder, value);
}

线程初始化

std::thread 构造函数:

  • 参数默认会被 decay() 转为值类型,因为异步场景易出现悬垂引用;

  • 若确实要传引用,需通过 std::ref 包装;

template<typename T>
class reference_wrapper {
    T* ptr;
public:
    reference_wrapper(T& ref) : ptr(&ref) {}
    operator T&() const { return *ptr; }  //隐式转换
    T& get() const { return *ptr; }
};

template<typename T>
reference_wrapper<T> ref(T& t) {
    return reference_wrapper<T>(t);
}

其他场景的内存管理

处理 C 风格字符串

C 风格字符串相关操作一般都要求 \0 结尾,否则属于未定义行为;

  • strlen():通过 \0 判断结尾;

  • strcpy():逐字符比较直到 \0

  • strcmp():复制直到遇到 \0

  • strdup()strlen() + malloc() + strcpy()

处理 C 风格可变参数

C 风格可变参数实际上是一种运行时参数,编译器无法在编译期检查参数是否匹配,因此必须小心使用。

  • va_startva_end 要匹配,否则属于 UB;

  • 如果要多次遍历或传给其他函数,应该用 va_copy 拷贝一份 va_list,而不能重复 va_start,否则也是不匹配 UB;

va_list args;
va_start(args, msg);

//拷贝一份,用于获取长度
va_list args_copy;
va_copy(args_copy, args);
const auto len = vsnprintf(nullptr, 0, msg, args_copy);
va_end(args_copy);

if (len > 0) {
    std::vector<char> buffer(len + 1);
    vsnprintf(buffer.data(), buffer.size(), msg, args);
    //...
}

va_end(args);

其实对于现代 C++,更安全的的方式是改用编译期可变模板参数:

  • 如果支持 C++20 的 std::format,一行代码即可优雅地处理可变参数格式化;

  • 如果不支持,可考虑结合 C++17 的 std::make_tuple 和折叠表达式:

#if !defined(USE_STD_FORMAT)
template <typename... Args>
std::string dynxxLogFormatT(std::string_view format, Args&&... args) {   
    std::ostringstream oss;
    
    auto formatWithArgs = [&oss, &format](auto... xArgs) {
        std::string tmpFormat{format};
        constexpr auto flag = "{}";
        ((oss << tmpFormat.substr(0, tmpFormat.find(flag)) << xArgs, tmpFormat.erase(0, tmpFormat.find(flag) + 2)), ...);
        oss << tmpFormat;
    };
    std::apply(formatWithArgs, std::make_tuple(args...));
    
    return oss.str();
}
#endif

template<typename... Args>
void dynxxLogPrintF(DynXXLogLevelX level, std::string_view format, Args&&... args) {
    auto fContent =
#if !defined(USE_STD_FORMAT)
    dynxxLogFormatT(format, std::forward<Args>(args)...)
#else
    std::vformat(std::string{format}, std::make_format_args(args...))
#endif
    ;
    dynxxLogPrint(level, fContent);
}