计算机科学与技术学院
2021年5月
本文以“Hello的自白”为引子,从一个Hello程序的角度窥探在Linux系统下,它是如何由一个静态的program到一个动态的process,并执行直到它的生命周期结束的过程。在这段分析中,我们将一同见证源代码的预处理,编译,汇编生成可重定向文件,直至链接生成可执行文件,以及当程序执行时操作系统为其开辟进程,分配空间,提供IO支持的努力。通过对Hello程序“一生”的探索,我们将更好地理解计算机系统这个更为宏观的概念,以及看似简单的程序背后隐藏着的繁杂的工作。
关键词:计算机系统;Hello程序;生命周期;预处理;编译;汇编;连接;进程;存储;IO
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 40 -
6.3 Hello的fork进程创建过程... - 40 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 49 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 51 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 53 -
7.5 三级Cache支持下的物理内存访问... - 55 -
7.6 hello进程fork时的内存映射... - 56 -
7.7 hello进程execve时的内存映射... - 56 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Hello的P2P过程:
P2P,就是从program到process的过程,这是一个从静态的代码到动态的进程的过程。首先需要用户编写静态代码(即hello.c文件),然后经过cpp预处理文件(即hello.i文件),由编译器ccl进行编译生成汇编文件(即hello.s文件),编译文件经过汇编器as转换为可重定位目标文件(即hello.o文件),最后由链接器ld进行链接生成可执行目标文件(即Hello)。然后开始运行阶段,在shell程序中输入对应命令执行Hello,此时shell进程会fork出一个子进程,再在子进程中用execve来运行Hello,此时,它便成为了一个进程,完成了P2P的转换。
Hello的020过程:
020,就是一开始是0(在执行之前并不在内存中,为0),到执行完后被回收,资源被释放,痕迹被抹除,变回了0。shell进程会fork出一个子进程,再在子进程中用execve来运行Hello,此时,它便成为了一个进程,拥有了自己在内存中的空间(虚拟空间),以及cpu为其分配时间片执行逻辑控制流。在运行结束后,shell程序会回收Hello进程,操作系统会释放进程占有的资源(虚拟空间等),变回了0。
1.2 环境与工具
硬件工具:X64 Intel CoreI7 CPU,8GRAM,512GHD DISK
软件工具:Windows10 64位,VM VirtualBox ,Ubuntu 20
开发者与调试工具:codeblocks,gcc,gdb,edb,gedit,Gvim,ld,readelf,objdump等
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c : 源代码文件 hello.i : 预编译文件 hello.s : 汇编文件
hello.o : 可重定位目标文件 hello : 可执行目标文件 hello.asm : 反汇编代码文件 helloo.asm : hello.o文件的反汇编文件
1.4 本章小结
本章是整个讨论的一个开端,对P2P以及020的概念进行了基本的解释,引出了一些深一层的概念,为接下来具体章节的具体分析埋下了伏笔。同时,本章节也简述了实验的硬软件环境,以及在后续实验中我们所使用的工具以及相应的文件,这些在后续的分析中都会经常使用。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理是一个.c文件编译成可执行目标文件的第一步。预处理器cpp根据程序定义的宏定义,条件编译等以#开头的命令修改原始的c程序,对其进行初步的转换,将引用的库合并为一个完整的文件,还进行删除程序中的注释和多余的空白字符等操作。
预处理的作用:
- 处理宏定义:对于一些宏定义,在预处理阶段进行宏替换。这个过程也叫宏展开,将#define定义的标识符文本替换为字符串,代码等。
- 处理头文件包含指令:比如#include等文件包含指令,将头文件的具体内容插入到该文件包含命令所在的位置,即将引用的库与源文件合并为一个完整的文件。
- 处理条件编译指令:根据#if,#ifdef等条件编译指令的判断对源文件进行选择性的包含或是排除。
- 处理其他符号:如行号,文件名称,注释的消除等等其他的符号
2.2在Ubuntu下预处理的命令
使用gcc: gcc -E hello.c -o hello.i (还可以直接使用cpp hello.c > hello.i)
图2.1 预处理
2.3 Hello的预处理结果解析
图2.2 预处理文件hello.i
我们可以看到原本只有短短几十行的源代码现在变成了3000多行,源代码到了最后面。而前面增加了头文件中的内容,比如说有定义的结构体,定义的变量,定义的宏,声明函数等等,注释也被消除了。整个文件依然是可以阅读的c语言文件。
2.4 本章小结
本章承接了第一章中所述的预处理阶段,对预处理的概念以及作用进行了基本的解释,并且以hello.c文件的预处理为例进行了一个探索,展现了如何使用预处理命令,以及具体分析了预处理的结果。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:编译是一个.c文件编译成可执行目标文件的第二步,就是在经过预处理后,编译器将c语言编写的hello.i翻译为汇编语言文件hello.s的过程,hello.s是个汇编语言程序。
编译的作用:编译的作用是将一个c语言的预处理文件转换为汇编语言文件,它还具备多种功能:
- 语法分析:根据源程序语言的语法,确定一个源程序的语法结构,能够检测出源程序中的语法错误,并且能够从常见的错误中恢复并继续处理程序的其余部分。
- 语义分析:语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查, 进行类型审查,即静态检查。
- 目标代码生成,优化:主要是把c语言代码变成汇编代码,这个过程十分依赖于目标机器,因为不同的机器有不同的字长.寄存器,证书数据类型和浮点数数据类型等。目标代码优化则对上述代码进行优化,比如寻找合适的寻址方式,使用位移来替代乘法计算,删除指令等。
除此之外,编译还有调试措施、修改手段、覆盖处理等功能。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
使用gcc: gcc -S hello.i -o hello.s
图3.1 编译
3.3 Hello的编译结果解析
图3.2 编译文件hello.s
图3.3 hello.s整体内容
首先我们来看看这个文件的整体内容(1~11行):.file是声明源文件,.text是声明代码段,.section .rodata是声明只读数据段,.align 8说明数据是按照8字节对齐的,.string 是声明一个字符串,我看应该是printf中的只读字符串,.globl声明main是全局变量,.type声明main为函数类型(函数类型或对象类型)
3.3.1 数据:
(1). 常量:这个程序里的常量大概只有printf的格式字符串了,这两个字符串作为printf函数的参数,存放在只读数据段rodata中:
"\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"
"Hello %s %s\n"
第一个字符串的那一堆数字对应的是中文的UTF-8编码(中文的UTF-8编码有2,3,4位的)对应为:“用法: Hello 学号 姓名 秒数!\n”
它们的调用如下:
图3.4 hello.s
(2). 变量:
首先是全局变量,这里的全局变量大概只有main函数了,也正符合了hello.s文件的开头.globl main。
然后是局部变量,这里的局部变量有int i,int argc与char *argv[],它们都是在栈中保存并使用的。对于i,他是作为循环的标记来使用的,我看观察c程序可以知道他先进入一个8次的循环,在.s文件中找就可以看到:
图3.5 hello.s(续)
可见局部变量i被放在栈上-4(%rbp)的位置。
对于int argc与char *argv[],它们是main函数的第一个与第二个参数,因此一开始被放在寄存器%rdi与%rsi中,如图:
随后,它们被转移到栈中:
-20(%rbp)与-32(%rbp)中。
至于char *argv[]作为数组的使用,由于该数组是一个指针数组,在64位下每个占8字节,因此访问下一个时要加8,且需要通过内存访问的形式来访问指针指向的数据,下图是访问argv[0]与argv[1]指向的数据的过程:
图3.6 hello.s(续2)
程序将它们分别放到了%rdx与%rdi、%rax中。
最后是静态变量,这个程序中没有静态变量。
该程序中也没有作为数据的宏,表达式,类型存在。
3.3.2 赋值操作:
这个程序中的赋值操作有显式的对i的初始化,自增操作。这两者的赋值操作在汇编代码层面上基本都是通过mov系列指令与add系列指令实现的。
对于i初始化位0:
这是通过movl实现的,将一个立即数移入栈中。l代表是一个四字节操作。
对于i自增:
addl $1, -4(%rbp)
这是通过add操作实现的,同样,l表示这是一个四字节操作。
3.3.3 类型转换:
该程序中并没有直接的隐式或显式类型转换操作,唯一一个与之相关的是调用了一个atoi函数,视图将字符串转为Int类型:atoi(argv[3])
这属于函数操作,将在之后的函数操作节中具体讲述。
3.3.4 算术操作:
对于源程序而言,算数操作只有一个,就是我们在赋值操作中提到的i++操作。
该操作使用的是addl $1, -4(%rbp) 指令。
但在.s程序中,有许多经过代码生成后的汇编语言算术操作,比如减法操作subq $32, %rsp,以及取址操作leaq .LC0(%rip), %rdi,它们分别是sub系列操作与lea系列操作的八字节个体。像add与sub这样的操作有两个操作数,前一个是操作数,后一个是被操作数,可以在中间加上一个to去理解,如:subq $32 to %rsp。而lea是取址操作,意为将第一个操作数所代表的地址放到第二个操作数里。汇编语言中还有许多算术操作没有在这个例子中出现,比如inc(自增1),dec(自减一),imul(乘)等操作。
3.3.5 逻辑/位操作:
逻辑、位操作有AND S,D(与操作),OR S,D(或操作),XOR S,D(异或操作),以及左移(SHL k,D)右移(SH(A)R k,D)(逻辑,算术)等,但是本hello.s中并未涉及。
3.3.6 关系操作:
一般来说,关系操作会通过比较来设置相应的条件码标志位。
在本示例中,有两个关系操作:
(1). if(argc!=4) 比较argc是否不等于4
在汇编中的实现为:
图3.7 hello.s(续3)
红框中,cmpl负责比较4与-20(%rbp)(后者是argc在栈中的位置),设置条件码后je根据标志位进行相应的跳转,相等时跳至.L2,否则不跳。
(2). for(i=0;i<8;i++) 比较i是否大于7
在汇编中的实现为:
图3.8 hello.s(续4)
红框中,cmpl负责比较7与-4(%rbp)(后者是i在栈中的位置),设置标志位后jle根据标志位进行相应的跳转,小于或相等时跳至.L4,否则不跳。
其实,cmp同样是一系列指令,l代表对4字节进行操作。
3.3.7 数组/指针/结构操作:
本实例的程序较为简单并没有指针或是结构,唯一的数组是参数数组char *argv[]。具体在3.3.1的局部变量那一块就进行了详细的描述,由于该数组是一个指针数组,在64位下每个占8字节,因此访问下一个时要加8,且需要通过内存访问的形式来访问指针指向的数据,下图是访问argv[0]与argv[1]指向的数据的过程:
图3.9 hello.s(续5)
3.3.8 控制转移:
对于c语言来说,控制转移一般是if,switch,while等条件程序段反应到汇编中,就是一般先设置条件码,然后根据条件码来进行控制转移,像是jmp系列的je,jne,一般是配合指令CMP和TEST这种设置条件码的指令来使用的。这个程序中的跳转在3.3.6 关系操作中就已提及,如图:
图3.10 hello.s(续6)
图3.11 hello.s(续7)
3.3.9 函数操作:
这个程序中的函数有printf,exit,sleep,atoi,getchar,以及main函数
(1). 参数传递(地址/值)
对于main函数,正如3.3.1中所说,int argc与char *argv[],它们是main函数的第一个与第二个参数,因此一开始被放在寄存器%rdi与%rsi中,如图:
对于exit函数,程序将立即数1放在%edi中作为参数传递。
对于printf函数,我们的字符串的首地址被当作参数传递,如图:
图3.12 hello.s(续8)
两个红框中,它们作为参数被放入%rdi中。
对于sleep函数与atoi函数,它们的参数传递代码如下:
movq %rax, %rdi
call atoi@PLT
movl %eax, %edi
call sleep@PLT
分别在%rdi与%edi中(%edi是%rdi的低4字节)
对于getchar函数,它并不需要参数传入。
(2). 函数调用
函数调用的方式
除了main函数之外,其他的函数都是在main函数中使用call指令来调用,比如call puts@PLT(put函数,是系统提供的,并非我们写的),call exit@PLT(exit函数),call printf@PLT(printf函数),call atoi@PLT(atoi函数),call sleep@PLT(sleep函数),call getchar@PLT(getchar函数)
可以看到,这里的call后面跟的都是一个标志,在后续的处理中它们会被逐步替换为相应的地址。且在它们被调用之前,一般都会有参数的传递。
但是main函数不一样,它作为程序的主函数,被系统启动函数__libc_start_main调用,并不在hello.s文件中显示。
(3). 函数返回
函数的返回值一般都存储在%eax寄存器中,在调用函数结束后可以访问%eax来获取返回值。在这个程序中有意义的返回只有这一个:sleep(atoi(argv[3]))
atoi函数的返回值作为了sleep函数的参数。如图:
图3.13 hello.s(续9)
红框中,先调用了atoi函数,返回值来到了%eax寄存器中,然后将其通过mov指令移至%edi中作为sleep函数的第一个参数。而且我们注意,由于aoti函数会使用%eax,它的值事先被调用者保存了起来,存在了%rdi中。
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
本章讲述了编译的基本原理,概念,作用,对编译的结果从多个方面(数据,赋值,类型转换,算术操作,逻辑/位操作,关系操作,数组/指针/结构操作,控制转移,函数操作)进行了深入的分析,涉及到了c语言中各种类型和操作所对应的的汇编代码以及常见的转化与对应,为后续的处理进行了铺垫。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编是汇编器as将一个汇编语言文件(.s文件)转化为一个可重定位目标程序(.o文件)(机器语言)的过程。
汇编的作用:汇编的作用是将一个汇编语言的.s文件转换为机器可直接识别执行的代码文件,这个文件是一个二进制文件,此时已经初具可执行文件的大致框架。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
使用gcc: gcc -c hello.s -o hello.o
如图:
图4.1 汇编
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
首先,我们列出elf中的各节:
图4.2 elf中的各节
首先是elf头信息:
使用命令 readelf -h hello.o 如图:
图4.3 elf头信息
Elf头以16B的序列开始,它描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
然后是节头部表:使用readelf -S hello.o:
图4.4 节头部表
节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
然后是符号表:使用readelf -s hello.o:
图4.5 符号表
符号表存放程序中定义和引用的函数和全局变量的信息。name是符号名称,对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。
最后来看重定位节.rela.text,,我们使用readelf -a hello.o查看全部的内容并找到重定位节,如图:
图4.6 重定位节
对于重定位节,该节包括的内容是:偏移量,信息,类型,符号值,符号名称和加数。
对于64位的重定位节,它的每一个entry的结构都是一致的(这里有9个entry,也就是9个引用),分为几个部分,其中,Offset是需要被修改的引用在节中的偏移,Info:包括symbol和type两个部分,symbol在前面四个字节,type在后面四个字节, symbol用来标识被修改引用应该指向的符号, type用来表示重定位的类型,Attend是一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整,Name则是重定向到的目标的名称。
事实上,重定位entry的结构可以简化如下:
typedef struct {
long r_offset;
long: type 32
symbol 32
long r_addend;
} Rela;
对于我们这个实例的重定位节,我们可以进行分析,其中,偏移量是指每个引用在最后内存中相应节中的偏移量,使用offset+addr(节)就是对应的引用地址。此时addr(x.symbol)也是知道的(最后内存中相应引用的地址),使用addr(x.symbol)-(offset+addr(节)-append)就是占位符需要修改为的数字。大致上的条目都可以按照下面的公式计算:
refaddr = ADDR(s) + r.offset; /* ref's run-time address */
*refptr = (unsigned) (ADDR(r.symbol) + append - refaddr);
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
首先,我们输入命令,查看反汇编代码,如下图:
图4.7 反汇编代码
hello.s的代码如下:
图4.8 hello.s
对比两者,我们可以看出代码基本框架与关键指令都是相同的,只不过有些地方不一样了。第一,反汇编查看存在机器码,而.s文件没有,这是显而易见的。第二,反汇编代码更少,显得更加简洁,而.s文件存在许多标记,以及一些附加信息。第三,反汇编代码增加了地址信息与重定位信息。
(1)机器语言的构成与汇编语言的映射关系:
构成机器语言的是二进制机器指令的集合,二进制机器指令是纯粹的二进制数据表示的,是机器可以直接识别的。机器指令由操作码和操作数构成。汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编指令都可以用二进制数据来表示,进而可以将所有的汇编指令(操作码和操作数)和二进制机器指令建立一一映射的关系,因此可以将汇编语言转化为机器语言。
(2)操作数
如果是立即数,那么反汇编语言就是将其化为2进制表示(展现给我们看的是16进制),如果是寄存器或者是内存表示,反汇编代码会根据寄存器的二进制表示来修改操作数。
(3)分支转移:
在.s文件中,分支转移是通过特定标记(段名称)进行跳转的,此时跳转的目标只是一个符号:
比如:je .L2 (这里的.L2就只是一个标识)
但是在反汇编语言中,分支转移是跳转至确定的偏移地址,比如:
7e b2 jle 38
(4)函数调用
在.s文件中,调用指令call后面跟着的是简单的函数名称,这里仅仅作为一个标记,但是在反汇编代码中,call指令后面本来需要跟具体的偏移地址,但是由于这时还没有进行链接,所以并不确定目标函数在内存中的地址,所以会使用占位符(一般为全0)来代替具体的地址,然后在重定向节中加入相应的条目,使其在链接时修改占位符为具体的偏移地址。
.s文件:call exit@PLT
反汇编代码:e8 00 00 00 00 callq 75
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
本章讲述了汇编的基本原理,概念,作用,对汇编的结果从多个方面(elf中的各节,反汇编代码,机器语言的构成与汇编语言的映射关系,操作数,分支转移,函数调用)进行了深入的分析,涉及到了不同操作的汇编代码所对应的机器代码以及汇编的处理工作与为后续操作的准备工作(如设置重定向条目),为后续的处理进行了铺垫。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:链接是是将各种代码和数据片段收集并组合为单一文件的过程,这个文件可以被加载(复制)到内存并执行。
链接的作用:链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的,链接器使得分离编译成为可能。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
使用gcc:gcc hello.o -o hello
使用ld: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 链接
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
分析:基本结构与hello.o应该相似,但是由于hello是一个可执行目标文件,与上一个hello.o(可重定向文件)有着些许的不同,比如它应该会有程序头(段头)来记录文件jie与内存的映射。其次,它的type也应该是可执行文件。
- 首先是ELF Header
使用readelf -h hello,如下图:
图5.2 ELF Header
- 然后是节头部表
使用readelf -S hello
图5.3 节头部表
图5.4 节头部表(续)
这里可以看见整个文件有26个段,地址栏就是每个段的起始地址,大小也在大小栏有标出。(还包括名称,大小,类型,全体大小,地址,旗标,偏移量,对齐等信息)
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
- 使用edb加载hello
图5.5 edb加载hello
- 查看本进程的虚拟地址空间各段信息
图5.6 edb
图5.7 edb(续)
与之前的做对比,可以看出虚拟地址从0x400000开始和5.3节中的节头表是一一对应的。根据节头部表我们也可以用edb找到各个节的信息。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
(1).使用objdump -d -r hello得到结果如下图:
由于反汇编得到的代码太长,我们把它重定向到hello.asm中。
同样,为了方便比较,我们把之前.o文件的反汇编也保存到另一个文件helloo.asm中
图5.8 helloo.asm
(2)分析hello与hello.o的不同
我们观察这两个文件,首先,最明显的一点差别是地址的表示。在hello.o(可重定向文件)的反汇编中,地址是用占位符表示的,代表在链接时需要进行重定向,而在hello(可执行目标文件)中,地址的表示是具体的偏移量,因为此时地址已经确定了。
如:e8 46 ff ff ff callq 401090 (hello)
e8 00 00 00 00 callq 25 (hello.o)
其次,我们还可以看到hello文件比起hello.o文件的反汇编多了许多代码节,如下图:
图5.9 对比1
图5.10 对比2
根据链接的作用,我们不难判断出这些节是属于除了hello.o文件之外的文件,在链接的时候链接器将这些代码合并到了最终代码中。(比如外部共享库函数)
(3)重定位分析
(1)汇编时产生重定位项目
在生成重定向文件时,我们需要确定程序的机器代码,对于地址的引用,由于这时我们还不知道最后内存中的具体地址情况,所以我们没办法确定具体的地址,于是汇编器会产生重定位条目,每一个条目对应一个待重定位的引用,这些条目记录了引用的信息(在节中的位置,类型,append)。然后代替具体地址的是地址占位符(一般都是0)。在我们之前的.o文件中就有这样的重定位节:
图5.11 重定位节
(2)链接时根据重定位条目修改地址
在链接时,当段与引用目标的地址都已经确定时,链接器会根据重定位节的条目来修改地址。此时已知段地址addr(s)与引用目标地址addr(x.symbol),结合重定位节的entry内容即可找到引用并修改地址:
refaddr = ADDR(s) + r.offset; /* ref's run-time address */
*refptr = (unsigned) (ADDR(r.symbol) + append - refaddr);
此时*refptr就是对应的地址偏移量,将其写到原来的全0占位符处即可。
通过上述公式与hello.o的重定位条目,可以计算得出hello文件中的地址,验证是正确的。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
5.6 hello的执行流程
(1)使用edb加载hello
图5.12 edb加载hello
(2)执行流程
0x7fd89e6faed0 ld -2.31.so!_dl_start
0x7fd89effcb31 ld-2.31.so!_dl_init
0x4010d8 hello!_start
0x7fd89d5d4e70 libc-2.31.so!__libc_start_main
0x4004a0 hello!exit@plt
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
动态链接的原理是将链接过程推迟到程序运行的时候,而不是在链接时就将所有模块整合成一个可执行目标文件。这种方法的问题很明显,就是在链接时需要重定位的外部函数无法确定位置。此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在hello文件的节头部表中,我们可以看到动态链接这个部分:
图5.13 动态链接
在调用dl_init之前,GOT信息如下:
图5.14 原数据
在调用dl_init之后,GOT信息如下:
图5.15 现数据
可以看出这里有两个地址发生了变化,在0x601000处的后八个字节,本来为全0(重定位占位符)的两个8字节地址变成了0x7f6f8dc46170与0x7f6f8da34750,这两个地址都是在调用dl_init的过程中改变的。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
本章讲述了链接的基本原理,概念,作用,对链接的结果从多个方面(可执行目标文件hello的格式,hello的虚拟地址空间,链接的重定位过程,hello的执行流程,Hello的动态链接)进行了深入的分析,涉及到了链接所包含的各个方面,比如重定向,虚拟地址空间,动态链接原理等等,在某些环节还与hello.o文件进行了对比分析,又借助edb这个工具来进行具体的演示探讨,为后续的处理进行了铺垫。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是正在运行的程序的实例,是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的作用:当我们在一个现代系统上运行一个程序时,会得到一个假象,就好像我们的程序是系统中当前运行着的唯一的程序。我们的程序好像是独占地使用处理器和存储器。处理器就好像是无间断地一条接一条地执行程序中的指令。最后,我们程序中的代码和数据好像是系统存储器中唯一的对象。这些假象都是通过进程的概念提供给我们的。进程提供给应用程序两个关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
Shell是用户与操作系统之间完成交互式操作的一个接口程序,bash由 GNU 组织开发,sh是UNIX上的标准shell,是第一个流行的Shell,bash保持了对sh shell 的兼容性,是各种Linux发行版默认配置的shell。
Shell-bash的处理流程:
(1).首先是运行shell程序
(2).用户输入命令
(3).解析用户输入的命令进行解析,判断是否为内置命令
(4).如果是内置命令就直接执行,如果不是则试图将其当作一个可执行文件运行(在初始进程的上下文中)(前台、后台)。
(5)重复2~5的过程
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
对于这个hello实例来说,我们在shell程序中解析命令为一个可执行文件,于是由父进程调用fork函数创建一个与父进程拥有相同上下文的子进程,等待之后的execve。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename, execve才会返回到调用程序。所以,与fork—次调用返回两次不同,execve调用1次并从不返回。
对于我们的这个hello程序实例而言,当shell调用fork()函数之后,就生成了一个子进程,现在要调用execve来在这个子进程的上下文加载并运行hello程序。这个函数执行了以下一些操作:
调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数。
删除当前进程虚拟地址的用户部分中已存在的区域结构,为新程序的代码、数据、bss和栈区域创建新的区域结构,并将一些数据初始化为0。
将共享对象映射到用户虚拟地址空间中的共享区域。
使当前进程上下文的程序计数器指向hello程序的入口点。
6.5 Hello的进程执行
进程上下文信息:
进程上下文是进程执行活动全过程的静态描述。我们把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为进程上文,把正在执行的指令和数据在寄存器与堆栈中的内容称为进程正文,把待执行的指令和数据在寄存器与堆栈中的内容称为进程下文。实际上linux内核中,进程上下文包括进程的虚拟地址空间和硬件上下文。
进程时间片:
进程的时间片是指它的控制流的每一段占用CPU的时间。
用户态与核心态转换:
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
阐述hello进程调度的过程:
首先由父进程fork一个子进程,将其上下文,虚拟地址空间等信息复制一份给子进程,随即使用execve在当前的上下文中执行hello程序。此时hello的控制流被搬上了CPU的处理工作中。在hello运行一开始,这个进程是处于用户模式下的,但是当它调用sleep函数,他会通过一个陷阱执行系统调用函数sleep并进入内核模式。当sleep结束时,进程会由内核模式重新变成用户模式,并将控制交给用户进程。此时hello继续执行。同样,别的一些系统调用函数都是如此。当hello进程结束时,会由其父进程shell来进行回收释放hello进程占用的资源。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.6 hello的异常与信号处理
(1). 陷阱
陷阱是一种形式的异常,通常是为了实现系统调用而触发的,是有意的异常,在这个程序中,sleep,exit等系统函数的调用就会触发这个异常。由硬件触发异常后,就由软件进行异常处理程序。在这个实例中,对于sleep而言,异常处理程序就是将控制由用户进程交予内核进程,并实现sleep的系统调用。
(2). 故障
故障可能被修复,也可能会终止程序。在这个程序中,有可能会产生缺页故障,修复的话会继续进行而不报错。
(3). 中断
在hello程序执行的过程中可能会出现外部I/O设备引起的异常,会进行异常处理程序,返回到下一条指令。
(4). 终止
在hello程序执行时也可能会产生致命的错误,比如内存损坏,或是出现DRAM或者SRAM位损坏的奇偶错误,这种会用异常处理程序结束进程,不会返回。
(5). 信号
1.首先是不停乱按键盘,结果如下图:
图6.1 乱按键盘
可见并没有产生什么影响。实际上仅仅会作为getchar的输入
2.然后是Ctrl-Z,结果如下图:
图6.2 Ctrl-Z
可以看到进程被挂起了,hello进程由前台进程变成被挂起的后台进程,但是并没有终止,利用ps命令就能观察到它其实依然存在。这是因为ctrl+z命令会导致内核向前台进程组中的每一个进程发送SIGTSTP信号,默认情况下结果是挂起进程。此时我们可以使用fg来使其被调至前台继续运行,如下图:
图6.3 Ctrl-Z(fg)
也可以使用kill来杀死该进程,此时会内核会发送SIGINT信号,信号处理程序默认会终止这个进程,如下图:
图6.4 Ctrl-Z(kill)
还可以使用jobs指令查看前台作业组状态(被挂起):
图6.5 Ctrl-Z(jobs)
还可以使用pstree指令,结果如下:
图6.6 Ctrl-Z(pstree)
图6.6 Ctrl-Z(pstree)
3. 最后是Ctrl-C,结果如下图:
图6.7 Ctrl-C
可以看到进程被结束了,hello进程结束后将由父进程回收并释放资源,利用ps命令就能观察到它已经不存在了。这是因为ctrl+c命令会导致内核向前台进程组中的每一个进程发送SIGINT信号,默认情况下结果是结束进程。
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.7本章小结
本章讲述了进程的基本概念与作用,结合hello程序实例对进程的管理从多个方面(壳Shell-bash的作用与处理流程,fork进程创建过程,Hello的execve过程,Hello的进程执行,异常与信号处理)进行了深入的分析,覆盖了一个进程从被创建到结束被回收的生命周期,重点描述了对于异常的处理以及信号作为应用级异常的相关知识点,同时介绍了Shell的一般处理流程,演示了相关指令操作。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序经过编译后出现在hello汇编代码中的地址,是指hello程序产生的产生的与段相关的偏移地址。
线性地址:线性地址是由逻辑地址加上一个基址得到的,hello的程序段内偏移地址加上段的基地址就生成了线性地址。
虚拟地址:虚拟地址的由来是基于进程提供的抽象。对于hello程序,进程提供给他一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统。这个假象就是虚拟地址空间。
物理地址:物理地址是实际内存的地址,是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。hello程序通过虚拟地址来获取它的物理地址空间,这之中涉及到页,页表,缓存,地址翻译等概念。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
索引号是段描述符的索引。段描述符具体地描述了一个段。复数个段描述符组成了段描述符表,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
逻辑地址其实就是cs:ip的一种组合地址,可以理解为一个cpu用的中间地址,它就是段寄存器和偏移寄存器的一个组合。Intel使用的方法是,把寻址分为两部分,基地址和偏移地址,这也是我们以前经常接触的代码段、数据段的由来,实际上就是带表的基地址。那么,CPU要访问一个20位地址,比如这个地址是代码段地址(根据逻辑地址的段标识符获得),那么首先CPU要到CS(代码段寄存器)中取出基地址,然后再到IP寄存器中取出指令偏移量(也就是段内偏移量),组合成一个20位地址(这就是线性地址)然后寻址。如图:
图7.1 段式管理
选择符就是对应上面的CS寄存器,偏移量就是对应上面的IP寄存器。
CS寄存器中的数字不再代表实际基地址,而是基地址一个索引。(也就是我们上面说的“段标识符的前十三位是一个索引,索引号是段描述符的索引,通过索引直接在段描述符表中找到一个具体的段描述符”)
总体原理如下图:
图7.2 段式管理2
7.3 Hello的线性地址到物理地址的变换-页式管理
从线性地址(虚拟地址)到物理地址需要进行地址翻译,而这里面涉及到了分页的机制。
VM系统通过将虚拟存储器分割为称为虚拟页(Virtual Page, VP)的大小固定的块来处理这个问题。每个虚拟页的大小为P字节。类似地,物理存储器被分割为物理页(Physical Page, PP),大小也为P字节(物理页也称为页帧(pageframe))。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联, 因此也就不占用任何磁盘空间。
缓存的:当前缓存在物理存储器中的已分配页。
未缓存的:没有缓存在物理存储器中的已分配页。
同任何缓存一样,虚拟存储器系统必须有某种方法来判定一个虚拟页是否存放在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理存储器中选择一个牺牲页,并将虚拟页从磁盘拷贝到DRAM中,替换这个牺牲页。这些功能是由许多软硬件联合提供的,包括操作系统软件、MMU (存储器管理单元)中的地址翻译硬件和一个存放在物理存储器中叫做页表(page table)的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。结构图如下:
图7.4 页式管理
在hello程序中,当cpu给出一个虚拟地址时,需要由地址翻译部件将其翻译为物理地址,然后进行内存操作。
虚拟地址的翻译过程:页表基址寄存器指向当前页表。虚拟地址包含两个部分:虚拟页面偏移和一个虚拟页号。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0, VPN 1选择 PTE 1,以此类推。将页表条目中物理页号和虚拟地址中的VPO 串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移PPO和 VPO 是相同的。如下图:
图7.5 页式管理2
7.4 TLB与四级页表支持下的VA到PA的变换
正如我们看到的,每次CPU产生一个虚拟地址,MMU就必须査阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这又会要求从存储器取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,这就是TLB。
同样,四级页表的创建也是出于减少资源占用,这从两个方面减少了存储器要求。第一,如果低级页表中的一个PTE是空的,那么相应的高级页表就根本不会存在,这代表着一种巨大的潜在节约,因为对于一个典型的程序, 4GB的虚拟地址空间的大部分都将是未分配的。第二,只有低级页表才需要总是在主存中;虚拟存储器系统可以在需要时创建、页面调入或调出高级页表,这就减少了主存的压力;只有最经常使用的高级页表才需要缓存在主存中。
对于拥有TLB的过程:
开始时,MMU从虚拟地址中抽取出VPN ,并且检查TLB,看它是否因为前面的 某个存储器引用,缓存了该PTE的一个拷贝。TLB从VPN中梱取出TLB索引和 TLB标记,若组中的某个条目中有效匹配,所以命中,然后将缓存的PPN返回给MMU。
如果TLB不命中,那么MMU就需要从主存中取出相应的PTE。现在,MMU有了形成物理地址所需要的所有东西。它通过将来自PTE的PPN和来自虚拟地址的VPO连接起来,这就形成了物理地址。如下图:
图7.6 TLB
对于四级页表:
MMU使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个 L2PTE的偏移量,以此类推。如下图:
图7.7 四级页表
接下来,MMU发送物理地址给缓存,缓存从物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT。
7.5 三级Cache支持下的物理内存访问
Cache:cache是对主存的缓存,对于每一个cpu,都有一个L1数据cache与一个L1代码cache,和一个L2cache。所有的cpu会共享一个L3cache。在访问主存内容时,会先在缓存中找,若找到,则直接取数据,若没有找到,则会在主存中找,然后更新缓存。
对于缓存的使用方式,这里我们只讨论一级缓存,因为后面的取用方式都是一样的。
首先,cpu给出一个物理地址,这个物理地址可以被分为三个块,一个组索引,一个行标记,一个偏移位,它们的位数分别为s , n-s-b , b。其中n是物理地址的位数。而缓存大小即为S*E*B。其中S = 2^s ,B = 2^b。对于一个物理地址,先由它的组索引得到它在缓存的哪个组,然后再由行标记看它是否在缓存中。若在,则根据偏移位来获得目标数据。若不在,则更新缓存,这里涉及到了替换策略(一般是牺牲掉最久没有)。对于写操作,还有不同的命中与不命中处理方式。处理流程如图:
图7.8 三级Cache下的物理内存访问
对于写策略:
若命中时,有两种:
直写:在缓存中修改后直接在源数据中修改
写回:在缓存中修改后不在源数据中修改,直到该缓存块被驱逐时再写回到源数据中
若不命中,有两种:
写分配:更新缓存,将源数据放入缓存中,然后修改缓存(没有改源数据)
非写分配:直接修改源数据,不操作缓存
7.6 hello进程fork时的内存映射
Shell进程通过fork函数来创建一个新的子进程(准备给hello),fork函数会给它一份与父进程虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段,堆,共享库以及用户栈。对于虚拟内存,它会创建当前进程的的mm_struct,vm_area_struct和页表的原样副本;两个进程中的每个页面都标记为只读;两个进程中的每个区域结构都标记为私有的写时复制。子进程还获得与父进程相同的文件描述符副本,它们之间最大的区别是它们有不同的Pid。
7.7 hello进程execve时的内存映射
Shell进程通过execve函数来在其子进程的上下文中加载并执行hello,用hello 程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
- 删除用户区域,jiang已经存在的结构删去
- 映射私有区域,将hello的各个段(数据段,代码段等)映射到它的虚拟内存中,这些都是私有的,写时复制的。
- 映射共享区域,将与hello程序动态链接的文件映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当页面命中时,一切处理都是由硬件完成的。
缺页故障指的是当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。
当出现缺页故障时:
- 由硬件捕获一个异常(类型为故障)
- 控制由用户进程(用户模式下)转移至异常处理程序(内核模式下),由异常处理程序处理这个故障。
- 控制返回到引发异常的那个指令,即再次访问内存。
对于缺页中断处理,在默认情况下,它会根据一个替换策略来选择一个牺牲页,如果这个页已经被修改了,则把它在磁盘中更新。紧接着缺页处理程序会从磁盘中调入新的页面进入内存,并更新内存中的PTE。流程如下图:
图7.9 缺页中断处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,它将堆视为一组不同大小的块的集合来维护。每个块就是一个虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器又分为两种:显示分配器与隐式分配器。
显式分配器要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,像Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
图7.10 内存结构
(1). 带边界标签的隐式空闲链表分配器
一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。 头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果 我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要存储块大小的29个高位,释放剩余的3位来编码其他信息。在这种情况下, 我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。对于带边界标签的隐式空闲链表而言,“在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本”
空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块,就是一个设置了已分配位而大小为零的终止头部。
关于在这种结构之上的操作,当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。其块结构如下:
图7.11 边界标签的隐式空闲链表
(2). 显式空闲链表
一种更好的方法是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如图:
图7.12 显式空闲链表
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFb排序的首次适配有更高的存储器利用率,接近最佳适配的利用率。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
(3). 分离的空闲链表
分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果它不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。
使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
使用分离适配,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。
C标准库中提供的GNU malloc包就是釆用的分离的空闲链表与分离适配。
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.10本章小结
本章讲述了有关存储管理的一些知识,从操作系统的段式管理与页式管理开始,介绍了四种地址类型以及它们的意义,引出了段式管理的概念,又仔细分析了页式管理的基本模式以及有关的机制比如快表(TLB)与多级页表。紧接着又从虚拟地址引渡到物理地址,介绍了三级缓存的知识。随后又从hello的内存管理角度,介绍了fork时的内存映射,execve时的内存映射,缺页故障与缺页中断处理以及动态内存管理机制。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:linux通过将设备抽象成文件的形式来操作设备,一个Linux文件就是一个m字节的序列:B0,B1,B2……Bm。Linux系统为各类设备分别配置不同的驱动程序,在用户程序中通过文件操作方式使用设备,如open\close\read\write等,由文件系统根据用户程序指令转向调用具体的设备驱动程序。
设备管理:将设备映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix IO 接口:
1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Unix外壳创建的每个进程幵始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件定义了常量STDIN_FILENO、STD0UT—FILEN0和STDERR_FILENO,它们可用来代替显式的描述符值。
2. 改变当前的文件位置。对于每个打开的件,内核保持着一个文件位置初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置。
3. 读写文件。一个读操作就是从文件拷贝字节到存储器,从当前文件位置开始,然后增加。给定一个大小为m字节的文件,当> m时执行读操作会触发一个称为end-of-file (EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。
类似地,写操作就是从存储器拷贝字节到一个文件,从当前文件位置开始, 然后更新。
4. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的存储器资源。
Unix IO 函数:
int open(const char* path, int oflag, .../*mode_t mode*/);
int openat(int fd, const char* path, int oflag, .../*mode_t mode*/)
若文件打开失败返回-1
若成功将返回最小的未用的文件描述符的值。
int create(const char *path, mode_t mode);
若文件创建失败返回-1;
若创建成功返回当前创建文件的文件描述符。
int close(int fd);
该函数的作用是关闭指定文件描述符的文件,关闭文件时还会释放该进程加在该文件上的所有的记录锁。
int lseek(int fd, off_t offset, int whence);
成功则返回新的文件的偏移量;
失败则返回-1.
使用lseek()函数显式的为一个打开的文件设置偏移量。
ssize_t read(int fd, void *buf, size_t nbytes);
ssize_t write(int fd, const void* buf, size_t ntyes);
fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数
buf为写入内容的缓冲区,ntyes为期待写入的字节数
8.3 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;
}
可以看到这里有一个缓冲区数组buf,而且printf函数调用了vsprintf与write函数。
我们来分析vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
从中我们首先可以得知这个函数返回了要打印出来的字符串的长度。
其次,我们结合原来printf的代码可以明白这个函数的作用只是接受带输出的字符串,并对其进行格式化,产生格式化输出。
我们再来看看write函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
很明显这个write是给寄存器eax,ebx,ecx传递了参数,然后以一个int结束。实际上,最后一行代码的意思是要通过系统来调用sys_call这个函数。
因此我们再来看看sys_call这个函数:
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
这个函数的作用是显示已经格式化的字符串,将字符串中的字节数据从寄存器中通过总线复制到显卡的显存中,这样一来显存中存储的就是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
这样一来printf的工作原理就显而易见了。他首先通过vsprintf来获得将要输出的字符串的格式化形式,然后通过write,sys_call等过程调用来辅助后面的显示芯片将输出显示在显示器上。对于hello来说,就会显示我们的姓名,学号等内容。
8.4 getchar的实现分析
函数实现代码:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return (--n>=0)?(unsigned char)*bb++:EOF;
}
getchar的作用是从输入缓冲区中读入一个输入的字符。当程序运行到getchar时,会将控制转移到操作系统,此时用户如果是第一次输入,则会将输入放入缓冲区中,再将控制转移到程序,并从缓冲区中读入一个字符。如果用户并非第一次输入,即缓冲区中已经有内容了,就会直接从里面取出第一个字符。
该函数以无符号 char 强制转换为 int 的形式返回读取的字符,如果到达文件末尾或发生读错误,则返回EOF。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。发生异常将由硬件进行捕获,随即控制由用户程序(hello)转移给异常处理程序,由它来处理异常(ctrl+z,ctrl+c等)。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章从IO管理的角度分析了hello程序,从Linux的IO设备管理方法开始,介绍了Unix IO接口及其函数,进一步分析了hello程序中使用到的printf与getchar在系统IO层面上的原理与过程,为整个围绕hello程序进行的讲解介绍做了一个补充,也让我们看到了平时看似简单的函数背后复杂的一面。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
Hello程序从一个c语言源文件(.c)开始,经过预处理成为hello.i文件,再经过编译形成汇编语言文件(.s文件),然后又经过汇编变成二进制机器代码文件(.o)文件,此文件是可重定位文件。最后由链接形成可执行目标文件,也就是我们的hello。
之后,由shell进程进行fork子程序,并且execve我们的hello程序,这时它就被加载并且成为了一个进程。操作系统会对其进行进程管理,内存管理,在运行的过程中,可能还有着异常的产生与处理。最后,hello进程运行结束(或被终止),由它的父进程回收并释放它,这个进程便不复存在。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
通过学习计算机系统,我真正认识到了一个简简单单的程序背后异常复杂的机制与实现。仅仅是一个hello程序的生命周期,覆盖的内容之广,知识之深令人乍舌。但这也只是计算机的冰山一角,要想真正了解计算机的设计,构造艺术,我们还得继续学习。想要对计算机进行新的设计与实现我还太早了。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
1. hello.i 预处理文件 第二章
2. hello.s 汇编语言文件 第三章
3. hello.o 可重定位目标文件 第四章
4. hello.asm hello文件的反汇编文件 第五章
5. helloo.asm hello.o 文件的反汇编文件 第五章
6. hello 可执行文件 第五章
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 【编译原理】语法分析 https://blog.youkuaiyun.com/jzyhywxz/article/details/78392644.
[2] 编译原理之词法分析、语法分析、语义分析https://blog.youkuaiyun.com/nic_r/article/details/7835908
[3] 编译原理(三)目标代码的生成与优化基本概念 https://blog.youkuaiyun.com/weixin_44415928/article/details/104358430
[4] 04可重定位目标文件ELF文件解析 https://blog.youkuaiyun.com/WZJwzj123456/article/details/112754502.
[5] Linux下shell脚本:bash的介绍和使用(详细)https://blog.youkuaiyun.com/weixin_42432281/article/details/88392219
[6] 深入理解Linux内核进程上下文切换https://blog.youkuaiyun.com/21cnbao/article/details/108860584
[7] 逻辑地址(段式)-> 线性地址或者虚拟地址(页式) -> 物理地址(页框) 解释 深入浅出https://blog.youkuaiyun.com/dellme99/article/details/30456849
[8] 内存管理第一谈:段式管理和页式管理 https://blog.youkuaiyun.com/qq_36662437/article/details/80876293
[9] Linux内核:IO设备的抽象管理方式https://www.pianshen.com/article/71511433738/
[10] Unix文件IO相关函数 https://blog.youkuaiyun.com/u014630623/article/details/89046099
[11] [转]printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
[12] getchar (计算机语言函数)https://baike.baidu.com/item/getchar/919709?fr=aladdin
(参考文献0分,缺失 -1分)