HIT2018ICS大作业——Hello的一生

计算机系统

大作业

题     目  程序人生-Hellos P2P  

专       业      计算机类         

学     号       1170300714    

班   级     1736101           

学       生    黄梓桐            

指 导 教 师      刘宏伟             

 

计算机科学与技术学院

2018年12月

摘  要

本文以hello为例程,主要研究了一般程序在其生命周期中的执行过程所发生的变化和意义。梳理了一个.c的源代码文件是如何一步一步被计算机所执行,是如何一步一步把结果反馈给用户的。同时我们在分析的过程中回顾了相关的计算机及计算机系统中相关的技术理论和应用。本文是一篇分析文和介绍文,借助本文可以更好的理解程序的执行以及计算机内部所发生的那些有条不紊的工作。

 

关键词:Hello;过程;系统                            

 

(摘要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和O2O到底是什么

  1. P2P——From Program to Process 从程序到进程,这个过程让我们阐述Hello这个程序是如何从一个干巴巴的文本代码到一个正在奔跑着的的程序(可不敢是割韭菜的那个p2p)。
  2. O2O——From Zero-0 to Zero-0 从0到0,从无到无,运行Hello之前是“无”,Hello完全运行后也是“无”,这个之间发生了什么(同样也不是电商的那个O2O!)。

好的解释完名词了,我们就可以看看这个过程到底发生了什么有趣的事情了

      1. P2P——From Program to Process

Hello.c作为一个已经被用户用editor编辑器编写好c文本文件(源代码),刚开始放在磁盘上,用户在终端中调用gcc命令来对hello.c进行操作。

  • 用户通过调用gcc来运行gcc程序,其中用户输入的参数就是我们的Hello.c,于是我们的Hello.c程序通过IO总线从磁盘被运送到主存。
  • GCC的中一个叫做preprocessor(cpp)(预处理器)的工具开始了它的工作,它先将Hello.c里面的所有注释清掉,把里面的宏定义展开,把#include头文件里的内容复制进来,把Hello.c改造成了Hello.i,这时的Hello虽然已不是原来的Hello,但里面的内涵基本没变(还是c代码)
  • Preprocessor工作后,compiler(ccl)(编译器)开始工作。它把hello.i变成了了Hello.s,Hello从c代码被翻译成了汇编代码。
  • 接下来Assembler(汇编器)开始工作了,它又开始改造Hello.s,把它变成了01代码Hello.o,此时,Hello已经成为了二进制目标代码
  • 然后linker(链接器),它解析Hello.o中未定义的符号,并把标准库中的函数拿过来与hello.o结合成一个整体,然后它还把其中的地址统一化了,终于,我们的hello.o变成了可执行的hello!
  • 接下来用户在在shell中,输入./hello就可以启动这个程序啦!首先输入后,用户的输入的字符串被shell中的解析函数解析成不同的小字符串,然后判断输入的是否为内置指令,若不是shell会假定用户输入为一个可执行文件的名字,从而去加载并执行该文件
  • 执行文件时,首先shell在终端下调用fork函数创建一个子进程,这个然后在子进程中用execve函数来加载我们刚刚的hello可执行文件
  • 在加载过程中,首先系统会删除子进程用户区域的内容,然后用mmap函数创建新的内存区域,同时,新创建的区域将与hello程序中的不同段进行映射(例如新区域的代码和数据区域会映射成hello程序中的.test和.data段,还有与hello链接形成的库的共享段等),然后execve设置当前进程上下文中的程序计数器,加载器跳转到_start处,最后运行到main函数开始执行hello中的指令,cpu为执行hello分配时间片。
  • Cpu按照程序计数器中的值对代码段的指令进行读取,然后按照流水线的cpu体系结构执行这些指令(PC,取值,译码,执行,访存,写回)等
  • 当执行到程序的最后,程序return,进程终止,这个子进程等待这父进程shell的回收,然后这个进程就消失了,机器的状态又回到了执行hello程序之前的样子。
      1.  O2O——From Zero-0 to Zero-0

由以上内容我们可以看出,在我们运行hello前和运行完hello后,整个内存中就不会出现有关于hello的内存映像。即hello就像一个演员一样,平时在后台(硬盘)后场,当需要它的时候,它被调到舞台(内存)中开始自己的精彩的表演(计算,图形,声音等功能),演完后,它默默的下台(子进程被收回),舞台恢复了空荡荡的原貌,这就是所谓的从0到0

1.2 环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。

1.2.1. 硬件环境:

Intel(R) Core(TM) i7-3517U CPU @ 1.90GHz

4GB RAM

512G HD Disk

1.2.2. 软件环境

Ubuntu 16.04 LTS 64位

1.2.3. 开发工具

CodeBlocks

gedit+gcc

Readelf

Objdump

1.3 中间结果

 

1.4 本章小结

本章内容是整个大实验的提纲,总结了hello程序一生所需经历的整体内容,为以后的详细分析提供了强有力的路线;对hello程序的分析是对所有程序的分析的缩影,将hello程序的一生分析透撤,其他程序的运行也是类似的步骤。

列出了实验所需要的工具,为接下来实验提供工具清单。

 

(第1章0.5分)

 

 


第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

为编译做的预备工作的阶段,修改原始的C 程序,由预处理器完成

2.1.2 预处理的作用

预处理主要处理#开始的预编译指令

  1. #include

该指令指示编译器将xxx.xxx文件的全部内容插入此处。若用<>括起文件则在系统的INCLUDE目录中寻找文件,若用" "括起文件则在当前目录中寻找文件。一般来说,该文件是后缀名为"h"或"cpp"的头文件。

  1. #define 宏定义

将所有的#define删除,并且展开所有的宏定义

  1. 处理所有条件编译指令,如#if,#ifdef等
  2. 将所有用户编写的注释删除

2.2在Ubuntu下预处理的命令

图2-1 Ubuntu下对hello.c的预处理的命令

2.3 Hello的预处理结果解析

首先生成了一个hello.i的文件:

图2-2 预处理后生成的hello.i文件

打开这个hello.i:

图2-3a hello.i文件的部分内容

图2-3b hello.i文件的部分内容

图2-3c hello.c的内容

如图2-3a和2-3c所示,在hello.i的3108行开始和hello.c中的头文件下的内容一样(即main函数附近),而hello.c的头文件(#include、#include、#include等)里的声明的符号在编译预处理时被插入进了hello.i文件之中,如图2-3b所示。而且我们可以看到原hello.c中的注释已经在hello.i中消失不见了。

2.4 本章小结

预处理将c的源代码文件进行修改和扩充,实际上,我们如果编写一个功能强大的.c文件,那么我们所要实现的结果就和预处理器实现的结果一样。因有预处理这个功能,使得我们可以编写更加抽象,封装性更好的程序,这大大降低了程序员的压力,同时,预处理还为后续的编译和链接工作做好了准备(例如把.h中的未定义符号拿过来,在链接时解析)。

 

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

3.1.1. 编译的概念

1. 广义的概念:  将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序, 称之为编译

2. 狭义的概念: 利用编译工具(ccl)从预处理后的程序产生汇编语言程序的过程,编译器通过词法分析,语法分析等步骤将.i文件转换为.s文件。我们此处只针对狭义的概念进行扩充。

3.1.2. 编译的作用

1. 形式上:将.i文件转换为.s文件,例如我们此题生成了hello.s文件

2. 具体作用:将c语言代码初步转换成汇编语言代码,不仅可供会使用汇编语言的程序员阅读和修改,更为之后汇编器的汇编工作提供了强有力的输入,降低了直接从高级语言到机器语言翻译的复杂度。

3.2 在Ubuntu下编译的命令

图3-1 Ubuntu下编译的命令

3.3 Hello的编译结果解析

图3-2a 编译结果

图3-2b 编译结果

 

3.3.1. 数据

对比.c文件和.s文件,本段程序的数据主要有:全局整形变量,局部整形变量,函数参数变量,字符串常量,数组

  1. Sleepsecs

该变量在.c文件里声明如下:

 

可见这是一个int型数,而却用2.5这个浮点数初始化,于是我们就在这里有个疑惑,我们可以去.s文件里看看是怎么处理的

如图上所示:globl代表sleepsecs是全局变量,然 后将 sleepsecs 存放在.data 节,类型是对象(即数据),分配大小4字节,.long那行赋初值2,可见虽然我们在c中用浮点数初始化,但在汇编代码中,程序还是把sleepsecs当做int处理。

  1. argc

Argc是命令行返回用户输入指令段的个数,在c中出现在如下位置:

可知它作为main函数的第一个参数,所以按照x86-64体系下的规则,它应该是被放在rdi中传参:

 

把edi给栈的一个位置r,然后让(r)与3比较大小,完美反映了c中代码:

  1. *argv[]

Argv[][]作为保存用户输入字符串的二维数组,在c中声明和argc一样,都是main函数的参数,所以按照规则,他应该保存在栈中

  1. i

   i在c中的位置如下:

可以发现它作为一个循环变量,也是局部变量,按照规则可以保存在寄存器或者栈中,查看汇编代码:

 

 

由以上代码分析可知,i的值一直由-4(%rbp)这个位置保存,刚开始用0初始化,然后每次和9比较,如果小于等于则继续循环并且+1

  1. 常量字符串

   在c中它们出现的位置如下:

在汇编中,它们被编译成:

可见它们在.rodata只读段,且分别分配了.LC0和.LC1这两个标识符标识

由以上分析之,全局变量和静态的全局字符串都在文件的一个段里,而函数内部的局部变量或参数是放在运行时栈或者寄存器中

 

3.3.2. 运算与操作

1.赋值操作

在上述讨论中已出现过,即对全局变量sleepsecs的赋值和对局部变量i 的赋值:

对sleepsecs的赋值

 

对i的赋值

2.运算操作

            在c程序中唯一出现的就是i++:

在汇编中对应如下:

3.比较操作

  在c程序中有如下操作:

   

分别是argc与3作比较和for循环中i与循环末尾的量做比较,在汇编中他们出现在如下位置:

可见这两个比较由于同时也是在控制语句里,所以在汇编代码里,在比较完成之后要判断是否要跳转。

  1. 数组操作

C代码中对数组的唯一操作就是在printf的时候,以两个字符串为参数:

在汇编代码中,对数组的访问就是基址加偏移量:

上图是对argv[2]的操作,-32(%rbp)是argv[0]的基址,而加16后就是argv[2]的地址,把它传给rdx(第三个函数参量)

同理,取argv[1]给rsi(第二个函数参量)

3.3.3. 控制转移

控制转移其实在之前的比较符号那里就已经提到了

上图是c程序,有两个控制转移,for和if,

下面是对应的汇编:

首先拿argc和3比,如果不等则继续执行后面的printf usage,如果相同则跳到后面的for函数那里

上图是循环变量i控制循环结束,让i和9比,如果大于9了,那么循环结束,否则继续调回L4继续循环

3.3.4. 函数调用

1. main函数

Main函数是c语言代码中的主函数,是程序的开端。在汇编中, main函数的信息如下:

main函数在.text段中,是globl型变量,type是function即  函数

Main函数在形式上由两个参数,一个是argc,一个是*argv[],

前者保存用户输入的字符串的个数,后者保存这些字符串,它们在传参过程中,前者保存在rsi,后者在运行时栈中

2. exit函数

Exit函数在c中是结束进程的功能:

在hello它以“1”为参数

汇编中:

其中在调用前,要把1放在rdi中,作为第一个参数

这个函数不是hello.c中声明的,而是在系统库中,由于已经预编译过,所以编译能找的到exit的原型

3. printf函数

              Printf的功能是格式化输出,printf的调用,参数被存放在寄存器传递。以printf(“Hello %s %s\n”,argv[1],argv[2]);为例,格式化字符串被存放在edi传递,argv[1]被放在rsi,argv[2]被放在rdx。使用call来调用printf,而printf的返回值则会被存入eax返回,返回值是正确输入的个数。

            4. sleep函数

              在c中的位置如下:

其中在汇编中:

对sleep的调用,参数被存放在edi传递,然后使用call调用sleep。

5. getchar函数 

在c中的位置如下:

汇编中:

可见在循环结束后,调用getchar函数,无参数传递

3.4 本章小结

本章我们通过对上一章产生的hello.i文件进行编译,我们得到了.s汇编文件,这个文件内部是贴近于机器底层的汇编语言,和源代码已有很大的区别。我们通过对比分析c源码和汇编代码,可以看出它们之间是如何组织与相互映射的,也明白了不同的数据类型,不同的操作在汇编语言中具体是如何表示的。

汇编语言作为高级语言和机器语言之前的过度语言,有着两面的特性,一是有着像机器语言那样的指令集的格式,它是以指令的形式,由cpu一条一条读取执行,它的变量隐士的保存在寄存器和堆栈中,有的没有名字,你将无法直接与c语言中的变量名子对应。二是有着像高级语言那样,汇编语言仍采用贴近于自然语言那样的助记符的形式,可以让程序员读懂的特性。

下一步,我们通过汇编器将汇编语言转变成机器语言。

(第3章2分)


第4章 汇编

4.1 汇编的概念与作用

4.1.1. 汇编的概念

 

汇编是指:

  1. 汇编语言
  2. 将汇编语言译成机器语言的过程,并生成一种叫做可重定位目标

   程序的格式的文件

4.1.2. 汇编的作用

从形式上看,汇编把.s文件转化为.o文件,.o文件就是可重定位目标文件从内容上看,汇编把汇编语言翻译的机器语言,也就是二进制代码,这是可以让机器读懂的语言,也是程序在运行的时真正的状态。此时.o文件还不能被执行,需要与其他的可重定位文件进行链接,生成可执行目标文件。汇编的做用就是为链接做好准备工作

4.2 在Ubuntu下汇编的命令

汇编命令:

目录下生成的.o文件:

尝试打开:

发现无法直接用文本文件的格式打开.o文件

4.3 可重定位目标elf格式

    

上图是ELF头,保存了文件的基本信息

从中我们可以看出这个文件是64位系统下的文件,数据表示方法(补码小端),类型是是REL(可重定位文件),这里如果不是.o而是可执行文件,那么类型就是“可执行文件”,可见不同的文件类型的elf头不同,而且可以从elf头中判断该文件的类型(可重定位,可执行或动态链接库)。程序入口点地址是0,这说明了这一定不是可执行文件。我们还可以看出,节头数量是13。

上图是节头的信息,节头中体现了不同节的序号,名称,所占大小,类型和地址空间等,通过节头的信息我们可以了解这个文件大概由哪些内容以及每个节的大小分布和起始地址位置,可供我们后续对每一个节的访问提供参考

上图是关于重定位的节的信息,里面阐明了可重定位的符号,在之后的链接,这些符号的地址将被修改。通过表中我们可以看出可重定位的符号有rodata里面的两个元素(.L0和.L1字符串), puts , exit ,printf, sleepsecs , sleep,和getchar

上图是hello.o的符号表,它保存在程序中定义和引用的函数和全局变量的信息,在之后的链接过程中,要对符号表中的Ndx=UND符号进行定义搜索。

4.4 Hello.o的结果解析

上图是用objdump反汇编出来的代码,与第三章编译出来的汇编代码作对比,我们可以看到一下几点不同:

  1. 对于汇编文件的整体格式

   在.s文件中,文件的结构是首先对.data .rodata. 等段进行描述,而描述的格式是静态的,以简单的符号——值的格式表明;而在反汇编出来的格式中,我们可以看到,上者改成了动态,在反汇编里都是用指令的形式表示,例如对sleepsecs的赋值。

  1. 控制指令

       在.s文件中,我们可以看到当对两个操作数比完大小后,跳转指令是直接跳转到以L3,L4为例的这样的符号位置。而在反汇编出来的结果中,跳转语句原来对应的符号都变成了相对偏移地址。

  1. 和第二条类似,函数调用时原来的函数名字也被替换成了函数的相对偏移地址。
  2. .s中汇编代码就是简单的只有汇编代码,而在反汇编文件里我们可以发现在左侧有汇编代码所对应的机器代码,而且每一条机器代码前都有地址
  3. 对于运行时栈的操作也有细微差别,例如:

  1. 反汇编文件中,全局变量和常量都有了自己的地址。
  2. 操作数在.s文件里面都是十进制,在到hello.o里面的机器级程序时都是十六进制,这一点也体现了底层代码的数据都是用二进制/类二进制的格式来体现的(因为十六进制是二进制每4位的压缩格式)。

4.5 本章小结

汇编器将汇编语言翻译为机器语言,将.s文件生成了.o文件,即可重定位文件。汇编器把人们所能理解的助记符翻译成机器代码,把变量显式的分配相对的内存地址。在这个过程中,还生成了不同的节和段,它们共同采用elf格式来保存文件,使得文件有条理性。汇编文件和.o文件反汇编出来的汇编代码有些区别,主要体现在把符号转化为地址,强调了“地址”的概念。

.o文件中符号的地址不是最后可执行的地址,而是暂时相对于文件的地址,在之后,同时,.o文件中有一些符号,像全局变量和函数,也没有在文件中定义,下一步的链接将解决这些问题。

(第4章1分)


5链接

5.1 链接的概念与作用

5.1.1. 链接的概念

链接是指在电子计算机程序的各模块之间传递参数和控制命令,并把它们组成一个可执行的整体的过程,通俗一点理解,链接是将一个或多个由编译器或汇编器生成的目标文件外加库链接为一个可执行文件的过程。

5.1.2. 链接的作用

链接最重要的作用是符号解析与重定位,将函数库中的代码与我们写的代码相结合。

正如第4章谈到的,我们生成的.o文件中的符号地址并不是真正的地址,而且还有好多无定义的符号出现在文件中,链接的作用就是在库或其他文件中搜索这些变量的定义,并且将重新分配整个文件内容的地址,最后将链接过来的库和文件整体生成一个可执行文件,也就是我们的hello。

5.2 在Ubuntu下链接的命令

链接命令:

生成了hello文件:

可执行测试:

5.3 可执行目标文件hello的格式

我们用elf工具测试:

上图是ELF头,我们可以看出此时的类型是EXEC可执行文件,而且入口点地址是0x4004d0,和.o文件当时有很大的区别,说明这里已经成功的重定位,而节头的数量是25,比之前的13又多了很多节头。

以上图片是节头的信息,可以看出这些节头的大小,地址,和偏移量等信息

 

5.4 hello的虚拟地址空间

    

如上图所示,这是edb在加载hello时的内存空间的一部分,可以看出是从0x400000开始

上图是用readelf查看.dynstr节的内容,起始地址是0x400300,我们在edb中找同样的位置:

可以发现他们的内容是一样的,这就证明了内存的映射是如何构建的了。

 

5.5 链接的重定位过程分析

5.5.1. 代码分析

以上图片为hello的反汇编代码的main函数之前的部分,与hello.o代码对比可知,有以下不同点。

  1. .text代码段
    1. Hello中的第一个函数是_start,在_start中又会去调用_libc_start_main函数
    2. 而hello.o里第一个函数就是main函数

   说明了hello可执行文件中,执行入口被修改为_start函数,这个入口是链接后为了给所有的被链接文件提取出的公共的入口

  1. 其他函数
    1. Hello中前面我们可以看到由printf,puts,gets,sleep等函数的实现代码
    2. 而hello.o中没有这些函数

以上论述说明了链接确实把动态库里的函数都拿了过来

  1. 地址
    1. Hello反汇编代码中可以看到每一条指令和函数前面都有0x400xxx的地址,这个地址就是所有文件重定位后最终的地址,而且这个地址符合内存映射的规则。
    2. Hello.o反汇编代码里的指令前也有地址,但那个是从0开始的地址,是相对于文件的地址
  2. .ini .plt段
    1. 在hello反汇编代码里可以看到这些段
    2. 在hello.o的反汇编代码里没有这些段

.init:程序初始化  .plt:动态链接表

由以上分析可知,重定位后的程序提供了一个程序初始化的段,用来对整个程序初始化,而.plt就是那些动态库里函数的集合。

 

5.5.2 重定位实例

我们根据链接器重定位的算法,列举一个例子来说明重定位的步骤——puts函数的重定位:

上图是.o的反汇编文件中调用puts函数的格式,在下边那行表明了重定位条目的信息:

R.offset = 0x1b,

R.symbol = puts,

R.type = R_X86_64_PC32 (pc相对引用)

R.addend = 0x4

上图是hello反汇编中main的起始位置0x4004fa

上图是hello反汇编中puts的起始位置0x400460

计算如下:

Refaddr = 0x4004fa+0x1b = 0x400515

*refptr=addr(r.symbol) + r.addend -refaddr)

= 0x400460-0x4-0x400515

而PC = 400519+*refptr = 400519+0x400460-0x4-0x400515 = 0x400460 = addr(sum)

这样就确实找到了sum的位置。

 

5.6 hello的执行流程

这里我们用gdb调试,gdb调试可以追踪代码快速跳转到函数前,通过gdb的输出,hello的执行流程如下:

程序名称                               

 

执行main前:

 

  1. _dl_start                             

 

  1. _dl_init                                     

 

  1. _start                                  

 

  1. _libc_start_main                      

 

  1. _init                                   

 

执行main:

 

  1. _main

 

  1. _printf

 

  1. _exit

 

  1. _sleep

 

  1. _getchar

 

  1. _dl_runtime_resolve_xsave

 

  1. _dl_fixup

 

  1. _dl_lookup_symbol_x

 

执行main后:

 

  1. exit

5.7 Hello的动态链接分析

  动态链接的核心是位置无关代码。

  目标文件会在自己的数据段存放一个GOT(全局偏移量表)来保存全局变量或函数的地址,同时会在代码段内保存一个PLT(全局偏移量表)来配合GOT使用(GOT是动态的)

根据他们共同工作的机制,我们知道初始时,GOT[i]保存的是PLT[i]里第二行指令的地址,而当调用一次GOT[i]所对应的函数后,GOT[i]的地址就指向那个它对应的函数的地址。所以我们可以通过输出GOT的变化来看看动态链接的状态

 

首先我们用edb先检测got的大概的地址位置,然后在edb运行hello,观测这个地址内部的变化:

由以上结果可知,got发生了变化,即发生了动态链接

5.8 本章小结

本章讲述了链接的概念,工作和作用。

链接是程序从源代码到可执行文件这个漫长步骤的最后一步,它把不同的可重定位文件和库结合起来,对目标文件里的未定义符号寻找定义,对所有文件的全局符号和函数进行重定位,将不同的模块整合到一起,起到收尾的工作。因为有了链接的存在,使得人们可以直接调用各种库中的函数和变量,而无需关心他们所在的位置,大大降低了程序的复杂性,提升了程序的可维护性和整洁性,也同时优化了内存分配的问题(动态链接)。

(第5章1分)
6hello进程管理

6.1 进程的概念与作用

6.1.1. 进程的概念

狭义概念:进程是正在运行的程序的实例

广义概念:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

6.1.2. 进程的作用

进程是对正在运行的程序过程的抽象,清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。

6.2 简述壳Shell-bash的作用与处理流程

在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(命令解析器)。他是用户与计算机交互的工具,用户利用shell来对计算机下达各种指令,同时用户也可以从shell中获取信息。

处理流程:

  1. shell从终端读入用户输入的命令。
  2. 将输入字符串依照空格切分成一个小子串来获得所有的参数
  3. Shell进行判断:如果用户输入的是内置命令则立即执行
  4. 如果是外部命令,shell将调用相应的程序并为其分配子进程并运行
  5. 在一个程序执行的过程中,shell 应该接受键盘输入信号,并对这些 信号进行相应处理

6.3 Hello的fork进程创建过程

当用户输入完命令敲下回车后,shell会解析命令。

如果是内部命令,则直接执行;

如果是外部命令,则需要开辟进程,在本案例中,我们的./hello显然是外部命令,所以需要开辟进程来供程序运行。

shell首先会调用 fork 函数创建一个新的运行的子进程,这个子进程就是当前shell的一个副本。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,这就意味着,当父进程调用 fork 时,子进程可以读写父进程中打开的 任何文件。父进程与子进程之间最大的区别在于它们拥有不同的 PID。

进程图如下图所示:

Fork函数调用,父进程将会准备处理新进程对应的任务job,把子进程添加到jobs组里,设置gpid号,添加jid号包,确定job前后台信息等其他操作。

子进程则将继续执行execve函数,加载程序,注意在加载函数之前,子进程并不是空的,而是与父进程拥有一样的数据,地址空间,数据结构,寄存器信息和状态信息等一切信息,这些信息的更新要留到execve再被清除。

 

6.4 Hello的execve过程

在刚刚fork出的子进程中,它将调用execve来执行输入的命令参数进而加载hello程序。

  1. 首先要删除已存在的用户区域,即上述提到的,子进程拥有和父进程完全相同的内存区域,我们先要把这些结构删除
  2. 用mmap函数创建新的用户区域,并把hello程序不同的段区与这个用户区域的代码区和数据区等相映射。并且要把hello中引用的外部符号所在的共享库映射进来
  3. 设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

6.5 Hello的进程执行

首先我们回顾书上讲的一些有关进程执行过程的概念:

6.5.1. 逻辑控制流

当一个进程在运行的时候,我们可以看到PC计数器的计数序列,这每一个序列唯一对应进程中所映射的可执行文件的的每一条指令,或者是包含在运行时动态链接到程序的共享库对象中的指令,这个PC的序列叫做逻辑控制流,简称逻辑流。

 

6.5.2. 并发流

1. 并发流:一个逻辑流的执行在时间上与另一个流重叠,这两个流被称为并发地执行。

2. 并发:多个流并发地执行的一般现象

3. 多任务:一个进程和其他进程轮流运行的概念。

4. 时间片:一个进程执行它的控制流的一部分的每一时间段,多任务也叫时间分片

5. 并行流:两个流并行地运行在不同的处理器上或计算机上。

 

6.5.3. 私有地址空间

进程为每个程序提供它自己的私有地址空间。一般而言这个空间中的某个地址相关联的那个内存字节是不能被其他进程读或者写的

尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的结构,如下图所示。

6.5.4 用户模式和内核模式

1. 内核模式:处理器通过某个控制寄存器中的一个模式位来提供限制一个应用可以执行的指令以及它可以访问的地址空间范围的功能。该寄存器描述了当前进程享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存为止

2. 用户模式:没有设置模式位时,进程就运行在用户模式中。用户模式的进程不允许和执行特权指令、也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。

 

6.5.5. 上下文切换

1. 上下文:内核为每个进程维持一个上下文,上下文就是内核重新启动的一个被强占的进程所需的状态。由包括通用目的寄存器、浮点寄存器、程序计数器、用户站、状态寄存器、内核栈和各种内核数据结构。

2. 调度:内核可以决定抢占当前进程,并重新开始开始一个先前被抢占了的进程,由内核中的调度器处理

3. 当内核代表用户执行系统调用时,可能会发生上下文切换。

4. sleep和中断也可能引起上下文切换。

6.5.6. Hello程序分析

由以上分析可知,结合hello.c的代码:

Hello运行时的过程是这样的:

  1. 首先由加载器把hello加载到子进程中,保存并解析用户传过来的参数,运行时是处于用户模式,由cpu分配时间片,有自己的控制流,假设不和其他进程并行。
  2. 将参数的个数和3作对比,若不等于三,则调用printf,printf是系统函数write,此时调用write陷入到内核,发生上下文切换,内核中的程序开始执行,将字符串输出到屏幕,在输出过程中,输出函数所在进程又回到了用户模式,直到输出结束后,输出进程收到输出完成的中断信号,又回到了hello程序中并陷入内核模式,当回到hello中继续执行时,又回到了用户模式,然后调用exit终止函数进入内核模式
  3. 若等于3,则进入循环,在循环中有输出函数,分析如第二条所示
  4. 循环中还有sleep函数,hello进程遇到sleep函数进入切换到内核模式,然后切换完后在用户模式下执行sleep程序的进程,执行完后又陷入内核模式回归hello,然后又恢复到用户模式。
  5. 在4步的基础上循环10次

6.6 hello的异常与信号处理

异常总的来说可分为中断、陷阱、故障、终止

首先程序中的系统调用函数如printf或getchar都是陷阱,他们故意产生一个异常来让异常处理程序执行,从而实现输入输出。

而ctrl-z, ctrl-c是中断,是异步异常会产生SIGTSTP 键盘挂起 , SIGINT  键盘终止,SIGCONT 进程继续等信号。

下面来具体分析每个指令:

Ctrl+z的输入发送一个SIGTSTP信号让进程暂时停止

此时job组把hello进程状态设为“挂起”

进程树,其中hello的进程在:

可以看到,它的父进程是bash,爷爷进程是gnome-terminal

Fg发送SIGTSTP信号让hello继续前台执行。

用kill将hello终止

可见无任务了

若用ctrl+C则直接终止

6.7本章小结

进程是计算机系统中非常重要的概念,它是程序运行的抽象的实体,是程序面向用户的代言人,用户了解进程是如何执行的有助于用户在shell中决策,进程的各种机制保证了程序运行的稳定性与对外界良好的交互性,进程上下文之间的各种切换也同时使得程序可以处理各种异常情况及不同的信号。

异常和信号是推进程序和程序,程序和用户间交互的媒介,有了异常处理机制,我们可以舒服地控制程序的运行状态,程序也可以轻松地调用各种系统函数来实现IO等操作。

 

以下格式自行编排,编辑时删除

(第6章1分)


7hello的存储管理

7.1 hello的存储器地址空间

7.7.1. 逻辑地址:由程序产生的与段相关的偏移地址部分。这样该存储单元的地 址就可以用段基址(段地址)和段内偏移量(偏移地址)来表示,段基址确定它 所在的段居于整个存储空间的位置,偏移量确定它在段内的位置,这种地址 表示方式称为逻辑地址,通常表示为段地址:偏移地址的形式。一个逻辑地   

址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的 字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一 些硬件细节

7.7.2. 线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑    地址是段中的偏移地址,然后加上基地址就是线性地址。线性地址是一个

   32位无符号整数,可以用来表示高达4GB的地址,也就是,高达4294967296个内存单元。线性地址通常用十六进制数字表示,值的范围从0x00000000到0xffffffff)。 程序代码会产生逻辑地址,通过逻辑地址变换就可以生成一个线性地址。如果启用了分页机制,那么线性地址可以再经过变换以产生一个物理地址。当采用4KB分页大小的时候,线性地址的高20位为页目录项在页目录表中的编号,中间十位为页表中的页号,其低12位则为偏移地址。如果是使用4MB分页机制,则高10位页号,低22位为偏移地址。如果没有启用分页机制,那么线性地址直接就是物理地址。

7.7.3.  虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。注意,  并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行

在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地

址,而是直接使用物理地址的。

如果CPU寄存器中的分页标志位被设置,那么执行内存操作的机器指令时,CPU(准确来说,是MMU,即Memory Management Unit,内存管理单元)会自动根据页目录和页表中的信息,把虚拟地址转换成物理地址,完成该指令。

7.7.4. 物理地址:在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

段式管理:段式管理(segmentation),是指把一个程序分成若干个段(segment)进行存储,每个段都是一个逻辑实体(logical entity),程序员需要知道并使用它。它的产生是与程序的模块化直接有关的。段式管理是通过段表进行的,它包括段号或段名、段起点、装入位、段的长度等。此外还需要主存占用区域表、主存可用区域表。

 段是对程序逻辑意义上的一种划分,一组完整逻辑意义的程序被划分成一段,所以段的长度是不确定的。

 

7.3 Hello的线性地址到物理地址的变换-页式管理

页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。

将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。

 CPU的页式内存管理单元负责把一个线性地址转换为物理地址。从管理和效率的角度出发,线性地址被划分成固定长度单位的数组,称为页(page)。例如,一个32位的机器,线性地址可以达到4G,用4KB为一个页来划分,这样,整个线性地址就被划分为一个2^20次方的的大数组,共有2的20次方个页,也就是1M个页,我们称之为页表,改页表中每一项存储的都是物理页的基地址。

这里不得不说的是另一个“页”,我们称之为物理页,或者页框、页桢。是分页单元将所有的物理内存都划分成了固定大小的单元为管理单位,其大小一般与内存页大小一致。

具体转换方式如下图所示:

7.4 TLB与四级页表支持下的VA到PA的变换

上图是关于corei7的地址翻译简图与cpu结构图,从中我们可以抽出TLB与四级页表两部分。

  1. 关于TLB:TLB(Translation Lookaside Buffer)转换检测缓冲区是一个

内存管理单元,用于改进虚拟地址到物理地址转换速度的缓存。

TLB是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单个PTE(Page Table Entry,页表项)组成的块。如果没有TLB,则每次取数据都需要两次访问内存,即查页表获得物理地址和取数据。VA的VPN部分分为TLBT和TLBI两部分对TLB进行访问,TLBI是组所以,TLBT是标记,访问方式和cache相似。若在TLB中找到了与VA的匹配,即找到了PPN,我们称为TLB命中,此时将PPN拿出,与VPO组成PA。TLB作为页表的缓存,可减少每次都从页表中访问的开销。

  1. 关于四级页表:页表是一种特殊的数据结构,放在系统空间的页表区,存 

 放逻辑页与物理页帧的对应关系。 每一个进程都拥有一  

个自己的页表,PCB表中有指针指向页表。

当TLB不命中时, VA的VPN将被分为4段,:VPN1-VPN4。cpu维护了一个CR3寄存器,它保存了第一级页表的基址,然后VPN1是在第一级页表的偏移量,我们通过这两个数据找到了第一级页表中的PTE1,它指向第二级页表的基址,而VPN2是第二级页表的偏移量。以此类推,最终在PTE4中找到了PPN,与VPO组成VA。当第一次在页表中访问时,页表会把那一条送给TLB,使得下次再访问时直接去TLB内访问即可。用这种方法可提高页表在主存中的空间利用率。

7.5 三级Cache支持下的物理内存访问

Cache:Cache存储器:电脑中为高速缓冲存储器,是位于CPU和主存储器DRAM(DynamicRandomAccessMemory)之间,规模较小,但速度很高的存储器,通常由SRAM(StaticRandomAccessMemory静态存储器)组成。它是位于CPU与内存间的一种容量较小但速度很高的存储器。CPU的速度远高于内存,当CPU直接从内存中存取数据时要等待一定时间周期,而Cache则可以保存CPU刚用过或循环使用的一部分数据,如果CPU需要再次使用该部分数据时可从Cache中直接调用,这样就避免了重复存取数据,减少了CPU的等待时间,因而提高了系统的效率。Cache又分为L1Cache(一级缓存)和L2Cache(二级缓存),L1Cache主要是集成在CPU内部,而L2Cache集成在主板上或是CPU上。

流程图为7.4中的图所示。

 

上述步骤构造出的物理地址PA分为三部分,CT, CI,CO,通过CI组索引找到

一级cache的组,然后对比PA的CT和组中的CT是否相同并且组那一行的有效位是否有效,如果有效就根据偏移码访问一块中的偏移值,称为命中。如果无效或标记没有匹配的,称为不命中,则从底层的设备(2,3级cache,主存)寻找并调到第1级cache。

7.6 hello进程fork时的内存映射

当fork函数被shell调用时,内核会为hello进程创建各种数据结构并分配给hello唯一的PID。为了给hello创建虚拟内存,内核创建了当前进程的mm_struct、区域结构和样表的原样副本,并将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为写时复制。

从上述过程可以看出,在调用fork后和调用execve之前,hello所在进程的内存空间情况和父进程完全一致

7.7 hello进程execve时的内存映射

上图来自csapp的内存映射

在执行execve函数后,内核开始加载hello程序到子进程,此时发生了如下几个事情:

  1. 删除已存在的用户区域结构。
  2. 调用mmap函数创建新的区域,并映射hello程序的各种段和区到这个新的区域,初始化栈和堆。
  3. 映射共享区域,由于hello程序里调用了共享库函数,这些动态的对象也被映射进内存空间。
  4. 设置程序计数器PC,使PC指向程序的入口准备执行。

 

7.8 缺页故障与缺页中断处理

  1. 缺页故障:页缺失(英语:Page fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。

通常情况下,用于处理此中断的程序是操作系统的一部分。如   果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的     

分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允 

许的,那么操作系统通常会结束相关的进程。

  1. 缺页中断处理

缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。在这个时候,被内存映射的文件实际上成了一个分页交换文件。

缺页中断发生时的事件顺序如下:

1) 硬件陷入内核,在内核堆栈中保存程序计数器。大多数机器将当前指令的各种状态信息保存在特殊的CPU寄存器中。

2) 启动一个汇编代码例程保存通用寄存器和其他易失的信息,以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。

3) 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析这条指令,看看它在缺页中断时正在做什么。

4) 一旦知道了发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。如果不一致,向进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。如果没有空闲页框,执行页面置换算法寻找一个页面来淘汰。

5) 如果选择的页框“脏”了,安排该页写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用。

6) 一旦页框“干净”后(无论是立刻还是在写回磁盘后),操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入。该页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行的用户进程,则选择另一个用户进程运行。

7) 当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映它的位置,页框也被标记为正常状态。

8) 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令。

9) 调度引发缺页中断的进程,操作系统返回调用它的汇编语言例程。

10) 该例程恢复寄存器和其他状态信息 [1]

7.9动态存储分配管理

7.9.1. 动态内存分配器的基本原理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护一个变量brk,它指向堆顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应用程序使用。空闲块可用来分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格:显式分配器和隐式分配器。两种风格都要求应用显式的分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。

7.9.2. 带边界标签的隐式空闲链表分配器原理

 

边界标记允许在常数时间内进行对前面块的合并。这种思想,是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚步,那么分配器就可以通过检查它的脚部判断前面一个块的起始位置和状态。这个脚部总是在距当前块开始位置一个字的距离。

考虑当分配器释放当前块时所有可能存在的情况:

1 前面的块和后面的块都是已分配的。

2 前面的块是已分配的,后面的块是空闲的。

3 前面的块是空闲的,后面的块是已分配的。

4 前面的和后面的块都是空闲的。

在情况1中,两个邻接的块都是已分配的,因此不可能进行合并。所以当前块的状态只是简单地从已分配变成空闲。在情况2中,当前块与后面的块进行合并。用当前块的大小的和来更新前面块的头部和当前块的脚部。在情况3中,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。

7.9.2. 显式空间链表的基本原理

根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数。这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用LIFO的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

另一种方法是按照地址顺序来维护链表,其中链表每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

7.10本章小结

由于计算机的内存永为奢饰品,所以科学家们提出各种各样的体系结构和机制来优化存储性能。现代计算机采用了多级的存储结构,速度越快的存储模块容量相对小,造价相对高,而速度越慢的存储模块容量相对大,造价相对低。这样计算机通过多级缓存的机制提高整体的运行效率,减少每次从庞大储存器上访问的开销。

虚拟内存现代计算一个重要的概念,它是核心的,强大的也是危险的,它是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个私有的地址空间。虚拟内存概念的提出几乎完美地结局了计算机经常的内存空间不足的为题。

正式由于这样那样的内存机制,使得我们的程序可以稳定的运行,而且不会严重拖垮计算机的运行负担,使得不同进程之间可以有条不稳的并行运作。

(第7章 2分)


8hello的IO管理

8.1 Linux的IO设备管理方法

8.1.1. IO设备管理的抽象层

在分时并行多任务系统中,为了合理利用系统设备,达到一定的目标,不允许进程自行决定设备的使用,而是由系统按一定原则统一分配、管理。进程要进行IO操作时,需向操作系统提出IO请求,然后由操作系统根据系统当前的设备使用状况,按照一定的策略,决定对改进程的设备分配。设备的应用领域不同,其物理特性各异,但某些设备之间具有共性,为了简化对设备的管理,可对设备分类,或对同类设备采用相同的管理策略,比如Linux主要将外部IO设备分为字符设备和块设备(又被称为主设备),而同类设备又可能同时存在多个,故而要定位具体设备还需提供“次设备号”。

根据主设备号+次设备号可以去相应的设备开关表中定位具体的设备驱动程序。 内核和设备驱动程序的接口是块设备开关表和字符设备开关表。每一种设备类型在表中占用一个表项,每个表项含有若干数据项,其中有一项为该类设备驱动程序入口地址,在系统调用时引导核心转向适当的驱动程序接口。

Linux系统为各类设备分别配置不同的驱动程序,在用户程序中通过文件操作方式使用设备,如open\close\read\write等,由文件系统根据用户程序指令转向调用具体的设备驱动程序。

对设备特殊文件的系统调用,根据文件类型转入块设备开关表或字符设备开关表进行打开、关闭设备的操作,字符设备特殊文件的系统调用read、write转向字符设备开关表中指示的设备驱动程序,而对普通文件或目录文件的read\write系统调用则通过高速缓冲模块(缓冲区)转向设备驱动模块中的strategy过程。Linux中关于不同设备种类的管理架构如下:

8.1.2. 设备分配使用方式

独占设备是指被分配给一个进程后,就被该进程独占使用,必须等到该进程退出后,其他进程才能进入,(如打印机)。

共享设备是指可以由多个进程交替使用的设备,如磁盘。

对于独占设备通常采用静态分配的方式,在一个作业开始执行前进程独占设备的分配,一旦把某独占设备分配给作业,就被该作业独占(永久地分配给该作业),直到作业结束撤离时,才由操作系统将分配给作业的独占设备收回,静态分配方式实现简单,但是设备利用效率不高。

对于共享设备采用动态分配方式,即在作业运行过程中,当进程需要使用设备时,通过系统调用命令向系统提出IO请求,系统按一定策略为进程分配所需设备,进程一旦使用完毕就立即释放该设备。共享设备一旦完成当前IO工作就被释放,从而使多个并发进程可以交替使用此设备,设备利用率高。

自然而然的设备分配算法有两种常见的:1.先来先服务FIFO队列;2.优先级队列(多个不同级别的FIFO队列)。

而一旦涉及到共享对象的分配,则显然需要考虑死锁的情况,多个进程彼此占有对方此刻正在请求的资源,形成“请求并且保持”的僵局。

3. 设备无关性

设备无关性是指当在应用程序中使用某类设备时,不直接指定具体使用哪个设备,而只指定使用哪类设备,由操作系统为进程分配具体的一个该类设备。设备无关性功能可以使应用程序的运行不依赖于特定设备是否完好、是否空闲,而由系统合理地进行分配,从而保证程序的顺利进行。

为了便于描述设备无关性,引入逻辑设备和物理设备这两个概念。逻辑设备指示一类设备,物理设备指示一台具体的设备。类似于虚拟内存和物理内存的对应关系。这要求存储管理要具备地址变换的功能。

设备无关性显然也是操作系统关于IO设备管理提供的一层抽象层,让上层程序无需关心使用IO设备的细节情况。

4. 虚拟设备技术:利用增加抽象层再一次完美解决独占设备影响进程速度的问题

系统中独占设备的数量有限,且对独占设备的分配往往采用静态分配方式,这样做不利于提高系统效率,这些设备只能分配给一个作业,且 在作业的整个运行期间一直被占用,直至作业结束后才能被释放。但是一个作业往往不能充分利用该设备,在独占设备被某个作业占用期间,往往只有一部分时间在工作,其余时间出于不工作的空闲状态,因此设备利用率低,其他申请该设备的作业因得不到该设备而无法工作,降低了系统效率。

另一方面,独占设备往往是低速设备,因此,在作业执行过程中,如果直接使用该类设备会因为数据传输的低速大大延长作业的执行时间。

为了克服独占设备的这些缺点,可以采用虚拟设备技术,即用为每个独占设备配备相应的高速缓冲区(如高速磁盘、内存中专门划分的存储区域)来模拟该独占设备同时并存的多个虚拟接口。

虚拟设备技术的关键是预读取、缓写入

预读取:在作业执行前,操作系统便将作业信息从独占设备预先读取到高速外存中,此后作业执行中不再占有独占输入设备,使用数据时不必再从独占设备输入,而是从高速外存中读取;

缓写入:在作业执行过程中,当要进行输出操作时,不必直接启动独占设备输出数据,可以先将作业输出数据先写入到高速缓冲区中,并设置延迟写的标志,可以在作业执行完毕或延迟写时间到达后,再由操作系统来组织信息的批量输出。

由于作业运行期间不占有独占设备,从而使一台独占设备可以利用虚拟设备技术制造出多个影分身,提高了独占设备的利用率,而且在作业执行过程中,作业并不直接和慢速的独占设备打交道,因此缩短了作业的执行时间。

8.2 简述Unix IO接口及其函数

8.2.1.open

打开文件 返回一个小的非负整数,即描述符。用描述符来标识文件。每个进程都有三个打开的文件:标准输入(0)、标准输出(1)、标准错误(2)

 

函数原型:int open(char *filename, int flags, mode_t mode);

flags:进程打算如何访问文件

 

O_RDONLY:只读    O_WRONLY:只写    O_RDWR:可读可写

也可以是一个或更多位掩码的或:

O_CREAT:如文件不存在,则创建

O_TRUNC:如果文件已存在,则截断

O_APPEND:每次写操作,设置k到文件结尾

mode:指定新文件的访问权限位

每个进程都有一个umask,通过调用umask函数设置。所以文件的权限为被设置成mode & ~umask

 

8.2.2.lseek

返回值成功函数返回新的文件偏移量  失败-1   fd文件描述符   off_t是有符号的整数  whence其实是和off_t配套使用的  SEEK_SET文件开始处   SEEK_CUR当前值的相对位置  SEEK_END文件长度+-。

 

函数原型:off_t lseek(int fd, off_t offset , int whence) ;

 

8.2.3. read

读操作:从文件拷贝n个字节到存储器,从当前文件位置k开始,将k增加到k+n,对于一个大小为m字节的文件,当k>=m时,读操作触发一个EOF的条件。

 

函数原型:ssize_t read(int fd, void *buf, size_t n);

 

返回值是文件读取字节数 ,在好几种情况下会出现返回值不等于文件读取字节数 也就是第三个参数nbytes的情况,  第二个形参buf读取到buf的内存 ,文件偏移量(current  file offset)受改变 。

 

8.2.4. write

写操作:从存储器拷贝n个字节到文件,k更新为k+n

 

函数原型:ssize_t write(int fd, const void *buf, size_t n);

 

返回值是文件写入字节数,fd是文件描述符,将buf内容写入nbytes个字节到文件,但这里需要注意默认情况是需要在系统队列中等待写入(打开方式不同也会不同)

 

8.2.8. 关闭文件

内核释放文件打开时创建的数据结构,并恢复描述符到描述符池中,进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。

close函数原型  int close(int fd)    

返回值 成功返回0  失败—1    关闭文件描述符

 

8.3 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;

}

以上是printf函数的定义,fmt是printf函数第一个字符串参数的指针,后面的...是不确定参数的个数。

va_list的原型是char*,所以arg是一个指向字符的指针,而(va_list)((char*)(&fmt) + 4)则表示的是...参数组中第一个参数(因为fmt是fmt是第一个参数,它在32位下大小为4,最后一个压入栈中,所以它的地址+4就是...中的第一个参数了)

接下来调用vsprintf函数

int vsprintf(char *buf, const char *fmt, va_list args)

 {

    char* p;

    char tmp[256];

    va_list p_next_arg = args;

    for (p=buf;*fmt;fmt++) {

     if (*fmt != '%') {

     *p++ = *fmt;

     continue;

     }

     fmt++;

     switch (*fmt) {

     case 'x':

    itoa(tmp, *((int*)p_next_arg));

     strcpy(p, tmp);

     p_next_arg += 4;

     p += strlen(tmp);

     break;

     case 's':

     break;

     default:

     break;

     }

    }

    return (p - buf);

 }

Vsprintf函数的作用是把变量格式化进字符串,然后把这个拼接好的字符串的长度返回,然后调用write函数

上图是write的内容,最后一步调用了系统函数syscall

上图是syscall的实现。

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

函数定义:

int getchar(void)

{

    static char buf[BUFSIZ];

    static char* bb=buf;

    static int n=0;

    if(n==0)

    {

        n=read(0,buf,BUFSIZ);

        bb=buf;

    }

    return(--n>=0)?(unsigned char)*bb++:EOF;

}

getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。

由getchar的代码可知它是调用了系统函数read来读取用户输入的字符

Read函数从缓冲区读取字符,然后判断是否读入的不是文件末尾,若不是则输出这个字符,若是则输出EOF。

 

8.5本章小结

系统提供的IO功能是计算机与用户,计算机与计算机,以及计算机内部交互的重要保障功能。它是主存与外部设备之间复制数据的过程。由于实现了write,read,close这样的底层功能的函数,使得开发者可以根据不同的用户需求来构造更庞大的IO函数,例如printf,scanf,甚至是在c++中的cin,cout等。可见系统级IO的重要性。

理解号系统级IO有助于理解其他有关操作系统概念,例如进程和信号,反之,只有理解好进程的工作原理,才知道什么时候需要IO来帮忙,两者是相辅相成的关系。

 

 

 

 

 

结论

Hello的一生,既短暂又漫长

  1. 程序员打开了一个文本编辑器,用c语言写好了hello的源代码文件,储存在了硬盘里。
  2. Gcc开始工作。GCC的中一个叫做preprocessor(cpp)(预处理器)的工具开始了它的工作,它先将Hello.c里面的所有注释清掉,把里面的宏定义展开,把#include头文件里的内容复制进来,把Hello.c改造成了Hello.i,使得hello的c语言代码更加完整和饱满,这时的Hello虽然已不是原来的Hello,但里面的内涵基本没变(还是c代码)
  3. Preprocessor工作后,compiler(ccl)(编译器)开始工作。它把hello.i变成了了Hello.s,Hello从c代码被翻译成了汇编代码,更加贴近于机器指令的语言。
  4. 接下来Assembler(汇编器)开始工作了,它又开始改造Hello.s,把它变成了01代码Hello.o,此时,Hello已经成为了二进制目标代码,并且把所有的全局符号(例如全局变量和常亮字符串,函数等)都分配了逻辑地址
  5. 然后linker(链接器),它解析Hello.o中未定义的符号,并把标准库中的函数拿过来与hello.o结合成一个整体,然后它还把其中的地址统一化了,终于,我们的hello.o变成了可执行的hello!
  6. 接下来用户在在shell中,输入./hello就可以启动这个程序啦!首先输入后,用户的输入的字符串被shell中的解析函数解析成不同的小字符串,这个输入的过程中包含了异常处理和系统级IO的领域,因为由系统级IO的机制,我们才能与外界设备交互,因为有了异常处理,shell才能对外界输入做出反应。然后判断输入的是否为内置指令,若不是shell会假定用户输入为一个可执行文件的名字,从而去加载并执行该文件。
  7. 执行文件时,首先shell在终端下调用fork函数创建一个子进程,这个子进程除了pid之外,一切的数据和内部结构和父进程相同。这个然后在子进程中用execve函数来加载我们刚刚的hello可执行文件
  8. 在加载过程中,首先系统会删除子进程用户区域的内容,然后用mmap函数创建新的内存区域,同时,新创建的区域将与hello程序中的不同段进行映射(例如新区域的代码和数据区域会映射成hello程序中的.test和.data段,还有与hello链接形成的库的共享段等),然后execve设置当前进程上下文中的程序计数器,加载器跳转到_start处,最后运行到main函数开始执行hello中的指令,cpu为执行hello分配时间片。
  9. Cpu按照程序计数器中的值对代码段的指令进行读取,然后按照流水线的cpu体系结构执行这些指令(PC,取值,译码,执行,访存,写回)等
  10. 当执行到程序的最后,程序return,进程终止,这个子进程等待这父进程shell的回收,然后这个进程就消失了,机器的状态又回到了执行hello程序之前的样子。

以上过程这就是Hello的一生。

感悟:

我记得有一天我因为学不会算法而很沮丧,我的一个同学鼓励我说:“加油!你已经在学计算机了,已经在学这个世界上最复杂,最困难的工具了,你已经超过了很多人了,千万别沮丧!”当时我只把它当做一个鼓励的话语,但直到今天,直到我学了计算机系统这门课,我才理解他那番话并不只是在鼓励我,现代计算机,这个年龄还不足百岁的工具,它内部的复杂程度已经超乎了人们的想象。

就拿hello程序这个例子做分析,其实抛开hello运行不谈,就只说hello.c程序编写时,我们从键盘上敲的每一个字,计算机都无时无刻的调用系统输入函数,异常管理,以及对主存的调入调出,可见我们多么简单的需求,计算机内部都会产生巨大的联动反应来实现我们的需求。而更神奇的是,它内部这么复杂的运行着,而我们这些简单的用户却毫无察觉,打字——只是一瞬间的事,看见显示出的字——又是一瞬间的事,从这些简单的外表又如何明白,那些科学家和工程师们日日夜夜奋战研究创新的成果呢?题目让我写感悟,我的感悟只有震撼二字,其实我们耐心分析,就像上文中提到的,一章一章的分析,这些工作其实人都是能做的。例如c语言到汇编语言的翻译,人有翻译能力啊;又例如内存分配,人也可以对着张表,一点一点规划啊。但是这一切的工作,尤其是大量重复性和需要大量计算的工做,计算机都帮我们自己完成了,它把最复杂的工作消化,把最简单的指令——那些让用户动动手就能的到结果的指令放在外面,其实我认为,这就是一种相当高度的抽象,简单的输入——复杂的过程——简单或不简单的输出,这也是人类繁衍一直所追求的过程,希望投入少量就可满足所需的梦想,就像自动炒菜机那样,炒菜机对加作料,翻炒工作的执行就像是操作系统对进程,信号,逻辑控制等内容的执行。随着人类的科技越来越高,我想这种抽象程度会越来越重,因而人类可能会更有效率的工作,也可能会变得很懒惰。

但无论如何,计算机系统这个庞大的体系是屹立在人类发展道路上一座不朽的里程碑。

(结论0分,缺 -1分,根据内容酌情加分)


附件

列出所有的中间产物的文件名,并予以说明起作用。

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


参考文献

为完成本次大作业你翻阅的书籍与网站等

  1.  readelf命令和ELF文件详解

     https://blog.csdn.net/linux_ever/article/details/78210089

  1.  逻辑地址、线性地址和物理地址之间的转换 

https://blog.csdn.net/gdj0001/article/details/80135196

  1.  缺页中断

https://baike.baidu.com/item/%E7%BC%BA%E9%A1%B5%E4%B 8%AD%E6%96%AD/5029040

  1.   Linux内核:IO设备的抽象管理方式

https://blog.csdn.net/roger_ranger/article/details/78473886

  1.   read和write系统调用以及getchar的实现

https://blog.csdn.net/ww1473345713/article/details/51680017

  1.   C++预处理器

http://www.runoob.com/cplusplus/cpp-preprocessor.html

  1.  深入理解计算机系统(第三版)
  2.  动态链接

https://baike.baidu.com/item/%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5/4142708?fr=aladdin

(参考文献0分,缺失 -1分)

 

你可能感兴趣的:(csapp)