转至元数据结尾
转至元数据起始

Dynamic Linker

动态链接器是在程序执行过程中对ELF二进制文件进行解释,将多个ELF文件组合成一个完整的程序镜像,也称解释器。

ELF

ELF(Executable and Linking Format)是linux用于存储二进制可执行文件的格式,有3种主要的二进制对象文件类型:

  1. 可重定位文件(relocatable file):存储代码和数据的二进制文件,如:C语言种编译生成的.o文件。可将这类文件链接成一个单独的可执行文件或者共享对象文件。

  2. 可执行文件(executable file):独立主程序可执行文件,即不依赖任何动态库的可执行文件。

  3. 共享对象文件(shared object file):存储代码和数据的二进制文件。存在两种上下文,分别为链接时上下文和运行时上下文,即生成共享对象文件和加载共享对象文件。首先,在链接时上下文阶段,链接编译器(link editor)将可重定位文件和共享对象文件链接生成的一个新的共享对象文件,即动态库;其次,在运行时上下文,动态链接器(dynamic linker)将其共享对象文件与可执行文件和其它共享对象文件组合成一个程序镜像。

Object File Format

对象文件参与的程序的链接(生成程序)和执行(运行程序),两个阶段的文件格式有差异。Linking view为生成程序阶段的视图,Executing view为运行程序阶段的视图

image-20240218-070844.png

对于可重定位文件来说,只存在于程序链接阶段,格式中没有程序头表,但需要分节头表。对于可执行程序来说,只存在于执行阶段,只需要程序头表。对于共享对象文件。存在于程序链接和执行阶段,需要程序头表和分节头表。

ELF Header

ELF头用于识别ELF可执行格式。

image-20240218-071231.png

Field

Name

Value

Meaning

e_type

ET_NONE

0

No file type

ET_REL

1

Relocatable file

ET_EXEC

2

Executable file

ET_DYN

3

Shared object file

ET_CORE

4

Core file

e_ident[EI_CLASS]

ELFCLASSNONE

0

Invalid class

ELFCLASS32

1

32-bit objects

ELFCLASS64

2

64-bit objects

e_ident[EI_DATA]

ELFDATANONE

 Invalid data encoding

ELFDATA2LSB

little endian

ELFDATA2MSB

big endian

共有3种类型的ELF,分别为.o文件,共享对象文件(.so文件和包含main的二进制文件)和静态链接的可执行文件。
以一个简单的C程序为例:

int main(int argc, char argv) { return 0; } / main.c */

可重定位文件的ELF头

其类型(Type)为REL,程序头的数目为0,我们在后续不讨论该类型

$ gcc -c main.c /* generate main.o */

$ readelf -h main.o

image-20240218-071523.png

可执行程序的ELF头

可执行程序的类型为EXEC,其入口地址为绝对虚拟地址。各代码段和数据段的地址已经确认,编译器对这种情况进行编译优化,减少间接寻址代码。(注:若在编译时指定-fPIE选项,以及打开ASLR,每次加载程序的入口虚拟地址随机变化)

$ gcc -static -o main-static main.o
$ readelf -h main-static

image-20240218-071543.png

共享对象文件的ELF头

$ gcc -o main-dynamic main.o
$ readelf -h main-dynamic

image-20240218-071620.png

分节

image-20240218-071640.png

Field

Name

Value

Meaning

sh_type

SHT_NULL

0

SHT_PROGBITS

1

程序比特流,程序运行时需要的数据

SHT_SYMTAB

2

符号表,协助调试

SHT_STRTAB

3

字符串表

SHT_RELA

4

新版重定位表

SHT_HASH

5

哈希表,用于符号比较查找

SHT_DYNAMIC

6

动态表

SHT_NOTE

7

注释信息

SHT_NOBITS

8

不占文件空间,但需内存空间,如.bss

SHT_REL

9

旧版重定位表

SHT_SHLIB

10

预留

SHT_DYNSYM

11

符号表,需重定位符号,导出符号等

SHT_INIT_ARRAY

14

Array of constructors

SHT_FINI_ARRAY

15

Array of destructors

SHT_PREINIT_ARRAY

16

Array of pre-constructors

SHT_LOOS

0x60000000

Start OS-specific

SHT_HIOS

0x6fffffff

End OS-specific type

SHT_LOPROC

0x70000000

Start of processor-specific

SHT_HIPROC

0x7fffffff

End of processor-specific

SHT_LOUSER

0x80000000

Start of application-specific

SHT_HIUSER

0x8fffffff

End of application-specific

sh_flags

SHF_WRITE

0x1

Writable

SHF_ALLOC

0x2

Occupies memory during execution

SHF_EXECINSTR

0x4

Executable

SHF_TLS

0x400

Section hold thread-local data

SHF_MASKOS

0x0ff00000

OS-specific

SHF_MASKPROC

0xf0000000

Processor-specific

$ readelf -W -S main-dynamic 

image-20240218-071716.png

Dynamic表

typedef struct {
Elf64_Sxwordd_tag; /* Dynamic entry type */
Union {
Elf64_Xword d_val; /* Integer value */
Elf64_Addr d_ptr; /* Address value */
} d_un;
} Elf64_Dyn;
Elf64_Dyn _DYNAMIC[];
$ readelf -W -d main-dynamic
image-20240218-071745.png
  • 依赖库:NEEDED类型,其值表示在字符串分节中的偏移。每项表示一个依赖库。glib静态链接器只对直接依赖库进行记录;而llvm静态链接对直接和间接依赖库进行记录。

  • 初始化和结束代码:INIT/FINI,其值表示虚拟地址,代码所在的位置或者为函数符号地址,具体含义与具体的CPU架构实现相关,只有一个。

  • 初始化和结束代码链表:INIT_ARRAY/ FINI_ARRAY,其值表示虚拟地址,数组的起始地址,INIT_ARRAYSZ/ FINI_ARRAYSZ,其值表示数组的长度,数组每个元素表示一个函数符号地址,类型为Elf64_Addr

  • 符号哈希表:GNU_HASH,其值表示哈希表的起始虚拟地址。哈希表主要用于符号字符串的查找。主要表示.gnu.hash分节的内容

  • 动态字符串表:STRTAB,其值表示字符串表的起始虚拟地址,一般为.dynstr分节的虚拟地址;STRSZ,符号表的长度。

  • 动态符号表:SYMTAB:其值表示符号表的起始虚拟地址,一般为.dynsym分节的虚拟地址;SYMENT,表示符号表项大小。

  • PLT重定位表:JMPREL,其值PLT表的起始虚拟地址;PLTRELSZ,其值表示PLT表的大小; RELAENT,表示重定位表项大小;PLTREL,其值表示重定位类型,示例类型为RELA。主要表示.rela.plt分节内容,针对过程链接表的重定位。

  • 符号重定位表:RELA,其值表示重定位表的起始虚拟地址;RELASZ,表示重定位表的大小;共用RELAENT,PLTREL。主要表示.rela.dyn分节内容。

  • 全局偏移表:PLTGOT,其值为全局偏移表的起始虚拟地址。

  • 动态符号版本表:VERSYM,其值为动态符号版本表的起始虚拟地址,其数量与动态符号的数量一致,且一一对应,用于描述每个动态符号的版本。其表项为2字节,表示版本字符串在动态字符串表的偏移。主要表示.gnu.version的内容。

  • 依赖的动态符号版本表:VERNEED,其值表示版本符号表的起始虚拟地址;VERNEEDNUM,其值表示版本符号的数量。每个表项的类型为Elf64_Syn。主要表示.gnu.version_r分节的内容(readelf -W -V main-dynamic)。内容含义为未定义的函数所依赖的版本号,包括依赖的动态库,依赖动态库中的函数版本号列表等,每个依赖库作为一个表项。

  • 组合重定位统计:RELACOUNT,其值为重定位组合在一起的数量,主要用一个统计指标,表示编译器优化生成的相对重定位的数量。

注:

  • 导出的动态符号版本表:VERDEF,其值为导出动态符号表的起始虚拟地址,VERDEFNUM,表项数量。主要表示.gnu.version_d分节内容。描述所有导出函数的符号版本集

动态符号表

动态符号表描述定义和引用的全局变量符号,函数等。

typedef struct {
Elf64_Wordst_name; /* Symbol name (string tbl index) */
unsigned charst_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Sectionst_shndx; /* Section index */
Elf64_Addrst_value; /* Symbol value */
Elf64_Xwordst_size; /* Symbol size */
} Elf64_Sym;
  • st_name:符号名,值为动态字符串表的索引

  • st_info:分两部分,高4位表示符号绑定方式(ELF64_ST_BIND),低4位表示符号类型(ELF64_ST_TYPE)。其定义见下表。

  • st_shndx:分节索引,其中包含特殊的分节索引(SHN_UNDEF/ SHN_ABS/ SHN_COMMON)。

  • st_value:与符号关联的值,描述符号所在的位置,可能是绝对值,相对虚拟地址等

    • 可重定位ELF:值表示在st_shndex指向的分节中的偏移,即符号做在分区的位置,比如:函数定义在.text代码分节中,该值指向该函数在.text中的偏移;若st_shndex为SHN_COMMON,表示符号的对齐字节数,该符号未分配空间,无具体位置

    • 共享对象或可执行程序ELF:值表示虚拟地址,指向符号所在位置的相对虚拟地址,一般不需要参照st_shndx

    • SHN_UNDEF:表示符号未定义,值为0

    • SHN_ABS:表示值为绝对虚拟地址

  • st_size:符号代表的数据大小。若为函数,则表示函数二进制机器指令大小。

Field

Name

Value

Meaning

ELF64_ST_BIND

STB_LOCAL

0

文件域全局符号,作用域在可重定位文件中

STB_GLOBAL

1

全局符号,作用域为整个程序

STB_WEAK

2

全局符号,不同共享对象文件中可重复定义

ELF64_ST_TYPE

STT_NOTYPE

0

未指定类型

STT_OBJECT

1

数据对象,如:变量、数组等

STT_FUNC

2

函数或者可执行代码段(如:汇编代码)

STT_SECTION

3

与分节关联的符号,主要跟重定位相关

STT_FILE

4

文件符号

注:符号查找顺序为STB_LOCAL-> STB_GLOBAL->STB_WEAK。

重定位表

可重定位表用于描述如何计算符号值,重定位的位置等。重定位表依赖于两个表,一个符号表和待修改的分节。

typedef struct{
Elf64_Addrr_offset; /* Address */
Elf64_Xwordr_info; /* Relocation type and symbol index */
Elf64_Sxwordr_addend; /* Addend */
} Elf64_Rela;
  • r_offset:需要重定位修改的位置。

    • 可重定位ELF:表示待修改分节中的偏移。

    • 可执行程序和共享对象ELF:待修改的相对虚拟地址。

  • r_info:低32位表示重定位类型,不同的类型计算符号值的方式不同,高32位表示符号表索引。

  • r_addend:一个加数(偏移),有助于减少符号数量,例如:相邻连续的一些符号可用一个符号加上偏移表示;或者对没有符号的具体位置进行重定位,该加数相当于相对虚拟地址。

全局偏移表

对于位置无关代码(PIC)来说,代码段中不能包含绝对地址。全局偏移表(GOT)包含绝对地址。代码段对绝对地址的访问可转换为相应GOT表项的相对地址的间接寻址访问,从而通过GOT表可将绝地地址访问转换为相对地址访问。在程序加载过程中,需要对每个共享对象中GOT表进行初始化。

extern Elf64_Addr GLOBAL_OFFSET_TABLE[];

程序段

程序段是将属性相同的分节按序整合在一个段中。

image-20240218-072218.png

Field

Name

Value

Meaning

p_type

PT_NULL

0

Program header table entry unused

PT_LOAD

1

Loadable program segment

PT_DYNAMIC

2

Dynamic linking information

PT_INTERP

3

Program interpreter

PT_NOTE

4

Auxiliary information

PT_SHLIB

5

Reserved

PT_PHDR

6

Entry for header table itself

PT_TLS

7

Thread-local storage segment

PT_LOOS

0x60000000

Start of OS-specific

PT_HIOS

0x6fffffff

End of OS-specific

PT_LOPROC

0x70000000

Start of processor-specific

PT_HIPROC

0x7fffffff

End of processor-specific

p_flags

PF_X

0x1

Segment is executable

PF_W

0x2

Segment is writable

PF_R

0x4

Segment is readable

PF_MASKOS

0x0ff00000

OS-specific

PF_MASKPROC

0xf0000000

Processor-specific

image-20240218-072237.png

从程序头的信息可知,由于段有对齐的要求,段间的内容可能有重叠。LOAD段必须按虚拟地址顺序排列,且地址相邻,其基本顺序为代码段,只读数据段,可读写数据段。这是相对寻址的规范要求。
INTERP代表动态链接器,由该链接器初始化动态库信息,包括符号地址的初始化,构建一个完整的可执行程序镜像。
LOAD类型的段需要加载至内存,程序执行时需要用到的代码和数据,其中一个段为代码段,有的段为只读的数据段,有的为可读写的数据段,有的为未初始化的数据段。一般来说未初始化的数据段和可读写的有初始化值的数据段整合至一个可读写数据段内。
DYNAMIC描述动态加载时需要的所有数据,包括符号表,可重定位表,依赖库,符号版本信息表,初始化代码段,结束代码段,初始胡数组数据段,结束数据数据段等。与可读写数据段重叠。
其他段主要在程序加载过程中做初始化使用,初始化完成后,可以丢弃。
GNU_EH_FRAME表示异常处理段,与只读数据段重叠。
GNU_RELRO表示该段在加载初始化阶段重定位后数据无需在修改,在运行阶段可只读,由于数据量少,从段信息可以看到,其与可读写数据段重叠。
在ELF头中程序入口地址为0x610,其在可执行代码段(0x0000 ~ 0x01f8)内。

程序的执行

程序运行前,需要对程序的镜像进行初始化,包括加载依赖的共享对象,符号重定位等。对于初始化时加载共享对象的行为,称之静态加载;对于程序运行时加载共享对象的行为,称之动态加载,如:使用dlopen加载共享对象。

加载程序

调用系统调用exec(exec[l,lp,le,v,vp,vpe])加载可执行程序,exec解析程序的ELF格式数据,并将可执行程序的LOAD段映射至内存中,找到解释器,并将解释器的LOAD段映射至内存中,执行解释器代码,入口为_start,链接解释器执行如下操作:

  1. 根据DYNAMIC段信息,解释器初始化自身数据,包括符号重定位,函数符号重定位,以及执行初始化代码等

  2. 程序代码重定位

    1. 根据程序的DYNAMIC段的NEEDED信息,按广度优先加载依赖的动态库,依次将库文件内容映射至内存。

    2. 按照依赖的逆序对动态库重定位

      1. 读取DYNAMIC段内容,初始化重定位需要的数据

      2. 根据可重定位表每个表项的内容进行重定位,对GOT等进行初始化。对于未定义的符号,从依赖库中查找符号的虚拟地址。

    3. 根据程序的重定位表进行重定位,完成符号的初始化

  3. 依赖逆序调用.init/.init_array指向的代码,对动态库和程序进行初始化。

  4. 调用程序的入口函数_start,运行程序。

链接解释器需要对每个动态库进行解释,依赖的段或分节主要有:

  • Relocation Table: 包含.rela.dyn和.rela.plt两个分节,

  • Procedure Linkage Table:包含.plt和.plt.got

  • Symbol Table:包含.dynsym描述全局符号信息,字符串内容保存在.dynstr(String Table)分节中。

  • Global Offset Table:包含.got和.got.plt两个分节,.got主要记录全局的变量符号虚拟地址,.got.plt主要记录全局的函数符号虚拟地址

  • .init/.init_array和.fini/.fini_array:对动态库运行前的初始化和退出前的处理。

  • DYNAMIC段:描述以上分节的位置,具体内容见下表。

$ readelf -W -d main-dynamic

Dynamic section at offset 0x2e00 contains 24 entries:
Tag Type Name/Value

0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x1140
0x0000000000000019 (INIT_ARRAY) 0x3df0
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3df8
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3b0
0x0000000000000005 (STRTAB) 0x468
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000000a (STRSZ) 136 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fc0
0x0000000000000007 (RELA) 0x530
0x0000000000000008 (RELASZ) 192 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW PIE
0x000000006ffffffe (VERNEED) 0x500
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x4f0
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0

https://refspecs.linuxfoundation.org/elf/elf.pdf

重定位类型

GLOB_DAT重定位

该类型主要用于解析有符号变量的绝对虚拟地址,一般包括变量符号和已定义的函数符号。重定位的位置指向与符号对应的GOT表项。根据GLOB_DAT指定的符号,从符号表中找到符号的地址从而计算绝对虚拟地址。对于在共享对象中定义的符号,符号表中存储了符号的相对地址,其与共享对象文件映射的起始虚拟地址之和,即为符号重定位的值;若无定义,则从其它共享对象中查找该符号,得到的地址即为符号重定位值。

JMP_SLOT重定位

该类型涉及函数符号重定位,实现了延迟符号解析功能。函数调用涉及解析函数符号和执行函数。链接编辑器采用过程链接表(PLT)来实现对函数的执行控制。
PLT表项内容为16字节的可执行代码,该代码间接寻址调用对应GOT表项指向的绝对虚拟地址。每一个导出的函数都存在一个PLT表项。JMP_SLOT重定位类型填充GOT表项绝对地址,方式与GLOB_DAT类似。
Glibc对共享对象进行重定位前对GOT的特殊表项进行初始化,不同的CPU架构实现有差异,其实现函数名为elf_machine_runtime_setup,初始化GOT[1]为共享对象地址,GOT[2]为_dl_runtime_resolve函数,用于对符号进行延迟解析。
JMP_SLOT重定位有两种方式,分别为加载时符号解析和延迟符号解析

  • 加载时解析:从其它共享对象文件中查找符号定义,将符号值写入GOT表项中。(elf_machine_rela)

  • 延迟符号解析:将共享对象加载虚拟地址与GOT表项值相加写入该表项中,即将相对虚拟地址改为绝对虚拟地址。(elf_machine_lazy_rel)

为了实现延迟重定位,将PLT[0]作为一个特殊的表项,该表项的内容跳转至GOT[2]指向的函数,调用_dl_runtime_resolve对符号进行解析,然后将符号绝对虚拟地址写入GOT表项中。在arm64架构中,无法用16字节实现该功能,其使用了两个表项,PLT[0]和PLT[1]作为特殊表项。
延迟符号解析流程:

  1. 通过bl等调用指令相对寻址PLT表项

  2. PLT表项代码间接寻址跳转至GOT表项表示的虚拟地址

  3. GOT表项虚拟地址指向PLT0,因此直接跳转至PLT0

  4. PLT[0]代码解析符号,并将符号地址写入GOT表项中

  5. 跳转至GOT表项指向的函数

当函数符号解析后,再次调用该时,执行至第3步即可。
以main-dynamic的__cxa_finalize为例。其对应的PLT[3]地址为0x10fa0,其值为0x5b0,指向PLT[0]的地址。PLT[3]的相对虚拟地址在共享对象加载时更改为绝对虚拟地址。

  1. 前3个PLT表项,PLT[0],PLT[1]作为一个代码段,PLT[2]调用__cxa_finalize。其中x16寄存器保存重定位地址,x17寄存器保存重定位地址的值,如GOT[3]的地址,在PLT[0]代码段中GOT[3]的地址压栈至栈顶,x16为GOT[2]的地址,x17为GOT[2]的值,即_dl_runtime_resolve函数地址,在该函数中,可通过x16找到GOT[1]的地址和值。
    $ objdump -dS -j .plt main-dynamic

  2. 调用__cxa_finalize的指令
    $ objdump --disassemble=__do_global_dtors_aux main-dynamic

image-20240218-072835.png
  1. PLT重定位
    $ readelf -W -r main-dynamic

image-20240218-072922.png
  1. 显示.got内容,可知0x10fa0的值为0x5b0,指向PLT[0]

$ readelf -W -x .got main-dynamic

image-20240218-072948.png

RELATIVE重定位

该类型用于对非符号的当前共享对象的指定位置进行重定位。r_offset指定重定位的地址,该地址内容重定位后,其值为r_addend与共享对象文件映射至内存的起始虚拟地址之和。比如.init_array分节,可将初始化函数的相对虚拟地址转换为绝对虚拟地址。

IRELATIVE重定位

GNUC支持间接函数(IFUNC)功能,提供一个解析函数,根据运行环境选择一个实现函数。解析函数不接收参数,返回值为函数地址;实现函数的接口必须一致,并通过_attribute_ ((ifunc ("resolve ")))修饰,resolve为解析函数的函数名。
GNUC提供两种实现方式:

  1. IRELATIVE重定位:一般为在共享对象内部定义和引用的IFUNC,无符号表项,在加载共享对象时解析,不支持延迟符号解析。

  2. JMP_SLOT重定位 + STT_GNU_IFUNC符号表项:在定义IFUNC的共享对象中生成STT_GNU_IFUNC符号表项,符号表项的值指向解析函数。在引用IFNUC的共享对象中生成JMP_SLOT重定位表项。支持延迟符号解析。

调用符号表项或者IRELATIVE重定位表项指向的解析函数地址,其放回的值即为符号地址。(elf_machine_rela)
另一种嵌入式汇编申明方式。

_asm_ (".type " sym ", %gnu_indirect_function");
typeof(sym) sym_resolver(){ /* do something */ return NULL; }

参考《System V ABI for the Arm® 64-bit Architecture》7 Code Models

符号查找

静态链接生成共享对象时,生成了重定位表,符号表,过程链接表,字符串表,字符串哈希表等,这些表生成后不再被修改,另外还生成了全局偏移表,该表在重定位时进行修改。符号可分为未定义函数符号和其它符号,共享对象中用不同的分节表来描述这两类符号,相同类型的表排列在连续空间中。每一个共享对象都有各自的与符号相关的分节。

  • 与未定义函数符号相关的分节:.rela.plt, .dynsym, .got.plt, .dynstr, .gnu.hash, .plt等

  • 与其它符号相关的分节:.rela.dyn, .dynsym, .got, .dynstr, .gnu.hash等

加载共享对象时,先对其它符号进行重定位(.rela.dyn),对于已定义的符号(包括变量符号和函数符号),根据.dynsym符号表对应符号值和共享对象加载起始地址相加得到。对于未定义的符号(变量符号),按照某种共享对象的依赖关系顺序(广度优先顺序),从主程序共享对象开始遍历,符号找到后终止遍历。得到的符号值更新全局偏移表项。
函数符号在共享对象加载中,一般只初始化GOT[1], GOT[2]的表项值,分别为表示共享对象的指针和符号解析函数。该解析函数在程序运行过程中,动态解析函数符号,而不需在加载过程中解析函数符号。

TLS

线程本地存储(Thread Local Storage)变量指每个线程有独立的存储,进程内不共享。对于TLS变量来说,不同的线程指向不同的存储空间。
在高级编程语言中,定义申请TLS(Thread Local Storage)变量在多线程环境下,线程间对TLS变量的修改互不影响,像普通变量一个在赋值语句中读写访问。
除了主线程外,其它线程都在程序运行时创建和销毁,在加载共享对象文件时,无法确认合适创建线程,创建多少线程等信息。因此对TLS变量libc需要进行动态管理。由于不同线程访问TLS变量的地址空间不一样,libc需要管理TLS变量的地址空间,需要动态获取TLS变量地址。
Libc实现TLS变量功能的基本原理

  • 在静态链接生成共享对象文件时

    • 将所有的TLS变量存放一个连续的TLS程序段中,包括有初始值和无初始值的TLS变量。定义的TLS变量在该共享对象的TLS程序段中的偏移是固定的,未知的是该TLS程序段的起始地址。一般来说,有初始值的TLS变量存储在TLS程序段的头部,无初始值的TLS变量存储在TLS程序段的尾部。在编程过程中,TLS变量都是可读写变量,因此TLS程序段具有读写权限。

    • 对每个TLS变量生成一组重定位记录,记录TLS变量所在的共享对象编号和TLS变量与tp的相对偏移。这些值在共享对象加载时计算,存储在GOT表中。

  • 创建线程时,libc需要对每个共享对象的TLS程序段的空间创建一个TLS副本,并获得共享对象每个TLS程序段的起始地址,后续当前线程对TLS变量的操作在副本上进行;销毁线程时,需要对TLS副本进行回收。因此,需要对TLS副本地址空间进行动态管理。

  • 通过dlopen动态加载共享对象。需要对已有线程的TLS副本中增加该TLS程序段的一个TLS副本;通过dlclose动态加载共享对象时,需要对已有线程的TLS副本中删除该TLS程序段的TLS副本。因此,需要提供动态插入/删除TLS程序段副本的功能。

  • 访问TLS变量:Libc通过一个动态查找TLS变量方式实现同一代码段在不同的线程上下文对TLS变量的访问不同的地址空间。在访问TLS变量前,通过一个调用指令调用解析TLS变量地址的函数。一般方式是:通过CPU架构的编程约定,使用一个专用段寄存器指向当前线程的各共享对象的TLS程序段的起始位置的链表;通过查找TLS变量所在共享对象的TLS程序段,根据TLS程序段的起始位置和TLS变量在TLS程序段的偏移获得其在当前线程的地址空间。

Libc根据共享对象不同的加载方式对内存布局进行优化:

  • 程序启动时加载共享对象:这种方式加载的共享对象不会在程序过程中进行卸载,因此称之静态加载。在程序加载过程中,将所有静态加载共享对象TLS程序段有序的放在一段连续地址空间中,这些地址空间称之为静态TLS块,后续通过重定位,计算得到TLS变量相对于静态TLS块的偏移。在程序运行过程中,通过静态TLS块的偏移以及静态TLS块的起始地址即可得到TLS变量的位置。

  • 程序运行时加载共享对象:这种方式加载的共享对象在程序执行过程中可能被卸载,一般通过dlopen加载,dlclose卸载;也可能被多次加载(比如:不同共享对象通过dlopen加载另一个相同的共享对象),因此称之为动态加载。这些共享对象的TLS程序段的数据存放在分离的TLS地址空间中,称之为动态TLS块。一般,使用一个数组,数组元素指向这些动态TLS块。这些TLS变量通过查找共享对象在数组中的位置找到动态TLS块的起始位置,加上TLS变量在该TLS程序段的偏移获得。

TLS数据结构

Drepper根据静态加载和动态加载共享对象的不同场景提供了内存布局的两种实现。基本原理如下:

  1. 静态链接生成共享对象时,使用专用段寄存器

  2. 每个共享对象TLS程序段都有一个TLS副本,存储在不同的地址空间。

  3. 所有静态加载的TLS程序段排列在一段连续的地址空间中,称之为静态TLS块。

  4. 定义一个dtv数组,记录每个共享对象TLS程序段起始地址的相对偏移。

  5. 将dtv数组指针(虚拟地址)和静态TLS块放在一个连续的地址空间中,定义一个线程指针tp,指向这块连续地址空间。线程指针存储在专用寄存器中。对于静态TLS块上TLS变量的访问,通过tp于静态TLS块中的偏移获得;对于动态TLS块上TLS变量的访问,通过tp找到dtv数组,根据TLS变量所在的TLS程序段获得起始地址,再加上TLS变量偏移获得。

  6. 延迟处理动态TLS块:使用dlopen动态加载共享对象时,libc不会分配TLS块,在当前线程访问TLS变量时,才进行动态TLS块的分配,对dtv数组进行重分配和初始化该动态TLS块的偏移。这种优化可减少未使用这些TLS变量的线程处理dtv数组和TLS块的开销,一般场景来说,动态加载的共享对象只在某些线程上下文中访问。dtv数组中增加一个generation的元素识别当前进程上下文是否存在新的加载共享对象。若存在,在重新分配dtv数组,若共享对象的动态TLS块不存在,则分配动态TLS块,构建TLS程序段副本。一般采用一个全局的generation,每个线程上下文的generation与其是否相等来判断。

变种1

每个一个线程都有一个tp(Thread Pointer)指针,该指针按照不同CPU架构规范,由内核存储在特定寄存器中,如:aarch64架构为 tpidr_el0,x86_64为%fs。

image-20240218-073201.png

每一个tp指向一个static TLS block块,需16字节对齐,该块的前16个字节用做TCB(Thread Contorl Block),其中前8个字节指向dtv(Dynamic Thread Vector)数组。Dtv数组中每个表项指向线程的TLS起始地址,TLS起始地址(或tlsoffset)需16字节对齐。对于使用dlopen等程序运行过程中加载的共享对象文件,都需为每个线程创建一个新的TLS block,作为其TLS程序段的副本,增加dtv数组大小,新增表项指向新的TLS block。
TLS变量地址可根据tp,dtv索引,以及变量在TLS程序段的偏移等计算得到。
对于通过dlopen等在程序运行过程中加载的共享对象文件,动态创建TLS block内存,并从TLS程序段中将内容复制至TLS block中。
Dtv数组中第一个表项表示generation,用于识别是否新的共享对象文件加载。一般有一个全局的generation递增计数器,每个线程的dtv generation与全局比较,若有变化,线程将更新dtv的内容。
下图为aarch64的内存布局,这些数据都分配在栈的高地址。数据结构tcbhead_t作为TCB,其tcbhead_t.dtv指向dtv数组,地址加载在专用段寄存器tpdir_el0中。dtv表项为16字节,第一个8字节指向对齐的虚拟地址,第二个指向原始的虚拟地址。

image-20240218-073212.png

该方式基本概念与变种1相同,对于static TLS block,其TLS程序段存放顺序与共享对象的索引相反,其TCB放在static TLS block的末端。X86_64使用该方案,TCB中存储struct pthread数据,嵌套的tcbhead_t数据结构用于处理TLS,dtv数组中,用dtv[-1]保存数组大小(count),将tcbhead_t.tcb的地址加载至专用段寄存器%fs中。dtv表项为16字节,第一个8字节指向对齐的虚拟地址,第二个指向原始的虚拟地址。

变种2

该方式基本概念与变种1相同,对于static TLS block,其TLS程序段存放顺序与共享对象的索引相反,其TCB放在static TLS block的末端。X86_64使用该方案,TCB中存储struct pthread数据,嵌套的tcbhead_t数据结构用于处理TLS,dtv数组中,用dtv[-1]保存数组大小(count),将tcbhead_t.tcb的地址加载至专用段寄存器%fs中。dtv表项为16字节,第一个8字节指向对齐的虚拟地址,第二个指向原始的虚拟地址。

注:x86_64中tcbhead_t的定义与aarch64的不一样,与CPU架构相关。

       可参见ELF Handling For Thread-Local Storage (akkadia.org)

Bionic变种

image-20240218-073820.png

Bionic P版的实现与ABI有差异,其TCB块为一个数组,bionic_tls指向TLS块。Bionic android13实现有较大差别,两个版本不兼容。Android13定义了TLS_SLOT_DTV代替bionic_tls指向TLS块,结构如下图。

image-20240218-074000.png

TLS访问模式

在drepper paper中,描述了4种访问模式,分别为通用动态访问(Generic Dynamic)、局部动态访问(Local Dynamic)、初始执行访问(Initial Exec)和局部执行访问(Local Exec)。后面3种访问模式是针对特定场景对通用动态访问的优化,顺序越后的访问模式应用的场景越少。
4种访问模式对应不同的应用场景。

  • Generic Dynamic:任何位置可引用TLS变量,这些TLS变量都需要符号化,通过符号查找TLS变量的地址,每个符号还需要一组重定位记录。比如:动态加载共享对象的TLS变量,其它共享对象也要引用这些TLS变量时,需要使用GD访问方式。这种访问方式在动态加载后才能计算得到TLS程序段相对于tp的偏移。

  • Local Dynamic:程序运行时使用dlopen加载共享对象的TLS变量,只在本对象中引用的TLS变量,使用LD访问方式。由于外部不会引用这些TLS变量,其变量不需要符号化,在静态链接生成共享对象时,指定相对TLS程序段相对偏移即可,只需要一条TLS程序段的重定位记录(module id)即可找到所有的TLS变量的地址。

  • Initial Exec:程序初始化时直接加载的共享对象定义的TLS变量,每个TLS变量有一条重定位记录,在程序加载时将记录计算相对于tp的偏移结果,使用专用段寄存器加上偏移(变量)即可得到当前线程TLS变量的地址。使用IE访问方式的共享对象,不能使用dlopen动态加载。

  • Local Exec:主程序定义的TLS变量(包含main函数),主程序的TLS程序段放在静态TLS块中的第一个,与tp相对偏移在加载前已确定,静态链接生成共享对象时,对TLS变量访问直接通过专用段寄存器和立即数偏移(常量)寻址。

在编译时,可使用编译参数-ftls-mode指定访问模式,其值包括:global-dynamic、local-dynamic、initial-exec和local-exec。
以定义两个TLS变量为例,说明各访问模式的差异,代码如下

#include <stdio.h>
#include <threads.h>

__thread int var = 0;
__thread int var2 = 2;

int tls() {
var = 2;
var2 += 2;
return 0;
}

int main() {
tls();
printf("call d1 var %d, var2 %d\n", var, var2);
return 0;
}

Generic Dynamic

typedef struct dl_tls_index{
unsigned long int ti_module;
unsigned long int ti_offset;
} tls_index;

extern void *__tls_get_addr (tls_index *ti)

该方式在程序编译时,在引用TLS变量时,使用__tls_get_addr函数获得其地址。该函数接收两个参数,分别是共享对象编号和相对于tp的偏移值。

$ gcc -O3 -shared -fPIC -mtls-dialect=trad -ftls-model=global-dynamic -o tls-gd.so tls.c
重定位表,var, var2重定位位置为GOT表,每个变量占用两个表项,分别为编号和偏移值。
$ readelf -W -r tls-gd.so

image-20240218-074119.png

从以下汇编代码看出,访问var, var2变量时,分别调用__tls_get_addr获取,参数地址为var和var2在GOT表项的起始地址,如:0x10fc8和0x10fb0所在地址。
$ objdump -j .text --disassemble=tls tls-gd.so

image-20240218-074131.png

Local Dynamic

Arm64 glibc的Local Dynamic和Generic Dynamic的编译结果一致,与设计不符,可能是其默认使用TLS Descriptors访问模式,不使用Generic Dynamic和Local Dynamic方式的缘故。X86_64的Local Dynamic和Generic Dynamic的结果有显著差距,只使用__tls_get_addr获取tp的地址,然后对TLS变量使用偏移量来获取,这是设计一致。可参见附录的《X86_64 TLS访问模型》章节。
另外对于作用域在共享对象内部的TLS变量,可使用Local Dynamic方式优化。

$ gcc -O3 -shared -fPIC -mtls-dialect=trad -ftls-model=local-dynamic -o tls-ld.so tls.c

$ readelf -W -r tls-ld.so

$ objdump -j .text --disassemble=tls tls-ld.so

Initial Exec

与Local Dynamic相比,去掉了对__tls_get_addr的函数调用,直接从偏移获取TLS变量值。由于无法提前获知共享对象在程序执行时的加载顺序,也即无法确切其与tp的偏移量,因此每个TLS变量都需要重定位,加载时复制给GOT表项。

$ gcc -O3 -shared -fPIC -mtls-dialect=trad -ftls-model=initial-exec -o tls-ie.so tls.c
var和var2存在一个R_X86_64_TPREL64重定位项,用于在加载过程中,计算变量与tp的偏移量。
$ readelf -W -r tls-ie.so

image-20240218-074212.png

var的偏移量保存在GOT表项的0x10fd0位置,var2的偏移量保存在GOT表项的0x10fc0。tp地址保存在%fs寄存器中。

$ objdump -j .text --disassemble=tls tls-ie.so

image-20240218-074233.png

Local Exec

动态库不支持Local Exec访问方式,只支持包含main函数的程序。

$ gcc -O3 -fPIC -mtls-dialect=trad -ftls-model=initial-exec -o tls-ie tls.c
从重定位表中,没有__tls_get_addr的重定位,也没有TLS变量的重定位。其使用段寄存器和TLS变量在TLS程序段的偏移进行寻址。
$ readelf -W -r tls-le

image-20240218-074256.png

从汇编中看出,var和var2在TLS程序段的偏移增加了0x10,这是aarch64的glibc使用变种1 TLS数据结构的原因。
$ objdump -j .text --disassemble=tls tls-le

image-20240218-074312.png

TLS函数实现

typedef struct dl_tls_index{
uint64_t ti_module;
uint64_t ti_offset;
} tls_index;

void *__tls_get_addr (tls_index *ti)

程序调用__tls_get_addr访问TLS变量,参数从GOT表中获得,在重定位过程中初始化了ti_module和ti_offset,ti_offset为变量在TLS程序段中的偏移。使用3个generation判断共享对象是否有更新。

  • Global.generation: 每次加载一个共享对象,原子递增一,作为一个参考generation。

  • Module.generation:加载共享对象时,当前global.generation值,暂缓在module.generation中。

  • Dtv.generation:当前线程处理动态TLS的最大generation。

    • Dtv.generation != global.generation:表示有新加载的共享对象。

    • Dtv.generation < module.generation:表示还未处理该共享对象的TLS块,或者说该共享对象是新加载的。

    • Dtv.generation < other_module.generation <= module.generation:表示other_module共享对象的TLS未被处理。glibc重用module id,该条件用于检测释放旧module的TLS块内存。

image-20240218-074402.png

TLS Descriptros

/* Type used to represent a TLS descriptor in the GOT. */
struct tlsdesc {
ptrdiff_t (*entry) (struct tlsdesc *);
void *arg;
};

/* Type used as the argument in a TLS descriptor for a symbol that needs dynamic TLS offsets. */
struct tlsdesc_dynamic_arg {
tls_index tlsinfo;
size_t gen_count;
};

extern ptrdiff_t _dl_tlsdesc_dynamic (struct tlsdesc *);

ptrdiff_t _dl_tlsdesc_return (struct tlsdesc *td){
return (ptrdiff_t)td->arg;
}

TLS Descriptors访问方式针对Generic Dynamic的优化。其对每个TLS变量构建两个GOT表项,存储tlsdesc数据,该数据在共享文件加载时初始化。

  • 静态链接生成共享对象时,对每个TLS变量生成一条重定位记录(TLSDESC)。

  • 静态加载时:计算出共享对象的TLS程序段与TP的偏移,TLS变量与TLS程序段的偏移保存在符号表中。两个偏移相加即可得到与TP的相对偏移,保存在tlsdesc::arg中,tlsdesc::entry的值设为_dl_tlsdesc_return。运行时访问TLS变量时,通过GOT表间接调用_dl_tlsdesc_return获得。

  • 动态加载时:编译时可知TLS变量在TLS程序段的偏移,加载时可知共享对象编号,将这些数据保存在tlsdesc_dynamic_arg::tlsinfo中,解析时可作为__tls_get_addr的参数。将tlsdesc_dynamic_arg::gen_count设置为共享对象的generation。然后将tlsdesc_dynamic_arg对象地址初始化tlsdesc::arg,将_dl_tlsdesc_dynamic初始化tlsdesc::entry。

  • Generation的赋值:若该共享对象已动态加载,则将该共享对象加载时的全局generation,若未加载过,则将全局generation加1的generation。这种算法在TLS变量的generation大于dtv generation时触发dtv数组和动态TLS块的重分配。每个TLS变量有一个generation变量,对原有TLS变量的访问不会触发dtv数组和动态TLS块的重分配,显著降低了动态TLS块的管理开销。

该访问为了减少寄存器的使用,使用各CPU架构函数调用约定中返回值使用的寄存器作为其输入参数寄存器使用,如:arm64的x0寄存器,x86_64的rax寄存器。tlsdesc这些解析函数都是汇编实现。
TLS descriptors相对于Generic Dynamic的好处有:

  • __tls_get_addr采用C语言实现,而tlsdesc为减少寄存器的使用,使用一个特别的函数调用约定,即返回值寄存器作为输入参数寄存器,一般使用汇编实现

  • Tlsdesc将初始的generation保存在tlsdesc_dynamic_arg中,在解析函数中,不需要对全局的generation进行原子访问。

$ gcc -O3 -shared -fPIC -mtls-dialect=desc -o tls-desc.so tls.c
TLS变量的重定位类型为TLSDESC,指向GOT表项
$ readelf -W -r tls-desc.so

image-20240218-074457.png

汇编代码寻址TLS变量的GOT表项,并使用函数调用指令执行GOT表项中的函数,同时将表项地址保存x0寄存器作为参数传递,函数返回TLS变量与TP的偏移。
$ objdump -j .text --disassemble=tls tls-desc.so

image-20240218-074510.png

fsfla.org/~lxoliva/writeups/TLS/RFC-TLSDESC-x86.txt

platform_bionic/docs/elf-tls.md at master · aosp-mirror/platform_bionic · GitHub

TLS重定位类型

DTPMOD64 and DTPREL64

       这种类型支持动态加载共享对象。由于动态加载时TLS块是动态分配的,这些TLS块的地址是随机的,每个线程创建的TLS块相对于其TP的偏移不相等。在加载共享对象过程中,只能确定,TLS变量相对于TLS程序段的偏移,也即dtv指向的LTS块的偏移,这个偏移写入DTPREL64类型指定的重定位的GOT表项中,共享对象的编号写入DTPMOD64类型指定的重定位的GOT表项中。

在延迟解析过程中(调用__tls_get_addr),其返回值等于dtv[ti_module] + ti_offset。

对于Local Dynamic方式,只需要重定位DTPMOD64,但保留DTPREL64对应的GOT表项,其值为0。

注:x86_64的D TPOFF64对应aarch64的DPTREL64。

TLS_TPREL64

       重定位的GOT表项值为相对于TP的偏移值。这是Initial Exec模式,静态加载共享对象时,确定了共享对象的加载顺序,每个线程按照相同顺序将共享对象的TLS程序段内容拷贝值静态TLS块上,静态TLS块中的数据相对于每个线程的TP偏移是固定的。TLS_TPREL64重定向类型将TLS变量相对于TP偏移写入GOT表项中。

       访问TLS变量时,直接通过专用段寄存器与TLS_TPREL64偏移进行访问。

描述bionic重定位TLS_TPREL64类型的实现:计算相对于thread pointer的相对偏移(mod.static_tls_offset – relocator.tls_tp_base + sym_addr + addend)。mod.static_tls_offset和relocator.tls_tp_base为线程tcb在TLS block的偏移,两个相减为tls_offset与tp_base的偏移,而sym_addr + addend之和为module TLS segment内的偏移。在汇编代码中,sym_addr为TLS变量在TSL segment中的偏移。(Initial Exec)

注:在aarch64的实现中,relocator.tls_tp_base指向dtv,mod.static_tls_offset和relocator.tls_tp_base相减为dtv的偏移。

TLS修饰符

       C/C++ TLS variables are declared with a specifier:

Specifier

Notes

__thread

  • non-standard, but ubiquitous in GCC and Clang

  • cannot have dynamic initialization or destruction

_Thread_local

  • a keyword standardized in C11

  • cannot have dynamic initialization or destruction

thread_local

  • C11: a macro for _Thread_local via threads.h

  • C++11: a keyword, allows dynamic initialization and/or destruction

glibc aarch64 TLS

根据libc.so的程序段和重定位信息可知,libc.so使用Initial Exec访问TLS模式。任何共享对象都依赖libc.so,在程序加载过程中,都会加载libc.so,静态链接libc.so,因此使用Initial Exec符合其使用场景。即使有共享对象动态加载(dlopen)libc.so,由于libc.so已经静态加载至内存,动态加载的libc.so时,会复用静态加载的libc.so
使用dlopen无法使用这些TLS变量,因为每个线程创建的Dynamic TLS block与TP的偏移不一致。(glibc支持该情况,对static TLS块预留了144字节,用于处理IE访问模式的动态加载情况,一般来说,框架库只会使用8字节的TLS变量,指向一个数据结构,这样减少的访问TLS的开销,也减少TLS变量占用空间,glibc可支持更多的动态库。但bionic未预留static TLS块空间)

Libc.so存在TLS程序段,其中16字节的TLS变量有初始化值,128字节的TLS变量未初始化
$ readelf -W -l /lib/aarch64-linux-gnu/libc.so.6

image-20240218-074607.png

Libc.so有14个R_AARCH64_TLS_TPREL64重定位记录,说明有14个TLS变量,共占用144个字节。
$ readelf -W -r /lib/aarch64-linux-gnu/libc.so.6

image-20240218-074617.png

有3个有符号的TLS变量,共占用16字节。
$ readelf -W -s /lib/aarch64-linux-gnu/libc.so.6 

image-20240218-074632.png

glibc使用TLS变量汇总

作用域

所在文件

size

说明

errno

errno.c

4

标准c语言错误码

strerror/strsignal

tls-internal.c

16

错误码转换成字符串,字符串buffer为TLS变量

dlerror

libc_dlerror_result.c

8

共享对象动态加载错误字符串

herror/h_errno

herrno.c

4

Inet网络处理错误码

inet_ntoa

inet_ntoa.c

18

Inet将ip地址转换成字符串格式

locale

global-locale.c

8

本地化字符串、时间、货币等,如:strerror

malloc

arena.c/malloc.c

20

内存管理,本地内存池

resolv

res_libc.c/resolv_context.c

16

域名解析,资源状态和上下文均为TLS变量

pthread

cxa_thread_atexit_impl.c

24

线程退出时,回收线程资源

runrpc

rpc_thread.c

8

远端过程调用服务以及rpc错误使用TLS变量

ctype

ctype_info.c

24

ctype中需要buffer的字符串转换

  • 错误码:包括标准C错误码,动态库操作错误,网络错误码等使用TLS变量,错误码字符串化时需要缓冲,需要本地化,这些都使用TLS变量来支持线程安全接口。

  • 网络/域名解析:域名解析IP地址等线程安全接口,都使用TLS变量来实现线程安全

  • 本地化:本地化涉及字符串编码、语言,时间、货币、数字等格式,使用TLS变量缓冲数据,实现线程安全接口

  • 堆管理:优化多线程内存分配等效率,采用TLS变量来管理本地内存池

  • 网络名字解析:为实现涉及字符串返回接口的线程安全,均采用TLS变量作为缓冲。

  • C语言类型处理:字符串类型转换,如:toupper/tolower,根据本地化配置转换

  • 远端过程调用:rpc服务使用所有的TLS变量,rpc客户端使用错误码的TLS变量。

注:libGLdispatch.so包含一个TLS变量(_glapi_tls_Current),使用Initial Exec访问模式,EGL,GLESv1,GLESv2都依赖GLdispatch库。Vulkan没有使用TLS变量。
elf.pdf (linuxfoundation.org)

Libhybris

Libhybris实现安卓动态库在linux环境下运行,起初为解决linux环境驱动缺乏问题,使用安卓上的驱动库进行驱动。支持OpenGL、vulkan等图形框架驱动。

分层架构

image-20240218-074723.png
  • 可执行程序:Linux环境下的可执行程序,使用动态链接方式,依赖各种动态库。

  • Gnu原生动态库:可执行程序依赖的动态库。

  • 接口适配动态库:适配gnu原生动态库与bionic原生动态库的接口,用bionic原生动态库代替gnu原生动态库提供的功能。两者接口和功能差异由适配库完成适配。在可执行程序启动阶段静态加载接口适配动态库。

  • 通用适配动态库:通过动态库,所有接口适配动态库都依赖其功能。

    • bionic动态库链接器完成对bionic原生动态库的动态加载和重定位;初始化bionic libc运行时环境;管理已加载的bionic原生动态库。不同bionic版本有一个专门的动态库链接器。

    • Libc适配器:适配bionic libc接口,将不能在bionic libc运行时正常执行的libc接口适配至gnu libc。对接口和实现不匹配的函数进行重构实现。

  • Bionic原生动态库:bionic原生的动态库。

  • Bionic libc运行时:包括bionic libc、ld-android等原生动态库。

  • Glibc libc运行时:包括gnu libc、ld-linux等原生动态库。

实现原理

Libhybris提供了bionic libc动态库在linux环境下运行的技术解决方案。具体原理如下:

  • 提供linux环境下的bionic动态库链接器。初始化bionic libc运行环境,动态加载bionic动态库以及其依赖库,并进行重定位。Bionic动态库链接器支持bionic特定的ELF格式。

  • Bionic libc功能适配。逐个对bionic libc接口进行适配,对于不能正常执行的接口进行重构,实现缺失的接口功能。

  • 逐一适配bionic框架动态库。对每个框架动态库提供一个 Linux适配动态库,且对接口逐一适配,对不能正常的接口进行重构,对正常的接口进行透传。框架动态库依赖的动态库使用原生的bionic动态库

  • 采用动态加载(dlopen)bionic动态库,每个函数都采用显示调用(dlsym)方式查找函数地址。

  • Linux gnu解释器延迟解析函数符号。第一次调用bionic框架动态库函数时,对动态库进行动态加载,然后再显示解析该函数符号。未使用的函数不会被解析,可降低程序启动延时,减少符号解析开销。

适配接口统计

Libc适配统计

直接转发

间接转发

重构

透传

210

211

15

906

  • 直接转发:转发调用gnu libc的接口

  • 间接转发:进行接口适配,然后再调用gnu libc接口

  • 重构:进行功能适配,后再调用gnu libc接口或者全新实现,如:_pthread_gettid、_errno

  • 透传:不做任何处理,直接调用bionic libc接口。

Libc适配转发和重构分类:

类别

直接转发

间接转发

重构

字符串操作

44

6

0

内存管理

9

2

0

pthread

29

56

3

Stdio

14

73

5

网络相关

4

2

0

动态链接操作

0

8

0

目录操作

8

7

4

时间操作

10

0

0

系统日志操作

4

0

0

文件操作

2

0

0

Unix标准接口

11

0

0

stdlib

2

0

0

C++ ABI

2

1

0

环境变量操作

3

0

0

Locale

6

0

0

宽字符

6

6

0

信号处理

0

20

0

文件系统挂载操作

1

3

0

安卓特有

0

11

0

System properties

0

13

1

其它

9

4

1

框架动态库适配统计

库名

数量

转义

直通

未实现

安卓库数量

libEGL.so

44

16

24

4

79

libGLESv1_CM.so

145

2

272

0

278

libGLESv2.so

358

2

358

0

846

libvulkan.so

210

3

436

0

182

libOpenCL.so

129

10

130

0

数量统计方法:
readelf -W --dyn-syms libEGL.so | awk '{print $8}' | grep ^egl | wc -l
readelf -W --dyn-syms libGLESv1_CM.so | awk '{print $8}' | grep ^gl | wc -l
readelf -W --dyn-syms libGLESv2.so | awk '{print $8}' | grep ^gl | wc -l
readelf -W --dyn-syms libvulkan.so | awk '{print $8}' | grep ^vk | wc -l
框架动态库版本号

库名

Libhybris

Linux

Android

libEGL.so

1.0.0

1.1.0

N/A

libGLESv1_CM.so

1.0.1

1.2.0

N/A

libGLESv2.so

2.0.0

2.1.0

N/A

libvulkan.so

1.2.183

1.2.131

N/A

libOpenCL.so

1.0.0

1.0.0

注:

  1. libvulkan.so.1.3.204达到1000个接口。

  2. libhybris对vulkan的封装,采用IFUNC间接函数链接功能实现函数转发。

附录

动态库依赖

在gcc编译生成的程序中,动态表包含直接依赖的动态库信息;在clang编译生成的程序中,不但包含直接依赖的动态库信息,也包含间接依赖的动态库信息。
在一个示例中,程序调用了libxab.so和libxc.so中的函数,而libxc.so调用了libxe.so中的函数。分别使用gcc和clang编译,可以看到,clang编译的程序动态表中包含了堆libxe.so的依赖。

$ gcc -L. -o demo-gcc ../../main.c -lxab -lxc -lxe

$ readelf -W -d demo-gcc

image-20240218-074851.png

$ clang -L. -o demo-clang ../../main.c -lxab -lxc -lxe

$ readelf -W -d demo-clang

image-20240218-074904.png

Hybris融合方案的问题:Android的交叉编译工具采用clang进行编译,共享对象中存在间接依赖记录,这些间接依赖的共享对象在程序启动时加载。

Libc与ld-linux关系

Libc与ld-linux版本一一对应,不同版本不能组合在一起使用。Libc依赖ld-linux,glibc-2.38的aarch64版本,libc有18个符号未定义,这些符号在ld-linux中定义。

类型

数量

符号

操作动态库操作相关,包括异常接口,审计库处理接口

8

_dl_argv@GLIBC_PRIVATE
_dl_audit_preinit@GLIBC_PRIVATE
_dl_audit_symbind_alt@GLIBC_PRIVATE
_dl_find_dso_for_object@GLIBC_PRIVATE
_dl_rtld_di_serinfo@GLIBC_PRIVATE
_dl_catch_exception@GLIBC_PRIVATE
_dl_signal_error@GLIBC_PRIVATE
_dl_signal_exception@GLIBC_PRIVATE

大多与操作动态库相关接口,用于更改操作的实现,加载时需初始化数据结构

2

_rtld_global@GLIBC_PRIVATE
_rtld_global_ro@GLIBC_PRIVATE

栈操作接口,加载时初始化栈范围

2

__libc_stack_end@GLIBC_2.2.5
__nptl_change_stack_perm@GLIBC_PRIVATE

操作TLS相关操作

4

_dl_allocate_tls@GLIBC_PRIVATE
_dl_allocate_tls_init@GLIBC_PRIVATE
_dl_deallocate_tls@GLIBC_PRIVATE
__tls_get_addr@GLIBC_2.3

安全相关

1

__libc_enable_secure@GLIBC_PRIVATE

调优参数

1

__tunable_get_val@GLIBC_PRIVATE

其中_rtld_global数据结构大小为4328,_rtld_global_ro为904,这两个数据结构大小与CPU体系结构相关。

图形框架

图形框架使用OpenGL/ES和vulkan接口规范,这些规范有不同的实现。

  • Android实现了OpenGLES的1,2,3版本,以及vulkan,OpenGLES和vulkan的驱动由mesa提供。

  • Linux使用mesa实现了OpenGL,OpenGLES的1,2,3版本,使用khronos Vulkan Loader实现vulkan,OpenGL、OpenGLES和vulkan的驱动由mesa提供

图形框架的驱动有3类:

  • DDX驱动:与xfree86的X窗口系统相关,其依赖DRM驱动进行直接渲染。

  • DRI驱动:与窗口系统相关, 采用gallium3D框架开发驱动,依赖DRM驱动。

  • DRM驱动:显卡设备的用户态驱动,主要封装ioctl操作。

OpenGL/ES的两个扩展库EGL和GLX,EGL是一个通用的窗口系统交互的适配库,而GLX针对X窗口系统交互的适配库。两个扩展库均使用DRI驱动。这些DRI驱动一般与窗口系统无关,为支持X客户端的直接渲染,linux的DRI驱动依赖libxcb-dri3扩展库。在Android环境下,使用EGL与窗口系统适配,linux的wayland也是用EGL与窗口适配,以及xserver通过glamor对接EGL。在mesa的实现中,不同设备使用同一个开源驱动实现,该驱动依赖各种设备的DRM驱动。
Vulkan驱动的实现分两种情况,已存在DRM驱动的设备,则只实现窗口系统与DRM接口相关的驱动;全新设备的驱动,将DRI和DRM融合成一个,不再单独开发DRM驱动,而且提供了与窗口系统的集成,与窗口系统依赖。Android系统依赖libnativewindow.so,linux依赖wayland和xserver,libwayland-client.so和libxcb.so。
对于OpenGL/ES来说,需要提供两个驱动,一个是DRM驱动,另一个是DRI驱动。对于vulkan来说,至少需要一个DRI驱动,其若依赖DRM驱动,则需提供一个DRM驱动。
与窗口系统相关的动态库,不同操作系统依赖不同,而且为直接链接依赖。虽然架构支持间接链接(通过dlopen)依赖,但从目前发行情况下,都是直接链接依赖。这对动态库跨平台直接移植带来了挑战。
OpenGL/ES和vulkan两种规范的实现在操作系统发行版中都会提供,由应用开发者选择。

image-20240218-074925.png

X86_64 TLS访问模型

Generic Dynamic

该方式在程序编译时,在引用TLS变量时,使用__tls_get_addr函数获得其地址。该函数接收两个参数,分别是共享对象编号和相对于tp的偏移值。

$ gcc -O3 -shared -fPIC -ftls-model=global-dynamic -o tls-gd.so tls.c
重定位表,var, var2重定位位置为GOT表,每个变量占用两个表项,分别为编号和偏移值。
$ readelf -W -r tls-gd.so

image-20240218-074938.png

从以下汇编代码看出,访问var, var2变量时,分别调用__tls_get_addr获取,参数地址为var和var2在GOT表项的起始地址,如:0x3fd8和0x3fc8所在地址。
$ objdump -j .text --disassemble=tls tls-gd.so

image-20240218-074957.png

Local Dynamic

与Global Dynamic类似,由于只在内部共享对象引用,只需调用__tls_get_addr获得第一个TLS变量的地址即可,其余TLS变量地址使用相对偏移获得。

$ gcc -O3 -shared -fPIC -ftls-model=local-dynamic -o tls-ld.so tls.c
从重定位表中看出,只使用了一个R_X86_64_DTPMOD64表项记录编号,预留了8字节空间默认偏移为0,也即只需通过__tls_get_addr获得tp的首地址即可。
$ readelf -W -r tls-ld.so

image-20240218-075011.png

从汇编看出,通过__tls_get_addr获取tp地址后,使用偏移量访问var,var2等。当TLS变量数量较多时,该访问模式性能优于Generic Dynamic。
$ objdump -j .text --disassemble=tls tls-ld.so

image-20240218-075021.png

Initial Exec

与Local Dynamic相比,去掉了对__tls_get_addr的函数调用,直接从偏移获取TLS变量值。由于无法提前获知共享对象在程序执行时的加载顺序,也即无法确切其与tp的偏移量,因此每个TLS变量都需要重定位,加载时复制给GOT表项。

$ gcc -O3 -shared -fPIC -ftls-model=initial-exec -o tls-ie.so tls.c
var和var2存在一个R_X86_64_TPOFF64重定位项,用于在加载过程中,计算变量与tp的偏移量。
$ readelf -W -r tls-ie.so

image-20240218-075038.png

var的偏移量保存在GOT表项的0xefe0位置,var2的偏移量保存在GOT表项的0x3fd8。tp地址保存在%fs寄存器中。
$ objdump -j .text --disassemble=tls tls-ie.so

image-20240218-075049.png

Local Exec

动态库不支持Local Exec访问方式,只支持包含main函数的程序。

$ gcc -O3 -fPIC -ftls-model=initial-exec -o tls-ie tls.c
从重定位表中,没有__tls_get_addr的重定位,也没有TLS变量的重定位。其使用段寄存器和TLS变量在TLS程序段的偏移进行寻址。
$ readelf -W -r tls-le

image-20240218-075108.png

X86_64采用变种2的TLS数据结构,从指令看到var的地址偏移为-8,var2的地址偏移为-4,tp地址放在%fs寄存器中。
$ objdump -j .text --disassemble=tls tls-le

image-20240218-075119.png

TLS Descrptors

$ gcc -O3 -shared -fPIC -mtls-dialect=gnu2 -o tls-desc.so tls.c

$ readelf -W -r tls-desc

image-20240218-075136.png

汇编代码寻址TLS变量的GOT表项,并使用函数调用指令执行GOT表项中的函数,同时将表项地址保存%rax寄存器作为参数传递,函数返回TLS变量与TP的偏移。
$ objdump -j .text --disassemble=tls tls-desc

image-20240218-075144.png