本文主要阐述hello程序在Linux系统的生命周期。探讨hello.c程序经过预处理、编译、汇编、链接生成可执行文件的全过程。并在其中结合虚拟内存,IO设备管理,shell,异常和信号处理,进程,动态内存管理等内容分析了hello程序的一生。
关键词:预处理;编译;汇编;链接;IO设备管理;进程;虚拟内存;异常和信号处理;生命周期
目 录
第1章 概述
1.1 Hello简介
P2P:在Linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生一个子进程,然后hello便从程序变为了进程。
020: shell调用execve函数为该子进程映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,内核将子进程的返回状态返回给父进程并回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:Intel Core i7-6700HQ x64CPU,16G RAM,256G SSD +1T HDD.
软件环境:VirtualBox版本 6.1.18 r142142 (Qt5.6.2) Ubuntu 20.04.2 LTS
开发与调试工具:vim,gcc,as,ld,edb,readelf,HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件的名字 | 文件的作用 |
hello.i | hello.c经过预处理得到的文本文件 |
hello.s | hello.i经过编译得到的汇编文件 |
hello.o | hello.s经过汇编得到的可重定位目标文件 |
hello | hello.o经过链接得到的可执行目标文件 |
hello.elf | hello.o的elf格式文件 |
Hello.elf | hello的elf格式文件 |
hello.txt | 存储hello的反汇编代码的文本文件 |
1.4 本章小结
本章介绍了hello的p2p,020过程,环境与工具,以及编写本论文中用到的中间结果及其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理:预处理器(cpp)根据以字符#开头的命令(宏定义、条件编译),修改原始C程序,加载头文件,将引用的所有库展开合并为一个文本文件,进行宏替换,完成条件编译。
作用:
- 处理宏定义
#define 常用来定义常量和字符串常量。例如#define PI 3.14159,#define FILE_PATH C:\csapp\cpp.txt
#undef 常用来撤销宏定义,宏常量的生命周期从#define开始到#undef结束。
- 处理头文件
将多个源文件连接成一个源文件进行编译,结果就生成一个目标文件(obj)
#include 用尖括号括起来的头文件一般都是系统自带的,表示系统将在指定的路径进行寻找。
#include “ xx.h” 双引号一般则用于我们自己编写的头文件,系统也会优先在当前目录中查找。如果找不到指定文件名的文件就会和一样在指定的路径进行寻找。
- 条件编译
#ifdef,#ifndef,#else,#elif,#endif 我们可以按照不同的条件去编译不同的部分,这对程序的移植和调试有着巨大的帮助。
- #error预处理
#error预处理用来提示错误。编译程序时如果遇到#error就会生成一个编译错误提示信息并停止编译。关于提示的错误信息都是系统定义好的。
- 处理特殊符号
预编译程序可以识别一些特殊的符号。
例如:
_LINE_:正在编译的文件的行号
_FILE_:正在编译的文件的名字
_DATE_:编译时刻的日期字符串
_TIME_:编译时刻的时间字符串
_STDC_:判断该程序是否为标准的c程序
2.2在Ubuntu下预处理的命令
在Linux系统下执行命令:gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
hello.c经过预处理之后转化为hello.i文件。打开该文件我们可以发现文件内容大幅度增加且仍可被以文本格式打开。main函数中的内容没有变化,注释被删除,预处理器(cpp)对原文件中的宏进行了宏展开,例如声明函数、定义结构体、定义变量、定义宏等内容。相关的头文件中的内容被添加进该文件中。
2.4 本章小结
本章介绍了预处理的定义及其作用,将hello.c通过预处理器(cpp)转换为hello.i并根据实例对该过程进行了解析。
第3章 编译
3.1 编译的概念与作用
编译:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言表示的目标程序作为输出。这个过程称为编译。
作用:编译过程是指对预处理生成的.i文件进行一系列操作:1.扫描(词法分析),2.语法分析,3.语义分析,4.源代码优化(中间语言生成),5.代码生成,目标代码优化。最后优化后生成相应的汇编代码文件。
(1)词法分析:将字符串转化为内部表示结构。
(2)语法分析:将上一步分析得到的标记流转化生成为一棵语法树。
(3)语义分析:指示判断是否合法,并不判断对错。
(4)中间语言生成:编译器前端产生与机器(或环境)无关的中间代码。
(5)编译器后端:代码生成:依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等。目标代码优化:选择合适的寻址方式,左移右移代替乘除,删除多余指令。
3.2 在Ubuntu下编译的命令
在Linux系统下执行命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 汇编指令介绍
.file:声明源文件
.text:代码节
.section:
.rodata:只读代码段
.align:数据或者指令的地址对齐方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据还是函数
3.3.2 数据
1.字符串
程序中有两个字符串分别为.LC0 .LC1,这两个字符串都在只读数据段中
这两个字符串作为printf函数的参数。
2.局部变量
对应与原文件main函数中的局部变量i,i存储在栈上,对应与%rbp-4的位置。
3.main函数
参数 argc 作为用户传给main的参数。也是被放到了堆栈中。
4.立即数
直接在汇编代码中表示,如 中的3。
5.数组
char *argv[]是mian函数的第二个参数,数组的每个元素都是一个指向字符类型的指针。
对应与main函数中for循环内容。
数组的起始地址存放在栈中,即%rbp-32的位置
argv[1]和argv[2]分别存放在%rsi和%rdx。
3.3.3 赋值操作
main函数中的赋值操作为 i=1 对应于汇编代码中 ,mov指令有两个操作数,分别为源操作数和目的操作数,mov指令的功能是将原操作数传送到目的操作数。源操作数可以是立即数、寄存器和内存引用,目的操作数可以是寄存器和内存引用,但源操作数和目的操作数不能同时为内存引用。同时根据传送数据大小可以分为4类:
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
3.3.4 算术操作
main函数中的算术操作为i++对应于汇编代码中
算数操作有以下几类
指令 | 作用 |
leaq S D | D = &S |
INC D | D += 1 |
DEC D | D -= 1 |
NEG D | D = -D |
ADD S D | D = D + S |
SUB S D | D = D - S |
3.3.5 关系操作
main函数中的 argc!=3对应于 将argc与3做差,同时设置条件码,以便后续跳转指令判断。
main函数中的i<10对应于 将i与9做差,同时设置条件码,以便后续跳转指令判断。
3.3.6 控制转移
对应于
判断argc是否等于3 如果判断成功则跳转到.L2,否则顺序执行。
对应于
判断i是否小于等于9(即小于10)如果判断成功则跳转到.L4,否则顺序执行。
3.3.7 函数操作
第一个printf转换成了puts,把.L0段的立即值传入%rdi,然后call跳转到puts。第二个printf有三个参数,第一个是.LC1中的格式化字符串%eax中,后面的两个依次是%rdi,%rsi,然后跳转到printf。exit 有一个参数1传送到%edi中,之后call跳转到exit中。sleep有一个参数传到%edi中,之后call跳转到 sleep中。getchar无参数,直接call跳转。
返回值:函数的返回值一般在寄存器%eax中。
3.3.8 类型转换
全局变量sleepsecs是int类型的,赋值语句int sleepsecs=2.5;将浮点数赋值给整型数据,这里有一个隐式的类型转换,2.5会转化为2并赋值给sleepsecs。
3.4 本章小结
本章介绍了编译的概念及作用,并根据hello.s实例对C语言中的变量和操作进行分析。
第4章 汇编
4.1 汇编的概念与作用
汇编:汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o 文件是一个二进制文件,它包含程序的指令编码。
作用:将汇编代码转换为机器指令,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
在Linux系统下执行命令:gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
在linux下生成hello.o文件elf格式的命令:readelf -a hello.o > hello.elf
生成了hello.elf文件
ELF头:ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
节头部表:记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
符号表:.symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
重定位节:.rela.text,保存的是.text节中需要被修正的信息。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改,另一方面,调用本地函数的指令则不需要修改。可执行目标文件中不需要定位信息,因此通常被省略。.rela.eh_frame是eh_frame节的重定位信息。本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep和.rodata中的.L0和.L1。
4.4 Hello.o的结果解析
执行objdump -d -r hello.o 生成的反汇编代码
通过对比发现以下差别:
分支转移:在反汇编文件中,分支的转移使用的是确定的地址,表现为main函数基址+偏移量(PC相对寻址)。在汇编文件中则是跳转段名称入.L2.由于
函数调用:在反汇编文件中,函数的调用时call加上一个确定的地址,表现为main函数基址+偏移量(PC相对寻址)定位到下一条指令的地址。而在汇编文件中函数调用之间调用函数名。
全局变量:在访问全局变量的时候汇编代码中使用.LC0(%rip),反汇编代码中为0x0(%rip),因数据地址是在链接后确定的,所以初始化为0并添加重定位条目。
4.5 本章小结
本章介绍了汇编的概念和作用,并将hello.s汇编生成为hello.o可重定位目标文件。同时利用readelf分析了该可重定位目标文件的ELF头、节头部表、符号表和可重定位节。同时分析了反汇编文件和汇编文件的不同。
第5章 链接
5.1 链接的概念与作用
链接:链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是在由应用程序来执行。链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能。
作用:我们可以将一个大型的应用程序分解成小的好管理的模块,并且可以独立地修改和编译这些模块,当我们修改其中的模块时,只需要重新编译并将其链接而不必重新编译整个项目。
5.2 在Ubuntu下链接的命令
在Linux系统下执行命令: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.3 可执行目标文件hello的格式
在Linux系统下执行命令:readelf -a hello > Hello.elf
ELF头: 区别在于hello的类型为EXEC(可执行文件) 节的数量为27
节头部表:对 hello中所有的节信息进行了声明,其中包括大小以及在程序中的偏移量,因此根据节头部表中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中地址是程序被载入到虚拟地址的起始地址。
符号表:.symtab
重定位节:.rela.dyn .rela.plt
5.4 hello的虚拟地址空间
使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的状况,并查看各段信息。
在0x400000~0x401000段中,程序开始于0x400000(虚拟地址),结束于0x400ff0。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
在Linux系统下执行命令:objdump -d -r hello > hello.txt
hello中的反汇编代码有了实际的虚拟地址,不再是默认地址0x000000,已经完成重定位。
hello中的反汇编代码中多了很多原本没有的节和函数。
该反汇编代码中有.init .plt .tetx .plt.sec和.fini节
.init:程序初始化
.plt:动态链接表
.fini:程序终止时需要的执行的指令
节中也包含一些函数:
puts
printf
getchar
exit
sleep
重定位过程:分为两种,绝对寻址和PC相对寻址。
以puts为例:其addend = -4,偏移量为0x21
puts实际地址为0x401080 main函数起始地址为0x401105
故引用的位置 = 0x401105 + 0x21 = 0x401126
0x401080 + (-4)- 0x401126 = -170
即0xffffff56(小端法) 故符合下图
5.6 hello的执行流程
(1)载入:_dl_start、_dl_init
(2)开始执行:_start、_libc_start_main
(3)执行main:_main、_printf、_exit、_sleep、
_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
(4)退出:exit
程序名称 | 地址 |
ld-2.27.so!_dl_start | 0x00007fd27e67a254 |
ld-2.27.so!_dl_init | 0x00007fd27e65493e |
hello!_start | 0x0000000000400489 |
lib-2.27.so! __libc_start_main | 0x00007fd27e67a254 |
hello!puts@plt | 0x0000000000400468 |
hello!exit@plt | 0x0000000000400463 |
5.7 Hello的动态链接分析
Hello在链接过程中首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。然后当加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。动态链接在调用函数时才进行符号的映射。使用偏移量表GOT和过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。
在elf文件中我们找到了.got.plt的地址
未调用init的.got.plt
调用init之后的.got.plt
可以看到从.got.plt条目中00 00 00 00 00 00 00 00 00 00 00 00 00变成了90 41 8c 38 7f 00 00 e0 da 8a 38 83 7f。
5.8 本章小结
本章介绍了链接的概念和作用,并分析了hello的elf格式文件的信息,分析了重定位的过程,动态链接和执行流程。
第6章 hello进程管理
6.1 进程的概念与作用
进程:进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
作用:
进程为用户提供了以下假象:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
其基本功能是解释并运行用户的指令,重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个命令行参数是否是一个内置的shell命令。
(4)如果不是内部命令,调用fork( )创建新进程/子进程。
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait)等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程, 新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。
例如,当我们在shell中输入 ./hello 1190202120 吉天相 的时候,shell会先拆分我们输入的命令,然后对其进行解析,./hello 1190202120 吉天相并不是一个shell的内置指令,因此shell会调用fork()函数创建一个子进程。
6.4 Hello的execve过程
通过fork函数创建一个新的子进程后,子进程调用execve函数在当前子进程的上下文中加载运行一个新的文件,execve函数通过调用启动加载器来执行hello程序。删除当前进程虚拟地址的用户部分中已存在的区域结构。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。.bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域初始长度为零,初始化为空。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。设置程序计数器,使其指向程序的入口。
6.5 Hello的进程执行
逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
上下文:内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:先保存之前进程的上下文,恢复新恢复进程被保存的上下文,然后将控制传递给这个新恢复的进程,来完成上下文切换。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
一开始hello程序运行在用户模式下,当程序调用sleep函数()发生系统调用的时候进入内核模式,内核将该进程挂起并开始计时,并进行一次上下文切换,将控制转移给其他进程,此时是用户模式,当sleep计时完成时,向系统发送一个信号。进入内核模式进行信号处理,再次进行上下文切换,将控制转移回原进程。CPU在运行过程中不断进行上下文切换,不同进程交替占用CPU,使得不同进程是以时间片来处理的。
6.6 hello的异常与信号处理
hello程序出现的异常可能有:
中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。异步发生,总是返回到下一条指令。
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。同步发生,总是返回到下一条指令。
故障:在执行hello程序的时候,可能会发生缺页故障。同步发生,可能返回到当前指令或终止。
终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。同步发生,不会返回。
1.正常运行:
2.按下Ctrl+z后发现进程暂停,并未终止
输入fg后又开始运行
输入pstree
输入kill -9 pid
Ctrl+z:向进程发送了一个SIGTSTP信号,让进程暂时挂起。
fg:发送SIGCONT信号继续执行停止的进程或将后台进程转移到前台进行。
kill -9 pid :向进程发送一个SIGKILL信号,将进程终止。
3.按下Ctrl+c后进程终止
Ctrl+c:向进程发送了一个SIGINT信号,让进程终止。
4.在程序执行过程中乱按
程序会将输入缓存在stdin中,等待程序运行完毕后再将其按照shell的命令行来处理,故下面会出现未找到命令。
6.7本章小结
本章介绍了进程的概念和作用,简述了Shell的作用与处理流程,以及fork()函数和execve函数的作用,并执行了hello进程,对其可能遇到的异常和信号以及其处理做了分析。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。也是在hello.s文件中call函数调用时未进行重定位的地址。
线性地址:也叫虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。也是hello在重定位后的地址。
虚拟地址:同线性地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址,但是事实上,这只是一个硬件提供给软件的抽像,内存的寻址方式并不是这样。所以,说它是“与地址总线相对应”,是更贴切一些,不过抛开对物理内存寻址方式的考虑,直接把物理地址与物理的内存一一对应,也是可以接受的。也许错误的理解更利于形而上的抽像。hello中的虚拟地址通过页表映射到物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器。
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这句话很关键,说明段标识符的具体作用,每一个段描述符由8个字节组成。
Base字段表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。那究竟什么时候该用GDT,什么时候该用LDT呢?这是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
将线性地址分为两部分,高位部分为VPN,低位部分为VPO,VPN又分为TLBT和TLBI。当线性地址转化为物理地址时,需要先将VPN传送给TLB如果缓存命中,则返回相应的PTE将VPN转化为PPN并和PPO(VPO)组合成为物理地址,如果缓存不命中则向主存发送VPN在主存中的页表中获得对应的PTE,构建物理地址。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。
7.4 TLB与四级页表支持下的VA到PA的变换
CPU产生VA并将VA传送给MMU,MMU解析VA,将其分成两部分高位部分为VPN,低位部分为VPO,VPN又分为TLBT和TLBI。当线性地址转化为物理地址时,需要先将VPN传送给TLB如果缓存命中,则返回相应的PTE将VPN转化为PPN并和PPO(VPO)组合成为物理地址,如果缓存不命中则向主存发送VPN在主存中的页表中获得对应的PTE。同时将VPN分成4部分,分别在各级页表中查询,最终在四级页表中找到PPN,将其与PPO(VPO)组合成PA,并将其添加到TLB。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。
7.5 三级Cache支持下的物理内存访问
将获得的PA分成三部分,分别为标记位t,组索引s和块偏移b,根据组索引在L1cache缓存组中查询,然后在组内分别匹配标记位,如果标记位相同且有效位为1则缓存命中,根据偏移获得有效字节,否则缓存未命中,依次向L2cache、L3cache重复上述操作,直到缓存命中,若依然未命中,则向主存获得数据,并将得到的数据返回,同时更新缓存中的块。
7.6 hello进程fork时的内存映射
在shell输入命令行后,内核调用fork()函数创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的PID。新创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
(1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个相应的虚拟地址,查询PTE发现与该虚拟地址相对应的物理页面不在内存中,会触发缺页故障。
缺页中断处理:控制转移到内核,内核从PTE获得其在磁盘上存储的地址,然后将该物理页缓存到主存,并修改PTE,然后将控制返回。然后重新执行该指令。并且不会出现缺页故障。
7.9动态存储分配管理
Printf会调用malloc,如下是动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:分配器检测一个已分配块如果不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
实现方法:
隐式空闲链表(带边界标记)
在块的首尾的四个字节分别添加头部和脚步,负责维护当前块的信息(大小和是否分配)。由于每个块是对齐的,所以每个块的地址低位总是0,可以用该位标注当前块是否已经分配。可以利用header和footer中存放的块大小寻找当前块两侧的邻接块,方便进行空闲块的合并操作。
显式空闲链表
在未分配的块中添加两个指针,分别指向前一个空闲块和后一个空闲块。采用该策略,方便进行空闲块的合并操作。在分配块中则没有这些要求,分配器不用维护分配块。
分离的空闲链表:维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。有很多种方式来定义大小类。
7.10本章小结
本章介绍了4种地址,并简介了部分转化,如逻辑地址转化为线性地址,线性地址转化为物理地址,其中包括页表和缓存的相关知识,最后介绍了fork,execve的内存映射,以及动态内存的分配和管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
(1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
(2)Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
(3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地改变当前文件位置k。
(4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O函数:
(1) int open(char* filename,int flags,mode_t mode) ,进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
(2)int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
(3)ssize_t read(int fd,void *buf,size_t n),read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
(4)ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
arg获得第二个不定长参数,即输出的时候格式化串对应的值。同时调用vsprintf和write。
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
将栈中的数据存入寄存器,%ecx是字符个数,%ebx存放第一个字符地址。int INT_VECTOR_SYS_CALLA代表通过系统调用syscall,syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。这样就将输入的字符串“Hello 1190202120 吉天相”显示在屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,同时产生中断请求,键盘中断处理子程序。接受按键并扫描码转成ASCII码,保存到系统的键盘缓冲区。getchar函数底层调用read系统函数,通过系统调用read函数读取按键ASCII码,直到接受到回车键才将整个字符串返回,getchar 进行封装,其大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法和简述Unix IO接口及其函数,最后分析了printf和getchar的实现。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
编写出一个C程序,hello.c,它经过预处理阶段变为hello.i。再经过编译阶段变为hello.s。此前它都还是一个文本文件,此后它变成了二进制文件。经过汇编阶段变为hello.o。hello.o与其他可重定位目标文件和动态链接库链接成为可执行目标文件hello。当在shell中输入./hello 1190202120 吉天相,shell程序识别出该命令不是一个内置命令,故调用fork函数为其创建一个子进程,并调用execve函数为其映射虚拟内存。在实践执行中可能遇到各种异常和信号,对于异常的处理和信号的处理使得hello能够继续走下去。printf函数申请了动态内存分配,还将传入的字符串打印在屏幕上,最终hello return,子进程返回,内核将子进程的返回状态给其父进程,父进程回收子进程,hello结束了它的一生。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
计算机设计十分精妙:
在处理不平衡的时候可以在其中插入一个缓存,而且这个思想用途很广。例如处理主存和CPU的不平衡可以引入高速缓存,在虚拟内存转化为物理内存时也可以创建一个缓存TLP将经常访问的PTE放入TLB。
把一个过程分成多个阶段,并行处理多个任务可以提高效率,例如流水线模式,将指令分成6个阶段,每个时刻可以并行处理处于不同阶段的。但分的阶段也不宜过多,流水线不易过深。
附件
hello.c:源代码
hello.i:预处理后的文本文件
hello.s:编译之后的汇编文件
hello.o:汇编之后的可重定位目标执行文件
hello:链接之后的可执行文件
hello.elf:hello.o的ELF格式
Hello.elf:hello的ELF格式
hello.txt:hello的反汇编代码
参考文献
[1] https://www.cnblogs.com/pianist/p/3315801.html
[2] 深入理解计算机系统第3版中文版
[3] https://blog.csdn.net/Pipcie/article/details/105670156
[4] https://www.cnblogs.com/xuwq/p/5014735.html
[5] https://segmentfault.com/a/1190000016433947