动手调试C库-2 --musl-c的动态链接器

0. 序

本篇承接上一篇的动手调试C库-1 ,主要讨论musl-c的动态链接器。

3. overview

musl-c的动态链接器代码主要位于ldso/dlstart.c, ldso/dynlink.c。musl-c的动态链接器重定位主要分为三个阶段,第一阶段是_dlstart_c函数,第二阶段是__dls2__dls2b函数,第三个阶段是__dls3函数。

描述动态库的数据结构是dso, 在dynlink.c中维护了四个重要的全局链表。

  • 第一个链表是关于动态库的全局加载序。static dso* head, static dso* tail对应了该链表的首尾部,以及dso.prev, dso.next来遍历链表。在动态链接4中谈到,动态链接器以广搜顺序加载动态库依赖,假设现在有动态库a,b,c,d,e,f,其中可执行文件o依赖于a,b,表示为o->a, o->b,且a->c, b->c,b->d,并且e,f被设置为LD_PRELOAD, 且e->c, 那么head, tail链表中的顺序为o->e->f->a->b->c->d 。设置为LD_PRELOAD的项被认为是广搜图中可执行文件的直接依赖,因此得到了这样的链表序。
  • 第二个链表对应的是符号查找链表,动态链接器的find_sym函数将按照这个链表序查找符号。static dso* syms_tail对应这个链表尾部,链表首部为static dso* head,通过dso.syms_next来遍历链表。在不考虑dlopen引入新的动态库时,符号查找链表上的动态库以及顺序与全局加载链表相一致。
  • 第三个链表对应的是tls链表,即线程局部存储,dso.tls记录了该动态库对应的tls信息。全局变量__libc.tls_head记录了该链表的头部,static struct tls_module* tls_tail记录了该链表的首部,该链表把每个动态库的tls属性串接起来,描述了整个程序的tls属性。挖一下坑,接下来专门写一篇讨论TLS的博文。
  • 第四个链表对应的是fini_head链表,描述了动态库的析构顺序。链表头是static struct dso* fini_head,通过dso.fini_next遍历链表。

接下来讨论一些有意思的技术细节。

4. copy relocation

musl-c的动态链接器的重定位工作是由do_relocs函数负责,关于copy relocation的关键代码如下:

	ctx = type==REL_COPY ? head->syms_next : head;
	// 这里ctx表示选择前面提到的第二个链表,符号链表的头节点,如果是
	// copy relocation, 就不能从head开始搜索,因为
	// head(可执行文件)中的符号是待copy的
    def = (sym->st_info>>4) == STB_LOCAL
            ? (struct symdef){ .dso = dso, .sym = sym }
            : find_sym(ctx, name, type==REL_PLT);
    // find_sym函数是利用gnu.hash在符号链表中查找符号
    sym_val = def.sym ? (size_t)laddr(def.dso, def.sym->st_value) : 0; 
    // sym_val 是查找到的符号的虚拟地址
    tls_val = def.sym ? def.sym->st_value : 0;
    
    /*...............
    .................. 省略中间的一些不太重要的代码
    */
    switch(type) {
    case REL_COPY:
        memcpy(reloc_addr, (void *)sym_val, sym->st_size);
        // 把前面 find_sym找到的符号的值copy到可执行文件对应的位置中
        break;
    /*...............
    ..................
    */        
     }

一个非常隐晦的地方是这里的写法没有考虑到TLS的情况。如果对应的copy relocation是TLS变量,就不应该使用sym_val来进行copy,因为TLS变量的虚拟地址毫无意义。正确的语义是把对应符号的值拷贝到可执行文件对应的dso的tls image中

不太清楚musl-c是不是为了简洁性,故意删掉了这种情况,因为copy relocation本来就是不太好的事情。

5. 再看全局符号介入与LD_PRELOAD

在动态链接2中提到全局符号介入,语义上是对上层保证如下的功能:

  • 按加载动态库的顺序寻找符号,多个重名的全局符号时选择第一个找到的。
  • 如果有通过LD_PRELOAD变量加载上来的动态库,优先在这些动态库中查找符号。
  • 可执行文件中的符号不可被抢占。

回顾上面提到的全局加载链表和全局符号链表的组织方式,我们可以看到musl-c是如何实现这些要求的。

  • 由于可执行文件对应的dso结构总是符号查询链表的头部,因此除了copy relocation外,其它情况下符号查询总是从可执行文件的动态符号表开始,保证了可执行文件中的符号不被抢占。
  • LD_PRELOAD加载上来的动态库在符号查询链表中的位置紧跟着可执行文件,因此查询完可执行文件后立即向LD_PRELOAD中加载的动态库查询。
  • 由于全局加载序对应的链表与广搜动态库的顺序一致,而全局符号链表在不考虑dlopen的情况下与全局加载序一致,因此最终查询符号的顺序即对应广搜的加载序(之所以需要排除dlopen介入的情况,是因为dlopen如果有RTLD_LOCAL`参数,要求这些动态库不能出现在符号查询的链表中,而只出现在全局加载序的链表中)。

6. 弱符号与动态链接器

弱符号本身的定义要求link editor如果遇到重名的强符号,应该以强符号为准。那如果动态链接器按照符号链表查询时遇到弱符号应如何处理呢?不妨看看find_sym函数的源码

static struct symdef find_sym(struct dso *dso, const char *s, int need_def)
{
    return find_sym2(dso, s, need_def, 0);
}
static inline struct symdef find_sym2(struct dso *dso, const char *s, int need_def, int use_deps)
{
    /*.................... 省略一些不重要的代码
    ..........*/
    struct symdef def = {0};
    struct dso **deps = use_deps ? dso->deps : 0;
    // 由find_sym调用时,use_deps=0,for循环沿着符号链表搜索符号
    for (; dso; dso=use_deps ? *deps++ : dso->syms_next) {
        Sym *sym;
        if ((ght = dso->ghashtab)) { // 如果存在gnu.hash节,使用gnu.hash搜索符号
            sym = gnu_lookup_filtered(gh, ght, dso, s, gho, ghm);
        } else {
            if (!h) h = sysv_hash(s);
            sym = sysv_lookup(s, h, dso);
        }
        if (!sym) continue;
/* ...............................省略一些不重要的代码
..............................*/
        def.sym = sym;
        def.dso = dso;
        break;
    }
    return def;
}

可以看到动态链接器在找到第一个符号就立即返回了,而没有判断是否为弱符号。man ld.so中对环境变量LD_DYNAMIC_WEAK的论述解释了这个问题。

Old glibc versions (before 2.2), provided a different behavior: if the linker found a symbol that was weak, it would remember that symbol and keep searching in the remaining shared libraries. If it subsequently found a strong definition of the same symbol, then it would instead use that definition. (If no further symbol was found, then the dynamic linker would use the weak symbol that it initially found.)

The old glibc behavior was nonstandard. (Standard practice is that the distinction between weak and strong symbols should have effect only at static link time.) In glibc 2.2, the dynamic linker was modified to provide the current behavior (which was the behavior that was provided by most other implementations at that time).

7. 动态库的初始化和析构顺序

动态库之间存在依赖,那么不同动态库之间的初始化的先后顺序也应该遵循依赖关系。system V规定如果一个动态库A依赖于B,那么A的初始化应当晚于B,即初始化顺序应该是拓扑排序的逆序,而如果依赖关系存在环则初始顺序未定义。相应地,动态库的析构则应该是动态库的初始化的逆序

musl-c的动态链接器在__dls3中调用了queue_ctors函数,该函数利用加载动态库时构建的依赖关系图dso.deps,进行深搜,构建拓扑排序。

do_init_fini函数负责根据queue_ctors函数得到的初始化顺序,依次调用各个动态库相应的DT_INITDT_INIT_ARRAY节的保存的函数指针,初始化各个动态库。每初始化一个动态库,就把这个动态库挂载到fini_head链表的头部,使得fini_head链表的顺序与动态库的初始化顺序相反。

一个困惑:在C++中全局对象的构造函数也是在DT_INIT_ARRAY中被调用的,既然cout,cin是标准库的全局对象,在执行可执行文件的构造函数时,根据上面的讨论,应该已经完成了初始化才对,为什么在constructor函数中调用cout会引发段错误呢?

8. dlsym的符号查找与RTLD_NEXT参数含义

dlsym用来查找动态加载的动态库中的符号,RTLD_NEXT参数在man页中解释如下。

RTLD_NEXT
Find the next occurrence of the desired symbol in the search order after the current object. This allows one to provide a wrapper around a function in another shared object, so that, for example, the definition of a function in a preloaded shared object (see LD_PRELOAD in ld.so(8)) can find and invoke the “real” function provided in another shared object (or for that matter, the “next” definition of the function in cases where there are multiple layers of preloading).

之前一直对这个特殊句柄的含义半懂不懂,现在我们看看musl-c的源码怎么实现的。

static void *do_dlsym(struct dso *p, const char *s, void *ra)
{
    int use_deps = 0;
    if (p == head || p == RTLD_DEFAULT) {
        p = head;
    } else if (p == RTLD_NEXT) {
        p = addr2dso((size_t)ra);
        if (!p) p=head;
        p = p->next;
    } else if (__dl_invalid_handle(p)) {
        return 0;
    } else
        use_deps = 1;
    struct symdef def = find_sym2(p, s, 0, use_deps);
    if (!def.sym) {
        error("Symbol not found: %s", s);
        return 0;
    }
    if ((def.sym->st_info&0xf) == STT_TLS)
        return __tls_get_addr((tls_mod_off_t []){def.dso->tls_id, def.sym->st_value-DTP_OFFSET});
    if (DL_FDPIC && (def.sym->st_info&0xf) == STT_FUNC)
        return def.dso->funcdescs + (def.sym - def.dso->syms);
    return laddr(def.dso, def.sym->st_value);
}

dlsym函数调用do_dlsym来完成实际的工作,参数struct dso* p其实就是dlsym传入的句柄,char* s就是要查找的符号名,dlsym中嵌入了一小段汇编,通过依赖于体系结构的方法(比如在riscv里面读取ra寄存器即可)拿到了dlsym函数的返回地址,对应这个参数void* ra

可以看到,如果传入的句柄是RTLD_NEXT,就调用addr2dso函数拿到实际的动态库句柄。因为动态链接器知道每一个动态库的加载地址,因此这个函数利用ra来获取到是哪一个动态库调用了dlsym,并返回给do_dlsym。随后p=p->next,从全局加载序中当前动态库的下一个动态库开始搜索所需要的符号。这就是RTLD_NEXT的含义。

我个人觉得这里是不是有一点bug,从RTLD_NEXT的语义上来讲,不应该写p=p->next,而应该是p=p->syms_next

OK,第二篇写到这里。

你可能感兴趣的:(C与C++,c,动态链接)