当前位置:   article > 正文

计算机系统大作业——程序人生——hello's P2P_外碎片可以用l1l2l3 catch来缓解

外碎片可以用l1l2l3 catch来缓解

计算机系统大作业——程序人生——hello’s P2P
摘 要
摘要:本文通过hello程序从高级语言程序被编译、汇编、链接、运行,从外部存储设备,经过I/O桥,进入到内存,各级cache,最后在I/O中输出,最后被回收的过程描述,诠释了hello,简单却复杂的一生,描述了即使是最简单的程序,却在生命周期中有着同样复杂的经历,从而揭开程序由高级层次到接近底层运行机制的过程。此次大作业结合CSAPP课本相关章节,分析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 -

写在前面:本次是我第一次书写博客,由于时间紧迫并且不熟悉markdown的使用方法,此博客内容完全从word复制而来,没来得及检查一些书写以及排版的错误,希望对大家有所帮助,原谅我的没有精心设计此篇博客。

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
首先一个程序的生命周期是从一个高级C语言程序开始的。在linux操作系统中通过工具GCC由高级语言文件,生成可执行文件。这个过程可一分为四个步骤:1.预处理,预处理器(cpp)根据以字符#开头的的命令,修改原始的c程序将一些库函数的内容插入,并且把一些宏替换带入。结果生成另一个c程序,以i为扩展名。2.编译,编译器(ccl) 接着对其进行编译,将高级语言转换为低一层次的形成机器语言如:mov $1,%rax 这样的hello.s汇编语言文本文件。3.汇编阶段,汇编器as 将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o 中,hello.o 文件是一个二进制文件,它的字节编码是机器预言指令而不是字符。可以使用二进制阅读软件hexedit等查看。4. 链接阶段,在连接阶段,连接器会把我们所调用的库函数单独预编译好了的重定位文件与hello.o进行链接,得到的结果就是可执行文件hello,这时就可以加载到内存并执行了。
以上是从.c到可执行文件的过程,当我们在命令行中输入./hello时,shell(壳)会对命令行进行解释,当我们回车结束时此时程序已经开始执行,首先操作系统会使用fork函数形成一个子进程,分配相应的内存资源,包括CPU的使用权限和虚拟内存等。然后使用execve函数加载进程。至此完成了从程序到进程的转变P2P(From Program to Process)。这时这个fork的进程的上下文已经变成hello的信息。下面CPU会一步一步执行此程序,按照取指、译码、执行、访存、写回、更新PC等微操作,逐步执行目标文件中的程序。同时,CPU使用流水线、进程切换等工作方式实现多进程作业。
同时程序在执行过程中会访问和用到各种内存中的数据,在计算机中存储涉及到的结构也是众多,从最快的cpu中的寄存器,到cache、主存、磁盘、等,并使用页表等辅助存储,实现访存的加速。一步一步的分级有条理的进行,在进程的执行过程中会出现各种信号,Os会进行处理,进程控制,上下文切换,使资源得到最大化的利用。当程序结束之后,会由操作系统进行进程的回收,此时这个程序已经不留任何痕迹,这时实现了020:From Zero-0 to Zero-0。
1.2 环境与工具
硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.
软件环境:Ubuntu18.04. LTS 64位, Windows10 64位
开发与调试工具:gedit,gcc,as,ld,gdb,readelf,HexEdit ,objdump
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1.hello.c 程序的源代码
2.hello.i 预处理之后的文本文件
3.hello.s 汇编后的文本文件
4.hello.o 可重定位文件hello.o
5.hello.out 从hello.o链接而成的可执行文件
6.hello 从源代码直接按照// gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello得到的可执行文件
7.hello.elf 由可重定位目标文件生成的elf文件
8.helloobj.s 由可重定位目标文件hello.o反汇编得到的汇编代码文件
9. hellooutobj.s 由可重定位目标文件hello.out反汇编得到的汇编代码文件
1.4 本章小结
本章我们分析了Hello展开从Program到Process以及0 to 0的大概过程。相当于一个目录的作用对我们大作业所要做的工作有了一个大概的理解。在之后的章节通过对其预处理、编译、汇编、链接的过程,更深入地理解程序由.c源文件到可执行文件的细节及工作方式,以及得到可执行文件之后应该如何操作与处理才能真正的在计算机内部运行起来,对于中途的异常如何处理等等。
(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)根据以#开头的命令,修改原始的C程序。比如hello.c中第6行的#include<stdio.h>命令高速预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件拓展名。
作用:一般来说,预处理阶段主要有三个作用
1.宏定义的替换
在程序编写中会人为定义一些宏,有些如sizeof本事也是一种宏,在预处理阶段,会将这些宏替换为对应的常数值,在编译的时候已经成为常数,如#define max 100
预处理的过程中,将标识符用字符串替代。
2.文件包含
程序中的头文件一般以#include<。。。.h>的形式给出,在预处理过程中会将这些封装好的代码直接插入程序中,在以后的编译中直接作文源文件编译。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i 或 cpp hello.c > hello.i
下图为执行预处理命令后的结果与部分预处理文件的内容:
在这里插入图片描述
图 2.2.1
2.3 Hello的预处理结果解析
在这里插入图片描述
图 2.3.1
打开.i文件们直观地可以发现,代码长度变得极长,由原来的十几行变为现在的所框出的3113行,而我们原来的hello.c也存在于其中,在文本的最末尾从3100行开始的位置。对其中的一函数进行简单的查看可以发现增加了许多带有extern的函数,这些外部函数便是预处理过程中对#include这样的头文件的展开的插入。
在这里插入图片描述
图2.3.2
在这里插入图片描述
图2.3.3
以上两图为一些预处理文件中的内容。

头文件一般包括以下几部分内容:
1.对类型/函数的声明 2.内置(inline)函数的定义
3.宏定义#definne以及以及一些其他的红定义
4.全局变量的定义 5.外部函数以及外部变量的定义
6.有需要的情况还可以包含其他头文件
总结地来说,预处理过程在main函数之前,预处理器(cpp)读取头文件stdio.h 、stdlib.h 、和unistd.h中的内容,三个系统头文件依次展开。比如stdio.h的展开,打开usr/include/stdio.h发现了其中还含有#开头的宏定义等,预处理器会对此继续递归展开,最终的.i程序中没有#define,以及#开头的语句
2.4 本章小结
本章介绍了hello.c的预处理阶段,根据预处理命令得到了修改后的hello.i文本,并且对hello.i程序进行了预处理结果分析与理解,理解了预处理器读取系统头文件中内容,并把它插入程序文本中的过程。
(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
编译的概念:
编译器ccl将文本文件hello.i翻译成文本文件hello.s。编译器中包含一个将我们用高级语言C写的程序按照一定的语法规则分方法翻译成汇编代码的程序,汇编语言相比高级语言更加贴近于机器,这种汇编语言的集合称为指令集,不同的CPU的指令集也不尽相同。一般包括mov赋值指令,jg条件跳转,comjg条件转移,逻辑运算指令等等。同时用编译器编译C程序的时候可以指定编译器的优化等级如-O1,-O2,以及编译选项如-no-pie等,不同的优化等级对程序等处理不同,优化等级越高,程序越符合机器的思维方式,能够最大化利用CPU,但是不利于人的理解。
编译的作用:
编译的主要作用就是将高级语言翻译成汇编语言,不同高级语言经过编译器编译后,都输出为同一汇编语言。在此过程中,编译器将会对程序语法检查以及优化,优化过程较为复杂,比如公共子式提取,循环优化,删除无用代码,最后得到目标代码。
(注意:这里的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序)
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
在这里插入图片描述
图3.2.1
在这里插入图片描述
图 3.2.2
3.3 Hello的编译结果解析

3.3.1汇编代码的解析
在这里插入图片描述
图3.3.1
可以看出相比于.i文件.s文件减少许多内容,可以看到已经变成汇编代码,注意到main之前有一段内容以.开始,这些内容本事不是汇编代码而是对编译器来说起到了类似于标签的内容,
如.file说明了文件的内容是 “hello.c”
.text 代表代码段
.section.rodata是只读代码段
.global main指明标签main是一个可以在其它模块的代码中被访问的全局符号
.align 8 代表采用的对齐方式是8字节
.type main, @function 声明对象类型或函数类型
.string “Hello %s %s\n” 代表字符串类型
3.3.2数据的解析
(1)字符串常量
此汇编代码中涉及到的字符串有两个 如下图3.3.2
第一个.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” 这个是printf传入的格式串,对应于我们的c语言程序就是 在这里插入图片描述
而且字符串使用UTF-8的格式编码的,一个汉字在UTF-8中占三个字节。正好与上面相对应。
第二个.string “Hello %s %s\n”,仍然是printf传入的格式串。对应的是 在这里插入图片描述同时作为格式串的字符串应该在rodata段
在这里插入图片描述
图 3.3.2
(2)整型变量
a.int i 这里的i是一个局部变量作为循环变量来进行计数,编译器将局部变量存放在栈和寄存器中,hello.s程序中i存放在-4(%rbp),占据4字节,对应于汇编语句movl $0, -4(%rbp)
b. int argc 这里的argc是main函数的参数记录传入参数个数,在这里参数用寄存器来传递如下图3.2.2argc 通过寄存器edi传递并放在栈-20(%rbp)处,用于判断是否有足够的4个参数

在这里插入图片描述
图 3.3.3
c. 立即数,在汇编代码中许多整数以立即数的形式出现,立即数以$开头,直接出现在代码段,如本代码中的subq $32, %rsp $4, -20(%rbp) 等,他们可能是本身就以立即数的形式出现在c语言代码中或者在预处理中由于宏替换直接被代换成了立即数
(3)字符串数组
此代码中涉及的变量类型只有整形与字符串,argv[]就是一个字符串数组,argv是一个二重指针,argv中每一个数组元素都是一个指针指向相应的字符串的首地址,由于argv同argc一样是main的参数,因此argv同样使用寄存器来传递如图3.2.3注意两个划线语句,argv是由寄存器rsi传递,存放在栈中的rbp-32的位置,对argv[]数组进行访问的时候就用到了这个二重指针,通过每次+16来实现对argv[]1,argv[]2的首地址的得到,然后间接引用这个地址的内容就可以得到各个字符串的内容。
在这里插入图片描述
图 3.3.4
3.3.3赋值的解析
1.=赋值
在这里用赋值语句进行的赋值只有一句就是对i这个循环变量的赋初值,这个变量在声明的时候并无初值,在栈中分配地址空间,如划线语句在这里插入图片描述
在汇编语句中可以发现由于这个i是局部变量,保存在栈中-4(%rbp)中,因此这里的赋值采用movl指令对栈中的空间进行立即数赋值。
在这里顺便说一下mov指令之后的后缀b代表一个字(8位),w16位,l32位,q64位
3.3.4类型转换的解析
1.这函数中调用了一个库函数atoi,作用是将字符串表示的数转化为实际的浮点数(double),这里出现了一次字符和浮点数的显式转换。属于显式转换。
2.由于转换之后的值作为sleep函数的参数进行调用,让我们来看一下sleep函数的定义,可以看到参数是无符号整型,这里出现了一次隐藏的转换,将浮点double数转换为unsigned int
头文件: #include <unistd.h>
函数:unsigned int sleep (unsigned int seconds);//n秒
此外:int usleep (useconds_t usec);//n微秒
一般来说c语言中的类型转换涉及到的情况比较复杂,不同的数据类型之间存在优先级关系,一般情况下类型的级别从高到低依次是 long double、double、float、unsigned long、long long、unsigned long、long、unsigned int、int,在赋值表达式语句中,计算的最终结果会被转换成赋值变量的类型,一般优先级,范围以及精度高的向低精度范围的类型转换时都能保证正确性,但是相反则很容易出现一些由于精度产生的很严重的错误。
3.3.5算术操作的解析
Linux中算术操作的指令有许多,在书中已经有详细地介绍与解释下面用书上的两张图来具体看一下算术操作指令用法:
imulq S R[%rdx]:R[%rax]SR[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax]S
R[%rax] 无符号全乘法
clto R[%rdx]:R[%rax]符号扩展(R[%rax]) 转换为八字
idivq S R[%rdx]R[%rdx]:R[%rax]modS 有符号除法
R[%rdx]R[%rdx]:R[%rax]÷S
divq S R[%rdx]R[%rdx]:R[%rax]modS 无符号除法
R[%rdx]R[%rdx]:R[%rax]÷S
在这里插入图片描述
图 3.3.5
在hello.s中出现的算术操作指令有三:
a) 加法操作add:
在对计数器加一时addq 这里时在对argv访问时的指针计数器每次+8
在这里插入图片描述
在对循环变量i加一是addl,由于i为int型因此使用addl每次+4
在这里插入图片描述
b)减法操作sub:
减法操作sub:为main函数开辟栈帧是将栈顶指针-0x32
在这里插入图片描述
c) 加载有效地址:将LC0的有效地址传送给%rdi。
在这里插入图片描述
3.3.6关系操作的解析
Cpu中关系操作主要依靠cmp及test系列指令来完成,Cpu维护着一组单个位的条件码,cmp指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,cmp指令与sub指令的行为是一样的。一般在此类指令之后都会跟着jx或检查条件码的操作,也就是通过这样的机制使得操作数的关系操作得以实现。
在这里插入图片描述
图3.3.6
Hello.c中涉及的条件有以下两个高级语句
if(argc!=4) 判断输入参数的个数是否正确 和 for(i=0;i<8;i++)循环判断
对应于hello.s中的语句如下:
在这里插入图片描述
图3.3.7

3.3.7控制转移的解析
C语言中的语句不仅仅是顺序结构执行,某些结构,比如条件语旬、循环语旬和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。一般来说关系操作会和控制转移相关联。常用的条件码如下:
在这里插入图片描述
图3.3.8
Hello这个程序中涉及到控制转移的操作仍然有两个
a) 在这里插入图片描述 这个汇编代码对应于高级语言是if(argc!=4),用cmpl指令将argc(在栈中)与立即数4进行比较,如果不相等则设置条件码ZF标志位,如果相减结果得0,ZF会被设置,je指令根据ZF标志位确定是否需要跳转到L2还是顺序执行下面的指令,从而实现了控制的转移。
b) 在这里插入图片描述 这个汇编代码对应于高级语言是for(i=0;i<8;i++)将循环变量i(存在栈中)与立即数7进行比较,同时会设置条件码,检查条件码如果小于则跳转到循环体内容实现了控制的转移。
在这里插入图片描述
图 3.3.9整个循环过程的控制转移汇编代码
3.3.8数组指针的解析
数组与指针的联系十分紧密,首先数组的名就是一个指针,对于数组的访问本质上也是通过指针来实现,同时字符串就是一个字符数组,但是都会通过指针来访问,在此函数中涉及到的数组只有一个argv[]这是命令行参数数组,每一个元素都是一个字符串,因此argv这个变量是一个二重指针,argv中每一个数组元素都是一个指针指向相应的字符串的首地址,而且这些指针存在栈中,当要访问这个字符串时需要先访问栈中的这个指针,再通过这个指针间接寻址访问这个字符串。下面是有关于字符串数组的一些汇编代码。
在这里插入图片描述
图3.3.10
3.3.9函数的解析
C语言中的用函数这个功能使得一个大的工程或问题可以得到细化,分而治之,使得主函数变得精简而优雅。函数操作涉及到参数传递(地址/值)、函数调用()、函数返回 return等。在汇编代码中涉及到函数的调用与返回的指令是call与ret,函数的调用一般与运行时栈联系紧密
在这里插入图片描述
图3.3.11
在过程P调用过程Q时,可以看到当Q在执行时, P以及所有在向上追溯到p的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。
另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当p返回时,这些信息会释放掉。

1)参数的传递,对于一个64位的系统,前六个参数的传递是通过寄存器来传递分别是 rdi rsi rdx rcx r8 r9,如果再多的参数需要在通过压栈传递,先出现的参数后压栈,如图中所示,第七个参数离上一个栈帧最远。
2)返回地址压栈,由于在调用过后会返回后调用者的地址处,因此这个返回地址非常重要,需要在参数的处理之后马上将这个地址压栈,当执行指令ret时直接到这个地址处去
3)call和ret 的含义,call这个指令的等同于push返回地址与jmp到所调用的函数两条语句 ,而ret则是pop这个地址然后jmp 到这个返回地址去执行。
4)返回return 一般来说,函数的返回值由寄存器rax来返回传递。
在hello.s中所涉及的函数有:
a) main函数:
调用:main 函数因为被调用 call 才能执行(被系统启动函数 __libc_start_main 调用),call 指令将下一条指令的地址 dest 压栈,然后跳转到 main 函数。
参数传递:main函数有两个参数argc argv argc代表命令行的个数,而argv代表命令行字符串,是一个字符串数组
栈帧的构建与释放:使用%rbp 记录栈帧的底,作为栈的基指针,函数分配栈帧空间 在%rbp 之上,程序结束时,调用 leave 指令,leave 相当于 mov %rbp,%rsp,pop %rbp,恢复栈空间为调用之前的状态,然后 ret 返回
b)puts函数
在这里插入图片描述
参数:所输入的字符串的首地,这里是.L0(%rip)
调用:call puts@PLT
c)exit函数
在这里插入图片描述
参数:一个整型数字,这里是1
调用:call exit@PLT
d) printf函数
在这里插入图片描述
参数:所要输出的字符串的首地址(指针),格式串%s %s
调用:call printf@PLT
e)atoi函数
在这里插入图片描述
参数:所用进行转换的字符串
返回值:一个double型的浮点数
调用:call atoi@PLT
f)sleep函数
在这里插入图片描述
参数:unsigned int seconds 一个无符号整型代表秒数
返回值:剩余没有执行完的睡眠时间。
调用: call sleep@PLT
g)getchar函数
在这里插入图片描述
参数:无
调用:call getchar@PLT

此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。

3.4 本章小结
编译的作用主要就是将高级语言转换为更低一级的汇编语言,.i->.o这种汇编语言不是机器语言(二进制),但是可以将不同的高级语言(c c++ java等形式完全不同的)根据编译器的不同类型转换成一些通用的汇编代码,它更加贴近于机器的思维与指令,同时编译器还可以检查高级语言代码的语法正确性,是我们编写代码到执行这个过程中最为重要的一环,同时从高级语言转化为汇编语言这个过程是比较复杂的,编译器需要作出许多我们看不到的调整与优化,而且会根据用户所输入的选项不同,如:gcc -m64 -Og -no-pie -fno-PIC作出不同的编译,同时对c语言中的数据、赋值、类型转换、算术操作、逻辑/位操作、关系操作、数组/指针/结构操作、控制转移、函数操作在汇编语言中都会找到对应的处理与映射。
(第3章2分)

第4章 汇编
4.1 汇编的概念与作用
汇编是汇编器(as)将汇编语言书写的程序翻译成机器语言程序的过程。把这些指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。
这时程序已经由高级语言转换为机器可以明白的二进制代码,这个二进制文件是一个可重定位文件。它的结构与经典的elf文件的结构类似。
汇编算法采用的基本策略是简单的。通常采用两遍扫描源程序的算法。第一遍扫描源程序根据符号的定义和使用,收集符号的有关信息到符号表中,形成符号表节;第二遍利用第一遍收集的符号信息,将源程序中的符号化指令逐条翻译为相应的机器指令。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o 或 gcc -c hello.s -o hello.o
在这里插入图片描述
图4.2.1
应截图,展示汇编过程!
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
在这里插入图片描述
图4.3.1 elf文件的头
使用readelf –h hello.o查看文件头信息 根据文件头的信息,可以知道该文件是可重定位目标文件,有13个节,入口地址为0x0 数据采用小端序 x86-64等信息
在这里插入图片描述
图4.3.2 elf文件的节头表
使用readelf –S hello.o查看节头表 这个表中包含了这个重定位文件中的各个节的名称大小,其中的数据类型,地址,相对于头的偏移量,以及对齐方式以及可以对各个节进行的操作:write 等
在这里插入图片描述
图 4.3.3elf文件的符号表
使用 readelf –s hello.o 可以查看elf文件的符号表信息,可以看到不同的符号的标码,他们的值(如果值有意义),所占空间大小(如main占142字节) 所属类型(全局还是局部),以及最后的符号的名称。
在这里插入图片描述
图4.3.4
首先看一下一个重定位条目的结构,一个重定位条目是由三个long型数据组成,offset代表节偏移,第二个long分成两部分第一部分是重定位类型,即采用哪种方式来进行重定位,第二部分是这个符号在符号表中的标号,第三个long型的有符号数指向所引用的值需要做的偏移调整。
在这里插入图片描述
图4.3.5
使用readelf -r hello.o可以查看elf文件的重定位信息,通过这个重定位表,我们可以得到不同的代码(函数)符号以及数据符号的重定位信息,偏移量指的是相对于节头的偏移,信息包含两个信息(在符号表中的标号以及类型),类型指的是重定位方式,以及最后的符号值。
在这个表中有8个重定位信息,他们分别是对.L0(第一个字符串内容),puts函数,exit函数,.L1(第二个字符串内容),printf函数,atoi函数,sleep函数,getchar函数进行重定位声明。
这里具体讲一下重定位类型中的R_X86_64_PC32 和R_X86_64_32
在这里插入图片描述
图4.3.6
这里的重定位节在之后的链接中会成为对这些函数的映射的具体位置计算的关键
同时书中也给出了重定位的计算方法:
在这里插入图片描述
图4.3.7
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
使用命令objdump -d -r hello.o >helloobj.s将其重定位到一个文件中截图如下:
在这里插入图片描述
图4.4.1
下面具体分析一下二者之间的区别:
在这里插入图片描述
图4.4.2
1)在对全局静态变量(rodata)的访问上,如在调用puts和printf时需要传递一个格式串这个是一个rodata,在编译得到的文件中这种rodata的访问是采用 在这里插入图片描述这种.LC0(%rip)来访问全局变量,而在反汇编的文件中则是 在这里插入图片描述这种方式,此时还没有进行连接的重定位因此是0x0(%rip)。
2)对于分支的跳转的方式上,原来的汇编代码使用L1,L2等lable来标记进行jmp,而在反汇编之后的代码中没有了这些以.开头的标签,变为main+0xXX这类的向对于main的偏移。
3)对函数的调用上,编译得到的文件中函数的调用采用在这里插入图片描述 函数名+@PLT的方式,只是提供了此函数的名字和PLT,代表库函数与位置无关代码而反汇编代码中采用 在这里插入图片描述main+偏移的方式进行调用,这里的机器代码中的重定位的值都是0因为像puts exits getchar等在hello.c中调用的函数都是库函数,库函数由于是动态链接到可执行文件中,因此在编译之后不会得到它的具体偏移量,只有在连接之后把所有的共享库函数全部连接才能确定最终的函数地址。

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
在本章通过使用汇编器(as)将hello.s汇编成二进制的机器指令,形成的是一个重定位目标文件,这个文件可以使用工具readelf来查看与分析,我们查看了文件头、节头段、重定位段、符号表获得了相关节的位置、大小,符号条目以及他们的大小和重定位信息……其次我们将hello.o文件利用objdump进行反汇编同样的得到了一个反汇编文件,将他与编译得到的文件进行比较,并分析了他们在函数调用以及一些符号引用上的差异。
(第4章1分)

第5章 链接
5.1 链接的概念与作用
概念: 链接器(ld)就负责处理将printf.o等c语言库函数的已经单独编译好的目标文件与hello.o进行合并。结果就得到hello文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
作用: 链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。由于链接是由链接器程序执行的,链接器使得分离编译成为可能,这样不仅节省了大量的共享代码所占用的空间,而且使得编译的效率有了很大的提升,向库函数这样就不用再次对它进行编译了。而这对于开发和维护大型的程序具有很重要的意义。
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/7/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out 截图如下:
在这里插入图片描述
图5.2.1
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
与可重定位文件结构类似。ELF头中给出了一个16字节序列,描述了生成该文件系统字的大小和字节顺序。其余的为帮助链接器进行语法分析和解释目标文件的信息,包括ELF文件的大小,节头部的起始位置,程序的入口地点,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小与数量,
使用readelf -a hello >hello.elf生成hello的ELF格式文件。
1.ELF头
在这里插入图片描述
图5.3.1
对比之前用hello.o形成的elf文件发现文件头发生了一些变化:
1)类型发生改变汇编之后是可重定位文件,而现在变成了可执行文件。
2)程序头大小改变,原本没有程序头现在程序头56个字节。
3)程序的入口地址发生改变,原本程序头与程序入口的地址均为0,现在都已经有了确切的地址,指明了程序第一条语句的地址。
4)节头数量与字符串表索引节头改变,elf节的数量改变
2.查看文件头信息
在这里插入图片描述
图5.3.2
原本只有13个节而现在增至28个节,而且已经没有重定位即rel节因为行成可执行文件之后已经完成重定位。多出许多节与我们连接的库函数有关。而且其中包括size大小和在程序中的偏移量offset,因为是已链接的程序,所以根据标出的信息就可以确定程序实际被加载到虚拟地址的地址。
3.符号表
在这里插入图片描述
图5.3.3
符号表中的条目也由原来的17条变为现在的64条,在链接过程中加入了许多新的符号。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
在这里插入图片描述
图5.4.1
通过分析典型的ELF可执行文件目标文件个结构可以知道这些节最后加载到虚拟内存空间为两个段——代码段和数据段,而且这两个段的起始地址可以通过5.3的分析得出。
查看ELF文件中的程序头,这是在程序被执行的时候告诉链接器运行时需要加载的内容并提供动态链接信息。每个表项提供了各段在虚拟地址空间中的大小,位置,访问权限和对齐方式。
在这里插入图片描述
图5.4.2
第一个有R/E(读/执行)权限,开始于内存0x400000处,总共大小是0x890字节,这其中包括ELF头,程序头部表,以及.init、.text和.rodata节。
第二个有RW(读写)权限,开始于内存0x600e00处,总共大小为0x254字节,占内存0x258字节(这里多的字节对应于运行时被初始化为0的.bss节)。

下面使用Edb打开hello程序,通过Data Dump窗口可以查看加载到虚拟地址中的hello程序。
在这里插入图片描述
图5.4.3
在这里插入图片描述
图5.4.4
在这里插入图片描述
在这里插入图片描述
图5.4.5
可以对这个datadump的开始的字节与elf头的magic进行对比可以发现,二者相同,说明程序正是从0x400000开始加载,而这段数据也正描述了整个elf文件的一些总体信息。并且代码段也开始于0x400000。其次我们观察0x600e10开始的地址空间代表了数据段。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
使用objdump反汇编可执行文件:
在这里插入图片描述
图5.5.1
使用objdump反汇编目标文件:
在这里插入图片描述
图5.5.2
总体来说二者的差别在于以下的几处:
1.链接增加新的函数:链接过程中,会从我们所引用的共享库中寻找连接器需要解析的符号(库函数),如puts,exits这类函数会加入到可执行文件的内容中。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于.rodata等全局变量的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
下面具体对一些点进行分析:
通过对比可以发现hello相比于hello.o增加了许多内容,而且这些内容是链接时所加入的,因此hello的但汇编是综合了hello.o与一些目标文件,下面分析这两个反汇编代码的具体不同。
首先来看一下有什么不同,(如上图)在.o文件中main段子执行其他函数的调用时,在注释中的仅仅是对main函数的一个偏移量,而且在代码中相应的偏移值也都是0,如图中所框以及一些类似的指令,这样并不能确定那个函数的位置在哪只能知道是一个函数以及重定位方式,具体要在链接之后才能确定。再看链接之后得到的hello的反汇编中main,如下图
在这里插入图片描述
图5.5.3
链接之后的main段,首先各个指令都有了自己的地址,而这个地址使他们的真实地址,其次在对函数的调用也讲这些偏移进行了填充,我们来具体计算一个地址在0x400653调用了一个call,后面的偏移是cefeffff我们用这条指令的下一条指令地址0x400662与这个有符号数求和得到的就时puts函数的地址也就是4004f0所有指令注释中的内容变成了系统的IO库中的函数名,lea后面跟的偏移量也是正确的偏移量了。接下来的call指令也是一样,在hello中准确的指明了具体调用的函数,而hello.o文件中也只有一个main函数的偏移量。这里充分说明了重定位进行的工作结果,而连接器进行重定位的依据也就是那些.o中的注释
在这里插入图片描述

                  图5.5.4  上面是相同的地方前后的对比
  • 1

下面我们看一下二者之间相差的那些节是什么,main.o只有一个main但是可执行文件却有很多其他的节,这些内容很明显是链接时加入的,这就要对链接的过程进行简要的分析。为了构造可执行文件,链接器先后完成两个主要任务:符号解析和重定位。
1 对各个目标模块中没有定义的变量,在其它目标文件中找到相关的定义
2 把不同目标文件中生成的相同类型的段进行合并
3 把不同目标文件中的变量进行地址重定位
动态链接库:程序在运行的时候才去定位这个库,并且把这个库链接到进程的虚拟地质空间。对于某一个动态链接库而言,所有使用这个库的可执行文件都共享同一块物理地址空间,该物理地址空间在当前动态链接库第一次被链接时加载到内存中。静态链接库:将不同的可重定位模块打包成一个文件,在链接的时候会自动从这个文件中抽取用到的模块。
其中重定位的过程很重要,通过重定位才能完成这些代码段,函数的调用……完成其在虚拟内存中的映射。
在这里插入图片描述
图5.5.5
这是重定位条目,根据这个条目如何计算出来地址在第四章已经进行了讲解,下面举一个具体的例子:
以exit函数为例,我们查看它的重定位信息,偏移量为0x27,我们查看main.o中的这个代码的地址为26,而call指令e8占有一个字节,因此offest正好是0x27对应上,下面我们看refptr如何计算,也就是e8之后的值,r.offest=0x27,r.symbol=exit,r.type=4,r.addent=4, 可以计算出refaddr=addr(main)+offest=0x40065e addr(symbol)=0x400530,根据书上所讲公式refptr=400530-4-40065e=fffffece正好与hello反汇编所得到的值对应。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,我们可以看到在这个函数的调用之后是一系列在这个程序中所用到的库函数(printf,exit,atoi等等)这些函数实际上在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处。之后调用了_start这个就是起始的地址,准备开始执行main的内容,main函数内部所调用的函数在第三章已经进行了充分的分析这里略过main内部的函数,在执行main之后还会执行__libc_csu_init 、__libc_csu_fini 、_fini 最终这个程序才结束。
下面列出了各个函数的名称与地址(有些没有具体进入分析,只体现了hello的执行的一个大概的流程)

  1. _init <0x00000000004004e0>

  2. puts@plt <0x0000000000400510>

  3. printf@plt <0x0000000000400520>

  4. getchar@plt <0x0000000000400530>

  5. atoi@plt <0x0000000000400540>

  6. exit@plt <0x0000000000400550>

  7. sleep@plt <0x0000000000400560>

  8. _start <0x0000000000400570>

  9. _dl_relocate_static_pie <0x00000000004005a0>

  10. deregister_tm_clones <0x00000000004005b0>

  11. register_tm_clones <0x00000000004005e0>

  12. __do_global_dtors_aux <0x0000000000400620>

  13. frame_dummy <0x0000000000400650>

  14. main <0x0000000000400657>

  15. __libc_csu_init <0x00000000004006f0>

  16. __libc_csu_fini <0x0000000000400760>

  17. _fini <0x0000000000400764>
    5.7 Hello的动态链接分析
    动态链接是为了减少共享库函数的代码频繁出现在各个程序中从而占用宝贵而昂贵的内存空间从而提出的一个有效的方法,共享库是一个.so目标模块(elf文件)在加载时由动态链接器程序加载到内存的任意位置并和一个内存中的程序(如当前的可执行文件)动态完全连接为一个可执行程序,使用这个技术可以节省内存与磁盘空间方便库函数的更新与升级。
    这里涉及到有关位置无关代码的知识,程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。
    在这里插入图片描述
    图 5.7.1
    在这里插入图片描述
    下面两个图所示的就是在执行函数dl_init前后,数据段的一些变化
    在这里插入图片描述

                    图5.7.2
    
    • 1

通过对两个图的对比,注意到0x601000开始的地方内发生了变化,由前面的分析可以知道GOT表的位置就在0x601000处,这里执行ld_init之后填入了GOT的信息。
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
5.8 本章小结
这一章主要介绍的是如何从可重定位文件hello.o生成可执行文件hello的过程。从ld链接器将hello.o的链接命令,然后到可执行文件ELF的查看,分析可执行文件的相关信息,hello的重定位过程,执行流程,hello的动态链接分析,进一步加深了对链接以及动态链接过程细节的理解。
(以下格式自行编排,编辑时删除)
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程的经典的定义是一个执行中的程序的实例。它包括存放在内存中的程序代码、数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程是计算机学科中很成功的概念,有了进程的概念我们可以认为不同程序单独占用内存与处理器,我们的指令也按顺序执行。
1.一个独立的逻辑控制流,它提供一个假象,好像我们程序单独使用处理器
2.一个私有的地址空间,它提供一个假象,好像我们的程序单独地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1.shell定义
Linux系统中,Shell是一个交互型应用级程序,提供了用户与内核进行交互操作的一种接口。代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程。
Shell有许多形式1)sh 最早的shell 2)csh/tcsh 变种
3)bash变种、缺省的Linux shell
2.shell的功能
Shell是一种命令解释器,它解释由用户输入的命令并且把它们送到内核去执行。Shell可以解析命令行将其划分为各个字符串存在main的参数argv[]中,对于各个命令,shell负责判断命令的正确性以及分类(内置还是可执行文件等),并execve加载运行这个可执行程序,同时shell还可以处理各种信号,负责回收终止的子程序,防止僵死进程的出现。(以下来自百度百科)除此之外,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。
3.shell的处理流程
(a)终端进程读取用户由键盘输入的命令行。
(b)分析命令行字符串,讲命令行分解以空格为间隙分解,获取命令行参数,并构造传递给execve的argv向量
©检查第一个命令行参数是否是一个内置的shell命令,如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。
(d)如果是一个路径下的可执行文件,调用fork( )创建新进程/子进程
(e)在子进程中,用步骤b获取的参数,调用execve( )执行指定程序。
(f)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…)等待作业终止后返回。
(g)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
fork()函数:父进程通过调用fork函数创建一个新的运行的子进程,子进程几乎与父进程完全一样,得到与父进程相同的用户级虚拟地址空间,代码、数据段、共享库以及用户栈,同时子进程可以读写父进程中打开的任何文件,他们的不同就是有着不同的PID。例如,要是父进程打开了五个文件,那么子进程也有五个打开的文件,而且这些文件的当前读写指针也停在相同的地方。所以,这一步所做的是复制。这样得到的子进程独立于父进程, 具有良好的并发性。同时fork函数还会返回两次,在子进程中返回0,而在父进程中返回子进程的PID,如下面的例子:
pid_t pid;
pid=fork();//fork不需要传递参数。
if(pid==0){ //返回值为0,说明在子进程,下面是子进程中需要做的事
printf("%d%d",getpid(),getppid());
}
else{ //返回值不为0 ,说明在父进程中,下面写父进程中需要做的事
printf("%d",pid);
}
总结来说fork();函数有一下特点:
1).调用一次,返回两次 2).父进程与子进程并发执行 3).相同但是彼此独立的地址空间 4).共享文件
Hello的fork过程:
在终端输入./hello 1180400522 王思博 2因为./hello不是内置命令,而是当前目录下的可执行文件,于是终端调用fork函数在当前进程中创建一个新的子进程,该进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同的副本,包括代码、数据、堆、共享库和用户栈。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行他们的逻辑控制流中的指令。这样一个子进程就创建好了,之后会将可执行程序hello加载到内存中,开始执行了。
6.4 Hello的execve过程
execve函数加载并运行一个可执行目标文件,这里是hello,而且带参数列表argv[]和环境变量列表envp[],只有当找不到可执行目标文件时才会但回到调用程序,否则execve函数会将控制转移到新的可执行文件去执行,与fork函数调用一次返回两次不同,execve函数调用一次从不返回。如下图:
在这里插入图片描述
图6.4.1
Fork与execve不同,fork函数在新的子进程中运行相同的程序,新的紫禁城是父进程的一个复制品,而execve函数在当前的进程上下文中加载并运行一个新程序。他会覆盖当前进程的地址空间,将其替换为这个可执行文件固有的一些信息,而且新的进程仍然有相同的PID。因此一般需要fork和execve配合使用才能加载并运行一个程序。主要进行的操作有:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器PC。
当 fork之后,子进程调用 execve 函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即 hello 程序。execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置 PC 指向_start 地址,_start 最终调用 hello 中的 main 函数。
main开始时,用户栈的组织结构如下图所示:
在这里插入图片描述
图6.4.2

6.5 Hello的进程执行
1.进程在执行时的顺序
在操作系统中,每一个时刻通常有许多程序在进行,但是我们通常会认为每一个进程都独立占用CPU内存以一些其他资源,如果单步调试我们的程序可以发现在执行时一系列的(PC)程序计数器的值,这个PC值的序列就是逻辑控制流,事实上,多个程序在计算机内部执行时,采用并行的方式,他们的执行是交错的,像下图每个程序都交错运行一小会儿,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
在这里插入图片描述
图6.5.1
2.进程的时间片
正是由于进程执行的并发性,在宏观上,我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但是在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,一个进程执行它的控制流的一部分的每一时间段叫做时间片。
3.用户模式与内核模式
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3级)用户代码中运行。
用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式(Kernel Mode)。
用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。当执行应用程序自己的代码时,称为用户模式(User Mode)。
4.上下文与上下文切换
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象比如描述地址空间的页表,包含当前进程有关信息的进程表,以及包含进程以打开文件的信息的文件表构成。
在进程执行到某些时刻,内核可以决定抢占当前进程,并开始一个之前被抢占的进程开始执行,这种决策就叫做调度,由调度器来决定,在内核调度了一个新的进程后就会发生上下文切换的操作。
上下文切换的过程:
1.保存当前进程的上下文
2.恢复现在调度进程的上下文
3.将控制传给新恢复进程
在本次大作业中涉及到的一个很明显的上下文切换是sleep的调用,hell初始运行在用户模式,在 hello 进程调用 sleep 之后陷入内核模式,内核处理休眠请求主动挂起当前进程,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时(我们输入的指定时间)发送一个中断信号,此时进入内核状态执行中断处理,将 hello 进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。如下图:
还有一个上下文切换是在getchar处,这个函数需要从标准输入读取字符,但是其实是调用了系统函数来执行,调用这个函数之后同样会陷入到内核中,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回 hello进程。
在这里插入图片描述
图6.5.2
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

6.6 hello的异常与信号处理
1)正常运行(见图6.6.1)
程序正常执行,总共循环8次每次输出提示信息之后等待我们从命令行输入的秒数,最后需要输入一个字符回车结束程序。
2)中途按下ctrl-Z(见图6.6.2)
内核向前台进程发送一个SIGSTP信号,前台进程被挂起,直到通知它继续的信号到来,继续执行。当按下fg 1 后,输出命令行后,被挂起的进程从暂停处,继续执行。
3)中途按下ctrl-C(见图6.6.3)
内核向前台进程发送一个SIGINT信号,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程,此时子进程不再存在
4)运行途中乱按(见图6.6.4)
运行途中乱按后,只是将乱按的内容输出,程序继续执行,但是我们所输入的内容到第一个回车之前会当做getchar缓冲掉,后面的输入会简单的当做我们即将要执行的命令出现在shell的命令行处。
5)输入ps打印前台进程组(见图6.6.5)
Ps打印当前进程的状态
6)打印进程树(见图6.6.6)
详细显示从开机开始的各个进程父子关系,以一颗树的形式展现。
7)列出jobs当前的任务(见图6.6.7)
jobs,打印进程状态信息
8)输入fg 1,继续执行前台进程1(见图6.6.8)
9)输入kill(见图6.6.9)
Kill之后会根据不同的发送信号的值,以及要发送的进程的pid发送相应的信号,这里我们将hello杀死。
在这里插入图片描述
图6.6.1
在这里插入图片描述
图6.6.2
在这里插入图片描述
图6.6.3
在这里插入图片描述
图6.6.4
在这里插入图片描述
图6.6.5
在这里插入图片描述
图6.6.6
在这里插入图片描述
图6.6.7
在这里插入图片描述
图6.6.8
在这里插入图片描述
图6.6.9
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.7本章小结
本章介绍了hello程序在计算中的加载与运行的过程。计算机为每个程序抽象出一个概念为进程。Hello是以进程的形式运行,每个进程都处在某个进程的上下文中,每个进程也都有属于自己的上下文,用于操作系统通过上下文切换进行进程调度。用户通过shell和操作系统交互,向内核提出请求,shell通过fork函数和execve函数来运行可执行文件。操作系统中有一套异常控制的系统,用于保障程序运行。异常的种类分为较低级的中断,终止,陷阱和故障,还有较高级的上下文切换和信号机制。通过对hello执行过程中对其发送各种信号对各个信号的处理以形式有了更深的理解。

(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址(physical address):用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相相应。是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
逻辑地址(Logical Address):是指由程式产生的和段相关的偏移地址部分。例如,你在进行C语言指针编程中,能读取指针变量本身值(&操作),实际上这个值就是逻辑地址,他是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等。表示为 [段标识符:段内偏移量],举一个具体的例子:DS:0x21785代表的就是代码段中偏移为0x21785的地址。
实模式下:逻辑地址CS:EA =物理地址:CS*16+EA
保护模式下:以段描述符作为下标,到GDT/LDT表查表获得段地址, 段地址+偏移地址=线性地址。
线性地址(Linear Address):是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址(Virtual Address):现在计算机系统提供了一种对主存的抽象概念,叫做虚拟内存。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美结合,他为每个程序提供了一个大的、一致的和私有的地址空间。通过一个很清楚的机制提供了三个重要的能力:1)他将主存看成是一个存储在存盘上的地址空间的高速缓存。2)他为每个进程提供了一致的地址空间,从而简化了内存的管理。3)它保护了每个进程的地址空间不被其他进程破坏。
而虚拟地址就是建立在这样的环境下的概念,进程加载到内存中以64位为例都是从0x400000开始这个地址就是虚拟地址,存在专门的虚拟地址到物理地址的映射的部件:MMU(地址翻译),这样虚拟地址通过MMU,主要是页表机制,完成对物理地址的映射。
在一个程序执行时,CPU对一个物理地址的得到经历了逻辑地址通过段式管理得到线性地址(虚拟地址),在通过地址翻译,即页式管理翻译成物理地址,从物理地址中得到想要访问的数据:这些地址之间的关系如下图所示:
在这里插入图片描述
图7.1.1
7.2 Intel逻辑地址到线性地址的变换-段式管理
段寄存器(16位),用于存放段选择符CS(代码段):程序代码所在段
SS(栈段):栈区所在段DS(数据段):全局静态数据区所在段
其他3个段寄存器ES、GS和FS可指向任意数据段
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
在这里插入图片描述
图7.2.1
CS寄存器中的RPL字段表示CPU的当前特权级(Current Privilege Level,CPL)
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT) RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成。如图所示

在这里插入图片描述
图7.2.2
逻辑地址向线性地址转换:
被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加得到线性地址。
在这里插入图片描述
图7.2.3
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下,虚拟地址到物理地址的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组. 磁盘上数组的内容被缓存在物理内存中 (DRAM cache)这些内存块被称为页 (每个页面的大小为P = 2p字节)。4如图7.3.1所示。
而分页机制的作用就是通过将虚拟和物理内存分页,并且通过MMU建立起相应的映射关系,可以充分利用内存资源,便于管理。一般来说一个页面的标准大小是4KB,有时可以达到 4 MB。而且虚拟页面作为磁盘内容的缓存,有以下的特点:DRAM缓存为全相联,任何虚拟页都可以放置在任何物理页中需要一个更大的映射函数 – 不同于硬件对SRAM缓存更复杂精密的替换算法太复杂且无限制以致无法在硬件上实现DRAM缓存总是使用写回,而不是直写。
页表:
实现从虚拟页到物理页的映射,依靠的是页表,页表就是是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。这个页表是常驻与主存中的,给定一个虚拟地址通过对页表的查询,有可能得到相对应的物理地址,如图7.3.2所示。
地址翻译:
例如32位的虚拟地址分成VPN+VPO,VPN是虚拟页号,这个值用来查询页表,VPO表示页内偏移。一个页面大小是4KB,所以VPO需要20位,VPN需要32-20=12位。CPU中的一个控制寄存器PTBR(页表基址寄存器)指向当前页表,MMU通过VPN来选择PTE(Page Table Entry),PTE(假设有效)中存放的即物理页号(PPN)。VPO和PPO是相同的。所以通过MMU,我们得到了线性地址相应的物理地址。注意页表一定是连续的,如果我们找到的PTE中有效位为0,MMU触发一次异常,调用缺页异常处理程序,程序更新PTE,控制交回原进程,再次执行触发缺页的指令。这就是地址翻译的一个最基本的过程,之后的快表(TLB),多级页表都是在这个原理上对效率进行提高。如图7.3.3所示。
在这里插入图片描述
图7.3.1
在这里插入图片描述
图 7.3.2
在这里插入图片描述
图7.3.3
在这里插入图片描述
图7.3.4页面的命中与缺页
7.4 TLB与四级页表支持下的VA到PA的变换
多级页表:如果在计算机内部页表是将所有的项都全部表示出来并且都存在主存中,那么会出现很多问题,假设:4KB (212) 页面, 48位地址空间, 8字节 PTE
问题:将需要一个大小为 512 GB 的页表!这512GB的页表如果存放在主存中,这笔开销是十分巨大的。但是事实是由于程序良好的局部性和程序的每一个段并不是连续的,如下图所示,中间会有大量的页表的映射是用不上的,即每次我们访问的页面大概率只有几个,有没有什么办法可以加速这个过程呢?为了解决这个问题,我们可以采用页表分级的策略减少常驻内存的页表的开销,依照多级cacahe的原理,将页表进行一级一级缓存。
在这里插入图片描述
图 7.4.1
下图是k级页表的地址翻译方式:
在这里插入图片描述
图7.4.2
TLB快表机制: 每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便得到PA(物理地址),最糟糕的情况,这会要求从内存多取一次数据,代价是几十个到几百个周期,如果对于页表的查找也采取缓存的方式,可能一次就命中这时会大大减小对页表的访问时间。TLB是一个小的,虚拟地址缓存,其中每一行都存放着一个由单个PTE组成的块。TLB通常具有高度的相联度。仍然是利用虚拟地址的VPN项,将VPN分成TLBI和TLBT两部分。如果给定一个有2^t个组的TLB,那么VPN的最低t位即作为TLB的index组索引。剩余为作为tag位,找到组中对应行。
在这里插入图片描述
图 7.4.3

在这里插入图片描述
图 7.4.4
TLB与四级页表支持下的VA到PA的变换:
下面给出结合TLB与多级页表支持下的地址翻译的过程,我们以Corei7下的地址翻译:一些简化的示意来进行分析:
在这里插入图片描述
图7.4.6描述了corei7的内存系统的结构,包括三级cache,四级页表以及二级TLB翻译后备缓冲器。在7.4.7中我们可以很清晰地看出一个虚拟地址是如何经过地址翻译得到对应的实际物理地址,然后访问数据的,首先一个虚拟地址呗分割成几个部分VPN,与VPO与上面所讲的地址翻译内容重复,这里我们更关注的是TLB,以及多级页表机制,这时VPN不是直接当做索引去搜索页表,而是分割成4个部分,对应于四个页表,只有一级页表时常住在内存中,其余的依靠上级的页表得到下一级也表的首地址,在通过VPN的对应位,在这个第k级页表中搜索内容,再结合TLB找到对应的页表项就可以翻译得到PPA,这个就是物理地址的前40位,VPO=PPO,现在利用cache的知识就可以对物理地址进行一级一级的分析与访问了。
在这里插入图片描述
图7.4.6
在这里插入图片描述
图7.4.7
7.5 三级Cache支持下的物理内存访问
在这里插入图片描述
图7.5.1
多级Cache访问策略:
当我们将一个虚拟地址翻译成了一个物理地址之后,下面要做的就是找到这个物理地址的数据并取出给CPU,如下图,一个物理地址可以分成两部分,PPO与PPN,由于内存的组织方式采用多级cache的方式,PPO还可以继续分割成CO与CI,CO是块偏移,即得到一个cache的一个命中行之后从哪个字节开始取出数据,CI作为组索引,PPN作为tag标记串。
因为L1L2L3这几级cache都是通过S组,E行,这种方式组织,先根据CI,CO找到对应的行,如果本级cache没有找到会从更下一级cache中找到合适的行,通过某些替换策略进行替换与对应的牺牲,这样一级一级的访问极大的提高了对内存访问的效率,找到这个行之后根据PPO就可以找到所需要的数据,传给CPU了,同时Cache的读写命中\不命中都有对应的处理流程。
在这里插入图片描述
图7.5.2
在这里插入图片描述
图7.5.3
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念
7.7 hello进程execve时的内存映射
在当前进程中的程序执行了execve(”a.out”,NULL, NULL)调用时,execve函数在当前程序中加载并运行包含在可执行文件a.out中的程序,用a.out代替了当前程序。加载并运行a.out主要分为一下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构;
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零;
3.映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内;
4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
在这里插入图片描述
图7.7.1
7.8 缺页故障与缺页中断处理
缺页故障:进程线性地址空间里的页面不必常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。
会出现缺页异常的情况:
1.线性地址不在虚拟地址空间中
2.线性地址在虚拟地址空间中,但没有访问权限
3.没有与物理地址建立映射关系fork等系统调用时并没有映射物理页,写数据->缺页异常->写时拷贝
4.映射关系建立了,但在交换分区中,页面访问权限不足
在这里插入图片描述
图 7.8.1
缺页中断处理:
一个较为简单的表述为:确定缺页是由于对合法虚拟地址进行合法的操作造成之后,系统选择一个牺牲页面,如果这个牺牲页面被修改过,将其交换出去,换入新页面并更新页表,当缺页处理程序返回后,CPU重启引起缺页指令。
事实上缺页中断的处理具体复杂得多,见下图:
在这里插入图片描述
图7.8.2
7.9动态存储分配管理
动态内存分配器的基本原理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。不同系统有微小的差别但是一般来说堆存在于未初始化的数据后面并想高地址生长。对于每个进程,内核负责维护一个变量叫brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护。每个块都是一个连续的虚拟内存片有两种状态(已分配或空闲)顾名思义,已分配的块已经被进程所使用,而空闲块可以用来分配,这时他就变成了一个已分配块,而已分配的块只有在被释放才能重新在使用。
根据分配器的风格来分类可以分为显式分配器与隐式分配器:
显式分配器:
要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。必须用户来主动来调用某些函数来进行内存的申请与释放。
隐式分配器:
这时需要分配器检测一个已分配块何时不再被程序所使用,那么久释放这个块,也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
碎片:
造成堆利用效率低的原因是碎片现象,碎片分为两种:
内部碎片:当一个已分配块比有效载荷的,由对齐要求产生
外部碎片:空闲内存合起来足够满足分配请求,但是处于不连续的内存片中
动态内存分配器首先需要实现的问题有:
1.空闲块如何组织 2.如何选择一个合适的空闲块来放置一个新分配的块
3.在空闲块中放置了一块已分配块之后如何处理空闲块的余下的位置呢
4.如何将刚刚释放的块与相邻的块进行合并
5.当前使用的堆的空间不够需要再分配
同时分配器在工作时有着严格的要求:
1.分配器必须可以处理任意请求序列,对于用户来说执行的malloc与free的数量未必对等,只要释放的块当前是已被分配的有应该响应。
2.为了提高分配的效率,分配器对于申请请求必须迅速响应,不能重新排列或者缓冲请求。
3.分配器必须对齐块,使得他们可以保存任何的数据类型。
4.分配器只能操作修改空闲块对于已分配的块,一旦一个块被分配,不允许改变大小及一些块的固有信息,即使块中有剩余也不能压缩。
分配器的设计可以有多种方式针对空闲块的组织方法有以下三种:
a.隐式空闲链表(implicit free list)
隐含链表方式即在每一块空闲或被分配的内存块中使用一个字的空间来保存此块大小信息和标记是否被占用。根据内存块大小信息可以索引到下一个内存块,这样就形成了一个隐含链表,通过查表进行内存分配。优点是简单,缺点就是慢,需要遍历所有。
在这里插入图片描述
图 7.9.1
b.显式空闲链表(explicit free list)
显示空闲链表的方法,和隐含链表方式相似,唯一不同就是在空闲内存块中增加两个指针,指向前后的空闲内存块。相比显示链表,就是分配时只需要顺序遍历空闲块,虽然在空间上的开销有所增大,但是放置以及合并操作所用到的时间会有所减少。
在这里插入图片描述
图 7.9.2
c.分离空闲链表(segregated free list)
分配器维护多个空闲链表,其中每个链表中的块大小大致相等,即讲这些空闲块分成一些等价类,先按照大小进行索引找到相应的空闲链表再,在链表内部搜索合适的块,这样相比于显式空闲链表时间效率更高。
针对查找空闲块的三个方法:
a.首次适应(first fit)
b.最佳适配(best fit)
c.下一次适配(next fit)
对于不同的空闲块组织方式,巧妙地设置头部和脚部,用指针可以有效地提升空闲块合并的效率
7.10本章小结
本章介绍了本章首先讲述了虚拟地址、线性地址、物理地址的概念与区别,从现代linux的内存系统出发,重点介绍了虚拟内存与物理内存之间的翻译转换通过虚拟地址到物理地址的转换,进一步加深了对虚拟地址空间的理解运作及其强大作用,虚拟内存的意义以及作用都是十分重大的,进一步介绍了一些从虚拟地址转换为物理地址的一些加速的方法(TLB,多级页表),和物理地址如何寻到数据的过程(cache的访问机制)。同时介绍了虚拟内存机制下的fork以及execve如何运作的,使进程的看似私有的地址空间变成了现实。接着,介绍了了动态内存管理时,如何组织空闲区域、空间的申请、分割、合并、回收等具体过程,对动态分配函数malloc系列函数有了更深的认识。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0,B1,B2…….所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。

8.2 简述Unix IO接口及其函数
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_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.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O接口函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,通过一些宏值的设置可以修改打开文件的方式,mode 参数指定了新文件的访问权限位,具体见图8.2.2.
返回:若成功则为新文件描述符,若出错为-1。
在这里插入图片描述
图 8.2.1
在这里插入图片描述
图8.2.2
2.进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
ssize_t write(int fd, const void buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图8.2.4展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
返回:若成功则为写的字节数,若出错则为-1。
在这里插入图片描述
图8.2.3
在这里插入图片描述
图8.2.4
8.3 printf的实现分析
https://www.cnblogs.com/pianist/p/3315801.html
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
首先我们通过查找得到printf的c语言代码,入下图所示:
在这里插入图片描述
在形参列表里有这么一个token:… 这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
下面我们一步一步进行分析:
va_list arg = (va_list)((char
)(&fmt) + 4);
va_list的定义:
typedef char va_list
这说明它是一个字符指针。
其中的: (char
)(&fmt) + 4) 表示的是…中的第一个参数。这个的理解和栈息息相关,由于参数的压栈方式是最后一个参数最先压栈,因此离fmt的地址也就是栈的最上方的指针最近的就是第一个参数。
下面printf函数又调用了另一个函数i = vsprintf(buf, fmt, arg);
在这里插入图片描述
由此可知,vsprint的功能为把指定的匹配的参数格式化,并返回字符串的长度。观察到printf中后面的一句:write(buf, i);我们可以分析出这是一个IO函数执行–写操作,把buf中的i个元素的值写到终端。vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
下面我们反汇编追踪一下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里的int表示要执行系统调用,执行某项特定的操作。
再来看看sys_call的实现:
sys_call:
call save //保存中断前进程的状态
push dword [p_proc_ready]
sti
push ecx //ecx中是要打印出的元素个数
push ebx //ebx中的是要打印的buf字符数组中的第一个元素
call [sys_call_table + eax * 4] //不断的打印出字符,直到遇到:’\0’
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
//[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
cli
ret
Syscall将字符串中的字节从寄存器复制到显卡的显存中,以ASCII字符形式。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
首先我们看一下getchar()函数的具体实现代码,主要通过调用read函数以及一些控制的代码来实现。
#include “sys/syscall.h”
#include <stdio.h>
int getchar(void)
{
char c;
return (read(0,&c,1)==1)?(unsigned char)c:EOF
//EOF定义在stdio.h文件中
}
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在 键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要讲述了Linux的IO设备管理方法,Unix I/O接口及其函数,以及printf函数实现的分析和getchar函数的实现。通过对系统函数的分析,我们对系统I/O函数和Linux中将设备统一为文件式来管理的方式有了进一步的认识。通过对一些库函数(输入输出)的分析,我们看到了printf函数的底层实现的分析过程中将看似简单的printf函数进行一些深入分析,发现就像我们的大作业hello一样,我们经常使用的或许我们注意到但是其背后蕴含的底层硬件软件的结合是十分庞大的,正是有了这些IO函数使得计算机能与用户良好的交互,也让我们进一步认识了底层的工作过程。
(第8章1分)
结论
本次大作业通过分析hello简单却不平凡的一生,对整个CSAPP课程也进行了总结,整个大作业设计十分巧妙,知识点覆盖面也十分广泛,以hello为线索穿起了整个这门课程的一个回顾,从数据的运算处理,到汇编、链接、信号异常的处理、内存管理、再到后面的I/O管理。下面对整个实验的各个章节进行一个总结。
程序的编写:一个c语言的程序我们使用高级语言进行编写,当我们刚刚接触高级语言的时候第一个程序就是hello,当时懵懂的我们惊叹于神奇的几行代码可以将文字转换到电脑屏幕上,后来随着我们不断的学习我们了解到各种各样的数据结构,控制结构,指针、链表,他们的功能十分强大给我们带来了极大的便利但是我们却还是没有明白,如何使一个c语言所写的程序到机器内部去执行的?机器可以理解我们写的代码吗?计算机系统这门课程让我们对这个过程有了一个相当的理解。
预处理:根据预处理命令得到了修改后的hello.i文本,并且对hello.i程序进行了预处理结果分析与理解,理解了预处理器读取系统头文件中内容,并把它插入程序文本中的过程。
编译:编译的作用主要就是将高级语言转换为更低一级的汇编语言,.i->.o这种汇编语言不是机器语言(二进制),但是可以将不同的高级语言(c c++ java等形式完全不同的)根据编译器的不同类型转换成一些通用的汇编代码,它更加贴近于机器的思维与指令,同时编译器还可以检查高级语言代码的语法正确性,是我们编写代码到执行这个过程中最为重要的一环,
汇编:将hello.s转化为二进制的机器代码,生成hello.o的可重定位目标程序。
链接:对hello.o中引用的外部函数、全局变量等进行符号解析,并重定位为可执行文件hello。
加载与运行:在终端输入./hello 1180400522 王思博 2以执行程序。通过fork创建子程序。通过execve加载器载入,建立虚拟内存映射,设置当前进程的上下文中的程序计数器,使之指向程序入口处。此时这个程序有了自己的生命(独立的物理内存空间)。
访存与缺页:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,将相应的页面调度。同时在访问代码、数据、堆栈、时会产生缺页现象,经过处理程序的处理回复运行。
接收信号与处理:中途接受ctrl+z挂起,ctrl+c终止;
动态内存申请:在程序中若需要即时地分配一些空间来存储数据需要用到动态内存分配器,其中分配器如何组织空闲区域、空间的申请、分割、合并、回收等具体过程,对动态分配函数malloc系列函数有了更深的认识。
系统的I/O管理:IO就是计算机与外界设备进行交互的基础。一个Linux文件就是一个m个字节的序列,所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。有了IO我们可以向计算机内部通过鼠标键盘写入内容,我们可以听到各种音频,借助显卡我们可以看到各种各样的视觉特效,有了IO计算机真正的活了起来。
一些理解与体会:历时两周多这次大作业最终认真地完成了,首先对我来说这次大作业是一次复习与查缺补漏,由于作业设计到的内容十分全面,在完成的时候需要翻阅相关的书籍与资料,最重要的是需要仔细思考与理解书上的内容,在之前课堂上听完没有理解的地方在这次作业中都有不同的思考。
其次在这个过程中有了很多多的收获。我们学习编程第一个接触的程序helloworld,看上去很简单,但是如果要深究,会发现计算机内部的工作是相当复杂的。但是也很有趣,你会发现有许许多多解决问题的奇思妙想,内部硬件与软件的巧妙配合,硬件的内部结构等等,通过这些更好的理解计算机的特点和魅力。
最后要感谢CSAPP这门课与老师的辛勤努力与付出,通过这门课真的增长了许多有关计算机的一些原理性的知识,虽然在短短一学期之内无法像课本题目那样深入理解计算机系统,但是这样更像是一个导论课的一个课程给了我们这样接触计算机不久的学生一个蓝图意义的指导,最重要的是实验,通过八次不同的实验,我们将课上的知识与实践结合,就像缓冲溢出攻击的试验我们切实地成为了一个“黑客”,为我们带来了极大的情趣,还有老师们也能细心的为我们答疑,用通俗的语言进行讲解,这都给了我很大的帮助。

用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
(结论0分,缺失 -1分,根据内容酌情加分)

附件
列出所有的中间产物的文件名,并予以说明起作用。
1.hello.c 程序的源代码
2.hello.i 预处理之后的文本文件
3.hello.s 汇编后的文本文件
4.hello.o 可重定位文件hello.o
5.hello.out 从hello.o链接而成的可执行文件
6.hello 从源代码直接按照// gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello得到的可执行文件
7.hello.elf 由可重定位目标文件生成的elf文件
8.helloobj.s 由可重定位目标文件hello.o反汇编得到的汇编代码文件
9. hellooutobj.s 由可重定位目标文件hello.out反汇编得到的汇编代码文件

(附件0分,缺失 -1分)

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔E.布莱恩特 大卫R.奥哈拉伦 深入理解计算机系统(第三版)
[2] https://blog.csdn.net/ww1473345713/article/details/51680017 getchar()具体实现方式
[3] https://www.cnblogs.com/pianist/p/3315801.html printf函数的深入剖析
[4] https://www.cnblogs.com/MaAce/p/7999795.html Linux 链接详解----动态链接库
[5] https://blog.csdn.net/zsl091125/article/details/52556766 逻辑地址、线性地址、物理地址和虚拟地址 概念与区别
[6] https://blog.csdn.net/dan15188387481/article/details/49536317 操作系统中的虚拟内存详解
[7] 袁春风 计算机系统基础
[8] https://blog.csdn.net/qq_39588027/article/details/80254497 IO原理与概念
[9] https://blog.csdn.net/qq_35066345/article/details/78747410 动态存储器分配:内存动态分区分配方式的理解以及模拟
[10] https://blog.csdn.net/hunter___/article/details/82963577 可执行程序加载到内存的过程
(参考文献0分,缺失 -1分)

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/709567
推荐阅读
相关标签
  

闽ICP备14008679号