计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 信息安全
学 号 2022112024
班 级 2203201
学 生 郭冠男
指 导 教 师 史先俊
计算机科学与技术学院
2024年5月
摘 要
本文将以一个简单的程序hello.c为例,从计算机系统的视角,介绍hello.c是如何从程序到进程的生命周期全过程的分析,并逐步展示了linux下一般程序的P2P过程和020过程,揭示了一个较为完整的x86-64计算机系统的主要工作机制以及沟通顶层程序员与底层机器的原理。在运行hello程序的同时,计算机也在运行其他程序。那么计算机如何协调这些同时运行,这就需要理解进程这一概念。
关键词:计算机系统;进程;预处理;编译;汇编;存储管理;I/O管理
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P(program to process): 编写好一个hello.c的C语言程序,它将作为程序一个文本存储。
首先是要对源程序进行预处理,针对预处理指令和特殊字符进行处理,然后编译器对预处理生成的文件进行编译,将高级语言语法编写的代码翻译成指令形式的汇编语言,汇编器再将其翻译成机器语言指令,并打包成一个二进制文件,链接器将代码片段和数据片段链接在一起,再生成可执行目标文件。
在计算机系统中,hello就有了自己的进程,在这个进程中hello便可以运行。
020( From Zero to Zero):shell为hello创建进程并加载hello的可执行文件,为其提供了虚拟地址空间等进程上下文,实现了hello的从无到有的过程。
shell为此子进程映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流,当程序执行完,同时为该程序的开辟的内存空间也会被回收,此时又变为0。
回收hello在运行时会经历诸多的异常与信号,以及对存储器的访问也会涉及诸多机制,以及通过中断和IO端口与外设交互。然后hello正常退出或收到信号后终止,都会使得操作系统结束hello进程,释放其占用的一切资源,返回shell,这便是hello的从无到有再到无的过程
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;4GHz;16G RAM;512G SSD Disk
软件环境:Windows 11 64位;Vmware 17;Ubuntu 20.04
开发工具:Visual Studio 2022;gcc;vim;edb;readelf等
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello | 可执行文件 |
objdump -d -r hello(hello1.asm) | 得到hello可执行文件的反汇编文本 |
readelf -a hello(hello1.elf) | 用readelf读取hello得到的ELF格式 |
1.4 本章小结
本章主要介绍了hello.c程序P2P,020的过程。列出了本次实验所需的环境和工具以及过程中所生成的中间结果,为后续对hello的进程进行前置保证。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。预处理是为编译做的准备工作,能够对源程序.c文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include、条件编译#ifdef等,最后将修改之后的文本进行保存,生成.i文件,预处理结束。
预处理的作用:主要是对宏定义、文件包含、条件编译进行不同的处理。
预处理过程将#include后继的头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理。预处理过程是对源代码进行相应的分割、处理和替换。这种预处理的机制提高了源文件的灵活性,能适应不同的计算机和操作系统;而且通过预处理指令,可以使用已经封装好的库函数,极大地提高了编程效率。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
图2.21 虚拟机运行命令
生成了hello.i文件,下面对hello.i文件进行分析。
2.3 Hello的预处理结果解析
图2.31hello.i文件内容
可以发现hello.i文件中的代码量相对于源程序hello.c增加了很多。这种就是因为执行的预处理命令将头文件的程序、宏变量、特殊符号等插入到hello.c中,最后生成了hello.i文件。
图2.32部分hello.i内容
其中3048-3061行为原来hello.c的代码部分,与源代码基本一致。
2.4 本章小结
本章论述了预处理的概念和作用,预处理就是对预处理指令进行处理,生成了hello.i文件,然后是对预处理的结果hello.i进行内容分析,得到结果。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
编译的作用:当C语言源程序经过预处理后,编译器会将原始的C语言语法格式的代码转换为机器指令,将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备。
注意:这儿的编译是指从.i到.s即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s -fno-PIC -no-pie -m64
图3.21虚拟机编译命令
3.3 Hello的编译结果解析
3.3.1文件结构分析
图3.31hello.s文件内容
文件名称 | 分析含义 |
.file | 源文件(指从hello.i汇编得来) |
.text | 代码段 |
.rodata | 只读变量 |
.align | 对齐方式 |
.string | 声明了两个字符串分别为.LC0; .LC1 |
.globl | 全局变量 |
.type | 声明一个符号是数据类型还是函数类型 |
3.3.2数据
1.常量
图3.32hello.c代码
图3.33hello.s内容分析
(1)观察发现,在if语句中有if(argc!=5),此处的常量5被储存在.text部分,被编译成图中cmpl $5部分。
图3.34hello.s内容分析
(2)源代码中的for(i=0;i<10;i++)储存在.L3部分,其中汇编代码将i<10转化成了i<=9。
图3.35hello.s字符串部分
(3)两次printf( )中的字符串都存储在.rodata节中,分别为.LC0 .LC1。
2.变量
图3.36变量
(1)全局变量:只有一个全局(global)为main函数。
图3.37局部变量
(2)局部变量:如int i被赋值为i=0,被编译成movl指令,并存在栈中%rbp-4的位置上。而main函数的两个参数,argc在寄存器%rdi中存入栈中-20(%rbp)的位置,argv[] 在寄存器%rsi中存入栈中-32(%rbp)。
3.3.3算术操作
图3.38i++操作
将指针存储的数据加上立即数1之后再存入到指针,对应源代码中的i++。
3.3.4关系操作和控制跳转操作
图3.38跳转操作
利用cmp指令将argv与5进行数值比较,等于时,通过je跳转.L2,不等于继续执行比较指令。
图3.39.L3相关内容
初始化i为0,传入-4(%rbp),无条件执行jmp跳转到.L3。
图3.310.L4相关内容
通过cmpl指令将i与常量7进行比较,如果小于等于,就执行jle跳入循环体.L4,其中jle用于判断cmpl产生的条件码,若后一个操作数的值小于等于前一个则跳转到指定地址。
3.3.4数组操作
图3.311数组操作
-32(%rbp)是argv数组所在地址,argv数组存着char *,一个8字节,所以addq $24, %rax。之后%rax存着argv[]对应所在地址,然后执行三次,将数组都传入寄存器。
3.3.5函数操作
(1)main函数:
参数传递:传入参数argc和argv[],分别用寄存器存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
图3.312main函数
可以发现argc 存储在%edi中,argv存储在%rsi中。
(2)printf函数:
参数传递:使用了两次。第一次将edi设置为待传递的字符串"用法: Hello 学号 姓名 秒数!\n"的首地址。第二次利用寄存器进行数组的传递,for循环中call printf时传入了 argv[1]、argv[2]和argv[3]的地址。
函数调用:if判断满足条件后调用,与for循环中被调用。
图3.313函数调用
(3)exit函数:
参数传递:传入的参数为1,再执行退出命令。
函数调用:if判断条件满足后被调用。
图3.314exit函数
(4)sleep函数:
参数传递:传入参数atoi(argv[4])
函数调用:for循环下被调用,call sleep
图3.315sleep函数
(5)getchar函数:
函数调用:在main中被调用,call getchar
图3.316getchar函数
3.4 本章小结
本章介绍了编译的概念和作用,编译是将文本文件翻译成汇编语言程序,为后续将其转化为二进制机器码做准备的过程。
以hello.s文件为例,介绍了编译器如何处理各个数据类型以及各类操作,验证了大部分数据、操作在汇编代码中的实现,直观地看到了编译的结果,并将起与C源程序的代码结合起来,理解汇编语言在整个程序当中发挥的作用,并且以在计算机系统这门课程的实验经历,也可以很熟练地将汇编代码翻译为C语言代码。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器把这些hello.s翻译成机器语言的指令,并且把这些机器语言指令编码打包成一个可重定位目标程序的指令编码格式,结果指令编码保存在了hello.o(二进制文件)中。
汇编的作用:汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
对hello.s进行汇编的命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
图4.21虚拟机操作指令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1命令
利用命令readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF格式。
图4.31elf文件生成
4.3.2格式分析
1.ELF头
图4.32ELF头分析
以16字节序列开始,描述生成该文件系统字的大小和字节顺序,而后包含帮助连接器语法分析和解释目标文件的信息。然后是节头部表具体信息。其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
2.节头
图4.33节头分析
描述了.o文件中出现的各个节的意义,包括节的类型、位置、所占空间大小等信息。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,数据段和只读数据段都不可执行,而且只读数据段也不可写。
3.重定位节
表述了各个段引用的外部符号等,在链接时,需要通过重定位节对这些位置的地址进行修改。链接器会通过重定位条目的类型判断需要使用的相应方法去计算正确的地址值。
偏移量:通常对应于一些需要重定向的程序代码所在.text或.data节中的偏移位置。
信息:重定位到的目标在符号表中的偏移量。
类型:代表重定位的类型,与信息相互对应。
名称:重定向到的目标的名称。
加数:用来作为计算重复或定位文件位置的一个辅助运算信息,共计约占8个字节。
(1).rela.text
图4.34.rela.text
(2).rela.eh_frame
图4.35.rela.eh_frame
4.符号表
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
name是符号名称,对于可冲定位目标模块。
value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。
size是目标的大小。
type要么是数据要么是函数。
Bind字段表明符号是本地的还是全局的。
图4.36符号表
4.4 Hello.o的结果解析
4.4.1命令
在终端输入 objdump -d -r hello.o > hello.asm指令输出hello.o的反汇编文件,并与hello.s文件进行对照。
图4.41虚拟机操作指令
4.4.2对照分析 ![](https://img-blog.csdnimg.cn/direct/d90e68b271b343019c90b2e1027b202a.png)
图4.42hello.s文件
1.操作数的表示
hello.s中的操作数时十进制,hello.o的反汇编代码hello.asm中的操作数是十六进制。
2.函数调用
汇编代码文件直接调用函数名称,但hello.o的反汇编文件则是直接call了一段地址,但这个相对地址目前就是下一条指令即0,在后面留下了虚拟地址,需要在链接后才能变为确定的地址,对共享库的函数调用要在执行时才能被动态链接器确定。
3.分支转移
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。
而在反汇编得到的hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令的地址之差,即相对地址。
4.5 本章小结
本章对汇编的概念与作用进行了介绍。通过对hello.o可重定位目标elf格式的基本分析观察elf文件的大体构成和比较hello.o的反汇编代码(hello.asm)与hello.s的异同,可以了解到从汇编语言到机器语言实现的转变,而重定位这一步正是为了供下一步链接使用的。
第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.21虚拟机链接命令
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
命令:readelf -a hello
ELF头:hello的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以16字节序列Magic(描述了生成该文件的系统的字的大小和字节顺序)开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
文件类型变成EXEC,可执行文件,而且可执行文件在虚拟内存的地址也不再是0,而是0x40开头的具体入口地址。
图5.31readelf可执行文件hello
节头:描述了各个节的大小、偏移量和其他属性。链接器链接时,将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。
图5.32可执行文件的节头
程序头:程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图5.33hello的程序头
Symbol table:符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图5.34hello的Symbol table
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
图5.41edb查看hello程序
打开edb,通过 data dump 查看加载到虚拟地址的程序代码。
查看 ELF 格式文件中的 Program Headers,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。
从ELF开始,我们可以知道起始地址为0x401000;而.text从4010f0开始。
图5.42hello文件起始位置
5.5 链接的重定位过程分析
5.5.1分析不同
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
图5.51反汇编hello
1.hello反汇编文件中,每行指令都有唯一的虚拟地址,而hello.o的反汇编没有。这是因为hello.o经过链接,已经完成重定位,每条指令的地址关系已经确定。
图5.52反汇编hello函数
2.链接后函数数量增多:链接后的文件中出现了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。这是因为动态链接器将共享库中hello.c用到的函数加入可执行文件中。
图5.53反汇编文件main函数
3.在main中,原本在hello.o中等待重定位而暂时置0的地址操作数被设置为了虚拟地址空间中真正的地址。所以可以知道链接器首先会将所有模块的节都组织起来,为可执行文件的虚拟地址空间定型。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT 中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
5.5.2重定位
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
在链接器完成符号解析之后,就把代码中的每个符号引用和正好一个符号定义关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小,然后便可以进行重定位。
重定位过程:重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,程序中每条指令和全局变量都有唯一运行时的地址。
重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
图5.54重定位.plt.sec
所有函数.plt.sec的节被全部合并成一个节。
图5.55hello.o与hello文件的地址关系
hello.o并没有链接,所以需要告诉链接器在链接时需要执行的动作,而hello已经完成链接,故其反汇编的地址关系已经确定,直接给出即可。
5.6 hello的执行流程
使用gdb/edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程(主要函数)。请列出其调用与跳转的各个子程序名或程序地址。
图5.61hello文件运行流程
子程序名称 | 程序地址 |
_init | 401000 |
.plt | 401020 |
puts@plt | 401030 |
printf@plt | 401040 |
getchar@plt | 401050 |
atoi@plt | 401060 |
exit@plt | 401070 |
start | 4010f0 |
deregister_tm_clones | 401130 |
register_tm_clones | 401160 |
main | 4011d6 |
_libc_csu_init | 401270 |
_libc_csu_fini | 4012e0 |
_fini | 4012e8 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb/gdb调试,分析在动态链接前后,这些项目的内容变化。要截图标识说明。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
图5.71hello文件.got地址
在edb中找到位置,发现在动态链接前,在.got的地址0x403ff0处,该位置处数据均为0,此时运行程序。
图5.72got运行前后地址变化
运行之后发现,0x403ff0之后的部分都发生了较大变化,几处为0的位置被填充上了相应的地址。这是程序开始运行,调用动态链接器的结果。
说明此时got表已经通过动态链接发挥了作用。
5.8 本章小结
本章中主要介绍了链接的概念与作用,并且详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,详细介绍了hello.o的ELF格式和各个节的含义。
通过查看hello的虚拟地址空间,得到了链接后的hello可执行文件的ELF格式文本,据此分析了hello与hello.o的异同,更好地掌握了链接与重定位过程。并且通过使用edb进行执行流程和体验动态链接之后,加深了对hello动态链接的理解。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:进程提供给应用程序的关键抽象:一个独立的逻辑控制流,如同程序独占处理器;一个私有的地址空间,如同程序独占内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:Shell是一个交互型应用级程序,也被称为命令解析器,它为用户提供一个操作界面,接受用户输入的命令,并调度相应的应用程序。
Shell-bash的处理流程:
1.从Shell终端读入输入的命令。
2.切分输入字符串,获得并识别所有的参数
3.若输入参数为内置命令,则立即执行
4.若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行
5.若输入参数非法,则返回错误信息
6.处理完当前参数后继续处理下一参数,直到处理完毕
6.3 Hello的fork进程创建过程
当运行hello程序时,在shell中输入./hello 郭冠男 2022112024 1,它会对其进行解析,发现是加载并运行一个可执行文件的命令,于是会先创建一个对应./hello的作业,再用fork()创建一个子进程,这个子进程几乎与父进程完全相同。
它们有着相同的代码段、数据段、堆、共享库以及栈段,但它们的pid与fork的返回值是不同的,因此可以进行区分。然后,父进程(即shell主进程)会将新创建的子进程放在一个新的进程组中,这样这个进程组就对应./hello这个作业,shell可以通过向进程组中的所有进程发信号的方式管理作业。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。与fork一次调用返回两次不同,execve调用一次,从不返回(除了出现错误)。
简单来说,execve函数就是用来执行一个可执行目标文件。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
在计算机系统中,通常有多个程序同时运行,但进程让用户看起来当前计算机是独占地使用处理器。用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一的对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。
1.逻辑控制流:系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。
2.用户模式和内核模式:处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
3.上下文:上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
4.用户态与核心态的转换:当发生硬件中断、系统故障或者系统调用这样的异常进入核心态,内核自己调度进程时,也会进入内核模式进行上下文切换然后再进入用户模式。处理器用寄存器中的一个模式为实现这种功能。便于操作系统内核提供抽象和保护安全。
5进程调度的过程:当内核调度到hello程序所在的进程时,进行上下文切换:保存原先进程的上下文、恢复hello进程的上下文、将控制转移给 hello 进程、逻辑控制流实现从另一个进程到hello进程的切换。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.正常运行时
图6.61正常运行终端情况
2.运行时输入回车
图6.62运行时输入回车
在程序运行时按回车,会多打印几处空行,程序可以正常结束。
3.运行时按下Ctrl-c
图6.63运行时按下Ctrl-c
按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
4.运行时按下Ctrl-z
图6.64运行时按下Ctrl-z
按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。通过pstree可以用树状图查看各进程。
图6.65ps和jobs查看进程
图6.66pstree
输入kill命令,则可以杀死指定进程。
图6.67kill杀死进程
图6.68fg恢复被挂起的文件
当输入fg时,可以让之前被挂起的hello恢复前台执行。
5.不停乱按
图6.69不停乱按结果
乱按时,进程继续运行,当getchar的时候读出一个’\n’(输入回车)结尾的字串时,会将其看作一次输入,hello结束后,此时被当作输入的其他字串会当做Shell的命令行输入。
6.7本章小结
本章我们逐步分析了如何通过shell执行我们得到的可执行目标文件。首先简要介绍了进程的概念和作用以及shell的作用和处理流程,还详细分析了hello程序的创建和执行的过程,尤其是对hello程序可能出现的异常情况,以及运行结果中的各种输入进行了解释和说明。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:由程序产生的与段相关的偏移地址部分。在这里指的是hello.o中的内容。一个逻辑地址由两部分组成,段标识符和段内偏移量。
线性地址:是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,或者说是(即hello程序)段中的偏移地址,它加上相应段的基地址就生成了一个线性地址。
虚拟地址:程序访问存储器所使用的逻辑地址称为虚拟地址。虚拟地址经过地址翻译得到物理地址。与实际物理内存容量无关,是hello中的虚拟地址。
物理地址:是真正的内存地址,CPU可以直接将物理地址传送到与内存相连的地址信号线上,对实际存在内存中的数据进行访问。物理地址决定了数据在内存中真正存储在何处。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
每个段的首地址都存放在自己的段描述符中,而所有的段描述符都存放在一个描述符表中(描述符表分为全局描述符表GDT和局部描述符表LDT)。而要想找到某个段的描述符必须通过段选择符才能找到。
1、逻辑地址=段选择符+偏移量;
2、每个段选择符大小为16位,段描述符为8字节(注意单位);
3、GDT为全局描述符表,LDT为局部描述符表;
4、段描述符存放在描述符表中,也就是GDT或LDT中;
5、段首地址存放在段描述符中。
保护模式下,以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过段式管理过程,可以得到了线性地址,记为VA。VA可被分为两个部分:虚拟页号和虚拟页偏移量,根据计算机系统的特性可以确定虚拟页号和虚拟页偏移量的具体位数,由于虚拟内存与物理内存的页大小相同,因此虚拟页偏移量与物理页偏移量一致。而物理页号则需通过访问页表中的页表条目获取。
CPU对于一个线性地址会取它的高若干位,通过它们去存储在内存中的页表里查询对应的页表条目,得到这个线性地址对应的物理页起始地址,然后与线性地址的低位(页中的偏移)相加就是物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
1.TLB:每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目)。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降到1或2个周期。许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为TLB。
2.多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。这样能够是单个页表在较小的占用小却实现对空间很大的虚拟内存的抽象。
3.VA到PA的转换:CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN 作为 TLBT+TLBI向 TLB 中匹配,如果命中,则得到 PPN (40bit)与 VPO(12bit)组合成 PA(52bit)。
如果 TLB 中没有命中,MMU 向页表中查询,CR3 确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成 PA,并且向 TLB 中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
图7.51三级cache流程
得到物理地址之后,先将物理地址拆分成CT(标记)+CI(索引)+CO(偏移量),然后在一级cache内部找,如果未能寻找到标记位为有效的字节(miss)的话就去二级和三级cache中寻找对应的字节,找到之后返回结果。
7.6 hello进程fork时的内存映射
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当着两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件 hello 中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要一下几个步骤:
1. execve加载hello程序后,设置栈,将控制传递给hello程序的主函数。
2. 删除已存在的用户区域。
3.映射私有区域:为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
4.映射共享区域:如果 hello 程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内;
5.设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当CPU执行某条指令的内存访问时,如果页表中的PTE表明这个地址对应的页不在物理内存中,那么就会引发缺页故障。
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
1.检查虚拟地址是否合法,如果不合法则触发一个段错误,终止这个进程。
2.检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
3.两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,向上生长,对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合。每个块就是一个连续的虚拟内存页,要么是已分配的,要么是空闲的。
已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
1.隐式链表:堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
2.显式链表:在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
3.带边界标记的合并:采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
4.分离存储维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
这样的话,通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。
7.10本章小结
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理的相关内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux中,所有的IO设备(网络、磁盘、终端等)都被模型化为文件,所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.Unix I/O接口
(1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
(2)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
(3)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(4)关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
2.Unix I/O函数
(1)int open(char* filename,int flags,mode_t mode),进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。
open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在 进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访 问这个文件,mode参数指定了新文件的访问权限位。
如果open的返回值为-1则说明其打开该文件失败。
(2)close()函数:这个函数会关闭一个打开的文件
读取和写入文件:
(3)ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd 的当前文 件位置赋值最多n个字节到内存位置buf。返回值-1 表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
(4)ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf 复制至多n个字节到描述符为fd的当前文件位置。
读写文件时,如果返回值<0则说明出现错误。
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;
}
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list,说明是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。返回要打印的字符串的长度,然后进行系统函数write调用打印i长度的字符串。
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall。
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。sys_call只有实现一个功能:显示格式化了的字符串。
8.4 getchar的实现分析
getchar是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数,最后通过对printf函数和getchar函数的底层实现的分析,对其工作过程有了基本了解。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1.将hello.c中include的所有外部的头文件头文件内容直接插入程序文本中,完成字符串的替换,方便后续处理。
2.通过词法分析和语法分析,将合法指令翻译成等价汇编代码。通过编译过程,编译器将hello.i 翻译成汇编语言文件hello.s。
3.将hello.s汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在hello.o目标文件中。
4.通过链接器,将hello的程序编码与动态链接库等收集整理成为一个单一文件,生成完全链接的可执行的目标文件hello。
5.shell中输入./hello 郭冠男 2022112024 1,父进程调用fork,生成子进程。
6.子进程调用execve函数加载并运行程序hello通过传递的参数,操作系统为这个进程分配虚拟内存空间
7.内存管理单元MMU将逻辑地址,一步步映射成物理地址,进而通过三级高速缓存系统访问物理内存/磁盘中的数据。
8.上下文切换:hello调用sleep函数之后进程陷入内核模式,处理休眠请求主动释放当前进程,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制传递给当前进程。
9.动态申请内存:当hello程序执行printf函数会调用 malloc 向动态内存分配器申请堆中的内存。
10.进程时刻等待着信号,如果运行途中键入ctr-c ctr-z 则调用shell 的信号处理函数分别进行停止、挂起等操作,对于其他信号也有相应的操作。
11.终止并被回收:Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。
计算机系统的设计需要合理协调系统各个部分,既要保证每个模块的正确运行,又要尽量的提高每个模块的执行速度,以及互相合作的效率。通过本次实验,我深切感受到计算机系统的精细和强大,每一个简单的任务都需要计算机的各种复杂的操作来完成。
在今后的学习中,我也会用心感受计算机系统带给我的知识,去将其运用在我未来的计算机生涯中。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 功能 |
hello.c | 源程序 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello | 可执行文件 |
objdump -d -r hello(hello1.asm) | 得到hello可执行文件的反汇编文本 |
readelf -a hello(hello1.elf) | 用readelf读取hello得到的ELF格式 |
参考文献
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] Pianistx.printf 函数实现的深入剖析[EB/OL].2013[2021-6-9].
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4] https://www.cnblogs.com/fanzhidongyzby/p/3519838.html.
[5] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[6] https://www.cnblogs.com/buddy916/p/10291845.html