摘 要
本论文简短的介绍了对于每个程序员来说最熟悉的伙伴—hello程序的一生,从预处理,编译,汇编,链接,再到进程管理,存储管理,IO管理。这短短的过程却蕴含着人生哲理!
主要在Ubuntu下进行相关操作,运用了一些Ubuntu下的操作工具,进行细致的历程分析,加深了自己对于计算机系统的理解,也对人生有了新的感触!
关键词:hello程序;程序人生;计算机系统;Ubuntu
P2P:
当你按下键盘进行代码的编写并存储hello.c时,一个hello的生命也就此开始(Program),当这个hello.c程序进行预处理,编译,汇编,链接后变成一个可执行目标文件存储在计算机中。
因为我们是在Linux的环境下进行本实验,那么一个hello在计算机中驰骋就主要依赖于Shell(本实验中为Bash),OS将hello进行fork(Process),再进行execve、mmap、分配时间片,最终我们的hello就可以在硬件上驰骋并且在Shell中打下那个我们魂牵梦绕的Hello World!
020:
从最开始的空无一物(Zero),到最后执行结束后OS和Bash在hello完美谢幕后为其收尸(Zero),这其中经历了诸多苦难。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,vim,edb,readelf,HexEdit
本章主要简单的介绍了P2P和020的基本概念,开发环境以及简要介绍一下本次实验的中间结果。
预处理概念
预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
预处理作用
1.处理宏定义指令预处理器根据#if和#ifdef等编译命令及其后的条件,将源程序中的某部分包含进来或排除在外,通常把排除在外的语句转换成空行。
2.处理条件编译指令条件编译指令如#ifdef,#ifndef,#else,#elif,#endif等。 这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
3.处理头文件包含指令头文件包含指令如#include "FileName"或者#include <>等。该指令将头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。
4.处理特殊符号预编译程序可以识别一些特殊的符号。例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
可以看到在hello.i文件中,对hello.c中的头文件进行解析,主要是将#include <stdio.h>、#include <unistd.h> 、#include <stdlib.h>所解析出的代码加在了hello中。
本章主要探究了预处理时hello.c到hello.i的过渡过程,可以看到预处理主要处理了一些宏定义、条件编译并将其整合在*.i文件中。
编译概念
编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。这个过程称为编译,同时也是编译的作用。
编译作用
是把源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
1.字符串
可以看到字符串是存储在只读数据段(.rodata)的。
2.局部变量
hello.c中有一个局部变量i,可以看到局部变量是存储在堆栈段的,因为是int类型,开辟了一段4字节的空间。
3.全局变量
说明main是一个全局变量。
两种颜色的框分别表示对argv[1]和argv[2]数据的获取。
关系操作主要是依赖于cmpxx和jxx的语句来完成。
控制转移类似于goto语句,使用类似jmp .L3的语句来实现。
调用函数时有以下操作:(假设函数P调用函数Q)
(1)传递控制:进行调用 Q 的时候,PC设置为 Q 的代码的起始地址,将P中调用Q后面的那条语句地址入栈,返回时,出栈,将PC设置为P中调用Q后面的那条语句地址。
(2)传递数据:P能够向Q提供一个或多个参数,Q 能够向P中返回一个值。
(3)分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
hello.C涉及的函数操作有:
main、printf、exit、sleep、getchar函数
如上图,主要依赖于call语句实现函数操作。
atoi将字符串转换为整数。
本章主要探究了编译阶段hello.i到hello.s的转变,更深入理解了编译器对数据的一些操作。
汇编概念
汇编指的是汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。hello.o文件是一个二进制文件,包含hello程序执行的机器指令。
汇编作用
实现将汇编代码转换为机器指令,使之在链接后能够被计算机直接执行。
ELF头
ELF头:以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
节头部表
节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
符号表
存放程序中定义和引用的函数和全局变量的信息。name是符号名称,对于可冲定位目标模块,value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。size是目标的大小,type要么是数据要么是函数。Bind字段表明符号是本地的还是全局的。
hello.o反汇编于与hello.s对比
通过对比可以发现二者并没有很大的不同,只是反汇编代码不仅仅有汇编语言,还有机器语言代码,机器语言程序的是二进制的机器指令序列集合,是纯粹的二进制数据表示的语言,是电脑可以真正识别的语言。机器指令由操作码和操作数组成。汇编语言是以人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的较为通俗的比较容易理解的语言。在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。机器语言与汇编语言具有一一对应的映射关系,一条机器语言程序对应一条汇编语言语句,但不同平台之间不可直接移植。
二者主要不同如下:
1.反汇编的跳转指令用的是确定的地址而不是类似.L1之类的信息。
2. 反汇编还给出了重定位信息,可以看到反汇编的call指令的机器代码后面都是00 00 00 00,这就是在等待链接为其重定位具体的地址。
本章主要探究了汇编阶段hello.s到hello.o文件的过渡,并且分析了可重定位目标文件的ELF文件的相关信息,比较了反汇编代码和*.s代码之间的差异。
链接概念
链接是指将文件中调用的各种函数跟静态库及动态库链接,并将它们打包合并形成目标文件,即可执行文件。
链接作用
通过链接可以实现将头文件中引用的函数并入到程序中。
ELF头
符号表
节头部表
可以看到ELF文件从0x400000到0x400ff0
可以根据节头部表来得到不同段的地址:
.text节
.rodata节
hello与hello.o不同
(1) hello的反汇编代码有确定的虚拟地址,已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0x00000000,未完成重定位。
(2) hello反汇编代码中多了很多节以及很多函数的汇编代码。
重定位过程
(1) 重定位节和符号定义链接器将所有类型相同的节合并在一起,这个节作为可执行目标文件的节,然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2) 重定位节中的符号引用这一步中,链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。
(3) 重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt。
重定位条目如下:
重定位的公式如下所示:
下进行举例说明:
上图中说明了atoi函数是一个64位相对引用,先来看看ADDR(s) = ADDR(main) = 0x400582,r.offset = 0x6d,先计算refaddr = ADDR(s) + r.offset = 0x4005ef。又已知:r.symbol = atoi、r.addend = -0x4,接下来我们可以查看ADDR(atoi):可以看到是0x400520
而*refptr = (unsigned)(ADDR(atoi) + r.addend - refaddr) = (unsigned)(-211) = 0xffffff2d,按照小端序:2d ff ff ff
可以看到完全符合!
子程序名:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
libc-2.27.so!__cxa_atexit
libc-2.27.so!__libc_csu_init
libc-2.27.so!_setjmp
hello!main
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!sleep@plt
hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave
ld-2.27.so!_dl_fixup
ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据上图可以看到hello里,GOT表的初始位置为0x601000
由上图可以看到程序还未启动时,GOT表的内容
我们点下启动,GOT表发生了改变。可以看到GOT[0] = 0x7f8fc9b4f170、GOT[1] = 0x7f8fc993b820,包含了动态链接器在解析函数地址时会用到的信息。GOT[2] = 0x4004f6,这是动态链接器在ld_linux.so模块中的入口点。
本章主要探究了在链接过程中可执行目标文件的ELF文件的相关信息以及重定位过程。
进程概念
是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情 况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数 据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量。
进程作用
进程为用户提供了以下假象:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
shell-bash的作用
shell-bash是一个C语言程序,它代表用户执行进程,它交互性地解释和执行用户输入的命令,能够通过调用系统级的函数或功能执行程序、建立文件、进行并行操作等。同时它也能够协调程序间的运行冲突,保证程序能够以并行形式高效执行。bash还提供了一个图形化界面,提升交互的速度。
shell-bash的处理流程
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
(3)检查第一个命令行参数是否是一个内置的shell命令。
(4)如果不是内部命令,调用fork()创建新进程/子进程。
(5)在子进程中,用步骤2获取的参数,调用execve()执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回。
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
以hello为例,当输入 ./hello 1190201423 顾海耀 1 的时候,首先shell对输入的命令进行解析,由于输入的命令不是一个内置的shell命令,因此shell会调用fork()创建一个子进程。
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
(1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域:如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器(PC):exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
上下文:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象比如描述地址空间的页表,包含当前进程有关信息的进程表,以及包含进程以打开文件的信息的文件表构成。在进程执行到某些时刻,内核可以决定抢占当前进程,并开始一个之前被抢占的进程开始执行,这种决策就叫做调度,由调度器来决定,在内核调度了一个新的进程后就会发生上下文切换的操作。
上下文切换的过程:1.保存当前进程的上下文2.恢复现在调度进程的上下文3.将控制传给新恢复进程。
逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。
用户模式和内核模式:处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
当执行hello程序时,控制流在hello内处于用户模式,调用系统函数sleep后,进入内核态,此时间片停止,1s后(命令行输入参数为一秒),发送中断信号,转回用户模式,继续执行指令。
异常分类:
1.中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
2.陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。
3.故障:在执行hello程序的时候,可能会发生缺页故障。
4.终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
信号:
正常执行:
不停乱按:
按回车
Ctrl-Z
Ctrl-C
Ctrl-Z + ps
Ctrl-Z + jobs
Ctrl-Z + pstree
Ctrl-Z + fg
Ctrl-Z + kill
本章主要描述了进程和shell,并且根据对hello程序进行一系列折腾,让我对进程和shell有了更深入的理解。
逻辑地址(Logical Address) 是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以读取指针变量本身值(&操作),实际上这个值就是逻辑地址,它是相对于当前进程数据段的地址,不和绝对物理地址相干。只有在Intel实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,CPU不进行自动地址转换);逻辑也就是在Intel 保护模式下程序执行代码段限长内的偏移地址(假定代码段、数据段如果完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制是完全透明的,仅由系统编程人员涉及。应用程序员虽然自己可以直接操作内存,那也只能在操作系统给你分配的内存段操作。
线性地址(Linear Address) 是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386的线性地址空间容量为4G(2的32次方即32根地址总线寻址)。
物理地址(Physical Address) 是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟内存(Virtual Memory) 是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。在Linux 0.11内核中,给每个程序(进程)都划分了总容量为64MB的虚拟内存空间。因此程序的逻辑地址范围是0x0000000到0x4000000。
如上图所示:首先根据段选择符的TI 部分判断需要用到的段选择符表是全局描述符表还是局部描述符表,随后根据段选择符的高 13 位的索引(描述符表偏移)到对应的描述符表中找到对应的偏移量的段描述符,从中取出 32 位的段基址地址,将32 位的段基址地址与 32 位的段内偏移量相加得到 32 位的线性地址。
实模式下:逻辑地址 CS: EA=物理地址 CS * 16 + EA
保护模式下:以段描述符作为下标,到 GDT/LDT 表查表获得段地址,段地址+偏移地址=线性地址。
如上图,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组,磁盘上数组的内容被缓存在物理内存中
于是构建一个页表,页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。
如上图说明了VA向PA的转换:VA分为p位VPO和n-p位VPN,PTBR将当前进程的物理页表地址发送给位于DRAM中的页表,通过比较VPN中的有效位,读出PPN,而PPO=VPO,于是就可以得到PA。
可以看到使用TLB快表之后将VPN分为TLBI和TLBT,而使用TLBI直接在TLB里查询物理页号即可,而TLB在cache中,所以可以加快转换速度。
如上图是一个二级页表的例子,可以通过这种方式有效减少TLB的内存占用。
在Core i7中48位虚拟地址分为36位的虚拟页号以及12位的页内偏移。
四级页表中包含了一个地址字段,它里面保存了40位的物理页号(PPN),这就要求物理页的大小要向4kb对齐。
四级页表每个表中均含有512个条目,故计算四级页表对应区域如下:
第四级页表:每个条目对应4k区域;第三级页表:每个条目对应4kb512=2MB区域,共512个条目;第二级页表:每个条目对应2MB512 = 1GB区域,共512个条目;第一级页表:每个页表对应1GB*512 = 512GB区域,共512个条目。
如下图所示:从VA中分出36位的VPN并根据其中的TLBI索引到对应的TLB组,结合TLBT找到对应的行并判断 TLB 是否命中。若是命中,则取出其中的 PPN(物理页号);否则转到页表索引。
将VPN分为四段,每段9位,里面保存的是对应页表的偏移量。从第一级页表开始索引,找到对应的 PTE 条目,从中取出相应的第二级页表的首地址。这个首地址加上VPN2的偏移即得到第二级 PTE,取出其中的内容即为第三级页表的首地址……以此类推从第四级页表中取出的即为 PPN(物理页号)将前面得到的 PPN 与VPO相加就可以得到虚拟地址翻译对应的物理地址
MMU发送物理地址PA给L1 缓存,L1缓存从物理地址中抽取出缓存偏移CO、 缓存组索引CI以及缓存标记CT。高速缓存根据CI找到缓存中的一组,并通过CT判断是否已经缓存地址对应的数据,若缓存命中,则根据偏移量直接从缓存中读取数据并返回;若缓存不命中,则继续从L2、L3缓存中查询,若仍未命中,则从主存中读取数据。
当fork函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的一个在后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
exceve函数加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域
2.映射私有区域
为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域
比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)
exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
缺页故障:当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面 到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。
动态内存分配器维护者一个进程的虚拟内存区域,成为堆。(如图7.9.1所示),分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格。两种风格都是要求显示的释放分配块。
(1) 显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
显示分配器的约束条件a.处理任意的请求序列b.立即相应请求c.只使用堆d.对齐块(对齐要求) e.不修改已分配的块
(2) 隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
a.放置已分配的块当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
b.分割空闲块一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。
c.获取额外堆内存如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
d.合并空闲块合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。Knuth提出了一种采用边界标记的技术快速完成空闲块的合并。
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cache的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
设备的模型化:文件
设备管理:Unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:UNIX I/O。
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转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flag参数指明了进程打算如何访问这个文件,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的当前文件位置。
首先查看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; }
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
接下来反汇编write函数:
write: mov eax, _NR_write mov ebx, [esp + 4] mov ecx, [esp + 8] int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf , i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
下面查看syscall的函数体:
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
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是打印字符串就显示在了屏幕上。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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; }
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成 ASCII码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章主要探究了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数的实现。
hello.c:这应该是hello最开始的样子,程序员小哥哥在键盘敲下一行行代码,构建起了最开始的hello.c文件。
hello.i:我们开始本次旅途,首先是预处理器来对我们写好的hello.c进行处理,前三行:
计算机也不认识这几行是啥意思,并且后面调用像printf这样的函数,程序员也没有写这个函数,编译器也不知道这个是啥,于是预处理器就来发挥作用了,它把这些头文件进行扩展,并且会把一些条件编译指令、宏定义进行处理。
hello.s:接下来就到了编译器的主场,因为我们知道计算机是没有办法识别我们写的程序的,计算机只认识机器语言,而我们采取的方式是用汇编语言进行过渡,于是编译器的作用就说产生汇编语言文件。
hello.o:再接下来就到了汇编器的部分,它可以产生重定位目标文件,这种文件时ELF形式的,基本具有统一的结构,也是方便计算机执行。
hello:最后就到了可执行目标文件了,这个就说最后的hello文件,我们只需要在shell里输入“./hello”即可在屏幕看到我们的结果。
hello与许多进程并行执行,执行过程中由于系统调用或者计时器中断,会导致上下文切换,内核会选择另一个进程进行调度,并抢占当前的 hello 进程。
hello执行的过程中可能收到来自键盘或者其它进程的信号,当收到信号时hello会调用信号处理程序来进行处理,可能出现的行为有停止终止忽略等。
hello 输出信息时需要调用 printf 和 getchar,而 printf 和 getchar 的实现需要调用 Unix I/O 中的 write 和 read 函数,而它们的实现需要借助系统调用。
hello 中的访存操作,需要经历逻辑地址到线性地址最后到物理地址的变换,而访问物理地址的数据可能已被缓存至高速缓冲区,也可能位于主存中,也可能位于磁盘中等待被交换到主存。
hello 结束进程后, bash 作为 hello 的父进程会回收 hello 进程,至此 hello的一生至此结束~
回顾最简单的hello程序,这个小小的程序却包含如此多的知识,实在是绝知此事要躬行,计算机系统真的是一个庞大又精密的系统啊!
为完成本次大作业你翻阅的书籍与网站等
[1] https://www.cnblogs.com/pianist/p/3315801.html
[2] 深入理解计算机系统,Computer Systems:A Programmer’s Perspective (美)布赖恩特(Bryant,R.E.)等
[3] https://blog.csdn.net/taocr/article/details/52433614
[4] https://blog.csdn.net/jason314/article/details/5640969
[5] https://blog.csdn.net/ylcangel/article/details/18188921