通过对一个C语言程序hello从源代码到进程结束的生命周期整个过程的分析,展示了在linux下程序的Program to Process过程(P2P)和zero to zero过程(020),介绍了了x86-64计算机系统的主要工作机制以及沟通顶层程序员与底层机器的原理。
关键词: 预处理;编译;汇编;链接;进程;内存管理;I/O管理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第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 -
参考文献....................................................................................... - 16 -
第1章 概述
1.1 Hello简介
hello程序是一个带有输入延时的打印hello信息的程序。但是,经过下文的分析,我们会发现它并不像自己表现出来的那么简单。
Hello的P2P:从 hello的原始C语言代码,经过预处理、编译、汇编、链接得到可执行目标文件,再由shell为hello创建进程并加载可执行文件,得到一个运行中的hello进程,hello便从代码变成了运行着的进程(from program to process)。
Hello的020:shell为hello创建进程并加载hello的可执行文件,为其提供了虚拟地址空间等进程上下文,实现了hello的从无到有的过程。最终,hello正常退出或收到信号后终止,都会使得操作系统结束hello进程,释放其占用的一切资源,返回shell,这便是hello的从无到有再到无的过程(from zero to zero)。
1.2 环境与工具
硬件环境:
处理器:Intel CORE i5 10th GEN
系统类型:X64 CPU; 2GHz; 16G RAM; 256G HD Disk
软件环境:
Windows11家庭和学生版
VMware Workstation pro2022
Ubuntu20.04
开发与调试工具:
Visual Stdio 2019; ClodeBlocks; gedit+gcc;VSCode
1.3 中间结果
作用 | |
hello.c | 储存hello源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | hello程序对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello.elf | hello.o的ELF文件格式 |
hello | 二进制可执行文件 |
hello.elf | 可执行文件的ELF文件格式 |
hello.s | 可执行文件的汇编语言文件 |
1.4 本章小结
本章简述了Hello程序的一生,概括了P2P到020的过程,本章还简要说明了实验的软、硬件环境以及编译处理工具。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,预处理器读取对应的头文件内容并把它直接插入程序文本。
2.1.1 头文件
作用:得到另一个C程序,通常是以.i作为文件扩展名。
具体内容分为以下三步执行
①文件包含
可以把源程序中的#include 扩展为文件正文,即把包含的.h文件找到并展开到#include 所在处。
②条件编译
预处理器根据#if和#ifdef等编译命令及其后的条件,将源程序中的某部分包含进来或排除在外,通常把排除在外的语句转换成空行。
③宏展开
预处理器将源程序文件中出现的对宏的引用展开成相应的宏 定义,即本文所说的#define的功能,由预处理器来完成。
2.2在Ubuntu下预处理的命令
预处理命令:cpp hello.c > hello.i
2.2.1 预处理命令
2.3 Hello的预处理结果解析
分析.i文件具体内容,我们可以发现stdio.h,unistd.h,stdlib.h被替换为对应的头文件内容,其中stdio.h头文件内容为第13行到第728行(见图2.3.2和图2.3.3),unistd头文件内容为第731行到第1966行(见图2.3.4和图2.3.5),stdlib头文件内容为第1970行到第3041行(见图2.3.6和图2.3.7),
2.3.1 执行命令后生成.i文件
2.3.2 stdio.h头文件开始处代码
2.3.3 stdio.h头文件结束处代码
2.3.4 unistd.h头文件开始处代码
2.3.5 unistd.h头文件结束处代码
2.3.6 stdlib.h头文件开始处代码
2.3.7 stdlib.h头文件结束处代码
剩余代码为源程序代码,少了#include开头的代码以及注释,因此可以得出经过预处理器处理的.i文件与之前的.c文件有所不同,在这个阶段所进行的工作只是纯粹的替换与展开,没有任何计算功能。
2.4 本章小结
经过预处理,对源程序进行头文件的读取以及插入后得到了.i文件。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:将经预处理器修改了的源程序翻译为汇编程序的过程
作用:对预处理过的源程序进行一系列以下操作
(1)词法分析
(2)语法分析
(3)语义分析
(4)优化后生成相应的汇编代码
最终将修改过的源程序翻译为汇编语言程序
3.2 在Ubuntu下编译的命令
3.2.2 编译命令
3.3 Hello的编译结果解析
3.3.1 伪指令
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令,我们通常可以忽略这些行。
3.3.2数据格式与寄存器
磨刀不误砍柴工,在分析汇编语言程序之前,我认为应该先了解64位机器的数据格式(图3.3.1)以及对应的寄存器构造(图3.3.2)。
3.3.1 C语言数据类型在x86-64中的大小
3.3.2 部分寄存器结构
3.3.3数据
字符串常量:.LC0与.LC1作为字符串变量,同时由于.rodata可知是只读数据,因此可以看作是字符串常量,每三个ASCII码表示一个汉字,对应的英文字符可直接表示。
3.3.3 字符串常量
立即数:直接包含在机器指令中的数据,在汇编指令中直接给出,以$作为标识符,在图3.3.4中将立即数4与指定寄存器中的值进行比较,并进行对应操作。
3.3.4 立即数
局部变量:汇编语言中%edi,%esi为寄存器变量,通常用来存储局部变量,该类变量通常也叫做寄存器变量,只有当寄存器无法存储过多的局部变量时才会将局部变量存放在栈中。
3.3.5 局部变量
3.3.4赋值操作
说到赋值,最常见的就是mov与lea指令,首先先来看mov指令,我们先来回顾一下mov指令的格式以及用法(图3.3.6)
3.3.6 mov指令
那么如图3.3.5中第一条指令表示将寄存器%edi中的一个双字传送给相对于栈指针%rbp减去20的对应地址,其他mov指令也像如上所述进行分析即可。
lea指令是mov指令的变种,表面上看,它做的事情非常简单,根据括号里的源操作数(图3.3.7)来计算地址,然后把地址加载到目标寄存器中,例如leaq a(b, c, d), %rax 先计算地址a + b + c * d,然后把最终地址载到寄存器rax中。
3.3.7 leaq指令
3.3.8 lea指令的不同类型
然而,lea指令真的就是只计算地址,这哥们根本不引用源操作数里的寄存器,只是单纯的计算,因此我们可以将它作为单纯的乘法来使用。
3.3.9 lea指令举例
在图3.3.9中该指令将%rip+.LC0处的值,也就是前文所说的第一个字符串常量,传送给%rdi。
3.3.5栈操作
对栈我们经常做的操作也就有两种,入栈和出栈,分别对应pushq与popq,其中在3.3.10中通过pushq指令将栈指针入栈,保留之前的栈指针。
3.3.10 pushq指令
总结pushq与popq的作用也就是
pushq %rax= R[%rsp]←R[%rsp] – 8 M[R[%rsp]]←R[%rax]
popq %rax=R[%rax] ←M[R[%rsp]] R[%rsp]←R[%rsp] + 8
3.3.6算数操作
3.3.11 算数操作
同赋值操作一样,算数操作也可以在指令后添加q,l等来限制操作数的大小。
3.3.12 算术操作举例
图3.3.12中我们可以看到是将%rsp中的值减去32,而图3.3.13中该指令实现了for循环中的i++。
3.3.13 算数操作举例
3.3.7关系操作
在图3.3.4中实现关系操作!=,通过比较4与栈中对应数的大小来实现关系操作,如果等于执行一种操作,如果不等于执行另一种操作。
3.3.8控制转移
①无条件转移指令jmp
3.3.13 无条件转移指令(段内直接寻址)
在图3.3.13中我们可以看到直接跳转到label所指定地址,而在hello.s文件中也有类似的指令,见图3.3.14
3.3.14
②条件转移指令jcc
3.3.15 条件转移指令实例
指定的条件cc如果成立,程序转移到由标号label指定的目标地址去执行指令;条件不成立,则程序将顺序执行下一条指令,一般常用在cmp指令之后,对条件码进行判断,决定是否跳转。常用的条件跳转指令如图3.3.16
3.3.16 条件转移指令详情
在图3.3.17中我们可以看到hello.s文件中的条件跳转。
3.3.17
③for循环的控制转移表示
在hello.s文件中使用了多条条件转移指令的组合来实现for循环,见图3.3.18
3.3.18 for循环的实现
在24行将栈中指定元素与4进行比较,若等于则跳转到.L2继续执行,将0传送到栈中指定的另一位置后直接跳转到.L3位置,直到R[%rbp-4]中的值大于7时才会继续往下执行从而实现了for循环。
3.3.9函数操作
①函数调用与返回
汇编语言通过call调用指定函数,见图3.3.19,调用函数的具体过程为先将返回地址压栈以便函数结束运行后返回原来的地址,然后运行函数,执行完毕后将通过ret指令返回。
3.3.19 函数调用
②参数传递
在调用函数之前将对应参数传递到指定位置
3.3.20 参数传递
3.4 本章小结
通过对编译过程的分析我了解到了对于.i文件的处理过程,编译器如何处理不同的数据类型以及操作以及如何将修改过的源程序转换为汇编语言程序,这一步将各种不同的编程语言转换为同一种汇编语言,方便了后续的处理。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将hello.s翻译成机器语言指令并打包成特定格式最终生成二进制文件的过程。
作用:将hello.s翻译成机器语言指令,将这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o这个二进制文件中。
4.2 在Ubuntu下汇编的命令
4.2.1 汇编命令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 ELF头:ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行、共享)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小的数量。
hello.o的ELF头包括类别(Class),数据(Data),机器类型(Machine),入口点地址(Start of program headers),程序头起点地址(Start of section headers),本头大小,节头部大小等信息,这个ELF头表明这是个64位的机器,字节顺序为小端序,可重定位,入口地址0x0,节头大小64字节(见图4.3.1)。
4.3.1 hello.o的ELF头
4.3.2节头
4.3.2 hello.o的节头
节头描述了不同节的位置以及大小,在目标文件中对于每个节都有一个条目,其大小固定。同时我们也可以观察到包含了.text,.rela.text,.data,.bss,.rodata等节的信息,包含了大小,地址,偏移量等信息。
.text | 已编译程序的机器代码 |
.rel.text | 一个.text节中位置的列表 |
.data | 初始化的全局和静态变量 |
.bss | 未初始化的全局和静态变量 |
.rodata | 只读数据(switch语句跳转表) |
.symtab | 符号表 |
.rel.data | 被模块引用或定义的所以全局变量的重定位信息 |
.debug | 调试符号表 |
.line | 行号与.text机器指令之间的映射 |
.strtab | 一个字符串表 |
在表格中我们展示了各节的基本信息。
4.3.3重定位节
4.3.3 重定位节格式
在重定位节(图4.3.3)中,offset是需要被修改的引用的节偏移,symbol标识被修改引用应该指向的符号,type告知链接器如何修改新的引用,addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
4.3.4 重定位节
图4.3.4中我们可以看到重定位部分有.rela.text和.rela.eh_frame,.rodata段中的两个字符串,puts,exit,printf,sleep,getchar需要重定位。最左边的是偏移量,最右边的是符号值也就是addend。
重定位一个使用32位PC相对地址的引用,当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC当前的运行值,得到有效地址;而对于绝对引用,则重定位引用的32位绝对地址就是有效地址。
4.3.4符号表
Symbol table符号表:函数和全局变量的信息。
4.3.5
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
4.4.1 hello.o的反汇编结果
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
操作数:hello.o的反汇编采用的是十六进制,hello.s采用十进制。
分支转移: hello.o的反汇编通过偏移量以及main跳转到指定地址,hello.s通过.L2,.L3等标记进行跳转。
函数调用:hello.o的反汇编通过偏移量以及main来调用函数,hello.s通过函数名来调用函数。
全局变量:hello.o的反汇编由于尚未重定位无法确定字符串地址用0来表示字符串,hello.s通过LC0来表示字符串地址。
4.5 本章小结
我们通过汇编器将hello.s文件转换为hello.o文件并得到其elf格式,了解了汇编器的任务。而通过hello.o的反汇编代码与hello.s比较,更认识到了汇编语言与机器语言之间的关系。此外获得未重定位时的汇编代码文件,为下文符号解析和重定位打下基础。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:将各种代码和数据片段收集并组合成为一个单一文件的过程
作用:使得分离编译成为可能,有了链接,我们就不用将一个大型的应用程序组织为一个巨大的源文件,而是可以将它分解成更小,更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单的重新编译它,并且重新连接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.2.1 链接命令
生成hello可执行文件。
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.1 hello的ELF头
5.3.1ELF头:我们看到可执行文件的elf头与可重定位文件的elf头比较相似(图5.3.1),但又有不同。比如类型是“EXEC(可执行文件)”,而且节头数也变多了。在加载可执行文件时,加载器跳转到这个程序的入口点,我们可看到这个入口点的地址为0x4010f0,这张图还告诉了我们节头部表的文件偏移量为14208bytes。
5.3.2节头:包括了hello中所有节的详细信息
5.3.2 节头
5.3.3 段节
5.3.3重定位节
5.3.4重定位节
5.3.4符号表
5.3.5 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.4.1 虚拟地址起始位置
由edb可知起始地址为0x401000,由图5.3.2可确定各个节的对应位置。
5.4.2 .init节对应位置
5.4.3 .plt节对应位置
5.4.4 .rodata对应位置
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
5.5.1 hello部分反汇编代码
5.5.1 hello与hello.o的不同:
1.地址不同:hello.o中的地址是相对偏移地址,而hello的地址是虚拟地址。并且打印字符串需要访问.rodata也就是只读数据区,而.rodata节的位置是在运行中确定,所有也要重定位。
2.调用函数并且新增加了函数:对于hello.o,没有调用的库函数信息,因此在callq语句后有4个字节的0表示需要重定位。但是对于hello,需要重定位的函数的信息已经获得,所以不再存在需要重定位的条目,跳转和函数调用都变成了虚拟地址。
3.增加新的节:增加了.init和.plt节。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.5.2链接过程:在使用ld命令进行链接时,指定了动态链接器,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数,链接器将上述函数加入。
5.5.3 重定位过程:
1.重定位节和符号定义:链接器将所有相同类型的节合并成为同一类型的新的聚合节。
2.重定位节中的符号引用:链接器修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 地址 |
ld-2.31.so!_dl_start | 0x7f8586bea770 |
ld-2.31.so!_dl_init | 0x7f8586bea9a0 |
hello!_start | 0x4010f0 |
libc-2.31.so!_libc_start_main | 0x7f634a96ce60 |
hello!printf@plt | 0x401040 |
hello!sleep@plt | 0x401080 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!exit | 0x7f634a7540d0 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接通过延迟绑定将过程地址的绑定推迟到第一次调用该过程时,从而避免在加载时不需要的重定位,延迟绑定通过GOT与PLT实现。
GOT:全局偏移量表,是每个条目为八字节地址的数组,和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d—linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。PLT[1]调用系统启动函数(libc_start main),它初始化执行环境,调用main 函数并处理其返回值。从PLT[2]开始的条目调用用户代码调用的函数。
由图5.3.2得到plt与got位置
5.7.1 运行dl_init前
5.7.2 运行dl_init后
由图可知项目内容发生了变化。
5.8 本章小结
介绍了链接的过程,具体分析了可执行目标文件hello的格式,虚拟空间,重定位过程,运行流程以及动态链接的实现,在追踪hello运行的过程中进一步介绍了链接的详细过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个执行中程序的实例
作用:提供给应用程序两个关键抽象
①一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
②一个私有的地址空间,它提供了一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
首先我们来了解一下shell是什么,shell是一个交互型应用级程序,代表用户运行其他程序,而bash是最常用的一种shell,是当前大多数Linux发行版的默认shell。
处理流程:
1.通过读步骤来读取用户的命令行
2.求值步骤来解析命令
3.代表用户运行
6.3 Hello的fork进程创建过程
6.3.1 fork()函数
6.3.1 fork()
父进程通过调用fork函数创建一个新的运行的子进程,与一般函数不同的是fork函数调用一次,返回两次,一次由子进程返回0,一次由父进程返回子进程PID(图6.3.1),新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.3.2 fork进程创建过程
6.3.2 进程创建
通过./运行hello可执行目标文件,运行程序时,shell创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
6.4 Hello的execve过程
6.4.1 execve函数
当hello的进程创建后会调用exceve函数在当前进程的上下文中加载并运行一个新程序,与fork一次调用返回两次不同,execve调用一次并从不返回。
execve过程:
1.加载filename,调用启动代码
2.启动代码设置栈,将控制传递给新程序的主函数
3.main开始执行
6.4.2 程序开始时用户栈的组织结构
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.5.1 上下文
上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.5.2 进程时间片
一个进程执行它的控制流的一部分的每一时间段。
6.5.3 逻辑控制流
PC值序列称为逻辑控制流,当一个逻辑流的执行在时间上与另一个流重叠时,成为并发流。
6.5.4 私有地址空间
进程为每个程序提供它自己的私有地址空间,该空间内存字节不能被其他进程读或者写,从而为程序提供独占使用系统地址空间的假象。
6.5.1 进程地址空间
6.5.5 用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供转换模式的功能,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
6.5.6 调度与上下文切换
操作系统内核使用一种称为上下文切换的异常控制流来实现多任务,在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程
上下文切换过程:①保存当前进程的上下文 ②恢复某个先前被抢占的进程被保存的上下文 ③将控制传递给这个新恢复的进程。
6.5.2 上下文切换
6.5.7 hello的进程执行
执行hello程序时处于用户模式,这时如果有其他进程要运行应通过内核调度要运行的进程,并通过上下文切换将控制转移到新的进程,当需要再次运行hello进程时应保存当前进程上下文,恢复hello进程上下文,将控制传递给hello进程,循环往复,hello与其他进程交替运行。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
6.6.1 异常:异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort),小结见图6.6.1。
6.6.1 异常小结
中断:异步发生,是来自处理器外部的I/O设备的信号的结果,由中断处理程序处理。
6.6.2 中断处理
陷阱:同步发生,是有意的异常,作为执行一条指令的结果,在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用,由陷阱处理程序处理。
6.6.3 陷阱处理
故障:由错误情况引起,可能被故障处理程序修正,如果能够修正,重新执行引起故障的指令,否则返回到内核中的abort例程,终止引起故障的程序。
6.6.4 故障处理
终止:不可恢复的致命错误造成的结果,通常是一些硬件错误引起,并且从不将控制返回给给应用程序。
6.6.2 信号
信号可以看作是小消息,通知进程系统中发生了一个某种类型的事件。
6.6.5 信号
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
Ctrl-Z:停止运行进程(向进程发送SIGSTP信号)
6.6.6 Ctrl-Z命令结果
ps命令:显示进程信息
6.6.7 ps命令结果
jobs命令:显示当前 shell 环境中已启动的作业状态
6.6.8 jobs命令结果
pstree命令:以树形结构显示程序和进程之间的关系
6.6.9 pstree命令结果
fg命令:将后台作业恢复到前台执行
6.6.10 fg命令结果
kill命令:停止指定进程
6.6.11 kill命令结果
Ctrl-C:终止运行进程(向进程发送SIGINT信号)
6.6.12 Ctrl-C结果
6.7本章小结
本章通过分析hello的进程管理,对运行流程进行从头到尾的分析,从fork到execve,从进程执行到异常与信号处理,简单阐述了整个运行流程。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
系统中hello进程与其他进程共享CPU和主存资源,因此为了更加有效地管理内存并且少出错,现代系统提出了虚拟内存的概念,虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。
虚拟内存的作用:①它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。2)它为每个进程提供了一致的地址空间,从而简化了内存管理。3)它保护了每个进程的地址空间不被其他进程破坏。
逻辑地址:是指应用程序角度看到的内存单元、存储单元、网络主机的地址。 逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。
线性地址:逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟寻址时由CPU生成来访问主存
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:一个段标识符和一个段内相对地址的偏移量。段标识符是一个16位长的字段,称为段选择符,而偏移量是一个32位长的字段。
转换过程如下:
①使用段选择符中的偏移值(段索引)在GDT(全局描述符表)或LDT(局部描述符表)中定位相应的段描述符.(仅当一个新的段选择符加载到段寄存器中是才需要这一步)
②利用段选择符检验段的访问权限和范围,以确保该段可访问。
③把段描述符中取到的段基地址加到偏移量(也就是上述汇编语言汇中直接出现的操作地址)上,最后形成一个线性地址。
7.2.1 逻辑地址转换为线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块,在任意时刻,虚拟页面集合都分为三个不相交的子集,见图7.3.1
7.3.1 虚拟页面集合
类似的物理内存被分割为物理页,见图7.3.2
7.3.2 VM系统使用主存作为缓存
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1页表
虚拟内存为了更加有效的管理虚拟页,采用了页表这一数据结构,页表是页表条目(PTE)的数组,将虚拟页映射到物理页,每次地址翻译硬件将一个一个虚拟地址转换为物理地址时都会读取页表,操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
7.4.1 页表
7.4.2 使用页表的地址翻译
7.4.2使用TLB加速地址翻译
由于每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,这将极大地增大时间开销,因此,为了消除该开销,系统在MMU中包括了一个关于PTE的小的缓存,称为TLB,也叫翻译后备缓冲器。
7.4.3 TLB标记以及索引
当TLB命中时,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
7.4.4 TLB命中与不命中的操作
7.4.3 多级页表
通过使用层次结构的页表来压缩页表
7.4.5 四级页表
若缓存页命中则返回页表条目,否则继续查询是否在下一级页表中。
7.5 三级Cache支持下的物理内存访问
在取址时,CPU先去TLB中寻址,若页命中则直接返回页表条目否则查询多级页表得到PTE,经过翻译后得到物理地址PA,再检测在下一级缓存中是否命中,若命中则返回数据,否则继续寻找直到找到指定物理地址。
7.5.1 三级Cahe下的物理内存访问
7.6 hello进程fork时的内存映射
fork为hello创建虚拟内存,创建当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW),在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序hello的步骤:
删除已存在的用户区域,创建新的区域结构,私有的、写时复制,代码和初始化数据映射到.text和.data区(目标文件提供),.bss和栈堆映射到匿名文件,栈堆的初始长度0,共享对象由动态链接映射到本进程共享区域,设置PC,指向代码区域的入口点,Linux根据需要换入代码和数据页面。
7.7.1 进程地址空间
7.8 缺页故障与缺页中断处理
7.8.1 缺页故障
当虚拟页缓存在内存中并引用该页中的字时称为页命中,DRAM缓存不命中称为缺页, 也就是对应虚拟页未被缓存,发生了页不命中。
7.8.1 对VP3中字的引用会不命中从而触发缺页
7.8.2 缺页中断处理
选定牺牲页,将所缺的页从磁盘复制到内存指定牺牲页中,并更新对应的PTE,返回导致缺页故障的指令重新执行。
7.8.2 缺页中断处理(处理7.8.3情况)
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
在程序运行时程序员使用动态内存分配器 (比如 malloc) 来获得虚拟内存,动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
7.9.1 堆
7.9.1基本方法
动态内存分配:分配器将堆视为一组不同大小的 块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器分为显式分配器与隐式分配器,我们这里只讨论显式分配器。
显式分配器:要求应用显式地释放任何已分配的块,例如C语言中的 malloc 和 free,该分配方法可以处理任意的分配( malloc)和释放(free)请求序列但是只能释放已分配的块。
但是该方法也有许多限制,比如无法控制分配块的数量或大小,必须从空闲内存分配块,必须对齐块,使得它们可以保护任何类型的数据对象,只能操作或改变空闲块,一旦块被分配,就不允许修改或移动它了。
7.9.2 分配示例
7.9.2策略
策略 1: 隐式空闲链表(Implicit list) 通过头部中的大小字段—隐含地连接所有块
策略 2: 显式空闲链表(Explicit list) 在空闲块中使用指针
策略 3: 分离的空闲列表 (Segregated free list) 按照大小分类,构成不同大小的空闲链表
策略 4: 按块按大小排序—平衡树 在每个空闲块中使用一个带指针的平衡树,并使用长度作为权值
7.10本章小结
本章我们介绍了hello的存储管理,包括段式管理与页式管理,还介绍了一个重要的概念虚拟内存,介绍了虚拟内存的工作方式,如何实现从虚拟地址到物理地址的转换以及怎么去加速地址访问,最后介绍了内存映射以及动态内存管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix IO接口可以实现4种基本操作:
①打开文件,应用程序要求内核打开相应的文件,来宣告它想要访问一个IO设备,内核返回这个文件的描述符以标识这个文件。Shell创建的每个进程开始时都有3个打开的文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。
②改变当前的文件位置,应用程序通过执行seek操作,显式地设置文件的当前位置为k。
③读写文件,读操作就是从当前位置k开始,从文件复制n个字节到内存,然后将k增加到k+n,当k超出文件长度时应用程序能够通过EOF检测到。而写操作则是从内存复制n个字节到一个文件,从当前文件位置k开始,然后更新k。
④关闭文件,当应用完成了对文件的访问之后,它就通知内核关闭这个文件,内核释放文件打开时创建的数据结构和内存资源。
8.2.1 open函数
int open(const char * pathname, int flags);
int open(const char * pathname, int flags, mode_t mode);
参数 pathname 指向欲打开的文件路径字符串,参数flags设置打开方式,参数mode表示权限,只有在建新文件时才会生效,返回值:若所有欲核查的权限都通过了检查则返回0 值, 表示成功, 只要有一个权限被禁止则返回-1。
8.2.2 create函数
int create(const char *path, mode_t mode);
若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。参数与open中对应的参数含义相同。create(path, mode)函数功能为创建新文件,与open(path, O_CREATE|O_TRUNC|O_WRONLY)功能相同。
8.2.3 lseek()函数
int lseek(int fd, off_t offset, int whence);
成功则返回新的文件的偏移量;失败则返回-1。使用lseek()函数显式的为一个打开的文件设置偏移量。lseek仅将文件的偏移量记录在内核中,并不引起IO开销。
8.2.4 read()函数
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。
8.2.5 write()函数
#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t ntyes);
若写入成功则返回写入的字节数;失败返回-1。buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。
8.3 printf的实现分析
printf函数代码如下
8.3.1 printf函数
首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。而write会通过syscall陷阱跳到内核,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.3.2 vsprintf函数
8.4 getchar的实现分析
8.4.1 getchar函数
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了hello的IO管理,总结了Linux的IO设备管理方法以及Unix IO接口以及函数。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
将hello比作人的话,那么hello.c便是便是hello的起点,也就是受精卵。
对hello.c进行预处理,实现hello.c到hello.i阶段的提升。
对hello.i进行编译,实现hello.i到hello.s阶段的提升。
对hello.s进行汇编,实现hello.s到hello.o阶段的提升。
然后对hello.o进行链接,成为已经初步成熟的hello可执行目标文件。
就像刚毕业的大学生步入社会一样,使用shell来运行hello可执行目标文件。
shell调用fork函数为hello创建一个进程,调用execve函数加载可执行文件hello及其所需的动态链接库,通过虚拟内存机制将可执行文件中的节映射到内存空间中。
运行hello时,通过页式管理和段式管理,并利用TLB,多级页表,三级cahe加速对地址的访问。
hello进程运行时,会产生诸多的异常与信号,例如键盘中断、SIGTSTP、SIGINT等。
hello进行printf时会调用malloc函数来动态申请内存。
hello运行时,要通过中断与IO端口等与外部硬件设备交互。
当hello执行结束后,由父进程对子进程进行回收。
至此,hello的一生就这么结束了,尽管它表现得十分简单,结束的如此迅速,它也经历了如此丰富而又多彩的一生。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
文件名称 | 作用 |
hello.c | 储存hello源代码 |
hello.i | 源代码经过预处理产生的文件 |
hello.s | hello程序对应的汇编语言文件 |
hello.o | 可重定位目标文件 |
hello.elf | hello.o的ELF文件格式 |
hello | 二进制可执行文件 |
hello.elf | 可执行文件的ELF文件格式 |
hello.s | 可执行文件的汇编语言文件 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)