实验代码之链接

我们知道,从实验代码的撰写构建到最终变成一个完整的可执行文件之间有一个编译的过程,对于初学者而言,这个步骤往往都是直接依靠代码环境平台间接完成的,而我们作为代码的开发者却没能接触到这个过程中更为核心的问题。

要想了解清楚这个编译过程,我们就要先搞清楚常说的“编译”到底包含多少具体步骤。以下转载不周山笔记对于此过程的详细介绍:

预处理器:将 C 语言代码(da.c)转化成 da.i 文件(gcc –E),对应于预处理命令 cpp
编译器:C 语言代码(da.c, wang.c)经过编译器的处理(gcc -0g -S)成为汇编代码(da.s, wang.s)
汇编器:汇编代码(da.s, wang.s)经过汇编器的处理(gcc 或 as)成为对象程序(da.o, wang.o)
链接器:对象程序(da.o, wang.o)以及所需静态库(lib.a)经过链接器的处理(gcc 或 ld)最终成为计算机可执行的程序
加载器:将可执行程序加载到内存并进行执行,loader 和 ld-linux.so

根据以上对于各种处理器对于代码程序的处理,这个过程已经变得很清晰,在此我用自己的理解简单地对这些处理器展开简单的描述。

以main.c以及sum.c为例,代码块如下:

/* main.c */
/* $begin main */
int sum(int *a, int n);

int array[2] = {1, 2};

int main() 
{
    int val = sum(array, 2);
    return val;
}
/* $end main */

/* sum.c */
/* $begin sum */
int sum(int *a, int n)
{
    int i, s = 0;
    
    for (i = 0; i < n; i++) { 
        s += a[i];
    }
    return s;
}        
/* $end sum */
预处理器

从文件类型上看,这个处理器将.c文件转化成.i文件。内容上,它是将文件内部所定义的宏定义/头文件引用/特殊符号/条件编译等指令(也就是一般.c文件开头的#代码部分)进行相关处理:

宏定义指令,如 #define a b 这种伪指令,预编译所要做的是将程序中的所有 a 用 b 替换,但作为字符串常量的 a 则不被替换。还有 #undef,则将取消对某个宏的定义,使以后该串的出现不再被替换
条件编译指令,如 #ifdef, #ifndef, #else, #elif, #endif等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉
头文件包含指令,如 #include “FileName” 。该指令将头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理
特殊符号,预编译程序可以识别一些特殊的符号。例如在源程序中出现的 LINE 标识将被解释为当前行号(十进制数),FILE 则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换

进行命令操作之后代码如下(gcc -E main.c -o main.i 命令语句能够生成可见的main.i文件进而查看):

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc -E main.c
# 1 "main.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "main.c"


int sum(int *a, int n);

int array[2] = {1, 2};

int main()
{
    int val = sum(array, 2);
    return val;
}

从这个main.c gcc 成 .i文件后,因为源代码并没有头文件的缘故,所以并没有引入其它代码或者说替换宏定义出现的场所。但是当我在这个文件最前面加上#includ<stdio.h>头文件之后,main.i文件则如下。显然,这是从头文件索引过来的代码。
在这里插入图片描述在这里插入图片描述

编译器

这个处理器主要是将程序员所撰写的源代码进一步向机器代码转变,让机器能够更好地理解代码内容,而最终转换成汇编语言代码。运行gcc -S main.i -o main.s之后打开main.s文件可见如下汇编语言:

.file	"main.c"
	.text
	.globl	array
	.data
	.align 8
	.type	array, @object
	.size	array, 8
array:
	.long	1
	.long	2
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
	movl	$2, %esi
	leaq	array(%rip), %rdi
	call	sum@PLT
	movl	%eax, -4(%rbp)
	movl	-4(%rbp), %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
	.section	.note.GNU-stack,"",@progbits

汇编器

源代码变成汇编代码之后,要想最终变成程序文件,还需要进一步优化并且真正转化为机器语言,成为对象程序,也就是二进制的目标文件。在其他博客上看到这样一段说明:

只编译不链接形成.o文件。里面包含了对各个函数的入口标记,描述,当程序要执行时还需要链接(link).链接就是把多个.o文件链成一个可执行文件。如 GCC 编译器就可以指定 -c选项进行输出。打开是乱码。
成为对象程序之后可以再次反编译看到优化之后的汇编代码

链接器

链接部分要做的事情就是把多个.o文件链成一个可执行文件。

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc main.o sum.o -o prog
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ ./prog

加载器

可执行程序加载到内存并进行执行。

除此之外,还可以一步到位完成整个编译过程:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ gcc -Og -o prog main.c sum.c
zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ ./prog

之后反汇编(objdump -dx prog)可以看到这样一段汇编代码:

00000000000005fa <main>:
 5fa:	48 83 ec 08          	sub    $0x8,%rsp
 5fe:	be 02 00 00 00       	mov    $0x2,%esi
 603:	48 8d 3d 06 0a 20 00 	lea    0x200a06(%rip),%rdi        # 201010 <array>
 60a:	e8 05 00 00 00       	callq  614 <sum>
 60f:	48 83 c4 08          	add    $0x8,%rsp
 613:	c3                   	retq   

0000000000000614 <sum>:
 614:	b8 00 00 00 00       	mov    $0x0,%eax
 619:	ba 00 00 00 00       	mov    $0x0,%edx
 61e:	eb 09                	jmp    629 <sum+0x15>
 620:	48 63 ca             	movslq %edx,%rcx
 623:	03 04 8f             	add    (%rdi,%rcx,4),%eax
 626:	83 c2 01             	add    $0x1,%edx
 629:	39 f2                	cmp    %esi,%edx
 62b:	7c f3                	jl     620 <sum+0xc>
 62d:	f3 c3                	repz retq 
 62f:	90                   	nop

重点讲述链接过程:

链接:

链接分为符号解析和重定位两个部分:

第一步:符号解析 Symbol resolution
我们在代码中会声明变量及函数,之后会调用变量及函数,所有的符号声明都会被保存在符号表(symbol table)中,而符号表会保存在由汇编器生成的 object 文件中(也就是 .o 文件)。符号表实际上是一个结构体数组,每一个元素包含名称、大小和符号的位置。
在 symbol resolution 阶段,链接器会给每个符号应用一个唯一的符号定义,用作寻找对应符号的标志。
第二步:重定位 Relocation
这一步所做的工作是把原先分开的代码和数据片段汇总成一个文件,会把原先在 .o 文件中的相对位置转换成在可执行程序的绝对位置,并且据此更新对应的引用符号(才能找到新的位置)

1.符号解析:

首先,符号(包括全局变量以及函数,局部变量存储在栈中而不在符号表中)可以分为以下三种:

  • 全局符号,由模块内部(单独一个.c文件)定义又能在其它模块使用的全局变量或者函数,如非static定义的全局变量
  • 外部符号,由模块外部定义且在本模块使用的符号
  • 局部符号,在本模块内部定义且只能在本模块使用的符号,特指static定义的全局变量和模块内static函数,只能在该模块使用

使用nm命令查看prog文件符号表如下:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ nm prog
0000000000201010 D array
0000000000201018 B __bss_start
0000000000201018 b completed.7697
                 w __cxa_finalize@@GLIBC_2.2.5
0000000000201000 D __data_start
0000000000201000 W data_start
0000000000000520 t deregister_tm_clones
00000000000005b0 t __do_global_dtors_aux
0000000000200df8 t __do_global_dtors_aux_fini_array_entry
0000000000201008 D __dso_handle
0000000000200e00 d _DYNAMIC
0000000000201018 D _edata
0000000000201020 B _end
00000000000006d4 T _fini
00000000000005f0 t frame_dummy
0000000000200df0 t __frame_dummy_init_array_entry
000000000000084c r __FRAME_END__
0000000000200fc0 d _GLOBAL_OFFSET_TABLE_
                 w __gmon_start__
00000000000006e4 r __GNU_EH_FRAME_HDR
00000000000004b8 T _init
0000000000200df8 t __init_array_end
0000000000200df0 t __init_array_start
00000000000006e0 R _IO_stdin_used
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
00000000000006d0 T __libc_csu_fini
0000000000000660 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
00000000000005fa T main
0000000000000560 t register_tm_clones
00000000000004f0 T _start
000000000000061b T sum
0000000000201018 D __TMC_END__

其中第一列是当前符号的实际地址(在执行到不同符号时按照该地址查询其数据值),第二列是当前符号的类型(需要按照符号表对照类型),第三列是当前符号的名称。

使用readelf命令符-s参数查看具体符号表:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -s main.o

Symbol table '.symtab' contains 12 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.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     8 OBJECT  GLOBAL DEFAULT    3 array
     9: 0000000000000000    33 FUNC    GLOBAL DEFAULT    1 main
    10: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND sum

这个表看来就比较清晰,Name是符号名称,Ndx表示符号所在节以及是否在本文件定义,Bind表示是全局符号还是局部符号等,Type则表明符号类型,Size是符号大小,Value表示符号存储地址。

在此引入全局符号中强符号和弱符号的概念:

  • 强符号:函数和初始化的全局变量
  • 弱符号:未初始化的全局变量

并且有以下规则需要遵守:

  • 不能出现多个同名的强符号,不然就会出现链接错误
  • 如果有同名的强符号和弱符号,选择强符号,也就意味着弱符号是『无效』d而
  • 如果有多个弱符号,随便选择一个

根据不周山笔记,我们做以下实验进行讨论:

/* main.c */
/* $begin main */

int sum(int *a, int n);

int array[2] = {1, 2};

int main() 
{
    int val = sum(array, 2);
    return val;
}
/* $end main */

就main.c文件源代码进行讨论,我们根据强弱符号定义可以很清楚地判定:
全局变量array数组已初始化定义,所以是强符号。
val是局部变量,并不会被链接器解析。

/* sum.c */
/* $begin sum */
int sum(int *a, int n)
{
    int i, s = 0;
    
    for (i = 0; i < n; i++) { 
        s += a[i];
    }
    return s;
}        
/* $end sum */

sum.c文件中:
i和s以及数组a都只是局部变量,不会被链接器解析。

因为当两个名称相同且同时初始化的全局变量在被链接的不同目标文件中被同时定义时,链接就会出错。而同时出现几个名称相同的弱符号定义时,数据上可能会出现问题,例如分别定义了double型和int型的同一个名称的弱符号,内存数据极可能造成数据错误。所以一般不推荐使用全局变量。

2.重定位:

目标文件总共分为以下三种:

可重定位目标文件 Relocatable object file (.o file)
每个 .o 文件都是由对应的 .c 文件通过编译器和汇编器生成,包含代码和数据,可以与其他可重定位目标文件合并创建一个可执行或共享的目标文件
可执行目标文件 Executable object file (a.out file)
由链接器生成,可以直接通过加载器加载到内存中充当进程执行的文件,包含代码和数据
共享目标文件 Shared object file (.so file)
在 windows 中被称为 Dynamic Link Libraries(DLLs),是类特殊的可重定位目标文件,可以在链接(静态共享库)时加入目标文件或加载时或运行时(动态共享库)被动态的加载到内存并执行

其中,可重定位目标文件也就是汇编之后的二进制.o文件,而链接就是将各种可重定位目标文件链接成可执行目标文件或者共享目标文件。

这里我们只讨论链接生成可执行目标文件的过程以及其中关于ELF的一些区别。

那么,链接是如何将可重定位目标文件转化为可执行目标文件的呢?这里我们就要先知道可重定位目标文件目标文件和可执行目标文件的一些区别。

  • 可重定位目标文件,又被称为链接视图,需要链接之后才能真正被执行使用,它的文件代码和数据地址都从0开始,实际上并没有进行固定存储
  • 可执行目标文件,又被称为执行视图,能够被直接执行。它的文件代码和数据地址是虚拟空间中的地址

我们看一下实际操作结果:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ objdump -s -d main.o > main.o.txt

将main.o可重定位目标文件反汇编之后得到的汇编代码如下:

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:	be 02 00 00 00       	mov    $0x2,%esi
   d:	48 8d 3d 00 00 00 00 	lea    0x0(%rip),%rdi        # 14 <main+0x14>
  14:	e8 00 00 00 00       	callq  19 <main+0x19>
  19:	89 45 fc             	mov    %eax,-0x4(%rbp)
  1c:	8b 45 fc             	mov    -0x4(%rbp),%eax
  1f:	c9                   	leaveq 
  20:	c3                   	retq   

上面有一段prog文件的反汇编代码,二者相互比对即可简单说明这个虚拟内存的问题。

所以,在一定程度上我们可以将二者理解成未加工的汽车零件和加工完成可以上路的汽车。

二者的区别当然不仅于此,更重要的区别在于ELF上。

  • ELF是一种标准的文件格式,它包含代码和数据以及相关重定位信息。其中所说代码,就是目标文件中的代码段,存放在.text节中,是只读的。数据又被细分为很多种,其中.data节中数据是已经初始化的全局变量,也就是符号解析中的强符号;.bss节中存放的是未初始化的全局变量。这两个数据节都是可读可写的。而.rodata节中存放的数据则是只读数据,如printf格式串,switch跳转表等。这些数据和代码在可执行目标文件中则是将所链接的所有可重定位目标文件全部整合起来的。

ELF文件在流程上,是从ELF头开始,去向它所指向的节头表,而节头表中则存储着ELF中所有节的名字以及相对于ELF头的相对偏移地址,进而执行所有的节部分。

ELF头中则存放着一些基本信息文件,实际操作及所读取信息如下:
未链接之前的main.o可重定位目标文件:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -h main.o
ELF 头:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF64
  数据:                              2 补码,小端序 (little endian)
  版本:                              1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              REL (可重定位文件)
  系统架构:                          Advanced Micro Devices X86-64
  版本:                              0x1
  入口点地址:               0x0
  程序头起点:          0 (bytes into file)
  Start of section headers:          720 (bytes into file)
  标志:             0x0
  本头的大小:       64 (字节)
  程序头大小:       0 (字节)
  Number of program headers:         0
  节头大小:         64 (字节)
  节头数量:         12
  字符串表索引节头: 11

将main.o和sum.o文件链接生成prog可执行目标文件:

zhaoxiaoan@zhaoxiaoan:~/桌面/Computer_Systems/chap7_code$ readelf -h prog
ELF 头:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF64
  数据:                              2 补码,小端序 (little endian)
  版本:                              1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              DYN (共享目标文件)
  系统架构:                          Advanced Micro Devices X86-64
  版本:                              0x1
  入口点地址:               0x4f0
  程序头起点:          64 (bytes into file)
  Start of section headers:          6472 (bytes into file)
  标志:             0x0
  本头的大小:       64 (字节)
  程序头大小:       56 (字节)
  Number of program headers:         9
  节头大小:         64 (字节)
  节头数量:         28
  字符串表索引节头: 27

其余还有一些区别。如在可执行目标文件中,ELF头中e_entry字段会给出执行入口地址;其中程序头表还会描述ELF节是如何映射到具体存储段。而在可重定位目标文件e_entry则为0,它的使命就是链接而不可被直接执行;它并不会有此映射描述。
链接部分到此打止。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值