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

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

函数调用场景的内存管理

函数调用过程

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

  • 返回值地址入栈;

  • 参数依次入栈;

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

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

  • 执行函数体;

函数返回过程

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

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

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

调用约定

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

__cdel

  • 默认的约定,可不声明;

  • 参数从右到左入栈;

  • 调用方负责清理堆栈;

__stdcall

  • 参数从右到左入栈;

  • 被调用方清理堆栈;

__fastcall

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

  • 被调用方清理堆栈;

__thiscall

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

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

    • this 指针存寄存器;

    • 被调用方清理堆栈;

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

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

RVO

编译器对于函数调用返回值的优化:直接在调用者的栈帧上构造对象,消除了保存返回值而执行的临时对象构造和拷贝。

  • 返回引用的场景不适用;

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

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

    • 返回 static 变量、类成员变量,或直接返回参数;

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

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

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

异常处理场景的内存管理

异常后,程序会从 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 编码)

运行时异常信息

运行时发生异常,会动态生成异常信息:

struct __cxa_exception {
  //...
  size_t referenceCount; // here on 64-bit platforms
  std::type_info *exceptionType;
  void (*exceptionDestructor)(void *);
  unexpected_handler unexpectedHandler; // by default std::get_unexpected()
  terminate_handler terminateHandler; // by default std::get_terminate()
  __cxa_exception *nextException; // linked to the next exception on the thread stack
  int handlerCount; // incremented in __cxa_begin_catch, decremented in __cxa_end_catch, negated in __cxa_rethrow; last non-dependent performs the clean
  //...
  const char *actionRecord;
  const char *languageSpecificData;
  void *catchTemp; // landingPad
  void *adjustedPtr; // adjusted pointer of the exception object
  //...
  _Unwind_Exception unwindHeader;
};

具体的处理流程这里不展开。但从上面可以看出,异常信息不仅包含析构等各种回调函数指针,还包含 type_info

编译选项

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

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

    • 直接 std::terminate

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

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

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

  • -fexceptions:

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

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

性能影响

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

  • 二进制膨胀;

  • 运行时 RTTI 开销。

最佳实践

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

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

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

更多相关内容可参考:

异步场景相关内存管理

函数式编程

  • 无捕获:

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

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

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

  • 引用捕获:

    • 不能捕获右值引用;

    • 有悬垂引用风险;

  • 指针捕获:

    • 裸指针要按值捕获;

    • 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

  • strcpm():复制直到遇到 \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);
}