本篇文章分析了hello的一生,从它编写完成到预处理、编译、汇编、链接生成可执行文件,再创建进程,执行程序,最后程序执行结束释放资源的过程。通过hello的经历,更生动具体的理解计算机系统,了解其各部分的分工合作以及他们各自的功能和实现方式。
关键词:hello;编译;汇编;预处理;链接;进程管理;存储管理;IO管理
目 录
第1章 概述
1.1 Hello简介
- P2P:(from program to process)程序员编写出hello的原始C语言代码,保存为hello.c,作为一个program,然后经过预处理、编译、汇编、链接得到可执行文件,在shell中,进程管理为hello创建进程并加载可执行文件,得到运行中的hello进程,这就是hello从代码变成进程的过程。
- 020:(From Zero to Zero)一开始hello程序执行前,是“0”,执行了上面所述的处理过程,操作系统将其加载到内存中作为一个进程来运行,一段时间后程序执行结束,该进程会保持在一种已终止的状态中,直到该进程被其父进程也就是回收然后退出,在内存中不留痕迹,这也代表它又变为了“0”了。这就是hello的从无到有再到无的过程。
1.2 环境与工具
X64 CPU(Core i7-8550U 1.80GHz),8GB RAM,512GB固态硬盘
Windows 10 64位;Ubuntu20.04 64位
开发与调试工具:gcc,edb;
1.3 中间结果
hello.i hello.c预处理的文本文件
hello.s hello.c编译后的汇编文件
hello.o hello.s汇编后的可重定位文件
hello.elf hello.o的elf格式文件
Helloe.elf hello的elf格式文件
hello 可执行目标文件
1.4 本章小结
本章内容主要有简述Hello的P2P,020的过程,软硬件环境,以及开发与调试工具,以及介绍生成的中间结果文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位——(用C/C++的术语来说是)预处理记号(preprocessing token)用来支持语言特性(如C/C++的宏调用)。
作用: 预处理可以处理条件编译指令,头文件指令,特殊符号等。对于c/c++的预处理操作,预处理中会展开以#起始的行,试图解释为预处理指令(preprocessing directive),其中ISO C/C++要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。
2.2在Ubuntu下预处理的命令
gcc hello.c -E -o hello.i
2.3 Hello的预处理结果解析
发现hello.i远远多于hello.c的代码数量
由于预处理操作对原文件中的宏进行了宏展开,hello.i大部分代码都是被include的头文件的源码内容:
2.4 本章小结
本章介绍了预处理的概念和作用,并且在linux系统下对hello.c文件进行了预处理操作。对比分析hello.i和hello.c具体的看到了预处理都干了什么。预处理操作对原文件中被include包含的头文件进行了扩充展开,并且进行了一些其他处理,比如将定义的宏进行符号替换,根据指令进行选择性编译等。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是指编译器(ccl)将预处理生成的后缀.i的文件进行编译,生成后缀.s文件的过程。编译器将预处理得到的文件进行语法分析,无错误后将预处理后得到的代码翻译为汇编语言,生成汇编程序文件。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1数组
hello.c中有char类型的指针数组:
编译器将其存到栈中
3.3.2整型局部变量,赋初值
hello.c中有int类型的局部变量i:
编译器赋值并将其存到栈中
3.3.3字符串
hello.c中有需要打印的字符串,编译处理时将这两个字符串常量声明在.rodata节中:
3.3.4关系操作
hello.c中有一些关系操作:
编译处理时,用cmp表示
3.3.5控制转移
hello.c中有if和for导致的控制转移:
If中argc与4比较大小,相等跳到L2;
Fou中i与8比较大小,若i小于等于7,跳转到L4;
3.3.6算术操作
hello.c中有i的不断加一的过程:
在编译过程中:
3.3.7函数操作
hello.c中有exit()和sleep()、printf()、getchar()函数
printf()函数:将字符串常量的首地址存入寄存器作为参数传递,并使用call调用;
exit()函数:将1存入寄存器作为参数传递,并使用call调用;
sleep()函数:将规定位置的值存入一个新的寄存器作为参数,使用call调用。
3.4 本章小结
本章介绍了编译的概念与作用,以及在linux系统下编译的命令,较为详细的分析了hello代码进行编译后的汇编程序与原代码各部分的对应,以及分析了各种运算和操作是如何通过汇编码来实现的。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指从 .s 到 .o 即从编译后的文件到生成机器语言二进制程序的过程。在这个过程中,汇编器将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件(一个包含程序指令编码的二进制文件)中。
作用:汇编的主要作用是将汇编代码转换为能够直接被CPU执行的机器码的形式,使其在链接后能被机器识别并执行。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
4.3 可重定位目标elf格式
命令:readelf -a hello.o > hello.elf生成elf文件
ELF头(ELF header)以16字节的序列Magic开始,这个序列中的7f,45,4c,46分别对应ASCII码的Del,E,L,F,还包括了机器的相关信息,小/大端序,版本号等。
节头部表:目标文件中的每个节都有一个固定的条目体现在这个表中,指明了各个节的信息,包括名称、类型、起始地址和偏移量。可以使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及所占空间的大小。
重定位节记录了各段引用的符号的相关信息。在链接时,可以通过重定位节对这些位置的地址进行重定位。即在重定位的过程中,链接器结合重定位节中的偏移量和其他信息来计算正确的地址。
符号表用来存放在程序中定义和引用的函数和全局变量的信息。Value字段是符号相对于目标节的起始位置偏移;Size字段代表目标的大小;Type字段是函数或数据类型;Bind字段表明符号是本地的还是全局的;Ndx Name字段是符号的名称。
4.4 Hello.o的结果解析
1.hello.s中的L2等标志在hello.o中消失了,跳转方式变成通过相对偏移地址
2.hello.o中立即数是16进制,而hello.s是10进制
3..函数调用时,hello.s上为函数名,在这里call指令后是调用函数的相对偏移地址。
4.5 本章小结
本章介绍了汇编的概念和作用,以及在linux系统下由汇编语言程序转换为可重定位目标文件的过程,分析了可重定位目标文件的结构和内容,以及hello.o和hello.s的区别。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是指将各种代码和数据片段收集并组合为一个单一的可执行文件的过程,链接可以执行于编译时,加载时,或者运行时。在现代系统中,链接由链接器自动执行。
作用:链接这一过程使得分离编译成为可能。我们可以对于同一个程序分割为若干独立模块,为其编写不同的源代码,分别独自编译为目标文件或库,最终将其链接起来。当我们改变这些模块中的一个时,只需要简单的重新编译它,并重新链接,不需要重新编译其他文件。
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
5.3 可执行目标文件hello的格式
生成elf文件
1.EIF头
2.节头
3.程序头
4.符号表
5.4 hello的虚拟地址空间
1.从程序头可发现LOAD可加载的程序段地址为0x0000000000400000
前四个段是hello可执行文件本身的代码和数据,对应的是5.3中程序头的四个LOAD段。紧随其后的四行是来自hello加载的动态链接库ld-2.31.so。
打开Data Dump可知开始是ELF头,我们可以找到各段的信息;
可知.interp的虚拟地址
.plt节信息
5.5 链接的重定位过程分析
1.hello.o的汇编代码中只出现了main函数的名字,而hello的汇编代码中出现了_init,.plt等函数名。这体现了很多非hello.c源程序中生命的函数被链接到了hello中。
2.在hello.o汇编代码中很多数据和函数地址不确定,在hello汇编代码中地址确定下来。如.plt函数的地址为0x401020。
链接器首先组织所有模块的节,将所有类型相同的节合并在一起,然后把运行时的内存地址赋给新的合并得到的聚合节,以及输入模块定义的每个节和符号。
在重定位过程中,链接器进行符号解析,关联每个符号引用和符号定义,进行重定位时,使用汇编器产生的重定位条目,把符号定义和一个内存位置关联起来,使每条指令和全局变量拥有唯一的运行时地址。最终,就得到了一个可执行目标文件。
5.6 hello的执行流程
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
hello!_init
hello!main
hello!puts@plt
hello!exit@plt
hello!printf@plt
hello!sleep@plt
hello!getchar@plt
sleep@plt
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。延迟绑定通过两个数据结构之间简洁但又有些复杂的交互来实现,即过程链接表(PLT)和全局偏移量表(GOT)。
由hello.elf可知,GOT起始位置为0x40400
执行dl_init之前:
执行dl_init之后:
可见发生了变化。
5.8 本章小结
本章简单介绍了链接的概念与作用,在linux下链接的命令,可执行文件hello的格式,hello的虚拟地址空间,分析了链接的重定位过程和hello的执行流程,进行了hello的动态链接分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:进程为用户提供了这样的假象,我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断地执行我们程序中地指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell俗称壳,是指"为使用者提供操作界面"的软件。同时它又是一种程序设计语言。作为命令语言,它交互式解释和执行用户输入的命令或者自动地解释和执行预先设定好的一连串的命令。它作为用户操作系统与调用其他软件的工具。
处理流程:
(1)从终端读入输入的命令。
(2)将输入字符串切分,分析输入内容,解析命令和参数。
(3)如果命令为内置命令则立即执行,如果不是内置命令则创建新的进程调用相应的程序执行。
(4)在程序执行期间始终接受键盘输入信号,并对输入信号做相应处理。
6.3 Hello的fork进程创建过程
输入命令后,由于hello不是一个内置命令,故解析后执行当前目录下的可执行目标文件hello,shell作为父进程通过fork函数为hello创建一个新的进程作为子进程。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本。当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。fork函数调用一次,返回两次,在父进程中,fork返回子进程的PID,在子进程中,返回0。
6.4 Hello的execve过程
子进程创建后,shell调用execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。之后当出现错误时,例如找不到hello,execve才会返回到调用程序。与fork 一次调用返回两次不同,execve 调用一次并不返回。
在execve加载了hello后,它调用启动代码,启动代码设置栈,并将控制转移给新程序的主函数main,此时用户栈已经包含了命令行参数和环境变量,进入main函数后开始逐步运行程序。
6.5 Hello的进程执行
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象构成。
时间片:一个进程执行它的控制流的一部分的每一个时间段。
调度:在执行过程中,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。
逻辑控制流:即使在系统中通常有许多其他程序正在运行,进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果使用调试器单步调试执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
用户态:进程运行在用户模式中时,不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
核心态:进程运行在内核模式中时,可以执行指令集中的任何指令,并且可以访问内存中的任意位置。
用户态与核心态转换:程序在涉及到一些操作时,例如调用一些系统函数,内核需要将当前状态从用户态切换到核心态,执行结束后再改回用户态。
hello执行时存在逻辑控制流,多个进程的逻辑控制流在时间上可以交错,表现为交替运行。进程控制权的交换需要上下文切换。操作系统内核使用一种成为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
例如hello中对sleep的调用,内核中的调度器将hello进程挂起,进入内核模式,在执行结束后,内核会恢复hello被抢占时的上下文,回到用户模式。
6.6 hello的异常与信号处理
1.hello执行过程中会出现四种异常:中断、陷阱、故障、终止。
2.hello执行过程中会出现的信号:SIGSTP、SIGCONT、SIGKILL、SIFGINT等。
3.(1)中断是来自I/O设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
(2)陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
(3)故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
(4)终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
4.(1)正常运行
- 不停乱按:不影响程序运行
- 回车
- Ctrl-Z:shell父进程收到SIGSTP信号,信号处理函数将hello进程挂起
- Ctrl-C:shell父进程收到SIGINT信号,信号处理函数将hello进程终止并回收
- Ctrl-z+ps:输出当前系统中的进程
- Ctrl-z+jobs:输出当前已启动的任务状态
- Ctrl-z+pstree:输出进程树
- Ctrl-z+fg:发送SIGCONT信号继续执行hello
- Ctrl-z+kill:发送SIGKILL信号杀死hello
6.7本章小结
本章简单介绍了进程的概念和作用,shell的作用和流程,结合fork和execve函数说明了hello进程的执行过程,最后分析了hello的异常与信号处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1. 物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。CPU通过地址总线的寻址,找到真实的物理内存对应的地址。在前端总线上传输的内存地址都是物理内存地。
2. 逻辑地址:是指由程序产生的与段相关的偏移地址部分,它表示为[段标识符:段内偏移量]的形式,由一个段标识符再加上段内相对地址的偏移量构成。
3. 虚拟地址:虚拟地址是CPU启动保护模式后,程序访问存储器所用的逻辑地址
4. 线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址然后加上基地址就是线性地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是实现逻辑地址到线性地址转换机制的基础,段的特征有段基址、段限长、段属性。这三个特征存储在段描述符中,用以实现从逻辑地址到线性地址的转换。段描述符存储在段描述符表中,通常,我们使用段选择符定位段描述符在这个表中的位置。每个逻辑地址由16位的段选择符和32位的偏移量组成。
段基址规定了线性地址空间中段的开始地址。在保护模式下,段基址长32位。因为基址长度和寻址地址的长度相同,所以段基址可以是0-4GB范围内的任意地址。
和一个段有关的信息需要8个字节来描述,这就是段描述符。为了存放这些描述符,需要在内存中开辟出一段空间。在这段空间里所有的描述符都在一起集中存放,这就构成了一个描述符表,描述符表分为两种,GDT和LDT。
一些全局的段描述符,就放在"全局段描述符表(GDT)"中,一些局部的,例如每个进程自己的段描述符,就放在的"局部段描述符表(LDT)"中。
介绍一个完整的变换过程,给出一个完整的逻辑地址[段选择符:段内偏移地址]。首先看段选择符判断当前转换时GDT中的段还是LDT中的段,再根据相应寄存器得到其地址和大小。之后拿出段选择符中的前13位,在对应地址中查找到对应的段描述符,这样就知道了基址。根据基址和偏移量结合,就得到了所求的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
分页机制是实现虚拟存储的关键,位于线性地址与物理地址的变换之间设置。虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页为大小固定的块来处理这个问题。每个虚拟页的大小固定。类似地,物理内存被分割为物理页,大小与虚拟页相同。
同任何缓存一样,虚拟内存系统必须用某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM,替换这个牺牲页。
页表是一个存放在物理内存中的数据结构,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时读取页表。操作系统负责维护页表中的内容,以及再磁盘与DRAM之间来回传送页。
内存分页管理的基本原理是将整个内存区域划分成固定大小的内存页面。程序申请使用内存时就以内存页位单位进行分配。转换通过两个表,页目录表PDE(也叫一级目录)和二级页表PTE。进程的虚拟地址需要首先通过其局部段描述符变换为CPU整个线性地址空间中的地址,然后再使用页目录表和页表PTE映射到实际物理地址上。
7.4 TLB与四级页表支持下的VA到PA的变换
1. TLB:CPU每次产生一个虚拟地址,内存管理单元MMU都需要查询一个页表条目。在最差情况下会从内存中多取一次数据,代价将高达几十到几百个周期。如果页表条目PTE缓存在L1中,开销会下降到1-2个周期。许多系统试图消除这样的开销,所以在MMU中包含了一个关于PTE的小的缓存,即翻译后备缓存器TLB。
2.多级页表:多级页表是用于压缩页表的层次结构。从两个方面减少内存要求:1.一级页表中如果有PTE为空,二级页表就不存在。2.一级页表需要总是在主存中,虚拟内存系统在需要时创建,页面调出或调入二级页表,减少主存压力。
3.VA到PA的变换:
以课本上Core i7地址翻译为例:
Core i7采用四级页表的层次结构,每个进程有它自己私有的页表层次结构。CPU产生虚拟地址,这个虚拟地址传送给内存管理单元MMU,MMU使用虚拟页号的前36位在缓存页表中寻找匹配。如果命中,则得到物理页号与虚拟页偏移量组合成的物理地址。如果没有命中,则MMU查询页表,确定第一级页表的起始地址,图中VPN1确定在第一级页表中的偏移量,查询出页表条目,如果在物理内存中且权限符合,则确定第二级页表的起始地址。以此类推,最终在第四级页表中得到组合成的物理地址。
7.5 三级Cache支持下的物理内存访问
MMU发送物理地址PA给L1缓存,L1缓存从物理地址中抽取出缓存标记CT、缓存组索引CI以及缓存偏移CO。高速缓存根据CI找到缓存中的一组,并通过CT判断是否已经缓存地址对应的数据,若我们找到标记为等于CT的Cache行,且这个行存在且有效位为1,则缓存命中,取出偏移量为CO的字节,并传递给CPU。如果缓存未命中,则继续到L2中寻找,L2未命中到L3中,L3未命中到主存中寻找。
7.6 hello进程fork时的内存映射
Shell通过fork函数为hello创建新进程,当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤。
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码,数据,bss和栈区域创建新的区域结构。所有这些新的区域都是私有的,写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。Bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为0。
3.映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。最后一件事情就是设置当前上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
物理内存缓存不命中称为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并没有缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,说明这个虚拟页没有被缓存,触发一个缺页故障。
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。处理程序执行如下步骤:
1.检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。
2.检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,程序终止。
3.两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种:显式分配器和隐式分配器。显式分配器要求应用显式地释放人设已分配地块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾回收器,而自动释放未使用的已经分配的块的过程叫做垃圾收集。
malloc使用的是显式分配器,通过free函数释放已分配的块。
1.隐式空闲链表:这种情况下,一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小任意。空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构为隐式空闲链表。
2.显示空闲链表 :显示空闲链表是将空闲块组织为某种形式的显示数据结构。堆被组织为双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
一种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并可以在常数时间内完成。
按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章介绍了hello的存储器地址空间、Intel逻辑地址到线性地址的变换-段式管理
、Hello的线性地址到物理地址的变换-页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射,以及缺页故障与缺页中断处理和动态存储分配管理。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O接口的统一操作:
(1)打开文件。一个应用程序要求通过内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个标识符。
(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
(3)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开始的字节偏移量。应用程序能够通过执行seek操作,现显式地设置文件的位置为k。
(4)读写文件。一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k≥m时执行读操作会触发一个称为end-of-file的条件,应用程序能够检测到这个条件。在文件结尾处并没有明确的"EOF符号"。
(5)关闭文件。当应用程序完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。
Unix I/O接口函数:
(1)int open(char* filename,int flags,mode_t mode):打开一个已存在的文件或者创建一个新文件的。open将filename转换为一个文件描述符,并且放回描述符数字。
(2)int close(fd):关闭一个打开的文件。关闭一个已关闭的描述符会出错。
(3)ssize_t read(int fd,void *buf,size_t n):应用程序通过read函数来执行输入。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误;返回值0表示EOF;否则,返回值表示的是实际传扫的字节数量。
(4)ssize_t wirte(int fd,const void *buf,size_t n):应用程序通过write函数来执行输出。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函数调用vsprintf函数,按照格式fmt结合参数arg生成格式化后的字符串,最后通过系统调用函数write输出,并返回字符串的长度。
vsprintf(buf, fmt, arg)是这样的:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
vsprintf接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
给几个寄存器传递了几个参数,然后一个int结束。
INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call,PRIVILEGE_USER);
int INT_VECTOR_SYS_CALL表示要通过系统来调用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
syscall将字符串中的字节从寄存器中复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。于是我们的打印字符串就显示在了屏幕上。
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;
}
bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下从缓冲区读入BUFSIZ个字节。返回时如果n大于0,那么返回缓冲区的第一个字符。否则返回EOF。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。键盘中断服务程序先从键盘接口取得按键的扫描码,然后根据其扫描码判断用户所按的键并作相应的处理,最后通知中断控制器本次中断结束并实现中断返回。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法,简述了Unix IO接口及其函数,分析了printf、getchar的实现。
(第8章1分)
结论
Hello的一生经历了这些过程:
- 编写:在编译器中用C语言完成hello.c的编写;
- 预处理:预处理器将hello.c预处理成为hello.i;
- 编译:编译器将hello.i文件翻译为汇编文件hello.s。
- 汇编:汇编器将hello.s文件翻译为二进制机器语言,生成可重定位目标文件hello.o。
- 链接:链接器将可重定位目标文件hello.o和其他目标文件链接成为可执行文件hello。
- 创建子进程:用户在shell中输入执行hello的命令后,shell通过fork创建进程。
- 加载: shell调用execve,在上下文中加载可执行程序hello,开始执行hello。
- 执行:hello程序运行,在此过程中,可能产生异常与信号,例如用户键盘输入ctrl+z,ctrl+c等,需要调用信号处理程序进行处理。在执行程序的过程中,hello还将利用各种复杂的机制进行内存访问。
- 访问内存:通过MMU将需要访问的虚拟地址转化为物理地址,并通过缓存系统访问内存。
- 动态申请内存:hello运行过程中可能会通过malloc函数动态申请堆中的内存。
- 异常:hello运行过程中可能会产生各种异常和信号,系统会针对出现的异常和收到的信号做出反应。
- 终止:hello运行结束或收到信号后终止,父进程结束并回收子进程,内核删除相关数据。
附件
hello.i hello.c预处理的文本文件
hello.s hello.c编译后的汇编文件
hello.o hello.s汇编后的可重定位文件
hello.elf hello.o的elf格式文件
Helloe.elf hello的elf格式文件
hello 可执行目标文件
参考文献
[1] 兰德尔·E·布莱恩特,大卫·R·奥哈拉伦著;深入理解计算机系统[M].北京:机械工业出版社,2016.7.
[2] C library – C++ Reference [http://www.cplusplus.com/reference].
[3] printf函数实现的深入剖析 [http://www.cnblogs.com/pianist/p/3315801.html].
[4] Linux 操作系统原理—内存—页式管理、段式管理与段页式管理.代码天地.