背景

线上有一个使用 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) 这一行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int FactoryImpl::upload(const std::string &cos_name,
                        const std::string &file_name,
                        std::string &url,
                        const char *data,
                        uint32_t data_len)
{
	// url是传出参数,用于生成url链接并返回给调用方
    url = gen_url(cos_name, file_name);

    do_something(url);
    return 0;
}

从经验来看,这个函数设计的不是很好,参数传递时使用了 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_storagestd::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_urlurl 的内容发生了交换。

继续跟进 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 程序也增加该选项限制导出符号内容后,终于复现了该问题。