在本文中,主要介绍了计算机执行一个程序的主要过程,详细分析了计算机在各部骤中采取的方法,以及执行过程,并仔细分析了每个阶段的详细数据。并以一个典型程序hello的一生,详细分析了程序执行各个阶段的各种数据,并使用gdb,edb,objdump等工具分析了程序在:预处理,编译,汇编,链接,进程管理,IO管理等阶段的处理方法。
关键词:计算机系统;预处理;编译;汇编;链接;进程管理;IO管理;
目 录
第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 -
P2P :From Program to Process
gcc编译器驱动程序读取程序文件hello.c,然后由预处理器(cpp)根据以#字符开头的命令,修改该程序获得hello.i文件,通过编译器(cll)将文本文件hello.i翻译成文本文件形式的汇编程序hello.s,再通过汇编器(cs)将hello.s翻译成机器语言指令,将指令打包成可重定位的目标文件hello.o,然后通过链接器(ld)合并获得可执行目标文件hello。Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,至此,hello.c完成了P2P的过程。
O2O:From Zero-0 to Zero-0
Shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,使之指向代码区域的入口点,进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的过程。
I5-6300HQ CPU;2GHz;8G RAM;1THD Disk
Windows10 64位;VirtualBox;Ubuntu 18.04 LTS 64位
Visual Studio 2010 64位;GDB/OBJDUMP/EDB等
Hello 可执行文件
Hello.c C文件
Hello.i 预处理之后的文件
Hello.ld 链接后的文件
Hello.o 可重定位目标文件
Hello.s 汇编语言文件
objhello 反汇编.o文件
本章简要介绍了本次实验的基本信息。
(第1章0.5分)
概念:编译之前进行的处理。将C的源程序main.c翻译成一个ASCII码的中间文件main.i;可使编译程序的结构在逻辑上更加简单明确。
作用:预处理会展开以#起始的行,包括#if、#ifdef、#if ndef、 #else 、 #elif 、 # endif、#define、#include、#line、 #error、#pragma以及单独的#。把除了函数(包括main)以外的东西都展开成指定的形式,比如加上行号等,并将头文件里包含的东西所对应的文件(包括子文件夹)也都写入进去以便调用。同时也会把写在文件里的注释都删掉。
预处理通常包含3个内容:1.宏定义:将程序中所有用#define定义的标识符替换成真正的数值;2.文件包含:一个文件包含另一个文件的内容,例如 #include<文件名>,被包含的文件是源文件的一部分;3.条件编译。
Ubuntu下预处理命令:
cpp [other arguments] hello.c hello.i
图 2-1
预处理后的结果:
图2-2
ASCII码的中间文件:(部分截图,全部文件见附件)
经预处理后hello.c文件扩充到3000+行,主要任务是将头文件stdio.h,unistd.h,stdlib.h的内容添加到hello.i中,并且删除所有注释
图2-3
在翻译器中,C预处理器(cpp)将源文件翻译成一个ASCII码的中间文件,将编译C文件需要用到的程序添加到文件中,使得hello.c完成了翻译的第一步,为其继续翻译成机器能够识别的汇编语言文件打下基础。
(第2章0.5分)
概念:把预处理完的文件进行一系列语法分析及优化后生成相应的汇编文件
作用:将main.i翻译成一个汇编语言文件main.s
编译命令: gcc -S hello.i -o hello.s
图3-1 命令行编译hello文件
图3-2 编译后的结果
3.3.0文件声明解析
声明 含义
.file 源文件
.text 代码段
.data 数据段(存储已初始化的全局和静态C变量)
.align 对齐格式
.type 符号类型
.size 数据空间大小
.section.rodata 只读代码段
.global 全局变量
.string 字符串类型数据
.long 长整型数据
变量:
Sleepsecs:sleepsecs被声明为全局变量,且已经被赋值,应存储在.data节中,大小是4个字节,且为4字节对齐格式,类型为对象类型。
图3-3 sleepsecs定义
字符串:
程序中调用printf时引用了两个字符串分别为"Usage: Hello 学号 姓名!\n"和"Hello %s %s\n",编译时这两个字符串保存在.rodata中
图3-4 字符串存储在rodata节
3.3.2赋值
全局变量sleepsecs:定义时赋值,直接将其声明为值为2的长整型数。
图3-5 sleepsecs的赋值
局部变量i:直接在使用该变量时用mov指令将其赋值为0
图3-6 局部变量i的赋值
3.3.3类型转换
强制类型转换,sleepsecs定义为int类型但赋值为2.5是浮点类型,编译器进行了隐式类型转换将2.5转换成2赋值给sleepsecs
3.3.4算术操作
自增运算符:i++此类自增运算符直接用add指令对该变量保存的值进行加1运算
图3-7 i++的程序实现
3.3.5关系操作
1.循环终止条件i<10的判断方式:用i的值与9比较,若比9大则退出循环
图3-8 i<10的判断方式
2.条件控制argc!=3的判断方式:通过将argc-3后的结果设置条件码,如果相等则跳转到.L2对应的代码段,否则继续执行。
图3-9 argc!=3的判断方式
3.3.6数组操作
字符指针数组char *argc[] :该数组存储的是用户输入的命令行信息的地址,
图3-10 数组的汇编实现
3.3.7控制转移
1.在for循环中比较i<10,作为循环终止条件。
3-11 for循环中的控制转移
2.if-else结构中的控制转移:
图3-12 if-else中的控制转移
3.3.8函数操作
函数是C语言中过程的一种体现,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。然后可以在程序的不同地方调用这个函数。过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是那些值,过程会对程序产生什么样的影响。
要提供对过程的机器级支持,必须要处理许多不同的属性,例如P调用Q,Q执行后返回到P,包含以下几个机制:
1.传递控制:进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
2.传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值。
3.分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
本实验中涉及到的函数操作有:
1.main函数:由系统进行调用,并通过外部输入向其传入参数argc,argv。
2.Printf函数:
图3-13 printf函数传参调用的过程-1
分别将printf函数要输出的两个参数保存在%rax和%rdx两个寄存器中,传递给printf函数。
图3-14 printf函数传参调用的过程-2
3.Exit函数:将%edi设置成要退出的值,调用exit函数。
图3-15 exit函数实现
4.Sleep函数:将全局变量sleepsecs中保存的数值读取到%edi中,调用sleep函数。
图3-16 sleep函数的实现
5.Getchar函数:直接调用getchar函数
图3-17 getchar函数的实现
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
在本章中,编译器将高级语言编译成汇编语言,在以上的分析过程中,详细的分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,按照不同的数据类型和操作格式,解释了hello.c文件与hello.s文件间的映射关系。在此阶段,编译器将hello.i文件编译成更抽象更低级的hello.s汇编语言文件,为汇编阶段产生机器可识别的机器语言指令打下基础。
(第3章2分)
概念:把生成的汇编指令逐条翻译成机器可以识别的形式,即机器码。
作用:汇编器(as)将main.s翻译成一个可重定位目标文件main.o
汇编命令:as [other arguments] -o hello.o hello.s
图4-1
图4-2
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
用readelf -a hello.o 命令获得hello.o文件的ELF格式
1.ELF头
图4-3 ELF头
2.节头:包含文件中出现的各个节的类型,地址,偏移量,大小,旗标,链接,对齐等信息。
图4-4 hello的节头部表的基本信息
ELF头
.text 已编译程序的机器代码
.rel.text 一个.text节中位置的列表
.data 初始化的全局和静态变量
.bss 未初始化的全局和静态变量
.rodata 只读数据(switch语句跳转表)
.symtab 符号表
.rel.data 被模块引用或定义的所以全局变量的重定位信息
.debug 调试符号表
.line 行号与.text机器指令之间的映射
.strtab 一个字符串表
节头部表
表4-1 ELF格式的可执行目标文件的各类信息
2.重定位节.rela.text和.rela.eh_frame:包含.text节中需要进行重定位的信息,链接时需要修改这些信息的位置。下图中的8条重定位信息分别对应于.L0(第一个printf中的字符串),puts函数,exit函数,.L1(第二个printf中的字符串),printf函数,全局变量sleepsecs,sleep函数,getchar函数进行重定位声明。
图4-5 重定位信息
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
对比hello.s和hello.o两个文件后发现,汇编器在汇编hello.s时:
1.为每条语句加上了具体的地址。
2.函数调用方式由直接调用函数名替换为函数的相对偏移地址。
3.全局变量引用,在汇编代码文件中,访问全局变量采用段名称+%rip的方式,在反汇编代码中统一采用0+%rip的方式,并在后面标注上他的偏移地址。
4.跳转地址直接采用确定的地址,而不是采用汇编代码中的段名称。
5.操作数均更改为16进制格式。
图4-6 hello.o反汇编结果
机器语言的构成:机器语言由二进制码组成,每一串二进制码叫做一个指令。指令通常由几个字节组成,第一个字节是操作码,他规定了计算机要进行的基本操作,后面的字节是操作数,规定了操作数或操作对象的地址。
机器语言与汇编语言的映射关系:与机器代码的二进制相比,汇编代码的主要特点是他用可读性更好的文本格式表述。汇编语言采用符号来表示地址码和操作码。汇编代码中的分支转移函数调用采用的是绝对地址,机器语言中的分支调用采用的是相对地址。
本章分析了hello.s到hello.o的汇编过程,并且详细分析了hello.o的ELF构成,执行汇编操作时汇编器进行了哪些工作,并且比较了hello.o的反汇编代码与hello.s之间的对应关系,了解了汇编代码与机器代码之间的映射关系。
(第4章1分)
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存中执行。链接可以执行于编译时,也就是源代码被翻译成机器代码时;链接也可以执行于加载时,也就是程序被加载器加载到内存中被执行时;甚至被执行于运行时,也就是由应用程序来执行。
作用:将生成的目标文件和其所依赖的库文件进行连接,生成一个可执行文件。
链接命令:
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-1 Ubuntu下链接过程
图5-2 生成的可执行程序
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
图5-3 hello各段的基本信息
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb加载hello后,在Data Dump中查看虚拟地址信息,该进程的虚拟地址是从0x400000-0x401000
图5-4 虚拟地址空间
图5-5 ELF文件中的程序头
对应于5.3节中的interp段,地址由0x400200开始到0x40021c截止,记录了程序所用ELF解析器(动态链接器)的位置位于: /lib64/ld-linux-x86-64.so.2。
图5-6 .interp段的信息
Load段,地址由0x400000开始到0x40076c截止,包括ELF头、程序头部表以及.init、.text、.rodata节。
图5-7 load段详细信息
NOTE段,地址由0x40021c开始到0x40023c截止,记录了输入的字符串信息。
图5-8 NOTE段详细信息
1.Hello与hello.o的不同之处:(截图在下方)
1.hello.o中的相对偏移地址到了hello中变成了虚拟内存地址
2.hello中将需要用到的一些库函数和外部函数写入进来。
3.Hello中使用的跳转地址和函数调用地址均为虚拟内存地址。
4.Hello中添加了许多节如.init,.plt等,而hello.o中只有.text节
2.链接的过程:在使用ld命令链接的时候,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
3.重定位:链接器完成符号解析后,将代码中每个符号引用和一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来,此时,链接器就知道了它的输入目标模块中的代码节和数据节的确切大小。重定位首先应进行重定位节和符号定义,在这一步骤中,链接器将所有相同类型的节合并为同一类型的新的聚合节,然后程序将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,由此程序中的每一条指令和全局变量都有唯一的运行时内存地址。其次,进行重定位节中的符号引用,这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
通过书中概念我又总结了重定位地址的计算方法,更易理解,过程如下:
重定位地址的计算:首先获取各节的大小,然后直接通过对应的节与程序头之间的相对距离大小确定节头的地址,节中对应的指令地址则由与节头之间的相对地址计算,各指令的具体地址均可由此获知。call指令后接的函数的绝对地址则应通过相对地址+PC(也就是下一条指令的地址)的方式进行计算。
图 5-9 hello.o反汇编分析
图5-10 hello反汇编分析
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
加载程序 ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
加载hello hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
Hello初始化 hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
调用main函数(运行) hello!main
调用打印函数 hello!puts@plt
调用退出函数 hello!exit@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
退出程序 libc-2.27.so!exit
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
图5-11 dl_init函数地址
图5-12 dl_init函数执行过程
图5-13 dl_init函数执行之前的地址
图5-14 dl_init函数执行之后的地址
在dl_init函数执行过程之后0x600a10处的global_offset表由全0的状态被赋上相应的偏移量的值。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。链接(linking) 是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
经过链接之后生成的hello文件已经可以运行,hello从.c文件到现在的可执行文件可谓历经千辛万苦。到本章为止,hello从源代码的翻译过程已经基本结束,hello也真正变成了一个富有生命力的程序,接下来我们将进一步讨论hello在计算机中运行时计算机会进行哪些操作以满足hello的需求!
(第5章1分)
概念:一个执行中程序的实例,一个程序关于某个数据集合的一次运行活动。
作用:提供给应有程序一个独立的逻辑控制流和一个私有的地址空间,好像程序在独占的使用处理器和内存系统。
作用:Shell 是指一种应用程序,Shell应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
(1).读取输入的命令行。
(2).解析引用并分割命令行为各个单词,各单词称为token。其中重定向所在的token会被保存下来,直到扩展步骤(5)结束后才进行相关处理,如进行扩展、截断文件等。
(3).检查命令行结构。主要检查是否有命令列表、是否有shell编程结构的命令,如if判断命令、循环结构的for/while/select/until,这些命令属于保留关键字,需要特殊处理。
(4).对第一个token进行别名扩展。如果检查出它是别名,则扩展后回到(2)再次进行token分解过程。如果检查出它是函数,则执行函数体中的复合命令。如果它既是别名,又是函数(即命令别名和函数同名称的情况),则优先执行别名。在概念上,别名的临时性最强,优先级最高。
(5).进行各种扩展。扩展顺序为:大括号扩展;波浪号扩展;参数、变量和命令替换、算术扩展(如果系统支持,此步还进行进程替换);单词拆分;文件名扩展。
(6).引号去除。经过上面的过程,该扩展的都扩展了,不需要的引号在此步就可以去掉了。
(7).搜索和执行命令。
(8).返回退出状态码。
在shell中输入命令./hello ,shell 会对输入的命令进行解析,发现不是内置命令则调用当前目录下的可执行文件, shell通过调用fork函数创建一个新的运行的子进程,新创建的hello进程几乎但不完全与父进程相同:hello进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本。hello进程获得与父进程任何打开文件描述符相同的副本。最大区别:hello进程有不同于父进程的PID
进程图如下:
图6-1 进程图
execve在当前进程的上下文中加载并运行一个新程序,execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
execve加载了filename后他调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数。
图6-2 调用execve后新程序的栈结构
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
用户模式和内核模式:处理器通过用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令和它可以访问的地址空间范围。当设置了模式位时,进程就运行着在内核模式,可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
用户模式与内核模式之间的转换方法:运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过中断,故障或陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将用户模式变为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理前就把模式从内核模式改为用户模式。
上下文信息:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。并通过上下文切换的机制来将控制转移到新的进程。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
Hello的进程调度过程:hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,计时器开始计时,内核通过上下文切换将当前进程将当前进程的控制权交给其他进程。当sleep函数时间到达时发送一个中断信号,此时进入内核状态执行中断处理,然后内核将进程控制权交还给hello进程,hello进程继续执行自己的控制逻辑流。
图6-3 hello的进程调度过程
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常种类:
1.中断(SIGSTP):挂起程序
2.终止(SIGINT):终止程序
Ps命令:列出当前系统中运行的进程
Jobs命令:查看shell当前正在处理的作业。
图6-7 Ctrl-z后运行pstree
fg 命令:发送SIGCONT信号继续执行停止的进程;
Kill -9 pid :发送SIGKILL信号给指定的pid杀死进程
在本章中,简要介绍了hello的进程管理方法,对异常控制流的了解更加深入。理解了许多重要的系统概念,例如:进程。理解了应用程序如何与操作系统交互的方式(陷阱或系统调用)。理解了软件异常如何工作,例如C++和Java中的catch和throw语句。
经过本章的分析,hello程序现在有了属于自己的活动空间!
(第6章1分)
逻辑地址:逻辑地址(LogicalAddress)是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。
虚拟地址:CPU产生的需要访问主存的地址(Virtual Address,VA).就是hello中的虚拟内存地址。
物理地址:计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。hello在运行时虚拟内存地址对应的物理地址。用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
逻辑地址如何转换为线性地址 一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
图 7-1 段选择符
图7-2 段选择符的描述
Base字段,它描述了一个段的开始位置的线性地址。 Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。段选择符中的T1字段指出选择GDT和LDT的方法。为0时表示用GDT,为1时表示用LDT。
图7-3 地址存储位置
逻辑地址转换成线性地址的流程:
1.看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。
2.拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,由此得到基地址。
3.基地址+偏移量就是要转换的线性地址。
图7-4 使用k级页表的地址翻译
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
图7-5 Linux中虚拟内存管理
下图给出了Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
图7-6 Core i7的页表翻译
图7-7 Core i7的内存系统
图7-8 Core i7的地址翻译概况
理解了虚拟内存和内存映射,那么我们就可以清晰地知道fork函数是如何创建一个带有自己独立虚拟地址空间的新进程的。
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
Execve函数在当前进程中,加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
3.映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC) execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7-9 加载器映射用户地址空间区域的方法
在虚拟内存的习惯说法中, DRAM 缓存不命中称为缺页(page fault) 。
下图展示了在缺页之前我们的示例页表的状态。以下为缺页故障与缺页中断处理流程:
1.CPU 引用了VP 3 中的一个字, VP 3 并未缓存在DRAM 中。
2.地址翻译硬件从内存中读取PTE 3, 从有效位推断出VP 3 未被缓存,并且触发一个缺页异常。
3.缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4 。
4.如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4 的页表条目,反映出VP 4 不再缓存在主存中这一事实。
5.内核从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。
6.重新启动导致缺页的命令,该命令会把导致缺页的虚拟地址重发送到地址翻译硬件。
图7-10 VM缺页之前,对VP3中的字的引用会不命中,从而触发了缺页
图7-11 VM缺页后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代他
动态内存分配器简介:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
基本方法:
1.首次适配从头开始搜索空闲链表,选择第一个合适的空闲块,优点是趋向于将大的空闲块保留在链表的后面,缺点是会在链表的起始处留下许多小空闲块的碎片。
2.显示空闲链表。在空闲块中使用指针连接空闲块。
3.分离的空闲链表.维护多个空闲链表,每个链表的块有大致相等的大小
放置策略:
1.隐式空闲链表是空闲块通过头部中的大小字段隐含着连接的,分配器可以通过遍历堆中的所有块简介遍历整个空闲块集合,优点是简单,缺点是比较浪费时间。
2.下次适配。下次适配和首次适配的方式很相似,只不过是从上一次查询结束的地方开始,而不是从链表的起始处开始搜索。
3.最佳适配。检查每个空闲块,选择适合所需请求大小的空闲块。
本章主要介绍了,与hello的存储地址空间有关的定义及解决策略。存储的段式管理与页式管理,以Core i7为例介绍了VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
2. 进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3. 应用程序是通过分别调用read write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。
返回:若成功则为写的字节数,若出错则为-1。
Printf函数的源代码:
图8-1 printf的源代码
图8-2 vsprintf的源代码
由printf源代码得知arg表示的是输入的第一个参数,由vsprintf源代码得知vsprintf返回的是一个长度,即要打印出来的字符串的长度。再看printf中后面的一句:write(buf, i),它的功能为将buf中的第i个字符写到终端。
接下来分析write函数的实现
图8-3 write函数的实现过程
第五行 INT_VECTOR_SYS_CALL指的是调用一个函数,该函数的目的是进行系统调用sys_call,实现特定的系统功能。
然后再来分析sys_call实现的具体细节:
图8-4 sys_call的源代码
可以看出sys_call的实现较为复杂,但本质上该函数的功能就是不断的打印出字符,直到遇到’\0’
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
图8-5 getchar的源码
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
输入/输出(I/O) 是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O 设备复制数据到主存,而输出操作是从主存复制数据到I/O 设备。所有语言的运行时系统都提供执行I/O 的较高级别的工具。例如, ANSI C 提供标准I/O 库,包含像printf 和scanf 这样执行带缓冲区的I/O函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Linux 系统中,是通过使用由内核提供的系统级Unix I/O 函数来实现这些较高级别的I/O 函数的。
(第8章1分)
1.预处理:将C的源程序main.c翻译成一个ASCII码的中间文件main.i
2.编译:把预处理完的文件进行一系列语法分析及优化后生成相应的汇编文件
3.汇编:把生成的汇编指令逐条翻译成机器可以识别的形式,即机器码
4.链接:将生成的目标文件和其所依赖的库文件进行连接,生成一个可执行文件。
5.进程管理:提供给应有程序一个独立的逻辑控制流和一个私有的地址空间
6.存储管理:程序存储的信息。
7.IO管理:所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
至此hello程序的执行过程已经结束,但我们对计算机系统的学习历程还未结束,相信有了本次hello程序的分析过程作为基础,我们今后的路会更加一帆风顺。
(结论0分,缺少 -1分,根据内容酌情加分)
Hello 可执行文件
Hello.c C文件
Hello.i 预处理之后的文件
Hello.ld 链接后的文件
Hello.o 可重定位目标文件
Hello.s 汇编语言文件
objhello 反汇编.o文件
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1] 青豆1113----UBuntu16.04下用gcc编译c文件过程详解https://blog.csdn.net/qq_31811537/article/details/79312908
[2] 机器语言到汇编 https://wenku.baidu.com/view/0d3ee0a0453610661ed9f4ae.html
[3] Linux的jobs命令https://blog.csdn.net/Quincuntial/article/details/53700161
[4] 郑州的文武Linux下 ps 命令http://www.cnblogs.com/zhengbin/p/5755141.html
[5] do2jiang—逻辑地址,线性地址,物理地址,和虚拟内存详解https://blog.csdn.net/do2jiang/article/details/4512417
[6] Pianistx----printf函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,确实 -1分)