iOS程序员的自我修养-MachO文件静态链接(三)

本篇将用下面例子分析:

// a.c 文件
extern int global_var;
void func(int a);
int main() {
    int a = 100;
    func(a+global_var);
    return 0;
}

=========================

// b.c 文件
int global_var = 1;
void func(int a) {
    global_var = a;
}

=========================

//生成a.o b.o
xcrun -sdk iphoneos clang -c a.c b.c -target arm64-apple-ios12.2

// a.o和b.o链接成可执行文件ab
xcrun -sdk iphoneos clang a.o b.o -o ab -target arm64-apple-ios12.2

复制代码

请注意,生成的a.o和b.o目标文件,都是基于arm64。a.o和b.o目标文件通过静态链接后生成可执行文件ab。(由于基于arm64,其实链接过程,也有动态链接库libSystem.B.dylib(系统库)参与,但本文忽略动态链接的参与,只讨论静态链接。要是基于X86,就不会有动态库的参与。后一篇文章专门再讨论动态链接)

这里先介绍两个概念:模块和符号。

  1. 模块:我们可以理解一个源代码文件为一个模块。比如上面a模块和b模块。我们现在写一个程序,不可能所有代码都在一个源代码文件上,都是分模块的,一般一个类在一个源文件上,就成为一个模块,模块化好处就是复用、维护,还有编译时候,未改动的模块,不用重新编译,直接用之前编译好的缓存。
  2. 符号:简单理解就是函数名和变量名,比如上面总共有三个符号:global_var、main、func。

空间和地址分配

相似段合并

静态链接:输入多个目标文件,输出一个文件(一般是可执行文件)。这个过程中,把多个目标文件里相同性质的段合并到一起。比如:上面a.o和b.o目标文件合并成可执行文件ab。合并过程是a.o里面的代码段和b.o里面的代码段一起合并成ab里面的代码段,数据段同理,两个目标文件里面的数据段一起合并成ab里的数据段…

两步链接

第一步 空间与地址分配

扫描所有的输入目标文件,并且获得他们各个段的长度、属性和位置,将输入目标文件中的符号表(下面详细讲解)中所有的符号定义和符号引用收集起来(就是收集函数和变量的定义与引用),统一放到一个全局符号表。这一步中,链接器能够获得所有的输入目标文件的段的长度,将它们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。

第二步 符号解析与重定位

使用上面第一步收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析和重定位,调整代码中的地址等。

重定位

a模块使用了global_var和func两个符号,那是怎么知道这两个符号的地址呢?

在a.o目标文件中:

global_var(地址0)和func(地址0x2c,这条指令本身地址)都是假地址。编译器暂时用0x0和0x2c替代着,把真正地址计算工作留给链接器。通过前面的空间与地址分配可以得知,链接器在完成地址与空间分配后,就可以确定所有符号的虚拟地址了。那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。

在链接后的ab可执行文件中:

可以看到global_var(地址0x100008000,指向data段,值为1)和func(地址0x100007f90,指向func函数地址)都是真正的地址。

重定位表

链接器是怎么知道a模块里哪些指令要被调整,这些指令如何调整。事实上a.o里,有一个重定位表,专门保存这些与重定位相关的信息。而且每个section的section_64的header的reloff(重定位表里的偏移)和nreloc(几个需要重定位的符号),让链接器知道a模块的哪个section里的指令需要调整。

struct section_64 { /* for 64-bit architectures */
    char        sectname[16];   /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint64_t    addr;       /* memory address of this section */
    uint64_t    size;       /* size in bytes of this section */
    uint32_t    offset;     /* file offset of this section */
    uint32_t    align;      /* section alignment (power of 2) */
    uint32_t    reloff;     /* file offset of relocation entries */
    uint32_t    nreloc;     /* number of relocation entries */
    uint32_t    flags;      /* flags (section type and attributes)*/
    uint32_t    reserved1;  /* reserved (for offset or index) */
    uint32_t    reserved2;  /* reserved (for count or sizeof) */
    uint32_t    reserved3;  /* reserved */
};
复制代码

重定位表可以认为是一个数组,数组里的元素为结构体relocation_info。

//定义在里
struct relocation_info {
   int32_t  r_address;  /* offset in the section to what is being
                   relocated */
   uint32_t     r_symbolnum:24, /* symbol index if r_extern == 1 or section
                   ordinal if r_extern == 0 */
        r_pcrel:1,  /* was relocated pc relative already */
        r_length:2, /* 0=byte, 1=word, 2=long, 3=quad */
        r_extern:1, /* does not include value of sym referenced */
        r_type:4;   /* if not 0, machine specific relocation type */
};
复制代码

每个参数都有注释,r_address和r_length足够让我们知道要重定位的字节了;r_symbolnum(当为外部符号)是符号表的index。其它参数可先不管。

例如a.o中,重定位表记录符号_func和_global_var,两个符号需要重定位。并且给出了两个符号在代码段的位置,和指向符号表的index,链接时候(a.o里面有这两符号的引用,然后b.o里面有这两符号的定义,一起合并到全局符号表里),在全局符号表里,可以找到这两个符号的虚拟内存位置和其它信息(见下面符号表),就可以完成重定位工作了。

上面说r_symbolnum(当为外部符号)是符号表的index,我们这里再给大家介绍一个加载命令:符号表

加载命令–符号表

//定义在中
struct symtab_command {
    uint32_t    cmd;        /* LC_SYMTAB */
    uint32_t    cmdsize;    /* sizeof(struct symtab_command) */
    uint32_t    symoff;     /* symbol table offset */
    uint32_t    nsyms;      /* number of symbol table entries */
    uint32_t    stroff;     /* string table offset */
    uint32_t    strsize;    /* string table size in bytes */
};
复制代码

上篇文章 我们说过,加载命令的前两个参数都是cmd和cmdsize。符号表加载命令的symoff和nsyms告诉了链接器符号表的位置(偏移)和个数;stroff和strsize告诉字符串表的位置和大小。

符号表也是一个数组,里面元素是结构体nlist_64

struct nlist_64 {
    union {
        uint32_t n_strx;   /* index into the string table */
    } n_un;
    uint8_t  n_type;       /* type flag, see below */
    uint8_t  n_sect;       /* section number or NO_SECT */
    uint16_t n_desc;       /* see  */
    uint64_t n_value;      /* value of this symbol (or stab offset) */
};
复制代码

n_un历史原因,忽略;n_strx字符串表的index,可以找到符号对应的字符串;n_sect第几个section;n_valuen符号的地址值。其它先不管,要是有兴趣,可以去头文件查看。

符号解析

从普通程序员的角度看,为什么要链接,因为一个模块(a模块)可能引用了其它模块(b模块)的符号,所以需要把所有模块(目标文件)链接在一起。重定位就是:链接器会去查找由所有输入的目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。其中有2个常见的错误:

  1. “ld: dumplicate symbols”,多个目标文件里有相同的符号,导致全局符号表出现多个一样的符号。
  2. “Undefined symbols”,需要重定位的符号,在全局符号表里没有找到(一个符号:有引用,未定义)。

静态库链接

一个静态库可以简单看成一组目标文件的集合,即多个目标文件经过压缩打包后形成的一个文件。

静态库链接:是指自己的模块与静态库里的某个模块(用到的某个目标文件,或多个目标文件)链接成可执行文件。其实和静态链接概念一样,只是这里,我们这里取了静态库里的某个/多个目标文件与我们自己的目标文件一起作为输入。

静态库一般包含多个目标文件,一个目标文件可能只有一个函数。因为链接器在链接静态库的时候是以目标文件为单位的。假如我们把所有函数放在一个目标文件里,那我们可能只用到一个函数,确把很多没用的函数一起链接到可执行文件里。