GLibc TLS实现
一、问题
在一些高级或者说接口相对比较完善的软件系统中,可能会使用到SO文件,无论是静态链接还是动态链接,这个功能还是需要了解一下。即使在一些相对比较简单的大型软件中,静态线程私有变量是不可避免的,为了了解这些线程的实现机制,我们分析一下glibc以及编译器、连接器是如何协作完成这个功能的。
另一方面,对于我们最为关心的errno的实现,事实上也是通过线程私有变量来实现的,所以对于线程私有变量的理解还是需要一些的。
二、线程私有变量的位置
在不同的体系结构中,一个线程私有变量是编译器最早感知到的一个概念,当我们在源文件中通过__thread声明一个线程私有变量的时候,编译器就需要对这个变量在可执行文件中存放的节、重定向的方式、生成的汇编代码进行特殊处理。
对于不同的体系结构,静态线程私有变量的存放位置是在编译时已经约定好了。例如,在i386系统结构中,这个线程的位置就是在gs段指向的位置向下的一段位置,这个位置向上是一个线程的pthread结构;而对于PowerPC系统,这个寄存器则通过r2来索引,所以编译器在生成汇编代码的时候会利用这个假设。
对于动态加载变量,编译器同样会进行特殊的假设,为了实现lazy binding,编译器对于PIC类型中引用的外部变量通过使用一个约定的__tls_get_addr函数来获得一个动态共享文件引用的外界变量,这个名字同样是在编译时约定好的,但是它最终的实现却是由运行时环境提供(也就是C库的动态连接器rtld或者运行环境中提供-__tls_get_addr函数的实现以及运行时空间的分配)。
三、线程私有数据的类型
根据gcc的说明,线程私有变量可以分为四种类型,也可以通过tls_model来制定一个特定变量的线程私有变量模型,在gcc中说明的四种类型为:
local-exec initial-exec local-dynamic global-dynamic
它们各自的意思为:
在可执行文件模块内定义的变量,也就是这个变量的定义在最终生成可执行文件的时候是由输入的某个.o文件提供的。
由连接时提供的某个动态库中定义。也就是说,这个变量时在一个so文件中定义的的,但是我们在链接的时候引用了这个so文件,所以在可执行文件执行之前已经由动态连接器将这个位置确定好。
前两者说明变量有两个特点,一个是在连接时这些变量都是对连接器可见的(无论是位于so或者.o文件),另一个就是链接最终生成的是可执行文件。
local_dynamic就是最终生成的是一个so文件,但是其中引用的线程私有数据是在这个so的输入文件内部定义的
最后一个就是最终生成也是一个so文件,但是引用的变量可能在另一个so文件中定义,例如,so的生成过程中以另一个so文件为输入。
其中动态链接的很多重定位项都是这些名字的缩写,例如LE IE LD GD等不同的重定位类型。
四、动态连接器的作用
动态连接器是一个内核可以知道和处理的概念。内核在加载一个ELF文件的时候会判断这个文件是否需要一个动态连接,如果需要则首先把控制权放给动态连接器来运行。此时动态连机器有两个重要特点,一个是它是在即将运行的可执行文件的地址空间中运行的,另一方面,它对用户程序透明,早于用户的任何代码的执行。其中第一点保证它动态分配的数据结构和空间在程序真正运行起来之后依然可以使用,同时可以访问用户空间的所有资源;后者保证它可以保证用户对动态内容的透明。
首先ld.so本身也是一个so文件,它的很多位置同样是不确定的,所以这个程序在刚开始运行的一段时间里不能引用全局变量,因为编译器并不会为它进行特殊处理,编译器认为它是一个普通的so文件,它的内容将会由ld.so来操作,按时由于它本身就是ld.so,所以没人为他开路,它只能自己执行bootstrap。
在rtld.c的实现中,这一点主要通过两种方法来实现:一个是全部使用内联函数,另一个是使用局部变量。
那么在ld.so运行开始的时候,它同样可以通过最原始的跳转方式来获得自己的真正地址,这个地址是由内核确定的,它运行时地址的确定和其它的so文件没有任何差别,就是通过call一个地址,然后把返回地址得到运行时PC,然后通过静态的PC和GOT的偏移计算出GOT的运行时位置,还有就是自己的DYNAMIC节的位置,这些都可以获得,然后rtld就可以完成对自己的动态重定位,从而这个so文件内部的函数及变量引用就可以通行了。
然后ld.so就通过内核传过来的exe的文件头,从中找到这个文件的所有依赖,将这些依赖逐个映射入可执行文件的地址空间,然后进行运行时重定位。
五、依赖的加载
这一点通过dl_main函数的
_dl_map_object_deps (main_map, preloads, npreloads, mode == trace, 0);
调用来实现,这个函数中进行的是一个宽度优先处理,这一点在该函数的注释中已经说明
/* Process each element of the search list, loading each of its
auxiliary objects and immediate dependencies. Auxiliary objects
will be added in the list before the object itself and
dependencies will be appended to the list as we step through it.
This produces a flat, ordered list that represents a
breadth-first search of the dependency tree.
其中的一些关键代码摘录一下
/* Pointer to last unique object. */
tail = &known[nlist - 1];
……
for (runp = known; runp; )
……
for (d = l->l_ld; d->d_tag != DT_NULL; ++d) 首先编译传入so文件的所有依赖,这个是主体so的第一级依赖。
if (__builtin_expect (d->d_tag, DT_NEEDED) == DT_NEEDED)
{
/* Map in the needed object. */
struct link_map *dep;
/* Recognize DSTs. */
name = expand_dst (l, strtab + d->d_un.d_val, 0);
/* Store the tag in the argument structure. */
args.name = name;
bool malloced;
int err = _dl_catch_error (&objname, &errstring, &malloced,
openaux, &args);将依赖文件打开,这里只是进行单个文件的打开,并不会递归处理它们的依赖或者其它额外操作。
if (__builtin_expect (errstring != NULL, 0))
{
char *new_errstring = strdupa (errstring);
objname = strdupa (objname);
if (malloced)
free ((char *) errstring);
errstring = new_errstring;
if (err)
errno_reason = err;
else
errno_reason = -1;
goto out;
}
else
dep = args.aux; 将打开文件的描述符保存到dep变量中。
if (! dep->l_reserved) 该so文件尚未被处理
{
/* Allocate new entry. */
struct list *newp;
newp = alloca (sizeof (struct list));
/* Append DEP to the list. */
newp->map = dep;
newp->done = 0;
newp->next = NULL;
tail->next = newp;
tail = newp; 注意,这里是宽度优先的关键,此时是把这个新添加的变量追加在了循环遍历链表的尾部,从而形成宽度优先。这是宽度优先实现的精髓。
++nlist;
/* Set the mark bit that says it's already in the list. */
dep->l_reserved = 1;
}
/* Remember this dependency. */
if (needed != NULL)
needed[nneeded++] = dep;
我们以一个典型的dl_open来分析其实现,可以从中发现,在所有的依赖都加载入内存之后,C库进行了两个重要的操作,也是动态链接的核心,一个是对于so文件的重定位,一个是对各个so文件的init函数的调用,还有一个相对不太重要的就是tls的处理_dl_add_to_slotinfo。
_dl_relocate_object (l, l->l_scope, reloc_mode, 0);这个
……
_dl_init (new, args->argc, args->argv, args->env);
这里从另一个方面说明一个重要的问题,就是这个过程是在所有依赖全部加载之后统一执行的,而不是在对单个文件打开的同时进行的,这个对于过程的理解有重要意义。两部分代码虽然看起来繁琐,但是里面的逻辑还是比较清晰和直观的,而且结合名字和文档应该不难理解。
六、多线程下新线程对线程私有变量TLS的处理
对于C库线程的创建,是通过我们众所周知的pthread_create函数来实现的,它进一步通过调用allocatestack来进行堆栈、pthread以及TLS的分配和初始化。在该函数中,其中的TLS通过下面的_dl_allocate_tls来实现
if (_dl_allocate_tls (TLS_TPADJ (pd)) == NULL)
void *
internal_function
_dl_allocate_tls (void *mem)
{
return _dl_allocate_tls_init (mem == NULL
? _dl_allocate_tls_storage ()
: allocate_dtv (mem)); 这里的allocate_dtv是一个数据结构的分配,没有实质性操作。
}
void *
internal_function
_dl_allocate_tls_init (void *result)
{
if (result == NULL)
/* The memory allocation failed. */
return NULL;
dtv_t *dtv = GET_DTV (result);
struct dtv_slotinfo_list *listp;
size_t total = 0;
size_t maxgen = 0;
/* We have to prepare the dtv for all currently loaded modules using
TLS. For those which are dynamically loaded we add the values
indicating deferred allocation. */
listp = GL(dl_tls_dtv_slotinfo_list);
while (1)
{
size_t cnt;
for (cnt = total == 0 ? 1 : 0; cnt < listp->len; ++cnt)
{
struct link_map *map;
void *dest;
/* Check for the total number of used slots. */
if (total + cnt > GL(dl_tls_max_dtv_idx))
break;
map = listp->slotinfo[cnt].map;
if (map == NULL)
/* Unused entry. */
continue;
/* Keep track of the maximum generation number. This might
not be the generation counter. */
maxgen = MAX (maxgen, listp->slotinfo[cnt].gen);
if (map->l_tls_offset == NO_TLS_OFFSET
|| map->l_tls_offset == FORCED_DYNAMIC_TLS_OFFSET)
{
/* For dynamically loaded modules we simply store
the value indicating deferred allocation. */
dtv[map->l_tls_modid].pointer.val = TLS_DTV_UNALLOCATED;
dtv[map->l_tls_modid].pointer.is_static = false;
continue;
}
assert (map->l_tls_modid == cnt);
assert (map->l_tls_blocksize >= map->l_tls_initimage_size);
#if TLS_TCB_AT_TP
assert ((size_t) map->l_tls_offset >= map->l_tls_blocksize);
dest = (char *) result - map->l_tls_offset;
#elif TLS_DTV_AT_TP
dest = (char *) result + map->l_tls_offset; dest将会在这里进行迭代增加,这里的每个map保存了自己的TLS在总共的可执行文件的TLS block中的偏移量,同样是一个累加值,这个问题在接下来一节中说明。
#else
# error "Either TLS_TCB_AT_TP or TLS_DTV_AT_TP must be defined"
#endif
/* Copy the initialization image and clear the BSS part. */
dtv[map->l_tls_modid].pointer.val = dest;
dtv[map->l_tls_modid].pointer.is_static = true;
memset (__mempcpy (dest, map->l_tls_initimage,
map->l_tls_initimage_size), '\0',
map->l_tls_blocksize - map->l_tls_initimage_size); 这里是实质性操作,也就是通过遍历当前系统中所有已经静态加载的所有模块,其中的map->l_tls_initimage也就是某个so文件镜像中tls的内存地址,因为TLS可能有初始值,所以需要从镜像中拷贝这些值到新创建线程的TLS中。剩余的对其部分清零。
}
total += cnt;
if (total >= GL(dl_tls_max_dtv_idx))
break;
listp = listp->next;
assert (listp != NULL);
}
/* The DTV version is up-to-date now. */
dtv[0].counter = maxgen;
return result;
}
七、模块中map->l_tls_offset的初始化
函数实现于dl-tls.c中,名为_dl_determine_tlsoffset,在rtld.c中调用
void
internal_function
_dl_determine_tlsoffset (void)
{
size_t max_align = TLS_TCB_ALIGN;
size_t freetop = 0;
size_t freebottom = 0;
/* The first element of the dtv slot info list is allocated. */
assert (GL(dl_tls_dtv_slotinfo_list) != NULL);
/* There is at this point only one element in the
dl_tls_dtv_slotinfo_list list. */
assert (GL(dl_tls_dtv_slotinfo_list)->next == NULL);
struct dtv_slotinfo *slotinfo = GL(dl_tls_dtv_slotinfo_list)->slotinfo;
/* Determining the offset of the various parts of the static TLS
block has several dependencies. In addition we have to work
around bugs in some toolchains.
Each TLS block from the objects available at link time has a size
and an alignment requirement. The GNU ld computes the alignment
requirements for the data at the positions *in the file*, though.
I.e, it is not simply possible to allocate a block with the size
of the TLS program header entry. The data is layed out assuming
that the first byte of the TLS block fulfills
p_vaddr mod p_align == &TLS_BLOCK mod p_align
This means we have to add artificial padding at the beginning of
the TLS block. These bytes are never used for the TLS data in
this module but the first byte allocated must be aligned
according to mod p_align == 0 so that the first byte of the TLS
block is aligned according to p_vaddr mod p_align. This is ugly
and the linker can help by computing the offsets in the TLS block
assuming the first byte of the TLS block is aligned according to
p_align.
The extra space which might be allocated before the first byte of
the TLS block need not go unused. The code below tries to use
that memory for the next TLS block. This can work if the total
memory requirement for the next TLS block is smaller than the
gap. */
#if TLS_TCB_AT_TP
/* We simply start with zero. */
size_t offset = 0;
for (size_t cnt = 0; slotinfo[cnt].map != NULL; ++cnt)
{
assert (cnt < GL(dl_tls_dtv_slotinfo_list)->len);
size_t firstbyte = (-slotinfo[cnt].map->l_tls_firstbyte_offset
& (slotinfo[cnt].map->l_tls_align - 1));
size_t off;
max_align = MAX (max_align, slotinfo[cnt].map->l_tls_align);
if (freebottom - freetop >= slotinfo[cnt].map->l_tls_blocksize)
{
off = roundup (freetop + slotinfo[cnt].map->l_tls_blocksize
- firstbyte, slotinfo[cnt].map->l_tls_align)
+ firstbyte; 此处进行迭代值off的更新,其中考虑到了map->l_tls_blocksize的大小和对齐的大小。
if (off <= freebottom)
{
freetop = off;
/* XXX For some architectures we perhaps should store the
negative offset. */
slotinfo[cnt].map->l_tls_offset = off;
continue;
}
}
off = roundup (offset + slotinfo[cnt].map->l_tls_blocksize - firstbyte,
slotinfo[cnt].map->l_tls_align) + firstbyte;
if (off > offset + slotinfo[cnt].map->l_tls_blocksize
+ (freebottom - freetop))
{
freetop = offset;
freebottom = off - slotinfo[cnt].map->l_tls_blocksize;
}
offset = off;
/* XXX For some architectures we perhaps should store the
negative offset. */
slotinfo[cnt].map->l_tls_offset = off; 更新该模块的TLS在整个线程的TLS block的开始逻辑偏移量。
}
GL(dl_tls_static_used) = offset;
GL(dl_tls_static_size) = (roundup (offset + TLS_STATIC_SURPLUS, max_align)
+ TLS_TCB_SIZE);这个也很重要,表示可执行文件最终使用了多少静态TLS,也就是直接和thread结构相邻,可以通过gs或者r2直接偏移得到。该值在_dl_allocate_tls_storage函数中有使用:size_t size = GL(dl_tls_static_size);。
#elif TLS_DTV_AT_TP
………………
#else
# error "Either TLS_TCB_AT_TP or TLS_DTV_AT_TP must be defined"
#endif
/* The alignment requirement for the static TLS block. */
GL(dl_tls_static_align) = max_align;
}
八、glibc中errno的定义和使用
\glibc-2.11.2\include\errno.h
# define errno __libc_errno
extern __thread int errno attribute_tls_model_ie;
#ifdef HAVE_TLS_MODEL_ATTRIBUTE
# define attribute_tls_model_ie __attribute__ ((tls_model ("initial-exec")))
#else
# define attribute_tls_model_ie
#endif
这里强制说明了errno的类型为initial_exec,并且这个也是不同的线程实现自己私有错误码的方法。
另一个问题就是这个errno在一次正确的系统调用结束之后是否会更新的问题。以i386为例
#define PSEUDO(name, syscall_name, args) \
.globl syscall_error; \
lose: SYSCALL_PIC_SETUP \
jmp JUMPTARGET(syscall_error); \
ENTRY (name) \
DO_CALL (syscall_name, args); \
jb lose
这里只有当结果为负值的之后才进行glibc-2.11.2\sysdeps\unix\i386\sysdep.S中进行错误码的赋值,也就是说,errno始终保持上次错误系统调用的错误码,正确情况下该值不更新。它的好处就是可以让上次错误的存活时间尽可能的长,另一方面可以尽量避免非必要的操作。