C++ addons undefined symbol 问题排查

image

背景

单元测试节点失败了, 点进来查看发现是一个内部的 Node.js C++ 插件运行时报错了 ❌, 错误信息为: undefined symbol: _ZN3leo6AppEnv9swimlane_B5cxx11E。

symbol 的基本概念

在计算机中,一个函数的指令被存放在一段内存中,当进程需要执行这个函数的时候,它必须知道要去内存的哪个地方找到这个函数,然后执行它的指令。也就是说,进程要根据这个函数的名称,找到它在内存中的地址,而这个名称与地址的映射关系,是存储在 “symbol table”中。

“symbol table”中的 symbol 就是这个函数的名称,进程会根据这个 symbol 找到它在内存中的地址,然后跳转过去执行。

问题分析

了解到 symbol 的概念后, 我们知道了 symbol 记录了变量在内存中的地址, 那么 undefined symbol 可能就是找不到该地址或者是非法不匹配的地址。

先查阅一下 undefined symbol 可能的原因 来指引一下接下来的排查方向

  1. 依赖库未找到

    • 这是最常见的原因,一般是没有指定查找目录,或者没有安装到系统查找目录里
  2. 链接的依赖库不一致

    • 编译的时候使用了高版本,然后不同机器使用时链接的却是低版本,低版本可能缺失某些 api
  3. 符号被隐藏

    • 如果动态库编译时被默认隐藏,外部代码使用了某个被隐藏的符号。
  4. c++ abi 版本不一致

    • 最典型的例子就是 gcc 4.x 到 gcc 5.x 版本之间的问题,在 4.x 编辑的动态库,不能在 5.x 中链接使用。

问题排查

首先拉取出现问题的镜像开始本地复现问题, 然后使用 nm 命令来显示更多查找 symbol 时具体的信息

linux nm 命令 显示关于指定 File 中符号的信息,文件可以是对象文件、可执行文件或对象文件库.

本地也顺利复现了该问题

1
undefined symbol: _ZN3leo6AppEnv9swimlane_B5cxx11E

接着我们运行 nm 命令来查看详细信息

1
nm -D /xxx/build/Release/leo_client.node

下面是截取的 nm 的输出日志, 可以看到 _ZN3leo6AppEnv9swimlane_B5cxx11E 的地址是缺失的

1
2
3
4
5
6
7
8
9
10
                 U _ZN3leo6AppEnv9swimlane_B5cxx11E
0000000000661c80 B _ZN3leo6AppEnv9swimlane_E
000000000026f34c W _ZN3leo6LeoKeyaSEOS0_
000000000026f6e0 W _ZN3leo6LeoKeyC1EOS0_
000000000026e3e2 W _ZN3leo6LeoKeyC1ERKS0_
000000000032eafc T _ZN3leo6LeoKeyC1ERKSs
000000000026e1d4 W _ZN3leo6LeoKeyC1ERKSsS2_
000000000026e188 W _ZN3leo6LeoKeyC1Ev
000000000026f6e0 W _ZN3leo6LeoKeyC2EOS0_
000000000026e3e2 W _ZN3leo6LeoKeyC2ERKS0_

这些日志还不能让我们准确定位到源码, 接着继续加了 c++filt 命令来还原 C++ 编译后的函数名

c++filt(1) — Linux manual page C++ 和 Java 语言提供函数重载, 意味着您可以编写许多具有相同名称的函数, 前提是每个函数都采用不同类型的参数。 为了能够区分这些同名的 函数 C++ 和 Java 将它们编码为低级汇编程序 唯一标识每个不同版本的名称。这 过程称为重整。 c++filt 程序执行 逆映射:它将低级名称解码(解码)成 用户级别的名称,以便它们可以被读取。

1
nm -D /xxx/build/Release/leo_client.node | c++filt

此时的日志已经可以让我们定位到对应源代码, 而错误处多了关键的 [abi:cxx11] 的信息, 似乎对应了上面所说的第4点 c++ abi 版本不一致

1
2
3
4
5
6
7
8
9
10
                 U leo::AppEnv::swimlane_[abi:cxx11]
0000000000661c80 B leo::AppEnv::swimlane_
000000000026f34c W leo::LeoKey::operator=(leo::LeoKey&&)
000000000026f6e0 W leo::LeoKey::LeoKey(leo::LeoKey&&)
000000000026e3e2 W leo::LeoKey::LeoKey(leo::LeoKey const&)
000000000032eafc T leo::LeoKey::LeoKey(std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
000000000026e1d4 W leo::LeoKey::LeoKey(std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
000000000026e188 W leo::LeoKey::LeoKey()
000000000026f6e0 W leo::LeoKey::LeoKey(leo::LeoKey&&)
000000000026e3e2 W leo::LeoKey::LeoKey(leo::LeoKey const&)

通过查看对应的源码找到了 swimlane_ 变量与其类型 std::string

1
2
3
// 报错的源码

static std::string swimlane_;

接着继续查阅 [abi:cxx11] 关键词相关的文档 Dual ABI

在 GCC 5.1 版本中,libstdc++ 引入了一个新库 ABI,其中包括std::string和 std::list. 为了符合 2011 C++ 标准,这些更改是必要的,该标准禁止 Copy-On-Write 字符串并要求列表跟踪其大小。

为了保持与 libstdc++ 链接的现有代码的向后兼容性,库的 soname 没有更改,旧实现仍与新实现并行支持。这是通过在内联命名空间中定义新实现来实现的,因此它们具有不同的名称以用于链接目的,例如,新版本 std::list实际上定义为 std::__cxx11::list. 因为新实现的符号具有不同的名称,所以两个版本的定义可以存在于同一个库中。

看到这里我们这里知道了 GCC 5.1 版本后实现了新的 std::string 定义为了 std::__cxx11::string, 同时也保留了旧版本的 std::string, 5.1 版本后可以自主选择使用哪个版本, 而 5.1 版本之前就固定是旧版本了。

[abi:cxx11] 的报错则表明你正在尝试将使用不同版本编译的目标文件链接在一起, 当链接到使用旧版本 GCC 编译的第三方库时,通常会发生这种情况。如果第三方库不能用新的 ABI 重建,那么你需要用旧的 ABI 重新编译你的代码。

由此也印证了之前分享的 API 与 ABI 的区别 提到的维持 API 稳定容易, ABI 稳定就涉及太多要素。

_GLIBCXX_USE_CXX11_ABI 宏(请参阅宏)控制库头文件中的声明是使用旧 ABI 还是新 ABI。因此,可以为每个正在编译的源文件单独决定使用哪个 ABI。使用 GCC 的默认配置选项,宏的默认值为 1,这会导致新 ABI 处于活动状态,因此要使用旧 ABI,您必须在包含任何库头之前将宏显式定义为 0。 (请注意,某些 GNU/Linux 发行版对 GCC 5 的配置不同,因此宏的默认值为 0,用户必须将其定义为 1 才能启用新的 ABI。)

问题解决

由上可知可以通过设置 _GLIBCXX_USE_CXX11_ABI 宏的值 0 为关闭, 1 为开启来决定采用旧版还是新版
。那我们的 Node.js C++ 插件如何设置这个宏的值了?

该插件根目录的 binding.gyp 的 defines 字段即可, 此时可以通过设置 _GLIBCXX_USE_CXX11_ABI=0 和 _GLIBCXX_USE_CXX11_ABI=1 分别进行编译一次, 这样就知道当前插件应该是用新还是旧才能和其他链接库兼容了, 最后设置 _GLIBCXX_USE_CXX11_ABI=0 后本地通过源码重新编译就能成功运行了 ✅ 。

1
2
- 'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS' ],
+ 'defines': [ 'NAPI_DISABLE_CPP_EXCEPTIONS', '_GLIBCXX_USE_CXX11_ABI=0' ],