摘 要
本文围绕经典的Hello程序,详细阐明了它在Linux操作系统环境下的完整生命周期。从源代码hello.c文件开始,我们跟踪分析了它经历的预处理、编译、汇编、链接,一直到最终执行和结束终止的全过程。同时,我们结合操作系统课程中学习的知识,详细解释了Linux系统如何对Hello程序实施管理和控制。在进程管理方面,系统如何创建、调度和终止Hello程序进程。在存储管理方面,系统如何为Hello程序分配内存空间,加载其代码和数据。在I/O管理方面,系统如何处理Hello程序的输入与输出等。全文内容系统而全面地回顾和梳理了CSAPP课程学习的所有核心知识点。
关键词:计算机系统;计算机体系结构;操作系统管理;计算机系统课程;
目 录
第1章 概述..................................................................................................... - 4 -
1.1 Hello简介.............................................................................................. - 4 -
1.2 环境与工具............................................................................................. - 4 -
1.3 中间结果................................................................................................. - 4 -
1.4 本章小结................................................................................................. - 5 -
第2章 预处理................................................................................................. - 6 -
2.1 预处理的概念与作用............................................................................. - 6 -
2.2在Ubuntu下预处理的命令.................................................................. - 6 -
2.3 Hello的预处理结果解析...................................................................... - 6 -
2.4 本章小结................................................................................................. - 8 -
第3章 编译..................................................................................................... - 9 -
3.1 编译的概念与作用................................................................................. - 9 -
3.2 在Ubuntu下编译的命令..................................................................... - 9 -
3.3 Hello的编译结果解析.......................................................................... - 9 -
3.4 本章小结..................................................................... 错误!未定义书签。
第4章 汇编................................................................................................... - 18 -
4.1 汇编的概念与作用............................................................................... - 18 -
4.2 在Ubuntu下汇编的命令................................................................... - 18 -
4.3 可重定位目标elf格式....................................................................... - 18 -
4.4 Hello.o的结果解析............................................................................ - 20 -
4.5 本章小结............................................................................................... - 21 -
第5章 链接................................................................................................... - 22 -
5.1 链接的概念与作用............................................................................... - 22 -
5.2 在Ubuntu下链接的命令................................................................... - 22 -
5.3 可执行目标文件hello的格式.......................................................... - 22 -
5.4 hello的虚拟地址空间........................................................................ - 25 -
5.5 链接的重定位过程分析....................................................................... - 26 -
5.6 hello的执行流程................................................................................ - 28 -
5.7 Hello的动态链接分析........................................................................ - 29 -
5.8 本章小结............................................................................................... - 30 -
第6章 hello进程管理........................................................................... - 31 -
6.1 进程的概念与作用............................................................................... - 31 -
6.2 简述壳Shell-bash的作用与处理流程............................................. - 31 -
6.3 Hello的fork进程创建过程............................................................. - 31 -
6.4 Hello的execve过程......................................................................... - 32 -
6.5 Hello的进程执行................................................................................ - 33 -
6.6 hello的异常与信号处理.................................................................... - 33 -
6.7本章小结............................................................................................... - 37 -
第7章 hello的存储管理....................................................................... - 38 -
7.1 hello的存储器地址空间.................................................................... - 38 -
7.2 Intel逻辑地址到线性地址的变换-段式管理.................................... - 38 -
7.3 Hello的线性地址到物理地址的变换-页式管理.............................. - 39 -
7.4 TLB与四级页表支持下的VA到PA的变换..................................... - 39 -
7.5 三级Cache支持下的物理内存访问.................................................. - 41 -
7.6 hello进程fork时的内存映射.......................................................... - 42 -
7.7 hello进程execve时的内存映射...................................................... - 42 -
7.8 缺页故障与缺页中断处理................................................................... - 43 -
7.9本章小结............................................................................................... - 43 -
结论................................................................................................................. - 44 -
附件................................................................................................................. - 45 -
参考文献......................................................................................................... - 46 -
Hello的P2P(From Program to Process)是指,Hello程序从可执行程序(program)变为运行时进程(process)的过程。hello.c文件先后经过预处理、编译、汇编和链接四个阶段,最终生成可执行目标程序hello。它在Linux系统中的转变过程具体为:预处理阶段通过cpp处理hello.c;编译阶段通过ccl将预处理结果编译为汇编代码;汇编阶段通过as将汇编代码转为目标文件;链接阶段通过ld将目标文件和库文件链接生成可执行目标程序hello。这就是“P2P” 的完整过程。
Hello的020(From Zero to Zero)是指,初始时内存中没有Hello程序的任何内容(from zero),当我们在Shell中输入./hello命令启动Hello程序时,系统通过fork创建Hello程序进程,然后通过execve系统调用将Hello程序载入内存,开始执行相关代码。 Hello程序运行结束后,系统回收Hello程序进程并删除内存中的Hello程序数据。这标志着Hello程序回到了开始的“零”状态,其生命周期结束(to zero)。 总之,Hello程序以“零”状态开始,经历运行期的高峰,最后再次回到“零”状态结束。这就是“020”的完整生命历程。
硬件:
CPU: Intel(R) Core(TM) i7-10870H CPU @ 2.20GHz 2.21 GHz
RAM:32.0 GB (31.8 GB 可用)
软件:
Windows10 64位
Oracle VM VirtualBox 6.1.16 r140961 (Qt5.6.2)
Ubuntu 18.04.5 LTS 64位
调试工具:
Visual Studio Code;
gedit,gcc,notepad++,readelf, objdump, hexedit,edb
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
表格1 中间结果
文件名称 |
说明 |
hello.i |
hello.c经预处理得到的ASCII文本文件 |
hello.s |
hello.i经编译得到的汇编代码ASCII文本文件 |
hello.o |
hello.s经汇编得到的可重定位目标文件 |
hello_elf.txt |
hello.o经readelf分析得到的文本文件 |
hello_dis.txt |
hello.o经objdump反汇编得到的文本文件 |
hello |
hello.o经链接得到的可执行文件 |
hello1_elf.txt |
hello经readelf分析得到的文本文件 |
hello1_dis.txt |
hello经objdump反汇编得到的文本文件 |
表 1
本章简要介绍了hello程序 的P2P,020的具体含义,同时列出了论文研究时采用的具体软硬件环境、中间结果。
2.1.1 预处理的概念
预处理是编译过程的第一个阶段,它的主要功能是处理源代码文件中以“#”开始的预处理指令。预处理指令用于在实际编译之前修改源代码,比如说导入头文件、定义宏等。预处理器是一种处理文本的程序,它直接处理源代码文件中的预处理指令,然后产生已经处理过的源代码,这些代码再由编译器编译成目标代码。即,预处理实际上是在编译之前对源代码进行的一种处理。除此之外,预处理过程还会删除程序中的注释和多余的空白字符。预处理通常得到另一个以.i作为拓展名的C程序。
2.2.2预处理的作用
在Ubuntu系统下,进行预处理的命令为:
cpp hello.c > hello.i
运行截图如下:
图0 命令截图
在Ubuntu系统下打开hello.i:
图 1 hello.i文件部分截图
可以发现:原本28行的hello.c经预处理后,扩展成了共3105行的hello.i文件。其中:main函数主程序在3092行至3105行出现:
图 2 main函数在hello.i文件中位置
在main函数内代码出现之前,头文件 stdio.h、unistd.h 和 stdlib.h 会依次展开。以stdio.h为例,CPP会先删除指令#include
图 3 路径截图
Stdio.h等在计算机中路径截图
本章主要介绍了预处理的概念及作用,并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。C语言预处理一般由预处理器(cpp)进行,它主要完成四项工作:宏展开、文件包含复制、条件编译处理和删除注释及多余空白字符。这些工作为之后的编译等流程奠定了基础。
3.1.1 编译的概念
编译是指C编译器ccl通过词法分析和语法分析,将合法指令翻译成等价汇编代码的过程。在这个过程中,编译器将文本文件 hello.i 翻译成汇编语言文件 hello.s。hello.s中以文本的形式描述了一条条低级机器语言指令。
3.1.2 编译的作用
编译的作用将高级语言源代码转换为计算机能够直接执行的机器语言。编译器通过一系列步骤,如词法分析、语法分析、中间代码生成、代码优化和目标代码生成,将源代码转换为更接近机器语言的形式。即,编译的作用是通过一系列步骤让源代码更接近机器语言,它是汇编阶段翻译成机器语言的前提。
命令为:gcc -S hello.i -o hello.s
图 4 编译命令截图
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
hello.s文件部分截图如下所示:
图 5 hello.s 文件部分截图
图 6 文件结构部分截图
对hello.s文件整体结构分析如下表:
内容 |
含义 |
.file |
源文件 |
.text |
代码段 |
.global |
全局变量 |
.data |
存放已经初始化的全局和静态C 变量 |
.section .rodata |
存放只读变量 |
.align |
对齐方式 |
.type |
表示是函数类型/对象类型 |
.size |
表示大小 |
.long .string |
表示是long类型/string类型 |
表 2 文件结构表
在hello.s中,涉及的数据类型包括以下三种:整数,字符串,数组。下面对每种数据类型依次进行分析。
在原hello.c中,多次出现整数常量(立即数),
图 7 代码中立即数示意
在hello.s中,可以看到涉及的整数常量被直接插入汇编代码,不需寻址。
以立即数32为例,这就是将栈增加了4个存储单元,为后续储值做准备:
图 8 立即数举例
原hello.c中共输出两个字符串:
图 9 原代码中字符串示意
可以看到,它们被存放于.rodata中。\XXX为UTF-8编码,一个汉字对应三个字节:
图 10 字符串在hello.s中位置示意
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。访问时,通过寄存器寻址的方式访问。
图 11 数组在hello.s中示意
其中,20~22行是先将栈拓展出4个存储单元,然后将edi、rsi中数据存放进栈中;35行是通过寄存器寻址访问(rdx是栈帧指针,指向栈帧底部)。
(1)int sleepsecs=2.5
在C语言源程序中,将2.5赋值给一个int类型变量会导致隐式类型转换,结果为2。在hello.s文件中,这一转换体现在将sleepsecs声明为值为2的long类型数据,位于.data节中。
(2)int i
对局部变量的赋值在汇编代码中通过mov指令完成。具体使用哪条mov指令由数据的大小决定,如图所示:
后缀 |
b |
w |
l |
q |
大小(字节) |
1 |
2 |
3 |
4 |
表 3 后缀说明
常量类型在3.3.1中已作详细阐述,本小节讨论变量类型。
这个函数里声明了一个int类型局部变量 i。局部变量在寄存器或栈中储存。i被分配在栈中,大小是四个字节,刚好在栈最底部。i在hello.s中如下图:
图 12 局部变量在hello.s中示意图
这个函数没有使用全局变量。已初始化的全局变量存放在.data节中,它们在文件中占据空间,并在程序加载时被加载到内存中。未初始化的全局变量则存放在.bss节中,它们在文件中不占据空间,而是在使用时在内存中分配并初始化为0。另:静态变量的处理方式与全局变量相同。
Hello.c中并没有进行显式类型转换,但是存在隐式类型转换。
hello.c中使用了atoi函数,该函数作用是把一个数字字符串转换为对应的整数。通过调用函数传参实现类型转换:
图 13 atoi函数实现隐式类型转换
在汇编指令中,算数操作可以达到多种目的。既可以对数进行加减乘除的操作,也可以将地址进行运算并传入另一地址。算数操作指令主要包括:
指令 |
效果 |
leaq s,d |
d=&s |
inc d |
d+=1 |
dec d |
d-=1 |
neg d |
d=-d |
add s,d |
d=d+s |
sub s,d |
d=d-s |
imulq s |
r[%rdx]:r[%rax]=s*r[%rax](有符号) |
mulq s |
r[%rdx]:r[%rax]=s*r[%rax](无符号) |
idivq s |
r[%rdx]=r[%rdx]:r[%rax] mod s(有符号) r[%rax]=r[%rdx]:r[%rax] div s |
divq s |
r[%rdx]=r[%rdx]:r[%rax] mod s(无符号) r[%rax]=r[%rdx]:r[%rax] div s |
表 4 汇编指令中的算数操作
而hello.s中设计的算数操作有:
在hello.s中,具体涉及的关系操作包括:
1. argc!=4
图 14 指令示意
argc!=4对应的汇编代码
此处-20(%rbp)即存放argc的地址。根据关系式的结果,会设置条件码的值,后续根据条件码的值来进行控制跳转。
图 15 指令示意
此处-4(%rbp)即存放i的地址。当i<=7时,进行跳转。
数组操作在3.3.1中进行了详细阐述。
在hello.s中,具体涉及的关系操作包括:
图 16 源代码中if()结构截图
可以看到,该处if()结构是判断argc的值是否为4,如果不为4,则输出"用法: Hello 学号 姓名 秒数!\n"。
在汇编代码中,此处对应的是:
图 17 hello.c中if()结构截图
通过argc与4做比较来设置条件码,然后je指令读取条件码,若相等则前往执行.L2节,若不相等则继续顺序执行。
图 18 源代码中for()循环结构截图
可以看到,这个for()循环结构是判断i的值是否小于8,若小于则执行printf和sleep操作并使i自增,若大于等于则跳出循环。
在汇编代码中,此处对应的是:
图 19 汇编代码中for()循环截图
通过比较i与7的大小来设置条件码,然后根据结果来决定是否跳转。当i=8时,结束循环,执行之后的指令。
在该代码中,程序入口处,调用了main 函数,其在hello.s中标注为@function函数类型。之后又调用 puts,printf,sleep,exit,getchar 函数,对函数的调用都通过call指令进行。
一般来说,调用函数时进行的操作有:
操作 |
作用 |
传递控制 |
进行过程 Q 的时候,PC必须设置为 Q 的代码的起始地址,然后在返回时,要把PC设置为 P 中调用 Q 后面那条指令(Q+1)的地址。 |
传递数据 |
P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。 |
分配和释放内存 |
在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些空间 |
表 5 调用函数时需进行的操作
可以发现,在hello.s中,第一处printf被优化为puts:
图 20 puts函数截图
第二处printf函数调用为:
图 21 printf函数截图
Printf的参数数量可变,在这里只需要用到一个参数,即一个字符串常量,于是将其地址放入%rdi中,向被调用函数传递,然后通过call指令来调用puts函数。
在hello.s中,调用exit函数的代码如图所示:
图 22 exit函数截图
使用寄存器EDI传递参数(整数值1),调用exit()函数以状态1退出。
本章介绍了编译的概念与作用。编译阶段分析检查源程序,确认所有的语句都符合语法规则后将其翻译成等价的汇编代码(中间代码)表示。它为后续将程序转化为二进制机器码做准备。本章以hello.s文件为例,介绍了编译器如何处理各种数据类型和操作,并验证了大部分数据和操作在汇编代码中的实现方式。
第4章 汇编
4.4.1 汇编的概念
汇编是指汇编器(assembler)将汇编语言程序(如hello.s)翻译成机器语言指令,并将这些指令打包成可重定位目标文件(如hello.o)的过程。hello.o是一个二进制编码文件,它包含程序的机器指令编码。汇编器可以通过直接运行或驱动程序运行来完成这一过程。
汇编的作用是:将汇编语言翻译为机器语言,并将相关指令以可重定位目标程序格式保存在.o文件中。
在Ubuntu下汇编的命令为:
图 23 汇编命令截图
首先,在shell中输入readelf -a hello.o > hello.elf 指令获得 hello.o 文件的 ELF 格式:
图 24 hello.elf在文件夹中截图
下面对hello.elf进行分析:
图 25 ELF头
ELF头以 16字节序列 Magic 开始,描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包括帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头大小、目标文件类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量等相关信息。
图 26 节头
节头阐述的是文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
图 27 重定位节
重定位节记录了各段引用的符号相关信息,在链接时,需要通过重定位节对这些位置的地址进行重定位。在原汇编代码中的地址在汇编后都会被赋予在程序实际执行时所需的寄存器、内存地址等信息。链接器会通过重定位条目的类型判断如何计算地址值并使用偏移量等信息计算出正确的地址。
图 28 符号表
符号表(.symtab)保存着定位、重定位程序中符号定义和引用的信息,即,符号表存放程序中定义和引用的函数和全局变量的信息。
(以下格式自行编排,编辑时删除)
使用命令:objdump -d -r hello.o 将hello.o反汇编:
图 29 反汇编指令
与hello.s对照,对于二者不同之处,分析如下表:
不同点 |
分析 |
分支转移 |
在hello.s中,跳转指令的目标地址直接记为段名称,如.L2,.L3等。 在hello.asm中,跳转的目标为具体的地址,在机器代码中体现为目标指令地址与当前指令下一条指令(PC=PC+1)的地址之差。 |
函数调用 |
在hello.s文件中,call之后直接跟着函数名称。而在反汇编得到的hello.asm中,call 的目标地址是当前指令的下一条指令。 |
全局变量访问 |
在hello.s 文件中,使用段名称+%rip访问 rodata(printf 中的字符串)。 在hello.asm中,使用 0+%rip进行访问。 |
表 6 不同点
本章详细介绍了汇编的概念、作用、可重定向目标文件的结构和反汇编代码。在汇编阶段,汇编语言代码通过汇编器(assembler)汇编被转化为机器语言,生成的可重定位目标文件(hello.o)为后续的链接阶段做好了准备。通过在Ubuntu下实际操作将hello.s文件翻译为hello.o文件,并生成hello.o的ELF格式文件hello.elf,我们研究了ELF格式文件的具体结构。通过比较hello.o的反汇编代码(保存在hello.asm中)与hello.s中的代码,我们了解了汇编语言与机器语言的异同之处。完成本章内容的过程加深了我们对汇编过程、ELF格式和重定位的理解。
第5章 链接
5.1.1 链接的概念
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。
5.1.2 链接的作用
提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。
使用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/7/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello
图 30 连接命令截图
在Shell中输入命令 readelf -a hello > hello1.elf 生成 hello 程序的 ELF 格式文件,保存为hello1.elf(与第四章中的elf文件作区分):
图 31 生成hello1.elf命令截图
分析hello1.elf文件:
图 32 ELF头
hello2.elf中的ELF头与hello.elf中的ELF头包含的信息种类基本相同,以 描述了生成该文件的系统的字的大小和字节顺序的16字节序列 Magic 开始,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。与hello.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图 33 节头(部分截图)
hello2.elf中的节头包含了文件中出现的各个节的语义,包括节的类型、位置、偏移量和大小等信息。与hello.elf相比,其在链接之后的内容更加丰富详细。
图 34 程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图 35 段到节的映射关系
这段代码显示了 ELF 文件中的段到节的映射关系。它列出了每个段中包含的节。
图 36 Dynamic section
图 37 symbol table部分截图
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明(此处仅截取部分展示)。
使用edb加载hello,如下图:
图 38 edb加载hello
图 39 data dump示意
由截图可知,程序被载入至地址0x400000~0x401000中,在该地址范围内,每个节的地址都与前一节中节对应的 Address 相同。
根据edb查看的结果,在地址空间0x400000~0x400fff中存放着与地址空间0x400000~0x401000相同的程序,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
使用命令:objdump -d -r hello > hello2.asm。(与前hello.asm作区分)
生成的hello2.asm部分截图如下所示:
图 40 hello2.asm部分截图
结合hello.o的重定位项目,有如下分析:
图 41 函数在hello2.asm中代码
图 42 call指令示意
图 43 跳转指令示意
使用edb执行hello,列出其调用与跳转的各个子程序名或程序地址如下表:
程序名称 |
程序地址 |
ld-2.27.so!_dl_start |
0x7fce8cc38ea0 |
ld-2.27.so!_dl_init |
0x7fce8cc47630 |
hello!_start |
0x400500 |
libc-2.27.so!__libc_start_main |
0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit |
0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init |
0x4005c0 |
hello!_init |
0x400488 |
libc-2.27.so!_setjmp |
0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp |
0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save |
0x7fce8c884bd0 |
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 |
0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup |
0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x |
0x7fce8cc420b0 |
libc-2.27.so!exit |
0x7fce8c889128 |
表 7
表7 子程序名和程序地址
由于编译器无法预测函数的运行时地址,因此需要添加重定位记录并等待动态链接器处理。为了避免在运行时修改调用模块的代码段,链接器采用了延迟绑定策略。动态链接器使用过程链接表(PLT)和全局偏移量表(GOT)来实现函数的动态链接。GOT 中存储着函数的目标地址,而 PLT 则使用 GOT 中的地址跳转到目标函数。在加载时,动态链接器会重定位 GOT 中的每个条目,使其包含目标的正确绝对地址。
.got与.plt节保存着全局偏移量表GOT,其内容从地址0x601000开始。通过edb查看,在dl_init调用前,其内容如下:
图 44 调用前示意
调用后:
图 45 调用后示意
通过比较,我们可以发现,0x601008 到 0x601017 之间的内容发生了变化,这对应着全局偏移量表(GOT)中的 GOT[1] 和 GOT[2] 的内容。GOT[1] 保存着指向已加载共享库的链表地址,而 GOT[2] 则是动态链接器在 ld-linux.so 模块中的入口。因此,在接下来执行程序的过程中,就可以使用过程链接表(PLT)和全局偏移量表(GOT)进行动态链接。
本章围绕可重定位目标文件hello.o链接生成可执行目标文件hello的过程,首先详细介绍、分析了链接的概念、作用及具体工作。通过readelf命令得到了链接后的hello可执行文件的ELF格式文本hello2.elf,据此分析了hello2.elf与hello.elf的异同;随后通过edb验证了hello的虚拟地址空间与节头部表信息的对应关系,分析了hello的执行流程。最后根据反汇编文件hello2.asm与hello.asm的比较,加深了对重定位与动态链接的理解。
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中,是操作系统对一个正在运行的程序的一种抽象。
给应用程序提供两个关键抽象:
Shell 是一个用C语言编写的交互型应用程序,代表用户运行其他程序。Shell 应用程序提供了一个界面,用户可以通过这个界面进行系统的基本操作,访问操作系统内核的服务。
从Shell终端读入输入的命令,切分输入字符串,获得并识别所有的参数。若输入参数为内置命令,则立即执行。若输入参数并非内置命令,则调用相应的程序为其分配子进程并运行。若输入参数非法,则返回错误信息。处理完当前参数后继续处理下一参数,直到处理完毕。
打开Shell,输入命令./hello 2021112802 why,带参数执行生成的可执行文件。
图 46 执行中示意
根据shell的处理流程,键入命令(./hello 2021112802 why)后,shell判断其不是内部指令,即会通过fork函数创建子进程。子进程与父进程近似,会得到一份与父进程用户级虚拟空间相同且独立的副本——包括数据段、代码、共享库、堆和用户栈等,父进程打开的文件,子进程也可读写。二者之间最大的不同在于PID的不同。fork函数被调用一次会返回两次,在父进程中,fork函数返回子进程的PID,在子进程中,fork函数返回0。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
在调用 `fork` 函数创建新的子进程后,子进程会调用 `execve` 函数来加载并运行一个新程序 `hello`。execve函数在加载了hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数原型如下:
int main(int argc, char **argv, char *envp)
execve函数的执行过程会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。`execve` 函数不会返回,它会删除该进程的代码和地址空间内容并将其初始化,然后通过跳转到程序的入口点来运行该程序。它会将私有区域(如打开的文件、代码段和数据段)和公共区域映射到地址空间中。然后,加载器会跳转到程序的入口点,即将 PC 设置为 `_start` 地址。最终,`_start` 函数会调用 `hello` 程序中的 `main` 函数,完成子进程中的加载。
图 47 hello程序执行示意
在程序运行时,Shell 会为hello程序创建一个子进程,该子进程与 Shell 具有独立的逻辑控制流。如果hello进程不被抢占,它会正常执行;如果被抢占,它会进入内核模式进行上下文切换,然后转入用户模式并调度其他进程。当 hello调用sleep 函数时,为了最大化利用处理器资源,sleep 函数会向内核发送请求将 hello挂起,并进行上下文切换。此时,hello进程会从运行队列中移除并加入等待队列,并开始计时。当计时结束时,sleep 函数返回并触发一个中断,使得 hello进程重新被调度并从等待队列中移出。此时,hello进程就可以继续执行其逻辑控制流。
打印8次提示信息,输入回车即可结束程序,并回收进程。
图 48 执行中不按键时
运行时按了3次回车,可以看到,程序运行时多打印了3行空行,最后程序结束时也提示了3次结束信息。程序可以正常结束。
图 49 运行中按回车
Shell进程收到SIGINT信号,Shell结束并回收hello进程。程序可以正常结束。
图 50 运行中按Ctrl+C
Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
图 51 运行中按Ctrl+Z
对hello进程的挂起可由ps和jobs命令查看,可以发现hello进程确实被挂起而非被回收,且其job代号为1。
图 52 ps命令和jobs命令
输入kill命令:kill -9 %1.
(题外话:我不小心输成了kill -1 %9,结果Ubuntu锁屏要求再次输入密码。查资料可知:`kill` 命令用于向进程发送信号。`-9` 是一个信号选项,表示发送 `SIGKILL` 信号,该信号会立即终止进程。`%1` 表示要发送信号的进程是当前 shell 的第一个作业。因此,`kill -9 %1` 命令的意思是立即终止当前 shell 的第一个作业。而 `kill -1 %9` 命令中的 `-1` 是一个信号选项,表示发送 `SIGHUP` 信号,该信号通常用于通知进程终端已断开连接。`%9` 表示要发送信号的进程是当前 shell 的第九个作业。因此,`kill -1 %9` 命令的意思是向当前 shell 的第九个作业发送 `SIGHUP` 信号。)
再次ps可以发现,hello进程已经被杀死。
图 53 kill命令
输入fg-1命令(进程挂起时):可以看到,shell继续执行hello剩余的打印命令。
图 54 fg-1命令
乱按后如果加enter在程序执行过程中乱按所造成的输入均缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),hello结束后,stdin中的其他字串会当做Shell的命令行输入。
如果不加enter,则仅在输出时连带输出乱按的结果。
图 55 运行中不断乱按(按enter时)
图 56 运行中不断乱按(不按enter时)
图 57 pstree命令及其结果部分截图
图 58 终止hello前pstree对应部分截图
图 59 终止hello后pstree对应部分截图
本章介绍了进程的概念与作用,以及Shell-bash的基本概念。针对进程,在这一章中根据hello可执行文件的具体示例研究了fork,execve函数的原理与执行过程,并给出了hello带参执行情况下各种异常与信号处理的结果。在hello程序运行的过程中,内核对其进行进程管理,决定何时进行进程调度,在接收到不同的异常、信号时,还要及时地进行对应的处理。
逻辑地址是指由程序产生的与段相关的偏移地址部分,逻辑地址由选择符和偏移量两部分组成。具体而言,其为hello.asm中的相对偏移地址。
逻辑地址经过段机制转化后为线性地址,其为处理器可寻址空间的地址,用于描述程序分页信息的地址。具体以hello而言,线性地址标志着 hello 应在内存上哪些具体数据块上运行。
根据CSAPP教材,虚拟地址即为上述线性地址(VPN+VPO).
CPU通过地址总线的寻址,找到真实的物理内存对应地址(PPN+PPO).
为了充分利用内存空间,Intel 8086 设计了四个段寄存器来保存段地址:代码段寄存器(CS)、数据段寄存器(DS)、堆栈段寄存器(SS)和附加段寄存器(ES)。当程序要执行时,需要确定代码、数据和堆栈所占用的内存位置,并通过设置 CS、DS 和 SS 段寄存器来指向这些起始位置。通常 DS 是固定的,而 CS 根据需要进行修改。因此,程序可以在可寻址空间小于 64K 的情况下被写成任意大小。但是,程序和数据的组合大小受到 DS 所指向的 64K 的限制,这就是 COM 文件不得大于 64K 的原因。
段寄存器是为了实现内存分段管理而设置的。计算机需要对内存进行分段,以便分配给不同的程序使用。描述内存分段时需要提供以下信息:段的大小、段的起始地址和段的管理属性(如禁止写入、禁止执行和系统专用等)。
在保护模式下,段寄存器的唯一目的是存放段选择符。其前 13 位是索引号,后 3 位包含一些硬件细节(还有一些隐藏位)。寻址方式为:使用段选择符作为下标,在 GDT/LDT 表中查找段地址,然后将段地址加上偏移地址得到线性地址。
在实模式下,段寄存器包含段值。访问存储器时,处理器会引用相应的某个段寄存器并将其值乘以 16,形成 20 位的段基地址。然后将段基地址加上偏移量得到线性地址。
线性地址(VA)到物理地址(PA)之间的转换通过对虚拟地址内存空间进行分页的分页机制完成。
通过7.2节中的段式管理过程,可以得到了线性地址/虚拟地址,记为VA。虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如下图所示。
图 60 页式管理的地址变换示意
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
针对Intel Core i7 CPU研究VA到PA的变换。
Intel Core i7 CPU的基本参数如下:
根据上述信息,我们可以得知 VPO 和 PPO 都有 12 位,因此 VPN 为 36 位,PPN 为 40 位。单个页表大小为 4KB,PTE 大小为 8 字节,因此单个页表有 512 个页表条目,需要 9 位二进制进行索引。而四级页表则需要 36 位二进制进行索引,对应着 36 位的 VPN。TLB 共有 16 组,因此 TLBI 需要 4 位,而 TLBT 则需要 36-4=32 位。
图 61 TLB与四级页表支持下的VA到PA的变换
如图所示,CPU 产生虚拟地址 VA 并将其传送至 MMU。MMU 使用前 36 位 VPN 来在 TLB 中进行匹配(前 32 位为 TLBT,后 4 位为 TLBI)。如果命中,则得到 PPN(40 bit)并与 VPO(12 bit)组合成物理地址 PA(52 bit)。如果 TLB 没有命中,则 MMU 向页表中查询。CR3 确定第一级页表的起始地址,VPN1(9 bit)确定在第一级页表中的偏移量,并查询出 PTE。如果 PTE 在物理内存中且权限符合,则执行下一步确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到 PPN 并与 VPO 组合成 PA,并向 TLB 中添加条目。多级页表的工作原理如下图所示:
图 62 多级页表示意
如果在查询 PTE 的过程中发现它不在物理内存中,则会引发缺页故障。如果发现权限不够,则会引发段错误。
(以下格式自行编排,编辑时删除)
cache结构示意图为:
图 63 缓存结构示意图
图 64 物理地址结构示意图
由于三级缓存的工作原理基本相同,因此以 L1 缓存为例,介绍在三级缓存支持下的物理内存访问。L1 缓存的基本参数如下: 8 路 64 组相连,块大小为 64 字节。
根据 L1 缓存的基本参数,我们可以分析得知:
- 块大小为 64 字节,因此需要 6 位二进制索引,即块偏移为 6 位。
- 共有 64 组,因此需要 6 位二进制索引,即组索引为 6 位。
- 剩余的标记位需要 PPN+PPO-6-6=40 位。
因此,L1 缓存可以划分为以下部分(从左到右):
CT(40 bit)CI(6 bit)CO(6 bit)
在前面的章节中,我们已经通过虚拟地址 VA 转换得到了物理地址 PA。首先使用 CI 进行组索引,每组有 8 路,对这 8 路的块分别匹配 CT(前 40 位)。如果匹配成功且块的 valid 标志位为 1,则命中(hit),并根据数据偏移量 CO 取出相应数据并返回。
如果没有匹配成功或者匹配成功但标志位为 0,则不命中(miss),并向下一级缓存请求数据(请求顺序为 L2 缓存→L3 缓存→主存,如果仍然不命中则继续向下一级请求)。查询到数据后,需要对数据进行读入。一种简单的放置策略是:如果映射到的组内有空闲块,则直接放置在空闲块中;如果当前组内没有空闲块,则产生冲突(evict),采用 LFU 策略进行替换。
当 `fork` 函数被父进程(即 shell)调用时,内核会为新进程(即将加载并执行 `hello` 程序的进程)创建各种数据结构,并分配给它一个唯一的 PID。为了为新进程创建虚拟内存,内核会创建当前进程的 `mm_struct`、区域结构和页表的副本。它会将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当 `fork` 在新进程中返回时,新进程的虚拟内存与调用 `fork` 时的虚拟内存相同。当这两个进程中的任意一个进行写操作时,写时复制机制就会创建新页面,从而为每个进程保持私有地址空间的抽象概念。
要加载并运行hello,execve函数需要执行以下步骤:
1. 删除当前进程hello虚拟地址用户部分中已存在的区域结构。
2. 为新程序的代码、数据、bss和栈区域创建新的私有的、写时复制的区域结构。
3. 若hello程序与共享对象或目标链接,则将这些对象动态链接到hello程序,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器,使之指向代码区域的入口点。
发生一个缺页异常后,引发缺页故障,控制会转移到内核的缺页处理程序。判断虚拟地址是否合法,若不合法,则产生一个段错误,然后终止这个进程。
若操作合法,则缺页处理程序从物理内存中确定一个牺牲页,若该牺牲页被修改过,则将它换出到磁盘,换入新的页面并更新页表。当缺页处理程序返回时,CPU 再次执行引起缺页的指令,将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中,主存将所请求字返回给处理器。
本章主要介绍了hello 的存储器地址空间、intel 的段式管理、hello 的页式管理, VA 到PA 的变换、物理内存访问,hello进程fork、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
hello程序的一生(P2P, program to progress; 020, from zero to zero)经历了预处理、编译、汇编、链接、加载运行、执行指令、访存、动态申请内存、信号处理和终止并被回收等过程。
文件名 |
功能 |
hello.i |
预处理后得到的文本文件 |
hello.s |
编译后得到的汇编语言文件 |
hello.o |
汇编后得到的可重定位目标文件 |
hello.elf |
用readelf读取hello.o得到的ELF格式信息 |
hello1.elf |
用readelf读取链接后的可执行目标文件得到的ELF格式信息 |
hello.asm |
反汇编hello.o得到的反汇编文件 |
hello2.elf |
由hello可执行文件生成的.elf文件 |
hello2.asm |
反汇编hello可执行文件得到的反汇编文件 |
表 8 生成的文件示意图
[1] Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
[2] Tanenbaum, A. S., & Woodhull, A. S. (2006). Operating systems: design and implementation (3rd ed.). Prentice Hall. ³