计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 1190200928
班 级 1903005
学 生 赵文康
指 导 教 师 史先俊
计算机科学与技术学院
2021年5月
本文根据hello.c程序从编写到运行的整个过程,综合贯通了计算机系统这门课的主要内容及精髓。分析并验证了hello.c预处理、编译、汇编、链接的过程及细节,查看了每个过程中hello的存在形态及变化过程,探究了进程管理下的程序运行到回收过程,在此期间是如何存储、存储在哪里、如何寻找访问,如何显示交互的。由此对程序的一生有了较详细的了解。
关键词:hello;预处理;汇编; 链接;进程;地址;I/O
目 录
hello.c的程序是在Linux环境下,使用Codeblocks或者Vim等文档编辑器编辑的,利用高级语言编写的可读程序。... - 4 -
6.2 简述壳Shell-bash的作用与处理流程... - 38 -
6.3 Hello的fork进程创建过程... - 39 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 44 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 44 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 44 -
7.5 三级Cache支持下的物理内存访问... - 44 -
7.6 hello进程fork时的内存映射... - 44 -
7.7 hello进程execve时的内存映射... - 44 -
第1章 概述
1.1 Hello简介
hello.c的程序是在Linux环境下,使用Codeblocks或者Vim等文档编辑器编辑的,利用高级语言编写的可读程序。
- P2P(From Program to Process):
用户通过编辑器编写人类可读的高级语言代码,得到一个hello.c程序。首先,利用预处理器cpp得到修改了的源程序,ASCII码的中间文件hello.i;然后,利用编译器cl得到汇编程序,ASCII汇编语言文件hello.s;然后,再利用汇编器as生成可重定位目标程序hello.o(二进制);再利用链接器ld生成可执行目标文件(二进制)hello;最后,用户在Ubuntu shell键入./hello启动此程序,shell调用fork函数为其产生子进程,hello便成为了进程(process)。
- O2O(From Zero-0 to Zero-0):
刚开始再内存空间中,程序是不存在。当shell通过execve加载并执行该程序时,操作系统为程序分配一部分虚拟空间,将程序加载到虚拟空间所映射的物理内存空间中,可执行目标文件hello中的代码和数据从磁盘复制到物理内存,然后通过跳转到程序入口点_start函数的地址,最终调用执行hello中的main函数,内核为hello进程分配时间片执行逻辑控制流。程序运行结束后,shell父进程回收这个僵死子进程,内核会从系统中删除它的所有痕迹。
1.2 环境与工具
硬件工具:X64 Intel i7-8750H CPU,20GRAM,1T DISK
软件工具:Windows10 64位,VirtualBox 6.1,Ubuntu20.04.2 LTS
开发者与调试工具:gcc,gdb,edb,Winhex,vim,ld,readelf,objdump
1.3 中间结果
文件名 | 文件功能 |
hello.i | hello.c预处理之后的文本文件 |
hello.s | hello.i编译之后的汇编文件 |
hello.o | hello.s汇编之后可重定位目标执行文件 |
hello | hello.o经过链接后的可执行目标文件 |
hello-o-objdump | hello.o的ELF格式文件 |
hello-o-elf_ | hello.o的反汇编代码文件 |
hello-objdmp | hello.o的反汇编代码文件 |
hello-elf | hello的ELF格式文件 |
1.4 本章小结
本章主要简单介绍了hello 的p2p,020 过程,列出了本次实验的环境、工具、中间结果。
第2章 预处理
2.1 预处理的概念与作用
- 概念
预处理是在编译之前进行的处理,一般指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理器并不对程序的源代码进行解析,但它把源代码分割或处理为特定的段。预处理中会展开以#起始的行,修改原始的C程序,它检查包含预处理指令的语句和宏定义,并对源代码进行转换,还会删除注释和多余的空白字符。
- 作用
主要作用有宏定义、文件包含、条件编译
- 将所有的#define删除,并且展开所有的宏定义,将宏名替换为文本。.
- 处理所有条件预编译指令,根据#if以及#endif和#ifdef以及#ifndef来判断执行编译的条件。
- 预处理程序中的#include,将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。
2.2在Ubuntu下预处理的命令
处理指令:cpp hello.c > hello.i2.3 Hello的预处理结果解析
打开hello.i文件可以发现,整个hello.i程序已经拓展为3060行。main函数出现在hello.c中的代码自3047行开始。我们发现hello.i文件的篇幅巨大是因为预处理工作进行了头文件的展开。对引用目录的标注,诸如”/usr/include/stdlib.h”。经过查阅得到这样引用的规范:
“#”后第一个数字是源代码来源的行号,之后的文件名是它来自的文件,最后的数字是标志。1表示文件的开始,2表示返回文件,3表示以下文本来自系统头文件,4表示文本被视为包含在隐式extern "C"块中。
此外,经过观察可得,预处理的头文件展开是顺序的,按照文件中的顺序依次展开。Stdio.h在13行,unistd.h在731行,stdio.h在1970行开始展开。
在Ubuntu的文件中找到:观察扩展头文件的部分,发现有大量对结构的定义,诸如typedef、struct、enum等等,对外部变量的引用,诸如extern。此外,我们还看到删除了大量的空白字符和注释。
2.4 本章小结
本章主要介绍了预处理的定义与作用、并结合预处理之后的程序对预处理结果进行了解析,分析了头文件展开、宏替换、条件编译、去除注释等工作。
第3章 编译
3.1 编译的概念与作用
- 概念
编译器(ccl)将预处理得到的ASCII码的中间文件hello.i翻译成ASCII汇编语言文件hello.s的过程。它以文本的形式描述了一条条低级机器语言指令。
- 作用
- 语法分析:分析代码的词法、语法、语义。词法分析对输入的字符串进行分析和分割,形成所使用的源程序语言所允许的记号,同时标注不规范记号,产生错误提示信息。语法分析词法分析得到的记号序列,并按一定规则识别并生成中间表示形式,以及符号表。同时将不符合语法规则的记号识别出其位置并产生错误提示语句。语义分析即静态语法检查,分析语法分析过程中产生的中间表示形式和符号表,以检查源程序的语义是否与源语言的静态语义属性相符合。
- 中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
- 代码优化:指对程序进行多种等价变换,转换为功能等价但是运行时间更短或占用资源更少的等价中间代码。
- 目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标汇编语言代码。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s3.3 Hello的编译结果解析
汇编代码展示:3.3.1数据
- 整数 int i:i在c程序里面被声明为局部变量,而且没有被初始化。它不占用文件的实际节空间,只有当运行时才在寄存器或者栈上分配空间进行操作。其中为初始化i=0。为循环控制条件为循环自增加一。
变量 argc :main函数的第一个形式参数,由寄存器%edi传入,进而保存在堆栈中。
为循环自增加一。
- 字符串
程序中涉及的字符串为:"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n"。在汇编文件中.section的.rodata节,声明了两个string类型的字符串。.LCO中声明的字符串汉字编码格式是UTF-8,而且一个数字一个字节,一个汉字在utf-8 编码中占三个字节,一个\代表一个字节。main函数中对字符串的引用格式是:leaq.LC1(%rip), %rdi,直接使用伪指令.LC1代指字符串。
- 数组
程序中涉及到的数组为字符串数组,程序中涉及到的数组为字符串数组(字符数组指针),即main函数的第二个参数char *argv[]。首先,它将数组的首元素地址存入栈中,当访问argv[1]和argv[2]时,采用寄存器寻址的方法:如图,先%rbp-32,在加上16,取到arg[2],再%rbp-32,在加上8,取到arg[1]。并和%rdi所保存的字符串一起作为printf函数的三个参数。
3.3.2赋值
源程序中的赋值操作有i=0;i++;对应汇编代码除如下 Mov指令的后缀:b/w/l/q 对应1/2/4/8字节。
3.3.3类型转换
没有发生隐式的类型转换,在语句:中利用atoi函数将字符类型转换为整数类型,对应的汇编如下:先取出argv[3],将其存入rdi。
3.3.4算数操作
对i进行了i++的运算操作,使用了addl语句:汇编中使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1 的段地址%rip+.LC1并传递给%rdi。3.3.5关系操作
3.3.5关系操作
程序进行了两次关系操作:argc!=4:
使用cmpl设置条件码,jxx根据条件码选择是否进行相应的跳转。
对应汇编代码处:
3.3.6控制转移
第一个控制转移操作:
对应处为
先比较argc和4,如果等于则进行跳转,如果不等于则执行if中的语句
第二个控制转移操作:对应处为: for循环中,源代码是小于8,而汇编代码优化为<=7,则执行循环体中的程序。
3.3.7数组、指针操作
此处的操作为取argv[2]的内容,使用寄存器寻址的方法。
3.3.8函数操作
调用函数的动作如下:
-
-
- 传递控制:进行过程Q 的时候,程序计数器必须设置为Q 的代码的起始地址,然后在返回时,要把程序计数器设置为P 中调用Q 后面那条指令的地址。
- 传递数据:P 必须能够向Q 提供一个或多个参数,Q 必须能够向P 中返回一个值。
- 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
-
64位下整数的参数传递方法:
1 | 2 | 3 | 4 | 5 | 6 | 7 |
%rdi | %rsi | %rdx | %rcx | %r8 | %r9 | 栈空间 |
程序中调用的函数如下:
- main函数:
main 函数因为被调用call 才能执行(被系统启动函数__libc_start_main 调用),call 指令将下一条指令的地址dest 压栈,然后跳转到main 函数。Int argc是main函数的第一个参数,它给出argv[]数组中非空指针的数目。在汇编中,
而argv[n]由等寄存器寻址方式存取。
在程序的最后,,用eax返回0,leave相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,ret 相当pop IP,将下一条要执行指令的地址设置为dest。
- printf函数
在.L4中,通过argv+8 和argv+16分别指向了argv[1],argv[2],再由argv[1],argv[2]指向的参数字符串得到了%rsi,%rdx两个参数。并和%rdi所保存的字符串一起作为printf函数的三个参数。而另一个printf函数只有一个字符串参数,所以在具体的汇编代码中被优化为puts函数
exit函数:
通过movl将传参的edi设置为1
atoi函数
取出arg[3]放入rdi传入atoi函数
getchar函数
3.4 本章小结
本章把hello.i文件编译成了hello.s,并对源程序中的数据和操作做了解析,阐述了和汇编代码的对应关系。
第4章 汇编
4.1 汇编的概念与作用
- 概念
汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令转化成一种叫做可重定位目标程序的格式,并将结果保存到二进制文件目标文件hello.o中。
- 作用
汇编过程将汇编代码转换为计算机能够理解并执行的二进制机器代码。
4.2 在Ubuntu下汇编的命令
gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o4.3 可重定位目标elf格式
- 首先使用readelf命令查看hello.o的ELF格式,指令如下:
readelf -a hello.o > hello-o-elf
- ELF结构分析
- ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。节头
记录每个节的名称、偏移量、大小、位置等信息.text节:已编译程序的机器代码以编译的机器代码。
.rela.text节:一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.data节:已初始化的静态和全局C变量。
.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
.rodata节:存放只读数据。
.comment节:包含版本控制信息。
.symtab:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。
.strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。
.shstrtab节:该区域包含节的名称。
- 重定位节.rela.text:
存放着代码的重定位条目。当链接器把这个目标文件和其他文件组合时,会结合这个节,修改.text节中相应位置的信息。八条重定位信息为:第一个printf中的字符串;puts函数;exit函数;第二个printf中的函数;printf函数;atoi函数;sleep函数;getchar函数。
而重定位条目常见共2种:
R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。
可以看出,对于字符串的都是绝对引用。每个重定位条目包含如下信息:该节包括的内容是:偏移量,信息,类型,符号值,符名称和加数。其结构如下:
offset | 需要进行重定向的代码在.text或.data 节中的偏移位置,8 个字节。 |
info | 包括symbol 和type 两部分,其中symbol 占前4 个字节, type 占后4 个字节,symbol 代表重定位到的目标在.symtab中的偏移量,type 代表重定位的类型 |
Addend | 计算重定位位置的辅助信息,共占8个字节 |
Type | 重定位到的目标的类型 |
Name | 重定向到的目标的名称 |
重定位PC相对引用重定位算法如下:
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
重定位绝对引用重定位算法如下:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
- 重定位节.rela.eh_frame:.symtab:一个符号表,他存放在程序中定义和引用的函数和全局变量的信息。程序头部表。告诉系统如何创建进程映像。当前不存在。节头表。一个目录,用来定位文件中的所有的节。当前不存在4.4 Hello.o的结果解析
objdump -d -r hello.o > hello-o-objdump获得hello.o的反汇编代码。
得到的文件和hello.s的差别不大,具体的差别如下:显示格式:hello.i前没有一串二进制数,即相应的机器码,而反汇编代码前面有与之对应的机器码。
- 数据显示:立即数在hello.i这一汇编语言文本文件中为十进制,而在反汇编代码中为十六进制。
- 跳转方式:在汇编代码中,通过助记符如.LC0,.LC1来进行跳转。在反汇编代码是依据地址跳转的。
- 函数调用:函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编序中,call 的目标地址是当前下一条指令。因为其地址还需要重定位地址。
- 字符串常量:汇编中字符串常量在.rodata中存储,反汇编中字符串常量使用0x0暂时代替,等待重定位。
4.5 本章小结
本章介绍了hello 从hello.s 到hello.o 的汇编过程、阅读了程序的ELF条目,了解了汇编、反汇编这两种相近而不相同的程序表现形式,,了解到从汇编语言映射到机器语言汇编器需要实现的转换。
第5章 链接
5.1 链接的概念与作用
概念
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
作用
链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
readelf -a hello > hello-elf查看hello的ELF信息。
ELF结构分析
ELF头:
对文件类型、程序入口地址、头起点地址、各节的大小进行修改,得到了相应的节大小。
- 节头部表:给出了各节的大小,地址和偏移量等。地址是程序运行时的虚拟地址。
- 程序头:相当于一个目录,描述了各节的分布,地址的数值范围。Section to Segment mapping: Section to Segment mapping:Section to Segment mapping:Dynamic section:如果程序进行了动态链接,则有该节。重定位节符号表:
- 其它信息:5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
在edb中找到并加载hello可执行文件。观察edb的Data Dump窗口。窗口显示虚拟地址由0x401000开始,到0x402000结束,这之间的每一个节对应5.3中的每一个节头表的声明。
5.5 链接的重定位过程分析
反汇编hello得到objdump -d -r hello >hello-objdump与hello-o-objudmp相比,它其中多了很多其它的节。比如.init节和.plt节。而且hello-objdump中的地址是虚拟地址,而hello-o-objdump中的地址是相对偏移地址,而且跳转和函数调用都以虚拟地址。因为hello反汇编的结果中,由于链接过程中重定位而加入进来各种函数、数据,许多开始的函数和调用的函数填充在main函数之前,所以main函数的位置发生了巨大的改变。而这些call函数,引用全局变量,和跳转模块值时地址也有所变化。hello-objudmp中增加了许多外部链接的共享函数库,例如printf@plt以getchar@plt等等。5.6 hello的执行流程
./edb --run hello
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7fce 8cc385c0 |
ld-2.27.so!_dl_init | 0x7fce 8cc55630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce 8c855ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce 8c877430 |
hello!_init | 0x40064c |
libc-2.27.so!_setjmp | 0x7fce 8c884c10 |
hello!main | 0x4007f3 |
hello!puts@plt | 0x400820 |
hello!exit@plt | 0x4008a1 |
hello!getchar@plt | 不执行 |
hello!sleep@plt | 不执行 |
libc-2.27.so!exit | 0x7fce 8c8a2128 |
5.7 Hello的动态链接分析
因为编译器没有办法知道函数运行时的地址,需要链接器进行连接处理。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT [0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT [0]和GOT [1]包含动态链接器在解析函数地址时会使用的信息。GOT [2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
因为编译器没办法预测地址,所以需要进行重定位,等待链接器进行处理。
dl_init调用之前:dl_init调用之后:
5.8 本章小结
本章介绍了链接的概念和作用,分析了hello的ELF文件格式,分析了hello的执行流程和动态链接分析,复习了edb的使用。
第6章 hello进程管理
6.1 进程的概念与作用
- 概念
进程是一个执行中的程序的实例,是一个“执行中的程序”,是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
2.作用
计算机通过为用户提供一种假象:一个是独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器。另一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
- 作用
Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。其基本功能是解释并运行用户的指令。
- 处理流程
- 将用户输入的命令行进行解析,分析是否是内置命令
- 若是内置命令,直接执行;若不是内置命令,则 bash在初始子进程的上下文中加载和运行它。
- shell执行时同时可以接受来自终端的命令输入.
- 运行时,shell还可以处理异常。
- 运行结束后,shell可以回收僵尸子进程。
6.3 Hello的fork进程创建过程
执行中的进程调用fork()函数,就创建了一个子进程。对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
父进程通过调用fork函数创建一个新的运行的子进程,其函数原型为pid_t fork(void)。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
以输入./hello 1190200928 赵文康 为例:
- 首先对于hello进程。我们终端的输入被判断为非内置命令,然后shell试图在硬盘上查找该命令(即hello可执行程序),并将其调入内存,然后shell将其解释为系统功能调用并转交给内核执行。
- Shell 创建一个子进程,使得hello开始运行。它获得了父进程的数据空间副本但并不共享,却可以读取父进程的打开的文件。此外,它们拥有不同的pid。
6.4 Hello的execve过程
execve函数原型:int execve(const char*filename,const char*argv[],const char*envp[]);
子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序,为执行hello程序加载器、删除子进程现有的虚拟内存段,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
进程时间片:一个进程执行它的控制流的一部分的每一时间段
上下文信息:内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等。
调度过程:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢断当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换的过程:保存当前进程的上下文;恢复某个先前被抢断的进程被保存的上下文;将控制权传递给这个新恢复的进程
用户态与核心态转换:进程从用户态进入核心态的唯一方法是通过中断、故障或陷入系统调用这样的异常,当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理器把模式从内核模式改回用户模式
刚开始的时候,系统处于内核态,当用户在shell中键入命令,系统保存上下文,进行拷贝,fork子进程,开始在用户模式里面执行hello,期间如果遇到sleep,触发陷阱进程休眠,切换到内核态进行处理,并将hello加入等待队列,等到计时器完成计时,内核进行中断处理,重新切换上下文到hello进程。
6.6 hello的异常与信号处理
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常可以分为四类:中断、陷阱、故障和终止。
- 正常执行
- 乱按
乱按并不影响程序运行,输入的字符会缓存到stdin中,当getchar的时候读入第一个字符,其他的字符会当作shell命令行的输入。
3.Ctrl-z
在执行完一部分后输入ctrl-z,shell将进程挂起,ps命令显示hello并没有被回收,jobs显示当前后台进程。并展示了pstree的作用
4.Ctrl-c按下ctrl-c,shell停止并回收hello进程。
5.Fg
使用fg命令调出剩下的进程执行。
6.Kill
利用kill指令杀死挂起的进程。
6.7本章小结
本章阐明了进程的定义与作用,介绍了Shell 的一般处理流程,并探讨了hello进程运行过程中可能的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。Intel 80386的线性地址空间容量为4G(32根地址总线寻址)。
虚拟地址:CPU 启动保护模式后,程序运行在虚拟地址空间中。与物理地址相 似,虚拟内存被组织为一个存放在磁盘上的N 个连续的字节大小的单元组成的数组,其每个字节对应的地址成为虚拟地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。每个段有三个参数定义:段基地址,指定段在线性地址空间中的开始地址。段偏移量:是虚拟地址空间中段内最大可用偏移地址。段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。在此基础上,处理器有两种寻址模式:实模式与保护模式。
- 保护模式
保护模式是现代计算机常用的寻址模式。保护模式下,将一个段地址进行分段,使用索引在描述符表中读取及地址。段标识符由16位长的字段组成,称为段选择符。
转化过程如下:
- 给定一个逻辑地址。
- 将逻辑地址进行划分得到索引、TL、RPL信息。
- 选择是GDT还是LDT中的段,再根据相应的寄存器得到地址。
- 寻找段描述符得到基地址
- 线性地址=基地址+偏移量 2.实模式 实模式下,逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,给出32位地址偏移量,则可以访问真实物理内存。
7.3 Hello的线性地址到物理地址的变换-页式管理
计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。
虚拟地址被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),VPO用来在页表中寻找相应的对应页表条目PTE,然后读取页表中存储的PPN物理页号,作为物理地址的PPN,然后,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。如果PTE的有效位为1,则页命中,符合上述步骤。
如果PTE的有效位为0,则页不命中,没有缓存到物理内存,引发一个缺页异常,调入新的页并写入PTE,然后回到刚才导致缺页的程序处重新调用。
7.4 TLB与四级页表支持下的VA到PA的变换
MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。TLB结构如下:翻译时,首先将虚拟地址的VPN划分为TLBT(TLB标记)和TLBI(TLB索引)。利用TLBI,访问TLB中的某一组。遍历该组中的所有行,若找到一行的标志等于TLBT,且有效位为有效,,则缓存命中,该行存储的即为PPN;若未找到一行的标签等于TLBT,或找到但该行无效,则缓存不命中。进而需要到页表中找到被请求的块,用以替换原TLB表项中的数据。
在此基础之上,计算机还采取了多级页表的形式。它主要处理TLB不命中的情况,用来翻译虚拟地址。此时VPN被解释为4各=个段,从高地址开始,第一段VPN部分作为第一级页表的索引,用以确定第二级页表的基地址,以此类推直到在第四级页表里取到所需要的PPN,在整个过程中,一旦有一级的页表有效位为0,则下一级页表就不存在,产生缺页故障。
7.5 三级Cache支持下的物理内存访问
64位下,物理地址有52位,0到5位时CO偏移量,6到11位是CI组索引,12到51位是CT标记。MMU发送物理地址给L1缓存,LI缓存按照这三个量来寻找缓存,若找到标记为等于CT的Cache行,且这个行存在且有效位为1,则缓存命中,取出偏移量为CO的字节,并传递给CPU。如果缓存未命中,则继续到L2中寻找,L2未命中到L3中,L3未命中到主存中寻找。找到后,要进行缓存的替换,有两种策略来进行替换,分别是哪一行:最不常使用(LFU)策略和最近最少使用(LRU)策略。
7.6 hello进程fork时的内存映射
当fork 函数被shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序。需要以下几个步骤:
-
- 删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
- 为hello的代码、数据、bss 和栈区域创建新的区域结构映射私有区域。。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
虚拟内存中,DRAM缓存不命中称为缺页。缺页后,执行如下处理步骤
- 判断是否为合法的地址
- 确认是否有读、写、或者执行这个区域内页面的权限。
- 正常缺页下,选择一个牺牲页,缺页异常处理程序调入新的页面,更新PTE,返回到原来的进程执行导致缺页的指令。7.9动态存储分配管理
printf 函数会调用malloc,下面简述动态内存管理的基本方法与策略:动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。C语言通过malloc函数来分配一个块,再通过free来释放这个块。
动态内存的分配方式:
- 带边界标签的隐式空闲链表分配器隐式空闲链表,它的每个空闲块中给出了四个字节的头部header,因为地址一定是8字节对齐,最后三位肯定为空的,所以最后三位中的第0位可以用来存放这个块是否分配(0/1)的信息。还可以加入一个脚部footer,脚部是头部一个副本,便于合并前面块,在合并时可以用来确定前一个块是不是空的,所以最小块大小是8个字节,而将空闲链表分配出去时可以不用加入footer,只需在header的空闲3位中再选1位用来作为前一块是否是空闲的标记(0/1)即可。中间部分有效载荷以及可能的一些额外的填充组成。分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块的集合。
对于隐式空闲链表,有三种放置策略:首次适配,从头开始搜索空闲链表,选择第一个合适的空闲块;下次适配,从上一次查询结束的地方开始,选择第一个合适的空闲块;最佳适配,检查每个空闲块,选择适合所需请求大小的最小空闲块;当分配器释放一个已分配块时,可以选择立即合并或者推迟合并,一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,只需要通过改变Header 和Footer 中的值就可以。
- 显示空闲链表
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。维护链表时,可以采用先进后出的方式,使得新释放的块放在量表的开始处,使用后进先出的顺序和首次适配的放置策略,分配器会检查最近使用过的块。另一种是按照地址顺序来维护链表,其中链表每个块的地址都小于它后继的地址。在这种情况下每释放一个快需要线性时间搜索来定位合适的前驱。
- 分离链表
简单分离链表的每个等价类中的块大小是一样的,所以可以根据地址判断块大小,所以分配可以在常数时间内完成。因为块大小固定,所以不需要合并,只需要寻找不同的等价类就可以了,那么header和footer也不需要了,只需要一个能够指向后面块的指针就可以了。它分为简单分离链表、分离适配和伙伴系统几个方式。
7.10本章小结
本章我们讨论了intel的段式管理、页式管理,以i7为例介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的IO设备都被模型化为文件,而所有的输入输出都被当作对相应文件的读和写来执行。
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
- 打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。Linux shell创建的每个进程开始时都有三个打开的文件: 标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
- 改变当前的文件位置。 对于每个打开的文件,内核保持着一个文件位置k,k初始为0,表示的是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O接口函数:
- 进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的,open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。int open(char *filename, int flags, mode_t mode);
- close函数关闭一个打开的文件。成功返回0错误返回。EOFint close(int fd);
- 应用程序是通过分别调用read和write函数来执行输入和输出的。read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
- lseek函数,应用程序能够显式地修改当前文件的位置。 off_t lseek(int handle, off_t offset, int fromwhere);
8.3 printf的实现分析
Printf函数:
int printf(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;
}
功能:接受一个格式化命令,并按指定的匹配的参数格式化输出
Vsprintf函数:
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 结合参数args 生成格式化之后的字符串,并返回字串的长度。
Syscall将字符串中的字节复制到显卡的显存中去,字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb = buf;
static int n = 0;
if(n == 0) {
n = read(0,buf,BUFSIZ);
bb= buf;
}
return (--n >= 0) ? (unsigned char)*bb++ : EOF;
}
异步异常-键盘中断的处理:当用户按键时,会产生一个SIGINT信号,触发键盘中断处理子程序,同时产生中断请求,请求抢占当前进程运行键盘中断子程序,键盘中断处理子程序接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
主要介绍了linux的IO设备管理方法和及其接口和函数,了解了printf的函数和getchar函数的底层实现。
结论
- 预处理:hello.c文本翻译为hello.i文本,预处理器cpp替换掉源码中的头文件和宏。
- 编译:将 hello.i 编译成为汇编文件 hello.s
- 汇编:将 hello.s 会变成为可重定位目标文件 hello.o
- 链接:静态链接,把外部函数的代码(通常是后缀名为.lib和.a的文件),添加到可执行文件中;动态链接的做法正好相反,它会设置过程链接表PLT和全局偏移量表GOT等,只在运行时动态引用相关代码。最后生成了hello可执行文件。
- 加载运行:shell中输入,终端为其新建进程(fork),把代码和数据加载入虚拟内存空间(execve),程序开始执行;
- 执行每一步指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
- 访存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。printf会调用malloc向动态内存分配器申请堆中的内存。
- 信号处理:如果运行途中键入中断,则调用shell的信号处理函数分别停止、挂起。
- 终止并被回收:shell父进程等待并回收子进程。
附件
文件名 | 文件功能 |
hello.c | 源文件 |
hello.i | hello.c预处理之后的文本文件 |
hello.s | hello.i编译之后的汇编文件 |
hello.o | hello.s汇编之后可重定位目标执行文件 |
hello | hello.o经过链接后的可执行目标文件 |
hello-o-objdump | hello.o的ELF格式文件 |
hello-o-elf_ | hello.o的反汇编代码文件 |
hello-objdmp | hello.o的反汇编代码文件 |
hello-elf | hello的ELF格式文件 |
参考文献
[1]深入理解计算机操作系统pdf,课程PPT,实验资料等。
[2]gcc命令http://man.linuxde.net/gcc
[3] printf 函数实现的深入剖https://blog.csdn.net/zhengqijun/article/details/72454714