摘 要
本论文研究了hello.c这一简单c语言文件在Linux系统下的整个生命周期,以其原始程序开始,依次深入研究了编译、链接、加载、运行、终止、回收的过程,从而了解hello.c文件的“一生”。在《深入理解计算机系统》这本书中,作者用一个程序员的角度看待了一个程序的生命周期,本文我们将根据本书的知识,介绍hello.c程序的运行周期,通过对hello.c程序的深入研究,加深对程序的认识和计算机底层工作原理的了解。
关键词:预处理;编译;汇编;计算机系统;进程;链接
目 录
第1章 概述
1.1 Hello简介
Hello的P2P是指hello.c文件从可执行程序(Program)变为运行时进程(Process)的过程。在Linux系统下,hello.c 文件依次经过cpp预处理、ccl编译、as 汇编、ld 链接最终成为可执行目标程序hello。打开shell,输入命令./hello后,shell 通过fork产生子进程,hello 便从可执行程序(Program)变成为进程(Process)。
图 1 生成可执行文件的过程
Hello的020是指hello.c文件“From 0 to 0”,初始时内存中并无hello文件的相关内容,这便是“From 0”。通过在Shell下调用execve函数,系统会将hello文件载入内存,执行相关代码,当程序运行结束后, hello进程被回收,并由内核删除hello相关数据,这即为“to 0”。
1.2 环境与工具
11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz 2.30 GHz
16GB RAM 1TB HD Disk
软件:
Windows10 64位
Ubuntu 22.04 LTS 64位
调试工具:
Visual Studio 2022 64-bit;
gedit,gcc,notepad++,readelf, objdump, hexedit, edb
1.3 中间结果
功能 | |
hello.c | 源代码 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
hello.asm | 反汇编hello.o得到的反汇编文件 |
hello | 链接之后形成的可执行目标文件 |
hello2.elf | 由hello可执行文件生成的.elf文件 |
hello2.asm | 反汇编hello可执行文件得到的反汇编文件 |
表1 中间结果
1.4 本章小结
本章简要介绍了hello的P2P,020的具体过程,同时列出了研究时采用的具体软硬件环境和中间结果。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理步骤是指程序开始运行时,预处理器(cpp,C Pre-Processor,C预处理器)根据以字符#开头的命令,修改原始的C程序的过程。为编译做预备工作,主要进行代码文本的替换工作。
2.1.2预处理的作用
宏定义:在编译预处理时,对程序中所有出现的宏名,都用宏定义中的字符串去代换,这称为“宏代换”或“宏展开”。
文件包含:文件包含命令的功能是把指定的文件插入到该命令行位置取代该命令行,从而把指定的文件和当前的源程序文件连成一个源文件。使用文件包含指令可以节省时间并减少出错,方便后续处理。
条件编译:预处理程序提供了条件编译的功能。可以按不同的条件去编译不同的程序部分,因而产生不同的目标代码文件。
2.2在Ubuntu下预处理的命令
在Ubuntu系统下,进行预处理的命令为:
gcc -E hello.c -o hello.i
运行截图如下:
图 2 Ubuntu下预处理
2.3 Hello的预处理结果解析
图 3 预处理结果
在ubuntu下用vim打开生成的hello.i文件,可以看到文件行数剧增,但对于main函数的部分,代码并没有发生变化,因此可以推断,在预处理阶段,预处理器仅仅是对main函数之前的引用和声明做了改动,文件行数的剧增就是因为#预处理命令将头文件的程序、宏变量、特殊符号等插入到hello.c中,而对于main函数本身以及之后的代码,预处理器对此并不感兴趣。
2.4 本章小结
本章主要介绍了预处理的概念及作用、并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。从预处理步骤就可以初步发现,一个小小的hello world程序,其背后隐藏的东西要多得多。
第3章 编译
3.1 编译的概念与作用
- 编译的概念
编译是指编译器(ccl)通过词法分析、语法分析、语义检查和中间代码生成、代码优化以及目标代码生成这五个阶段来讲一个源程序翻译成目标程序的工作过程,总的来说,编译就是把代码转化为汇编指令的过程。
- 编译的作用
将高级程序语言源程序翻译为汇编语言程序,为后续将其转化为二进制机器码做准备。此外,编译还可以进行错误分析,并给出提示信息;对程序中的代码进行优化。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
在Ubuntu系统下,进行预处理的命令为:
gcc -m64 -no-pie -fno-PIC -S -o hello.s hello.i
图 4 Ubuntu下编译命令
3.3 Hello的编译结果解析
- 文件结构分析
对hello.s文件整体结构分析如下:
内容 | 含义 |
.file | 源文件 |
.text | 代码段 |
.global | 全局变量 |
.data | 存放已经初始化的全局和静态C 变量 |
.section .rodata | 存放只读变量 |
.align | 对齐方式 |
.type | 表示是函数类型/对象类型 |
.size | 表示大小 |
.long .string | 表示是long类型/string类型 |
表2 hello.s文件结构
3.3.2数据类型
在源代码之中,我们构造了数组,局部变量,常量和字符串四种数据类型,接下来我们观察在hello.s中这些变量的表示方式。
- 整型数组
程序中涉及的数组为char *argv[],即函数的第二个参数。在hello.s中,其首地址保存在栈中。
图 5 数组的情况
- 局部变量
编译器将局部变量存储在寄存器或者栈空间中。i作为函数内部的局部变量,并不占用文件实际节的空间,只存在于运行时栈中。对于i的操作就是直接对寄存器或栈进行操作。
图 6 局部变量i的情况
- 常量
图 7 常量的情况
可以看出编译过程中,常量直接用立即数来代替,并没有对其分配空间。
- 字符串
程序中保存了两个字符串,分别为:
图 8 字符串情况
两者均为字符串常量,储存在只读数据段中。\XXX为UTF-8编码。
3.3.3赋值操作
对局部变量i的赋值在汇编代码中通过mov指令完成。具体使用哪条mov指令由数据的大小决定。通过源代码可以得知赋值操作在for循环中。在汇编指令中的操作如下:
图 9 对i的赋值操作
3.3.4关系操作
由程序源代码可知,关系判断出现了2次,分别在if(argc!=4)和for(i=0;i<8;i++),通过下面的汇编代码可以看出,编译器利用cmp指令对两个操作数进行大小比较,并利用结果对操作码进行设置。
图 10 关系操作情况
3.3.5 算数操作
在hello.s中,具体涉及的算数操作包括:
- subq $32, %rsp:开辟栈帧
- addq $16, %rax:修改地址偏移量
- addl $1, -4(%rbp):实现i++的操作
可以参考图10。
3.3.6 数组操作
在3.3.2中提到,数组储存在栈中,分析汇编代码可以看出,数组采用寄存器寻址的方式进行访问。
图 11 数组操作
3.3.7 控制转移
程序中控制转移的具体表现有两处:
1)if(argc!=4):
当argc不等于4时,执行函数体内部的代码。在hello.s中,使用cmpl $4,-20(%rbp),比较 argc与4是否相等,若相等,则跳转至.L2,不执行后续部分内容;若不等则继续执行函数体内部对应的汇编代码。
2)for(i=0;i<8;i++):
当i < 时进行循环,每次循环i++。在hello.s中,使用cmpl $7,-4 (%rbp),比较 i与7是否相等,在i<=7时继续循环,进入.L4,i>7时跳出循环。
跳转过程参考图10。
3.3.8函数操作
1)main函数
函数调用:由系统来调用,或者更准确地说,由execve函数来调用。
函数返回:使用movl指令将%rax寄存器中的值置为0。
2)printf函数
图 12 printf函数调用
传入.LC1作为参数,作为printf的输出
3)Aoti函数
图 13 Atoi函数调用
4)sleep函数
图 14 sleep函数调用
5) getchar函数调用
图 15 getchar函数调用
对于函数调用,需要call指令和ret指令配合完成,call指令负责将返回地址压栈,并将PC的值跳转为调用函数的第一条指令的地址,ret指令则将返回地址弹出到PC寄存器中,从而完成函数调用的返回。在函数调用过程中,有六个寄存器负责传递参数,参数数量超过六个需要用栈来传递。
3.4 本章小结
本章重点介绍了编译的概念和作用,它是将文本文件转化为汇编语言程序的过程,为后续生成二进制机器码做准备。以hello.s文件为例,我们详细讨论了编译器如何处理不同的数据类型和各种操作,并验证了这些数据和操作在汇编代码中的实现方式。我们发现,编译器并不是简单地按照原始文本的顺序逐条翻译代码。在编译过程中,编译器会进行一些隐式的优化,并使用控制转移等技术来处理代码中的跳转和循环等操作。最终,编译器生成了我们所需的hello.s文件。通过这一过程,我们可以更好地理解编译器在将高级语言转化为底层机器码的工作原理。
第4章 汇编
4.1 汇编的概念与作用
- 汇编的概念
汇编是指汇编器(assembler)将以.s结尾的汇编程序翻译成机器语言指令,并把这些指令打包成可重定位目标程序格式,最终结果保存在.o 二进制目标文件中的过程
- 汇编的作用
将我们之前在hello.s中保存的汇编代码翻译成可以供机器执行的二进制代码,这样机器就可以根据这些01代码,真正的开始执行我们写的程序。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
在Ubuntu下汇编的命令为:
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
汇编过程如下:
图 16 Ubuntu下汇编命令
4.3 可重定位目标elf格式
1.ELF Header
以16字节序列Magic开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,其中7f 45 4c 46为固定的魔法字节,02表示64位,第一个01表示小端序,第二个01表示ELF头版本。ELF头剩下的部分包括帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位的)、机器类型、节头部表的文件偏移。以及节头目表中条目的大小和数量。
图 17 ELF header中的信息
2.重定位节.rela.text
一个.text节中位置的列表,存放着代码的重定位条目,其类型为 RELA,也就是说它是一个重定位表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。在此重定位表中,每个要被重定位的地方叫重定位入口,我们可以看到每个重定位入口在段中的偏移位置,重定位入口的类型,重定位入口的名称以及重定位修正的辅助信息等。
图 18 重定位表和重定位节
3.重定位节.rela.eh_frame
.rela.eh_frame节同.rela.eh_frame一样属于重定位信息的节,包含的是eh_frame的重定位信息
4.符号表Symbol table .symtab
符号表中存放着在程序中定义和引用的函数和全局变量的信息,与编译器的符号表不同,.symtab符号表不包含局部变量的条目。
图 19 符号表
4.4 Hello.o的结果解析
使用objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
图 20 hello.o的反汇编
反汇编得到的文件左边比hello.s多了机器语言,而机器语言实际上就是对汇编语言的编码,每条汇编代码都具有唯一的机器编码。就二者之间的差异更具体地分析如下:
1.分支转移:在hello.s中,跳转指令的目标地址为段名称,如.L2、.L3;在反汇编代码中,跳转指令的目标地址为具体的地址;在机器代码中为目标指令地址与下一条指令地址的差值。
2.操作数:机器语言和反汇编语言中的操作数都是十六进制的,而汇编语言是十进制的。
3.函数调用:在hello.s中,函数的调用是通过在call指令后面直接跟随函数名来实现的。然而,在反汇编代码中,对函数的调用的目标地址却是当前指令的下一条指令的地址。这种差异的原因在于hello.c中调用的函数属于共享库函数,需要在运行时通过动态链接器确定函数的地址。因此,在汇编过程中,我们将call指令后的目标地址设置为下一条指令的地址,并在函数调用后生成了一个重定位条目。这个重定位条目的作用是告诉链接器使用函数名之前的重定位类型来进行函数引用的重定位操作。通过这种方式,我们可以在编译过程中处理共享库函数的调用,确保函数的正确引用和地址解析。
图 21 汇编与反汇编对比
4.5 本章小结
本章主要探讨了汇编语言的概念和作用。首先,我们演示了如何在Linux系统下将hello.s文件进行汇编,生成了可重定位目标文件hello.o。通过使用readelf指令,我们还生成了hello.o的elf格式,并对其具体结构进行了研究。为了进一步理解hello.o的机器代码,我们将其与hello.s的汇编代码进行对比,进行了反汇编并解析了其中的指令。这个比较帮助我们认识到汇编语言与机器语言之间的相似之处和差异之处。需要指出的是,在二进制代码中,所有的指令和函数名等都被转换为相应的存储地址,使得机器可以直接读取和执行这些代码。因此,综上所述,hello.o已经非常接近于可执行的机器代码形式。
第5章 链接
5.1 链接的概念与作用
链接的概念
链接是指通过链接器(Linker),将程序编码与数据块收集并整理成为一个单一文件,生成完全链接的可执行的目标文件(windows系统下为.exe文件,Linux系统下一般省略后缀名)的过程。
链接的作用
链接可以将程序封装成多个模块。在编写程序时,我们只需要关注主程序部分,而可以直接调用其他模块,就像在C语言中使用printf函数一样。链接的任务是处理程序对其他模块的调用,并将这些模块中的代码组合到相应的可执行文件中。在链接的过程中,我们还可以使用静态库中提供的各种函数和声明,进一步扩展程序的功能。通过链接,我们可以将程序的各个部分连接在一起,形成一个完整的可执行文件,使得程序能够正常运行并调用所需的模块和库函数。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
在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
链接过程如下:
图 22 Ubuntu下链接
5.3 可执行目标文件hello的格式
readelf -a hello > hello1.elf 生成 hello 程序的 ELF 格式文件,保存为hello1.elf。
1.ELF Header
以16字节序列Magic开始,这个序列描述了生成该文件的系统 的字的大小和字节顺序,其中7f 45 4c 46为固定的魔法字节,02表示64位 ,第一个01表示小端序,第二个01表示ELF头版本。ELF头剩下的部分包 括帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目 标文件的类型(可重定位的)、机器类型、节头部表的文件偏移。以及节头目表中条目的大小和数量。与hello.elf相比较,hello2.elf中的基本信息未发生改变(如Magic,类别等),而类型发生改变,程序头大小和节头数量增加,并且获得了入口地址。
图 23 hello可执行文件的ELF Header
2.节头
可以看到hello文件中的节的数目比hello.o中多了很多,说明在链接过后有很多文件有添加了进来。下面列出每一节中各个信息条目的含义:名称和大小这个条目中存储了每一个节的名称和这个节在重定位文件种所占的大小。地址这个条目中,保存了各个节在重定位文件中的具体位置也就是地址。偏移量这一栏中保存的是 这个节在程序里面的地址的偏移量,也就是相对地址。
图 24 hello的节头
3.程序头
程序头部分是一个结构数组,描述了系统准备程序执行所需的段或其他信息。
图 25 程序头
4.Dynamic section
图 26 Dynamic section
5.符号表
符号表中保存着定位、重定位程序中符号定义和引用的信息,所有重定位需要引用的符号都在其中声明。
图 27 hello的符号表
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
根据计算机系统的特性,程序被载入至地址0x400000~0x401000中。在该地址范围内,每个节的地址都与前一节中节对应的Address相同。根据edb查看的结果,在地址空间0x400000~0x400fff中存放着与地址空间0x400000~0x401000相同的程序,在0x400fff之后存放的是.dynamic到.shstrtab节的内容。
图 28 虚拟地址空间
5.5 链接的重定位过程分析
5.5.1.hello与hello.o的不同
1)链接后函数数量增加。动态链接器将共享库中hello.c用到的函数加入可执行文件中。所以链接后的反汇编文件hello2.asm中,多出了.plt,puts@plt,printf@plt,getchar@plt,exit@plt,sleep@plt等函数的代码。
2)函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。
3)跳转指令参数发生变化。在链接过程中,链接器解析了重定位条目,并计算相对距离,修改了对应位置的字节代码为PLT中相应函数与下条指令的相对地址,从而得到完整的反汇编代码。
5.5.2链接过程分析
1)链接器合并相同类型的部分:链接器首先将同一类型的部分合并到新的相同类型部分中。例如,所有文件的.data部分合并为一个新的.data部分,形成可执行文件hello的.data部分。
2)确定地址:链接器为新的聚合部分以及输入模块定义的部分和符号分配内存地址。一旦地址确定,全局变量、指令等都将具有唯一的运行时地址。同时,链接器判断输入文件是否为库文件,如果不是目标文件,则将其放入集合E 中。
3)符号解析:链接器解析目标文件中的符号,并将它们放入集合U中,如果符号看起来已定义但未使用,则将其放入集合D中。链接器还读取 crt* 库的目标文件。
4)动态链接库接入:链接器将动态链接库libc.so进行接入,使得可执行文件能够使用该动态链接库提供的函数和资源。
图 29 链接后的反汇编
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。其调用与跳转的各个子程序名或程序地址。
0000000000401000 <_init>
0000000000401020 <.plt>
0000000000401090 <puts@plt>
00000000004010a0 <printf@plt>
00000000004010b0 <getchar@plt>
00000000004010c0 <atoi@plt>
00000000004010d0 <exit@plt>
00000000004010e0 <sleep@plt>
00000000004010f0 <_start>
0000000000401120 <_dl_relocate_static_pie>
0000000000401130 <deregister_tm_clones>
0000000000401160 <register_tm_clones>
00000000004011a0 <__do_global_dtors_aux>
00000000004011d0 <frame_dummy>
00000000004011d6 <main>
0000000000401270 <__libc_csu_init>
00000000004012e0 <__libc_csu_fini>
00000000004012e8 <_fini>
5.7 Hello的动态链接分析
动态链接的基本思想是将程序拆分为相对独立的模块,在程序运行时形成完整的程序,而不像静态链接那样将所有模块链接成单个可执行文件。然而,在形成可执行文件时,仍然需要使用动态链接库。当程序引用一个外部函数时,会检查动态链接库并找到相应的动态链接符号,但并不进行符号的重定位。直到程序执行过程中加载时,才会由动态链接器使用过程链接表(PLT)和全局偏移量表(GOT)来实现函数的动态链接。
每个被可执行程序调用的库函数都有自己的PLT条目,每个条目负责调用一个具体的函数。GOT则包含动态链接器在解析函数地址时使用的信息。在加载时,动态链接器会对GOT中的每个条目进行重定位,确保它们包含目标函数的正确绝对地址。
通过以上过程,动态链接器能够在程序执行时根据需要加载并链接库函数,实现函数的动态链接。这种动态链接的机制使得程序模块化、灵活性更高,能够在运行时根据需要动态装载和链接所需的库函数,提供了更加高效和灵活的程序执行方式。
图 30 动态链接前
图 31动态链接后
5.8 本章小结
本章详细介绍了链接的概念和作用,并通过对链接后的可执行文件的ELF格式进行结构分析来深入理解链接的过程。我们对比了可重定位目标文件的ELF格式,分析了不同类型的信息。接下来,通过使用edb进行hello程序的调试,我们进一步分析了hello程序的虚拟地址空间,并验证了链接的过程。通过这个过程,我们清楚了hello程序的执行流程,并对其动态链接进行了深入分析,加深了对可执行文件执行过程和动态链接的理解。通过学习本章内容,我们对链接在程序开发和执行中的重要性有了更深入的认识,也增强了对可执行文件的结构和执行过程的理解。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念
进程是一个正在运行的程序的实例,系统中的每一个程序都运行在某个进程的上下文中。
进程的作用
给应用程序提供两个关键抽象:
一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
以下是对提供的信息进行提炼和整理的一段话:
Shell是一个用C语言编写的交互式应用程序,其作用是代表用户运行其他程序,并提供了一个用户界面来执行系统的基本操作和访问操作系统内核的服务。Shell的处理流程包括以下步骤:
1.从Shell终端读取输入的命令。
2.切分输入字符串,提取和识别所有的参数。
3.如果输入的参数是内置命令,立即执行相应的操作。
4.如果输入的参数不是内置命令,则调用相应的程序为其分配子进程并运行。
5.如果输入的参数非法,则返回错误信息。
6.处理完当前参数后,继续处理下一个参数,直到所有参数都被处理完毕。
通过这样的流程,Shell能够接受用户输入的命令,并根据命令的类型执行相应的操作,包括执行内置命令或调用外部程序运行。Shell作为用户与操作系统之间的接口,提供了一种方便和灵活的方式来操作和管理计算机系统。
6.3 Hello的fork进程创建过程
打开Shell,输入命令./hello 2021112018 lr 1,带参数执行生成的可执行文件。
fork进程的创建过程如下:首先,父进程通过fork函数创建一个新的子进程hello,同时执行当前目录下的可执行文件hello。子进程会获取与父进程相同的上下文,包括栈、通用寄存器、程序计数器、环境变量和打开的文件的副本。子进程与父进程最大的区别是它有一个不同的PID,并且可以读取父进程打开的任何文件。
当子进程运行结束时,如果父进程仍然存在,则父进程会执行对子进程的回收。如果父进程已经终止,那么子进程会由init进程来回收。这样可以确保子进程的资源被正确释放,避免产生僵尸进程。
图 32 程序执行情况
6.4 Hello的execve过程
execve函数的原型为:
int execve(const char *filename,const charargv[],const char envp[])
execve函数在当前进程的上下文中加载并运行一个新的程序。与fork函数不同的是,execve函数不创建新的进程,而是直接在当前进程中替换现有的虚拟内存段,并创建一组新的代码、数据、堆和用户栈段。
在执行execve函数时,当前进程的虚拟内存段被删除,并被新程序的代码、数据和堆所替代。栈和堆会被初始化为0,代码段和数据段则会被初始化为新程序可执行文件中的内容。最后,程序计数器(PC)被设置为新程序的入口点(通常为_start的地址)。
在CPU开始访问被映射的虚拟页时,内核会从磁盘中将需要的数据加载到内存中。这样,通过execve函数加载新程序后,当前进程就会执行新程序的代码,从而实现了程序的替换和执行。
6.5 Hello的进程执行
6.5.1进程相关概念介绍:
1)用户模式和内核模式:
处理器利用控制寄存器的模式位来实现限制应用程序指令数和地址空间范围的功能。该寄存器描述了当前进程所拥有的权限。当模式位被设置时,进程以内核模式运行,可以执行指令集中的任何指令并访问系统内存的任意位置。相反,当模式位未被设置时,进程以用户模式运行,禁止执行特权指令,如停止处理器或更改模式位,并且不允许直接引用内核区域的代码片段和数据。此时,用户程序必须通过系统调用接口来间接访问内核代码和数据。
2)控制流:
从上电时间到断点位置计算的PC值序列,程序计数器称为控制流。
3)逻辑控制流:
使用调试器单步执行程序时,您会看到一系列程序计数器 (PC) 值,这些值与程序的可执行对象文件中包含的指令唯一对应,或者包含在运行时动态链接到程序的共享对象中。此 PC 值序列称为逻辑控制流,或简称为逻辑流。也就是说,逻辑控制流是进程中的一系列 PC 值。
4)进程上下文:
上下文是内核重新启动抢占进程所需的状态,它由对象的值组成,例如通用寄存器,浮点寄存器,程序计数器,用户堆栈,状态寄存器,内核堆栈和各种内核数据结构。
6.5.2 程序进程执行:
在程序运行时,Shell为hello fork了一个子进程,这个子进程与Shell有独立的逻辑控制流。在hello的运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。直到当hello调用sleep函数时,为了最大化利用处理器资源,sleep函数会向内核发送请求将hello挂起,并进行上下文切换,进入内核模式切换到其他进程,切换回用户模式运行抢占的进程。与此同时,将 hello 进程从运行队列加入等待队列,由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数返回,触发一个中断,使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时 hello 进程就可以继续执行其逻辑控制流。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
1.在程序正常运行时,打印八次提示信息,以输入回车为标志结束程序,并回收进程。
图 33 程序正常运行
2.乱按和一直按回车,程序可以正常结束
图 34 乱按和回车
3.按下Ctrl + C,Shell进程收到SIGINT信号,Shell结束并回收hello进程。
图 35 Ctrl +C结束进程
4.按下Ctrl + Z,Shell进程收到SIGSTP信号,Shell显示屏幕提示信息并挂起hello进程。
图 36 Ctrl+Z挂起
5.ps查看进程,发现hello进程还在继续
图 37 ps查看进程
6.fg继续hello进程
发现Shell首先打印hello的命令行命令,hello再从挂起处继续运行,打印剩下的语句。程序仍然可以正常结束,并完成进程回收。
图 38 继续hello进程
7.kill杀死进程
图 39 杀死进程
8.jobs查看作业
图 40 jobs结果
9.pstree查看进程树
图 41 进程树
6.7本章小结
本章介绍进程的概念和作用,详细说明了Shell-bash的作用和执行流程。通过对Shell-bash下hello程序的执行进行研究,我们深入探讨了fork函数创建子进程的过程、execve函数的执行过程,以及各种异常和信号处理的结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指由程序产生的段内偏移地址。逻辑地址与虚拟地址二者之间没有明确的界限。在hello中,逻辑地址为hello.asm中的相对偏移地址。
线性地址:指虚拟地址到物理地址变换的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。逻辑地址,或者说段中的偏移地址,加上相应段基址就成了一个线性地址。在hello中,线性地址标志着hello应在内存上哪些具体数据块上运行。
虚拟地址:是由程序产生的由段选择符和段内偏移地址组成的地址。这两部分组成的地址并不能直接访问物理内存,而是要通过分段地址的变化处理后才会对应到相应的物理内存地址。
物理地址:指内存中物理单元的集合,地址转换的最终地址,进程在运行时执行指令和访问数据最后都要通过物理地址来存取主存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
通过段式管理方式,Intel处理器实现了从逻辑地址到线性地址的变换。每个程序在系统中都有一个段表,用于保存各个段在主存中的状态信息。这些信息包括段号或段名、段的起始地址、装入位、段的长度、主存占用区域表和主存可用区域表等,以便于进行段式管理。
在Intel处理器中,段选择符存储在段寄存器中。通过段选择符,可以获取对应段的首地址。
段选择符的结构如下:
图 42 段选择符结构
其包含三部分:索引,TI,RPL
索引:用来确定当前使用的段描述符在描述符表中的位置;
TI:根据TI的值判断选择全局描述符表(TI=0,GDT)或选择局部描述符表(TI=1,LDT);
RPL:判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态;
通过一个索引,可以定位到段描述符,进而通过段描述符得到段基址。段基址与偏移量结合就得到了线性地址,虚拟地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(VA)到物理地址(PA)的转换是通过分页机制对虚拟地址内存空间进行分页来实现的。虚拟地址(VA)由虚拟页号(VPN)和虚拟页偏移量(VPO)两部分组成,其位数取决于计算机系统的特性。由于虚拟内存与物理内存的页大小相同,VPO与物理页偏移量(PPO)是一致的。
物理页号(PPN)需要通过访问页表中的页表条目(PTE)来获取。当PTE的有效位为1时,表示发生了页命中,可以直接获取到PPN,PPN和PPO共同构成了物理地址。然而,如果PTE的有效位为0,意味着对应的虚拟页没有缓存在物理内存中,这时就会发生缺页故障。
在发生缺页故障时,系统会调用操作系统内核的缺页处理程序。该程序会确定要牺牲的页,并将新的页面调入内存。然后返回到原来的进程,再次执行导致缺页的指令。此时会发生页命中,获取到PPN,PPN和PPO一起组成了物理地址。通过这种方式,线性地址最终被转换成物理地址,从而完成内存的访问。
图 43 页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.4.1相关概念介绍:
TLB的概念:
我们注意到,每次在进行虚拟地址翻译的过程中都会有访问PTE的操作,如果在比较极端的情况下,就会存在访存的操作,这样的效率是很低的。TLB的运用,就可以将PTE上的数据缓存在L1中,也就是TLB这样一个专用的部件,他会将不同组中的PTE缓存在不同的位置,提高地址翻译的效率。
多级页表的概念:
一级页表有一个弊端,就是对于每一个程序,内核都会给他分配一个固定大小的页表,这样有一些比较小的程序会用不到开出的页表的一些部分,就造成了空间的浪费,多级页表就很好的解决了这个问题。以二级页表为例,首先我们先开一个比较小的一级页表,我们将完整的页表分组,分别对应到开出来的一节页表的一个PTE中,在执行程序的过程中,如果我们用到了一个特定的页表,那么我们就在一级页表后面动态的开出来,如果没用到就不开,这样就大大的节省了空间。
7.4.2转换过程:
如图44,当CPU生成虚拟地址VA后,该地址被传送给内存管理单元(MMU)。MMU使用VA的前36位虚拟页号(VPN)作为索引,在转换后的转换后备缓冲器(TLB)中进行匹配。TLB是一个高速缓存,用于存储最近访问的虚拟页和物理页的映射关系。如果TLB中存在匹配的条目,MMU可以直接从TLB中获取物理页号(PPN)(40位)和虚拟页偏移量(VPO)(12位),将它们组合成物理地址(PA)(52位)。
如果TLB中没有匹配的条目,MMU将根据页表进行查询。首先,通过CR3确定第一级页表的起始地址。然后,使用VPN的第一级索引(9位)在第一级页表中查找对应的页表目(PTE)。如果PTE表示的页表条目在物理内存中存在且访问权限符合要求,MMU确定第二级页表的起始地址,以此类推。最终,在第四级页表中找到匹配的PTE,得到物理页号(PPN)和虚拟页偏移量(VPO),将它们组合成物理地址(PA)。
在进行页表查询时,如果发现PTE在物理内存中不存在或访问权限不符合要求,将引发缺页异常,操作系统需要介入来处理该异常。同时,如果在页表查询的过程中,找到了正确的PTE并生成了PA,MMU将在TLB中添加一个新的条目,以便将来对相同虚拟页的访问可以直接命中TLB,提高访问效率。
图 44 页表翻译过程
7.5 三级Cache支持下的物理内存访问
首先,使用组索引CI来定位缓存中的组。每个组有8路,每一路对应一个块。接下来,根据CT(前40位)来逐路匹配块,如果匹配成功且块的valid标志位为1,则命中(hit)。此时,根据数据偏移量CO(后6位)可以直接获取所需的数据并返回。
然而,如果匹配失败或者匹配成功但块的标志位为0,则发生不命中(miss)。在这种情况下,需要向下一级缓存进行数据查询(L2缓存->L3缓存->主存)。一旦查询到数据,需要确定数据的放置位置。
一种简单的放置策略是,如果映射到的组内有空闲块,则将数据直接放置在该空闲块中。然而,如果组内的所有块都是有效块,就会发生冲突(evict)。为了解决冲突,采用最近最少使用策略(LRU)进行替换。LRU策略选择最近最少被使用的块进行替换,以便为新的数据腾出空间。
通过这样的组织和替换策略,可以有效地管理缓存中的数据,提高数据的访问效率和命中率。
7.6 hello进程fork时的内存映射
在当前进程"hello"调用fork函数时,内核会为新进程"hello"创建各种数据结构,并分配给它一个唯一的PID。为了为新的"hello"进程创建虚拟内存,内核会创建当前进程的mm_struct(内存描述符)、区域结构和页表的副本。
在创建完副本后,内核将两个进程中的每个页面标记为只读,并将两个进程中的每个区域结构标记为私有的写时复制。这意味着当fork函数在新进程中返回时,新进程的虚拟内存与调用fork时的进程的虚拟内存完全相同。
当这两个进程中的任何一个进程进行写操作时,写时复制机制就会被触发,它会创建新的页面。因此,每个进程都保持了私有地址空间的抽象概念。这样做的好处是,进程之间可以独立地进行写操作,而不会相互干扰,从而确保了进程之间的隔离性和安全性。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页现象的发生是由于页表只充当磁盘的缓存,无法保存磁盘中的所有信息,因此某些信息的查询可能会失败,即发生缺页。当访问虚拟内存的指令遇到缺页现象时,CPU会触发缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果其已经被修改,则先将其存回磁盘中。
一旦确定了要存储的页,内核会从磁盘中将需要访问的内存放入先前已使用的页中,并更新页表项(PTE)中的信息。这样,物理地址就成功地缓存在页表中。当异常处理程序返回时,CPU会重新执行对虚拟内存的访问操作,此时可以正常访问,不会发生缺页现象。
7.9本章小结
本章主要介绍了hello 的存储器地址空间、段式管理、hello 的页式管理,VA 到PA的变换、物理内存访问,hello进程fork、execve时的内存映射、缺页故障与缺页中断处理。
结论
hello程序的一生经历了如下过程:
1.预处理阶段:
在预处理阶段,将hello.c文件中所有外部头文件的内容直接插入程序文本中,进行字符串的替换,以便后续处理。
2.编译阶段:
通过词法分析和语法分析,将合法指令转换为等效的汇编代码。编译器将hello.i翻译为汇编语言文件hello.s。
3.汇编阶段:
将hello.s汇编程序翻译为机器语言指令,并将这些指令打包成可重定位目标程序格式,保存在hello.o目标文件中。
4.链接阶段:
通过链接器,将hello程序的编码与动态链接库等收集整理为单一文件,生成完全链接的可执行目标文件hello。
5.加载和运行:
打开Shell,运行程序,终端会为其fork新的进程,并通过execve将代码和数据加载到虚拟内存空间,程序开始执行。
- 执行指令:
在进程被调度时,CPU为hello进程分配时间片。在每个时间片内,hello进程可以完全占用CPU资源。程序计数器(PC)逐步更新,CPU不断取指令,并按顺序执行控制逻辑流程。
- 访存:
内存管理单元(MMU)将逻辑地址逐步映射为物理地址,并通过三级高速缓存系统访问物理内存/磁盘中的数据。
- 信号处理:
进程时刻等待信号的到来。如果在运行过程中键入ctrl-c或ctrl-z,Shell会调用相应的信号处理函数,执行停止或挂起等操作。对于其他信号,也会有相应的处理方式。
- 终止和回收:
Shell父进程等待并回收hello子进程,内核删除为hello进程创建的所有数据结构。