读《程序是如何加载的》知识点整理(简版)

1,668 阅读9分钟

文章写作灵感来自于在阅读过程中的截图的知识点信息整理,本人从事有6年左右的iOS开发经验,更多的是从linux系统iOS应用开发的角度去收集信息。文章更多描述了静态链接和动态链接的内容。

-Linux系统的可执行文件格式是ELF。

  • 程序源代码编译后的机器指令经常被放在代码段,代码段常见的名字有".code"或".text";全局变量和局部静态变量数据经常放在数据段(也称".data")。除了最基本的代码段、数据段和BSS段以外,还有3个段分别是只读数据段(.rodata)、注释信息段(.comment)、和堆栈提示段。
// .data段 
int global_init_var = 1; 

// .bss段,(.bss段是只相对于虚拟空间而言存在)
int global_uninit_var;

void func1(int i) {
}

int main() {
    //.data段
    static int static_var = 2;
    //.bss段
    static int static_var2;
    
    int a = 1;
    int b;
    func1(static_var + static_var2 + a + b);
    
    return 0;
}

截屏2023-02-01 11.21.25.png

  • GCC提供了扩展机制,使得程序员可以指定变量所处的段:
__attribute__((section(*FOO*))) int global = 42;
__attribute__((section(*BAR*))) void foo();

在全局变量或函数之前加上__attribute__((section("name")))属性就可以把相应的变量或函数放到以"name"作为段名的段中。

  • 链接过程的本质就是要把多个不同的目标文件之间相互"粘"到一起。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址引用。在链接中,我们将函数和变量统称为符号,函数名或变量名就是符号名。

  • 链接过程主要包括了地址和空间分配、符号决议、重定位等步骤。符号决议也称符号绑定名称绑定、名称决议,甚至称地址绑定、指定绑定。从细节上的区别,决议更倾向于静态链接,而绑定更倾向于动态链接,即他们所使用的范围不一样。最基本的静态链接过程:模块源代码文件(如.c)经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库一起链接形成最终可执行文件。

  • 在编译器以及链接器处理符号时,它们使用某种名称修饰的方法,使得每个函数签名对应一个修饰后的名称。编译器在将C++源代码编译成目标文件时,会讲函数和变量的名字进行修饰,形成符号名。

  • 强符号和弱符号:对于C/C++语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。我们可以通过GCC的__attribute_((weak))来定义任何一个强符号为弱符号。强符号和弱符号都是针对定义来说的,不是针对符号的引用。

  • 弱引用和强引用:我们所看到的对外部目标文件的符号引用在目标文件被最终链接称可执行文件时,它们须被正确决议,如果没有找到该符号的定义,链接器就会报符号为定义错误,这种被称为强引用。而弱引用是,在被处理时,如果该符号有定义,则链接器将该符号的引用决议。一般对于未定义的弱引用,链接器默认为0,或者是一个特殊的值。弱引用和弱符号主要用于库的链接过程。

  • 动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。基本实现是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接成一个独立的可执行文件。覆盖装入和页映射是两种很典型的动态装载方法,原则上都是利用了程序的局部性原理。

  • 装载时重定位是解决动态模块中有绝对地址引用的办法之一,但有一大缺点是,指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。为了解决共享对象指针中绝对地址的重定位问题,希望程序模块中共享的指令部分在装载时,不需要因为装载地址的改变而改变,所以实现了地址无关代码(PIC,Position-independent Code)的方案。基本思想就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。

  • 模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定,代码地址无关的基本思想是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT, Global Offset Table),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。链接器在装载模块的时候,会查找每个变量所在的地址,然后填充GOT的各个项,以确保每个指针所指向的地址正确。由于每个GOT本身是在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

  • 动态链接相对于静态链接慢的主要原因是,动态链接下 对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用页要先定位GOT,然后再进行间接跳转;还有一个原因,动态链接的链接工作是在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作,会寻找并装载所需要的共享对象,为解决模块之间的函数引用进行符号查找地址以及重定位等工作,这势必会减慢程序等启动速度。

  • 动态链接性能优化方式:延迟绑定(Lazy Binding)。基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等)。也就是程序开始执行时,并不就将模块间的函数调用进行绑定。而是需要用到时,才用动态链接器进行绑定。

  • 为了完成动态链接,最关键的还是所依赖的符号和相关文件信息。静态链接中,有一个专门的段叫做符号表".symtab"(Symbol Table, MachO文件中有此表),里面包含了所有关于该目标文件的符号的定义和引用。为了表示动态链接模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表(Dynamic Symbol Table)的段来保存这些信息,这个段的段名通过叫做".dynsym"(Dynamic Symbol),只保存了与动态链接相关的符号。对于模块内部的符号,比如模块私有变量则不保存。很多时候动态链接的模块同时拥有".dynsym"和".symtab"两个表;".symtab"中往往保存了所有符号,包括".dynsym"中的符号。

动态链接的步骤基本分3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。

截屏2023-02-01 17.18.49.png 完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,这个表称全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象。当一个新的共享对象被装载进来的时候,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号

截屏2023-02-01 17.47.25.png

-在Linux中,从文件本身的格式来看,动态库实际上跟一般的共享对象没有区别。主要的区别是:共享对象是由动态链接在程序启动之前负责装载和链接的,这一系列步骤都是由动态链接器【自动】完成,对于程序本身是透明的;而动态库的装载则是通过一系列由动态链接器提供的【API调用】完成(由程序员控制调用时机)。具体地讲共有4个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose)。这几个函数的声明和相关变量被定义在了系统标准头文件<dlfcn.h>

截屏2023-02-02 10.06.05.png

截屏2023-02-02 10.08.10.png dlopen()两个参数:一个文件名和一个标志。文件名就是一个动态库so文件,标志指明是否立刻计算库的依赖性。如果设置为 RTLD_NOW 的话,则立刻计算;如果设置的是 RTLD_LAZY,则在需要的时候才计算。另外,可以指定 RTLD_GLOBAL,它使得那些在以后才加载的库可以获得其中的符号。(# dlopen系列函数详解

截屏2023-02-02 10.10.59.png

截屏2023-02-02 10.19.30.png

来自于原文的代码演示:

#include <dlfcn.h>

double test(double x) {
    printf("test_function \n");
    return x + 10.0;
}

int main(int argc, char *argv[]) {
    void *handle;
    double (*func)(double);
    char* error;
    /*
    #define RTLD_LAZY 0x1
    #define RTLD_NOW 0x2
    #define RTLD_LOCAL 0x4
    #define RTLD_GLOBAL 0x8
    */
    handle = dlopen(argv[1], RTLD_NOW);
    if(handle == NULL) {
        printf("open library %s error: %s\n", argv[1], dlerror());
        return 0;
    }
    func = dlsym(handle, "test");
    if((error = dlerror()) != NULL) {
        printf("Symbol test not found: %s\n", error);
        goto exit_test;
    }

    double y = func(3.1415926);
    printf("open success \n y = %f", y);

    exit_test:
        dlclose(handle);    
    return 1;
}

xcode工程中,main文件中导入以上代码执行输出: image.png -共享库系统路径:

截屏2023-02-02 11.29.36.png

截屏2023-02-02 12.58.29.png

-strip工具可清除掉共享库或者可执行文件的所有符号和调试信息。

ps:如有知识点错误,还请高人斧正,不吝赐教。在此感谢。

扩展:

  • MachO文件截图:

WeChate86d2f558689cef22a961e28925d8076.png

WeChatbed2b71a4c730e6db6459e3a159905da.png

个人意识:通过学习这本书,再结合分析dyld的流程和查看MachO文件的结构表,结合着底层代码,能够更清晰掌握从源代码预处理、编译、汇编、链接再到应用启动中的库加载启动的逻辑。