03_ELF文件_静态链接

https://github.com/carloscn/blog/issues/11

03_ELF文件_静态链接

1. Pre-condition

前面两节是对单独的c文件编译出的elf文件进行解析,静态链接是对多个c文件编译出的二进制文件进行合并的过程。我们对下面的简单的c文件进行静态链接。本文基于aarch64 armv8体系架构编译出的文件对多个目标文件的链接过程做出探究。

flowchart LR
    A[a.c]-->B[a.o]-->C[ab.o]-->D[ab.elf]
    E[b.c]-->F[b.o]-->C
    G[libc]-->D

1.1 c文件

File1: a.c

extern int b_share;
extern int b_func(int c, int d);

static int a_0 = 0x7f;
static const char *a_1 = "aaaaaaaaaaaa";
static int a_3;
static int a_4 = 0;

static int a_func(int a, int b)
{
    return a - b;
}

int main(void)
{
    int m = 0;
    a_0 ++;
    a_1[5];
    a_3 = 1;
    a_4 = 5;
    m = b_func(m, b_share);
    m = a_func(m, b_share);
    return 0;
}

File2: b.c

1.2 o及asm文件

编译: aarch64-linux-gnu-gcc -c a.c b.c 分别编译出a.o, b.o

我们查看一下a.o和b.o的具体的指令段

aarch64-linux-gnu-objdump -s -d a.o

aarch64-linux-gnu-objdump -s -d b.o

1.3 ab.o合体文件

使用aarch64-linux-gnu-gcc -c a.o b.o -o ab.o 生成ab.o合体文件。

2. 链接器对elf的空间与地址分配

2.1 相似段合并

链接器的作用就是对几个目标文件合并成一个输出文件,从上面的例子中可以看出,a.o和b.o合并为ab.o,ab.o和clib里面的函数合并称为ab.elf文件。a.o和b.o文件合并为ab.o采用的是相似段合并的策略,把相似的段合并到一起,在合成ab.o文件之后,.text段连续的将a.o和b.o的.text段合并到了一起,.data段也类似。这里需要注意的是:

  • .bss段:bss段在目标文件不占用文件的空间(输出可执行文件的空间),但是bss段在装载时占用地址空间(虚拟地址空间分配)

  • 这里有个虚拟地址空间分配的概念,对于.text和.data可执行文件和虚拟地址空间都需要有空间分配。

2.2 两步链接(Two-pass Linking)

  • 空间与地址分配

  • 符号解析与重定位

探究一下合并之后段发生变化的过程:

aarch64-linux-gnu-objdump -h a.o

aarch64-linux-gnu-objdump -h b.o

aarch64-linux-gnu-ld a.o b.o -e main -o ab

aarch64-linux-gnu-objdump -h ab

关于VMA和LMA的概念这里需要阐述一下:

Every loadable or allocatable output section has two addresses. The first is the VMA, or virtual memory address. This is the address the section will have when the output file is run. The second is the LMA, or load memory address. This is the address at which the section will be loaded. In most cases the two addresses will be the same. An example of when they might be different is when a data section is loaded into ROM, and then copied into RAM when the program starts up (this technique is often used to initialize global variables in a ROM based system). In this case the ROM address would be the LMA, and the RAM address would be the VMA.

总结下来:

  • VMA和LMA一般情况下都是一致的

  • 嵌入式系统里面,程序放在ROM中的,LMA通常为加载后的RAM的地址,VMA通常为ROM的地址。

  • 在ARMv8体系架构里面,VMA和LMA是一致的。

06_ARMv8_指令集_一些重要的指令arrow-up-right 的1.4部分,可以看到嵌入式系统里面LMA和VMA不一样的例子,ARMv8的LDR指令和ADRP指令加载的地址上对VMA和LMA由非常明显的区别。

image-20220323103217350
  • 注意1,如果对于单个的.o文件,LMA和VMA地址全都是0,因为虚拟内存空间还没有被分配,等到链接之后,VM和LM都已经有值了,

  • 注意2,.data段在操作系统中是从0x00410220开始分配的,.text段是在操作系统中0x004000e8分配的。

  • 注意3,bss的section最后叠加出来的,并非a和b之和,而多了4个字节。在a.o和b.o文件中,bss段还没有展开,只是有个占位符,等着进行链接之后,才会给分配地址,才有了真正意义的.bss。所以说多了4个字节并不准确,是在链接之后才开始对bss处理。

对于注意2,在《程序员的自我修养》一书中p103提到:

在Linux下,ELF可执行文件默认从地址0x08048000开始分配。

对于这个设定,在网上也能找到相关资料,但是在x86的体系架构中和arm体系架构中对文件进行编译,得到的.data段却有着不同的结果,是从0x00400000开始分配的。这个似乎和书上讲的不一致。本文先留个悬念,后面到虚拟内存映射再回来解答这个问题。

2.3 符号解析与重定位

a.c文件中引用了b_func,如果单独gcc -c a.c文件,此时b_func并没有值,因此在汇编上可以看到指令 ,此时跳转指令bl 地址为0。

然而在ab文件汇编里面,已经进行了符号重定位,则在ab文件的汇编中可以看到:

看到bl指令后面已经有地址 0x400194,正是b_func的入口程序。前后比较main的变化,左边是未链接的main函数,右边是经过链接的main函数

image-20220323114805949

上面需要调整的地方有个重定位表(Relocation Table)保存这些重定位的信息:

aarch-linux-gnu-objdump -r a.o

可以看到OFFSET就是上面compare结果需要修改的位置。

使用readelf -s a.o可以读出哪里的符号没有被定义:

第18/19行,可以看到b_share和b_func没有被定义。

2.4 Common block

如果有全局未初始化的变量,该符号会被认为是弱符号,同样的通过readelf -s也可以看到该符号被标记为COM。

对于c.c文件:

image-20220323122937142

c_3被标记为COM,且占4个字节。这里就存在一种冲突的情况。

  • 若在其中一个c文件中,定义了int c_3,未初始化,又在另一个文件里面定义了 double c_3,未初始化。按照common的链接规则,以最大的size为准,因此这里就是使用double c_3的size,为8。如果定义了强符号,谁强,则按照谁的来。

  • 如果弱符号的长度大于强符号,ld链接器会报错:

    ld warning: alignment 4 of symbol xxx is smaller than 8

  • 如果在多个文件中没有extern关键字,是的编译器在多个目标文件中产生同一个变量的定义。编译器通常把未初始化的变量当做COMMON类型处理。GCC提供方法让我们把所有的未初始化的全局变量不以COMMON类型处理:

    • 使用编译选项: -fno-common

    • 使用attribute扩展: int c_3 __attribute__((nocommon))

  • 如果不以common形式处理,那么在链接的时候遇到相同的符号,就会发生编译报错。

3. 静态库链接

3.1 静态链接库文件生成

  • 准备d.c文件,里面提供sum, sub 和 abs函数,其中sum和sub有本身提供,abs依赖于另一个库文件中的函数,编译成d.a文件。

  • 准备e.c文件,里面提供e_ab函数供abs调用,编译成e.a文件。

  • 准备f.c文件,为main函数调用,使用这个库。

d.c

e.c

  • 编译: d.c和e.c文件 aarch64-linux-gnu-gcc -c e.c d.c

  • 生成静态库文件:aarch64-linux-gnu-ar crv ed.a e.o d.o

3.2 静态链接库文件解压

aarch64-linux-gnu-ar -t ed.a

3.3 使用静态链接文件编译

两个方法:

  • 使用ar -t解压成o文件,然后进行链接

  • 直接使用gcc进行编译链接aarch64-linux-gnu-gcc f.c ed.a -I ./ -L ./ -o f.elf

关闭内置优化aarch64-linux-gnu-gcc f.c ed.a -I ./ -L ./ -o f.elf -fno-builtin

4. TIPS

4.1 内建函数

内建函数,顾名思义,就是编译器内部实现的函数。这些函数跟关键字一样,可以直接使用,无须像标准库函数那样,要 #include 对应的头文件才能使用。内建函数的函数命名,通常以 __builtin 开头。这些函数主要在编译器内部使用,主要是为编译器服务的。内建函数的主要用途如下。

4.2 -fno-builtin编译选项

aarch64-linux-gnu-gcc f.c ed.a -I ./ -L ./ -o f.elf -fno-builtin

Ref

最后更新于