题 目 程序人生-Hello’s P2P
专 业 数据科学与大数据技术
学 号 2021111789
班 级 2103501
学 生 李万华
指 导 教 师 史先俊
目 录
摘 要
一个程序的真正的工作流程绝对不仅仅是我们按下编译运行之后就结束了,现实是恰恰相反,属于这个程序的一生才真正开始;当我们看到终端显示中“请按任意键继续”这种字样就满足地离去,却殊不知这个程序已经在这短短的几毫秒中度过了自己的一生。程序员的浪漫不是写出多么天花乱坠的代码,而是发自内心地了解这些与我们朝夕相处的程序,它们的一生究竟都经历些什么。P2P——From Program to Process,这个早就听过不知道多少遍的术语,又究竟蕴藏着什么样的内涵。
本文将从最简单的程序——hello谈起,讲述一个程序从我们按下运行键开始直至运行结束,进程被回收时所经历的所有过程。按照《深入理解计算机系统》这本书的研究顺序,结合Codeblocks、edb,甚至是最基本却又最有深度在其中的Shell,研究明白从hello登场到谢幕,都经历了什么。
预处理、编译、汇编、链接,这是hello作为Program经历的过程。看似简单的四个名词让计算机读懂hello,并能够让hello有机会在计算机上形成自己的进程。从汇编代码的阅读,再到反汇编代码的解读,我们尽全力在了解hello这一生的每一步都做了些什么,究竟计算机在作何尝试让hello绽放其生命的色彩。
从进程管理,到存储管理,hello此刻作为进程,在其短暂的一生中也会在计算机里面留下自己的痕迹,我们要尝试着去查找这个痕迹,这样才不会如hello所说,它了无牵挂离去却没有人记得它。
I/O则切实可行给了我们用户和hello交互的机会,这一刻我们切实体会到了hello的存在。
对于hello的总览,让我们第一次懂得了程序员的浪漫,也让我们更加清晰地知道了每日与我们朝夕相处的程序的运行流程,也许这就是hello带给我们的最宝贵的财富。
关键词:hello程序;预处理;编译;汇编;链接;异常处理;内存 ;I/O
第1章 概述
1.1 Hello简介
Hello程序一开始是作为由程序员写的高级语言hello.c诞生的。此时它可以叫做源文件,源程序。接下来,hello.c将通过cpp预处理器,被转换为hello.i文件;然后再经过编译器ccl,转换为hello.s;然后在汇编器as的作用下转变为hello.o;最后,通过链接器ld进行多个.o文件的链接过程,最终形成hello可执行程序。在执行此程序时,操作系统会为其进行fork操作,产生子进程,再调用execve函数加载进程。
操作系统会在调用execve后映射虚拟内存,删除当前的数据结构,为hello创建新的区域结构。在通过程序入口载入物理内存之后,进入main函数开始执行整个hello代码。结束后,由父进程会回收hello子进程,并由内核删除相关的数据结构。
1.2 环境与工具
硬件环境:AMD Ryzen 5 Model 4600H
软件环境:Win10
虚拟机VMware Workstation Pro12.0
开发与调试工具:visual studio, edb, gcc, gdb, readelf, HexEdit, ld
1.3 中间结果
Hello.c
hello.i(预处理生成的文本文件)
hello.s(编译后的汇编语言文件)
hello.o(可重定位目标文件)
hello(链接后的可执行目标文件)
1.4 本章小结
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理又叫预编译,是完整编译过程的第一个步骤。预处理就是预处理器(cpp)对hello.c文件进行文本替换处理的工作。
将源代码中的预处理指令进行预先处理,清理和标记工作,然后将结果代码输出到hello.i文件中。
作用:预处理包含以下操作
(1)文件包含:#include是其中最常用的预处理指令,有例如#include等指令,就会在目录下查找,将stdio.h文件中的全部内容拿出来替换该命令行。
(2)添加行号和文件名标志:比如在生成的hello.i文件中就有以下图片的内容:
这样有利于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
(3)宏定义的展开和处理:在函数中用的比如#define N 100,这样的叫做宏定义常量会被进行替换,将文本中的宏常量N都替换为100.除此之外还会将一些内置的宏展开。
(4)条件编译处理:比如#else,#ifdef等条件编译指令引入,将不必要的代码过滤,防止文件重复等。
(5)清理注释:在预处理阶段会将“//”,“/* */”这些符号中的注释内容清除掉。在hello.i中就已经找不到原来hello.c中的注释内容了。
(6)特殊控制处理:这部分会保留#pragma编译指令和输出错误信息的#error指令。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
打开经过预处理后的hello.i文件,发现文件有3060行,远远多余之前的程序,仔细观察发现#后include的头文件已经被插入其中,所有注释都已经删除。在hello.i
文件开始的几行内也记录了被插入的头文件的位置。
2.4 本章小结
本章对预处理的概念和作用进行了说明,并对hello.c进行了预处理操作以验证。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译是将高级程序语言(源代码)转换为低级机器语言(目标代码)的过程。它是由编译器完成的,编译器是一种软件工具,负责将源代码翻译为可执行的机器码。编译的作用主要有以下几个方面:
1.提高执行效率:编译器将源代码转换为机器码后,可以直接在计算机上执行,无需解释器的参与,因此执行速度更快。
2.平台无关性:编译后的目标代码可以在不同的平台上执行,而无需重新编写代码。这是因为编译器将源代码翻译为特定平台的机器码,使得程序具有跨平台的能力。
3.代码优化:编译器可以进行各种优化技术,以改善程序的性能和资源利用率。例如,优化循环、减少内存访问次数等。
4.错误检查:编译器可以检测源代码中的语法错误、类型错误等问题,并提供错误提示和警告信息。这有助于开发人员在编译时发现和修复问题,提高代码的质量和可靠性。
3.2 在Ubuntu下编译的命令
gcc -m64 -Og -S -no-pie -fno-PIC hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1 开头伪指令分析
.file "hello.c" (源文件)
.text (代码段)
.section .rodata.str1.8,"aMS",@progbits,1 (.rodata节)
.align 8 (对齐方式)
.LC0:
.string "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201" (字符串)
.section .rodata.str1.1,"aMS",@progbits,1 (.rodata节)
.LC1:
.string "Hello %s %s\n" (字符串)
.text (代码段)
.globl main (全局变量名)
.type main, @function (指定对象类型或函数)
3.3.2 数据
C语言的数据有:变量、常量、表达式、类型、宏
(1)常量 大多数以立即数的形式出现在汇编代码中
1:中的“1”,在hello.s中形式为
(2)变量 类型有全局变量、局部变量、静态变量
已初始化的全局变量和静态变量存放在.data节,而未初始化的全局变量存放于.bss节,局部变量则在栈中。
在hello.c文件中并无全局变量和静态变量,所以在我们hello.s中开头的伪指令里并没有.data和.bss,而是有只读数据节(.rodata),里面会存放输出的格式串
在hello.s中的代码为
而.LC0中存放的就是输出的字符串的编码形式:
3.3.3 赋值
赋值操作通过mov指令实现,mov指令有多个形式,用不同方式传送不同大小的数据
1:int类型赋值操作在hello.s中为
3.3.4 算术操作
算术操作有+ - * / % ++ -- 取正/负+- 复合“+=”等,在汇编语言指令如下图
本例中 i加1操作为
3.3.5 类型转换
类型转换分为显示与隐式类型转换
1代码int i = 1.1,1.1赋给整型i,小数部分会向0舍入,即i的值为1,这是一个隐式类型转换。
2代码 float i = (int)2.5*2.5,结果为5,因为(int)2.5会将2.5转换成int类型的数字,即为2,这是一个显式类型转换。
3.3.6 关系操作
关系操作指令表为
在hello.s程序中,存在两个关系操作指令,分别是CMP和TEST。这两条指令的作用是设置条件码,而不对其他寄存器进行任何修改。CMP指令通过比较两个操作数的差异来设置条件码。而TEST指令的行为类似于AND指令,但不会改变目标寄存器的值,只会设置条件码。
在hello.s中使用了cmp指令来判断argc!=4和i<8,分别为和
3.3.7 数组操作
在hello.c中的main函数有个参数为char *argv[],这是一个指针数组,每个数组元素是一个指向参数字符串的指针。在hello.c中,有对数组元素的使用:
其汇编代码为:
其中%rsi和%rdx分别向printf传递参数argv[1]和argv[2],%rdi则向sleep传送参数argv[3]。以下是argv函数的存放方式举例。
3.3.8 控制转移
控制转移常用jump指令,jump指令根据条件不同也有不同的跳转方式,以下是跳转规则:
在hello.c中,有if语句的条件控制在汇编语言中如下
argc 是 main 函数的第一个参数,因此存储在 %edi 中。程序会使用 cmpl 指令来比较 argc 和数字 4 的大小,并设置条件码。接下来,程序会使用 jne 指令根在hello.c程序中,存在一个条件判断语句用来比较argc和4是否相等,并根据比较结果来决定是否跳转到标签.L6处执行特定的代码块。如果argc不等于4,则程序将跳转到.L6标签处执行特定的代码。如果argc等于4,则程序将继续执行下一条指令,继续循环控制结构中的逻辑。
下为本程序的循环的具体情况:
在汇编语言中为
首先,程序会将变量i初始化为0。接下来,程序会跳转到循环条件处进行判断,检查是否满足循环条件。如果循环条件为真,则程序将跳转到循环体中执行相应的代码,并执行循环表达式来更新变量i的值。程序将重复执行这个过程,直到循环条件不再满足为止。这样,程序通过循环控制结构实现了重复执行特定代码块的目的。
3.3.9 函数操作
两个函数A、B,A调用B,函数调用包括以下机制:
传递控制:当进入函数B时,程序计数器需要被设置为函数B代码的起始地址,然后在返回时,程序计数器需要被设置为调用函数B之后的指令地址。这个过程通常通过使用call和ret指令来实现。
传递数据:函数A需要向函数B传递一个或多个参数,而函数B需要向函数A返回一个值。函数A可以通过将参数存放在%rdi、%rsi、%rdx、%rcx、%r8、%r9这些寄存器中来向函数B传递最多6个参数。当参数数量超过6个时,需要使用栈来传递额外的参数。
分配和释放内存:在函数B开始执行时,可能需要为局部变量分配内存空间。而在函数B执行完毕并准备返回之前,需要释放这些局部变量所占用的内存空间。这个过程确保了函数的局部变量在适当的时候被分配和释放,以避免内存泄漏和冲突。
这些机制共同协作,实现了函数之间的调用和数据传递,并且确保了函数执行期间所需的内存空间的正确分配和释放。
在hello.s中的函数调用有如下示例(图片为汇编语言):
1.printf("用法: Hello 学号 姓名 秒数!\n"):将唯一的参数.LC0传递到%edi中,然后调用puts函数。
2.exit(1):将唯一的参数1传递到%edi中,然后调用exit函数。
3.printf("Hello %s %s\n",argv[1],argv[2]):传递了4个参数:
第一个参数是存储在%edi寄存器中的.LC1,它是一个格式串,在.rodata段中。
第二个参数是存储在%eax寄存器中的0。
第三个参数是通过计算8(%rbp)获得的,即argv[1]。
第四个参数是通过计算16(%rbp)获得的,即argv[2]。
4.atoi(argv[3]):将唯一参数24(%rbx),即argv[3],传入%rdi中,然后调用atoi函数。
在函数调用过程中,使用call指令将返回地址压入栈中,并将程序计数器(PC)设置为被调用函数的起始地址。而在函数结束时,使用ret指令从栈中弹出返回地址,并将PC设置为返回地址,以便程序继续执行调用函数后的指令。如果函数需要返回一个值,可以将该值存储在%rax寄存器中,供调用函数使用。
3.4 本章小结
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编是一种低级程序设计语言,它使用助记符(mnemonics)来表示计算机指令,以便与机器语言相对应。汇编语言与特定的计算机体系结构密切相关,每种体系结构都有自己的一组指令集和语法规则。
汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存到目标文件hello.o中。
汇编语言的作用主要有以下几个方面:
(1)直接控制硬件:汇编语言允许程序员直接控制计算机硬件,包括处理器、寄存器、内存等。通过编写汇编代码,可以实现底层的操作和功能,例如操作系统内核、设备驱动程序等。
(2)提高执行效率:由于汇编语言直接映射到机器语言,它可以达到最高的执行效率。程序员可以精确地控制指令的执行顺序和内存访问方式,以最大程度地提高程序的性能。
(3)与高级语言交互:汇编语言可以与高级语言进行混合编程。通过在高级语言中嵌入汇编代码,可以利用汇编语言的底层能力和性能优势来优化关键部分的代码。
在此例中,生成可重定位目标文件。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
readelf -a hello.o > helloo.elf
- ELF头:描述文件的总体格式。
(1) .text节:编译程序后生成的机器代码部分。
(2) .rodata节:只读数据部分,包括printf语句中的格式字符串和开关语句的跳转表。
(3) .data节:已初始化的全局和静态C变量存储部分。局部C变量在运行时保存在栈中,不包含在.data节或.bss节中。
(4) .bss节:未初始化的全局和静态C变量存储部分。
(5) .symtab节:符号表,记录程序中定义和引用的函数和全局变量的信息。
(6) .rel.text节:.text节中需要在链接器将该目标文件与其他文件组合时进行修改的位置列表。
(7) .rel.data节:引用或定义的模块中所有全局变量的重定位信息。
(8) .debug节:调试符号表,包含程序中定义的全局变量和类型定义、引用的全局变量以及原始的C源文件。
(9) .line节:原始C源代码的行号与.text节中机器指令的对应关系。
(10) .strtab节:字符串表,包含.symtab和.debug节中的符号表以及节头部中的节名称。
(11) 节头部表:描述目标文件中各个节的信息。
4.31ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,节头部表中条目的大小和数量。
用vim 记事本打开
4.32可重定位相关
1)rel.text节与rel.data节
在目标文件进行链接时,链接器将相同类型的节合并为新的聚合节。在这个过程中,链接器会修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址。这一步的执行依赖于可重定位目标模块中称为重定位条目的数据结构。代码的重定位条目存放在.rel.text节中,已初始化数据的重定位条目存放在.rel.data节中。
.rel.text节
当前包含九个条目,其中包括puts、exit、sleep等需要进行重定位的函数,这些函数的运行时地址能够确定。
每个可重定位目标模块m都有一个符号表,用于存储m所定义和引用的符号信息。在链接器的上下文中,存在三种不同类型的符号:
全局符号:由模块m定义并可以被其他模块引用的全局链接器符号。这些符号对应于非静态的C函数和全局变量。
外部符号:由其他模块定义并被模块m引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态的C函数和全局变量。
局部符号:只由模块m定义和引用的局部符号。这些符号对应于具有静态属性的C函数和全局变量。这些符号在模块m中可见,但不能被其他模块引用。
符号表(.symtab)中不包含与本地非静态程序变量相对应的任何符号,这些变量在运行时在栈中进行管理。
.symtab中的符号表不包含对应于本地非静态程序变量的任何符号,它们在运行时在栈中被管理。
符号表
4.4Hello,o的结果解析
汇编代码
机器语言是由二进制数字组成的指令集,与汇编语言存在映射关系。然而,机器语言中的操作数与汇编语言不一致,特别是在分支、转移和函数调用等方面。
与hello.s文件相比较有以下不同:
1.反汇编代码中的指令前面有与之对应的指令。一般而言,指令包含操作数、目的寄存器、源寄存器、立即数等部分,这些部分与汇编语句的类型、源操作数、目的操作数和立即数相对应。
2.反汇编代码使用十六进制表示数字,而hello.s文件使用十进制表示。两者之间进行对比如图所示。
反汇编代码
Hello.s文件
反汇编代码中,在调用函数后,会紧跟着下一条指令的地址(因为该地址尚未经过重定位以确定最终位置),而在hello.s文件中,call后面并不是地址,而是函数的名称。
此外,在反汇编代码中,字符串常量以立即数0表示(因为字符串常量尚未经过重定位以确定最终位置,它们保存在.rodata节中),而在hello.s文件中,这些字符串常量以.LC1等标识符表示。
4.5 本章小结
本节主要介绍了汇编的概念与作用,可重定位目标elf格式与其各节详细信息,还介绍了反汇编代码与hello.s文件的区别.
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将多个目标文件(编译后的对象文件)合并为一个可执行文件或库文件的过程。链接器是负责执行链接的工具。
链接的主要作用有:
(1)符号解析:链接器通过符号解析将模块之间的符号引用与符号定义关联起来。它会解析函数调用和全局变量的引用,将其与定义进行匹配.
(2)符号重定位:链接器根据符号解析的结果,将目标文件中的相对地址转换为最终的绝对地址。这样可以确保程序在运行时能够正确地访问和调用函数以及使用全局变量。
(3)节合并:链接器将多个目标文件中相同类型的节(如代码节、数据节)合并成一个单独的节。这样可以消除重复的节并减小最终可执行文件的大小。
(4)库链接:链接器能够将程序所依赖的库文件与目标文件进行链接,将库中的函数和代码合并到最终的可执行文件中。这样可以在程序中使用库中提供的功能。
(5)生成可执行文件或库文件:链接器最终生成一个可执行文件,该文件包含了所有链接后的目标文件和库文件,可以直接执行。另外,链接器也可以生成库文件,供其他程序进行链接使用。
通过链接的过程,将多个目标文件整合成一个可执行文件或库文件,使得程序能够顺利运行并调用所需的函数和资源。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
ELF头:
ELF文件的头部是一种用于描述文件总体格式的数据结构,类似于hello.o的ELF头。该头部包含了与文件相关的重要信息,如文件类型、架构、段表和节表等。
其中,一个关键字段是程序的入口点,它指定了程序在运行时要执行的第一条指令的地址。当操作系统加载并启动程序时,会将控制转移到该地址,从而开始执行程序的代码逻辑。
ELF头部的存在使得操作系统和其他工具能够准确解析和处理ELF格式的可执行文件,确定程序的入口点,并执行相应的加载和运行操作。这对于实现程序的正确执行和管理起着至关重要的作用。
窗体顶端
节头部表:
描述目标文件的节,同时给出各节的类型、地址、偏移量、大小等信息。
程序头:
可执行文件的连续的片(chunk)被映射到连续的内存段,而程序头部表描述了这种映射关系。
Dynamic section:
重定位节:
符号表:
版本信息:
5.4 hello的虚拟地址空间
用edb加载hello,可以看到hello程序被加载到0x00400000——0x00400ff0段中。
由5.3的程序头可知
PHDR:
保存程序头表。起始地址0x400000偏移0x40字节处、大小为0x2a0字节
INTERP:
指定在程序已经从可执行文件映射到内存之后,必须调用的解释器。位于内存起始位置0x400000偏移0x2e0字节处,大小为0x1c个字节,记录了程序所用ELF解析器(动态链接器)的位置位于: /lib64/ld-linux-x86-64.so.2
其他:
LOAD表示一个从二进制文件映射到虚拟地址空间的段。其中保存了常量数据(如字符串),程序的目标代码等等。 DYNAMIC段保存了其他动态链接器(即,INTERP中指定的解释器)使用的信息。NOTE保存了专有信息。它们都能从edb的Data Dump找到。
5.5 链接的重定位过程分析
不同:
·hello_out.txt比helloo.txt多了.plt节与.init节等。
·多出了一些函数。
·地址从相对地址变成了虚拟地址。
·call指令后添加了正确的地址。
·jmp等跳转指令后也填上了正确地址。
hello_out.txt
分析:
符号解析阶段是链接器将每个符号引用与输入的可重定位目标文件的符号表中的确定的符号定义进行关联的过程。当编译器遇到一个未在当前模块定义的符号引用时,它会假设该符号是在其他模块中定义的,并生成一个链接器符号条目,将其提交给链接处理。如果链接器在任何输入模块中都无法找到该符号的定义,将输出错误信息并停止执行。
随后,进行重定位步骤,链接器将相同类型的节合并为新的聚合节。例如,来自所有输入模块的.data节将合并为输出的可执行目标文件中的一个.data节。然后,链接器为新的聚合节、输入模块定义的每个节以及输入模块定义的每个符号分配运行时内存地址。完成此步骤后,程序中的每条指令和全局变量都具有唯一的运行时内存地址。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
ld-2.27.so!_dl_start——0x7fce 8cc385c0
ld-2.27.so!_dl_init——0x7fce 8cc55630
hello!_start——0x400500
libc-2.27.so!__libc_start_main——0x7fce 8c855ab0
-libc-2.27.so!__cxa_atexit——0x7fce 8c877430
hello!_init——0x40064c
libc-2.27.so!_setjmp——0x7fce 8c884c10
hello!main——0x4007f3
hello!puts@plt——0x400820
hello!exit@plt——0x4008a1
hello!getchar@plt——不执行
hello!sleep@plt——不执行
libc-2.27.so!exit——0x7fce 8c8a2128
5.7 Hello的动态链接分析
现代操作系统采用了一种称为位置无关代码(Position-Independent Code,PIC)的技术,使得代码可以被加载到内存的任何位置而无需修改链接器。这种方法允许多个进程共享同一个共享模块的代码段,节约了内存空间。
在PIC中,数据段和代码段的相对距离保持不变。为了实现全局变量的访问,使用了全局偏移量表(Global Offset Table,GOT)。GOT是一个位于数据段开头的数组,每个条目都是一个8字节的地址。
当调用共享库函数时,编译器无法预测函数的运行时地址,因为定义该函数的共享模块可以在运行时加载到任何位置。传统的方法是为这样的引用生成重定位记录,并在程序加载时由动态链接器进行解析。为了避免运行时修改调用模块的代码段,链接器采用了延迟绑定的策略。
动态链接器使用过程链接表(Procedure Linkage Table,PLT)和全局偏移量表(GOT)来实现函数的动态链接。GOT中存储了函数的目标地址,PLT使用GOT中的地址进行跳转到目标函数。这种延迟绑定的机制使得函数的链接过程可以在程序运行时进行,提高了程序的执行效率
从5.3的节头部表可以看到.got.plt节的位置
.got.plt节
.got.plt节dl_init前
.got.plt节dl_init后
可以看出.got.plt节在dl_init前后发生了变化。
5.8 本章小结
本节介绍了链接的概念与作用,描述了用readelf看hello文件各节的内容,以及hello动态链接的过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机系统中正在运行的程序的实例。它是操作系统对程序的一种抽象,表示一个独立的执行单元,具有独立的内存空间、指令序列、数据和执行状态。
进程的主要作用包括:
1.并发执行:操作系统通过创建和管理多个进程,实现了多任务的并发执行。每个进程可以独立运行,相互之间不会干扰。这样可以提高系统的资源利用率和整体性能。
2.资源分配:每个进程拥有自己的内存空间、文件描述符、处理器时间等资源。操作系统负责为进程分配和管理这些资源,以确保它们能够按需使用系统资源。
3.进程间通信:进程可以通过进程间通信(IPC)机制进行信息交换和数据共享。常见的IPC方式包括管道、共享内存、消息队列、信号量等。这使得不同进程之间可以协同工作、共享数据,实现复杂的协作和通信需求。
4.隔离和保护:每个进程运行在独立的内存空间中,使得它们相互隔离,互不干扰。进程之间的内存访问受到操作系统的保护,防止进程越权访问和修改其他进程的数据。
5.系统调度:操作系统负责对多个进程进行调度,合理分配处理器时间片,使得各个进程能够公平地获得执行机会。调度算法可以根据不同的策略和优先级,提供合理的资源分配和响应时间要求。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互式应用程序,用于代表用户与计算机交互。它扮演了多个角色和功能,包括命令行解释器、作业调度、信号处理和进程控制等。
作用:
命令执行:Shell负责解析和执行用户输入的命令。它能够运行内置命令并调用外部程序执行用户指定的任务。
作业调度:Shell可以管理多个任务或作业的执行。它支持前台运行,将控制权交给正在执行的程序,并等待其完成。同时,它还支持后台运行,使得用户可以继续输入和执行其他命令。
信号处理:Shell可以接收和处理操作系统发送的信号。它能够响应不同的信号事件,并采取相应的措施,如终止进程、忽略信号或执行特定的操作。
进程控制:Shell负责创建和管理子进程。当用户输入一个命令时,Shell会创建一个子进程来加载和运行相应的程序。子进程的执行可以在前台或后台进行,具体取决于用户的需求和命令的要求。
处理流程:
(1)Shell读取用户输入的命令。
(2)如果命令是一个内置命令,Shell会直接执行相应的操作。
(2)如果命令不是内置命令,Shell会创建一个子进程,并加载并运行对应的命令程序。根据用户的要求,子进程可以在前台或后台运行。在前台运行时,Shell将控制权交给程序,等待其执行完成。在后台运行时,Shell继续接受和执行其他命令。当子进程执行完成后,Shell会回收子进程,释放相关资源。
通过这个处理流程,Shell实现了用户与计算机系统之间的交互,使得用户能够方便地执行命令和管理进程。
6.3 Hello的fork进程创建过程
在Hello程序中,当遇到fork()函数调用时,操作系统会创建一个新的进程作为Hello程序的子进程。子进程返回值为0,表示当前代码在子进程中执行。父进程返回子进程的进程ID(PID),用于区分父进程和子进程的执行路径。子进程有如下特点:
- 子进程几乎但不完全与父进程相同,它得到与父进程虚拟地址空间相同的一份副本,包括代码段、数据段、堆、共享库和用户栈等。这样,子进程可以独立地执行自己的逻辑。
- 子进程还会继承父进程的打开文件描述符,即与父进程相同的副本。这意味着子进程可以访问父进程打开的文件,并在文件操作上继续进行。子进程拥有一个与父进程不同的PID,用于在系统中唯一标识子进程。
通过fork()函数,Hello程序创建了一个子进程。子进程返回0,父进程返回子进程的PID。子进程得到与父进程虚拟地址空间相同的一份副本,包括代码、数据段、堆、共享库和用户栈,以及与父进程相同的打开文件描述符的副本。子进程具有独立的PID,用于在系统中唯一标识子进程。
6.4 Hello的execve过程
Int execve(char*filename,char*argv[],char*envp[])
在Hello的execve过程中,执行加载器(loader)函数,该函数将当前进程中的代码替换为指定的可执行文件、目标文件或脚本。加载器函数的参数包括filename(要执行的程序文件名)、argv(参数列表,通常argv[0]等于filename)和envp(环境变量列表)。加载器函数的工作包括以下步骤:
- 删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。
- 通过将虚拟地址空间中的页映射到可执行文件的页大小的片段,初始化新的代码和数据段。这意味着在加载过程中没有将整个可执行文件从磁盘复制到内存。只有当CPU引用一个被映射的虚拟页时,操作系统才会将页面从磁盘传送到内存。这是通过操作系统的页面调度机制来实现的。
- 加载器设置程序计数器(PC)指向可执行文件的_start地址。_start是一个入口点,最终会调用Hello程序中的main函数。
在加载过程中,除了一些头部信息外,没有进行从磁盘到内存的数据复制操作。实际的数据传输是在程序执行时根据需要进行的,操作系统会根据页面调度机制将页面从磁盘传送到内存。
综上所述,Hello的execve过程中的加载器函数负责在当前进程中载入并运行指定的程序文件。它删除子进程的现有虚拟内存段,并创建新的代码、数据、堆和栈段。通过将虚拟地址空间中的页映射到可执行文件的页大小的片段,加载器初始化新的代码和数据段。加载器设置程序计数器(PC)指向_start地址,并最终调用Hello程序中的main函数。在加载过程中没有进行磁盘到内存的数据复制,实际的数据传输是根据页面调度机制进行的。
6.5 Hello的进程执行
进程的物理实体(代码和数据等)和支持进程运行的环境合称为进程的上下文。进程上下文包括进程的用户级上下文和系统级上下文。用户级上下文包括进程的程序块、数据块、堆和用户栈等用户空间信息,而系统级上下文包括进程的标识信息、现场信息、控制信息和内核栈等内核空间信息。寄存器上下文是指处理器中各寄存器的内容,它记录了进程的现场信息。用户级上下文和系统级上下文构成了进程的存储器映像。
进程时间片:
进程时间片是指操作系统为每个进程分配的执行时间段。在每个时间片内,进程可以执行其控制流的一部分。
进程调度过程:
内核根据调度算法从就绪队列中选择一个进程来执行。内核维护每个进程的上下文,并在适当的时机决定是否抢占当前进程并切换到先前被抢占的进程,这个决策是由内核内的调度器实现的。当内核选择新的进程运行时,我们说内核进行了进程调度。
用户态与核心态转换:
用户态与核心态之间的转换是为了限制应用程序的特权级别和访问权限,以确保操作系统内核的安全性和稳定性。处理器通常通过一个控制寄存器的模式位来实现这种转换。当模式位设置时,进程运行在核心态,具有完全的特权和系统资源的访问权限。而当模式位未设置时,进程运行在用户态,受限于操作系统规定的特权级别和访问权限。这种转换机制确保了操作系统和用户程序的分离,并提供了对系统资源的保护。
6.6 Hello的异常信号处理
异常是指为响应某个事件将控制权转移到操作系统内核中的情况,一共有四种异常:硬件异常、陷阱、故障和终止。
硬件异常:处理器外部I/O设备引起,中断处理程序返回到下一条指令处。
陷阱:执行指令的结果,陷阱处理程序将控制返回到下一条指令。
故障:处理程序要么重新执行引起故障的指令(已修复),要么终止。
终止:不可恢复的致命错误造成,处理程序中止当前程序。
正常运行如上图
中途按下ctrl + c:
中途按下ctrl + z:
运行Ps
运行jobs:
运行Pstree
运行fg:
运行kill:
异常与信号的处理:
按下Ctrl + C会向shell发送SIGINT信号,shell捕获信号后终止当前进程,并进行进程回收和内存清理操作。
而按下Ctrl + Z会向shell发送SIGTSTP信号,shell捕获信号后暂停当前进程,并将其添加到作业列表(jobs)。此时,运行ps命令可以看到新增的"hello"进程,并且其状态为停止;运行jobs命令可以查看到新增的"hello"作业;运行pstree命令可以显示整个进程树,其中可以找到当前的bash进程,并从其分支中找到"hello"进程。
运行fg命令将"hello"进程从后台切换到前台,并且shell向该进程发送SIGCONT信号,使被暂停的"hello"进程继续运行。而运行kill -9 %1命令,则向"hello"进程发送SIGINT信号,强制终止该进程,并由shell进行回收处理。
6.7本章小结
本章介绍了进程的概念、什么是shell、以及上下切换的过程与异常与信号的处理。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在用户编程过程中使用的地址,由段地址和偏移地址两部分组成。
线性地址:在CPU的保护模式下,逻辑地址通过"段基址 + 段内偏移地址"的方式计算得到线性地址。
虚拟地址:在虚拟内存中的地址,它是由一个存储在磁盘上的连续字节单元数组组成的,每个字节对应一个虚拟地址。
物理地址:在内存单元的真实地址,用于访问实际的物理内存空间。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理是将虚拟地址空间划分为长度可变的段,并为每个段指定段基地址、段偏移量和段属性。段基地址指定段在线性地址空间中的起始地址,段偏移量指示段内的偏移地址。段属性包括段的可读性、可写性、可执行性和特权级等信息。基于段式管理,处理器采用两种寻址模式:实模式和保护模式。
在保护模式下,采用分段的寻址模式。逻辑地址经过划分得到索引、TL和RPL信息,通过段选择符从描述符表(GDT或LDT)中获取段描述符。根据段描述符获取基地址,然后将偏移量与基地址相加得到线性地址。
在实模式下,逻辑地址等于线性地址,即直接使用真实的物理地址。段寄存器存储真实的段基址,通过给定的32位地址偏移量可以直接访问物理内存。
段寄存器(16位)用于存储段选择符,包括:
CS(代码段):存放程序代码所在的段。
SS(栈段):存放栈区所在的段。
DS(数据段):存放全局静态数据区所在的段。
ES、GS和FS(可选):可以指向任意数据段。
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种用于处理虚拟内存的管理方式。它将虚拟内存和物理内存分成固定大小的块,称为虚拟页和物理页。每个虚拟页和物理页的大小相同。
为了确定虚拟页是否在物理内存中的某个位置缓存,并确定虚拟页映射到哪个物理页,操作系统和地址翻译硬件共同进行这个过程。
这使用了包括地址翻译硬件在MMU(内存管理单元)中的功能以及存放在物理内存中的页表数据结构(用于虚拟页到物理页的映射)。每当地址翻译硬件将虚拟地址转换为物理地址时,都会访问页表。操作系统负责维护页表内容,并在磁盘和物理内存之间传输页。
CPU 进行地址翻译过程如下:
- 处理器生成虚拟地址,并将其传递给MMU。
- MMU生成页表项(PTE)地址,并从高速缓存/主存中获取PTE。
- 缓存/主存返回PTE给MMU。
- MMU构建物理地址,并将其传递给高速缓存/主存。
- 高速缓存/主存将所请求的数据返回给处理器。
7.4 TLB与四级页表支持下的VA到PA的变换
上图给出了Core i7 MMU将虚拟地址翻译成物理地址的过程。
对于36位的VPN(虚拟页号),它被划分成四个9位的片段,每个片段用作到一个页表的偏移量。CPU控制寄存器CR3包含L1页表的物理地址。
VPN1提供了到一个L1页表项的偏移量,该页表项包含L2页表的基地址。
VPN2提供了到一个L2页表项的偏移量,以此类推。通过这种方式,层级的页表结构可以被逐级访问,最终获得虚拟地址对应的物理地址。
7.5 三级Cache支持下的物理内存访问
当CPU生成虚拟地址后,它将该地址传递给MMU(内存管理单元)。MMU将页表项地址(PTEA)传递给L1缓存。如果L1缓存未命中,那么PTEA将被传递给L2缓存。如果L2缓存未命中,那么PTEA将被传递给L3缓存。如果L3缓存仍未命中,那么PTEA将被传递给主存(内存)。
在主存中,根据PTEA找到页表项(PTE),并一级一级地传递回缓存层次结构,将PTE加载到适当的缓存中。
MMU接收到PTE后,对其进行翻译。如果PTE的有效位为0,表示对应的页面不存在,触发缺页异常处理程序。如果翻译正常,则生成的物理地址将传递给三级缓存/内存,以取回相应的数据。
7.6 hello进程fork时的内存映射
通过fork创建一个新进程时,会为hello程序创建虚拟内存。这包括创建当前进程的mm_struct(内存管理结构)、vm_area_struct(区域结构)和页表的副本。
在新进程中,每个页面都被标记为只读,而每个区域结构(vm_area_struct)被标记为私有的写时复制(Copy-on-Write,COW)。
当新进程返回时,它将拥有与调用fork的进程相同的虚拟内存。此后的写操作通过写时复制机制创建新的页面,确保两个进程之间的数据隔离。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行新程序hello的步骤如下:
- 删除已存在的用户区域。
- 创建新的区域结构,标记为私有的写时复制(Copy-on-Write,COW)。
- 将新程序的代码和初始化数据映射到.text和.data区域,根据目标文件提供的信息进行映射。
- 将未初始化数据(.bss)和堆栈映射到匿名文件。
- 设置堆栈的初始长度为0。
- 将共享对象通过动态链接映射到本进程的共享区域。
- 设置程序计数器(PC)指向代码区域的入口点。
- 根据需要,Linux系统会将代码和数据页面换入内存,使其可访问。
7.8 缺页故障与缺页中断处理
在虚拟内存中,当DRAM缓存不命中时,会触发缺页。
以下是缺页处理的流程:
- 页面不命中导致缺页异常。
- 缺页处理程序确定物理内存中的牺牲页(被替换出的页),如果该页面已经被修改,则将其换出到磁盘。
- 缺页处理程序将所需页面调入到新的物理页面,并更新内存中的页表项(PTE)。
- 缺页处理程序返回到原始进程,重新执行导致缺页的指令。CPU将重新发送导致缺页的虚拟地址给MMU进行地址翻译。
7.9动态存储分配管理
动态内存分配器用于维护进程的虚拟内存区域,即堆。堆被视为一组不同大小的块的集合,每个块是连续的虚拟内存片段,可以是已分配或空闲状态。已分配的块用于应用程序使用,而空闲块用于分配新的内存。空闲块保持空闲状态,直到被应用程序分配。已分配的块保持分配状态,直到被释放。在C语言中,使用malloc函数分配块,使用free函数释放块。
动态内存分配有如下几种方式:
- 隐式空闲链表:使用隐式空闲链表来维护空闲块的集合。每个空闲块包含一个头部,用于存储块是否已分配的信息,并可能包含一个脚部。有效载荷和额外填充组成中间部分。分配器可以通过遍历堆中的所有块来间接遍历空闲块集合。放置策略可以是首次适配、下次适配或最佳适配。
- 显式空闲链表:将空闲块组织成链表形式的数据结构。每个空闲块包含前驱和后继指针。链表可以按先进后出的顺序维护,使得最近释放的块放在链表的开始处。分配器可以检查最近使用的块。另一种方式是按地址顺序维护链表,确保每个块的地址小于其后继块的地址。释放时需要线性搜索来定位适当的前驱块。
- 分离链表:分离链表将空闲块按照大小分类到不同的等价类中。每个等价类中的块大小相同,因此可以通过地址判断块大小。分配可以在常数时间内完成。不需要合并操作,只需要查找适当的等价类。这种方式包括简单分离链表、分离适配和伙伴系统等几种实现方式。
7.10本章小结
本节主要介绍了虚拟内存的相关知识,即页式管理,段式管理,虚拟地址翻译,堆空间管理等。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:
文件是C语言和Linux管理的思想(一切皆文件),所有的IO设备都被抽象为文件,所有的输入输出都作为对文件的操作。
设备管理:
这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O接口。
8.2 简述Unix IO接口及其函数
打开文件:通过请求内核打开文件来声明要访问的I/O设备。内核返回一个描述符,用于标识文件。应用程序只需记住该描述符即可。
改变当前文件位置:对于每个打开的文件,内核维护一个文件位置指针,初始位置为0。应用程序可以使用seek操作显式地设置文件的当前位置。
读写文件:读操作从文件复制n个字节到内存,从当前文件位置开始,并更新文件位置。写操作从内存复制n个字节到文件,从当前文件位置开始,并更新文件位置。
关闭文件:应用程序通知内核关闭文件,并释放相应的资源。
Unix I/O函数:
1.int open(const char *filename, int flags, mode_t mode):打开文件或创建新文件。返回文件描述符,表示打开的文件且返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(int fd):关闭已打开的文件。返回0表示成功,-1表示失败。
3.ssize_t read(int fd, void *buf, size_t n):从文件中读取最多n个字节到内存缓冲区buf。返回实际读取的字节数,-1表示错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t write(int fd, const void *buf, size_t n):将内存缓冲区buf的最多n个字节写入文件。返回实际写入的字节数,-1表示错误。
8.3 printf的实现分析
Printf函数
其中(char*)(&fmt) + 4) 表示的是...中的第一个参数。
Vsprintf函数
此函数的作用是按照格式fmt 结合参数args 生成格式化之后的字符串,并返回字串的长度。而write函数的作用显然是将字符串输出到屏幕。
Write函数
由我们学到的汇编知识,它先是传了几个参数,然后调用系统调用sys_call。
Sys_call函数
从代码可以看出,调用(call)是用来访问字库模板并获取每个点的RGB信息。这些信息被存储在eax寄存器中,它最终代表了要输出的显示vram的值。接下来,系统显示芯片会按照刷新频率逐行读取vram,并通过信号线将每个点的RGB分量传输到液晶显示器。
8.4 getchar的实现分析
键盘中断处理子程序:
接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar()等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本节介绍了linux的IO管理,以及其各种函数。还分析了printf函数与getchar函数的实现。
(第8章1分)
结论
- 预处理:编译器预处理器根据以字符#开头的命令,修改C程序,生成hello.i文件.
- 编译:编译器将hello.i翻译成汇编语言文件hello.s。
- 汇编:汇编器将hello.s翻译成机器语言指令,并生成可重定位目标文件hello.o。
- 链接:链接器将hello.o与所需的外部库链接起来,生成可执行目标文件。
- 加载运行:在命令行中输入"./hello"后,操作系统的shell解析该命令并创建子进程。子进程调用execve()函数加载可执行目标文件hello,并创建新的虚拟内存段并将其映射到可执行文件的页。
删除子进程现有的虚拟内存段,创建一组新的段(段与栈初始化为0),并将虚拟地址空间中的页映射到可执行文件的页。
1执行:CPU为hello程序分配时间片,在一个时间片中,hello程序顺序执行其控制逻辑流程。
2访存:内存管理单元(MMU)通过页表将程序使用的虚拟内存地址转换为物理内存地址。例如,printf函数调用malloc向动态内存分配器申请堆中的内存。
3信号:hello程序在运行过程中可能会收到某些事件的信号,并对其进行相应的处理。
4回收:shell将子进程hello回收,内核清理相关资源并将其痕迹删除
最为简单的hello程序,其实真的没有我们想象的那么简单,任何一种习以为常的规则,其底层都是集中了前人的智慧。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c:源程序
hello.i:预处理后的文本文件
hello.s:编译后汇编程序文本文件
hello.o:汇编后的可重定位目标程序(二进制文件)
hello:链接后的可执行目标文件
helloo.txt:hello.o的反汇编文件
hello_out.txt:hello的反汇编文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)