- 摘要
- 目录
- 第一章
- 第二章
- 第三章
- 第四章
- 第五章
- 第六章
摘 要
本文详述了hello的一生。二至五章逐一按照预处理、编译、汇编、链接四个阶段详细分析了生成最终可执行文件的中间产物,并全面解析了各个步骤的功能与实现流程;六至八章分别从进程管理、存储管理、I/O管理三个方面展开,对hello的执行以及执行时可能产生的异常等进行了深入地剖析。最后对全文进行总结,重览hello的一生。
目录
本文详述了hello的一生。二至五章逐一按照预处理、编译、汇编、链接四个阶段详细分析了生成最终可执行文件的中间产物,并全面解析了各个步骤的功能与实现流程;六至八章分别从进程管理、存储管理、I/O管理三个方面展开,对hello的执行以及执行时可能产生的异常等进行了深入地剖析。最后对全文进行总结,重览hello的一生。
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:Hello诞生于一个C程序,即hello.c。首先经过的是预处理过程,它处理了宏、#开头的库文件等等,将.c文件转换成预处理文件hello.i;而后编译器完成常量表达式计算等工作,将hello.i文件转为文本文件hello.s(汇编语言),这个过程被称作编译;接下来是汇编过程,在这里汇编语言被转换成二进制代码,生成二进制文件hello.o(可重定位文件);最后是链接阶段,在这里将进行符号解析和重定位,经过动态链接的过程就生成了最后的可执行文件hello。
020:用户在shell中输入./hello命令后,第一步会调用Fork函数生成一个只有pid与父进程不同的子进程,并在子进程中调用evecve函数。evecve函数首先启动加载器loader,它将原来的上下文等内容全部丢弃,并新建出task_struct及其目录下包括mm_struct等的数据结构,映射私有区域和共享区域,然后设置程序计数器到代码区域的入口点,使程序开始运行(这其中会发生缺页故障、根据时间片的分配切换上下文等过程)。经过一系列的函数的调用、代码的执行,程序运行结束,成为僵死子进程,等待被父进程回收。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
1.2.1 硬件环境
X64 CPU; 1.8GHz; 16G RAM; 512GHD Disk;
1.2.2 软件环境
Windows10 64位;Ubuntu 20.04;
1.2.3 开发工具
Visual Studio 2019 64位;CodeBlocks 64位;vim/gedit+gcc;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c:源程序
hello.i:经过预处理得到的结果,是文本文件
hello.s:经过编译得到的结果,指令以汇编语言的形式出现,是文本文件
hello.o:经过汇编得到的结果,是可重定向文件,是二进制文件
hello:经过链接得到的结果,是可执行文件
1.4 本章小结
本章从P2P、020的角度概括了hello的一生,并列出了开发环境与工具,以及后续所有操作分析中所产生的中间产物。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。处理器根据以字符#开头的命令,修改原始的C程序。
主要处理三个方面的内容:1.宏定义。例如定义的宏常量在引用的位置会直接发生替换。 2.文件包含。比如hello.c中第一行的#include<stdio.h>名辽宁告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中,从而得到另一个以.i作为文件扩展名的c程序。3.条件编译。有些语句希望条件满足时才编译。使用条件编译可以使目标程序变小,运行时间变短。
预编译使问题或算法的解决方案增多,有助于我们选择合适的解决方案。同时,它使编译器对程序代码的翻译更加方便。
2.2在Ubuntu下预处理的命令
预处理命令如下:
gcc hello.c -E -o hello.i
图2.1 预处理命令
而后运行ls命令,可以看到得到了预处理文件hello.i:
图2.2 预处理结果
2.3 Hello的预处理结果解析
首先通过gedit命令打开预处理文件hello.i:
开头的截图:
图2.3 预处理结果1
最后面截图:
图2.4 预处理结果2
很容看到的是,最后整个预处理得到的结果竟然变成了3000多行,根本不可能完整截图。之所以我专门截下来最后一页,就是想说明只有最后一点是原来的程序,而且前面的3000多行全部都是插入进来的,对比一下就知道了:
图2.5 源程序
寻找预处理文件,可以发现开头的注释已经被清除掉了;原来程序中#开头的命令消失了,而对应的系统头文件已经被插入了文本之中。
2.4 本章小结
本章主要进行了预处理阶段的执行和分析。通过上述截图对比,可以看到预处理命令对原来程序产生的变化并不是特别大,只是将#开头的文件对应的代码放入了文本之中,并将注释清除(这里没有体现宏的替换等)。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译,是指把用高级程序设计语言书写的源程序,翻译成等价的机器语言格式目标程序。
我们所编写的代码是人类可理解的,但是对于机器来讲如果不作任何处理是绝对不能理解的。然而,机器能够看懂的是01指令,再往上一层讲就是比起高级语言更为模式化的汇编指令。所以说,编译的作用是初步翻译人类所能理解的高级语言,将人的意志转换为模式化的汇编指令,以便后续机器对代码的执行。注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
Ubuntu下编译的命令为:
Gcc hello.i -S -o hello.s
图3.1 编译命令
而后运行ls命令可以看到多了一个hello.s文件:
图3.2 编译结果
3.3 Hello的编译结果解析
编译结果:
图3.3 编译结果
下面我将对同时在这个.s文件和参考PPT第4页中同时出现的数据与操作类型的处理方式进行详细的分析。它们包括:不赋初值的局部变量i、printf格式串、算术操作++、赋值=、数组操作、关系操作!=以及<、函数调用、参数传递、函数返回、分支结构、循环结构。
3.3.1数据存储方式
首先看未赋初值的局部变量int i。因为是局部变量,所以并不是符号,所以存储在栈中,与main的生命周期相同。在主函数main的栈中,rsp向下移动了32个字节,其中就有给int i预留的空间:
图3.4 数据储存方式
通过后面对于分支循环的条件,很容易得知局部变量i的位置是rbp向下4字节的位置:
图3.5 数据储存方式
然后还有一个,就是printf中的格式串。两次printf的格式串是比较特殊的东西,之所以这么说是因为它并不能被归为字符串常量,它还包括格式控制字符串和转义字符。我们可以看到这玩意专门放在了段.LC0和.LC1里:
图3.6格式串存储
以后访问的时候使用的是rip+段偏移量间接寻址:
图3.7 格式串寻址方式
3.3.2赋值操作
赋值操作使用的就是mov指令。在hello.c中,只有一个赋值操作,就是循环式将局部变量i赋值为了0。这个操作过于简单,就不多做分析了:
图3.8 赋值操作
3.3.3算术操作
这里面的算术操作也很简单,只有循环中的一个i++操作。通常来讲,这个++操作可以直接使用更快的inc指令,不过这里翻译成add指令也没有什么关系。这里$1是立即数1,-4(%rbp)就是局部变量i,这条指令就是将1加到i上:
图3.9 算术操作
3.3.4关系操作
这里有两个关系操作:一个是!=,一个是<。
对于前者,翻译成cmp和je命令的组合,cmp命令仅设置标志位,je命令通过标志位进行判断。这里-20(%rbp)就是传进来的参数argc,将它和立即数4进行了比较,而后利用je分别对相等和不相等的两种情况进行了分离(另见分支结构的分析):
图3.10 关系操作
对于后者,小于符号被翻译成cmp和jle命令的组合,cmp命令仅设置标志位,jle命令通过标志位进行判断。这里-4(%rbp)就是存储的局部变量i,将它和立即数7进行了比较,而后利用jle分别对小于等于和大于的两种情况进行了分离(另见循环结构的分析)。注意,这里判断是否小于等于7和判断是否小于8是等价的,毕竟i是int型:
图3.11 关系操作
3.3.5数组操作
这里的数组是argv,这是一个char*类型的指针数组,一个是向printf传的参数,一个是向atoi函数传的参数。它采用的是地址+偏移量的方式来定位数组中的元素。
首先分析向atoi中传的参数(更多详见后面传参分析)。这几行是传参并条用atoi函数的过程。首先-32(%rbp)是数组argv的首地址(详见传参分析),首先放进了%rax中。然后addq那条命令将这个地址加了24,正好是三个char*的大小,这个就是偏移量。第三条的movq指令将指向argv[3]的指针解引用,就是把argv[3]给取出来放到%rax中。接下来就是通过寄存器传参的过程。
图3.12 数组操作
同样的,上面向printf中传argv[1]和argv[2]的过程也是一样,这两个元素相对首地址的偏移量分别是1和2个char*的大小,即8和16。注意,这里先传的是argv[2]再传的argv[1]:
图3.13 数组操作
3.3.6控制转移
这里面有两个体现控制转移的地方,一个是if分支结构,一个是for循环结构。
首先看if分支结构。这里用了cmp和je命令来判断应该跳入的分支,如果argc==4那么就跳入分支.L2,否则就不跳入.L2分支,继续执行下面的代码,即输出.LC0中的提示信息并调用exit函数退出:
图3.14 控制转移
在.L2中,i被赋值为0,然后跳转至.L3进行循环结构:
图3.15 控制转移
接下来每次都是现在.L3中先判断是否满足循环条件,然后执行循环体.L4中的指令。.L4的最后一行更新了i,再进行判断,直到i被增加到8,.L3的jle终于判定为否,终止了循环:
图3.16 控制转移
3.3.7函数操作
我觉得最重要的大概就是传参过程,毕竟调用就是call指令,返回就是ret指令,返回值放在%rax中。
在linux64位环境下,前6个参数通过寄存器传输,而后面的参数才通过栈进行传输。放置的寄存器顺序为%rdi、%rsi、%rdx、%rcx、%r8、%r9。
首先看主函数main。这个比较特殊,因为main的参数是直接从外部传进来的(其实应该是execve函数生成的参数列表argv和参数个数argc)。不过通过main在自己的栈中保存参数的行为可以看出,依然满足上述寄存器传参规则。argc被放在了rbp下20字节的位置上是通过&edi传进来的,而argv被放在了rbp下32字节的位置,是通过%rsi传进来的:
图3.17 传参过程
接下来看两个printf函数的调用。第一个printf(这里被转为了puts)只有一个常量字符串作为参数。这里以rip+.LC0偏移量的方式将常量字符串通过寄存器%rdi传给了printf:
图3.18 传参过程
第二个printf传入了三个参数。格式串作为第一个参数,传递过程同上;argv[0]和argv[1]分别作为第二、三个参数,通过%rsi、%rdx进行传输:
图3.19 传参过程
还有一个exit函数的调用,这个是最简单的,只有一个参数,直接将立即数1移到传递第一个参数的寄存器%rdi就可以call了:
图3.20 函数调用
最后就是sleep和atoi函数的一个嵌套调用。首先计算的肯定是里层函数atoi,这个调用和之前的函数调用过程基本相同:
图3.21 函数调用
而后atoi返回的东西肯定是放在的%rax中,这个返回值是要作为sleep函数唯一的参数,所以直接把%rax的东西拷贝到%rdi再call就行了:
图3.22 函数调用
3.4 本章小结
本章主要分析了编译结果,详细解释了生成的汇编语言文件hello.s。
从上述分析来看,未赋初值的局部变量在栈中分配位置,而后直接使用;赋值翻译成mov指令;至于算术功能,++使用了add指令;这里的关系操作翻译为了cmp和条件跳转指令,包括分支结构和循环结构也是由此实现的;函数传参按照linux64位寄存器传参的规则从后向前放入指定寄存器(这里没涉及栈传参),调用、返回则是配套使用了call和ret指令,返回值位于%rax中。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是把汇编语言书写的程序翻译成与之等价的机器语言程序的过程。
它的主要作用是将汇编指令翻译成机器能够识别、执行的形式,例如将人习惯使用的十进制数转换为机器运算需要的二进制数,按指令集编码规则将汇编指令翻译成机器能读懂的二进制编码。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编的命令为:
as hello.s -o hello.o
图4.1 汇编命令
汇编后执行ls命令,可以看到多了一个hello.o文件:
图4.2 汇编结果
4.3 可重定位目标elf格式
首先使用readelf -a hello.o命令打出所有hello.o的基本信息。这里列出的只是显示了有内容部分,其中还有一些hello.o不涉及而空缺的信息,例如:
图4.3 radelf结果
表明了没有版本信息。
4.3.1 ELF头
图4.4 ELF头
hello.o的ELF的头以一个16字节的序列开始,这个序列被称为魔数,描述了生成该文件的系统的字的大小与字节顺序。
其次它包含了类别、数据、版本等信息,在上图中已经列出,此处不再赘述。
4.3.2 节头
图4.5 节头
在ELF头中,我们也可以看到一共有13个节,这些节的更为详细的信息在节头中被列了出来。
第一列列出了各个节的名称以及它们的大小关系。后面几列分别是类型、全体大小、地址、旗标、链接、信息、偏移量和对齐,如上图所示。
4.3.3 两个重定位节
图4.6 重定位节
.rela.text’节是text节的重定位信息,这里面分别给出了偏移量、信息、寻址类型、符号值、符号名称还有addend的数值。因为还没有进行重定位,所以符号值必然都是0。
‘.rela.eh_frame’节是eh_frame节的重定位信息。
4.3.4 符号表
图4.7 符号表
符号表记录了程序中出现的各种符号,从左到右依次是他们的重定位值、大小、类型、全局还是局部、是否可见是否被定义了、名称。同样,因为还没有进行重定位,所以value都是0。可以看到puts、exit、printf等函数都是UND,也就是未定义,这表明它们还需要经过链接从外部获取定义。
4.4 Hello.o的结果解析
4.4.1反汇编文件体现出二进制独有特征
首先看到的是数字表示方式的变化。在汇编文件中,数字的表示方式采用的是十进制,这是因为.s文件还是文本文件:
图4.8 反汇编结果
然而在反汇编文件中,数字的表示方式变为了十六进制,已经已经经过了汇编阶段,hello.o已经是二进制文件,反汇编出来的东西也不是使用十进制表示的:
图4.9 反汇编结果
其次一个很明显的不同就是反汇编文件中左侧有汇编指令对应的机器代码以及它们的地址,这是在汇编过程中生成的。
4.4.2 函数调用上的区别
在hello.s中,可以看到调用函数的名称都是直接将名称显式地放在call指令后面的,例如:
图4.10 反汇编结果
但是已经过了汇编过程,call后面的东西变成了这种形式:
图4.11 反汇编结果
可以看到这里puts和exit函数都被看作是main+偏移量处的一个符号,下面还标明了重定位信息,方便后面的链接过程中的重定位。这就是汇编过所体现出来的痕迹。
4.4.3 跳转命令的变化
于是说是分支结构和循环结构在命令上体现的方式发生了改变,不如说是跳转命令发生了变化。在汇编文件中,可以看到跳转命令后面跟的都是助记符,借此来实现跳转功能:
图4.12 跳转命令的对比
然而在反汇编文件中,这种助记符已经不复存在了,取而代之的是相对的跳转位置:
图4.13 跳转命令的对比
这是因为在汇编的过程中产生了相应的机器编码,所以代码的相对位置信息都已经完成了计算,自然而然跳转需要的位置信息就变成了代码的位置转移。
4.5 本章小结
这一章主要对汇编后的可重定向文件hello.o进行了分析,分别使用了readelf和objdump工具查看了hello.o中的信息。
通过上述分析,可以非常清楚地看到汇编阶段处理的一些痕迹:文本文件被改成了二进制文件,使得汇编指令被翻译成了二进制代码;因为有了二进制代码指令,所以地址都可以被确定,在这一基础上又对一些需要相对寻址的地方做了改动(如4.4.2、4.4.3节所述)。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为单一文件的过程。链接过程可以发生在编译时,也可以发生在执行与加载时,甚至可以发生在运行时。
链接使得一个程序的编写能够被模块化,这极大程度上简化了大型程序的开发维护,同时实现了合作安全性。当某个大型程序需要升级或修改时,只需要重新编写单个模块并重新生成单个模块的可重定位文件,而后直接链接即可。如果没有链接过程,程序只能整个重新编译,这将产生极大的时间开支。
5.2 在Ubuntu下链接的命令
Ubuntu下的链接命令为:
Ld-ohello-dynamic-linker/lib64/ld-linux-x86-64.so.2/usr/lib/x86_64-linux-gnu/crt1.o/usr/lib/x86_64-linux-gnu/crti.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu/crtn.o
图5.1 链接命令
链接后运行ls命令,可以看到多了一个可执行文件hello:
图5.2 链接结果
5.3 可执行目标文件hello的格式
运行readelf -a hello命令来查看其各段的基本信息。
5.3.1 ELF头
图5.3 readelf结果
开头还是16个字节大小的魔数,描述了生成该文件的系统的字的大小与字节顺序。可以从中获取到类别、数据、版本等基本信息。
5.3.2节头表
图5.4 节头表
在节头表中记录了各个节的名称、大小、偏移量、地址、旗标、对齐方式等信息。可以看到因为是可执行文件不需要再重定位,所以不存在包含重定位信息的.rel.data和.rel.text节。
5.3.3程序头表
图5.5 程序头表
程序头部表是一个数组,数组中的每一个元素就称为一个程序头,每一个程序头描述一个内存段或者一块用于准备执行程序的信息;内存中的一个目标文件中的段包含一个或多个节;也就是ELF文件在磁盘中的一个或多个节可能会被映射到内存中的同一个段中;程序头只对可执行文件或共享目标文件有意义,对于其它类型的目标文件,该信息可以忽略。
5.3.4节到段的映射信息
图5.6 节到段的映射信息
5.3.5其他信息
readelf -a命令还会得到许多别的信息。例如:
图5.7 其他信息
它反映的是动态库间关系依赖的信息。个人认为这些信息较难解读且没有太大阅读意义,故此处略去不再深究。
5.4 hello的虚拟地址空间
这里应该参考5.3中的程序头表,其中两个LOAD分别对应的是数据段和代码段:
图5.8 程序头表
LOAD(代码段):
起始地址为0x400000,大小为lff。edb中查看如下(截图不完整):
图5.9 代码段
5.5 链接的重定位过程分析
重定位过程靠的是.rel.data以及.rel.text节,它们之中记录了代码段和数据段中的重定位信息。每一个表项中记录的是符号的地址信息以及相对的偏移量。
常见的重定位至少有两种,它们算法如下:
foreach section s
{
foreach relocation entry r
{
refptr = s + r.offset;
If(r.type == R_X86_64_PC32)
{
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned)(ADDR(r.symbol) + r.addend – refaddr);
}
If(r.type == R_X86_64_32)
*refptr = (unsigned)(ADDR(r.symbol) + r.addend);
}
}
可以看到两种算法实际上差不多,一种是相对寻址(R_X86_64_PC32),一种是绝对寻址(R_X86_64_32)。对于前者,是将当时下一条命令的PC值与相对地址做差;对于后者,直接将原地址放入其中即可。
下面对比objdump下的hello与hello.o的不同,这里以R_X86_64_PC32的方式为例。可以看到在lea命令处(PC为15)有4个字节的0需要被替换,而这里应该填充的是传入第一个printf的格式串。在旁边表明了采用的是R_X86_64_PC32的方式:
图5.10 重定位前后对比
根据上述算法,首先根据.rodata节的起始位置和格式串的偏移量定位那个格式串的地址,即0x400698。然后在lea命令发生时,PC值应当处于下一条命令的位置,即0x40059e。将这两个数值做差,可以得到正确的结果,即0xfa。于是按照小端法的规则,被填充的四个字节应该是fa 00 00 00。
5.6 hello的执行流程
下图是edb上运行可执行文件hello的整个过程,可以看到第一个碰到的函数时_start,地址为0x00000000004010f0:
图5.11 EDB运行结果
下面只列出出现的函数,具体的地址和运行截图略:
_init
_start
_dl_relocate_static_pie
__libc_csu_init
__libc_csu_fini
_fini
_IO_stdin_used
_DYNAMIC
__init_array_end
__init_array_start
_GLOBAL_OFFSET_TABLE_
__data_start
图5.12 EDB 循环部分运行截图
5.7 Hello的动态链接分析
ELF采用了一种叫做延迟绑定的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号解析、重定位),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。
hello采用的就是延迟绑定的策略,配合PLT和GOT实现了动态链接。
在5.3.2节中,从节头表中可以看到.got.plt节从404000开始:
图5.13 .got.plt节
在_dl_start之前,这个位置的内存映像是这样的:
图5.14 内存映像
在之后,内存映像发生了如下改变:
图5.15 内存映像
GOT[2]是动态链接器在ld-linux.so模块中的入口点,即0x7f7dc12afbb0。
此时PLT表中的第一条命令是跳转到对应的GOT项,而GOT里面的初值是PLT表的第二条指令,并不是对应符号的地址。这时GOT将跳转至PLT的第二条指令,这条指令是将这个符号在符号表中的编号压到动态链接器之中,启用动态链接器将GOT的表项修改这个符号的绝对地址。而后PLT表项中的命令再次访问相同的GOT位置就会跳转到对应符号的地址,而不是像之前一样跳回到PLT,这样就完成了动态链接。
看一下puts函数第一次调用的位置:
图5.16 puts函数
5.8 本章小结
本章主要对链接后的可执行文件进行了分析,利用objdump、edb等工具分析了重定位之后的代码。经过这个过程,可以清晰地看到代码在重定位前后发生的变化,在5.5、5.7节中对重定位的具体过程进行详细的剖析。
此外,5.6节中还探索了hello程序运行的具体流程,看到了许多在原始的C代码中看不到的函数调用,它们是执行一个程序必不可少的。
在前面几节中,主要通过readelf工具探索了一个可执行文件的各种信息。虽然其中有不少晦涩难懂的地方,但是所展示出来的虚拟地址排布等信息却是与后面虚拟内存的章节息息相关。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机科学中最深刻,最成功的概念之一。在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统当中运行的唯一程序一样。我们的程序好像是独占使用处理器和内存。处理器就好像是无间断地一条接着一条的执行我们的指令。最后我们程序中的代码和数据好像是系统内存中的唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中的程序的实例。系统的每一个程序都是运行在某一个进程上下文中。上下文是由程序正确运行所需要的状态构成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及上下文描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
什么是shell:是指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。shell是一个交互性应用级程序,代表用户运行其他程序。
处理流程如下:
1.从脚本或终端或bash -c选项后的字符串中获取输入
2.将获取的输入分解成词元(token),此步骤会执行别名(alisa)展开
1)shell识别出的一个字符序列单元称为词元,shell一般通过元字符(metacharacter)将得到的输入进行分割,得到若干个词元,再对词元进行处理。
2)shell的元字符有:space,tab, newline,‘|’, ‘&’, ‘;’, ‘(’, ‘)’, ‘<’, or ‘>’.元字符用于词元分割符;
3)shell中的词(word):不包含非转义元字符的字符序列;
4)shell中的操作符(operator):
newline,‘||’, ‘&&’, ‘&’, ‘;’, ‘;;’, ‘;&’, ‘;;&’, ‘|’, ‘|&’, ‘(’, or ‘)’.
5)词和操作符都是词元
3.将词解析为简单命令或复合命令
1)简单命令是由空格进行分割的词组成的序列
2)复合命令包括循环结构,条件结构,命令组
4.执行各种shell展开
1)shell主要有七大展开:大括号展开,波浪符展开,参数展开,命令替换,算术展开,分词,文件名展开;
2)展开执行完后,没有转义的\,',”会被移除。
5.执行必要的重定向,
6.执行命令
如果命令中包含/,则执行制定路径的程序;如果命令中不包含/,会检查是否是shell函数,shell内建命令,如果都不是,则在PATH环境变量中的路径进行查找。
7.等待命令结束获取命令执行状态
6.3 Hello的fork进程创建过程
当fork被调用时,内核为新进程创建各种数据结构,并分配给它唯一PID。然后创建当前mm_struct、区域结构和页表的原样副本。将两个进程每个页面都标为只读,并将每个区域结构都标记为私有的写时复制。
6.4 Hello的execve过程
1.删除当前进程虚拟内存的用户部分中已存在的区域结构
2.映射私有区域,为新程序的代码、数据、.bss和栈区域创建新区域结构。
3.共享对象动态链接hello程序,将其映射至用户虚拟内存空间的共享区域中
4.设置程序的计数器,使之指向代码区域。
6.5 Hello的进程执行
总体来讲,内核为hello的进程划分时间片,当hello抢占到CPU时上下文切换至hello的进程的上下文。
内核为hello的进程维持了一个上下文,运行期间必然会根据时间片的划分发生进程的转换。当由一个进程转换为另一个进程,内核便进行了上下文切换,上下文切换主要有三个步骤:保存当前进程的上下文;恢复某个先前被抢占的进程被保存的上下文;将控制传递给这个恢复的进程。
当hello程序运行到sleep函数时,系统会显式地请求让hello休眠;直到hello的sleep函数结束,内核才会决定返回hello的上下文并运行hello。
6.6 hello的异常与信号处理
6.6.1正常运行
按照要求运行可执行文件hello,可以看到正常的运行结果是每隔一个时间间隔(我这里设置的是一秒一次)输出一次,共输出八次,最后随便输入字符回车后结束:
图6.1 正常输出结果
6.6.2在运行时按ctrl-c
在运行过程中按ctrl-c,发现程序被终止了。再键入ps命令,发现当前进程已经从作业列表中删除:
图6.2 ctrl-c
这是因为ctrl-c使shell收到了信号SIGINT,启用了相应的信号处理程序,将hello的进程终止并让父进程shell使用waitpid函数等待子进程结束并回收。
6.6.3在运行时按ctrl-z
在运行过程中按ctrl-z,发现进程被停止了。此时查看ps发现hello的进程被挂起;再执行fg 1命令,hello的进程继续执行至结束:
图6.3 ctrl-z
这是因为ctrl-z使shell收到了信号SIGTSTP,启用了相应的信号处理程序,将hello的进程停止(被挂起)。接下来的命令fg 1将后台作业1改为前台运行,向hello的进程发送了信号SIGCONT,启用了相应的信号处理程序,让hello继续在前台运行。
6.6.4 ctrl-z后执行kill+PID命令
操作一:首先正常运行hello程序,按ctrl-z将程序挂起,并执行ps命令确认其PID为44876。然后执行命令kill 44876,在执行ps发现hello进程仍然存在。但再执行fg 1命令后提示“已终止”,再执行ps命令发现hello进程已经不复存在:
图6.4 kill命令
操作二:前面操作同上,只是在kill命令后反复执行ps命令,发现hello进程始终存在;在执行fg 1命令后执行ps命令,发现hello进程立刻消失:
图6.5 kill命令实验
操作一可说明,kill命令可以使父进程收到信号SIGINT并启用相应的信号处理程序使子进程终止;操作二可说明,kill命令并不会使父进程使用waitpid函数等待子进程终止并回收,回收操作是命令fg 1触发的,否则执行命令fg 1时会得到“无此任务”的提示而非“已终止”。
6.6.5 ctrl-z或ctrl-c后执行jobs命令
ctrl-z或ctrl-c后执行jobs命令,可以和上述ps命令的效果差不多,只不过jobs命令列出的是作业,而ps命令列出的是进程:
图6.6 jobs 命令
可以再次看到,ctrl-z的后果就是让进程停止并在作业列表中标明,而ctrl-c的后果就是让进程终止,并从作业列表中删除。
6.6.6乱按
在hello的运行过程中乱按,可以看到乱按的内容虽然会不合时宜地在屏幕上立刻输出,但并不会影响hello的运行:
图6.7 乱按
6.7本章小结
本章中主要讨论了hello进程管理的各个方面,包括从加载到运行再到运行时各种异常与信号处理的测试。
首先对进程的概念、作用进行了说明,然后详细剖析了加载运行程序时使用的fork、execve函数的作用以及工作流程。最后,在hello运行的过程中分别配合ps、fg等命令测试了ctrl-c、ctrl-z、乱按等情况下信号的接收与处理情况,展示了详细的测试结果,并分析了相应的处理机制。
(第6章1分)