计算机系统大作业 题 目 程序人生-Hello’s P2P 2018年12月28日
更新了第五章动态链接的分析,更改了错误,请各位码官笑纳
Hello小白横空出,开山别派祖师爷。Hello在能够成功运行于shell之上的过程中经历了种种变换。先是经过预处理、编译、汇编、链接的洗礼hello终于形成了一个完整的生命,同时执掌程序生杀大权的掌门人shell为其分配出了一个属于它自己的空间(进程)……,自此,hello正式出现在了人们的眼中。本篇论文将利用edb、readelf等等工具,跟踪hello从源文件到真正执行的一个个过程,展现hello的前世今生,探究计算机内部的运行原理。
关键词:汇编;虚拟内存;管理形式;ELF可执行文件;I/O系统
目 录
第1章 概述
第2章 预处理
第3章 编译
第4章 汇编
第5章 链接
第6章 hello进程管理
第7章 hello的存储管理
第8章 hello的IO管理
结论
附件
参考文献
hello.c文件腾空出世,在经历了cpp的预处理、gcc的编译、as的汇编于ld的链接后,终于形成一个完整的生命。随后伟大的fork为hello产生了一个子进程,自此hello由一个程序级摇身一变称为进程级P2P
通过execve的加载hello终于能够在硬件上驰骋,为其分配虚拟内存空间,构建虚拟内存映射,MMU组织各级页表与cache为其开路,给予hello想要的所有信息,CPU给予时间片并控制逻辑流,hello终于能够大放异彩。最后,shell回收hello的进程,内核清除与hello相关的所有存在,一切回到
“原点”,这就是020.
硬件环境:Intel Core i7-7700HQ x64CPU,8G RAM,128G SSD +1T HDD.
软件环境:Ubuntu16.04 LTS
开发与调试工具:vim,gcc,as,ld,edb,readelf,objdump
文件名称 | 文件作用 |
---|---|
hello.c | 程序源程序 |
hello.i | 预处理文件 |
hello.s | 编译文件 |
hello.o | 汇编文件 |
helloallelf.txt | hello的ELF信息文件 |
helloallobj.txt | hello的反汇编文件 |
helloelf | hello.o的ELF信息文件 |
helloobj | hello.o的反汇编文件 |
本章主要介绍了P2P,020的概念,并给出了实验环境以及实验的中间生成数据。
概念:利用预处理器在程序编译前对一些预处理命令进行解析,预处理能够扩展C语言程序的运行环境。同时预处理指令不属于C语言,而属于编译器
常见的作用:
文件包含:#include(将include后边的文件加入到源文件中进行整合)
条件编译:#if、#endif、#ifdef、#undef……(根据编译器的测试条件来将一段文本包含在程序中或排除在文件之外,以此来达到版本控制,减少文件重复包含的作用)
布局控制:#pragma(为编译程序提供非常规的控制流信息)
宏替换:#define(可以定义符号常量、函数功能、重命名符号字符串拼接等等)
加速编译:以后再进行编译时,源文件头部分直接使用已进行预处理后的文件,加快编译速度
应截图,展示预处理过程!
命令:cpp hello.c > hello.i
图2.2.1 hello.c文件预处理为hello.i
图2.3.1 hello.i 文件(a)
可以看到从别的源文件中引入了相关函数符号
图2.3.2 hello.i 文件(b)
.i文件还包含了引用的头文件的绝对路径并将对应的库函数装配到了文件中
图2.3.3 hello.i 文件(c)
main函数被放在了.i文件的最后,所有的预处理信息采用递归的方式进行展开分析。即若为include则找到原文件并解析,若还有include则继续解析;若为define宏定义则直接展开……可以看到预处理后的文件是将所有预处理命令进行处理的过程,并将一些函数、文件位置、宏等相关信息提前整合在.i文件中,为以后的编译操作做准备。
Hello.c能够运行的第一道关口!预处理为源程序提供了很好的前置基础,即将相关的引用文件的内容进行了整合,对一些预处理命令进行了解析,使得在之后的过程中若想要使用这些信息变得十分快捷简单,为程序编译后的快速运行提供了解决方案
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:将.i文件翻译成.s文件,即将源程序翻译成目标程序,过程中包含了汇编语言程序,它以特定格式确切地描述每一条机器语言指令
作用:
词法分析:将源程序改造成单词字符串的中间程序,即变为内部表示结构
语法分析:对单词字符进行分析,判断其是否形成符合语法规则的语法单位
代码优化:对程序进行多种等价变换,使得在不改变程序原有过程及结果的基础上生成更加有效的代码(主要是减少运行时间,减小占用的存储空间)
生成目标代码:将优化后的中间代码变换成目标代码,其中目标代码有三种形式:
·可以直接执行的机器语言代码
·待使用的机器语言模块(可快速装配变为源程序机器语言代码的一部分)
·汇编语言代码
命令:gcc -S hello.i -o hello.s
图3.2.1 hello.i 编译为hello.s文件
在编译阶段,编译器会对C语言的各种数据类型以及指令进行处理,接下来我们来具体观察编译器是怎么对其进行处理的
·整型int
图3.3.1 hello 全局整型变量
图3.3.2 hello.s
在hello.s文件的开头就已经声明sleepsecs变量是一个全局变量并被放入.data节,.data节是4字节对齐的。同时声明了sleepsecs的大小是4字节的。接下来给出了sleepsec变量的具体信息,编译器设置其为long型变量,值为2
其它的像for循环中的整型变量i则存储在-4(%ebp)中,采用寄存器相对寻址的方式进行取值
图3.3.3 hello.s中循环部分汇编代码
·字符串
图3.3.4 hello.s中字符串的声明
注意:无论是普通的字符串还是带有格式化信息的字符串都被直接保存在rodata段
hello.c的赋值操作有两处,一是将全局变量sleepsecs赋值为2.5,二是在循环开始时将i赋值为0。下面分析编译器如何处理赋值操作:
全局变量sleepsecs的赋值在.data节中就已经完成,直接将sleepsecs赋值为long型的2。而局部变量i的赋值则由mov指令完成(图3.3.3),movl $0,-4(%rbp),其中movl的l表示i的大小是四字节,$0的$符表示0是一个立即数
首先给出汇编语言中算术操作及其效果:
指令 | 效果 | 描述 |
---|---|---|
INC D | D = D+1 | 加1 |
DEC D | D = D-1 | 减1 |
NEG D | D = -D | 取负 |
ADD S,D | D = D+S | 加 |
SUB S,D | D = D-S | 减 |
IMUL 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 | 无符号除法 |
leaq S,D | D = &S | 加载有效地址 |
示例:
图3.3.5 hello.s中算术加的示例
C语言中的关系运算符==,!,!=,=>……等等在汇编中使用条件传送cmov、cmp比较以及test等指令来完成。cmp指令根据两个操作数之差来设置条件码。
除了只设置条件码而不更新目的寄存器之外, cmp指令与sub指令的行为是一样的
图3.3.6 hello.s中的条件判断
从图3.3.6与图3.3.3中可以看到两处cmp指令,他们分别对应的是if语句中argc的判断与for循环的循环终止条件
Hello程序中使用到了一次数组操作(argv[1]、argv[2]),数组操作通过基址变址的方式进行访存的,即通过数组基址+数组类型大小*偏移量(下标)来确定地址,所以编译器处理后的数组操作就是add指令与取内容操作的组合
图3.3.7 数组操作
可以看到,编译器先将%rax+16,然后将对应地址的内容取出放在%rdx中,这样就实现了hello.c中的argv[2],后面的操作与之相似
汇编语言中的控制转移主要由jmp的一系列指令完成。
图3.3.8 jmp指令
hello程序中的控制转移示例如下:
图3.3.9 hello.s中的条件判断
main函数
main函数在程序运行时,由系统启动函数调用,因此main函数是hello.c的起点。main函数的两个参数分别为argc和argv[],由命令行输入,存储在%rdi和%rsi中。
printf函数
hello.c中第一个printf的参数只有一个,所以在hello.s中被优化为puts函数。在判断得知argc!=3之后,编译器通过指令movl $.LCO,%edi将.LC0字符串赋值给%edi,作为puts的第一个参数,然后调用puts函数输出字符串。
对于第二个printf函数,共有三个参数,分别是.LC1,argv[1]和argv2,编译器先用movq指令将对应的参数从内存中取出赋值给%rdi,%rsi和%rdx,然后执行call
printf调用printf函数。
图3.3.10 hello.s中的printf
exit函数
hello.c中的代码是exit(1),可以看到exit有且只有一个参数1,到hello.s中对应的位置观察,编译器直接将立即数1赋值给%edi作为第一个参数,然后call exit,调用exit函数,终止程序
图3.3.11 hello.s中的exit
sleep函数
sleep函数的调用过程与exit函数类似,不过根据优化情况不同,结果可能不一样,这里是增加了一步将参数赋值给%eax,然后再赋值给%edi,即多出过渡部分
图3.3.12 hello.s中的sleep
sleep函数的参数是%rip+sleepsecs,其中%rip是指令指针寄存器,保存当前指令的下一条指令的地址。
getchar函数
getchar函数没有任何参数,在执行完循环后直接用call
getchar指令调用getchar函数。
编译使得hello的程序向机器语言更近了一步,生成的汇编语言用简单而巧妙的方式处理了C语言中不同的各种类型数据与结构体,自此hello完成了一级蜕变,开始了它冒险的新篇章
概念
将汇编语言翻译成为机器语言指令的过程成为汇编。它将这些指令打包为可重定向目标程序的格式,结果保存在一个二进制文件.o中
作用
将汇编语言翻译成计算机能够直接执行的机器语言指令,使得程序的源码在计算机上的运行成为可能
应截图,展示汇编过程!
编译指令:as hello.s -o hello.o
图4.2.1 汇编命令截图
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
图4.3.1 hello.o ELF头信息
Magic:
7f为固定的值而后面的45,4c,46分别为E,L,F的ascii码表示,由此标识这是一个ELF目标文件。
除此而外节头包括了程序的位数、section头的相对偏移量,本节头的大小,section节头表中的头数目等等信息
节头表
节头表中保存了节头数量与开始的偏移量,同时还列出了所有节头的相关信息
重定向text节:
图4.3.2 hello.o 重定向text节
重定向节中标记了符号名称、对应符号的偏移量、重定向类型、重定向信息以及符号加数(重定向偏移辅助信息)
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用
R_X86_64_32:重定位一个使用32位绝对地址的引用
符号表symtab:
图4.3.3 hello.o symtab节
符号表中包含用于重定位的信息,符号名称、符号是全局还是局部,同时标识了符号的对应类型,比如hello.c标识的是FILE
Type,说明这个符号是一个文件类型
.text表:
图4.3.4 hello文件的反汇编文本
利用objdump工具反汇编可以得到.text表的各种信息,里面显示了程序代码的汇编与机器语言实现,同时将其中需要进行重定向的符号进行了标注,对于重定向的项目的分析,我们就函数getchar进行分析,如下:
·首先观察.o文件的.text节,可以得到getchar的重定向条目r的四个字段为:
r.offset = 0x72
r.symbol = getchar
r.type = R_X86_64_PC32
r.addend = -0x4
通过objdump工具可以获取到可执行文件hello的main首地址地址为0x400646[ARRD(s)]
图4.3.5 hello程序main函数
所以通过计算可以得到getchar引用的运行时地址为refaddr = 0x400646+0x72 = 0x4006b8
图4.3.6 hello程序getchar函数
这里可以知道getchar的实际地址ADDR(r.symbol)为0x400510
在实际运行过程中需要从PC指令设置一个偏移值,使得执行的是getchar函数。故需要进行距离的计算*refptr
= (unsigned) (ADDR(r.symbol) + r.addend - refaddr) = 0xfffffe54
图4.3.7 hello调用getchar函数部分代码
同时我们可以看到call指令的下一条指令的地址(PC)为0x4006bc,所以在执行call指令时进行操作:
·PC压栈
·PCPC+0xfffffe54 = 0x400510即成功调用getchar函数
图4.4.1 hello.s文件
图4.4.2 hello.s文件(续)
图4.4.3 hello.o文件
对比分析:
·函数调用:编译后的文件中在描述call语句时,是直接使用的对应函数的符号;而经汇编后的文件中则采用的是主函数相对偏移的方式来告知程序要在指定的位置进行一次函数调用,同时它让待调用函数的相对偏移值置为0,即call紧接着的下一条指令即为函数例程的第一条指令。因为hello程序中调用的均为动态链接库中的函数,故需要让其地址先待定并在rela.text节中设置其重定向条目等待链接,如下:
图4.4.4 hello.o文件中.rela.text节信息(重定位)
分支跳转:编译后的文件中采用的是诸如L1、L2这样的汇编语言助记符(汇编语言的开始地址符)来标识的,程序由此可以划分成各个不同的部分,方便阅读;而在汇编后的.o文件中则完全是由确定的地址(可能为还未确定的重定向的地址)来表示的,即汇编后的机器语言不存在这样的助记符
操作数:在调用全局变量的时候,汇编语言中直接使用全局变量助记符,而在汇编后的文件中则采用的是段基址+偏移量的形式进行访问,因为运行时地址还不确定,故偏移量置为0
文件编译后会生成可执行文件ELF表,里面记录了这个文件的基本信息,通过查看这张表可以得到段基址、文件节头数、符号表等等信息。同时,在未知符号出现时,会为其添加重定向条目,并将其偏移量置为0,待到真正链接的时候才将其变为确定的地址。
概念
将各种代码和数据片段收集并组合成为一个单一文件得过程称为链接,即允许从多个目标文件创建程序。这个生成得文件可以被加载(复制)到内存并执行。
作用
生成可执行文件:链接后的程序可以直接运行
使程序模块化:程序可以编写为一个较小的源文件的集合,而不是一个大团。
分解为更小的模块也可以方便管理与维护,因为可以独立地修改和编译这些模块
符号解析与重定向:将文件中出现的每个符号引用与指定符号对应起来,形成一个符号集。而重定向则将这些符号从.o文件中重定向到可执行文件的最终内存位置并更新这些符号的相关信息,是链接的基本功能
构建动态链接库:将一些函数打包成为共享库,这样程序在编译时可以先不编译特定的库函数,而在运行时执行的情况下才动态链接到程序中,有效减少内存浪费与空间占用,节省了稀缺资源
调试:利用库打桩机制,可以在链接时获得特殊的符号名解析并依此来进行调试与跟踪信息
命令: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/5/crtbegin.o hello.o -lc
/usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z
relro -o hello
图5.3.1 ld链接
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
图5.3.1 hello的ELF头
ELF头中主要介绍了程序包括节表大小、节表数量、数据存储方式(这里是小端)、文件类型(可执行文件)、程序入口点(0x400510)等等与文件整体框架相关的信息
图5.3.2 hello的节头表项
图5.3.3 hello的节头表项(续)
节头表中列举了程序所有的节头,每一个节头都包含了它的节名称、节大小、节类型、节的开始地址、节的偏移量(向地址最高位对齐的相对偏移量)等等
图5.3.4 hello的程序头
图5.3.5 hello的.dynsym表
图5.3.6 hello的.symtab符号表(节选)
图5.4.1 hello的虚拟地址空间(i)
地址0x00400200对应节头部表中的.interp节,存的是linux动态共享库的路径。
图5.4.2 hello的虚拟地址空间(ii)
地址0x00400338对应.dynsym节,保存的是与动态链接相关的导入导出符号,,该节保存动态符号表。
图5.4.3 hello的虚拟地址空间(ii)
地址0x00400710对应.rodata节,保存的是第一个if语句块内的字符串
再看虚拟地址0x00600000到0x00602000的空间,发现前面的0x0~0xfff的内容与0x00400000到0x00400fff的内容相同:
图5.4.4 hello的虚拟地址空间(iv)
而之后的内容存的是.got.plt到.strtab节。
具体的重定位分析在第四章已经给予示例,具体不再叙述,但需要注意的是:
hello和hello.o除了在反汇编生成的汇编代码有所不同,hello的反汇编文件还在开头比hello.o多了.init、.fini、.plt和.plt.got节,其中.init节是程序初始化需要执行的代码,.fini是程序正常终止时需要执行的代码,.plt和.plt.got节分别是动态链接中的过程链接表和全局偏移量表。
使用edb跟踪程序运行记录程序名与程序地址如下:
程序名 | 程序地址 |
---|---|
ld-2.23.so!_dl_start | 0x00007f11037199b0 |
ld-2.23.so!_dl_init | 0x00007f1103728740 |
hello!_start | 0x400510 |
libc-2.23.so!__libc_start_main | 0x00007fa17c942740 |
ld-2.23.so!_dl_fixup | 0x00007fb4dc2d39f0 |
libc-2.23.so!__cxa_atexit | 0x00007fb4dbf34280 |
libc-2.23.so!__libc_csu_init | 0x400690 |
libc-2.23.so!_setjmp | 0x00007fb4dbf2f250 |
hello!main | 0x400606 |
hello!puts@plt | 0x4004a0 |
hello!exit@plt | 0x4004e0 |
ld-2.23.so!_dl_fixup | 0x00007fb4dc2d39f0 |
libc-2.23.so!exit | 0x00007f2137ae8030 |
libc-2.23.so!__run_exit_handlers | 0x00007f2137ae7f10 |
因为采用的是动态链接的方式进行整合,所以程序在一开始运行时是不知道重定位函数的地址的,故采用延迟绑定技术计算函数的运行时地址
延迟绑定技术:
动态链接下对于全局变量和静态数据的访问都需要进行GOT定位后才能跳转。GOT中保存的是函数的目标地址。
动态链接程序调用均变为func@plt的调用。程序首先计算got.plt的地址,然后在函数调用时更新重定位节并将对应的地址标记为目标函数的符号,然后got.plt地址加上一个偏移量得到got.plt中目标函数的地址,再根据地址就可以取出函数例程的第一条指令地址,完成动态链接函数调用
两个概念:
过程链接表(PLT):PLT是一个数组,其中每个条目为16字节代码。其中:
PTL[0]:指向动态连接器
PLT[1]:调用__libc_start_main函数初始化环境,调用main函数并处理返回值
PLT[2]及以后:用于调用用户代码调用的函数
全局偏移量表(GOT):GOT是一个数组,每个条目为8字节地址,其中:
GOT[0]与GOT[1]:包含动态链接器在解析函数地址时的有效信息
GOT[2]:动态链接器在ld-linux.so模块的入口点(动态链接处理函数)
其余:对应一个函数,在第一次调用时进行解析,结束后将其指向正确的函数运行时地址
图5.7.1 动态链接相关的两张表示意图
注意:每个GOT都有与之对应的PLT条目且初始化时,GOT条目指向对应PLT条目的第二条指令。
图5.7.2 init前后变化的got.plt节内存显示
调用dl_init函数前后(可能在dl_start后就变化)可以看到图中两处发生了变化地址0x601018的位置储存的是GOT[2]中动态链接器的首地址,通过edb跳转可以验证,如下:
图5.7.3 动态链接器
解析过程分析:
1) 观察进入call puts@plt这条指令:
图5.7.4 puts@plt的代码展示
从edb下面的地址运算可以看出,第一条指令即为PLT[2]中的第一条指令,它对应的GOT条目中的地址为0x4004a6,刚好为当前PLT[2]中紧接着的第二条指令,验证了之前分析的GOT初始化指向对应PLT第二条指令的规定。
2) 紧接着第二条指令push 0,纵观全局,可以知道这实际上是其在.got.plt节中的偏移量,偏移量在程序中顺序存储,因为puts是第一个调用的函数,故其偏移量为0(第一个入栈信息)
3) 紧接着下一条指令将跳到PLT[0]的位置进行第二步处理,我们先来看看跳转到的地方:
图5.7.5 edb中PLT[0]的代码(a)
图5.7.6 反汇编中PLT[0]的代码(b)
第一步push GOT[1]中重定位节.got.plt的地址(第二个入栈信息),随后跳转至动态链接器(见图5.7.1)进行函数运行时地址的计算处理。
动态链接器主要的工作就是将入栈的两个信息进行结合计算出函数的运行时地址,并将其重写至对应GOT条目,更新后我们使用edb设置rip到第一次调用puts@plt函数的位置查看第一条指令中的将要跳转的地址(图5.7.7对比图5.7.3)
图5.7.7 真正的puts函数
图5.7.8 地址变更效果(图示中的地址为0x00007fab9748d690,与图5.7.7一致)
因此在下一次调用这个函数时,这里的跳转会直接跳转至目标函数,而不用动态链接器的解析。
以上即为延迟绑定的原理及过程分析,建议在看这一部分的时候时刻记得图5.7.1,它是整个过程的核心
本章理解了链接器在程序运行中的作用,动态链接库为我们提供了模块化的编程方式,同时也让程序变得更加灵活,虽然相比于静态链接速度上变慢了1%~5%,但由于其灵活性与模块性,动态链接库成为了现世代计算机世界不可缺少的顶梁柱
概念
一个执行中的程序的实例,同时也是系统进行资源分配和调度的基本单位。一般情况下,包括文本区域、数据区域和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
作用
给予应用程序关键抽象:
一个独立的逻辑流,它提供一个假象,好像我们的程序独占地使用处理器
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统
Shell-bash作用
本身是一个C语言程序,是用户使用Unix/Linux的桥梁
交互性地解释和执行用户输入的命令
定义了多种变量与参数,在程序执行过程中给予便利
不是系统内核地部分但能够通过调用系统级的函数或功能来执行程序、建立文件、进行并行操作等等。同时它也能够协调程序间的运行冲突,保证程序能够以并行形式高效执行
提供了一个图形化界面,提升交互的速度
处理流程
1)从终端或控制台获取用户输入的命令
2)对读入的命令进行分割并重构命令参数
3)如果是内部命令则调用内部函数来执行
4)判断内部命令所需要的参数是否正确
5)否则执行外部程序
6)判断程序的执行状态是前台还是后台,若为前台进程则等待进程结束;否则直接将进程放入后台执行,继续等待用户的下一次输入
首先想要执行hello程序,需要在终端命令行处输入./hello
因为hello不是一个内部命令,因此shell会从文件系统中找到当前目录下的hello文件进行执行
在开始执行hello之前,需要fork出一个子进程来加载hello,在刚刚分配时这个子进程与父进程几乎没有差别,子进程的虚拟地址空间均与父进程的映射关系一致,是父进程虚拟地址空间的一份副本,包括代码和数据段、堆、共享库以及用户栈。同时,子进程还获得与父进程任何打开文件描述符相同的副本,故此时子进程可以读写父进程打开的任何文件。此时子进程与父进程最大的区别在于PID编号
注意:fork出来的子进程与父进程并发执行,故在子进程的执行过程中,父进程始终处于等待状态,等到子进程运行结束或内核中断子进程时才会恢复。
流程图如下:
图6.3.1 fork流程图
execve函数加载并运行可执行文件hello
execve调用加载器将hello中的代码和数据从磁盘复制到内存,然后跳转到程序的第一条指令或入口点(即_start函数的地址)来开始运行hello程序(加载器删除子进程现有的虚拟内存段,创建一组新的段(栈与堆初始化为0),并将虚拟地址空间中的页映射到可执行文件的页大小的片chunk,新的代码与数据段被初始化为可执行文件的内容,然后跳到_start)
_start函数调用系统启动函数__libc_start_main来初始化环境,调用用户层中hello的main函数,并在需要的时候将控制返回给内核
注意:exec用被执行的程序完全替换调用它的程序的影像。fork创建一个新的进程就产生了一个新的PID,exec启动一个新程序,替换原有的进程,因此这个新的被exec执行的进程的PID不会改变,和调用exec函数的进程一样。
初始化的堆栈:
图6.3.3 初始化的堆栈
初始化环境变量结构:
图6.3.4 初始化的环境变量
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
上下文切换:
保存当前进程的上下文
恢复某个先前被抢占的进程被保存的上下文
将控制传递给这个新恢复的进程
用户模式和内核模式
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
程序源码
图6.5.1 hello的程序源码
hello在刚开始运行时内核为其保存一个上下文,进程在用户状态下运行。,如果没有异常或中断信号的产生,hello将继续正常地执行。
·若输入的参数个数不等于3个,那么就执行第一个块的内容
·输出一行字符串后调用exit函数正常退出,程序产生一个终止异常,程序结束
·否则执行第二个块的内容
在sleep之前,若没有异常或系统中断产生,hello依然将正确地执行。否则,内核将启用调度器休眠当前进程,并在内核模式中完成上下文切换,完成的同时将控制权传递给这个被恢复的进程。这是一个被动的过程。
否则在进行sleep系统调用的时候,hello显式地请求让其休眠,并发生上下文切换,另一个进程开始执行,此时计时器开始计时。在等待计时器达到指定值2.5s时,它会产生一个中断信号,中断当前正在进行的进程,进行上下文切换,恢复hello在休眠前的上下文信息,控制权回到hello继续执行
·当循环结束后,程序执行getchar函数。这本质上是通过执行系统调用的read来完成。read会产生一个中断,内核调度上下文切换,hello进程陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器,此时内核接收到这个中断知道hello进程的获取了输入因而调度上下文切换回到hello进程,hello重新在用户模式下继续运行。在return语句后,程序从主程序返回,程序结束。
上下文切换图示:
图6.5.2 上下文切换
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps
jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.6.1 hello的正常运行截图
图6.6.2 hello的运行时按下ctrl+z截图
程序中途接收到一个ctrl+Z的挂起信号,通过jobs命令查看作业可以看到程序被挂起
图6.6.3 hello的运行时按下pstree截图(节选)
Ctrl+Z后执行pstree、jobs、fg、kill命令均可以正常执行
图6.6.4 hello的运行时按下ctrl+z后jobs命令截图
图6.6.5 hello的运行时按下fg截图
发现程序的进程号为2,故用命令fg 2将其调至前台继续运行,程序正常结束
图6.6.6 hello的运行时按下kill截图
图6.6.7 hello的运行时按下ctrl+c截图
图6.6.8 hello的运行时随便乱按截图
在过程中随便按下按键,程序依然能正常结束
本章论述了进程在shell中的运行模式,进程在shell中以用户模式运行,在接收到异常或中断时则会进行上下文切换,另一个进程恢复上下文并执行。内核采用这种调度的方式,成功实现了进程间的并行流,大大增加的效能。同时本章还陈述了fork以及execve函数的具体流程,通过hello的例子来将程序加载的过程展示出来。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
线性地址:地址空间是一个非负整数地址的有序集合,而如果此时地址空间中的整数是连续的,则我们称这个地址空间为线性地址空间
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,其每一个字节都被给予一个唯一的地址,这个地址称为物理地址。
逻辑地址:由程序产生的与段有关的偏移地址。分为两个部分,一个部分为段基址,另一个部分为段偏移量
虚拟地址:与物理地址相似,虚拟内存被组织为一个存放在磁盘上的N个连续的字节大小的单元组成的数组,其每个字节对应的地址成为虚拟地址。虚拟地址包括VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB索引)、TLBT(TLB标记)
其中,由逻辑地址翻译得到的线性地址即为虚拟地址
逻辑地址空间表示:段地址:偏移地址
段式管理有两种模式:
·实模式:逻辑地址CS:EA=CS*16+EA物理地址
·保护模式下:以段描述符作为小标,到对应的描述符表中得到段地址,此时的段基址+偏移地址=线性地址
·基本概念
段寄存器(16位):用于存放段选择符
·CS(代码段):程序代码所在段
·SS(栈段):栈区所在段
·DS(数据段):全局静态数据区所在段
·其他三个段寄存器ES、GS和FS可指向任意数据段
段描述符:一种数据结构,等价于段表项,分为两类
·用户的代码段和数据端描述符
·系统控制段描述符
描述符表:实际上为段表,由段描述符(段表项构成)分为三种类型:
·全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
·局部描述符表LDT:存放某任务(即用户进程)专用的描述符
·中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
段选择符中字段的含义:
图7.2.1 段选择符示意
其中CS寄存器中的RPL字段表示CPU的当前特权级
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)
RPL=00为第0级,位于最高级的内核态;RPL=11为第3级,位于最低级的用户态
高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
逻辑地址线性地址的转变过程:
图7.2.2 逻辑地址转线性地址
48位的逻辑地址分为32位的段内偏移量和16位的段选择符
首先根据段选择符的TI部分判断需要用到的段选择符表是全局描述符表还是局部描述符表
随后根据段选择符的高13位的索引(描述符表偏移)到对应的描述符表中找到对应的偏移量的段描述符,从中取出32位的段基址地址
将32位的段基址地址与32位的段内偏移量相加得到32位的线性地址
基本概念
页表:一个页表条目的数组,将虚拟地址映射到物理地址
图7.3.1 虚拟内存与物理内存的映射
虚拟地址空间中的每个页在页表中一个固定偏移量的位置都有一个PTE,而每个PTE是由一个有效位和一个n位的地址字段组成的。页表PTE分为三种情况:
·已分配:PTE有效位为1且地址部分不为null,即页面已被分配,将一个虚拟地址映射到了一个对应的物理地址
·未缓冲:PTE有效位为0且地址部分不为null,即页面已经对应了一个虚拟地址,但虚拟内存内容还未缓存到物理内存中
·未分配:PTE有效位为0且地址部分为null,即页面还未分配,没有建立映射关系
图7.3.2 虚拟地址翻译成物理地址
虚拟地址分为两个部分:前半部分为虚拟页号,后半部分为虚拟也偏移量,它们的位数根据页表的大小进行分配。
·首先根据虚拟页号在当前进程的物理页表中找到对应的页面
·若符号为设置为1,则表示命中,从页面中取出物理页号+虚拟页偏移量即组成了一个物理地址;否则表示不命中,产生一个缺页异常,具体的缺页处理将在后序7.8中讨论
以Core i7 系统进行讨论
在Corei7 中48位虚拟地址分为36位的虚拟页号以及12位的页内偏移。
四级页表中包含了一个地址字段,它里面保存了40位的物理页号(PPN),这就要求物理页的大小要向4kb对齐
四级页表每个表中均含有512个条目,故计算四级页表对应区域如下:
第四级页表:每个条目对应4kb区域,共512个条目
第三级页表:每个条目对应4kb*512=2MB区域,共512个条目
第二级页表:每个条目对应2MB*512 = 1GB区域,共512个条目
第一级页表:每个页表对应1GB*512 = 512GB区域,共512个条目
每个页表项PTE为64位8b大小,共512个条目,故每个页表的大小均为4kb,这样就做到了向4kb对齐的要求
前三级页表的条目格式:
图7.4.1 前三级页表的条目格式
第四级页表的条目格式:
图7.4.2 第四级页表的条目格式
从VA到PA的变换:
图7.4.3 VA到PA的变换流程
·从VA中分出36位的VPN并根据其中的TLBI索引到对应的TLB组(类cache),结合TLBT找到对应的行并判断TLB是否命中。若命中,则取出其中的PPN(物理页号);否则转到页表索引。
图7.4.4 四级页表的多级索引
·将VPN分为四段,每段9位,里面保存的是对应页表的偏移量。从第一级页表开始索引,找到对应的PTE条目,从中取出相应的第二级页表的首地址。这个首地址加上VPN2的偏移即得到第二级PTE,取出其中的内容即为第三级页表的首地址……以此类推从第四级页表中取出的即为PPN(物理页号)
·将前面得到的PPN与VPO相加就可以得到虚拟地址翻译对应的物理地址
图7.5.1 物理地址取值过程
CO:缓冲块内的字节偏移量 CI:Cache索引 CT:Cache标记
从物理地址中取出对应的CO、CI、CT并根据CI找到cache对应的组,若有效位为1则命中,否则不命中。若命中则再判断CT是否一致,最后根据CO取出对应的内容。否则从依次从下一级缓存L2,L3或主存中取出对应的内容。找到内容后需要进行缓存块的替换,有很多种替换策略,这里不再赘述。
Fork函数前:
图7.6.1 fork子进程前
Fork函数后:
图7.6.2 fork进程后
创建了一个副本,子进程具有与父进程相同的内存映射
私有的写时复制:
图7.6.3 fork函数后的写时复制
写私有页触发保障机制
故障处理程序创建这个页面的一个新的副本,更新PTE条目,权限可写
故障处理程序返回到写私有页的指令
执行指令进行写操作
Fork函数
为新进程创建虚拟内存
创建当前进程的mm——struct,vm_area_struct和页表的原样副本
两个进程中的每个页面都标记为只读
两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制
在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存
在随后若进行了写操作,则通过写时复制基址创建新页面,构造新的映射
步骤
1)删除已存在的用户区域
2)创建新的区域结构
3)私有的,具有写时复制的特性
4)代码和初始化的数据映射到.text和.data段
5).bss和堆栈映射到匿名文件,堆栈的初始长度为0
6)共享对象由动态链接映射到本进程共享区域
7)设置PC,指向代码区域的入口点
图7.7.1 execve函数加载时的内存区域结构
缺页概念:DRAM缓存不命中称为缺页,即虚拟内存中的字不在物理内存中
图7.8.1 缺页处理流程(a)
缓存不命中导致缺页异常,调用内核的缺页异常处理程序,该程序会选择一个牺牲页,这里我们示例牺牲VP4。在这里需要注意,若VP4已经被修改则将其复制回磁盘。
接下来,内核会修改VP4的页表条目(PTE),反映其已不在主存中的事实。随后内核会将VP3的内容从磁盘复制到内存中,并更新PTE3(内存中PP3的位置)
图7.8.2 缺页处理流程(b)
图示中已经牺牲了VP4而将VP3加载到缓存中,此时,缺页异常程序会返回到导致缺页的指令,该指令会重新发送导致缺页的虚拟地址到地址翻译硬件。此时VP3已经缓存在主存中,所以会导致命中。
内存管理基本方法:
隐式空闲链表
包含一个头部与有效载荷,存储时按照双字对齐,每个块必须为8字节的倍数
显式空闲链表
包含一个头部、脚部、指向空闲链表中前驱和后继的两个指针。显式链表可以是LIFO(后进先出)策略或是地址排序策略进行维护
依据边界的合并
在头部与脚部同时设置size/alloc,这样便可以做到双向搜索,在邻近块合并时,直接将头部脚部的size进行相加,并在对应的新块的头部尾部对块的信息进行更新,即可完成块的合并
放置策略
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块并返回
下一次适配:从上一次适配的空闲块开始搜索,选择第一个空闲块并返回,否则从空闲链表的头开始搜索到上一次适配的空闲块,找到第一个合适的空闲块
最佳适配:从空闲链表中找到最合适的块返回
分割空闲块
为了减少内部碎片,分配器在找到合适的空闲块后需要进行分割处理,将多余的空间重新加入到空闲链表中去
垃圾回收器
遍历堆,回收其中的不可达节点并将其返回空间链表
虚拟内存有效使用了主存,简化了内存管理,是现世代有力的内存管理工具。同时虚拟内存还为进程分配提供了解决方案,有效减少了在fork子进程时的内存占用,构建出一套完整的地址系统,使得地址空间能够根据需要灵活变换,让hello的运行大放异彩。
(第7章 2分)
设备的模型化:文件
文件类型:
普通文件:包含任何数据,分两类
文本文件:只含有ASCII码或Unicode字符的文件
二进制文件:所有其他文件
目录:包含一组链接的文件。每个链接都将一个文件名映射到一个文件
套接字:用于与另一个进程进行跨网络通信的文件
其余类型命名通道、符号链接等不予讨论
Linux将所有文件都组织为目录层次结构来进行管理,诶个位置均可以用相对路径或绝对路径来描述。
设备管理:unix io接口
所有的输入和输出都被当作对应文件的读和写来执行,利用Unix
IO接口,这些输入输出都能用一种统一的方法来执行:
·打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息
·shell在进程的开始为其打开三个文件:标准输入、标准输出、标准错误
·改变当前文件的位置:程序可以通过接口显式改变文件位置(相对于文件开头字节的偏移量)
·读写文件:将n字节文件从文件位置k处开始复制,k=k+n;读文件时用k判断是否读完,读完触发EOF条件
·关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源,并将描述符恢复到可用的描述符池
int open(char *filename, int flags, mode_t mode);
参数:
filename:转换为文件描述符
flags:访问文件的方式(只读/只写/可读可写)
mode:文件的访问权限(对于其他用户来说的)
返回一个最小的文件描述符
int close(int fd);
参数:fd:文件描述符
返回值:成功关闭返回0;否则返回-1
ssize_t read(int fd, void *buf, size_t n);
参数:
fd:文件描述符
buf:一次读入的字节大小
n:程序要求读入的总字节数
返回值:成功读入返回读入字节数;若文件已读完则返回0;否则返回-1
·ssize_t write(int fd, const void *buf,size_t);
返回值:成功写入返回写入字节数;否则返回-1
DIR *opendir(const char *name);
返回值:成功返回指向目录的指针,否则返回NULL
int closedir(DIR *dirp);
返回值:成功返回0;错误返回-1
RIO健壮包,其余函数等不予赘述
参考链接:https://www.cnblogs.com/pianist/p/3315801.html
Printf函数源码:
int print(const char *fmt,…)
{
int i;
char buf[256];
va_list arg = (va_list)((char\*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
分析:
typedef char *va_list ;
在C语言中函数参数是从右向左依次压入栈的,而fmt为一个字符指针,指向*fmt中的第一个参数,故可以得到arg指向的是输入…中的第一个参数
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != ‘%’) {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case ‘x’:
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf);
}
功能:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
剩下的write是Unix
I/O接口函数,它用到了sys_call函数,用于进行系统函数调用,我们来看看sys_call的结构:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax \* 4]
add esp, 4 \* 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。由此write函数显示一个已格式化的字符串
总体的过程就是定义一个256字节的缓冲区,printf读入用户参数并将arg指向第一个参数,随后从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用
int
0x80或syscall。最后利用调用系统级函数将buf缓冲区中的内容输出到终端上完成打印。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
日后有时间更新本模块
一个简单的printf便能有如此结构,充分展现了计算机软硬件的合作关系。小小的printf函数中包含了指针寻参,缓冲区填入,程序调用内核系统级函数,硬件支持输出等等方面的知识,可见Linux整体的I/O系统将会是一个融汇了各方支持的大型工程。但就像printf函数只能根据fmt格式字符串读取内容一样,想要设计更为安全的函数还需要仔细斟酌
编写:通过高级程序语言C语言写出hello.c源程序
预处理:对hello.c的预处理指令进行处理,导入外部文件并整合源码
编译,将hello.i编译成为汇编文件hello.s
汇编,将hello.s会变成为可重定位目标文件hello.o
链接,将hello.o与动态链接库进行动态链接成为可执行目标程序hello
运行:在shell中输入./hello 1170300825 lidaxin
创建子进程:shell进程调用fork为其创建子进程
运行程序:shell调用execve,execve调用启动加载器,重构映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入
main函数。
执行指令:CPU为其分配时间片,hello在时间片中顺序执行自己的逻辑控制流
访问内存:MMU处理虚拟内存地址并通过多级页表以及TLB等将其翻译为物理地址,再根据物理地址访问内存取出内容
动态内存申请:程序调用malloc函数向动态内存分配器申请堆中的内存
回收:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构以及占用的内存资源
感受:计算机的内部设计需要考虑多方兼容性,同时为了应对大数据带来的复杂性,必须要有对应的方法来以标准化模式化地处理数据,这样才能做到有逻辑高效地处理各种情况
文件名称 | 文件作用 |
---|---|
hello.c | 程序源程序 |
hello.i | 预处理文件 |
hello.s | 编译文件 |
hello.o | 汇编文件 |
helloallelf.txt | hello的ELF信息文件 |
helloallobj.txt | hello的反汇编文件 |
helloelf | hello.o的ELF信息文件 |
helloobj | hello.o的反汇编文件 |