计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 1190201315
班 级 1936602
学 生 陆星宇
指 导 教 师 刘宏伟
计算机科学与技术学院
摘 要
本文通过对hello例程从C语言源程序经过预处理、编译、汇编、链接到执行的整个完整过程的逐步仔细剖析,将本学期计算机系统课程所学的包括:C语言程序的预处理和编译、汇编语言及其语法要素、汇编的过程和ELF目标文件的格式、链接及重定位的流程、操作系统进程管理、异常与信号、各级存储系统及数据在各级存储系统之间的传送、地址翻译与动态内存管理、I/O操作等等大量知识进行了整体的运用和梳理。极大地加深了对信号与异常、虚拟地址、内核、进程、存储、I/O等计算机系统非常重要的概念的理解。从组织逻辑上来将,贯穿全文的主线是分析hello程序的P2P(from Program to Process)和O2O(from zero to zero)的生命周期。
关键词:计算机系统;C程序运行分析;进程管理;存储器体系结构;I/O
目 录
第1章 概述... - 4 -
1.1 Hello简介... - 4 -
1.2 环境与工具... - 4 -
1.3 中间结果... - 5 -
1.4 本章小结... - 5 -
第2章 预处理... - 7 -
2.1 预处理的概念与作用... - 7 -
2.2在Ubuntu下预处理的命令... - 8 -
2.3 Hello的预处理结果解析... - 8 -
2.4 本章小结... - 9 -
第3章 编译... - 10 -
3.1 编译的概念与作用... - 10 -
3.2 在Ubuntu下编译的命令... - 10 -
3.3 Hello的编译结果解析... - 11 -
3.4 本章小结... - 26 -
第4章 汇编... - 27 -
4.1 汇编的概念与作用... - 27 -
4.2 在Ubuntu下汇编的命令... - 27 -
4.3 可重定位目标elf格式... - 27 -
4.4 Hello.o的结果解析... - 32 -
4.5 本章小结... - 35 -
第5章 链接... - 36 -
5.1 链接的概念与作用... - 36 -
5.2 在Ubuntu下链接的命令... - 36 -
5.3 可执行目标文件hello的格式... - 36 -
5.4 hello的虚拟地址空间... - 41 -
5.5 链接的重定位过程分析... - 43 -
5.6 hello的执行流程... - 46 -
5.7 Hello的动态链接分析... - 47 -
5.8 本章小结... - 49 -
第6章 hello进程管理... - 50 -
6.1 进程的概念与作用... - 50 -
6.2 简述壳Shell-bash的作用与处理流程... - 50 -
6.3 Hello的fork进程创建过程... - 51 -
6.4 Hello的execve过程... - 52 -
6.5 Hello的进程执行... - 53 -
6.6 hello的异常与信号处理... - 55 -
6.7本章小结... - 58 -
第7章 hello的存储管理... - 59 -
7.1 hello的存储器地址空间... - 59 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 60 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 61 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 65 -
7.5 三级Cache支持下的物理内存访问... - 67 -
7.6 hello进程fork时的内存映射... - 68 -
7.7 hello进程execve时的内存映射... - 71 -
7.8 缺页故障与缺页中断处理... - 71 -
7.9动态存储分配管理... - 72 -
7.10本章小结... - 75 -
第8章 hello的IO管理... - 77 -
8.1 Linux的IO设备管理方法... - 77 -
8.2 简述Unix IO接口及其函数... - 78 -
8.3 printf的实现分析... - 80 -
8.4 getchar的实现分析... - 82 -
8.5本章小结... - 82 -
结论... - 83 -
附件... - 85 -
参考文献... - 86 -
对于一个像hello.c这样的C语言程序来说,其整个生命周期是从磁盘上一个C源代码文本文件开始的。
在linux实验环境下,通过bash中通过GNU编译器套件GCC工具,经过预处理、编译、汇编和链接四个步骤将文本源程序转化为二进制可执行目标文件。其中我们在第2章详细分析在预处理阶段的工作;在第3章详细地对编译及得到的汇编代码进行分析;在第4章介绍汇编步骤及分析ELF可重定位目标文件;第5章介绍链接以及分析ELF可执行目标文件。
接下来,当我们在bash中输入命令:./hello并按下回车后,bash会对命令行进行解释,从当前目录下找到hello可执行文件并准备执行hello程序。
首先,内核使用fork函数为hello程序创建一个子进程,初始化hello程序的上下文,然后使用execve函数执行hello程序。执行期间可能出现各种各样的异常和信号,对应进程管理和异常处理的分析对应第6章的内容。
除此之外,hello程序在执行的时候需要访问内存中的指令和数据也会对外部设备和磁盘进行I/O操作。其中涉及到虚拟地址到物理地址的翻译,内存映射、高速缓存等等概念详见第7章。而对于hello程序运行时涉及的I/O操作的分析详见第8章。
总而言之,hello这一看似渺小简单的程序,想要在计算机中运行起来,背后设计到大量精妙绝伦的硬件和软件的完美配合,涉及到整个计算机系统中大量绝妙的设计的配合,最终才能在屏幕呈现出hello程序的输出信息。
hello程序的P2P(from Program to Process)和O2O(from zero to zero)的生命周期中蕴含了深入理解计算机系统的钥匙,理解了hello程序的P2P和O2O,就理解了计算机系统。
软硬件环境:
CPU: Intel(R) Core(TM) i5-8265U CPU @ 1.80 GHz
显卡: Nvida GeForce MX250
磁盘: 383GHD Disk
本机: 64位Windows10
虚拟机:Vmware 16.1.0 Unbuntu 20.04.2
GCC版本:9.3.0
开发工具:
codeBlocks version-20.03
gdb version-9.2
edb version-1.3.0
objdump version-2.34
readelf version-2.34
cpuz version-1.90
如下表格所示:
文件名 | 文件说明 |
hello.c | hello程序的C语言源代码文本文件 |
hello.i | hello.c经过预处理得到的预处理后的文本文件 |
hello.s | hello.i经过编译得到的编译后的文本文件 |
hello.o | hello.s经过汇编后得到的二进制可重定位目标文件 |
disa_hello.txt | hello.o的反汇编结果输出到文本文件 |
hello | hello.o经过链接后得到的二进制可执行目标文件 |
afterlink.txt | hello的反汇编结果输出到文本文件 |
before_elf.txt | 使用readelf查看hello.o可重定位目标文件的ELF信息输出到文本文件 |
after_elf.txt | 使用readelf查看hello可执行目标文件的ELF信息输出到文本文件 |
本章首先对本文研究的hello程序进行了一个简要介绍,对hello的一生进行整体的概括,以及对后续各个章节的内容做一个大致的介绍。接着罗列了实验环境以及所有使用到的开发工具及其版本。最后对整个实验所有涉及到的中间结果文件进行了罗列说明。
(第1章0.5分)
C语言预处理器(cpp)用来将C语言源程序(.c文件)翻译成一个ASCII码中间文件(.i文件)。cpp根据以字符#开头的命令,修改原始的C程序。例如一般我们编写C语言代码时都会在使用的定义标准输入输出头文件#include <stdio.h>,该条命令告诉cpp读取系统头文件stdio.h的内容,并将其直接插入到程序文本中。经过一系列对C源程序中以字符#开头的命令进行预处理,就得到了另一个以.i后缀作为文件扩展名的,更完整的,同样以ASCII码存储的C程序。
ANSI C标准定义的C语言预处理指令一共包括以下四种:文件包含、宏定义、条件编译和特殊控制。
预处理命令:
gcc -E hello.c -o hello.i或gcc -E hello.c > hello.i
使用GCC指令中-E选项,将C语言源文件hello.c经过cpp预处理后,通过-o选项输出到文件hello.i中(也可以通过> hello.i输出到文本文件)。
下图是hello.c源文件代码和文件属性的截图,可以看到其引用了stdio.h、unistd.h和stdlib.h三个头文件,源文件大小为534B。
经过预处理器预处理后,得到的hello.i文件部分代码和文件属性的截图如下。可以看到,经过预处理后,原来28行的C语言代码被扩展成了惊人的3065行,文件大小从534B增长至64746字节。不难发现,.c文件中第10行到第28行与.i文件中3047行到3065行完全一致。而.i文件中上面三千余行都是cpp执行上述三个文件包含指令的替换结果。cpp在系统目录下依次寻找并读取头三个文件 stdio.h 、stdlib.h 和 unistd.h 中内容,并以此替换对应的.c文件中的#include指令行,将这三个系统头文件依次展开。
头文件一般包括对变量类型以及函数的声明,宏定义以及对全局变量的定义,外部函数以及外部变量的定义,还有可能嵌套包含其他的有用的头文件等。
在展开的过程中,如果被引用的头文件中也引用其他头文件,预处理器会继续递归地进行展开。而最终预处理完成后,我们得到的.i程序中就不会再留下诸如#include或者#define等以#开头的预处理指令了。
在本章中,我们首先介绍了C语言预处理器对.c源文件进行预处理时的通用预处理指令,包括文件包含、宏定义、条件编译和特殊控制。接着我们在Linux下使用gcc -E命令对hello.c源文件进行预处理得到了对应的hello.i文件,并对与处理前后C程序的变化进行了一些总体的分析,并以此理解了C语言预处理器进行预处理的具体流程和工作。
(第2章0.5分)
C程序的编译指的是编译器(cc1)将预处理后得到的C语言文本文件(以.i后缀为扩展名)翻译成汇编语言文本文件(以.s后缀为扩展名)。
可以这样更简单地概括C语言编译器的工作:
将我们使用C语言这种高级编程语言编写的代码这种相对抽象的执行模型表示的程序按照某一种对应方式映射为汇编语言编写的汇编代码描述的CPU执行的指令。这种映射能够保证不改变程序执行的功能、逻辑与语义。而目标文件中汇编代码的规范,来源于所使用的指令集,不同计算机系统中的不同CPU所支持的指令集有所不同,但他们的基本模型和框架具有共性。常见的指令集,比如我们使用的ATT格式的汇编代码,其包含了数据传送指令、算术运算指令、逻辑运算指令、条件控制指令、循环指令、条件跳转指令等等。
在编译的时候,不同的优化级别会导致最终输出的.s文件中汇编代码有所不同。优化等级越高,得到的汇编代码可能就跟源程序中C语言代码的组织方式差距较大,难以理解。但是优化等级较高的代码对CPU来说是友好的,它更贴近机器执行,尽可能为提高指令的执行效率而优化,能够尽量利用CPU强大的潜在性能,坏处就是不利于程序员的理解。
对于C语言这样的静态类型检查的语言,编译器会检查源代码中的一些静态错误,如变量名拼写错误导致的引用不存在、使用的标点符号错误、括号不匹配、表达式缺少操作数、函数返回值错误、参数类型错误、缺少头文件、使用的变量未定义等等。并在编译时输出相应的警告/错误提示编译失败。
总之,在编译这一步,编译器将.i文件经过相应处理后输出为汇编代码组成的.s文本文件。
编译命令:
gcc -S hello.i -o hello.s或gcc -S hello.i > hello.s
使用GCC指令中-S选项,将hello.i经过cc1编译后,通过-o选项输出到文件hello.s中(也可以通过> hello.s输出到文本文件)。
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
汇编代码中对于不同数据的表示有不同的含义和对应规则,下图总结了ATT格式汇编代码的操作数格式:
熟练理解操作数格式能够帮助我们区分不同类型的数据,对理解汇编代码作用很大。
hello.s中有一个int型的全局变量sleepsecs,对应于源文件第10行:
其在hello.s中对应如下:
其中.global表示sleepsecs是全局的;.data标记了其所存储位置在data节,在C语言中已初始化的全局变量和静态变量都存储在data节;.align 4表示sleepsecs的对齐要求为4字节,即其起始地址必须为4的倍数;.type sleepsecs, @object表示sleepsecs是数据对象;.size定义了该全局变量所占大小为4字节;.long 2表示其存储在一个long word(长字)中,值为2(之所以值为2的原因是在hello.c源程序第10行对其赋初值2.5时,由于浮点数2.5与int类型不匹配,所以发生了从浮点数到整型的隐式类型转换,最终sleepsecs被赋值为2)。
如上图所示,hello.c源程序中第18行和第23行中printf语句中打印了两个字符串,它们在编译后会以两个字符串常量的形式存在:
.section .rodata表明这两个字符串常量存储在.rodata只读数据节,在C语言中只读数据比如pritf语句的格式串或开关语句的跳转表等存储在只读数据节。
第一个字符串:
"Usage: Hello \345\255\246\345\217\267 \345\247\223\345\220\215\357\274\201"
后面的\xxx\xxx\xxx是以八进制存储的UTF-8编码的汉字。汉字在UTF-8中一般以3个字节编码,将其转化后得到的字符串就是hello.c第18行打印的"Usage: Hello 学号 姓名!\n",只不过puts默认输出完成后会换行,所以去掉了结尾的换行符‘\n’。
而第二个字符除串:
"Hello %s %s\n"
就是hello.c中第23行printf中的格式串。
.text表明main函数在text代码节;.global表示该函数是全局的;.type main @function表明标识符main的类型是函数。
根据hello.c中源代码,main函数有两个参数:
第一个int型参数argc,第二个char** 型参数argv。这两个变量作main函数的参数常见于命令行编译程序。它们的具体作用是:argc表示命令行中传递给main函数的参数的个数;argv[]是指针数组,其中每一个元素指向一个字符串表示的命令行参数,其中argv[0] 指向程序运行的全路径名,argv[1]指向命令行中路径名后第一个字符串,以此类推。一共有argc个命令行参数,因此argv[]数组的长度就为argc。
具体到hello.s中,与64位机器汇编指令的一般的参数传递规则一致,一般将函数的前6个参数依次存在寄存器:%rdi、%rsi、%rdx
、%rcx、%r8、%r9中,如果还有超过6个的参数,则按序存储在栈中。(注:以上为了简便以参数类型对应大小为8字节为例,实际传参是使用大小相匹配的寄存器,比如如果第一个参数是int型则使用寄存器%edi传参)
如上图所示,在main函数的汇编代码中,首先将帧指针%rbp入栈,再将栈指针%rsp的值传递给帧指针%rbp,此时帧指针%rbp指向的位置就是分配新栈帧前的栈顶位置。
接着,第27行,%rsp栈指针减32,新分配32字节的栈帧。将%edi第一个参数寄存器的中值存储到帧指针%rbp下方20字节处,再将%rsi第二个参数寄存器中的值存储到帧指针%rbp下方32字节处。根据接下来的汇编代码分析,结合C源程序,我们可以发现,此时第一个参数寄存器%edi中存储的就是main函数的第一个参数argc的值,而第二个参数寄存器%rsi中存储的就是main函数的第二个参数指针数组argv[]的首地址。
其中第18行的printf语句被编译器优化为直接调用系统函数puts@PLT来执行输出:
可以看到,仍然是使用第一个参数寄存器%rdi传递需要打印的字符串的首地址,接着使用call语句调用puts@PLT函数。
而第18行的printf语句被编译器忠实地翻译为调用系统函数printf@PLT来执行输出:
通过这段汇编代码我们可以分析出printf@PLT使用了三个参数,对应C源码中的字符串常量,即格式串"Hello %s %s\n",和argv[1]以及argv[2]。同样地,它们依次存储在第一参数寄存器%rdi,第二参数寄存器%rsi和第三参数寄存器%rdx中,让我们来仔细看看:
首先栈中%rbp-32处存储的argv[]数组的首地址&argv[0]赋给寄存器%rax。接着将%rax的值加16,即得到argv[2]的地址&argv[2]。接着加载&argv[2]指向内存中存储的argv[2]的值到%rdx,此时%rdx中存储的值就是argv[2],即第三个参数寄存器中存储argv[2]的值。
同样的,经过类似的步骤,将argv[1]的值加载到第二个参数寄存器%rsi中。
接着加载常量字符串"Hello %s %s\n"的首地址到第一个参数寄存器%rdi,作为printf的格式串。
最终通过call指令调用printf@PLT,使用的三个参数就是我们上面所分析的%rdi、%rsi、%rdx中存储的值。
对于以立即数1做参数调用的exit函数,其在hello.s中被翻译后得到的汇编代码如下:
显然,与我们之前的分析一致,首先将立即数1赋给第一个参数寄存器%edi,接着使用call指令调用对应的exit@PLT函数。
而对于以全局变量sleepsecs为参数调用的sleep函数,其在hello.s中被翻译后得到的汇编代码如下:
可以看到,其调用方法也是将sleepsecs的值赋给第一个参数寄存器%edi接着使用call指令调用对应的sleep@PLT函数。
如上图所示在hello.c源代码的第14行声明了一个int型局部变量i,一般地,编译器会分配相应的栈帧将过程调用内部的局部变量存储在栈内:
在hello.s中,编译器将局部变量i存储在栈中%rbp-4指向的位置。
结合3.3.1-(3)-①中对argc和argv在栈中的存储的分析,我们可以画出如下的main函数栈帧图:
hello.c源程序中使用了五个立即数:exit的参数1;条件判断语句中的3;return的返回值0;循环初值0;以及循环控制中的循环上限10。
立即数的值是在编译阶段就已经确定的常量,因此在翻译为汇编代码的时候可以被直接使用,其中exit(1)我们已经在3.3.1-(3)-③中顺带分析过了,此略。接着我们看看return 0在hello.s中是怎么翻译的:
首先将返回值寄存器%eax的值置为需要返回的值0,然后leave并ret返回。这里面返回值0就是作为立即数$0被直接调用的。
然后我们来看看源代码中第16行的if(argc!=3)对应的汇编代码:
可以看到,汇编代码中直接将栈中存储的argc的值与立即数$3比较,如果不相等则跳转到.L2处执行相应语句,否则顺序执行接下来的语句。同样可以看到此处3也是作为立即数被直接使用的。
最后来看看循环语句的初值和循环上限:
同样地,都是作为立即数直接使用。注意这里,本来C源程序中循环判断是i<10,这里编译器直接翻译为了i<=9。
总的来说,hello.s中汇编代码中与赋值有关指令有数据传送指令,主要涉及最简单的数据传送指令MOV系列指令代表的及其变种leaq指令。除此之外,还有与栈有关的push、pop指令。
首先是MOV系列的指令,它们是最常见的数据传送指令。下表是对MOV系列指令的一个简单总结:
movb、movw、movl、movq它们的主要区别在于传送的数据长度,b代表1个字节;w代表2个字节,即一个字;l代表4个字节,即一个双字;q代表8个字节,即一个四字。
源操作数S指定一个数字,存储在寄存器或内存中,目的操作数D指定一个目的传输位置,或为一个寄存器或为一个内存地址。x86-64对源操作数和目的操作数做出了限制,要求它们不能同时都指向内存。特别地,movl指令以寄存器为目的的时候,会将对应寄存器的高位4字节清零。
如果在数据传送时进行了零扩展或者算数扩展,则需要使用MOV类指令的变种MOVZ和MOVS类指令,不过由于hello.s中并未涉及扩展传送,所以对MOVZ和MOVS指令,此处不再赘述。
而加载有效地址指令,即leaq指令,是一个很神奇的指令,他是movq指令的变形。其指令形式上从内存读取数据到寄存器,但实际上它根本没有引用内存,其第一个操作数看上去是一个内存引用,但该指令并不是从指定内存位置读取数据,而是将有效地址写如目的操作数。此外,它还可以用于优化实现简单的一些算数计算,这一部分优化是由编译器自行发现并完成的,在这种情况下,leaq指令的行为甚至根本与有效地址计算有关。
pushq和popq指令用对于栈进行操作, pushq的作用是使栈指针-8分配8字节的栈帧,压入源操作数S,即入栈操作;popq的作用是使栈指针+8释放8字节的栈帧,弹出原来栈顶的四字到目的操作数D对于的寄存器。
接下来让我们具体看看MOV类指令和leaq指令以及栈操作指令是如何在hello.s中的汇编代码被使用的:
首先是main函数一开始分配栈帧前用帧指针%rbp保存原来的栈指针的%rsp。然后新分配32字节栈帧后,使用movl、movq指令将参数寄存器%edi、%rsi中的argc和argv保存到栈中,这一部分我们已经在3.3.1-(3)-①中详细分析了。
如上图所示,过程调用会将局部变量保存到栈中,此时使用的也是MOV类的指令完成数据传送。
当一个内存中存储了一个地址,而程序又需要引用这个地址对应的内存的时候,会使用寄存器作为中介。这种情况时普遍的,常出现于对指针指针、指针数组的操作。比如hello.c中main函数的第二个参数argv[]就是一个指针数组,其首地址argv就是一个指针指针。Movq -32(%rbp),%rax首先将存储在栈中的argv[0]的地址即argv取出,赋给寄存器%rax。再对寄存器%rax加16得到argv[1]的地址,然后通过中介%rax访问argv[1]。
另一方面,数值也经常在寄存器之间传递,因为不同的寄存器有不同的分工,当一个寄存器需要执行其专有的任务,比如参数寄存器需要存放过程调用的参数,返回值寄存器需要存放返回值等等的时候,可能会将自己原本的值保存到另一个寄存器中,或者将自己需要的值从另一个寄存器中传送过来。如上面的movq %rax,%rsi将%rax中存储的argv[1]传递给参数寄存器%rsi。
上述过程的指向显然也离不开MOV系列指令的身影。
类似的使用MOV系列指令的例子还有很多,可以说MOV系列的指令是汇编代码中最常见的一类指令,程序的指向离不开数据再内存与寄存器、寄存器与寄存器之间的传送,也就离不开MOV系列的指令。因此,总结起来说,MOV系列指令是大量赋值操作的对应汇编实现。
除了MOV类指令,有趣的是在hello.s中还有两处涉及到加载有效地址leaq指令的运用,它们都与C源程序中printf语句有关:
从对应逻辑来分析,不难理解这两条语句的作用是加载之前在3.3.1-(2)中所说的两个作printf语句格式串的字符串常量的首地址到第一个参数寄存器%rdi。这里.LC0(%rip)和.LC1(%rip)中的.LC0和.LC1是两个标签,此处源操作数的寻址模式与基址加偏移量内存寻址的形式相同,但这种以%rip为基地址,以标签为变址的内存引用形式以前没有见过,所以具体是怎么一回事还有待后续研究,在此处我们先埋下一个伏笔。
在过程调用中涉及到对栈的操作,具体地在hello.s中,main函数一开始就使用pushq %rbp将帧指针原来的值入栈保存,然后让帧指针指向当前栈指针的位置记录栈原来的位置。这种模式是过程调用的一种通用处理方式。对应在退出返回的时候,首先让栈指针回退到帧指针的位置,再将栈中保存的原来的帧指针的值pop回%rbp即可返回调用前的状态。这一部分操作被封装隐藏到ret指令中了:
hello.s中涉及的过程调用的内容在3.3.1和3.3.2中已经顺带详细分析过了,此处不再赘述。
如上图所示,hello.c源程序中有一个for循环,通常一个C语言for循环可以由以通式表示:
for (init-expr; test-expr; update-expr)
body-statement
C语言标准说明,在通常情况下,以上for循环与下面这个while循环的代码行为一致:
init-expr;
while(test-expr) {
body-statement
body-expr;
}
程序首先对初始表达式init-expr求值,然后进入循环;在循环中首先对测试条件test-expr求值,如果为假则退出循环,否则执行循环体body-statement,最后对更新表达式update-expr求值。
根据编译优化等级不同,上述while循环可能有两种对应的汇编翻译,重新用C语言的模式来描述翻译后汇编代码的逻辑如下:
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if(t)
goto loop;
init-expr;
t = test-expr;
if(!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if(t)
goto loop;
done:
让我们来看看hello.s中对这个for循环具体是怎么翻译的:
仔细分析一下,首先.L2标记的汇编代码将0移动到栈中,这里的含义我们之前已经分析过了,是对hello.c中main函数局部变量,同时也是这个for循环的循环遍量i赋初值,因为i是main函数的局部变量,所以存储到main的栈帧中。这一部分对应init-expr的求值。
接着跳转到标记.L3处执行后续的代码。将栈中存储的i的值与9比 较,如果i小于等于9则跳转到标记.L4处,否则退出循环调用 getchar@PLT。这一部分对应guarded-to策略翻译的
t = test-expr;
if(!t)
goto done;
如果i小于等于9,即继续执行循环体的情况下,控制跳转到标记.L4处,这部分的内容我们之前在3.3.2中已经详细分析过了,其对应guarded-to策略翻译的:
loop:
body-statement
update-expr;
当执行完循环变量更新,即addl $1,-4(%rbp)后,进入.L3执行比较和条件判断是否继续执行循环还是退出循环,对应guarded-to策略翻译的:
t = test-expr;
if(t)
goto loop;
done:
综上可知,在hello.s中对这个for循环的汇编翻译采用的是guarded-to策略。
如上图所示,hello.c源程序中有一个显式的if条件语句,在C语言中,if-else条件语句的通用形式模板如下:
if(test-expr)
then-statement
else
else-statement
对于上述通用形式,其对应汇编翻译通常会使用下面这种C语法描述的控制流:
t = test-expr;
if(!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
由于hello.c中的if-else语句并不完整,实际上只使用了if语句的部分,让我们看看在hello.s的汇编代码中这段条件语句是如何翻译的:
首先将栈中存储的argc的值与立即数3比较。对应于上述示意控制流中t = test-expr。
如果argc等于3,即不满足if的条件,则控制转移到.L2标记处,后续执行逻辑就如我们在3.3.4中所述的那样。这种情况对应于上述示意控制流中的:
if(!t)
goto false;
而如果argc不等于3,即满足if的条件,则执行下一条语句,将printf的格式串首地址加载到第一个参数寄存器%rdi,调用puts@PLT。然后将第一个参数寄存器%edi的值置为1,调用exit@PLT退出。这一部分我们在之前的小节里也同样分析过,其对应对应于上述示意控制流中的then-statement。
综上可知,hello.c中使用的if条件语句在hello.s被翻译为上述我们分析的汇编代码。
hello.s中汇编代码设计到一些算数运算和逻辑运算。首先我们先回顾一下常见的算数运算和逻辑运算的汇编指令,它们被分为各种各样的指令类,比如ADD指令类包含了addb、addw、addl、addq等:
我们按它们在hello.s中出现的先后顺序,依次来仔细看看这些算数、逻辑运算指令在hello.s中的具体应用:
首先是对main进行栈帧分配的时候,对栈指针%rsp的值减32使用的就是SUB类指令中的subq,其作用是使目的操作数的值减去源操作数,并将结果保存到目的操作数中,即D←D-S。
然后是在从栈中取出argv[1]和argv[2]的地址的时候,对存在寄存器%rax中的argv[]数组首地址分别加8和加16使用的就是ADD类指令中的addq,其作用是使目的操作数的值加上源操作数,并将结果保存到目的操作数中,即D←D+S。
接着是对作为main函数的局部变量存储在栈中的循环变量i进行自加1,即循环中的update-expr,此时编译器采取的方式也是使用ADD类指令中的addq。
以上便是对hello.s中设计到的算逻运算指令的解析。
hello.s中汇编代码设计到的关系操作主要来源于C代码中的if语句的条件判断以及循环语句的循环条件判断。
其具体内容解析我们在之前的部分已经详细分析过了,这里只是简单总结一下:
除了整数寄存器,CPU还维护着一组由单个位的条件码寄存器,他们描述了最近的算逻运算的属性,可以通过检测这些寄存器来执行条件分支命令,其中最常见的条件码有:
CF:进位标志,如果最近的操作使得最高位产生了进位则置为1。
ZF:零标志,如果最近的操作结果为0则置为1。
SF:符号标志,如果最近的操作结果为负数则置为1。
OF:溢出标志,如果最近的操作导致一个补码溢出则置为1。
除了3.3.6中所述的算数运算和逻辑运算外,比较和测试指令的执行会修改条件码寄存器的值,这些指令如下图所示:
其中CMP类指令基于S2-S1的值来设条件码寄存器的值,比如如果S1与S2相等,则CMP S1, S2 指令就会将ZF零标志置为1。类似地,TEST类指令基于S1&S2来设置对应的条件码。
而下图中一系列条件跳转指令就是根据相应条件码的值来确定是否进行条件跳转的:
比如jne就是当ZF条件码为0的时候才进行条件跳转。
有了条件码寄存器和条件跳转指令我们就可以实现与条件分支有关的功能了,同样地,我们来分析 hello.c中出现的一些关系操作在hello.s中对应的汇编代码:
首先是hello.c中第16行的if语句判断条件中的argc!=3,这里使用的C语言关系运算符是“!=”,其在hello.s中对应翻译为:
首先使用比较指令cmpl比较栈中存储的argc的值与3的大小。接着使用条件跳转指令je。当argc与3相等的时候,执行cmpl $3, -20(%rbp)会将ZF置为0,此时je条件跳转指令读取ZF的值发现为1,说明argc与3相等,则跳转到.L2标记执行相应代码。而如果argc与3不相等的时候,执行cmpl $3, -20(%rbp)会将ZF置为0,此时je条件跳转指令读取ZF的值发现为0,说明argc与3不相等,则不发生条件跳转,而是顺序执行下一条汇编指令。
可知hello.s中if条件语句的条件判断是通过比较指令和条件跳转指令结合起来实现的。
类似的源代码21行处for循环里的循环条件的关系运算i<10,也是被翻译为比较指令和条件跳转指令的结合:
总的来说,C语言里的关系运算,包括>、<、=、!=、<=、>=在编译时一般会被翻译为一条比较指令。
hello.c源代码中定义全局变量sleepsecs的时候涉及到隐式类型转换,这一点我们已经在3.3.1-(1)中分析过了,此处不再赘述。
hello.c中的main函数的第二个参数argv[]是一个指针数组的首地址,即指针指针argv,在汇编代码中设计到对指针的地址进行算术运算,对数组元素按下标进行访问,获得数组对应下标元素的地址等等数组、指针有关的操作。这些我们已经在之前的部分分析过了,这里也不再赘述。
我们首先介绍了编译的概念和作用。接着我们对编译器如何将hello.c中的源代码翻译为hello.s中的汇编代码进行了详尽的解析说明。我们详细分析了包括各种类型的数据、赋值、过程调用及参数传递、条件分支和循环中的控制转移、算术运算、逻辑运算、关系运算、类型转换、数组指针操作等等C语言语法元素是如何被编译器翻译为对应的汇编代码的,并在分析的过程中给出了对hello.s中的汇编代码各个部分代码逻辑的完整注释和详尽分析。
(第3章2分)
C程序的编译指的是汇编器(as)将编译后得到的汇编语言文本文件(以.s后缀为扩展名)翻译成机器语言指令,并把这些指令打包为一种叫做可重定位目标程序(relocatable object program)的格式,并将结果相应的保存在二进制文件中(以.o后缀为扩展名)。
编译命令:
gcc -c hello.s -o hello.o或gcc -c hello.s > hello.o
使用GCC指令中-c选项,将hello.s经过as汇编后,通过-o选项输出到文件hello.o中(也可以通过> hello.o输出到二进制文件)。
现代x86-64Linux系统使用可执行可链接格式(Excutable and Linkable Format, ELF)作为目标文件的文件格式。一个典型的ELF可重定位目标文件的结构如下图所示:
该ELF可重定位目标文件包含了以下基本信息:
ELF头:描述文件的总体格式,包括程序的入口点。以一个16个字节的序列开始,描述了声称该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息如ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移、节头部表中条目的大小和数量等。
节头部表:用于描述目标文件的节。
夹在ELF头和节头部表中的都是节,一个典型的ELF可重定位目标文件包含以下几个节:
. init节:定义了_init函数,程序初始化代码会调用它。
. text节:包含已编译程序的机器代码。
. rodata节:包含只读数据,比如printf语句中的格式串和开关语句的跳转表。
. data节:存储已初始化的全局和静态C变量。
. bss节:存储未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。
. symtab节:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
. debug节:一个调试符号表,其条目时程序中定义的全局变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。可以被用GDB等调试工具,在GCC中只有以-g命令编译时,才会得到这张表。
. line节:原始C源程序的行号和.text节中机器指令之间的映射,同样需要-g参数才能生成。
. strtab节:一个字符串表,其内容包括.symtab 和 .debug节中的符号表,以及节头部中的节名字。
具体的,使用readelf可以查看hello.o的节头部表以及各节信息:
4.3.1 ELF头
由readelf -h指令读取的hello.o可重定位目标文件的ELF头信息可知: hello.o的文件类型为可重定位文件;使用的机器指令对应系统架构为 AMD x86-64;操作系统类型为UNIX;数据采用补码、小端存储;程 序入口地址为0x0;ELF头大小为64字节;节头部表文件偏移量为1232 字节;节头部表大小为64字节;一共有有14个节。
4.3.2 节头部表
由readelf -S指令读取的hello.o可重定位目标文件的节头部表信息可知:每一个条目对应一个节,如同ELF头描述的一致,一共有14个节,每个条目记录了每个节的名称、类型、属性(读写权限)、在 ELF 文件中所占的长度、对齐方式和偏移量。
4.3.3 符号表
由readelf -s指令读取的hello.o可重定位目标文件的.symtab节符号表。可以看到不同的符号的标码值,所占空间大小,访问权限,所属类型(全局还是局部),以及符号的名称。
如main函数、printf函数、puts函数、exit函数、sleep函数、getchar函数都在这张符号表中有对应条目,全局变量sleepsecs也在。
4.3.4 重定位节:
当汇编器生成一个目标模块的时候,它并不知道数据和代码最终会放在内存中的什么位置,也不知道当前这个模块引用的任何外部定义的函数或者全局变量的位置。所以无论何时汇编器遇到对最终位置未知的目标引用的时候,它就会生成一个重定位条目,告诉链接器在将可重定位目标文件合并成可执行文件的时候如何修改这个引用。代码的重定位条目放到.rela.txt节中,已初始化的数据的重定位条目放到.rela.data节中。
64位ELF重定位条目的格式可以由下图中的结构体来表示:
64位offset是需要被修改引用的节偏移,对代码重定位来说就是.text节偏移;32位type告知连接器该如何修改新的引用;32位symbol标识被修改的引用应当指向的符号;64位addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的偏移做偏移调整。
对于type,ELF定义了32种不同的重定位类型,我们只关心其中两种最基本的重定位类型:
R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。
R_X86_64_32:重定位一个使用32位绝对地址的引用。
除了这两种类型之外,在hello.o的重定位节中,我们还会发现一种名为R_X86_64_PLT32的重定位类型,其应当是与动态链接中的GOT和PLT有关。关于这种重定位类型,书上没有提及,网上资料较少,说是与新版本GCC有关,只能暂且搁置。
如上图所示,由readelf -r指令读取的hello.o可重定位目标文件的.rela.text重定位节。
通过该重定位表,我们可以得到各处未确定引用的重定位信息,其中偏移量对应64位ELF重定位条目的格式中的offset;信息对应type和symbol,其中类型就是type,符号名称就是symbol;加数对应的就是addend。
可以在objdump -d -r hello.o得到的反汇编结构中看到这些需要重定位的引用:
比如第17行: 1c: R_X86_64_PC32 .rodata-0x4就对应了重定位节中的这个重定位条目:
此时偏移量1c指的是相对于.text代码节的偏移量位0x1c字节,即从机器码48 8d 3d 后的第一个字节开始的4个字节,32位地址(此时全部以00占位)需要重定位到.rodata只读数据节中对应的只读数据,也就是之前所说的用于打印的字符串常量。
其余的重定位条目同理。关于如何在链接的时候使用重定位算法修改需要重定位的引用得到可执行目标代码,我们在第5章链接中再仔细分析。
使用objdump反汇编可以查看hello.o中机器指令的编码及其对应的汇编代码:
反汇编得到的汇编代码与直接通过编译器编译hello.i得到的汇编代码有一些不同。这是由于机器语言相比汇编语言更加贴近机器执行,更加不易阅读,汇编器将将汇编语言中的助记符全部替换为对应的机器码,因此原本汇编代码中使用的符号、标记全都被替换为机器码,再反汇编回汇编代码的时候不能还原。
机器语言是计算机可以直接理解的语言,其编码方式完全采用二进制编码,为了方便显示,objdump将其转换为对应的16进制。每两个16进制对应一个字节编码,是机器语言中能解释一个汇编代码运算符或操作数的最小单位,比如字节E8对应会汇编代码中的call指令。
在同一台计算机完全相同的环境上,机器语言与汇编代码之间的对应关系是一一对应的。接下来我们具体分析一下反汇编得到的汇编代码与直接通过编译器编译hello.i得到的汇编代码有哪些不同:
hello.s中汇编代码使用的立即数是10进制的,而反汇编得到的汇编代码中立即数是16进制的。
在对静态常量字符串的访问上有所不同:
hello.s中采用标记的方式:
而反汇编代码中使用的是PC相对地址重定位,以%rip寄存器中PC值为 基地址。因为此时的可重定位目标文件hello.o还没有进行重定位,所以PC相 对寻址的变值部分只能用0x0占位,在对应机器码中也是用32位相对地址 0x000000进行占位。只有当重定位修改引用后才能得到对应字符串常量的相 对PC地址,并修改占位。
在分支转移的跳转方式上有所不同:
hello.s中汇编代码使用 .L1, .L2 等标记进行跳转,如下图所示:
而在反汇编结果中,将这些标记转变为了形如 main+0x--的相对于当前函 数main 函数的首地址的偏移进行寻址,如下图所示:
在系统函数调用上有所不同:
hello.s中汇编代码使用function_name@PLT的形式调用系统函数,如下图所示:
在hello.s中只是提供了这些库函数的名字和PLT标识,其中PLT表示该 外部定义的库函数在PLT表中具有与之关联的条目,与动态链接生成位置无 关代码等机制有关。
而在反汇编结果中将这些标记转变为了形如 main+0x--的相对于当前函 数main 函数的首地址的偏移进行寻址,如下图所示:
不过同理,由于hello.o可重定位目标文件还没有进行重定位,所以这里的 机器码中用于相对寻址的32位地址都用0x00000000占位,因此反汇编得到的 汇编指令call的目标地址都是下一条指令的首地址,对应PC偏移为0,当进 行重定位后,这些call指令就会通过相对寻址,得到对应系统函数的首地址。
这一部分在之前对ELF文件中重定位节中已经有一些初步的讨论了,在第5 章链接部分我们还会继续深入研究下去。
在本章中,我们首先介绍了汇编的概念和作用。接着我们对汇编器如何将hello.s中的汇编代码映射为hello.o重定位目标文件做出了分析。并使用readelf工具查看了hello.o的ELF头、节头部表、符号表以及重定位表的信息,并作出相关的分析。接着我们通过objdump工具对hello.o进行反汇编,通过对比反汇编得到的汇编代码与原生的hello.s中汇编代码,分析了它们的差异以及差异产生的原因,并对汇编和反汇编过程以及ELF文件的组织有了更清晰的了解。
(第4章1分)
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时;也可以执行于加载时;甚至可以执行于运行时。在早期的计算机系统中,链接是手动执行的;而在现代计算机系统中,链接是由连接器(ld)程序自动执行的。
链接的过程就是链接器将一个或多个由经过编译和汇编生成的可重定位目标文件以及所需的外部库中的共享目标文件链接为一个可执行目标文件。
链接使得分离编译成为可能,我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,使得我们可以独立地修改和编译这些模块。而当我们修改这些模块中的一个时,只需简单地重新编译、汇编这一个小模块,并重新链接应用,而不必重新编译其他模块。
链接命令(为什么需要这些库文件不是很懂,参考了一些学长学姐的报告):
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/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
一个典型的ELF可执行目标文件的结构如下图所示:
该ELF可执行目标文件格式类似于4.3中可重定位目标文件的格式,包含 了以下基本信息:
ELF头描述文件的总体格式,包括程序的入口点,即程序运行时要执行的第一条指令的地址。
.text节、.rodata节、.data等节与可重定位目标文件中相应的节是相似的,除了这些节已经被定位到他们最终运行时的虚拟地址外。
.init节定义了一个_init函数,程序的初始化代码会调用它。
ELF可执行文件被设计成容易加载到内存中的形式,由连续的片被映射到相应的内存段。程序头部表(program header table)描述了这种映射关系。
具体的,使用readelf可以查看hello的ELF头、程序头表以及各节信息:
5.3.1 ELF头:
使用readelf -h hello查看可执行目标文件hello的ELF头可知:hello.o的文件类型为可执行文件;使用的机器指令对应系统架构为AMD x86-64;操作系统类型为UNIX;数据采用补码、小端存储;程序入口地址0x4010d0;ELF头大小为64字节;程序头表中有12个程序头条目,每个程序头大小为56字节;节头部表大小为64字节,一共有30个节。
对比之前hello.o可重定位目标文件的ELF头,主要有以下几点变化:
5.3.2 节头部表:
由readelf -S指令读取的hello可执行目标文件的节头部表信息可知:每一个条目对应一个节,如同ELF头描述的一致,一共有30个节。与之前所说的ELF节头部表一致,节头部表中每个条目记录了每个节的名称、类型、属性(读写权限)、在 ELF 文件中所占的长度、对齐方式和偏移量。
可以看到,hello的ELF节数目比hello.o中ELF节的数量更多,其中有一些节是因为动态链接而新产生的,而像hello.o中的.rela.text节经过重定位确定了相应的引用位置后就不会再出现在hello的ELF节中。
这里留意一下几个我们关注的,对于后续分析比较重要的节的起始地址:
第12个条目,.init节的起始地址为0x401000
第15个条目,.text节的起始地址为0x4010d0
第17个条目,.rodata节的起始地址为0x402000
第24个条目,.data节的起始地址为0x404040
第25个条目,.bss节的起始地址为0x404054
5.3.3 符号表:
由readelf -s指令读取的hello可执行目标文件的符号表。
其中,.dynsym节中记录的是一些于动态链接库有关的符号,比如puts、printf、getchar、exit、sleep等库函数的对应符号。
同样,从每一个条目中可以看到不同的符号的标码值,所占空间大小,访问权限,所属类型(全局还是局部),以及符号的名称。
可以看到,hello中符号表的个数远远多于hello.o中符号表中符号的个数,这是因为链接和重定位产生了很多新的符号。
5.3.4 程序头表:
由readelf -l指令读取的hello可执行目标文件的程序头表。
程序头表的每个表项提供了各段在虚拟地址空间中的大小、位置、访问权限和对齐方式。其中:
PHDR段指定程序头表在文件及程序内存映像中的位置和大小。
INTERP段指定要作为解释程序调用的以空字符结尾的路径名的位置和大小。
NOTE段指定辅助信息的位置和大小。
DYNAMIC段指定动态链接信息。
LOAD段指定可装入段,通过 FileSiz 和 MemSiz 进行描述。文件中的字节会映射到内存段的起始位置。如果段的内存大小 MemSiz大于文件大小FileSiz,则将多余字节的值定义为 0。这些字节跟在段的已初始化区域后面。程序头表中的可装入段的各项基于VirtAddr进行排列。
以上内容是有关文档内对程序头表中段的基本介绍,比较复杂,超出了课程的要求范围,因此难免分析有误。接下来我们主要来分析分析这四个LOAD段的内容:
这是一个只读R的LOAD段,地址从0x400000开始,大小为0x588 个字节。结合节头部表中条目分析,这一段内存应该对应包含了ELF头 和程序头表的中的内容。
这是一个可读可执行RE的LOAD段,地址从0x401000开始,大小 为0x2c5个字节。结合节头部表中条目分析,这一段内存应该对应包含 了.init节以及.text节中的代码。
这是一个只读R的LOAD段,地址从0x402000开始,大小为0x130 个字节。结合节头部表中条目分析,这一段内存应该对应包含了.rodata 节中的只读数据。
这是一个只读R的LOAD段,地址从0x403e00开始,大小为0x258 个字节。结合节头部表中条目分析,这一段内存应该对应包含了.data节 和.bss中的数据。其中最后的0x258-0x254=4字节是被初始化0的.bss节 中的数据。
使用edb调试工具运行hello程序:
调处Memory Regions以及Data Dump观察窗,查看程序对应的内存状态。
可以发现该程序的内存映像中虚拟内存地址从0x400000开始,符合Linux运行时内存映像的通用标准,也与我们之前在5.3中分析程序头部表中看到第一个LOAD段中的ELF头从0x40000开始吻合,对应的magic字段7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00也与5.3中ELF头中相同。
调试运行可以看到程序入口地址_start就在0x4010d0处,与5.3中一致。从0x4010d0开始的内存中存储的机器码也与objdump观察得到的.text节中_start的代码一致
类似的,可以找到main函数对应的位置:
还可以找到从地址0x402000开始的.rodata节中存储的两个字符串常量:
其中第一个字符串常量 "Usage: Hello 学号 姓名!"首地址为0x402004,相对.rodata节偏移为0x4字节:
第二个字符串常量"Hello %s %s\n"首地址为0x402022,相对.rodata节偏移为0x22字节:
第二个字符串常量"Hello %s %s\n"首地址为0x402022,相对.rodata节偏移为0x22字节:
最后我们来看看位于从0x404040开始的.data节里存储的初始化为2的全局变量sleepsecs,其首地址为0x404050:
采用objdump -d -r hello > afterlink.txt,对可执行目标文件hello进行反汇编,结果输出到afterlink.txt中,其中main函数的反汇编代码如下图所示:
与hello.o的反汇编结果进行对比分析,hello的反汇编结果主要有以下几点不同:
链接过后得到的.text节中代码前的地址都是使用的运行时的虚拟地址, 而不是像hello.o中那样使用相对函数首地址的偏移作为每行指令的地址:
比如hello.o中下面指令的地址采用的是相对main函数首地址的偏移 量17。
而在hello中就是定位并标明了其绝对地址0x4011cd。
链接过后得到的.text节中代码会包含之前引用的库函数的对应代码:
比如puts、exits、sleep、getchar等函数的代码都会存放在.text节中:
而在连接前的可重定位目标文件中并没有这些库函数的具体的机器码。
hello中将不再包含hello.o中的重定位条目,因此所有原来在hello.o中加载需要重定位引用地址的数据,比如两个字符串常量以及全局变量sleepsecs对应的占位符0都会在链接后被修改为具体相应的PC偏移量。
下图是hello.o中对.rodata中两个字符串常量的访问代码:
可以看到,机器码中操作码后32位相对地址均用0占位,并有各自对应的重定位条目标记这里需要进行重定位。而经过重定位后,在hello中,这些占位的0就会被修改为真正的相对偏移:
上图就是hello中通过计算出相应的PC偏移量,利用相对寻址加载两个字符串常量对应的代码。
执行指令lea 0xe2e(%rip),%rdi时,寄存器%rip中保存的PC值为下一条指令的地址:0x4011d6,加上偏移量0xe2e,得到地址0x4011d6+0xe2e = 0x402004,即我们之前在5.4中查看Mem Dump所定位的第一个字符串常量的首地址。
同理,执行指令lea 0xe14(%rip),%rdi时,寄存器%rip中保存的PC值为下一条指令的地址: 0x40120e,加上偏移量0xe14,得到地址0x40120e+0xe14 = 0x402022,即我们之前在5.4中查看Mem Dump所定位的第二个字符串常量的首地址。
而对于hello.o中队.data节中全局变量sleepsecs的引用,通用也需要进行重定位替换相对寻址的占位符0:
被替换为下图中的:
当执行mov 0x2e32(%rip),%eax时,PC值为下一条指令的地址0x40121e,加上相对寻址偏移量0x2e32,得到sleepsecs在虚拟内存中的地址为:0x40121e+0x2e32=0x404050,符合5.4中查看Mem Dump所定位的全局变量sleepsecs的首地址。
与(3)类似,连接后得到的hello中将不再包含hello.o中的重定位条目,因此所有原来在hello.o中调用需要重定位确定地址的函数的代码,比如对库函数puts、exit等调用指令中的占位符0都会在链接后被修改为具体相应的PC偏移量。
我们以hello.o中对库函数puts的引用举例,说明重定位算法是如何对hello.o中的puts函数的调用地址进行重定位的。
回顾一下hello.o中对应代码:
以及重定位后的结果:
依据如下所示的重定位算法,我们来模拟一下对puts函数的调用进行重定位的过程:
首先,当前对puts的调用出现在.text节中的main函数里,ADDR(main) = 0x4011b6。而链接后,puts函数的地址为ADDR(puts) = 0x401080。
而从重定位条目中可以看到,offset = 0x21、addend=-0x04。
因此refaddr = ADDR(main) + offset = 0x4011b6 + 0x21 = 0x4011d7。 *refptr = (unsigned) (ADDR(puts) + addend – refaddr )= (unsigned)( 0x401080 – 0x4 – 0x4011d7) = 0xfffffea5(注意采用补码运算)
恰好就是hello中对应call指令的操作数——按小端序补码表示的相对偏移量。执行该条指令时,通过%rip中保存的PC值加上该偏移量作为地址就可以调用puts函数了。
以上便是对链接时如何通过重定位算法进行重定位的一个简单的解析。
使用edb调试hello,以“1190201315 LuXingyu”为参数,对hello从_start到call main到程序终止调用与跳转的各个子程序名和相应地址如下表所示:
函数名 | 首地址 |
hello!_start | 0x4010d0 |
libc-2.31.so!__libc_start_main | 0x7f72a9d0afc0 |
libc-2.31.so!__cxa_atexit | 0x7f72a9d2df60 |
libc-2.31.so!__csu_init | 0x401240 |
hello!_init | 0x401000 |
hello!frame_dummy | 0x4011b0 |
hello!register_tm_clones | 0x401140 |
libc-2.31.so!__setjump | 0x7f72a9d29e00 |
libc-2.31.so!__sigsetjump | 0x7f72a9d29d30 |
hello!main | 0x4011b6 |
hello!printf@plt | 0x401040 |
hello!.plt | 0x401020 |
libc-2.31.so!printf | 0x7f72a9d48e10 |
hello!sleep@plt | 0x401070 |
libc-2.31.so!sleep | 0x7f6709b04f40 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!getchar | 0x7f6709aad6e0 |
hello!exit@plt | 0x401060 |
libc-2.31.so!exit | 0x7f6709a68bc0 |
若程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器加载的时候再解析它,不过这种方法并不是PIC,因为它需要链接器修改调用模块的代码。GNU编译系统采用了一种有趣的技术解决这个问题,叫做延迟绑定(lazy binding),将过程地址的绑定推迟到第一次调用该过程的时候。
使用延迟绑定策略的动机是:于一个像libc.so这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要 的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是GOT(全局 偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何 函数,那么它就会有自己的GOT和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。
PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,他跳转到动态链接器中。每个被可执行程序调用的库函数都有其对应的PLT条目,每个条目都负责调用一个具体函数。PLT[1]调用系统函数__libc_start_main,它初始化执行环境,调用main函数并处理其返回值。从PLT[2]开始的条目调用用户代码调用的函数。
GOT是一个数组,其中每个条目是8字节地址,当其和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.o模块的入口点。其余的每个条目对应一个被调用的函数,其地址需要在运行时解析,每个条目都有一个与之匹配的PLT条目,初始时,每个GOT条目都指向对应PLT条目的第二条指令。
具体到hello中:
根据readelf查看的信息可知,hello的.got.plt节的起始地址为0x404000
打开edb,在Mem Dump中找到调用前的0x404000开始的GOT表中内容:
可以看到0x404000~0x404007中的8个字节对应GOT[0],存放了指向可执行文件hello的动态段的地址0x403e10:
而此时GOT[1]和GOT[2]中内容为空。
而dl_init调用后,再次观察Mem Dump中的GOT表:
可以看到GOT[1]和GOT[2]都已经被赋予了各自的值。其中,GOT[1] 存放link_map结构的地址,而GOT[2]存放指向动态链接器_dl_runtime_resolve()函数的地址。
此外,GOT[3]存放地址0x401030指向puts函数:
此外,GOT[4]存放地址0x401030指向printf函数:
等等……
不过此时这些库函数连一次都没有被调用,延迟绑定还没有生效,当其第一次被调用后,比如第一次调用puts时,发生延迟绑定,可以看到GOT[3]存放地址被修改:
修改后的地址0x7f3ce5ba35a0指向的地方就是真正的puts库函数动态链接代码:
以上便是对动态链接中GOT和PLT作用下调用库函数的延迟绑定机制的简单举例分析。
在本章中,我们首先介绍了链接的概念和作用。接着我们对器如何将hello.o这个可重定位目标文件经过重定位,生成hello可执行目标文件做出了分析。并使用readelf工具查看了hello的ELF头、节头部表、符号表以及程序头表的信息,并作出相关的分析。接着我们通过objdump工具对hello进行反汇编,并将结果与hello.o的反汇编进行对比,分析了它们的差异以及差异产生的原因,并对链接以及重定位算法更清晰的了解。除此之外,我们还借助EDB调试工具研究了hello程序运行时的内存映像,各个段的内存地址,以及hello程序的执行流程。最后我们简单分析了一下hello中设计到的动态链接,举例说明了GOT表是如何在延迟绑定机制下发生变化的,以及通过EDB工具如何查看GOT表的内容及其指向的库函数。
(第5章1分)
进程的经典定义是一个执行程序中的实例,系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的,包括存放在内存中的程序代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
在现代计算机系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一程序,我们的程序独占使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序的代码和数据好像是系统中内存唯一的对象。这些假象都是通过进程的概念提供给我们的。
我们关注的是进程提供给应用程序的两个关键抽象:
一个独立的逻辑控制了,提供一个假象,好像我们的程序独占地使用处理器;
一个私有的地址空间,提供一个假象,好像我们的程序独占地使用内存系统。
每次用户通过向shell输入一个可执行目标文件的名字,比如我们输入hello运行hello程序的时候,shell就会创建一个新的进程,然后再这个新进程的上下文中运行这个可执行目标文件。应用程序也能够自己创造进程,并且在这个新进程的上下文中运行它们自己的代码或者其他的应用程序。
在计算机科学中,shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。
bash是日常使用的Linux系统中默认的shell,它是一个交互型应用级程序,作用是提供用户与操作系统内核之间进行交互操作的一种接口。shell或bash执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求职步骤解析这个命令行,分析其相应命令是内置的系统命令还是一个可执行程序名,以及解析出相应的命令行参数,并代表用户运行程序。
shell既是一种命令语言,又是一种程序设计语言。作为命令语言,它交互式地解释和执行用户输入的命令;作为一门程序设计语言,shell 有自己的语法规则,与一些其他的高级语言类似,shell编程语言同样支持分支、循环等操作,shell编程语言允许用户编写由 shell 命令组成的程序,可以用于对命令的编辑。
shell的一般处理流程可以分为以下几个步骤:
fork函数的函数原型定义在头文件<unistd.h>中:
pid_t fork(void)
C语言中进程的创建方法是:使用fork函数,父进程调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。
子进程得到和父进程用户及虚拟地址空间完全相同的一个副本,包括代码和数据段、堆、共享库以及用户栈。子进程还可以获得与父进程任何打开文件描述符相同的副本,意味着当父进程调用fork的时候,子进程可以读写父进程中打开的任何文件。父进程与子进程之间最大的不同在于它们会有不同的PID。
当父进程成功调用fork函数创建子进程后,fork函数会返回两次:对于父进程而言,fork函数的返回值为子进程的PID,而对子进程而言,该fork函数的返回值为0,这是区别父进程与子进程的方法。如果创建子进程失败,则fork函数返回-1。
具体地,当我们在Linux bash中输入命令行“./hello 1190201315 陆星宇”的时候,bash读取该命令行并进行参数解析,得到:argv[0]指向字符串“./hello”、argv[1]指向字符串“1190201315”、argv[2]指向字符串“陆星宇”。因为argv[0]指向的“./hello”并不是一个linux bash的内置命令,所以bash会将其理解为一个可执行程序的路径,这里采用相对路径,bash在当前文件夹中找到了我们的hello程序,并将以argv[]作为参数数组执行这个hello程序。
此时bash会fork一个子进程,并在这个子进程的上下文中运行我们的hello程序。
execve函数的函数原型定义在头文件<unistd.h>中:
int execve(const char *filename, const char *argv[], const char *envp[])
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。当出现错误,例如找不到filename对应的可执行程序时,execve才会返回到调用程序,返回值为-1并将失败原因存储在全局变量errno中。而在正常情况下,execve如果调用成功,则从不返回。
参数列表argv时指向一个以null结尾的指针数组,其中每个指针都指向一个参数字符串。环境变量列表envp的数据结构与argv类似,envp指向一个以null结尾的指针数组,其中每个指针指向一个环境变量字符串,每个串都是形如“name=value”的名字-值对。
具体到我们在Linux bash中运行hello程序时,我们来分析hello的execve过程:
当bash fork一个子进程用于执行hello后,子进程会调用execve函数,传入argv以及相应的运行环境envp,在子进程的上下文中运行hello程序。
execve函数会调用驻留在内存中的被称为加载器(loader)的操作系统代码来运行hello 程序,任何Linux程序都可以通过execve函数来调用加载器。
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序入口运行该程序。这个将程序复制到内存中并运行的过程叫做加载。
当加载器运行时,它会创建一个类似于上图所示的内存映像。加载器首先删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。当完成文件映射后,加载器设置程序计数器PC值指向_start的地址,跳转并执行_start,_start函数调用系统启动函数__libc_start_main,它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并在需要的时候把控制返回给内核。
6.5.1 逻辑控制流
即使在系统中通常有许多其他程序正在运行,进程也可以给每个程序提供一种假象,好像它在独占地使用处理器等资源。如果使用调试器单步调试程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象当中的指令。这个PC值的序列就叫做逻辑控制流,简称逻辑流。如下图所示:
需要注意,进程是轮流使用处理器的。每个进程执行其逻辑流的一部分,然后被抢占,暂时挂起,轮到其他进程。对于一个运行在这些进程之一的上下文中的程序,它看上去就像是独占地使用处理器。
一个进程执行它的逻辑控制流的一部分的每一段时间叫做时间片(time slice)。如上图中进程A有两个时间片,进程B有一个时间片,进程C有两个时间片。
6.5.2 逻辑控制流
为了使操作系统内核提供一个无懈可击的进程抽象,处理器提供了一种机制来限制一个应用可以执行的指令以及可以访问的地址空间的范围。
处理器通常使用某个控制寄存器当中的一个模式位来实现该机制。该寄存器描述了进程当前享有的特权,当设置了模式位时,进程就运行在内核模式,也称超级用户模式中,此时该进程可以执行指令集中的任何指令并访问操作系统中的任何内存位置。
而如果没有设置模式位,进程就运行在用户模式中。此时进程不允许使用特权指令,比如停止处理器、改变模式位、发起一个IO操作等,也不允许直接引用地址空间中内核区的代码和数据,否则会触发段保护故障。
运行应用程序代码的进程初始时处于用户模式,进程从用户模式变为内核模式的唯一方式是通过中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式切换为内核模式,处理程序运行在内核模式中,当其返回到应用程序代码时,处理器又切换回用户模式。
6.5.3 上下文切换
操作系统内核使用一种成为上下文切换的异常控制流来实现多任务。内核为每个进程维护上下文,即内核重新启动一个被抢占的进程所需要的状态,它由一些对象的值组成,包括通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构构成。
在进程执行到某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决策叫做调度,由内核中叫做调度器的代码处理。当内核选择一个新的进程运行,我们说内核调度了这个进程。当内核调度了一个新的进程并执行后它就抢占当前进程,并使用上下文切换机制来将控制转移到新的进程。
上下文切换的过程为:1).保存当前进程的上下文,2).恢复某个先前被抢占的进程保存的上下文,3).将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用的时候,可能会发生上下文切换。此外中断也可能引起上下文切换。
6.5.4 对hello进程执行的具体分析
如果不考虑在运行时通过键盘键入Ctrl+Z或Ctrl+C发送信号,以及一些其他非程序本身产生的异常或中断,hello进程在正常执行中会发生以下种明显的进程调度、运行模式以及上下文切换:
第一种是通过调用sleep函数陷入系统调用导致的。hello程序执行到调用sleep函数后,会陷入休眠系统调用,此时操作系统将hello进程挂起,从用户模式切换为内核模式,内核处理休眠请求,并使定时器开始计时。同时内核调度另一个进程运行,通过上下文切换机制将控制转移到新的进程,并切换回用户模式执行这个新进程。
当定时器计时满了sleepsecs=2秒后,计时器会像当前正在执行的进程发送一个中断信号,此时触发相应的中断信号处理程序,处理器将模式切换为内核模式。内核进行上下文切换,恢复hello进程的上下文,将控制传递回hello进程并重新运行hello进程。
第二种是通过调用getchar函数陷入系统调用导致的。hello程序执行到调用getchar函数后,会陷入等待系统调用,直到用户进行输入。此时操作系统将hello进程挂起,从用户模式切换为内核模式,并调度另一个进程运行,通过上下文切换机制将控制转移到新的进程,并切换回用户模式执行这个新进程。
当用户完成键盘缓冲区到内存的数据传输时,会触发中断信号,进入中断处理程序,此时进入内核模式,将当前进程进行上下文切换回 hello 进程的上下文,然后回到用户模式,恢复hello进程的执行。
以下是对hello进程中因为sleep调用而导致的上下文切换的图示,getchar同理:
进程在执行的过程中可能会遇到中断、陷阱、故障和终止四种异常,其中:
中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
硬件和软件合作提供了低层异常机制,低层的硬件异常是由内核异常处理程序处理的,通常情况下对用户进程而言不可见。而信号提供了一种更高层的软件形式的异常,该机制可以通知用户发生了异常,并允许进程和内核中断其他进程。
hello在执行的过程中可能出现一些信号和异常,我们分别来分析分析:
6.6.1 执行时乱按键盘
如上图所示,在 hello进程运行过程中乱按键盘,屏幕上会依次显示出我们随便乱按键盘的内容,但并不影响hello的输出。
实际上,我们每次胡乱按下键盘的时候,都会产生中断信号,使得hello进程被暂时中断,而中断处理程序会对其进行处理,在返回后继续执行调用前待执行的下一条代码,这个过程对我们用户是隐形的,可以看到,在屏幕上没有留下任何中断处理的痕迹,就像完全没有发生过中断。
还有一处值得留意的地方在于,当hello程序打印了10次“Hello 1190201315 陆星宇”后,会调用getchar等待用户输入一个字符。而改行输入中其他的字符被清空。
而剩下的各行刚才我们胡乱键入的内容都会被继续当成用户输入的命令行被bash读取并解析,只不过显然,我们胡乱按下的内容既不是内置命令也不是一个可执行程序的路径,因此bash不断输出提示信息:“未找到命令”。
6.6.2 执行时按下Ctrl+C
如上图所示,在 hello进程运行过程中,打印了两次“Hello 1190201315 陆星宇”后,我们通过键盘按下Ctrl+C。
此时内核会发送一个SIGINT信号给正在运行的hello进程,并终止其执行。
可以看到按下Ctrl+C后,hello程序就直接终止了,不再继续打印,也不再等待用户输入一个字符。
6.6.3 执行时按下Ctrl+Z
如上图所示,在 hello进程运行过程中,打印了三次“Hello 1190201315 陆星宇”后,我们通过键盘按下Ctrl+Z。
此时内核会发送一个SIGSTP信号给正在运行的hello进程,将其挂起。直到有一个SIGCONT信号到来,通知hello进程恢复执行,hello进程都将处于停止状态。
可以看到按下Ctrl+Z后,hello进程的状态变为已停止,此时bash等待用户继续输入一行命令行。
进程树非常庞大,这里仅仅截了与hello进程有关的部分子树
可以看到输入fg命令后,hello进程恢复执行。期间,内核向被挂起的处于停止状态的hello进程发送了一个SIGCONT信号,收到信号后hello进程恢复上下文,并回到前台继续执行。
kill指令可以向指定的进程(组)发送指定的信号。这里我们向pid=3427的进程,即hello进程发送一个编号为9的信号,即SIGKILL信号,该信号会杀死hello进程,可以看到,执行该kill命令后,hello进程的状态变为了已杀死。
以上便是对hello进程执行过程中可能遇到的异常和信号的一些分类分析。
在本章中,我们首先回顾了进程的概念与作用,shell(bash)的作用与处理流程,以及fork函数与execve函数的功能。接着我们具体分析了hello进程的创建和执行过程,研究了当中的上下文切换以及用户模式与内核模式之间的转换,时间片的划分等等概念的应用。最后我们实际运行了hello程序,并分析了当中可能出现的各种异常和发送各种信号。通过本章的实验和理论分析,对进程管理、异常处理和信号等机制有了更深入的理解和体会。
(第6章1分)
物理地址(Physical Address):
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每个字节都有唯一的物理地址,第一个字节的地址为0,之后的以此类推。CPU访存的最自然方式是使用物理地址,这种访存方式称为物理寻址。早期的PC,以及数字信号处理器、嵌入式微控制器以及Cray超级计算机这样的系统仍然使用这物理寻址的方式进行访存。
hello程序最终在计算机上运行时,需要访问内存,获取相应的代码和数据,归根结底访问的还是内存单元的物理地址。
逻辑地址(Logical Address):
CPU将一个虚拟内存空间中的逻辑地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址,CPU要利用其段式内存管理单元,先将这个逻辑地址转换成一个线性地址,再利用其页式内存管理单元,转换为最终物理地址。这样麻烦地做两次转换,是为了兼容古老系统。
在有地址变换功能的计算机中,访存指令给出的操作数地址叫逻辑地址,也叫相对地址。需要经过地址变换的有关的计算才可以得到需要访问内存单元的物理地址。
线性地址(Linear Address):
线性地址是逻辑地址到物理地址变换之间的中间层。程式代码产生的逻辑地址。通过段中的相对偏移地址,再加上相应段的基地址就可以生成了一个线性地址。
如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟地址(Virtual Address):
为了更加有效的管理内存并且少出错,现代计算机系统提供了一种对主存的抽象概念,叫做虚拟内存。虚拟内存是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美结合,他为每个程序提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟内存提供了三个重要的能力:
使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存。这个虚拟地址在被送到内存之前需要先转换为适当的物理地址,该任务叫做地址翻译,需要硬件和操作系统之间的紧密合作才能完成。CPU芯片上叫做内存管理单元(MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。
这部分知识在CSAPP书上没有讲,只能参考学长的报告以及一些其他博客来简单写写:
段式管理的实际工作是讲逻辑地址转换为线性地址。
逻辑地址由48位组成,其中一部分是16位的段选择符,剩下部分为32位段内偏移量。
其中,16位段选择符的结构如下图所示:
有13位描述符表的索引;接着是1位TI,如果TI为0则描述符表是全局描述符表(GDT),如果TI为1则描述符表为局部描述符表(LDT);最后是2位RPL,它标志了段的级别,00表示最高级内核态,11表示最高级用户态,在Linux中仅使用这两种级别。
段描述符表,即段表,由段描述符(段表项)组织成数组的形式。有三种类型:
而段描述符是用来记录每个段的信息的数据结构,其中逻辑地址中的13位段选择符就是该地址所在段对应段描述符在对应段表中的索引。
一个段描述符的大小为8字节,其组织形式如下图所示:
B32~B0:32位段基地址。
L19~L0:20位限界,表示段中的最大页号。
G:与限界的单位有关。G=1表示页以4KB为单位,此时一个段最大为4KB*220=4GB;G=0表示页以字节为单位,此时一个段最大为1B*220=1MB。
D:用于表示段内偏移量的宽度。D=1为32位,D=0为16位。
P:Linux总会把P置为1,不会以段为单位淘汰
DPL:访问当前段的最低等级要求。
S:S=0表示系统控制描述符;S=1表示普通的代码段或数据段描述符
TYPE:系统描述符类型
A:A=1表示已经访问,A=0表示未被访问过。
通过逻辑地址中的16位段选择符,可以索引到对应的段表中的段描述符,获取对应段的32位基地址值,再加上逻辑地址中剩下32位的段内偏移量,即可计算出该逻辑地址对应的线性地址的值。如下图所示:
Linux为了简化,在初始化的时候,用户程序的所有的代码段和数据段的基地址都是0。
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N 个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在物理内存中。和存储器层次结构中的其他缓存一样,较低层的磁盘上的数据被分割成块,这些块作为磁盘和较高层的主存之间的传输单元。虚拟内存系统通过讲虚拟内存分割为虚拟也(Virtual Page,VP)的大小固定的块来处理这个问题,每个虚拟页的大小位P=2p字节。类似的,物理内存被分割为物理页(Physical Page,PP),大小也为P字节。
任何时候,虚拟页面的集合都由以下三个不相交的子集组成:
虚拟页与物理页的对应关系如下图所示:
我们使用术语SRAM缓存来表示位于CPU和主存之间的L1、L2、L3高速缓存,用术语DRAM缓存来表示虚拟内存系统的缓存,它主存缓存虚拟页。DRAM的组织结构由于巨大的不命中除法和访问第一个字的开销,往往采用较大的虚拟页(通常为4KB~4MB),并且采用全相联、写回的方式。
虚拟内存系统中通过包括操作系统软件,内存管理单元MMU中的地址翻译硬件和存储在物理内存中的页表(page table)的数据结构等软硬件联合的方式,来判断一个虚拟页是否被缓存到DRAM中,具体缓存到哪个物理页中,以及如果不命中,需要判断这个虚拟页存放到磁盘的哪个位置,可能需要在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM中并替换这个牺牲页。
页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表,操作系统负责维护页表的内容,以及在磁盘和DRAM中来回传送页。
页表是一个页表条目(Page Table Entry,PTE)的数组,虚拟地址空间中的每个页表中一个固定偏移量处都有一个PTE。
我们假设每个PTE由一个有效位和n为地址字段组成。有效位表明该虚拟页是否被缓存在DRAM中。如果设置了有效位,则地址字段就表示DRAM中相应物理页的起始地址,否则地址字段或为一个空地址表示这个虚拟页还未被分配,或者地址字段中的地址指向该虚拟页在磁盘上的起始位置。
如下图所示:
我们使用以下的符号和术语来描述页式管理如何将虚拟地址翻译为物理地址:
如上图所示,n位的虚拟地址被分成n-p位虚拟页号VPN和p位虚拟页面偏移量VPO。MMU利用VPN来选择适当的PTE,VPN i对应PTE i。将对应PTE中的PPN物理页号和虚拟地址中的VPO虚拟页面偏移串联起来,就得到了相应的物理地址。注意因为虚拟页和物理页都是P字节大小的,所以物理页面偏移PPO与虚拟页面偏移VPO是相同的。
下图展示了当页面命中时,CPU硬件执行的步骤:
而下图展示了当发生缺页时,CPU硬件和操作系统协同处理缺页异常的步骤:
以上便是对页式管理中地址翻译的流程的简单介绍。
我们在7.3中使用的地址翻译模型,CPU每次产生一个地址,MMU就必须查阅一个PTE并将VA翻译为PA。许多系统试图消除这种开销,它们在MMU中包括了一个关于PTE的小缓存,成为翻译后备缓冲器TLE,也成为快表。
TLB是一个小的虚拟寻址缓存,其每一行都保存着一个由单个PTE组成的块。其结构如下图所示:
用于行标记的n-p-t位TLBT和用于组选择的t位TLBI划分自虚拟地址中的虚拟页号VPN,而低p位是虚拟地址偏移VPO。
如下图(a)所示,通常情况下当TLB命中时,在TLB帮助下将一个CPU产生的VA翻译为PA的步骤:
而如上图(b)所示,当TLB不命中时,MMU必须从高速缓存中取出对应的PTE放入TLB中,这个过程可能会覆盖掉一个已经存放在TLB中的PTE的缓存。
除了用TLB快表提高地址翻译的速度外,我们还可以采用多级页表的形式来减少页表驻留在内存中的空间开销。下图是一个二级页表的示意图:
一方面如果一级页表中的一个PTE为空,则其对应二级页表就不存在;另一方面只有一级页表才需要长驻主存,虚拟内存系统可以在需要时创建、页面调入或调出二级页表,减少主存的压力,只有最经常使用的二级页表才会缓存在主存中。
下图描述了一个使用k级页表层次结构的地址翻译。虚拟地址被划分为k个VPN和1个VPO,每个VPN i都是一个到底i级页表的索引,其中1≤k≤1。前k-1级页表中的每个PTE都指向第j+1级页表的基地址。第k级页表中的每个PTE包含对应物理页的PPN或一个磁盘块的地址。
为了将VA翻译为PA,在确定PPN前,MMU必须先访问k个PTE,每次通过VPN i访问第i级页表的对应PTE,得到第i+1级页表的基地址。然后通过VPN i+1来访问第i+1级页表的的对应PTE。直到访问到第k级列表对应的PTE,就获得了PA的PPN,和VPO串联起来就得到了物理地址PA。
而将TLB和四级页表结合起来,就可以给出一个相对完整的地址翻译过程流程图了,下图就是对Intel core i7中地址翻译将VA转换为PA的过程示意图:
以上便是对快表和多级页表支持下VA到PA地址翻译的流程的分析。
经过7.4中的地址翻译步骤,我们将一个虚拟地址VA翻译成了对应的一个物理地址之PA。
此时我们需要从该物理地址指向的内存中取出我们想要的数据或指令,此时会优先从高速缓存cache中寻找是否有主存中该内容的缓存,此时涉及到多级cache的缓存机制。
之前说过PA由PPN和PPO组成,在高速缓存的支持下,我们可以继续对PA进行划分,如下图所示:
高t位被划分为高速缓存的标记位(CT),中间s位被翻译为高速缓存的组索引(CI),低b位被划分为高速缓存的块偏移(CO)。
一个高速缓存的结构如下图所示。该高速缓存有S=2s组,分别编号为0到2s-1。每个组有E行高速缓存行,每个缓存行缓存一个低级cache或内存中的块。每个缓存行由1位有效位,t位标记位和B=2b字节个用于存储缓存的内容。
以支持三级cache的系统为例。当需要从物理地址PA加载位于主存中的指令或数据时:
首先根据PA中的组索引位CI到L1-cache中对应的组。
接着通过PA中的标记位CT寻找在该缓存组中是否有该地址对应的缓存行,并且判断该缓存行的有效位是否存在。
如果通过CI和CT找到了L1-cache中对应的缓存行并且有效位为1,则L1缓存命中,此时通过块偏移CO确定读取该高速缓存行中相同偏移量处的字,而无需直接从内存中读取,大大提高了访存效率。
否则没有找到对应的缓存行或者有效位为0,则L1缓存不命中,继续从L2寻找,直至找到主存。当从L2或L3高速缓存或者主存中找到对应的块后,根据有关替换和写回策略,更新各高速缓存中的缓存行,并最终将该内存块新放入到L1-cache中。
以下是Intel core i7的高速缓存层次结构示意图。可以看到i7支持三级高速缓存,其中L3是所有和共享的统一缓存,L2是各个核独有的统一换成,L1不仅独有并且还去分了指令高速缓存和数据高速缓存。
以上便是对三级cache支持下通过物理地址访存的流程的分析。
Linux为每个进程维护了一个单独的虚拟地址空间,如下图所示:
我们之前在第5节已经接触过这幅图的一部分了,包括进程虚拟内存中熟悉的代码段、数据段、堆、共享库、用户栈等。现在我们来填充内核虚拟 内存的细节,这部分虚拟内存位于用户栈之上。
内核虚拟内存包含内核代中的代码和数据结构。内核虚拟内存中的某些区域被映射为所有进程共享的物理页面,例如每个进程共享内核的代码和全局数据结构。Linux也将一组连续的,大小等于系统中DRAM总量的虚拟页面映射到相应的一组连续的物理页面,这位内核提供了一种便利的方法来访问物理内存中的任何特定位置。内核虚拟内存的其他区域包含每个进程都不同的数据,比如页表、内核在进程的上下文执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。
Linux将虚拟内存组织为区域(段)的集合,一个区域就是已分配的虚拟内存的连续片,这些页时以某种方式相关联的。
下图时记录一个进程中虚拟内存区域的内核数据结构。内核为系统中每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含指向内核运行该进程所需要的所有信息。
任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。我们关注其中的pgd和mmap,其中pgd指向第一级页表(页全局目录)的基地址,而mmap指向一个vm_area_structs(区域结构)的链表,每个vm_area_structs都描述了当前虚拟地址的一个区域。当内核运行这个进程时,就将pgd存放在CR3控制寄存器中。
一个区域结构包含以下字段:
Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,已初始化这个虚拟内存区域的内容,这个过程成为内存映射。
一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。对于一个映射到私有对象的区域做出的改变,对于其他进程来说是不可见的,并且进程对这个区域所作的任何写操作都不会反映在磁盘的对象中。一个映射到私有对象虚拟内存区域叫做私有区域。
私有对象使用写时复制的技术被映射到虚拟内存中。一个私有对象开始生命周期的方式基本上与共享对象一样,在物理内存中只保存私有对象的一份副本。当两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象的同一个物理副本。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,且区域结构被标记为私有的写时复制。只要没有进程试图写它的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域的某个页面,则该写操作会触发一个保护故障。
故障处理程序注意到该保护异常是由于进程试图写私有的写时复制区域中的一个页面所引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向该副本,然后恢复该页面的写权限。当故障处理程序返回时,CPU从新执行这个写操作。则在新创建的页面上,写操作就可以正确执行。如下图所示:
有了上述Linux虚拟内存以及内存映射的有关知识,我们来看看当hello进程fork时发生的内存映射:
当fork函数被linux bash进程调用时,内核为新进程(也就是我们对hello进程)创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意个后来进行写操作的时候,写时复制机制就会创建新页面,就像我们刚才所说的那样。
通过内存映射的有关技术,我们就可以为每个进程保持子集私有的地址空间的概念,这也是进程这个伟大的概念所提供的一种假象。
如上图所示,当执行hello进程的时候,execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
下一次调度这个进程时,他将从这个入口点开始执行。Linux根据需要,通过页面调度,换入代码和数据页面。
关于缺页故障与缺页中断处理,我们在7.3中页式管理的时候已经讨论过处理器硬件和操作系统是如何协同处理缺页中断异常的。我们使用的是一个简单的模型,并且忽略了很多技术上的细节,只是对其做了一个简单的了解,实际上缺页处理的过程更加复杂,在了解了区域结构的细节后,我们再来看看Linux缺页异常处理。
假设MMU在试图翻译某个虚拟地址VA时出发了一个缺页异常,这个异常导致控制转移到内核的缺页异常处理程序,执行以下步骤:
以上便是Linux缺页处理程序的大致工作流程。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。不同系统之间细节但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向高地址生长。对于每个进程,内核负责维护一个叫 brk的变量,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块都是一个连续的虚拟内存片,有两种状态,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲状态,直到其显式地被应用分配;一个已分配的块保持已分配状态,知道其被应用程序显式或许内存分配器隐式释放。
分配器分为显式分配器和隐式分配器两种。它们都要求应用程序显式地分配块,而不同之处在于显式分配器要求应用显式地释放任何已分配的块;而隐式分配器检测一个已分配的块不再被程序使用,就释放这个块,这个过程叫做垃圾收集,隐式分配器也叫做垃圾收集器。
C和C++使用显式分配器而Java使用隐式分配器。其中,C语言程序提供了包括malloc函数在内的一系列动态内存分配函数作为显式分配器来进行动态内存分配;同时提供了free函数来显式释放掉已分配的堆空间。
显式分配器必须在一些相当严格的约束条件下工作:
碎片现象是造成堆利用率低的主要原因,当虽然有未使用的内存但不能满足分配请求的时候,就会发生这种现象。碎片分为内部碎片和外部碎片两种。
内部碎片是在一个已分配块比有效载荷大时发生的,比如分配器在回应动态内存申请的时候可能增加申请块的大小以满足对其要求。内部碎片就是已分配块大小和它们的有效载荷之差,任意时刻,内部碎片的数量只取决于既往的请求模式和分配器的实现方式。
外部碎片时当空闲内存合计起来能够满足一个分配请求,但没有一个空闲块足够大来处理这个请求时发生的。外部碎片的量化比内部碎片的量化困难的多,因为其不仅取决于既往请求的模式和分配器的实现方式,还取决于将来请求的模式。所以分配器通常采用启发式策略来试图维持少量的大空闲块而不是大量的小空闲块。
一个实际的显式分配器需要在吞吐率和利用率之间平衡,需要考虑以下几个问题:
接下来我们介绍几种机制来探讨一下上述四个问题的几种解决方法。
任何实际的分配器都需要一些数据结构来区别块边界以及区别已分配块和空闲块,大多数分配器将这些信息嵌入块本身。
如下图所示是一种组织形式:
在这种情况下,一个块是由一个字(4B)的的头部、有效载荷以及一些可能的填充组成的。头部编码了包括头部和所有填充在内的块的大小,以及这个块是已分配的还是空闲的。如果我们附加一个双字的对其约束条件,则块的大小一定是8的倍数,且块的最低3位一定为0。因此我们只需要内存大小的29个高维,释放剩余3位来编码其他信息,比如如上图所示我们用a位置这个位来编码该块是空闲的还是已分配的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面十一篇不适用的填充块。
在这样的块的格式下,我们可以将堆组织成一个连续的已分配块和空闲块的序列,如下图所示:
我们称这种结构位隐式空闲链表,因为空闲块总是通过头部中大小字段隐含地链接着的。分配器可以遍历堆中的所有块从而间接地遍历整个空闲块的集合。注意需要一个特殊标记的结束块,在下图示例中,我们设置一个已分配位而大小为0的终止头部:
隐式空闲链表的优点是简单。显著的缺点在于操作的开销,例如放置已分配的块,要对空闲链表进行搜索,搜索时间与堆中已分配块和空闲块的总数成正比。
隐式空闲链表的缺陷在于块分配时间与堆块总数程线性关系,一种更好的方式是将空闲块组织程某种显式的数据结构,即将空闲块组织成显式空闲链表。
使用显示空闲链表的方法,和隐含链表方式相似,唯一不同就是在空闲内存块中增加两个指针,指向前后的空闲内存块。相比隐式空闲链表,显示空闲列表虽然在空间上的开销有所增大,但其只需要在分配时顺序遍历块,而在放置以及合并操作时所用到的时间会大大减少。
显式空闲链表是一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)指针和succ(后继)指针,如下图所示:
使用显式空闲链表,使得首次适配的分配时间从块总数的线性时间减小到空闲块数量的线性时间。不过释放一个块的时间取决于空闲链表中块的排序顺序。
一种方法是使用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO顺序和首次适配的放置策略,分配器会优先检查最近使用过的快。在这种情况下,释放一个块可以在常数时间内完成,如果加上边界标记,合并一个块也可以在常数时间内完成。
另一种方法是按照地址顺序维护链表,其中链表中每个块的地址都小于其后继块。在这种情况下,释放一个块需要线性时间来定位合适的前驱,但是按照地支排序的首次适配比LIFO顺序的首次适配有更高的内存利用率,接近最佳适配。
一般而言,显式链表的缺点是空闲块必须足够大,导致了更大的最小块大小,提高了内部碎片化的程度。
分配器维护多个空闲链表,其中每个链表中的块大小大致相等,即把这些空闲块分成一些等价类,先按照大小进行索引找到相应的空闲链表再在链表内部搜索合适的块,这样相比于显式空闲链表时间效率更高。
有关动态内存分配的文献描述了几十种分离存储的方法,主要区别在于如何定义大小类以及何时进行合并,何时向操作系统请求额外的堆内存,是否允许分割等等,此处从略。
当应用请求一个k字节的块时,分配器搜索空闲链表,找到一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的:
常见的放置策略有:
一旦分配器找到了一个匹配的空闲块,就必须决定分配空闲块中多少空间。一种选择是使用整个空闲块,这种方式虽然简单快捷,但是缺点是会造成内部碎片,这对于趋向于产生好的匹配的放置策略可以接受。如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分,一部分变成分配块,剩下一部分变为空闲块。
如果分配器不能为请求找到合适的空闲块。一个选择是合并内存中物理上相邻的空闲块来创建更大的空闲块。如果仍然不足够或者空闲块已经最大程度的合并了,那么分配器会调用sbrk函数向内核请求额外的堆内存。并将额外的内存转化为一个大的空闲块插入到空闲链表中,然后将被请求块放到这个大的空闲块中。
当分配器释放一个已分配块时,可能有其他空闲块与该新释放的空闲块相邻,这些邻接的空闲块可能引起假碎片现象,即许多可用的空闲块被分割成小的无法使用的空闲块。为了解决假碎片问题,分配器需要合并相邻的空闲块,该过程被成为合并。分配器选择合并时机一般有以下几种策略:
以上便是对C语言动态存储分配管理中设计到的内容的大致分析。
在本章中,我们首先介绍了虚拟地址、线性地址、逻辑地址和物理地址的概念。接着我们分析了段式管理如何利用段表将逻辑地址翻译为线性地址。然后我们介绍了页式管理如何利用页表进行地址翻译将虚拟地址转化为物理地址,并分析了如何利用TLB快表和多级页表来加速地址翻译的过程。然后我们介绍了三级高速缓存的工作原理。至此对整个存储器体系架构以及它们是如何工作的有了一个完整而清晰的认识。进一步我们讨论了Linux是如何进行虚拟内存映射的,包括fork和execve时的内存映射机制以及如何缺页异常的发生和异常处理流程。最后我们总结了C语言动态内存分配机制,如何利用动态内存分配器进行堆空间的分配,以及所涉及到的空闲块搜索、放置、合并、回收等策略,对动态内存分配有了更深刻的认识。
(第7章 2分)
输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备。
所有语言的运行时系统都提供执行I/O的较高级别工具。比如hello程序中的printf就是ANSI C提高的标准I/O库中的I/O函数。在Linux系统中,是通过使用内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。
一个Linux文件就是一个m个字节的序列:B0,B1,… ,Bk,… ,Bm。所有的I/O设备(例如网络、磁盘和终端)都被模型化文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,即Unix I/O接口,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
每个Linux文件都有一个类型来表明它在系统中的角色:
Linux内核将所有文件都组织成一个目录层次结构(directory hierarchy),由名为/的根目录确定。系统中的每个文件都是根目录的直接或间接后代,示意图如下:
作为其上下文的一部分,每个进程都有一个当前工作目录来确定其在目录层次结构中的当前位置。可以用cd命令来修改shell中的当前工作目录。
目录层次结构中的位置用路径名(pathname)来指定。路径名是一个字符串,包括一个可选斜杠,其后紧跟一系列的文件名,之间用斜杠分隔。路径名有以下两种形式:
以上便是对Linux的I/O管理以及文件模型的简要介绍。
对Unix I/O接口的介绍详见8.1,下分析Unix I/O接口提供的一系列函数:
8.2.1 打开和关闭文件
进程是通过调用open函数来打开一个已存在的文件或创建一个新文件的:
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。
flags参数指明了进程打算如何访问该文件:
flags参数也可以是一个或更多位掩码的或,为写提供一些额外的指示。
mode参数指定了新文件的访问权限位,如下图所示:
作为上下文的一部分,每个进程都有一个umask,它是通过调用umask 函数来设置的。当进程通过某个带mode参数的open函数来创建一个新文件 时,文件的访问权限就被设置为mode&~umask。
进程关闭一个文件的方式是调用close函数,关闭一个已经关闭的文件会 出错:
8.2.2 读和写文件
应用程序是通过分别调用read函数和write函数来执行输入输出的:
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF,否则返回值表示实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
在某些情况下,read和write传送的字节比应用程序的要求要少,这些不足值不表示有错误,出现这样的情况可能有:读时遇到EOF;从终端读文本行;读和写网络嵌套字等等。
8.2.3 读取文件元数据和读取目录内容
应用程序能够通过调用stat和fstat函数,检索到关于文件的信息,即文件的元数据(metadata)。
stat函数以一个文件名为输入,并填写如下图所示的一个stat数据结构中的各个成员。fstat函数是相似的,只不过以文件描述符而不是文件名为输入。
应用程序可以用readdir系列函数来读取目录的内容:
opendir函数以路径名为参数,返回指向目录流的指针。流是对条目有序列表的抽象,这里是指目录项的列表。
每次对readdir的调用返回的都是指向流dirp中下一个目录项的指针,如果没有更多目录则返回NULL。
由于这部分设计到许多之前未涉及的概念,并且也比较冷门,所以不做 过多讨论。
以上便是对Unix I/O 接口中函数的简要介绍。
首先查看printf的源码,如下图所示:
注意到形参列表中有三个“.”, 这是可变形参的一种写法。当需要传递参数的个数不确定时,就可以用这种方式来表示。
在printf中va_start和va_end函数是获取可变长度参数的函数,任何可变长度的变元被访问之前,必须先用 va_start()初始化变元指针 argptr。初始化 argptr 后,经过对 va_arg()的调用,以作为下一个参数类型的参数类型,返回参数。最后取完所有参数并从函数返回之前。必须调用 va_end()。由此确保堆栈的正确恢复。里面的具体机制设计到边长参数列表的机制,此处不做过多纠缠。
然后,printf 调用了Unix I/O接口中的write 函数,用以在标准输出(文件描述符为1)中输出printbuf字符串中的前i 字节。其中 i = vsprintf(printbuf, fmt, args)。
接下来我们来看看vsprintf 函数,vsprintf的一个简化版本的示意源代码如下图所示:
回忆一下printf接受一个格式化的命令,并把指定的匹配的参数格式化输出。
不难发现,vsprintf 的功能就是将 printf 的参数按照各种各种格式进行格式化分析,将格式化后需要输出的字符串储存在缓冲buf 中,最终返回要输出的字符串的长度。上图示意代码是一个简化版本,其只实现了对%x,也就是十六进制输出的格式化。仅仅是用来示意vsprintf的功能的,实际上内部实现比较复杂,我们不必关心其具体实现,只需要知道它所作的事情就是按照printf格式串的模式,格式化输出字符串存储在buf中,并返回需要输出字符串的长度。
接着就轮到write系统函数来执行写文件操作了,正如我们之前在8.2中介绍的那样:
write 函数的第一个参数为写的目标文件的文件描述符,而此时1就是标准输出的文件描述符。
通过追踪write 函数的汇编实现可以发现,它首先给寄存器传递参数,然后执行int INT_VECTOR_SYS_CALL,代表通过系统调用syscall,syscall 将寄存器中的字节通过IO总线复制到显卡的显存中。字符显式驱动子程序通过需要打印字符的ASCII值,从字模库中选取对应的点阵字模传送给Video RAM。显示芯片按照刷新频率逐行读取 Video RAM,并通过信号线向液晶显示器传输每一个点 (RGB分量),由此显示器就可以最终显示出需要打印的字符串了
以上便是对printf的实现的分析。
当我们按下键盘上的按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个键盘中断请求,键盘中断请求会抢占当前进程。内核通过中断号查找相应的异常处理程序的首地址,跳转到执行对应的键盘中断异常处理程序,响应并处理这个键盘中断。
键盘中断处理程序先取得该按键对应的扫描码,然后将该按键扫描码转换成相应的ASCII 码,保存到系统的键盘缓冲区之中。
以上便是键盘中断的大致介绍,下面我们来看看getchar函数的示意代码:
可以看到,getchar函数的实现是调用了Unix I/O接口中的read函数读取文件:
其中第一个参数为0,表示从描述符为0的文件即标准输入中读取。第二个参数&c表示将读取的字符存到c中。第三个参数1表示读取一个字节。
而read函数会通过系统调用的方式,读取存储在键盘缓冲区中键入的内容中的第一个字符。而如果还按下了其他按键,则将其键盘扫描码转换为ASCII码后会缓存在输入缓存区,等待下一次read操作时被读走或者被fflush函数清空。
以上便是对getchar的实现的分析。
在本章中,我们讨论的是hello程序所涉及到的Linux I/O管理的内容。首先我们介绍了Linux的I/O管理的模型,最重要的就是Linux文件的概念以及Unix I/O接口的概念。接着我们介绍了Unix I/O接口中几个常见的函数,包括打开关闭文件、读写文件等。最后我们简单地介绍了hello中涉及到的printf函数是怎样通过调用write函数向标准输出中写入来实现屏幕打印的,以及getchar函数是怎样通过调用read函数向标准输入中读取来实现键盘输入的。
(第8章1分)
Hello的一生看似简单,实则丰富多彩,在上面的8章,我们详细地分析了hello程序的整个P2P和O2O的生命周期。
从数据表示,到预处理、编译到汇编、链接,到进程管理,异常处理和信号处理,到存储器体系结构,到内存映像,到虚拟内存和地址翻译,到I/O管理等等。囊括了本学期计算机系统课程的几乎所有内容,所覆盖的知识点相当广泛,是一个难得的复习和运用所学计算机系统知识来综合分析问题、实验解决问题的机会。
计算机系统课,就是想从了解一个程序是怎样在计算机上运行的这一问题引入,来串起书中的各个章节的,我们的大作业也是。
HelloWorld程序是我们学习计算机课程所编写第一个程序页,也是最简单的一个。然而,看似如此简单的一个程序,想要在计算机上运行起来,其背后涉及到的原理也是相当复杂的,小小的hello也有其精彩的一生:
通过本次大作业,我收获巨大。主要是对课程中各个章节的内容构建起了一个整体的知识体系,在学习的过程中,由于知识过于庞杂,导致一直都没有很好的将课程中各个章节的内容有机地结合到一起,贯融会贯通。但随着8个实验的逐步完成,课程逐渐来到尾声,进程、内核、异常与信号、存储体系、内存映像、虚拟内存等等重要概念一环扣一环的被提及,在本次大作业的实践帮助下,终于打通了任督二脉,对CSAPP课程的整个知识体系,搭建起了一个总体完整的知识体系。
整整四万余字的报告,作为自己这一学期努力学习CSAPP课程的收官一役,的确是拿出来应有的认真态度。我相信,在不远的未来,或许我并不会从事硬件底层的工作,但在未来的学习和工作中,对于计算机系统的理解能够给予我很大的帮助,能够润物细无声,潜移默化地影响计算机学生对于其他计算机课程的理解。最终也必将通过未来的不断地学习,加深和巩固我们对于计算机系统的理解!
感谢老师们的辛苦付出,计算机系统课,名不虚传!
也感谢我自己,为未来打下了一个坚实的基础!
如下表格所示:
文件名 | 文件说明 |
hello.c | hello程序的C语言源代码文本文件 |
hello.i | hello.c经过预处理得到的预处理后的文本文件 |
hello.s | hello.i经过编译得到的编译后的文本文件 |
hello.o | hello.s经过汇编后得到的二进制可重定位目标文件 |
disa_hello.txt | hello.o的反汇编结果输出到文本文件 |
hello | hello.o经过链接后得到的二进制可执行目标文件 |
afterlink.txt | hello的反汇编结果输出到文本文件 |
before_elf.txt | 使用readelf查看hello.o可重定位目标文件的ELF信息输出到文本文件 |
after_elf.txt | 使用readelf查看hello可执行目标文件的ELF信息输出到文本文件 |
CS大作业论文.docx | 大作业论文 |
CS大作业自媒体截图.jpg | 大作业传到个人公开博客的截图,博客网址: |
[1] 《深入理解计算机系统》第三版
[2] C语言预处理命令和预处理器概述 http://c.biancheng.net/view/286.html
[3] 百度百科编译词条
https://baike.baidu.com/item/%E7%BC%96%E8%AF%91/1258343?fr=aladdin
[4] 百度百科汇编词条
https://baike.baidu.com/item/%E6%B1%87%E7%BC%96%E8%AF%AD%E8%A8%80/61826?fr=aladdin
[5] objdump命令详解 https://blog.csdn.net/qq_41683305/article/details/105375214
[6] readelf命令使用说明 https://blog.csdn.net/yfldyxl/article/details/81566279
[7] 百度百科链接词条
https://baike.baidu.com/item/%E9%93%BE%E6%8E%A5%E5%99%A8/10853221?fr=aladdin
[8] ELF文件解析 https://blog.csdn.net/qq_43390703/article/details/108592073
[9] 程序头表解析
https://docs.oracle.com/cd/E26926_01/html/E25910/chapter6-83432.html#chapter6-69880
[10] 百度百科shell词条
https://baike.baidu.com/item/shell/99702?fr=aladdin
[11] 百度百科bash词条
https://baike.baidu.com/item/Bash/6367661?fr=aladdin
[12] 《深入理解计算机系统》读书笔记
https://blog.csdn.net/diaozunli1756/article/details/101209314
[13] 操作系统内存地址 https://blog.csdn.net/s1234567_89/article/details/7924369
[14] 百度百科逻辑地址词条
https://baike.baidu.com/item/%E9%80%BB%E8%BE%91%E5%9C%B0%E5%9D%80/3283849?fr=aladdin
[15] 段页式访存——逻辑地址到线性地址的转换
https://blog.csdn.net/Pipcie/article/details/105670156
[16] 缺页中断处理过程 https://blog.csdn.net/yusiguyuan/article/details/46820061
[17] printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
[18] 学长的报告:https://github.com/shijiyuanaa/HIT_CSAPP_2018
[19] 学长的报告:https://github.com/1180400522/1180400522-2019-CSAPP--Hello-s-P2P