计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 1190200424
班 级 1903005
学 生 孙显熠
指 导 教 师 史先俊
计算机科学与技术学院
2021年6月
本文主要介绍了程序从产生到结束的整个历程。从最开始的hello.c经过预处理、编译、汇编、链接生成可执行程序,再到运行过程中的加载和执行,最后到终止和回收。其中还包括信号的发生、异常的处理以及对其内存地址的探索。展现了一个程序“精彩的生命旅程“。
关键词:程序;编译;进程;加载;回收;信号;异常;信号处理程序;异常处理程序;内存映射;存储;
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 6 -
3.2 在Ubuntu下编译的命令............................................................................. - 9 -
4.2 在Ubuntu下汇编的命令........................................................................... - 17 -
5.2 在Ubuntu下链接的命令........................................................................... - 23 -
5.3 可执行目标文件hello的格式.................................................................. - 23 -
5.5 链接的重定位过程分析............................................................................... - 26 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 31 -
6.3 Hello的fork进程创建过程..................................................................... - 31 -
6.6 hello的异常与信号处理............................................................................ - 35 -
第7章 hello的存储管理............................................................................... - 39 -
7.1 hello的存储器地址空间............................................................................ - 39 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 39 -
7.3 Hello的线性地址到物理地址的变换-页式管理...................................... - 40 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 42 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 45 -
7.6 hello进程fork时的内存映射.................................................................. - 47 -
7.7 hello进程execve时的内存映射.............................................................. - 47 -
7.8 缺页故障与缺页中断处理........................................................................... - 48 -
8.1 Linux的IO设备管理方法.......................................................................... - 52 -
8.2 简述Unix IO接口及其函数....................................................................... - 52 -
第1章 概述
1.1 Hello简介
P2P:即from program to process。将代码写入hello.c文件中,然后通过编译系统将其处理为可执行目标程序hello。在shell中键入启动指令./hello后,shell通过fork为其产生子进程,并通过execve将程序加载并运行在子进程上的过程。
图1.1-1 编译系统处理hello.c
020:即from zero-0 to zero-0。当hello程序加载到子程序之后,shell利用多级页表映射虚拟内存,然后通过缺页中断对应到相应的物理内存。CPU为其分配时间片来执行逻辑控制流。在执行过程中,许多信号也会影响其运行,例如通过键盘键入的Ctrl+Z会通过SIGTSTP信号停止其运行,Ctrl+C会通过SIGINT信号终止进程等。IO系统使其能够输出在屏幕上。最后,当程序运行结束后,父进程负责回收hello进程,内核删除与之相关的所有数据。
1.2 环境与工具
(1)硬件环境:X64CPU;8GHz;8GRAM;1TB HD
(2)软件环境:Windows10 64位;Vmware 11;Ubuntu 16.04 LTS 64位
(3)使用工具:Codeblocks;Objdump;Edb;HexEdit;ld
1.3 中间结果
生成的中间结果文件的名字,文件的作用如表所示:
文件名 | 文件作用 |
hello.c | 源文件,其中保存着源代码。 |
hello.i | 预处理器处理后的文件,用于编译。 |
hello.s | 编译器编译后的文本,用于汇编。 |
hello.o | 由汇编器生成的可重定位目标程序,用于链接生成可执行程序。 |
hello | 最终生成的可执行文件。 |
hellosdump.s | 由hello.o文件反汇编所得 |
hellodump.s | 由hello反汇编所得 |
表1.3-1 出现的所有文件
1.4 本章小结
本章简介了Hello的P2P、020的过程,给出了用到的软硬件环境、工具,列出了在分析hello的过程中产生的一系列文件。
第2章 预处理
2.1 预处理的概念与作用
(以下格式自行编排,编辑时删除)
2.1.1 概念:
预处理器(cpp)根据以字符#开头的命令(宏、头文件等)修改原始的C程序,将包含的头文件插入到c代码中,并将宏定义进行替换,去除注释等,形成一个.i文本文件。
图2.1-1 hello.c中的源代码
例如在本程序中,hello.c第一行的#include 就告诉预处理器读取系统头文件stdio.h的内容,并将其插入程序文本,结果就得到了另一个C程序,以.i作为文件扩展名,即生成了hello.i。
2.1.2 作用:
(1)删除#define并且展开其定义的宏,即字符替换。
(2)处理所有的条件编译指令,如#ifdef、#ifndef、#endif等。
(3)处理#include,将对应的头文件的内容插入到程序文本中。
(4)删除所有注释(eg. //,/**/)。
(5)添加行号和文件名标识,以便编译器在编译时产生行号信息
(6)保留#pragma编译器指令,以供编译器使用。
2.2在Ubuntu下预处理的命令
图2.2-1 使用gcc指令实现预处理
图2.2-2 使用预处理器指令实现预处理
2.3 Hello的预处理结果解析
图2.3-1 预处理生成的hello.i文本的内容
打开hello.i文本查看其内容,通过翻阅可以在最后找到hello.c中的源代码,它被安置到了3000多行上,而由图2.1-1可见,hello.c中的代码只有20行左右,所以说,hello.i文本中,在main函数前面插入了大量代码。通过对比可以发现,在hello.i中,hello.c中的头文件和注释全都不知去向,这就想到了在2.1中介绍的预处理,预处理有删除注释,插入头文件内容,生成新的C程序文件,所以前面3000行的代码应该就是hello.c中指明的三个头文件的内容。
向上翻阅,查看代码,寻找常用头文件中的比较熟悉的函数:
图2.3-2 hello.i中插入的头文件内容
在代码中找到了文件的读取、写入以及在屏幕上打印的函数的函数声明,而这些函数都应该存放在stdio.h(标准输入输出头文件)中,由此可以断定,预处理过程中,预处理器将头文件的内容插入到了程序文件中,所以导致其代码量剧增。
2.4 本章小结
本章主要介绍了预处理,作为编译C程序的第一步,通过处理源程序的头文件、注释、宏定义和条件编程等,为后续的编译工作做准备。
第3章 编译
3.1 编译的概念与作用
3.1.1 概念:
编译器将文本文件hello.i翻译成hello.s,它包含一个汇编语言程序。
3.1.2 作用:
编译过程将高级语言翻译成比较低级的汇编语言,以便后续翻译得到机器语言。
3.2 在Ubuntu下编译的命令
图3.2-1 使用gcc指令进行编译过程
3.3 Hello的编译结果解析
3.3.1 头部声明:
图3.3-1 hello.s的声明部分
声明类型 | 含义 |
.file | 源文件名 |
.text | 代码段 |
.rodata | 只读数据段 |
.align | 对齐方式 |
.globl | 全局变量 |
.type | 用来指定一个符号的类型 |
.size | 符号大小 |
表3.3-1 hello.s的头部信息
3.3.2 数据
- 整型:
int i:
图3.3-2 hello.s的局部变量i分析图
整型变量i是一个局部变量,我们已经知道,局部变量会被放置在寄存器或者堆栈中,于是在汇编语言中找到了%rbp-4这个位置,通过其初始化、比较和加一等操作可以判定这个位置存放的变量就是i。
int argc:
图3.3-3 整型数据argc的分析图
通过观察hello.c发现argc有一个与4比较的操作,因此可以通过cmpl $4的指令来找到argc的存放位置,于是可以看到argc一开始存放在%edi中,然后放入堆栈中与4比较。
- 字符串:
图3.3-4 源代码和汇编语言的字符串
在汇编语言中可以看到两个字符串LC0和LC1,分别与源代码中需要打印的两个字符串一一对应,这两个字符串都被放在.rodata段中,LC0字符串中含有汉字,汉字按照UTF-8格式被编码。
- 指针数组argv[]:
图3.3-5 *argv[]
作为main函数的参数,一定有argc放置在%edi中,argv[]放置在%esi中。所以在汇编语言中找到%rsi寄存器,这里面就存放了argv[],可以看到它被取出放到了堆栈中,在读取其中信息的时候,通过在其地址上+8、+16来实现地址偏移,从而读出数组中的元素argv[1],argv[2]。
3.3.3 赋值
在汇编语言中,赋值是通过mov指令来实现的,例如汇编语句中的一段:
图3.3-6 通过mov赋值
mov是赋值的指令,而后面的b,w,l,q则是表明赋给的值所占的字节:
b | w | l | q | |
字节大小 | 1B | 2B | 4B | 8B |
表3.3-2 汇编指令中表示大小的后缀
3.3.4 算术操作
在hello中,用到了加法操作和地址计算操作:
图3.3-7 hello中出现过的算数操作
add为加法指令,add A,B意为将A+B存入B中。
lea为地址计算指令,lea A,B意为得到A的地址并传送给B。
其他算术操作:
INC A | A = A + 1 |
DEC A | A = A - 1 |
NEG A | A = - A |
SUB A, B | B = B – A |
IMUL A, B | B = A * B,有符号或无符号乘法,结果截断为64位 |
Imulq A | R[%rdx]:R[%rax] = A * R[%rax],有符号乘法,保留128位 |
mulq A | R[%rdx]:R[%rax] = A * R[%rax],无符号乘法,保留128位 |
IDIVQ A | R[%rdx] = R[%rdx]:R[rax] mod A; R[%rax] = R[%rdx]:R[rax] div A 有符号 |
DIVQ A | R[%rdx] = R[%rdx]:R[rax] mod A; R[%rax] = R[%rdx]:R[rax] div A 无符号 |
表3.3-3 除add和lea外的其他算术操作指令
3.3.5 关系操作
在hello中用到了比较大小的操作,其中,一处为直接判断argv是否为4,另一处是在循环中判断是否满足跳出循环的条件,即i是否大于7。
图3.3-8 cmp指令
cmp为比较指令,cmp A,B意为比较B和A的大小,根据B-A的结果来给出条件码,以供跳转指令使用,该指令只改变条件码,而不改变寄存器。
其他的比较操作:
TEST A, B | 根据A&B来设置条件码,不改变寄存器 |
SETXX A | 根据XX得到标志位的值来设置A的值 |
表3.3-4 除cmp之外的其他比较操作指令
3.3.6 数组操作
在hello程序中有一个argv[]数组(在3.3.2中已经提及),关于其传送、访问的问题可见3.3.2 数据 3),在此不再赘叙。
3.3.7 控制转移
- if语句:
找到if语句的汇编语句,可以看到有一个比较操作,即条件判定,判断argc是否为4,若满足条件则通过je跳转到if内部语句,否则继续向下执行。
图3.3-9 对if语句的跳转分析
- for语句:
找到for语句的汇编语句,可以发现循环体内部的代码和判定条件部分的代码是分离的,先判断是否满足循环条件i<8,再利用跳转语句jle跳转到循环体执行其代码,而且在循环结束时要给i加一。
图3.3-10 对for语句的跳转分析
可以看到,实现控制转移需要需要使用跳转操作,而跳转操作可能需要一个条件来实现。下面给出跳转指令:
JMP | 无条件跳转 |
JXX | 有条件跳转,当条件为XX时方可跳转。(包括jne,je,ja,jbe,jge,jle等) |
表3.3-5 跳转指令
3.3.8 函数操作
可以将函数的操作分为参数传递、函数调用以及函数返回三个阶段。
3.3.8.1 函数调用
图3.3-11 函数调用
在高级语言中,函数名就是函数的地址(与指针类似),所以可以直接使用函数名来调用函数,对应于汇编语言,调用函数其实就是执行call语句,通过call + (函数名)f来调用函数f。
在此应该提一下,在call指令函数调用之前,会将返回地址(当前指令的下一条指令的地址,存放在%eip中)压栈,以供后面函数返回时ret指令使用,回到当前函数,并恢复当前函数的栈帧。
3.3.8.2 函数返回
函数在返回时,若有返回值,在返回之前会将返回值存放到%eax中,否则直接利用ret返回。在返回之前要恢复栈帧,删除被调用函数的栈帧,恢复调用函数的栈帧。
图 3.3-12 getchar函数返回时的汇编语言
在函数返回时一个非常重要的指令为leave指令,leave指令的作用就是将被调用函数的栈帧抹除,实现过程如下:
将开栈时生长的栈顶%esp恢复至%ebp处,然后将%ebp弹栈,此时栈顶存放的就是返回地址(在3.3.8.1中提到过,调用函数前%eip中存放的地址),以供ret指令返回。
leave指令等价于movl %ebp, %esp(将栈顶恢复值%ebp处); pop %ebp(弹出%ebp);
3.3.8.3 参数传递
一个函数要调用另一个函数,二者之间应该建立起某种联系,这个联系就是参数传递。
参数传递存在一种规则:
参数次序 | 1 | 2 | 3 | 4 | 5 | 6 | >=7 |
存放位置 | %rdi | %rsi | %rdx | %rcx | %r8 | %r9 | 堆栈 |
表 3.3-6 参数传递使用寄存器的顺序规则
- main函数:
对main函数而言,在3.3.2介绍数据时,已经指出argc存放在%rsi中,argv[]存放在%rdi中(见图3.3-3和图3.3.5)。
- printf函数:
图3.3-13 puts函数的参数传递
第一个printf函数只输出了一个常量字符串,所以编译器采用puts输出,在调用puts之前,参数被存入%rdi中。
图3.3-14 printf函数的参数传递
第二个printf在调用之前,参数也被放入了%rdi中,然而第二个printf要输出的字符串中包含指针数组的内容,所以需要寻址,可以通过头地址的相对偏移寻址,具体寻址方法已在3.3.2中的指针数组中介绍。
- exit函数:
图3.3-15 exit函数的参数传递
在hello中调用了exit(1),参数为1,所以会在call exit之前将参数1放入%edi中。
- atoi函数:
图3.3-16 atoi函数的参数传递
同样在调用atoi函数之前将参数放入%rdi中。
- sleep函数:
图3.3-17 sleep函数的参数传递
在此我们可以看到,在调用sleep之前依然是在%edi中存放了参数,但是这个参数是%eax,通过观察源代码发现,函数的参数为atoi的返回值,根据3.3.8.2中的介绍分析,main函数首先调用atoi函数,将其返回值存放到%eax中后返回,然后再将返回值作为参数放到%edi中,最后在调用sleep函数。
3.4 本章小结
本章介绍了编译环节,编译器将.i文件编译为.s文件,将高级语言翻译成汇编语言,还根据汇编语言,分析了相关数据、函数和符号存放的地点、调用(使用)的方式等。
第4章 汇编
4.1 汇编的概念与作用
4.1.1 概念:
汇编器将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中,hello.o是一个二进制文件。
4.1.2 作用:
得到机器语言,计算机可以直接识别并执行。
4.2 在Ubuntu下汇编的命令
由hello.s到hello.o应采用gcc -c hello.s -o hello.o指令。
但是要有-no-pie -fno-PIC的约束条件,所以使用gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello.o。
图4.2-1 完成汇编过程生成hello.o
4.3 可重定位目标elf格式
4.3.1 ELF头部
图4.3-1 ELF头
通过readelf -h查看hello.o的ELF头信息。
Magic | 16字节序列,这个序列描述了生成该文件的系统的字的大小和字节顺序。 |
Data(数据存储方式) | little endian,小端存储 |
Type(文件类型) | REF,可重定位文件 |
Entry point address | 程序从虚拟地址0x0开始运行 |
ELF头大小 | 64字节 |
节头大小 | 64字节 |
节头数目 | 15 |
表4.3-1 ELF头信息
4.3.2 节头信息
图4.3-2 ELF文件节的头信息
通过readelf -S hello.o查看ELF文件的所有节的头信息,包括节的名字、大小、开始地址和偏移量。
.text | 代码段 |
.data | 数据段,放置已初始化(不为0)的全局变量 |
.bss | 数据段,放置未初始化的全局变量 |
.rodata | 只读数据段,放置全局常量和字符串常量(指针字符串) |
.symtab | 符号表 |
.strtab | 字符串表 |
表4.3-2 ELF节信息
4.3.3 重定位信息
图4.3-3 ELF文件的重定位信息
.rela.text中显示的是重定位信息,在链接器进行链接时,这些信息都需要重新定位。
offset | 重定位时在.text中的偏移量 |
Info | 高两个字节表示该符号在符号表中的序号,低四个字节表示Type(重定位的方式) |
Addent | 某些类型的重定位要用其对被修改引用的值做偏移调整 |
表4.3-3 ELF的重定位信息
在PC相对寻址时,要求重定位位置和符号位置之间的差值,使用的是%rip进行相对寻址,由于%rip存放的是下一条指令的地址,所以两个地址之间的差值就会差出一条指令的长度,而Addent的作用就是弥补这个差值,保证重定位的位置的正确性。
4.3.4 符号表信息
图4.3-4 ELF的符号表信息
符号表中存放着所有的全局变量、函数的信息,在4.3.3中重定位时所使用的符号序号就是符号在符号表中的序号。
4.4 Hello.o的结果解析
4.4.1 两种汇编语言的比较:
图4.4-1 反汇编得到的汇编语言
反汇编得到的汇编语言与编译得到的汇编语言的对照分析:
图4.4-2 mov、sub和push等操作的比较
图4.4-3 调用函数时,call指令的比较
图4.4-4 对全局变量的访问
图4.4-5 两种汇编语言的分块比较
- 如图4.4-2所示,反汇编得到的汇编语言中,mov、add、sub和push等操作后面没有b、w、l和q。
- 如图4.4-3所示,反汇编得到的汇编语言中,对函数的调用是通过call一个数字(通过PC相对寻址得到),但由于未重定位,所以机器指令中用0占位;而由编译得到的汇编语言中,通过直接call函数名来调用函数。
- 对全局变量的访问方面(见图4.4-4),利用PC相对寻址。反汇编得到的汇编语言中,通过全局变量所处的段+%rip进行访问,由于未进行重定位,所以暂时用0占位;在编译得到的汇编语言中,通过ADDR(symbal) + %rip来进行访问。
- 如图4.4-5所示,在hello.s中,main函数被分成了很多块,在跳转时通过跳转指令在这些块之间跳转,如循环体、条件语句等,而hellodump.s中没有分块,而是一整段。
4.4.2机器语言:
4.4.2.1 机器语言的构成:
机器语言由二进制序列构成,机器指令包括操作码和操作数。
4.4.2.2 机器语言与汇编语言的映射关系:
一条汇编语言对应一条机器指令,机器指令中,分支转移、函数调用时的操作数是PC相对寻址的结果,由汇编语言中的地址计算而来,并不是直接使用。
4.5 本章小结
本章介绍了编译过程,该过程将汇编语言翻译成机器语言,以供计算机识别并执行相关指令,得到了.o文件,.o文件是可重定位目标程序,可以通过readelf来查看其信息,但是它还不是可执行程序,需要进一步的链接来生成可执行程序,即从hello.o到hello。
第5章 链接
5.1 链接的概念与作用
5.1.1 概念:
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。
5.1.2 作用:
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把他分解成为更小的、更好管理的模块,可以独立地修改和编译这些模块。
5.2 在Ubuntu下链接的命令
ld -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 /usr/lib/gcc/x86_64-linux-gnu/10/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/10/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图5.2-1 Ubuntu下对hello.o的链接
5.3 可执行目标文件hello的格式
5.3.1 ELF文件头信息:
图5.3-1 ELF文件头信息
Magic | 16字节序列,这个序列描述了生成该文件的系统的字的大小和字节顺序。 |
Data(数据存储方式) | little endian,小端存储 |
Type(文件类型) | EXEC,可执行文件 |
Entry point address | 程序从虚拟地址0x4010f0开始运行 |
ELF头大小 | 64字节 |
节头大小 | 64字节 |
节头数目 | 15 |
表5.3-1 ELF文件头信息
其中大部分信息和hello.o大致相同,相差较大的地方就是hello具有更大的内存、更多的节,它的文件形式为EXEC(可执行文件),而且hello程序的入口位于虚拟地址的0x4010f0,而不再是0x0,这都是链接和重定位的功劳。
5.3.2 节头信息:
图5.3-2 ELF文件节头信息
在ELF中,节头展示了hello的各个节的信息,包括节名称、类型、大小、开始地址以及偏移量等。
5.3.3 程序头信息:
图5.3-3 ELF文件程序头信息
program headers table | 程序头,其中包含所有的段 |
segment headers table | 段头,其中包含各段中含有的节 |
表5.3-2 程序头表和段头表
程序头是可执行文件的ELF信息中所特有的,链接器在链接可执行文件或动态库的过程中,它会把来自不同可重定位对象文件中的相同名称的节合并起来构成同名的节。接着,它又会把带有相同属性的节都合并成段。段作为链接器的输出,常被称为输出节。一个单独的段通常会包含几个不同的节,例如一个可被加载的、只读的段通常就会包括代码段.text、只读数据段.rodata以及给动态链接器使用的符号.dymsym等。节是被链接器使用的,但是段是被加载器所使用的。加载器会将所需要的段加载到内存空间中运行。与使用段头表来指定一个可重定位文件中到底有哪些节一样。在一个可执行文件或者动态库中,也需要有一种信息结构来指出包含有哪些段。这种信息结构就是程序头表。
PHDR | 包含程序头表本身 |
INTERP | 只包含了一个节.interp,其中包含了动态链接过程中使用的解释器的路径和名称 |
LOAD | 第一个是代码段,第二个是数据段 |
DYNAMIC | 保存了由动态链接器使用的信息 |
NOTE | 保存辅助信息 |
GUN_STACK | 堆栈段,除了Flg/Aligh不为空外,其他均为0 |
GUN_RELRO | 指定在重定位后哪些内存区域需要设置只读 |
表5.3-3 程序头表中的信息
5.4 hello的虚拟地址空间
读取图5.3-3中的信息,得到各段的虚拟地址:
段名 | offset | Virtual Address |
PHDR | 0x40 | 0x400040 |
INTERP | 0x2e0 | 0x4002e0 |
LOAD | 0x1000 | 0x401000 |
DYNAMIC | 0x3e10 | 0x403e10 |
GUN_STACK | 0x300 | 0x400300 |
表5.4-1 各段的虚拟地址
使用edb加载hello,并使用hexedit打开hello进行比对:
5.4.1 PHDR段比对:
图5.4-1 进程的虚拟地址中的PHDR段
5.4.2 INTERP段比对:
图5.4-2 进程的虚拟地址中的INTERP段
经过对比可以发现,在ELF的程序头表中可以读取到每一个段的信息,其中就包括段的偏移量offset以及虚拟地址virtual address,进程从虚拟地址0x400000开始加载,所以offset+0x400000就是对应段被加载到进程时的虚拟地址。例如,如表5.4-1所示,PHDR段的偏移量为0x40,那么可以通过hexedit,在hello文件的0x40找到它的内容;其在进程的虚拟地址就是0x40+0x400000=0x400040,在edb的data dump中查找0x400040并与PHDR段内容进行比对,发现PHDR段确实被加载到了0x400040中。
因此可知,所有的段都被加载到进程的虚拟地址offset+0x400000的位置上。
5.5 链接的重定位过程分析
5.5.1 hello与hello.o的不同
hello是链接器通过将hello.o、crt1.o、crti.o等.o文件合并得到的,所以说hello.o只是hello的一部分,而且没有进行重定位,通过比对二者的反汇编语言也可以看到,hello要比hello.o大得多,而且hello中除了包含hello.o中的exit、sleep和getchar等函数外,还包含与main函数运行有关的_start、_fini、_libc_等函数,如图5.5-1、图5.5-2:
图5.5-1 hello的反汇编语言中的特有函数
图5.5-2 hello中来自hello.o的函数
5.5.2链接的过程
链接器在链接可执行文件或动态库的过程中指定了/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o,所以将会把这些.o文件的每个节与hello.o的节合并。在合并的过程中,会根据重定位信息对相应的地方进行重定位,比如hello.o中的.text节中的puts函数和exit函数,.rodata节的字符串等。接着,它又会把带有相同属性的节都合并成段,段作为链接器的输出,常被称为输出段。
5.5.3 链接过程中的重定位:
首先找到hello.o文件中的一个需要重定位的符号作为例子进行重定位演示:
在图4.3-3中列出了hello.o中所有需要重定位的符号信息,在此,采用exit来进行重定位过程的推演:
图5.5-3 hello.o中需要重定位的符号信息表
选中的符号为exit函数,列出其信息:
符号名 | offset | order(体现在Info的高两个字节) | attend | type(体现在Info的低四个字节) |
exit | 0x29 | 0xd | -4 | 0x4(R_X86_64_PLT32) |
用于确定重定位符号 | 重定位的位置在.text中的偏移 | 用于确定重定位符号在符号表中的序列位置 | 用于修正重定位的位置 | 用于确定重定位的形式 |
表5.5-1 exit的重定位信息
- 根据得到的exit的信息计算运行时需要重定位的位置:
offset的含义是该符号的重定位位置在.text(.data)段中的偏移量,因此,.text的地址加上offset就是重定位位置。
refaddr = ADDR(.text) + offset = 0x4011d6 + 0x29 = 0x4011ff
图5.5-2 .text以及refaddr
得到重定位位置为0x4011ff。
- 更新该位置:
该位置应该填入PC相对寻址得到的相对地址,首先得到exit的地址,由于PC相对寻址是ADDR(sym) = %rip + x,所以要得到x应该利用ADDR(sym) - %rip,但是%rip寄存器中存放的是下一条指令的地址,因此x得到的值应该是正确操作数y再加上其字节数,而这个字节数就是abs(attend),因此正确式子为:
*refptr = ADDR(exit) + attend – refaddr = 0x4010c0 - 4 - 0x4011ff = 0xfffffebd
图5.5-3 ADDR(exit)
所以最后应该更新重定位的位置,填入0xfffffebd,与汇编语言对比:
图5.5-3 正确的操作数
发现汇编语言中该地址处的内容与计算得到的一致,说明重定位推演成功。
5.6 hello的执行流程
hello调用与跳转的各个子程序名及其程序地址:
子程序名 | 程序地址 |
ld-2.32.so!_dl_start | 0x7f394d8d7e80 |
ld-2.32.so!_dl_init | 0x7f3628c01e10 |
hello! _start | 0x4010f0 |
libc-2.32.so! __libc_start_main | 0x7f3628a17bc0 |
libc-2.32.so! __libc_cxa_atexit | 0x7f3628a33f80 |
0x7f3628a33d20 | |
hello! __libc_csu_init | 0x401260 |
hello! _init | 0x401000 |
hello!frame_dummy | 0x4011d0 |
hello!register_tm_clones | 0x401160 |
libc-2.32.so!_setjmp | 0x7f3628a30550 |
libc-2.32.so!_sigsetjmp | 0x7f3628a30480 |
hello!puts@plt | 0x401030 |
hello!exit@plt | 0x401060 |
hello!printf@plt | 0x401050 |
hello!sleep@plt | 0x401070 |
hello!getc@plt | 0x401080 |
libc-2.32.so!exit | 0x7f8474d0ebe0 |
表5.6-1 hello调用和跳转的程序
5.7 Hello的动态链接分析
由图5.3-2中找到got节的信息,得到其起始地址:
图5.7-1 GOT表的起始地址
图5.7-2 通过GOT表跳转到PLT
根据图5.7-2可知,puts函数对应的GOT表应该就是0x404018,在data dump中找到该地址,并观察其在调用dl_init前后的变化情况:
图5.7-3 调用dl_init之前的GOT表
图5.7-4 调用dl_init之后的GOT表
可以看到,在调用dl_init前后,GOT表中puts函数对应的位置发生了变化,从0x401030变成了0x7f777d637d90,那么这两个地址代表什么呢,通过查找地址来查看其内容:
图5.7-5 0x401030处的内容
图5.7-6 0x7f777d637d90处内内容
可以发现,0x401030处是puts函数的PLT,所以在调用init之前,调用puts是通过GOT表跳转到其PLT后间接跳转的,在调用init之后的0x7f777d637d90处发现,该地址就是puts函数的起始地址,由此可知dl_init在第一次调用puts函数时,将其对应的GOT表条目由puts的PLT修改为puts函数真正的地址。
5.8 本章小结
本章主要介绍了链接过程。讲述了如何将多个.o文件组合成一个大的可执行文件,分析了可执行文件的ELF信息,推演了重定位的过程,探索了hello中的段的虚拟地址,最后还给出了hello的动态链接的分析。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1 概念:
进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。
6.1.2 作用:
进程为程序提供两个关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占CPU。
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占内存。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell的作用:
Shell是一个命令行解释器,它等待一个命令行的输入,然后执行这个命令。
6.2.2 Shell的处理流程:
- shell收到一个命令
- 如果这个命令行的第一个指令是一个内置命令,那么shell将其解释为系统的功能调用并且转交给内核执行。
- 如果这个命令行的第一个指令不是一个内置命令,那么shell就会把它当作一个可执行文件的名字, shell会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
- 运行完成之后,shell回收这个进程并输出一个提示符,等待下一个输入的命令行。
- shell还可以接收键盘送入的信号(ctrl-z,ctrl-c),并作出响应。
6.3 Hello的fork进程创建过程
当在shell中输入命令行./hello 学号 姓名 睡眠时间时,按6.2.2中的叙述,由于./hello并不是一个内置命令,所以shell会hello当作一个可执行文件,创建一个新的子进程,然后在这个子进程的上下文中运行hello程序。
新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。父进程与新创建的子进程之间最大的区别在于他们有不同的PID。
6.4 Hello的execve过程
在创建好子进程后,子进程会调用execve函数加载并运行hello,execve只有在找不到可执行目标文件时才会返回到调用程序,否则调用一次,从不返回。
用户栈如图6.4-3所示,栈底放置以null结尾的环境变量字符串和以null命令行字符串,接下来就是指针数组,然后是系统启动函数libc_start_main。argc存放在%rdi中,其值代表参数个数,在hello的运行过程中,argc应该为4;argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串,argv存放在%rsi中,在hello的运行过程中,argv[0] ~ argv[3]分别指向./hello、学号、姓名和睡眠时间,见图6.4-1;envp变量也指向一个以null结尾的指针数组,其中每个指针都指向一个环境变量字符串,见图6.4-2。
图6.4-1 mian函数的参数列表
图6.4-2 进程的环境变量列表
图6.4-3 hello进程的用户栈
6.5 Hello的进程执行
6.5.1 概念介绍:
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
图6.5-1 时间片图示
上下文信息:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中成为调度器的代码处理的。但内核选择了一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种成为上下文切换的机制来将控制转移到新的进程。
上下文切换: 保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,将控制传递给这个新恢复的进程。 中断和系统调用可能会引发上下文切换。
图6.5-2 上下文切换图示
用户模式与内核模式:处理器通常时用某个控制寄存器的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中任何内存位置。
没有设置模式位时,进程就运行在用户模式中,用户程序必须通过系统调用接口间接的访问内核代码和数据。
用户态与核心态的转换:进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或陷入系统调用之类的异常。当异常发生时,控制传递给异常处理程序,处理器将从用户模式变为内核模式。处理程序运行在内核模式中。当它返回到应用程序代码时,处理器由内核模式改回到用户模式。
6.5.2 hello的进程执行:
内核调度hello进程,CPU会发现虚拟页并未被缓存,因此会出现缺页异常,于是进程调用缺页处理程序,此时进程由用户模式转换为内核模式,陷入内核,等到缺页处理程序运行完毕,会通过中断信号返回到导致缺页异常的指令重新执行,此时,进程由内核模式恢复为用户模式。
图6.5-3 缺页故障的处理流程
接下来程序会执行sleep函数,sleep是一个系统调用,它会导致进程陷入内核,显式地请求让调用进程休眠,在休眠的过程中,内核执行上下文切换,转而执行其他进程,等到sleep返回,内核再次调度hello进程,恢复其上下文,进程从内核模式回到用户模式,从sleep的下一条指令继续运行。
后面运行到getchar函数,getchar函数是通过read函数实现的,会使进程陷入系统调用,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,由于取数据需要一段较长的时间,所以内核执行上下文切换,等到数据被读入缓存区,发出中断信号,内核重新调度hello进程,恢复其上下文,继续运行hello进程。
图6.5-4 调用getchar引发的上下文切换图示
6.6 hello的异常与信号处理
异常种类 | 原因 | 异步/同步 | 返回行为 | 处理方法 |
中断 | 来自I/O设备的信号 | 异步 | 返回到下一条指令 | 调用异常处理程序。 |
故障 | 潜在可恢复的错误 | 同步 | 返回到当前指令或者不返回 | |
陷阱 | 有意的异常 | 同步 | 返回到下一条指令 | |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
表6.6-1 hello执行过程中会出现的异常种类
信号名称 | 默认行为 | 相应事件 | 处理方法 |
SIGINT | 终止 | 来自键盘的中断 | 若检测到有未被阻塞的待处理信号,调用信号处理程序。 |
SIGILL | 终止 | 非法指令 | |
SIGKILL | 终止 | 杀死程序 | |
SIGCHLD | 忽略 | 一个子进程停止或终止 | |
SIGCONT | 忽略 | 若进程停止,继续该进程 | |
SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 | |
SIGSTOP | 停止直到下一个SIGCONT | 不是来自终端的停止信号 |
表6.6-2 hello执行过程中会产生的信号
6.6.1 乱按键盘(不包括回车、ctrl-z和ctrl-c)
图6.6-1 乱按键盘(不按回车、ctrl-z和ctrl-c)的运行结果
可以看到在运行过程中按键盘,程序仍然能正常运行和正常结束。
出现的异常 |
|
|
产生的信号 | hello进程结束产生的SIGCHLD信号 | 忽略 |
表6.6-3 hello执行过程中乱按键盘中产生的信号和异常
按回车
图6.6-2 乱按键盘(按回车)
可以看到在程序运行时,键盘的输入都会进入缓存区中,由于在程序运行过程中,往缓存区里面输入了asd和回车,所以getchar不需要再输入,而是直接读取缓存区中的asd,此时缓存区以rrr+回车开始,于是他被当作命令行输入到了shell中,然后缓存区中还有一个回车,也被输入到了shell中。
由此可以推测在6.6.1中,所有在过程中键入的值都被存放在缓冲区中,只是没有回车,所以getchar的读取不会停下,因此,键入回车后,getchar读取的字符串就是在6.6.1中输入的所有字符。
出现的异常 |
|
|
产生的信号 | hello进程结束产生的SIGCHLD信号 | 忽略 |
表6.6-4 hello执行过程中按回车
按ctrl-c
图6.6-3 键入ctrl-c后的进程状态
可以看到,按ctrl-c后,进程直接终止,是因为ctrl-c的键入产生了SIGINT信号,该信号的默认行为是终止进程。
出现的异常 | 1、缺页故障 2、来自I/O信号的中断 3、系统调用 4、终止 |
|
产生的信号 |
| 1、忽略 2、终止 |
表6.6-5 hello执行过程中按ctrl-c
按ctrl-z
图6.6-4 键入ctrl-z后的进程状态
按ctrl-z后,产生了SIGTSTP信号,导致进程停止,而由于进程停止,又会产生一个SIGCHLD信号。
使用ps命令查看当前shell的进程信息:
图6.6-5 ps展示的进程信息
使用jobs查看hello进程:
图6.6-6 jobs展示的hello信息
通过ps和jobs可以看到,进程并没有消失,只是被暂时挂起。
输入fg命令:
图6.6-7 使用fg指令让hello继续运行
输入fg之后,内核调度该进程继续执行,发出SIGCONT信号。
使用kill信号杀死进程:
图6.6-8 利用kill给hello发送一个SIGKILL信号
通过ps中得到的hello的PID,给这个PID发送一个kill -9(SIGKILL)信号,此时再按下ctrl-c发现shell显示这个进程的状态从stopped变成了killed,表示这个进程已被杀死。
出现的异常 | 1、缺页故障 2、来自I/O信号的中断 3、系统调用
|
3、陷阱处理程序 |
产生的信号 | 1、hello进程结束和停止时产生的SIGCHLD信号 2、ctrl-c产生的SIGINT信号 3、kill指令发送的SIGKILL信号 4、ctrl-z产生的SIGTSTP信号 5、fg使进程恢复产生的SIGCONT信号 | 1、忽略 2、终止 3、终止 4、停止 5、忽略 |
表6.6-6 hello执行过程中按ctrl-z
6.7本章小结
本章介绍了shell通过fork创建子进程、通过execve使hello程序在子进程中的加载并执行、hello的运行过程以及过程中发生的异常和产生的信号。同时还给出了进程、时间片、调度和上下文等重要概念的定义和作用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
线性地址:指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说段中的偏移地址,加上相应段基址就成了一个线性地址。
虚拟地址:程序访问存储器所使用的逻辑地址就是虚拟地址。如图7.1-2中的指令地址都是虚拟地址。
物理地址:指内存中物理单元的集合,他是地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址来存取主存。
逻辑地址:由程序产生的段内偏移地址。如图7.1-1中相对于DS数据段的偏移量和相对于CS代码段的偏移量等。
图7.1-1 hello中的逻辑地址
图7.1-2 hello中的虚拟地址
7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器中存放段选择符,段选择符结构如图7.2-1所示:
图7.2-1 段选择符
段选择符由索引、TI和RPL组成。
其中TI决定描述符表的选取,若TI为0,则选择全局描述符表GDT;TI为1,则选择局部描述符表LDT。
RPL表示CPU当前的特权级,若RPL为00,则为最高级的内核态;若RPL为11,则为最低级的用户态。
索引部分为13位,它表示当前使用的段描述符在对应的描述符表中的位置。
逻辑地址到线性地址的转换:
如图7.2-2所示,首先从段寄存器中取出段选择符,由段选择符来确定要使用的段描述符,然后将被选中的段描述符送到描述符cache中,从cache中取出32位段基址,与32位偏移量(有效地址)相加得到32位线性地址。
图7.2-2 逻辑地址转换为线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
此处提到的线性地址就是虚拟地址,虚拟地址到物理地址的转换要通过页表来实现。
图7.3-1 页表
页表是一个页表条目(PTE)的数组,虚拟地址空间的每个页在页表中一个固定偏移量处都有一个PTE。
PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在主存中,若有效位为1,那么地址字段就表示DRAM中对应的物理页的起始位置,这个物理页中缓存了该虚拟页;若有效位为0,如果地址字段为一个空地址,那么表示这个虚拟页没有被分配,如果地址字段指向该虚拟页在磁盘上的起始位置,就说明虚拟页被分配但未被缓存。
地址翻译是由CPU中的内存管理单元MMU实现的。CPU中的一个控制寄存器,页表基址寄存器指向页表的首地址
虚拟地址由n位组成,对于一个大小为2^p的页面而言,虚拟地址可以分为p位的虚拟页偏移量(VPO)和(n-p)位的虚拟页号(VPN)。
图7.3-2 虚拟地址的组成
了解了页表和虚拟地址,接下来就可以进行地址翻译了。
如图7.3-3所示,给定一个要访问的虚拟地址,从虚拟地址中取出虚拟页号,MMU会利用VPN和页表基址寄存器来选择正确的PTE,若该PTE对应的虚拟页已被缓存到主存中,即可直接读出其地址字段,见图7.3-4;若该虚拟页未被缓存,那么需要缺页处理程序更新PTE,将其缓存入主存中,再读取其地址字段,见图7.3-5。
至此已经得到了物理页号PPN,接下来将虚拟地址中的VPO当作PPO直接填写到PPN后即可得到一个完整的物理内存,至此,地址翻译完成。
图7.3-3 虚拟地址到物理地址的地址翻译
图7.3-4 页面命中的地址翻译
图7.3-5 发生缺页的地址翻译过程
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 TLB
TLB是MMU中所包含的一个关于PTE的小缓存,称为翻译后备缓冲器。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着由单个PTE组成的块,TLB通常有高度的相连度。
图7.4-1 TLB的块的组成
如图7.4-1就是TLB中的块的结构,其实每个块都是一个完整的PTE组成的,它依然被分成VPO和VPN两部分,不同的是,VPN还要被分成标记位和索引位,假设TLB有T=2^t个组,那么索引就需要t位,剩下的作为tag位。
MMU访问TLB时,使用虚拟地址的VPN部分得到索引以确定需要的PTE在TLB的哪个组中,接下来到TLB对应的组中寻找PTE,在此过程中要注意找到的块有效位要被设为1,标记位与VPN中的完全相同,若找到满足条件的块,那么TLB命中;若没有找到,则不命中,就需要从主存或者缓存中找到该PTE,将其放入TLB中。
图7.4-2 TLB命中
图7.4-3 TLB未命中
TLB不命中引发了额外的内存访问,但是一般而言,由于局部性的存在,TLB不命中很少发生。
7.4.2 四级页表
图7.4-4 多级页表示意图
对于k级页表,虚拟地址的VPO和VPN的划分规则不变,但是VPN要被分成k个,每一个VPNi都是一个到第i级页表的索引,VPO与PPO仍然相同。
了解了多级页表,使用四级页表进行地址翻译:
图7.4-5 Core i7的四级页表示意图
图7.4-5中所示的虚拟地址为48位,由于虚拟页面的大小为4K,所以VPO占12位,剩下的36位为VPN,又因为是四级页表,所以VPNi应该占9位,分别对应到第i级页表中相应PTE的索引。
图7.4-6 四级页表的地址翻译操作
CPU给出虚拟地址,取出VPN,到TLB中查找对应的PTE,若命中,取出PPN,将其与PPO结合生成物理地址。若TLB未命中,到四级页表中寻址,通过VPNi在第i级页表中找到对应的PTB,直到找到第4级页表中的PTE,将从这4级页表中找到的四个PTE作为一组放入TLB中,读出第四级页表中找到的PTE的地址字段,生成物理地址。
7.5 三级Cache支持下的物理内存访问
图7.5-1 三级cache的物理结构图
三级cacheL1、L2和L3都是组相联的高速缓存,所以在此分析组相联的高速缓存的工作原理。
首先要了解高速缓存中块的构成以及它与内存地址的关系
图7.5-2 数据的地址
图7.5-3 高速缓存中的块结构
在高速缓存的理论当中,将地址划分为三块,分别为标记位、组索引和块偏移。其中块偏移的位数b取决于高速缓存中的块大小B=2^b,它的作用是表明数据开始的偏移量,组索引的位数s取决于高速缓存的组数S=2^s,它的作用是确认数据放置的组号,确定了块偏移和组索引之后,剩下的部分全部作为tag位,用于和高速缓存中的块进行比较。
而高速缓存中的块也分为三部分,分别是有效位、标记位tag和块内容。其中有效位为0表示该块无效,该位置还没有缓存块,有效位为1表示该块为缓存块,标记位tag用来和地址中提到的tag进行比较,若有效位相同且有效位为1,则命中,块内容是用来读取内存的,地址中给出了块偏移,它标志着从何处开始读取数据。例如图7.5-4中的例子,其中块偏移为3位,其值为100,高速缓存块中对应的内容应该有2^3=8位(记为0,1,2,…,7),在本例中应该从第4位开始读取数据。
图7.5-4 从高速缓存块中读取内存数据
接下来从完整的高速缓存中模拟数据的读取过程:
图7.5-5 E-路组相联高速缓存
首先获得要读取的数据的地址,划分好标志位、组索引和块偏移。
先找到组索引,为1,所以应该从1组中查找,1组中有两个块,先通过有效位找到有效块,然后比较有效块的tag位是否和地址中划分出的tag相等,若相等,则命中,根据偏移量读出数据即可。
不命中的处理方法:
如果对应组中的所有块均不匹配,则说明发生了不命中,此时应该根据要操作的数据的地址中的组索引找到该组,看是否还有空闲块,若有,直接将其放入,有效位设为1;若没有,则需要驱逐一个块,放入该块。
7.6 hello进程fork时的内存映射
当fork函数被进程调用时,内核为新进程创建了各种数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中任意一个进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有地址空间的抽象概念。
图7.6-1 内存映像
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行hello,用hello替代了当前程序,加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7-1概括了私有区域的不同映射。
- 映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7-1 加载器映射到用户地址空间区域
7.8 缺页故障与缺页中断处理
在7.3中已经提到过,当要使用的虚拟地址对应的PTE的有效位为0时,说明该页面没有缓存到主存中,就发生了缺页异常,于是调用缺页异常处理程序。
缺页处理程序会选择一个牺牲页,若其内容发生改变,将其复制到磁盘中,更新PTE,将牺牲页的PTE的地址字段更新为牺牲页在磁盘中的起始地址,有效位置为0,将缺少的页的PTE的地址字段更新为缓存页的起始位置,也就是对应的PPN,将有效位置为1,缺页处理程序返回。
返回到导致缺页异常的指令再次运行,重新进行地址翻译。
图7.7-1 发生缺页的地址翻译过程
7.9动态存储分配管理
7.9.1 动态内存分配器原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,堆是一个请求二进制零的区域。
分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配,空闲块保持空闲,直到它显式地被应用分配。
分配器有两种基本风格:显式分配器、隐式分配器。
显式分配器要求应用显式地释放任何已分配的块。
隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。
分配器的要求:
1、处理任意请求序列
2、立即响应请求
3、只使用堆
4、对齐要求
5、不修改已分配块
7.9.2 带边界标签的隐式空闲链表分配器原理
一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的,脚部是头部的一个副本。
头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。块的格式如图所示,空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构就隐式空闲链表。
(1)放置已分配的块
当一个应用请求一个p字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
(2)分割空闲块
一旦分配器找到一个匹配的空闲块,就必须决定分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分仍然为空闲块
(3)申请额外堆内存
如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
(4)合并空闲块
合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。
图7.9-1 隐式空闲链表块结构
7.9.3显式空闲链表的基本原理
显示空闲链表是将空闲块组织为某种形式的显式数据结构。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
一种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
按照地址顺序来维护链表,其中链 表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要 线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比 LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
图7.9-2 显式空闲链表的块结构
7.10本章小结
本章介绍了存储地址、地址转换、段式管理和页式管理,还涉及了四级页表以及三级cache的结构和工作原理,并且从虚拟内存的角度分析了fork和execve的内存映射,最后还简介了动态内存分配的原理以及管理方式。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:
B0,B1,…,Bk,……,Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,成为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口简述:
1.打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUT FILENO和STDERR_ FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。
4.读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of- file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似地,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 函数简述
8.2.2.1 open函数:
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。
图8.2-1 open函数的函数原型
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:
1)O_RDONLY:只读
2)O_WRONLY:只写
3)O_RDWR:可读可写
8.2.2.2 read和write函数:
应用程序是通过分别调用read和write函数来执行输入和输出的。
图8.2-2 read函数和write函数的函数原型
read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
8.2.2.3 close函数:
进程通过调用close函数关闭一个打开的文件。
图8.2-3 close函数的函数原型
关闭一个已关闭的描述符会出错。
8.2.2.4 lseek函数:
应用程序能够通过调用lseek函数显示地修改当前文件的位置。
8.3 printf的实现分析
先看printf:
图8.3-1 printf的实现代码
va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义:
typedef char *va_list
这说明它是一个字符指针。
其中的(char*)(&fmt) + 4)表示的是...中的第一个参数。
再看vsprintf:
图8.3-2 vsprintf代码
vsprintf返回的是一个长度,返回的是要打印出来的字符串的长度
所以说:vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出,其中这段代码中的vsprintf只实现了对16进制的格式化。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回,返回值为第一个字符的ascll码或者是EOF。
getchar有一个int型的返回值。当getchar被调用时时,程序等待用户输入。用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从输入流中一个字符一个字符的读入。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾EOF则返回-1,且将用户输入的字符回显到屏幕。
如过用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar来读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
图8.4-1 getchar函数的实现代码
键盘中断的处理:键盘中断处理程序,接受按键扫描码转成ascll码,保存到系统的键盘缓冲区。
8.5本章小结
本章主要介绍了Linux下IO设备和Unix I/O接口以及与I/O相关的系统级函数的函数原型,之后又着重讲述了printf和getchar这两个函数的实现。
具体方法 | |
预处理 | 预处理器cpp将hello.c文件进行预处理,得到hello.i文件。 |
编译 | 编译器ccl编译hello.i文件,得到hello.s文件,变成汇编语言。 |
汇编: | 汇编器as将hello.s进行汇编,得到hello.o文件,该文件是可重定位的目标程序,是一个二进制文件,存储机器指令。 |
链接 | 链接器通过链接将多个.o文件合并,生成可执行文件hello。 |
运行 | 通过./hello使shell运行hello程序。 |
创建子进程 | 父进程通过fork,创建一个新的子进程。 |
加载并执行 | 在子进程中通过execve加载hello,内核分配时间片,调度该进程。 |
信号和异常 | 在运行过程中会出现异常和信号,利用异常处理程序和信号处理程序进行相应处理 |
回收 | 程序最后正常退出,子进程发出SIGCHLD,最终被父进程回收。 |
结论
对计算机系统的设计与实现的深切感悟:
麻雀虽小五脏俱全,即使是一个再简单不过的程序,它的运行也包含了众多操作,需要软硬件配合、内核与操作系统协作。
计算机系统的设计与实现蕴含了多年以来众多技术人员的经验与智慧,通过不断地完善发展,才有了现在比较完备的体系。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 文件作用 |
hello.c | 源文件,其中保存着源代码。 |
hello.i | 预处理器处理后的文件,用于编译。 |
hello.s | 编译器编译后的文本,用于汇编。 |
hello.o | 由汇编器生成的可重定位目标程序,用于链接生成可执行程序。 |
hello | 最终生成的可执行文件。 |
hellosdump.s | 由hello.o文件反汇编所得 |
hellodump.s | 由hello反汇编所得 |
表 中间产物的文件名及其作用
参考文献
[1] https://www.cnblogs.com/pianist/p/3315801.html
[2] https://blog.csdn.net/
[3] 《深入了解计算机系统》原书第3版