静态链接的细节

一、前言

  1. 先了解目标文件的内容(ELF目标文件的文件细节

  2. 事实:可执行文件中的代码段和数据段都是由输入的目标文件中合并而来

  3. 静态链接所要解决的问题:怎样将多个目标文件的组合起来,形成一个可以使用的程序或者一个更大的模块

二、空间与地址的分配

合并到输出文件的方式

  1. 按序叠加:将输入的目标文件按照次序叠加起来
  • 缺陷:浪费空间,并且段的装在地址和空间的对齐单位是页,不足一个页的大小也要占用一个页,这样会造成内存空间大量的内部碎片
  1. 相似段合并:现在链接器空间分配的策略基本采用这个方式

源码与编译

  1. a.c
extern int shared;
extern void swap(int *a, int* b);

int main(void)
{
        int a = 100;
        swap(&a, &shared);
}
  1. b.c
int shared = 1;

void swap(int *a, int* b)
{
        *a ^= *b ^= *a ^= *b;
}
  1. 编译源码与注意点说明
# 编译环境:64位Ubuntu16.04
$ gcc -c a.c -fno-stack-protector -o a.o  //必须在编译时加入参数-fno-stack-protector,不然链接报错
$ gcc -c b.c -fno-stack-protector -o b.o
$ ld a.o b.o  -e main -o ab

# 如果在64为机子上要编译为32位的文件,gcc后边加上参数-m32

# 百度答案:
# 编译源码到目标文件时,一定要加“-fno-stack-protector”,不然默认会调函数“__stack_chk_fail”进行栈相关检查,
# 然而是手动裸ld去链接,没有链接到“__stack_chk_fail”所在库文件,所以在链接过程一定会报错: undefined reference to `__stack_chk_fail'
# 解决办法不是在链接过程中,而是在编译时加此参数,强制gcc不进行栈检查,从而解决。此外,ld 的时候加上参数"-e main"就可以了,意思是将main函数作为程序入口,ld 的默认程序入口为_start。

链接过程分析

第一步:空间与地址的分配

  1. 工作:扫描所有目标文件,获取目标文件的段长度,并将他们合并,计算出输出文件中各个段合并后的长度与位置,并进行映射

  2. 目标文件分析

$ objdump -h a.o 

a.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000002c  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
  1 .data         00000000  0000000000000000  0000000000000000  0000006c  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  0000006c  2**0
                  ALLOC
  3 .comment      00000036  0000000000000000  0000000000000000  0000006c  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000a2  2**0
                  CONTENTS, READONLY
  5 .eh_frame     00000038  0000000000000000  0000000000000000  000000a8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
$ objdump -h b.o 

b.o:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         0000004b  0000000000000000  0000000000000000  00000040  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000004  0000000000000000  0000000000000000  0000008c  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  0000000000000000  0000000000000000  00000090  2**0
                  ALLOC
  3 .comment      00000036  0000000000000000  0000000000000000  00000090  2**0
                  CONTENTS, READONLY
  4 .note.GNU-stack 00000000  0000000000000000  0000000000000000  000000c6  2**0
                  CONTENTS, READONLY
  5 .eh_frame     00000038  0000000000000000  0000000000000000  000000c8  2**3
                  CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
  1. 可执行文件分析
$ objdump -h ab 

ab:     file format elf64-x86-64

Sections:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .text         00000077  00000000004000e8  00000000004000e8  000000e8  2**0  # Size: 0x77 = 0x2c + 0x4b(a.o和b.o的.text段大小合并)
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .eh_frame     00000058  0000000000400160  0000000000400160  00000160  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .data         00000004  00000000006001b8  00000000006001b8  000001b8  2**2  # Size: 0x04 = 0x0 + 0x04(a.o和b.o的.data段大小合并)
                  CONTENTS, ALLOC, LOAD, DATA
  3 .comment      00000035  0000000000000000  0000000000000000  000001bc  2**0
                  CONTENTS, READONLY
#说明:
#链接前,a.o和b.o的所有段的VMA都是0,因为虚拟空间还没有分配,默认为0
#链接成ab后各个段都被分配到相应的虚拟地址
#.text段分配地址为0x004000e8,大小为0x77字节,.data段分配地址为0x006001b8,大小为0x04字节
#在linux下,i386 ELF可执行文件默认从地址(.text)0x08048000开始分配, 而x64是0x400000
  1. 在linux下,为什么 i386 ELF可执行文件默认从地址(.text)0x08048000开始分配,而x64是0x400000(转载)

第二步:符号解析与重定位

  1. 重定位源码分析
$ objdump -d a.o 

a.o:     file format elf64-x86-64  

Disassembly of section .text:

0000000000000000 <main>:
   0:	55                   	push   %rbp
   1:	48 89 e5             	mov    %rsp,%rbp
   4:	48 83 ec 10          	sub    $0x10,%rsp
   8:	c7 45 fc 64 00 00 00 	movl   $0x64,-0x4(%rbp)
   f:	48 8d 45 fc          	lea    -0x4(%rbp),%rax
  13:	be 00 00 00 00       	mov    $0x0,%esi  # 未分配空间之前,目标文件代码段中的起始地址以0x0开始
  18:	48 89 c7             	mov    %rax,%rdi
  1b:	b8 00 00 00 00       	mov    $0x0,%eax
  20:	e8 00 00 00 00       	callq  25 <main+0x25> #近址相对位移调用,相对于下一条指令(0x25)的偏移,这里偏移为0,因此实际调用地址为0x25
  25:	b8 00 00 00 00       	mov    $0x0,%eax
  2a:	c9                   	leaveq 
  2b:	c3                   	retq

$ objdump -d ab 

ab:     file format elf64-x86-64

Disassembly of section .text:

00000000004000e8 <main>:
  4000e8:	55                   	push   %rbp
  4000e9:	48 89 e5             	mov    %rsp,%rbp
  4000ec:	48 83 ec 10          	sub    $0x10,%rsp
  4000f0:	c7 45 fc 64 00 00 00 	movl   $0x64,-0x4(%rbp)
  4000f7:	48 8d 45 fc          	lea    -0x4(%rbp),%rax
  4000fb:	be b8 01 60 00       	mov    $0x6001b8,%esi  #地址修正,shared变量在.data段,地址为0x006001b8(计算公式:S+A)
  400100:	48 89 c7             	mov    %rax,%rdi
  400103:	b8 00 00 00 00       	mov    $0x0,%eax
  400108:	e8 07 00 00 00       	callq  400114 <swap>  # 近址相对位移调用,相对于下一条指令(0x40010d),偏移0x7,因此调用地址为0x400114
  40010d:	b8 00 00 00 00       	mov    $0x0,%eax
  400112:	c9                   	leaveq 
  400113:	c3                   	retq   

0000000000400114 <swap>:
  400114:	55                   	push   %rbp
....

# 对于400108地址的0x7: 已知函数<swap>的虚拟地址为0x400114,则计算公式为0x400114 + 0 - (0x4000e8 + 0x25)= 0x7  (计算公式:S+A-P)
  1. 指令修正方式

  2. 重定位表(重定位段)

  • 在ELF文件中往往是一个或多个段,比如上边的.rel.text包含.text的重定位段

  • 查看重定位表

$ objdump -r a.o 

a.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:  # 作用的段
OFFSET           TYPE              VALUE 
0000000000000014 R_X86_64_32       shared
0000000000000021 R_X86_64_PC32     swap-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE 
0000000000000020 R_X86_64_PC32     .text

# R_X86_64_32 : 绝对寻址方式修正
# R_X86_64_PC32:相对寻址修正
  • 相关结构体
$ cat /usr/include/elf.h
...
typedef struct
{
  Elf32_Addr    r_offset;               /* Address */  # 对于可重定位文件来说,是偏移量,对于可执行文件或共享文件来说,是第一字节的虚拟地址
  Elf32_Word    r_info;                 /* Relocation type and symbol index */
} Elf32_Rel;

typedef struct
{
  Elf64_Addr    r_offset;               /* Address */
  Elf64_Xword   r_info;                 /* Relocation type and symbol index */
} Elf64_Rel;
...
  1. 符号解析
  • 缺少符号会导致链接错误的原因(即是平时出现链接符号未定义的现象):每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号,重定位过程中,每一个重定位入口都是对一个符号的引用,那么当链接器须要对某个符号进行引用和重定位时,他就要确定这个符号的目标地址,这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。

  • 查看a.o的符号表

$ readelf -s a.o 

Symbol table '.symtab' contains 11 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS a.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     8: 0000000000000000    44 FUNC    GLOBAL DEFAULT    1 main
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND shared  # 未定义类型,在链接器在扫描结束后,这些符号必须在全局符号表可以找到,否则就会报未定义错误
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND swap

三、COMMON块机制

存在的原因

  1. 编译器和链接器允许不同类型的弱符号存在,但最本质的原因是链接器不支持符号类型,即链接器无法判断各个符号的类型是否一致(但可以知道大小)

  2. 早期的C语言程序员经常忘记“extern”来声明变量,使得多个目标产生同一个变量的定义,为了解决这个问题,直接将未初始化的变量都当做COMMON块来处理

规则

  1. 现代的链接机制在处理弱符号(未初始化的全局变量定义)的时候,采用的是COMMON块的机制

  2. 情况一:当存在多个弱符号,存在多个COMMON块空间大小不一致时,以最大的那块为准

  3. 情况二:当存在一个强符号和多个弱符号时,输出结果的符号所占空间与强符号相同,而当链接过程中有弱符号的大小大于强符号时,ld链接器会发出警告

//a.c 
#include <stdio.h>

int value = 1;

int main(void)
{
	printf("value:%d\n", value);
	return 0;
}

//b.c
#include <stdio.h>

long value;

//编译和警告信息
$ gcc a.c b.c 
/usr/bin/ld: Warning: alignment 4 of symbol `value' in /tmp/cckJJ0X1.o is smaller than 8 in /tmp/ccIQn1MA.o

未初始化的全局变量与BSS段的关系

  1. 未初始化的全局变量最后是放到BSS段的

  2. 在编译器编译之后的弱符号所占空间是不确定的,因为可能其他的编译单元也用到这个符号,且所占的空间大小是更大的,因此无法在目标文件的BSS段分配空间,但链接器在读取完所有输入目标文件后,任何一个弱符号的最终大小是可以确定的,所以在最终的输出文件的BSS段分配空间

GCC的扩展

  1. GCC的“fno-common”允许我们把所有未初始化的全局变量不以COMMON块处理

  2. 定义变量时,使用“__attribute__扩展”,如int global attribute((nocommon)),相当于一个强符号了

四、静态库链接

静态库

  1. 概念:可以简单看成一组目标文件的集合,即很多目标文件经过打包(ar工具)后形成的文件(C语言静态库libc位于/usr/lib/i386-linux-gnu/libc.a或/usr/lib/x86_64-linux-gnu/libc.a )

  2. 查看某个函数在具体哪个目标文件,使用"objdump"或“readelf”加上文本查找工具"grep"来查找

$ objdump -t libc.a | grep  "printf"
...
printf.o:     file format elf32-i386
00000000 g     F .text	0000001e __printf
00000000         *UND*	00000000 vfprintf
00000000 g     F .text	0000001e _IO_printf
00000000 g     F .text	0000001e printf
...

printf的递归依赖

  1. 当我们一个程序只用到printf时,理论上我们只需要将我们程序的.o文件和printf.o文件链接起来,应该就可以执行了,但printf还依赖于vprintf(位于vprintf.o),而vprintf又依赖于其他目标文件,而ld链接器会处理这些繁琐的事务,自动寻找所有需要的符号以及他们所在的目标文件,将这些文件从libc.a中“解压”出来,最终链接为可执行文件,但实际上程序不单单要用到C语言库libc.a,还需要辅助性质的目标文件和库

  2. 链接器的中间步骤细节

# -verbose表示将整个编译链接过程的步骤打印出来
# -fno-builtin参数关闭内置函数优化选项,禁止GCC将printf替换为puts函数,GCC这优化可以提高运行速度
# 源码
$ cat test_hello.c
#include <stdio.h>
int main(void)
{
        printf("hello world!\n");
        return 0;
}

$ gcc -static --verbose -fno-builtin test_hello.c
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 5.4.0-6ubuntu1~16.04.12' --with-bugurl=file:///usr/share/doc/gcc-5/README.Bugs --enable-languages=c,ada,c++,java,go,d,fortran,objc,obj-c++ --prefix=/usr --program-suffix=-5 --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --with-sysroot=/ --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-libmpx --enable-plugin --with-system-zlib --disable-browser-plugin --enable-java-awt=gtk --enable-gtk-cairo --with-java-home=/usr/lib/jvm/java-1.5.0-gcj-5-amd64/jre --enable-java-home --with-jvm-root-dir=/usr/lib/jvm/java-1.5.0-gcj-5-amd64 --with-jvm-jar-dir=/usr/lib/jvm-exports/java-1.5.0-gcj-5-amd64 --with-arch-directory=amd64 --with-ecj-jar=/usr/share/java/eclipse-ecj.jar --enable-objc-gc --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu
Thread model: posix
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.12) 
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic' '-march=x86-64'
# 使用cc1,就是GCC的c语言编译器,将test_hello.c编译为临时的/tmp/ccPrToJR.s
 /usr/lib/gcc/x86_64-linux-gnu/5/cc1 -quiet -v -imultiarch x86_64-linux-gnu test_hello.c -quiet -dumpbase test_hello.c -mtune=generic -march=x86-64 -auxbase test_hello -version -fno-builtin -fstack-protector-strong -Wformat -Wformat-security -o /tmp/ccPrToJR.s
GNU C11 (Ubuntu 5.4.0-6ubuntu1~16.04.12) version 5.4.0 20160609 (x86_64-linux-gnu)
	compiled by GNU C version 5.4.0 20160609, GMP version 6.1.0, MPFR version 3.1.4, MPC version 1.0.3
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/5/../../../../x86_64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
 /usr/lib/gcc/x86_64-linux-gnu/5/include
 /usr/local/include
 /usr/lib/gcc/x86_64-linux-gnu/5/include-fixed
 /usr/include/x86_64-linux-gnu
 /usr/include
End of search list.
GNU C11 (Ubuntu 5.4.0-6ubuntu1~16.04.12) version 5.4.0 20160609 (x86_64-linux-gnu)
	compiled by GNU C version 5.4.0 20160609, GMP version 6.1.0, MPFR version 3.1.4, MPC version 1.0.3
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
Compiler executable checksum: 8087146d2ee737d238113fb57fabb1f2
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic' '-march=x86-64'
# 调用as程序,是GNU的汇编器,将/tmp/ccPrToJR.s汇编成临时的/tmp/cc09PaMy.o
 as -v --64 -o /tmp/cc09PaMy.o /tmp/ccPrToJR.s
GNU assembler version 2.26.1 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.26.1
COMPILER_PATH=/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/
LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/5/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/5/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-static' '-v' '-fno-builtin' '-mtune=generic' '-march=x86-64'
# 调用collect2程序,这是ld链接器的一个包装,它会调用ld链接器完成目标文件的链接,再对链接结果进行一些处理,主要收集所有与程序初始化相关的信息并且构造初始化结构
 /usr/lib/gcc/x86_64-linux-gnu/5/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/5/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper -plugin-opt=-fresolution=/tmp/ccIDhvRf.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_eh -plugin-opt=-pass-through=-lc --sysroot=/ --build-id -m elf_x86_64 --hash-style=gnu --as-needed -static -z relro /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/5/../../.. /tmp/cc09PaMy.o --start-group -lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crtn.o
# 至少有几个库文件和目标文件被链接进来:
# crt1.o、crti.o、crtbeginT.o、libgcc.a、libgcc_eh.a、libc.a、crtend.o、crtn.o
  1. 示意图

静态库中目标文件的组织方式

  1. 采用的方式是一个目标文件只包含一个函数,比如libc.a里边printf.o只有printf函数,strlen.o只有strlen()函数

  2. 这样组织的原因:

五、小结

  1. 输入目标文件的各个段是通过相似段合并的方式合并到输出文件中的

  2. 链接器为它们分配在输出文件中的空间和地址,一旦输入段的最终地址被确定,就可以进行符号的解析和重定位(指令修正方式)

  3. 链接器会把各个输入目标文件对于外部符号的引用进行解析,把每个段中须重定位的指令和数据进行修补,使他们指向正确的位置

posted @ 2021-10-31 14:36  回忆浅唱  阅读(155)  评论(0编辑  收藏  举报