计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 人工智能
学 号 2022111697
班 级 2203602
学 生 李昀泽
指 导 教 师 吴锐
计算机科学与技术学院
2024年5月
本文探讨了在Linux操作系统环境下,如何通过使用GCC编译器、EDB调试器和OBJDUMP反汇编工具,来追踪一个名为"hello.c"的源代码文件如何被编译和链接成可执行的程序"hello"。文章结合了课堂内外的知识,将计算机系统中的软件和硬件方面的理论知识与实际的程序构建、编译、链接和运行过程相结合,分析了程序"hello"的虚拟内存布局,以及进程的创建和销毁机制。通过研究程序"hello"的生命周期,本文旨在系统地整合计算机体系结构的各个方面,提供一个全面的视角。
关键词:Linux系统;程序的生命周期;编译;汇编;链接;进程;I/O管理
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
P2P(预处理、编译、汇编、链接):
在Ubuntu的终端输入命令gcc -m64 -Og -no-pie -fno-PIC hello.c -o hello。
hello.c将通过如下图示的过程,转化为可执行程序hello。
图1.1
hello执行时将通过调用fork函数为其创建子进程完成预处理、编译、汇编、链接的过程,即From Program to Process。
020(创建进程、执行、内存映射、分配时间片、结束进程):
在以上过程完成后,通过execve函数将程序加载到内存空间中,并进入其主函数。CPU为程序"hello"分配时间片,随后通过内存管理机制和各种存储器的联动,程序"hello"开始执行其控制流并输出内容。当程序执行结束后,shell通过调用waitpid函数来回收程序,完成了整个进程的创建、执行、内存映射、时间片分配以及结束的过程,将进程从创建到执行再到结束的完整生命周期展现了出来。
1.2 环境与工具
12th Gen Intel(R) Core(TM) i5-12500H
Ubuntu 20.04
Windows11 64位
Gcc,edb,objdump,vim
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名称 | 文件作用 |
hello.i | 预处理后的文本文件 |
hello.s | 编译后汇编程序文本文件 |
hello.o | 汇编后的可重定位目标程序 |
hello | 链接后的可执行目标文件 |
hello_o.txt | hello.o的ELF格式文件 |
helloall.txt | hello的ELF格式文件 |
hello.o的反汇编文件 | |
hello_obj.txt | hello的反汇编文件 |
1.4 本章小结
本章简述了hello程序的一生,也就是P2P和020的整个过程,介绍了大作业中的硬软件环境和工具以及生成的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理是在编译之前进行的处理过程,预处理命令是由ANSI C统一规定的[1],而非C语言的一部分。在编译之前,需要对程序中包含的特殊命令进行预处理,因为编译器无法直接识别这些命令。
C语言提供了三种主要的预处理功能:宏定义、文件包含和条件编译。预处理器cpp会根据以字符#开头的命令来修改原始的C程序,读取系统头文件并将其直接插入程序文本中[2]。
预处理的作用包括但不限于以下几点:
1. 将源文件中以“#include”格式包含的文件复制到编译的源文件中。
2. 使用实际值替换由“#define”定义的字符串。
3. 根据“#if”、“#ifdef”等条件来决定需要编译的代码。
4. 删除所有的注释,包括"//" 和 "/* */"。
5. 添加行号以便于编译器进行错误报告和调试。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i生成预处理文件hello.i(一般情况)
本程序要求使用gcc –m64 –no-pie –fno-PIC指令
故由gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i生成hello.i文件
图2.1
生成的预处理文件hello.i如下:
图2.2
2.3 Hello的预处理结果解析
图2.3
hello.i文件的末尾显示了int main()函数的源代码。在该文件之前的部分包含
了头文件stdio.h、unistd.h和stdlib.h所插入的内容。通过预处理过程,我们可以确定预处理文件中已经用实际的宏定义替换了所有的“#define”指令。
图2.4
因此,在hello.i中搜索不到define关键字。
源程序.c经过预处理(cpp)后生成.i文件,由于复制了头文件中的内容,故它扩展了很多内容,仍然是一个可读的文本文件,而所有宏定义都被替换。
2.4 本章小结
本章首先对预处理的概念和作用进行了理论分析,然后展示了对hello.c文件进行预处理后生成的hello.i文件。通过结合之前学习的理论知识,我们可以对生成的.i文件有一个较清晰的解析结果。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是将高级语言源代码(如C、Java等)转换为目标平台可执行的机器代码的过程。这个过程通常分为词法分析、语法分析、语义分析、中间代码生成、代码优化和目标代码生成等阶段。
编译的作用:通过编译器生成高效的目标代码,程序在运行时能够更快速地执行。编译器能够将源代码转换为特定平台的机器码,使得程序能够在不同的硬件平台上运行。编译器在编译过程中能够检测代码中的语法错误、类型错误等问题,帮助程序员提前发现并解决潜在的bug。编译过程中生成的目标代码具有模块化的特性,便于程序的维护和管理。
3.2 在Ubuntu下编译的命令
Ubuntu下编译命令有
gcc –S hello.c –o hello.s 从.c文件到.s文件
gcc –S hello.i –o hello.s 从.i文件到.s文件
终端中输入:gcc -m64 -no-pie -fno-PIC -S hello.i > hello.s
图3.1
编译后产生的.s文件
图3.2
3.3 Hello的编译结果解析
3.3.1 hello.s文件开头部分解析
编译后的hello.s仍然是一个可读文件
文件起始部分为如下:
图3.3
.file 源文件名称 这里是hello.c
.text 代码段
.section .rodata 节区包含只读数据,包含需要打印的字符串
.align 声明对指令或者数据的存放地址进行对齐的方式
.globl main 声明全局变量 main
.type 用来指定是函数类型或是对象类型 本处main @function,意味着main就是函数
3.3.2 hello.s文件C数据解析
本程序中使用到的 C 数据类型包括整数(int)、数组和字符串。
1. 整数:
在 main 函数中的参数 int argc表示传入的参数个数,是一个整数类型。
在 main 函数中定义的整数变量 int i 用作循环变量,在栈中的位置为 -4(%rbp)。
在这个程序中,整数类型被用于存储参数个数和循环计数,提供了对内存中整数数据的存储和访问功能。
图3.4
图3.5
而.s文件中的立即数,会直接在汇编指令中出现。
2. 数组:
指针数组 char *argv[] 用于存储参数值,其中每个元素是一个指向字符型数据的指针,大小为 8 字节(64 位系统下的指针大小)。
argv[0] 指向输入的程序路径及名称(*filename),而后的参数值则从 argv[1] 开始保存。具体地,argv[1] 的地址为 argv 的起始地址 + 8 字节处,argv[2] 为 +16 字节处,argv[3] 为 +24 字节处。
在 hello.s 文件中,通过三次 %rax 加上特定字节数的偏移地址来获取参数值。这里的偏移地址是相对于 argv 的起始地址而言的。
图3.6
3.字符串
程序中的字符串:
图3.7
在源程序中的两处如下:
图3.8
在第一个 printf 函数中,输出的格式化字符串是编码为 UTF-8 格式的字符串,汉字字符通常由三个字节来表示,每个字节用 \ 分隔。在 hello.s 中,这些字符串会在 .LC0 等段下进行存储,并以 UTF-8 编码的形式进行表示。
在第二个 printf 函数中,传入的格式化字符串是 "Hello %s %s %s",后面的三个参数分别是从 main 函数中传入的前三个参数,这些参数是只读的,用于填充 %s 位置的字符串。通常情况下,程序在运行时会将这些参数替换%s位置的占位符,实现相应的输出。
3.3.3 hello.s中的赋值解析
程序中赋值一般使用mov指令实现,根据数据长度的不同,有不一样的后缀
b,w,l,q分别代表1,2,4,8个字节(byte,word,long,四字)。
汇编语言实现如下图:
图3.9
i是4个字节的int类型,即“long”,所以mov后缀是l。
3.3.4 hello.s中的类型转换解析
类型转换有以下几种类型:
1.从int转换为float
这种情况不会发生溢出,但可能有数据被舍入。
2.从int或 float转换为double
因为double的有效位数更多,所以能够保留精确值。
3.从double转换为float和int
由于有效位数变少,所以可能被舍入;也有可能发生溢出。
4.从float 或double转换为int
因为int没有小数部分,所以数据可能会向0方向被截断。
在这个C程序中,虽然没有显式的类型转换,但使用了一个常见的转换函数 atoi(),用来将字符串转换为整数。
具体来说,atoi() 函数用于将传入的字符串参数转换为整数。在这个程序中,通过 `atoi(argv[4])` 将第四个参数(argv[4])所表示的字符串转换为整数,并将结果作为秒数传递给 `sleep` 函数。
atoi() 函数的定义通常在 <stdlib.h> 头文件中,其功能是扫描参数字符串,跳过前导空格,直到遇到数字或正负号开始转换,直到遇到非数字或字符串结尾符号 \0 时结束转换,并将结果作为整数返回。
3.3.5 hello.s中的算术操作解析
本程序中的算术操作有 i++,计数器i的自增操作,汇编指令为
addl $1, -4(%rbp),其中后缀l表示i为4字节的数据。
3.3.6 hello.s中的关系操作解析
从《深入理解计算机系统》上可以找到汇编代码中跳转指令的解析,如下图:
图3.10[2]
本程序只用到了上图中的对jle,jmp和je的条件码判断,set和cmov没有用到。
图3.11
1. jmp:直接跳转
在源程序中,当 for 循环开始时,将 i 赋值为 0 后,然后执行跳转到 .L3的操作。在 .L3 中判断 i 是否小于 10,若 i < 10 则执行循环体中的内容。
2. je:等于时跳转
在源程序中,通过 if(argc!=5) 来判断 argc 是否等于 5。cmpl $5, -20(%rbp) 用于比较 argc 和 5,设置条件码为下一步 `je` 指令准备跳转。
如果 argc 等于 5,则跳转到 .L3。
- jle:小于或等于时跳转
在源程序中的 for(i=0;i<10;i++) 循环中,通过 cmpl $9, -4(%rbp) 判断 i 是否小于或等于 9。然后计算 i - 9 并设置条件码,为下一步 jle 指令使用 (SF^OF)|ZF 来准备跳转。
如果 i 小于或等于 9,则跳转到 .L4。
3.3.7 hello.s中的控制转移操作的解析
此程序中涉及到的控制转移有:for循环和if判断,没有用到用条件传送cmov进行的判断
1.if判断
if(argc!=5)
当argv不等于5的时候执行程序段if中的代码。
对于if判断,编译器使用跳转指令实现,首先cmpl比较argv和5,对于比较操作,是用减法实现的,并设置条件码,使用je判断ZF标志位,如果为0,说明 argv - 5=0即argv==5,则不执行if中的代码直接跳转到.L2顺序执行下一条语句。
图3.12
2.for循环
for(i=0;i<10;i++)
计数变量为i,跳转循环10次。首先将i赋值为0后无条件跳转到.L4的比较代码。用cmpl进行比较,如果i<10,则跳转到.L4循环体执行,否则说明循环结束,顺序执行for循环体之后的代码,即getchar及其之后的部分。
图3.13
3.3.8 hello.s中的函数操作解析
过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。函数是一种过程,善于利用函数,可以减少重复编写程序段的工作量。
程序是按顺序执行的指令流水线 (PipeLine)。分支和循环逻辑,可以通过在流水线中往后跳或往前跳实现。其实,函数调用也不过是在跳转。调用某个函数,就跳转到那个函数的指令流的开始位置,函数执行完成后,再跳转回来。函数能获取外部写入的数据(输入),能持有自己独特的数据(本地状态),还能向外部写数据(输出)[3]。
例如过程P调用过程Q,Q执行后返回到P,这些动作包含下面一个或多个机制[2]:
- 传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
- 传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 中返回一个值。分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
- 分配和释放内存:在开始时Q可能需要为局部变量分配空间,而在返回时,必须释放这些存储空间。
本程序中涉及的函数操作有:
1.main函数(主函数)
main 函数被系统启动函数__libc_start_main调用,call指令将下一条指令的地址压栈然后跳转到main函数,即传递控制。
main函数中的参数为argc和argv,分别使用%rdi和%rsi存储,返回时将%eax 赋为0,也就是return0。
图3.14
最后这个leave指令,相当于将栈恢复为调用main函数之前的状态,然后ret,即从栈中pop出调用main函数之前压入栈中的下一条指令的地址,也就是返回地址。
2.printf函数
第一处printf函数
图3.15
将%edi设置为.LC0的首地址,这个就是("用法: Hello 学号 姓名 手机号秒数!\n")的首地址。然后call puts,而不是call printf。这有点出人意料,不过事实上这是编译器对printf的一种优化。实践证明,对于printf的参数如果是以'\n'结束的纯字符串,printf会被优化为puts函数,而字符串的结尾'\n'符号被消除。除此之外,都会正常生成call printf指令[4]。调用puts函数的好处是puts函数可以立即刷新输出缓冲区。
第二处printf函数
图3.16
将%edi赋为.LC1的首地址,即为字符串"Hello %s %s %s\n"的首地址,由于次字符串不是纯字符串,故call printf。
3.exit函数
图3.17
将%edi设置为立即数1,然后退出。
4.sleep函数
图3.18
将%eax赋给%edi,即将%edi设置为atoi(argv[4])。然后call sleep,进行休眠。
5.atoi函数
参数为argv[4],利用%rdi传参,通过一系列的movq和addq指令,将(%rbp-12)处的数据赋给%edi,然后call atoi,调用此函数。
atoi函数作用见3.3.4节
6.getchar函数
无数据传递,有控制传递call getchar。
3.4 本章小结
本章通过对hello.i文件进行编译,生成hello.s文件,并结合编译知识对这一过程进行了分析。详细探讨了数据处理、赋值操作、类型转换、算术运算、逻辑和位运算、关系操作、以及数组、指针和结构的操作等过程。对hello.s文件中涉及的这些操作进行了透彻的解释。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是将汇编语言转换为机器语言的过程。汇编器(as)将hello.s文件转换为机器语言指令,并将这些指令打包成一种称为可重定位目标程序的格式,最终保存在二进制目标文件hello.o中。hello.o文件是一个二进制文件,若用文本编辑器打开,会看到一堆乱码[2]。
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编指令有
gcc –c hello.c –o hello.o
gcc –c hello.s –o hello.o
as hello.s -o hello.o
在终端中输入:gcc –m64 –no-pie –fno-PIC –c hello.s –o hello.o指令,如下图:
图4.1
生成的.o文件如下
图4.2
4.3 可重定位目标elf格式
在终端中用vim文本编辑器打开此.o文件如下图:
图4.3
显示为一堆乱码,也就是说.o文件是不能用文本显示的。
接下来利用readelf分析此.o文件,下图是典型的ELF可重定位目标文件的格式,以及各个节包含的内容:
图4.4
1.ELF头
利用readelf工具可以查看到此hello.o文件的ELF头内容
图4.5
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。[2]
由于我的Ubuntu是中文版本,所以在此文件中已给出了部分信息的中文(如上图)在此就不赘述了。值得注意的是节头部表数量(Number of section headers),这里是14,下一行是字符串表在节头部表中的索引,这里是13。
2.节头部表分析
利用readelf工具可以看到hello.o文件的可重定位目标文件的Section Herders如下图所示,在下图中我们可以看见各个节的名称,及此节的大小,地址,偏移量等信息。
图4.6
其中重定位项目有:
.rela.text:可重定位代码
.rela.eh_frame
内容如下图所示:
图4.7
可以在.rela.text重定位节中看到此节的偏移量,类型等信息,从类型栏中可以看见.rela.text节是使用的绝对地址(32)寻址,而.rela.eh_frame节使用PC相对地址(PC32)寻址。
ELF重定位条目的格式如下图所示:
图4.8
Offset:需要进行重定向的代码在.text或.data节中的偏移位置。
Symbol标识被修改引用应该指向的符号
Type告知链接器如何修改新的引用
Addend是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整[2]。
重定位算法[2]算法如下图示:
当生成可执行目标文件时,链接器会对这些重定位项目进行引用,重新确定其在可执行目标文件中的地址。
图4.9
3.符号表.symbol
图4.10
符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
图4.11
对照分析:
1. .s汇编代码会有一些意义不明的伪代码,如下图示
图4.12
这些可能是用来标识一些信息的代码。
2.分支转移,.s汇编代码是跳到.LX位置,这个是段名称,这个段名称只是在汇编语言中便于编写的助记符,而hello.o反汇编的文件中则不再出现类似符号,跳转地址则是类似<main+0x__>的相对寻址方式
3.函数调用,在.s 汇编代码中,函数调用之后直接跟着函数名称,如下图s所示:
图4.13
在hello.o的反汇编文件中,call后跟的是地址而不是函数名称。然而,值得注意的是,在相应的机器指令代码中,call后的指令全为0。这是因为hello.o文件还没有与共享库链接,所以无法确定函数的最终地址,因此需要进行重定位。
图4.14
1f: R_X86_64_PLT32这样的信息表示重定位信息在.rela.text节中,等待链接的时候再确定它们的具体信息,并填写进去。
4.反汇编器objdump使用的指令命名规则与GCC生成的.s汇编代码使用的有些细微的差别,hello.o的反汇编代码省略了很多指令结尾的q,这些后缀是大小指示符,而又给ca11和ret指令添加了‘q’后缀。
4.5 本章小结
本章对hello.s文件进行了汇编,生成了hello.o文件,并对hello.o文件的可重定位ELF格式进行了分析,详细解释了节头部表的各个部分含义,并对重定位项目进行了详细的剖析。通过使用反汇编工具objdump对hello.o文件进行了反汇编,与hello.s文件进行了对比,以便了解汇编器在将汇编语言映射到机器语言时所需实现的转换,对结果进行了相应的解释。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各个单独的二进制代码文件加载到同一个文件,并使之可以加载到内存中执行的一个过程。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行[2]。
链接使分离编译成为可能。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
链接指令: 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.1
这段指令会生成hello可执行文件。
5.3 可执行目标文件hello的格式
在终端中输入指令readelf -a hello > helloall.txt生成helloall.txt文件。
按顺序写出ELF格式的可执行目标文件的各类信息,如下图:
图5.2
先看ELF头,如下图所示:
图5.3
相应信息在图中已表示出来。
ELF Header:以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。
然后我们查看节头部表信息,如下图所示:
图5.4
包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
我们将两部分结合起来分析一下,ELF头中信息确定了节头部表的开始和大小,如下图:
图5.5
在可重定位目标文件的elf中,入口点地址和程序头起点是0,当程序被链接后生成的可执行文件中的elf中都被填入了正确的地址,并且也增加了许多节。
和可重定位目标文件相比,可执行文件增加了一个Dynamic section,如下图:
图5.6(a)是可重定位目标文件的elf,(b)是可执行文件中的(注意这里提到了本文件中没有程序头,下文会对可执行文件中的程序头进行分析)。
图5.6(a)
图5.6(b)
可以看到不再有.rela.text和.rela.eh_frame节。
图5.7
符号表中也增加了动态链接解析出来的符号。
图5.8
图中题头解释如下:
Value是字符串在strtab节中的偏移量
Size是目标字节数
Type是类型:数据、函数、源文件、节、未知
Bind代表绑定的属性:全局符号、局部符号、强弱符号
Ndx符号对应目标所在的节,或其他情况,比如UND节
接下来我们分析可执行文件中特有的部分,程序头,如下图:
图5.9
程序头表描述可执行文件中的节与虚拟空间中的存储段之间的映射关系。
图4.4给出了程序头表包含的内容。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
输入指令:edb --run hello
在edb的Data Dump可以看到hello程序的虚拟地址:
图5.10
由图可以看到虚拟空间从0x401000开始。
利用edb工具我们查看Loaded Symbols,如下图:
图5.11
对照图5.9,每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。
5.5 链接的重定位过程分析
终端中输入objdump -d -r hello
内容如下:
图5.12
对比之后可以发现,hello和hello.o反汇编生成的代码完全相同,二者的区别在于:后者地址是相对偏移,前者地址是可由CPU直接访问的虚拟地址,也就是说链接器完成了对hello的重定位。hello中不存在类似R_X86_64_32.rodata这样的利用相对偏移求地址的字段,这说明在链接时,链接器把hello.o中的相对偏移的寻址加上程序在虚拟内存中的起始地址得到了可直接访问的地址。
5.6 hello的执行流程
图5.12(a)
调用的子程序名有:_start(0x4010f0),_init(0x401000),main(0x40112d),puts@plt(0x401090),exit@plt(0x4010d0),_finl(0x4011c0),sleep@plt(0x4010e0),atoi@plt(0x4010c0),printf@plt(0x4010a0)。
5.7 Hello的动态链接分析
在.got节中存放的是变量的全局偏移量,在链接之后由于动态添加了很多目标执行所需要的程序,所以.got节中的内容会发生改变。
打开ELF文件(hello_obj.txt),找到节头表,找到.got节的首地址发现是0x403ff0,执行链接前后如下图所示:
图5.13
图5.14
5.8 本章小结
本部分对hello.o进行了链接,生成了可执行文件hello,并分析了hello的ELF格式。进一步查看了hello的虚拟空间地址的各段信息,并利用objdump工具比较了hello.o与hello之间的区别。通过这些分析,详细说明了链接过程中的重定位过程,以及hello执行的流程中涉及的函数。最后,对hello的动态链接进行了简要分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是操作系统中的一个重要概念,它是正在执行的程序的实例。每个进程都有自己的地址空间、代码、数据和系统资源的副本,它们相互隔离,互不影响。进程的主要作用包括:
1. 资源分配和管理:操作系统为每个进程分配必要的资源,如内存空间、CPU时间片、文件描述符等,以便程序能够正常运行。
2. 并发执行:多个进程可以同时执行,操作系统通过进程调度算法来控制进程的执行顺序,实现并发和多任务处理。
3. 进程间通信:进程之间可以通过各种通信机制(如管道、消息队列、共享内存等)进行数据交换和信息共享,实现协作和同步。
4. 程序隔离:每个进程都有自己独立的地址空间,可以防止程序之间的相互干扰和意外修改,提高系统的稳定性和安全性。
5. 提供服务:进程可以作为服务提供者,响应其他进程或用户的请求,执行特定的任务,如网络服务、文件服务等。
6.2 简述壳Shell-bash的作用与处理流程
Shell(如bash)是操作系统提供的一个命令解释器,它允许用户与操作系统进行交互,并执行各种命令和程序。Shell的作用包括:
1. 命令解释和执行:用户可以在Shell中输入各种命令和程序,Shell会解释并执行这些命令,从而实现对系统资源和功能的控制和管理。
2. 环境配置:用户可以通过Shell设置环境变量、别名、函数等,以定制自己的工作环境,提高工作效率。
3. 脚本编写和执行:用户可以编写Shell脚本,将一系列命令和程序组合在一起,实现复杂的任务自动化和批处理。
4. I/O重定向和管道:Shell支持将命令的输入和输出重定向到文件或其他命令,以及通过管道连接多个命令,实现数据流的处理和传递。
5. 作业控制:用户可以在Shell中启动、暂停、恢复和终止后台运行的作业,管理当前Shell会话中的任务。
Shell的处理流程为:
- 提示符显示:Shell等待用户输入命令,通常显示一个提示符,表示可以接受用户输入。
2. 命令解析:用户输入的命令被Shell解析成可执行的格式,包括命令名称、参数和选项等。
3. 命令执行:Shell根据解析后的命令执行相应的操作,可能是执行系统内置命令、调用外部程序,或者执行Shell脚本等。
4. 输出显示:执行结果被输出到标准输出设备(通常是终端),用户可以看到执行结果或者错误信息。
5. 循环等待:Shell再次等待用户输入,继续处理下一条命令,直到用户退出或者关闭Shell。
6.3 Hello的fork进程创建过程
在终端中输入命令,shell会先解析是否为内置命令,若是则执行内置命令,否则将会此命令解析为可执行文件,在当前目录下寻找此文件,./hello被shell解析为可执行程序。
执行hello如下图所示:
图6.1
之后shell会调用fork函数创建一个新的子进程,此子进程和父进程即shell进程拥有完全相同的地址空间包括只读代码段、读写数据段、堆及用户栈等。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同但是独立的一份副本,这就意味着,当父进程调用fork时,子进程可以读写父进程中打开的任何文件[2]。父子进程最大的区别就是他们拥有不同的PID。
6.4 Hello的execve过程
调用execve()函数在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间。
书上[2]给出了如下例子,一个新程序开始时,用户栈的典型组织结构,此hello程序的栈帧与此类似。
图6.2
6.5 Hello的进程执行
上下文切换的流程:1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。
hello的进程执行:刚开始运行时内核为其保存一个上下文,进程在用户状态下运行。循环结束后,hello调用getchar函数,进入内核模式,当前时间片用尽,执行上下文切换,把控制转移给其他进程。完成键盘输入后,内核从其他进程切换回hello进程,最终hello执行return终止进程,hello程序的时间片完全结束。
过程与下图类似:
图6.3
6.6 hello的异常与信号处理
会出现的异常有:故障和终止;信号有:SIGINT,SIGSTP。
键入CTRL-Z后shell父进程会受到SIGSTP信号,调用信号处理子程序将hello进程挂起,即停止hello进程;CTRL-C会调用信号处理子程序结束hello进程
1.ctrl-c,进程结束。
图6.4
2.运行时按回车,空格和乱按键盘,可以看到不会打断程序运行
图6.5
3.在挂起下运行ps等指令
图6.6
6.7本章小结
本章详细探讨了在Shell中运行hello程序的过程,特别是着重分析了进程管理。通过讲解进程的概念和Shell的工作流程,解释了Shell如何利用fork()函数创建子进程,并使用execve()函数加载并执行hello程序。另外,还介绍了时间片的概念以及内核的进程调度过程,强调了在进程执行过程中用户态和内核态的切换。最后,对hello执行过程中可能遇到的异常和信号进行了分析。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址(physical address):用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
虚拟地址(virtual memory):虚拟地址即线性地址,虚拟地址是对物理地址的映射。
逻辑地址(logical address):Intel为了兼容,将远古时代的段式内存管理方式保留了下来。逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。
线性地址(linear address):线性地址或也叫虚拟地址,逻辑地址经过段机制后转化为线性地址,是一个不真实的地址,线性地址对应硬件页式内存的转换前地址[6]。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是一种内存管理方式,它将程序分割成若干段并存储,每个段是一个逻辑实体。这种管理方式与程序的模块化直接相关,因为它需要程序员了解和使用这些段。段式管理通过段表实现,其中包括段号或段名、段起点、装入位、段长度等信息。此外,还需要主存占用区域表和主存可用区域表。
系统在进行段式管理时需要为每个程序设置段映射表,同时为整个主存系统建立一个实际主存管理表,包括占用区域表和可用区域表。占用区域表指示哪些区域已被占用,由哪个程序的哪个段占用以及该段在主存中的位置和长度。可用区域表则指示未被占用的基地址和区域大小。
当一个段从辅存加载到主存时,操作系统会更新占用区域表并修改可用区域表。当段从主存中移出时,对应的项会从占用区域表移入可用区域表,并进行必要的处理,如判断是否与其他可用区域合并。当程序执行结束或者被更高优先级的程序取代时,相应的段项也会从占用区域表移入可用区域表,并进行处理[8]。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存管理方式,它将每个进程的虚拟空间划分为大小相等的页(page),然后将内存空间按页的大小划分为页框(page frame)。通过建立页表,页式管理实现了虚拟地址到内存地址的映射,并利用硬件地址变换机构来解决地址变换问题。
该管理方式支持请求调页或预调页技术,统一管理内外存储器,有效提升了内存的利用效率。
图7.1页表示意图
任何页表都被分为如下三种状态之一:
未分配的:系统还未分配(或创建)的页。未分配的页没有任何数据与他们相关联,因此也就不占用任何磁盘空间。
缓存的:当前已缓存在物理内存中的已分配页。
未缓存的:未缓存在物理内存中的已分配页。
现代CPU一般采用多级(4级)页表的方式,如下图所示:
图7.2 多级页表示意图
7.4 TLB与四级页表支持下的VA到PA的变换
Intel Core i7采用如下图示的地址翻译方案:
图7.3
VA48位,其中0-11位是VPO,12-47位是VPN,被分为4个9位,分别对应于每一级页表的偏移,最后在四级页表中找到PPN,VPO = PPO,所以最后的PA = PPN + PPO(VPO),得到52位的PA。
7.5 三级Cache支持下的物理内存访问
Cache被组织和划分为了如下图示的组织结构。
高速缓存通常被组织成一系列缓存组的数组,每个组中包含一个或多个缓存行。每个缓存行由一个有效位、一些标记位和一个数据块组成。高速缓存的结构将一个地址划分为几个部分:标记位(t bits)、组索引位(s bits)、块偏移位(b bits)。这样,对于总共 m 位的地址,就有 t+s+b=m。
图7.4
Intel Core i7 CPU采用三级缓存结构。虚拟地址经过MMU转换为物理地址。
其中,物理地址有52位,其中0~5位是块偏移量(CO),6~11位是组索引(CI),12~51位是标记(CT)。首先使用组索引 CI 找到对应的组,然后在组中找到标记与 CT 匹配的行。如果该行存在且有效位为1,则缓存命中,将块偏移量为 CO 的字节传递给 CPU。如果缓存未命中,则继续在 L2、L3 和主存中寻找。
7.6 hello进程fork时的内存映射
Hello进程fork时的内存映射与下图类似:
图7.5
当 fork() 函数被调用时,内核会为新进程创建各种数据结构,并分配一个唯一的PID。新进程是父进程的副本,它们的虚拟内存映射到同一块物理内存,但采用写时复制技术。这意味着,当新进程的某个虚拟页被修改时(与父进程的虚拟页不同),才会将新进程的虚拟页映射到另一个物理页上。
7.7 hello进程execve时的内存映射
加载器映射用户地址空间区域如下图示
图7.6
加载并运行hello程序需要以下几个步骤:
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中已存在的区域结构。
- 映射私有区域
- 映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
图7.7缺页操作图
CPU硬件执行步骤:
1.处理器生成了一个虚拟地址,并把它传送给MMU
2.MMU生成PTE地址,并从高速缓存器/主存请求得到它
3.高速缓存/主存向MMU返回PTE
4.PTE有效位为零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序
5.缺页处理程序确定出物理内存中牺牲页,若页面被修改,则把它换出到磁盘
6.缺页处理程序调入新的页面,并更新内存中的PTE
7.缺页处理程序返回到原来进程,再次执行缺页的指令
7.9动态存储分配管理
程序中的 `printf` 调用了 `malloc` 函数,这是一个显式分配器,用于从堆中分配内存块。
动态存储分配管理由动态内存分配器完成。它维护着一个进程的虚拟内存区域,称为堆,这是一个请求二进制零的区域,紧接在未初始化的数据区后开始,并向上生长。堆被视为一组不同大小的块的集合来维护。每个块都是连续的虚拟内存片,要么已分配,要么空闲。已分配的块保留供应用程序使用,而空闲块可用于分配。
动态内存分配器在堆中分配空间时将对应的块标记为已分配,在回收时将其标记为未分配。分配和回收过程涉及分割、合并等操作。动态内存分配器的目标是在对齐块的基础上提高吞吐率和空间利用率,以减少因内存分配而导致的碎片。
常见的实现数据结构有隐式空闲链表、显式空闲链表、分离空闲链表,而放置策略则有首次适配、下一次适配和最佳适配[2]。
7.10本章小结
本章涵盖了内存管理的两种方式:段式管理和页式管理,并通过分析"hello"进程的存储空间来说明这些概念。我们还探讨了虚拟地址(VA)到物理地址(PA)的转换过程,以及在进行进程复制(fork)或执行新程序(execve)时如何进行内存映射。此外,我们还详细介绍了缺页故障的情况以及操作系统如何处理这些缺页故障。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
普通文件(包含任意数据的文件)、目录(文件夹,包含一组链接的文件,每个链接都将一个文件名映射到一个文件)、套接字(用来与另一个进程进行跨网络通信的文件)、命名通道、符号链接以及字符和块设备[2]。
设备管理:unix io接口
打开关闭文件,读写文件,改变文件的位置
8.2 简述Unix IO接口及其函数
Unix IO接口统一执行方式:
- 打开文件
- Linux Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前文件位置
- 读写文件
- 关闭文件
1. open(char* filename, int flags, mode_t mode):
open函数将文件名 filename 转换为一个文件描述符,并返回该描述符。返回的描述符是进程中当前尚未打开的最小描述符号。
flags 参数指示进程打算如何访问该文件。
mode参数指定新文件的访问权限位。
2. close(int fd):
close 函数用于关闭文件描述符 fd,返回操作结果。
3. ssize_t read(int fd, void *buf, size_t n):
read 函数从描述符为 `fd` 的当前文件位置读取最多n个字节到内存位置 buf。
返回值为 -1 表示出现错误,返回值为 0 表示已经到达文件末尾(EOF),否则返回的值表示实际传输的字节数。
4. ssize_t write(int fd, const void *buf, size_t n):
write 函数从内存位置 buf 复制最多n个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
对于函数printf的实现过程,通常会调用vsprintf和write函数。printf函数接受一个格式字符串,然后按照格式字符串的格式来输出相应的参数内容。而vsprintf的作用是进行格式化操作,它接受一个确定输出格式的格式字符串fmt,然后根据这个格式字符串对不定数量的参数进行格式化,生成格式化的输出结果。接着,vsprintf函数生成的显示信息会经过write系统函数,并触发陷阱-系统调用(比如int 0x80或syscall)来执行相应的系统调用。
在字符显示驱动的子程序中,整个显示过程涉及到多个层次:从ASCII字符转换到字模库,再到显示内存(vram,其中存储了每个点的RGB颜色信息)。显示芯片按照设定的刷新频率逐行读取显示内存,并通过信号线向液晶显示器传输每个像素点的信息,最终在屏幕上实现字符的显示。整个过程涵盖了字符显示的各个方面,包括格式化、存储以及物理设备的交互过程[7]。
8.4 getchar的实现分析
当程序调用getchar()函数时,它会等待用户输入字符。这些字符被存储在键盘缓冲区中,直到用户按下回车键为止(回车键也会存储在缓冲区中)。
一旦用户按下回车键,getchar()函数开始从输入流中每次读取一个字符。getchar()函数的返回值是用户输入的第一个字符的ASCII码,如果出错则返回EOF。如果用户在按下回车键之前输入了多个字符,这些额外的字符会保留在键盘缓冲区中,等待后续的getchar()调用读取。换句话说,后续的getchar()调用不会等待用户输入字符,而是直接读取缓冲区中的字符,直到缓冲区中的字符被读取完毕后才会再次等待用户输入。
getchar()函数通过调用read()函数来返回字符。read()函数通过系统调用中断来调用操作系统内核中的系统函数。键盘中断处理程序会接收按键的扫描码,并将其转换为ASCII码后保存在缓冲区中。然后read()函数调用的系统函数可以读取缓冲区中的ASCII码,直到接收到回车键后返回结果。
8.5本章小结
本章描述了Unix IO及其函数的详细分析,特别是针对标准的printf和getchar函数进行了探讨。在C标准I/O函数中,通常会调用Unix I/O函数,但其安全性更高。I/O函数通常通过中断指令来调用系统函数,然后由系统函数再调用相应的中断处理程序,以完成相应的I/O操作。这种层层调用的机制保证了IO操作的安全性和可靠性。
(第8章1分)
结论
Hello的一生:
1. 编写代码:使用编辑器等工具,将Hello程序的源代码编写并保存为.c文件。
2. 预处理:.c文件通过预处理器cpp进行处理,包括调用库、宏替换等操作,并将结果合并为hello.i文本文件。
3. 编译:hello.i文件经过编译器ccl编译生成可读的汇编文件hello.s。
4. 汇编:hello.s文件通过汇编器as生成可重定位目标文件hello.o,这个文件无法直接打开。
5. 链接:.o文件经由链接器ld与外部库链接,形成可执行文件hello。
6. 运行程序:在Shell中输入命令运行Hello程序。
7. 创建子进程:通过调用fork函数,Shell为Hello创建子进程,使Hello程序具有生命。
8. 进程入群:Hello进程成为操作系统下的进程,获得唯一标识PID,execve函数加载进程内存空间,这空间是父进程的复制,但Hello有自己的私有空间。
9. 执行指令:CPU分配时间片给Hello进程,在时间片内享有CPU资源执行逻辑控制流,但不会持续占用CPU,可能被挂起,体现进程在社会中的交互。
10. 访问内存:MMU将虚拟地址翻译为物理地址,malloc函数动态申请内存。
11. 处理信号:Hello在运行时接收来自键盘的信号,如CTRL-C,CTRL-Z等,可导致进程休眠或终止。
12. 终止进程:Hello子进程接收终止信号后死亡,若父进程未调用waitpid回收,子进程将变为僵尸进程。
13. 进程回收:Shell父进程调用waitpid回收子进程,内核删除为Hello进程创建的所有数据结构,正式结束Hello程序的生命周期。
感想:通过对 "Hello" 程序从编写到运行的整个生命周期进行了详细的描述,我更好地理解程序的运行方式和背后的原理。编写、预处理、编译、汇编、链接、运行等环节,让我意识到一个简单的程序背后所涉及的复杂性和隐含的细节。在程序的运行过程中,涉及到许多重要的概念和技术,如进程、内存管理、信号处理等,这些知识对于深入理解计算机系统至关重要。通过学习计算机系统,我可以更好地理解程序的运行方式、调试错误以及优化性能,并且编写更高效、可靠的程序。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.i 预处理后的文本文件
hello.s 编译后汇编程序文本文件
hello.o 汇编后的可重定位目标程序
hello 链接后的可执行目标文件
hello_o.txt Hello.o的ELF格式文件
helloall.txt Hello的ELF格式文件
hello_o_obj.txt hello.o的反汇编文件
hello_obj.txt hello的反汇编文件
大作业自媒体发表网址:
https://blog.csdn.net/m0_51407177/article/details/118292656?spm=1001.2014.3001.5501
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] https://wenku.baidu.com/view/61feeec60708763231126edb6f1af00bed57000.html
[2] Randal E. Bryant, David R. O'Hallaron.深入理解计算机系统(原书第3版) [M]. 龚奕利,雷迎春译北京:机械工业出版社,2016
[3] 函数调用详解(函数状态保存参数传递和返回值)_WHOAMIAnony的博客-CSDN博客
[4] printf背后的故事 - Florian - 博客园 (cnblogs.com)
[5] https://blog.csdn.net/shenhuxi_yu/article/details/71437167
[6] https://blog.csdn.net/zsl091125/article/details/52556766
[7] https://www.cnblogs.com/pianist/p/3315801.html
[8] 段式存储管理_百度百科 (baidu.com)
(参考文献0分,缺失 -1分)