当前位置: 首页 > 工具软件 > ics-openvpn > 使用案例 >

1190201210-陈则睿-HIT-ICS大作业

韦高谊
2023-12-01

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业 计算机类
学   号 1190201210
班   级 1903007
学 生 陈则睿   
指 导 教 师 吴锐

计算机科学与技术学院
2021年5月

摘 要
一花一世界,一叶一菩提。小小的hello中潜藏着大大的奥秘。本文将从计算机系统的角度,分析hello从诞生到消亡的全过程:预处理,编译,汇编,链接,进程,存储管理以及I/O。通过细致的历程分析,读者将对程序运行周期有一个全面的了解,进而加深对计算机系统的理解。

关键词:预处理;编译;汇编;链接;进程;存储管理;I/O;

目 录

第1章 概述 - 5 -
1.1 HELLO简介 - 5 -
1.2 环境与工具 - 5 -
1.3 中间结果 - 6 -
1.4 本章小结 - 6 -
第2章 预处理 - 7 -
2.1 预处理的概念与作用 - 7 -
2.2在UBUNTU下预处理的命令 - 7 -
2.3 HELLO的预处理结果解析 - 8 -
2.4 本章小结 - 12 -
第3章 编译 - 13 -
3.1 编译的概念与作用 - 13 -
3.2 在UBUNTU下编译的命令 - 13 -
3.3 HELLO的编译结果解析 - 14 -
3.3.1 数据之常量 - 14 -
3.3.2 数据之全局变量 - 15 -
3.3.3 数据之局部变量 - 15 -
3.3.4 操作之赋值 - 17 -
3.3.5 操作之类型转换 - 17 -
3.3.6 操作之自增 - 18 -
3.3.7 操作之关系操作 - 18 -
3.3.8 操作之数组操作 - 18 -
3.3.9 操作之控制转移 - 19 -
3.3.10 操作之函数操作 - 20 -
3.4 本章小结 - 22 -
第4章 汇编 - 23 -
4.1 汇编的概念与作用 - 23 -
4.2 在UBUNTU下汇编的命令 - 23 -
4.3 可重定位目标ELF格式 - 23 -
4.3.1 ELF典型格式 - 23 -
4.3.2 ELF头 - 25 -
4.3.3 节头部表 - 25 -
4.3.4 符号表 - 26 -
4.3.5 重定位节 - 27 -
4.4 HELLO.O的结果解析 - 28 -
4.5 本章小结 - 30 -
第5章 链接 - 31 -
5.1 链接的概念与作用 - 31 -
5.2 在UBUNTU下链接的命令 - 31 -
5.3 可执行目标文件HELLO的格式 - 31 -
5.3.1 ELF头 - 32 -
5.3.2 节头部表 - 32 -
5.3.3 符号表 - 34 -
5.3.4 程序头表 - 35 -
5.3.5 重定位节 - 36 -
5.4 HELLO的虚拟地址空间 - 36 -
5.5 链接的重定位过程分析 - 38 -
5.6 HELLO的执行流程 - 41 -
5.7 HELLO的动态链接分析 - 42 -
5.8 本章小结 - 43 -
第6章 HELLO进程管理 - 44 -
6.1 进程的概念与作用 - 44 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 44 -
6.3 HELLO的FORK进程创建过程 - 45 -
6.4 HELLO的EXECVE过程 - 45 -
6.5 HELLO的进程执行 - 46 -
6.6 HELLO的异常与信号处理 - 47 -
6.6.1 不停乱按,包括回车 - 48 -
6.6.2 Ctrl-Z - 48 -
6.6.3 Ctrl-C - 51 -
6.7本章小结 - 51 -
第7章 HELLO的存储管理 - 52 -
7.1 HELLO的存储器地址空间 - 52 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 52 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 53 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 53 -
7.5 三级CACHE支持下的物理内存访问 - 57 -
7.6 HELLO进程FORK时的内存映射 - 57 -
7.7 HELLO进程EXECVE时的内存映射 - 58 -
7.8 缺页故障与缺页中断处理 - 59 -
7.9动态存储分配管理 - 59 -
7.10本章小结 - 62 -
第8章 HELLO的IO管理 - 63 -
8.1 LINUX的IO设备管理方法 - 63 -
8.2 简述UNIX IO接口及其函数 - 63 -
8.3 PRINTF的实现分析 - 65 -
8.4 GETCHAR的实现分析 - 66 -
8.5本章小结 - 67 -
结论 - 67 -
附件 - 68 -
参考文献 - 69 -

第1章 概述
1.1 Hello简介
P2P(From Program to Process)
程序员通过编辑器编写hello.c源程序(即Program)。
当程序员在Shell中运行下述命令后:

  1. gcc -m64 -no-pie -fno-PIC hello.c -o hello
    首先,预处理器将源程序hello.c中的头文件进行头文件展开以及宏拓展,生成预处理后的源程序hello.i。
    在预处理之后,编译器将预处理后的源文件hello.i翻译为汇编文件hello.s。
    然后,汇编器将hello.s翻译成机器码,形成可重定位的目标文件hello.o。
    接下来,链接器将hello.o以及其他可重定位目标文件进行链接,生成hello可执行文件。
    最后,shell通过fork创建子进程,并通过execve将hello加载,在子进程的上下文运行hello,至此,hello从Program转化为Progress(P2P)。
    O2O(From Zero-0 to Zero-0):
    首先,shell通过fork创建子进程,OS为hello进行虚拟内存映射,并通过加载器将程序计数器设置为hello的程序入口点,hello程序开始运行。
    在运行过程中,CPU为hello分配时间周期,hello的指令通过流水线快速运行。CPU借助MMU完成从虚拟地址到物理地址的转换,并通过cache、TLB等加速数据的访问过程。除此之外,OS还借助异常以及信号机制,对进程进行管理。
    最终,当hello进程终止后,父进程shell回收子进程hello的相关资源,hello的生命迎来了终结。(hello从无到有再到无,From Zero-0 to Zero-0)
    1.2 环境与工具
    硬件环境:
      X64 CPU;Intel® Core™ i7-9750H CPU @ 2.60GHz 2.59 GHz
    软件环境:
      Windows10,Ubuntu 20.04.2 LTS
    开发与调试工具:
      GCC,EDB,Objdump,readelf
    1.3 中间结果
    名称 作用
    hello.i 预处理得到的文本文件
    hello.s 编译后得到的汇编程序文本文件
    hello.o 汇编后得到的可重定位目标文件
    hello 链接后的可执行目标文件
    hello_elf.txt hello的elf格式文件
    hello_objdump.txt hello的反汇编文件
    hello_o_elf.txt hello.o的elf格式文件
    hello_o_objdump.txt hello.o的反汇编文件
    1.4 本章小结
    在第一章,我们首先介绍了hello的P2P(From Program to Process)以及O2O(From Zero-0 to Zero-0)。然后我们罗列了本次大作业使用的环境以及工具,最后给出了本次大作业所生成的所有中间结果的名称及作用。

第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——预处理记号(preprocessing token)用来支持语言特性。
根据C语言标准规定,预处理是指前4个编译阶段:

  1. 三字符组与双字符组的替换
  2. 行拼接: 把物理源码行中的换行符转义字符处理为普通的换行符,从而把源程序处理为逻辑行的顺序集合。
  3. 单词化: 处理每行的空白、注释等,使每行成为token的顺序集。
  4. 扩展宏与预处理指令处理。

预处理的作用:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如:

  1. #include <stdio.h>
    上述命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件拓展名。
    预处理器用于在编译器处理程序之前预扫描源代码,完成头文件的包含, 宏扩展, 条件编译, 行控制等操作。
    2.2在Ubuntu下预处理的命令
    预处理命令:
  2. cpp hello.c > hello.i
    或者
  3. gcc -E hello.c -o hello.i
    在Ubuntu执行上述命令,得到hello.i预处理文件。如图2-1所示。

图2-1 Ubuntu下预处理命令
2.3 Hello的预处理结果解析
如图2-2所示,相较于hello.c 文件,hello.i文件在程序开头加入了近3000行的头文件内容。

图2-2 hello.c(左)与hello.i(右)对比
下面我们来对hello.i文件进行具体分析:

  1. 对#include <stdio.h>的处理
    因为stdio.h是使用尖括号括起来的,这表示cpp在系统头文件目录中查找对应的.h文件。如图2-3所示,从hello.i的第13行,我们可以知道stdio.h文件位于"/usr/include/stdio.h"目录下。

图2-3 stdio.h路径
如图2-4所示,我在"/usr/include/stdio.h"目录下的确发现了stdio.h文件。

图2-4 stdio.h文件
在得到stdio.h文件之后,预处理器在hello.c中对stdio.h文件进行头文件展开。
根据图2-5所示,stdio.h最后一次出现在728行,之后为unistd.h,因此我们可以推测stdio.h头文件展开长度大约为720行。

图2-5 stdio.h头文件展开结尾

  1. 对#include <unistd.h>的处理
    类似与对stdio.h的分析,如图2-6所示,我们从hello.i的第731行得知 unistd.h文件位于"/usr/include/ unistd.h"目录下。

图2-6 unistd.h路径
如图2-7所示,我在"/usr/include/ unistd.h"目录下的确发现了unistd.h文件。

图2-7 unistd.h文件
在得到unistd.h文件之后,预处理器在hello.c中对unistd.h文件进行头文件展开。
根据图2-8所示,unistd.h最后一次出现在1966行,之后为stdlib.h,因此我们可以推测unistd.h头文件展开长度大约为1200行。

图2-8 unistd.h头文件展开结尾

  1. 对#include <stdlib.h>的处理
    类似与对stdio.h的分析,如图2-6所示,我们从hello.i的第1970行得知 stdlib.h文件位于"/usr/include/ stdlib.h"目录下。

图2-9 unistd.h路径
如图2-7所示,我在"/usr/include/ stdlib.h"目录下的确发现了stdlib.h文件。

图2-10 stdlib.h文件
在得到stdlib.h文件之后,预处理器在hello.c中对stdlib.h文件进行头文件展开。
根据图2-8所示,stdlib.h最后一次出现在3041行,因此我们可以推测stdlib.h头文件展开长度大约为1000行。

图2-11 stdlib.h头文件展开结尾
4. 对程序主体的处理
如图2-12所示,hello.i中的程序主体部分除了注释语句被删除掉了之外,没有其他的变化。

图2-12 hello.i中的程序主体
2.4 本章小结
在第二章,我们首先介绍了预处理的概念及作用。然后,我们以hello.c程序为例,展示了C预处理器如何将头文件在.c文件中进行展开,生成.i文件。最后,我们对.i文件进行了剖析,探究了预处理器如何在系统中定位相应的头文件、头文件的展开长度以及预处理器对程序主体部分的处理。最终得出结论:在预处理阶段,预处理器在源文件中进行头文件展开以及宏拓展,对程序主体删去注释后进行保留。

第3章 编译
3.1 编译的概念与作用
编译的概念
编译即借助编译器(ccl)将修改了的源程序.i文件翻译为汇编程序.s文件的过程。编译的具体过程就是将修改了的源程序.i文件经过词法分析、语法分析、语义分析以及一系列优化后生成汇编程序.s文件。
对于本实验来说,编译即将修改了的源程序hello.i文件翻译为汇编程序hello.s文件的过程。

编译的作用
编译的作用是将高级语言程序(在本实验中,即C语言)转化为机器可直接识别处理执行的的机器码的中间步骤。编译包括下述4个过程:

  1. 词法分析。对输入的字符串流进行分析,并基于一定的规则对其进行分割,形成所使用的源程序语言所允许的记号(token),同时标注不规范记号,产生错误提示信息。通常采用DFA来构造词法分析工具。
  2. 语法分析。分析词法分析得到的记号序列,并按一定规则识别并生成中间表示形式,以及符号表。同时将不符合语法规则的记号识别出其位置并产生错误提示语句。
  3. 语义分析。分析语法分析过程中产生的中间表示形式和符号表,以检查源程序的语义是否与源语言的静态语义属性相符合。
  4. 代码优化。将中间表示形式进行分析并转换为功能等价但是运行时间更短或占用资源更少的等价中间代码。
    3.2 在Ubuntu下编译的命令
  5. gcc -S hello.c -o hello.s
    在Ubuntu执行上述命令,得到hello.s汇编程序文件。如图3-1所示。

图3-1 Ubuntu下编译命令
3.3 Hello的编译结果解析
3.3.1 数据之常量

  1. printf格式字符串
    hello.c中有两个格式字符串:
  2. printf(“Usage: Hello 学号 姓名!\n”);
  3. printf(“Hello %s %s\n”,argv[1],argv[2]);
    如图3-2所示,在.rodata节中存放着这两个格式字符串。值得注意的是,根据UTF-8的编码规则,第一个字符串中的汉字被编码为3个字节,其他字符(英文,空格等)仍按照原ASCⅡ规则进行编码。

图3-2 printf格式字符串

2.立即数
hello.c中的立即数在hello.s中被直接编码到汇编代码中。如图3-3所示,hello.c中的立即数与hello.s中汇编代码的对应关系我已在图中标出。

图3-3 立即数
3.3.2 数据之全局变量
hello.c中的全局变量为sleepsecs:

  1. int sleepsecs=2.5;
    如图3-4所示,hello.s中首先在.text节中将sleepsecs声明为全局变量,然后在.data中存放sleepsecs:四字节对齐(.align)、类型(.type)为@object、大小(.size)为4字节、值为2(发生了从浮点数→int的隐式类型转换)。

图3-4 全局变量sleepsecs
3.3.3 数据之局部变量

  1. argc与argv
  2. int main(int argc,char *argv[])
    如图3-5所示,①展示了argc与argv[]的存放位置:argc存放在-20(%rbp),argv[]存放在-32(%rbp)。
    ②展示了与argc相关的操作:程序判断传进来的参数数量是否为3。如果是3,则进行下一步操作。
    ③展示了与argv[2]相关的操作:程序首先将argv[]首地址存放在%rax中,然后将%rax+16寻找到argv[2]的首地址,最后通过movq语句将argv[2]所指的8字节对象(实际上是字符串的地址)复制到了%rdx中。
    ④展示了与argv[1]相关的操作:程序首先将sargv[]首地址存放在%rax中,然后将%rax+8寻找到argv[1]的首地址,最后通过movq语句将argv[1]所指的8字节对象(实际上是字符串的地址)复制到了%rax中。

图3-5 局部变量argc与argv[]

  1. 循环变量i
    在hello.c中定义了一个局部变量i。
  2. int i;
    如图3-6所示,局部变量i作为循环变量存在于程序之中。
    如图3-6的①所示,i在栈中的-4(%rbp)处存在,并被初始化为0。
    如图3-6的②所示,i在每次进入循环之前与9进行比较,如果大于9,则退出循环。
    如图3-6的③所示,i在每次循环结束之后+1,然后进入②判断循环条件是否满足。

图3-6 局部变量i
3.3.4 操作之赋值
在hello.c中,赋值操作只有两处:一处是对全局变量sleepsecs的赋值,还有一处是对循环变量i的赋值。
如图3-4所示,我们在3.3.2节中已经分析了全局变量sleepsecs的赋值。hello.s中首先在.text节中将sleepsecs声明为全局变量,然后在.data中存放sleepsecs:四字节对齐(.align)、类型(.type)为@object、大小(.size)为4字节、值为2(发生了从float→int的强制转换,数据截断)。
如图3-6所示,我们在3.3.3节中已经分析了对循环变量i的赋值操作:在循环开始之前,对i赋初值0;在每次循环结束后,使用i++对其进行自增操作。
3.3.5 操作之类型转换
在hello.c中,发生了一次浮点数到int的隐式类型转换。
如图3-4所示,我们在之前已经对这次隐式类型转换进行过了分析。sleepsecs声明为int类型,但是却赋值为浮点数,因此发生了隐式类型转换,结果为将sleepsecs赋值为2。
3.3.6 操作之自增
在hello.c中,有一处自增操作:

  1. for(i=0;i<10;i++)
    具体在hello.s中,为:

  2. addl $1, -4(%rbp)
    其中-4(%rbp)即为循环变量i。
    3.3.7 操作之关系操作
    在hello.c中,有两处关系操作判断:

  3. if(argc!=3)

  4. for(i=0;i<10;i++)

  5. !=判断
    如图3-5的②部分所示,hello.s在汇编代码中通过:

  6. cmpl $3, -20(%rbp)
    进行argc!=3的判断。如果argc不等于3,则提示错误信息并退出。

  7. <判断
    如图3-6的②部分所示,hello.s在汇编代码中通过:

  8. cmpl $9, -4(%rbp)
    来判断循环变量是否满足i<10的循环条件。
    3.3.8 操作之数组操作
    hello.c中的数组操作主要指对argv[]的操作。如图3-5所示,③展示了与argv[2]相关的操作:程序首先将argv[]首地址存放在%rax中,然后将%rax+16寻找到argv[2]的首地址,最后通过movq语句将argv[2]所指的8字节对象(实际上是字符串的地址)复制到了%rdx中。
    ④展示了与argv[1]相关的操作:程序首先将sargv[]首地址存放在%rax中,然后将%rax+8寻找到argv[1]的首地址,最后通过movq语句将argv[1]所指的8字节对象(实际上是字符串的地址)复制到了%rax中。
    3.3.9 操作之控制转移
    hello.c的控制转移主要有两个部分:分别是if语句判断以及for循环。

  9. if语句控制转移
    如图3-7,正如我们在前面已经多次分析过的那样,这里的if语句首先判断argc是否等于3,如果等于3,那么跳转到.L2处进行下一步操作;如果不等于3,则输入usage提示字符串,然后调用exit函数退出。

图3-7 if控制转移

  1. for循环语句控制转移
    如图3-8的①所示,循环变量i在栈中的-4(%rbp)处存在,并被初始化为0。
    如图3-8的②所示,循环变量i在每次进入循环之前与9进行比较,如果大于9,则退出循环。
    如图3-8的③所示,i在每次循环结束之后+1,然后进入②判断循环条件是否满足。

图3-8 for循环控制转移
3.3.10 操作之函数操作

  1. printf函数操作
    在hello.c中,总共有两次调用了printf函数。第一次如图3-9所示,如果argc不等于3,那么程序将字符串"Usage: Hello 学号 姓名!\n"的地址(.LC0)放入寄存器%rdi(第一个参数寄存器)中,然后通过call语句调用puts函数(在这里,编译器将printf函数优化为puts函数!)。

图3-9 printf函数操作1

第二次如图3-10所示,在循环体中,程序将argv[2]中存放的参数字符串地址放入%rdx(第三个参数寄存器)、将argv[1]中存放的参数字符串地址放入%rsi(第二个参数寄存器)、将格式字符串"Hello %s %s\n"的地址(.LC1)放入%rdi(第一个参数寄存器),最后通过call语句调用printf函数。

图3-10 printf函数操作2

  1. exit函数操作
    如图3-11所示,当argc不等于3时,程序将立即数1放入%edi中(第一个参数寄存器),然后通过call语句调用exit@PLT函数。

图3-11 exit函数操作

  1. sleep函数操作
    如图3-12所示,在循环体内,程序将全局变量sleepsecs放入%edi(第一个参数寄存器),然后通过call语句调用sleep函数。

图3-12 sleep函数操作

  1. getchar函数操作
    如图3-13所示,由于getchar函数并不需要参数,因此程序直接通过call语句调用getchar函数。

图3-13 sleep函数操作

  1. main函数操作
    main函数的参数argc以及argv[]是由调用main函数的函数分别放入%edi以及%rsi中的。
    如图3-14所示,当控制刚转移至main函数时,main首先将原帧指针(%rbp)压入栈中进行保存:
  2. pushq %rbp
    然后设置新的帧指针:
  3. movq %rsp, %rbp
    接着分配栈帧:
  4. subq $32, %rsp
    最后将传进来的argc和argv[]参数保存在-20(%rbp)以及-32(%rbp)中。.
    图3-14 main函数操作(入)
    当从main函数返回时,程序首先将0放入%eax中作为返回值;然后,程序通过leave语句恢复栈帧;最后,main函数通过ret函数返回到调用它的函数中。

图3-15 main函数操作(出)
3.4 本章小结
在第三章,我们首先介绍了编译的概念和作用,然后在Ubuntu中使用相关指令对hello.i文件进行了编译,生成了hello.s文件。最后我们对hello.s中的所有数据以及操作进行了深入分析。

第4章 汇编
4.1 汇编的概念与作用
汇编的概念
汇编即汇编器(as),将.s文件翻译为机器指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件.o中的过程。对于本实验而言,即将hello.s翻译为hello.o的过程。

汇编的作用
汇编过程将人容易理解的汇编代码文件hello.s,翻译成机器可以识别的二进制机器代码文件hello.o。
4.2 在Ubuntu下汇编的命令

  1. gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o
    在Ubuntu执行上述命令,得到hello.o可重定位目标文件。如图4-1所示。

图4-1 Ubuntu下汇编命令
4.3 可重定位目标elf格式
4.3.1 ELF典型格式
如图4-2所示,为ELF的典型格式。表4-1为各节的具体解释。

图4-2 ELF典型格式
ELF头 描述了生成该文件的系统的字的大小和字节顺序以及帮助链接器语法分析和解释目标文件
. text 已编译程序的机器代码
. rodata 只读数据,比如printf语句中的格式串和开关语句的跳转表
. data 已初始化的全局和静态C变量
. bss 未初始化的全局和静态C变量
. symtab 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息
.rel.text 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.rel.data 被模块引用或定义的所有全局变量的重定位信息。
. debug 一个调试符号表,其条目时程序中定义的全局变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。
. line 原始C源程序的行号和.text节中机器指令之间的映射
. strtab 一个字符串表,其内容包括 .symtab 和 .debug节中的符号表,以及节头部中的节名字
节头部表 描述目标文件的节。
表4-1 ELF各节的具体解释
4.3.2 ELF头
通过下述指令获得ELF头的相关信息:

  1. readelf -h hello.o
    具体内容见图4-3。

图4-3 ELF头
ELF头以一个16字节的序列7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00开始,从此序列可以看出生成该文件的系统的字的大小为8字节,字节顺序为小端序。
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小(64字节)、目标文件的类型(REL 可重定位文件)、机器类型(Advanced Micro Devices X86-64)、节头部表(section header table)的文件偏移(0x4d0),以及节头部表中条目的大小(64字节)和数量(14)。
4.3.3 节头部表
通过下述指令获得节头部表:

  1. readelf -S hello.o
    如图4-4所示,为hello.o的节头部表。

图4-4 节头部表
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节在节头部表中都有一个固定大小的条目(entry),其中重要的节的具体解释已在表4-1中进行呈现。
4.3.4 符号表
通过下述指令获得符号表:

  1. readelf -s hello.o
    如图4-5所示,为hello.o的符号表。

图4-5 符号表
符号表中存放着程序中定义和引用的函数和全局变量的信息。其中共有18个条目,每个条目8个字段。其中重要的符号有:

  1. hello.c为不应被重定位(Ndx = ABS)的局部符号(Bind = LOCAL),类型为FILE(Type = FILE).
  2. sleepsecs,它位于.data节(Ndx=3)偏移量为0(Value=0)的位置上,大小为4字节(Size = 4),类型为OBJECT(Type = OBJECT)的全局符号(Bind = GLOBAL)。
  3. main,它位于.text节(Ndx=1)偏移量为0(Value=0)的位置上,大小为133字节(Size = 133),类型为FUNC(Type = FUNC)的全局符号(Bind = GLOBAL)。
  4. puts、exit、printf、sleep和getchar,它们是未定义(Ndx = UND)的全局符号(Bind=GLOBAL),类型为NOTYPE(Type = NOTYPE)。
    4.3.5 重定位节
    通过下述命令获得重定位节:
  5. readelf -r hello.o
    如图4-6,为hello.o的重定位节。

图4-6 重定位节
接下来我们简单回顾ELF的重定位类型,下面我们假设链接器已经为每个节(ADDR(s)表示)和每个符号都选择了运行时的地址(ADDR(r.symbol)表示).

  1. R_X86_64_PC32
    重定位一个使用32位PC相对地址的引用。我们不妨以图4-6的第一个重定位条目为例完成R_X86_64_PC32的重定位。此重定位条目r由四个主要字段构成:
    r.offset = 0x1c
    r.symbol = .rodata
    r.type = R_X86_64_PC32
    r.addend = -4
    首先计算引用的运行时地址:
    refaddr = ADDR(s) + r.offset = ADDR(s) + 0x1c
    然后,更新该引用即可:
    *refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr) = (unsigned) (ADDR(r.symbol) -4 – ADDR(s) – 0x1c)
    第4、6、9条重定位条目计算方法与上面一致,不再赘述。

  2. R_X86_64_PLT32
    我们以图4-6的第二个条目为例完成对R_X86_64_PLT32的重定位。 当程序第一次调用printf时,并不直接调用,而是进入printf的PLT条目。
    进入PLT条目后,又间接跳转至GOT条目。又因为每个GOT条目初始时都指向它对应的PLT条目的第二条指令,这个间接跳转只是简单的把控制传送回PLT对应条目的下一条指令。
    接下来,将printf函数的ID压入栈中,跳转到PLT[0]。PLT[0]将GOT[1]间接的把动态链接器的第一个参数压入栈中,然后通过GOT[3]间接跳转到动态链接器中。动态连接器使用两条栈条目确定printf函数的运行时位置,用这个地址重写printf对应的GOT条目,再把控制传递给printf函数。
    当第二次调用printf时,和前面一样,控制传递到printf对应的PLT条目中,不过这次通过printf对应的GOT条目的间接跳转,会将控制直接转移到printf函数。
    其他库函数(puts、exit、sleep、getchar)分析与上面一致,不再赘述。
    4.4 Hello.o的结果解析
    输入下述命令,对hello.o进行反汇编。反汇编结果如图4-7所示。

  3. objdump -d -r hello.o > hello.txt

图4-7 hello.o反汇编结果
hello.s与hello.o反汇编对比,大致上一样,具体区别如下:
①重定位条目:如图4-8所示,hello.o反汇编出的代码中有重定位条目,而hello.s中并没有重定位条目。

图4-8 重定位条目对比(左:hello.o反汇编结果,右:hello.s)

②机器码:如图4-9所示,hello.o反汇编出的代码中最左边有对应的机器码,而hello.s中并没有机器码。

图4-9 机器码对比(左:hello.o反汇编结果,右:hello.s)

③分支转移:如图4-10所示,hello.o反汇编出的代码中通过地址进行跳转,而hello.s中通过标签(如.L3)进行跳转。。
.图4-10 分支转移对比(左:hello.o反汇编结果,右:hello.s)
④函数调用:如图4-11所示,hello.s中通过call后面加上函数名称实现函数调用,而在hello.o反汇编出的代码中通过call后面加上相对地址进行函数调用。值得注意的是,从hello.o反汇编出的代码中我们可以看到call后面的地址0x2f正好是下一条语句的地址,而从前面的机器码00 00 00 00也可以看出,此时调用的printf为共享库的函数,其尚未确定具体位置,因此汇编器为其生成一个重定位条目,等待链接进一步确定其地址。

图4-11 函数调用对比(左:hello.o反汇编结果,右:hello.s)

⑤:全局变量:如图4-12所示,hello.s中通过%rip+标签(如.LC1)实现全局变量的访问,而在hello.o反汇编出的代码中,由于.rodata只有在运行时才可以确定地址,因此在汇编器并不知道全局变量的地址,因此通过0x0(%rip)来访问全局变量,并生成对应的重定位条目,等待链接进一步确定其地址。

图4-12 全局变量对比(左:hello.o反汇编结果,右:hello.s)

⑥:数据表示:如图4-13所示,hello.s中立即数为10进制形式,而hello.o反汇编出的代码中立即数为16进制形式。

图4-13 数据表示对比(左:hello.o反汇编结果,右:hello.s)
4.5 本章小结
在第四章,我们首先介绍了汇编的概念和作用,然后在Ubuntu中使用相关指令对hello.s文件进行了汇编,生成了hello.o文件,接着我们通过readelf对elf格式有了一个初步的理解,最后我们通过objdump比较了hello.s和hello.o通过反汇编生成的汇编代码之间的区别与联系。

第5章 链接
5.1 链接的概念与作用
链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。

链接的作用
链接使得分离编译(seperate compila)成为可能。我们不用将一个大型程序组织为巨大的源文件,而是将其分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令

  1. 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
    使用在Ubuntu执行上述命令,得到hello可执行目标文件。如图5-1所示。

图5-1 Ubuntu下链接命令
5.3 可执行目标文件hello的格式

  1.  readelf -a hello > helloelf.txt  
    

使用在Ubuntu执行上述命令,得到hello的elf文件相关信息,如图5-2所示。

图5-2 readelf命令

5.3.1 ELF头
通过下述指令获得ELF头的相关信息:

  1. readelf -h hello
    具体内容见图5-3。

图5-3 ELF头
ELF头以一个16字节的序列7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00开始,从此序列可以看出生成该文件的系统的字的大小为8字节,字节顺序为小端序。
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小(64字节)、目标文件的类型(EXEC 可执行文件)、机器类型(Advanced Micro Devices X86-64)、节头部表(section header table)的文件偏移(0x3778),以及节头部表中条目的大小(64字节)和数量(27)。
5.3.2 节头部表
通过下述指令获得节头部表:

  1. readelf -S hello
    如图5-4所示,为hello的节头部表。

图5-4 节头部表
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节在节头部表中都有一个固定大小的条目(entry),其中重要的节的具体解释已在表4-1中进行呈现。
5.3.3 符号表
通过下述指令获得符号表:

  1. readelf -s hello
    如图5-5所示,为hello的符号表。

图5-5 符号表
符号表中存放着程序中定义和引用的函数和全局变量的信息。
5.3.4 程序头表
通过下述指令获得程序头表:

  1. readelf -l hello
    如图5-6所示,为hello的程序头表。

图5-6 程序头表
5.3.5 重定位节
通过下述指令获得重定位节:

  1. readelf -r hello
    如图5-7所示,为hello的重定位节。

图5-7 重定位节
5.4 hello的虚拟地址空间
在edb安装目录下运行下述指令,打开edb,加载hello,如图5-8所示。

  1. ./edb

图5-8 edb
观察Data Dump窗口,如图5-9所示,程序虚拟空间从0x400000开始。

图5-9 虚拟空间开始
使用Plugins中的Symbol Viewer插件,发现各段信息与5.3的分析一致。
图5-10 Symbol Viewer(前半部分)

图5-10 Symbol Viewer(后半部分)
5.5 链接的重定位过程分析
输入下述命令,对hello进行反汇编。反汇编结果如图4-7所示。

  1. objdump -d -r hello > hello2.txt

图5-11 hello反汇编指令
我们来分析hello反汇编结果与hello.o反汇编结果之间的区别:
①地址区别:如图5-12所示,hello.o反汇编代码中的地址都是相对偏移地址,而hello反汇编代码中的地址都是虚拟地址。

图5-12 地址区别(左:hello.o反汇编代码 右:hello反汇编代码)

②节区别:如图5-13所示,hello.o反汇编代码中的只有一个.text节,而hello反汇编代码中出了.text节,多出了许多其它的节(如.init)。

图5-13 节区别(左:hello.o反汇编代码 右:hello反汇编代码)

③共享库函数:如图5-14所示,hello.o反汇编代码中没有共享库函数相关信息,而hello反汇编代码中存在共享库函数的相关信息。

图5-14 共享库函数(左:hello.o反汇编代码 右:hello反汇编代码)

④重定位:如图5-15所示,hello.o反汇编代码中有重定位条目,并且call函数,引用全局变量,和跳转模块值时地址均为无意义的填充符;而hello反汇编代码中相关重定位条目不存在,且上述地址均变为真正的虚拟地址。

图5-15 重定位(左:hello.o反汇编代码 右:hello反汇编代码)
下面我们不妨以图5-15作为实例,分析hello如何对其进行重定位的:
此重定位条目r由四个主要字段构成:
r.offset = 0x1c
r.symbol = .rodata
r.type = R_X86_64_PC32
r.addend = -4
首先计算引用的运行时地址:
refaddr = ADDR(.text_main) + r.offset = 0x401105 + 0x1c = 0x401121
然后,更新该引用即可:
*refptr = (unsigned) (ADDR(.rodata+0x4) + r.addend – refaddr) = (unsigned) (0x402004-4 –0x401121) = 0xedf
此结果与图5-15所示结果一致。
注意:上述之所以是ADDR(.text_main)而不是ADDR(.text),是因为合并节之后,main的地址不在和.text节的地址一致了,而offset是引用在main中的偏移,故因此应该使用ADDR(.text_main)而不是ADDR(.text)。
其次,之所以使用ADDR(.rodata+0x4)而不是ADDR(.rodata),是因为相较于hello.o,hello中的.rodata在前面加上了一些内容,如图5-16所示,原printf格式符应该在.rodata节中偏移量为0x4的位置上。故应该使用ADDR(.rodata+0x4)而不是ADDR(.rodata)。

图5-16 .rodata节细分析(左:hello.o反汇编代码 右:hello反汇编代码)
在链接过程中,需要合并相同的节,然后确定新节中所有符号在虚拟地址空间中的地址。通过.rel.data和.rel.text中的重定位信息,链接器对引用符号进行重定位,修改.text节和.data节中对每个符号的引用。
5.6 hello的执行流程
程序载入
名称 地址
_dl_start 0x7ffff7de6630
_dl_init 0x7ffff7df6740
表5-1 程序载入时子程序名称与地址
开始执行
名称 地址
_start 0x4010d0
__libc_start_main 0x7ffff7de7fc0
_init 0x7ffff7de7f30
_setjmp 0x7ffff7e06e00
__sigsetjmp 0x7ffff7e06d30
__sigjmp_save 0x7ffff7e06db0
表5-2 程序开始执行时子程序名称与地址
开始执行main
名称 地址
main 0x4011b6
printf 0x7ffff7e25e10
_exit 0x7ffff7ea7290
_sleep 0x7ffff7ea6f40
getchar 0x7ffff7e4f6e0
表5-3 开始执行main时子程序名称与地址
终止
名称 地址
exit 0x7ffff7e0abc0
表5-4 终止时子程序名称与地址
5.7 Hello的动态链接分析
分析动态链接:
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要为其添加重定位记录,并等待动态链接器处理。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT 中存放函数目标地址,PLT使用 GO T中地址跳转到目标函数。

dl_init前,GOT内容如图5-17所示。可以从中看出,在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令。

图5-17 dl_init前GOT内容

dl_init后,GOT内容如图5-18所示。可以从中看出,在dl_init调用之后,GOT的相关条目即为对应函数的地址。

图5-18 dl_init后GOT内容

GOT的变化分析:不妨以程序调用printf为例说明。
当程序第一次调用printf时,并不直接调用,而是进入printf的PLT条目。
进入PLT条目后,又间接跳转至GOT条目。又因为每个GOT条目初始时都指向它对应的PLT条目的第二条指令,这个间接跳转只是简单的把控制传送回PLT对应条目的下一条指令。
接下来,将printf函数的ID压入栈中,跳转到PLT[0]。PLT[0]将GOT[1]间接的把动态链接器的第一个参数压入栈中,然后通过GOT[3]间接跳转到动态链接器中。动态连接器使用两条栈条目确定printf函数的运行时位置,用这个地址重写printf对应的GOT条目,再把控制传递给printf函数。
当第二次调用printf时,和前面一样,控制传递到printf对应的PLT条目中,不过这次通过printf对应的GOT条目的间接跳转,会将控制直接转移到printf函数。直接跳转到目标函数。
5.8 本章小结
在第五章,我们首先介绍了链接的概念和作用,然后在Ubuntu中使用相关指令对hello.o文件进行了链接,生成了hello文件,接着我们通过readelf对hello文件有了一个初步的理解,最后我们通过edb探究了hello的运行流程以及重定位过程。

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

进程的作用
在现代计算机中,进程为用户提供了以下假象:
①程序好像是系统中当前运行的唯一程序。
②程序好像是独占使用处理器和内存。
③处理器好像是无间断地执行程序中的指令。
④程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用
shell是一个交互型应用级程序,代表用户运行其他程序。是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。

Shell-bash的处理流程
shell首先检查命令是否是内置命令,如果是,则立即执行;若不是再检查是否是一个应用程序。然后shell在搜索路径里寻找这些应用程序。如果键入的命令不是一个内置命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内置命令或应用程序将被分解为系统调用并传给Linux内核。本质上就是shell在执行一系列的读和求值的步骤,在这个过程中,shell同时可以接受来自终端的命令输入。
6.3 Hello的fork进程创建过程
进程通过fork()函数进行创建。在子进程中,该函数返回0;在父进程中,该函数返回子进程的PID。
对于本实验而言,当我们输入

  1. ./hello 1190201210 陈则睿
    Shell首先判断出这不是一个内置命令,然后shell在搜索路径里寻找hello,并通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。
    Shell执行fork函数,创建一个子进程。hello程序运行在此子进程的上下文中。hello子进程是shell父进程的副本,虽然hello拥有父进程shell数据空间、堆、栈等资源的副本,但是实际上hello和shell两个进程之间不共享存储空间,hello有其独立的地址空间。
    进程图如图6-1所示。

图6-1 进程图
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行新程序hello。函数原型为:

  1. int exeve(const char *filename, const char *argv[], const char *envp[]);
    如果成功,则不返回;如果错误,则返回-1。
    execve函数加载并运行可执行文件filename(hello),且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
    当加载器运行时,它创建一个类似与图 6-2 的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。

图6-2 内存映像
6.5 Hello的进程执行
进程上下文信息
上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。

进程时间片
一个进程执行它控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)

进程调度
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程。这种决策就叫调度。
上下文切换
在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。上下文切换共有三个步骤:
①保存当前进程的上下文
②恢复某个先前被强占的进程被保存的上下文
③将控制传递给这个新恢复的进程

用户态与核心态转换
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。

结合hello程序具体分析
我们以hello中的sleep语句为例,对上述过程进行具体分析

  1. sleep(sleepsecs);
    ①初始状态:hello运行在用户模式之下。
    ②调用sleep时:陷入内核模式,内核处理sleep请求,并通过三步进行上下文切换(保存hello的上下文、恢复某个先前被强占的进程被保存的上下文、将控制传递给这个新恢复的进程)。
    ③sleep运行完毕,定时器发出中断信号,再次进入内核模式,进行上下文切换(保存当前进程的上下文、恢复hello的上下文、将控制传递给hello)。在这之后,hello继续进行自己的控制流。
    6.6 hello的异常与信号处理
    hello产生的异常有:
    ①中断。来自处理器外部的I/O设备的信号的结果,如CTRL -C或CTRL-Z。
    ②陷阱。有意的,执行指令的结果,比如系统调用。
    ③故障。由错误情况引起,可能被故障处理程序修正,如缺页异常。
    (终止异常通常是硬件错误,在hello中不一定发生!)

hello产生的信号有:
SIGINT,SIGSTP,SIGCONT等。
下面结合实例,对异常和信号处理具体分析。
6.6.1 不停乱按,包括回车
如图6-3所示,我在运行过程中输入了:

  1. a 回车 s 回车d 回车f 回车g 回车h 回车j 回车k 回车l 回车
    结果为运行完hello后,又分别向shell中输入了s 回车d 回车f 回车g 回车h 回车j 回车k 回车l 回车,即8个命令。因此在程序结束之前,用户通过键盘输入的内容被暂时缓存了起来。当执行完10个printf后,hello中的getchar会将第一个回车之前的内容全部进行读取,当读取到第一个回车之后,hello程序结束并返回。这个时候,第一个回车之后的内容就全部输出到shell中了,即被当作了shell要执行的命令。

图6-3 不停乱按,包括回车
6.6.2 Ctrl-Z
如图6-4所示,输入Ctrl-Z会发送一个SIGTSTP信号给前台进程组的每个进程,结果是停止前台作业hello程序。

图6-4 Ctrl-Z
运行ps命令,结果如图6-5所示。

图6-5 Ctrl-Z后ps

运行jobs命令,结果如图6-6所示。

图6-6 Ctrl-Z后jobs

运行pstree命令,结果如图6-7所示。

图6-7 Ctrl-Z后pstree(前部分)

图6-7 Ctrl-Z后pstree(中部分)

图6-7 Ctrl-Z后pstree(后部分)

运行fg命令,结果如图6-8所示。

图6-8 Ctrl-Z后fg

运行kill命令后,用ps查看验证,结果如图6-9所示。

图6-9 Ctrl-Z后kill
6.6.3 Ctrl-C
如图6-10,输入Ctrl-C会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程。用ps查看验证,发现进程已经被回收。

图6-9 Ctrl-C
6.7本章小结
在第六章,我们首先介绍了进程的概念和作用,然后简述了壳Shell-bash的作用与处理流程。接着,我们结合hello程序,分析了fork进程创建过程、execve过程以及进程执行过程。最后,我们探究了hello的异常与信号处理。

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址
逻辑地址即汇编程序hello.o中的程序代码与段相关的偏移地址。

线性地址
线性地址是逻辑地址到物理地址变换之间的中间层。hello程序段中的偏移地址加上段的基地址就生成了一个线性地址。

虚拟地址
虚拟地址与实际物理内存容量无关,是hello.o中的程序代码与段相关的偏移地址。

物理地址
存储器里以字节为单位存储信息,其中每一个字节单元都有一个唯一的存储器地址,称为物理地址,又称实际地址或绝对地址。在分页机制下,hello的线性地址会通过页表项等变成hello的物理地址;如果没有分页机制,那么hello的线性地址等同于其物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址必须加上段的基地址,才能构成线性地址。在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。

分段机制将逻辑地址转化为线性地址的步骤:
①当一个新的段选择符加载到段寄存器时,使用段选择符中的偏移值(段索引)在GDT或LDT表中定位相应的段描述符。
②利用段选择符检验段的访问权限和范围,以确保该段可访问。
③把段描述符中取到的段基地址加到偏移量上,最后形成一个线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理
如图7-1所示,计算机借助MMU,通过页表完成从线性地址(即虚拟地址)到物理地址的转换。

图7-1 页式管理
虚拟地址包括虚拟页号VPN以及虚拟页偏移量VPO。首先,CPU中的一个控制寄存器,页表基址寄存器指向当前页表。然后,MMU利用VPN作为在页表中的所有,寻找对应的PTE:如果有效位为0,则发生缺页;如果有效位为1,则将PTE中的物理页号PPN与VPO串联起来,就得到了对应的物理地址。值得注意的是,物理和虚拟页面字节数一样,因此物理页面偏移和虚拟页面偏移是相同的。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:
CPU每产生一个虚拟地址,MMU必须查阅一个PTE,这样会导致时间开销较大,因此现代计算机在MMU中包括了一个关于PTE的缓存,即翻译后备缓冲器(Translation Lookaside Buffer,TLB)。
  如图7-2所示,TLB每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。

图7-2 虚拟地址中访问TLB的组成部分
当CPU产生一个虚拟地址后,MMU首先先从TLB中寻找对应的PTE:如果TLB不命中,则MMU从L1缓存中取出对应的PTE,并把新取出的PTE放在TLB中;如果TLB命中,则MMU从TLB中取出对应的PTE,并借助PTE把虚拟地址翻译为物理地址。

多级页表:
将虚拟地址的VPN划分为相等大小的不同部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。如下图,VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。

图7-3利用k级页表进行地址翻译

TLB与四级页表支持下的VA到PA的变换:
如图7-4所示,为TLB与四级页表支持下从VA到PA的翻译过程。

图7-4 TLB+4级页表的虚拟地址翻译
如图7-5所示,是第一到三级页表条目的格式:

图7-5 第一级到第三级页表条目格式
当P=1时,其中40位的物理页号PPN指向适当的页表的开始处。要求物理页表4KB对齐。
如图7-6所示,为第四级页表条目格式。当P=1时,地址字段的40位PPN指向物理内存中某一页的基地址。

图7-6 第四级页表条目格式
如图7-7所示,36位的VPN被划分为四个9位的片,每个片都被用作一个页表的偏移量。CR3寄存器包含L1页表的物理地址,VPN1提供一个到L1 PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个到L2PTE的偏移量,以此类推。

图7-7 四级页表翻译

7.5 三级Cache支持下的物理内存访问
如图7-8所示,在得到PA之后,PA被划分为CO、CI以及CT。首先,在一级Cache中,利用CI进行组索引,然后利用CT,在组内的8行中进行标志位匹配,如果匹配成功并且有效位为1,命中,根据偏移量CO取出数据并返回。
如果组内8行均不匹配或者标志位为0,则缓存miss。向下一级cache(二级或者三级cache)或者主存中寻找并查询数据,最后逐级写入cache(如果没有空闲位,则根据驱逐策略将一个块进行驱逐)。

图7-8 三级cache支持下物理内存的访问
7.6 hello进程fork时的内存映射
Linux通过将一个虚拟内存区域与一个磁盘对象关联起来,以初始化这个虚拟内存区域的内容,这个过程叫做内存映射。
当shell调用fork时,内核首先为hello进程创建各种数据结构,并为它分配唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
①删除已存在的用户区域。删除当前进程shell虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。如图7-9所示,概括了私有区域的不同映射。
③映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度hello进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

图7-9 加载器映射用户地址空间区域
7.8 缺页故障与缺页中断处理
虚拟内存中,DRAM缓存不命中称为缺页。如图7-10所示,处理缺页是由硬件和操作系统内核协作完成的。

图7-10 缺页
下面是缺页中断处理步骤:
1)处理器生成一个虚拟地址,并将它传送给MMU
2)MMU生成PTE地址,并从高速缓存/主存请求得到它
3)高速缓存/主存向MMU返回PTE
4)PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5)缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6)缺页处理程序页面调入新的页面,并更新内存中的PTE
7)缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。最终,主存会将所请求的字返回给处理器。
7.9动态存储分配管理
如图7-11所示,动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap),它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

图7-11 堆
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块:
显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp,ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

  1. 带边界标签的隐式空闲链表
    如图7-12所示,一个块是由一个字的头部、有效载荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。

图7-12 堆块格式

①头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
②头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
如图7-13所示,我们称这种结构为隐式空闲链表,其中空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。

图7-13 隐式空闲链表

  1. 显式空闲链表
    如图7-14所示,显式空闲链表结构将堆组织成一个双向空闲链表,在每个空闲块的主体中,都包含一个前驱和后继指针。
    使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
    一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。

图7-14 双向空闲链表堆块结构

  1. 分离空闲链表
    分离存储,即维护多个空闲链表,其中每个链表中的块有大致相等的大小一般是将所有可能的块大小分成一些等价类,也叫做大小类。分配器维护一个分离空闲链表,其中每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,他就搜索相应的空闲链表,如果不能找到合适的块与之匹配,就搜索下一个链表,以此类推。

  2. 放置策略
    首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。
    下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。
    最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
    7.10本章小结
    在第七章,我们首先介绍了hello程序的存储器地址空间,然后介绍了段式管理和页式管理。接下来,我们探究了在TLB与四级页表支持下的VA到PA的变换一级三级Cache支持下的物理内存访问。除此之外,我们以hello进程为例,分析了fork以及execve时的内存映射。最后,我们探讨了缺页中断处理以及动态存储分配管理。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O接口
通过Unix I/O,所有的输入和输出都能以一种统一且一致的方式来执行:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O函数

  1. 打开和关闭文件
    ①打开文件:进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的,函数原型如下:

  2. int open(char *filename, int flags, mode_t mode);
    返回值:若成功则为新文件描述符,若出错为-1。
    open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。
    ②关闭文件:进程通过调用close函数关闭一个打开的文件。函数原型如下:

  3. int close(int fd);
    返回值:若成功则为0,若出错则为-1。
    注意,关闭一个已关闭的描述符会出错。

  4. 读和写文件
    ①读文件:应用程序通过调用read函数来执行输入。read函数从描述符为fd的当前文件位置复制至多n的字节到内存位置buf。函数原型如下:

  5. ssize_t read(int fd, void *buf, size_t n);
    返回值:若成功则为读的字节数,若EOF则为0,若出错则为-1。
    ②写文件:应用程序通过调用write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。函数原型如下:

  6. ssize_t write(int fd, const void *buf, size_t n);
    返回值:若成功则为写的字节数,若出错则为-1。

  7. 修改当前文件的位置
    通过调用lseek函数,应用程序能够显式地修改当前文件的位置。

8.3 printf的实现分析
printf函数体如下:

  1. int printf(const char *fmt, …)
  2. {
  3.  int i;  
    
  4.  char buf[256];  
    
  5.  va_list arg = (va_list)((char*)(&fmt) + 4);  
    
  6.  i = vsprintf(buf, fmt, arg);  
    
  7.  write(buf, i);  
    
  8.  return i;  
    
  9. }
    其中printf调用了vsprintf函数,其函数体如下:
  10. int vsprintf(char *buf, const char *fmt, va_list args)
  11. {
  12.  char* p;   
    
  13.  char tmp[256];   
    
  14.  va_list p_next_arg = args;   
    
  15.  for (p=buf;*fmt;fmt++) {   
    
  16.  if (*fmt != '%') {   
    
  17.  *p++ = *fmt;   
    
  18. continue;   
    
  19. }   
    
  20. fmt++;   
    
  21. switch (*fmt) {   
    
  22. case 'x':   
    
  23. itoa(tmp, *((int*)p_next_arg));   
    
  24. strcpy(p, tmp);   
    
  25. p_next_arg += 4;   
    
  26. p += strlen(tmp);   
    
  27. break;   
    
  28. case 's':   
    
  29. break;   
    
  30. default:   
    
  31. break;   
    
  32. }   
    
  33. }   
    
  34. return (p - buf);   
    
  35. }
    vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。
    printf还调用了write函数,其函数实现如下
  36. write:
  37. mov eax, _NR_write
  38. mov ebx, [esp + 4]
  39. mov ecx, [esp + 8]
  40. int INT_VECTOR_SYS_CALL
    write函数首先给几个寄存器传递了若干个参数,然后通过int结束
    注意到在write函数中,有下面一条语句:
  41. int INT_VECTOR_SYS_CALL
    它表示要通过系统来调用sys_call这个函数,此函数实现如下:
  42. sys_call:
  43. call save
  44. push dword [p_proc_ready]
  45. sti
  46. push ecx
  47. push ebx
  48. call [sys_call_table + eax * 4]
  49. add esp, 4 * 3
  50. mov [esi + EAXREG - P_STACKBASE], eax
  51. cli
  52. ret
    此函数功能为显示格式化的字符串,将要输出的字符串从总线复制到显卡的显存中。
    接下来,字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
    最后,显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
    8.4 getchar的实现分析
    首先,getchar的函数体如下:
  53. int getchar(void)
  54. {
  55.  static char buf[BUFSIZ];  
    
  56.  static char* bb=buf;  
    
  57.  static int n=0;  
    
  58.  if(n==0)  
    
  59.  {  
    
  60.      n=read(0,buf,BUFSIZ);  
    
  61.      bb=buf;  
    
  62. }  
    
  63. return (--n>=0)?(unsigned char)*bb++:EOF;  
    
  64. }
    对上述函数体进行分析可知,getchar调用了一个read函数,它将整个缓冲区都读到了buf里面,返回值是缓冲区的长度。
    进一步注意到,只有buf长度为0,getchar才会调用read函数,否则getchar直接将保存在buf中的最前面一个元素进行返回。
    异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
    getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
    8.5本章小结
    在第八章,我们首先介绍了Linux的IO设备管理方法,然后简介了Unix IO接口及其函数,最后我们分析了printf以及getchar函数的实现。

结论
hello经历的过程:

  1. 诞生:程序员通过编辑器编写文本文件hello.c,即源程序。
  2. 预处理:预处理器将源程序hello.c中的头文件进行头文件展开以及宏拓展,生成预处理的源程序hello.i。
  3. 编译:编译器将预处理后的源程序hello.i翻译为汇编文件hello.s。
  4. 汇编:汇编器将hello.s翻译成机器码,形成可重定位的目标文件hello.o。
  5. 链接:链接器将hello.o以及其他可重定位目标文件进行链接,生成hello可执行文件。
  6. 运行:当程序员在shell中运行hello时,shell通过fork生成子进程,加载器将hello加载进内存中,并在其上下文中运行hello。最终,通过printf函数,输出了hello 1190201210 陈则睿。
  7. 终结:当hello运行完毕后,被shell回收,最终内核回收与之有关的所有信息。
    我的感悟:
    hello虽小,五脏俱全。对hello的深入刨析,就如同庖丁解牛一样,只有对计算机系统的各个方面都深入了解,才能正确地完成对hello历程的分析。
    除此之外,我对于系统有了更深的认识。从hello诞生伊始,到hello迎来终结,计算机系统的软硬件协同合作,相互配合,让hello顺利地完成了它自己的使命。因此,我们在学习计算机科学时,一定要以系统观去看待和分析问题,计算机系统的每一部分都不是独立存在的,只有将它放在整个系统中去分析和学习,才能对其有一个深刻的理解。

附件
名称 作用
hello.i 预处理得到的文本文件
hello.s 编译后得到的汇编程序文本文件
hello.o 汇编后得到的可重定位目标文件
hello 链接后的可执行目标文件
hello_elf.txt hello的elf格式文件
hello_objdump.txt hello的反汇编文件
hello_o_elf.txt hello.o的elf格式文件
hello_o_objdump.txt hello.o的反汇编文件

参考文献
[1] printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com) https://www.cnblogs.com/pianist/p/3315801.html
[2] 逻辑地址,线性地址和物理地址转换_atarik@163.com-CSDN博客_逻辑地址和线性地址https://blog.csdn.net/asdfsadfasdfsa/article/details/98223811
[3] Randal E. Bryant, David R. O’Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737

 类似资料: