计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021928
班 级 2003006
学 生 吴恺昕
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本论文以“Hello World!”这一再熟悉不过的字符串为起点与中心,辐射至整个计算机系统。本论文将详细探讨Hello.c是如何一步步经过程序员的操作,经过预处理、编译、汇编、链接、加载、运行、终止、回收等一系列步骤,结合这一学期所学的知识,最终转变为可执行文件,成为一个真正完整的程序。是我们程序员赋予了“Hello World!”以生命。通过对hello.c研究的不断深入,我们将回顾本学期知识,温故而知新。
关键词:程序;计算机系统;系统操作;hello;
目 录
第1章 概述
1.1 Hello简介
P2P:from Program to Process
在Linux中,经过编写得到的Hello.c经过cpp的预处理得到.i文件,再经过ccl的编译得到.s文件,再经过as的汇编得到.o文件,最后经过ld的链接最终成为可执行目标程序Hello。然后经过fork的启动,成为进程,开始运行
020:Zero to Zero
Shell为创建的子进程execve使其独立,获得虚拟内存,存入物理内存,进入main函数后开始执行目标代码。进程执行完之后shell父进程会将其回收,并删除内核中相关信息。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk以上
软件环境:Windows10 64位;VirtualBox;Ubantu 16.04 LTS 64位
开发与测试工具:gcc,vim,edb,gdb,readelf,objdump
1.3 中间结果
- hello.i hello.c预处理得到的.i文件
- hello.s hello.i编译后得到的.s文件
- hello.o hello.s汇编后得到的.o文件
- hello hello.o经链接后得到的可执行文件
- hello_obj.s hello经过反汇编后生成的.s文件
1.4 本章小结
本章对hello进行了一个总体的介绍。该章对整个实验的目的、要求做出大致的描述,同时解读了“hello的自白”。对于实验所需工具与中间生成的相关文件做了大致称述。
第2章 预处理
2.1 预处理的概念与作用
2.1.1概念
预处理又称预编译,指的是在程序编译之前,根据以字符#开头的命令修改原始的c程序(C语言的预处理主要有三个方面的内容: 1.宏定义; 2.文件包含; 3.条件编译)
步骤:
1.删除所有的#define,并展开所定义的宏;
2.处理所有的预编译指令,例如:#if,#elif,#else,#endif等;
3.插入头文件到“#include”处,可以递归方式进行处理;
4.添加行号信息文件名信息,便于调试;
5.删除所有的注释:// 和/**/;
6.保留所有的#pragma编译指令。
2.1.2作用
C语言预处理命令的出现,使得编写的程序便于阅读、修改、移植和调试,同时也有利于模块的设计。
提高了C语言的通用性,不同的计算机能兼容的执行C语言的代码程序。
执行源文件包含#include 的指令,告诉cpp读取源程序所引用的系统源文件,并把源文件直接插入程序文本中。
2.2在Ubuntu下预处理的命令
图2.1
执行命令:gcc hello.c -E -o hello.i
执行上述命令后,如图2.1可以看到,在文件夹中生成了hello.i文件。
2.3 Hello的预处理结果解析
图2.2
如图2.2所示hello.i的部分代码所示,编译成功将hello.c经过预处理生成了hello.i文件,该文件满足2.1.1中预处理步骤中各步骤的要求,是合格的预处理结果。
2.4 本章小结
本章介绍了预处理的概念、步骤以及预处理的作用与好处。展示了如何在Linux中通过命令进行预处理并通过图文展示了将hello.c进行预处理的结果,对结果做出分析与概括。
第3章 编译
3.1 编译的概念与作用
3.1.1 概念
编译指利用编译程序从源语言编写的源程序产生目标程序的过程,即用编译程序产生目标程序的动作。 编译就是把高级语言变成计算机可以识别的2进制语言,计算机只认识1和0,编译程序把人们熟悉的语言换成2进制的。
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
3.1.2 作用
词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。
各步骤作用合并在一起,编译的作用即为将高级语言源程序翻译成目标程序,并翻译成等价的机器语言格式目标程序。
3.2 在Ubuntu下编译的命令
图3.1
编译命令:gcc -S hello.i -o hello.s
如图3.1所示,通过命令,成功生成.s文件,编译操作成功。
3.3 Hello的编译结果解析
3.3.1数据
图3.1
字符串:如图3.1所示
图3.2
全局变量:(函数)如图3.2所示
图3.3
变量:i,作为循环累加量,存储在-4(%rbp)中,如图3.3所示
图3.4
变量:argc,main的参数之一,在main中与4进行比较,如图3.4所示
图3.5
立即数:用$符号开头,后接数字即为立即数。在.s文件中常见,例如用于访问栈中变量常常用到$8等8的倍数。图3.5展示的是for循环i每次累加加1的1的立即数表示。
3.3.2赋值
图3.6
赋值语句在.s文件中也很常见,通常用mov语句对寄存器或栈内进行赋值。Mov后缀决定了赋值对象的空间大小。如图3.6所展示的是for循环i每次累加加1的赋值语句。
3.3.3类型转换
图3.7
类型转换通常由mov语句进行强制转换,当然也可以用专门的汇编语句进行扩位等。如图3.7所示,是将char类型强转至int类型的语句。
3.3.4算数操作
图3.8
图3.9
算数操作包括leaq,add,sub,mul等诸多操作。如图3.8所示,该算数操作为leaq,将字符取出给%rdi。如图3.9所示,该算数操作为addq,给寄存器%rax加16后赋值给%rax,用于地址计算。操作众多,不再一一展示
3.3.5关系操作
图3.10
图3.11
通常由cmp语句实现比较,通过标识符记录结果,通过后缀判断是要进行什么样的关系操作。如图3.10所示,判断语句为argc!=4。如图3.11所示,判断语句是i<7
3.3.6数组操作
图3.12
数组:argv:同样是main的参数,保存在栈中,如图3.12所示。.s文件中将栈中argv首地址传给寄存器%rax,而后通过对%rax进行地址相关操作,从而达到访问数组各个元素的效果。
3.3.7控制转移
图3.13
判断语句是argc!=3。当argc不等于3时,继续执行之后的代码;若相等,则跳转至.L2
图3.14
判断语句是i<10,for循环判断终止条件。在i<=9时继续循环,进入.L4,i>9时跳出循环
3.3.8函数操作
调用函数时有以下操作:
(1)传递控制:进行过程Q的时候,程序计数器必须设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址;
(2)传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P中返回一个值;
(3)分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
3.4 本章小结
本章对编译的概念与作用进行了阐述,并通过实际操作生成.s文件。而后结合PPT第四页,列举概括了.s文件中汇编语言相关的概念知识以及操作,就数据、赋值、类型转换、算术操作、关系操作、数组操作、控制转移、函数操作进行图文分析。
第4章 汇编
4.1 汇编的概念与作用
4.1.1概念
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件中的过程。.o文件是一个二进制文件,它包含程序的指令编码。
4.1.2 作用
将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,方便机器识别运行。
4.2 在Ubuntu下汇编的命令
执行命令:gcc hello.s -c -o hello.o
了。怕
图4.1
如图4.1所示,经过执行命令之后,陈工生成了.o文件
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
执行readelf hello.o -a指令,打印信息进行分析
4.3.1 ELF Header
图4.2
ELF头先是一个16字节大小的Magic头,用于辨别ELF二进制库。随后给出了程序的文件类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。如图4.2所示
4.3.2 Section Header
图4.3
描述了.o文件中每一个节出现的名字,类型,位置,大小。
几个比较重要的节:
.bss:存储未初始化的全局数据,默认的当程序运行时,会被初始化为0。是data段的一部分。
.comment:版本控制信息
.data:存储已经初始化了的全局变量等数据
.debug:存储调试(符号调试)信息
.rodata && .rodata1:只读数据
4.3.3 Relocation section
图4.4
重定位是连接符号引用与符号定义的过程。当链接器把目标文件和其他文件合并时,需要修改重定位节。程序可以通过偏移量等信息计算出相应地址。
4.3.4 .Symtab
图4.5
符号表用于存放程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
执行objdump -d -r hello.o,得到hello.o的反汇编代码。
图4.6
将结果与第三章的.s文件比较。机器语言中的操作数与汇编语言差异巨大。
机器语言是二进制语言,一条汇编语言由字节构成。
1) 汇编语言中操作数是十进制的,机器语言中的操作数是十六进制的。
2) 对全局变量(即字符串常量)的引用,汇编语言中是用的全局变量所在的段的名称,而hello.o中全为00,这是因为当前为可重定位目标文件,然后在.rodata 节中为其添加重定位条目,再确定地址。
3) 对分支转移,hello.s的汇编语言中在跳转指令后用对应段的名称(如.L3)表示跳转到的位置,而hello.o中因为每行指令都被分配了对应的地址(从main函数第一条指令地址为0开始),在跳转指令后用跳转目的的地址(相对或者直接)来表示跳转到的位置。
4) 函数调用hello.s中的汇编语言在函数调用时,在call指令后用函数的名字表示对其调用,而反汇编指令在call指令后加上下一条指令的地址来表示。观察机器语言,其中操作数都为00,这是因为同2),当前为可重定位目标文件,然后还要在.rela.text 节中为其添加重定位条目,等待静态链接进一步确定。
4.5 本章小结
首先概括了汇编的概念与作用。在实际操作之下通过命令成功生成了Hello.o文件。而后结合具体程序,论述了可重定位ELF中各部分的含义与作用。最后与第三章中.s文件相比较,探讨二者差异,增进对汇编的理解
第5章 链接
5.1 链接的概念与作用
5.1.1概念
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(load-er)加载到内存并执行时﹔甚至执行于运行时(run time),也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
5.1.2作用
链接器在软件开发中扮演着一个关键的角色,它们使得分离编译( separate com-pilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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.ohello.o/usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图5.1
如图5.1所示,经过运行命令之后,成功得到了hello文件。
5.3 可执行目标文件hello的格式
命令:readelf -a hello
5.3.1 ELF Header
图5.2
ELF头先是一个16字节大小的Magic头,用于辨别ELF二进制库。随后给出了程序的文件类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。与第四章elf相比较,基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加。如图5.2所示
5.3.2 Section Header
图5.3
节头表包含的节数增加,变为27个;每个节中都指定了一个类型,定义了节数据的语义。节中的信息更加丰富,体现了更多具体的内容,比如具体大小、在虚拟内存中的位置、偏移量等。在可执行文件中,这些代码都已经定位到最终要执行的地址,所以涉及定位的节如rel.text等被删除。、
5.3.3 Program Header
图5.4
section header用于描述section的特性,而program header用于描述segment的特性,目标文件(也就是文件名以.o结尾的文件)不存在program header,因为它不能运行。一个segment包含一个或多个现有的section,相当于从程序执行的角度来看待这些section。
5.3.4 Dynamic Section
图5.5
假如一个object文件参与动态的连接,它的程序头表将有一个类型为PT_DYNAMIC的元素。该“段”包含了.dynamic section。
5.3.5 Symbol table
图5.6
存放程序中定义的函数和局部变量的信息,如名称、偏移量等等。
5.4 hello的虚拟地址空间
图5.7
输入命令之后,如图5.7所示,可以从Data Dump窗口查看。程序的虚拟地址从0x401000开始,于0x401ff0结束。通过节头表可以看出,并结合edb可以找到各个节的相关信息。例如:.text的虚拟地址为0x4010f0,大小为0x145,而.data节,虚拟地址开始于0x404040,大小为0x8。
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello_obj.s
图5.8
重定位步骤:
- 重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输人模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
- 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构。
运用公式进行计算:refaddr = ADDR(s) + r.offset以及*refptr = (unsigned)(ADDR(r.symbol) + r.addend-refaddr),发现对于每个函数,重定位得出的地址确实为其地址,重定位正确。
5.6 hello的执行流程
图5.9
子函数名和地址(后6位)
401000 <_init>
401020 <.plt>
401030 <puts@plt>
401040 <printf@plt>
401050 <getchar@plt>
401060 <atoi@plt>
401070 <exit@plt>
401080 <sleep@plt>
401090 <_start>
4010c0 <_dl_relocate_static_pie>
4010c1 <main>
401150 <__libc_csu_init>
4011b0 <__libc_csu_fini>
4011b4 <_fini>
5.7 Hello的动态链接分析
图5.10
如图5.10所示,在elf文件中找到got.plt地址而后去edb查看。
图5.11
图5.12
图5.11与5.12分别展示了edb执行init之前与之后的地址。对比发现,0x404008和0x404010处的两个8字节的数据发生改变,出现了两个新地址。其原理为程序在got与plt之间反复跳转与调用,通过动态链接器计算正确地址,从而达到跳转到正确区域的目的。
5.8 本章小结
本章探讨了链接的概念与作用。通过实际操作生成hello可执行文件。而后对hello的elf文件与之前的elf文件做对比并分析。接着运用edb结合程序分析了hello的虚拟地址空间、重定位过程、执行过程的各种处理操作及原理。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1概念
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文(context)中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户通过向shell输人一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.1.2作用
进程提供给应用程序两个关键抽象。
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 作用
Shell是用户与操作系统之间完成交互式操作的一个接口程序,连接用户和操作系统以及内核,它为用户提供简化了的操作。Bash是Linux系统中默认的shell程序。
6.2.2 处理流程
(1)输入
在交互模式下,输入来自终端。bash使用GNU Readline库处理用户命令输入,Readline提供类似于vi或emacs的行编辑功能(如Ctrl+a、Ctrl+e等等)。
当敲击键盘时,字符会存入Readline的编辑缓冲区,Readline会处理输入的变化并及时地将结果显示到终端上。Readline还要保持命令提示符(prompt)的稳定(比如提示符的颜色)。
(2)解析
解析阶段的主要工作为:词法分析和语法解析。词法分析指分析器从Readline
或其他输入获取字符行,根据元字符将它们分割成word,并根据上下文环境
标记这些word(确定单词的类型)。语法解析指解析器和分析器合作,根据各
个单词的类型以及它们的位置,判断命令是否合法以及确定命令类型。
- 扩展
扩展阶段对应于单词的各种变换,最终得到可用于执行的命令。
- 执行
1、bash执行fork()系统调用创建子进程(如果命令已经处于子shell内,则不会再次fork(),例如上述管道命令)
2、执行重定向
3、执行execve()系统调用,控制权移交给操作系统。
4、内核判断该文件是否是操作系统能够处理的可执行格式(如ELF格式的可 执行二进制文件或开头顶格写#!的可执行文本文件)
5、如果操作系统能够处理该文件,则调用相应的函数(二进制文件)或解释器(脚本文件)进行执行。
6、如果文件不具备操作系统的可执行格式(如文本文件但没有顶格写的#!),execve()失败,此时,bash会判断该文件,如果该文件有可执行权限并且不是一个目录,则认为该文件是一个脚本,于是调用默认解释器解释执行该文件的内容。
7、执行完毕后,bash收集命令的返回值。
6.3 Hello的fork进程创建过程
在Shell,输入带参数的命令,执行生成的可执行文件。
fork进程的创建过程如下:首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve 才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
execve启动代码hello,将控制传递给主函数,当main开始执行时,用户栈的组织结构如图6.1所示。首先是参数和环境字符串。栈往上紧随具后的定以nulI络用时旧地数组,其中每个指针都指向栈中的一个环境变量字符串。全同发重environ 拍问达些后针中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的 argv[效组,共中母个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main。
图6.1
6.5 Hello的进程执行
(1)逻辑控制流:即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。
(2)并发流:一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。
(3)时间片/时间分片:多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务( multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
(4)私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
(4)用户模式与内核模式:处理器通常是用某个控制寄存器中的一个模式位( mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
(5)上下文信息与上下文切换:内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
下面结合上述概念来具体看一下hello的进程执行过程。在进程调用execve函数之后,进程为hello程序分配了新的虚拟地址空间。开始时hello运行在用户模式下,输出hello 120L021928 吴恺昕,如图6.2所示。然后hello运行sleep函数之后进程切换进入内核模式。在内核模式中,系统处理休眠请求释放当前空间,计时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程。当计时器结束时发送一个中断信号,此时再上下文切换进入内核状态执行中断信号处理,将hello进程从等待队列中移出重新加入到运行队列。
图6.2
6.6 hello的异常与信号处理
运行过程中可能出现的异常种类由四种:中断、陷阱、故障、终止。
中断:来自I/O设备的信号,异步发生。硬件中断的异常处理程序被称为中断处理程序。
陷阱:是执行一条指令的结果。调用后返回到下一条指令。
故障:由错误情况引起,可能能被修正。修正成功则返回到引起故障的指令,否则终止程序。
终止:不可恢复,通常是硬件错误,这个程序会被终止。
- 正常运行
图6.3
- 乱按键盘
图6.4
如图6.4所示,乱按键盘后,无关的输入会被缓存,程序继续输出
- Ctrl-Z
图6.5
如图6.5所示,程序停止运行
图6.6
如图6.6所示,在Ctrl-Z之后调用ps(或jobs)查看进程状态,发现并未停止,再输入fg,程序继续运行。
图6.7
如图6.7,再调用kill指令,将进程终止,此时输入fg显示进程已完全terminated
图6.8
如图6.8所示,如果在Shell中输入pstree命令,可以将所有进程以树状图显示。
- Ctrl-C
图6.9
如图6.9所示,输入Ctrl-C之后,再查看进程,发现没有hello进程,说明进程被终止。
6.7本章小结
本章讨论了进程的概念与作用,探讨了fork与execve两个函数的功能。然后结合上下文切换、信号处理探讨进程运行中的过程与细节。最后图文结合进行实际操作,验证前文所述。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(1)逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由段基址和偏移量两部分组成。具体而言,其为hello.asm中的相对偏移地址。
(2)线性地址
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。具体以hello而言,线性地址标志着 hello 应在内存上哪些具体数据块上运行。用hello段基址加上段偏移量即为线性地址。
(3)虚拟地址
CPU启动保护模式后,程序运行在虚拟地址空间中。与物理地址相似,虚拟内存被组织为一个存放在磁盘上的N个连续的字节大小的单元组成的数组,其每个字节对应的地址成为虚拟地址。
(4)物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
linux下逻辑地址转换成线性地址的流程
(1)使用段选择符中的偏移值 在GDT或LDT中定位相应的段描述符。
(2)利用段描述符检验段的方位权限和范围,以便确定该段是可访问的并且偏移量位于段的段界限内。
(3)把段的偏移量加到段的基地址上最后形成一个线性地址。
段式管理特点:
(1)段式管理以段为单位分配内存,每段分配一个连续的内存区。
(2)由于各段长度不等,所以这些存储区的大小不一。
(3)同一进程包含的各段之间不要求连续。
(4)段式管理的内存分配与释放在作业或进程的执行过程中动态进行
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址通过分页机制对应到物理地址,具体的说,就是通过页表查找来对应物理地址。分页是CPU提供的一种机制,Linux根据这种机制的规则,利用它实现了内存管理。
保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接做为物理地址。
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页(page),每页包含4k字节的地址空间。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table)。为了实现每个任务的平坦的虚拟内存,每个任务都有自己的页目录表和页表。
为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。
32位的线性地址被分成3个部分:最高10位 Directory 页目录表偏移量,中间10位 Table是页表偏移量,最低12位Offset是物理页内的字节偏移量。
页目录表的大小为4k(刚好是一个页的大小),包含1024项,每个项4字节(32位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,则物理地址填0。页表的大小也是4k,同样包含1024项,每个项4字节,内容为最终物理页的物理内存起始地址。每个活动的任务,必须要先分配给它一个页目录表,并把页目录表的物理地址存入cr3寄存器。页表可以提前分配好,也可以在用到的时候再分配。
页式管理优点:
(1)由于它不要求作业或进程的程序段和数据在内存中连续存放,从而有效地解决了碎片问题。
(2)动态页式管理提供了内存和外存统一管理的虚存实现方式,使用户可以利用的存储空间大大增加。这既提高了主存的利用率,又有利于组织多道程序执行。
页式管理缺点:
(1)要求有相应的硬件支持。例如地址变换机构,缺页中断的产生和选择淘汰页面等都要求有相应的硬件支持。这增加了机器成本。
(2)增加了系统开销,例如缺页中断处理机,
(3)请求调页的算法如选择不当,有可能产生抖动现象。
(4)虽然消除了碎片,但每个作业或进程的最后一页内总有一部分空间得不到利用果页面较大,则这一部分的损失仍然较大。
7.4 TLB与四级页表支持下的VA到PA的变换
假设:虚拟地址空间48位(n=48),物理地址空间52位(m=52),页表大小 4KB,4 级页表。VPN共36位,VA 48位,VPO 12位;TLBI需4位,VPN 36位,TLBT 32位。
图7.1
如图7.1所示多级页表地址翻译。解析VA,利用前m位vpn1寻找一级页表位置,接着一次重复3次,在第4级页表获得了页表条目,将PPN与VPO组合获得PA。
图7.2
在得到PA之后,按照图7.2的流程继续执行,即可访问。
7.5 三级Cache支持下的物理内存访问
图7.3
MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中条目,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则在下一级cache或是主存中寻找需要的内容,储存到上一级cache后再一次请求读取。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并且把两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用hello程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1) 删除当前进程虚拟地址的用户部分中已存在的区域结构
2) 映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。代码和数据区域被映射为 hello 文件中的.text 和.data 区
3) 映射共享区域, hello 程序与共享对象 libc.so 链接,然后再映射到用户虚拟地址空间中的共享区域内。
4) 设置程序计数器(PC)
7.8 缺页故障与缺页中断处理
图7.4
缺页故障:当指令引用一个相应的虚拟地址,而与之相应的物理页面不再内存中,会触发缺页故障。
处理缺页要求硬件和操作系统内核协作完成,如图7.4所示。
第1步到第3步:和图7.4中的第1步到第3步相同。
第4步:PTE中的有效位是零,所以 MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) o系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break”),它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk), 要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么 是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7.5
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
(1)显式分配器(explicit allocator),要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
(2)隐式分配器(implicit allocator),另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector) ,而自动释放未使用的已分配的块的过程叫做垃圾收集(garbage collection) o 例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
隐式空闲链表的内存分配:
(1)放置已分配的块当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
(2)分割空闲块一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。
(3)获取额外堆内存如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
(4)合并空闲块合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。
显式空闲链表:在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要介绍了hello的存储器的地址空间,阐述了四种地址空间的差别和地址的相互转换。同时展示了hello的四级页表的虚拟地址空间到物理地址的转换。深化了对三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等知识的理解。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作相应文件的读和写来完成,这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单的,低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量sTDIN_FILENO、STDOUT FILENO和STDERR FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k十n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置l开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2函数
1.open函数: 打开或创建一个文件
返回值:成功返回一个整型(int)的文件描述符,这个文件描述符可以看作是一个打开文件的钥匙,凡是对文件的操作都离不开它。错误返回-1。
参数:
pathname:要打开或者创建的文件名。
flag :打开方式的选项,用下面一个或多个常量进行或|运算构成的flag参数。
Mode :它其实是一个8进制数,说明你将要创建文件的权限
2.write函数:往打开的文件里面写数据
返回值:成功则返回已写的字节数,错误返回-1。
参数:
fd: open函数返回的文件描述符
buf: 要写入数据的地址,通常是已声明的字符串的首地址
count:一次要写几个字节,通常为strlen()个
3.read函数:从打开的设备或文件中读取数据。
返回值:成功返回读取的字节数,出错返回-1并设置errno,0表示文件末端。如果在调read之前已到达文件末尾,则这次read返回0
参数:
fd: 文件描述符
buf: 要读数据的地址,通常是已声明的字符串的首地址
count 一次要读多少个数据
4.lseek函数:显式地为一个打开的文件设置偏移量,通常读写操作都是从当前文件偏移量处开始的,并使偏移量增加所读写的字节数
off_t lseek(int fd, off_t offset, int whence);
返回值:成功返回新的文件偏移量注意可能负数,出错返回-1
如果文件描述符引用的是一个管道,FIFO或者 网络套接字,则lseek返回-1,,并将error设置成ESPIPE
参数:
fd: 文件描述符
offset 的含义取决于参数 whence
whence有三个选项:
(1)如果 whence 是 SEEK_SET,文件偏移量将被设置为 0加上 offset。
(2)如果 whence 是 SEEK_CUR,文件偏移量将被设置为当前值加上 offset,offset 可以为正也可以为负。
(3)如果 whence 是 SEEK_END,文件偏移量将被设置为文件长度加上 offset,offset 可以为正也可以为负。
5.close函数: 用于关闭一个被打开的的文件
int close(int fd)
返回值:0成功,-1出错
参数:fd文件描述符
8.3 printf的实现分析
研究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代码:
图8.1
结合图8.1可知,vsprintf返回要打印出来的字符串的长度,printf中后面的一句:write(buf, i)写操作,把buf中的i个元素的值写到终端。因此vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键-getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了linux系统中的I/O设备基本概念和管理方法,同时简单介绍了printf和getchar函数的实现。
(第8章1分)
结论
1. 用计算机系统的语言,逐条总结hello所经历的过程:
- 预处理
在hello编译之前,根据以字符#开头的命令修改原始的c程序,方便后续编译
- 编译
利用编译程序从hello.c产生目标程序,即把高级语言变成计算机可以识别的2进制语言,主要进行词法分析和语法分析。
- 汇编
将以.s结尾的hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
- 链接
通过链接器,将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行,生成完全链接的可执行的目标文件hello
- 加载运行
打开Shell,在其中键入 ./hello 120L021928 吴恺昕,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
- 执行指令
在该进程被调度时,CPU为hello其分配时间片,hello在用户模式和内核模式之间进行上下文切换,顺序执行自己的控制逻辑流;
- 访存
内存管理单元MMU将逻辑地址,译址得到物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据,对缺页等情况联系信号处理进行处理;
- 信号处理
如果用户等对程序发送信号,则调用shell 的信号处理函数分别进行停止、挂起等相应的操作;
- 动态申请内存
调用malloc 向动态内存分配器申请堆中的内存;
- 终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
- 你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法:
- 经过这次大作业,将这一学期所学的大部分知识都联系起来,融会贯通。我们复习了总共八个主题,围绕hello程序,并上手实操,深化了我对于这几个专题的理解,温故而知新。
- 作为计算机系统最后一次实验类型的作业,本次大作业融合了前四次大作业所需要的知识,让我们感受到了实验设计者与老师们的良苦用心。
- 大作业PPT用拟人的口吻叙述了hello的一生,在追寻探讨种种细节的过程之中,让我对于自身的生活以及人生都有了一些新的感悟。人生也当如此,面对异常冷静处理,合理分配自身的时间与资源,有始有终,短暂而又绚烂。
附件
- hello.i hello.c预处理得到的.i文件
- hello.s hello.i编译后得到的.s文件
- hello.o hello.s汇编后得到的.o文件
- hello hello.o经链接后得到的可执行文件
- hello_obj.s hello经过反汇编后生成的.s文件
参考文献
[1] https://blog.youkuaiyun.com/LINZEYU666/article/details/115420915
[1] http://blog.chinaunix.net/uid-1835494-id-2831799.html
[3] http://blog.chinaunix.net/uid-22920230-id-2979406.html
[4] https://www.ucloud.cn/yun/9522.html
[5] https://blog.youkuaiyun.com/qq_29021545/article/details/122785118
[6] https://wenku.baidu.com/view/e32a7e0c3269a45177232f60ddccda38376be1cc
[7] 预处理在c语言中的作用,C语言(预处理) - 百度文库
[8] 深入理解计算机系统原书第3版
[9] https://blog.youkuaiyun.com/KomaCC/article/details/80282611