本论文研究hello.c这个简单的c语言文件在于Linux系统下的整个生命周期从其原始程序开始,对编译、链接、加载、操作、终止和回收过程进行了深入的研究,以了解hello.c文件的一生。该论文以hello.c文件是研究对象,结合对计算机系统的深入理解和课堂教师的教学,Ubuntu系统下对hello程序的整个生命周期进行了研究,通过对hello.c通过对程序的深入研究,可以将整个计算机系统系列在一起,真正实现学以致用、融合贯通。
关键词:计算机系统;计算机系统结构;程序生命周期;底层原理;
1.1 Hello简介... - 4 -
1.2 环境与工具... - 4 -
1.3 中间结果... - 4 -
1.4 本章小结... - 4 -
2.1 预处理的概念和作用... - 5 -
2.2在Ubuntu下一个预处理命令... - 5 -
2.3 Hello预处理结果分析... - 5 -
2.4 本章小结... - 5 -
3.1 概念与作用的编译... - 6 -
3.2 在Ubuntu下面编译的命令... - 6 -
3.3 Hello分析编译结果... - 6 -
3.4 本章小结... - 6 -
4.1 汇编的概念和功能... - 7 -
4.2 在Ubuntu下面汇编的命令... - 7 -
4.3 可重定位目标elf格式... - 7 -
4.4 Hello.o的结果解析... - 7 -
4.5 本章小结... - 7 -
5.1 链接的概念与作用......................................................................................... - 8 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
5.4 hello的虚拟地址空间.................................................................................. - 8 -
5.5 链接的重定位过程分析................................................................................. - 8 -
5.6 hello的执行流程.......................................................................................... - 8 -
5.7 Hello的动态链接分析.................................................................................. - 8 -
5.8 本章小结......................................................................................................... - 9 -
6.1 进程的概念与作用....................................................................................... - 10 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.4 Hello的execve过程................................................................................. - 10 -
6.5 Hello的进程执行........................................................................................ - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
6.7本章小结....................................................................................................... - 10 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
7.9动态存储分配管理....................................................................................... - 11 -
7.10本章小结..................................................................................................... - 12 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
8.3 printf的实现分析........................................................................................ - 13 -
8.4 getchar的实现分析.................................................................................... - 13 -
8.5本章小结....................................................................................................... - 13 -
第1章 概述
1.1 Hello简介
首先对Hello的P2P,020进行简述。
Hello的P2P是指hello.c文件从可执行程序(Program)变为运行时进程(Process)的过程。在Linux系统下,hello.c 文件依次经过cpp(C Pre-Processor,C预处理器)预处理、ccl(C Compiler,C编译器) 编译、as (Assembler,汇编器)汇编、ld (Linker,链接器)链接最终成为可执行目标程序hello(在Linux下该文件无固定后缀)。打开shell,输入命令./hello后,shell 通过fork产生子进程,hello 便从可执行程序(Program)变成为进程(Process)。
Hello的020是指hello.c文件“From 0 to 0”,初始时内存中并无hello文件的相关内容,这便是“From 0”。通过在Shell下调用execve函数,系统会将hello文件载入内存,执行相关代码,当程序运行结束后, hello进程被回收,并由内核删除hello相关数据,这即为“to 0”。
1.2 环境与工具
硬件:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
16GB RAM
1T HDD + 512GB SSD
软件:Windows10 64位
Ubuntu 18.04.5 LTS 64位
调试工具:Visual Studio 2016 64-bit;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
表格 1 中间结果
文件名 |
功能 |
hello.i |
预处理后得到的文本文件 |
hello.s |
编译后得到的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
hello.asm |
反汇编hello.o得到的反汇编文件 |
hello2.elf |
由hello可执行文件生成的.elf文件 |
hello2.asm |
反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章简要介绍了hello 的P2P,020的具体含义,同时列出了论文研究时采用的具体软硬件环境和中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理步骤是指程序开始运行时,预处理器(cpp,C Pre-Processor,C预处理器)根据以字符#开头的命令,修改原始的C程序的过程。例如,hello.c文件6到8行中的#include 命令会告诉预处理器读取系统头文件stdio.h,unistd.h,stdlib.h 的内容,并把这些内容直接插入到程序文本中。用实际值替换用#define定义的字符串。除此之外,预处理过程还会删除程序中的注释和多余的空白字符。预处理通常得到另一个以.i作为拓展名的C程序。
预处理过程将#include后继的头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理。预处理过程中并未直接解析程序源代码的内容,而是对源代码进行相应的分割、处理和替换。简单来说,预处理是一个文本插入与替换的过程,生成的hello.i文件仍然是文本文件。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为:
cpp hello.c > hello.i
运行截图如下:
2.3 Hello的预处理结果解析
在Linux下打开hello.i文件,可以发现hello.i程序已经拓展为3060行,行数比起hello.c文件大幅增加。其中, hello.c中的main函数相关代码在hello.i程序中对应着3047行到3060行。
图 2 预处理结果部分展示
在main函数内代码出现之前是大段的头文件 stdio.h unistd.h stdlib.h 的依次展开。展开的具体流程概述如下(以stdio.h为例):CPP先删除指令#include <stdio.h>,并到Ubuntu系统的默认的环境变量中寻找 stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。除此之外,CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。
第3章 编译
3.1 编译的概念与作用
编译是指C编译器ccl通过词法分析和语法分析,将合法指令翻译成等价汇编代码的过程。通过编译过程,编译器将文本文件 hello.i 翻译成汇编语言文件 hello.s,在hello.s中,以文本的形式描述了一条条低级机器语言指令。
将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
gcc -S -o hello.i hello.s
运行截图如下:
3.3 Hello的编译结果解析
对hello.s文件整体结构分析如下:
表格 2 hello.s文件结构
内容 |
含义 |
.file |
源文件 |
.text |
代码段 |
.global |
全局变量 |
.data |
存放已经初始化的全局和静态C 变量 |
.section .rodata |
存放只读变量 |
.align |
对齐方式 |
.type |
表示是函数类型/对象类型 |
.size |
表示大小 |
.long .string |
表示是long类型/string类型 |
在hello.s中,涉及的数据类型包括以下三种:整数,字符串,数组。下面对每种数据类型依次进行分析。
在hello.s中,涉及的整数有:
- int sleepsecs
查看C语言文件可知,sleepsecs为int型全局变量,已被初始化赋值2.5。
经过编译阶段得到的hello.s文件中,编译器在.text段中将sleepsecs声明为全局变量,在.type段声明其为object类型,在.size段声明其长度为4,设置其值为2。具体情况如下:
图 4 sleepsecs的情况
- int i
编译器将局部变量存储在寄存器或者栈空间中。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
在hello.s中我们可以看出,i占据了4字节的地址空间:
- int argc
argc是main函数的参数之一,64位编译下,由寄存器传入,进而保存在堆栈中。
- 立即数3
立即数3在汇编语句中直接以$3的形式出现
程序中保存了两个字符串,分别为:
图 5 字符串的情况
两者均为字符串常量,储存在.text数据段中。\XXX为UTF-8编码,一个汉字对应三个字节。
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。访问时,通过寄存器寻址的方式访问。
图 6 数组的情况
- int sleepsecs=2.5
在C语言源程序中包含一个隐式类型转换:将2.5赋值给一个int类型,结果为2。体现在hello.s中,直接在.data节中将sleepsecs声明为值2的long类型数据。
- int i
对局部变量的赋值在汇编代码中通过mov指令完成。具体使用哪条mov指令由数据的大小决定,如图所示:
表格 3 mov指令的后缀
后缀 |
b |
w |
l |
q |
大小(字节) |
1 |
2 |
3 |
4 |
在C语言源程序中包含一个隐式类型转换:
将浮点型的2.5赋值给int类型,值向零舍入, 2.5 舍入为 2。
汇编语言中,算数操作的指令包括:
表格 4 算数操作指令
指令 |
效果 |
leaq s,d |
d=&s |
inc d |
d+=1 |
dec d |
d-=1 |
neg d |
d=-d |
add s,d |
d=d+s |
sub s,d |
d=d-s |
imulq s |
r[%rdx]:r[%rax]=s*r[%rax](有符号) |
mulq s |
r[%rdx]:r[%rax]=s*r[%rax](无符号) |
idivq s |
r[%rdx]=r[%rdx]:r[%rax] mod s(有符号) r[%rax]=r[%rdx]:r[%rax] div s |
divq s |
r[%rdx]=r[%rdx]:r[%rax] mod s(无符号) r[%rax]=r[%rdx]:r[%rax] div s |
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp:开辟栈帧
- addq $16, %rax:修改地址偏移量
- addl $1, -4(%rbp):实现i++的操作
图 7 hello.s中涉及的算数操作
在hello.s中,具体涉及的关系操作包括:
- argc!=3:
检查argc是否不等于3。在hello.s中,使用cmpl $3,-20(%rbp),比较 argc与3的大小并设置条件码,为下一步je利用条件码进行跳转作准备。
图 8 检查argc!=3
- i<10:
检查i是否小于10。在hello.s中,使用cmpl $9, -4(%rbp)比较i与9的大小,然后设置条件码,为下一步jle利用条件码进行跳转做准备。
图 9 检查i<10
如前述3.3.2所述,hello.s中存在数组char *argv[],对其的访问操作通过寄存器寻址方式实现。
程序中控制转移的具体表现有两处:
- if(argc!=3):
当argc不等于3时,执行函数体内部的代码。在hello.s中,使用cmpl $3,-20(%rbp),比较 argc与3是否相等,若相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
图 10 控制转移
- for(i=0;i<10;i++):
当i < 10时进行循环,每次循环i++。在hello.s中,使用cmpl $9,-4 (%rbp),比较 i与9是否相等,在i<=9时继续循环,进入.L4,i>9时跳出循环。
图 11 循环的情况
C语言中,调用函数时进行的操作如下:
- 传递控制:
进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
- 传递数据:
P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。
- 分配和释放内存:
在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
另附64位系统下的参数传递顺序:
表格 5 64位系统的传参顺序
1 |
2 |
3 |
4 |
5 |
6 |
7 |
%rdi |
%rsi |
%rdx |
%rcx |
%r8 |
%r9 |
栈空间 |
具体到hello.s中,程序入口处,调用了main 函数,其在hello.s中标注为@function函数类型。之后又调用 puts,printf,sleep,exit,getchar 函数,对函数的调用都通过call指令进行。
图 12 call指令的情况
3.4 本章小结
本章介绍了编译的概念与作用,编译是将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备的过程。同时,本章以hello.s文件为例,介绍了编译器如何处理各个数据类型以及各类操作,验证了大部分数据、操作在汇编代码中的实现。
第4章 汇编
4.1 汇编的概念与作用
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件中的过程
将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o文件中
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编的命令为:
gcc -c hello.s -o hello.o
汇编过程如下:
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先,在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
图 14 生成ELF文件
其结构分析如下:
- ELF 头(ELF Header):
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图 15 ELF头的情况
- 节头:
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
图 16 节头的情况
- 重定位节.rela.text
一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
在这里,8 条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、sleepsecs、sleep 函数、getchar 函数进行重定位声明。
.rela.text节包含如下信息:
表格 6 .rela.text节包含的信息
偏移量 |
代表需要进行重定向的代码在.text或.data节中的偏移位置 |
信息 |
包括symbol和type两部分,其中symbol占前半部分,type占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型 |
类型 |
重定位到的目标的类型 |
加数 |
计算重定位位置的辅助信息 |
图 17 .rela.text节
- 重定位节.rela.eh_frame
图 18 .rela.eh_frame节
- 符号表Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图 19 符号表的情况
4.4 Hello.o的结果解析
使用objdump -d -r hello.o > hello.asm 分析hello.o的反汇编,并与第3章的 hello.s文件进行对照分析。
通过对比hello.asm与hello.s可知,两者在如下地方存在差异:
- 分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差。
图 21 分支转移
- 函数调用:
在hello.s文件中,call之后直接跟着函数名称,而在反汇编得到的hello.asm中,call 的目标地址是当前指令的下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器作用才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其 call 指令后的相对地址设置为全0(此时,目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接进一步确定。
图 22 函数调用
- 全局变量访问:
在hello.s 文件中,使用段名称+%rip访问 rodata(printf 中的字符串),而在反汇编得到的hello.asm中,使用 0+%rip进行访问。其原因与函数调用类似,rodata 中数据地址在运行时才能确定,故访问时也需要重定位。在汇编成为机器语言时,将操作数设置为全 0 并添加相应的重定位条目。
图 23 全局变量访问
4.5 本章小结
本章介绍了汇编的概念与作用,在Ubuntu下通过实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在hello.asm中)与hello.s中代码,
了解了汇编语言与机器语言的异同之处。
第5章 链接
5.1 链接的概念与作用
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。
提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
gcc -o hello hello.o
链接过程如下:
5.3 可执行目标文件hello的格式
在Shell中输入命令 readelf -a hello > hello2.elf 生成 hello 程序的 ELF 格式文件,保存为hello2.elf(与第四章中的elf文件作区分):
打开hello2.elf,分析hello的ELF格式如下:
- ELF 头(ELF Header)
hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以 描述了生成该文件的系统的字的大小和字节顺序的16字节序列 Magic 开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图 26 ELF头的情况
- 节头
hello2.elf中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与hello.elf相比,其在链接之后的内容更加丰富详细(此处仅截取部分展示)。
图 27 节头的情况
- 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图 28 程序头的部分
- Dynamic section
图 29 Dynamic Section
- Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明(此处仅截取部分展示)。
图 30 Symbol table
5.4 hello的虚拟地址空间
打开edb,通过 data dump 查看加载到虚拟地址的程序代码。
图 31 加载到虚拟地址的程序代码
根据计算机系统的特性,程序被载入至地址0x400000~0x401000中。在该地址范围内,每个节的地址都与前一节中节对应的 Address 相同。根据edb查看的结果,在地址空间0x400000~0x400fff中存放着与地址空间0x400000~0x401000相同的程序,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
5.5 链接的重定位过程分析
在Shell中使用命令objdump -d -r hello > hello2.asm生成反汇编文件hello2.asm,与第四章中生成的hello.o.asm文件进行比较,其不同之处如下:
图 32 生成.asm文件
- 链接后函数数量增加。链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图 33 链接后的函数
- 函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图 34 call指令的参数
- 跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
图 35 跳转指令的参数
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
表格 7 程序名称与程序地址
程序名称 |
程序地址 |
ld-2.27.so!_dl_start |
0x7fce8cc38ea0 |
ld-2.27.so!_dl_init |
0x7fce8cc47630 |
hello!_start |
0x400500 |
libc-2.27.so!__libc_start_main |
0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit |
0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init |
0x4005c0 |
hello!_init |
0x400488 |
libc-2.27.so!_setjmp |
0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp |
0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save |
0x7fce8c884bd0 |
hello!main |
0x400532 |
hello!puts@plt |
0x4004b0 |
hello!exit@plt |
0x4004e0 |
*hello!printf@plt |
-- |
*hello!sleep@plt |
-- |
*hello!getchar@plt |
-- |
ld-2.27.so!_dl_runtime_resolve_xsave |
0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup |
0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x |
0x7fce8cc420b0 |
libc-2.27.so!exit |
0x7fce8c889128 |
5.7 Hello的动态链接分析
编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
.got与.plt节保存着全局偏移量表GOT,其内容从地址0x601000开始。通过edb查看,在dl_init调用前,其内容如下:
图 36 调用前的情况
在调用后,其内容变为:
图 37 调用后的情况
比较可以得知,0x601008~0x601017之间的内容,对应着全局偏移量表GOT[1]和GOT[2]的内容发生了变化。GOT[1]保存的是指向已经加载的共享库的链表地址。GOT[2]是动态链接器在ld-linux.so模块中的入口。这样,接下来执行程序的过程中,就可以使用过程链接表PLT和全局偏移量表GOT进行动态链接。
5.8 本章小结
本章中介绍了链接的概念与作用、并得到了链接后的hello可执行文件的ELF格式文本hello2.elf,据此分析了hello2.elf与hello.elf的异同;之后,根据反汇编文件hello2.asm与hello.asm的比较,加深了对重定位与动态链接的理解。