计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与工程
学 号 1181910504
班 级 1803010
学 生 倪煜昕
指 导 教 师 史先俊
计算机科学与技术学院
2019年12月
摘 要
本文介绍了hello在计算机系统中灿烂的一生,借助Linux环境下开发工具,从源文件hello.c经预处理、编译、汇编及链接成为可执行文件hello,再通过shell的动态链接执行hello进程的故事。在shell执行进程的过程中,还高度详细概括了hello的内存管理、进程管理以及I/O管理等深层次理解,更深入到异常控制流、储存层次结构、动态链接机制以及物理与虚拟内存等更内层的程序执行内容。下来让我们走进这个hello辉煌的一生…
关键词:hello;预处理;编译;汇编;链接;内存管理;进程管理;I/O管理;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.5 三级Cache支持下的物理内存访问 - 11 -
7.6 hello进程fork时的内存映射 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
7.8 缺页故障与缺页中断处理 - 11 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
P2P:From Program to Process
从源文件到目标文件的转化是由编译器驱动程序完成的,用高级文件编写hello.c文件,GCC编译器驱动程序读取源文件hello.c,并把它编译成一个可执行目标文件hello。这个编译可分为四个阶段完成。首先,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,将hello.c文件转化为hello.i;而后通过编译器(ccl)将文本文件hello.i翻译成为文本文件hello.s汇编程序,它 包含一个汇编语言程序;再用汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中;最后,由于hello程序调用了printf函数(属于标准C库的一个函数),因此要将含printf函数的printf.o用链接器(ld)合并到我们hello.o程序中,结果得到hello文件(可执行文件)。
Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,至此,hello.c完成了P2P的过程。
O2O: From Zero-0 to Zero-0
shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码段、数据、bss以及栈区域创建新的区域结构,然后映射虚拟内存,设置程序计数器,使之指向代码区域的入口点,进入程序入口后程序开始载入物理内存,而后进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell父进程会回收hello进程,并且内核会从系统中删除hello所有痕迹,至此,hello完成O2O的过程。
1.2 环境与工具
硬件环境:X64 CPU ,2.50GHz , 8G RAM
软件环境:Windows 10 64位 ,Vmware 14 ,Ubuntu 16.04 LTS 64 位
开发工具:gcc + gedit , Codeblocks , gdb edb
1.3 中间结果
文件名称 文件作用
hello.i 预处理之后文本文件
hello.s 编译之后的汇编文件
hello.o 汇编之后的可重定位目标执行
hello 链接之后的可执行目标文件
helloo.objdmp Hello.o 的反汇编代码
helloo.elf Hello.o 的 ELF 格式
hello.objdmp Hello 的反汇编代码
hello.elf Hellode ELF 格式
1.4 本章小结
通过hello的自述,经过了编写、预处理、编译、汇编、链接和执行等阶段,详细的说明了计算机系统体系多个分系统的具体功能,以及体现了通过及具体功能相互协同合作从而实现的计算机系统工作方式。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,读取头文件stdio.h的内容,并把它直接插入程序文本中。
预处理的作用:
1.以字符#开头的命令,告诉预处理器读取系统头文件stdio.h的内容,并将其直接插入程序文本中。例如:hello中#include
2.用实际值替换用#define 定义的字符串
3.根据#if 后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
图2.1 调用cpp生成hello.i文件
2.3 Hello的预处理结果解析
图2.2 hello.i文件中文件首部 图2.3 hello.i文件中文件尾部main函数
图2.4 hello.c程序文件
在本章2.1对预处理的概念以及作用的介绍中,说明了在预处理过程中,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,读取头文件stdio.h的内容,并把它直接插入程序文本中。
首先观察图2.4 hello.c程序文件内容,发现hello.c一共有三个头文件,分别为#include
再看图2.3hello.i文件尾部main函数,与图2.4 hello.c程序文件中main函数相对比,发现相同,并无变化。即应征了预处理过程只对头文件进行操作,对例如main函数这些函数符号不做改动。
2.4 本章小结
通过本章对预处理过程的详细分析以及阐述,让我更加深刻的理解了有关计算机系统处理文件的第一步操作——预处理指令的具体步骤以及系统中的具体操作,更好的理解了计算机系统,为今后处理文件有良好帮助。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:
编译,就是把代码转化为汇编指令的过程,并把把预处理完的文件进行一系列语法分析及优化后生成相应的汇编文件。编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
编译的作用:
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,该程序包含函数main的定义,其中每条语句都以一种文本格式描述了一条低级语言指令。编译主要作用除了是将文本文件hello.i翻译成文本文件hello.s之外,还在出现语法错误时给出提示信息。执行过程主要从其中四个阶段进行分析:
1.词法分析:词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
2.语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位。
3.代码优化:代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。
4.生成目标代码:目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
图3.1 将hello.c生成汇编文件的命令
3.3 Hello的编译结果解析
图3.2 汇编文件hello.s
3.3.0声明
观察图3.2汇编文件前几行,首先解释这几行声明:
.file 声明源文件
.text 代码段
.section .rodata 以下是 rodata 节
.align 声明对指令或者数据的存放地址进行对齐的方式
.string: 字符串
.globl 声明一个全局变量
.type 用来指定是函数类型或是对象类型
其它声明:
.size 声明大小
.long 长整型数据
图3.3 hello.c程序文件
3.3.1 数据
hello.s中C语言的数据类型主要有:整数类型、字符串、数组
(1).整数
1)局部变量:
观察图3.3得,int i为局部变量,并且未初始化,在hello.c中用于循环的计数以及条件判断。由图3.2指令movl $0, -4(%rbp)判断得出编译器将i存储在栈上空间-4(%rbp)中。
又发现传入参数 int argc,作为main函数的第一个参数传入,被储存在寄存器%edi之中。通过指令movl %edi, -20(%rbp)得出编译器将argc存储在栈上空间-20(%rbp)中。
2)立即数:
其他整形数据的出现都是以立即数的形式出现的,直接硬编码在汇编代码中。
(2).字符串
观察图3.2,看到前几行中有指令
.LC0:
.string “\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”
.LC1:
.string “Hello %s %s\n”
其中在.LC0中的字符串被编码为UTF-8格式,一个汉字在UTF-8编码中占据3个字节。
第二个字符串为.LC1中的"Hello %s %s”编译时将这两个字符串保存在.roda中。
(3).数组
观察图3.3,在hello中访问了数组argv的三个元素,而后在图3.2中寻找与此对应的指令。
图3.4 .L4.L3
观察图3.4,分析发现,argv数组的起始地址-32(%rbp),在寻找数组中元素的时候,通过指针偏移的方式进行寻找,每个元素之间的指针偏移量为8,故通过对临时寄存器%rax加8来访问argv[1],加16访问argv[2],加24访问argv[3]。
3.3.2赋值
赋值操作只有一处,即在定义整型变量i时,通过指令movl $0, -4(%rbp)对储存在-4(%rbp)处的进行赋值,并将其赋值为0. mov赋值指令有四种,分别为movb、movw、movl、movq,本指令使用的时movl是由于i的数据类型为整型,属于Inetl数据类型中的双字,故代码后缀为l。
3.3.3sizeof
i为整型变量,故为4字节。
argc为整型变量,故为4字节。
*argv为指针型字符串,其中每个元素的sizeof为8字节。
3.3.4算术操作
观察图3.2,发现在hello程序中主要的算数操作:
首先是i的加法运算,在.L4的末尾存在语句addl $1, -4(%rbp),对i进行加1操作,而使用l代码后缀的原因与3.3.2中算解释的原因相同。
另一种加法使用是在对数组argv进行访问时,对%rax进行地址偏移操作,例如.L4中的语句addq $8, %rax等3处,使用q代码后缀的原因是每个元素的sizeof为8字节,属于Inetl数据类型中的四字,故代码后缀为q。
3.3.5关系操作
观察图3.2,如下所示:
.L3:
cmpl $7, -4(%rbp)
在.L3中,将-4(%rbp)与7进行比较,是C语句i<=7的等效,与图3.3中的i<8语句相同。之所以编译结果与C代码有出入,是因为编译器在编译的过程中会对程序进行优化。
.LFB6:
cmpl $4, -20(%rbp)
将4与 -20(%rbp)处的值进行比较,等效与C代码argc!=4。
3.3.6数组/指针操作
通过对字符串指针数组argv寻址的方式来读取参数,在.L4中,通过mov以及add的偏移寻址,对argv的数组元素进行访问。
如3.3.1中所属:观察图3.4,分析发现,argv数组的起始地址-32(%rbp),在寻找数组中元素的时候,通过指针偏移的方式进行寻找,每个元素之间的指针偏移量为8,故通过对临时寄存器%rax加8来访问argv[1],加16访问argv[2],加24访问argv[3]。
3.3.7控制转移
观察图3.2,如下所述:
.LFB6:
cmpl $4, -20(%rbp)
je .L2
结合3.3.5中关系操作的分析,这个控制转移指令等价于hello.c中的C代码
if(argc!=4){
printf(“用法: Hello 学号 姓名 秒数!\n”);
exit(1);
}
中的if跳转指令,这个if跳转指令,设置了略条件码,通过判断ZF零标志得出的结果为0,判断是否跳转暖到.L2中,否则顺序执行下一条语句。
.L3:
cmpl $7, -4(%rbp)
jle .L4
结合3.3.5中关系操作的分析,这个控制转移指令等价于hello.c中的C代码
for(i=0;i<8;i++){
printf(“Hello %s %s\n”,argv[1],argv[2]);
sleep(atoi(argv[3]));
}
的for循环跳转指令,for循环指令通过比较i与7的值,判断是否跳出循环,从而进入.L4的执行。
3.3.8函数操作
函数是一种过程,假设过程P调用过程Q吗Q执行后返回到P,这些动作包含限免一个或多个机制:
1)传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始 地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的 地址。
2)传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回 一个值。
3)分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
1.main函数:
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
2.printf函数:
参数传递:第一次 printf 将%rdi 设置为“Usage: Hello 学号 姓名! \n”字符串的首地址。第二次 printf 设置%rdi 为“Hello %s %s\n” 的首地址,设置%rsi 为 argv[1],%rdx 为 argv[2]。
函数调用:第一次 printf 因为只有一个字符串参数,所以 call puts@PLT;第二次 printf 使用 call printf@PLT。
3.exit函数:
参数传递:传入的参数为1,并将%edi 设置为 1,再执行退出命令
函数调用:if判断条件满足后并在if内容基本执行完成后被调用,对应于汇编 文件中语句call exit@PLT。
4.sleep函数:
参数传递:传入参数argv[3]
函数调用:for循环下被调用,对应于汇编文件中的call sleep@PLT。
5.getchar
函数调用:在main中被调用,对应于汇编文件中的call gethcar@PLT。
3.4 本章小结
本章内容,通过对文本文件hello.c的学习,再次强调了对汇编语言的掌握程度的重要性。
编译器将高级语言编译成汇编语言,在以上的分析过程中,详细的分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,按照不同的数据类型和操作格式,解释了hello.c文件与hello.s文件间的映射关系。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念与作用:汇编器(as)将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位 目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它 包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
图4.1 将hello.c生成hello.o文件的命令
4.3 可重定位目标elf格式
获得 hello.o 文件的 ELF 格式指令:readelf -a hello.o > helloo.elf
得到文件helloo.elf
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
图4.2 hello.o 文件的 ELF 格式
1)ELF Header:
图4.3 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小与字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者可共享的)、鸡西类型(如x86-64)、姐头部表的文件偏移,以及节头部表条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
2)Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
图4.4 ELF节头部表
3)重定位节.rela.text
图4.5 ELF重定位节
偏移量(offset):需要被修改的引用的节的偏移。
信息(info):包括 symbol 和 type 两部分, 其中 symbol 占前 4 个字节, type占后 4 个字节,symbol 代表重定位到的目标在.symtab中的偏移量,type 代表重定位的类型。
类型(type):告知链接器如何修改新的引用。
两种最基本的重定位类型:
R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_64_32 :重定位一个使用32位PC绝对地址的引用。
符号名称(name):重定位目标的名称。
加数(addend):一个有符号常熟,一些类型的重定位要使用它对被修改引用的值做便宜调整。
4).rela.eh_frame:
图4.6 ELF中.rela.eh_frame
用来储存eh_frame 节的重定位信息
5).symtab:.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
图4.7 ELF中的.symtab节
4.4 Hello.o的结果解析
反汇编命令:objdump -d -r hello.o
如下所示:
图4.8 hello.o的反汇编
对比hello.s文件和反汇编代码,除去显示格式之外两者差别不大,主要有以下的差别:
1.操作数:hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。
2.分支转移:观察hello.s,其中跳转指令的操作数是.L2和.L3等段名称,而在反汇编代码中跳转指令之后是相对偏移的地址。
3.函数调用:
在hello.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
4.全局变量访问:
在hello.s文件中,访问rodata,使用段名称+%rip;在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章通过对重定位目标文件转化.elf格式,简介了汇编器对汇编的处理结果,而且通过对hello.s与helloo.elf的对比,分析了机器语言和汇编语言的映射关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序执行的。
链接器使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,二是可以把他分解为更小的更好管理的模块,可以独自修改和编译这些模块。当我们改变其中的一个块时,只需要简单的重新编译他,并重新链接应用,而不必重新编译其它文件。
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.1 Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
生成 hello 程序的 ELF 格式文件指令:readelf -a hello > hello.elf
生成hello.elf文件,打开此文件,开始对照分析。
首先是ELF Header,如图5.2所示;发现可执行目标文件格式类似与可重定位目标文件格式。ELF描述文件总体格式,发现它还包括了程序的入口点,即程序运行时要执行的第一条指令的地址。
图5.2 ELF Header
再看Section Headers,如图5.3;在 ELF 格式文件中,Section Headers 对 hello 中所有的节信息进行了声明,其中包括大小 Size 以及在程序中的偏移量 Offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。
图5.3 Section Headers
除此之外,.text、.rodata、.data节与可重定位目标文件的节是相似的,除了这些节已经被重定位到他们最终的运行时内存地址以外。.init定义了一个小函数,叫做_init,程序的初始代码会调用他。除此之外,由于可执行文件是完全链接的,故也没有了rel节。
5.4 hello的虚拟地址空间
首先,再edb中加载hello,查看edb中的Data Dump 窗口,如图5.4所示:
图5.4 Data Dump 窗口
在图中发现hello加载到虚拟空间的地址范围为0x0401000-0x0402000段。自虚拟地址 0x400000 开始这中间的每个节都如图5.3中ELF文件Section Headers所示。
再查看ELF中的Program Headers节,程序头表告诉链接器运行时加载的内容并提供动态链接的信息。从中看出程序包含七个段:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
图5.5 ELF文件中Program Headers节
通过 Data Dump 查看虚拟地址段 0x600000~0x602000,在 0~fff 空间中,与 0x400000~0x401000 段的存放的程序相同,在 fff 之后存放的是.dynamic~.shstrtab 节。
5.5 链接的重定位过程分析
命令: objdump -d -r hello
objdump -d -r hello.o
图5.5 hello.o的objdump
图5.6 hello的objdump
对比图5.5与图5.6,分析hello与hello.o的不同,得区别:
1.在hello.o中,main函数地址从0开始,即hello.o中保存的都是相对偏移地址;而在hello中main函数0x4010c1开始,即hello中保存的是虚拟内存地址,对hello.o中的地址进行了重定位。
2.ELF描述文件总体格式,发现它还包括了程序的入口点,即程序运行时要执行的第一条指令的地址。除此之外,由于可执行文件是完全链接的,故也没有了rel节。
3.hello可执行目标文件中多出了.init节和.plt段。.init节定义了一个小函数叫做_init,程序的初始化代码会用到他,用于初始化程序执行环境;.plt段是程序执行时的动态链接。所有的重定位条目都被修改为了确定的运行时内存地址。
4.在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数
链接过程:
链接就是链接器ld将各个目标文件组装在一起,就是把.o文件中的各个函数段按照一定规则累积在一起,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
重定位:
在这个步骤中,将核并输入模块。并未每个符号分配运行时的地址。重定位由两步组成:重定位节与符号定义、重定位节中的符号引用。
在重定位节与符号定义这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节,而后,链接器将运行时内存地址赋值给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
在重定位节中的符号引用中,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行地址,这一步依赖hello.o中的重定位条目。
除此之外重定位类型分为两种,分别为R_X86_64_PC32与R_X886_64_32,这两种分别为PC相对寻址与绝对寻址。对于hello.o中使用PC相对寻址的指令使用R_X86_64_PC32类型进行重定位,而对hello.o直接引用地址的指令,采用R_X886_64_32类型进行重定位。
5.6 hello的执行流程
图5.7 使用edb查看的hello
程序名称
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!atoi@plt
hello!sleep@plt
hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave -ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
5.7 Hello的动态链接分析
动态链接是由一个叫做动态链接器的程序来执行的,将共享库目标模块在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。
在global表中,执行之前全为0,执行之后被赋值新的地址;推断其中一个函数为重定位函数,故下一条地址开始应从dl_iniit开始:
观察 dl_init 前后动态链接项目的变化
图5.8 dl_init 前的.got.plt 节
图5.9 dl_init 后的.got.plt 节
可以看到 dl_init 后出现了两个地址,0x7f948f9c9190 和 0x7f948f9b4200。 0x7f948f9b4200应该就是上文提到的动态链接函数的地址,用 Follow address in CPU 查看该地址的内容,发现是动态链接函数。
5.8 本章小结
本章介绍了有关链接的相关知识,通过使用edb以及objdump的使用更好的区分了重定位目标文件与可执行文件之间的区别。通过对hello程序的分析,了解了它的虚拟地址出储存空间结构,还了解了有关链接过程,链接行为等知识。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:进程是计算机科学中对深刻最成功的概念之一,进程是操作系统对一个正在运行的程序的一种抽象,进程的经典定义就是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的沉痼的代码和数据,她的栈、通用目的寄存器的内容,程序计数器、环境变量以及打开文件描述符的集合。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
进程的作用:它提供一个假象,好像我们的程序是系统当前运行的唯一的程序一样。我们的程序好像是独占的使用处理器和内存。处理器好像无间断的一条接一条的执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
像Unix shell和Web服务器这样的程序大量的使用了fork和execve函数。Shell是一个交互型应用级程序,它代表用户运行其它程序。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,代表用户运行程序。
Shell-bash处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
(3)如果不是内部命令,调用fork( )创建新进程/子进程
(4)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(5)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…)等待作业终止后返回。
(6)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
根据shell的处理流程,输入命令执行当前目录下的可执行文件hello,由于命令行参数不是一个内置的shell命令,所以父进程通过调用fork函数创建一个新的运行的子进程。
新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得父进程打开任何文件描述符相同的副本,这意味着当父进程调用fork函数时,子进程可以读写父进程中任何打开的文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。
Fork函数制备调用一次,却返回两次;一次是在调用父进程中,一次是在创建新的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0.因为子进程的pid总是非0,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中进行。
在子进程执行期间,父进程默认选项是显示等待子进程的完成。
接下来 hello 将在 fork 创建的子进程中执行。 流程图如下:
6.4 Hello的execve过程
Hello程序在使用fork创建了子进程之后,在子进程中分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量,调用execve( )执行指定程序。
在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(int argc , char **argv , char *envp);
结合虚拟内存和内存映射过程,详细说明execve函数在当前进程中加载并运行新程序a.out的步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。如果由a.out程序与共享对象链接,比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
图6-1 加载器是如何映射用户地址空间的区域的
6.5 Hello的进程执行
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制, 限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常使用 某个控制寄存器的一个模式位提供两种模式的区分,该寄存器描述了进程当前享 有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
操作系统内核使用一中称为上下文切换的较高层形式的异常控制流来实现多任务:内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进 程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程一打开文件的信息的文件表。
在进程之心的某些实可,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种调度决策,是由内核中调度器的代码处理的。当内核选择一个新的进程时,内核调度了这个过程。在内核调度了一个新的进程运行后,他就抢占当前进程,使用上下文切换机制来控制转移到新的进程,上下文切换的流程是(如图6.2):1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。
图6.2 进程的上下文切换
分析hello的进程调度,hello 在刚开始运行时内核为其保存一个上下文, 进程在用户状态下运行。如果没有异常或中断信号的产生,hello将继续正常地执行。如果遇到了异常或者终端信号,那么内核便会根据上述内容执行上下文切换,将控制转换到其它进程。在执行遇到exit函数时,以status退出状态来终止进程。在hello 运行到 sleep时,sleep函数将当前进程挂起一个指定时间(我外部输入1s),发生上下文切换,将控制转移至另一个进程;当请求的时间量到了的时候,再次发生上下文切换,sleep函数返回0,将控制权在转移回原先挂起的函数。当sleep函数被一个信号中断而过早的返回时,要返回剩下的要休眠的秒数。当hello的循环结束了之后,调用getchar函数,通过键盘缓冲区DMA 传输产生陷阱处理程序请求,并执行上下文切换,将控制转移给其他进程。当完成键盘缓冲区到内存的数据传输后,引发中断信号,内核从其他进程切换回 hello 进程,然后hello执行 return,进程终止。
6.6 hello的异常与信号处理
如图6.3是正常执行 hello 程序的结果,当程序执行完成之后,进程被回收。
图6.3 正常执行hello程序时
如图6.4,是执行过程中在键盘输入ctrl-z的结果,在输入ctrl-z之后,shell 父进程收到 SIGSTP 信号,将hello 进程挂起,通过ps命令观察,得到hello进程并未被回收。
图6.4 正常执行时中止hello程序时
再执行ctrl-z后,分别执行几种指令,并进行观察:
图6.5 输入pstree指令后
如图6.5所示,pstree命令是以树状图显示进程间的关系。
此时可以通过fg 1指令将其调往前台,如图6.6:
图6.6 使用fg指令将hello程序调回前台时
观察图6.6,发现首先打印出执行指令,后执行了之前未执行的剩下四行printf指令。之后输入回车,程序结束,同时回收进程。
再输入kill指令:如图6.7所示:
图6.7 执行ctrl-z后执行kill指令
通过观察图6.7,发现使用kill指令,将原本进行的hello程序终止了,并且回收了程序。
如图6.8,是执行过程中在键盘输入ctrl-c的结果,在输入ctrl-c之后,shell 父进程收到 SIGINT 信号,使用ps查看,发现,信号终止hello程序,并将进程回收。
图6.8 使用ctrl-c终止hello程序
再尝试在运行中乱按,如图6.9所示:
图6.9 运行hello程序时乱按
发现乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
6.7本章小结
本章介绍了hello程序在shell中运行形式,并通过此方式延申至对于进程以及unix环境下终端指令的实验,使得对进程工作原理更加熟悉了解。明白了有关进程创建,管理运行以及终止/上下文切换的各种知识。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址即为程序产生的与段相关的偏移地址部分,是由一个段标识符加上一个指定段内相对地址的偏移量, 表示为 [段标识符:段内偏移量]。
结合hello程序,即为hello.o文件中的地址,就是相对偏移地址。
线性地址:逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。hello中的虚拟内存地址。
虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。CPU在寻址的时候,是按照虚拟地址来寻址。CPU通过生成一个虚拟地址(VA)来访问主存,这个虚拟地址被送到内存之前先转换为适当的物理地址。对应于hello程序即为hello可执行文件转化为objdump时所显示的地址。
物理地址:计算机系统贮存被组织成一个由M个连续字节大小的单元组成的数组,每字节都有一个唯一的地址,即物理地址。对于hello程序为当hello运行时通过MMU将虚拟内存地址映射到内存中的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址空间:段地址:偏移地址
23:8048000 段寄存器(CS等16位):偏移地址(16/32/64)
实模式下: 逻辑地址CS:EA =物理地址CS*16+EA
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址,
段地址+偏移地址=线性地址。
段寄存器(16位),用于存放段选择符
CS(代码段):程序代码所在段
SS(栈段):栈区所在段
DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段,如图7.1所示:
段选择符各字段含义:CS寄存器中的RPL字段表示CPU的当前特权级
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT),RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级,高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
图7.1 段寄存器的含义
段描述符是一种数据结构,实际上就是段表项,分两类:用户的代码段和数据段描述符与系统控制段描述符。
而系统控制段描述符,又分两种:特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符;控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符。
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型:
1.全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
2.局部描述符表LDT:存放某任务(即用户进程)专用的描述符
3.中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
图7.2展示了逻辑地址到线性地址的转化过程:
图7.2 逻辑地址到线性地址的转化过程
其中,被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
概念上而言,虚拟内存被组织为一个由存放在磁盘上的 N 个连续的字节大小 的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上 数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层) 上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM 系 统通过将虚拟内存分割位称为虚拟页的大小固定的块来处理这个问题。每个虚拟 页的大小位 P = 2p 字节。类似地,物理内存被分割为物理页,大小也为 P 字节。
在任意时刻,虚拟页面的集合都被分为三个不相交的子集:已缓存、未缓存和未分配。
非分配:VM系统还未分配的页。未分配的块没有任何数据和它们像关联,因此也就不占用任何磁盘空间。
已缓存:当前已经缓存在物理内存中的已分配页。
未缓存的:未缓存在物理内存中的已分配页。
有关三种情况时,虚拟页与物理页的映射情况如下图7.3所示:
图 7.3 虚拟页与物理页
图7.4 页表
如图7.4,页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。虚拟地址空间中每个页在页表中一个固定偏移量处都有一个PTE。为了我们的目的,我们建设每个PTE是由一个有效位和一个 n 位的地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始地址,这个物理页中缓存了该虚拟页,如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的位置。
图7.4中的示例展示了一个由8个虚拟页和四个物理页的系统的页表。四个虚拟页(VP1、VP2、VP4、VP7)当前被缓存在DRAM中,两个页(VP0、VP5)还未被分配,而剩下的页(VP3、VP6)已经被分配了,但是当前还未被缓存。图7.4中还有一个要点要注意,因为DRAM缓存是全相联的,所以任何物理页都可以包含任何虚拟页。
除此之外,图7.5展示了页式管理中虚拟地址到物理地址的转换:
图7.5 基于页表的地址翻译
虚拟地址分为两部分:前一部分为虚拟页号,可以索引到当前进程的的物理页表地址,后一部分为虚拟页偏移量,将来可以直接作为物理页偏移量,页表是一个存放在物理内存中的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。
7.4 TLB与四级页表支持下的VA到PA的变换
为了消除每次 CPU 产生一个虚拟地址,MMU 就查阅一个 PTE 带来的时间开 销,许多系统都在 MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB 的速度快于 L1 cache。
如图7.6,CR3控制今存其指向第一级页表的起始位置。CR3的值时每个进程上下文的一部分,每次上下文切换的时,CR3的值都会被恢复。
图7.6 地址翻译
图7.7给出了第一级,第二级,第三级页表中条目的格式。当P=1,地址字段包含了一个40段位物理页号,它指向适当的页表开始处,这强加了一个物理页面4KB对其的要求。
图7.7 第一级,第二级,第三级页表中条目的格式
图7.8给出了第四级页表中条目的格式。当P=1,地址字段包含了一个40段位物理页号,它指向物理内存中某一页的地址。这强加了一个物理页面4KB对其的要求。
图7.8 第四级页表中条目的格式
在四级页表层次结构的地址翻译中,虚拟地址被划分为 4 个 VPN 和 1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,第 j 级页表中的每个 PTE 都指向第 j+1 级某个页表的基址,第四级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定 PPN 之前,MMU 必须访问四个PTE。如图7.9:
图7.9 四级页表映射
7.5 三级Cache支持下的物理内存访问
每个CPU芯片有四个核,每个核都有自己私有的L1 i-cache、L1 d-cache和L2同意的高速缓存。所有的核共享片上L3统一的高速缓存。这个层次结构中国所有的SRAM告诉缓存储存器 都在CPU芯片上。
L1 Cache 是 8 路 64 组相联。块大小为 64B。 解析前提条件:因为共 64 组,所以需要 6bit CI进行组寻址,因为共有 8 路, 因为块大小为 64B 所以需要 6bit CO 表示数据偏移位置,因为 VA 共 52bit,所以 CT 共 40bit。如图7.10所示:
图7.10 Intel Core i7高速缓存层次结构
获得了物理地址 VA,如图 7.11,使用 CI(后六位再后六 位)进行组索引,每组 8 路,对 8 路的块分别匹配 CT(前 40 位)如果匹配成功且块的 valid 标志位为 1,则命中(hit),根据数据偏移量 CO(后六位)取出数 据返回。 如果没有匹配成功或者匹配成功但是标志位是 1,则不命中(miss),向下一 级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的 放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块, 产生冲突(evict),则采用最近最少使用策略 LFU 进行替换。如图7.11所示:
图7.11 地址翻译
(以下格式自行编排,编辑时删除)
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。在这两个进程中的任一个后来进行写操作时,写时赋值机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve函数在当前进程中建在并运行包含在可执行目标文件ahello中的程序,用hello程序有效代替了当前长须,如图7.12所示,记载并运行hello程序需要以下几个步骤:
1.删除已存在的用户区域:
2.删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域:
为新程序的代码、数据、bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域:
hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC):
execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
图7.11 execve 函数内存映射
7.8 缺页故障与缺页中断处理
缺页故障:用户写内存地址(虚拟地址);该地址对应的物理页不在内存,在磁盘中。如图7.12:
图7.12 缺页故障
缺页异常:
图7.13 缺页异常
如图7.13,
7.9动态存储分配管理
动态内存管理的基本方法与策略:
在程序运行时程序员使用动态内存分配器 (比如 malloc) 获得虚拟内存. 数据结构的大小只有运行时才知道.动态内存分配器维护者一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型分为两种,分别是:
显式分配器: 要求应用显式地释放任何已分配的快
隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块
策略:
1.隐式空闲链表
如图7.14所示:
7.14 用隐式空闲链表法的细节示例
隐式链表法:找到一个空闲块;首次适配 (First fit),从头开始搜索空闲链表,选择第一个 合适的空闲块,可以取总块数 ( 包括已分配和空闲块 ) 的线性时间,在靠近链表起始处留下小空闲块的 “碎片”;下一次适配 (Next fit),和首次适配相似,只是从链表中上一次查询结束的地方开始,比首次适应更快: 避免重复扫描那些无用块,一些研究表明,下一次适配的内存利用率要比首次适配低得多。最佳适配 (Best fit):查询链表,选择一个最好的空闲块,适配,剩余最少空闲空间,保证碎片最小——提高内存利用率,通常运行速度会慢于首次适配。
2.带边界标签的隐式空闲链表
堆及堆中内存块的组织结构如图7.15所示:
图7.15 带边界标签的隐式空闲链表
在内存块中增加 4B 的 Header 和 4B 的 Footer,其中 Header 用于寻找下一个 blcok,Footer 用于寻找上一个 block。Footer 的设计是专门为了合并空闲块方便的。因为 Header 和 Footer 大小已知,所以我们利用 Header 和 Footer 中存放的块大小就可以寻找上下 block。
3.显示空间链表
图7.16 显示空间链表
如图7.16所示,将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表, 在每个空闲块中,都包含一个 pred(前驱)和 succ(后继)指针,使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线 性时间减少到了空闲块数量的线性时间。
维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比 LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率
4.分离链表分配器
如图7.17,分配器维护空闲链表数组,每个大小类一个空闲链表,当分配器需要一个大小为n的块时:搜索相应的空闲链表,其大小要满足m > n
如果找到了合适的块:
拆分块,并将剩余部分插入到适当的可选列表中;
如果找不到合适的块, 就搜索下一个更大的大小类的空闲链表,直到找到为止。
如果空闲链表中没有合适的块:向操作系统请求额外的堆内存 (使用sbrk()),从这个新的堆内存中分配出 n 字节,将剩余部分放置在适当的大小类中.
图7.17 分离链表分配器
7.10本章小结
本章介绍了hello的储存地址空降,通过与 intel Core7 指定环境下分析了VA 到 PA 的变换、物理内存访问, 还介绍了 hello 进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。以hello问开始,很好的了解了计算机系统储存空间的分配以及虚拟空间与物理空间之间的转化方式。
(第7章 2分)
第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接口及其函数
Unix I/O 接口:
打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描述符),用于标识这个文件。程序在只要记录这个描述符便能记录打开文件的所有信息。
2. shell 在进程的开始为其打开三个文件:标准输入、标准输出和标准错误。
3.改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行 seek 操作显式地设置文件的当前位置为 k。
4.读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件, 当 k>=m 时执行读操作会出发一个称为 EOF 的条件,应用程序能检测到这个条件,在文件结尾处并没有明确的 EOF 符号。
5. 关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源, 并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O 函数:
open()函数
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(const char *pathname,int flags,int perms)
参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,
返回值:成功:返回文件描述符;失败:返回-1
close()函数
功能描述:用于关闭一个被打开的的文件
所需头文件: #include
函数原型:int close(int fd)
参数:fd文件描述符
函数返回值:0成功,-1出错
read()函数
功能描述: 从文件读取数据。
所需头文件: #include
函数原型:ssize_t read(int fd, void *buf, size_t count);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回值-1 表示一个错误,0 表示 EOF,否则返回值表示的是实际传送的字节数量。
write()函数
功能描述: write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文 件位置
所需头文件: #include
函数原型:ssize_t write(int fd, void *buf, size_t count);
返回值:写入文件的字节数(成功)或-1(出错)
lseek()函数
功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负
返回值:成功:返回当前位移;失败:返回-1
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;
}
首先 arg 获得第二个不定长参数,即输出的时候格式化串对应的值。
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);
}
vsprintf返回的是要打印出来的字符串的长度,而write,写操作,把buf中的i个元素的值写到终端,在再追踪write函数
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 syscall,查看 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 将字符串中的字节“Hello 1181910504 倪煜昕 1”从寄存器中通过总线复
制到显卡的显存中,显存中存储的是字符的 ASCII 码。
字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存 储到 vram 中。
显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)。 于是我们的打印字符串“Hello 1181910504 倪煜昕”就显示在了屏幕上。
8.4 getchar的实现分析
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在 键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了 Linux 中 I/O 设备的管理方法,Unix I/O 接口和函数,并且做了有关printf 和 getchar 函数的Unix I/O 实现分析。
(第8章1分)
结论
Hello挥一挥手,不带走一片云彩!Hello终于结束了它艰辛的一生。
1.首先用高级语言输出生成了hello.c文件
2.后通过cpp预处理器将hello.c文件根据以#开头的命令,修改原始C程序,都得到了hello.i文件。
3.再编译器ccl的帮助下将文件hello.i翻译成文本文件hello.s,包含一个汇编语言程序。
4.通过汇编器ac将hello.s翻译成机器语言指令,并把这些指令打包成可重定位目标文件,存入文件hello.o。
5.链接器ld将hello.o链接成为一个可执行文件,从这时开hello开始可以被建在到内存中,由系统执行。
6.在shell中输入一个./hello 1181910504 倪煜昕 1,shell 为 hello fork 一个子进程,并在子进程中调用 execve,加载运行 hello。
7.CPU为hello分配内存,将hello从磁盘加载到内存之中。
8.在一个时间片中,hello有自己的CPU资源,顺序执行逻辑控制流。
9.当 CPU 访问 hello 时,请求一个虚拟地址,MMU 把虚拟地址转换成物理地址并通过三级 cache 访存。
10.hello在运行过程中会有异常和信号等,shell 为各种信号提供了各种信号处理程序
11.Unix I/O 帮助 hello 实现了与外接显示设备以及输入设备的连接,即实现了输出到屏幕和从键盘输入的功能。
12.shell父进程回收hello子进程,内核删除为hello创建的所有数据结构
13.Hello程序结束了它光荣的一生。
我的感想:
计算机系统真是一个庞大复杂但又美妙的组织,平时被程序员们忽略的小小hello程序也能在计算机系统这片舞台上绽放出这样绚丽的风采。我伴随着hello程序走完了它的一生,尽自己所能为它写下了这个供世人研读的传记,在hello的一生中,帮助了我太多,带着我走入了我所崇敬的计算机系统奇妙世界,在那里,我们一起度过的时光将永存…
hello,并不是只有CS知道你的生、你的死,你的坎坷,我也同样明白你的心~~
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名称 文件作用
hello.c 源文件
hello.i 预处理之后文本文件
hello.s 编译之后的汇编文件
hello.o 汇编之后的可重定位目标执行
hello 链接之后的可执行目标文件
helloo.elf Hello.o 的 ELF 格式
hello.elf Hellode ELF 格式
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1].博客园 printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
[2].深入理解计算机系统 Randal E.Bryant David R.O’Hallaron 机械工业出版社
[3].博客园 内存管理https://www.cnblogs.com/xavierlee/p/6400230.html
[4].sleep函数: https://www.ibm.com/support/knowledgecenter/zh/SSMKHH_10.0.0
(参考文献0分,缺失 -1分)