6.函数调用。hello.s中函数调用都是直接写上函数的名字,但是在反汇编代码中,都替换成
本文主要研究了hello这一简单c程序的整个生命周期。从hello.c源程序为起点,从编译、链接,到加载、运行,再到终止、回收。结合《深入理解计算机系统》一书的内容及计算机系统课上的讲授,在Ubuntu系统下对hello程序的编译、链接、调试、运行等实际操作,顺着hello的生命周期,漫游了整个计算机系统,把计算机系统整个的体系串联在一起。
关键词:编译、进程、存储
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
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章 hello进程管理 - 10 -
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章 hello的存储管理 - 11 -
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章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
hello.c源文件经过预处理、编译、汇编、链接由文本文件变成计算机可以执行的二进制文件。通过在shell中输入hello和相应的命令行参数,shell利用fork函数创建一个子进程,子进程调用execve函数在当前进程的上下文中运行hello,该函数首先调用加载器完成内存映射,经过多次调用缺页处理程序,逐渐将磁盘上的数据和代码复制到物理内存,运行过程中,要随时准备处理各种事件和信号,还要完成hello本身要完成的工作,不可避免的要进行上下文切换。Hello运行完成之后,变成僵尸进程,等待父进程将其回收,至此,hello绚烂却又短暂的一生结束了。
硬件环境:Intel® Core™ i7-7700HQ CPU; 8.00GB RAM
软件环境:Ubuntu18.04LTS64位
开发工具:edb-debugger,readelf,objdump,GCC,vim,gedit
文件名称 | 作用 |
---|---|
hello.i | Hello.c源文件预处理之后得到的修改过后的文本文件 |
hello.s | hello.i经过编译之后得到的汇编程序 |
hello.o | hello.s经过汇编之后得到的可重定位的二进制文件 |
hello | hello.o经过链接得到的可执行文件 |
hello1.s | hello.o的反汇编代码文件 |
hello2.s | hello的反汇编代码文件 |
hello.elf | hello的elf文件 |
helloo.elf | hello的elf文件 |
printf.c | printf等函数的分析 |
表1:中间文件
本章主要介绍了hello程序的”一生”,交代了实验的软硬件环境以及生成的中间文件。
1.预处理的概念
预处理一般是指由预处理器对程序源代码文本在编译之前进行的处理。预处理工作也叫作宏展开。预处理过程中,预处理器通过执行以#开头的命令、删除注释等工作来修改.c文件为.i文件。
2.预处理的作用
根据以字符#开头的命令,修改原始的C程序,删除所有的注释。使源程序成为中间码之后,再进行编译工作。详细情况如下:
A.宏定义
(1)不带参数的宏定义
宏定义又叫做宏代换、宏替换,简称“宏”。将宏名替换为字符串,也就是在对相关命令或者语句的含义和功能做具体分析之前就要替换。例如:#define PI 3.14159 在预处理阶段将程序中出现的所有PI都替换为3.14159
需要注意的问题有:
1.宏名一般都要大写。
2.使用宏可以提高程序的通用性和易读性,减少不一致性,减少输入错误和便于修改。
3.预处理是在编译之前的处理,不做语法检查,编译工作的任务之一是语法检查。
4.宏定义的结尾不加分号。
5.宏定义卸载函数的花括号外面,作用域是其后面的程序,通常在文件的最开头。
6.可以用#undef来终止宏定义的作用域。
7.宏定义是允许嵌套的。
8.字符串(“”)中永远不包含宏。
9.宏定义不分配内存,变量定义分配内存。
10.宏定义不存在类型问题,它的参数也是无类型的。
(2)带参数的宏定义
例如:
#define SUB(a, b) a - b在程序中出现result = SUB(2,3)则被替换为result = 2 - 3。
需要注意的问题有:
1.实参如果是表达式是容易出问题的。
2.宏名和参数的括号间不能有空格。
3.宏替换只做替换,不做计算,不做表达式求解。
4.函数调用在编译后程序运行时进行,并且分配内存。宏替换在编译前进行,不分配内存。
5.红的哑实结合不存在类型,也米有类型转换。
6.宏展开使程序变长,函数调用不会。
7.宏展开不占运行时间,只占用编译时间,函数调用会占用运行时间。
B.文件包含
文件包含处理是指在一个源文件中,通过文件包含命令将另一个源文件的内容全部包含在此文件中。在源文件编译时,连同被包含进来的文件一同编译,生成目标文件。
例如:#include
C.条件编译
程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,哪些代码不被编译。可以根据表达式的值或者是某个特定的宏是否被定义来确定编译条件。例如:
#ifdef 标识符
程序段1
#else
程序段2
只有当标识符已经定义时,程序段1才参加编译,否则,编译程序段2
1.预处理命令
gcc -E hello.c -o hello.i
其中-E是让编译器在预处理之后就退出,不进行后续编译过程;还可以加-I指明头文件目录;-o指明输出文件名。
2.预处理阶段截图
1.终端运行预处理命令
图1:终端运行预处理命令
2.运行上述命令之后,当前文件夹多了hello.i文件。
图2:文件夹显示
Hello.c源文件如图3所示:
图3:hello.c源文件
预处理之后得到的.i文件的开头部分和结尾部分如图4,图5所示。
图4:hello.i部分文件
图5:hello.i部分文件
解析:对比hello.c文件和hello.i文件可以看到,源文件开头的三个#include消失了,而用一系列的#加一个路径清晰的.h文件来替换。则预处理处理了文件包含,并且统观整个hello.i文件可以看到该文件一共有3113行,仅有结尾3099行之后的部分对应hello.c中的main函数部分,之前的内容都是头文件stdio.h,unistd.h,stdlib.h被复制并插入到#include指令出现的位置上产生的。被包含的这三个文件本身可能还包含其他的文件,因此此过程是嵌套进行的,导致.i文件整个非常大。
本章主要阐述了预处理概念和作用,以及Ubuntu下的预处理命令,分析了.i文件所包含的信息,对比了hello.i文件和hello.c文件的不同。
预处理用于将所有的#include头文件以及宏定义替换为其真正的内容,预处理之后得到的依然是文本文件,但是文件体积会大很多。gcc的预处理是预处理器cpp来完成的,预处理完成之后的程序还是文本,可以用文本编辑器打开。
1.编译的概念
编译是利用编译器ccl从预处理文本文件.i产生文本文件.s的过程,.s文件包含一个汇编语言程序。
2.编译的作用
编译过程对预处理文件进行词法分析、语法分析、目标代码的生成,检查无误之后生成汇编代码文件。
词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的,单词符号,把座位字符串的源程序改造成单词符号传的中间程序。
语法分析主要分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等。最后看是否构成一个符合要求的程序,按照该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。
1.Ubuntu下的编译命令
gcc -S hello.i -o hello.s
其中-S让编译器在编译之后停下来,不进行后续过程。
2.编译阶段截图
1运行命令的界面如图6所示。
图6:终端运行编译命令
2.运行该命令之后,当前文件夹中多了.s文件,如图7所示:
图7:文件夹显示
1.字符串常量
本程序包含两个字符串常量,分别是”用法: Hello 学号 姓名 秒数!\n”和”Hello %s %s\n”,都存放在.rodata段。如图所示,.L0和.L1分别存放两个字符串,如图8所示。
图8:hello.s部分文件
2.局部变量int i
通常循环变量都存在寄存器中,但是这个循环变量存在栈里面。通过图9可以看出循环变量存储在栈里面-4(%rbp)的位置。
图9:hello.s部分文件
3.数组
main中的数组有四个元素,分别是argv[0],argv[1],argv[2],argv[3],每个元素都是一个指针。数组存放在栈里面。其中,argv[1]和argv[2]作为循环体中printf语句的参数,argv[3]作为sleep函数的参数。从下面的汇编代码可以看到argv[]数组的起始地址是-32(%rbp).这些数组元素依次存放在栈里面,argv[1]在栈中-24(%rbp)的位置,argv[2]在栈中-16(%rbp)的位置,argv[3]在栈中-8(%rbp)的位置。下图圈出来的部分实现将argv[1]、argv[2]和argv[3]分别复制到%rdx、%rsi和%rdi,如图10所示。
图10:hello.s部分文件
4.函数中的参数(在下面函数调用部分也有涉及)
该程序的main函数有传参,分别是int argc,char *argv[],作为参数传递时,利用寄存器%edi和寄存器%rsi,由下面的语句可以看出,参数又被存储到栈里面。地址分别是-20(%rbp)和-32(%rbp),如图11所示。
图11:hello.s部分文件
5.程序中其他数值则以立即数的形式出现。例如:
对循环变量i赋初值的0(图12)
图12:hello.s部分文件
1.对循环变量i赋初值
由图13可以看到,程序利用movl指令实现将i赋初值为0,存储在栈中,位置是-4(%rbp).mov类指令实现赋值操作,根据操作数字节数mov指令有不同的后缀),如表2所示。
图13:hello.s部分文件
指令 | movb | movw | movl | movq |
---|---|---|---|---|
字节大小/B | 1 | 2 | 4 | 8 |
表2:mov类指令
汇编语言中,算数操作指令及其功能如表3所示。
指令 | 功能 |
---|---|
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(有符号) |
DIVQ S: R[%rdx] | R[%rdx]=R[%rax] mod S(无符号) |
表3:算数操作指令
本程序中涉及的操作有
1.为函数开辟栈空间而做的栈指针的减小(图14)
图14:hello.s部分文件
2.在数组元素寻址过程中做的加法(图15)
图15:hello.s部分文件
3.对循环变量i做加一操作(图16)
图16:hello.s部分文件
4.使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1的段地址%rip+.LC1并传递给%rdi.如图17所示。
比较和测试指令,见表4。
指令 | 功能 |
---|---|
CMP S1,S2 | S2-S1 比较并设置条件码 |
TEST S1,S2 | S1&S2 测试-设置条件码 |
SET** D D=** | 按照**将条件码设置D |
Jmp类指令 | 根据条件码进行跳转 |
表4:比较和测试指令
本程序中涉及到的操作有
1.判断命令行参数argc是不是4,采用了cmpl指令,其中-20(%rbp)存放的是argc.如图18所示
图18:hello.s部分文件
2.比较循环变量是不是小于等于7,使用的cmpl指令,其中-4(%rbp)存放的是循环变量i.如图19所示
该程序中仅有一个数组,char* argv[].涉及到的操作有
1.将数组的首地址存放在栈里面。如图20所示,圈住的语句将数组第一个元素存放在栈中,地址是-32(%rbp)
图20:hello.s部分文件
2.访问数组元素
采用”基址+偏移量”的方式来进行对数组元素的寻址。如图21所示
图21:hello.s部分文件
图22:hello.c源文件
在汇编代码中的体现是
1.由前面的分析可以看到-20(%rbp)存放的是命令行参数argc,则该指令比较argc和4,如果不相等,则执行汇编语句中的leaq指令,如果相等,则跳过下面的汇编语句,直接执行.L2。(图23)
图23:hello.s部分文件
2.由上面的分析可以看到-4(%rbp)位置存放的是循环控制变量i,cmpl语句比较i和7的大小关系,如果i小于等于7,则跳转到循环体.L4,从而实现循环。否则,调用getchar函数,结束循环操作。如图24所示。
图24:hello.s部分文件
1.函数调用要完成两个部分的操作:控制的传递和数据的传递
A.控制的传递
P函数调用Q函数,使用call指令,该语句先将返回地址A压栈以便于可以顺利返回,同时将PC的值设置为Q函数的起始地址,从而实现控制的转移。
B.参数传递
参数传递有两种,函数调用时传参和函数返回时传参。
例如:P调用Q,则P可以向Q传递一个或者多个参数,Q也可以向P返回一个参数。其中P向Q传递参数,在LInux64位系统 中,使用寄存器传参,多于6个的部分参数,或者是因为无法使用寄存器来进行传递。则使用栈来进行传递。前6个参数使用的寄存器传参次序如表5所示。
参数数量 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
使用的寄存器 | %rdi | %rsi | %rdx | %rcx | %r8 | %r9 |
表5:寄存器传参顺序
Q向P返回的参数利用寄存器%rax来传递。
2.函数返回
被调用函数Q返回之前,会将自己的函数栈清空,则栈顶就是调用函数Q时压栈的返回地址,则ret指令相当于两条指令的结合。将返回地址A从栈中弹出,并且赋值给PC,从而实现将控制传递给调用函数P,实现控制的顺利转移。函数调用时的栈结构如图25所示。
图25:函数调用时的栈表示
在本程序中涉及到以上函数操作的有:
1)main 函数
控制传递: main 函数也是一个普通的函数,只有被调用才能执行,调用mian函数的是系统启动函数__libc_start_main,启动函数中的call 指令将main函数的返回地址dest 压栈,然后跳转到main 函数,完成对main函数的调用。
数据传递:外部调用过程通过%rdi和%rsi实现对main函数传递argc和argv两个参数。
函数返回中的参数传递:main函数将%rax设置为0,实现参数的传递。
函数返回中的控制转移:使用%rbp 记录栈帧的底,函数分配栈帧空间在%rbp 之上,程序结束时,调用leave 指令,恢复栈空间为调用之前的状态,然后ret返回,ret 相当pop IP,将下一条要执行指令的地址设置为dest。
2)exit函数
参数传递:调用exit函数之前,将%edi设置为1,从而实现参数传递。如图26所示。
图26:hello.s部分文件
控制传递:调用语句call完成。
3)printf函数
源程序中的第一个printf在汇编代码中被优化为puts函数,通过将%rdi设置为字符串”用法: Hello 学号 姓名 秒数!\n”的首地址实现参数传递。如图27所示
图27:hello.s部分文件
控制传递:由call语句来完成。
源程序的循环体中的printf在汇编代码中仍然是printf函数。
数据传递:%rdi 为“Hello %s %s\n”的首地址,设置%rsi 为argv[1],%rdx 为argv[2]。如图28所示:
图28:hello.s部分文件
控制传递:由调用语句call来完成。
4)sleep函数
参数传递:因为atoi函数将argv[3]转换为int类型,该函数的返回值存储在%eax中,则将%eax中的数据通过movl指令传递到%edi中,实现调用sleep函数之前的参数传递。如图29所示:
图29:hello.s部分文件
控制传递:通过调用语句call来实现。
5)atoi函数
数据传递:将argv[3]从栈中取出,传送到%rdi,从而实现数据传递。另外,从图30可以看出,atoi函数的返回值,存储在%rax中。
图30:hello.s部分文件
控制转移:由函数调用语句call来实现。
本章主要阐述了对程序hello的编译操作,解释了编译的概念和作用,主要对编译产生的hello.s文件做了详细的分析,分别介绍了编译之后对程序中的数据、赋值、算数操作、关系操作、控制转移、函数调用等的处理,变异的核心就是将高级语言变得”低级”,经过这一步,hello.i变成了helo.s,又走出了”人生中重要一步”。
1.汇编的概念
把汇编语言翻译成机器语言的过程叫做汇编,这一步产生的文件叫可重定位目标文件,是二进制格式。
2.汇编的作用
汇编操作得到的二进制文件更加容易被机器理解。
1.Ubuntu下的汇编命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
这一步会为每一个源文件产生一个目标文件
2.运行汇编命令界面,如图31所示。
图31:终端运行汇编命令
运行之后在当前文件夹下生成了.o文件。该文件是二进制文件,无法直接查看。图32.
图32:文件夹显示
1.hello.o的ELF头如图33所示。
图33:hello.o的elf文件头
第一行的”ELF头”表明ELF文件头开始。
第二行的”Magic”,指明该文件是一个ELF目标文件。第一个字节”7F”是一个固定的数字,后面的三个字节是”E””L””F”三个字母的ASCII码形式。
第三行的”类别”表示文件的类型,这里是64位的ELF格式。
第四行的”数据”表示文件中的数据是按照什么格式组织的(大端还是小端),不同处理器平台数据组织格式可能就不同,这里是小端法的机器。
第五行的”版本”指明当前ELF文件头的版本号,这里版本号是1。
第六行的”OS/ABI”指明了操作系统类型,ABI是Application Binary Interface的缩写。
第七行的”ABI版本”指明当前的版本号是0。
第八行的”类型”表示文件类型。ELF文件有三种类型,一种是如上所示的可重定位目标文件,以后总是可执行文件,另外一种是共享库。
第九行的”系统架构”指明了当前的机器平台。
第十行的”版本”指明了当前目标文件的版本号。
第十一行的”入口点地址”指明了程序的虚拟地址入口点,因为当前的文件不是可以执行的,所以这里是0x0。
第十二行的”程序头起点”同样因为当前文件不是可以执行的而是0。
第十三行的”Start of section headers”指明了sections头开始处。这里的1160是十进制,表示从地址偏移1160处开始。
第十四行是一个与处理器相关联的表示,0x64平台上是0。
第十五行指明了ELF文件头的字节数。
第十六行同样因为该文件不是可执行文件而是0。
第十七行同理。
第十八行是节头的大小,这里每个节头大小都是64字节。
第十九行一共有多少个section头,可以看到这里有13个。
第二十行是节头字符串表索引号,从节头的输出部分可以看到其内容保存着各个节的名字,比如说.data,.text,.bss等。
2.hello.o的节头表
图34:hello.o的elf文件的节头表
上图描述了各节的名称、大小、类型、全体大小、地址、偏移量等信息。
各界的含义如下:
.text是已编译程序的机器代码。
.rela.text是一个.text节中位置的列表,它告诉链接器在链接过程中需要修改哪些指令,一般来说,任何调用外部函数或者引用全局变阿玲的指令都需要修改。
.data是已经初始化的全局和静态C变量。局部C变量保存在栈里,不出现在符号表里面。
.bss是未初始化的全局和静态C变量,以及所有被初始化为0的全局或者静态变量。
.rodata是只读数据,比如说printf语句中的格式穿和开关语句的跳转表。
.symtab是一个符号表,它存放在程序中被定义和引用的函数和全局变量的信息。该节不包含局部变量的信息。
.strtab是一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以NULL结尾的字符串序列。
3.hello.o的重定位节
图35:hello.o的elf文件重定位节
如上图所示,该节是一个.text节中位置的列表,表明了当链接器将这个目标文件和其他文件组合时如何修改这个引用。如图36所示。
图36:重定位条目结构体
offset:指明需要被修改的引用的字节偏移。
symbol:指明被修改引用应该指向的符号。
type告诉链接器如何修改新的引用。
Addend是一个有符号常数,一些类型的重定位需要使用它对被修改的引用的值做偏移调整。
ELF定义了32中不同的重定位类型,下面解释两种最基本的重定位类型
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。
R_X86_64_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
4.hello.o的符号表
图37:hello.o的elf文件符号表
如上图所示,符号表存放了在程序中定义和引用的函数和全局变量的信息。这些信息包括大小、类型、名字等。如果程序引用了一个自身代码未定义的符号,则称之为未定义符号,这类引用必须在静态连接期间用其他目标模块或者库解决,或者在加载是通过动态连接解决。
下面简要解释TYPE中的各种类型:
FUNC表示符号关联到一个函数。
NOTYPE表示符号类型未指定,用于未定义引用。
因为无法直接打开,所以使用工具objdump来打开文件,查看hello.o文件的反汇编表示。在终端运行objdump -d -r hello.o后的显示如图38所示。
由图38可见,左侧的二进制代码就是hello.o中的内容,而右侧的反汇编代码仅仅是为了帮助我们查看而增加的,hello.o中并没有,这些汇编代码是objdump通过hello.o中的二进制代码反汇编得到的,和hello.s(图39)中的汇编代码来源不同,下面,对hello.s(图39)和hello.o中的反汇编代码(图38)作比较。
对比结果如下:
1.大部分还是比较相似的。
2.Hello.s中的mov,lea, push, sub均有表示操作数大小的后缀,但是在反汇编中没有这些后缀。在反汇编中使用的函数调用指令是callq,但是在hello.s中使用的是call.
3.Hello.s中使用的立即数都是十进制表示的,在反汇编中都是十六进制表示的数字。
4.对全局变量的访问,在hello.s中使用.LCi(%rip)进行访问,但是在反汇编代码中,使用0x0(%rip)进行访问。这是因为,机器语言中需要访问的全局变量地址都是0,都需要在链接的时候重新确定地址,对这些引用的地址进行更新。
5.分支转移。hello.s中的跳转目标都是用.Li这样的标号来表示,但是在反汇编代码中,都用其
6.函数调用。hello.s中函数调用都是直接写上函数的名字,但是在反汇编代码中,都替换成
图38:hello.o的反汇编代码
图39:hello.s文件
本章主要说明了hello.s汇编成hello.o的过程,简述了汇编的概念和作用,复习了在Ubuntu环境下的汇编命令,利用readelf查看了hello.o的ELF文件,详细解释了ELF文件各个部分的主要内容。最后,利用objdump工具查看了反汇编代码,并与hello.s中的汇编代码进行比较,分析了其异同以及产生不同的原因。
1.链接的概念
链接是将各种代码和数据片段手机并组合称为一个单一文件的过程,这个文件可以被加载(复制)到内存中并执行。
2.链接的作用
链接允许我们使用现成的共享库,使得我们编写代码更加方便,快捷。
1.Ubuntu下链接的命令
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello
1.运行链接命令的终端界面(图40)
图40:终端运行链接命令
2.运行该命令之后的文件夹中多了hello可执行文件。(图41)
图41:文件夹显示
1.节头表
相关分析,在对可重定位目标文件的ELF文件分析过程中已经给出。
1.程序头表
图43:hello的elf文件的程序头表
这个在上面的可重定位目标文件中没有,下面简要分析:
上述各段组成了最终在内存中执行的程序,其还提供了各段在虚拟地址空间和物理地址空间中的大小、位置、标志、访问权限和对齐方面的信息。各段语义如下:
PHDR:保存程序头表。
INTERP:指明程序从可执行文件映射到内存之后,必须调用的解释器,他是通过链接其他库来满足未解析的引用,用于在虚拟地址空间中插入程序运行所需要的动态库。
LOAD:表示一个需要从二进制文件映射到虚拟地址空间的段,其中保存了常量数据(如字符串),程序目标代码等。
DYNAMIC:保存了由动态链接器(即INTREP段中指定的解释器)使用的信息。
NOTE:指明了辅助信息的位置和大小。
GNU_STACK:权限标志,标志栈是否是可执行的。
GNU_RELRO:指明在重定位结束之后哪些内存区域是需要设置只读的。
5.4 hello的虚拟地址空间
链接完成的可执行文件的ELF文件结构如图44所示:
图44:可执行文件的elf文件结构
1.从图45可知,PHDR的虚拟地址是0x400040,大小是0x1c0字节.则其地址范围是0x400040~0x4001ff
图45:hello的elf文件的程序头部分
图46
2.由图47可知,INTERP的起始地址是0x400200,大小是0x1c,则地址范围是0x400200~0x40021b
图47:hello的elf文件程序头
在edb中查看对应的部分如图48所示
图48
3.由图49可知,LOAD的起始地址是0x400000,大小是0x88c,则地址范围是0x400000~0x40088b
图49:hello的elf文件程序头
在edb中查看对应的部分如图50所示
图50
图50
图50
4.由图51可知,LOAD的起始地址是0x600e00,大小是0x260,则地址范围是0x600e00~0x60115f
图51:hello的elf文件程序头
在edb中查看对应的部分如图52所示
图52
5.由图53可知,DYNAMIC的起始地址是0x600e10,大小是0x1e0,则地址范围是0x600e10~0x600f5f
图53:hello的elf文件程序头
在edb中查看对应的部分如图54所示
图54
6.由图55可知,NOTE的起始地址是0x40021c,大小是0x20,则地址范围是0x40021c~0x40023b
图55:hello的elf文件程序头
在edb中查看对应的部分如图56所示
图56
7.GNU_STACK未分配
8.由图57可知,GNU_RELRO的起始地址是0x600e00,大小是0x200,则地址范围是0x600e00~0x6010ff
图57:hello的elf文件程序头
在edb中查看对应的部分如图58所示
图58
图58
分析说明:由上面的ELF程序头的地址声明,通过edb定位,发现不同的节都可
以对应的很好。
5.5 链接的重定位过程分析
1.利用objdump工具查看两个文件可以看到,hello.o中只有main函数的代码,hello中除了main函数之外,还有_init,.plt,puts,printf,getchar,atoi,exit,sleep,_start,_dl_relocate_static_pie,deregister_tm_clones,registers_tm_clones,do_global_dtors_aux,frame_dummy,_libc_csu_init,_frame_dummy_init_array_entry,_libc_csu_fini,_fini这些库函数的代码。
部分函数如图59所示。
图59:hello的反汇编代码
2.由下面两幅图圈出部分的对比可以看到,hello.o中未重定位的全局变量引用、过程调用、控制转移全部定位了。比如,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。对.rodata的引用也发生了改变,.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差。控制转移指令也采用了PC相对寻址方式来定位目标地址。
3.注意到,在hello中main函数结束之后,多了nop指令,并且查看hello文件,可以看到还有其它函数结束之后也有nop指令。该指令除了改变PC,没有其他影响。应该是希望通过nop指令的填充(nop指令一个字节),使指令按字对齐,从而减少取指令时的内存访问次数。(一般用来内存地址偶数对齐,比如有一条指令,占3字节,这时候使用nop指令,cpu 就可以从第四个字节处读取指令了。)
4.Hello.o文件只有.text节,但在hello中增加了.init,.plt和.fini节。如图60,图61所示。
图60:hello.o的反汇编代码
图61:hello的反汇编代码
5.6 hello的执行流程
使用edb执行的主要子函数如表6所示。
程序名称 |
---|
ld-2.27.so!_dl_start |
ld-2.27.so!_dl_init |
hello!_start |
libc-2.27.so!__libc_start_main |
-libc-2.27.so!__cxa_atexit |
-libc-2.27.so!__libc_csu_init |
hello!_init |
libc-2.27.so!_setjmp |
-libc-2.27.so!_sigsetjmp |
–libc-2.27.so!__sigjmp_save |
hello!main |
hello!puts@plt |
hello!exit@plt |
ld-2.27.so!_dl_runtime_resolve_xsave |
-ld-2.27.so!_dl_fixup |
–ld-2.27.so!_dl_lookup_symbol_x |
|libc-2.27.so!exit
表6:函数顺序执行图
程序调用一个由共享库定义的函数时,无法准确知道这个函数的运行时地址,因为它所在的共享模块可以在运行时加载到内存的任意位置。GNU编译系统使用延时绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。延迟绑定需要用到全局偏移量表和过程链接表两个数据结构。一个模块只要调用共享库中的函数,那么该模块就有自己的全局偏移量表和过程链表。其中,全局偏移量表是一个数组,其中的每个条目都是16字节。PLT[0]是一个特殊的条目,跳转到动态链接器中。每个条目负责调用一个具体的函数。PLT[1]调用一个系统启动函数,从PLT[2]开始,每一个条目都负责调用用户代码调用的函数。过程链表是一个数组,其中每个条目是8字节地址。和全局偏移量表联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的诶个条目对应一个被调用的函数,其地址在运行时被解析。
在本程序中的体现:
图62
如图62所示,GOT的起始位置是0x601000.
使用edb查看函数调用_dl_start之前GOT的内容。可以看到有16字节全都是0,如图63所示。
图63
当函数执行_dl_init之前,这部分的数值变成图64所示。
图64
GOT[2]是动态链接器在ld-linux.so模块中的入口,由其值可以看到共享库模板的入口地址是0x7f64165d7750.根据这个入口地址找到的共享库模块如图65所示:
图65
再看第一次调用printf之前的跳转地址,如图66所示:
图66
和再次调用printf之前的函数地址比较,可以发现,调用printf之后确实链接到了动态库。
本章介绍了链接的概念和作用,分析了hello的格式和反汇编代码,并将其与hello.o作比较,通过实例分析了链接过程所做的工作。对虚拟地址空间、重定位的作用、执行流程和动态连接过程做了简要的分析。
1.进程的概念
进程的经典定义就是一个执行中程序的实例。进程是系统进行资源分配和调度的基本单位,是操作系统结构的基础。系统中的每个程序都运行在某个进程的上下文中,上线爱温室由程序正确运行的状态组成的,包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内存,程序计数器,环境变量以及打开文件描述符的集合。
2.进程的作用
进程提供给应用程序两个关键的抽象。一个独立的逻辑控制流,他提供一个假象,好像我们的程序独占的使用处理器。一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。
Shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。如果这个命令行的第一个单词是一个内置命令,则shell将通过内部的解释器将其解释为系统的功能调用并且转交给内核执行。如果这个命令行的第一个单词不是一个内置的shell命令,那么shell就会假设这是一个可执行文件的名字,它将加载并运行这个文件,运行这个程序时,shell会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。运行完成之后,shell输出一个提示符,等待下一个输入的命令行,如此循环。
首先我们在shell中输入./hello 学号 姓名 睡眠时间,shell根据命令行的第一个参数判断指令不是一个内置命令,就会把它当做一个可执行文件。之后会加载并且执行该可执行文件hello。Shell通过fork函数创建一个子进程,新创建的进程和父进程是几乎相同的。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和子进程之间最大的区别仅仅在于它们有不同的PID.
Fork函数只被调用一次,但是却会返回两次,一次返回是在父进程中,返回紫禁城的PID,另一次返回是在子进程中,返回0.根据这个返回值,可以辨别进程是在子进程还是在父进程中。
在父进程调用fork函数之后,子进程会调用execve函数。execve函数在当前进程的上下文中加载并且运行一个新程序。针对当前情况,则是execve在新创建的子进程中加载并且运行目标文件hello,并且带参数列表argv和环境变量envp.只有当出现错误,例如找不到目标文件hello时,execve才会返回到调用程序。和fork调用一次,返回两次不同,execve函数从来不返回。
参数列表使用图67所示的数据结构来表示。argv变量指向一个以NULL结尾的指针数组,其中每个指针都指向一个参数字符串。惯例是argv[0]指向可执行目标文件的名字。环境变量的列表是由一个类似的数据结构表示的,如下图所示,envp变量指向一个以NULL结尾的指针数组(图68),其中每个指针都指向一个环境变量字符串,每个穿都是形如”name-value”的名字-值对。
图67
图68
当execve加载了hello之后,调用某个驻留在存储器中称为加载器的操作系统代码来运行它。加载器将删除子进程现有的虚拟内存段,并且创建一组新的代码、数据、堆和栈,如图69所示。新的栈和堆被初始化为0。通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据被初始化为可执行文件的内容。最后,加载器跳转到_start的地址,它最终会调用应用程序的main函数。除了一些头部信息,加载过程没有任何从磁盘到内存的护具赋值,直到CPU引用一个被映射的虚拟页时才会进行复制。可执行文件hello的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或者说是入口点来运行该程序。
图69
hello的入口地址(假如说是0x080480c0)已经被记录,之后就等待进程被执行,从程序的入口点地址读取第一条命令。
CPU终于要执行hello了。它读取了虚拟地址0x80480c0,通过CPU的地址翻译(MMU)试图将虚拟地址转换成物理地址,但是在页表条目中查看,发现当前地址的指令尚未缓存在主存,于是MMU报告CPU。
CPU检测到该事件发生,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间接过程调用,通过异常表(图71)的条目k,转到相应的异常处理程序(具体过程见图72:该图展示了CPU如何使用异常表来形成适当的异常处理程序的地址。异常号是到异常表的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中)——缺页异常处理程序的地址。
异常处理程序运行在内核模式下,则该缺页异常处理程序岁所有的系统资源都有完全的访问权限。处理器通常是用某个控制寄存器中的一个位模式来提供这种功能,该寄存器描述了进程当前享有的权限。通过设置模式位,缺页异常处理程序就运行在内核模式了。
缺页异常处理程序将hello相应位置所在的页一页代码都取走,完成将磁盘上相应页的代码拷贝到内存中的任务,完成执行。
处理程序执行完成之后,通过一条特殊的”从中断返回“指令,返回到触发缺页异常的程序处。该指令将释放的状态弹回到处理器的控制和数据结存器中,并且通过将上文提到的模式位恢复,将状态恢复为用户模式,然后将控制返回给被中断的程序。
异常返回到触发缺页异常的代码,重新执行,CPU通过和上面相同的方式,试图通过该虚拟地址翻译成的物理地址从主存找到相应的指令,这次,因为已经缓存了代码,CPU顺利的得到指令。随着指令的执行,越来越多的代码和数据被加载到物理内存,hello在这个过程中,被拆分成不同的页,一页一页的装进内存。
之后,hello的时间片到了。CPU决定抢占当前进程,并且重新开始一个先前被抢占了的进程,这种决策叫做调度,是由内核中称为调度器的代码处理的。在内核调度了新的进程运行后,它就抢占当前进程,使用一种称为上下文切换的机制来讲控制转移到新的进程。(图xxxx)上下文切换:
1.保存当前进程的上下文。
2.恢复某个先前被枪战的进程被保存的上下文。
3.将控制传递给这个新恢复的进程。
这种上下文切换机制,造成了一种假象,好像hello进程独占的使用了CPU,这些时间片有交叉的进程,叫做并发流,如图70所示。
图70
之后,hello的时间片又到了,继续运行该进程。
最后,进程结束,向父进程发送一个信号,告诉父进程自己运行结束了。同时该进程变成了僵尸进程,虽然运行结束,但是仍然占用系统资源,它静静的躺着等待被回收。
父进程或者通过waitpid或者wait函数,接收到子进程发送来的信号,蒋子进程回收,至此,hello的代码数据全部被从内存清理,hello结束了。
图71
图72
6.6 hello的异常与信号处理
可能遇到的异常如图73所示。
图73
可能收到的信号如图74所示。
图74
如何处理异常:调用异常处理程序。
如何处理信号:当内核把进程hello从内核模式切换到用户模式时,它会检查进程hello的未被阻塞的待处理信号的集合。如果集合非空,内核强制hello接收信号k。收到这个信号会触发进程采取某种行为,一旦完成行为,控制就传递回hello的逻辑控制流中的下一条指令,每个信号类型都有一种默认行为,也可以通过设置signal函数改变和信号signum相关联的行为。
1.乱按键盘
图75
上图圈住的部分是程序运行过程中的输入。
进程没有收到信号。
收到的异常:
1.时钟中断:不处理。
2.I/O中断:将键盘输入的字符读入缓冲区。
3.缺页故障:调用缺页处理程序。
2.正常退出
图76
进程没有收到信号。
收到的异常:
1.时钟中断:不处理。
2.缺页异常:调用缺页异常处理程序。
3.按回车
图77
收到的异常:
1.时钟中断:不处理。
2.缺页异常:调用缺页异常处理程序。
从截图可以看到,运行过程中输入的回车被缓存在缓冲区,在程序运行结束之后,读取缓冲区输出的回车,程序运行结束,被回收,shell输出命令行提示符。
4.按Ctrl-Z
图78
如图所示,进程收到的信号:SIGSTOP,则停止该进程。
5.按Ctrl-Z之后运行ps
图79
由图可见,hello进程只是被挂起。
6.按Ctrl-Z之后运行jobs
图80
7.按Ctrl-Z之后运行fg
图81
该进程收到信号SIGCONT。从被挂起状态恢复成前台进程,继续执行。
8.按Ctrl-Z之后运行kill
图82
进程收到信号SIGKILL,杀死进程。
9.按Ctrl-C
图83
进程收到信号SIGINT,shell使进程终止。
本章指明了进程的定义与作用,介绍了shell的一般处理流程,介绍了fork和execve的步骤,重点介绍了hello运行的过程,最后说明了hello运行过程中接收到的异常和信号。
逻辑地址:就是CS:IP的一组组合地址,可以理解为一个CPU用的中间地址,就是段寄存器和便宜寄存器的一个组合。如图84所示。
图84
线性地址:是逻辑地址到物理地址变换之间的中间层是处理器可寻址空间的地址。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:程序访问存储器所使用的逻辑地址就是虚拟地址。Hello程序中的地址就是虚拟地址。
虚拟地址:程序访问存储器所使用的逻辑地址就是虚拟地址。
物理地址:就是实实在在物理内存上的地址。为了使存储器可以正确的存放或者读取信息,将存储器的每一个字节单元赋予一个唯一的存储器地址,这个地址就称为物理地址。比如说PC有1G内存,那么最大地址就是0x40000000,0x800就代表1KB的地址。(图85展示了虚拟地址和逻辑地址的组成)
图85
1.段式管理的基本原理
在段式管理系统中,将程序的地址空间划分为若干个段,这样每个进程都有一个二位的地址空间。在段式存储管理系统中,位每个段分配一个连续的分区,进程中的每个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有的段分配其所需要的内存,这些段不需要连续,物理内存的管理采用欧动态分区的管理方法:
1.在为每个段分配物理内存时,可以采用首次适配、下次适配、最佳适配等方法。
2.再回收某个段所需要的空间时,要注意将收回的空间与其他相邻的空间合并。
3.段式管理也需要硬件支持,实现逻辑地址到物理地址的映射。
4.程序通过分段划分为多个模块,如代码段、数据段、共享段:
(1)可以分别编写和编译
(2)可以针对不同类型的段采取不同的保护
(3)可以按照段为单位今次那个共享,包括通过动态链接进行代码共享。
2.段式管理的数据结构
为了实现段式管理,操作系统需要如下的数据结构来实现进程的地址空间到物理空间的映射,并跟踪物理内存的使用情况,以便在装入新的段时,合理的分配内存空间。
1.进程段表:描述组成进程地址空间的各段,可以使指向系统段表中表项的索引。每段有段基址,和段内偏移。在系统中,为每个进程建立一张段映射表。如图86所示:
图86
2.系统段表:系统所有占用段(已经分配的段)。
3.空闲段表:内存中所有的空闲段,可以结合到系统段表中。
3.段式管理的地址变换如图87所示。
图87
在段式 管理系统中,整个进程的地址空间是二维的,即其逻辑地址由段号和段内地址两部分组成。为了完成进程逻辑地址到物理地址的映射,处理器会查找内存中的段表,由段号得到段的首地址,加上段内地址,得到实际的物理地址(见图87)。这个过程也是由处理器的硬件直接完成的,操作系统只需在进程切换时,将进程段表的首地址装入处理器的特定寄存器当中。这个寄存器一般被称作段表地址寄存器。
1.页式管理的基本原理
将程序的逻辑地址空间划分为固定大小的页,物理内存划分为同样大小的页框。程序加载时,可将任意一页放入内存中任意一个页框,这些页框不必连续,从而实现了离散分配。该方法需要CPU的硬件支持,来实现逻辑地址和物理地址之间的映射。在页式存储管理方式中地址结构由两部构成,前一部分是虚拟页号(VPN),后一部分为虚拟页偏移量(VPO)见图88:
图88
2.页式管理的数据结构
页表
页表将虚拟内存映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。页表是一个页表条目(PTE)的数组。虚拟地址空间的每个页在页表中一个固定偏移量处都有一个PTE。假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。映射图见图89.
2.页式管理的地址变换
CPU中的一个控制寄存器,页表基址寄存器指向当前页表页。n位的虚拟地址包括两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。
MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE0。将页表条目中物理页号和虚拟地址中的VPO串联起来,就得到了相应的物理地址。见图90.
图89
寻址和7.3寻址方式大致相同。不同之处仅在于利用VPN寻找物理页号的时候:36位的VPN被划分为四个9位的片,每个片都用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供一个到L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个到L2 PTE的偏移量,以此类推。
如图92所示,一个m位的物理地址被划分为t个标记位,s个组索引和b个块偏移位。组索引告诉我们这个字必然存放在哪个组中,如果找到了相应的组,之后在这个组中寻找相应的行。当且仅当设置了有效位并且改行的标记位与地址中的标记位相匹配时,组中的这一行才包含这个字。一旦在组索引标识的组中定位了由标号所标识的行,那么b个块偏移给出了在B个字节的数据块中的字偏移。
对一级cache匹配之后,如果成功匹配,结束。否则,对二级cache进行相同的操作。取回数据后,如果有空闲块则放置在空闲块中,否则根据替换策略选择牺牲块。
图92
图93
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的pid。为了给这个新进程创建虚拟内存。它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。
进程地址空间图:
图94:进程地址空间图
1.缺页故障
2.缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault)。图95展示了在缺页之前我们的示例页表的状态。CPU引用了VP 3中的一个字,VP 3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE 3,从有效位推断出VP 3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择-一个牺牲页,在此例中就是存放在PP 3中的VP 4。如果VP 4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
图95:处理缺页之前
接下来,内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到翻译硬件。但是现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件。见图96.
图96:处理缺页之后
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(见图100)。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1.隐式空闲链表:空闲块通过头部中的大小字段隐含地连接着。分配器可以通过遍历堆中所有的块,从而间接地便利整个空闲块的集合。
图97:隐式空闲链表结构单元
图98:隐式空闲链表
放置已分配的块的策略:
1.首次适配:从头开始搜索空闲链表,选择第一个大小合适的空闲块。
2.下一次适配:和首次适配近似,只是它不是从链表的起始处开始搜索,而是从上一次查询结束的抵挡开始。
3.最佳适配:检查每个空闲块,选择适合所需请求的大小的最小的空闲块。
合并空闲块:
1.立即合并:每次一个块被释放时,就合并所有的相邻块。
2.推迟合并:等到某个稍晚的时候再合并空闲块。
2.显示空闲链表:将空闲块组织为某种形式的显式数据结构,将实现这个数据结构的指针存放在这些空闲块的主体里面。将堆组织成一个双向空闲链表,每个空闲块里面,都包含一个pred和一个succ.如图99所示:
图99:显示空闲链表结构单元
链表的维护策略:
1.后进先出顺序:将新释放的块放置在链表的开始处。
2.按照地址顺序:链表中每个块的地址都小于它后继的地址。
3.分离的空闲链表:维护多个空闲链表,其中每个链表的块有大致相等的大小。分配器维护者一个空闲链表数组,每个大小类有一个空闲链表,按照大小的升序排列。当分配器需要一个大小是n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。
存储方法:
1.简单分离存储:每个大小类的空闲链表包含大小相等的块。每个块的大小就是这个大小类中最大元素的大小。
2.分离适配:分配器维护一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或者隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。
图100:堆结构
本章讨论了存储器地址空间、段式空间、页式管理,TLB与四级页表支持下的虚拟地址到物理地址的变换、三级cache支持下的物理内存的访问、hello在fork时的内存映射以及execve时的内存映射,最后介绍了动态内存分配管理的几种方式。
设备的模型化:文件
设备管理:Unix IO接口
一个Linux文件就是一个m个字节的序列:Bo,B1,B2……Bm-1
所有的I/O设备都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输人和输出都能以一种统一且一致的方式来执行。
1.打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:
图101
如图101所示,open函数将filename转换为-一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。
2.Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUT FILENO和STDERR_ FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4.读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of- file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
应用程序是通过分别调用read和write函数来执行输入和输出的,如图102所示。
图102
5.关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
进程通过调用close函数关闭一个打开的文件。
图103
函数汇总:
1.open函数:
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
1)O_RDONLY:只读
2)O_WRONLY:只写
3)O_RDWR:可读可写
2.read和write函数:应用程序是通过分别调用read和write函数来执行输入和输出的。
read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
4.lseek函数,应用程序能够通过调用lseek函数显示地修改当前文件的位置。
先看printf的代码:
图104
va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义是typedef char va_list。说明它是一个字符指针。其中的(char)(&fmt) + 4) 表示的是第一个参数。
再看vsprintf(buf, fmt, arg)函数:
图105
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write系统函数:
图106
再找到INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数
图107
于是我们可知直到printf函数执行流程如下:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,为用户输入的ASCII码或EOF。
getchar函数原型:可以发现其调用了系统函数read,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar可用宏实现:
#define getchar() getc(stdin)。
getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章主要介绍了Linux下IO设备的管理,和Linux的接口的简述,又介绍了printf和getchar这两个输出函数的实现,在hello的O2O过程中占最后的输出部分,但是也很重要。
hello.c经过预处理器cpp的预处理,由源文件变成修改过的文本文件hello.i。
hello.i经过编译器ccl的编译,变成了汇编程序,仍然是文本文件hello.s。
hello.s经过汇编器as的汇编,变成了可重定位的二进制目标程序hello.o.
hello,o经过连接器和库函数进行链接,变成了可执行的二进制文件hello.
用户输入正确的命令行命令,shell会fork一个子进程。
在新创建的子进程中,调用execve函数加载hello.
从hello程序的入口点地址开始运行。
运行过程中要处理各种事件和信号,内核完成进程的调度。
进程结束,hello等待被父进程回收。
这就是hello的一生,最初我们只是一键编译运行,只看到hello在舞台上绚烂而又短暂的表演,但是谁曾想它的一生如此艰辛漫长。这些都是我学习了《深入理解计算机系统》之后才认识到的。这一学期对计算机系统的深入学习,使我真正地能够用系统的思维去解决问题和优化问题的解决方案,遇到问题能够从程序员的角度来思考,如何能够利用系统知识来编写出更好的程序,同时学习一个高效的计算机系统应该做些什么。
文件名称 | 作用 |
---|---|
hello.i hello.c源文件预处理之后得到的修改过后的文本文件 | |
hello.s | hello.i经过编译之后得到的汇编程序 |
hello.o | hello.s经过汇编之后得到的可重定位的二进制文件 |
hello | hello.o经过链接得到的可执行文件 |
hello1.s | hello.o的反汇编代码文件 |
hello2.s | hello的反汇编代码文件 |
hello.elf | hello的elf文件 |
helloo.elf | hello的elf文件 |
printf.c | printf等函数的分析 |
表7
[1] https://blog.csdn.net/edonlii/article/details/8779075
[2] https://blog.csdn.net/gdj0001/article/details/80135196
[3] https://www.cnblogs.com/bhlsheji/p/4868964.html
[4] 《深入理解计算机系统(第三版)》
[5]https://blog.csdn.net/hxy_0118/article/details/89510682
[6][转]printf 函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html