现代 C++ 跨平台开发-内存篇:函数调用、异常处理、异步等场景的内存管理
本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 5 篇,主要涉及:函数调用、异常处理、异步等场景的内存管理。
- 现代 C++ 跨平台开发-内存篇:类型转换、重载决议、内存模型
- 现代 C++ 跨平台开发-内存篇:内存管理、智能指针
- 现代 C++ 跨平台开发-内存篇:字节序、内存对齐、内存布局、虚函数表与多态
- 现代 C++ 跨平台开发-内存篇:拷贝、移动、可重定位
- 现代 C++ 跨平台开发-内存篇:函数调用、异常处理、异步等场景的内存管理
- 现代 C++ 跨平台开发-内存篇:STL 内存管理
- 现代 C++ 跨平台开发-内存篇:内存一致性、未定义行为、可观测性
- 现代 C++ 跨平台开发-内存篇:多平台跨层调用场景的内存管理
- 现代 C++ 跨平台开发-内存篇:内存问题分析工具
函数调用场景的内存管理
函数调用过程
调用指令(如
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 相关源码:
- llvm-project/libcxxabi/cxa_exception.h
- llvm-project/libcxxabi/cxa_exception.cpp
- llvm-project/clang/lib/Headers/unwind.h
性能影响
综合上面的二进制信息和运行时信息看,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_start和va_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);
}