计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机专业
学 号 120L020228
班 级 200300
学 生 陶喆
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本文通过一个给定的程序hello.c,通过hello.c在Linux环境中被编译为可执行文件的步骤,逐步介绍在程序员视角下的计算机系统相关知识。结合《深入理解计算机系统》书中的内容与课上老师的讲授,在Ubuntu系统下对hello程序的整个生命周期进行了研究,通过对hello.c程序的深入研究,得以把计算机系统整个的体系串联在一起,真正做到了学以致用,融会贯通。
例如汇编代码的产生,CPU的架构以及指令集设计,关于代码优化技术与多级缓存结构的结合等等,还有关于进程的基本概念和抽象,关于虚拟内存的概念和机制等等内容。
关键词:Ubuntu;汇编代码;进程;文件操作;CPU机制;虚拟内存机制
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
1.1.1 P2P的过程阐述:
P2P,全程Program to Process,其中的Program是指在编辑器中写下的hello.c文件。
而process则是指写下的程序经过预处理编译等操作,变为可执行文件的过程,具体过程为:cpp预处理器将hello.c文件中的#开头的命令进行预处理,插入对应的系统头文件中的内容,得到hello.i文件;然后编译器ccl将其翻译为汇编语言文件hello.s然后汇编器as负责将汇编语言翻译为机器指令代码,得到hello.o文件;最后,在链接阶段,链接器ld将hello.o和其它用到的预编译好的目标文件合并到一起并且完成引用的重定位工作,就得到了一个可执行文件hello。
在Ubuntu的shell程序中我们可以输入./hello执行hello程序,此时shell程序会调用fork函数创建子进程,并且在子进程中加载该程序,由此,hello.c文件就成为了一个进程。
1.1.2 O2O的过程阐述
在子进程调用execve函数加载hello执行后,操作系统OS为其映射虚拟内存至物理内存,CPU也为运行此进程分配时间,通过cache,TLB等机制加速访问时间,程序结束后,shell父进程通过相应的信号机制得到子进程结束的消息并且回收进程,同时内核将控制权转移回shell,子进程的相关数据被清除
1.2 环境与工具
硬件环境:
I7-12700 CPU 16g内存 983.58G硬盘 GTX1650显卡
软件环境:
VirtualBox 6.1.18 Ubuntu20.04.2LTS
开发工具:
VIM8.1.2269 Visual Studio Code1.54.3
1.3 中间结果
文件名 | 文件作用 |
hello.i | 预处理后的ASCII码文件 |
hello.s | 编译之后得到的文件 |
hello.o | 汇编之后得到的目标文件 |
hello | 链接之后得到的可执行文件 |
hello_disassemble.s | 目标文件hello.o文件反汇编得到的结果 |
hello_objdump.s | 可执行文件hello反汇编之后的结果 |
helloELF.txt | 目标文件hello.o的ELF格式 |
helloELF1.txt | 可执行文件hello的ELF格式 |
表 1 各个中间结果
1.4 本章小结
本章介绍hello.c的P2P和O2O机制,并举了几个例子加以说明然后是写出来大作业的软硬件环境,最后按要求列举出所有的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1 预处理的概念:
预处理是指程序在编译一个c文件之前,会调用cpp将所有#起始的行解释为预处理指令,并且对源代码进行相应的转换,包括加入包含的源文件,处理宏定义或者处理条件语句等等,预处理阶段也会删除注释等等。
2.1.2预处理的作用:
a.处理头文件,cpp预处理器可以将头文件插入对应指令的位置,从而修改源文件。
b.用实际值或者是代码去替换#define定义的宏。
c.#if等先关语句代表需要判断条件编译等情况。
2.2在Ubuntu下预处理的命令
命令如下:
图 1 预处理命令
生成文件如下:
图 2 预处理生成文件
图 3 预处理文件内容
2.3 Hello的预处理结果解析
我们可以发现,经过预处理之后,原本23行的代码被扩展为3060行,并且原本的注释也被删除,原本的代码被放置在3046-3060行,没有太大变化。
图 4 预处理中的stdio内容
其中,stdio.h文件被插入在13-728行,unistd.h文件被插入在731-1966行,stdlib.h文件被插入在1970-3041行。
此外,我们发现所有调用的头文件地址如下:
图 5 预处理中头文件地址
在对应的目录下:
图 6 各头文件
打开其中的stdio.h文件:
图 7 stdio.h文件内容
我们可以发现,在stdio.h文件中,依然存在着对其它头文件的调用,而这些头文件的调用也可以在最终的hello.i文件中被找到(如下图),因此,我们得出结论,cpp在处理针对头文件的调用时,是进行递归调用的,知道调用的头文件中再无别的头文件引用。
2.4 本章小结
本章介绍了如何进行预处理,如何在Linux进行预处理,通过对hello.i文件的分析我们知道了预处理时,预处理器具体的工作方式:插入头文件,扩展宏定义,删除注释等等。其中,插入头文件的过程采取递归的方式,直到再也没有对于其它头文件的引用为止。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译程序所要作得工作就是通过词法分析、语法分析和语义分析等等,在确认所有的指令都符合语法规则之后,将其翻译成等价汇编代码。 在本次大作业中,我们需要让编译器将预处理之后得到的hello.i翻译成hello.s。
3.1.2编译的作用
编译主要是为了将高级语言(C,C++等)翻译为机器语言一一对应的汇编语言,具体步骤如下:
语法分析:首先,编译器会对程序代码进行分析和分割,形成一些C语言所允许的记号,同时会检查代码中是否生成了不规范记号,如果存在不规范表达就生成错误提示。然后分析语义并且生成符号表,将不符合语法规则的地方标出并且生成错误提示。
代码优化:将中间表示形式进行分析并转换为功能等价但是运行时间更短或占用资源更少的等价中间代码。
3.2 在Ubuntu下编译的命令
输入指令为:
图 8 编译命令
生成文件为:
图 9 编译生成文件展示
3.3 Hello的编译结果解析
(以下格式自行编排,编辑时删除)
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.3.1常量的解析
我们发现,hello.c文件中的字符串常量有两处:
在hello.s文件中我们可以找到常量字符串对应的存储位置:
图 10 hello.s中常量字符串的存储
汉字以utf-8的格式进行编码,每个汉字3字节,这一点在字符串的长度上也得到了体现(其中的中文感叹号也使用了utf-8编码,占3字节)
整形常量
整形常量在hello.c文件中是以代码中的数字的形式存在,例如:
这些常量被嵌入了汇编代码中:
图 11 常量嵌入汇编代码
3.3.2变量的解析
局部变量
在hello.c中出现的局部变量主要是int i
在汇编代码中可以发现其被分配在运行时栈上
图 12 hello.s中的局部变量
3.3.3算数操作
在for循环中,int i进行了++操作
在对应的汇编代码中,是通过add指令完成的
图 13 算数操作的处理
3.3.4控制转移
Hello.c中的控制转移有对于表达式i<8的值的判断,通过cmpl指令和jle指令组合完成对于for循环体是否循环的判断,从而完成循环体的跳转。
图 14 控制转移
此外,还有对于表达式argc!=4的判断,通过cmpl和je组合判断完成if语句的跳转。
3.3.6数组/指针/结构操作
在main函数中传递的参数出现了指针数组
每一个数组的元素都是一个char*类型的指针,可以指向一个字符串,而具体来说argv[0]指向文件名,argv[1] argv[2]分别指向命令行输入的第一个和第二个参数,在汇编代码中这个指针数组的首地址被存放在rsi中,也就是存放main函数的第二个参数的寄存器。
图 15 数组的存储
对于指针数组argv,其每一个元素的大小都是8字节,而且数组中的元素应是连续存放的,因此地址偏移量也应该是8的倍数
从此处的代码我们可以验证这一点。
3.3.7函数操作
Main函数
首先我们可以查看main函数的汇编代码,发现其两个参数,argc和argv分别被存放在寄存器rdi和rsi中,这与我们的课本知识吻合
图 16 arge与argv的存储
继续查看后面的汇编代码,我们发现对于argv的操作,其指针数组的基地址通过寄存器存放,我们将基地址加上8的倍数的偏移量就可以访问指针数组的别的成员指针,而为了访问每一个指针成员指向的字符串,我们需要访问每一个指针指向的内存空间,也就是说,每个指针成员指向的字符串是存放在内存中的
图 17 字符串的存储位置
Main函数不是由我们编写的函数调用的,是由系统函数调用,main函数可以调用别的函数,但是需要遵从寄存器保护的规则和参数传递的规则等等。
Main函数的返回值是int类型的,存储在寄存器rax中,在需要返回时,先将rax的值设置为0,然后返回即可,对应于hello.c文件中的return 0.
结合之前的代码我们可以发现,rax虽然被用作传递返回值,但是在此之前都可以被main函数自由使用。
Printf函数
Hello.c文件中,printf函数写了两次,运行时调用一次或者8次
第一次对应的汇编代码部分如下:
可以发现,第一次实际上是调用了puts函数,这是因为此次调用printf函数不需要传递额外的参数,只需要将内存中存储的字符串复制到屏幕上即可,因此编译器做了一点等价的替换。
第二次的对应汇编代码如下:
图 18 printf对应的汇编代码
可以看到总共传递了三个参数,所以无法使用puts函数替换,第一个参数是主体字符串的首地址,被存放在rdi中,第二、第三个参数分别是替换的字符串的首地址,通过寄存器rsi和rdx传递,传递规则与课本中讲述的一致。
Exit函数
Exit函数的源代码如下:
对应的汇编代码如下:
图 19 exit对应的汇编代码
我们可以看到函数传递的参数存放在edi中,对应了源代码中的exit(1),说明传递的整数值直接作为函数退出的状态值。
Sleep函数
Sleep函数的源代码如下:
对应的汇编代码如下:
图 20 sleep对应的汇编代码
可以看到sleep函数通过rdi传递了一个参数作为休眠时间的控制,需要注意的是,全局变量并非存储在运行时栈上,所以调用方式也有所不同
Atoi函数:
atoi函数汇编代码如下:
图 21 atoi对应的汇编代码
Getchar函数
Getchar函数的汇编代码如下:
图 22 getchar对应的汇编代码
由于此函数没有参数,因此不需要通过寄存器进行参数传递,。
3.4 本章小结
本章介绍了编译的概念,作用,以及过程。编译是将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备的过程。同时,本章以hello.s文件为例,介绍了编译器如何处理各个数据类型以及各类操作,验证了大部分数据、操作在汇编代码中的实现。我对于编译阶段做的事情有了更多了解。
第4章 汇编
4.1 汇编的概念与作用
- 汇编的概念
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 目标文件中的过程
- 汇编的作用
将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o文件中
4.2 在Ubuntu下汇编的命令
命令如下:
图 23 汇编生成.o文件的命令
生成的文件:
图 24 生成的文件展示
(以下格式自行编排,编辑时删除)
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先,在shell中输入readelf -a hello.o > helloELF.txt 指令获得 hello.o 文件的 ELF 格式:
命令如下:
图 25 生成ELF文件
生成文件展示:
图 26 生成的文件展示
其结构分析如下:
- ELF 头(ELF Header):
以 16字节序列 Magic 开始,其描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图 27 ELF头的情况
- 节头:
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
图 28 节头的情况
- 重定位节.rela.text
一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
在这里,8 条重定位信息分别是对.L0(第一个 printf 中的字符串)、puts 函数、exit 函数、.L1(第二个 printf 中的字符串)、printf 函数、sleepsecs、sleep 函数、getchar 函数进行重定位声明。
.rela.text节包含如下信息:
偏移量 | 代表需要进行重定向的代码在.text或.data节中的偏移位置 |
信息 | 包括symbol和type两部分,其中symbol占前半部分,type占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型 |
类型 | 重定位到的目标的类型 |
加数 | 计算重定位位置的辅助信息 |
表 2 节中的信息
图 29 .rela.text节
- 重定位节.rela.eh_frame
图 30 .rela.eh_frame节
- 符号表Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图 31 符号表的情况
4.4 Hello.o的结果解析
(以下格式自行编排,编辑时删除)
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
使用objdump -d -r hello.o > hello_disassemle.s 分析hello.o的反汇编,并与第3章的 hello.s文件进行对照分析。
命令如下:
图 32 反汇编命令
生成文件展示:
图 33 反汇编生成文件展示
通过对比hello.asm与hello.s可知,两者在如下地方存在差异:
- 分支转移:
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。而在反汇编得到的hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差。
图 34 分支转移
- 函数调用:
在hello.s文件中,call之后直接跟着函数名称,而在反汇编得到的hello.asm中,call 的目标地址是当前指令的下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器作用才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其 call 指令后的相对地址设置为全0(此时,目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接进一步确定。
图 35 函数调用
4.5 本章小结
本章介绍了汇编的概念与作用,在Ubuntu下通过实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在hello.asm中)与hello.s中代码,
了解了汇编语言与机器语言的异同之处。
(以下格式自行编排,编辑时删除)
(第4章1分)
第5章 链接
5.1 链接的概念与作用
- 链接的概念
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。
- 链接的作用
提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。
5.2 在Ubuntu下链接的命令
命令如下:
图 36 链接的命令
生成文件展示:
图 37 链接生成的文件展示
(以下格式自行编排,编辑时删除)
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
输入命令:
图 38 readelf查看hello的命令
生成文件展示:
图 39 生成的helloELF文件展示
打开helloELF1.txt,分析hello的ELF格式如下:
- ELF 头(ELF Header)
helloELF1.txt中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以 描述了生成该文件的系统的字的大小和字节顺序的16字节序列 Magic 开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与helloELF.txt相比较,helloELF1.txt中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图 40 ELF头的情况
- 节头
helloELF1.txt中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与helloELF.txt相比,其在链接之后的内容更加丰富详细(此处仅截取部分展示)。
图 41 节头的情况
- 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图 42 程序头的部分
- Dynamic section
图 43 Dynamic Section
- Symbol table
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图 44 Symbol table
5.4 hello的虚拟地址空间
打开edb,通过 data dump 查看加载到虚拟地址的程序代码。
图 45 加载到虚拟地址的程序代码
根据计算机系统的特性,程序被载入至地址0x400000~0x401000中。在该地址范围内,每个节的地址都与前一节中节对应的 Address 相同。根据edb查看的结果,在地址空间0x400000~0x400fff中存放着与地址空间0x400000~0x401000相同的程序,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
命令如下:
图 46 反汇编命令
生成文件展示:
图 47 生成的文件展示
- 链接后函数数量增加。链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图 48 链接后的函数
- 函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
图 49 call指令的参数
- 跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
图 50 跳转指令的参数
5.6 hello的执行流程
程序名称 | 程序地址 |
<ld-2.31.so> | 00007fa2 4b62e100 |
<hello!_start> | 00000000 004010f0 |
<libc-2.31.so!_libc_start_main> | 00007fe9 63a66fc0 |
<libc-2.27.so!__cxa_atexit> | 00007fe9 63a89e10 |
<libc-2.31.so> | 00007fa2 4b46cbb0 |
上一函数执行完会返回上上函数,并再次返回到main函数 | -- |
<hello!_libc_csu_init> | 00000000 00401270 |
<hello_init> | 00000000 00401000 |
<hello!_libc_csu_init> | 00000000 004012a1 |
<hello> | 00000000 004011d0 |
<Libc-2.31.so!_setjmp> | 00007fe9 63a85cb0 |
Hello!main | 00000000 004011d6 |
hello | 00000000 004010a0 |
*hello!printf@plt | -- |
*hello!sleep@plt | -- |
*hello!atoi@plt | -- |
表 3 执行流程表
401000 <_init>
401020 <.plt>
401090 <puts@plt>
4010a0 <printf@plt>
4010b0 <getchar@plt>
4010c0 <atoi@plt>
4010d0 <exit@plt>
4010e0 <sleep@plt>
4010f0 <_start>
401120 <_dl_relocate_static_pie>
401130 <deregister_tm_clones>
4011a0 <__do_global_dtors_aux>
4011d0 <frame_dummy>
4011d6 <main>
401270 <__libc_csu_init>
4012e0 <__libc_csu_fini>
4012e8 <_fini>
5.7 Hello的动态链接分析
我们可以在ELF文件中发现如下条目:
图 51 动态链接条目
如果有一个变量,我们利用代码段和数据段的相对位置不变的原则计算正确地址;而如果我们有一个库函数的话,就需要通过plt、got的合作,在plt中初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got。这样,在我们的下一次再调用plt时,指向的就是正确的内存地址。
5.8 本章小结
本章中介绍了链接的概念与作用、并得到了链接后的hello可执行文件的ELF格式文本helloELF1.txt,据此分析了helloELF.txt与helloELF1.txt的异同;之后,根据反汇编文件hello_objdump.txt与hello_disassemle.s的比较,加深了对重定位与动态链接的理解。
(以下格式自行编排,编辑时删除)
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
- 进程的概念
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。
- 进程的作用
给应用程序提供两个关键抽象:
- 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
- 一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
(以下格式自行编排,编辑时删除)
6.2 简述壳Shell-bash的作用与处理流程
Shell 的作用:
Shell 是一个用C语言编写的交互型应用程序,代表用户运行其他程序。Shell 应用程序提供了一个界面,用户可以通过这个界面进行系统的基本操作,访问操作系统内核的服务。
Shell的处理流程大致如下:
- 从Shell终端读入输入的命令。
- 切分输入字符串,获得并识别所有的参数
- 若输入参数为内置命令,则立即执行
- 若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
- 若输入参数非法,则返回错误信息
- 处理完当前参数后继续处理下一参数,直到处理完毕
(以下格式自行编排,编辑时删除)
6.3 Hello的fork进程创建过程
命令如下:
fork进程的创建过程如下:首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
6.4 Hello的execve过程
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。execve 函数从不返回,它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置PC指向_start 地址。_start函数最终调用hello中的 main 函数,这样,便完成了在子进程中的加载。
(以下格式自行编排,编辑时删除)
6.5 Hello的进程执行
在程序运行时,Shell为hello fork了一个子进程,这个子进程与Shell有独立的逻辑控制流。在hello的运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。直到当hello调用sleep函数时,为了最大化利用处理器资源,sleep函数会向内核发送请求将hello挂起,并进行上下文切换,进入内核模式切换到其他进程,切换回用户模式运行抢占的进程。与此同时,将 hello 进程从运行队列加入等待队列,由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数返回,触发一个中断,使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时 hello 进程就可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
- 在程序正常运行时,打印8次提示信息,以输入回车为标志结束程序,并回收进程。
图 52 程序正常执行
- 在程序运行时按回车,会多打印几处空行,程序可以正常结束。
图 53 程序运行时按下回车
- 按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
图 54 程序运行时按Ctrl + C
- 按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
ps
图 55 程序运行时按Ctrl + Z
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
图 56 用ps命令查看挂起进程
在Shell中输入pstree命令,可以将所有进程以树状图显示(此处仅展示部分):
图 57 用pstree命令查看所有进程
输入kill命令,则可以杀死指定(进程组的)进程:
图 58 kill命令杀死指定进程
输入fg 1则命令将hello进程再次调到前台执行,可以发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
图 59 用fg命令将进程调回前台
- 不停乱按
在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。
图 60 不停乱按的情况
6.7本章小结
本章介绍了进程的概念与作用,以及Shell-bash的基本概念。针对进程,在这一章中根据hello可执行文件的具体示例研究了fork,execve函数的原理与执行过程,并给出了hello带参执行情况下各种异常与信号处理的结果。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
- 逻辑地址
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。具体而言,其为hello.asm中的相对偏移地址。
- 线性地址
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。具体以hello而言,线性地址标志着 hello 应在内存上哪些具体数据块上运行。
- 虚拟地址
根据CSAPP教材,虚拟地址即为上述线性地址。
- 物理地址
CPU通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
在段寄存器中,存放着段选择符,可以通过段选择符来得到对应段首地址。段选择符的结构如下:
图 61 段选择符的情况
其包含三部分:索引,TI,RPL
索引:用来确定当前使用的段描述符在描述符表中的位置;
TI:根据TI的值判断选择全局描述符表(TI=0,GDT)或选择局部描述符表(TI=1,LDT);
RPL:判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态;
通过一个索引,可以定位到段描述符,进而通过段描述符得到段基址。段基址与偏移量结合就得到了线性地址,虚拟地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如下图所示。
图 62 Hello的线性地址到物理地址的变换-页式管理
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
因为三级Cache的工作原理基本相同,所以在这里以L1 Cache为例,介绍三级Cache支持下的物理内存访问。
L1 Cache的基本参数如下:
- 8路64组相连
- 块大小64字节
由L1 Cache的基本参数,可以分析知:
块大小64字节→需要6位二进制索引→块偏移6位
共64组→需要6位二进制索引→组索引6位
余下标记位→需要PPN+PPO-6-6=40位
故L1 Cache可被划分如下(从左到右):
CT(40bit)CI(6bit)CO(6bit)
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO取出相应的数据后返回。
若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用LFU策略进行替换。
7.5 三级Cache支持下的物理内存访问
因为三级Cache的工作原理基本相同,所以在这里以L1 Cache为例,介绍三级Cache支持下的物理内存访问。
L1 Cache的基本参数如下:
- 8路64组相连
- 块大小64字节
由L1 Cache的基本参数,可以分析知:
块大小64字节→需要6位二进制索引→块偏移6位
共64组→需要6位二进制索引→组索引6位
余下标记位→需要PPN+PPO-6-6=40位
故L1 Cache可被划分如下(从左到右):
CT(40bit)CI(6bit)CO(6bit)
在7.4中我们已经由虚拟地址VA转换得到了物理地址PA,首先使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO取出相应的数据后返回。
若没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中请求数据(请求顺序为L2 Cache→L3 Cache→主存,若仍不命中才继续向下一级请求)。查询到数据之后,需要对数据进行读入,一种简单的放置策略如下:若映射到的组内有空闲块,则直接放置在空闲块中,若当前组内没有空闲块,则产生冲突(evict),采用LFU策略进行替换。
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域
删除当前进程hello虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域
为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。其中,代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域
若hello程序与共享对象或目标(如标准C库libc.so)链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器
最后,execve设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
发生一个缺页异常后,控制会转移到内核的缺页处理程序。判断虚拟地址是否合法,若不合法,则产生一个段错误,然后终止这个进程。
若操作合法,则缺页处理程序从物理内存中确定一个牺牲页,若该牺牲页被修改过,则将它换出到磁盘,换入新的页面并更新页表。当缺页处理程序返回时,CPU 再次执行引起缺页的指令,将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
7.9动态存储分配管理
动态内存管理的基本方法与策略介绍如下:
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
具体而言,分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
下面介绍动态存储分配管理中较为重要的概念:
- 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
图 63 隐式链表的结构
- 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
图 64 显式链表的结构
- 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
- 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行:打开文件、改变当前的文件位置、读写文件、关闭文件。
8.2 简述Unix IO接口及其函数
- Unix I/O接口:
- 打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件
一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件
内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- Unix I/O函数:
- int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
- int close(fd)
fd是需要关闭的文件的描述符,close返回操作结果。
- ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
- ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
查看printf生成的汇编代码,可以发现对于printf的参数如果是以'\n'结束的纯字符串,printf会被优化为puts函数,而字符串的结尾'\n'符号被消除。除此之外,都会正常生成call printf指令。
图 65 printf对应的汇编代码
静态链接时,链接器将C语言的运行库(CRT)链接到可执行文件,其中crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o便是这五个核心的文件,它们按照上述命令显示的顺序分居在用户目标文件和库文件的两侧。由于我们使用了库函数puts,因此需要库文件libc.a,而libc.a与libgcc.a和libgcc_eh.a有相互依赖关系,因此需要使用-start-group和-end-group将它们包含起来。我们可以确认puts的实现代码是在库文件libc.a内的,并且知道它是以二进制的形式存储在文件ioputs.o内的。
阅读printf源码,确认实现流程:
- puts 调用 _IO_new_file_xsputn
图 66 puts的源码
- _IO_new_file_xsputn 调用 _IO_new_file_overflow
- _IO_new_file_overflow 调用 _IO_new_do_write
图 67 _IO_new_file_overflow源码
- _IO_new_do_write 调用 new_do_write
- new_do_write调用 _IO_new_file_write
- _IO_new_file_write调用 write_nocancel
- write_nocancel 调用 linux-gate.so::__kernel_vsyscall
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar调用系统函数read,发送一个中断信号,内核抢占这个进程,用户输入字符串,键入回车后(字符串和回车都保存在缓冲区内),再次发送信号,内核重新调度这个进程,getchar从缓冲区读入字符。
8.5本章小结
本章主要介绍了linux的IO设备管理方法和及其接口和函数,对printf函数和getchar函数的底层实现有了基本了解。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序的一生经历了如下过程:
- 预处理
将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理;
- 编译
通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件 hello.s;
- 汇编
将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o 目标文件中;
- 链接
通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello;
- 加载运行
打开Shell,在其中键入 ./hello 1190200208 李旻翀,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行;
- 执行指令
在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流;
- 访存
内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据;
- 动态申请内存
printf 会调用malloc 向动态内存分配器申请堆中的内存;
- 信号处理
进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作;
- 终止并被回收
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
通过跟踪hello.c的编译以及运行过程,我对于计算机中程序的运行有了更加清晰的认识,同时我也知道了,编译链接等过程都有着各种实现的方法,可以使用gcc,ld,as等。同时也对shell的运行机制有了更深刻的认识。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 文件作用 |
hello.i | 预处理后的ASCII码文件 |
hello.s | 编译之后得到的文件 |
hello.o | 汇编之后得到的目标文件 |
hello | 链接之后得到的可执行文件 |
hello_disassemble.s | 目标文件hello.o文件反汇编得到的结果 |
hello_objdump.s | 可执行文件hello反汇编之后的结果 |
helloELF.txt | 目标文件hello.o的ELF格式 |
helloELF1.txt | 可执行文件hello的ELF格式 |
表 4 中间产物文件名
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] Pianistx.printf 函数实现的深入剖析[EB/OL].2013[2021-6-9].
[转]printf 函数实现的深入剖析 - Pianistx - 博客园.
[3] 梦想之家xiao_chen.ELF文件头更详细结构[EB/OL].2017[2021-6-10].
https://blog.youkuaiyun.com/qq_32014215/article/details/76618649.
[4] Florian.printf背后的故事[EB/OL].2014[2021-6-10].
https://www.cnblogs.com/fanzhidongyzby/p/3519838.html.
(参考文献0分,缺失 -1分)