第1章 概述
1.1 Hello简介
1.1.1 P2P
首先,来介绍一下.c文件(Program)转化为可执行目标程序的过程。
预处理阶段:预处理器根据以字符#开头的命令,修改原始的C程序。
编译阶段:编译器将.i文本文件翻译成.s文本文件,它包含一个汇编语言程序。
汇编阶段:汇编器将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在.o二进制文件中。
连接阶段:链接器负责处理各个.o文件的合并,得到可执行目标文件,其可以被加载到内存中,由系统执行。
为了介绍可执行文件被加载的过程,先来介绍一个典型系统的硬件组成,如图1.1所示。
总线:图中灰色双向箭头称为/代表总线,它携带信息字节并负责在各个部件间传递。其被设计成传送定长的字节块,也就是字。
I/O设备:I/O设备是系统与外部世界的联系通道,每个I/O通过一个控制器或适配器与I/O总线相连。
主存:在处理器执行程序时,主存用来临时存放程序和程序处理的数据。
处理器:是解释存储在主存中指令的引擎,其核心为一个大小为一个字的寄存器,称为程序计数器,其指向主存中的某条机器语言指令。执行一条指令包含执行一系列的步骤。处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令。
此外,寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字;ALU计算新的数据和地址值。
下面介绍从磁盘中的可执行目标文件到生成进程(Process)的过程。
初始时,shell程序执行它的指令,等待我们输入一个命令。当在键盘上输入字符串“./hello 1183710211 WDZR”后,shell程序将字符逐一读入寄存器,再把它们放到内存中,如图1.2所示。
当在键盘上敲回车键时,shell程序就知道我们已经结束了命令的输入。然后shell执行一系列指令来加载可执行的hello文件,这些指令将hello目标文件中的代码和数据从磁盘复制到主存。数据包括最终会被输出的字符串“Hello 1183710211 WDZR”。
利用直接存储器存取技术,数据可以不通过处理器而直接从磁盘到达主存,如图1.3所示。
一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将“Hello 1183710211 WDZR”字符串中的字节从主存复制到寄存器文件,再从寄存器文件复制到显示设备,并最终显示在屏幕上,如图1.4所示。
1.1.2 020
每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
1.2 环境与工具
软件环境:Windows10、Ubuntu 19.04
硬件环境:Core i7-7660U
开发和调试工具: Codeblocks、Visual Studio 2019、edb
1.3 中间结果
文件名 |
作用 |
hello.c |
源程序 |
hello.i |
预处理文件 |
hello.s |
根据hello.i编译得到的.s文件 |
hello.o |
可重定位目标程序 |
helloo.elf |
根据hello.o得到的.elf文件 |
hello2.s |
根据hello.o反汇编得到的.s文件 |
hello3.s |
可执行目标文件反汇编得到的.s文件 |
hello.elf |
可执行目标文件的.elf文件 |
1.4 本章小结
作为概述,大体理一下思路,明确程序执行的各阶段,和我们接下来需要做的工作。
下面正式开始分析。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器根据以字符#开头的命令,修改原始的C程序。包括:1)将#include引用的.h文件展开在引用处 2)处理#if和#ifdef语句 3)将源程序中#define的宏定义进行展开。
作用:生成.s文件,为下一步的编译做准备。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
如图2.2所示,.i文件开头为include的.h文件,和这些.h文件引用的其他头文件的文件名和路径。
此外,stdio.h、unistd.h、stdlib.h分别首次出现在13行、731行和1973行,顺序是和在.c中引用的顺序是一致的,且在之后再未出现过。
值得注意的是,.i文件删除了.c文件开头的注释,因为这些信息只有辅助作用。
如图2.3所示,每个.h文件之后,有各个头文件中的变量、函数声明等,实际上为各个头文件中的内容。
如图2.4所示,.i文件的最后,是我们在.c文件中的代码,可以看到没有任何的修改,这与我们未在.c文件中使用#define等有关。
2.4 本章小结
.h头文件使得我们将一些泛用性较强的函数封装起来,在各个源程序中自由调用。而预处理,是这个过程中必不可少的一环。
第3章 编译
3.1 编译的概念与作用
概念:编译器将文本文件.i翻译成文本文件.s,它包含一个汇编语言程序。
作用:将不同的高级语言统一起来,不同的高级语言编译为相同的汇编语言程序,从而可以被相同的汇编器翻译为机器语言程序。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1 指示
文件开头为程序相关的一些信息,称为指示,以点开头。指示本身不为汇编指令,用以为编译器、链接器等提供有用的信息。
如图3.2所示,其中.file为文件名,.text为程序段的开始地址,.global为调用的全局变量,.data为数据段的开始地址,.align为对齐方式,.type为声明对象的类型,.size为声明对象的大小。
3.3.2 整型
如图3.3所示,sleepsecs为初始化的全局变量,其中.long为其大小,.section指明其被存储的节。
以及在程序中定义的局部整型变量:int i为循环辅助变量,int argc记录传入参数的个数,其均由寄存器存储。
3.3.3 数组
如图3.4,程序只声明了一个数组int argv[],首地址被存储在%rsi作为参数传入main,随后被压栈。
3.3.4 字符串
如图3.5,程序中,在printf使用的两个字符常量被提取出来,保存在内存中某位置,.LC提供了其相对%rip的偏移量。
3.3.5 类型转换
实际上,程序中只有sleepsecs在定义时,需要将浮点数2.5转换为整型。
但这个操作在汇编代码中没有展示更多细节。
3.3.6 算术操作
下面举例说明程序所用到的算术操作,值得注意的是,Linux下算术操作均为源操作数在前,目的操作数在后。
如图3.6所示,subq表示对四字寄存器做减法,图中为%rsp减去立即数32,用于栈的向下生长。
如图3.7所示,addq表示对四字寄存器做加法,图中为%rax加上立即数8,用于获取argv[1]的地址,其中8为偏移量。
如图3.8所示,leap用于获取有效地址,图中为将.LC0+%rip的值赋给%rdi
3.3.6 关系操作、控制转移
程序中设计两处关系操作、控制转移的操作。
图3.9对应于源程序中if(argc != 3),用于比较argc与3的关系。
图3.10对应于源程序中for(i = 0; i < 10; i++),使用了跳转到中间的实现:首先无条件跳转到循环退出判定处,假如无需退出,则跳转到for内部执行。
3.3.7 过程调用
汇编利用call指令来实现过程调用,其本质上是把当前的程序计数器的值压栈,并修改控制流下个执行的指令。
程序中调用的过程均为外部过程,其流程均为:设置传入的参数、调用call指令。
以图3.11为例,movl指令为参数寄存器赋值,call指令调用sleep指令。
其他过程调用与之相同,不再一一赘述。
3.3.8 整体分析
下面给出.text节的整体分析。
subq $32, %rsp #栈向下生长32字节
movl %edi, -20(%rbp) #将argc压栈
movq %rsi, -32(%rbp) #将argv的头指针压栈
cmpl $3, -20(%rbp) #比较argc与3的大小关系
je .L2 #若argc == 3,则跳转
leaq .LC0(%rip), %rdi #获取初始时声明的字符串常量的头指针
call puts@PLT #调用输出函数
movl $1, %edi #设置exit的调用参数
call exit@PLT #调用exit
.L2:
movl $0, -4(%rbp) #初始化i
jmp .L3 #循环的跳转
.L4:
movq -32(%rbp), %rax #获取argv[0]的地址
addq $16, %rax #获取argv[2]的地址
movq (%rax), %rdx #获取argv[2]
movq -32(%rbp), %rax #重新获取argv[0]的地址
addq $8, %rax #获取argv[1]的地址
movq (%rax), %rax #获取argv[1]
movq %rax, %rsi #转存到另一个寄存器
leaq .LC1(%rip), %rdi #获取初始时声明的字符串常量的头指针
movl $0, %eax #初始化printf的另一个参数
call printf@PLT #调用printf
movl sleepsecs(%rip), %eax #调用初始化的sleepsecs
movl %eax, %edi #转存到参数在的存储器
call sleep@PLT #调用sleep
addl $1, -4(%rbp) #i++
.L3:
cmpl $9, -4(%rbp) #只要循环没结束
jle .L4 #跳回到循环的开始
call getchar@PLT #调用getchar
movl $0, %eax #设置main的返回值
3.4 本章小结
根据指令集,将源程序翻译为汇编代码,为下一步生成二进制文件做准备。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中。
作用:将文本文件转化为机器可以理解的二进制文件,为下一步的链接做准备。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
如图4.2所示,生成.elf文件。
如图4.3所示,elf文件的开头描述了生成该文件的机器的相关信息,包括负数存储方式、大小端等。
之后是关于.o文件的相关信息,包括程序入口、节头部的位置和大小、程序的大小等。
如图4.4所示,之后是节头部,其指明各个节的名称、类型、起始地址和偏移量(所占空间)。
如图4.5所示,重定位节,下面将对本节进行详细分析。
其中有八个重定位信息:.L0、puts、exit、.L1、printf、sleepsecs、sleep、getchar。
如图4.6所示,Offset为偏移量;Info由type和symbol构成,分别指明目标在.symbol节中的偏移量,和重定位的类型,类型在后面已给出;Addend为计算用的数据。
以.L1为例,可知offset = 0x50, type = R_X86_64_PC32, symbol = .rodata, addend = 0x1a。
重定位使用相对寻址方式,方法如下:
如图4.7所示,文件的最后是符号表。
如图4.8所示,指明表示程序中调用的变量、过程的大小、名称、全局/局部等信息。
4.4 Hello.o的结果解析
从图4.9中看到,与原.s文件相比:
1)过程调用的符号,被替换为相对寻址。
2)条件转移中的.L标记被准确的相对main的开始的偏移所代替。
3)全局变量的符号,被替换为相对寻址。
值得注意的是,由于还未进行链接,上述的相对寻址全都暂时被0x0(%rip)代替。
此外,可以发现,.text中的各个汇编指令的作用效果没有太大变化。
4.5 本章小结
汇编结束后,程序的可执行部分已经搭建完成。
但是,由于外部声明的存在,文件中依然有许多坑需要填。
接下来的链接,将会把这些坑填上。
第5章 链接
5.1 链接的概念与作用
概念:将多个可重定位目标程序合并,生成可执行目标文件。
作用:处理可重定位目标程序中的重定位,生成最终的可被加载的文件。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
如图5.2,利用readelf得到hello.elf文件。
新的ELF表头部,从图5.3中可以看到入口地址、程序头等都发生了改变,表明已经完成了链接。
图5.4中为新的各节的信息,包括名称、类型、起始地址、大小等。
以.text为例,其起始地址为0x401080,大小为0x121。其他各节格式与此相同,不赘述。
5.4 hello的虚拟地址空间
从图5.5的data dump可以看到,程序从0x400000处开始被加载,直到0x401ff0结束,各部分对应关系与.elf文件中的Program Headers部分对应一致。
其中:PHDR保存程序头表、INTERP指定需要调用的解释器、LOAD指明保存了常量数据与程序目标等的段,其他部分指明一些辅助信息。
5.5 链接的重定位过程分析
1)如图5.6所示,被引用的过程全都被加入。
2)由于被引用的过程已加入,现在可以计算到被引用位置的偏移量,计算方式与4.3部分所述相同,在此不再赘述。
3)由于被引用的变量已加入,现在可以直接用其在虚拟内存中的地址相对%rip的偏移来代替,如图5.7所示。值得注意的是,这里采用重定位绝对引用R_X86_64_32,计算公式为:ADDR(r.symbol) + r.append。
5.6 hello的执行流程
执行过程中调用的程序及其地址,如表5.1所示。
程序名称 |
程序地址 |
ld-2.27.so!_dl_start |
0x7fe7a8177030 |
ld-2.27.so!_dl_init |
0x7fe7a81859e0 |
hello!_start |
0x400500 |
libc-2.27.so!_libc_start_main |
0x7fe7a7f98a80 |
-libc-2.27.so!_cxa_atexit |
0x7fe7a7f9ac43 |
-libc-2.27.so!_libc_csu_init |
0x4005c0 |
hello!_init |
0x400488 |
libc-2.27.so!_setjmp |
0x7fe7a7884c10 |
-libc-2.27.so!_sigsetjmp |
0x7fe7a7884b70 |
--libc-2.27.so!_sigjmp_save |
0x7fe7a7884bd0 |
hello!main |
0x400532 |
hello!puts@plt |
0x4004b0 |
hello!puts@plt |
0x4004e0 |
*hello!printf@plt |
— |
*hello!sleep@plt |
— |
*hello!getchar@plt |
— |
ld-2.27.so!_dl_runtime_resolove_xsave |
0x7fe7a784e680 |
-ld-2.27.so!_dl_fixup |
0x7fe7a7846df0 |
--ld-2.27.so!_dl_lookup_symbol_x |
0x7fe7a78420b0 |
libc-2.27.so!exit |
0x7fe7a7889128 |
5.7 Hello的动态链接分析
动态链接过程如图5.8所示。
如图5.9,调用dl_init之前,调用的目标地址都指向实际的PLT中的相关部分。
如图5.10,调用dl_init之后,GOT[1]指向重定位表,GOT[2]指向ld-linux.so运行时的地址。
在之后执行中,第一次对过程的调用会跳转到PLT中的实际位置,执行之后重写GTO,使得之后的调用可以直接跳转到目标过程。
5.8 本章小结
在链接完成后,拼图的最后一块已经填完,我们得到了最终的可执行目标文件。
接下来,我们通过shell来将可执行目标文件加载。
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个执行中程序的实例。
作用:
1)它提供一个假象,好像程序独占地使用处理器。
2)它提供一个假象,好像程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个交互型的应用级程序,它代表用户运行其他程序。
流程:
1)从终端获得命令行。
2)解析命令行中的命令、参数。
3)若为内核命令,则立即执行。
4)若为调用,则创建一个新的进程以执行。
6.3 Hello的fork进程创建过程
如图6.1,其中./hello要求shell创建新的进程以调用hello,其参数为1183710211和WDZR。
子进程得到与父进程用户级虚拟地址空间相同但独立的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
在子进程被创建后,shell会调用execve函数以加载并运行hello程序。
当加载器运行时,它创建类似于下图所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。接下来,加载器跳转到程序的入口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
内存结构如图6.2所示。
6.5 Hello的进程执行
在调用sleep过程之前,进程会一直保持运行,直到被其他进程抢占,这时会发生上下文切换:内核中的调度器会保存该进程的上下文,恢复被调用进程的上下文,并将控制转交给被调用的进程。
此外,在调用sleep过程时,也会发生上下文切换:内核处理休眠导致的主动释放控制,同时定时器开始计时,当到达特定时间后,定时器会发送一个信号给内核,以恢复当前进程的执行。
而当调用getchar时,实际上进程遭遇了一个陷阱,此时调度器会调度执行其他进程,直到其捕获了键盘输入的一个信号,才会恢复该进程的执行。
6.6 hello的异常与信号处理
图6.3为正常执行结束的结果。
图6.4为键入ctrl+c后,进程被终止,且不可恢复。
图6.5为键入ctrl+z,进程被暂时挂起。可以看到,进程并没有被终止,但是不会被调度执行。在键入fg 1后,进程恢复执行。
图6.6为执行过程中乱输(?),可以发现其只是被缓存到stdin。
6.7本章小结
可执行目标文件被成功加载到内存中,并得以执行。
接下来,我们来看一下程序执行背后的故事。
存储结构,与虚拟内存的调用。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序代码经过编译后出现在汇编程序中的地址。逻辑地址由选择符和偏移量组成。
线性地址:如果一个非负整数地址的有序集合中,所有的元素都是连续的,那么我们称这些地址为线性地址。
虚拟地址:如图7.1所示,CPU在执行时,会生成一个地址,称为虚拟地址,每个虚拟地址会对应到一个具体的物理地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的地址,称为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段,构造如图7.2所示。
保护模式时分段机制如图7.3所示。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传输单元。VM系统通过将虚拟内存分割为称为虚拟页为大小固定的块来处理这个问题。每个虚拟页的大小固定。类似地,物理内存被分割为物理页,大小与虚拟页相同。
同任何缓存一样,虚拟内存系统必须用某种方法来判定一个虚拟页是否缓存在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM,替换这个牺牲页。
这些功能是由软硬件联合提供的,包括操作系统软件、MMU中的地址翻译硬件和一个存放在物理内存中叫做页表的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
组织方式如图7.4所示。
7.4 TLB与四级页表支持下的VA到PA的变换
虚拟地址格式大抵如此。
首先,先来讨论只有一级页表时的寻址。
虚拟地址的形式如上图所示。
每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。为了降低时间开销,MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,下述为一个从TLB中获取物理地址的过程:
1)CPU产生一个虚拟地址。
2)MMU从TLB中取出相应的PTE。
3)MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。
4)高速缓存/主存将所请求的数据字返回给CPU。
流程如图7.6所示。
对于多级页表,虚拟地址中的VPN被划分为4部分,VPO保持不变。每个VPNi都是一个到第i级页表的索引。第j级页表中的每个PTE,都指向j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页表的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN,MMU必须访问k个PTE。
流程如图7.7所示。
7.5 三级Cache支持下的物理内存访问
Cache内的组织结构如图7.8所示。
一个机器的高速缓存被组织成一个有S个高速缓存组的数组。每个组包含E个高速缓存行。每个行是由一个B字节的数据块组成的,一个有效位指明这个行是否包含有意义的信息,还有t个标记位,它们唯一地标识存储在这个高速缓存行中的块。
一条访问(地址)的形式如图7.9所示。
组索引s被解释为一个无符号整数,它告诉我们这个字必须被存储在哪个组中。一旦我们知道了这个字必须放在哪个组中,t个标记位就告诉我们这个组的哪一行包含这个字。当且仅当设置了有效位并且该行的标记位与地址中的标记位相匹配时,组中的这一行才包含这个字。一旦我们在由组索引标识的组中定位了由标号所标识的行,那么b个块偏移位给出了在B个字节的数据块中的字偏移。
从VA到PA,过程与7.4类似,不同的是,每次不命中后,我们需要从下一级的存储结构中提取我们需要的数据块。
以一级Cache为例,如图7.10所示。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
当加载并运行可执行目标文件时,需要以下几个步骤:
1)删除已存在的用户区域。
2)映射私有区域。
3)映射共享区域。
4)设置程序计数器。
私有区域的不同映射如图7.11所示。
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页。
地址翻译硬件从内存中读取某个虚拟地址对应的页表,从有效位推断出某个虚拟地址未被缓存,并且触发了一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,来替换为该虚拟地址所对应的物理页。
当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
7.9动态存储分配管理
动态存储可用内存空间被称为堆。
如图7.12所示,第一个字是一个双字节边界对齐的不使用的填充字。填充后面紧跟一个特殊的序言块,这是一个8字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由malloc或者free调用创建的普通块。堆总是以一个特殊的结尾块来结束,这个块是一个大小为零的普通块。堆总是以一个特殊的结尾块来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时条件的技巧。分配器使用一个单独的私有全局变量,它总是指向序言块。
块的组织结构如图7.13所示。
在每个块的开头和结尾,分别添加头部和脚部,其记录了块大小和是否被分配。
之后是有效载荷,其可以记录所需记录的数据。
为了保证各个块的字节对齐,块中会有填充空间。
在分配块时,一个应用通过调用mm_malloc函数来向内存请求大小为size字节的块。在检查完请求的真假后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。
一旦分配器调整了请求的大小,它就会搜索空闲链表,寻找一个合适的空闲块。如果有合适的,那么分配器就放置这个请求块,并可选地分割出多余的部分,然后返回新分配块的地址。
7.10本章小结
计算机中的存储结构具有极强的层次性。
从上到下,存储空间逐渐扩大,读写速度逐渐减慢。
由于局部性的存在,我们可以在保证低花费的情况,有着较好的速度。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
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函数:
int open(char* filename,int flags,mode_t mode) :进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
int close(fd):fd是需要关闭的文件的描述符,close返回操作结果。
ssize_t read(int fd,void *buf,size_t n):read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
ssize_t wirte(int fd,const void *buf,size_t n):write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
以下假设printf和vsprintf代码是windows下的。
则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;
}
首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看vsprintf代码如下:
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': //只处理%x一种情况
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case 's': break;
default: break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在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将字符串中的字节“Hello 1183710211 WDZR”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
系统级I/O完成了Hello一生的最后一步。
以文件为单位,保证了较高的读写速度。
在各个层次上,这样的分块抽象,极大地提高了计算机的处理性能。
结论
Hello的P2P过程大抵如下。
1)编写:通过IDE将代码键入hello.c
2)预处理:所有外部的库展开合并到一个hello.i文件中
3)编译:将hello.i编译成为汇编文件hello.s
4)汇编:将hello.s汇编成为可重定位目标文件hello.o
5)链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello
6)运行:在shell中输入./hello 1183710211 WDZR
7)创建子进程:shell进程调用fork为其创建子进程
8)运行程序:shell调用execve,在子进程中加载可执行目标文件,根据入口执行main函数。
9)执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流
10)访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
11)动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
12)结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。