AIGC标识 C++ .so 跨库 ABI 问题

C++ .so 跨库 ABI 问题

动态库,ABI,内存布局。

把重新编译好的行情读写动态库 libRW.so 发给合作方之后,对方在自己的机器上跑主程序,\(20\) 个因子的 compare 结果全部不对,C++ 侧读回来的值一直是 Cdata:0。主程序本身又跑得很完整,读写大概 \(2470\) 万行,速度也正常,直到所有工作做完,在退出析构时才报:

free(): invalid pointer
Aborted (core dumped)

结果全错和退出才崩放在一起,一开始不太好往同一个方向想。后来用 gdb 看调用栈:

#8  std::vector<TradeAggPoint>::~vector (this=0x5657e7a98)
#9  StockInput::~StockInput                (this=0x5657e7a38)
#18 DataManager::~DataManager
#21 Pipeline::~Pipeline
#24 Runner::~Runner

这里最直接的信息是两个 \(\text{this}\) 的差值:

0x5657e7a98 - 0x5657e7a38 = 0x60

被析构的 std::vector<TradeAggPoint> 在 StockInput + 0x60。而我这边编译出的 StockInput 只有 \(4\) 个成员,\(\text{sizeof}\) 正好是 0x60,也就是说旧头文件版本认为第五个成员应当从 0x60 开始,但当前 .so 编译出来的对象只占到这里。

翻版本历史后,事情就清楚了。最近一次重构删掉了 StockInput 的第五个字段 upperTrades。合作方主程序用的还是包含这个字段的旧头文件,\(\text{sizeof}\) 是 0x78;我发过去的 .so 则用删掉字段后的新头文件编译,\(\text{sizeof}\) 是 0x60。主程序在退出时按照旧布局调用析构,于是把对象边界外的内存当成了 upperTrades,又继续调用了它的 vector 析构函数。

这不是普通的参数传错,而是两边对同一个类型的内存布局已经没有共识。

动态库的动态主要体现在加载和代码共享上,ld.so 可以在运行时把 .so 装进进程,多个进程也能共享同一份代码。但结构体字段偏移、\(\text{sizeof}\)、虚表这些内容,仍然在编译时就被固定到二进制里了。主程序和 .so 哪边没有重新编译,哪边就会继续使用旧布局。源码里看到的还是同一个 StockInput,二进制层面却已经是两个不同的类型。

这次接口其实已经改过两轮。

最早直接把读写门面类暴露出去,参数里还带着 std::string 和 std::unique_ptr。如果两边使用的 _GLIBCXX_USE_CXX11_ABI、GCC 或 libstdc++ 版本不同,这些类型的内部实现也可能不同,参数一传就错位。后来把接口收成纯 C 风格,参数只留 const char*、int32_t、void* 这类类型,对象由 .so 自己创建和释放:

extern "C" {
IRW* rw_create();
void rw_destroy(IRW*);
int32_t rw_read(IRW*, const char* file, const char* date);
}

这样处理后,接口调用的 ABI 稳定了很多。对象也改成谁分配谁释放,避免一方 \(\text{new}\) 出来的内存交给另一方 \(\text{delete}\)。当时内存后端对象的生命周期需要跨库,就把它改成借用语义,.so 只保存指针,不负责释放,真正生命周期仍归调用方。

前两轮解决的是显式接口和所有权,到了这次,接口里已经看不到 STL 了,程序还是崩。问题在于传进去的 void* 后面仍然挂着完整的 C++ 对象。SubConfig、DataManager、StockInput 内部都有 STL 成员,.so 拿到指针后会 static_cast 回具体类型,再按自己编译时的那份头文件去算字段偏移。

void* 只是没有把类型写在参数上,并不表示这个对象真的没有类型。cast 回去之后,编译器还是会使用它知道的布局。

所以跨库共享的结构体不能随手改。增加、删除、调整字段顺序或者改变字段类型,都会改变后续成员的偏移和整个对象的 \(\text{sizeof}\)。即便只在尾部追加字段,只要两边可能互相创建、析构对象,或者其中一边会按照对象大小访问内存,也不能让一边单独使用新头文件。要改共享布局,就得两边用同一份头文件重新编译并一起发布。

虚表和 RTTI 也有类似的问题。通过 IFactor* 调虚函数时,双方对虚函数声明顺序和 vtable 布局必须一致;跨 .so 抛异常时,typeinfo 也要一致。边界上不确定这些条件时,直接在 .so 里面 \(\text{try}/\text{catch}\),再转成错误码会更稳一些。

编译选项同样会影响二进制布局。_GLIBCXX_DEBUG、_GLIBCXX_ASSERTIONS 会改变容器实现,NDEBUG 如果用在了结构体声明周围,也可能让 Debug 和 Release 两侧看到不同的字段。_GLIBCXX_USE_CXX11_ABI 以及不同位置的 libstdc++.so.6 都可以用 ldd 和 readelf -d 查一遍。

回到这次崩溃,最有效的线索还是析构调用栈里的 \(\text{this}\) 地址。主程序带调试信息时,可以用 ptype /o StockInput 直接看字段偏移,再和 .so 编译时使用的头文件对比。两边的偏移一旦不同,free(): invalid pointer 就不一定是谁 free 错了,它可能只是错误析构了一块本来不属于当前对象的内存。

后来稳定性更高的一种做法,是让跨库共享的部分尽量只剩 POD 记录和字节格式,vector 这类容器留在各自编译单元内部,不把带有内部指针的对象直接交给另一边。不过这次事故已经足够说明问题:接口层清理干净之后,借用对象内部的字段布局仍然是一份 ABI 契约,只是它藏得更深一些。

那次印象比较深的就是,接口都已经改成 C 风格了,最后还是栽在一份没有同步的头文件上。

posted @ 2026-09-04 09:21  Ke_scholar  阅读(7)  评论(0)    收藏  举报