1.4 Dynamic Linker
- 1 Dynamic Linker
- 2 ELF
- 2.1 Object File Format
- 2.2 ELF Header
- 2.2.1 可重定位文件的ELF头
- 2.2.2 可执行程序的ELF头
- 2.2.3 共享对象文件的ELF头
- 2.3 分节
- 2.4 程序段
- 3 程序的执行
- 3.1 加载程序
- 3.2 重定位类型
- 3.2.1 GLOB_DAT重定位
- 3.2.2 JMP_SLOT重定位
- 3.2.3 RELATIVE重定位
- 3.2.4 IRELATIVE重定位
- 3.3 符号查找
- 4 TLS
- 4.1 TLS数据结构
- 4.2 TLS访问模式
- 4.2.1 Generic Dynamic
- 4.2.2 Local Dynamic
- 4.2.3 Initial Exec
- 4.2.4 Local Exec
- 4.3 TLS函数实现
- 4.4 TLS Descriptros
- 4.5 TLS重定位类型
- 4.5.1 DTPMOD64 and DTPREL64
- 4.5.2 TLS_TPREL64
- 4.6 TLS修饰符
- 4.7 glibc aarch64 TLS
- 5 Libhybris
- 6 附录
- 6.1 动态库依赖
- 6.2 Libc与ld-linux关系
- 6.3 图形框架
- 6.4 X86_64 TLS访问模型
- 6.4.1 Generic Dynamic
- 6.4.2 Local Dynamic
- 6.4.3 Initial Exec
- 6.4.4 Local Exec
- 6.4.5 TLS Descrptors
Dynamic Linker
动态链接器是在程序执行过程中对ELF二进制文件进行解释,将多个ELF文件组合成一个完整的程序镜像,也称解释器。
ELF
ELF(Executable and Linking Format)是linux用于存储二进制可执行文件的格式,有3种主要的二进制对象文件类型:
可重定位文件(relocatable file):存储代码和数据的二进制文件,如:C语言种编译生成的.o文件。可将这类文件链接成一个单独的可执行文件或者共享对象文件。
可执行文件(executable file):独立主程序可执行文件,即不依赖任何动态库的可执行文件。
共享对象文件(shared object file):存储代码和数据的二进制文件。存在两种上下文,分别为链接时上下文和运行时上下文,即生成共享对象文件和加载共享对象文件。首先,在链接时上下文阶段,链接编译器(link editor)将可重定位文件和共享对象文件链接生成的一个新的共享对象文件,即动态库;其次,在运行时上下文,动态链接器(dynamic linker)将其共享对象文件与可执行文件和其它共享对象文件组合成一个程序镜像。
Object File Format
对象文件参与的程序的链接(生成程序)和执行(运行程序),两个阶段的文件格式有差异。Linking view为生成程序阶段的视图,Executing view为运行程序阶段的视图
对于可重定位文件来说,只存在于程序链接阶段,格式中没有程序头表,但需要分节头表。对于可执行程序来说,只存在于执行阶段,只需要程序头表。对于共享对象文件。存在于程序链接和执行阶段,需要程序头表和分节头表。
ELF Header
ELF头用于识别ELF可执行格式。
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
可执行程序的ELF头
可执行程序的类型为EXEC,其入口地址为绝对虚拟地址。各代码段和数据段的地址已经确认,编译器对这种情况进行编译优化,减少间接寻址代码。(注:若在编译时指定-fPIE选项,以及打开ASLR,每次加载程序的入口虚拟地址随机变化)
$ gcc -static -o main-static main.o
$ readelf -h main-static
共享对象文件的ELF头
$ gcc -o main-dynamic main.o |
分节
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 |
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
依赖库: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[];
程序段
程序段是将属性相同的分节按序整合在一个段中。
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 |
从程序头的信息可知,由于段有对齐的要求,段间的内容可能有重叠。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,链接解释器执行如下操作:
根据DYNAMIC段信息,解释器初始化自身数据,包括符号重定位,函数符号重定位,以及执行初始化代码等
程序代码重定位
根据程序的DYNAMIC段的NEEDED信息,按广度优先加载依赖的动态库,依次将库文件内容映射至内存。
按照依赖的逆序对动态库重定位
读取DYNAMIC段内容,初始化重定位需要的数据
根据可重定位表每个表项的内容进行重定位,对GOT等进行初始化。对于未定义的符号,从依赖库中查找符号的虚拟地址。
根据程序的重定位表进行重定位,完成符号的初始化
依赖逆序调用.init/.init_array指向的代码,对动态库和程序进行初始化。
调用程序的入口函数_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]作为特殊表项。
延迟符号解析流程:
通过bl等调用指令相对寻址PLT表项
PLT表项代码间接寻址跳转至GOT表项表示的虚拟地址
PLT[0]代码解析符号,并将符号地址写入GOT表项中
跳转至GOT表项指向的函数
当函数符号解析后,再次调用该时,执行至第3步即可。
以main-dynamic的__cxa_finalize为例。其对应的PLT[3]地址为0x10fa0,其值为0x5b0,指向PLT[0]的地址。PLT[3]的相对虚拟地址在共享对象加载时更改为绝对虚拟地址。
前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调用__cxa_finalize的指令
$ objdump --disassemble=__do_global_dtors_aux main-dynamic
PLT重定位
$ readelf -W -r main-dynamic
显示.got内容,可知0x10fa0的值为0x5b0,指向PLT[0]
$ readelf -W -x .got main-dynamic
RELATIVE重定位
该类型用于对非符号的当前共享对象的指定位置进行重定位。r_offset指定重定位的地址,该地址内容重定位后,其值为r_addend与共享对象文件映射至内存的起始虚拟地址之和。比如.init_array分节,可将初始化函数的相对虚拟地址转换为绝对虚拟地址。
IRELATIVE重定位
GNUC支持间接函数(IFUNC)功能,提供一个解析函数,根据运行环境选择一个实现函数。解析函数不接收参数,返回值为函数地址;实现函数的接口必须一致,并通过_attribute_ ((ifunc ("resolve ")))修饰,resolve为解析函数的函数名。
GNUC提供两种实现方式:
IRELATIVE重定位:一般为在共享对象内部定义和引用的IFUNC,无符号表项,在加载共享对象时解析,不支持延迟符号解析。
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根据静态加载和动态加载共享对象的不同场景提供了内存布局的两种实现。基本原理如下:
静态链接生成共享对象时,使用专用段寄存器
每个共享对象TLS程序段都有一个TLS副本,存储在不同的地址空间。
所有静态加载的TLS程序段排列在一段连续的地址空间中,称之为静态TLS块。
定义一个dtv数组,记录每个共享对象TLS程序段起始地址的相对偏移。
将dtv数组指针(虚拟地址)和静态TLS块放在一个连续的地址空间中,定义一个线程指针tp,指向这块连续地址空间。线程指针存储在专用寄存器中。对于静态TLS块上TLS变量的访问,通过tp于静态TLS块中的偏移获得;对于动态TLS块上TLS变量的访问,通过tp找到dtv数组,根据TLS变量所在的TLS程序段获得起始地址,再加上TLS变量偏移获得。
延迟处理动态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。
每一个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字节指向对齐的虚拟地址,第二个指向原始的虚拟地址。
该方式基本概念与变种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变种
Bionic P版的实现与ABI有差异,其TCB块为一个数组,bionic_tls指向TLS块。Bionic android13实现有较大差别,两个版本不兼容。Android13定义了TLS_SLOT_DTV代替bionic_tls指向TLS块,结构如下图。
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
从以下汇编代码看出,访问var, var2变量时,分别调用__tls_get_addr获取,参数地址为var和var2在GOT表项的起始地址,如:0x10fc8和0x10fb0所在地址。
$ objdump -j .text --disassemble=tls tls-gd.so
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
var的偏移量保存在GOT表项的0x10fd0位置,var2的偏移量保存在GOT表项的0x10fc0。tp地址保存在%fs寄存器中。
$ objdump -j .text --disassemble=tls tls-ie.so
Local Exec
动态库不支持Local Exec访问方式,只支持包含main函数的程序。
$ gcc -O3 -fPIC -mtls-dialect=trad -ftls-model=initial-exec -o tls-ie tls.c |
从汇编中看出,var和var2在TLS程序段的偏移增加了0x10,这是aarch64的glibc使用变种1 TLS数据结构的原因。
$ objdump -j .text --disassemble=tls tls-le
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块内存。