计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L021009
班 级 2003009
学 生 张梓骐
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
本文将追随hello的一生,探索从源文件到执行再到结束的每一个细节中,hello和软硬件是如何配合实现的。该流程将课程所学知识融会贯通,应用到实际中,对于理解计算机系统有很大帮助。
关键词:计算机系统;预处理;编译;汇编;重定位;链接;进程;存储;数据访问;UNIX I/O;虚拟地址;异常与信号
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
Hello的P2P(From Program to Process):
hello.c__cpp__hello.i__ccl__hello.s__as__hello.o_(。。。)_ld__hello
该过程描述的是hello如何从一个程序变成一个进程。我们在编辑器里编写好hello.c后:
第一个阶段,预处理阶段。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中,结果就得到了hello.i。
第二个阶段,编译阶段。编译器(ccl)将文本文件hello.i翻译成汇编语言并存在文本文件hello.s中。
第三个阶段,汇编阶段。汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成可重定位目标程序的格式,并将结果保存在可重定位目标文件hello.o中。
第四个阶段,链接阶段。例如hello程序调用了printf函数,该函数存在于printf.o的单独的预编译好了的目标文件中,需要把这个文件通过链接器(ld)合并到hello.o程序中,得到可执行目标文件hello。
执行hello程序,操作系统会首先fork一个子进程,然后再通过execve加载hello程序。
Hello的020(From Zero-0 to Zero-0):
shell通过execve在fork产生的子进程中加载执行hello可执行文件,首先删除已经存在于虚拟地址的用户部分的数据结构,并为hello文件创建新的区域结构,然后映射虚拟内存,设置程序计数器,使之指向代码区域的入口点,进入程序入口后,程序开始载入物理内存,而后进入main函数,CPU为hello分配时间片执行逻辑控制流。当程序运行结束后,shell 父进程负责回收 hello 进程,并且内核会从系统中删除hello所有痕迹。
1.2 环境与工具
硬件环境:X64 CPU ,2.50GHz , 8G RAM
软件环境:Windows 10 64位 ,Oracle VM Virtualbox ,Ubuntu 20.04.4 64 位
开发工具:gcc + gedit , Codeblocks , gdb edb
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i ——预处理之后文本文件
hello.s ——编译之后的汇编文件
hello.o ——汇编之后的可重定位目标执行
hello ——链接之后的可执行目标文件
helloo.objdmp ——hello.o 的反汇编代码
helloo.elf ——hello.o 的 ELF 格式
hello.objdmp ——hello 的反汇编代码
hello.elf —— hello的ELF 格式
1.4 本章小结
在第一章,介绍了一个程序如何从源代码,通过预处理器、编译器、汇编器、链接器处理得到可执行目标文件,以及计算机的软硬件如何相互配合使程序运行。列出了大作业用到的环境和工具以及中间结果。好的开始!
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
预处理的作用:
最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase),通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令,其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令) 。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2.2在Ubuntu下预处理的命令
命令:gcc -E hello.c -o hello.i
gcc -E hello.c
图2.2.1 预处理命令
图2.2.2 预处理结果
2.3 Hello的预处理结果解析
由上图二可以看出来,hello.i远远大于hello.c,打开hello.i后,发现hello.c的源代码在最下面,上面插入了很多其他的片段,这些代码便是处理#开头的命令,将其展开并插入到源程序。
2.4 本章小结
在本章中,详细介绍了预处理的概念和作用,演示了预处理在linux下的命令,直观的体会了预处理的效果。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译就是把代码转换成汇编指令的过程。编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
编译的作用:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,该程序包含函数main的定义,其中每条语句都以一种文本格式描述了一条低级语言指令。编译主要作用除了是将文本文件hello.i翻译成文本文件hello.s之外,还在出现语法错误时给出提示信息。执行过程主要从其中四个阶段进行分析:
1.词法分析:词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
2.语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位。
3.代码优化:代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
4.生成目标代码:目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.c -o hello.s(或gcc -S hello.i -o hello.s)
图3.2.1 hello.s内容
如上图,hello.s内便是编译得到的汇编代码。
3.3 Hello的编译结果解析
3.3.1开头部分
3.3.1.1
.file 声明源文件
.text 代码段
.section .rodata 以下是 rodata 节
.align 声明对指令或者数据的存放地址进行对齐的方式
.string: 字符串
.globl 声明一个全局变量
.type 用来指定是函数类型或是对象类型
3.3.2数据
通过hello.c我们可以看出来,变量有int类型的i,和两个参数int类型的argc、字符指针类型的argv[],在hello.s中,右图所示的区域,开辟出新的栈空间后,首先把寄存器%edi和%rsi内的数据存入了内存中,而且下面有将-20(%rbp)的值和立即数4比较,对应于源代码,可以确定-20(%rbp)内储存的是argc的值,即两个参数argc和argv[]的值首先分别保存在寄存器%edi和%rsi中,然后将寄存器中的值存到栈里。第31行将0存入内存-4(%rbp),然后跳到L3(进行比较),可以看出该内存存的是变量i。
图3.3.2.1
3.3.3赋值
在源代码中发现赋值操作只有一处,即i=0,在hello.s中,如上图31行,通过指令movl $0, -4(%rbp)进行赋值,并将其赋值为0。 mov赋值指令有四种,分别为movb、movw、movl、movq,分别对应于1、2、4、8个字节,本指令使用的时movl是由于i的数据类型为整型,占4个字节,故代码后缀为l。在hello.s中,还发现有leaq .LC0(%rip), %rdi这样的运算,这样的运算是将前面运算的结果赋值给%rdi,这是一种比较高效的运算。
3.3.4 算术操作
在源代码中发现只有一处算术运算,即i++,在hello.s中,通过指令
addl $1, -4(%rbp)对储存在-4(%rbp)的值进行加一运算,并存储在-4(%rbp)中。addl的“l”同理于3.3.3。在hello.s中,还发现别的地方也出现了算术操作,比如一开始subq $32,%rsp 是将%rsp的值减去32并存在%rsp中,开辟了一块栈空间。在访问argv[]数组时,也使用了add的运算操作计算地址。
3.3.5 关系操作
在源代码中发现的关系操作,有argc!=4和i<8.在hello.s中,argc!=4通过指令cmpl $4, -20(%rbp) 、je .L2、......实现了不等于,因为关系操作一般都存在于条件句中,条件句通过cmp指令和跳转指令配合实现功能;而小于操作i<8通过指令cmpl $7, -4(%rbp)、jle .L4实现,将当前值与目标值作比较,如果小于等于7就跳转继续循环,否则就跳出循环。
3.3.6数组/指针/结构操作
通过对字符串指针数组argv寻址的方式来读取参数,通过mov以及add的偏移寻址,对argv的数组元素进行访问。对hello.s分析发现,argv数组的起始地址-32(%rbp),在寻找数组中元素的时候,通过指针偏移的方式进行寻找,每个元素之间的指针偏移量为8,故通过对临时寄存器%rax加8来访问argv[1],加16访问argv[2],加24访问argv[3]。
3.3.6控制转移
通过源代码发现,有一处if和一处for循环,在hello.s中,
对应于if(argc!=4),如果相等,就跳转到。L2,如果不相等,就执行退出的操作。
如右图,在L3中将i与7比较,如果小于等于,就跳转到上面继续循环,如果不满足,就执行下面的程序。
图3.3.6.1
3.3.7函数操作
函数操作通过参数传递和函数调用来实现,首先将参数保存在寄存器中,然后使用call指令调用函数。
1、main函数:
参数传递:传入的参数为argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
2、exit函数:
参数传递:传入的参数为1,并将%edi 设置为 1,再执行退出命令
函数调用:if判断条件满足后并在if内容基本执行完成后被调用,对应于汇编 文件中语句call exit@PLT。
3、printf 函数:
参数传递:call puts 时传入了字符串参数首地址;for 循环中 call printf 传入了 argv[1]和 argc[2]的地址。
函数调用:第一次 printf 因为只有一个字符串参数,对应于call puts@PLT;第二次 printf 对应于 call printf@PLT。
4、sleep函数:
参数传递:传入参数atoi(argv[3])
函数调用:for循环下被调用,对应于汇编文件中的call sleep@PLT。
5、getchar
函数调用:在main中被调用,对应于汇编文件中的call gethcar@PLT。
3.4 本章小结
本章介绍了编译的概念和作用,以及linux下编译的命令,查看了编译的结果。在3.3中,从数据、赋值、算术操作、关系操作等角度对Hello的编译结果进行了解析,再次体会了简单的c语句是如何通过汇编代码实现的,并且也体会到了在这个过程中编译器做出的优化。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是将编译后的hello.s文件通过汇编器生成机器语言并存储到hello.o文件的过程,但是生成的hello.o并不是可执行文件,是可重定位目标文件,是不能运行的。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.c -o hello.o(或as hello.s -o hello.o)
图4.2.1 结果
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先生成elf文件:readelf -a hello.o > helloo.elf
图4.3.1 结果
- ELF Header
图4.3.2 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。
2、Section Headers
图4.3.3 节头部表
节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。各个节如下:
. text :已编译程序的机器代码
. rodata:只读数据,比如printf语句中的格式串和开关语句的跳转表
. data:已初始化的全局和静态C变量
. bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
. symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息
.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
. strtab:一个字符串表,其内容包括 .symtab 和 .debug节中的符号表,以及节头部中的节名字。
3、Relocation section
图4.3.4 重定位信息
表述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断该使用什么养的方法计算正确的地址值,通过偏移量等信息计算出正确的地址。
4、Symbol table
图4.3.5 符号表
存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析
首先使用指令objdump -d -r hello.o > helloo.objdmp生成反汇编
图4.4.1 反汇编代码
对比hello.s和helloo.objdump,发现有如下差异:
- 反汇编代码的左侧有相对于main函数的偏移量,而hello.s中没有。
- 反汇编代码的操作数是十六进制的,hello.s的操作数是十进制的
- 反汇编代码的跳转指令后跟的是地址,hello.s的跳转指令后跟的是L2、L3等的段名称
- 反汇编代码的函数调用call的目标地址是当前下一条指令,hello.s文件中,函数调用之后直接跟着函数名称。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数运行时的执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将call指令后的相对地址设置为全0(main函数的地址),然后在.rel.text节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章解释了汇编的概念和作用,以及linux下汇编的指令,在4.3中,生成了elf文件,查看了各节的基本信息。在4.4中,生成了反汇编代码,比较了反汇编代码和hello.s的差别,对可重定位有了更深的认识。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
(以下格式自行编排,编辑时删除)
注意:这儿的链接是指从 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.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
图5.2.1 结果
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
首先使用指令readelf -a hello > hello.elf生成hello.elf
图5.3.1 结果
可执行目标文件的格式类似于可重定位目标文件的格式。
1、ELF Header
图5.3.2 ELF头
ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。。
2、Section Headers
图5.3.3 节头部表
.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到他们最终的运行时内存的地址以外。.init节定义了一个_init的函数,程序的初始化代码会调用他。
3、Program Headers
图5.3.4 程序头
4、Relocation section
图5.3.5 重定位信息
5、Symbol
图5.3.6 符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
在edb中打开hello,通过Data Dump查看hello程序的虚拟地址空间各段信息。 结合5.3中获得的指令与地址的关系表,就可以查看想查看的内容。
以.init为例:
图5.4.1 节头部表中.init的信息
首先,在Section Headers中找到.init的地址为0x401000
图5.4.2 反汇编代码中.init的部分
然后,在hello.objdump中找到该地址,对应于.init,然后我们可以看到十六进制显示的指令。
图5.4.3 edb中.init地址对应的部分
在edb的data dump中也找到相应的地址,进行比较,发现是相同的。
以.text为例:
图5.4.4 节头部表中.text的信息
首先,在Section Headers中找到.text的地址为0x4010f0
然后,在hello.objdump中找到对应的部分,
可以看到十六进制的命令
在edb中的Data Dump也找到相应的地址,比较发现,也是相同的。
图5.4.5 反汇编代码和edb对应.text的部分
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
首先使用指令生成hello.objdump文件。
分析 hello 与 hello.o 的不同:
- 包含的函数不同:在hello.o的反汇编代码中,只有main函数的反汇编代码,但是在hello的反汇编代码中,链接加入了在 hello.c 中用到的库函数,如 exit、printf、sleep、getchar 等函数,如下图所示。
图5.5.1 各函数
- 节不同:hello中增加了.init 和.plt 节。
图5.5.2 .init节和.plt节
3.函数调用: hello 中无 hello.o 中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。 hello.o文件中对于某些地址的定位是不明确的,其地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为 0,并且添加重定位条目。
链接的过程:从5.2的命令可以看出,链接就是把hello.o和其他需要的可重定位文件“合并”。先进行符号解析,再进行重定位生成可执行文件。
重定位的两步:
1、重定位节和符号的定义。在这一步中,链接器将所有相同类型的节合并成同一类型的新聚合节。包括hello.o在内的所有可重定位目标文件中的.data 节全被合并成一个节——输出的可执行目标文件hello中的.data节。然后,连接器将运行时的内存地址赋入新节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和变量都有唯一的运行时内存地址了。
2、重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码 节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。
5.6 hello的执行流程
加载:_dl_start、_dl_init
开始执行:_start、_libc_start_main
执行main:_main、_printf、_exit、_sleep、_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
退出:exit
调用与跳转的各个子程序名如下:
._dl_start, ._dl_init, ._cax_atexit, ._new_exitfn, ._libc_start_main,
._libc_csu_init, ._main, ._printf, ._atoi, ._sleep, ._getchar,._exit
._dl_runtime_resolve_xsave, ._dl_fixup, ._dl_lookup_symbol_x, .exit
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
不管在内存中何处加载一个目标模块,数据段和代码段的距离总是保持不变的,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。编译器利用这个事实,在数据段开始的地方创建了一个全局偏移量表GOT,在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使包含目标的正确的绝对地址。
编译器无法定位由共享库定义的函数,因为共享模块在运行时可以被加载到任意位置,应该为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它,GUN使用延迟绑定来实现,将过程地址的绑定推迟到第一次调用该过程时。
图5.7.1 dl_init前
图5.7.2 dl_init后
5.8 本章小结
在本章中,介绍了链接的概念和作用,介绍了Linux下链接的指令,分析了hello.elf文件的内容,分析了hello的虚拟内存空间,分析了重定位的过程,讨论了hello的执行流程,对hello进行了动态链接分析,对链接有了更深的认识。
(以下格式自行编排,编辑时删除)
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态还包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。这是进程提供给程序的关键抽象,一个独立的逻辑控制流,一个私有的地址空间,使程序好像独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假像都是通过进程的概念提供给我们的。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
shell是一个交互型的应用及程序,代表用户运行其他程序。最早的shell是sh,bash是它的一个变种。shell执行一系列的读、求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序
shell-bash的处理流程:
在lab4中我们体验了一个简易的shell是如何实现的,shell-bash也有相同的处理流程:
- 读取从键盘键入的命令
- 判断命令是否正确,且将命令行的参数改造为系统调用execve()内部处理所要求的形式
- 终端进程调用fork()来创建子进程,自身用wait()来等待子进程的完成。
- 当子进程运行时,调用execve()根据命令的名字指定的文件到目录中查找可行性文件,调入内存并执行这个命令
- 如果命令行末尾有后台命令符号&,终端进程不执行等待系统调用,而是立即发提示符,让用户输入下一条命令;如果命令末尾没有&,则终端进程要一直等待。当子进程完成处理后,向父进程发送信号,此时终端进程被唤醒,做完必要的判别工作后,再发提示符,让用户输入新命令。
6.3 Hello的fork进程创建过程
按照如上步骤,向shell输入./hello,shell会首先对其进行解析,检查是否为内置命令,因为不是内置命令,所以shell会认为hello是一个可执行目标文件。接着终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本。子进程获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同在于他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
在6.3中已经产生了子进程,在子进程中可以通过调用某个驻留在存储器中成为加载器的操作系统代码来运行它。任何Linux程序都可以通过execve()函数来调用加载器。子进程通过execve调用加载器,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页的大小的片,新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,他最终会调用hello的main函数。
6.5 Hello的进程执行
时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:
处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:
上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策叫做调度,是由内核中的调度器处理的。当内核选择了一个新的进程运行时,称为内核调度了这个进程。在内核调度了一个新的进程运行后,他就抢占当前进程,并使用上下文切换的机制将控制转移到新的进程
上下文切换:
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
分析hello的进程调度:
运行hello程序,内核会首先保存上下文,进程在用户状态下运行。
如果没有异常或中断信号的产生,hello将继续正常地执行。如果遇到了异常或者中断信号,那么内核便会根据上述内容执行上下文切换,将控制转换到其它进程。
当执行到exit函数时,以status退出状态来终止进程。
当hello运行到sleep函数时,sleep函数将当前进程挂起一个指定时间,即atoi(argv[3]),发生上下文切换,将控制转移至另一个进程;当请求的时间量到了的时候,再次发生上下文切换,sleep函数返回0,将控制权在转移回main函数。
当hello的循环结束后,调用getchar函数,通过键盘缓冲区传输产生陷阱处理程序请求,并执行上下文切换,将控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发中断信号,切换回 hello进程,执行return,进程终止。
6.6 hello的异常与信号处理
Hello执行过程中会出现4种异常:中断、陷阱、故障、终止。
中断:外部的I/O设备造成的,这里是键盘。
陷阱:系统调用,hello会执行系统调用函数read陷入到内核。
故障:故障由错误情况引起,它可能被故障处理程序修正,hello在被加载器加载后,第一次执行取命令时,便会发生缺页故障,之后由缺页故障处理程序将虚拟页缓存到物理内存。
终止:不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
1、在输入指令后,程序正常执行,每三秒输出一次信息,这时键盘输入ctrl+z,进程会收到SIGTSTP信号,该程序被停止。
图6.6.1 输入ctrl+z
- 输入ps,会看到现在的进程,可以看到hello进程的信息
图6.6.2 输入ps
- 输入jobs,可以看到shell已启动的作业
图6.6.3 输入jobs
4、输入pstree,生成进程树
图6.6.4 输入pstree
5、输入fg,hello进程会收到SIGCONT信号,会被调回前台继续运行
图6.6.5 输入fg
6、停止后,输入kill -9 pid,hello进程收到SIGKILL信号,杀死hello进程
图6.6.6 输入kill -9 PID
7、如果输入ctrl+c,进程会收到SIGINT信号,进程会被终止
图6.6.7 输入ctrl+c
8、乱输入的话,程序可以正常运行,不会被终止或停止
图6.6.8 乱输入
6.7本章小结
(以下格式自行编排,编辑时删除)
本章讲述了hello的进程管理,介绍了进程的概念和作用,介绍了shell-bash的处理流程,hello程序的fork和execve,hello程序的执行和hello程序的异常和信号处理,从hello程序的执行理解了一般程序的执行流程,对于进程、异常和信号有了更深的理解。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 “段标识符:段内偏移量”。hello.o文件中的地址,就是相对偏移地址。
线性地址:逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。hello中的虚拟内存地址。
虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。CPU在寻址的时候,是按照虚拟地址来寻址。CPU通过生成一个虚拟地址(VA)来访问主存,这个虚拟地址被送到内存之前先转换为适当的物理地址。对应于hello程序即为hello可执行文件转化为objdump时所显示的地址。
物理地址:计算机系统贮存被组织成一个由M个连续字节大小的单元组成的数组,每字节都有一个唯一的地址,即物理地址。对于hello程序为当hello运行时通过MMU将虚拟内存地址映射到内存中的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部份组成,段标识符和段内偏移量。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,Base字段表示包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。先检查段选择符中的TI字段,以决定段描述符保存在哪一个描述符表中。由于一个段描述符是8字节长,因此在GDT或LDT内的相对地址是由段选择符的最高13位的值乘以8得到,得到要转换的线性地址即为Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页,页式管理把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
图7.3.1 页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE以便将虚拟地址翻译为物理地址,为了这带来的时间开销,许多系统都在MMU中包括了一个关于PTE的小的缓存,称为翻译后被缓冲器(TLB),TLB的速度快于L1 cache。
TLB通过虚拟地址VPN部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU在读取PTE时会直接通过TLB,如果不命中再从内存中将PTE复制到TLB。
同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表页压缩页表大小。
Core i7使用的是四级页表。在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个第i个VPN都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。
7.5 三级Cache支持下的物理内存访问
三级Cache的核心思想就是每次访问数据的时候都将一个数据块存放到更高一层的存储器中,根据计算的局部性,程序在后面的运行之中有很大的概率再次访问这些数据,高速缓存器就能够提高读取数据的速度。
每级Cache的物理访存大致过程如下:
1、组选择取出虚拟地址的组索引位,将二进制组索引转化为一个无符号整数,找到相应的组
2、 行匹配把虚拟地址的标记为拿去和相应的组中所有行的标记位进行比较,当虚拟地址的标记位和高速缓存行的标记位匹配时,而且高速缓存行的有效位是1,则高速缓存命中。
3、字选择一旦高速缓存命中,我们就知道我们要找的字节在这个块的某个地方。因此块偏移位提供了第一个字节的偏移。把这个字节的内容取出返回给CPU即可
4、不命中如果高速缓存不命中,那么需要从存储层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块, 产生冲突,则采用最近最少使用策略LFU进行替换。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。
当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1、删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3、映射共享区域:如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC):execve函数做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
缺页: 引用虚拟内存中的字,不在物理内存中 (DRAM 缓存不命中)
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存管理的基本方法:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不用,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器类型有如下两种:
显式分配器:要求应用显式地释放任何已分配的块。例如,C语言中的malloc函数申请了一块空间之后需要free函数释放这个块
隐式分配器:应用检测到已分配块不再被程序所使用,就释放这个块。比如Java,ML和Lisp等高级语言中的垃圾收集。
动态内存管理的策略:
记录空闲块:可以选择隐式空闲链表,显示空闲链表,分离的空闲链表和按块大小排序建立平衡树
放置策略:可以选择首次适配,下一次适配,最佳适配
合并策略:可以选择立即合并,延迟合并
7.10本章小结
本章介绍了hello的存储器地址空间、段式管理和页式管理、TLB与四级页表支持下的VA到PA的变换、三级cache支持下的物理内存访问、hello进程fork和execve时的内存映射、缺页故障和缺页中断处理以及动态存储分配管理,尤其对虚拟内存有了更深的认识。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,所有的输入输出都被当成对应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入输出都能以一种统一且一致的方式来执行:打开文件、改变当前文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
1、打开文件——open函数
打开一个已经存在的文件或创建一个新的文件。
2、关闭文件——close函数
关闭一个打开的文件,内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。关闭一个已关闭的描述符会出错。
3、读取文件——read函数
从当前文件位置复制限定字节到指定内存位置,如果返回值为-1,表示一个错误;如果返回值为0,表示EOF;否则返回实际传送的字节数。
4、写入文件——write函数
从指定内存复制限定字节到当前文件位置。
5、改变文件位置——lseek函数
通过调用lseek函数,应用程序能够显示地修改当前文件的位置。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
在该网站中得知,printf函数的函数体为:
图8.3.1 printf函数的具体实现
对该代码进行解析:
1、va_list arg = (va_list)((char*)(&fmt) + 4);
typedef char *va_list这说明它是一个字符指针。
fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。4是指针的大小。所以(char*)(&fmt) + 4) 表示的是...中的第一个参数。
2、i = vsprintf(buf, fmt, arg);
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。返回的是要打印出来的字符串的长度
3、write(buf, i);
write的实现如下:
图8.3.2 write函数的具体实现
给几个寄存器传递了几个参数,然后一个int结束,int表示要调用中断门,通过中断门,来实现特定的系统服务。
INT_VECTOR_SYS_CALL的实现如下:
int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call的实现如下:
图8.3.3 sys_call的实现
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。
getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕.若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
8.5本章小结
本章介绍了Linux中I/O设备的管理方法,UnixI/O接口和函数,并且分析了如何通过UnixI/O函数实现printf和getchar函数。
(第8章1分)
结论
本篇文章介绍了hello从诞生到被回收的过程。一开始,程序猿在编辑器中写好了hello.c;通过预编译,经过预处理器,处理#开头的行,修改原始代码,得到了hello.i;再经过编译,将c语句翻译为汇编语句,得到了hello.s;再经过汇编,将hello.s翻译为机器语言,得到可重定位文件hello.o;再经过链接,链接器将hello.o和用到的库链接生成可执行文件hello,现在,hello已经可以运行了。
在shell中输入./hello 120L021009 zhangziqi 3,运行hello;shell父进程会先使用fork创建一个子进程;然后shell调用execve,启动加载器,映射虚拟内存,载入物理内存,进入main函数;然后CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流;当CPU访问hello时,请求一个虚拟地址,MMU把虚拟地址转换成物理地址并通过三级cache访存;在调用printf函数时,会调用malloc申请堆中的内存;在hello运行的过程中,可能会遇到各种信号,针对每种信号,shell都会进行相应的处理;当hello运行结束或者遇到ctrl+c,hello就结束了一生,shell父进程回收子进程,内核删除和他有关的数据结构,什么都没留下。
通过本次大作业,研究hello的一生,将本课程的知识融会贯通,在探索中解答了之前的很多疑惑,对计算机系统有了新的认识,也让我对我写的程序和我的电脑有了更深的感情(羞羞)。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.i ——预处理之后文本文件
hello.s ——编译之后的汇编文件
hello.o ——汇编之后的可重定位目标执行
hello ——链接之后的可执行目标文件
helloo.objdmp ——hello.o 的反汇编代码
helloo.elf ——hello.o 的 ELF 格式
hello.objdmp ——hello 的反汇编代码
hello.elf —— hello的ELF 格式
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 兰德尔 E. 布莱恩特. 大卫R. 奥哈拉伦 《深入理解计算机系统》(老师PPT)
- https://blog.csdn.net/flurry_rain/article/details/122949875
- https://baike.baidu.com/item/%E9%A2%84%E5%A4%84%E7%90%86/7833652
- https://baike.baidu.com/item/%E7%BC%96%E8%AF%91/1258343?fr=aladdin
- https://baike.baidu.com/item/%E8%BF%9B%E7%A8%8B/382503
- Shell执行流程详细解释 - 百度文库
- https://www.csdn.net/tags/MtjaIgysNDMzMzctYmxvZwO0O0OO0O0O.html
- https://baijiahao.baidu.com/s?id=1663915740492408592&wfr=spider&for=pc
(参考文献0分,缺失 -1分)