记一次stdlibc++动静态链接导致的崩溃
文章目录
背景
线上有一个使用 gcc 4.8.5 实现的 cpp 框架服务,该框架加载另外一个同 gcc 版本实现的业务 so,框架提供一些接口供 so 调用,两者结合实现一些功能。
因为功能需要,需要将线上业务 A 的 2.1.0 版本的框架服务升级到 2.1.13 版本,但在升级后,线上服务出现必现 core。
导致 core 的是 upload
函数,该函数用于上传数据到腾讯云 COS ,这个函数由框架服务实现,并提供给业务 so 调用。崩溃发生在 url = gen_url(cos_name, file_name)
这一行。
|
|
从经验来看,这个函数设计的不是很好,参数传递时使用了 std::string
,如果业务 so 和框架服务使用的编译器版本不一致,那么很可能出现 ABI 不兼容的问题,进而触发崩溃。
但这里先不讨论 ABI 不兼容的问题,现在的问题是,为什么接口未做任何变更的情况下,在 2.1.0 版本上没有问题,而在升级 2.1.13 版本后,出现了崩溃?
问题分析
在框架服务升级 2.1.13 版本前后,业务 so 未做任何变更,因此可以先排除业务 so 的问题。
同时,另外一个业务 B 上,框架服务升级了 2.1.12 版本,且业务 so 也调用了 upload
函数没有发生任何问题。因此,在当前业务 A 上也将框架服务升级到 2.1.12 版本进行验证,也没有任何问题。因此可以将问题缩小到 2.1.12 与 2.1.13 版本的差异上。
经过对比,2.1.12 与 2.1.13 版本只是修复了一个 bug,这个 bug 与 upload
接口没有任何关系。
除了代码层面的变更,在构建环境上,两个版本均由流水线构建,构建环境理论上一致。
至此似乎陷入了僵局。
基于过去的经验,或许可以尝试看下两者在汇编层面有什么区别。只有一条语句不同。
请教了 ChatGPT,std::string::_Rep::_S_empty_rep_storage
是 std::string
中判断是否为空字符串的一个静态变量,两个版本的差异看起来只是这个常量存储的位置不同,似乎也没什么问题(如果有经验的话,这里其实能发现一些问题)。
由于看不出来差异,从汇编层面跟踪了一下流程,两个版本的执行流程上也没有差异。区别在于在 2.1.13 版本中执行到 std::string::_Rep::_M_destroy
时就崩溃了,这个方法是用来释放字符串内存的。
在代码中,url
参数在函数中被重新赋值时,需要将 url
内存储的原始字符串的内存释放掉,这时便会执行到 std::string::_Rep::_M_destroy
函数。
此时终于注意到 2.1.12 和 2.1.13 两个版本的 std::string::_Rep::_M_destroy
汇编实现存在差异,在 2.1.13 版本中直接调用了 delete
,而在 2.1.12 版本中,则是链接到了外部的函数。
此时恍然大悟,两个版本的 C++ 库链接的方式肯定存在差异,但问题是,两个版本都未调整过编译的选项。难道是流水线的构建工具链存在问题?
经过确认,果然在 1.30 号这天,构建工具的默认构建配置中增加了静态链接 c++ 库的配置。
2.1.12 版本在 1.29 号构建,2.1.13 版本则在 1.30 之后构建,两个版本的构建时间刚好在默认构建配置更新前后。
回退构建工具的默认编译配置后,发布测试果然不再触发崩溃问题了。
继续分析
虽然知道了 2.1.13 版本是静态链接了 libstdc++
库后才会触发崩溃,但接下来的问题是,为什么静态链接 libstdc++
库就会崩溃?
业务 so 单独在另一台机器上使用动态链接 libstdc++
的方式进行构建,难道框架服务改为静态链接后,他们使用的 libstdc++
版本不同,进而导致 ABI 不兼容?
框架服务构建流水线的 gcc 版本依旧是 4.8.5,与执行环境中的版本一致,不存在 ABI 不兼容的问题。
另一个猜测则是,upload
函数中的 url
是在业务 so 中申请的,当它被重新赋值时,是在框架服务代码里被释放的,由于他们使用的不是一个堆,所以在释放时触发崩溃?
查了下资料,libstdc++
库使用的堆都由 libc
进行维护,虽然框架服务静态链接了 libstdc++
,但它的 libc
还是动态链接的,与业务 so 一样。也就是说他们使用的是同一个堆,在业务 so 中申请内存,并在框架服务中释放并无问题。
此时其实没有什么思路了,唯一能想到的就是看看在触发崩溃时释放的那块内存到底是什么,从哪来?
我们已经知道是在析构调用 gen_url
方法返回的临时字符串时崩溃了,而在析构前,将临时字符串与参数传进来的 url
参数 进行了内存交换。因此实际上,是在释放 url
参数原始的内存时触发了崩溃,这其实与前面的猜测相符,但问题是,既然用了同一个堆,为什么会崩溃?
基于汇编代码详细调试跟踪了一番。
在调用 swap
前,我们可以看到,tmp_url
是生成的 url 链接,而 url
是空字符串 (图中标成了 cos_url
),这里注意下 url
中指向的字符串指针是 0x7ffff739a3f8 <std::string::_Rep::_S_empty_rep_storage+24> ""
接下来执行 swap
函数,tmp_url
和 url
的内容发生了交换。
继续跟进 tmp_url 的内存释放逻辑,首先将 tmp_url._M_dataplus._M_ptr - 24
之后的值与 &std::string::_Rep::_S_empty_rep_storage
进行了比较,如果不等,那么则释放 tmp_url.__M_dataplus._M_ptr - 24
指向的内存。
从跟踪的结果来看,这两个值并不同,因此最终会释放 tmp_url._M_dataplus._M_ptr - 24
的值,即 0x7ffff739a3e0
,而在这里便触发了崩溃。
通过 ChatGPT 的解释,,在 std::string
的实现中,std::string::_Rep::_S_empty_rep_storage
是一个静态变量,用于表示空的字符串或是使用了 SSO。查看 std::string
的源码,确实 std::string::_Rep::_S_empty_rep_storage
被定义成了静态变量。
到这里原因已经基本清楚了。
在调用 upload
方法时,传入的 url
参数是空字符串,而这个空字符串指向了静态的 std::string::_Rep::_S_empty_rep_storage
,由于动态链接,因此这个地址实际指向了进程中的 libstdc++
动态库中的地址。而框架进行内存释放时,需要先判断字符串是否为空字符串,如果不为空,那么再进行释放。但由于框架静态链接了 libstdc++
库,因此框架中的 std::string::_Rep::_S_empty_rep_storage
指向了框架地址空间的一个地址。
由于这两个地址不是同一个地址,因此误将指向动态 libstdc++
中的静态 std::string::_Rep::_S_empty_rep_storage
变量释放,但由于它是静态变量,因此在 delete
时触发异常。
为了验证上述结论,在业务调用 upload
方法是,将 url
的参数设置为非空字符串,这样该字符串将会在堆上分配内存,再次测试,这个时候确实没有发生崩溃。
将代码也再改为使用动态链接 libstdc++
,可以看到 std::string::_Rep::_S_empty_rep_storage
的地址是一样的,因此也就不会走到 delete
逻辑,不会触发崩溃。
总结
在定位出原因后,再回头就可以发现在一开始对比汇编代码时,两个版本的汇编代码获取的 std::string::_Rep::_S_empty_rep_storage
方式不同,其实已经说明问题了。但经验不足,完全不知所以然。
除此之外,在开始基于汇编代码进行分析时,应该立即拿 debug
非优化版本进行测试定位(前文 IDA 等截图均基于 debug
版本),整个过程中,因为搞不清楚优化后的汇编代码逻辑,前前后后浪费了很多时间。
最后,在跨模块进行接口设计时,尽量使用 C 接口,足够简单、稳定,虽然使用起来麻烦一点,但相较于后续运营过程中可能出现的问题及投入的时间,还是值得的。
补充说明
前文的分析均基于自己事后实现的 demo 程序,在复现 demo 中,一开始并未能复现问题,与线上环境比对好久后,确认是符号导出的问题。
向 ChatGPT 了解到,
这是因为在 Linux 系统中,动态链接库(DLL)是进程级别共享的。即使 A 静态链接了
libstdc++
,在运行时,如果系统检测到已经有一个libstdc++
的实例被加载(由于 B 动态链接了libstdc++
),它可能会尝试重用这个已加载的实例,以节省内存和避免冗余。这种情况下,即使 A 中包含了它自己的
libstdc++
代码副本,操作系统和动态链接器(如ld.so
)可能会优化并重定向对libstdc++
符号的引用,使得 A 和 B 实际上都使用了同一个libstdc++
实例中的std::string::_Rep::_S_empty_rep_storage
。这就解释了为什么两个共享对象获取的静态对象地址是一样的。
为了强制静态链接 libstdc++
的模块使用其代码副本,可以考虑使用版本脚本来限制模块 A 导出的符号。
- 创建一个版本脚本,其中指定了模块 A 应该导出的符号。
- 在链接模块 A 时,使用
--version-script
选项来指定版本脚本。
而线上环境刚好使用了 --version-script
选项指定了导出的符号信息,在为 demo 程序也增加该选项限制导出符号内容后,终于复现了该问题。