Home ELF符号:复杂又麻烦的技术细节
Post
Cancel

ELF符号:复杂又麻烦的技术细节

前言

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

大纲

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

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

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

前置内容

这个话题属于编译链接过程,我在以前也整理过一些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

This post is licensed under CC BY 4.0 by the author.
Contents