哈工大深入理解计算机系统大作业

文章目录

摘 要

本文以hello程序的P2P过程(即From Progress to Process)为例,运用gcc,objdump,edb等工具,详细介绍了程序从最初的源代码到可执行的目标文件的整个过程,需要经历预处理、编译、汇编、链接等操作;并详细分析了运行hello程序时系统的进程管理、存储管理与I/O管理的原理与机制。

关键词:hello程序、P2P过程、编译系统、进程、系统级I/O

第1章 概述

1.1 Hello简介

P2P过程(From Program to Process):
从hello.c程序到二进制的可执行文件hello的过程。
在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的。

linux> gcc -o hello hello.c

GCC编译器驱动程序读取源程序文件hello.c,并把它翻译成一个可执行目标文件hello。这个翻译过程由四个阶段完成,如上图,执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统。

O2O过程(From Zero-0 to Zero-0):
hello进程从无到有再到无的过程。

  1. 在shell中,键入命令行执行hello
linux> ./hello 学号(参数1) 姓名(参数2)

在这里插入图片描述

shell通过fork创建一个新的进程,linux加载器execve()将程序计数器设 置为程序入口点,并为hello进程映射虚拟内存,虚拟内存机制通过mmap为 该进程规划一片空间。
2. CPU为执行文件hello分配时间周期,执行逻辑控制流,每条指令在流水线上取值、译码、执行、访存、写回、更新PC。MMU和CPU在执行过程中通过高速缓存和TLB、多级页表在物理内存中存取数据、指令,通过I/O系统输入输出。如果触发异常,则把控制返回给内核进行处理。
3. 当程序运行结束时,shell回收进程hello以及其僵死子进程。释放内存并且删除有关的上下文。

1.2 环境与工具

硬件环境:Intel Core i7-8565U x64 CPU;1.80GHz;8G RAM
软件环境:Ubuntu20.04
开发与调试工具:gcc,EDB,Hexedit,objdump,readelf,gedit

1.3 中间结果

文件名 作用
hello.c 源程序(文本)
hello.i 预处理之后的程序(文本)
hello.s 汇编语言程序(文本)
hello.o 可重定位目标程序(二进制)
hello 可执行目标程序(二进制)
hello1_asm.txt hello.o的反汇编文件
hello2_asm.txt hello的反汇编文件
hello1_elf.txt hello.o的elf头信息
hello2_elf.txt 可执行文件hello的elf头信息

1.4 本章小结

本章概述了hello从源程序到可执行程序的过程,并且结合shell执行hello进程的整个过程,介绍了开发环境以及中间文件。

第2章 预处理

2.1 预处理的概念与作用

  1. 概念:预处理(pre-treatment),在程序设计领域,一般是指程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor)对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理为特定的单位。

  2. 作用:预处理阶段读取C源程序,对其中的预处理指令(以#开头的指令)和特殊符号进行处理。或者说是扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。
    预处理过程先于编译器对源代码进行处理,读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行转换。预处理过程还会删除程序中的注释和多余的空白字符。
    预处理指令主要有以下三种:
    1)包含文件:将源文件中以#include格式包含的文件复制到编译的源文件中,可以是头文件,也可以是其它的程序文件。
    2)宏定义指令:#define指令定义一个宏,#undef指令删除一个宏定义。
    3)条件编译:根据#ifdef和#ifndef后面的条件决定需要编译的代码。

2.2在Ubuntu下预处理的命令

在这里插入图片描述

预处理命令:gcc -E hello.c -o hello.i 或者 cpp hello.c > hello.i
结果:预处理后生成文件hello.i

2.3 Hello的预处理结果解析

查看hello.i文件
在这里插入图片描述

main函数体以及全局变量sleepsecs的代码保持不变,在这之前的部分约有三千行,这是由于头文件stdio.h,unistd.h,stdlib.h依次被展开。
以stdio.h的展开为例,stdio.h是标准库文件,cpp到Ubuntu中的默认环境变量下寻找stdio.h,打开文件/usr/include/stdio.h,发现其中依然使用了#define预处理语句,cpp对stdio中的宏定义递归展开,最终hello.i文件中没有#define语句。可以发现,其中是用来大量的#ifdef / #ifndef条件编译语句,cpp会根据#ifdef和#ifndef后面的条件决定需要编译的代码。

2.4 本章小结

本章主要介绍了预处理相关的概念、作用以及方法,并结合预处理文件对预处理过程进行分析。

第3章 编译

3.1 编译的概念与作用

概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译的过程实质上是把预处理文件进行词法分析、语法分析、语义分析、优化,从C语言等高级语言转换为成机器更好理解的汇编语言程序,转换后的文件仍为ASCII文本文件。

作用:编译后生成的.s汇编语言程序文本文件比预处理文件更容易让机器理解、比.o可重定位目标文件更容易让程序员理解,是对于程序像机器指令的一步关键过程。

3.2 在Ubuntu下编译的命令

在这里插入图片描述

编译命令:gcc -S hello.c -o hello.s 或者 ccl hello.i -o hello.s
结果:生成汇编程序hello.s

3.3 Hello的编译结果解析

3.3.1数据

查看hello.c代码(如下图):
在这里插入图片描述

程序中的数据主要有以下几类:①全局变量:sleepsecs ②局部变量:整型i ;main函数的函数参数:argc, argv ③字符串常量:两个printf中的字符串 ④整型常量:汇编代码中的0、1、2、3、9等整型常量

①全局变量:由于全局变量sleepsecs被初始化,所以存放在.data节中,由于源程序中声明sleepsecs为int类型,赋初值为2.5,可以发现在汇编代码中数据变为2,小数点后的部分被截断,并规定了对齐字节为4字节。
在这里插入图片描述

②局部变量:i;main函数的参数argc,argv
1)局部变量int类型的i:主要起计数作用,存储在运行时的栈中,地址为%rbp-4
如图,首先对i变量赋初值为0
在这里插入图片描述

然后执行循环,比较i与9的大小,如果i<=9,则执行循环体,跳到.L4
在这里插入图片描述

.L4末尾对i进行加一,实现循环变量迭代
在这里插入图片描述

2)main函数的参数:int型的argc和字符型指针数组的指针argv。
从汇编代码中可以看到,argc开始存储在寄存器rdi中,比较过程中放在了栈中,帧指针为%rbp-20,argv开始存储在寄存器rsi中,使用时放在栈中,帧指针为%rbp-32
在这里插入图片描述

第三行代码与if(argc!=3)相对应,如果相等,则跳转到.L2

③字符串常量
程序中存在两个printf中的字符串,分别是"Usage: Hello 学号 姓名!\n"和
“Hello %s %s\n”,如图所示,字符串常量以uft-8格式编码并存储在.rodata段。
在这里插入图片描述

由于该文件还未进行汇编,此处仅使用符号.LC0和.LC1代表两个字符串的首地址。

④整型常量:汇编代码中的0、1、2、3、9等整型常量
代码段中的整型常量被改写为汇编语言中的立即数,作为代码段的一部分存储,上文中的0、1、2、3、9等均在此列。

3.3.2赋值

①对全局变量sleepsecs的赋值
int sleepsecs=2.5;
对应汇编代码如图,在main函数之前就进行了赋值,赋值为2,并且四字节对齐。
在这里插入图片描述

②对循环变量i的赋值
for(i=0;i<10;i++)
对应汇编代码
在这里插入图片描述

可以看到,对局部变量的赋值语句通过mov指令实现。除此之外也可以通过lea指令实现。

3.3.3类型转换

过程中对sleepsecs进行了隐式类型转换,如3.3.2图所示,要求赋值为2.5,但由于定义为int类型,所以发生了隐式类型转换,数据变为2。

3.3.4算术操作

本程序中唯一的算术运算是for循环对i的自增运算i++,在编译过程中被编译器翻译为ADD类指令,C代码与汇编代码如下所示
在这里插入图片描述

3.3.5关系操作

本程序中进行了两次关系操作:
①argc!=3
使用cmpl语句设置条件码,je语句根据条件码做出是否需要跳转的选择,如图,将argc的值与3比较,如果相等,则跳转到.L2,否则顺序执行。
在这里插入图片描述

②i<10
如图,将i的值与9进行比较,如果i小于等于9,则跳转至.L4执行循环体代码,否则继续向下执行。
在这里插入图片描述

3.3.6数组操作

程序用到的数组为argv指向的字符串指针数组,打印数组元素argv[1],argv[2]
printf(“Hello %s %s\n”,argv[1],argv[2]);
对应汇编代码如下,首先对指针argv解引用,加16后对应argv[2],将argv[2]的值取出放入寄存器rdx中,再对指针argv解引用,加8后对应argv[1],将argv[1]的值取出放入寄存器rsi中,最后将字符串常量放入寄存器rdi中,调用printf函数进行输出。
在这里插入图片描述

3.3.7控制转移

关系操作往往作为控制转移的判定表达式,本实验C代码中共有2处控制转移操作:if分支和for循环,分别对应两次关系操作。
①if(argc!=3)控制转移
如图,将argc的值与3比较,如果相等,则跳转到.L2,否则顺序执行。控制转移通过je实现。
在这里插入图片描述

②for(i=0;i<10;i++)控制转移
for循环在.L2处对循环变量i进行初始化,然后通过jmp跳转到.L3,在.L3中进行了循环终止条件的判断,如果i<=9,则跳转到循环体部分.L4,否则顺序执行。
在这里插入图片描述

3.3.8函数操作

本实验中的函数操作主要涉及以下函数:①printf ②exit ③sleep ④getchar。
①printf函数
第一处:printf("Usage: Hello 学号 姓名!\n");
printf函数在调用之前,首先将.LC0的printf格式串的地址放入寄存器rdi中进行传参,然后由于只有一个参数,编译器采取了一个小trick,将printf函数翻译为puts函数来进行输出。
在这里插入图片描述

第二处:printf("Hello %s %s\n",argv[1],argv[2]);
printf函数在调用之前,三个参数按顺序依次为.rodato节的.LC1的printf格式串的地址,argv[1],argv[2],分别存放在了寄存器rdi,rsi,rdx中。
在这里插入图片描述

②exit函数
exit函数调用之前,将立即数1存入rdi中,作为exit的第一参数进行传递。
在这里插入图片描述

③sleep函数

sleep(sleepsecs);

sleepsecs放入寄存器rdi中,作为第一参数传递并调用sleep函数。
在这里插入图片描述

④getchar函数

getchar();

由于getchar函数无参数,直接调用getchar函数如图:
在这里插入图片描述

3.4 本章小结

本章主要介绍编译操作的过程,主要将预处理后的hello.i文件编译为汇编代码文件hello.s,在此过程中,编译器将会对源文件进行语法分析、词法分析,得到汇编文件hello.s。同时,编译器还会对源代码进行保守的、有限的优化。
同时,本章中解析了变量、相关运算,以及各类C语言的基本语句的汇编表示,更便于理解高级语言的底层表示。

第4章 汇编

4.1 汇编的概念与作用

概念:汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件,包含着程序的指令编码,如果用文本编辑器打开,将看到一堆乱码。

作用:在汇编过程中,文件格式将由面向阅读友好的文本文件转化为机器可执行的二进制文件,并且将文本文件中的常量转化为对应的二进制补码,
同时,汇编过程也将生成可重定位目标文件的结构信息,Linux系统使用可执行可链接格式(ELF)对目标文件进行组织。

4.2 在Ubuntu下汇编的命令

在这里插入图片描述

汇编命令:gcc -c hello.s -o hello.o 或者as hello.s -o hello.o
结果:生成可重定位目标程序hello.o

4.3 可重定位目标elf格式

4.3.1 ELF头

使用readelf命令查看hello.o的elf格式

readelf -a hello.o > hello1_elf.txt 

在这里插入图片描述

ELF头以一个16字节的序列Magic开始,该序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位/可执行/共享)、机器类型、节头部表的文件偏移,以及节头部表条目的大小和数量。
由hello.o文件的ELF头部分可知:该文件为REL(可重定位目标文件);机器类型为AMD X86-64;节头部表的文件偏移为0;字节顺序为小端序;节头大小为64字节;节头数量为14。

在这里插入图片描述

4.3.2 节头部表

Linux系统使用可执行可链接格式(ELF)对目标文件进行组织,其具体结构及其内容如图所示:在这里插入图片描述

.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态C变量
.bss:未初始化的全局和静态C变量
.symtab:符号表
.rel.text:代码段重定位信息表
.rel.data:数据段重定位信息表
.debug:调试符号表
.line:C代码行号与机器码行号映射表
.strtab:字符串表

节头部表包含目标文件各节的语义,包括节的名称、大小、类型、地址、偏移量、是否链接、读写权限等信息。
本程序对应的节头部表如下图所示:
以.text节为例分析具体节头部表条目的含义,其大小为0x92字节;虚拟内存地址为0x0000000000000000,这是由于hello.o是可重定位目标文件,所以每个节都从0开始,用于重定位;读写权限为AX,即分配内存、可执行;相对于文件头的偏移量为0x40字节。
在这里插入图片描述

.rela.text节:一个.text节中的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.data节:已初始化的静态和全局C变量。类型为PROGBITS,意为程序数据,旗标为WA,即权限为可分配可写。
.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。类型为NOBITS,意为暂时没有存储空间,旗标为WA,即权限为可分配可写。
.rodata节:存放只读数据,例如printf中的格式串和开关语句中的跳转表。类型为PROGBITS,意为程序数据,旗标为A,即权限为可分配。
.comment节:包含版本控制信息。 .note.GNU_stack节:用来标记executable stack(可执行堆栈)。
.eh_frame节:处理异常。 .rela.eh_frame节:.eh_frame的重定位信息。
.shstrtab节:该区域包含节区名称。 .symtab节:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.strtab节:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部的节名字。

4.3.3 符号表

符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组。下图中展示了每个条目的格式:
在这里插入图片描述

.symtab节
在这里插入图片描述

符号表每个条目都对应一个符号的语义,具体包括:
①符号名称name:以整型变量存放在符号表中,是字符串表.strtab中的字节偏移,指向符号的以null结尾的字符串名字。
②符号地址value:距定义目标的节的起始位置的偏移,对于可执行目标文件来说是一个绝对的运行时地址。
③符号类型type:表明符号的类型,通常要么是数据,要么是函数。
④符号范围binding:该字段表明符号是本地的还是全局的。
⑤分配目标section:该字段是一个到节头部表的索引,表明对应符号被分配至目标文件的某个节;
有三个特殊的伪节,它们在节头部表中是没有条目的:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,即本模块中引用的外部符号;COMMON表示还未被分配位置的未初始化的数据目标。
⑥目标大小size

在上图中,全局符号sleepsecs定义的条目,它是位于.data节中偏移量为0(即value值)处的4字节目标;全局符号main定义的条目,它是位于.text节中偏移量为0(即value值)处的133字节函数。全局符号puts,exit,printf,sleep,getchar定义的条目,是未定义的符号(UND),为NOTYPE未知类型;局部符号hello.c定义的条目为文件,是不该被重定位的符号(ABS)。

4.3.4 重定位节

当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。
在这里插入图片描述

①offset:需要被修改的引用的字节偏移。
②symbol:标识被修改引用应该指向的符号。
③type:告知链接器如何修改新的引用。
④addend:一些类型的重定位要使用它对被修改引用的值做偏移调整。

其中,重定位类型(type)常见有2种:
①R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改;
②R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。

·重定位PC相对引用重定位算法如下:
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
·重定位绝对引用重定位算法如下:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend);
其中,假设算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol))表示。

本程序中的重定位节,见下图:
在这里插入图片描述

以图中第一个条目.rodata的重定位为例,设它的重定位地址为refptr。第一步,计算引用的运行时地址refaddr=ADDR(s)+ r.offset, .rodata的r.offset为0x16,ADDR(s)是由链接器确定的,所以可以计算出refaddr。第二步,更新引用,refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr), .rodata的ADDR(r.symbol)也是由链接器确定的,r.addend由上图可知为0,refaddr第一步已经算出来了,所以,可以计算出refptr,即.rodata的重定位的重定位地址。

同时我们可以用objdump结合反汇编代码和查看.rodata节分析:
objdump -d hello.o > hello_asm.txt
首先查看0x1c附近的代码,可以看到该处代码与第一个格式串相关。
在这里插入图片描述

objdump -s hello.o > hello_asm1.txt
然后,查看.rodata节的内容,.rodata-4对应第一个格式串的位置。
在这里插入图片描述

4.4 Hello.o的结果解析

4.4.1 结果对比

在shell中输入命令objdump -d hello.o > hello_asm.txt
查看反汇编文件hello_asm.txt和汇编程序hello.s,比较如下:
① 跳转语句不同
hello.s:代码直接声明具体的段存储位置,操作数为助记符如.LC0,.LC1
在这里插入图片描述

反汇编代码:计算出地址,依据地址跳转
在这里插入图片描述

②数据内容不同
hello.s:立即数为10进制格式
在这里插入图片描述

反汇编代码:立即数为16进制格式
在这里插入图片描述

③有无对应机器码
hello.s:只有汇编代码
反汇编代码:有对应的机器码
④有无重定位条目
hello.s:
调用函数时采用函数的助记符,直接声明
在这里插入图片描述

对全局变量的引用采用助记符
在这里插入图片描述

反汇编:生成重定位的条目,在链接时计算运行时的内存地址,然后分配给每一条引用,保证每一条引用最终都能指向正确的地址。
在这里插入图片描述

4.4.2 机器语言的构成及与汇编语言的映射关系

机器语言的构成:机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指指令系统令的集合。以Y86- 64指令集为例(如下图)。指令编码长度从1个字节到10个字节不等。一条指令含有一个单字节的指令指示符,可能含有一个单字节的寄存器指示符,还可能含有一个8字节的常数字。字段fn指明是某个整数操作(OPq)、数据传送条件(cmovXX)或是分支条件(jXX)。所有的数值都
用十六进制表示。
下图展示了机器语言与汇编语言直接的映射关系:

在这个图中,左边是指令的汇编码表示,右边是字节编码。

4.5 本章小结

本章介绍了汇编的整个过程,从汇编语言到机器码,重点关注了生成文件hello.o可重定位这一特性,并且通过objdump反汇编得到的代码与hello.s进行比较,了解了其中的映射机制以及两者之间的区别。

第5章 链接

5.1 链接的概念与作用

  1. 概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
  2. 作用:把可重定位目标文件和命令行参数作为输入,产生一个完全链接的,可以加载运行的可执行目标文件,使得分离编译成为可能。

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

结果:生成可执行目标文件hello

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

在这里插入图片描述

查看hello的ELF格式
readelf -a hello > hello2_elf.txt

5.3.1 ELF头

ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table) 的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
查看ELF头:在这里插入图片描述

hello.o的ELF以一个16进制序列:
  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00作为ELF头的开头。这个序列描述了生成该文件的系统的字的大小为8字节和字节顺序为小端序。
  ELF头的大小为64字节;目标文件的类型为EXEC(可执行文件);机器类型为AMD X86-64;程序头条目数量为12、大小为56字节;节头条目数量为27、大小为64字节。

5.3.2 节头部表

节头部表如下图:与hello.o的节头部表作对比,可以得出:①hello的节头部表增加了若干条目,下图中一共有26个节的信息,而hello.o的节头部表中只有13个节的信息。②所有节被分配了运行时的地址。可以看到下图中某些节地址有所不同,而hello.o的节头部表中所有节的Address为全0。
在这里插入图片描述

5.3.3 程序头表

可执行文件或共享目标文件的程序头表是一个结构数组。每种结构都描述了系统准备程序执行所需的段或其他信息。

typedef struct {
    
    
        Elf64_Word      p_type;
        Elf64_Word      p_flags;
        Elf64_Off       p_offset;
        Elf64_Addr      p_vaddr;
        Elf64_Addr      p_paddr;
        Elf64_Xword     p_filesz;
        Elf64_Xword     p_memsz;
        Elf64_Xword     p_align;
} Elf64_Phdr;

p_type:此数组元素描述的段类型或解释此数组元素的信息的方式。表 13-1 中指定了类型值及其含义。
p_offset:相对段的第一个字节所在文件的起始位置的偏移。
p_vaddr:段的第一个字节在内存中的虚拟地址。
p_paddr:段在与物理寻址相关的系统中的物理地址。由于此系统忽略了应用程序的物理地址,因此该成员对于可执行文件和共享目标文件具有未指定的内容。
p_filesz:段的文件映像中的字节数,可以为零。
p_memsz:段的内存映像中的字节数,可以为零。
p_flags:与段相关的标志。表 13-2 中指定了类型值及其含义。
p_align:可装入的进程段必须具有 p_vaddr 和 p_offset 的同余值(以页面大小为模数)。此成员可提供一个值,用于在内存和文件中根据该值对齐各段。值 0 和 1 表示无需对齐。另外,p_align 应为 2 的正整数幂,并且 p_vaddr 应等于 p_offset(以 p_align 为模数)。

查看程序头表如下:
在这里插入图片描述

5.3.4 重定位节

基本概念与4.3.4节一致
在这里插入图片描述

5.3.5 符号表

如下图所示,hello程序的符号表包含编号Num、Value、Size、Type、Bind、Vis、Ndx、Name字段。其含义可以参照4.3.3内容,在此不做赘述。
可以看到,可执行目标文件的符号表表项数目(51 entries)明显多于可重定位目标文件的表项数目(18 entries)。一方面,可执行目标文件中加入了与调试、加载、动态链接相关的节,使得表示节的符号数增多;另一方面,由于链接器对可重定位目标文件中的符号进行了进一步解析,加入了若干系统调用。
在这里插入图片描述

5.3.6 动态符号表

动态符号表 (.dynsym) 用来保存与动态链接相关的导入导出符号,不包括模块内部的符号。而 .symtab 则保存所有符号,包括 .dynsym 中的符号。
在这里插入图片描述

5.4 hello的虚拟地址空间

使用edb加载hello,由图知虚拟空间从0x400000开始。

在这里插入图片描述

以.text节和.rodata节为例
在这里插入图片描述

由节头部表知.text节起始地址为0x4010d0
在这里插入图片描述

结束于内存地址0x401215处

在这里插入图片描述

由节头部表知.rodata节起始地址为0x402000,结束于地址0x40202f

5.5 链接的重定位过程分析

objdump -d hello > hello2_asm.txt 反汇编hello文件

5.5.1 hello.o与hello区别:

①虚拟地址不同
hello.o:反汇编代码虚拟地址从0开始
在这里插入图片描述

hello:反汇编代码虚拟地址从0x400000开始
在这里插入图片描述

②反汇编节数不同
hello.o:只有.text节,其中只有main函数的反汇编代码
在这里插入图片描述

hello:在main函数之前填充有链接过程中重定位而加入进来各种函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。
在这里插入图片描述

③call函数跳转地址,引用全局变量地址不同
hello.o:
在这里插入图片描述

hello
在这里插入图片描述

不过注意到,相对地址没有改变。

5.5.2 hello重定位地址计算

重定位地址计算伪代码如下图所示:
·重定位PC相对引用重定位算法如下:
在这里插入图片描述

refaddr = ADDR(s) + r.offset;

*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);

·重定位绝对引用重定位算法如下:

*refptr = (unsigned) (ADDR(r.symbol) + r.addend);

其中,假设算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol))表示。

5.6 hello的执行流程

本小节中使用edb执行hello,下面列出了hello从加载到程序终止的所有调用与跳转的函数名及其运行时地址
函数名 运行时地址
ld-2.27.so!_dl_start 0x00007f294d8086c0
ld-2.27.so!_dl_init 0x00007f294d808c50
hello!_start 0x400550
hello!init 0x401000
hello_main 0x401105
hello!puts@plt 0x401080
hello!exit@plt 0x4010b0
hello!printf@plt 0x401090
hello!sleep@plt 0x404044
hello!getchar@plt 0x404028
sleep@plt 0x4010c0

5.7 Hello的动态链接分析

动态链接项目中,查看dl_init前后项目变化。对于动态共享链接库中PIC函数,编译器加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略,将过程地址的绑定推迟到第一次调用该过程。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
GOT运行时地址为0x403ff0,PLT的运行时地址为0x404000。
在这里插入图片描述

在程序调用dl_init前,使用edb查看地址0x404000处的内容,如下所示:
在这里插入图片描述

在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令。
调用前:
在这里插入图片描述

调用后:
在这里插入图片描述

在dl_init调用前后, 0x6008b0和0x6008c0处的两个8字节的数据分别发生改变。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址, GOT[2]是动态链接器ld-linux.so模块中的入口点。
  在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问时,GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。

5.8 本章小结

本章首先介绍了链接的概念及作用,详细分析了可执行目标文件hello的ELF格式,并且通过edb调试查看其虚拟地址空间,并分析了重定位过程、执行流程和整个动态链接过程。

第6章 hello进程管理

6.1 进程的概念与作用

  1. 概念:进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。
  2. 作用:进程提供给应用程序关键抽象:
    ①一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。②一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。

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

1.shell的定义
shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
2.shell的作用
实际上shell是一个命令解释器,它解释由用户输入的命令并且把它们送到
内核。不仅如此,shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的shell程序与其他应用程序具有同样的效果。
3.shell的处理流程
shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程

终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
我们在shell上输入./hello,由于这不是一个内置的shell命令,所以shell会认为hello是一个可执行目标文件,通过调用某个驻留在存储器中被称为加载器的操作系统代码来运行它。

6.4 Hello的execve过程

在这里插入图片描述

execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开所有文件描述符。
hello的execve过程可以总结为以下几个步骤:删除已存在的用户区域;映射私有区域;映射共享区域;设置程序计数器。
hello加载并运行后栈的结构如下:在这里插入图片描述

6.5 Hello的进程执行

上下文切换:操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在较低层异常机制之上的。内核为每个进程维持-一个.上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
调度:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。
hello程序与操作系统其他进程通过操作系统的调度,切换上下文,拥有各自的时间片从而实现并发运行。hello在调用sleep函数时做了上下文切换。
在这里插入图片描述

hello初始运行在用户模式中,直到它通过执行系统调用sleep陷入到内核。内核处理休眠请求主动释放当前进程(hello),同时计时器开始计时,内核进行如上图所示的上下文切换,将当前进程的控制权交给其他进程,当进程达到sleep_secs的时间时,给其他进程发送中断信号,触发中断异常处理子程序,将hello进程从等待队列中移出,重新加入到运行队列。

6.6 hello的异常与信号处理

6.6.1 异常处理与信号

异常处理可以分为四类,如下表:
在这里插入图片描述

一种更高层的软件形式的异常,称为Linux信号,它允许进程和内核中断其他进程。一个信号就是一条小消息, 它通知进程系统中发生了一个某种类型的事件。
下图展示了Linux 系统上支持的30种不同类型的信号。
在这里插入图片描述

6.6.2 Hello异常分析

hello执行过程中进行如下操作:
①键盘随机按键:如果按键过程中没有回车键,会把输入屏幕的字符串缓存起来;如果按键过程中有回车键,则当程序运行完成后,缓存区中的换行符前的字符串会被shell当作指令执行。
在这里插入图片描述

②按Ctrl-Z键
输入Ctrl-Z键会发送一个SIGTSTP信号给前台进程组的每一个进程,故hello进程停止。
在这里插入图片描述

运行ps命令:显示当前进程的状态
运行jobs命令:用于显示Linux中的任务列表及任务状态,包括后台运行的任务。
运行fg命令:用于将后台作业(在后台运行的或者在后台挂起的作业)放到前台终端运行。由于后台作业只有hello,于是hello被转到前台运行,继续循环输出字符串。
在这里插入图片描述

pstree命令:将所有行程以树状图显示
在这里插入图片描述

kill命令
kill -9 <进程号> :杀死对应进程
在这里插入图片描述

②按Ctrl-C键
在这里插入图片描述

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

6.7本章小结

本章介绍了进程的定义与作用,对hello被加载、执行的过程进行分析,同时介绍shell的一般处理流程和作用,并且着重分析了调用fork 函数创建新进程,调用execve函数加载并执行hello,以及hello的异常与信号处理。

第7章 hello的存储管理

7.1 hello的存储器地址空间

(1)逻辑地址:
在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
(2)物理地址:
在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。
(3)虚拟地址:
 CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
(4)线性地址:
 线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

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

7.2.1 CPU的段寄存器:

在CPU中,跟段有关的CPU寄存器一共有6个:cs,ss,ds,es,fs,gs,它们保存的是段选择符(或者叫段描述符)。而同时这六个寄存器每个都有一个对应的非编程寄存器,它们对应的非编程寄存器中保存的是段描述符。系统可以把同一个寄存器用于不同的目的,方法是先将其寄存器中的值保存到内存中,之后恢复。而在系统中最主要的是cs,ds,ss这三个寄存器。

7.2.2 段描述符

段描述符就是保存在全局描述符表或者局部描述符表中,当某个段寄存器试图通过自己的段选择符获取对于的段描述符时,会将获取到的段描述符放到自己的非编程寄存器中,这样就不用每次访问段都要跑到内存中的段描述符表中获取。

7.2.3 全局描述符表与局部描述符表

全局描述符表和局部描述符表保存的都是段描述符,记住要把段描述符和段选择符区别开来,保存在寄存器中的是段选择符,这个段选择符会到描述符表中获取对于的段描述符,然后将段描述符保存到对应寄存器的非编程寄存器中。
系统中每个CPU有属于自己的一个全局描述符表(GDT),其所在内存的基地址和其大小一起保存在CPU的gdtr寄存器中。其大小为64K,一共可保存8192个段描述符,不过第一个一般都会置空,也就是能保存8191个段描述符。第一个置空的原因是防止加电后段寄存器未经初始化就进入保护模式而使用GDT。
而对于局部描述符表,CPU设定是每个进程可以创建属于自己的局部描述符表(LDT),当前被使用的LDT的基地址和大小一起保存在ldtr寄存器中。不过大多数用户态的liunx程序都不使用局部描述符表,所以linux内核只定义了一个缺省的LDT供大多数进程共享。描述这个局部描述符表的局部描述符表描述符保存在GDT中。

7.2.4 分段机制将逻辑地址转化为线性地址的步骤:

1)使用段选择符中的偏移值(段索引)在GDT或LDT表中定位相应的段描述符。(仅当一个新的段选择符加载到段寄存器中是才需要这一步)
2)利用段选择符检验段的访问权限和范围,以确保该段可访问。
3)把段描述符中取到的段基地址加到偏移量(也就是上述汇编语言汇中直接出现的操作地址)上,最后形成一个线性地址。
在这里插入图片描述

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

Linux采用了分页的方式来记录对应关系。所谓的分页,就是以更大尺寸的单位页来管理内存。在Linux中,通常每页大小为4KB。CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register, PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offsetm, VPO)和一个(n-p)位的虚拟页号(Virtual Page Number, VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN 1选择PTE 1,以此类推。将页表条目中物理页号(Physical Page Number, PPN) 和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移( Physical Page Offset,PPO)和VPO是相同的。下图展示了从虚拟地址到物理地址的基于页表的翻译过程:
在这里插入图片描述

下图展示了当页命中时,CPU硬件执行的步骤:
在这里插入图片描述

第1步:处理器生成一个虚拟地址,并把它传送给MMU。
第2步: MMU生成PTE地址,并从高速缓存/主存请求得到它。
第3步:高速缓存/主存向MMU返回PTE。
第4步:MMU构造物理地址,并把它传送给高速缓存/主存。
第5步:高速缓存/主存返回所请求的数据字给处理器
用来压缩页表的常用方法为使用层次结构的页表:
以二级页表为例:
第一级页表: 每个 PTE 指向一个页表 (常驻内存)
第二级页表: 每个 PTE 指向一页
在这里插入图片描述

下图描述了使用k级页表层次结构的地址翻译:
在这里插入图片描述

虚拟地址被划分成为k个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引,其中1≤i≤k。第j级页表中的每个PTE, 1≤j≤k-1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN。之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。访问k个PTE,第一眼看上去昂贵而不切实际。然而,这里TLB能够起作用,正是通过将不同层次,上页表的PTE缓存起来。实际上,带多级页表的地址翻译并不比单级页表慢很多。

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

7.4.1 利用TLB加速地址翻译

正如我们看到的,每次CPU产生一个虛拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会要求从内存多取一次数据,代价是几十到几百个周期。
如果PTE碰巧缓存在L1中,那么开销就下降到1个或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小
的缓存,称为翻译后备缓冲器(Translation Lookaside Buffer,TLB)。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。如图9-15 所示,用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
在这里插入图片描述

TLB具有如下特征:
MMU中一个小的相联存储设备
实现虚拟页码向物理页码的映射
对于页码数很少的页表可以完全包含在TLB中

上图展示了当TLB命中时(通常情况)所包括的步骤。这里的关键点是,所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。
第1步:CPU产生一个虚拟地址。
第2步和第3步: MMU从TLB中取出相应的PTE。
第4步:MMU将这个虚拟地址翻译成–个物理地址,并且将它发送到高速缓存/主存。
第5步:高速缓存/主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,如上图所示。新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。

7.4.2 单级页表的局限性

在32位系统中,地址空间有32位,假设每个页面大小为4KB,每个PTE大小为4字节,那么即使所引用的只是虚拟地址空间中很小的一部分,也总是需要一个4MB的页表驻留在内存中,对于地址空间为64位的系统而言,问题将变得更加复杂。
为解决上述问题,我们使用层次结构的页表来对其空间进行压缩,其主要思想为:将页表构建出层次结构,高级页表中存储低级页表的低质,最底层页表存储相应的物理内存地址。

7.4.3 四级页表支持下的VA到PA的变换

Linux为了在更高层次提供抽像,为每个CPU提供统一的界面。提供了一个四层页管理架构,来兼容这些二级、三级、四级管理架构的CPU。
在这里插入图片描述

这四级分别为:
①页全局目录PGD(对应刚才的页目录)
②页上级目录PUD(新引进的)
③页中间目录PMD(也就新引进的)
④页表PT(对应刚才的页表)。
在这里插入图片描述

具体的翻译步骤参考7.4.3节k级页表层次结构的地址翻译,不再重复叙述。

其一级、二级、三级PTE格式如下所示:
在这里插入图片描述

四级PTE格式如下所示:
在这里插入图片描述

每次对一个页进行了写之后,MMU都会设置D位,又称修改位或脏位。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位和修改位。

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

对于一个虚拟地址请求,CPU首先将去TLB寻找,看是否已经在TLB中缓存。如果命中的话就直接MMU获取,没有命中的话就先在结合多级页表,得到物理地址PA,L1 Cache对PA进行分解,将其分解为标记(CT)、组索引(CI)、块偏移(CO),检测物理地址是否L1 cache命中,若命中,则直接将PA对应的数据内容取出返回给CPU,若不命中则在下一级中寻找,并重复L1 cache中的操作。CPU的高速缓存机制具体过程图如下:
在这里插入图片描述

7.6 hello进程fork时的内存映射

在这里插入图片描述
在这里插入图片描述

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

7.7 hello进程execve时的内存映射

execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开所有文件描述符。
hello的execve过程可以总结为以下几个步骤:删除已存在的用户区域;映射私有区域;映射共享区域;设置程序计数器。
hello加载并运行后栈的结构如下:
在这里插入图片描述

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

7.8.1 缺页故障
虚拟内存中的字不在物理内存中 (DRAM 缓存不命中)即缺页。下图中展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。
在这里插入图片描述

7.8.2 Linux缺页处理
假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
在这里插入图片描述

①判断虚拟地址A是否合法,即A是否在某个区域结构定义的区域内。
缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_ start 和vm_ end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。
②判断试图进行的内存访问是否合法。即进程是否有读、写或者执行这个区域内页面的权限。例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。
③如果不是上述两点,则内核知道这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。

7.9动态存储分配管理

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

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

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

对于带边界标签的隐式空闲链表分配器,一个块是由一个字的头部、有效载
荷、可能的一些额外的填充,以及在块的结尾处的一个字的脚部组成的。
在这里插入图片描述

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

7.9.3 显式空间链表的基本原理

因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
在这里插入图片描述

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

7.9.4 分离空闲链表的基本原理

分离空闲链表的核心思想是分离存储,即维护多个空闲链表,其中每个链表的块有大致相等的大小,实现有两种基本方法:简单分离存储和分离适配。C语言的malloc函数实现方法介绍显示空闲链表加分离适配。
在这里插入图片描述

7.10本章小结

在本章中整理了有关内存管理的知识,介绍了四种地址空间,以及intel环境下的段式管理和页式管理,同时以Intel i7处理器为例,介绍了基于四级页表、三级cache的虚拟地址空间到物理地址的转换,阐述了fork和exceve的内存映射,并介绍缺页故障和缺页中断管理机制。

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm
所有的IO设备(如网络、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
Cool fact: 所有的I/O设备(网络、磁盘、终端)都被模型化为文件:
/dev/sda2(用户磁盘分区)
/dev/tty2(终端)
甚至内核也被映射为文件:
/boot/vmlinuz-3.13.0-55-generic(内核映像)
/proc (内核数据结构)
设备的模型化:文件
设备管理:unix io接口

8.2 简述Unix IO接口及其函数

8.2.1 Unix I/O接口

①打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息
②Shell创建的每个进程都有三个打开的文件:
标准输入、标准输出、标准错误。
③改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k
④读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k
⑤关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去

8.2.2 Unix I/O 函数

①open函数
进程通过调用open函数来打开一个已存在的文件或者创建一个新文件:
在这里插入图片描述

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件:
O_ RDONLY:只读。
O_ WRONLY:只写。
O
RDWR:可读可写。

②close函数
进程通过调用close函数关闭一个打开的文件。
在这里插入图片描述

③read和write函数
在这里插入图片描述

read函数从描述符为fd 的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

8.3 printf的实现分析

查看printf函数的函数体:

int printf(const char *fmt, ...)
{
    
    
    int i;
    char buf[256];
    va_list arg = (va_list)((char*)(&fmt) + 4);
    i = vsprintf(buf, fmt, arg);
    write(buf, i);
    return i;
}

printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。printf用了两个外部函数,一个是vsprintf,还有一个是write。
vsprintf函数作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write函数将buf中的i个元素写到终端。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

查看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有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。

结论

程序从源代码hello.c到可执行文件hello需要经历编译系统处理的过程,包括预处理、编译、汇编、链接的过程。首先预处理过程预处理器cpp修改源程序hello.c为hello.i,读取头文件内容并插入到程序文本中;编译过程编译器cc1将hello.i翻译为汇编语言程序hello.s;汇编阶段汇编器as将hello.s翻译成机器语言指令,打包成可重定位目标程序hello.o;最后链接器ld将所有可重定位目标程序合并,得到可执行目标文件hello。
我们在终端运行hello程序时,shell首先调用fork函数创建子进程,其地址空间与shell父进程完全相同;并调用execve函数在当前进程的上下文中加载并运行hello程序;之后调用hello程序的main函数,hello程序开始在一个进程的上下文中运行;期间,MMU,TLB,多级页表机制,三级cache共同完成对地址的请求;异常处理机制保证了hello对异常信号的处理,使程序平稳运行;Unix I/O让程序能够与文件进行交互。最后,hello运行完毕,shell回收子进程,内核会删除这个进程使用所需要创建的一系列数据结构。至此,hello程序运行结束。
hello的一生,从源码到可执行文件,从执行再到运行结束,经历了复杂的过程,需要操作系统、硬件与软件的参与;通过学习这门课,我深深感受到了计算机系统的复杂和奥妙,同时对内部实现的有了更多了解,能更好地编写系统性能优良的代码以及规避掉一些由底层机制带来的bug。

附件

文件名 作用 hello.c 源程序(文本)
hello.i 预处理之后的程序(文本)
hello.s 汇编语言程序(文本)
hello.o 可重定位目标程序(二进制)
hello 可执行目标程序(二进制)
hello1_asm.txt hello.o的反汇编文件
hello2_asm.txt hello的反汇编文件
hello1_elf.txt hello.o的elf头信息
hello2_elf.txt 可执行文件hello的elf头信息

参考文献

[1] Randal E.Bryant / David O’Hallaron. 深入理解计算机系统(原书第3版)[M]. 机械工业出版社,2016:1-87.
[2] 段页式存储管理方式详解
https://blog.csdn.net/low5252/article/details/106075945

猜你喜欢

转载自blog.csdn.net/weixin_45577864/article/details/118197347
今日推荐