选项汤:结合编译器标志的微妙陷阱

Firefox 开发揭示了许多跨平台差异及其依赖项组合的独特功能。Firefox 的工程师经常克服这些挑战,虽然我们无法详细介绍所有挑战,但我们认为您会乐于听到其中一些,因此这里是一个最近的技术调查样本。

在 Firefox 120 测试版周期中,我们的雷达上出现了一种新的崩溃签名,而且数量很大

当时,跨操作系统的分布显示,超过 50% 的崩溃来自 Ubuntu 18.04 LTS 用户。

主进程在 CanvasRenderer 线程中崩溃,崩溃堆栈如下

0  firefox  std::locale::operator=  
1  firefox  std::ios_base::imbue  
2  firefox  std::basic_ios<char, std::char_traits<char> >::imbue  
3  libxul.so  sh::InitializeStream<std::__cxx11::basic_ostringstream<char, std::char_traits<char>, std::allocator<char> > >  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/gfx/angle/checkout/src/compiler/translator/Common.h:238
3  libxul.so  sh::TCompiler::setResourceString  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/gfx/angle/checkout/src/compiler/translator/Compiler.cpp:1294
4  libxul.so  sh::TCompiler::Init  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/gfx/angle/checkout/src/compiler/translator/Compiler.cpp:407
5  libxul.so  sh::ConstructCompiler  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/gfx/angle/checkout/src/compiler/translator/ShaderLang.cpp:368
6  libxul.so  mozilla::webgl::ShaderValidator::Create  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/dom/canvas/WebGLShaderValidator.cpp:215
6  libxul.so  mozilla::WebGLContext::CreateShaderValidator const  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/dom/canvas/WebGLShaderValidator.cpp:196
7  libxul.so  mozilla::WebGLShader::CompileShader  /build/firefox-ZwAdKm/firefox-120.0~b2+build1/dom/canvas/WebGLShader.cpp:98

乍一看,我们想责怪 WebGL。C++ 标准库函数不可能有错,对吧?

但当查看 WebGL 代码时,崩溃发生在下面总结的完美有效的 C++ 行中

std::ostringstream stream;
stream.imbue(std::locale::classic());

这段代码不应该崩溃,但它确实崩溃了。事实上,仔细查看堆栈提供了一个调查的第一个线索
虽然我们崩溃到了属于 C++ 标准库的函数中,但这些函数似乎位于 firefox 二进制文件中。

这是一种不寻常的情况,在 Firefox 的官方版本中从未发生过。
然而,对于发行版来说,更改配置设置并将下游补丁应用于上游源代码非常常见,对此不必担心。
此外,只有一个版本的 Firefox 测试版会导致这种崩溃。

我们知道这一点,因为与任何 ELF 二进制文件相关联的唯一标识符。
这里,如果我们选择任何特定版本的 Firefox 120 测试版(例如 120b9),所有崩溃都包含相同的 Firefox 唯一标识符。

现在,我们如何猜测哪个版本生成了这个奇怪的二进制文件?

一个有用的用户评论提到,他们从更新到 120.0~b2+build1-0ubuntu0.18.04.1 开始经常遇到这种崩溃。
通过查找这个版本标识符,我们很快找到了 Firefox 测试版 PPA
然后,事实上,我们能够通过在 Ubuntu 18.04 LTS 虚拟机中安装它来重现崩溃:当加载任何 WebGL 页面时都会发生崩溃!
现在有了二进制文件,运行 nm -D ./firefox 确认存在与 libstdc++ 相关的多个符号,这些符号位于文本部分(T 标记)。

来自 libstdc++ 的模板化符号和内联符号通常显示为弱符号(W 标记),因此这种情况只有一个解释:firefox 已使用 libstdc++ 静态链接,可能是通过 -static-libstdc++

幸运的是,所有 Ubuntu 包的构建日志都可用。
经过一番挖掘,我们找到了 120b9 构建的日志,其中确实包含对 -static-libstdc++ 的引用。

但是为什么呢?

同样,一切都记录在案,由于经过良好训练的挖掘技巧,我们找到了 一个错误报告,其中提供了有趣的见解。
Firefox 需要现代 C++ 编译器,因此需要现代 libstdc++,而这种库在像 Ubuntu 18.04 LTS 这样的旧系统上不可用。
构建使用 -static-libstdc++ 来弥合这一差距。
但这只解释了奇怪的设置。

崩溃是怎么回事?

既然我们现在可以重现它,我们可以在调试器中启动 Firefox 并继续调查。
当检查崩溃位置时,我们似乎因为 std::locale::classic() 未正确初始化而崩溃。
让我们看一下实现。

const locale& locale::classic()
{
  _S_initialize();
  return *(const locale*)c_locale;
}

_S_initialize() 负责确保在返回对它的引用之前,c_locale 将被正确初始化。
为了实现这一点,_S_initialize() 调用另一个函数 _S_initialize_once()

void locale::_S_initialize()
{
#ifdef __GTHREADS
  if (!__gnu_cxx::__is_single_threaded())
    __gthread_once(&_S_once, _S_initialize_once);
#endif

  if (__builtin_expect(!_S_classic, 0))
    _S_initialize_once();
}

_S_initialize() 中,我们首先通过 pthread_once() 的包装器:第一个到达此代码的线程会消耗 _S_once 并调用 _S_initialize_once(),而其他线程(如果有)则被卡住,等待 _S_initialize_once() 完成。

这看起来相当万无一失,对吧?

如果 _S_classic 在此之后仍然未初始化,甚至还有一个对 _S_initialize_once() 的额外直接调用。
现在,_S_initialize_once() 本身非常简单:它分配 _S_classic 并将其放入 c_locale 中。

void
locale::_S_initialize_once() throw()
{
  // Need to check this because we could get called once from _S_initialize()
  // when the program is single-threaded, and then again (via __gthread_once)
  // when it's multi-threaded.
  if (_S_classic)
    return;

  // 2 references.
  // One reference for _S_classic, one for _S_global
  _S_classic = new (&c_locale_impl) _Impl(2);
  _S_global = _S_classic;
  new (&c_locale) locale(_S_classic);
}

崩溃看起来就像我们从未执行过 _S_initialize_once() 一样,所以让我们在那里设置一个断点,看看会发生什么。
仅仅这样做,我们就已经注意到了一些可疑的事情。
我们确实到达了 _S_initialize_once(),但不是在 firefox 二进制文件中:相反,我们只到达了 liblgpllibs.so 导出的版本。
事实上,libgpllibs.so 也与 libstdc++ 静态链接,因此 firefox 和 liblgpllibs.so 都嵌入了并导出了自己的 _S_initialize_once() 函数。

默认情况下,符号插桩 会生效,_S_initialize_once() 应该始终通过过程链接表 (PLT) 调用,以便每个模块最终调用相同版本的函数。
如果符号插桩在此处生效,我们预计 liblgpllibs.so 会到达 firefox 导出的 _S_initialize_once() 版本,而不是它自己的版本,因为 firefox 是先加载的。

所以可能没有符号插桩。

当使用 -fno-semantic-interposition 时,就会发生这种情况。

每个版本的标准库都将独立存在,与其他版本相互独立。
但是 Firefox 构建系统和 Ubuntu 维护者似乎都没有将这个标志传递给编译器。
然而,通过查看 _S_initialize()_S_initialize_once() 的反汇编代码,我们可以看到导出的全局变量(_S_once_S_classic_S_global确实受符号插桩的影响

这些访问都通过全局偏移表 (GOT) 进行,因此每个模块最终访问同一版本的变量。
鉴于我们之前关于 _S_initialize_once() 的说法,这似乎很奇怪。
但是,未导出的全局变量(c_localec_locale_impl)是直接访问的,没有符号插桩,正如预期的那样。

我们现在有足够的知识来解释崩溃。

当我们在 liblgpllibs.so 中到达 _S_initialize() 时,我们实际上消耗了 firefox 中的 _S_once,并初始化了 firefox 中的 _S_classic_S_global
但我们使用指向 liblgpllibs.so 中的已正确初始化的变量 c_locale_implc_locale 的指针来初始化它们!
然而,firefox 中的变量 c_locale_implc_locale 仍然未初始化。

因此,如果我们稍后在 firefox 中到达 _S_initialize(),一切都看起来像初始化已经完成一样。
但随后我们返回对 firefox 中的 c_locale 版本的引用,而这个版本从未初始化过。

轰!

现在主要的问题是:为什么我们看到对 _S_once 的插桩生效,而对 _S_initialize_once() 却没有?
如果我们退一步,这两个符号之间存在一个根本区别:一个是函数符号,另一个是变量符号。
事实上,Firefox 构建系统使用了 -Bsymbolic-function 标志!

ld 手册页将其描述如下

-Bsymbolic-functions

When creating a shared library, bind references to global function symbols to the definition within the shared library, if any.  This option is only meaningful on ELF platforms which support shared libraries.

与之相反的是

-Bsymbolic

When creating a shared library, bind references to global symbols to the definition within the shared library, if any.  Normally, it is possible for a program linked against a shared library to override the definition within the shared library. This option is only meaningful on ELF platforms which support shared libraries.

搞定了!

崩溃发生是因为这个标志让我们使用了符号插桩的奇怪变体,其中对变量符号(如 _S_once_S_classic)进行了符号插桩,而对函数符号(如 _S_initialize_once())则没有。

这会导致我们访问全局变量的方式不匹配:导出的全局变量由于插桩而变得唯一,而每个非插桩函数都将访问其自己的任何非导出全局变量的版本。

有了我们现在收集到的所有知识,编写一个不涉及任何 Firefox 代码的重现器就很容易了

/* main.cc */
#include <iostream>
extern void pain();
int main() {
pain();
   std::cout << "[main] " << std::locale::classic().name() <<"\n";
   return 0;
}

/* pain.cc */

#include <iostream>
void pain() {
std::cout << "[pain] " << std::locale::classic().name() <<"\n";
}

# Makefile
all:
   $(CXX) pain.cc -fPIC -shared -o libpain.so -static-libstdc++ -Wl,-Bsymbolic-functions
   $(CXX) main.cc -fPIC -c -o main.o
   $(CC) main.o -fPIC -o main /usr/lib/gcc/x86_64-redhat-linux/13/libstdc++.a -L. -Wl,-rpath=. -lpain -Wl,-Bsymbolic-functions
   ./main

clean:
   $(RM) libpain.so main

理解错误是一回事,解决它又是另一回事。
是否应该将其视为 libstdc++ 错误,即区域设置的代码与 -static-stdlibc++ -Bsymbolic-functions 不兼容?

感觉结合这些标志是给自己挖坑的好方法,而且 这似乎确实是 libstdc++ 维护者的观点

总的来说,也许这个故事中最奇怪的部分是这种组合直到现在还没有造成任何麻烦。
因此,我们建议该软件包的维护者停止使用 -static-libstdc++

还有其他方法可以使用与系统上不同的 libstdc++,例如使用动态链接并将 RPATH 设置为链接到捆绑的版本。

这样做 让他们成功部署了修复后的软件包版本。
在那之后几天,随着 Firefox 120 的正式发布,我们注意到同一崩溃签名的数量大幅增加。不会吧!

这次,数量完全来自 NixOS 23.05 用户,而且数量巨大!

在我们将测试版调查的结论与他们分享之后,NixOS 的维护人员能够 快速将崩溃与 尚未向 23.05 回溯的一个问题相关联,该问题导致编译器像 -static-libstdc++ 一样运行。

为了避免将来出现这种混乱,我们在 Firefox 的配置中添加了对这种特定设置的检测

我们感谢帮助解决此问题的人,特别是

  • Rico Tzschichholz (ricotz) 迅速修复了 Ubuntu 18.04 LTS 软件包,以及 Amin Bandali (bandali) 在此过程中提供的帮助;
  • Martin Weinelt (hexa) 和 Artturin 迅速修复了 NixOS 23.05 软件包;
  • Nicolas B. Pierron (nbp) 帮助我们开始使用 NixOS,这让我们能够快速与 NixOS 软件包维护人员分享有用的信息。

 

关于 Serge Guelton

更多 Serge Guelton 的文章…

关于 Yannis Juglaret

更多 Yannis Juglaret 的文章…