摘 要
本文以hello程序切入点,研究一个程序从一个高级C语言程序开始,经过预处理、编译、汇编、链接等到最后变成一个可执行文件的生命周期,以此来了解系统如何通过硬件和系统软件的交织、共同协作以达到运行应用程序的最终目的。将全书内容融会贯通,帮助深入理解计算机系统。
关键词:计算机系统;进程;汇编;抽象
目 录
第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 本章小结 - 6 -
第3章 编译 - 7 -
3.1 编译的概念与作用 - 7 -
3.2 在UBUNTU下编译的命令 - 7 -
3.3 HELLO的编译结果解析 - 7 -
3.4 本章小结 - 13 -
第4章 汇编 - 14 -
4.1 汇编的概念与作用 - 14 -
4.2 在UBUNTU下汇编的命令 - 14 -
4.3 可重定位目标ELF格式 - 14 -
4.4 HELLO.O的结果解析 - 16 -
4.5 本章小结 - 18 -
第5章 链接 - 19 -
5.1 链接的概念与作用 - 19 -
5.2 在UBUNTU下链接的命令 - 19 -
5.3 可执行目标文件HELLO的格式 - 19 -
5.4 HELLO的虚拟地址空间 - 21 -
5.5 链接的重定位过程分析 - 22 -
5.6 HELLO的执行流程 - 23 -
5.7 HELLO的动态链接分析 - 24 -
5.8 本章小结 -24 -
第6章 HELLO进程管理 - 25 -
6.1 进程的概念与作用 - 25 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 25 -
6.3 HELLO的FORK进程创建过程 - 25 -
6.4 HELLO的EXECVE过程 - 25 -
6.5 HELLO的进程执行 - 26 -
6.6 HELLO的异常与信号处理 - 27 -
6.7本章小结 - 29 -
第7章 HELLO的存储管理 - 30 -
7.1 HELLO的存储器地址空间 - 30 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 30 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 30 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 31 -
7.5 三级CACHE支持下的物理内存访问 - 32 -
7.6 HELLO进程FORK时的内存映射 - 32 -
7.7 HELLO进程EXECVE时的内存映射 - 33 -
7.8 缺页故障与缺页中断处理 - 33 -
7.9动态存储分配管理 - 34 -
7.10本章小结 - 36 -
第8章 HELLO的IO管理 - 37 -
8.1 LINUX的IO设备管理方法 - 37 -
8.2 简述UNIX IO接口及其函数 - 37 -
8.3 PRINTF的实现分析 - 38 -
8.4 GETCHAR的实现分析 - 40 -
8.5本章小结 - 40 -
结论 - 41 -
附件 - 42 -
参考文献 - 43 -
第1章 概述
1.1 Hello简介
程序员通过编辑器键入代码并保存得到hello.c。hello.c经cpp预处理得到hello.i,然后经ccl编译得到hello.s,再经as汇编得到hello.o,最后通过ld链接得到可执行文件hello。于是hello由program变成process,此为P2P过程。
用户输出命令,bash为hello文件fork出一个子进程,并用execve来加载程序,映射虚拟内存,然后在开始运行进程的时候分配并载入物理内存,再进入CPU执行程序。程序执行完毕后,进程被bash回收,内存中数据也被清除,此为020过程。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk
软件环境:Windows 10;Vmware 16;Ubuntu 16.04
开发工具:Codeblocks;vim;gcc;readelf;HexEdit
1.3 中间结果
hello.c 源程序
hello.i 预处理之后的文本文件
hello.s 编译之后的汇编文件
hello.o 汇编之后的可重定位目标执行文件
hello 链接之后的可执行目标文件
hello_o.objdump hello.o的反汇编文件
hello_o.elf hello.o的ELF格式文件
hello.objdump hello的反汇编文件
hello.elf hello的ELF文件
1.4 本章小结
简述了P2P和020过程,并列出了环境和工具、中间结果,是对实验的总括。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,读取系统头文件的内容,并把它们直接插入程序文本中,得到.i扩展名文件。
作用:
调用系统头文件
用实际值替换用#define 定义的字符串
简化了程序,使编译器翻译程序时更加方便
2.2在Ubuntu下预处理的命令
在终端输入命令cpp hello.c > hello.i
图2.1 使用 cpp 命令生成 hello.i 文件
2.3 Hello的预处理结果解析
打开hello.i发现,程序已被拓展为3000多行,main函数从3110行开始,如下图:
图 2.2 hello.i中main函数的位置
main函数在文件最后的位置,之前被插入了各种头文件和会用到的函数的声明,如下图:
图2.3 hello.i中头文件
图2.4 hello.i中调用函数的声明
2.4 本章小结
主要介绍了预处理的过程、定义与作用,并结合实验对预处理结果进行了解析。
第3章 编译
3.1 编译的概念与作用
概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s, 它包含一个汇编语言程序。
作用:生成语法树并转化为目标代码,使下一步转成二进制代码更加方便。
3.2 在Ubuntu下编译的命令
在终端输入命令gcc -S hello.i -o hello.s
图3.1 使用gcc命令生成64位的hello.s文件
3.3 Hello的编译结果解析
3.3.1 hello.s中的标识
图3.2 hello.s文件中的标识
.file 源文件
.data 数据段
.globl 全局标识符
.string 字符串类型
.long long类型
.text 代码段
.align 对齐方式
3.3.2 数据
一、字符串
图3.3 hello.s中声明字符串
在.rodata只读数据节声明了两个字符串“Usage: Hello 学号 姓名!\n”和“Hello %s %s\n”作为两个printf的格式化参数。
二、整数
程序中包含的整数类型数据有全局变量sleepsecs,局部变量i和main的参数argc。
1.sleepsecs
图3.4 hello.s中全局变量sleepsecs的声明
可见将sleepsecs定义为全局变量,对齐方式为4、大小为4字节、类型为long、初始值赋为2。
2.i
局部变量没有特殊声明,只有在调用的时候才会存储在寄存器或栈中。比对汇编代码与C语言代码,我们可以发现在hello.s中,i存储在栈上空间-4(%rbp)中,占有4个字节。如下图划线部分所示:
图3.5 hello.s中的局部变量i
3.argc
作为main的参数传入,出现在main的栈中,不需要声明,直接调用即可。如下图划线部分:
图3.6 hello.s中main的参数argc
三、数组
数组char *argv[]作为main的参数,没有单独声明,出现main的栈中,直接使用即可,如下图划线部分:
图3.7 hello.s中main的参数数组argv
3.3.3 数值操作
一、赋值操作
1.对全局变量sleepsecs赋值
在开头对于全局变量的声明中赋初值,上文已有说明和截图,不再赘述。
2.对局部变量i赋值
由于i占4个字节,使用movl语句实现赋值,如下图:
图3.8 hello.s中对局部变量的赋值
二、运算操作
1.减法:SUB S,D D=D-S
图3.9 hello.s中的减法
2.加法:ADD S,D D=D+S
图3.10 hello.s中的加法
3.乘法:
IMULQ S R [%rdx]:R[%rax]=SR[%rax](有符号)
MULQ S R [%rdx]:R[%rax]=SR[%rax](无符号)
4.除法:
IDIVQ S R [%rdx]=R[%rdx]:R[%rax] mod S(有符号)R[%rax]=R[%rdx]:R[%rax] div S
DIVQ S R [%rdx]=R[%rdx]:R[%rax] mod S(无符号) R[%rax]=R[%rdx]:R[%rax] div S
3.3.4 关系操作与控制语句
一、关系操作
主要有cmp和test,如下图:
图3.11 比较指令
二、控制(跳转)语句
汇编语言中的跳转语句如下图:
图3.12 跳转指令
hello.s中用到了多种控制指令,通过这些指令的组合可以实现语句的跳转、for、while循环等结构功能,如下图划线部分:
图3.13 hello.s中控制语句
3.3.5 对数组的操作
取数组的第i位一般是去数组头指针再加上第i位的偏移量得到。首先从-32(%rbp)读取argv地址存入rax,然后rax分别增加8个字节、16个字节,得到argv[1]、argv[2]的地址存入rax中,读取此地址指向的值即是读取argv[1]、argv[2]的值。
图3.14 hello.s中对数组的操作
3.3.6 函数操作
一、调用printf
以“printf(“Hello %s %s\n”,argv[1],argv[2]);”为例,将之前定义好的格式封装如一个寄存器%edi中传递,再将所需要的数值分别放在寄存器%rsi、%rdx中,准备好参数后,使用命令call调用printf。Printf的返回值会被存放在%eax中并返回。
图3.15 hello.s中调用printf函数
二、调用getchar和exit
无需准备参数,直接用call调用即可。
图3.16 hello.s中调用exit和getchar函数
三、main函数返回值
若有返回值,先将返回值传入寄存器%eax,然后leave使调用的栈空间恢复为之前的状态,再执行ret。
图3.17 hello.s中设置main返回值
3.4 本章小结
本章简述了编译的概念和作用,具体分析了一个c程序是如何被编译器编译成一个汇编程序的过程,还详细分析了不同的c语句和翻译成汇编语句之后的表示方法。
第4章 汇编
4.1 汇编的概念与作用
概念:编器(as)将hello.s翻译成机器语言指令,把这些指令打包成 一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标 文件hello.o中o hello.o文件是一个二进制文件,它包含的17个字节是函数main 的指令编码。
作用:汇编,将代码转成二进制。
4.2 在Ubuntu下汇编的命令
在终端输入命令as hello.s -o hello.o
图4.1 使用 as 指令生成 hello.o 文件
4.3 可重定位目标elf格式
ELF文件的内容及各节基本信息如下图:
图4.2 ELF文件格式
输入命令readelf –a hello.o > hello_o.elf可得到hello.o文件的ELF格式。其组成如下:
1.ELF头
以16字节的序列Magic开始,描述了生成该文件的系统 的字的大小和字节顺序。然后是含帮助链接器语法分析和解释目标文件的信息,其中包括节头大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息,具体如下图:
图4.3 ELF头
2.节头
描述了.o文件中各节的信息,包括类型、位置、偏移量等。
图4.4 节头
3.重定位节
重定位节是.test节中位置的列表包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。可见本程序中需要重新定位的信息有printf、puts、exit、sleep、getchar函数,还有全局变量sleepsecs,以及.rodata中的两个元素.L0和.L1,具体如下图所示:
图4.5 重定位节
4.4 Hello.o的结果解析
输入命令objdump -d -r hello.o > hello_o.objdump可得到反汇编文件的文本文件形式,下面对照hello.s进行分析比较。我们显然可以发现,二者的代码没有太大差别,只在一些语句的使用上有微小的不同。
1.跳转分支语句
hello.s中的跳转是跳转到分支的段名称处如.L1、.L3,而反汇编文件中的跳转则是直接跳到具体的地址。
2.函数调用
hello.s中调用函数,直接call+函数名,而在反汇编文件中则是调用标准库中的函数,通过动态链接器确定函数的地址,在调用时需要call+函数地址。
3.全局变量调用
hello.s文件中未对main的地址进行赋值,而反汇编文件则在开头将main的首地址设为全0。在调用全局变量时,.s文件采用变量名(%rip)形式,而反汇编文件则直接使用main的首地址,运用0x0(%rip)的方式。
4.十六进制机器代码
反汇编文件相较于.s文件,每一行在相对地址后很明显地多了一块十六进制代码。这些代码是每一行的汇编语句对应的机器指令。机器指令完全由0/1构成,这里将其转换成十六进制显示。
图4.6 反汇编文件与.s文件的对比
4.5 本章小结
本章简述了汇编的概念和作用,并分析了hello.s汇编指令被转换成hello.o机器指令的过程,通过readelf查看hello.o的ELF、反汇编的方式查看了hello.o反汇编的内容,比较其与hello.s之间的差别。学习了汇编指令映射到机器指令的具体方式。
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于程序编译、加载、运行时。
作用:可以将一个大型程序分解为更小、更好管理的模块,并独立地修改、编译这些模块,降低了编程的难度。
5.2 在Ubuntu下链接的命令
输入命令
ld -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 /usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
图 5.1 使用ld命令链接生成可执行程序hello
5.3 可执行目标文件hello的格式
输入命令reaadelf –a hello > hello.elf可以得到hello的ELF文件,具体内容如下,关于ELF文件的结构和具体内容上一章已经介绍过,此处不再赘述。
1.ELF头
图5.2 hello的ELF文件的ELF头
2.节头部表
存放了每个节的大小,每个节在程序中的偏移量和程序被载入后各段的虚拟地址。
图5.3 hello的ELF文件的节头
3.程序头表
图5.4 hello的ELF文件的程序头
5.4 hello的虚拟地址空间
将hello拖入edb中运行,在Data Dump中可以看到程序在0x4000000x401000段中载入,从虚拟地址0x400000开始,到0x401000结束。其余节.dynamic.shstrtab存放在虚拟地址0x40fff之后。
图5.5 hello在edb中执行程序虚拟地址
我们再回到图5.4所示的程序头表。程序头表在执行的时候被使用,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐等信息。各表项功能如下:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:保存常量数据、程序目标代码等
DYNAMIC:保存动态链接器使用信息
NOTE:保存辅助信息
GNU_STACK:异常标记
GNU_RELRO:保存重定位后只读区域的位置
5.5 链接的重定位过程分析
输入命令objdump -d -r hello > hello.objdump反汇编hello,我们可以很明显地看到,这个反汇编文件比之前的hello_o.objdump长了很多,因为多了很多节,我们选取一部分列出:
.interp:保存ld.so的路径
.gnu.hash:GNU拓展的符号的哈希表
.dynsym:运行时/动态符号表
.rela.dyn:运行时/动态重定位表
.plt:动态链接-过程链接表
.rela.plt:.plt节的重定位条目
.init:程序初始化需要执行的代码
.fini:当程序正常终止时需要执行的代码
.dynamic:存放被ld.so使用的动态链接信息
.got:动态链接-全局偏移量表-存放变量
.got.plt:动态链接-全局偏移量表-存放函数
通过比较 hello.objdump 和 hello_o.objdump 了解链接器。
在进行链接后,程序将调用标准库中的函数加入到了程序的.plt节中,调用函数时,hello_o.objdump采用call+<相对main的偏移量>的方法,而在hello.objdump则可以使用call+<函数名>的方法。具体可看下图中划线部分:
图5.6 比较hello.objdump和hello_o.objdump
5.6 hello的执行流程
使用 edb 执行 hello,观察函数执行流程,将过程中执行的主要函数列在下面:
ld-2.23.so!_dl_start 0x00007f5ffaf54a50
ld-2.23.so!_dl_setup_hash 0x00007f5ffaf54a50
ld-2.23.so!_dl_sysdep_start 0x00007fcfbfcee210
ld-2.23.so!_dl_init 0x00007fcfbfce5740
hello!__libc_start_main@plt 0x00000000004004c0
ld-2.23.so!_dl_fixup 0x00007f9cb4e9c9f0
ld-2.23.so!_dl_lookup_symbol_x 0x00007f18464559d0
libc-2.23.so!__cxa_atexit 0x00007f18460bb280
libc-2.23.so!__new_exitfn 0x00007f18460bb0a0
hello!_init
libc-2.23.so!__sigjmp_save 0x00007f18460b6210
hello!puts@plt 0x00000000004004a0
hello!exit@plt 0x00000000004004e0
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表 GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
图5.7 调用dl_init前的.got.plt节
图5.8 调用dl_init后的.got.plt节
5.8 本章小结
在本章中主要介绍了链接的概念与作用、hello 的ELF格式,使用edb分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。包括存放在内存中的程序的代码和数据等。
作用:为程序提供逻辑控制流、私有地址空间的抽象。使我们的程序看起来好像是系统中当前运行的唯一程序一样。
6.2 简述壳Shell-bash的作用与处理流程
作用:是用户使用Linux的桥梁。为用户提供了一个界面以访问操作系统内核的服务。
处理流程:
1.读取用户输入的命令
2.对输入内容进行分割,获取参数
3.判断命令是否是内置命令,如果是则直接执行,否则调用相应的程序为其分配子进程并运行
4.时刻监控键盘的输入信号,并对其进行相应的处理
6.3 Hello的fork进程创建过程
输入命令后终端程序对其进行分析,发现不是内置命令,然后终端会调用fork 函数创建一个新的子进程,新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,但与父进程拥有不同的PID。在运行期间父、子进程可以任意交替,父进程会默认等待子进程结束并回收。
6.4 Hello的execve过程
在fork子进程之后,execve函数调用内存中的启动加载器来执行hello程序,加载器删除子进程现有的虚拟内存段, 并创建一组新的代码、数据、堆和栈段,具体内容如下图。最后,加载器设置PC指向_start地址,_start最终调用hello中的main函数。
图6.1 启动加载器内部结构
6.5 Hello的进程执行
输入命令后,hello开始运行。在hello运行时,还有一些程序在并发地运行,称为并发流,这些程序在运行过程中与hello来回切换,如下图所示:
图6.2 进程切换示意图
hello初始运行在用户模式,不久后调用sleep函数,定时器开始计时。Sleep函数使进程后陷入内核模式,内核处理休眠请求主动释放当前进程,并调度其他进程。此时需要先进行上下文切换:
1)保存以前进程的上下文
2)恢复新恢复进程被保存的上下文
3)将控制传递给这 个新恢复的进程
整个过程在内核模式下完成。完成上下文切换后,新的进程以用户模式进行。
当定时器到时时(2.5secs)发送一个中断信号,再次进入内核模式执行中断处理,完成上下文切换后,hello进程进入用户模式继续向下执行,直到再一次调用其他函数,执行流程与上述类似。程序的具体切换过程如下图:
图6.3 进程模式切换
6.6 hello的异常与信号处理
1.没有乱按键盘
程序正常结束并被回收。
图6.4 正常运行hello程序
2.输入Ctrl+Z
该操作向进程发送了一个SIGSTP信号,将进程挂起,但输入ps命令我们可以看到进程未被回收。我们可以用fg 1命令将其调至前台继续执行直至结束并被回收。
图6.5 在中途按下Ctrl+Z
3.输出Ctrl+C
向进程发送了一个SIGINT信号,进程处理后结束hello并将其回收。
图6.6 在中途按下Ctrl+C
4.输入Ctrl+Z后再输入pstree命令
可以看到进程的进程树。这里截取一部分如下图:
图6.7 进程树
5.乱按
我们可以发现在进程中途乱按对进程的运行没有什么影响,程序还是会照常输出。当然,你乱按输入的字符也会被显示在屏幕上。
图6.8 在进程运行中途乱按
6.7本章小结
本章主要介绍了进程的定义与作用、shell 的一般处理流程,调用fork创建新进程的过程、execve函数的过程、hello的异常与信号处理等内容。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,址由选择符和偏移量组成。
线性地址&虚拟地址:二者是一个概念,是经过段机制转化之后用于描述程序分页信息的地址,是对程序运行区块的一个抽象映射。分页机制中线性地址作为输入。
物理地址:CPU 通过地址总线的寻址,找到真实的物理内存对应地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
实模式下:逻辑地址=线性地址=物理地址
保护模式下:线性地址=段选择符+段内偏移地址,段选择符的结构如下图:
图7.1 段选择符内部结构
我们先看T1字符是0/1确定要转移的是GDT中的段还是LDT中的段,再根据指定的寄存器的地址和大小得到数组。再拿出段选择符的前13位,在数组中查找到对应的段描述符,可得基地址,再结合段内偏移量获得线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
hello的线性地址到物理地址的转换需要查询页表。线性地址分为两个部分,虚拟页号VPN和虚拟页偏移量VPO。VPN用于在页表查询物理页号PPN,再与VPO结合获得物理地址。该过程如下图所示:
图7.2 线性地址转换至物理地址
7.4 TLB与四级页表支持下的VA到PA的变换
前提如下:虚拟地址空间48位,物理地址空间52位,页表大小4KB,4级页表。TLB4路16组相联。由一个页表大小4KB,一个PTE条目8B,共512个条目,使 用 9 位二进制索引,一共 4 个页表共使用 36 位二进制索引,所以VPN共36位,因为VA共 48位,所以VPO占12位;因为TLB共16组,所以 TLBI 需 4 位,因为 VPN共36 位,所以TLBT占32位。具体结构如下图:
图7.3 虚拟地址中用以访问TLB的组成部分
变换地址时,CPU产生一个虚拟地址,MMU从TLB中取出相应的PTE。如果命中,则得到对应的物理地址。如果不命中,VPN会被分成4个部分。MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成 PA,并且向TLB中添加条目。
图7.4 四级页表支持下VA到PA的转换
7.5 三级Cache支持下的物理内存访问
前提:L1 Cache 是8路64组相联,块大小为64B。由于有64组,所以组索引CI需要6 bit,块大小为64B故组内偏移CO需要6bit。因为PA共52 bit所以剩余部分CT共40 bit。
物理内存访问时,MMU发送PA给L1缓存,高速缓存根据CI找到组、CT找到地址,并根据标记位判断该地址是否已缓存数据。若命中,则根据偏移量CO找到值取出数据后返回。若不命中,则再一次查找缓存L2、L3。如果仍不命中,则要去主存中读取数据。过程如下图所示:
图7.5 三级Cache下访问物理缓存的过程
7.6 hello进程fork时的内存映射
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。
当用fork创建一个进程时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、vm_area_struct和页表的原样副本。然后将这两个两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数加载并运行hello的步骤如下:
1.删除当前的用户区域
2.映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。
3.映射共享区域:hello程序与标准C库链接,这些对象动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器。execve做得最后一件事情是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7.6 加载器映射用户地址空间区域
7.8 缺页故障与缺页中断处理
当指令引用一个虚拟地址,在MMU中查找页表 时发现与该地址相对应的物理地址不在内存中,此时即为缺页故障。
当出发缺页故障时,处理程序执行如下的步骤:
1.判断虚拟地址是否合法
把该虚拟地址与每个区域结构中的vm_start和vm_end作比较。如果不合法,则发送段错误信号,终止这个进程。
2.判断试图访问的内存是否合法
如果不合法则出发一个保护进程终止进程。
3.判断地址和访问内存都合法后
选择牺牲一个页面,如果这个页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
图7.7 Linux的缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为 一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已 分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
图7.8 堆
分配器分为两种基本风格:显式分配器、隐式分配器。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
显式分配器:要求应用显式地释放任何已分配的块。
一、 带边界标签的隐式空闲链表
1.堆及堆中内存块的组织结构:
图7.9 使用边界标记的堆块的格式
2.隐式链表
所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中header和footer中的block 大小间接起到了前驱、后继指针的作用。
3.空闲块合并
因为有了footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变header和footer中的值就可以完成这一操作。
图7.10 不同情况下的空闲块合并
二、显示空间链表基本原理
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
图7.11 使用双向空闲链表的堆块的格式
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章主要介绍了hello的存储器地址空间、段式管理和页式管理,以intel Core7 为例介绍了VA到PA的变换、物理内存访问,以及进程fork、execve时的内存映射,缺页故障与缺页中断处理,动态存储分配管理等。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:所有的 IO 设备都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O
文件的类型:
1.普通文件(regular file):包含任意数据的文件。
2.目录(directory):包含一组链接的文件,每个链接都将一个文件名映射到一个文件(他还有另一个名字叫做“文件夹”)。
3.套接字(socket):用来与另一个进程进行跨网络通信的文件
4.命名通道
5.符号链接
6.字符和块设备
设备管理:unix io接口
1.打开和关闭文件
2.读取和写入文件
3.改变当前文件的位置
8.2 简述Unix IO接口及其函数
1.int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符。
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的当前文件位置。
5.int dup2(int oldfd, int newfd);
dup2函数复制描述符表表项oldfd到描述符表项newfd,覆盖描述符表表项newfd以前的内容。如果newfd已经打开了,dup2会在复制oldfd之前关闭newfd。
8.3 printf的实现分析
查看printf代码如下:
图8.1 printf代码
我们可以看到在参数列表中,有…的写法,当传递参数的个数不确定时,我们可以用这种写法。很显然,我们需要一种方法来确定具体调用时参数的个数。首先,由va_list的定义,arg获得…中的第一个参数的地址。然后是vsprintf语句,我们查看vsprintf代码如图8.2。
由i=vsprintf(buf, fmt, arg);我们可以推测vsprintf函数返回要打印的字符串的长度。所以vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。vsprintf 程序按照格式 fmt 结合参数 args 生成格式化之后的字符串,并返回字串的长度。
图8.2 vsprintf代码
我们再回到printf代码,接着程序调用了write,查看write函数如下:
图8.3 write函数
在 write 函数中,将栈中参数放入寄存器,ecx 是字符个数,ebx 存放第一个 字符地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用sys_call,查看 sys_call 的实现:
图8.4 sys_call函数
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。最终使要显示的字符串出现在屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。getchar等调用read系统函数,通过系统调用读取按键ASCII码,直到接受到回车键才返回。
getchr函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要介绍了linux的I/O设备管理机制,了解了开、关、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法以及操作过程。
结论
hello的一生主要经历了如下阶段:
- 键盘输入程序,得到hello.c源文件,hello诞生了
- 预处理:将hello.c调用的所有外部的库展开合并到一个hello.i文件中
- 编译:将hello.i编译成汇编文件hello.s
- 汇编:将hello.s会变成为可重定位目标文件hello.o
- 链接:链接器对hello.o进行链接得到可执行文件hello,此时hello已经被操作系统加载和执行
- 运行hello:在终端输入命令,shell进程调用fork为hello创建一个子进程,随后调用execve启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数
- 执行指令:CPU为其分配时间片,在一个时间片中hello享有CPU资源,顺序执行自己的控制逻辑流
- 内存使用:MMU 将程序中使用的虚拟内存地址通过页表映射成物理地址。printf 会调用 malloc 向动态内存分配器申请堆中的内存
- hello执行过程中可能会收到来自键盘等的信号,收到信号后调用信号处理程序进行处理,可能会有停止、挂起等
- 进程结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
通过对hello的一生的学习、梳理,深入理解了现代计算机操作系统各部分之间的协作、调用,对系统各部分的设计思想、处理方式有了基本的认识了解。并通过这些对计算机有了新的看法,为一些问题的解决提供了新的思路。
附件
hello.c 源程序
hello.i 预处理之后的文本文件
hello.s 编译之后的汇编文件
hello.o 汇编之后的可重定位目标执行文件
hello 链接之后的可执行目标文件
hello_o.objdump hello.o的反汇编文件
hello_o.elf hello.o的ELF格式文件
hello.objdump hello的反汇编文件
hello.elf hello的ELF文件
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 龚奕利. 深入理解计算机系统.北京:机械工业出版社,2016.
[2] CSDN https://www.csdn.net/
[3] Baidu https://www.baidu.com/
[4] printf函数实现的深入剖析:
https://www.cnblogs.com/pianist/p/3315801.html
[5] 逻辑地址、线性地址和物理地址之间的转换:
https://blog.csdn.net/gdj0001/article/details/80135196
[6] Github https://github.com/