3.2.4 动态链接器融合

简介

Bionic linker64用于加载Android程序以及程序依赖的动态链接库,而glibc ld-linux用于加载linux程序以及程序依赖的动态链接库,两个动态链接器不兼容。在FDE环境中,原生系统为linux桌面系统,提供了主机上所有硬件设备驱动,包括内核态和用户态的硬件设备驱动。FDE的Android环境共享了linux内核环境,但使用独立了用户桌面环境,这些桌面环境缺少了硬件设备的用户态驱动。为支持FDE Android环境下应用的显示和渲染硬件加速,采用linux用户态驱动来驱动Android环境下的硬件,因此,采用一种动态链接器融合方案,使Android的动态链接器支持加载Linux gnu动态库和bionic动态库

涉及的问题

规范差异

Android以及linux的程序和动态库都采用ELF格式,该格式有基本的规范,也可根据CPU架构,运行环境扩展其规范。这两个动态加载器一般都遵循GNU扩展,但Android动态加载器实现不完全。bionic为支持的规范包括:

  • .gnu.version分节中VERSYM的定义,在Symbol Versioning中描述的“11.7.6. Symbol Resolution”章节中,bionic未处理versym中的最高位,导致索引越界

  • 未实现符号的STB_GNU_UNIQUE类型

  • 不支持DT_FLAGS_1的DF_1_INITFIRST(0x00000020)标记等

由于规范中功能较多,一般采用发现什么差异,解决什么差异,不按照glibc的要求全部实现。

类别

名称

说明

备注

类别

名称

说明

备注

section header table

SHT_LOOS

0x60000000

Start OS-specific

 

SHT_GNU_ATTRIBUTES

0x6ffffff5

Object attributes

 

SHT_GNU_HASH

0x6ffffff6

GNU-style hash table

代替elf hash

SHT_GNU_LIBLIST

0x6ffffff7

Prelink library list

 

SHT_CHECKSUM

0x6ffffff8

Checksum for DSO content

 

SHT_GNU_verdef

0x6ffffffd

Version definition section

 

SHT_GNU_verneed

0x6ffffffe

Version needs section

 

SHT_GNU_versym

0x6fffffff

Version symbol table

bionic未处理最高位标志

SHT_HIOS

0x6fffffff

End OS-specific type

 

symbol table bind

STB_LOOS

10

Start of OS-specific

 

STB_GNU_UNIQUE

10

Unique symbol

bionic未实现

STB_HIOS

12

End of OS-specific

 

symbol table type

STT_LOOS

10

Start of OS-specific

 

STT_GNU_IFUNC

10

Symbol is indirect code object

 

STT_HIOS

12

End of OS-specific

 

program table

PT_LOOS

0x60000000

Start of OS-specific

 

PT_GNU_EH_FRAME

0x6474e550

GCC .eh_frame_hdr segment

 

PT_GNU_STACK

0x6474e551

Indicates stack executability

 

PT_GNU_RELRO

0x6474e552

Read-only after relocation

一般不再使用

PT_GNU_PROPERTY

0x6474e553

GNU property

 

PT_GNU_SFRAME

0x6474e554

SFrame segment

 

PT_HIOS

0x6fffffff

End of OS-specific

 

dynamic table

 

 

 

 

DT_VERSYM

0x6ffffff0

Address of version symbol table

 

DT_RELACOUNT

0x6ffffff9

Number of relocation and adden definitions

 

DT_RELCOUNT

0x6ffffffa

Number of relocation definitions

 

DT_FLAGS_1

0x6ffffffb

State flags

bionic不支持DF_1_INITFIRST(0x20)标记

DT_VERDEF

0x6ffffffc

Address of version definition table

 

DT_VERDEFNUM

0x6ffffffd

Number of version definitions

 

DT_VERNEED

0x6ffffffe

Address of table with needed versions

 

DT_VERNEEDNUM

0x6fffffff

Number of needed versions

 

注:省略了Sun、Solaris、HP等特定OS的类型定义。

TLS问题

在多线程环境下,提供了TLS(线程局部存储)变量,每个线程拥有该变量的一份拷贝,各自修改互不影响。在C/C++等高级语言对TLS变量的访问与普通变量访问无异。为实现该功能,编译器和链接器在编译和链接阶段对TLS变量进行了特殊处理。

编译器对TLS变量的访问编译成函数调用返回TLS变量地址,链接器实现该函数功能。编译器将动态库中所有的TLS变量汇编在一个TLS程序段中,链接器对该TLS程序段进行处理,只需计算TSL程序段与段寄存器的相对偏移。每创建线程时,链接器须对每个动态库的TLS程序段内容复制一个新的备份,交给新的线程使用。

为支持bionic和GNU的TLS变量,需对bionic和GNU动态库进行统一处理,融合链接器使用bionic linker来支持gnu的TLS变量。

为提高TLS变量的访问性能,在特定场景下,编译器和链接器对TLS变量的编译进行了优化,使用与段寄存器的相对偏移来寻址访问,减少函数调用开销。这种场景只有在程序启动过程中加载的动态库才有效。linux的OpenGL/OpenEGL等实现一般采用这种性能优化方式编译TLS变量,然而,为了支持不同的OpenGL/OpenEGL的实现,一般都通过dlopen方式动态加载OpenGL/OpenEGL实现库。这中优化编译方式与加载方式不符,gnu加载器通过预留TLS空间来支持这种方式,而bionic未预留TLS空间。

libc符号兼容问题

Android动态库依赖bionic的libc,而linux动态库依赖gnu libc。这两个libc的实现有差异,对于存在资源共享的操作来说,需要进行适配,比如:内存,标准io终端,本地化(locale),TLS变量,线程资源管理,文件操作等。gnu动态库调用这些接口时,都需将gnu libc适配至bionic libc的实现,也就是这些接口调用bionic libc的实现

融合链接器使用一个单独的动态库实现libc符号兼容问题,在初始化主程序前对该动态库进行初始化,将gnu libc定义的符号由该库进行替换,从而达到兼容的效果。

Gnu库的加载

Gnu库的依赖库均在gnu路径搜索;bionic库的依赖库既可以是bionic库,也可以是gnu库,如何从库名中,得知依赖库为gnu还是bionic库。有以下两种方式,建议选择第一种方式实现。

  1. 白名单(white list):名单中的库为gnu库,其它为bionic库。格式:库名,搜索路径列表(空为默认路径),字段间以空格或制表符(/t)分隔,井号(#)为注释,名称间有空格,可用说引号括起来,支持空行。该方式支持bionic原生库不做任何改变的场景,FDE发布者需要预先设置。

  2. 路径搜索:先搜索bionic路径,若未找到则搜索gnu默认路径。若gnu库在原位置替换bionic库,则在bionic路径中找到gnu库,该情况需要识别库是bionic还是gnu。不支持在bionic和gnu路径下均存在相同库名的情况,该情况只会找到bionic库,无法找到gnu库。

根据规范需要支持以下自定义路径搜索:

  • 支持ELF中Dynamic程序段中DT_RUNPATH字段,优先搜索该字段指定的搜索路径。

  • 使用环境变量增加gnu库默认搜索路径,如:增加DL_GNU_LIBRARY_PATH环境变量,与LD_LIBRARY_PATH功能保持一致。路径间以冒号(:)分隔

  • 使用环境变量设置gnu预加载库,如:增加DL_GNU_PRELOAD环境变量,与LD_PRELOAD功能保持一致。指定值为空,表示删除。库间以空格或冒号分隔。

注:解析方法分别为bionic的parse_LD_LIBRARY_PATH和parse_LD_PRELOAD。

Gnu和bionic动态库识别

通过读取.gnu.verions_r的name字段识别,以“LIBC”开头的表示bionic动态库,以”GLIBC”开头的为gnu动态库。
注意事项:linker为静态库,缺少符号版本信息,比如:bionic的ld-android.so,gnu的ld-linux.so。linker库可通过库名来区别。
警告

  • 版本符号表的内容库开发者可以自定义,若bionic的库开发者对版本符号的命名以”GLIBC”开头,则会误判,反之亦然。

  • 缺少.gnu.version_r的开发库,默认为bionic库。

符号查找

将bionic和gnu库分别由一个列表对应,不同命名空间与不同列表对应

  • 库选择:从bionic库中查找,还是从gnu库中查找。

    • Gnu库的未定义符号均从gnu库查找

    • Bionic库的未定义符号,优先搜索bionic库,然后搜索gnu库

  • 库共享:bionic原生库(framefork.so)与原生应用(Android Application)都直接依赖一个共享库(shared.so),当共享库定义了全局变量时,bionic原生库替换成gnu库后,如何与原生应用共享数据,如何识别哪些操作影响全局变量?分两种情况。不支持存在全局变量影响的共享库。

    • Gnu库也依赖同名共享库,这类库全局变量如何共享,能否共享,与OS平台相关的库全局变量定义不一致问题?

    • Gnu库不依赖同名共享库,这类场景不支持

TLS融合

TLS融合包括两个方面:

  • TLS相关管理,包括__tls_get_addr函数,线程管理,动态库操作等,通过符号适配和转发至bionic libc来实现。

    • __tls_get_addr函数在gun库中,由__tls_get_addr实现,glibc.so.6引用该函数,而bionic是在libc.so中实现的

    • 标准dlopen/dlclose/dlsym/dlerror接口,可兼容,而GNU扩展接口:dlvsym/dladdr/dladdr1/dlinfo/dlmopen/dl_iterate_phdr,bionic只实现了前1个且不兼容(符号版本)

    • 线程管理相关接口相当多,gnu libc.so.6有32个,gnu libpthread.so.0中有423个,其中包含gnu扩展接口;而bionic libc.so中包含246个。

  • 动态加载支持IE访问方式,预留bionic static TLS block内存空间,总共可支持144字节大小的TLS数据

以上适配和转发接口参照libhybris适配的接口实现,其它建议实现为不支持(errno = ENOTSUP),使用假断言,方便调试时尽快找到问题。

通过预留静态TLS块空间来支持动态加载库的IE访问模式。动态加载库的场景与静态加载库的场景相比,多了一个并发场景,即多线程进程,包括多线程同时打开不同或相同的动态库场景:

  • 动态加载库时,存在多个线程运行

    • 需要将该库的TLS程序段内容拷贝至所有线程的static TLS block内存中,同时涉及并发问题,如:有新的线程创建,或线程被销毁

    • 如何获取各线程的static STL block的地址,bionic的线程由pthread管理,未暴露给linker,不能获取线程链表

  • TLS程序段偏移值获取,涉及并发和获取时机问题

    • 可能存在不同线程同时获取TLS程序段偏移值的问题,涉及互斥访问和更新偏移值问题

    • 只需对IE和TLSDESC使用static TLS,这对这两个访问方式有很好效果,其它访问模式建议使用dynamic TLS。bionic在库映射和重定位之间处理TLS存储空间,此时无法感知TLS访问方式;而glibc在重定位时,即时处理TLS存储空间,只对R_AARCH64_TLSDESC和R_AARCH64_TLS_TPREL类型尝试分配static TLS空间。

  • 新线程初始化static TLS block:需对所有的static TLS block进行初始化。bionic因不支持预留static TLS空间,其只初始化靠前的动态库TLS程序段内容

注意

  • static TLS block一旦分配就不再回收,重复动态加载和销毁IE访问模式的动态库,将耗尽static TLS block预留空间,导致再次加载失败

TLS介绍及实现

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

编译器和链接器将动态库中所有的TLS变量放在同一个TLS程序段中,TLS变量相对于TLS程序段的偏移是固定的。

为了优化TLS变量的访问性能,TLS变量的实现采用了以下四种方式,性能逐步增加,实现场景逐步减少。

  • Generic Dynamic:通用方式,每个TLS变量的访问都需函数调用获取地址。可跨动态库引用访问

  • Local Dynamic:局部方式,在函数内多个TLS变量引用,通过函数调用获取TLS程序段的地址,TLS变量通过对TLS程序段的偏移获取地址。只能在动态库内引用访问

  • Initial Exec:段寄存器和TLS变量偏移间接寻址,在主程序起始时静态加载的动态库中引用访问

  • Local Exec:段寄存器和TLS变量偏移直接寻址,只能在主程序中引用访问

另外有一个对GD的优化的访问模式TLSDESC。优化主要包括优化static TLS block中TLS变量访问,减少函数调用对寄存器污染,减少原子操作访问等

编译器一般用tls-dialect和tls-model2个选项支持5种访问方式:

  • -mtls-dialect=trad -ftls-model=global-dynamic : GD方式

  • -mtls-dialect=trad -ftls-model=local-dynamic :LD方式

  • -mtls-dialect=trad -ftls-model=initial-exec  :IE方式

  • -mtls-dialect=trad -ftls-model=local-exec  :LE方式,只支持主程序,不支持动态库

  • -mtls-dialect=desc :TLSDESC方式

注:以上为aarch64的编译选项,x86_64的tls-dialec编译选项值与aarch64不同,为gnu和gnu2,分别对应trad和desc

C/C++的TLS变量修饰符:

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

TLS数据结构介绍

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

  • 定义一个动态线程数组,名为dtv,每个元素指向动态库的TLS程序段内容在当前线程的位置,该数组可动态增加

  • 分配一段静态的物理内存,称之为静态TLS块(static tls block),一旦分配不能增减,大部分用来存储TLS段内容,一小段内存用来存储线程控制块(TCB),该TCB指向dtv数组

  • 动态加载的动态库,为每个动态库的TLS程序段分配一段内存,称之为动态TLS块(dynamic tls block),由dtv元素指向这段内存

  • 使用CPU某个专用段寄存器保存线程指针地址(tp),如:x86_64的%fs段寄存器,aarch64的tpidr_el0段寄存器

image-20240218-093339.png
图1 aarch64 TLS基本数据结构
image-20240218-093350.png
图2 x86_64 TLS基本数据结构

TLS数据布局

分别列出gnu和bionic的arrch64 TLS数据布局,gnu的实现与图1的结构类似,bionic的实现的TCB定义不一样,其它类似。

image-20240218-093602.png
图3 bionic aarch64 TLS数据布局

上图为bionic线程栈的物理布局图,地址从上往下增加,静态TLS块内存分配在线程栈上,TCB大小为bionic_tcb数据结构大小,而非16字节。静态TLS块紧挨着bionic_tcb数据结构。

image-20240218-093638.png
图4 gnu aarch64 TLS数据布局

上图为gnu线程栈的物理布局图,地址从下往上增加,静态TLS块内存分配在线程栈上,TCB大小为tcbhead_t数据结构大小(16字节)。静态TLS块紧挨着tcbhead_t数据结构。

静态TLS空间

涉及静态TLS空间,TCB管理空间(bionic_tcb),线程指针(tp ),动态线程数组(dtv)等。

  • 静态TLS空间:静态TLS空间涉及静态TLS空间和TLS管理空间,一段连续的物理内存,一般分配在线程栈上,动态库的tls_offset相对于该空间计算的

  • 线程指针:线程指针需要保存在段寄存器中,变种1的TCB所占空间需要与编译器达成一致,以满足LE访问方式的要求,如:bionic的TCB所占空间为64,要求编译器生成主程序TLS变量访问指令时,增加64的偏移量。变种2约定偏移为0,因此无此要求。

  • TCB管理空间:记录线程指针、动态线程数组,线程局部数据管理等地址,bionic在aarch64定义的空间大小为9个8字节数组共72字节,比线程指针与第一个TLS变量的偏移大8,因此线程指针指向其第二个元素地址。另外,TCB管理空间涉及字节对齐问题,因此与静态TLS空间的起始地址可能不一样,这可能与bionic的实现相关

  • 动态线程数组:记录每个动态库的TLS程序段的起始位置,其地址保存在线程指针指定的空间中。

bionic TLS数据结构初始化流程

动态链接器对TLS数据结构的初始化分两部分,一部分在加载主程序过程中,称之为静态加载库,另一部分在主程序运行中调用dlopen加载动态库过程中,称之为动态加载库。

静态加载库初始化TLS

动态链接器在加载主程序过程中,使用StaticTlsLayout和TlsModules两个全局变量初始化TLS数据结构

  • StaticTlsLayout类型变量计算每个依赖库TLS程序段在静态TLS块的偏移。

  • TlsModules类型变量计算拥有TLS程序段的所有动态库

在主程序加载过程中,所有的TLS程序段都存储在静态TLS块内存块中,具体步骤如下:

  1. 在linker初始化过程中,调用__libc_init_main_thread_early初始化bionic_tcb和tpidr_el0,调用__init_tcb_dtv初始bionic_tcb中的TLS_SLOT_DTV,其更新标志值为0

  2.  调用linker_setup_exe_static_tls:(假设主程序包含TLS程序段)

    1. 在StaticTlsLayout类型变量中预留bionic_tcb对象和TLS空间以及其位置

    2. 调用register_tls_module获得module id,并加入TlsModules类型变量中

    3. 预留bionic_tls对象空间及其位置

  3. 加载主程序依赖库,若存在TLS程序段,调用soinfo::register_soinfo_tls在StaticTlsLayout类型变量中预留TLS空间和位置,并加入TlsModules类型变量中

  4. 调用linker_finalize_static_tls,计算StaticTlsLayout类型变量中预留空间总大小,即为静态TLS块大小

  5. 调用__allocate_thread_mapping,为静态TLS块分配内存空间,空间分配在主程序的栈上。静态TLS块空间包括:bionic_tcb、bionic_tls以及TLS程序段。

  6. 调用__init_static_tls,将TlsModules类型变量中动态库的TLS段内容拷贝至静态TLS块空间中。

  7. 调用bionic_tcb::copy_from_bootstrap同步第一步初始化bionic_tcb的内容

  8. __init_tcb更新bionic_tcb

  9. 调用__init_bionic_tls_ptrs更新bionic_tls地址

  10. 调用__set_tls设置静态TLS块地址至段寄存器tpidr_el0中

  11. 重定位初始化TLS变量的GOT表项

    1. GD:重定位类型包括R_AARCH64_TLS_DTPMOD64和R_AARCH64_TLS_DTPREL64,分别将其相邻的GOT表项初始化为module id和变量在其TLS段中的偏移

    2. LD:aarch64对LD的实现与GD相同,而x86_64的实现不同,其重定位类型为R_X86_64_DTPMOD64,分别将其相邻的GOT表项初始化为module id和偏移0

    3. IE:重定位类型为R_AARCH64_TLS_TPREL64,将其GOT表项值初始为static STL block上的偏移

    4. LE:无重定位项和GOT表项,不需要初始化

    5. TLSDESC:重定位类型为R_AARCH64_TLSDESC,将其相邻的GOT表项初始化为tlsdesc_resolver_static函数地址和静态TLS块上的偏移量

从上初始化流程中,可看出dtv数组为空,dtv数组只在访问TLS变量时才会创建,这种延时分配的好处有:

  • 对于TLS变量的IE和LE访问方式来说,可直接通过段寄存器和偏移量来获得TLS变量的地址,不需要通过dtv数组查找。

  • 对于TLS变量的GD和LD访问方式来说,通过调用__tls_get_addr函数获取TLS变量地址,在其检查dtv数组为空时,根据TLS动态库的数量重新分配dtv数组空间

  • 对于TLS变量的TLSDESC访问方式来说,其tlsdesc_resolver_static函数直接返回TLS变量在static STL block上的偏移量,也无需分配dtv数组

从上面的用例看,TLSDSC访问方式相对于GD来说,对静态TLS块上的TLS变量的访问优化是显著的,TLSDESC方式直接返回GOT表项中的静态TLS块偏移量,而GD方式需要访问dtv数组,计算而得其地址。

动态加载库初始化TLS

bionic通过dlopen动态加载库,初始TLS的步骤如下:

  1. 在do_dlopen->find_library->soinfo::register_soinfo_tls→register_tls_module流程中,获得module id,并加入至TlsModules类型变量中

  2. 在soinfo::relocate->plain_relocate->plain_relocate_impl->process_relocation→process_relocation_impl流程中重定位初始化TLS变量的GOT表项

    1. GD:重定位类型包括R_AARCH64_TLS_DTPMOD64和R_AARCH64_TLS_DTPREL64,分别将其相邻的GOT表项初始化为module id和变量在其TLS段中的偏移

    2. LD:aarch64对LD的实现与GD相同

    3. IE/LE:不支持

    4. TLSDESC:重定位类型为R_AARCH64_TLSDESC,将其相邻的GOT表项初始化为tlsdesc_resolver_dynamic函数地址和TlsDynamicResolverArg类型变量地址

      1. 初始化TlsDynamicResolverArg中TlsIndex的module id以及offset,offset的值为TLS变量在其TLS程序段的偏移量。另外初始化更新标志为库的更新标志,该标志表示动态库是否有更新

      2. 为了存储TlsDynamicResolverArg类型变量,将变量保存在soinfo::tlsdesc_args_数组中,为处理数组重新分配内存,Relocator::deferred_tlsdesc_relocs缓冲重定位信息,当该库的所有重定位操作完成后,再更新TLS变量的GOT表项

线程创建过程中初始化TLS

调用pthread_create创建线程,需要对主程序上的所有TLS数据结构进行拷贝。(pthread_create->__allocate_thread)

  1. 调用__allocate_thread_mapping分配线程栈空间,包含了静态TLS块空间。(Allocate in order: stack guard, stack, static TLS, guard page)

  2. 调用__init_static_tls,将TlsModules类型变量中动态库的TLS段内容拷贝至静态TLS块空间中。

  3. 调用__init_tcb更新bionic_tcb

  4. 调用__init_tcb_dtv初始bionic_tcb中的TLS_SLOT_DTV,其更新标志值为0。

  5. 调用__init_bionic_tls_ptrs更新bionic_tls地址

  6. 调用clone,将静态TLS块地址传递给clone,由内核设置段寄存器tpidr_el0值

__tls_get_addr函数实现

GD/LD访问方式使用__tls_get_addr函数获取TLS变量绝对地址。__tls_get_addr函数涉及对dtv数据更新,其更新的条件由3个更新标志(generation)控制

  1. 全局generation,保存在__libc_tls_generation_copy,为TlsModules::generation一个副本,每次新增拥有TLS程序段的动态库时,递增该值,表示有动态库新增。不需要处理动态库删除问题

  2. dtv数组中的generation,保存在数组中的第一个元素,初始化为0,每次更新dtv数组时,更新generation只为当时的全局generation值。与全局generation不相等,说明有新的动态库加载,需要更新dtv数组内容

  3. 动态库的generation,保存在TlsModule::first_generation,该值初始化为加载该库时全局generation的值。该值用于判断dtv指向的动态库是否有变化,即是否为旧的动态库

struct TlsIndex { size_t module_id; size_t offset; }; // ti的值保存在动态库的GOT表项中,在重定位时初始化,占两个表项内容 extern "C" void* __tls_get_addr(const TlsIndex* ti){ // 获取dtv数组 TlsDtv* dtv = __get_tcb_dtv(__get_bionic_tcb()); // 获取全局动态库更新标志 size_t generation = atomic_load(&__libc_tls_generation_copy); if (__predict_true(generation == dtv->generation)) { void* mod_ptr = dtv->modules[__tls_module_id_to_idx(ti->module_id)]; if (__predict_true(mod_ptr != nullptr)) { // 无动态库更新,且内存已分配,则进入快速路径,返回TLS变量偏移地址 return static_cast<char*>(mod_ptr) + ti->offset + TLS_DTV_OFFSET; } // 延时分配动态库的动态TLS块内存,只有访问该动态库的TLS变量时才分配内存,进入慢速路径 } // 有动态库更新或者第一次访问,进入dtv和动态TLS块的分配和初始化 return tls_get_addr_slow_path(ti); }

tls_get_addr_slow_path函数包含dtv和动态TLS块的分配和初始化

__attribute__((noinline)) static void* tls_get_addr_slow_path(const TlsIndex* ti) { TlsModules& modules = __libc_shared_globals()->tls_modules; bionic_tcb* tcb = __get_bionic_tcb(); ScopedSignalBlocker ssb; // 互斥写,防止多线程同时修改__libc_shared_globals()->tls_modules全局变量 ScopedWriteLock locker(&modules.rwlock); // 更新dtv数组或者重新分配数组内存 update_tls_dtv(tcb); TlsDtv* dtv = __get_tcb_dtv(tcb); const size_t module_idx = __tls_module_id_to_idx(ti->module_id); void* mod_ptr = dtv->modules[module_idx]; if (mod_ptr == nullptr) { // 不存在,则分配内存,将动态库TLS程序段内容拷贝至新内存,并初始化该模块指针 const TlsSegment& segment = modules.module_table[module_idx].segment; mod_ptr = __libc_shared_globals()->tls_allocator.memalign(segment.alignment, segment.size); if (segment.init_size > 0) { memcpy(mod_ptr, segment.init_ptr, segment.init_size); } dtv->modules[module_idx] = mod_ptr; // Reports the allocation to the listener, if any. if (modules.on_creation_cb != nullptr) { modules.on_creation_cb(mod_ptr, static_cast<void*>(static_cast<char*>(mod_ptr) + segment.size)); } } return static_cast<char*>(mod_ptr) + ti->offset + TLS_DTV_OFFSET; }

update_tls_dtv动态分配dtv数组空间,代码太多不再列出,其实现步骤如下:

  1. 更新dtv数组的条件:dtv数组的更新标志与全局动态库更新标志不相等,说明动态库有更新,当前指向的为旧的动态库

  2. 重新分配dtv数组,条件条件为拥有TLS程序段的动态库总数量大于dtv数组大小

    1. 根据动态库数量重新分配dtv数组空间

    2. 将dtv数组中的内容备份至新的dtv数组空间

    3. 调用__set_tcb_dtv更新为新的dtv数组

    4. 为实现无锁操作,不释放旧的dtv数组空间,而是将其插入一个垃圾回收队列中,待程序结束时回收

  3. 重新更新静态TLS块对应动态库的dtv数组元素

  4. 重新更新动态TLS块对应动态库的dtv数组元素,条件:动态库的更新标志大于dtv数组更新标志(表明dtv数组指向的为旧的动态库)

    1. 释放旧的动态库动态TLS块内存

    2. 将dtv数组元素清零

  5. 更新dtv数组的更新标志为全局动态库更新标志

TLSDESC访问方式实现

TLSDESC访问方式有两种方式获取TLS变量相对静态TLS块的偏移地址:一种对于静态TLS块上的TLS变量由tlsdesc_resolver_static获取,另一种对于动态TLS块的TLS变量由tlsdesc_resolver_dynamic获取。这两种方式都采用汇编实现,不遵循C/C++函数调用的寄存器传参规范。其使用规范中的返回值寄存器传参,如aarch64的x0寄存器,x86_64的rax寄存器。

/* Type used to represent a TLS descriptor in the GOT. */ struct TlsDescriptor { TlsDescResolverFunc* func; size_t arg; }; // tlsdesc_resolver_static函数,其TlsDescriptor::arg值为TLS变量的相对偏移 // tlsdesc_resolver_dynamic函数,其TlsDescriptor::arg值为下列类型变量地址 struct TlsDynamicResolverArg { size_t generation; TlsIndex index; }; struct TlsIndex { size_t module_id; size_t offset; };

tlsdesc_resolver_static的实现相当简单,返回TlsDescriptor::arg值即可。

tlsdesc_resolver_dynamic对更新标志的判断有所优化,共有4个generation更新标志:

  1. 全局generation,保存在__libc_tls_generation_copy,为TlsModules::generation一个副本,每次新增拥有TLS程序段的动态库时,递增该值,表示有动态库新增。不需要处理动态库删除问题

  2. dtv数组中的generation,保存在数组中的第一个元素dtv[0],初始化为0,每次更新dtv数组时,更新generation只为当时的全局generation值。与全局generation不相等,说明有新的动态库加载,需要更新dtv数组内容

  3. 动态库的generation,保存在TlsModule::first_generation,该值初始化为加载该库时全局generation的值。该值用于判断dtv指向的动态库是否有变化,即是否为旧的动态库

  4. TLS变量GOT表项中指向的TlsDynamicResolverArg::generation,该值初始化为TlsModule::first_generation值,该值只要不大于dtv数组中的generation不需要重新分配dtv数组,否则表示该动态库的TLS程序段在dtv数组中未初始化。

tlsdesc_resolver_dynamic实现步骤:

  1. 快速路径,条件:TlsDynamicResolverArg::generation <= dtv[0] && dtv[mod_id] != NULL

    1. 返回 dtv[mod_id] + TlsDynamicResolverArg::TlsIndex::offset相对于静态TLS块的偏移

  2. 慢速路径,调用__tls_get_addr获取TLS变量的绝对地址,返回与静态TLS块的相对偏移

gnu TLS数据结构初始化流程

原理与上节类似,不再详述。两者的差异包括:

  • 静态加载库初始化TLS时,gnu会为静态TLS块预留144字节空间

  • 动态加载库初始化TLS时,先从预留的静态TLS块获取空间,不足时采用动态TLS块,这种方式可满足调用dlopen动态加载的TLS变量IE访问方式的动态库

  • TLSDESC实现函数名称不同:_dl_tlsdesc_return/_dl_tlsdesc_dynamic/_dl_tlsdesc_undefweak -> tlsdesc_resolver_static/tlsdesc_resolver_dynamic/tlsdesc_resolver_unresolved_weak

预留静态TLS空间的作用有两个,一个是支持动态加载IE访问模式的库,另一个是优化TLSDESC访问模式性能

在linux环境下,图形加速库(OpenGL/EGL)的使用预留静态TLS空间的典型应用。一般linux应用程序会使用图形API转发库,如glvnd,图形API转发库通过dlopen动态加载OpenGL/EGL库,而OpenGL/EGL库一般都使用了IE访问模式的TLS变量,通常是一个指针变量,指向一个数据结构,从而减少静态TLS块预留空间的占用。

预留静态TLS空间注意事项:

  • 分配时机:glibc在重定位时尝试分配静态TLS空间,支持的两个重定位类型,分别为R_AARCH64_TLSDESC和R_AARCH64_TLS_TPREL

  • 初始化数据:对所有线程的静态TLS空间进行初始化,TLS数据结构在线程栈上,有的线程使用用户栈,有的使用系统栈,在_dl_init_static_tls函数中实现

  • 并发互斥访问:预留静态TLS空间分配在dl_open_worker_begin中实现,调用函数前加了一把大锁dl_load_tls_lock;初始化静态TLS数据时,在_dl_init_static_tls函数内加了一把锁dl_stack_cache_lock

libc适配接口设计与实现

libc适配接口用于gnu libc接口适配至bionic libc接口实现,以动态库的形式存在,融合链接器在重定向主程序前加载该动态库。共提供两类接口,一类为供融合链接器调用接口,定义在glibc-adapter.h同文件中;另一类为适配实现注册接口,定义在adapter-register.h。

所有实现代码在glibc-adapter.c文件中,实现的功能有:

  • 根据符号查找适配接口函数,采用二分查找法搜索符号

  • 适配接口按类别注册,一般建议按照标准C的头文件分类,如:stdio.h中接口的适配,定义stdio类别,在该类别中实现功能。最多支持8192个适配接口

  • 定义了日志函数接口,采用Android log记录日志,级别为ANDROID_LOG_DEBUG,tag名为"glibc-adapter"

注意事项:

  • 该实现代码中不能包含动态内存分配,以及涉及动态内存分配的libc函数

  • 不能包含运行前的初始化代码,即调用函数初始化全局变量,否则会报错,主要原因linker调用接口会在初始化该库前被调用。可以通过查看动态库ELF文件是否包含.init或.init_array分节来确认

适配接口开发说明

// 注册基础接口:定义接口元素类型和注册函数,不直接使用 struct glibc_adapter_t { const char* symbol; void* adapt_fun; }; // 适配接口时定义adapters静态数组,指定类别名称,用于打印日志 int register_adapters(const char* classes, const struct glibc_adapter_t* adapters, size_t adapter_count); // 注册封装接口 #define ADAPTERS_SIZE(adapters) (sizeof(adapters) / sizeof(adapters[0])) #define REGISTER_ADAPTER_ARRAY(classes, adapters) register_adapters(classes, (adapters), ADAPTERS_SIZE((adapters))) //只需调用REGISTER_ADAPTERS_BY_CLASS(classes)注册即可,"classes"表示类别 #define REGISTER_ADAPTERS_BY_CLASS(classes) REGISTER_ADAPTER_ARRAY(#classes, classes ## _adapters)

接口适配封装

// 符号转发,即gnu glibc符号功能由bionic libc对应符号功能代替 #define ADAPT_DIRECT(symbol) {#symbol, (void *)symbol} //符号适配,即bionic需要适配才能满足gnu libc符号功能,如:gnu libc符号在bionic中不存在;gnu libc接口参数与bionic对应接口或者名称不匹配等 // 适配函数的命名规则为"adapt_"接上gnu libc符号名 #define ADAPT_INDIRECT(symbol) {#symbol, (void *)adapt_##symbol}

注意:有些函数名称为宏定义,比如stdin/stdout/stderr/errno等,因此不能直接使用该名称作为符号,需要找到对应的符号名称,两种方式确定符号名称

  • 查找libc头文件对应的定义,或者进一步查看源代码

  • 使用命令readelf -W --dyn-syms /lib/aarch64-linux-gnu/libc.so.6找到与名称相似的符号,如:有些符号在名称前加上两个下划线"__"

日志记录接口

// 开发者需调试适配函数的调用,可使用adaper_log记录,该日志输出值Android log中,级别为DEBUG int adapter_log(const char* format, ...);

新增适配类别

一般建议一个头文件中的接口使用对应的c文件实现适配,方便查找、阅读和扩展

  1. 新增类别需创建一个c文件,建议命名规则为"classes-adapter.c","classes"表示类别名称。建议每个C头文件定义一个新类别

  2. 在src/CMakeLists.txt中的ADAPTER_FILES变量中增加该文件

  3. 编写适配函数代码,所有适配函数使用static修饰,函数的命名规则为"adapt_classes","classes"表示类别名称。

  4. 定义glibc_adapter_t数组,数组名的命名规则为"classes_adapters","classes"表示类别名称。

  5. 使用ADAPT_DIRECT或ADAPT_INDIRECT初始化数组元素

  6. 实现注册函数的封装功能,命名规则为"register_adapters_classes","classes"表示类别名称。该函数由主功能代码调用

  7. 在src/glibc-adapter.c中register_all_adapters函数末尾加上REGISTER_ADAPTERS_BY_CLASSES(classes); "classes"表示新增的适配接口类别

适配函数的实现和定义可参照errno-adapter.c代码

新增适配接口

  1. 编写适配函数代码,所有适配函数使用static修饰,函数的命名规则为"adapt_classes","classes"表示类别名称。

  2. 在glibc_adapter_t数组末尾追加适配接口,也可以按照功能插入

约束

  • 所有适配接口都以"adapt_"开头,再接上适配函数的名称

  • 所有适配函数都需用static修复,否则可能会产生符号污染问题

  • 代码必须使用标准C语言实现,不能使用C++,否则导致加载报错,此时libc++库未调用初始化过程

  • 必须静态定义glibc_adapter_t数组,不支持动态分配该数组,否则加载时出现段错误,这些数组在libc库初始化之前使用

注意事项

  • libc中,有些函数名或变量名为宏定义,实际符号与宏名不一致,需找到实际符号进行接口适配

  • 最多支持8192个接口适配,包括:函数和全局变量,超过该数时,该类型适配接口不会生效,可使用logcat -s glibc-adapter查看日志确认,如:输出“No enough memory for %s adapters”。

  • 不能包含运行前的初始化代码,即调用函数初始化全局变量,否则会报错,主要原因linker调用接口会在初始化该库前被调用。可以通过查看动态库ELF文件是否包含.init或.init_array分节来确认

融合链接器

融合链接器包括加载libglibc-adapter.so和替换gnu libc中的符号。定义了一个LinkerAdapter单实例类,提供了加载libglibc_adapter.sod的LoadAdapter接口,以及从适配动态库中查找符号的FindSymbolByAdapter接口。

  • 初始化时机:LoadAdapter接口必须在主程序镜像重定位前,vdso和tls初始化之后。

    • 在vdso之后,是因为适配库需要调用libc接口

    • 在tls初始之后,是因为需确保主程序的TLS程序段数据放在static TSL block第一个块中,以支持主程序的TLS LE访问模式

  • 符号替换时机:只对在libc.so.6库中的符号进行替换。链接器先找符号,当符号在libc.so.6时从适配库查找替换符号,若存在则替换,否则不替换

    • 可减少对适配库的符号查找操作

    • 可支持对gnu动态库的钩子实现,如:某个动态库对其它动态库的libc接口进行替换跟踪,然后再调用libc接口

    • 在适配库中的符号地址会进一步从bionic libc中查找符号

    • 在以符号名字母序的adapters中采用二分法查找

约束

  • 适配库的名称必须为libglibc-adapter.so,搜索路径为Android默认的搜索路径,如:/system/lib64/, /vendor/lib64/等,建议放在/vendor/lib64/路径下

建议

  • 适配接口注册函数可利用clang/gcc等特性消除掉,如:将adapters数组直接定义在特定的数据段中(指定链接脚本将所有数组放在一个数据段中),主功能函数得到该数据段的起始和结束地址即可得到注册接口信息

  • 采用白名单加载gnu库,加载第一个gnu库时,先加载接口适配库,这样可显示减少对原生Android应用的影响

  • 使用hash查找符号,以提升成百上千的接口适配的查找性能。注意不能使用动态分配的方式申请内存空间

不足

  • Android环境中每个应用都加载libglibc-adapter.so,每个符号都需在adapter中查一遍,影响加载应用效率

  • 二分查找法效率低,对于上千个适配接口来说,需要十次字符串比较,且随机访问内存

  • 缺少对__tls_get_addr符号的替换,gnu在ld-linux.so中定义,而非gnu libc.so,而bionic在libc.so中定义