本文通过对hello程序的声明周期进行分析,具体介绍并分析了程序从生成到运行的各个步骤,涉及到与计算机系统相关的很多重要内容,例如机器级表示,链接,ELF文件,进程,储存管理,异常控制流等等。本文通过objdump、edb等工具,对某些步骤进行了实际观察验证,例如分析了链接器的功能,观察了加载的过程等。通过本文,可以对计算机运行程序的过程有一定了解。
关键词:计算机系统;预处理;编译;汇编;链接;可执行目标文件;进程;异常;储存管理;
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
7.1 hello的存储器地址空间............................................................................ - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................ - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理....................................... - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换............................................. - 11 -
7.5 三级Cache支持下的物理内存访问.......................................................... - 11 -
7.6 hello进程fork时的内存映射.................................................................. - 11 -
7.7 hello进程execve时的内存映射.............................................................. - 11 -
7.8 缺页故障与缺页中断处理........................................................................... - 11 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P的过程:首先程序员编写hello.c程序。而后经过预处理器生成hello.i、经过编译器生成hello.s、再通过汇编器生成hello.o、最后通过链接器生成最终的可执行目标文件hello。而后shell调用fork创建子进程,再调用execve将hello加载,便完成了hello程序的运行。这就是hello从程序到进程的过程。
020的过程:调用execve函数时,进行了虚拟内存的映射,进入程序入口后程序开始载入物理内存,这里包括段式管理和页式管理等。CPU为运行的hello分配时间片、执行逻辑控制流,使其进入main函数执行目标代码。在程序运行结束后,shell执行进程回收,释放虚拟地址空间,删除进程上下文信息等。这就是进程020的过程。
1.2 环境与工具
X64 CPU;2GHz;2G RAM;256GHD Disk 以上。
Windows 10 64位,Vmware 15,Ubuntu 22.04 LTS 64位。
Vim/gedit+gcc,objdump,ld,edb。
1.3 中间结果
hello.c hello的源程序代码
hello.i hello.c经过预处理器后的代码
hello.s hello.i经过编译器后的代码
hello.o hello.s经过汇编器的可重定位目标文件
hello hello.o经过链接器的可执行目标文件
hello_o_dump hello.o经过反编译器后的汇编代码
hello_dump hello经过反编译器后的代码
1.4 本章小结
本章对hello程序进行了基本的概述,并说明了进行测试的环境与工具,以及生成的中间结果。
第2章 预处理
2.1 预处理的概念与作用
GCC编译器将源程序hello.c编译为可执行目标文件hello的过程,可分为四个阶段,而预处理是第一个阶段。在预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的c程序。预处理后的结果是另一个c程序,通常是以.i为扩展名,仍为文本格式。
预处理的作用便是生成一个不包含#开头命令的c程序,便于后续编译过程的开展。具体而言,预处理包括:
- 将#define语句的宏定义进行展开;
- 将#include语句的引用文件插入到对应位置;
- 处理#ifndef、#ifdef、#if、#endif等条件指令;
- 删除所有注释;
- 根据#pragma添加编译选项等等。
2.2在Ubuntu下预处理的命令
在Ubuntu预处理的命令为:
gcc hello.c -E -o hello.i -m64 -no-pie -fno-PIC
在终端中运行结果如图 1所示。
图 1 预编译命令运行结果
2.3 Hello的预处理结果解析
使用文本编辑器打开hello.i,进行分析。该文件共有3091行,文件大小大大增加。大致浏览后可以发现,其仍然为可以阅读的c语言代码,但不再包含#include、#define等语句。hello.i中大部分为#include语句展开的头文件代码,而hello.c中的main函数仍在hello.i中的最后部分,没有发生改变,如图 2所示。
图 2 hello.i部分内容截图
2.4 本章小结
本章介绍了c源程序编译为可执行程序的第一个步骤预处理的含义以及作用,并用hello.c程序进行实际操作,进行验证,得到了预处理后的hello.i文件。
第3章 编译
3.1 编译的概念与作用
编译是指由预编译的文件(通常以.i为扩展名)通过编译器(ccl)生成汇编程序(通常以.s为扩展名)的过程。汇编程序是一种便于人们阅读的机器代码表示方法,仍然为文本格式。汇编语言仍为语句形式,其中每条语句都描述了一条低级机器语言指令。
3.2 在Ubuntu下编译的命令
在Ubuntu预处理的命令为:
gcc hello.i -S -o hello.s -m64 -no-pie -fno-PIC
在终端中运行结果如图 3所示。
图 3 编译命令运行结果
3.3 Hello的编译结果解析
3.3.1 数据
1. 常量
图 4 常量字符串
如图 4所示,汇编语言中将hello.c的printf语句的格式串看作常量字符串,储存在.rodata常量区的.LC0和.LC1中。其中.LC0的内容为“用法: Hello 学号 姓名 秒数!”,.LC1的内容为“Hello %s %s\n”。
2. 变量
hello.c中共有3个变量,均定义在main函数中,分别为int argc、char **argv、int i。接下来分析在hello.s中对应变量的定义。
图 5 hello.c中main函数部分汇编代码
根据图 5,分析三个变量在汇编代码中的对应位置。由22、23、24行汇编代码对应hello.c的代码,可以得知变量argc初始在寄存器%edi中,而后存放在栈%rbp-20对应的内存位置,变量argv首地址初始存放在%rsi中,而后存放在栈%rbp-32对应的内存位置。根据图 6,由第31、51行汇编代码可知,循环变量i储存在栈%rbp-4中。
3.3.2 赋值
hello.c中仅有一处赋值语句,即第17行的循环变量赋初始值语句i=0。在汇编代码hello.s中,对应第31行的movl $0, -4(%rbp),如图 6所示。mov语句是汇编代码的赋值操作,后接两个操作数,表示将前一个数赋值给后一个。在这里,第一个数字为立即数%0,直接写在了代码中,而后一个数-4(%rbp)即地址%rbp-4对应的数据,即变量i。
3.3.3 算数操作
hello.c中仅有一处算数操作语句,即第17行的循环变量自增语句i++。在汇编代码hello.s中,对应第51行的addl %1, -4(%rbp) ,如图 6所示。add语句是汇编代码的加法运算操作,后接两个操作数,表示将前一个数加给后一个数。在这里,第一个数字为立即数%1,直接写在了代码中,而后一个数-4(%rbp)即地址%rbp-4的数据,为变量i。
图 6 hello.c中main函数部分汇编代码
3.3.4 关系操作
hello.c中有两处关系操作语句。第一个关系操作为第13行的比较语句argc!=4。在汇编代码hello.s中,对应第24行的cmpl $4, -20(%rbp) ,如图 5所示。cmpl语句用于比较两个参数的大小关系,这里传入的参数分别为立即数$4和地址-20(%rbp)对应数据。在第25行中使用的je指令,说明本次比较是判断两个数字是否相等。第二个关系操作为第17行循环语句中的i<9。在汇编代码hello.s中,对应第53的cmpl $8, -4(%rbp) ,如图 6所示。这里传入的参数分别为立即数$8和地址-4(%rbp)对应数据。在第54行中使用的jle指令,说明本次比较是判断第一个数字是否小于等于第二个数字。这里编译器进行了一个转化,将i<9的判断转化为了i<=8,二者等价。
3.3.5 数组操作
hello.c中仅有一个数组,即第10行用于参数传递的数组char* argv[]。在汇编代码hello.s中,第23行的movq %rsi, -32(%rbp)将数组的首地址存入栈中,具体地址为%rbp-32。而后,在hello.c中的18、19行对该数组进行了访问,访问了argv[1]、argv[2]、argv[3]的值,分别对应汇编代码的34-35,37-39,44-46行,访问过程均可以概括成:为先利用movq将数组首地址从栈中取出,而后利用addq将首地址加上偏移量(如果有偏移量),最后使用movq将计算后的地址对应数据进行传送。
3.3.6 控制转移
1. if语句
hello.c的第13-16行使用了if语句,对应汇编代码的24、25两行。这个if语句的汇编代码使用了条件控制,即不满足进入if语句的条件时,执行jump指令跳过if内部的代码。
2. for语句
hello.c的第17-20行使用了for语句,控制转移对应汇编代码的32、53、54三行,如图 5所示。32行是跳转到for循环体后进行条件判断的部分,而53、54行是进行条件判断,若满足则跳转到for循环题的开始。
3.3.7 函数操作
1. main函数
主函数的参数包括int argc、char **argv两个,变量argc初始在寄存器%edi中,变量argv首地址初始存放在寄存器%rsi中。在返回时,寄存器%eax储存返回值。
2. puts函数
虽然在hello.c中没有显示地调用puts函数,但在第14行的调用printf输出并换行语句被编译器替换为了对puts函数的调用。调用前将储存在.LC0的常量字符串进行传参,储存到寄存器%edi中。
3. printf函数
在hello.c的第18行调用的printf函数共传入了三个参数:模式字符串,argv[1],argv[2]。在汇编代码的34-41行即为参数传递的过程,具体而言,将argv[2]储存到寄存器%rdx中,将argv[1]储存到寄存器%rsi中,将模式串(即.LC1)储存到寄存器%edi中。
4. exit、sleep、atoi、getchar函数
这些函数的调用与上述同理,exit、sleep、atoi函数有一个参数,需要传参,具体而言将参数放入寄存器即可,而getchar函数无需传参。
3.4 本章小结
本章介绍了程序编译的含义以及其过程,并用hello.c作为例子,具体分析了c语言的各种语句是如何转换为汇编代码的。
第4章 汇编
4.1 汇编的概念与作用
汇编是指由编译后的汇编语言文件(通常以.s为扩展名)通过汇编器(as)生成机器语言程序(通常以.o为扩展名)的过程。机器语言程序为二进制格式的文件,它是通过将汇编语言文件的每个语句转换成对应的机器代码得来的,与汇编语言文件一一对应,但格式不同。
4.2 在Ubuntu下汇编的命令
在Ubuntu预处理的命令为:
gcc hello.s -c -o hello.o -m64 -no-pie -fno-PIC
在终端中运行结果如图 7所示。
图 7 汇编命令运行结果
4.3 可重定位目标elf格式
运行命令readelf -a hello.o,可以显示出该ELF文件的各节基本信息,接下来对每节分别进行分析。
4.3.1 ELF头
运行命令,可查看得ELF头信息如图 8所示。ELF头以一个16字节的序列开始,描述了生成该文件的系统的字的大小和字节顺序。剩下的部分有帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小,目标文件类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小和数量。从图中可以看出,该程序是64位程序,ELF头的大小为64bytes,目标文件类型为可重定位目标文件,系统架构信息,节头部表包含13条信息等。
图 8 ELF头信息
4.3.2 节头部表
运行命令,得到节头部表信息如图 9所示。节头部表描述了不同节的位置和大小,其信息对应目标文件的每个节固定大小的条目。分析查看结果,可以得知该程序包含了.text、.reladata、.data、.bss、.rodata、.symtab、.strtab等节,与书中介绍一致,并可得知这些节的地址偏移量等信息。
图 9 节头部表信息
4.3.3 重定位节
运行命令,可查看得重定位节信息如图 10所示。.rel.text是链接时.text节需要重定位的位置列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。由图可知,该表中记录了8条信息,每个信息都包含了该位置在节中的偏移量,应该指向的符号信息,符号类型,以及符号值。
图 10 重定位节信息
4.3.4 符号表
运行命令,可查看得重符号表信息如图 11所示。.symtab存放了程序中定义和引用的函数和全局变量信息(但不包含局部变量)。由图可知,在hello.o中,该表存储了11条信息,包含了符号地址、目标大小、符号是函数还是变量、符号是局部符号还是全局符号、符号名称等。
图 11 符号表信息
4.4 Hello.o的结果解析
使用命令objdump -d -r hello.o,对hello.o进行反汇编,得到内容如图 12所示。
图 12 hello.o的反汇编
对比反汇编得到的汇编码与hello.s中汇编代码的区别。在汇编指令方面,有的命令包含对操作数大小的后缀,例如pushq, subq, movl等,而有的不包含,例如push, sub, mov,两个文件中不一致。在立即数方面,hello.s中所有立即数均为10进制;而在反汇编代码中,所有立即数均为16进制。在操作数方面,hello.s中将某些信息存放在.rodata中,并命名,在后续使用时直接填入名字,例如movl $.LC0, %edi;而在反汇编代码中,填入的是地址数字,但由于还没有链接,为0x0,例如mov $0x0,%edi。在分支转移方面,hello.s中使用标签标注,在jmp语句中仅仅填入标签,例如:je .L2,并在需要跳转的位置添加.L2标签;而在反汇编程序中,没有标签的存在,jmp指令参数为地址数字,具体而言,是相对地址,例如je 2d <main+0x2d>。在函数调用方面,hello.c中所有call指令的参数均为所需调用的函数名称,例如call exit则为调用exit函数;而在反汇编程序中,call指令的参数均为地址,但由于还未进行链接,函数地址未知,填入的仅仅是该call指令的下一条指令的地址,例如call 2d <main+0x2d>。除此之外,通过反汇编,可以清晰地观察出汇编语言与机器代码的映射关系,由图 12可发现,左边为机器代码,由若干字节组成,而右面即为同等含义的的汇编语言,可知汇编语言与机器代码是一一对应的关系,汇编语言只是机器代码的一种便于阅读的呈现形式。
4.5 本章小结
通过对hello.o的二进制文件信息进行研究,以及对它进行反汇编进行对比,可以更加深入地理解ELF文件格式,以及可重定位目标文件的内容,也能够为后续理解链接的作用有一定帮助。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
链接在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能,我们可以把应用程序分解成模块,并对每个模块独立编译,最终链接应用即可。
5.2 在Ubuntu下链接的命令
在Ubuntu中链接的命令为: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/11/crtbegin.o ./hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/11/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
在命令行中运行,得到结果如图 13所示。
图 13 链接命令运行结果
5.3 可执行目标文件hello的格式
运行命令readelf -a hello,可以显示出该ELF文件的各节基本信息,接下来对每节分别进行分析。
5.3.1 ELF头
运行命令,可查看其ELF头信息如图 14所示。可以看出,该ELF属于可执行目标文件,入口点地址也已经确定,节头部表有29条信息。
图 14 hello的ELF头信息
5.3.2 节头部表
运行命令,可查看其节头部表如图 15所示。共有29个条目,记录了每一个节的地址等信息。
5.3.3 重定位节
运行命令,可查看其重定位节如图 16所示。可以发现,其中包含一些库函数调用的重定位条目信息。
5.3.4 符号表
运行命令,可查看其符号表如图 17所示。hello的符号表共包含38个条目,图中仅显示部分,条目中包含了每个符号的信息,例如符号属于全局符号还是局部符号,是什么类型,偏移量是什么,名字是什么等等。
图 15 hello节头部表部分内容
图 16 hello的重定位节内容
图 17 hello的符号表内容
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,如图 18所示。Data Dump从0x401000开始显示,即程序从该地址开始,通过与图 15对比,可知这是init节的开始。类似地,我们可以在edb的Data Dump中找到.text节的入口0x4010f0,.rodata节的入口0x402000,.data入口为0x404048,均与查看的符号表保持一致。
图 18 hello程序虚拟地址占用
5.5 链接的重定位过程分析
运行命令objdump -d -r hello,得到hello的反汇编代码,如图 19、图 20所示。将其与hello.o的反汇编代码进行比较。可以发现,hello.o中仅有main函数,而hello中链接了一些库函数的调用代码,比如puts、printf、exit等等;hello.o中仅有.text节,而hello中还有.init、.plt、.plt.sec等节。
接下来观察对比main函数部分。首先,在hello.o中,每条语句的地址是从零开始计数的,而在hello中,每个指令都已经被分配了固定的地址。在变量引用方面,hello.o中对.rodata节中内容的引用使用了0x0占位,而在hello中填入了具体的地址。在函数调用方面,hello.o中填入了0x0作为占位,而hello已经计算好了具体位置,填写了相对PC的地址。
通过对比两个文件,可以得到重定位时链接器的功能。首先,链接器将目标文件的节合并,形成聚合节,完成节的重定位。而后,链接器将确定每个节的运行时地址,并确定模块中定义的符号的地址,完成符号定义的重定位。最后,链接器通过重定位条目,将所有的符号引用重定位。例如,在hello.o中,对puts函数的调用为: 1e: e8 00 00 00 00 call 23 <main+0x23>,可以发现还没有填入地址,而在重定位条目中,可以找到对应的条目:
1f: R_X86_64_PLT32 puts-0x4。而后,链接器根据该条目的信息,确定了其引用的符号,再通过查询该符号的重定位地址,计算出PC相对地址,即可完成该符号引用的重定位。在hello中,该调用被改写成:
4011f4: e8 97 fe ff ff call 401090 <puts@plt>。采用类似的方法,链接器通过读取重定位条目,对每个符号引用一一进行计算,即可完成符号引用的重定位。
图 19 hello部分反汇编代码
图 20 hello反汇编代码main函数部分
5.6 hello的执行流程
使用edb打开hello,如图 21所示。通过点击Step Over按键,可以逐指令观察hello从加载到结束的全过程。
最开始,程序还未加载,运行的指令如图 22所示,地址为00007f17:166ca2b0,猜测为内核代码。而后通过点击Step Over,可以发现运行的指令跳转到hello!_start,如图 23所示,地址为0x4010f0,可知现在已经加载完毕,运行处在低地址的指令,即hello程序代码。
图 21 使用edb执行hello
图 22 加载前运行指令
图 23 运行到_start
而后,通过同样的方式,点击Step Into,详细追踪程序的运行,可以发现程序先后运行了如下函数。
- libc.so.6!__libc_start_main,地址为7f3e6f408dc0,如图 24所示;
图 24 libc.so.6!__libc_start_main
- libc.so.6!__cxa_atexit,地址为7f3e6f4248c0,如图 25所示;
图 25 libc.so.6!__cxa_atexit
- hello!_init,地址为401000,如图 26所示;
图 26 hello!_init
- hello!register_tm_clones,地址为401160,如图 27所示;
图 27 hello!register_tm_clones
- libc.so.6!_setjmp,地址为7f5418f2ele0,如图 28所示;
图 28 libc.so.6!_setjmp
- hello!main,地址为4011d6,如图 29所示;
图 29 hello!main
最后,main函数正常运行,最后在调用getchar函数时,输入一个字符,而后正常执行leave、ret指令,程序终止。
5.7 Hello的动态链接分析
通过前文所述节头部表,可以得知.got.plt节的起始地址为0x404000。使用edb打开程序,并查看该地址的内容,如图 30所示。
图 30 .got.plt节内容
而后,点击Jump Over,使程序运行完dl_init,再次查看对应地址内容,如图 31所示。对比二者变化,即可得知程序完成了动态链接的初始化,生成了运行时延迟绑定的条目。
图 31 动态链接后.got.plt节内容
5.8 本章小结
本章分析了链接的过程,并对目标文件的格式进行了具体分析,而后用edb实际模拟了程序加载运行与动态链接等过程,验证了教材中的理论知识。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,系统的每一个程序都是运行在某一个进程的上下文中。
进程向用户提供了一个抽象,即程序好像是系统中当前运行的唯一程序一样。我们的程序好像独占地使用处理器的内存,处理器好像无间断地一条条执行程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象,即我们的程序拥有独立的控制流和私有地址空间。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型应用级程序,为用户提供命令行界面,使用户可以输入命令,而后shell代表用户执行程序,管理进程。
Shell首先输出一个命令提示符’>’,并等待用户在键盘中输入命令;而后shell解析用户输入的命令。如果该命令是shell的内置命令,则立即执行该命令;若不是,shell则会认为用户要运行一个程序,因此会fork一个子进程,并在子进程中调用execve函数,执行用户输入的程序,而后使用waitpid函数回收执行完的子程序。如果用户希望程序在前台运行,则会一直等待直到子进程运行完成;如果用户希望程序在后台运行,则会直接重复上一步骤。
6.3 Hello的fork进程创建过程
当我们在命令行中输入./hello时,shell解析后得知要运行hello可执行程序。此时,shell会调用fork函数,创建一个子进程。子进程获得与父进程虚拟地址空间相同但是独立的一个副本。接下来在子进程中调用execve函数运行,使其成为hello程序的子进程,其父进程为shell。
6.4 Hello的execve过程
Shell在执行fork语句创建进程后,便会调用execve函数。该函数会在当前进程上下文加载运行一个新的程序,并破坏原有进程。这里加载hello程序,即可运行hello程序。其中,execve函数还能传入hello程序的调用参数以及环境变量。具体而言,execve函数依次进行了如下操作:首先删除由父进程fork而来的已存在的用户区域。其次为Hello的代码数据创建新的区域结构。而后映射共享区,比如对动态链接库的引用等。完成以上准备工作后,最后设置程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
上下文信息是内核在进行上下文切换前后需要保存恢复的必要信息,包含了进程的一些状态,一般由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
当内核选择暂时停止一个进程的运行,继续运行其它进程时,将会进行上下文切换,将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
当一个进程执行时,内核可以选择暂时停止当前进程,并重新开始一个先前被抢占了的进程,这种决策过程就叫做调度。在对进程进行调度时,主要进行上下文的保存,而后加载保存的寄存器,最后切换虚拟地址空间。
用户态与内核态是程序运行的两种状态,它们有不同的权限。运行程序的进程初始是在用户态中的。而进程从用户态转换为内核态的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,此时处理器将态从用户态变为内核态,异常处理子程序运行在内核态中。该子程序返回时,处理器将把内核态转换回用户态,而后继续运行程序代码。
6.6 hello的异常与信号处理
首先正常运行hello程序,而在运行过程中可能发生各种异常。异常分为中断、陷阱、故障、终止四类。在发生异常时,控制会转移到对应的异常处理子程序中,而后根据异常类型,选择返回到当前指令、返回到下一指令、或者终止运行。而信号是允许用户与内核交互的机制,在程序运行时,可以使用各种方法向进程发送信号。
- 运行时按CTRL+Z
在程序运行过程中,按CTRL+Z,默认是向该进程发送SIGSTP信号。该信号的默认行为为停止该进程,效果如图 32所示。
图 32 运行时按CTRL+Z
此时,可以在终端中运行命令,查看该进程状态。例如,运行ps命令,可以查看到这个被停止的进程,如图 33所示。运行jobs命令,可以查看到这个命令是已停止的状态,以及该命令的运行语句,如图 34所示。运行pstree命令,可以查看当前系统的进程树。在树中,可以找到名为gnome-terminal的节点,即为测试运行hello程序所用的终端。可以发现该节点有两个子节点,其中一个则为停止运行的hello程序,如图 36所示。运行fg命令,可以观察到该hello进行重新回到前台,并继续刚刚中断前的状态运行。中断前输出了两行,运行fg命令后继续输出了8行,而后终止,如图 35所示。kill命令能向进程发送信号。这里根据ps查看的进程编号,利用kill -9向其发送编号为9的信号,即可杀死该进程,通过再次运行ps指令,可以发现该进程已不存在,如图 37所示。
图 33 CTRL+Z后运行ps命令
图 34 CTRL+Z后运行jobs命令
图 35 CTRL+Z后运行bg命令
图 36 CTRL+Z后运行pstree命令
图 37 CTRL+Z后运行kill命令
- 运行时按CTRL+C
运行时按CTRL+C,可以向该进程发送SIGINT信号,默认行为是终止进程,用hello测试,如图 38所示。可以发现该程序被直接终止,而后在ps中也查询不到该进程。
图 38 运行时按CTRL+C
- 运行时乱按
在运行时乱按键盘,可以发现可以输入字母,但能得知,仅仅是能提前输入到输入缓冲区中,当程序进行系统调用试图获得输入时,便会直接使用乱按时在缓冲区中输入的内容,如图 39所示。
图 39 运行时乱按
6.7本章小结
本章对异常控制流相关知识进行了实际操作,理解了shell的机制与进程管理,阐述了异常处理与信号相关机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指编译器生成的汇编程序中出现的地址,他一般用来指示一条指令或者一个操作数的地址。它由两部分组成,一个是段基址,一个是偏移量。
线性地址是指地址空间是连续的整数的地址。
虚拟地址是程序用来访问内存所使用的地址。几乎所有程序都运行在虚拟内存中,虚拟内存可以看作一个一维数组,这是操作系统给进程提供的一种抽象,因此在访问内存时,程序使用的地址位虚拟地址。
将计算机的主存组织成一个有若干连续字节大小的单元组成的数组,访问该数组的索引即为物理地址。物理地址是机器访问主存时放在寻址总线上的地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式管理中,逻辑地址被分为两部分:段标识符和段内偏移量。逻辑地址共有48位,前16位是段标识符。段标识符中,前13位是索引号,后3位记录了一些硬件细节。段标识符指向段描述符表的条目,其中记录了该段的信息。后32位是段内偏移量,在表中找到基地址后,通过段内偏移量,即可找到待访问的实际地址,完成地址变换。
7.3 Hello的线性地址到物理地址的变换-页式管理
在页式管理中,虚拟地址被分成若干大小相同的“组”,称为虚拟页。当使用虚拟地址进行内存访问时,将进行地址转换。地址的前半部分将作为虚拟页号,它是页表的索引,可以用来找到对应的页表条目,进而得到条目中记录的物理页号。若该条目无效,则发生缺页异常,需要重新从磁盘读取。在获得物理页号后,再使用地址的后半部分作为偏移量,即可完成地址的翻译。
7.4 TLB与四级页表支持下的VA到PA的变换
为了加速页表的读取速度,使用TLB对页表进行缓存;为了减少页表的内存占用,使用了多级页表。在这种情况下,虚拟地址到物理地址的变换如下。对于四级页表而言,需要将虚拟页号分成四个部分,分别用于访问四个级别的页表。具体而言,令虚拟页号的四个部分分别是VPN1,VPN2,VPN3,VPN4,首先MMU用VPN1作为索引,在一级页表中查询,查询到的条目包含了其对应二级页表的地址;而后,使用VPN2作为索引,查询该二级页表,得到的条目包含了对应的三级页表地址,三级页表的查询同理;最终,在四级页表中查询后,可以得到对应的物理页号。以上是多级页表的查询流程,但是省略了具体的页表访问过程,下面介绍使用虚拟页号(VPN)利用TLB访问页表的过程。访问TLB时,将VPN分成两个部分:TLB标记和TLB索引。类似于L1高速缓存,使用TLB索引进行TLB的组选择过程,再利用TLB标记进行行匹配,即可找到对应PTE。若发现缺页,MMU需要从L1缓存中取出对应的PTE。最后,即可根据PTE得到页表的查询结果(可能是下一级页表的地址,也可能是物理页号)。
7.5 三级Cache支持下的物理内存访问
在使用物理地址进行内存访问时,CPU会首先在L1高速缓存中查找。查找时,将地址分为三部分:高速缓存标记、组索引、块偏移。首先,根据组索引,进行组选择,而后利用标记进行行匹配。如果找到对应的行,且标记位被设置,即可利用块偏移,直接返回数据;否则发送缓存不命中,需要在下一级缓存(L2 cahce)中寻找。如果L2中也没有缓存,则需要在L3中寻找,以此类推。
7.6 hello进程fork时的内存映射
当shell调用fork函数闯进子进程时,内核为新建的进程创建了各种数据结构,并分配了唯一的pid。为了给这个新进程创建虚拟内存,内核创建了当前进程的mm_struct,区域结构和页表的原样副本。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程的任一一个进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
execve函数会在当前进程中加载并运行包含在目标可执行文件中的程序,用该程序替换当前程序。替换过程中,进行了私有区域的映射和共享区域的映射。具体而言,创建了新程序的代码,数据,bss和栈区。代码区和数据区被映射到了该可执行文件的.text和.data段。bss区映射到匿名文件。共享对象是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
7.8 缺页故障与缺页中断处理
利用虚拟地址进行内存访问,可能会发生缺页故障时,此时会执行缺页处理子程序,此时内核和硬件协助执行以下操作。首先缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它写回到磁盘。而后,缺页处理程序页面调入新的页面,并更新内存中的PTE。最后,缺页处理程序返回到原来的进程,返回异常发生前的命令,再次执行。
7.9动态存储分配管理
使用动态内存分配器进行虚拟内存的管理,它维护着一个进程的虚拟内存区域,称为堆。分配器分为显式分配器和隐式分配器。
7.10本章小结
本章对hello程序的储存相关内容进行介绍,分析了地址转换过程,以及现代机器TLB和四级页表配合进行内存访问时的地址转换过程,和三级cache下物理内存访问的过程,阐述了相关函数执行时的内存分配。
结论
hello的一生主要分为程序生成以及程序运行两个阶段。
在程序生成阶段,最初是由程序员编写了.c的程序代码,储存在文件hello.c中。而后,hello.c经过预处理器,生成了hello.i文件,这是进行了宏展开以及库文件合并后的文本程序。接下来,hello.i经过编译器,生成了hello.s文件,这是和机器代码非常接近的汇编语言。hello.s继续通过汇编器,生成hello.o。hello.o是一个二进制格式的文件,具体而言,是可充定位目标文件。最后,链接器将hello.o与对应的链接库进行链接,生成了可执行程序hello。
在程序运行阶段,用户在shell中输入./hello命令后,shell便开始为程序运行做准备。首先,shell调用fork函数,创建了一个子进程。而后在刚刚创建的子进程中,调用execve函数,加载hello程序,这一步骤由加载器执行。现在,hello程序便可在提供给他的抽象中开始运行。运行过程中,他需要进行内存访问,会动态申请内存,可能发生异常,进行信号处理,这些都要CPU、MMU等模块协助完成。最后,程序执行完成,shell便会将其回收,删除其所有信息。
附件
hello.c hello的源程序代码
hello.i hello.c经过预处理器后的代码
hello.s hello.i经过编译器后的代码
hello.o hello.s经过汇编器的可重定位目标文件
hello hello.o经过链接器的可执行目标文件
hello_o_dump hello.o经过反编译器后的汇编代码
hello_dump hello经过反编译器后的代码
参考文献
[1] Randal E.Bryant. David O'Hallaron. Computer Systems: A Programmer's Perspective (3rd Edition)