计算机系统大作业
摘 要
本文通过分析hello.c程序从创建到消失的全过程,从而更深入地理解计算机系统在预处理、编译、汇编、链接等阶段的行为,以及进程管理、存储管理和IO管理的相关知识。
关键词:hello,编译,链接,进程管理,存储管理,IO管理。
第1章 概述
1.1 Hello简介
程序员写完C语言代码后,利用gcc编译器对C语言程序执行编译命令。
P2P过程:
hello.c文件先经过预处理器生成hello.i,再经过编译器生成hello.s(汇编程序),然后经过汇编器as生成可重定位目标程序hello.o,最后通过链接器ld链接生成可执行文件hello。在Linux终端执行./hello命令,运行该可执行文件(Process)。
O2O:可执行文件hello执行后,shell通过execve函数和fork函数创建子进程并将hello载入,映射虚拟内存,进入程序入口后将程序载入物理内存,开始执行目标代码,CPU执行逻辑控制流。最后程序运行结束后,shell接受到相应的信号,启动信号处理机制,对该进程进行回收处理,释放其所占的内存并删除有关进程上下文,hello程序重新回归0。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows10 64位;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位;
开发工具:Visual Studio 2019 64位;GDB/OBJDUMP;GCC;EDB等
1.3 中间结果
文件名称 文件说明
hello.c hello源文件
hello.i 预处理后生成的文本文件
hello.s 编译生成的汇编文件
hello.o 汇编后生成的可重定位目标文件
hello 链接后生成的可执行文件
hello.objdump hello的反汇编代码
hello.elf hello的elf文件
objdump.txt hello.o的反汇编代码
elf.txt hello.o的ELF格式
1.4 本章小结
本章简单介绍了hello程序的一生,交代了本实验的硬件环境、软件环境、开发工具以及本实验中生成的中间结果文件的名字和作用。
第2章 预处理
2.1 预处理的概念与作用
基本概念:
是指在源代码编译之前对源代码进行的错误处理,一般在源代码被翻译为目标代码的过程中,生成二进制代码之前。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,并删除注释。C和C++中常见的编译程序需要进行预处理的程序指令主要有#define、#include、#error等。
作用:
预处理的功能包括宏定义,文件包含,条件编译三部分,分别对应宏定义命令、文件包含命令、条件编译命令三部分实现。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行响应的转换。预处理过程还会删除程序中的注释和多余的空白字符。
上述处理可以使一个源代码编译程序可以在不同的程序运行语言环境中被各种语言编译器更方便地编译。
2.2在Ubuntu下预处理的命令
对hello.c文件进行预处理,指令为:gcc -E -o hello.i hello.c
图2-1 预处理命令
2.3 Hello的预处理结果解析
图2-3-1 hello.c文件截图
图2-3-2 hello.i文件截图
通过上面两张图片我们可以对比看出原本内容很少的hello.c文件被扩充成了很多内容。编译器分别对头文件、#include<stdio.h>、#include<stdio.h>进行了相应处理,对于原文件的宏进行了宏展开,引入头文件内容,同时注释也被删除了。
2.4 本章小结
本章引入了预处理的概念并介绍了预处理的运行机制及其作用。在预处理时。编译器对程序代码进行第一次修改,主要针对头文件、宏定义以及注释进行操作,将结果保存在相应的.i文件中。
预处理是对编译器程序进行修改的第一步,是之后所有操作的基础,需要我们掌握原理。同时本章中我们也分析了.c文件预处理的结果(.i)文件。
第3章 编译
3.1 编译的概念与作用
基本概念:
编译指的是程序从预处理文本文件(.i文件)经由编译器处理产生汇编程序(.s)的过程。编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,其中.s包含的是汇编语言程序。
作用:
首先,编译实现的功能为以下三个:进行词法分析、语法分析、目标代码的生成。编译器首先将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号,然后基于词法分析得到的一系列记号生成语法树。编译器的前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码。
总的来说,编译就是将高级程序设计语言源程序翻译成汇编语言或机器语言表示的目标程序。
3.2 在Ubuntu下编译的命令
对hello.i文件进行编译,指令为:gcc -S -o hello.s hello.i
图3-2 编译命令
3.3 Hello的编译结果解析
3.3.1 汇编指令
对hello.s的头部进行分析:
指令 内容
.file 声明源文件
.text 声明代码段
.globl 声明全局变量
.data 声明数据段(已初始化的全局和静态C变量)
.align8 声明对指令或者数据的存放地址进行对齐的方式
.type 指明函数、对象类型
.size 声明变量大小
.section .rodata 只读数据段
具体代码如下:
3.3.2 数据
hello.c中的数据类型包括整型、数组、字符串三种类型。
(1)整型数据
i) int i
i是main函数中定义的局部变量,不会出现在.s文件头部声明中,只是因为局部变量仅会被保留在栈区或者寄存器内,不会存放在数据或者代码段。
在hello.s中,我们可以看到i被保留在栈指针%rbp-4的地址处:
图3-3-2-1 局部变量i在.s中的存储
ii) int argc
argc是main函数的第一个参数,也是整个程序的第一个参数,先由%edi保存argc(函数的第一个参数),最后argc被存放在栈中栈指针%rbp-20的位置。具体代码如下图所示:
图3-3-2-2 局部变量argv在.s中的存储
(2)数组
char
argv[]
argv数组是main函数的第二个参数,数组元素为char
类型,数组名argv也就是指向存储着多个char*类型数据的连续空间的指针。在hello.s函数中,%rsi寄存器保存argv数组首地址(也就是函数的第二个参数),具体代码如下图所示:
图3-3-2-3 数组argc在.s中的存储
(3)字符串
i)“用法: Hello 学号 姓名 秒数!\n”
是第一个printf传入的输出格式化参数,被存放在.rodata段中。具体代码如图所示:
图3-3-2-4 字符串在.s中的存储
ii) “Hello %s %s\n”
是第二个输出格式化参数,tongyang存放在.rodata中。具体代码如下图所示:
图3-3-2-5 字符串在.s中的存储
3.3.3 处理运算、关系操作符与控制语句
(1)赋值与算数操作
运用movl语句对局部变量i进行赋值,具体如图:
图3-3-3-1 对i赋值
当执行for循环时,运用add命令来实现i++,具体如图:
图3-3-3-2 i++实现
(2)关系运算符
在本课程中,经典关系操作分为以下四类:
CMP指令、TEST指令、SET指令、Jmp指令。在hello程序中只涉及到CMP和Jmp指令,所以这里只详细介绍这两个指令。
先简单复习一下CMP指令。必须清楚的是,CMP指令的运作机制是根据两个操作数之差来设置条件码,不更新目的寄存器。指令后缀b,w,l,q分别表示比较字节、字、双字、四字。如果两个操作数相等,这些指令会将零标志设置为1,而其他标志可以用来确定两个操作数之间的大小关系。
对于Jmp指令,它会检查条件码并根据条件码进行有条件或是无条件的跳转。
图3-3-3-3 jmp指令
然后,我们来看一下hello程序中的关系运算。
i)argc != 4
对于判断argc不为4,.s文件中使用了cmpl指令比较 %rbp-20地址中存放的数据也就是argc是否与4这个立即数相等,具体实现如图:
图3-3-3-4 判断argc!=4
ii)i < 8
在for循环中需要判断临界条件,也就是i与8的关系,如果i>=8则结束循环。.s文件中使用了cmpl指令,通过比较%rbp-4指向的数据(也就是i)和立即数7的关系来设置条件码,方便之后的jle指令进行跳转。具体实现如图:
图3-3-3-5 判断i<8
(3)控制转移
i)当argv!=4时,进行跳转。如图所示,首先,运用cmpl指令判断%rbp-20与4的关系,根据结果设置条件码,再用je指令判断ZF标志位。ZF为零说明argv=4,那么就执行跳转,直接到L2,否则不跳转,直接顺序执行下一条代码。
图3-3-3-6 if判断
ii)for循环中也存在跳转。首先无条件跳转到cmpl部分,i<8的话再执行循环,否则结束循环。之后每执行一次循环体都要重复以上操作。具体如图:
图3-3-3-7 for循环跳转
3.3.4 函数操作
在进行函数操作时,例如:A函数调用B函数,那么PC会先将值设置为B的起始地址,然后在返回时设置值调用的下一条指令的地址。A需要传递一至多个参数给B,B返回一个返回值。此外,调用B时会给B分配单独栈帧,在返回时还要回收空间。
在hello程序中,涉及的函数操作如下:
(1)main函数
控制:基于系统内部指令调用编译器系统可以通过使用系统相应的一个call内部指令调用语句对一个main调用函数内部指令进行函数调用。call会将下一个系统调用函数指令的初始化和地址数据进行自动压缩并将其写入栈中,然后自动依次跳转这个调用指令语句到系统相应的一个main调用函数内部。
数据:main函数的两个参数argc和argv分别存储在%rdi和%rsi中,这里不再赘述。
(2)printf函数
调用两次printf函数,分别传递两个字符串。
控制:第一次printf调用call puts@PLT;第二次printf使用call printf@PLT
数据:第一次调用printf传入了一个字符串,该字符串首地址被存入%rdi寄存器。第二次再将第二个输入的字符串首地址放入%rdi。
(3)exit函数
控制:call exit@PLT。
数据:设置%rdi寄存器为1。
(4)sleep函数:
控制:call sleep@PLT。
数据:设置%edi。
(5)getchar函数
控制:call gethcar@PLT。
数据:读入空白字符。
3.4 本章小结
本章简单的介绍了编译器处理c语言程序的概念、作用、基本过程,根据hello原程序中使用的各种数据类型、运算、循环操作和函数调用等操作对hello.s中的汇编代码进行分析。
编译器将.i 的拓展程序编译为.s 的汇编代码。通过分析更加底层的汇编代码,我们能更深入地体会到机器是如何拆分执行一条高级语言指令的,这对我们了解计算机底层很有帮助。
第4章 汇编
4.1 汇编的概念与作用
基本概念:
编译器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。简单来说,就是将汇编代码转变成可执行的指令并生成目标文件。
作用:
hello.o文件是一个简单的二进制指令编码文件,它可以包含目标程序的所有指令进行编码。此外汇编器还可以通过翻译生成计算机能直接自动识别和控制指令执行的一种二进制语言,也即机器语言。
4.2 在Ubuntu下汇编的命令
对hello.s文件进行汇编,指令为:gcc -c hello.s -o hello.o
图4-2 汇编指令
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用指令:readelf -a hello.o > elf.txt 以获得hello的ELF文件并重定向到elf.txt。通过文本编辑器可以查看elf.txt。
图4-3 用readelf命令查看elf文件
4.3.1 ELF头
ELF头以一个16字节的目标序列开始,它描述了一个生成该目标文件的操作系统的目标文件大小和生成目标字节的顺序,剩余的其他部分包括ELF头的位置和大小、目标文件的位置和类型(可重定位、可执行或者目标文件所共享的)、机器文件类型、节头部表的目标文件位置和偏移、节头部表的偏移大小和目标文件数量等信息。不同节的目标文件位置和偏移大小都是由一个节头部表条目来描述的,其中每个目标文件中每个目标字节都有一个固定大小的节头部表条目。
图4-3-1 ELF头
4.3.2 节头表
节头表记录目标文件的节的全部信息,描述目标文件中不同节的位置和大小。具体如下图所示:
图4-3-2 节头表
4.3.3重定位信息
用诸如readelf等多种工具准确列出ELF文件其各个数据节的基本工作项目定位信息后,我们需要特别注意针对重定位项目的分析。重定位是将EFL文件中的未定义符号关联到有效值的处理过程。在重定位信息中,包括了将虚拟地址转换为绝对地址的相关信息。
图4-3-3 重定位信息
4.3.4符号表
符号表用来存放程序中定义和引用的函数和全局变量的信息。要注意的是,局部变量存在于栈中,并不存于这里。符号表索引是对此数组的索引。
具体实现如下图所示:
图4-3-4 符号表
4.4 Hello.o的结果解析
输入命令:objdump -d -r hello.o 将hello.o的反汇编结果输入到objump.txt中,然后利用文本编辑器查看该文件。
跟hello.s中内容对比得到结果如下(以main函数为例):
图4-4 hello.s中main函数汇编代码与反汇编代码对比
4.4.1 函数调用
在hello.s中,call指令后面直接就是目标函数的名称,并没有直接确定下一条指令的相对地址位置,而在反汇编代码中,call后面的函数调用的目标函数相对地址是下一条的指令。
因为hello.s在程序中需要调用的每个函数都是一个共享库程序当中的重定位函数,它需要等待重定位的函数目标地址被链接到共享库程序当中。在没有重定位函数时,调用的动态链接函数的地址和运行时目标地址也无法确定。此时对于这写不确定的函数调用,汇编器就将其call指令后的相对地址设置为全0,然后在重定位的代码段节中添加重定位条目,在链接后再进一步确定。
4.4.2 全局变量的访问
在hello.s中,访问全局变量.rodata段的方式是段的名称+寄存器,而在反汇编输出的文本文件中,变成了0+寄存器,也就是把段名称对应的地址设置为0。这个的原因和上面类似,也是因为全局变量数据的地址是在运行时候确定的,对全局变量进行访问的时候也是需要进行重定位操作的。
4.4.3 分支转移
反汇编代码跳转指令的操作数使用的不是.s中的段名称,段名称只是在汇编语言中便于编写的助记符,在汇编成机器语言之后使用地是确定的地址。
4.5 本章小结
本章针对hello.s汇编到hello.o进行了详细的分析。在本章中,我们查看了hello.o的可重定位目标文件的格式,并使用反汇编查看hello.o的反汇编代码,把它与hello.s进行比较,分析了从汇编语言进一步翻译成为机器语言的汇编过程。
第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-2 链接命令
5.3 可执行目标文件hello的格式
输入指令: readelf -a hello > hello.elf 读取hello的ELF格式至hello.elf中。
ELF各节信息中保存了可执行文件hello中的各个节的信息。如下图所示, hello文件中的节的数目比hello.o中要多,说明在链接过后有新内容被添加进来。
(1)链接后的节头表
图5-3-1 链接后的节头表
(2)链接后的ELF头:
图5-3-2 链接后的ELF头·
(3)程序头部表:
图5-3-3 链接后的程序头表
(4)重定位节:
图5-3-4 链接后的重定位节
5.4 hello的虚拟地址空间
如下图所示,我们可以看出虚拟地址空间起始地址为0x400000:
图5-4-1 hello虚拟地址空间
我们可以在edb中找到.inerp偏移量和.text段和.rodata 段等信息:
图5-4-2 .text段和.rodata 段
5.5 链接的重定位过程分析
输入命令:objdump -d -r hello > hello.objdump 得到hello的反汇编文件。
下面比较hello.o反汇编结果和hello反汇编结果:
图5-5-1 hello.o反汇编结果
图5-5-2 hello反汇编结果
分析hello与hello.o的不同,说明链接的过程。
函数个数:在动态共享库libc.so中,定义了hello需要的printf、sleep、getchar、exit 函数和_start 中调用的 __libc_csu_init,__libc_csu_fini,__libc_start_main。这些函数在链接时被链接器加入其中。
节的改变:增加了.init和.plt节,和一些节定义中的函数。
函数调用:链接器解析重定条目时需要对R_X86_64_PLT32进行重定位。在对外部内存调用函数的地址进行重定位后,链接器针对已经确定的.text与对应的.plt节的相对距离调用地址计算相对偏移距离,修改下条指令的相对偏移地址,将其指向链接器对应的函数,这样hello.o的相对距离偏移调用地址本身就变成了链接器hello.o中的虚拟内存调用地址。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
重定位的方法:链接器通过把每个符合定义与一个内存位置关联起来,然后修改所有对这些符号的引用,从而重定位这些节。
5.6 hello的执行流程
如下表所示:
程序名称 程序地址
ld-2.27.so!_dl_start 0x7fce:8cc38ea0
ld-2.27.so!_dl_init 0x7fce:8cc47630
hello!_start 0x400500
libc-2.27.so!__libc_start_main 0x7fce:8c867ab0
-libc-2.27.so!__cxa_atexit 0x7fce:8c889430
-libc-2.27.so!__libc_csu_init 0x4005c0
hello!_init 0x400488
libc-2.27.so!_setjmp 0x7fce:8c884c10
hello!main 0x400532
hello!puts@plt 0x4004b0
hello!exit@plt 0x4004e0
*hello!printf@plt ——
*hello!sleep@plt ——
*hello!getchar@plt ——
ld-2.27.so!_dl_runtime_resolve_xsave 0x7fce:8cc4e680
-ld-2.27.so!_dl_fixup 0x7fce:8cc46df0
–ld-2.27.so!_dl_lookup_symbol_x 0x7fce:8cc420b0
libc-2.27.so!exit 0x7fce:8c889128
5.7 Hello的动态链接分析
共享链接库代码是一个动态的目标模块,在程序开始运行或者调用程序加载时可以自动加载该代码到任意的一个内存地址,并和一个在目标模块内存中的应用程序链接起来,这就是对动态链接的重定位过程。
但是编译器在程序中的函数开始运行时是不能自动预测各个函数的开始运行时间和地址的。这就可能需要系统添加重定位的记录,交给一个动态共享链接器或者采用它来进行重定位的动态共享链接,这样有效地防止了程序运行时自动修改或者调用目标模块的位置无关代码段。
动态的链接器在正常工作时链接器采取了延迟绑定的链接器策略。由于静态的编译器本身无法准确预测变量和函数的绝对运行时地址,动态的链接器需要等待编译器在程序开始加载时再对编译器进行延迟解析,这样的策略称之为动态延迟绑定。got链接器叫做全局变量过程偏移链接表,在plt和got中分别存放着链接器的目标变量和函数的运行时地址。一个动态的链接器通过静态的过程偏移链接表plt+got链接器实现了函数的一个动态过程链接,这样一来,它就包含了正确的绝对运行时地址。
5.8 本章小结
本章通过对hello可执行程序的分析,回顾了链接的基本概念、文件的重定位过程、动态链接过程、虚拟地址空间、可重定位目标文件ELF格式的各个节等与链接有关的内容。
经过链接,可重定位目标文件变为可执行的目标文件,链接器会将静态库代码写入程序中,以及调用动态库等相关信息,将地址进行重定位,从而保证寻址的正确进行。静态库直接写入代码即可,而动态链接过程相对复杂一些,涉及共享库的寻址。
通过本章可知,链接的过程在软件开发中扮演一个关键的角色,它们为分离编译提供了可能性。
第6章 hello进程管理
6.1 进程的概念与作用
基本概念:
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,包括代码段、数据段、和堆栈区。代码段存储CPU执行的代码,数据段存储变量和进程执行期间使用的动态分配的内存,堆栈区存储活动过程调用的指令和本地变量。
进程的地址空间如下图:
图6-1 进程的地址空间
作用:
进程是一个针对执行中的应用程序的程序的实例。系统中的每个应用程序都可以运行在某个应用进程的可执行上下文中。每次程序用户可以通过向系统中的shell应用程序输入一个可执行程序的英文名字,运行这个应用程序时,shell就可能会自动创建一个新的应用进程,然后在这个新应用进程的可执行上下文中自动运行这个文件,应用程序也同样可以自动创建新的可执行进程,并且在这个新进程的可执行上下文中用户可以运行他们自己的可执行代码或者其他的应用程序。
进程有一个重要的概念,它就是局部性。局部性有两个关键抽象:一个独立的程序逻辑控制流:它可以提供一个独立的假象,好像我们的应用程序在一个独占的空间使用内存处理器。一个应用程序私有的地址处理器空间,它可以提供一个独立的假象,好像我们的应用程序独占的一个使用内存的系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash的作用:
shell俗称壳,是一种指”为使用者提供操作界面”的嵌入式软件(也被称为命令解析器)。shell提供了一种允许用户与其他操作系统之间进行通讯的一种方式。这种简单的通讯方式可以以交互方式(从键盘输入,并且用户可以立即地得到命令响应),或者以交互方式shellscript(非交互)的方式允许用户执行。shell(即壳)是一个简单的命令解释器,它允许系统接收到一个用户的命令,然后自动调用相应的命令执行应用程序。
Shell 的处理流程:
shell读取用户从终端使用外部设备输入(通常是键盘输入)的指令。解析所读取的指令,如果这个指令是一个内部指令则立即执行,否则加载调用一个应用程序为申请的程序创建新的子进程,在子进程的上下文中运行。同时shell还允许接收从键盘读入的外部信号(如:kill)并根据不同信号的功能进行对应的处理。
6.3 Hello的fork进程创建过程
用户在终端输入对应的指令(./hello 1190200627 王思睿),这时shell就会读取输入的命令,并开始进行以下操作。
首先判断hello不是一个内置的shell指令,所以调用应用程序,找到当前所在目录下的可执行文件hello,准备执行。
Shell会自动的调用fork()函数为父进程创建一个新的子进程,子进程就会因此得到与父进程(即shell)虚拟地址空间相同的一段各种的数据结构的副本(包括代码和数据段,堆,共享库和用户栈)。父进程与子进程最大的不同在于他们分别拥有不同的PID,父进程与子进程分别是两个并发的进程,在子进程中程序运行的这个过程中,父进程在原位置等待着程序的运行完毕。
6.4 Hello的execve过程
execve函数在新创建的子进程的上下文中加载并运行hello程序。execve函数的功能是加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有发生错误时execve才会返回到调用程序。所以,execve调用一次且从不返回。
当maqin函数开始时,用户栈的组织结构如下图所示:
图6-6 用户栈的组织结构
6.5 Hello的进程执行
进程为每个程序提供了一种假象,好像程序在独占的使用处理器。如图每个竖直的条表示一个进程的逻辑控制流的一部分。
图6-5-1 逻辑控制流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流称为并发地运行。进程也为每个程序提供一种假象,好像它独占地使用系统地址空间。
处理器通常使用某个控制寄存器中的一个模式位来提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存的位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不允许直接引用地址空间中内核区的代码和数据。
运行应用程序的代码的进程开始处于用户模式中。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式改为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式改回用户模式。
内核为每个进程维持一个上下文。它由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈等。在进程执行的某些时刻,内核可以决定抢占当前进程,并开始一个先前被抢占的进程,这种决策就叫调度。在内核调度了一个新的进程运行后,它他就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
上下文切换的步骤:
(1) 保存当前进程的上下文
(2) 恢复某个先前被抢占的进程被保存的上下文
(3) 将控制权传递给这个新恢复的进程。
图6-5-2 上下文切换
在hello中,程序执行sleep函数时,sleep显式请求让调用进程休眠,调度器抢占当前的进程,并且利用上下文切换转移到新进程。Sleep函数结束后,再通过上下文切换返回到hello函数中。
6.6 hello的异常与信号处理
会出现的异常:
中断:信号SIGTSTP,默认行为是停止直到下一个SIGCONT。
终止:信号SIGINT,默认行为是终止。
(1)下图是程序正常运行的结果:
图6-6-1 hello正常运行结果
(2)下图是运行时乱按的结果:
图6-6-2 hello运行中乱按的结果
乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
(3)运行时按Ctrl+C:
图6-6-3 运行时按Ctrl+C的结果
此时父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
(4)运行时按Ctrl+Z后运行ps命令:
图6-6-4 运行时按Ctrl+Z后运行ps命令
在键盘下按下Ctrl-Z之后,会导致内核发送一个SIGTSTP信号到前台进程组的每个进程,默认情况下结果是停止(挂起)前台作业。ps命令列出当前系统中的进程(包括僵死进程)。
(5)运行时按下Ctrl+Z后运行pstree命令:
图6-6-5 运行时按Ctrl+Z后运行pstree命令的结果
(6)挂起 hello后输入fg:
图6-6-6 挂起 hello后输入fg的结果
6.7本章小结
本章中主要介绍了进程的概念以及进程在计算机中的调用过程。还介绍了shell的作用和处理流程,执行hello时的fork和execve过程。分析了hello的进程执行和异常与信号处理过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:
在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
线性地址:
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
物理地址:
在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址,又叫实际地址或绝对地址。
虚拟地址:
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。就是hello里面的虚拟内存地址。
虚拟地址经过地址翻译得到物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理指的是把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。段式管理是通过段表进行的,包括段号(段名),段起点,装入位,段的长度等。程序通过分段划分为多个块,如代码段,数据段,共享段等。
逻辑地址是程序源码编译后所形成的跟实际内存没有直接联系的地址,即在不同的机器上使用相同的编译器来编译同一个源程序则其逻辑地址是相同的,但在不同机器上生成的线性地址是不相同的。
一个逻辑地址由两部分组成,包括段标识符和段内偏移量。段标识符是由一个16位长的字段组成的,称为段选择符。前13位是一个索引号,后3位为一些硬件细节。索引号即是“段描述符”的索引,段描述符具体地址描述了一个段,很多个段描述符就组成了段描述符表。通过段标识符的前13位直接在段描述符表中找到一个具体的段描述符。
图7-1 段选择符
全局描述符表(GDT)整个系统只有一个,包含:
(1) 操作系统使用的代码段、数据段、堆栈段的描述符
(2) 各任务、程序的LDT(局部描述符表)段
每个任务/程序有一个独立的LDT,包含:
(1) 对应任务/程序私有的代码段、数据段、堆栈段的描述符
(2) 对应任务/程序使用的门描述符:任务门、调用门等
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。VM系统将虚拟内存分割,称为虚拟页,类似地,物理内存也被分割成物理页。利用页表来管理虚拟页,页表就是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个n位地址字段组成,有效位表明了该虚拟页当前是否被缓存在DRAM中,如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置。如果发生缺页,则从磁盘读取。
MMU利用页表来实现从虚拟地址到物理地址的翻译。
图7-3 页式管理
当页命中时,CPU硬件执行以下步骤:
(1) 处理器生成一个虚拟地址并把它传送给MMU。
(2) MMU生成PTEA,并从高速缓存/主存请求得到它。
(3) 高速缓存/主存向MMU返回PTE。
(4) MMU构造物理地址,并把它传送给高速缓存/主存。
(5) 高速缓存/主存返回所请求的数据字给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
下图给出了Core i7 MMU如何使用四级页表来将虚拟地址翻译成物理地址:
图7-4 使用四级页表的地址翻译
36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
下图为通用的高速缓存存储器组织结构:
图7-5-1 通用Cache结构
高速缓存的结构将m个地址位划分成了t个标记位,s个组索引位和b个块偏移位:
图7-5-2 地址结构
如果选中的组存在一行有效位为1,且标记位与地址中的标记位相匹配,我们就得到了一个缓存命中,否则就称为缓存不命中。如果缓存不命中,那么它需要从存储器层次结构的下一层中取出被请求的块,然后将新的块存储在组索引位指示组中的一个高速缓存行中,具体替换哪一行取决于替换策略,例如LRU策略会替换最后一次访问时间最久远的那一行。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任何一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
图7-6 fork内存映射
7.7 hello进程execve时的内存映射
execve函数加载并运行可执行目标文件需要以下几个步骤:
(1)删除已存在的用户区域
(2)映射私有区域:代码和数据区域被映射为.text区和.data区,bss区域是请求二进制零的,映射到匿名文件,栈和堆区域也是请求二进制零的,初始长度为零。
(4)映射共享区域
(5)设置程序计数器(PC):设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
图7-8 组织结构
7.8 缺页故障与缺页中断处理
假设MMU在试图翻译某个虚拟地址A时触发了一个缺页,这个异常会导致控制转移到内核的缺页处理程序,执行以下步骤:
(1)如果虚拟地址A不合法,那么缺页处理程序就会触发一个段错误,进而终止这个进程。
(2)进程是否有读、写或者执行这个区域内页面的权限?例如这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,终止进程。
(3)如果缺页是由于对合法的虚拟地址进行合法的操作造成的,内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会缺页中断了。
图7-8 缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对每个进程,内核维护一个变量brk,指向堆的顶部。分配器将堆视作一组不同大小的块的集合,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格:显式分配器和隐式分配器。显式分配器要求应用显式地释放任何已分配的块,隐式分配器要求分配器检测一个已分配块何时不再被程序所用,那么就释放这个块。隐式分配器又叫垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
图7-9 边界标记
为实现动态内存分配器,可以使用隐式空闲链表。当一个应用请求k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。一旦分配器找到一个匹配的空闲块,就需要决定分配这个空闲块中多少空间。一个选择是用整个空闲块,但这样会造成内部碎片。如果匹配不太好,那么分配器会将这个空闲块分割,第一部分变成分配块,剩下的变成一个新的空闲块。利用边界标记,可以允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾添加一个脚部,其中脚部就是头部的一个副本。这样分配器就可以通过检查它的脚部,判断前面一个块的起止位置和状态,这个脚部总是在距离当前块开始位置一个字的距离。但是这种方法也存在潜在缺陷,就是在应用程序操作许多个小块时,会产生显著的内存开销。
7.10本章小结
本章介绍了存储管理的有关内容。介绍了存储器的地址空间:物理地址、虚拟地址、逻辑地址、线性地址,然后对段式管理和页式管理进行了较为详细的描述,同时还讨论了VA到PA的变换、物理内存访问、fork和execve的内存映射、缺页故障和缺页处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的接口,称为Unix I/O,这使得所有的输入和输出能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O使得所有的输入和输出都以一种统一且一致的方式来执行:
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,他在后续对此文件的所有操作中标识这个文件。
(2)改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。
(3)读写文件。一个读操作就是从文件复制n个字节到内存,从当前文件位置k开始,然后增加k到k+n。给定一个大小为m字节的文件,k>=m时执行型读操作会触发一个EOF的条件。
(4)关闭文件。内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符地址中。
函数:
(1)open函数:进程通过调用open函数来打开一个已存在的文件或者创建一个新文件。
(2)close函数:进程通过调用close函数来关闭一个打开的文件。
(3)应用程序通过调用read和write函数来分别进行输入和输出。
(4)调用stat和fstat函数检索到关于文件的信息(元数据)。
(5)应用程序可以用readdir系列函数来读取目录的内容。
8.3 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;
}
va_list是字符指针,而(char*)(&fmt + 4)表示fmt后的第一个参数的地址。vsprintf函数返回值是要打印出来的字符串的长度,其作用是格式化,产生格式化的输出并保存在buf中。最后的write函数即为写操作,把buf中的i个元素的值写到终端。
在write函数中,追踪之后的结果如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call函数。在write函数中可以理解为其功能为显示格式化了的字符串。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 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;
}
在getchar函数中,首先声明了几个静态变量:buf表示缓冲区,BUFSIZ为缓冲区的最大长度,而bb指针指向缓冲区的首地址。
getchar调用read函数,将缓冲区读入到buf中,并将长度送给n,再重新令bb指针指向buf。最后返回buf中的第一个字符(如果长度n < 0,则报EOF错误)。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简述了Linux的I/O设备管理机制,Unix I/O接口及函数,并简要分析了printf函数和getchar函数的实现。
结论
hello程序首先从hello.c的源代码文件开始,依次经过:
(1)预处理:对hello.c进行预处理,生成hello.i文件
(2)编译:将预处理完的hello.i文件通过一系列词法分析、语法分析和优化之后生成hello.s汇编文件
(3)汇编:将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中
(4)链接:与动态库链接,生成可执行文件hello
(5)运行:在shell中输入./hello 1190200627 王思睿
(6)创建子进程:shell进程调用fork函数为hello创建子进程
(7)运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入hello程序入口后将程序载入物理内存,进入 main函数执行hello;
(8)执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行相应的控制逻辑流;
(9)访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址;
(10)动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存;
(11)信号:如果运行中键入Ctrl + C或Ctrl + Z,则调用shell的信号处理函数分别停止、挂起;
(12)结束:shell进程回收hello进程,内核删除为hello进程创建的所有数据结构。
完成本次大作业后,我从一个简单的Hello程序的开始到结束,深入学习了计算机系统的运作机制。这让我不禁感叹计算机设计的巧妙之处。
附件
文件名称 文件说明
hello.c hello源文件
hello.i 预处理后生成的文本文件
hello.s 编译生成的汇编文件
hello.o 汇编后生成的可重定位目标文件
hello 链接后生成的可执行文件
hello.objdump hello的反汇编代码
hello.elf hello的elf文件
objdump.txt hello.o的反汇编代码
elf.txt hello.o的ELF格式
参考文献
[1] Linux C 常用库函数手册https://www.linuxfoundation.org/resources/open-source-guides/
[2] UTF-8编码规则 https://blog.csdn.net/shenyongjun1209/article/details/51785791
[3] ELF文件格式解析https://blog.csdn.net/mergerly/article/details/94585901.
[4] 内存地址转换与分段https://blog.csdn.net/drshenlei/article/details/4261909
[5] Linux下逻辑地址、线性地址、物理地址详细总结https://blog.csdn.net/freeelinux/article/details/54136688
[6] printf 函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html
[7] Linux进程的睡眠和唤醒 https://blog.csdn.net/shengin/article/details/21530337
[8] Linux的jobs命令 https://blog.csdn.net/Quincuntial/article/details/53700161