2018-11-23
在Linux操作系统中,一段C程序从被写下到最终被CPU执行,要经过一段漫长而又复杂的过程。下图展示了这个过程
编译就是把程序员所写的高级语言代码转化为对应的目标文件的过程。一般来说高级语言的编译要经过预处理、编译和汇编这几个过程。
预编译过程对源代码做了如下的操作
gcc可以使用如下命令对C语言进行预编译并且把预编译的结果输出到hello.i文件中
gcc -E hello.c -o hello.i
编译就是对预处理之后的文件进行词法分析、语法分析、语义分析并优化后生成相应的汇编文件。我们使用如下命令来编译预处理之后的文件
gcc -S hello.i -o hello.s
或者我们也可以把预处理和编译合为一步
gcc -S hello.c -o hello.s
汇编的目的是把汇编代码转化为机器指令,因为几乎每一条汇编指令都对应着一条机器指令,所以汇编的过程相对而言非常的简单。我们可以使用如下命令实现汇编
gcc -c hello.s -o hello.o
或者我们也可以直接把源代码文件编译为目标文件
gcc -c hello.c -o hello.o
汇编操作所生成的文件叫做目标文件(Object File),目标文件的结构与可执行文件是一致的,它们之间只存在着一些细微的差异。目标文件是无法被执行的,它还需要经过链接这一步操作,目标文件被链接之后才可以产生可执行文件。
下面我们了解一下目标文件的格式以及链接这一步具体做了哪些工作。
Linux下的目标文件格式叫做ELF(Executable Linkable Format),ELF的格式如下图所示:
ELF header是ELF文件中最重要的一部分,header中保存了如下的内容
从header中我们可以得到很多有用的信息,其中的一个尤其重要,那就是段表的位置和长度,通过这一信息我们可以从ELF文件中获取到段表(Section Hedaer Table),在ELF文件中段表的重要性仅次于header。
段表保存了ELF文件中所有的段的基本属性,包括每个段的段名、段在ELF文件中的偏移、段的长度以及段的读写权限等等,段表决定了整个ELF文件的结构。
既然段表决定了所有的段的属性,那么ELF文件中的段究竟是个什么东西呢?其实段只是对ELF文件内的不同类型的数据的一种分类。例如,我们把所有的代码(指令)放到一个段中,并且给这个段起名.text
;把所有的已经初始化的数据放在.data
段;把所有的未初始化的数据放在.bss
段;把所有的只读数据放在.rodata
段,等等。
至于为什么要把数据(指令在ELF文件中也算是一种数据,它是ELF文件的数据之一)分为不同的类型,除了方便进行区分之外,还有以下几个原因
既然分段有着诸多的好处,那么接下来我们就近距离的看一看ELF文件中的段信息。有如下的示例文件 hello.c
int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i)
{
printf("%d\n", i);
}
int main(void)
{
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var + a + b);
return a;
}
使用如下命令把源代码编译成目标文件
gcc -c hello.c -o hello.o
接下来我们可以使用objdump
命令查看ELF文件的内部结构,-h 表示显示ELF文件的头部信息
objdump -h hello.o
得到结果如下
hello.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000055 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 00000098 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a0 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a0 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000036 0000000000000000 0000000000000000 000000a4 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000da 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
可以看到上面的结果中显示了7个段,每个段都有一些属性信息,下面我们了解一下这些属性的含义
我们重点关注.text
、.data
、.bss
和.rodata
这几个段:
-s
参数表示将段的内容以十六进制的方式打印出来,而-d
参数则会对所有包含指令的段进行反汇编,因此使用如下命令就可以获取代码段的详细信息objdump -s -d hello.oELF还包含了很多其它类型的段,感兴趣的话可以查阅相关资料做进一步的了解。
因为现在机器的内存和磁盘空间已经足够大,而动态链接对于内存和磁盘的节省十分有限,所以我们已经可以忽略动态链接带来的在节省使用空间上的优势。相反因为没有了动态链接库的依赖,不需要考虑动态链接库的不同的版本,静态链接的文件可以做到链接即可执行,减少了运维和部署上的复杂度,是非常的方便的,在有些新发明的语言(例如golang)中链接过程默认已经开始使用静态链接。
静态链接过程分为两步
我们有如下的a.c和b.c两个源文件
// a.c
extern int shared;
int main()
{
int a = 100;
swap(&a, &shared);
}
// b.c
int shared = 1;
void swap(int *a, int *b)
{
*a ^= *b ^= *a ^= *b;
}
编译源代码得到目标文件a.o和b.o
gcc -c a.c b.c -zexecstack -fno-stack-protector -g
链接a.o和b.o目标文件得到可执行文件
ld a.o b.o -e main -o ab
在ELF文件中有两个叫做重定位表和符号表的段我们之前没有介绍,它们对于链接过程起着及其重要的作用,接下来我们详细了解一下这两个段
可以简单的认为是编译器把所有需要被重定位的数据存放在重定位表中,这样链接器就能够知道该目标文件中哪些数据是需要被重定位的。
我们可以使用 objdump -r a.o
来获取重定位表的信息
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000014 R_X86_64_32 shared
0000000000000021 R_X86_64_PC32 swap-0x0000000000000004
我们也可以使用 readelf -S a.o
命令来详细的了解一个ELF文件
…
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000002c 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000430
0000000000000030 0000000000000018 I 18 1 8
其中以.rela
开头的就是重定位段,上面的.rela.text
就存放了需要被重定位的指令的信息,同样的如果是需要被重定位的数据则段名应该叫做.rela.data
。
上面的操作都是针对目标文件a.o进行的,我们对目标文件b.o执行以上命令可以发现其既不存在数据段的重定位表,也不存在代码段的重定位表。这是因为b.c中的变量shared和函数swap都已经明确的知道了其地址,所以不需要重定位。
而a.c中则不一样,因为在a.c中变量shared和函数swap都没有定义在当前的文件中,因此编译后产生的目标文件中不存在它们的地址信息,所以编译器需要把它们放在重定位表中,等到链接时再到其它目标文件中找到对应的符号信息之后对其进行重定位。
目标文件中的某些部分是需要在链接的时候被使用到的“粘合剂”,这些部分我们可以把其称之为“符号”,符号就保存在符号表中。符号表中保存的符号很多,其中最重要的就是定义在本目标文件中的可以被其它目标文件引用的符号和在本目标文件中引用的全局符号,这两个符号呈现互补的关系。
使用命令 readelf -s
可以查看符号表的内容
$ readelf -s a.o
8: 0000000000000000 79 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
$ readelf -s b.o
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 shared
9: 0000000000000000 75 FUNC GLOBAL DEFAULT 1 swap
$ readelf -s ab
10: 0000000000000000 0 FILE LOCAL DEFAULT ABS a.c
11: 0000000000000000 0 FILE LOCAL DEFAULT ABS b.c
12: 0000000000400114 45 FUNC GLOBAL DEFAULT 1 swap
13: 00000000006001a0 4 OBJECT GLOBAL DEFAULT 3 shared
14: 00000000006001a4 0 NOTYPE GLOBAL DEFAULT 3 __bss_start
15: 00000000004000e8 44 FUNC GLOBAL DEFAULT 1 main
16: 00000000006001a4 0 NOTYPE GLOBAL DEFAULT 3 _edata
17: 00000000006001a8 0 NOTYPE GLOBAL DEFAULT 3 _end
命令 nm
也可实现对符号的查看操作
$ nm a.o
U __stack_chk_fail
0000000000000000 T main
U shared
U swap
$ nm b.o
0000000000000000 D shared
0000000000000000 T swap
$ nm ab
00000000006001a4 D __bss_start
00000000006001a4 D _edata
00000000006001a8 D _end
00000000004000e8 T main
00000000006001a0 D shared
0000000000400114 T swap
其中 D 代表该符号是已经初始化的变量,T 表示该符号是指令,U 代表该符号尚未定义。
从上面的结果我们可以看到,链接过程确实是对目标文件的符号做了“粘合”操作。
问:重定位表和符号表之间是什么关系?
答:它们之间是相互合作的关系,链接器首先要根据重定位表找到该目标文件中需要被重定位的符号,之后再根据符号表去其它的目标文件中找到可以相匹配的符号,最后对本目标文件中的符号进行重定位。
从上面的过程中我们可以看到链接器最终需要完成的工作有三个
操作系统一般都附带有一些库文件,Linux最有名的就是libc静态库,其一般位于 /usr/lib/libc.a
,libc.a其实是个压缩文件,里面包含了printf.o,scanf.o,malloc.o,read.o等等的库文件。当使用到标准库中的内容时,链接器会对用户目标文件和标准库进行链接,得到最终的可执行文件。
链接默认情况下生成的是一个ELF文件,这在Linux操作系统上是符合我们的要求的。但是我们有的时候想要其它的目标文件格式,甚至我们有时候想自己写操作系统内核,此时ELF文件的格式就显然不能满足我们的要求了。事实上我们可以通过一些命令行参数或者直接使用配置文件的方式来控制链接的过程以及链接产生的结果,详细内容可以参考命令ld的相关文档,这里不再做介绍。
在上一节我们已经通过链接得到了可执行文件,在可执行文件中包含了很多的段(section),但是一旦这些段被加载到内存中之后,我们就不在乎他到底是什么类型的数据,而只在乎这份数据在内存中的读写权限。所以可执行文件被加载到内存中的数据可以分为两类:可读不可写和可读可写。
由于现代操作系统均采用分页的方式来管理内存,所以操作系统只需要读取可执行文件的文件头,之后建立起可执行文件到虚拟内存注5的映射关系,而不需要真正的将程序载入内存。在程序的运行过程中,CPU发现有些内存页在物理内存中并不存在并因此触发缺页异常,此时CPU将控制权限转交给操作系统的异常处理函数,操作系统负责将此内存页的数据从磁盘上读取到物理内存中。数据读取完毕之后,操作系统让CPU jmp到触发了缺页异常的那条指令处继续执行,此时指令执行就不会再有缺页异常了。
忽略物理内存地址以及缺页异常的影响,一旦操作系统创建进程(fork)并载入了可执行文件(exec),那么虚拟内存的分布应该如下图所示
可以看到在ELF文件中的多个section在内存中被合并为3个segment
Segment name | Data type | |
---|---|---|
1 | BSS segment | 保存未初始化的数据 |
2 | Data segment | 保存已经初始化的数据 |
3 | Text segment | 保存程序的指令 |
上面的图片中除了三个保存了ELF文件的数据的segment之外,还有如下的几个部分
名称 | 描述 |
---|---|
Kernel space | 操作系统的内核空间,保存操作系统内核的数据,用户进程无权访问该地址 |
Stack(栈) | 用于实现程序中的函数调用,在下一节的程序运行中我们会详细了解栈的工作方式 |
Heap(堆) | 为了保存在程序运行时(而非编译时)产生的全局变量注6 |
Memory Map | 磁盘空间到内存的映射,可以像操作内存中的数据一样操作磁盘中的数据 |
操作系统jmp到进程的第一条指令并不是main方法,而是别的代码。这些代码负责初始化main方法执行所需要的环境并调用main方法执行,运行这些代码的函数被称为入口函数或者入口点(Entry Point)。
一个程序的执行过程如下:
栈用于维护函数调用的上下文,函数调用是通过栈完成的。
栈本身是一个容器,它的特性是FILO。通过上面的Linux内存分布图我们可以知道,内存中的栈是向下增长的。在x86中esp寄存器用于保存当前进程的栈顶的地址,push元素到栈中,esp中的值减小;从栈中pop元素,esp中的值增大。
栈为每一个函数调用维护了其所需要的一些信息,为每个函数所维护的信息部分叫做栈帧(Stack Frame),栈被分割为很多个栈帧。每一个栈帧保存了一个函数的如下信息
一个函数被调用时将会有如下操作
当一个函数被调用完毕之后,esp减小到上面的步骤2中的数据的位置,从栈中pop该指令地址,jmp到该指令继续执行。
堆是一块巨大的内存,程序可以在堆中申请内存,这些内存在被程序主动放弃之前都可以随意使用。上图中黄色部分的堆我们在这里把它称为传统堆内存,Linux的堆内存由传统堆和Memory Map Segment共同组成。
Linux下的 brk()
和 mmap()
系统调用都可以用于申请堆内存,它们获取堆内存的方式分别如下
但是我们一般不会直接使用系统调用,而是使用库函数来申请堆内存,我们一般使用glibc中的malloc函数来申请内存,它会根据申请内存的大小的不同而使用不同的实现
brk
系统调用增大传统堆以获取新的内存分配给用户程序brk
系统调用减小堆大小brk
系统调用mmap
系统调用申请内存mmap
所申请内存的释放使用 munmap
系统调用来实现操作系统负责实现对计算机系统资源的管理,用户程序无权直接使用系统资源。用户程序想要使用系统资源就必须调用操作系统所提供的接口,操作系统提供的接口叫做系统调用。
x86 CPU提供了4个特权级,Linux用到了其中的两个特权级,在Linux中分别叫内核态和用户态,内核态的特权级比用户态高。操作系统的内核(上图中最高位的kernel space)运行在内核态,用户程序无权访问内核态的数据,用户程序想要调用内核中的函数就必须要使用系统调用。
x86下使用中断(interrupt)来发送信息给CPU,一旦CPU收到了中断信息,就会停止执行当前任务转而根据中断编号去执行中断处理函数。中断处理函数由操作系统实现,一般来说每个中断编号都有自己的中断处理函数,这些中断处理函数组成了一个中断向量表,中断向量表由操作系统负责实现并管理。中断可以是由硬件产生的,例如键盘按下、鼠标点击等等;中断也可以由软件产生,x86 下 0x80 中断就是由软件触发的,0x80 中断是实现系统调用的核心。
用户程序调用系统调用的过程如下:
eax
寄存器就应该保存系统调用的编号,eax = 1
对应系统调用 exit
,eax = 2
对应 fork
,等等int 0x80
指令,CPU收到中断信息eax
中获取到系统调用编号,根据系统调用编号找到指定的系统调用函数用户写C语言时并不会手动的调用系统调用,它们一般都被封装在库函数中。例如 printf
函数就是对系统调用 write
的封装,下面我们就手动的调用 write
系统调用来实现向标准输出打印字符的功能。
相较于gcc支持的AT&T和Intel格式的汇编,我更喜欢NASM汇编的语法,下面是使用NASM实现的向标准输出打印字符串的汇编代码
global _start ; _start是一个符号(.symbol),链接器会把其作为entry point
; 数据段 section .data buffer db 'hello, system call', 10, 0 ; buffer,10是换行符,0是字符串结束 length db 20 ; buffer的长度
; 代码段 section .text _start: mov eax, 4 ; 4,write系统调用 mov ebx, 1 ; fd(文件描述符),1为标准输出 mov ecx, buffer ; buffer的地址 mov edx, [length] ; 根据地址从数据段获取buffer的长度 int 0x80 ; system call
mov eax, 1 ; 1,exit系统调用
mov ebx, 0 ; exit code
int 0x80 ; system call
把汇编代码保存为 print.asm 文件,之后执行以下命令执行打印操作
$ nasm -f elf64 print.asm -o print.o
$ ld print.o -o print
$ ./print
hello, system call
操作系统和编译器之间联系的非常的紧密,ELF文件就是操作系统和编译器之间的一个纽带。除了操作系统和编译器之间的关系很紧密,操作系统和编译器与CPU和内存的关系也是十分的紧密:操作系统要负责内存的管理,而我们的程序的很大一部分操作也是与内存相关;至于CPU我们不仅要通过中断才能实现系统调用,操作系统本身也需要CPU的特权级来实现对内核的保护。
回顾历史我们就会发现,C语言就是为了Unix而被发明的,它们之间在发展的过程中也不断的互补与完善,这才有了我们今天所看到的联系的十分紧密的类Unix操作系统和C语言编译器。
#include <filename.h>
:编译器会优先到一些默认的文件夹注2中去寻找该头文件,如果未能找到则在当前目录下继续查找#include “filename.h”
:编译器优先在当前目录下查找头文件,找不到再去默认的文件夹中进行查找/usr/include
或 /usr/local/include
会被当做默认文件夹,在gcc中你也可以使用 -I $include_path
来指定被 include 文件的目录ldd