前言

符号是讨论编译链接过程的中非常特殊的概念,随便写都能用,乱写却会引发血案。但麻烦在于并没有多少足够系统的资料去讨论这些问题,于是不得不花了一堆时间去整理这些支离破碎的知识。

大纲

我还没想好具体怎么组织这篇文章(因为博客缺了一篇介绍 ELF 的文章,后面补上),讨论内容有:

  • 符号本身的概念,比如 binding 和 visibility。
  • ELF 文件中的符号,重点关注它是怎么组织和寻址的。
  • 符号的解析和重定位,以及关于符号介入的关键特性说明。
  • 不同编译器下未初始化变量所生成的符号。
  • C++ 在 ELF 中能控制的符号特性。

内容本身虽然是技术细节,但我希望能指出一些搬砖上可能会出现的问题以免踩坑。

前置内容

这个话题属于编译链接过程,我在以前也整理过一些 CSAPP 的相关内容,可以作为一个入门基础:CSAPP 第七章笔记 – Caturra’s Blog

保命声明:非工具链选手,如有错误还请指正;这篇文章也是入门基础;我啥都不会,这些内容对本人来说很难。

符号的基本印象

什么是符号(symbol)?就符号本身来说,你目前可简单认为它指向一个语言层面的实体,比如:

  • 变量(OBJECT)。
  • 函数(FUNC)。

要达到能描述一个实体的目的,至少需要一个字符串来表示它本身的名字;除此以外,它还需要做到在翻译单元中能声明以及能被其它单元所引用。

实际上只要是需要定义或者引用的实体,都可作为一个符号来描述。后面你可以看到符号所具有的类型,应该能更加明白它是什么。

在这个简化模型中,可以认为作为一个描述符号的数据结构存在以下成员:

  • 名字。
  • 大小。
  • 类型和值。
  • 绑定方式。
  • 可见性。

前三个成员是相对好理解:

  • 名字即是一个字符串。
  • 大小即是作为一个符号需要占用的存储空间大小。
  • 一个符号本身当然有类型和值,就像一个变量一样。

符号的值会随不同类型而有不同的性质,而常见的类型可以是:

  • NOTYPE:未知。
  • OBJECT:变量(包含数组)。
  • FUNC:函数。
  • SECTION:section。
  • FILE:文件。

绑定方式和可见性是涉及到符号作为二进制接口的不同行为,在后面章节单独介绍。

其实,symbol 实际的数据结构如下所示:

typedef struct
{
  Elf64_Word    st_name;                /* Symbol name (string tbl index) */
  unsigned char st_info;                /* Symbol type and binding */
  unsigned char st_other;               /* Symbol visibility */
  Elf64_Section st_shndx;               /* Section index */
  Elf64_Addr    st_value;               /* Symbol value */
  Elf64_Xword   st_size;                /* Symbol size */
} Elf64_Sym;

可以看出,除了上面其它的信息以外,还有一点点细节:

  • st_name 居然是个 word 数值类型,真正名字该存在哪里?
  • st_shndx 指向到一个 section 中,这是一个什么 section
  • 符号(symbol)的数据结构命名以 Elf 作为前缀,它需要怎样做才能与 ELF 关联?

st_info 解析如下(ELF64 与 ELF32 相同):

#define ELF32_ST_BIND(val)              (((unsigned char) (val)) >> 4)
#define ELF32_ST_TYPE(val)              ((val) & 0xf)
#define ELF32_ST_INFO(bind, type)       (((bind) << 4) + ((type) & 0xf))

可以看出,info = bind + type,即包含了绑定方式和类型信息。

实际上符号的类型是相当的多,既有 OS 层面的,也有 processor 层面的:

#define STT_NOTYPE      0               /* Symbol type is unspecified */
#define STT_OBJECT      1               /* Symbol is a data object */
#define STT_FUNC        2               /* Symbol is a code object */
#define STT_SECTION     3               /* Symbol associated with a section */
#define STT_FILE        4               /* Symbol's name is file name */
#define STT_COMMON      5               /* Symbol is a common data object */
#define STT_TLS         6               /* Symbol is thread-local data object*/
#define STT_NUM         7               /* Number of defined types.  */
#define STT_LOOS        10              /* Start of OS-specific */
#define STT_GNU_IFUNC   10              /* Symbol is indirect code object */
#define STT_HIOS        12              /* End of OS-specific */
#define STT_LOPROC      13              /* Start of processor-specific */
#define STT_HIPROC      15              /* End of processor-specific */

ELF 中的符号表

ELF 文件中的符号表(symbol table)存放着前面提到的 Elf64_Sym,表示该文件内已经定义或者需要引用的符号。

symbol table 可以视为一个连续的 Elf64_Sym[] 数组,其标准规定下标 0 作为表的起始位置以及必然是一个预留的未定义的符号。

symbol table 是一个 ELF section。

Elf64_Sym 下的 st_shndx(section index)指明了这个符号位于 ELF 的 section 下标,通过该值作为 SHT(section header table)的下标进行访问。

但是也存在一些特殊的情况:

  • SHN_ABS:表示符号是一个绝对值,比如 FILE 类型的符号。
  • SHN_COMMON:表示未分配的 common symbol,通常指的是 FORTRAN COMMON 或者未初始化的 C 外部变量定义,也可以是 tentative 引用。
  • SHN_UNDEF:未定义的符号,即引用了不知具体定义(不在这个文件中)的符号。

通常 ELF 格式中具有 2 种符号表:

  • .symtab:该文件中接触到的所有符号都能在这找到,不会被载入到内存中。
  • .dynsym.symtab 的子集,提供给动态链接器使用,会被载入到内存中。

可以通过 readelf --symbols <file> 获取这 2 种符号表的信息。

$ readelf --symbols /usr/bin/sh | head -n 20

Symbol table '.dynsym' contains 94 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND raise@GLIBC_2.2.5 (2)
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND free@GLIBC_2.2.5 (2)
     4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _[...]@GLIBC_2.34 (3)
     7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND abort@GLIBC_2.2.5 (2)
     8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
     9: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT[...]
    10: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _exit@GLIBC_2.2.5 (2)
    11: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
    12: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND qsort@GLIBC_2.2.5 (2)
    13: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
    14: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
    15: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)
    16: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND [...]@GLIBC_2.2.5 (2)

除非你的程序是完全的静态链接,否则符号表就可能存在未定义的符号。这里由于展示的符号都需要动态链接到 C 库,因此 Ndx 均为 UND

关于 strip 操作:

符号的值

st_value 表示 symbol value,这个值在不同的上下文中有不同的含义:

  • 位于可重定位文件且符号是 SHN_COMMON 时,st_value 表示对齐要求。
  • 位于可重定位文件且符号是已定义时,st_value 表示 section 内的偏移量。
  • 位于动态目标文件时,st_value 表示符号的虚拟地址。

符号的绑定与可见性

关于符号绑定(symbol binding)的概念是相当含糊的,在 ELF spec 中也只提到:A symbol’s binding determines the linkage visibility and behavior.

然而可见性(visibility)的定义是根本不提啊 orz,不过我在 linker 开发者的博客中找到点线索:ELF symbol visibility was invented to provide more control over which symbols were accessible outside a shared library. The basic idea is that a symbol may be global within a shared library, but local outside the shared library.

绑定方式可以是:

  • LOCAL
  • GLOBAL
  • WEAK

下表列出符号所决定的可见性(其他行为放后面章节解释):

符号绑定 默认的可见性
LOCAL 该符号仅在含有符号定义的目标文件内可见。
GLOBAL 该符号对所有目标文件可见。
WEAK GLOBAL

精力有限,感兴趣自己看看:

一个符号的可见性是可以改变的,因为选择合适的可见性很重要。除此以外,redhat 工程师也抱怨过,C++ 暴露过长的符号名字会导致链接性能问题,相比 C 更需要关注可见性:见这份文件,第 7 页。

可见性可以是:

  • DEFAULT
  • HIDDEN
  • PROTECTED
  • INTERNAL

下表给出可见性的一些行为:

可见性 行为
DEFAULT 一般来说,GLOBAL 或者 WEAK 符号在任何地方都可见。
HIDDEN 符号在当前的共享库或者可执行文件外不可见,但可以通过间接的方式访问到该符号(比如通过地址访问)。
PROTECTED 符号在当前的共享库或者可执行文件外可见,但不能被覆盖(就是不能被介入)。
INTERNAL 符号在当前的共享库或者可执行文件外不可见。

关于符号可见性的用法可以见这里:Caturra000/Snippets/Linkage/symbol/hidden – Github

对于 C/C++ 来说,最简单的可见性控制方式就是善用 static,详见后面语言章节。

符号介入

对于一个 DEFAUTL 可见性的 GLOBAL / WEAK 符号来说,它可以进行符号介入(symbol interposition),通常是指用户实现一个和共享库已实现的同名函数,从而替换掉共享库的实现,这种介入即使是共享库内部调用原来的同名函数,实际调用的也会是用户自行实现的版本。

Interposition can occur when multiple instances of a symbol, having the same name, exist in different dynamic objects that have been loaded into a process.

这种技巧常用于 malloc() 等关键函数的“偷天换日”,不管是提高性能还是调试、打日志都有实际意义。

符号介入至少有 2 种做法:

  • 通过环境变量 LD_PRELOAD 优先搜索指定 DSO,见微软 mimalloc使用方式
  • 可执行文件先介入符号,再通过库函数 dlsym() 获取原有的符号,比如微信 libco协程 hook

如果不希望被介入,那就利用编译器选项修改符号可见性,我在上面的 Github 例子也给出用法了。

ELF 规范规定只有 visibility 为 DEFAUTL 的符号才能被介入

除了通过修改可见性以外,还有另外 2 种方法避免介入:

  • -Bsymbolic
  • -fno-semantic-interposition

符号解析

符号解析(symbol resolution)可以理解为:

  • 将一个 NDX 为 UND 的符号。
  • 修正为具有所属的 section 的符号。

一句话来说就是:resolve undefined symbols。

我认为前面提到的符号介入特性,其实就是一种特殊的解析。

符号解析过程中一些通用的规则我已经在前文总结了,可以先过目一下。

前面提到符号绑定除了决定可见性以外,还决定了(链接时的)行为:

符号绑定 链接行为
LOCAL 允许在不同的目标文件中,存在多个相同名字的符号,符号之间相互隔离(无关联)。
GLOBAL 只允许一个目标文件存在 GLOBAL 符号定义,其它目标文件允许相同名字的未定义的符号引用,即不允许多个定义。
WEAK 允许在不同的目标文件中存在多个相同名字的符号。具体来说,如果同时存在一个 GLOBAL 符号和其它同名 WEAK 符号的定义,那就挑选 GLOBAL 符号(ignores the weak ones);如果存在多个同名 WEAK 符号的定义,事实标准是挑选 linker 解析过程中接触到的第一个符号。

这些行为会影响到你的项目的模块化设计。……事实上规则没那么简单,我在下面附加了更多细节。

关于上面提的 WEAK 符号的行为,特意指出“事实标准”,那是因为它那里没有标准,只是现在的链接器都这么干。

WEAK 符号在动态库的行为是很复杂的:

  • 有观点指出 WEAK 符号的定义在动态库中并没有用处,链接器不会使用它的定义;但是也有观点解释现代链接器对 WEAKGLOBAL 的定义在动态库中是平等看待的,即都会使用。我测试过 ld.so 链接器行为符合后者说法。
  • 还有关于 WEAK 符号的引用行为:A weak reference in the dynamic symbol table does not cause a symbol lookup error if no definition is found.
  • 可以看下我的简单测试:DSO lookup

除此以外还有静态链接上的规则,当使用 -static 时需要注意:This is a lesser-known rule. When an ELF linker sees a weak reference, it does not extract an archive member to satisfy the weak reference. Please make sure the archive member is extracted due to other symbols.

% cat a.cc
#include <condition_variable>
int main() { std::condition_variable a; }
% g++ -static -pthread a.cc
% ./a.out
Segmentation fault

关于 GLOBAL 符号在动态库中的行为存在特例:它允许存在多个相同名字的符号定义(即每动态库各一个符号定义)。动态链接器在搜索符号的过程中只会挑选第一个出现的符号定义,其它的定义均被忽略。因此需要注意你的动态库的链接顺序(其实这就是一种 interposition),见 DEMO

另外前面提到的 -Bsymbolic,实际上是链接器给 DSO 打上 DF_SYMBOLIC 标记:If this flag is set in a shared object library, the dynamic linker’s symbol resolution algorithm for references within the library is changed. Instead of starting a symbol search with the executable file, the dynamic linker starts from the shared object itself. If the shared object fails to supply the referenced symbol, the dynamic linker then searches the executable file and other shared objects as usual.

还有符号解析的序列(search list)问题,前面提到“第一个出现的符号定义”,那么这些要符号定义是按什么顺序出现的?通常一个准则就是按指定库(及其依赖)的广度优先搜索的顺序。如果设 neededexecutable 需要的依赖,needed_of_needed 为依赖库的下一级依赖,那么符号解析的全局顺序是 (executable, needed0, needed1, needed2, needed0_of_needed0, needed1_of_needed0, ...),每一个符号引用只需要在序列中迭代直到匹配上首个定义即可。但有不少细节:

  • 如果使用了 LD_PRELOAD,那么新增的依赖将头插到 executable 之后,但是下一级依赖会拼接到序列后面,即 (executable, preload0, preload1, needed0, needed1, needed2, needed0_of_preload0, ..., needed0_of_needed0, needed1_of_needed0, ...)
  • 如果使用了 -Bsymbolic,如前面所述,通过链接器传递了 -Bsymbolic 的动态库不会被 LD_PRELOAD 提前介入,即内部优先查找库内的符号定义,如果不存在再按正常的顺序重新遍历。
  • 如果使用了 dlopen(),那需要区分 RTLD_GLOBAL 还是 RTLD_LOCAL(默认选项),前者解析的符号能在后续加载的动态库可见(仍是全局的 flat namespace),后者则不可见(local namespace)。

符号重定位

重定位(relocation)是一个从符号引用修正到符号定义所指向的虚拟地址的过程。编译过程中需要重定位的符号会存放在 .rel 或者 .rela 中。比如,.text 存在需要重定位的符号,那么会多一个名为 .rela.text 的 section。这样在链接器的视角来看,重定位的过程就是遍历 .rela 的过程。

AndroidLP64 下使用 .rela.rela 相比 .rel 是多了一个 addend。这个 addend 会在特定的重定位类型中起作用。

/* I have seen two different definitions of the Elf64_Rel and
   Elf64_Rela structures, so we'll leave them out until Novell (or
   whoever) gets their act together.  */
/* The following, at least, is used on Sparc v9, MIPS, and Alpha.  */

typedef struct
{
  Elf64_Addr    r_offset;               /* Address */
  Elf64_Xword   r_info;                 /* Relocation type and symbol index */
} Elf64_Rel;

/* Relocation table entry with addend (in section of type SHT_RELA).  */

typedef struct
{
  Elf64_Addr    r_offset;               /* Address */
  Elf64_Xword   r_info;                 /* Relocation type and symbol index */
  Elf64_Sxword  r_addend;               /* Addend */
} Elf64_Rela;

同样我在前文也做了重定位的总结。

这里补充一下为什么需要 PLTGOT 的机制,因为:

  1. 链接器不应该对 .text 原地修改,破坏只读会失去动态库的共享特性,所以需要 GOT
  2. 延迟绑定有助于程序的启动性能,以及避免大量无用功的过早绑定,所以需要 PLT

并且我也验证了 X86_64 PC32 重定位 \(S+A-P\) 的实际计算过程:Caturra000/Snippets/Linkage/relocate/simple_pc32 – Github

还有我也顺手查阅了安卓的 bionic (dynamic) linker 的重定位源码,确实是直接算出来的:Caturra000/RTFSC/bionic/linker – Github

重定位对于 dynamic linker 自身来说是很重要的,因为链接器自己也是一个 DSO,需要通过自身的重定位来完成自举。可以看这一段代码来了解其中的复杂问题。

上面的内容感兴趣就点进去看吧,这里不赘述。

思考一下,虽然重定位流程复杂,但是对于上层开发来说可操作的地方似乎并没有?其实还是有的。

前面(或者说是前文)提到 PLT 会延迟绑定,但这显然不是重定位所必须的。如果你很在乎首次函数执行的延迟,或者相信避免 PLT 会让编译器多出一个可利用的寄存器(某些体系结构下)以达到更好的性能优化,那可以使用编译器选项 -fno-plt,这样只会生成 GOT。除此以外,还可以选用链接器提供的环境变量 LD_BIND_NOW 做到装载时绑定。

还有说明一下 PLT/GOT 既是实现符号介入的关键,也是潜在的优化点,见下图:

plt-interposition

下文指出当不需要符号介入(比如把部分符号设为 HIDDEN 可见性)时,那就可以不创建对应的 PLT 条目来提高性能。

In the first example, there was not enough information to tell if the function would ever be able to be overridden, hence a PLT entry had to be created and the function called through it (disassemble it to see the details!). With correct symbol visibility attributes, there is enough information to know that common_but_not_part_of_api is never to be overridden, hence the PLT (and the associated costs of trampolining) can be avoided.

未初始化符号

我之前困惑于关于未初始化变量到底放 COMMON 还是放 .bss 的问题,直到我做了一些调研和测试:Caturra000/Snippets/Linkage/tentative – Github

总结是:别看了,不要把时间花在这种事情上。

C++ 的符号管理

C++ 可以通过关键字进行符号管理,比如:

  • extern
  • static
  • inline
  • template
  • namespace
  • [[attribute]]

g++-9g++-12 的环境中测试结果如下:

关键字 绑定 可见性 备注
extern - - UNDEFINED
static LOCAL DEFAULT  
inline - - NOT FOUND
UNSPECIFIED GLOBAL DEFAULT  
[[gnu::weak]] WEAK DEFAULT compiler-specific feature(s)

针对一般使用场合再补充几点说明:

  • 对于 template 一般来说就等同于 inline(注意特化的坑,此时不算入 inline),它本质上也是 WAEK 符号,但如果在编译单元内没有被引用到,那么会直接抹掉符号(光有定义是不够的)。
  • 普通的 namespace 并不改变符号的链接行为,最多影响了符号名字,也就是应用开发者比较熟悉的name manglingextern "C" 或者 inline namespace 同理)。
  • unnamed namespace可以认为是作用域内全部实体加上 static,即符号管理等同于 static
  • inline namespace如果是匿名的话,链接行为等同于 unnamed namespace,详见测试

其实 C++ 的符号管理也有自成体系的 name lookup 机制,但这是另一个(与 ELF 符号无关的)话题了。

除了这些一般情况,刁钻的角度可多了。比如在 function template 内提供 static local variable,后者符号绑定就成了 GNU 特有的 UNIQUE,据说是为了补 RTLD_LOCAL 的坑,真是见了鬼了(我还没算 TLS 下的情况呢)。

总结

本文梳理了一些没人愿意了解的 ELF 符号细节,但你肯定碰到过相关的问题,我随便说几个:

  • 不了解动态库下的符号特性导致错误的模块设计,还有符号被埋了。
  • pthread 由于用了 weak 符号而不适用于静态库,但链接器觉得这很正常。
  • 写模块没有写 static ,不过 review 没人发现
  • 成功 hook 了某个函数,结果递归爆栈。
  • 坚信未初始化变量的多重定义没有问题。
  • 认为 fpicfPIC 只是大小写区别。

虽然问题不是很大,但是也有可能是血案,因此该了解还是要了解的(所以上面有几个是我干的?)。

图文无关

按照惯例,加上一张内容无关的图:

higurashi

References

ELF-64 Object File Format
Oracle Solaris 11.4 Linkers and Libraries Guide
Symbol Interposition – Lancern@zhihu
A ToC of the 20 part linker essay – LWN.net
Why symbol visibility is good – technovelty
Weak Symbol – MaskRay
How To Write Shared Libraries – Ulrich Drepper
ELF interposition and -Bsymbolic – MaskRay
dlopen(3) – Linux manual page