摘 要
本文主要研究了hello这一简单c程序的整个生命周期。以hello.c源程序为起点,从编译、汇编、链接,到加载、运行,再到终止、回收。在过程中解释各个阶段的内容与实现机制。根据hello程序的生命周期,漫游了深入理解计算机系统这本书的基本内容,体会了现代计算机系统的实现。
关键词:深入理解计算机系统;hello程序;程序人生;P2P;020
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
1.1 Hello简介… - 4 -
1.2 环境与工具… - 4 -
1.3 中间结果… - 4 -
1.4 本章小结… - 4 -
2.1 预处理的概念与作用… - 5 -
2.2在Ubuntu下预处理的命令… - 5 -
2.3 Hello的预处理结果解析… - 5 -
2.4 本章小结… - 5 -
3.1 编译的概念与作用… - 6 -
3.2 在Ubuntu下编译的命令… - 6 -
3.3 Hello的编译结果解析… - 6 -
3.4 本章小结… - 6 -
4.1 汇编的概念与作用… - 7 -
4.2 在Ubuntu下汇编的命令… - 7 -
4.3 可重定位目标elf格式… - 7 -
4.4 Hello.o的结果解析… - 7 -
4.5 本章小结… - 7 -
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.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.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.1 Linux的IO设备管理方法… - 13 -
8.2 简述Unix
IO接口及其函数… - 13 -
8.3 printf的实现分析… - 13 -
8.4 getchar的实现分析… - 13 -
8.5本章小结… - 13 -
结论… - 14 -
附件… - 15 -
参考文献… - 16 -
P2P:From Program to Process
Hello程序从初始的源程序文件hello.c(Program),经过预处理,编译器编译,汇编器汇编,再经链接器链接生成可执行目标问价hello,在shell中输入运行hello的指令后,shell解析参数,为其创建子进程,内核为其创建数据结构,此时程序变为进程(Process)。
020:From Zero-0 to Zero-0
操作系统调用execve后映射虚拟内存,先删除当前虚拟地址的数据结构并未hello创建新的区域结构,进入程序入口后载入物理内存,再进入main函数执行代码,将内容输出到显示器。程序执行完成后,父进程bash回收hello进程,内核删除相关数据结构,回收其内存空间。
Intel® Core™
i5-8300H CPU @2.30GHz
8G RAM
Windows 10 家庭中文版 64位;
VMware
Workstation Pro 15;
Ubuntu-19.04 LTS
64位
Visual
Studio 2019;
CodeBlocks 64位;
vi,vim,gedit,gcc,readelf,objdump,edb
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i:由hello.c预处理(ccp)生成的文本文件
hello.s:由hello.i经编译器(ccl)编译后产生的汇编程序(文本文件)
hello.o:由hello.s经汇编器(as)处理后产生的可重定位目标文件(二进制)
hello:最终生成的可执行目标文件,可以被加载到内存中,由系统执行
hello.o.txt:可重定位目标文件hello.o使用objdump工具产生的反汇编代码
hello.asm:可执行目标文件hello使用objdump工具产生的反汇编代码
hello-elf.txt:可执行文件的ELF文件
hello.o-elf.txt:可重定位文件hello.o的ELF文件
本章简单介绍了hello程序 的P2P,020 过程,介绍了本次实验的环境。并且列举了本次作业的所有中间结果。
(第1章0.5分)
在集成开发环境中,在程序编译之前,由预处理器(cpp)根据以#开头的命令,修改原始的C程序。
主要任务为:
头文件包含指令处理,将源文件中以”include”格式包含的文件复制到编译的源文件中。
宏定义指令处理,用实际值替换用“#define”定义的字符串,进行宏替换。
条件编译指令处理,根据“#if”后面的条件决定需要编译的代码。
特殊符号,预处理程序可以识别一些特殊的符号。 例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预处理程序对于在源程序中出现的这些串将用合适的值进行替换。
为下一阶段的编译做准备。
命令:gcc -E hello.c -o hello.i
或者: cpp hello.c > hello.i
命令示例如图2.2-1
执行预处理命令后,会生成修改后的源程序hello.i文件,如下图:
图2.2-2
源程序hello.c如图2.2-3
预处理后hello.i文件(极小一部分)如图2.2-4:
由图2.2-3与图2.2-4可以看出,程序由原来的23行扩展为3042行,源程序中以#开头的include指令被替换为相应的系统头文件的内容,图2.2-4中源程序上方注明了各个头文件的位置信息,比如stdlib.h在第1010行。并且且注释被删除。源程序hello.c中没有定义宏常量,所以没有体现宏替换。
本章讲述了hello程序生命周期中预处理阶段,预处理器以#开头的预处理命令,完成将对应的头文件插入程序文本中,进行宏替换等任务。生成的hello.i仍旧是文本文件,将被传递给编译器进行下一阶段。
(第2章0.5分)
在这里指编译器(ccl)将文本文件hello.i翻译成汇编语言程序(文本文件)的过程。
通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码,便于后续生成机器代码。
命令:gcc -S hello.i -o hello.s
命令执行如图3.2-1
生成.s文件:
hello.s文件(部分)如图3.2-3:
汇编程序文件内容:
.file 声明源文件
.text 声明代码段
.data 声明数据段
.section .rodata 只读数据,rodata节
.globl 全局变量
.size 声明大小
.type 指定类型
.align 声明对指令或数据的存放地址进行对齐的方式
(1)局部变量
int i;
作为局部变量,并不占用文件的实际节的空间,一般通过栈或寄存器存储。对其的访问,也是对栈或者寄存器的访问。
如图3.3-1,变量i被保存在栈中,地址为-4(%rbp)
(2)全局变量一般会在文件节中占相应类型的空间,本程序中不含全局变量。
i = 0; 使用数据传送指令完成赋值如图3.3-2
数据传送指令根据传送数据的大小分为movb,movw,movl,movq,分别为传送字节、字、双字、四字
atoi(argv[3]) 字符串类型转int
这个是调用atoi函数将字符串类型的argv[3]转换为int类型。函数的调用之后再说。
汇编语言中的算数运算指令有:
上述指令除leaq外都有相应的变种,比如ADD由addb,addw,addl,addq四条加法指令。
此外还有特殊的算数操作:
imulq S ——有符号全乘法
mulq S——无符号全乘法
cqto——转换为8字
idivq S——有符号除法
divq S——无符号除法
本程序中的算数操作
i++:调用了addl 并用立即数$1为源操作数实现i的加一操作。
进行关系操作的指令有:
cmp S1,S2 ——S2-S1——比较——设置条件码
test S1,S2 ——S1&S2——测试——设置条件码
常用的条件码有:
CF:进位标志。最近产生的操作使最高位产生了进位
ZF:零标志。最近的操作产生的结果为0
SF:符号标志。最近的操作产生的结果为负数
OF:溢出标志。最近操作导致一个补码溢出——正溢出或负溢出
根据cmp或test指令设置的条件码,使用相应的跳转指令jXX,跳转到制定代码。
jXX包含:
jmp(直接跳转);je(==);jne(!=);js(<0);jns(>=0);jg(有符号>);jge(有符号>=);jl(有符号<);jle(有符号<=);ja(无符号>);jae(无符号>=);
jb(无符号<);jbe(无符号<=)等等。
在本程序中,if(argc != 4) 调用cmpl设置条件码,然后je若相等则继续运行,否则调用exit退出程序
同理有i<8: i<=7
本程序涉及一个字符串数组(字符数组指针),即main函数的第二个参数char *argv[]。数组是在栈中分配空间的:如图3.3-6
首先栈指针rsp减去相应数值来分配空间,再将数组内容存入栈中
访问时:
(1)数组:取数组头指针加上第i位偏移量来处理。
(2)指针与数组类似,如果rax表示指针所存的寄存器,访问x指向的值就是(%rax)
(3)结构体:通过结构体内部的偏移量来访问。
if/else语句的控制转移已在3.3.5 关系操作中讲解
for循环的控制转移:
类似于if/else,不过每次条件符合时需要执行循环体,知道条件不符合了,才结束,执行循环后的指令。
如图3.3-7,首先执行条件测试.L3,若符合条件,跳转到.L4循环体,接着顺序执行到.L3,重复上述操作,直到条件不符合。
1 返回值:函数的返回值一般保存在寄存器%rax中,返回时,将返回值传递给%rax,然后,调用ret指令
2 函数传递与参数传递:函数使用call指令来调用,调用前需要设定参数,寄存器%rdi,%rsi,%rdx,%rcx,%r8,%r9(64位)分别保存第1-6个函数,多余6个的函数保存在栈中。
如下图puts(printf的优化),exit函数的调用:
图3.3-9
main函数的调用过程:
传递控制:main 函数因为被调用call 才能执行(系统启动函数__libc_start_main 调用),call 指令将下一条指令的地址dest 压栈,然后跳转到main 函数。
传递数据:外部调用过程向main 函数传递参数argc 和argv,分别使用%rdi 和%rsi 存储,函数正常出口为return 0,将%eax 设置0返回。
分配和释放内存:使用%rbp 记录栈帧的底,函数分配栈帧空间在%rbp 之上,程序结束时,调用leave 指令,恢复栈空间为调用之前的状态,然后ret返回,ret 相当pop IP,将下一条要执行指令的地址设置为dest。
本章学习了编译器是如何将C语言程序转化为汇编语言代码的,并且详细介绍了编译器对各种数据与操作的处理。
(第3章2分)
概念:汇编器(as)将.s文件翻译成机器语言指令,并把这些指令打包成可重定位目标文件(.o)的过程。其中.o文件为可重定位目标文件,包含程序的二进制代码和数据。
作用:将.s文件中的汇编指令对应地翻译为二进制的机器语言指令,汇编生成的可重定位目标文件可以与其他可重定位目标文件合并起来,创建一个可执行目标问价。
命令:gcc -c hello.s -o hello.o 或 as hello.s -o hello.o
命令Ubuntu运行实例:
结果:
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
(1)ELF头:以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位的、可执行或共享的)、机器类型(如X86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。如图4.3-1
(2)节头:显示了各个节的名字、类型、位置、大小等信息。如图4.3-2
(3)重定位节.rela.text:保存着代码的重定位条目。如图4.3-3
汇编器遇到对最终位置未知的目标引用,就会生成一个重定位条目,
告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
ELF重定位条目的格式:如图4.3-4
offset为需要被修改的引用的节偏移。type告知链接器如何修改新的引用。symbol标识被修改引用应该指向的符号。addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
type也就是重定位类型,ELF中定义了32种。
比如:R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。重定位条目r
需要重定位的.text节地址为ADDR(s) =ADDR(.text)
被修改引用应该指向的符号地址:ADDR(d) = ADDR(r.symbol)
refaddr =ADDR(s) + r.offset;(1)
*refptr =(unsigned) (ADDR(r.symbol) + r.addend – refaddr)
其中refaddr为引用的运行时地址;*refptr为重定位地址。
其余31种重定位类型各有计算方式。
(4)重定位节.rela.eh_frame:
(5)符号表:用来存放程序中定义和引用的函数和全局变量的信息。链接器进行重定位需要引用的符号都在其中声明。
objdump -d -r hello.o分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
objdump -d -r hello.o > hello.o.txt 产生的hello.o的反汇编代码如图4.4-1
与hello.s对比可得差别:
(1)分支转移:在汇编代码中,分支跳转是直接以.L0等助记符表示,但是在反汇编代码中,分支转移表示为主函数+段内偏移量。
(2)函数调用:汇编代码中函数调用时直接使用函数名称,而在反汇编的文件中call之后加main+偏移量(定位到call的下一条指令)。且在.rela.text节中为其添加重定位条目等待链接。
(3)访问全局变量:汇编代码中使用.LC0(%rip),反汇编代码中为0x0(%rip)。访问时需要重定位,所以初始化为0并添加重定位条目。
一:构成:为若干个二进制字节
操作码字段+地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
(1)操作码。它具体说明了操作的性质及功能。一台计算机可能有几十条至几百条指令,每一条指令都有一个相应的操作码,计算机通过识别该操作码来完成不同的操作。
(2)操作数的地址。CPU通过该地址就可以取得所需的操作数。可以时直接寻址地址,也可以是相对寻址的偏移量等。
(3)操作结果的存储地址。把对操作数的处理所产生的结果保存在该地址中,以便再次使用。
二:与汇编语言的映射关系:
机器语言由机器指令集构成,能够直接被机器执行。机器语言写的程序存在不便于阅读、难以记忆的问题。汇编语言本质上也是直接对硬件操作,由于采用了助记符,相比机器语言更加方便书写与阅读。
机器语言是比汇编语言更加低级的语言,能直接在机器上运行,与汇编语言可以相互转化,机器语言指令具有特殊意义。
本章介绍了hello 程序的汇编阶段,从汇编程序hello.s转化为可重定位目标程序hello.o。查看了hello.o 的ELF格式、对使用objdump 得到的反汇编代码与hello.s 进行比较,了解从汇编语言映射到机器语言汇编器需要实现的转换。
(第4章1分)
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在现代系统中,链接是由叫做链接器的程序自动执行的。
作用:链接器使得分离编译成为可能。生成的可执行文件,可直接加载到内存执行。
命令:ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 -z relro -o hello
/usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o
/usr/lib/gcc/x86_64-linux-gnu/8/crtbegin.o -lc /usr/lib/gcc/x86_64-linux-gnu/8/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o
命令执行:如图5.2-1
结果:如图5.2-2
执行hello:如图5.2-3
使用gcc添加-v参数查看链接时需要的库:如图5.2-4
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
典型的ELF可执行目标文件,如图5.3-1.
使用readelf -a hello >hello-elf.txt查看hello的ELF文件。
(1)ELF头:描述程序的总体格式。总共27个节头。还包括程序的入口点(entry point),也就是程序运行时要执行的第一条指令的地址。如图5.3-2中为0x401090
图5.3-2 ELF Header
(2)节头:描述目标文件的节,包含节的大小、名字、类型、地址等信息。如图5.3-3
(3)程序头部表:为链接器提供运行时的加载内容和提供动态链接的信息,每一个表项提供了各段在虚拟地址空间大小和物理地址空间大小,位置,标志,访问权限和对齐方式。如图5.3-4,提供了10个段,它们组成了最终在内存中执行的程序。
举例说明:第四个段(代码段),有读/执行的权限,开始于内存地址0x401000,总共内存大小为0x279,从图5.3-3中得到,该段包括.init、.text节。
前三个LOAD区域为只读代码段(包含只读数据);第四个LOAD区域为读/写数据段。
offset:目标文件中的偏移;VirtAddr/
PhysAddr:虚拟/物理内存地址;FileSiz:目标文件的大小;MemSiz:内存中的段大小;Flg:运行时的访问权限。
PHDR保存程序头表。
INTERP指定在程序已经从可执行映射到内存之后,必须调用解释器。
通常/lib/ld-linux-so.2、/lib/ld-linux-ia-64.so.2等库,用于在虚拟地址空间中插入程序运行所需的动态库。对几乎所有的程序来说,可能c标准库都是必须映射的,还需要添加各种库,包括GTK、数学库、libjpeg等。
LOAD表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等。
DYNAMIC段保存了其他动态链接器(即INTERP中指定的解释器)使用的信息。
NOTE保存了专有信息。
GNU_STACK:权限标志,标志栈是否是可执行的
GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读
如图5.4-1,程序从0x400000开始加载,根据图5.3-4的程序头中也可以读出程序第一个LOAD(代码段)地址为0x400000。此区域包含ELF头、程序头部表。
第二个LOAD(代码段):包含.init、.text(0x401000-0x401279),区域符合
接着验证各段都符合。
使用指令objdump -d -r hello > hello.asm查看hello的反汇编:
1 hello的反汇编代码增加了.plt,.init,.fini节,如图5.5-1。库函数的代码都已经链接到了程序中,程序各个节变得更加完整,跳转的地址也具有参考性。
2 如图4.4-1,hello.o的反汇编代码只有.text节,且只有main函数的具体实现,main函数也是从地址0开始的。而在hello的反汇编代码中,程序中使用的库函数的代码已经复制到程序中,且已经完成了重定位,代码的地址已经是内存中的地址。如下图中main函数的部分代码:
可以看出在hello中对于函数等重定位项目的引用已经完成了重定位,直接call +地址<函数名+偏移> 进行引用。
可以分析出是根据重定位条目,按不同类型对重定位项目进行重定位。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。
编译器没有办法知道函数运行时的地址,需要链接器进行连接处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。
延迟绑定是通过全局偏移量表(GOT)和过程链接表(PLT)之间的交行实现的。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,PLT是代码段的一部分。GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
本章主要详细介绍了链接的概念与作用,可执目标文件的格式,分析了hello的虚拟地址空间,重定位的过程。
(第5章1分)
概念:
进程的经典定义就是一个执行中的程序实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需要的状态组成的。
作用:
进程向我们提供一个假象,就好像我们地程序是系统中当前运行地唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条的执行我们程序地指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。
作用:Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序,是命令行解释器,以用户态方式运行的终端进程,其基本功能是解释并运行用户的指令。
处理流程:
(1) 终端进程读取用户由键盘输入的命令行。
(2) 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3) 检查首个命令行参数是否是一个内置的shell命令。
(4) 若不是内部命令,调用fork()创建子进程。
(5) 在子进程中,用步骤2获取的参数,调用execve执行指定程序。
(6) 如果用户未要求后台运行,则shell使用waitpid等待作业终止后返回。
(7) 若要求后台运行,则shell返回。
在终端中键入./hello 1180301008 wang 1 , 终端程序会对输入的命令行进行解析,hello 不是一个内置的shell 命令。所以解析之后终端程序判断./hello 的语义为执行当前目录下的可执行目标文件hello,之后终端程序首先会调用fork 函数创建一个新的运行的子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用fork 时,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。
fork 创建子进程之后,子进程调用execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello
程序。
execve 调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello 程序,加载器删除子进程现有的用户虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。如图6.4-1,显示了加载器是如何映射用户地址区域的。
最后加载器设置PC 指向_start 地址,_start 最终调用hello中的main 函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。
直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
上下文信息:上下文就是内核重新启动一个被抢占进程所需要的状态。它包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
逻辑控制流:一系列程序计数器PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
在hello运行过程中,若hello进程不被抢占且没有系统调用,则正常执行;若被抢占或进行了系统调用,那么内核会让当前进程休眠,切换到另一个进程。
进程的切换,是通过上下文切换的机制实现的,(1)保存当前进程的上下文;(2)恢复某个先前被抢占的进程的上下文(3)将控制转移给这个新恢复的程序。如图6.5-1,为进程上下文切换的示意图。
这里有个特殊的情况,当hello执行到系统调用函数sleep()的时候,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
1 陷阱:使用了系统调用,比如sleep(),exit。比如:当用户程序想要申请sleep的服务时,会执行一个syscall指令,接着控制转移给内核,运行陷阱处理程序,这个处理程序会解析参数,调用sleep函数,调用后返回到用户程序的下一指令。
2 中断:时钟中断等。
3 缺页故障(可能):运行缺页异常处理程序
6.6.2 信号
(1)Ctrl + Z
信号:发送一个SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起前台作业)。
Ctrl + Z后执行jobs,ps:
Ctrl + Z后执行pstree:
Ctrl + Z后执行fg:进程收到一个SIGCONT信号,进程继续执行。
Ctrl + Z后执行
kill -9 %1 指令,hello进程收到一个SIGKILL信号,进程被杀死。
(2)Ctrl + C
信号:内核发送一个SIGINT信号到前台进程组中每个进程。默认情况下,结果是终止前台进程。如图6.6-5
(3)过程中乱按:如图6.6-6
进程收到的信号:无
收到的异常:
本章阐明了进程的定义与作用,介绍了Shell 的一般处理流程,以及调用fork创建新进程,调用execve 执行hello,hello 的进程执行的过程,还有系统对于异常与信号的处理。
(第6章1分)
(1) 逻辑地址:由段选择符+偏移地址构成。其中段选择符位于段寄存器(16位,CS、SS等)中。而偏移地址即为汇编、c代码中显示的地址。常见段寄存器有CS(代码段)、DS(数据段)、SS(栈)等。例如,你在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于你当前进程数据段的地址,不和绝对物理地址相关。
如下图7.1-1,为hello的反汇编代码,其中402008就是逻辑地址。
图7.1-2为edb跟踪,DS = 0000,所以实际逻辑地址为0000:402008
(2) 线性地址:线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
(3) 虚拟地址:本书中与线性地址相同。
(4) 物理地址:实的物理内存对应地址。 CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
实模式下: 逻辑地址CS:EA =物理地址CS*16+EA
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
如下图,为逻辑地址采用段式管理转化为线性地址的过程:
在页式管理下,虚拟内存被分割成称为虚拟页(VP)的节。类似地,物理内存被分割为物理页(PP)。每个虚拟页或物理页大小为P
= 2^p字节。每个虚拟(线性)地址对应某个虚拟页面的某个字节。
如图7.3-1,虚拟地址被分为虚拟也好与虚拟页偏移量,物理地址类似。每个虚拟页都对应一个页表条目(PTE),根据VPN访问页表条目,可以获得对应的物理页号(PPN)。
每个页表条目由一个有效位和一个n位地址字段(PPN)组成,有效位确定该虚拟页是否缓存在DRAM中,决定访问该页面时是否命中。
下面为根据某个虚拟地址访问某个页时的过程:
每次CPU生成一个虚拟地址,MMU就必须查阅一个PTE,为提升速度,在MMU中增加了一个PTE的缓存——TLB(翻译后备缓冲器)。
使用TLB进行地址翻译时,会将VPN解释为两部分:TLB标记,与TLB索引。如下图:
由TLBI,访问TLB中的某一组。遍历该组中的所有行,若找到一行的标记等于TLBT,且有效位valid为1,,则缓存命中,该行存储的即为PPN;若未找到一行的tag等于TLBT,或找到但该行的valid为0,则缓存不命中。进而需要到页表中找到被请求的块,用以替换原TLB表项中的数据。
使用k级页表的地址翻译如图7.4-2.
四级页表缓存不命中后,VPN被解释成从低位到高位的4段,从高地址开始,第一段VPN作为第一级页表的索引,用以确定第二级页表的基址;第二段VPN作为第二级页表的索引,用以确定第三级页表的基址;第三段VPN作为第三级页表的索引,用以确定第四级页表的基址;第四段VPN作为第四级页表的索引,若该位置的有效位为1,则第四级页表中的该表项存储的是所需要的PPN的值。
值得注意的是,在上述过程中,只要有一级页表条目的有效位为0,下一级页表就不存在,也就是产生缺页故障了,需要到内存中加载。
从页表中取出的PPN加上与VPO相同的PPO就构成了物理地址PA。
得到物理地址PA后,就可以进行物理内存访问。首先PA被解释为3部分,如下图。
首先在L1 Cache中寻找,若命中则将寻址结果传给CPU,若缓存不命中,则需要向低一级缓存(L2->L3->主存)中寻找。找到数据之后,开始进行行替换。若该组中有一个空行,那就将数据缓存至这个空行,并置好tag和valid位;若该组中没有空行,则按照替换策略替换一个行,然后重新进行寻址。
图7.5-2为采用四级页表和3级Cache的Core i7地址翻译情况。
当fork函数被当前进程调用时,即创建hello进程时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样的副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。
当fork在新进程返回时,新进程现在的虚拟内存刚好与调用fork之前的虚拟内存相同。也就是说,此时新进程并不包括hello程序的相关内容,只有在新进程调用execve加载hello程序,要对虚拟内存进行写操作时,写时复制机制就会创建新页面,将hello的各个段映射到相对应区域。
图7.6-1为进程的虚拟内存空间。
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello 中的程序,用hello 程序有效地替代了当前程序。加载并运行hello 需要以下几个步骤:
1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3)映射共享区域,hello 程序与共享对象libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.7-1 为内存映射对应区域。
首先,先判断这个缺页的虚拟地址是否合法,那么遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,那么就返回一个段错误(segment fault)
接着查看这个地址的权限,判断一下进程是否有读写改这个地址的权限。
若以上都不是,那就是正常的缺页故障。
缺页故障:当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,必须从磁盘中取时,就会发生缺页故障。
缺页中断处理:当发生缺页故障时,控制转移给处理程序,处理程序从磁盘加载适当的页面,然后将控制转移给引起故障的指令。接着指令再次执行,相应的物理页面已经驻留在内存中,指令就可以没有故障地运行完成了。
下图为缺页故障的处理。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,都要求显式分配块,不同之处在与怎样释放块。
1 显示分配器:要求显式地释放任何已分配的块。比如C标准库的malloc
2 隐式分配器:也叫垃圾收集器,要求分配器检测一个已分配块何时不再被程序使用,此时释放块。
动态内存分配的方法:
(1) 隐式空闲链表:
一 堆中空闲块的格式:
二 分割空闲块:若找到一个空闲块,当分配块比空闲块小时,将空闲块分为两部分,剩余部分重新作为一个空闲块。
三 获取额外的堆内存:当找不到合适的空闲块时,需要合并空闲块。若空闲块已经最大程度的合并了,分配器就需要调用sbrk函数,向内存申请额外的堆内存。
四 合并空闲块:将相邻的空闲块合并,如图7.9-3。分为立即合并和推迟合并两种。
为合并方便,使用带边界标记的合并,及内存块增加一个脚部:如图
(2) 显式空闲链表
堆中内存块:
图7.9-5
空闲链表:使用双向链表。
维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比IFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
(3) 分离的空闲链表
如下图,每个大小的类链接在一起。
寻找空闲链表的方法:
1 首次适配
2 下一次适配
3 最佳适配
本章主要介绍了hello 的存储器地址空间、段页式管理,VA 到PA 的地址翻译、物理内存访问,还介绍了hello进程fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
设备的模型化:所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件。
设备管理:所有的输入与输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
IO接口及其函数
接口:
首先查看printf代码(来自https://www.cnblogs.com/pianist/p/3315801.html)
va_list的定义:typedef char va_list,它是一个字符指针,其中 (char)(&fmt) + 4) 即arg表示的是…中的第一个参数
然后查看vsprintf代码
printf函数的功能:接受一个格式化命令,并按指定的匹配的参数格式化输出
故I = vsprintf(buf, fmt, arg)应该是得到打印出来的字符串长度,以及后面的write(buf, i)应该是将buf中的i个元素写到终端
所以vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出
下面我们来看write函数
这里是给几个寄存器传递了几个参数,然后一个int INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
getchar有一个int型的返回值,当程序调用getchar时,程序就等着用户键入,用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中),当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符,getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。
如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
表述了Unix I/O接口及其函数,简单了解了一下printf,getchar的实现
(第8章1分)
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello所经历的过程总结如下:
1、编写代码:用高级语言写.c文件
2、预处理:从.c生成.i文件,将.c中调用的外部库展开合并到.i中
3、编译:由.i生成.s汇编文件
4、汇编:将.s文件翻译为机器语言指令,并打包成可重定位目标程序hello.o
5、链接:将.o可重定位目标文件和动态链接库链接成可执行目标程序hello
6、运行:在shell中输入命令
7、创建子进程:shell嗲用fork为程序创建子进程
8、加载:shell调用execve函数,将hello程序加载到该子进程,映射虚拟内存
9、执行指令:CPU为进程分配时间片,加载器将计数器预置在程序入口点,则hello可以顺序执行自己的逻辑控制流
10、访问内存:MMU将虚拟内存地址映射成物理内存地址,CPU通过其来访问
11、动态内存分配:根据需要申请动态内存
12、信号:shell的信号处理函数可以接受程序的异常和用户的请求
13、终止:执行完成后父进程回收子进程,内核删除为该进程创建的数据结构
至此,hello运行结束
计算机系统的设计与实现是一个严密而精致的过程,不断更新,不断有新的技术涌现。
(结论0分,缺失 -1分,根据内容酌情加分)
列出所有的中间产物的文件名,并予以说明起作用。
如下图
hello.c:源文件
hello.i:hello.c经预处理后产生的修改后的源文件
hello.s:hello.i经编译器编译后形成的汇编语言代码
hello.o:hellos经汇编器产生的可重定位目标文件(二进制)
hello:最终产生的可执行目标文件
hello.o.txt:可重定位目标文件hello.o使用objdump工具产生的反汇编代码
hello.asm:可执行目标文件hello使用objdump工具产生的反汇编代码
hello-elf.txt:可执行文件的ELF文件
hello.o-elf.txt:可重定位文件hello.o的ELF文件
(附件0分,缺失 -1分)
[1] [美]大卫R.奥哈拉伦,兰德尔·E.布莱恩特.深入理解计算机系统[M].龚奕利,贺莲译.北京:机械工业出版社,2016.7.
[2]博客园.printf函数的深入剖析.http://www.cnblogs.com/pianist/p/3315801.html,2013-9-11.
[3] 程序头表.
https://blog.csdn.net/ylcangel/article/details/18145155
[4] C语言文件的编译与执行的四个阶段并分别描述. https://blog.csdn.net/yimingsilence/article/details/52800987
[5] C语言中的预处理详解.
https://blog.csdn.net/dlutbrucezhang/article/details/8753765
(参考文献0分,缺失 -1分)