计算机系统大作业 程序人生-Hello

本文详细剖析了Hello程序从源代码到可执行文件的全过程,包括预处理、编译、汇编、链接等步骤,并探讨了其在Linux系统下的进程管理、存储管理和IO管理等方面的知识。

程序人生-Hello

摘 要
本文通过对hello.c程序的预处理、编译、汇编、链接、运行等过程的分析以及对hello.c程序的进程管理、存储管理、IO管理的简要描述,再现了hello.c程序的一生,同时加深了我对程序生命周期的理解。

关键词:预处理;编译;汇编;链接;运行;进程管理;存储管理;IO管理

第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简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
在Linux系统下,hello程序是在vim、codeblocks等应用程序中编写相应代码所得到的程序,即Program。
而所谓的Process(进程),则是hello程序在Linux系统下进行预处理、编译、汇编、链接、运行等过程所得到的。具体来说,Linux下使用如下命令行:gcc –m64 –no-pie –fno-PIC hello.c -o hello来完成运行前的一系列操作,接着在shell中输入命令:./hello 学号 姓名 秒数 ,来执行hello程序。

图1.1 hello程序编译过程
shell会解析命令行参数,发现其非内置命令,于是将其视为可执行文件来运行。将学号、姓名、秒数存储到argv数组中,在加载和运行过程中调用fork()函数以及execve()函数来创建进程并在当前进程中加载并运行可执行文件中的程序,并通过内存映射使hello程序获得自己的内存空间,真正成为了Process(另外由于没有‘&’符号,hello程序在前台运行)。
此即Program转换为Process的具体过程(P2P)。
而O2O(From Zero-0 to Zero-0)则是hello程序从无(0)到有再到无的过程,具体描述如下:Linux下加载器execve()将PC(程序计数器)的值设为程序入口点,即_start函数的地址。该函数调用系统启动函数__libc_start_main,并初始化执行函数,调用用户层的main函数,处理main函数的返回值,并在需要时把控制交还给内核。
处理器为可执行文件hello分配时间周期,执行逻辑控制流。每条指令的指令周期包含取值、译码、执行、访存、写回、更新PC操作,并且使用流水线的方式执行。MMU(内存管理单元)和处理器在指令执行过程中通过L1、L2、L3 Cache(高速缓存)、TLB(翻译后备缓冲器)、多级页表在物理内存中存取数据、指令,通过I/O系统进行输入输出操作。当程序运行结束(比如键盘键入Ctrl+C发送SIGINT信号终止进程),由shell回收进程,释放hello程序的内存并且删除有关进程上下文。
此即O2O(From Zero-0 to Zero-0)的内容。
1.2 环境与工具
硬件环境:
X64 CPU;AMD Ryzen 5 3500U with Radeon Vega Mobile Gfx ; 2.10GHz; 8.0G RAM;
  软件环境:
    Windows10,Ubuntu 20.04LTS
  开发与调试工具:
    GCC,EDB,Hexedit,Objdump,readelf, Code:Blocks

1.3 中间结果
hello.c:源程序
  hello.i:预处理后的文本文件
  hello.s:编译后汇编程序文本文件
  hello.o:汇编后的可重定位目标程序(二进制文件)
  helloboy.txt:hello.o的反汇编文件
  hellogirl.txt:hello的反汇编文件
  hellobelf.txt:ELF格式下的hello.o
  hellogelf.txt:ELF格式下的hello
  hello:链接后的可执行目标文件

1.4 本章小结
本章简要介绍了hello程序的一生,主要叙述了P2P以及O2O的概念与具体过程;另外介绍了本次实验所用的环境与工具以及实验过程产生的文件及其作用。

第2章 预处理

2.1 预处理的概念与作用
2.1.1 概念
预处理是指在编译前对源文件的简单处理。
[1]实际上预处理就是做些代码文本的替换工作:处理以#开头的指令,比如拷贝#include包含的文件代码,#define宏定义的替换,条件编译等。就是为编译做的预备工作的阶段。
[2]最常见的预处理是C语言和C++语言。ISO C和ISO C++都规定程序由源代码被翻译分为若干有序的阶段(phase) ,通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive) ,其中ISO C/C++要求支持的包括#if、#ifdef、#ifndef、#else、#elif、#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma以及单独的#(空指令),如图所示(图片来源[4]):

图2.1 预处理名称及意义
[4]除了这些之外还有几个没有出现在表中的宏:
LINE:正在编译的文件的行号、
FILE:正在编译的文件的名字、
DATE:编译时刻的日期字符串、
TIME:编译时刻的时间字符串、
STDC:判断该程序是否为标准的c程序;
若编译器是非标准的,则上述宏有可能支持部分或根本不支持。

2.1.2 作用
[3]预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中的#include < stdio.h > 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件拓展名。
2.2在Ubuntu下预处理的命令
预处理命令:
gcc -E hello.c -o hello.i
生成预处理文件的过程、结果如图所示:

图2.2 预处理命令及生成文件

2.3 Hello的预处理结果解析
观察hello.i文件与hello.c程序代码对比,发现main函数部分代码基本不变,而代码前面原本的注释以及#include <stdio.h>、#include <unistd.h> 、#include <stdlib.h>三条语句则被替换成3000多行不同寻常的语句,如图所示:

图2.3 hello.c程序代码

图2.4 hello.i文件
这3000多行语句即扩展了的#include文件。
仔细观察拓展了的头文件语句部分,发现其中包含大量对结构的定义,诸如typedef、struct、enum等,以及对外部变量的引用,如extern,还有对引用目录的标注,如“/usr/include/features.h”等。如图所示:

图2.5 拓展头文件语句
但由于程序本身并没有关于宏定义#define及其他预处理语句的相关代码,故在hello.i中看不到其他类型的语句在预处理中的替换。
2.4 本章小结
本章介绍了预处理阶段的相关概念、作用,在Ubuntu下预处理的命令,以及分析了hello程序的预处理结果。通过具体的hello示例说明了预处理过程中对头文件的解析、拓展与替换。

第3章 编译

3.1 编译的概念与作用
概念:
[5]编译是将进行过预处理的高级语言(C)程序进行词法分析;语法分析;语义检查等处理转换为汇编语言程序的过程。编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。值得注意的是,转换后的文件仍为文本文件(只由ASCII码构成的文件)。
作用:
编译把高级程序设计语言书写的源程序翻译成汇编语言程序,而汇编语言程序比起高级程序设计语言书写的源程序更容易让机器理解,是程序迈向机器指令的关键一步。
3.2 在Ubuntu下编译的命令
编译命令:
gcc -S hello.c -o hello.s
生成编译文件的过程、结果如图所示:

图3.1 编译命令及生成文件

3.3 Hello的编译结果解析
3.3.1 数据
数据分为常量与变量,我们首先分析常量:
3.3.1.1 常量
对比hello.c源程序,我们发现程序中的常量有printf格式串:用法: Hello 学号 姓名 秒数!\n以及Hello %s %s\n。

图3.2 hello.c源程序
对比hello.s中可知其刚好对应.LC0、.LC1标号部分,如图3.3所示:

图3.3 汇编文件对应printf格式串
两字符串都被放在.rodata节中。由此进一步分析,根据UTF-8的编码规则,汉字被编码为三个字节,其它英文、空格、换行等字符与ASCII规则相同,编码为一个字节,但在汇编文件中保留其原字符形式。
3.3.1.2 变量
对比C语言源程序可得变量有局部变量i,argv,argc;
如图3.4所示,argc作为第一个函数参数自然存放在寄存器%edi中,然后存放在栈中-20(%rbp)的位置,接着和4进行比较;而相应argv作为第二个函数参数存放在寄存器%rsi中,使用时存放在栈中-32(%rbp)的位置:

图3.4 argc、argv条目
如图3.5所示局部变量i作为循环计数器,在循环开始时初始化为0,循环终止条件是判断i是否大于8。值得注意的是,汇编语言使用了“跳转到中间”的翻译方法:

图3.5 i条目
3.3.2 赋值
在C语言源程序中只有对循环变量i的两次赋值:i = 0和i++(相当于i = i + 1)。在汇编文本文件中的表示如图3.6,图3.7所示:

图3.6 i=0赋值

图3.7 i++赋值
3.3.4 算数操作
对i进行i++操作,如图3.7所示
3.3.5 关系操作
程序有两次关系操作:argc!=4和i<8;
3.3.5.1 argc!=4
如图3.8所示,使用cmpl语句将argc与4进行比较并设置条件码,jxx语句则根据条件码做出是否跳转的选择。

图3.8 比较argc
3.3.5.2 i<8
如图3.9所示,使用cmpl语句将i与7进行比较并设置条件码,jxx语句则根据条件码做出是否跳转的选择。

图3.9 比较i
3.3.6 数组/结构/指针操作
在源程序中只有对于数组argv的操作,对照代码可知在printf语句中调用了argv[1]、argv[2],而且在调用sleep函数时用到了argv[3]。
对应于汇编文本文件中的指令如图3.10所示:

图3.10 argv[1]、argv[2]的引用
由前文可知argv数组首地址存放在-32(%rbp)处,那么先将该地址存放在寄存器%rax中,那么%rax+16和%rax+8分别对应argv[2]与argv[1]的地址,对其进行内存引用即可得到argv[2]与argv[1]的值。
如图3.11所示:

图3.11 argv[3]的引用
类似argv[1]、argv[2]的引用,argv数组首地址存放在-32(%rbp)处,先将该地址存放在寄存器%rax中,那么%rax+24对应argv[3]的地址,对齐内存引用即可得到argv[3]的值。
3.3.7 控制转移
由C语言源程序可知一共有两处控制转移,分别为:if(argc!=4)和for(i=0;i<8;i++)。
3.3.7.1 if(argc!=4)
如图3.12所示:

图3.12 控制转移if(argc!=4)
argc存放在-20(%rbp)处与4进行比较,若不等于4,则最终调用exit退出;若相等则跳转到.L2标号处继续执行,这一过程通过je语句执行。

3.3.7.2 for(i=0;i<8;i++)
如图3.13所示:

图3.13 控制转移for(i=0;i<8;i++)
for循环在.L2处初始化,循环结束在.L3处通过cmpl语句比较变量i与7的大小关系判断循环是否终止,若i小于等于7,则跳转到循环体继续循环;否则跳出循环执行语句。
3.3.8 函数操作
C语言源程序中的函数调用有三处:if(argc!=4)中对printf函数、exit函数的调用;for(i=0;i<8;i++)中对printf函数、sleep函数的调用;getchar函数的调用。
3.3.8.1 if(argc!=4)中函数调用
如图3.14所示:

图3.14 if(argc!=4)中函数调用
若argc != 4,函数首先调用printf函数,将.rodata节中.LC0标号对应的printf格式串的地址存入寄存器%rdi,作为调用printf函数的首个参数;再将1存入寄存器%edi作为exit函数的第一个参数。
3.3.8.2 for(i=0;i<8;i++)中函数调用
如图3.15所示:

图3.15 for(i=0;i<8;i++)中函数调用
函数将argv[1]、argv[2]的地址存进%rax、%rdx,将.LC1标号printf格式串地址存进%rdi,调用printf函数;再将argv[3]的地址存入%rax调用sleep函数。
3.3.8.3 getchar函数调用
如图3.16所示:

图3.16 getchar函数调用
函数直接对getchar函数进行调用,调用后将寄存器%eax的值改为0并从main函数返回。
3.4 本章小结
本章介绍了编译的概念、作用以及实现过程。通过对示例hello程序进行编译,得到C语言源程序代码在汇编文本文件中汇编语言的表示,尤其是对其中的数据、赋值、算术操作、函数操作、控制转移等进行解析,加深了对高级语言底层表示方法的理解。

第4章 汇编

4.1 汇编的概念与作用
概念:
汇编器(as)将hello.s翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中。注意hello.o是一个二进制文件,如果我们在文本编辑器中打开则会看到一堆乱码。
作用:
汇编过程将汇编文本文件中的汇编代码转换成二进制机器代码,更便于计算机的理解与执行。
4.2 在Ubuntu下汇编的命令
汇编命令:
gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o
执行汇编命令生成文件的过程、结果如图4.1所示:

图4.1 汇编命令及生成文件

4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用readelf -a hello.o> hellobelf.txt指令生成hellobelf.txt文件并将hello.o的ELF格式存入其中,如图4.2所示:

图4.2 指令将hello.o的ELF格式存入txt文件
4.3.1 ELF头
如图4.3所示:

图4.3 hello.o的ELF头信息
易见ELF头以一个16字节的序列开头,查阅资料可知生成该文件的系统的字的大小和字节顺序就是由这个序列所描述的。这个序列接下来的信息包含辅助链接器进行语法分析以及解释目标文件的信息,包含有目标文件类型,节头部表中条目的大小与数量,ELF头的大小等。
在本例中可得知ELF头大小为64字节,目标文件的类型为REL(可重定位文件),节头大小为64字节,节头部表中条目数量为13。
4.3.2 节头部表
如图4.4所示:

图4.4 节头部表信息
由图可知节头部表总共包含13个节的有关信息,分别为:
1、.text节: 已编译的机器代码,类型为PROGBITS(意为程序数据),旗标为AX(即权限为可分配内存、可执行)。
2、.rela.text节: 一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
3、.data节: 已初始化的静态和全局C变量。类型为PROGBITS(程序数据)旗标为WA(可分配可写)。
4、.bss节: 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。类型为NOBITS(意为暂时没有存储空间),旗标为WA(可分配可写)。
5、.rodata节: 存放只读数据,例如printf中的格式串和开关语句中的跳转表。类型为PROGBITS(程序数据),旗标为A(权限为可分配)。
6、.comment节: 包含版本控制信息。
7、.note.GNU-stack节: 用来标记executable stack(可执行堆栈)。
8、.note.gnu.propert节
9、.eh_frame节: 处理异常。
10、.rela.eh_frame节: 包含.eh_frame的重定位信息。
11、.symtab节: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
12、.strtab节: 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。
13、.shstrtab节: 包含节区名称。
4.3.3 符号表
符号表由汇编器构造,保存了程序中出现的所有符号,包含定义和引用的函数、全局变量、静态变量等。
.symtab节中包含了ELF符号表,符号表是包含了多个条目的数组,图4.5为每个条目的格式(图片来源https://blog.csdn.net/zl6481033/article/details/84990262):

图4.5 ELF符号表条目
hello.o程序的符号表包含标号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段,如图4.6所示:

图4.6 hello.o程序的符号表
该符号表共包含18个条目,其中:
1.puts、exit、printf、atoi、sleep、getchar为NOTYPE未知类型,未定义(UND)符号。
2.hello.c为文件,ABS表示不该被重定位的符号。
3.main位于.text节(Ndx=1),偏移量(Value)为0,大小(Size)为146个字节的全局(GLOBAL)符号,类型为函数(FUNC)。
4.3.4 .rela.text节和.rela.eh_frame节
如图4.7(图片来源 https://blog.csdn.net/zl6481033/article/details/84990262)所示:

图4.7 ELF重定位条目
该图向我们展示了ELF重定位条目的格式。
ELF定义了32种不同的重定位类型,我们只关心其中两种最基本的重定位类型:
1、R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值。
2、R_X86_64_32:重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
在hello.o程序的ELF格式中,有关重定位条目的信息如图4.8所示:

图4.8 hello.o程序ELF格式中有关重定位条目信息
可见符号名称为.rodata和.text的节采用PC相对引用的方式。
重定位算法如下所示:

  1. refaddr = ADDR(s) + r.offset;
  2. if(r.type == R_X86_64_PC32){
  3. *refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
  4. }
  5. If(r.type == R_X86_64_32) {
  6. *refptr = (unsigned) (ADDR(r.symbol) + r.addend);
  7. }
  8. //注意计算结果是unsigned类型。

4.4 Hello.o的结果解析
在终端执行命令:objdump -d -r hello.o>helloboy.txt,获取hello.o的反汇编代码,如图4.9所示:

图4.9 hello.o的反汇编代码
而hello.s文件中的汇编代码,如图4.10所示:

图4.10 hello.s文件中的汇编代码
对比可得两个文件中具体的语句还是十分类似的,但仍然存在以下诸多不同点:
首先,最明显的不同点应该是反汇编代码文件中每一行语句前面有一串十六进制数而hello.s文件中的汇编代码之前没有。
其次,hello.s文件中的汇编代码有许多类似.LC0、.LC1之类的标号,作为辅助实现跳转指令,而反汇编代码则一般是直接根据地址来跳转的,没有标号的辅助,具体如图4.11,4.12所示:

图4.11 汇编代码中跳转

图4.12 反汇编代码中跳转
再次,调用函数时,hello.s文件中的汇编代码使用call puts@PLT的方式直接声明调用的函数,而反汇编代码则采用重定向的方式进行跳转,机器代码在此处留下一些信息辅助重定向,如图4.13,4.14所示:

图4.13 反汇编代码函数调用重定向方式

图4.14 汇编代码中函数调用
链接时根据这些信息自动填充地址
细心观察,汇编代码与反汇编代码还有一个细微的不同之处,即立即数在汇编代码是十进制,而在反汇编代码中是十六进制(会在立即数前加上”0x”标识),具体如图4.15,4.16所示:

图4.15 反汇编代码中立即数表示

图4.16 汇编代码中立即数表示

4.5 本章小结
本章叙述了汇编的概念及作用,通过阅读hello.o的ELF文件格式对其中的信息做了了解,重点在分析ELF头,节头部表、符号表以及重定位条目。同时将hello.o的反汇编代码与hello.s文件中的汇编代码进行对比,初步了解两者异同。

第5章 链接

5.1 链接的概念与作用
概念:
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
为了构造可执行文件,链接器必须完成两个主要任务:符号解析和重定位。

作用:
[6]将可重定位目标文件进行链接形成一个可以加载运行的可执行目标文件,使得分离编译成为可能:我们不用将一个大工程组织为巨大的源文件,而是将它分解为更小的模块,可以独立修改和编译这些模块,并重新链接应用,而不必重新编译其他的文件。
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所示:

图5.1 链接命令的执行结果

5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf -a hello > hellogelf.txt指令查看hello的ELF文件格式并将信息保存在hellogelf.txt中,如图5.2所示。

图5.2 将hello的ELF文件格式信息保存在hellogelf.txt
5.3.1 ELF头
如图5.3所示:

图5.3 hello的ELF头信息
可见同可重定位目标文件一样,hello的ELF头也以一个16字节的序列开头,这个序列描述了生成该文件的系统的字的大小和字节顺序。序列接下来的信息包含有目标文件类型,节头部表中条目的大小与数量,ELF头的大小等。
在此例中,hello的ELF头以十六进制序列:7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00开头;字节顺序为小端序;目标文件的文件类型为EXEC(可执行文件);ELF头的大小为64字节;节头部表中条目的数量为27;
5.3.2 节头部表
如图5.4,5.5所示:

图5.4 hello的节头部表信息(上)

图5.4 hello的节头部表信息(下)
与hello.o的节头部表相比,主要有几个多出来的部分:
1、.interp节:动态链接器在操作系统中的位置由ELF文件中的 .interp节指定。该节里保存的是可执行文件所需要的动态链接器的位置,常位于/lib/ld-linux.so.2。
2、.dynamic节:该节中保存了动态链接器所需要的基本信息,是一个结构数组,可以看做动态链接下 ELF文件的“文件头”。存储了动态链接会用到的各个表的位置等信息。
3、.dynsym节:该节与 “.symtab”节类似,但只保存了与动态链接相关的符号,很多时候,ELF文件同时拥有 .symtab 与 .dynsym节,其中 .symtab 包含 .dynsym 中的符号。该符号表中记录了动态链接符号在动态符号字符串表中的偏移,与.symtab中的记录对应。
4、.rel.dyn节:对数据引用的修正,其修正的位置位于 “.got”以及数据段。
5、.rel.plt节:对函数引用的修正,其所修正的位置位于 “.got.plt”。
6、dynstr节:该节是 .dynsym 节的辅助节,.dynstr 与 .dynsym 的关系类似 .symtab 与 .strtab的关系哈希节:在动态链接下,需要在程序运行时查找符号,为了加快符号查找过程,增加了辅助的符号哈希表,功能与 .dynstr 节类似
5.3.3 符号表
符号表保存了程序中出现的所有符号,包含定义和引用的函数、全局变量、静态变量等。
如图5.5,5.6所示:

图5.5 hello符号表(上)

图5.6 hello符号表(下)
可见hello程序的符号表也包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name。
5.3.4 程序头表
如图5.7所示:

图5.7 hello程序头表
可见程序头包含编号Type、PHDR、INTERP、LOAD、DYNAMIC、NOTE、GNU_PROPERTY、GNU_STACK、GNU_RELRO。
5.3.5 Section to Segment mapping
如图5.8所示:

图5.8 Section to Segment mapping
5.3.6 Dynamic section
如图5.9,5.10所示:

图5.9 Dynamic section(1)

图5.10 Dynamic section(2)
5.3.7 重定位节(动态链接库)
如图5.11所示:

图5.11 重定位节
包含偏移量、信息、类型、符号值、符号名称+加数字段。
5.3.8 版本信息
如图5.12所示:

图5.12 版本信息

5.4 hello的虚拟地址空间
使用edb加载hello,得到结果如图5.13所示:

图5.13 edb查看hello的空间各段信息
由图5.13可知,虚拟空间从0x400000开始。
由图5.4的hello节头部表可知,.interp段地址从0x4002e0开始,偏移量为0x2e0,大小为0x1c,对齐要求为1,故而.interp段是从地址0x4002e0开始。
如图5.14所示,查看0x4002e0处的值:

图5.14 .interp段
.text段地址从0x4010f0开始,偏移量为0x10f0,大小为0x145,对齐要求为16,故查看地址0x4010f0处,如图5.15所示:

图5.15 .text段
并且与反汇编代码中的机器码进行比较发现两者是一致的。
.rodata段的地址为0x402000,偏移量为0x2000,大小为0x3b,对其要求为8,查看地址0x402000处,如图5.16所示:

5.16 .rodata段

其中存的是printf格式串。

5.5 链接的重定位过程分析
使用命令:objdump -d -r hello>hellogirl.txt生成hello的反汇编文本文件,如图5.18所示:

图5.18 生成hello的反汇编文本文件

hello的反汇编代码如图5.19,5.20,5.21,5.22,5.23所示:

图5.19 hello反汇编代码(1)

图5.20 hello反汇编代码(2)

图5.21 hello反汇编代码(3)

图5.22 hello反汇编代码(4)

图5.23 hello反汇编代码(5)
注意以下几点:
1、hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码从0x400000开始。
2、hello.o反汇编代码直接就是.text节与main函数,而hello反汇编代码由于链接过程中重定位,加入了各种函数、数据,导致main函数的位置出现在中间部分。
3、hello.o进行跳转时引用的并非虚拟内存地址,而hello中进行跳转所引用的是ELF格式。
重定位举例:
如图5.24,5.25所示:

图5.24 hello中重定位完的语句

图5.25 重定位条目
其中:
Type = R_X86_64_PC32
ADDR(.text_main) = 0x401125;
ADDR(目标) = 0x402008;
Offset = 0x1c;
addend = -0x4
根据计算公式:
*refptr = (unsigned)(ADDR(目标)+addend - ADDR(.text_main)- Offset)
  =(unsigned)(0x402008 + -0x4 – 0x401125-0x1c)
  =(unsigned)(0xec3)
与机器代码反汇编结果中的0xec3一致。同理可得其他重定位条目计算的结果。
5.6 hello的执行流程
函数调用如下表格所示:
名称 地址
hello!_start 0x4010f0
hello_main 0x401125
hello!puts@plt 0x401090
hello!printf@plt 0x4010a0
hello!getchar@plt 0x4010b0
hello!exit@plt 0x4010d0
hello!sleep@plt 0x4010e0
hello!atoi@plt 0x4010c0
libc-2.31.so!__libc_start_main+0 0x7fb56cdb9fc0

5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器生成一条重定位记录,等待动态链接器在程序加载时解析它。为避免运行时修改调用模块的代码段,链接器采用了一种叫做延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程时。
动态链接器通过两个数据结构,即过程链接表PLT和全局偏移量表GOT来实现延迟绑定。
1、过程链接表PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。PLT[1]调用系统启动函数(_ _libc_start_main),它初始化执行环境,调用main函数并处理其返回值。从PTE[2]开始的条目调用用户代码调用的函数。
2、全局偏移量表GOT:GOT是一个数组,其中每个条目时8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
dl_init调用前,对每一条PIC函数调用,调用的目标地址实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令,如图5.26(a)所示:

(a)
在dl_init调用后,如图5.27(b)所示

(b)
可以观察到一些地方的值变了,说明发生了延迟绑定的动态链接。

5.8 本章小结
本章介绍了链接的概念与作用,完成了对hello.o的链接生成hello可执行文件,并分析了hello的虚拟地址空间,利用EDB查看了hello的虚拟地址空间,最后对链接的重定位过程,hello的执行流程和hello的动态链接进行了分析。

第6章 hello进程管理

6.1 进程的概念与作用
概念:
进程的经典定义是一个执行中程序的实例,系统的每个程序都运行在某个进程的上下文。上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存里的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
作用:
通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
[7]Shell 是一种命令行解释器, 其读取用户输入的字符串命令, 解释并且执行命令. 它是一种特殊的应用程序, 介于系统调用/库与应用程序之间, 其提供了运行其他程序的的接口.它可以是交互式的, 即读取用户输入的字符串;也可以是非交互式的, 即读取脚本文件并解释执行, 直至文件结束. 无论是在类 UNIX, Linux 系统, 还是 Windows, 有很多不同种类的 Shell: 如类 UNIX, Linux 系统上的 Bash, Zsh 等; Windows 系统上的 cmd, PowerShell 等.
[8]Bash 是 Bourne Again SHell 的缩写,是大多数Linux系统以及MacOSX默认的shell,它能运行于大多数类Unix风格的操作系统之上,甚至被移植到了MicrosoftWindows上的Cygwin系统中,以实现Windows的POSIX虚拟接口
处理流程:
第一步:用户输入命令。
第二步:shell对用户输入命令进行解析(parse),判断是否为内置(built-in)命令。
  第三步:若为内置命令,调用内置命令处理函数,否则shell将其看作可执行文件名,调用execve函数创建一个子进程来执行。
  第四步:判断是否为前台运行程序,如果是,则调用等待函数等待前台作业结束;否则将程序转入后台,直接开始下一次用户输入命令。  
第五步:shell应该接受键盘输入信号,并对信号进行相应处理

6.3 Hello的fork进程创建过程
函数原型:
pid_t fork( void);
执行过程:
通过在shell上输入./hello命令,由于这不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。
  当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程与父进程之间最大的区别在于它们拥有不同的PID,而其用户级虚拟地址空间包括代码段、数据段、堆、共享库以及用户栈都是相同的。

图6.1 fork进程创建过程示意图

6.4 Hello的execve过程
函数原型:
int execve(const char * filename,char * const argv[ ],char * const envp[ ]);
执行过程:
execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行文件filename(本例中为hello),且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序(调用一次,从不返回)。
在execve加载了filename之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数main,当main开始执行时,用户栈的组织结构类似图 6-2 的内存映像。在程序头表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口_start函数的地址,这个函数在系统目标文件ctrl.o中定义。_start函数调用系统启动函数,_libc_start_main函数定义在libc.so中,作用是初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核。

图6.2 进程虚拟内存映像

6.5 Hello的进程执行
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。多个流并发地执行的一般现象被称为并发。一个进程和其他进轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫做时间分片。
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策称为调度。
hello程序与其他进程通过内核调度,切换上下文,各自拥有时间片来实现并发。
再来具体分析下hello程序的调度情况,可以借助图6.3来帮助理解:

图6.3 上下文切换
刚开始hello程序运行在用户模式中,当hello调用sleep函数主动发出定时休眠请求时进入内核模式,内核处理该休眠请求释放当前进程,并将hello进程从运行队列转移到等待队列,然后定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程。定时器设定时间到后会发送一个中断信号,再次进入内核模式执行中断处理,将hello重新加入到运行队列,接着hello进程就可以继续在用户模式中执行了。
接着hello调用getchar函数,实际执行系统调用read。在进行read调用后进入内核模式,请求来自键盘缓冲区的DMA传输,并且在完成从键盘缓冲区到内存的数据传输后发送中断信号给处理器。再次进入内核模式执行上下文切换,切换到hello进程。

6.6 hello的异常与信号处理
6.6.1 不停乱按
如图6.4所示:

图6.4 不停乱按
如果乱按时候没有回车,那么输入屏幕的字符串都会被缓存起来;如果输入时有回车,getchar函数会读取回车并将其作为shell输入的命令。

6.6.2 Ctrl-Z
键盘输入Ctrl-Z会发送一个SIGTSTP信号给前台进程组的每个进程,结果是停止前台作业,如图6.5所示:

图6.5 Ctrl-Z

6.6.3 Ctrl-C
如图6.6所示:

图6.6 Ctrl-C
程序运行过程中输入Ctrl-C会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程。

6.6.4 Ctrl-Z后可以运行ps 、jobs 、pstree 、fg 、kill 等命令
如图6.7所示,ps命令用于显示当前进程的状态。

图6.7 Ctrl-Z后运行ps
如图6.8所示,jobs命令列出属于当前用户的进程。

图6.8 Ctrl-Z后运行jobs
如图6.9所示,fg的功能是使第一个后台作业变为前台,而第一个后台作业是刚才用SIGTSTP信号停止的hello,所以输入fg 后hello程序又在前台开始运行,并且继续刚才的进程,输出剩下的字符串。

图6.9 Ctrl-Z后运行fg
如图6.10所示,kill的功能是向指定进程发送一个信号(包括调用kill的进程自己)。

图6.10 Ctrl-Z后运行kill

6.7本章小结
本章介绍了进程的概念和作用,简要叙述了壳Shell-bash的作用与处理流程,并阐述了fork函数和execve函数在创建进程与运行进程时发挥的作用。此外主要描述了hello的进程执行,进程的上下文切换机制以及各种内核信号和命令。

第7章 hello的存储管理

7.1 hello的存储器地址空间
[9]逻辑地址:
在有地址变换功能的计算机中,访存指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。

[11]线性地址:
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层,即虚拟地址。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

[10]虚拟地址:
即线性地址。CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。

[9]物理地址:
要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
[9]一个逻辑地址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。索引号,可以理解为数组的下标——而它将会对应一个数组,即“段描述符”。段描述符具体地描述了一个段。这样,很多个段描述符,就组成了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。而Base字段,它描述了一个段的开始位置的线性地址。Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表(GDT)”中;一些局部的,例如每个进程自己的,就放在“局部段描述符表(LDT)”中。
变换的具体过程:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1等于0还是等于1,得知当前要转换是GDT中的段,还是LDT中的段。再根据相应寄存器,得到其地址和大小。我们就得到一个数组。
2、拿出段选择符中前13位,可以在这个数组中查找到对应的段描述符,这样Base(基地址)就知道了。
3、把Base + offset,就是要转换的线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)到物理地址的变换通过硬件MMU以及页表来实现。
图7.1展现了页表在虚拟地址映射到物理地址过程中所起的过渡作用:

图7.1 页表
如图7.2所示:

图7.2 虚拟地址到物理地址
在翻译过程中,虚拟地址(VA)被分为虚拟页号(VPN)和虚拟页偏移量(VPO)两部分,处理器取出VPN,通过页表基址寄存器(PTBR)定位页表条目,(有效位为1时)从页表条目中取出物理页号(PPN),通过将PPN与VPO结合,得到相应物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1 利用TLB加速地址翻译
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降到1个或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器(即TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段从虚拟地址中的虚拟页号中提取的。
当TLB命中时(通常情况),所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。

图7.3 TLB参与地址翻译

7.4.2 多级页表
用来压缩页表的常用方法是使用层次结构的页表,k级页表层次结构的地址翻译具体实现如下:
1、虚拟地址被划分成为k个VPN和1个VPO。
2、每个VPN i都是一个到第i级页表的索引,其中1≤i≤k。第j级页表中的每个PTE,1≤j≤k-1,都指向第j+1级的某个页表的基址。
3、第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。
4、对于只有一级的页表结构,PPO和VPO是相同的。
如图7.4所示:

图7.4 k级页表的地址翻译
Intel Core i7是四级页表层次结构,每个进程有它自己私有的页表层次结构。CR3控制寄存器指向第一级页表的起始位置,与之结合并利用多级页表知识可以得到目标PPN,与PPO结合得到物理地址。

7.5 三级Cache支持下的物理内存访问
将一个虚拟地址转换为物理地址请求,首先MMU去TLB中寻找其是否已有缓存,若TLB命中,则MMU直接从TLB中取出相应PTE;若TLB不命中,则MMU必须从L1缓存中取出相应PTE,若L1缓存中仍不命中,则去L2缓存中寻找,若仍不命中,再去L3缓存中寻找。这种寻找方式利用了CPU的高速缓存机制,即按照优先级顺序一级一级往下找,直到找到对应的内容。

7.6 hello进程fork时的内存映射
当fork 函数被当前进程(即shell)调用时,内核为新进程(即hello进程)创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
私有的写时复制机制如图7.5所示:

图7.5 一个私有的写时复制对象

7.7 hello进程execve时的内存映射
如图7.6所示:

图7.6 加载器是如何映射用户地址空间的区域的
execve 函数在当前进程(即shell)中加载并运行包含在可执行目标文件a.out(对应hello)中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:
1、删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件(对应hello)中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
3、映射共享区域:如果a.out程序(对应hello)与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面

7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。
图7.7展示了在缺页前页表的状态:

图7.7 VM缺页(之前)
CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘,无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中的事实。
接下来内核从磁盘复制VP3到内存中的PP3,更新PTE3随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。但是现在VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了。图7.8展示了缺页后的页表状态:

图7.8 VM缺页(之后)
图7.9展示了缺页的操作图:

图7.9 缺页的操作图

7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块。
1、显示分配器:要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。
2、隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的巳分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
动态内存分配有两种基本的空闲块组织结构:
1、带边界标签的隐式空闲链表:带边界标记的隐式空闲链表中的每个块由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成,如图7.10所示:

图7.10 带边界标签的隐式空闲链表块
我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要一种特殊标记的结束块,即一个设置了已分配位而大小为零的终止头部。
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置已分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
2、显式空闲链表:在显式空闲链表中,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如图7.11所示:

图7.11 使用双向空闲链表的堆块的格式
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
还有一种流行的减少分配时间的方法,通常称为分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫大小类。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排序。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表。

7.10本章小结
本章描述了存储器地址空间的种类,介绍了Intel逻辑地址到线性地址的变换以及hello的线性地址到物理地址的变换。整理了TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,以及hello进程fork和execve时的内存映射等相关知识。另外还叙述了缺页故障及处理以及动态存储分配管理的相关内容

第8章 hello的IO管理

8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:B0,B1,…,Bk,…,Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

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

Unix I/O函数:
1、open函数:函数声明如下:
int open(char *filename, int flags, mode_t mode);
进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,也可以是一个或更多位掩码的或,为写提供一些额外的暗示。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode & ~umask。
2、close函数:函数声明如下:
int close(int fd);
进程通过调用close函数关闭一个打开的文件。注意关闭一个已关闭的描述符会出错。
3、read函数:函数声明如下:
ssize_t read(int fd, void *buf, size_t n);
应用程序通过调用read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
4、write函数:函数声明如下:
 ssize_t write(int fd, const void *buf, size_t n);
应用程序通过调用write函数来执行输出。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
5、lseek函数:函数声明如下:
off_t lseek(int handle, off_t offset, int fromwhere);
通过调用lseek函数,应用程序能够显式地修改当前文件的位置。

8.3 printf的实现分析
[12]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以及write。
    vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
    write(buf, i)则将存在buf中的i个数据打印出来。
    从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
    字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
    显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
    8.4 getchar的实现分析
    [13]getchar函数体如下:
  10. int getchar(void)
  11. {
  12.  static char buf[BUFSIZ];  
    
  13.  static char* bb=buf;  
    
  14.  static int n=0;  
    
  15.  if(n==0)  
    
  16.  {  
    
  17.      n=read(0,buf,BUFSIZ);  
    
  18.      bb=buf;  
    
  19. }  
    
  20. return (--n>=0)?(unsigned char)*bb++:EOF;  
    
  21. }
    getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。
    getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
    异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
    getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
    8.5本章小结
    本章介绍了Linux I/O设备管理方法,将要阐述了Unix I/O接口及其函数,重点分析了printf函数和getchar函数的实现。

结论

hello的一生:
hello刚“出生”时还是一个C语言源程序,是由我们亲手编写的program。为了让这个逐步“成长”为一个可执行文件,我们早已对它的“程序生”进行了精心规划,让它按照预处理,编译,汇编,链接的顺序一步一步慢慢“成长”。其中预处理过程对带#的指令进行解析,生成hello.i文件;编译过程产生hello.s文件,即汇编语言程序;汇编过程将汇编语言转换成机器代码,生成重定位信息到这一步,生成hello.o文件;最后将hello.o与动态库链接,生成可执行文件hello。至此,hello已经从那个天真的孩子蜕变为一个朝气蓬勃的青年了。
在shell输入./hello来运行hello程序,父进程通过fork函数为hello创建进程。再通过加载器调用execve函数,删除原来的进程内容,加载hello进程的代码,并获得它自己的虚拟内存空间。运行hello时,它的好朋友内存管理单元MMU、翻译后备缓冲器TLB等等一起来帮助它完成对地址的请求(包括三级Cache,多级页表机制一同助力)。此外,得益于异常处理机制,hello对异常信号的优秀处理使得它能够和Unix I/O“意气相投”,实现与文件的交互。
当hello运行完毕,内核驱使父进程回收hello,接着删除为这个进程创建的所有数据结构。
hello的传奇一生落下帷幕。
感悟:小小的hello程序尽管简单,它一生的传奇经历也是丰富多彩:经历预处理、编译、汇编、链接等诸多过程,最后在shell中创建进程,运行,再到回收死亡,期间有操作系统、硬件与软件的共同参与。不由得让我想起那句谚语:“麻雀虽小,五脏俱全”。通过本次论文的撰写,我头一次守望着一个程序从“出生”到“死亡”的全部过程,虽然途中经历一波三折,但是最后能够看到这一个小小的程序在我的电脑上“呱呱坠地”到“大放异彩”再到“英勇就义”,也是感概万分。

附件

hello.c:源程序
hello.i:预处理后的文本文件
hello.s:编译后汇编程序文本文件
hello.o:汇编后的可重定位目标程序(二进制文件)
helloboy.txt:hello.o的反汇编文件
hellogirl.txt:hello的反汇编文件
hellobelf.txt:ELF格式下的hello.o
hellogelf.txt:ELF格式下的hello
hello:链接后的可执行目标文件

参考文献

[1]https://baike.baidu.com/item/%E9%A2%84%E7%BC%96%E8%AF%91/3191547?fr=aladdin
[2] https://zhidao.baidu.com/question/650612230884931725.html
[3]《深入理解计算机系统》p3
[4] https://blog.csdn.net/czc1997/article/details/81079498
[5] https://baike.baidu.com/item/%E7%BC%96%E8%AF%91/1258343?fr=aladdin
[6] https://blog.csdn.net/nullganbadie/article/details/100602951
[7] https://zhuanlan.zhihu.com/p/128654625
[8] https://m.iask.sina.com.cn/mib/5KI3rveE33.html
[9] https://baike.baidu.com/item/%E9%80%BB%E8%BE%91%E5%9C%B0%E5%9D%80/3283849?fr=aladdin
[10] https://baike.baidu.com/item/%E8%99%9A%E6%8B%9F%E5%9C%B0%E5%9D%80/1329947?fr=aladdin
[11] https://baike.baidu.com/item/%E7%BA%BF%E6%80%A7%E5%9C%B0%E5%9D%80/9013682?fr=aladdin
[12] https://www.cnblogs.com/pianist/p/3315801.html
[13] https://baike.baidu.com/item/getchar/919709?fr=aladdin

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值