CSAPP 程序人生-Hello’s P2P

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业  计算机                

学     号  1190200913            

班     级  1936602               

学       生  林煜鹏                

指 导 教 师  刘宏伟                 

计算机科学与技术学院

2021年6月

摘  要

本文简述了一个c文件从源文件到程序,再到进程,最后被回收的全流程。其中,c文件通过预处理,编译,汇编,链接,最后生成可执行程序。而可执行程序通过shell创建和运行,成为一个进程。进程在经过存储管理和IO管理后,顺利运行,并最终被回收。本文就对这些过程进行一一详述。

关键词:CSAPP;编译;缓存;进程;系统流程;                           

(摘要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: hello.c源文件在cpp, ccl , as , ld的作用下,经过预处理——编译——汇编——链接这四个步骤生成一个名为hello的可执行文件。最终在shell中新建进程运行。这就是所谓from Program to Process(P2P)。

020:hello进程从不存在开始,在shell的作用下通过execve在fork产生的子进程中生成hello,为hello的代码、数据、bss和栈映射虚拟内存。在hello开始运行时分配并载入物理内存,cpu为hello分配时间片并执行逻辑控制流,UNIX I/O管理输入输出,最终hello进程结束,shell回收其内存空间,hello不复存在。这就是所谓的 from Zero-0 to Zero-0(020)

1.2 环境与工具

硬件环境:CPU: Intel Core i7-10870H;32G DDR4 3200hz RAM;2TB SSD Disk

软件环境:Microsoft Windows10 Home 64位;VMware Workstaion 15.5 Pro; Ubuntu 18.04

开发工具 gcc  readelf  edb  wxHexeditor  codeblocks

1.3 中间结果

文件名称

作用

hello.c

源代码文件

hello.i

经过预处理得到的C语言文件

hello.s

经过编译得到的汇编文件

hello.0

经过汇编之后的可重定位目标执行文件

hello

经过链接之后的可执行文件

1.4 本章小结

本章主要介绍了什么是P2P、020,以及hello的P2P、020过程,给出了本次实验的软硬件信息和开发工具,以及文件的中间结果

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

2.1.1 何为预处理

预处理即在编译驱动程序处理程序源代码,使之转化为目标程序的过程中,首先执行的行为。预处理将源代码分割或处理成为特定的单位,用来支持语言特性。在C、C++语言中,预处理会对源代码文件中以字符#开头的代码行进行处理。

2.1.2 预处理的作用

具体处理内容包括宏定义、源文件包含、条件编译、行控制、错误指令等。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

预处理指令主要有以下4类

  1. 宏定义指令

#define Name TokenString#undef等。对于前一个预处理指令,预处理所要做的是将程序中的所有名为Name的字符用TokenString替换,但作为字符串常量的Name则不被替换。对于后者,则将取消对某个宏的定义,使以后该串的出现不再被替换。

  1. 条件编译指令

#ifdef,#ifndef,#else,#elif,#endif,等等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉

  1. 源文件包含指令

#include "FileName"或者#include 等。在头文件中一般用伪指令#define定义了大量的宏(最常见的是字符常量),同时包含有各种外部符号的声明。采用头文件的目的主要是为了使某些定义可以供多个不同的C源程序使用。因为在需要用到这些定义的C源程序中,只需加上一条#include语句即可,而不必再在此文件中将这些定义重复一遍。预编译程序将把头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。

包含到c源程序中的头文件可以是系统提供的,这些头文件一般被放在/usr/include目录下。在程序中#include它们要使用尖括号(<>)。另外开发人员也可以定义自己的头文件,这些文件一般与c源程序放在同一目录下,此时在#include中要用双引号("")。

  1. 特殊符号

预编译程序可以识别一些特殊的符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的.C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。

预编译程序所完成的基本上是对源程序的“替换”工作。经过此种替换,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。这个文件的含义同没有经过预处理的源文件是相同的,但内容有所不同。下一步,此输出文件将作为编译程序的输出而被翻译成为机器指令。

2.2在Ubuntu下预处理的命令

gcc -E hello.c -o hello.i

图2.1 预处理前

图2.2 预处理后

2.3 Hello的预处理结果解析

预处理之后,我们打开两个文件,查看内部内容

图2.3 预处理后文件内容比较

通过比较 我们可以发现,经过预处理后,#include指令消失,被头文件内容代替,并且 //注释 也消失了。可以看到main函数部分没有变化,说明预处理只是简单的替换和删除,并没有真正开始程序的编译等过程。

2.4 本章小结

预处理过程是计算机对源程序进行操作的第一步,在这个过程中预处理器(cpp)会对hello.c文件进行初步的处理,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除,最后将初步处理完成的文本保存在hello.i中,方便以后的编译过程。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

       3.1.1 编译的概念

编译阶段是在预处理之后的下一个阶段,在预处理阶段过后,我们获得了一个hello.i文件,编译阶段就是编译器(ccl)对hello.i文件进行处理的过程。此阶段编译器会完成对代码的语法和语义的分析,生成汇编代码,并将这个代码保存在hello.s文件中。

3.1.2 编译的作用

代码在编译阶段会进行语法的检查。如果出现语法错误,则会编译失败。如

果语法不存在问题,编译器会将代码转换为汇编代码。

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

图3.1 编译前

图3.2 编译后

3.3 Hello的编译结果解析

3.3.1数据

(1)全局变量

查看源代码,可以知道我们的全局变量是int类型,标识符为sleepsecs

图3.3 源代码的全局变量

查看汇编代码

     

图3.4 汇编代码中的全局变量定义

可以看出,汇编代码中 .globl代表的是全局变量,.type说明这个是一个数据,而.size表示其占有的字节大小,为4个字节。

(2)局部变量

查看源代码,可知我们定义了一个局部变量int i

 查看汇编代码,我们并没有发现int i的定义,但是在赋值部分可以看到i

影子,这个下一部分讨论

3.3.2赋值

我们观察源代码可知,在最初定义全局变量的时候就进行了赋值

查看汇编代码,赋值语句是这样的形式

图3.5 汇编代码中全局变量的赋值

可以看见有三行,第一行声明了变量名称sleepsecs,第二行为变量的值,第三行为储存的位置,由代码可知为.rodata节。

我们可以看出,当时赋值语句是将2.5赋值给这个变量,但是他保存的值却是2,这其中实际上发生了一次隐式类型转换,这个下一部分讨论

我们观察局部变量的赋值

 

图3.6 汇编代码中局部变量的赋值

可以看出,局部变量是存储在栈中的,需要使用的时候,通过mov指令进行赋值

3.3.3类型转换

上节指出,sleepersec赋值时发生了隐式的类型转换。这是由于sleepersec是int型,而赋值的数据为2.5,为float型,因此自动发生了类型转换。查看代码我们可以看到,这一步是直接赋值为2,而没有其他特殊的操作。

3.3.4算数运算

源代码中只出现了一次算数操作,即i++。

在汇编代码中是这样显示的

图3.7 汇编代码中算数操作

3.3.5关系操作

两个关系操作,阅读源代码可知,一个为不等于操作,另一个为小于操作

 

图3.8 关系操作

这里我们可以看出,无论是哪种关系操作,在汇编代码中都是使用cmp进行比较,比较之后设定条件码,为之后的条件跳转打下基础

3.3.6控制转移

我们知道,在c语言中的逻辑结构,如循环,条件判断等,转换为汇编语言后就变成了控制转移。

控制转移主要通过jX指令执行,其中X为条件,即上一节谈到的条件码。当条件码满足跳转条件时,就会进行跳转。

jX指令有11条,条件类型有无条件,是否相等,是否为负,是否大于(等于),是否小于,并且有符号数和无符合数的跳转指令略有区别。

在hello.s中,有if语句的控制转移和循环体的控制转移,我们分别分析

  1. if语句

图3.9 if语句的控制转移

我们可以简单看出,在使用cmpl比较了3和argc之后,进行了一个条件跳转,若相等则跳转到L2(if语块之后),即不运行je之后的语句而直接跳转到循环体部分。因此,这个比较和跳转即为if语句转换为汇编代码的形态。

  1. 循环体

图3.10 循环体的控制转移

分析代码可以看出,先进行了一个赋值操作(即i=0),然后无条件跳转到L3部分。L3部分将i和9进行对比,若i<=9,则跳转到L4,每次运行完成L4的部分时,i都会加1,然后再与9比较大小。与源代码对比,可以很清楚看出这个部分就是原来的循环体。因此,循环体转换为汇编代码时,会先进行一个无条件跳转到循环体的退出部分进行条件判断,若满足条件则正常退出,若不满足则跳转回循环体的开头执行指令,在执行完所有指令后进行自加一(或者其他操作改变值),然后再进行条件判断,以此反复直到完成循环。

3.3.7数组/指针/结构操作

阅读源代码,可以知道我们调用了argv[]里面的元素。对比汇编代码,我们可以看出汇编代码中已经没有数组、结构等概念,只存在地址和地址指向的值。对于数组的储存,在汇编中我们只保存了其起始地址,即argv[0]的地址,由于数组的储存是连续的地址,若要访问数组中其他元素只需要起始地址加上偏移量即可。

图3.11 数组操作

分析代码,我们可以知道%rbp-32储存的是argv[0]的地址,由于argv[]是char*型,因此一个元素占8字节。先将argv[0]的地址赋给%rax,然后再将%rax加16,我们可以知道,此时的%rax中储存的是argv[2]的地址,下面类似的调用了argv[1]的地址,并将它们分别赋值给了%rdx和%rsi,在下一节中我们会清楚这些寄存器是用来做什么的

3.3.8函数操作       

源代码中存在多个函数和函数调用。包括main函数,printf函数,exit函数,sleep函数,getchar函数,我们仅分析比较复杂的main函数,printf函数

首先我们先说明一下函数调用的过程。

(1)在调用者角度,我们需要向被调用函数传递参数,唤出被调用函数,然后接收返回值。对于传参操作,在参数比较少的情况,就直接存储在特定寄存器中,如%rdi,%rsi,%rdx,%rcx就分别用来存储第一至四个参数。X86的及其一共为我们提供了6个寄存器来保存参数。如果参数多于6个,那么就只能放在栈中保存了。传递参数完成后,我们使用call命令调用函数。call指令的操作原理是将call之后下一条指令的地址压入栈中,然后将被调用函数的首地址压入%rip中,从而达到调用的目的。调用完成后,返回值会储存在%rax中

(2)在被调用者角度,首先需要先将原先的栈底%rbp压栈,然后把原先栈顶%rsp的地址存入栈底%rbp中。这个操作的目的是保存原先被调用者的状态,并且方便在调用完成之后恢复到原先的状态。之后%rsp会减去一定数量的字节,这个步骤即为新内容分配空间(当前栈帧),然后将参数依次压入栈中,排序越靠前的参数,其压入栈的地址越高。之后执行完成后会先使用leave指令,让被调用者的状态返回到被调用前。leave指令的具体操作是将%rbp的值赋值回%rsp,然后将原%rbp弹出,以达到恢复的效果。然后最后使用ret指令,将返回地址弹出并压入%rip中,继续执行原指令。

汇编代码分析

(1)main函数

图3.12 被调用函数的初始化

main函数开始运行时,进行的保存原寄存器值,分配栈帧,保存局部变量的操作。.cfi代码是debug使用的,我们分析时可以无视。观察代码,我们可以很明显的看出,main函数首先先将%rbp的原先值进行储存,然后对%rsp-32,分配了大小为32字节的栈帧空间。由于我们知道main函数有两个参数,即argc和argv[],根据顺序分别保存在%rdi和%rsi中,之后的mov操作将这两个参数储存在栈中。初始化完成后开始正式运行

图3.13 被调用函数的退出

main函数完成运行后,将返回值0压入%rax中,然后使用leave指令将保存的%rbp的值恢复,最后使用ret完成调用,释放栈帧,回到当初调用main的位置(即结束)。

(2)printf函数

图3.14 调用函数前的初始化

这是第二次调用printf的时候,printf有三个参数:原来要输出的字符串(即"Hello %s %s\n"),argv[1], argv[2]。通过上面的代码我们可以清楚看出,参数按顺序存储在%rdi,%rsi,%rdx中,然后再使用call指令调用printf函数

3.4 本章小结

本章我们主要介绍了编译器是如何将文本编译成汇编代码的。可以发现,编译器并不是死板的按照我们原来文本的顺序,逐条语句进行翻译下来的。编译器在编译的过程中,不时会对我们的代码做一些隐式的优化,而且会将原来代码中用到的跳转,循环等操作操作用控制转移等方法进行解析。最后生成我们需要的hello.s文件。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

汇编器(as)将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中。这里的hello.o是一个二进制文件。

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o

图4.1 通过汇编生成可重定位文件的过程

4.3 可重定位目标elf格式

一般来说,elf文件的格式是这个样子的

图4.2 ELF文件的通用结构

而由于可重定向目标文件和可执行文件的不同,我们阅读ELF文件中又存在两种不同的视图,分别是链接视图和执行视图

链接视图是以节(section)为单位,执行视图是以段(segment)为单位。链接视图就是在链接时用到的视图,而执行视图则是在执行时用到的视图。上图左侧的视角是从链接来看的,右侧的视角是执行来看的。总个文件可以分为四个部分:

- ELF header: 描述整个文件的组织。

- Program Header Table: 描述文件中的各种segments,用来告诉系统如何创建进程映像的。

- sections 或者 segments:segments是从运行的角度来描述elf文件,sections是从链接的角度来描述elf文件,也就是说,在链接阶段,我们可以忽略program header table来处理此文件,在运行阶段可以忽略section header table来处理此程序(所以很多加固手段删除了section header table)。从图中我们也可以看出,segments与sections是包含的关系,一个segment包含若干个section。

- Section Header Table: 包含了文件各个section的属性信息。

图4.3 两种不同视图的ELF阅读方式

由于我们的hello.o为可重定向目标文件,因此我们用链接视图阅读

使用指令 readelf -a hello.o阅读hello.o的ELF文件

(1)ELF header

图4.4 ELF头的信息

第 1 行,ELF Header: 指名 ELF 文件头开始。

第 2 行,Magic 魔数,用来指名该文件是一个 ELF 目标文件。第一个字节 7F 是固定的;后面的 3 个字节正是 E, L, F 三个字母的 ASCII 形式。之后的02表示的是文件类型,其中0为无效文件,1为ELF32位文件,2为ELF64位文件。接下去的01表示的是字节序,其中0为无效格式,1为小端序,2为大端序,再之后的01为ELF版本,再之后的字节没有定义,一般填零。用图可以表示如下

图4.5 ELF魔数的构造

第 3 行,CLASS 表示文件类型,这里是 64位的 ELF 格式。

第 4 行,Data 表示文件中的数据是按照什么格式组织(大端或小端)的,不同处理器平台数据组织格式可能就不同。

第 5 行,当前 ELF 文件头版本号,这里版本号为 1 。

第 6 行,OS/ABI ,指出操作系统类型,ABI 是 Application Binary Interface 的缩写。

第 7 行,ABI 版本号,当前为 0 。

第 8 行,Type 表示文件类型。ELF 文件有 3 种类型,一种是如上所示的 Relocatable file 可重定位目标文件,一种是可执行文件(Executable),另外一种是共享库(Shared Library) 。 [这里就是区分上面三种类型的ELF文件]

第 9 行,机器平台类型,这里是在X86-64位机器。

第 10 行,当前目标文件的版本号。

第 11 行,程序的虚拟地址入口点,因为这还不是可运行的程序,故而这里为零。如果是可运行程序,这个地址并不是main函数的地址,而是_start函数的地址,_start由链接器创建,_start是为了初始化程序。

第 12 行,与 11 行同理,这个目标文件没有 Program Headers。

第 13 行,sections 头开始的地址偏移,这里 1320 是十进制.

第 14 行,是一个与处理器相关联的标志,x86 平台上该处为 0 。

第 15 行,ELF 文件头的字节数。64bytes

第 16 行,因为这个不是可执行程序,故此处大小为 0。

第 17 行,同理于第 16 行。

第 18 行,sections header 的大小,这里每个 section 头大小为 64bytes。

第 19 行,一共有多少个 section 头,这里是 14个。

第 20 行,section 头字符串表索引表(.strtab)。表中存储的信息是用来链接使用的,主要包括:程序代码、程序数据(变量)、重定向信息等。比如:.text保存的是代码,.data保存的是初始化或未初始化的数据,等等。

(2)节头部表(sections header)

图4.6 节头部表

  • .text:已编译程序的机器代码。
  • .rel.text:当链接噐把这个目标文件和其他文件结合时,.text节中的许多位置都需要修改。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非使用者显式地指示链接器包含这些信息。
  • .data:已初始化的全局C变量。局部C变量在运行时被保存在栈中,既不出现在.data中,也不出现在.bss节中。
  • .bss:未初始化的全局C变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分初始化未初始化变量是为了空间效率在:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
  • .rodata:只读数据,比如printf语句中的格式串和开关(switch)语句的跳转表。
  • .common现代编译器用 .COMMON 和 .bss 来更细化的区分这些变量,将未初始化的全局变量分配至 .COMMON 中。
  • .eh_frame & .rela.eh_framegcc编译的过程中用于处理异常的部分
  • .symtab:一个符号表(symbol table),它存放在程序中被定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,得到符号表信息。实际上,每个可重定位目标文件在.symtab中都有一张符号表。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的表目。
  • .strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。
  • .shstrtab此节区包含节区名称。

(3)重定位节

图4.7 重定位节

接下来可以看到.text节重定位的信息。重定位节中包括所有需要重定位的符号的信息,包括偏移量、信息、类型、符号值和符号名称+加数。当链接器把这个可重定位目标文件与其他文件相结合时,需要修改这些符号的位置。

其中,这些符号有两种类型。第一种类型为R_X86_64_PC32,表示重定位PC相对引用。链接器首先计算出引用的运行时地址,refaddr=ADDR(.text)+偏移量,然后更新该引用,使得它在运行时指向符号。这里的加数就是r.append,偏移量就是r.offset。故*refptr=ADDR(运行时地址)+r.append-refaddr,因此PC的值为PC+refptr,之后CPU调用call指令。

第二种类型为R_X86_64_PLT32,为位置无关代码,即无需重定位的代码。

另外,还有一种类型在这里没有显示,R_X86_64_32,表示重定位PC绝对引用。地址计算为fefptr=ADDR(运行时地址)+r.append

在.rela.text表之后是.rela.eh_frame,保存eh_frame的重定位信息。

(4)符号表 .symtab

图4.8 符号表

接下来我们可以看到.symtab表,记录了hello.c中调用的函数和全局变量的的名称,类型,地址等信息。

value地址信息,在可重定位文件中是起始位置的偏移量。

size 表示符号的尺寸大小。例如main符号代表的就是main函数代码的总体大小

type为符号的定义,如object表示数据,func表示函数,notype表示暂未定义

bind表示符号是全局的还是本地的。

ndx表示每个符号表项与其他节区的关系,代表的就是此成员所在的节区的头部表索引,UND表示为在本文件中定义的符号。

4.4 Hello.o的结果解析

objdump -d -r hello.o 

图4.9 反汇编代码

  1. 机器语言的构成

机器语言是计算机能直接识别的二进制代码,在hello.o的反汇编文件中,使用16进制表示机器代码,每个字节使用两个16进制数表示

  1. 机器语言和汇编语言的映射关系

汇编语言的每一条语句由机器语言中多个字节表示,而每一组特定的字节都可以翻译会汇编语言。

    1. 操作数

hello.s中使用十进制表示操作数,而反汇编语言中使用16进制表示操作数

    1. 函数调用

hello.s中函数调用只需使用call+函数名的方式即可,但是经过汇编后可以看出,链接器为每个函数调用找到了匹配的可执行代码的地址,并且需要经过重定位确定最后的实践地址。

    1. 分支转移

hello.s中的跳转指令是类似jX .L1的格式,其中.L1是标签,指代了某一句指令的位置。而经过汇编后,跳转指令是直接跳转到某个地址,可以是具体的地址,也可以是相对的地址。

4.5 本章小结

本章中我们汇编了hello.s,将其转换为可重定位目标文件,并具体分析了可重定位文件的ELF格式和具体内容。并且反汇编了可重定位目标文件,并将其与原先的汇编代码进行对比。

通过这章,我们了解到汇编器将汇编代码处理成机器可以看懂的机器码,也就是二进制代码。二进制代码较汇编代码来说,虽然可读性变得比较差,但是在执行效率方面有了非常大的提升,汇编代码虽然已经在原来的文本的基础上进行了优化,但是还是存在着一些字符等不能够直接处理的数据。但是二进制代码中,已经将所有的指令、函数名字等量变成了相应的存储地址,这样机器就可以直接读取这些代码并执行。所以总的来说hello.o已经非常接近一个机器可以执行的代码了。

(第41分)

5章 链接

5.1 链接的概念与作用

5.1.1链接的概念

    链接是通过链接器(ld)将各种代码和数据片断收集并组合成一个单一文件的过程。这个文件可以被加载(复制)到内存并执行。链接包含符号解析和重定位两步。链接器将每个符号引用与符号的定义相关联,将符号在可重定位文件的位置重定位至可执行文件的位置。

5.1.2链接的作用

     因为有了链接这个概念的存在,所以我们的代码才会变得比较方便和简洁,同时可移植性强,模块化程度比较高。因为链接的过程可以使我们将程序封装成很多的模块,我们在编程的过程中只需要考虑主程序的部分,对于其他的外部函数我们可以直接调用模块,就像C中调用printf一样。

        同时我们可以将一个大型的应用程序分解成更小、更好管理的模块,可以独立地修改和编译这些模块,当我们改变这些模块中的一个时,只需要简单地重新编译,并重新链接应用,而不必重新编译其他文件。

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 经过链接之后的可执行文件hello

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

总体的来说,ELF内部结构发生了一定的变化,链接完成后,ELF中多了程序头部表,这个是用来记录有哪些段(segments)的,多了不少的section和表,接下来我们依次说明

  1. ELF头

图5.2 可执行程序的ELF头

与上一章的ELF头相比,最大的变化发生在ELF类型,可以看到ELF类型已经变成可执行文件。同时我们发现相比与之前多了程序头,段数目,节的数量也有增加。同时入口点位置也不是虚拟地址0x0了,现在的入口地址是一个真实的物理地址

  1. 节头

图5.3 可执行程序的节头表

多了几个节,我们来简单介绍一下

  1. .interp:用于保存动态链接器的绝对地址,如/lib/ld-linux.so
  2. .hash:使用哈希技术存储的符号表,方便快速查找
  3. .dynsym:动态链接符号表
  4. .dynstr:动态链接字符串表,存放着所有符号名称的字符串,与.shstrtab类似
  5. .init:初始化信息,包括_start的地址,可以通过_start进入main函数
  6. .got:全局偏移量表,用来支持位置无关代码(PIC,Position Independent Code),存储的是所有引用的符号的地址,以实现对这些符号的间接访问。
  7. .plt.got:过程链接表,用来支持所引用函数的延迟绑定(即只有当该函数被第一次调用时才确定它的实际地址)
  8. .dynamic:动态链接信息表
  1. 程序头

图5.4 程序头和段-节映射表

可以看到,此时的程序已经分配了真实的物理地址,因而可以运行

同时我们看到,多个节映射到一个段内。

  1. 动态链接信息表

图5.5 动态链接信息表

动态链接信息表的数据结构非常简单,一个成员表示类型,另一个成员表示该类型的值(整数或地址)

  1. 重定位节

图5.6 重定位节

.rel.dyn和.rel.plt是动态定位辅助段。由连接器产生,存在于可执行文件或者动态库文件内。借助这两个辅助段可以动态修改对应.got和.got.plt段,从而实现运行时重定位。

.rel.dyn:重定位的地方在.got段内。主要是针对外部数据变量符号。例如全局数据。重定位在程序运行时定位,一般是在.init段内。定位过程:获得符号对应value后,根据rel.dyn表中对应的offset,修改.got表对应位置的value。另外,.rel.dyn 含义是指和dyn有关,一般是指在程序运行时候,动态加载。区别于rel.plt,rel.plt是指和plt相关,具体是指在某个函数被调用时候加载。我个人理解这个Section的作用是,在重定位过程中,动态链接器根据r_offset找到.got对应表项,来完成对.got表项值的修改。

.rel.plt:重定位的地方在.got.plt段内(注意也是.got内,具体区分而已)。 主要是针对外部函数符号。一般是函数首次被调用时候重定位。首次调用时会重定位函数地址,把最终函数地址放到.got内,以后读取该.got就直接得到最终函数地址。我个人理解这个Section的作用是,在重定位过程中,动态链接器根据r_offset找到.got对应表项,来完成对.got表项值的修改。

  1. 动态符号表

图5.7 动态符号表

  1. 符号表

图5.8 符号表(部分)

5.4 hello的虚拟地址空间

用edb打开hello,使用symbolview查看虚拟地址各段信息

图5.9 动态库各段的虚拟地址

我们查看与节头表类似的部分

图5.10

与5.3的节头表对比,我们可以发现,基本每个节都被展开了,实际运行时占用内存更多,并且多了不少重定位之后的的外部函数地址。

       

5.5 链接的重定位过程分析

图5.11 可执行文件的反汇编代码

hello与hello.o相比,还是有很大不同的。

可以看到,在hello.o的反汇编代码中,只有一个main函数,但是对于hello的反汇编代码来说,可以看到很多如_init样子的函数。这些函数都是在链接的过程中,被加载到可执行文件中的。

同时,地址发生了很大的改变。链接之前,.o文件中main函数的反汇编代码从地址0开始往下,可以认为是相对偏移地址,而在链接之后,在main函数之前还链接上了其他的库文件,因此hello的main函数是从地址0x401105开始的,这时,在main函数中每一条指令的地址,每一个函数的地址都可认为是绝对地址,是CPU可以直接访问的地址。在hello main函数中的绝对地址是通过可重定位文件中地址的偏移量加上起始地址得到的。

链接主要包括解析符号和重定位两步。在重定位之前,汇编器在hello.o文件的重定位段记录了需要重定位的符号和相应的类型和偏移量。链接器通过对符号的解析(包括局部符号和全局符号),将每个符号的引用和符号的定义相关联。这之后还需要将命令行输入的静态库链接,然后就开始重定位,在重定位过程中,将合并输入模块,并为每个符号分配运行时的地址。

首先需要对符号和节进行重定位。链接器将所有相同类型的节合并为同一类型的新的聚合节,然后链接器将运行时内存地址赋给新的聚合节,赋给定义的每个节和符号,此时程序中的每条指令和全局变量都有唯一的运行时内存地址。

然后重定位节中的符号引用,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。

5.6 hello的执行流程

hello在执行的过程中一共要执行三个大的过程,分别是载入、执行和退出。

函数

阶段

_dl_start

加载

_dl_init

加载

hello!start

开始执行

_libc_start_main

开始执行

_al_fixup

开始执行

_libc_csu_init

开始执行

_setjmp

开始执行

_main

执行

_printf

执行

_exit

执行

__sleep

执行

_getchar

执行

_dl_fixup

退出

exit

退出

5.7 Hello的动态链接分析

 编译器在数据段开始的地方创建全局偏移量表(GOT),在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。

每个被可执行程序调用的库函数都有它自己的PTL条目,每个条目负责调用一个具体的函数。

查看dl_init运行前,.got.plt的值

.got.plt的起始地址是0x404000

在dl_init之前

图5.12

在dl_init之后

图5.13

5.8 本章小结

链接的过程,是将原来的只保存了你写的函数的代码与代码用所用的库函数合并的一个过程。在这个过程中链接器会为每个符号、函数等信息重新分配虚拟内存地址,方法就是用每个.o文件中的重定位节与其它的节想配合,算出正确的地址。同时,将你会用到的库函数加载(复制)到可执行文件中。这些信息一同构成了一个完整的计算机可以运行的文件。链接让我们的程序做到了很好的模块化,我们只需要写我们的主要代码,对于读入、IO等操作,可以直接与封装的模块相链接,这样大大的简化了代码的书写难度。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

 进程的概念:进程是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

进程的作用:提供给应用程序一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;提供给应用程序一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

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

 shell是一个linux中提供的应用程序,他在操作系统中为用户与内核之间提供了一个交互的界面,用户可以通过这个界面访问操作系统的内核服务。他的处理流程如下:

  1. 从界面中读取用户的输入。
  2. 将输入的内容转化成对应的参数。
  3. 如果是内核命令就直接执行,否则就为其分配新的子进程继续运行。
  4. 在运行期间,监控shell界面内是否有键盘输入的命令,如果有需要作出相应的反应

6.3 Hello的fork进程创建过程

 首先先来了解一下fork函数的机制。父进程通过调用fork函数创建一个新的子进程。新创建的子进程几乎但不完全与子进程相同。在创建子进程的过程中,内核会将父进程的代码、数据段、堆、共享库以及用户栈这些信息全部复制给子进程,同时子进程还可以读父进程打开的副本。唯一的不同就是他们的PID,这说明,虽然父进程与子进程所用到的信息几乎是完全相同的,但是这两个程序却是相互独立的,各自有自己独有的用户栈等信息。

fork函数虽然只会被调用一次,但是在返回的时候却有两次。在父进程中,fork函数返回子进程的PID;在子进程中,fork函数返回0。这就提供了一种用fork函数的返回值来区分父进程和子进程的方法。

同时fork在使用的过程中,有一个令人比较头疼的问题,就是父进程和子进程是并发执行的所以我们不能够准确的知道那个进程先执行或者先结束。这也就造成了每次执行的输出结果可能是不同的,也是不可预测的。

我们实际的执行一下,在命令行输入./hello 1190200913 lyp,shell检查该命令是否为内置命令,显然这不是内置命令。于是,shell调用fork函数创建一个新的子进程,子进程得到与父进程用户级虚拟地址空间相同的一份副本,这意味着父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于有不同的PID。

之后,更改进程组编号,准备hello的execve。

6.4 Hello的execve过程

 execve函数的作用是在当前进程的上下文中加载并运行一个新的程序。与fork函数不同的是,fork函数创建了一个新的进程来运行另一个程序,而execve直接在当前的进程中删除当前进程中现有的虚拟内存段,并穿件一组新的代码、数据、堆和用户栈的段。将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址。在CPU开始引用被映射的虚拟页的时候,内核才会将需要用到的数据从磁盘中放入内存中。

在shell新创建的子进程中,execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp,只有出现错误是,execve才会返回到调用程序。

在execve加载hello之后,调用启动代码来执行hello,新的代码和数据段初始化为可执行文件的内容,跳转到_start调用libc_start_main设置栈,并将控制传递给新程序的主函数,

6.5 Hello的进程执行

 在执行hello程序之后,hello进程一开始运行在用户模式,进程从用户模式变为内核模式的唯一方法是通过中断、故障等异常的调用,当进程处于内核模式时,可以访问任何内存位置,调用任何指令。当处理程序返回到应用程序代码时,从内核模式改为用户模式。

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

当内核选择一个新的进程运行时,即内核调度这个进程,内核使用上下文切换的机制来控制转移到新的进程:保存当前进程的上下文,恢复某个先前被强占的进程被保存的上下文,将控制传递给这个新恢复的进程。

在hello程序运行时,会有其他进程并发地运行,这些进程时间与hello重叠,为并发流,这些进程轮流运行,一个进程执行它的控制流的一部分的每一时间段叫做时间片。

接下来根据上述知识分析一下hello的进程调度。hello一开始运行在用户模式,内核保存一个上下文,继续运行调用printf函数,系统调用使得进程从用户模式变成内核模式,在printf函数执行完之后又返回到用户模式,继续运行调用sleep函数,此时会有些不同,由于该进程进行休眠,内核进行上下文切换,调用其他进程运行,同时计数器记录休眠的时间,等到休眠的时间到时,系统发生中断,再次进行上下文切换,转换到hello进程原先运行的位置。继续运行,遇到循环之后,hello进程会多次进行用户模式和内核模式的转变。之后调用getchar函数,进入内核模式,需要完成从键盘缓冲区到内存的数据传输,故上下文切换,运行其它进程,当数据传输结束,发生中断,再次上下文切换,回到hello进程,此时hello进程就运行结束了,return,hello进程运行终止。

6.6 hello的异常与信号处理

hello的异常种类

  1. 中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果,中断处理程序运行之后,返回到下一条指令。

  1. 陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果,就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令,在用户程序和内核之间提供一个像过程一样的接口,即系统调用。如读一个文件、创建一个进程、加载一个新的程序等。

  1. 故障

故障是由错误情况引起的,当故障发生时,将控制转移给故障处理程序,如果错误情况可以修正,则将控制返回到引起故障指令,重新执行,否则处理程序返回到内核abort,终止故障的应用程序。

  1. 终止

终止是不可恢复的致命错误造成的结果,会终止应用程序。

hello的信号

图6.1 正常运行

可以看到在执行ps命令之后,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。

在程序执行过程中,可以在命令行中乱按,包括回车,对于程序运行来说没有影响。

  1. SIGTSTP信号

输入ctrl-z会发送一个SIGTSTP信号到前台进程组中的每个进程,默认情况下,停止(挂起)前台作业。

图6.2 执行ctrl+z(挂起)

通过ps命令我们可以看到hello进程的pid,ctrl-z后hello进程被挂起,jobs命令看到hello进程的状态

图6.3 通过pstree命令在进程树中找到bash的hello进程

使用fg 将JID最大的放到前台,即继续运行hello

图6.4 使用fg命令继续运行hello

使用kill -9 PID杀死hello进程

图6.5 使用kill命令终止hello进程

  1. SIGINT信号

当用户输入ctrl-c时产生中断信号,导致内核发送一个SIGINT信号到前台工作组中的每个进程,默认终止前台作业,在这里,hello被终止。

图6.6 使用ctrl+c终止hello进程

6.7本章小结

 这一章介绍了hello可执行文件在进程中的执行过程。介绍了shell-bash的工作流程,shell利用fork和execve运行hello程序的过程,用户模式和内核模式,上下文的切换。最后,通过在命令行的各种命令的演示,讲述了hello进程的异常处理和信号机制。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:由程序产生的段偏移地址。由段标识和段偏移量组成。以段标识为下标到GDT/LDT查表获得段地址。段地址+端偏移量=线性地址

线性地址:一个非负整数地址的有序集合,如果此时地址是连续的,则称这个空间为线性地址空间。

虚拟地址:在保护模式下,程序运行在虚拟内存中。虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。虚拟地址由VPO(虚拟页面偏移量)、VPN(虚拟页号)、TLBI(TLB索引)、TLBT(TLB标记)组成。

物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每字节都有一个唯一的物理地址,比如第一个字节地址为0,第二个地址为1,以此类推。物理地址空间对应于系统中物理内存的M个字节:{0,1,2……M-1}。

在hello中,main函数的地址为0x401105,这是逻辑地址中的段偏移量,加上段地址就是main函数的虚拟地址,虚拟地址与物理地址之间存在一种映射关系,MMU利用页表实现这种映射,可得到实际的物理地址。

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

实模式:逻辑地址CS:EA=EA+16*CS

保护模式:逻辑地址由段标识和段偏移量组成。以段标识为下标,去索引段描述符表,若T1=0,索引全局段描述符表(GDT),若T1=1,索引局部段描述符表(LDT)。将段描述符表中的段地址(base字段)加上段偏移量,即为线性地址。

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

 hello的线性地址到物理地址的变换,也就是从虚拟地址寻址物理地址,在虚拟地址和物理地址之间存在一种映射,MMU通过页表实现这种映射。

虚拟地址由虚拟页号(VPN)和虚拟页偏移量(VPO)组成,页表中由有效位和物理页号组成,VPN作为到页表的索引,去页表中寻找相应的PTE,其中PTE有三种情况,分别为已分配,未缓存,未分配。已分配表示已经将虚拟地址对应到物理地址,有效位为1,物理页号不为空。未缓存表示还未将虚拟内容缓存到物理页表中,有效位为0,物理页号不为空。未分配表示未建立映射关系,有效位为0,物理页号为空。

如果有效位为0,表示缺页,进行缺页处理,从磁盘读取物理页到内存,若有效位为1,则可以查询到相对应的PPN,物理页偏移量和VPO相同,PPN和PPO组成物理地址。

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

首先介绍一下TLB。为减少内存读取数据的次数,在MMU中包括了一个关于PTE的小的缓存,即TLB,每一行都保存着一个由单个PTE组成的块。TLB索引由VPN的t个最低位组成,剩余的为为TLB标记。

其次我们来介绍一下多级页表的概念。在前面我们了解了一级页表是如何进行工作的。可以发现一级页表有一个弊端,就是对于每一个程序,内核都会给他分配一个固定大小的页表,这样有一些比较小的程序会用不到开出的页表的一些部分,就造成了空间的浪费,多级页表就很好的解决了这个问题。以二级页表为例,首先我们先开一个比较小的一级页表,我们将完整的页表分组,分别对应到开出来的一节页表的一个PTE中,在执行程序的过程中,如果我们用到了一个特定的页表,那么我们就在一级页表后面动态的开出来,如果没用到就不开,这样就大大的节省了空间。

CPU产生一个虚拟地址,当TLB命中时,MMU从TLB中取出相应的PTE,MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存中,高速缓存/主存将所请求的数据字返回给CPU。若TLB不命中,MMU必须从页表中的PTE取出PPN复制到PTE,而在得到PTE还会发生缺页或者是缓存不命中的情况。

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

 知道虚拟地址对应的物理地址之后,需要对物理地址进行访问。CPU访问物理地址是访问三级cache L1、L2、L3。MMU将物理地址发送给L1缓存,从物理地址中得出CT(缓存标记)、CI(缓存组索引)、CO(缓存偏移)。根据缓存组索引找到L1缓存中对应的组,若缓存标记为1,根据缓存偏移直接从缓存中读取数据并返回。如果缓存标记为0,即缓存不命中,需要从L2、L3中去读取,如果在三级缓存中都不存在,需要到主存中读取。

7.6 hello进程fork时的内存映射

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

 execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello有效替代当前程序。加载并运行hello需要以下几个步骤:

删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。

映射私有区域。为hello程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data段,bss是请求二进制零的,映射到匿名文件,大小包含在hello中,栈和堆也是请求二进制零的,初始长度为0。

映射共享区域。如果hello程序与共享对象链接,如libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

设置程序计数器。execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时将从这个入口点开始执行。

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

缺页现象的发生是由于页表只相当于磁盘的一个缓存,所以不可能保存磁盘中全部的信息,对于有些信息的查询就会出现查询失败的情况,也就是缺页。

对于一个访问虚拟内存的指令来说,如果发生了缺页现象,CPU就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已经被更改,那就先将其存回到磁盘中。

找到了要存储的页后,内核会从磁盘中将需要访问的内存,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。

7.9动态存储分配管理

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

动态内存分配器分为显式分配器和隐式分配器两种。

7.9.1、带标签的隐式空闲链表

将堆组织为一个连续的已分配块和空闲块的序列的结构是隐式空闲链表,空闲块通过头部的大小字段隐含地链接。而带边界标签的隐式空闲链表则在每个块的结尾处添加一个脚部——头部的副本,脚部总是在距当前块开始位置一个字的距离。分配器可以通过检查它的脚部,判断前面一个块的起始位置和状态。

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以防止所请求块的空闲块。这种搜索方式由放置策略确定,包括首次适配、下一次适配和最佳适配。

一旦分配器找到匹配的空闲块之后,作出另一个决定——分配这个空闲块多少空间。通常选择将空闲块分割成两个部分,剩下的一部分变成新的空闲块。

当分配器释放一个已分配块之时,可能有其他空闲块与新释放的空闲块相邻,可能会导致假碎片问题,因此需要合并相邻的空闲块。而带有边界标签的隐式空闲链表分配器就可以在常数时间内完成对前面块的合并。简单来说,就是双向合并。

7.9.2、显式空闲链表

将空闲块组织成某种形式的显式数据结构,实现这个数据结构的指针可以存放在这些空闲块的主体里。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针。

这样做的好处就是在我们在malloc的时候,隐式的方法是要遍历所有的块,包括空闲块了分配块。但是显式的结构只需要在空闲块中维护的链表检索就可以了,这样降低了在malloc时候的复杂度。

一种方法是后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处,分配器会最先检查最近使用过的块。.这样在malloc的时候会首先看一下最后被free的块是否符合要求。这样的好处是释放一个块的时候比较高效,直接放在头部就可以。

另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索定位合适的前驱。

7.10本章小结

 这一章介绍了hello的存储器地址空间的概念和相关的地址计算方法,缺页和缺页处理,重点介绍了虚拟地址转换成物理地址的过程,包括四级页表、TLB加速、三级cache等。除此以外,介绍了内存映射,以及fork创建进程和execve函数运行hello时的具体过程。最后讲述了动态内存分配管理的不同结构的链表的操作。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

(1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个叫做描述符的小的非负整数,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符。

对应的函数为int open(char *filename, int flags, mode_t mode);filename为文件名,flags参数指明了进程打算如何访问这个文件,可以是只读、只写、可读可写。mode参数指定了新文件的访问权限位。若open成功则返回新文件描述符,若失败则返回-1.

(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。

(3)改变当前文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k。

(4)读写文件:一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后k增加到k+n,给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个EOF的条件,应用程序能检测到这个条件,在文件结尾处并没有明确地EOF符号。

类似的,写操作就是从内存复制n个文件到一个文件,从当前文件位置k开始,更新k。

读文件对应的函数为ssize_t read(int fd, void *buf, size_t n);fd为当前文件的描述符,buf是内存位置,n是复制最多n个字节。若成功则为读的字节数,若EOF则为0,若出错-1。

写文件对应的函数是sisze_t write(int fd, const void *buf, size_t n);若成功则为写的字节数,若出错则为-1。

(5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核丢回关闭所有打开的文件并释放他们的内存资源。

关闭文件的函数为int close(int fd);若成功为0,若出错为-1.关闭一个已经关闭的描述符会出错。

8.3 printf的实现分析

 首先观察一下Linux下printf的函数体

static int printf(const char *fmt, ...)

{

     va_list args;

     int i;

  

     va_start(args, fmt);

     write(1,printbuf,i=vsprintf(printbuf, fmt, args));

     va_end(args);

     return i;

}

   printf需要做的事情是:接受一fmt的格式,然后将匹配到的参数按照fmt格式输出。图8-1是printf的代码,我们可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write。

定义va_list型变量args,指向参数的指针。va_start和va_end是获取可变长度参数的函数,首先调用va_start函数初始化args指针,通过对va_arg返回可变的参数,然后va_end结束可变参数的获取。

重点需要看write函数和vsprintf函数。

vsprintf函数的作用是以fmt为格式字符串,根据args中的参数,向printfbuf输出格式化后的字符串。然后调用write函数,write函数是Unix I/O函数,用以在屏幕上输出长度为i的在printfbuf处的内容。查看write函数的汇编代码可以看出它将栈中参数存入寄存器,然后执行 INT_VECTOR_SYS_CALL,代表通过系统调用syscall,syscall将寄存器中存储的字符串通过总线复制到显卡的现存中,字符显示驱动子程序通过ASCII码在字模库中找到点阵信息并将其存储到vram中。接下来显示芯片按照刷新频率逐行读取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函数通过调用read函数来读取字符,read函数的返回值是读入字符的个数,若出错则返回-1.read函数通过调用内核中的系统函数,读取键盘缓冲区的ASCII码,直到读到回车为止,然后将整个字符串返回。

8.5本章小结

 这一章介绍了Linux的I/O管理方法、I/O接口及其函数,以及通过阅读printf函数和getchar函数的代码了解如何通过Unix I/O实现功能。总的说,Unix I/O使得所有的输入和输出都能以一种统一且一致的方式来执行。

(第81分)

结论

hello的一生是这样度过的

  1. 我们通过各种方法,编写得到hello.c源文件
  2. 预处理器将hello.c文件初步修改为hello.i文件
  3. 编译器将hello.i文件编译成汇编代码存储在hello.s文件中
  4. 汇编器将hello.s文件处理成可重定位目标文件hello.o,这时候的hello.o已经和之后能够实际运行的机器代码相差不大了
  5. 链接器将hello.o与库进行链接,完成能实际运行的可执行文件hello
  6. 当我们在shell中运行hello文件时,内核会为我们分配运行程序所需的堆栈,虚拟内存等信息,方便我们执行
  7. hello在运行过程中遇到各种信号和键盘输入,shell为其提供信号处理程序
  8. hello访存时,请求的地址为虚拟地址,通过MMU、TLB、四级页表等得到对应的物理地址,在三级cache中进行访存
  9. hello 输入输出调用的getchar函数和printf函数,通过Unix I/O接口进行实现
  10. 当hello执行完成所有工作之后,最终被shell回收,结束了程序的一生

通过这次大作业,我更加全面系统的了解了这门课程,对书中的知识有了更加全面的认识。同时感受到了计算机系统的复杂性以及严密性。我们一个程序的成功运行需要多少计算机硬件和软件的共同配合。

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

附件

文件名称

作用

hello.c

源代码文件

hello.i

经过预处理得到的C语言文件

hello.s

经过编译得到的汇编文件

hello.0

经过汇编之后的可重定位目标执行文件

hello

经过链接之后的可执行文件

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

参考文献

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

[1]  林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.

[2]  辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.

[3]  赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).

[4]  谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.

[5]  KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.

[6]  CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值