计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机系
学 号 1170301005
班 级 1703010
学 生 白镇北
指 导 教 师
计算机科学与技术学院
2018年12月
摘 要
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。
关键词:关键词1;关键词2;……;
(摘要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 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
首先hello.c通过I/O设备如键盘等经过总线存入主存。
GCC编译器驱动程序读取源程序文件hello.c,经过预处理(变成hello.i)、编译(变成hello.s)、汇编(变成hello.o),终于成为机器可以理解的二进制代码了。
然后再通过链接,变成(可执行的二进制目标程序hello)、由shell程序将字符读入寄存器,放入到内存里面去,再调用fork函数创建一个新运行的子进程,然后子进程通过execve系统调用启动加载器。加载器使用mmap函数创建新的内存区和新的代码、数据、堆和栈段。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段被初始化为可执行文件的内容。
最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,再执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。最后变成一个Process运行在内存中。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
软件环境:Ubuntu16.04.1
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i 预处理过的文本文件
hello.s 编译过的汇编语言文件
hello.o 汇编过的可重定位目标执行文件
hello 链接过的可执行目标文件
hello2.c 测试程序代码
hello2 测试程序
helloo.objdmp Hello.o的反汇编代码
helloo.elf Hello.o的ELF格式储存
hello.objdmp Hello的反汇编代码
hello.elf Hellode ELF格式储存
1.4 本章小结
本章主要介绍了hello 的P2P、020过程,列出了本次实验的基本实验信息:环境、中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是依据预处理指令,对源程序进行修改,包含其引用的部分使其成为后缀.i的文本文件。其中预处理指令是以#号开头的代码行。#号必须是该行除了任何空白字符外的第一个字符。#后是指令关键字,在关键字和#号之间允许存在任意个数的空白字符。整行语句构成了一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。
作用
1.宏定义
宏定义又称为宏代换、宏替换。预处理(预编译)工作也叫做宏展开,将宏名替换为字符串,且在对相关命令或语句的含义和功能作具体分析之前将所有的宏等价代换。是一种转义方式。
2.文件包含
文件包含处理是指在一个源文件中,通过文件包含命令将另一个源文件的内容全部包含在此文件中。这种文件包含处理在程序开发中会给我们的模块化程序设计带来很大的好处,通过文件包含的方法把程序中的各个功能模块联系起来是模块化程序设计中的一种非常有利的手段。在源文件进行预处理时,连同被引用进来的文件一同添加到文本中
3.条件编译
程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。条件编译指令将决定哪些代码被编译,而哪些不被编译的。可以根据表达式的值或者某个特定的宏是否被定义来确定编译条件。
2.2在Ubuntu下预处理的命令
命令:cpp hello.c > hello.i
图2.1 使用cpp命令生成hello.i文件
2.3 Hello的预处理结果解析
图2.2 hello.i文件局部
可以看出,相比于原来精简的hello.c文件,hello.i被拓展为具有相当内容(3118行)的文件,其中直到3102行才出现main函数的踪影,而占据前3000多行的,是hello.c中引用的
2.4 本章小结
本章的主要内容是预处理器对hello.c文件的预处理过程,可以看出,预处理阶段主要是完成对源程序的替换工作。经过替换后,会生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件.i,该输出文件中只有常量如数字、字符等,或者是变量的定义,以及C语言的关键字如if、else等。其中用处最广泛的当属文件包含功能。通过这个功能,我们能在编程时方便地引用自己乃至前人预先完成的模块化程序,大大提高了开发效率。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译,对于预处理后的hello.i文件在确认所有指令都符合语法规则之后,将其翻译成等价的中间代码或者是汇编代码。其中.s后缀的为汇编语言程序。
作用:通过编译,使预处理后的hello.i文件变成了可以汇编为机器指令的汇编语言,编译阶段所有做的工作就是通过词法分析和语法分析,生成一棵语法树再转换为目标代码。编译阶段会对代码进行优化处理,不仅涉及到编译技术本身,还涉及到机器的硬件环境。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
图3.1 使用gcc生成hello.s文件
3.3 Hello的编译结果解析
首先,声明汇编指令及其含义:
指令 含义
.file 声明源文件
.text 标志代码段
.section .rodata 标志rodata节
.globl 声明全局变量
.type 指定是函数类型或是对象类型
.size 声明大小
.long、.string 声明long、string类型
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.1 数据
hello.s中用到的C数据类型有:整数、字符串、数组。
1)字符串
可知程序中的字符串有:
“Usage: Hello 1170301005 白镇北!\n”,这是第一个printf传入的输出格式化参数,在hello.s中声明如下图:
图3.2 hello.s中声明在.LC0和.LC1段中的字符串
可以发现字符串中数字没有变化,而汉字被编码成UTF-8格式,一个汉字在utf-8编码中占三个字节(一个\为一个字节)。
“Hello %s %s\n”,这是第二个printf传入的输出格式化参数,在hello.s中也声明如图3.2。
2)整数
可知程序中涉及的整数有:
int sleepsecs:sleepsecs在C程序中被声明为全局变量,且已经被赋值,编译器处理时在.data节声明该变量,.data节存放已经初始化的全局和静态C变量。如下图:
图3.3 hello.s中sleepsecs的声明
可以看处,编译器最先在.text代码段中就进行了声明:globl sleepsecs。然后又在.data代码段中,设置其对齐方式为4、类型为object(对象)、大小(size)为4字节、设置为long类型其值为2(从这可以看出,编译器在这一步就已经进行了优化(整型数舍入),且舍入规则为向零舍入)
其余数据
int i:编译器将局部变量存储在寄存器或者栈空间中,在hello.s中编译器将i存储在栈上空间-4(%rbp)中(可以看出在linux下long和int一样占4B)。
int argc:作为第一个参数传入。
立即数:其他整形数据的出现都是以立即数的形式出现的,即直接编码在汇编代码中。
3)数组
可知程序中涉及的数组是:
char argv[] main,记录函数执行时输入的命令行,argv作为存放char指针的数组同时是也第二是个参数传入。
Argv中没个元素char大小为8B,argv指针指向已经分配好的的连续空间(若为链表则不一定连续)。argv作为数组名,也是数组的首地址。在main函数内访问数组元素argv[1],argv[2]时,按照起始地址argv大小8B计算数据地址取数据,在hello.s中,使用两次(%rax)取出其值。如下图
图3.4 依照地址取出数组值
3.3.2 赋值
程序中有关赋值操作的有:
int sleepsecs=2.5 :由sleepsecs是全局变量,所以直接在.data节中将sleepsecs声明为值2的long类型数据(如图3.3)
i=0:整型数据的赋值使用mov指令完成,不同的指令(b、w、l、q)分别对应不同的数据大小:8b (1B)、16b (2B)、32b (4B)、64b (8B)
由于i是int类型,大小为4B,所以使用movl进行赋值,如下图:
图3.5 hello.s中对i的赋值
3.3.3 类型转换
程序中有关隐式类型转换的有:
int sleepsecs=2.5,将浮点数类型的2.5转换为int类型。
当在double或float向int进行类型转换的时候,程序改变数值和位模式的原则是:值会向零舍入。例如1.999将被转换成1,-1.999将被转换成-1。进一步来讲,可能会产生值溢出的情况,与Intel兼容的微处理器指定位模式[10…000]为整数不确定值,一个浮点数到整数的转换,如果不能为该浮点数找到一个合适的整数近似值,就会产生一个整数不确定值。
浮点数默认类型为double,所以上述强制转化是double强制转化为int类型。遵从向零舍入的原则,将2.5舍入为2。
3.3.4算数操作
程序中有关的算数操作有:
i++,令计数器i自增。汇编中使用leaq .LC1(%rip),%rdi,使用了加载有效地址指令leaq计算LC1的段地址%rip+.LC1并传递给%rdi。
3.3.5 关系操作
程序中有关的关系运算有:
argc!=3:判断argc不等于3。hello.s中使用cmpl $3,-20(%rbp),计算argc-3然后设置条件码,为下一步je利用条件码进行跳转作准备。
i<10:判断i小于10。hello.s中使用cmpl $9,-4(%rbp),计算i-9然后设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.6 控制转移
程序中有关控制转移的有:
if (argv!=3):当argv不等于3的时候执行程序段中的代码。如下图:
图3.6 if语句的编译
对于if判断,首先cmpl比较argv和3,设置条件码,使用je判断ZF标志位,如果为0,说明argv-3=0 argv==3,则不执行if中的代码直接跳转到.L2,否则顺序执行下一条语句,即执行if中的代码。
for(i=0;i<10;i++):使用计数变量i循环10次。如下图:
图3.7 for循环的编译
编译器的编译逻辑是,首先无条件跳转到位于循环体.L4之后的比较代码,使用cmpl进行比较,如果i<=9,则跳入.L4 for循环体执行,否则说明循环结束,顺序执行for之后的逻辑。
可以看出,同样是跳转,if大多是跳转到后方代码段,而循环往往是跳转到前方代码段
3.3.7 函数操作
程序中有关函数操作的有:
1)main函数:
传递控制,main函数因为被调用call才能执行(被系统启动函数__libc_start_main调用),call指令将下一条指令的地址dest压栈,然后跳转到main函数。
传递数据,外部调用过程向main函数传递参数argc和argv,分别使用%rdi和%rsi存储,函数正常出口为return 0,将%eax设置0返回。
分配和释放内存,使用%rbp记录栈帧的底,函数分配栈帧空间在%rbp之上,程序结束时,调用leave指令,leave相当于mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后ret返回,ret相当pop IP,将下一条要执行指令的地址设置为dest。
2)printf函数:
传递数据:第一次printf将%rdi设置为“Usage: Hello 学号 姓名!\n”字符串的首地址。第二次printf设置%rdi为“Hello %s %s\n”的首地址,设置%rsi为argv[1],%rdx为argv[2]。
控制传递:第一次printf因为只有一个字符串参数,所以call puts@PLT;第二次printf使用call printf@PLT。
3)exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
4)sleep函数:
传递数据:将%edi设置为sleepsecs。
控制传递:call sleep@PLT。
5)getchar函数:
控制传递:call gethcar@PLT
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
编译是对编程语言的一次“降维”,在降维之后的汇编语言变得比原来较长,同时操作也更加基本。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器(as) 将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program) 的格式,并将结果保存在目标文件hello.o 中。
作用:汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
图4.1 使用指令as生成hello.o文件
4.3 可重定位目标elf格式
使用指令readelf -a hello.o > helloo.elf 指令即可获得hello.o文件的ELF格式。
图4.2 使用指令readelf生成helloo.elf文件
ELF文件主要分为以下三个部分:
1)ELF Header:以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
图4.3 elf Header
2) Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
图4.4 Section Headers
3)重定位节.rela.text ,一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如下图:
图4.5 重定向节.rela.text
中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
使用objdump -d -r hello.o > helloo.objdump即可得到helloo.objdump文件
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
机器语言指的是二进制的机器指令集合,而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
在对比两个文件后,可以发现汇编器在汇编hello.s时:
为每条语句加上了具体的地址,全局变量和常量都被安排到了具体的地址里面。操作数在hello.s里面都是十进制,在到hello.o里面的机器级程序时都是十六进制。跳转语句jx&jxx原来对应的符号都变成了相对偏移地址。函数调用时原来的函数名字也被替换成了函数的相对偏移地址。
4.5 本章小结
汇编器将汇编语言转化成机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。它是计算机的设计者通过计算机的硬件结构赋予计算机的操作功能。机器语言具有灵活、直接执行和速度快等特点。 不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。合并相同的“节”
作用:将不能直接执行的目标代码变成可执行程序。其中最重要的操作就是将函数库中相应的代码组合到目标文件中。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
使用ld的链 接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
Ld -o hello -dynamic-linker /lin/ld-linux-x86-64-so,2/usr/lib/x86_64-linux-gnu/crt
1.o /usr/lib/x8
图5.1 使用ld命令链接生成可执行程序hello
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf -a hello > hello.elf 命令生成hello程序的ELF格式文件。
在ELF格式文件中,Section Headers对hello中的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据Section Headers中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
图5.2 hello ELF格式中的Section Headers Table
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5.3 hello ELF格式中的Section Headers Table
如图5.3,查看ELF格式文件中的Program Headers,程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。可以看出,程序包含8个段:
PHDR保存程序头表。
INTERP指定在程序已经从可执行文件映射到内存之后,必须调用的解释器(如动态链接器)。
LOAD表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串)、程序的目标代码等。
DYNAMIC保存了由动态链接器使用的信息。
NOTE保存辅助信息。
GNU_STACK:权限标志,标志栈是否是可执行的。
GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。
5.5 链接的重定位过程分析
使用objdump -d -r hello > hello.objdump 获得hello的反汇编代码。
与hello.o反汇编文本helloo.objdump相比,在hello.objdump中多了许多节:
.interp 保存ld.so的路径
.note.ABI-tag Linux下特有的section
.hash 符号的哈希表
.gnu.hash GNU拓展的符号的哈希表
.dynsym 运行时/动态符号表
.dynstr 存放.dynsym节中的符号名称
.gnu.version 符号版本
.gnu.version_r 符号引用版本
.rela.dyn 运行时/动态重定位表
.rela.plt .plt节的重定位条目
.init 程序初始化需要执行的代码
.plt 动态链接-过程链接表
.fini 当程序正常终止时需要执行的代码
.eh_frame contains exception unwinding and source language information.
.dynamic 存放被ld.so使用的动态链接信息
.got 动态链接-全局偏移量表-存放变量
.got.plt 动态链接-全局偏移量表-存放函数
.data 初始化了的数据
.comment 一串包含编译器的NULL-terminated字符串
5.6 hello的执行流程
通过使用objdump查看反汇编代码,以及使用gdb单步运行,可以找出.text节中main函数前后执行的函数名称。在main函数之前执行的程序有:_start、__libc_start_main@plt、__libc_csu_init、_init、frame_dummy、register_tm_clones。在main函数之后执行的程序有:exit、cxa_thread_atexit_impl、fini。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章讨论了链接过程中对程序的处理。Linux系统使用可执行可链接格式,即ELF,具有.text,.rodata等节,并且通过特定的结构组织。
经过链接,ELF可重定位的目标文件变成可执行的目标文件,链接器会将静态库代码写入程序中,以及动态库调用的相关信息,并且将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂一些,涉及共享库的寻址。
链接后,程序便能够在作为进程通过虚拟内存机制直接运行。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Linux实质上是一个操作系统内核,一般用户不能直接使用内核,而是通过外壳程序,也就是所谓的shell来与内核进行沟通。外壳程序可以保证操作系统的安全性,抵御用户的一些不正确操作。Linux的外壳程序称作shell(命令行解释器),它能够将命令翻译给内核、将内核处理结果翻译给用户。一般我们使用的shell为bash。在解释命令的时候,bash不会直接参与解释,而是创建新进程进行命令的解释,bash只用等待结果即可,这样能保证bash进程的安全。
首先 shell检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux 本身的实用程序,如ls 和rm;也可以是购买的商业程序,如xv;或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果输入的命令不是一个内部命令且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能找到命令,该内部命令或应用程序分解后将被系统调用并传给Linux 内核。
6.3 Hello的fork进程创建过程
一个进程,包括代码、数据和分配给进程的资源。fork函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。一个进程调用fork函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
6.4 Hello的execve过程
创建进程后,在子进程中通过判断pid即fork()函数的返回值,判断处于子进程,则会通过execve函数在当前进程的上下文中加载并运行一个新程序。execve加载并运行可执行目标文件,且带参数列表argv和环境变量列表envp。只有当出现错误时,execve才会返回到调用程序。
在execve加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,即可执行程序的main函数。此时用户栈已经包含了命令行参数与环境变量,进入main函数后便开始逐步运行程序。
6.5 Hello的进程执行
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
简单看hello sleep进程调度的过程:当调用sleep之前,如果hello程序不被抢占则顺序执行,假如发生被抢占的情况,则进行上下文切换,上下文切换是由内核中调度器完成的,当内核调度新的进程运行后,它就会抢占当前进程,并进行1)保存以前进程的上下文2)恢复新恢复进程被保存的上下文,3)将控制传递给这个新恢复的进程 ,来完成上下文切换。
hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
hello执行过程中可能出现四类异常:中断、陷阱、故障和终止。
中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
hello执行过程中,可能会遇到各种异常,信号则是一种通知用户异常发送的机制。例如较为底层的硬件异常以及较高层的软件事件,比如Ctrl-Z和Ctrl-C,分别触发SIGCHLD和SIGINT信号。
收到信号后进程会调用相应的信号处理程序对其进行处理。
图6.1 正常运行+Ctrl-Z
图6.2 Ctrl-C
图6.3 回车符
图6.3 乱输入
6.7本章小结
本阶段通过在hello.out运行过程中执行各种操作,了解了与系统相关的若干概念、函数和功能。分析了在程序运行过程中,计算机硬件、软件和操作系统之间的配合和协作的方式。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址空间的格式为“段地址:偏移地址”,例如“23:8048000”,在实模式下可以转换为物理地址:逻辑地址CS:EA = 物理地址CS × 16 + EA。保护模式下以段描述符作为下标,通过在GDT/LDT表获得段地址,段地址加偏移地址得到线性地址。
线性地址空间是指一个非负整数地址的有序集合,例如{0,1,2,3……}。在采用虚拟内存的系统中,CPU从一个有N = 2n个地址的地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间。
而对应于物理内存中M个字节的地址空间{0, 1, 2, 3, …, M-1}则称为物理地址空间。
Intel处理器采用段页式存储管理,前者将逻辑地址转换为线性地址从而得到虚拟地址,后者将虚拟地址转换为物理地址。
以hello程序为例,反汇编可以得到这样一段汇编代码“mov $0x400772,%edi”,其中0x400772其实是逻辑地址的偏移地址,必须加上隐含的DS数据段的基地址才能构成线性空间地址,或者说0x400772是当前任务DS数据段的偏移。
这样得到的线性地址其实是数据存储的虚拟地址,还需要经过MMU转换为物理地址,转换为其物理内存的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理,介绍段式管理就必须了解段寄存器的相关知识。段寄存器对应着内存不同的段,有栈段寄存器(SS)、数据段寄存器(DS)、代码段寄存器(CS)和辅助段寄存器(ES/GS/FS)。其大体对应关系如下图:
图7.1 段寄存器
段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。段选择符分为三个部分,分别是索引、TI(决定使用全局描述符表还是局部描述符表)和RPL(CPU的当前特权级)。
图7.2 段选择符
这样,Intel处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。至于偏移量,基址寄存器还是变址寄存器有不同的计算方法,后者需要经过乘比例因子等处理。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7 MMU 使用四级的页表将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位VPN,分别用于一个页表的偏移量。具体结构如下图:
图7.3 i7 页表翻译
7.5 三级Cache支持下的物理内存访问
(1)直接映射高速缓存
直接映射高速缓存每个组只有一行,当CPU执行一条读内存字w的指令,它会向L1高速缓存请求这个字。如果L1高速缓存中有w的一个缓存副本,那么就会得到L1高速缓存命中,高速缓存会很快抽取出w,并将它返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求块最终从内存到达时,L1高速缓存将这个块存放在它的一个高速缓存行里,从被存储的块中抽取出字w,然后将它返回给CPU。确定是否命中然后抽取的过程分为三步:1)组选择;2)行匹配;3)字抽取。
图7.4 直接映射高速缓存中的组选择
组选择即从w的地址中间抽取出s个索引位,将其解释为一个对应组号的无符号整数,从而找到对应的组;行匹配即对组内的唯一一行进行判断,当有效位为1且标记位与从地址中抽取出的标记位相同则成功匹配,否则就得到不命中;而字选择即在行匹配的基础上通过地址的后几位得到块偏移,从而在高速缓存块中索引到数据。
图7.5直接映射高速缓存中的行匹配
(2)组相联高速缓存
组相联高速缓存每个组内可以多于一个缓存行,总体逻辑类似于直接映射高速缓存,不同之处在于行匹配时每组有更多的行可以尝试匹配,遍历每一行。如果不命中,有空行时也就是冷不命中则直接存储在空行;如果没有空行也就是冲突不命中,则替换已有行,通常有LFU(最不常使用)、LRU(最近最少使用)两者替换策略。
(3)全相联高速缓存
全相联高速缓存只有一个组,且这个组包含所有的高速缓存行(即E =
C/B)。对于全相联高速缓存,因为只有一个组,组选择变的十分简单。地址中不存在索引位,地址只被划分为一个标记位和一个块偏移。行匹配和字选择同组相联高速缓存。
写入数据时,假设我们要写一个已经缓存了的字w,在高速缓存中更新了它的w的副本之后,有两种方法来更新w在层次结构中紧接着低一层中的副本。分别是直写和写回,在这里分别介绍:
(1)直写
立即将w的高速缓存块写回到紧挨着的低一层中。优点是简单,缺点则是每次写都会引起总线流量。其处理不命中的方法是非写分配,即避开高速缓存,直接将这个字写到低一层去。
(2)写回
尽可能地推迟更新,只有当替换算法要驱逐这个更新过的块时,才把它写到紧接着的低一层中。优点是能显著地减少总线流量,缺点是增加了复杂性,必须为每个高速缓存行增加一个额外的修改位,表明是否被修改过。写回处理不命中的方法是写分配,加载相应低一层中的块到高速缓存中,然后更新这个高速缓存块,利用了写的空间局部性,但会导致每次不命中都会有一个块从低一层传到高速缓存。
通过这样的Cache读写机制,实现了从CPU寄存器到L1高速缓存,再到L2高速缓存,再到L3高速缓存,再到物理内存的访问,有效的提高了CPU访问物理内存的速度。
7.6 hello进程fork时的内存映射
虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间。Fork函数为新进程创建虚拟内存。创建当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)。在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行文件hello中的程序,用hello程序有效地替代了当前程序。加载hello的过程主要步骤如下:
首先删除已存在的用户区域,也就是将shell与hello都有的区域结构删除。然后映射私有区域,即为新程序的代码、数据、bss和栈区域创建新的区域结构,均为私有的、写时复制的。下一步是映射共享区域,将一些动态链接库映射到hello的虚拟地址空间,最后设置程序计数器,使之指向hello程序的代码入口。
经过这个内存映射的过程,在下一次调度hello进程时,就能够从hello的入口点开始执行了。
7.8 缺页故障与缺页中断处理
Linux将虚拟内存组织成段的集合。内核为每个进程维护一个单独的任务结构,这个任务结构的第一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中的pgd字段又会指向一个区域结构的链表,每个区域结构都描述了当前虚拟地址的一个区域,或者称为一个段。一个具体的区域结构包括vm_start和vm_end等字段,记录区域的相关信息。
图7.6 Linux虚拟内存组织结构
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
首先判断虚拟地址A是否合法,缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果指令不合法则触发段错误,从而终止该进程。
然后处理程序会判断试图进行的内存访问是否合法,也就是进程是否有读写这个区域内页面的权限。如果访问不合法,那么处理程序会触发一个保护异常,终止这个进程。
最后,确保了以上两点的合法性后,根据页式管理的规则,牺牲一个页面,并赋值为需要的数据,然后更新页表并再次触发MMU的翻译过程。
图7.8.2 Linux缺页处理
7.9动态存储分配管理
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
带边界标签的隐式空闲链表:
1)堆及堆中内存块的组织结构:
在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block。
2)隐式链表
所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。
3)空闲块合并
因为有了Footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变Header和Footer中的值就可以完成这一操作。
显示空间链表基本原理:
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
7.10本章小结
程序的实现涉及到从磁盘到主存,从主存到高速缓存,从高速缓存再到寄存器的层级存储。
操作系统将主存抽象为虚拟内存,作为磁盘的缓存。在程序执行时从磁盘加载到主存,并将其主存的物理地址映射为虚拟地址,这样,便可以通过虚拟地址对主存进行访问,从而防止了各个进程之间的冲突与错误。操作系统通过MMU将虚拟地址转换为物理地址,利用TLB和多级页表提高其访问速度和内存利用率,从而实现对主存的有效访问。另外,在发生缺页时,操作系统通过信号处理程序能够很好的解决。
CPU对主存的访问同样采用缓存, 通过三级Cache高效的对数据进行读写。
程序运行过程中常常涉及到动态内存分配,动态内存分配通过动态内存分配器完成,能够对堆空间进行合理的分配与管理,分割与合并。现代使程序内存分配器采取了多种策略来提高吞吐量以及内存占用率,从在灵活使用内存的基础上保证了效率。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
首先是设备的模型化。在设备模型中,所有的设备都通过总线相连。每一个设备都是一个文件。设备模型展示了总线和它们所控制的设备之间的实际连接。在最底层,Linux 系统中的每个设备由一个 struct device 代表,而Linux统一设备模型就是在kobject kset ktype的基础之上逐层封装起来的。设备管理则是通过unix io接口实现的。
8.2 简述Unix IO接口及其函数
Linux以文件的方式对I/O设备进行读写,将设备均映射为文件。对文件的操作,内核提供了一种简单、低级的应用接口,即Unix I/O接口。
Unix I/O接口提供了以下函数供应用程序调用:
打开文件:int open(char *filename, int flags, mode_t mode);
关闭文件:int close(int fd);
读文件:ssize_t read(int fd, void *buf, size_t n);
写文件:ssize_t write(int fd, const void buf, size_t n);
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
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;
}
(char)(&fmt) + 4) 表示的是…可变参数中的第一个参数的地址。而vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。接着从vsprintf生成显示信息,到write系统函数,直到陷阱系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
getchar的实现大体如下:
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
可以看到,getchar函数通过调用read函数返回字符。其中read函数的第一个参数是描述符fd,0代表标准输入。第二个参数输入内容的指针,这里也就是字符c的地址,最后一个参数是1,代表读入一个字符,符号getchar函数读一个字符的设定。read函数的返回值是读入的字符数,如果为1说明读入成功,那么直接返回字符,否则说明读到了buf的最后。
read函数同样通过sys_call中断来调用内核中的系统函数。键盘中断处理子程序会接受按键扫描码并将其转换为ASCII码后保存在缓冲区。然后read函数调用的系统函数可以对缓冲区ASCII码进行读取,直到接受回车键返回。
这样,getchar函数通过read函数返回字符,实现了读取一个字符的功能。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
经过研究,可以大体概括hello程序所经历的过程:
生成阶段:预处理→编译→汇编→链接;
加载阶段:shell fork子进程→execve;
执行阶段:磁盘读取、虚拟内存映射、CPU执行指令、内核调度、缓存加载数据、信号处理、Unix I/O输入与输出;
终止阶段:进程终止、shell与内核对其进行回收。
可以发现,一个简单的hello程序涉及一系列复杂的编译器、操作系统、硬件实现机制。程序的运行与内核、硬件的多方面协调工作密不可分。简单的一条指令需要成千上万条底层步骤,无论是内部处理还是输入输出。
硬件系统的设计贯彻了冯诺依曼的构想,又经过数十年的迭代更新变得精巧与复杂。操作系统的设计体现了多方面程序设计思想,从底层出发让软件与应用层面能够调度硬件设备。抽象与系统的思想在计算机系统的实现过程中得到了深入的体现。
为了提高性能,硬件层在设计与制造工艺角度不断进阶,软件层则在时空效率上处处考虑。提高运行速度,降低资源占用,保证系统安全,操作系统为了程序能够高效的运行在设计上令人惊叹。通过文件对I/O设备进行抽象,通过处理器层级关系实现缓存从而提高运行速度,命中与缺页均有不同的策略。通过虚拟内将进程隔离,防止程序直接互相干扰以及影响内核安全。通过信号对异常进行有效的反馈,应对系统运行中的各类问题。
系统的思想将一切软硬件设备组织的恰到好处,交互的过程有一种协调的美感,程序运行的背后是无数二进制码在硬件层面的流动,一个简单的hello world程序背后也充满着思想。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.i 预处理过的文本文件
hello.s 编译过的汇编语言文件
hello.o 汇编过的可重定位目标执行文件
hello 链接过的可执行目标文件
hello2.c 测试程序代码
hello2 测试程序
helloo.objdmp Hello.o的反汇编代码
helloo.elf Hello.o的ELF格式储存
hello.objdmp Hello的反汇编代码
hello.elf Hellode ELF格式储存
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[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分)