第1章 概述
1.1.1 From Program to Process
首先hello.c通过I/O设备如键盘等经过总线存入主存。然后GCC编译器驱动程序读取源程序文件hello.c,通过预处理器cpp变成hello.i(修改了的源程序)然后通过编译器ccl变成hello.s(汇编程序),然后通过汇编器as变成hello.o(可重定位目标程序),这时的hello.o就不是之前的文本了,而是对机器友好的二进制代码了。最后再通过链接器ld与标准C库进行链接,最终变成hello(可执行的二进制目标程序)此时的hello就是一个Program了。然后在shell(Bash)里面输入字符串“./hello”后,shell程序将字符逐一读入寄存器,然后再放入到内存里面去,然后shell调用fork函数创建一个新运行的子进程,这个子进程是父进程shell的一个复制,然后子进程通过execve系统调用启动加载器。加载器删除子进程现有的虚拟内存段,然后使用mmap函数创建新的内存区域,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段被初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,然后再从寄存器读入最多两个数,然后在执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。然后在流水线化的系统中,待执行的程序被分解成几个阶段,每个阶段完成指令执行的一部分。最后变成一个Process运行在内存中。
1.1.2 From Zero-0 to Zero-0
首先说明这里的020应该指的是程序(Process)在内存中From Zero to Zero。一开始hello先是执行了上面所述的过程,然后在程序执行结束以后,该进程会保持在一种已终止的状态中,直到该进程被其父进程也就是shell进程回收然后退出,shell会再次变成hello执行之前的状态,也就是说又变成Zero了。
1.2.1 硬件环境
X64 CPU;2.8GHz;8G RAM;1THD Disk;
1.2.2 软件环境
Windows10 64位;Vmware 14;Ubuntu 16.04 LTS 64位;
1.2.3 开发工具
Visual Studio 2015 64位;CodeBlocks;vi/vim/gpedit+gcc;
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i(修改了的源程序):预处理阶段实现的功能主要有三个:
1.加载头文件2.进行宏替换3.条件编译
hello.s(汇编程序):包含汇编语言程序。
hello.o(可重定位目标程序):将汇编语言翻译成机器语言指令,并将指令打包成一种叫做可重定位目标程序的格式。
hello.c被编写出来,然后在编译器的作用下被编译成可执行文件,然后在系统的操作下被执行,然后被回收,看似简单的步骤却经历了一番伟大的路程。这不仅仅代表了一个程序,也代表了绝大多数程序的历程。
(第1章0.5分)
概念:预处理器(cpp) 根据以字符#开头的命令,修改原始的C 程序。
作用:预编译过程主要处理那些源代码中以#开始的预编译指令,主要处理规则如下:
1)将所有的#define删除,并且展开所有的宏定义;
2)处理所有条件编译指令,如#if,#ifdef等;
3)处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。
4)删除所有的注释//和 /**/;
5)添加行号和文件标识,如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号信息;
6)保留所有的#pragma编译器指令,因为编译器须要使用它们;
gcc -E hello.c -o hello.i
图2-1 在Ubuntu下预处理的命令
#include
图2-2 Hello的预处理结果解析
.c文件中包含有头文件也就是有外部文件的,还有一些程序员需要但是对于程序执行没有任何帮助的宏定义以注释,和一些程序员需要的条件编译和完善程序文本文件等操作都需要通过预处理来实现。预处理可以使得程序在后序的操作中不受阻碍,是非常重要的步骤。
(第2章0.5分)
概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
作用:编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
gcc -S hello.i -o hello.s
图3-1 在Ubuntu下编译的命令
3.3.1 数据
有变量int sleepsecs,编译器将其编译成
.type sleepsecs, @object
.size sleepsecs, 4
3.3.2 赋值
赋值语句sleepsecs=2.5编译器将其编译成
sleepsecs:
.long 2
.section .rodata
赋值语句i=0编译器将其编译成
movl $0, -4(%rbp)
3.3.3 类型转换(显示或隐式)
由于sleepsecs是int型的而2.5是float类型的,这就有一个隐式的类型转换,编译器将2.5隐式地转换成了2存入sleepsecs。
3.3.4 算术操作
编译器将i++编译成
addl $1, -4(%rbp)
3.3.5 关系操作
编译器将i<10编译成
cmpl $9, -4(%rbp)
jle .L4
将argc!=3编译成
cmpl $3, -20(%rbp)
je .L2
3.3.6 数组/指针/结构操作
printf函数里面的一系列对指针和对数组的操作编译器编译为:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
3.3.7 控制转移
编译器将if,for等控制转移语句都使用了cmp来比较然后使用了条件跳转指令来跳转。编译器将if(argc!=3)编译成:
cmpl $3, -20(%rbp)
je .L2
将for循环里面的比较和转移编译成:
cmpl $9, -4(%rbp)
jle .L4
3.3.8 函数操作
编译器将printf("Usage: Hello 学号 姓名!\n");编译为:
movl $.LC0, %edi
call puts
将printf("Hello %s %s\n",argv[1],argv[2]);编译为:
movq -32(%rbp), %rax
addq $16, %rax
movq (%rax), %rdx
movq -32(%rbp), %rax
addq $8, %rax
movq (%rax), %rax
movq %rax, %rsi
movl $.LC1, %edi
movl $0, %eax
call printf
将sleep(sleepsecs);编译为:
movl sleepsecs(%rip), %eax
movl %eax, %edi
call sleep
图3-2 在Ubuntu下编译结果对比图示
在编译阶段,编译器将高级语言编译成汇编语言。汇编语言是直接面向处理器(Processor)的程序设计语言。处理器是在指令的控制下工作的,处理器可以识别的每一条指令称为机器指令。每一种处理器都有自己可以识别的一整套指令,称为指令集。处理器执行指令时,根据不同的指令采取不同的动作,完成不同的功能,既可以改变自己内部的工作状态,也能控制其它外围电路的工作状态。
汇编语言的另一个特点就是它所操作的对象不是具体的数据,而是寄存器或者存储器,也就是说它是直接和寄存器和存储器打交道,这也是为什么汇编语言的执行速度要比其它语言快,但同时这也使编程更加复杂,因为既然数据是存放在寄存器或存储器中,那么必然就存在着寻址方式,也就是用什么方法找到所需要的数据。例如上面的例子,我们就不能像高级语言一样直接使用数据,而是先要从相应的寄存器AX、BX 中把数据取出。这也就增加了编程的复杂性,因为在高级语言中寻址这部分工作是由编译系统来完成的,而在汇编语言中是由程序员自己来完成的,这无异增加了编程的复杂程度和程序的可读性。
再者,汇编语言指令是机器指令的一种符号表示,而不同类型的CPU 有不同的机器指令系统,也就有不同的汇编语言,所以,汇编语言程序与机器有着密切的关系。所以,除了同系列、不同型号CPU 之间的汇编语言程序有一定程度的可移植性之外,其它不同类型(如:小型机和微机等)CPU 之间的汇编语言程序是无法移植的,也就是说,汇编语言程序的通用性和可移植性要比高级语言程序低。
总结起来就是三个特点:机器相关性、高速度和高效率、编写和调试复杂(相对于高级语言)。
(第3章2分)
概念:汇编器(as) 将hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program) 的格式,并将结果保存在目标文件hello.o 中。
作用:汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
gcc –c hello.s –o hello.o
图4-1 在Ubuntu下汇编的命令
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
节头信息如下:
图4-2 Ubuntu下readelf列出的节头信息
其中重定位节有.rela.text以及.rela.eh_frame详细信息如下:
图4-3 Ubuntu下重定位节的详细信息
机器语言指的是二进制的机器指令集合,而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。
在对比两个文件后,汇编器在汇编hello.s时:
汇编器将汇编语言转化成机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。它是计算机的设计者通过计算机的硬件结构赋予计算机的操作功能。机器语言具有灵活、直接执行和速度快等特点。 不同型号的计算机其机器语言是不相通的,按着一种计算机的机器指令编制的程序,不能在另一种计算机上执行。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
用机器语言编写程序,编程人员要首先熟记所用计算机的全部指令代码和代码的涵义。手编程序时,程序员得自己处理每条指令和每一数据的存储分配和输入输出,还得记住编程过程中每步所使用的工作单元处在何种状态。这是一件十分繁琐的工作。编写程序花费的时间往往是实际运行时间的几十倍或几百倍。而且,编出的程序全是些0和1的指令代码,直观性差,还容易出错。除了计算机生产厂家的专业人员外,绝大多数的程序员已经不再去学习机器语言了。但是作为最基础的语言我们还是要稍作了解,目的是对计算机系统的执行方式进行了解,有助于我们编写出质量更高的代码。
(第4章1分)
链接本质:合并相同的“节”
作用:目标代码不能直接执行,要想将目标代码变成可执行程序,还需要进行链接操作。才会生成真正可以执行的可执行程序。链接操作最重要的步骤就是将函数库中相应的代码组合到目标文件中。
ld /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 hello.o -lc -lgcc -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -o hello
图5-1 Ubuntu下链接的命令
ELF各段信息如下:
图5-2 helloELF各段基本信息
使用edb加载hello,下图是在edb中能获取到的数据,和5.3中的对应关系:
hello相对于hello.o有如下不同:
重定位:链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时的地址。在hello到hello.o中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
图5-4 hello.o与hello的对比图
注:使用edb由于动态链接库的原因单步调试太久了,所以以上内容是使用gdb得出,以下列出我从edb获取到的一些动态链接的一些内容:
图5-5 hello运行过程中执行动态链接库过程
在dl_init前后
图5-6 _GLOBAL_OFFSET_TABLE_的变化对比图
链接(linking) 是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行千编译时(compile time), 也就是在源代码被翻译成机器代码时;也可以执行千加栽时Cload time), 也就是在程序被加栽器(loader)加载到内存并执行时;甚至执行于运行时(run time), 也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker) 的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
本章我们对链接的步骤和过程进行了详细的分解和解析,相信在以后处理相关的问题的时候能够不会手忙脚乱。
(第5章1分)
概念:一个执行中程序的实例。
作用:每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
作用:shell 是一个交互型的应用级程序,它代表用户运行其他程序。
处理流程:shell 执行一系列的读/求值(read /evaluate ) 步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
Shell通过调用fork 函数创建一个新的运行的子进程。也就是Hello程序,Hello进程几乎但不完全与Shell相同。Hello进程得到与Shell用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。Hello进程还获得与Shell任何打开文件描述符相同的副本,这就意味着当Shell调用fork 时,Hello可以读写Shell中打开的任何文件。Sehll和Hello进程之间最大的区别在于它们有不同的PID。
图6-1 Hello的fork进程创建进程图
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
图6-2 Hello的execve进程创建进程图
Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell 运行一个程序时,父shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段袚初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。
6.6.1 异常种类
hello执行过程中会出现的异常种类有:
6.6.2 命令的运行
1.ps:
图6-3 ps命令运行截屏
2.jobs:
图6-4 jobs命令运行截屏
3.pstree:
图6-5 pstree命令运行截屏
4.fg:发送SIGCONT信号继续执行停止的进程
图6-6 fg命令运行截屏
5.kill -9 pid:发送SIGKILL信号给指定的pid杀死进程
图6-6 kill命令运行截屏
异常控制流发生在计算机系统的各个层次。比如, 在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。
本章对异常控制流和信号的了解和认知可以使得我们更加了解异常的相关机制,是我们可以编写异常处理函数处理异常和为程序丰富功能,也可以使得我们在以后编写程序的时候尽量减少异常的发生。
(第6章1分)
逻辑地址:逻辑地址(LogicalAddress)是指由程序产生的与段相关的偏移地址部分。就是hello.o里面的相对偏移地址。
线性地址:地址空间(address space) 是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space) 。就是hello里面的虚拟内存地址。
虚拟地址:CPU 通过生成一个虚拟地址(Virtual Address, VA) 。就是hello里面的虚拟内存地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。就是hello在运行时虚拟内存地址对应的物理地址。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
图7-1 段选择符说明
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如下图:
图7-2 段选择符说明
其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:
图7-3 概念关系说明
下面是转换的具体步骤:
线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。如图:
图7-4 二级管理模式图
由上图可得:
1.分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
2.每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中
3.每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)
依据以下步骤进行转换:
1.从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器)。
2.根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
3.根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址。
4.将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址。
图7-5给出了Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
图7-5 TLB与四级页表支持下的VA到PA的变换
首先我们要先将高速缓存与地址翻译结合起来,首先是CPU发出一个虚拟地址给TLB里面搜索,如果命中的话就直接先发送到L1cache里面,没有命中的话就先在页表里面找到以后再发送过去,到了L1里面以后,寻找物理地址又要检测是否命中,这里就是使用到我们的CPU的高速缓存机制了,通过这种机制再搭配上TLB就可以使得机器在翻译地址的时候的性能得以充分发挥。
图7-6(a) 三级Cache支持下的物理内存访问示意图
图7-6(b) 三级Cache支持下的物理内存访问示意图
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
在虚拟内存的习惯说法中, DRAM 缓存不命中称为缺页(page fault) 。图7-7 展示了在缺页之前我们的示例页表的状态。CPU 引用了VP 3 中的一个字, VP 3 并未缓存在DRAM 中。地址翻译硬件从内存中读取PTE 3, 从有效位推断出VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4 。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4 的页表条目,反映出VP 4 不再缓存在主存中这一事实。
图7-7 缺页异常示意图
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
基本方法:这里指的基本方法应该是在合并块的时候使用到的方法,有最佳适配和第二次适配还有首次适配方法,首次适配就是指的是第一次遇到的就直接适配分配,第二次顾名思义就是第二次适配上的,最佳适配就是搜索完以后最佳的方案,当然这种的会在搜索速度上大有降低。
策略:这里的策略指的就是显式的链表的方式分配还是隐式的标签引脚的方式分配还是分离适配,带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。显式空间链表就是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
存储器系统(memory system) 是一个具有不同容量、成本和访问时间的存储设备的层次结构。CPU 寄存器保存着最常用的数据。靠近CPU 的小的、快速的高速缓存存储器(cache memory) 作为一部分存储在相对慢速的主存储器(ma in memory) 中数据和指令的缓冲区域。主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。存储器层次结构是可行的,这是因为与下一个更低层次的存储设备相比来说,一个编写良好的程序倾向千更频繁地访问某一个层次上的存储设备。所以,下一层的存储设备可以更慢速一点,也因此可以更大,每个比特位更便宜。整体效果是一个大的存储器池,其成本与层次结构底层最便宜的存储设备相当,但是却以接近于层次结构顶部存储设备的高速率向程序提供数据。作为一个程序员,你需要理解存储器层次结构,因为它对应用程序的性能有着巨大的影响。如果你的程序需要的数据是存储在CPU 寄存器中的,那么在指令的执行期间,在0个周期内就能访问到它们。如果存储在高速缓存中,需要4~7 5 个周期。如果存储在主存中,需要上百个周期。而如果存储在磁盘上,需要大约几千万个周期。
这里就是计算机系统中一个基本而持久的思想:如果理解了系统是如何将数据在存储器层次结构中上上下下移动的,那么就可以编写自己的应用程序,使得它们的数据项存储在层次结构中较高的地方,在那里CPU 能更快地访问到它们。
本章通过对hello在储存结构,高速缓存,虚拟内存涉及到的方面进行了详细的探索,通过对这些结构的了解我们可以以后编写一些对高速缓存友好的代码,或者说运行速度更快的代码,对我们来说都是受益匪浅。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
返回:若成功则为新文件描述符,若出错为-1。
2.进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
返回:若成功则为写的字节数,若出错则为-1。
先来分析一下printf函数:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
参数中明显采用了可变参数的定义,可以看到*fmt是一个char 类型的指针,指向字符串的起始位置。
再看一系列的va函数:
va_list arg_ptr;
void va_start( va_list arg_ptr, prev_param );
type va_arg( va_list arg_ptr, type );
void va_end( va_list arg_ptr );
首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。然后使用va_start使arg_ptr指针指向prev_param的下一位,然后使用va_args取出从arg_ptr开始的type类型长度的数据,并返回这个数据,最后使用va_end结束可变参数的获取。
再来看vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
int len;
int i;
char * str;
char *s;
int *ip;
int flags; /* flags to number() */
int field_width; /* width of output field */
int precision; /* min. # of digits for integers; max number of chars for from string */
int qualifier; /* 'h', 'l', or 'L' for integer fields */
for (str=buf ; *fmt ; ++fmt) {
if (*fmt != '%') {
*str++ = *fmt;
continue;
}
/* process flags */
flags = 0;
repeat:
++fmt; /* this also skips first '%' */
switch (*fmt) {
case '-': flags |= LEFT; goto repeat;
case '+': flags |= PLUS; goto repeat;
case ' ': flags |= SPACE; goto repeat;
case '#': flags |= SPECIAL; goto repeat;
case '0': flags |= ZEROPAD; goto repeat;
}
/* get field width */
field_width = -1;
if (is_digit(*fmt))
field_width = skip_atoi(&fmt);
else if (*fmt == '*') {
/* it's the next argument */
field_width = va_arg(args, int);
if (field_width < 0) {
field_width = -field_width;
flags |= LEFT;
}
}
/* get the precision */
precision = -1;
if (*fmt == '.') {
++fmt;
if (is_digit(*fmt))
precision = skip_atoi(&fmt);
else if (*fmt == '*') {
/* it's the next argument */
precision = va_arg(args, int);
}
if (precision < 0)
precision = 0;
}
/* get the conversion qualifier */
qualifier = -1;
if (*fmt == 'h' || *fmt == 'l' || *fmt == 'L') {
qualifier = *fmt;
++fmt;
}
switch (*fmt) {
case 'c':
if (!(flags & LEFT))
while (--field_width > 0)
*str++ = ' ';
*str++ = (unsigned char) va_arg(args, int);
while (--field_width > 0)
*str++ = ' ';
break;
case 's':
s = va_arg(args, char *);
len = strlen(s);
if (precision < 0)
precision = len;
else if (len > precision)
len = precision;
if (!(flags & LEFT))
while (len < field_width--)
*str++ = ' ';
for (i = 0; i < len; ++i)
*str++ = *s++;
while (len < field_width--)
*str++ = ' ';
break;
case 'o':
str = number(str, va_arg(args, unsigned long), 8,
field_width, precision, flags);
break;
case 'p':
if (field_width == -1) {
field_width = 8;
flags |= ZEROPAD;
}
str = number(str,
(unsigned long) va_arg(args, void *), 16,
field_width, precision, flags);
break;
case 'x':
flags |= SMALL;
case 'X':
str = number(str, va_arg(args, unsigned long), 16,
field_width, precision, flags);
break;
case 'd':
case 'i':
flags |= SIGN;
case 'u':
str = number(str, va_arg(args, unsigned long), 10,
field_width, precision, flags);
break;
case 'n':
ip = va_arg(args, int *);
*ip = (str - buf);
break;
default:
if (*fmt != '%')
*str++ = '%';
if (*fmt)
*str++ = *fmt;
else
--fmt;
break;
}
}
*str = '\0';
return str-buf;
}
从代码中不难看出这个函数的作用是把后面的参数加到字符串里面然后输出字符串的长度。
最后就是write函数了,我们先来看一下write的汇编代码:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
通过查询资料可以知道int INT_VECTOR_SYS_CALL的作用是调用sys_call这个函数,应该就是这个函数驱动了显示器,于是我们再追 踪一下这个函数的反汇编:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
可以看出代码里面的call是访问字库模板并且获取每一个点的RGB信息最后放入到eax也就是输出返回的应该是显示vram的值,然后系统显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
输入/输出(I/O) 是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O 设备复制数据到主存,而输出操作是从主存复制数据到I/O 设备。所有语言的运行时系统都提供执行I/O 的较高级别的工具。例如, ANSI C 提供标准I/O 库,包含像printf 和scanf 这样执行带缓冲区的I/O函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在Linux 系统中,是通过使用由内核提供的系统级Unix I/O 函数来实现这些较高级别的I/O 函数的。
了解Unix I/O 将帮助我们理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到I/O 和其他系统概念之间的循环依赖。例如, I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解I/O, 你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中,我们已经接触了I/O 的某些方面。既然你对这些概念有了比较好的理解,我们就能闭合这个循环,更加深入地研究I/O 。有时你除了使用Unix I/O 以外别无选择。在某些重要的情况中,使用高级I/O 函数不太可能,或者不太合适。例如,标准I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。
本章我们就hello里面的函数对应unix的I/O来细致地分析了一下I/O对接口以及操作方法,这有助于我们以后在写函数的时候在标准I/O 库没有的时候我们可以编写自己的I/O函数。
(第8章1分)
用计算机系统的语言,逐条总结hello所经历的过程。
我对计算机系统的设计实现的深切感悟就是,计算机系统是一个平衡求稳的一个系统,考虑到了很多的现实问题,在硬件条件有限的情况下尽可能地发挥出来计算机最大的性能,在CS:APP里面让我印象很深的一句话就是内存和厨房里的垃圾桶一样,不管你用不用都是稀缺资源,从中足以可见计算机系统设计人员在其中投入了多少的精力和脑力才能设计出如此精妙完善的一个系统来。我的创新理念就是我觉得在设计计算机系统的时候很多东西其实是没有理论可以说明的,可以往概率的方向发展,利用概率来设置一些策略,比如说malloc里面的适配,有首次适配,有第二次适配,会不会有更多次的适配会有更好的结果呢,我想通过超级计算机加上软件方面的实现可以实现这一方面的设想。
(结论0分,缺少 -1分,根据内容酌情加分)
hello.c |
hello的源代码 |
hello.i |
预处理后的hello.c的代码 |
hello.s |
hello.i编译后的代码 |
hello.o |
hello.s汇编后的代码 |
hello |
hello.o链接后的代码 |
asm.txt |
hello.o的反汇编文件 |
asmelf.txt |
hello的反汇编文件 |
(附件0分,缺失 -1分)
[1] C语言再学习 -- GCC编译过程:https://blog.csdn.net/qq_29350001/article/details/53339861
[2] 深入理解计算机系统(1.1)------Hello World 是如何运行的:http://www.cnblogs.com/ysocean/p/7497468.html
[3] 深入理解计算机系统(3.1)------汇编语言和机器语言:https://www.cnblogs.com/ysocean/p/7580162.html
[4] 百度百科:机器语言:https://baike.baidu.com/item/%E6%9C%BA%E5%99%A8%E8%AF%AD%E8%A8%80/2019225?fr=aladdin
[5] LINUX 逻辑地址、线性地址、物理地址和虚拟地址 转:https://www.cnblogs.com/zengkefu/p/5452792.html
[6] Linux内核中的printf实现https://blog.csdn.net/u012158332/article/details/78675427
[7] [转]printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)